Files
base/core/notify_watcher.go
zeekay 66b9335f95 finish removing the Bases twin, and stop asserting on machine speed
Half of this removal is already on main: 25ae70fcb swept up the staged deletion
of core/bases.go and core/bases_test.go, but not the unstaged edits that took
out what referenced them — so main currently declares Bases() *Bases over a
deleted type and does not compile. This is the other half.

What went, and why it could go: core.Bases was a second, complete implementation
of the product's central noun — a per-org database registry with its own
encryption, lazy open, and concurrent/nonconcurrent pools, reachable through
three core.App methods and gated behind MULTI_BASE/MASTER_KEY. It lived in the
same directory as the live one and disagreed with it on everything else:
data.db + auxiliary.db against org.db, one shared master key against a per-org
DEK from KMS, validateOrgID against validateSlug. Two Bases for one org, neither
aware of the other, had both ever run.

Neither ever did. Its only callers were its own tests, no deployment in the
fleet sets either env var, and nothing outside this repo calls Bases(). The live
path is plugins/org, which provisions on the first request carrying an org and
derives that org's key from KMS.

Also fixes TestNotifyWatcher_CollectionsUpdate, which failed under parallel load
and had been widened once before ("1-2 reloads") without the cause being found.
The cause is that reload count tracks the gaps between writes, gaps track
machine load, and the ceiling was a constant — so a loaded runner pushes a
healthy watcher past any fixed number, and no burst size fixes it because the
burst's duration scales with load too. The ceiling now comes from the burst's
own measured duration divided by the watcher's own debounce window, which takes
machine speed out of the assertion entirely while still catching a watcher that
has stopped debouncing (it emits one reload per change at any speed). The 50ms
window was written twice, once as a literal and once in English in a comment; it
is named once now, and the test reads that name through export_test.go rather
than restating the number.

Verified: full core suite green three times under six competing builds, the
configuration that reproduced the failure.
2026-08-10 22:37:20 -07:00

223 lines
5.8 KiB
Go

package core
import (
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/fatih/color"
"github.com/fsnotify/fsnotify"
"github.com/hanzoai/base/tools/hook"
"github.com/hanzoai/base/tools/security"
)
const systemHookIdNotifyWatcher = "__hzNotifyWatcherSystemHook__"
// notifyDebounce is how long a change must be followed by quiet before the
// watcher reloads. A burst of changes therefore costs one reload rather than
// one per change.
const notifyDebounce = 50 * time.Millisecond
// notifyFileTTL is how long a notify file stays on disk before cleanup.
// kqueue-backed watchers (macOS) detect directory children by rescanning
// on dir-change events — a file created and unlinked within one scan
// window is never observed, so the signal file must outlive the watcher
// latency (event delivery + notifyDebounce).
const notifyFileTTL = 200 * time.Millisecond
func (app *BaseApp) registerNotifyWatcherHooks() {
var notifyWatcher *fsnotify.Watcher
instanceId := "@" + security.PseudorandomString(10)
localNotifyDirPath := filepath.Join(app.DataDir(), LocalNotifyDirName)
settingsFile := filepath.Join(localNotifyDirPath, "settings"+instanceId)
collectionsFile := filepath.Join(localNotifyDirPath, "collections"+instanceId)
// init
app.OnBootstrap().Bind(&hook.Handler[*BootstrapEvent]{
Id: systemHookIdNotifyWatcher,
Func: func(e *BootstrapEvent) error {
err := e.Next()
if err != nil {
return err
}
if notifyWatcher != nil {
_ = notifyWatcher.Close()
}
notifyWatcher, err = createNotifyDirWatcher(e.App, instanceId, localNotifyDirPath)
if err != nil {
e.App.Logger().Warn("Notify dir watcher failure.", "error", err)
}
return nil
},
Priority: -998,
})
// cleanup
app.OnTerminate().Bind(&hook.Handler[*TerminateEvent]{
Id: systemHookIdNotifyWatcher,
Func: func(e *TerminateEvent) error {
if notifyWatcher != nil {
_ = notifyWatcher.Close()
}
_ = os.Remove(settingsFile)
_ = os.Remove(collectionsFile)
return e.Next()
},
Priority: -998,
})
// ---------------------------------------------------------------
settingsNotify := func(e *ModelEvent) error {
err := e.Next()
if err != nil || e.Model.PK() != paramsKeySettings {
return err
}
if notifyWatcher != nil {
if err := os.WriteFile(settingsFile, nil, 0644); err != nil {
e.App.Logger().Warn("Failed to write watcher file", "error", err, "file", settingsFile)
}
time.AfterFunc(notifyFileTTL, func() { _ = os.Remove(settingsFile) })
}
return nil
}
app.OnModelAfterCreateSuccess(paramsTable).Bind(&hook.Handler[*ModelEvent]{
Id: systemHookIdNotifyWatcher,
Func: settingsNotify,
Priority: 999,
})
app.OnModelAfterUpdateSuccess(paramsTable).Bind(&hook.Handler[*ModelEvent]{
Id: systemHookIdNotifyWatcher,
Func: settingsNotify,
Priority: 999,
})
// ---------------------------------------------------------------
collectionsNotify := func(e *CollectionEvent) error {
if err := e.Next(); err != nil {
return err
}
if notifyWatcher != nil {
if err := os.WriteFile(collectionsFile, nil, 0644); err != nil {
e.App.Logger().Warn("Failed to write watcher file", "error", err, "file", collectionsFile)
}
time.AfterFunc(notifyFileTTL, func() { _ = os.Remove(collectionsFile) })
}
return nil
}
app.OnCollectionAfterCreateSuccess().Bind(&hook.Handler[*CollectionEvent]{
Id: systemHookIdNotifyWatcher,
Func: collectionsNotify,
Priority: 999,
})
app.OnCollectionAfterUpdateSuccess().Bind(&hook.Handler[*CollectionEvent]{
Id: systemHookIdNotifyWatcher,
Func: collectionsNotify,
Priority: 999,
})
app.OnCollectionAfterDeleteSuccess().Bind(&hook.Handler[*CollectionEvent]{
Id: systemHookIdNotifyWatcher,
Func: collectionsNotify,
Priority: 999,
})
}
func createNotifyDirWatcher(app App, instanceId string, localNotifyDirPath string) (*fsnotify.Watcher, error) {
// create the notify dir (if not already)
err := os.MkdirAll(localNotifyDirPath, os.ModePerm)
if err != nil {
return nil, fmt.Errorf("failed to create a notify dir: %w", err)
}
watcher, err := fsnotify.NewWatcher()
if err != nil {
return nil, fmt.Errorf("failed to init notify dir watcher: %w", err)
}
err = watcher.Add(localNotifyDirPath)
if err != nil {
_ = watcher.Close()
return nil, fmt.Errorf("unable to watch notify dir: %w", err)
}
var debounceTimer *time.Timer
stopDebounceTimer := func() {
if debounceTimer != nil {
debounceTimer.Stop()
debounceTimer = nil
}
}
// watch
go func() {
defer stopDebounceTimer()
for {
select {
case event, ok := <-watcher.Events:
if !ok {
return
}
// modified from within the current app instance or cleanup event
if strings.HasSuffix(event.Name, instanceId) || event.Has(fsnotify.Remove) || !app.IsBootstrapped() {
continue
}
stopDebounceTimer()
debounceTimer = time.AfterFunc(notifyDebounce, func() {
filename := filepath.Base(event.Name)
// settings changed
if strings.HasPrefix(filename, "settings@") {
app.Logger().Debug("Reloading settings after notify event")
err := app.ReloadSettings()
if err != nil {
app.Logger().Warn("Failed to reload app settings after notify", "error", err)
}
return
}
// collections changed
if strings.HasPrefix(filename, "collections@") {
app.Logger().Debug("Reloading cached collections after notify event")
err := app.ReloadCachedCollections()
if err != nil {
app.Logger().Warn("Failed to reload cached collections after notify", "error", err)
}
return
}
})
case err, ok := <-watcher.Errors:
if app.IsDev() && err != nil {
color.Red("Notify dir watch error:", err)
}
if !ok {
return
}
}
}
}()
return watcher, err
}