feat(internal): add reload orchestration with debouncing and failure retry to file data loading - #406
feat(internal): add reload orchestration with debouncing and failure retry to file data loading#406kinyoklion wants to merge 11 commits into
Conversation
…retry to file data loading
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 18fc662. Configure here.
…ning Creates the reloader close channel up front so Close never races a concurrent Sync/Start assigning it (an explicit flag now gates the change-driven reload log instead); makes the change-set version counter atomic since loads can run concurrently from Fetch and the reloader; adds regression tests proving Close closes the channel given to the reloader; and documents MergeResult's ordering guarantee precisely (deterministic at document granularity only).
…iledata-reloader # Conflicts: # ldfiledata/file_data_source_impl.go # ldfiledatav2/file_data_source_impl.go
Close no longer waits for an in-flight reload, so a read wedged on unresponsive storage cannot wedge shutdown; a reload instead re-checks the closed state before delivering through Apply/OnError. OnError now fires once per distinct failure rather than on every automatic retry, sparing status listeners a broadcast per second for a permanently broken file. The Reloader is created in the file data source constructors, so a repeated Sync cannot orphan an earlier instance and Close never races an assignment. Also: Apply nil-guarded like OnError; a failed synchronous ReloadNow arms the retry timer directly instead of routing through the change-trigger path (whose log line claimed a change was detected); zero-vs-negative delay semantics documented; the read-error message built in one place; debounce test timing ratio widened.
|
Addressed the multi-agent review findings in 41f0f21:
Finding 6 left as-is (unreachable today; not worth complicating the Apply seam), finding 8 covered by the new dedup test's log assertion, finding 14 pre-existing/out of scope. Propagated through #407 and the |
…ent is unchanged With SkipUnchanged, a success whose bytes matched the last good hash returned without calling Apply. If a failure had intervened, the consumer had heard OnError and moved to an interrupted state, and the skipped Apply meant it never heard about the recovery: both file data sources would report Interrupted indefinitely after a transient read failure whose retry found byte-identical content. The first success after a failure now always applies.
Creating the Reloader in the data source constructors meant its worker goroutine started at build time, and a source built as a one-shot initializer -- an interface with no Close that the data system never tears down -- leaked that goroutine for the life of the process, once per build. The worker now starts on the first ReloadNow or Trigger, which the initializer path never calls. Also guards the v2 synchronizer against a repeated Sync (which would accumulate watchers and listeners) with a buffered rejection so the guard itself cannot block, and notes that the safety of a post-Close status broadcast rests on the Broadcaster's lock discipline.
|
Round-2 finding N1 addressed in 54aeb92:
R1/R3/R6 left as noted (theoretical/unreachable/correct). All file-machinery suites pass under |
…-reloader # Conflicts: # internal/filedata/document.go # ldfiledata/file_data_source_impl.go # ldfiledatav2/file_data_source_impl.go # ldfiledatav2/file_data_source_test.go
…deadlocking Sync When the reloader factory failed (for example fsnotify hitting inotify instance or watch limits), the v2 synchronizer sent the Off result on the unbuffered result channel before the reader goroutine existed, so Sync never returned: client startup hung for its full timeout with no fallback attempted, and the already-started reload worker leaked. The failure branch now uses the same buffered single-result rejection as the repeated-Sync guard, stops the reload worker, and detaches the listeners it registered. Also adds the missing tests for this branch and the repeated-Sync rejection, and makes two timing-sensitive tests robust (the goroutine-count check now polls for the process-global count to settle, and the debounce test enables SkipUnchanged like production configs to absorb a legitimate tick-vs-trigger race).
|
Round-3 finding N1 addressed in 8893141:
Propagated through #407, |
Enabling SkipUnchanged had made the test vacuous: with identical file contents, even one reload per trigger collapses to a single Apply. SkipUnchanged is off again and the test now counts applies across the settle window, tolerating the one documented extra reload from a debounce tick racing a fresh trigger and failing on anything more. Verified to fail with debouncing disabled.
|
Round 4 came back clean — approved for merge. The one non-blocking note (L1) is addressed in the latest commit: the debounce test had lost its discriminating power (with SkipUnchanged and identical contents, even a broken debounce collapses to one Apply). It now runs with SkipUnchanged off and counts applies across the settle window, tolerating the single documented tick-vs-trigger extra reload and failing on anything more; verified to fail with debouncing disabled and stable at I1 noted as a pre-existing design choice (a source whose files loaded but whose watcher failed reports Off rather than serving statically — same intent as base); leaving it as-is for this PR. |
| func (fs *fileDataSource) Close() (err error) { | ||
| fs.closeOnce.Do(func() { | ||
| close(fs.closeReloaderCh) | ||
| fs.reloader.Close() |
There was a problem hiding this comment.
Seeing these two lines next to each other looks like double duty. Are both necessary now?
close(fs.closeReloaderCh)
fs.reloader.Close()
There was a problem hiding this comment.
They are two different lifecycles. One closes the watcher and the other the loader/debouncer. Though maybe the watcher part wasn't originally encapsulated in a way as to make that clear.
| return | ||
| } | ||
| r.startOnce.Do(func() { | ||
| go r.run() |
There was a problem hiding this comment.
Are you guaranteed r.run() has executed by the time ensureStarted() returns? I recall there being some go runtime versions that did jump to execute the target of the go func and some that did not. Not sure what the current state of that is. Perhaps it doesn't matter either way due to the arrangement.
There was a problem hiding this comment.
It doesn't matter due to the channel arrangement. Things that are going to interact with it via a buffered channel, so a message before the select will still be handled once the select happens.
| // A trigger already queued when Close was called can still reach here; the select in | ||
| // run() does not prioritize closeCh over triggerCh. | ||
| if r.isClosed() { | ||
| return true |
There was a problem hiding this comment.
Returning true on a skip is a little weird.
There was a problem hiding this comment.
Maybe? A closed item isn't a failure and isn't something we would want to retry.
| return r.fail(&ReadError{Err: err, Path: path}) | ||
| } | ||
| docs = append(docs, doc) | ||
| } |
There was a problem hiding this comment.
Is there a way to do this in a streaming fashion so all file content doesn't have to come into memory at once for the hash?
There was a problem hiding this comment.
So the same read buffer is also used for the parsing, and the entire document will need read for that. Also just aside from the face we always want the hash and the read from the same payload to prevent a TOCTOU issue.
| // Close may have happened while the files were being read; deliver nothing in that case. | ||
| if r.isClosed() { | ||
| return true | ||
| } |
There was a problem hiding this comment.
Is this check necessary? Or just a statistically good spot to put it after some IO?
There was a problem hiding this comment.
It is a longer window during which a close can happen. Its also how the method is documented currently. But it could be changed.

Adds a
Reloadertointernal/filedatathat owns the reload cycle for a set of data files, and routesldfiledataandldfiledatav2through it. This is the robustness layer the file-based override source (OVERRIDE spec) requires, applied to the existing file data sources as well since the same failure modes (partial writes, rename-replace editors, event storms) affect them equally.What the Reloader provides:
Applysimply isn't called).Consumers keep their own success/failure semantics via
Apply/OnErrorcallbacks (v1: store init + status; v2: change-set broadcast + status, with read failures still mapped toUNKNOWNand merge failures toINVALID_DATAvia the newfiledata.ReadError). Sources configured without a reloader factory still load exactly once with no debounce or retry, as before.Behavior notes for existing file data source users (changelog-worthy): change-driven reloads now settle for ~100 ms before applying; a reload that failed because a file was temporarily unreadable now recovers automatically instead of waiting for the next filesystem event; and byte-identical rewrites no longer re-initialize the store. One v2 test needed to tolerate the retry loop.
ldfilewatch's public API is untouched — debouncing lives in the orchestrator so it applies to any trigger source.Reloader unit tests run with millisecond-scale delays and observe behavior deterministically through the callbacks (coalescing, retry-without-trigger, retry-stops-after-success, skip-unchanged, close semantics), including under
-race.SDK-2654
Note
Medium Risk
Changes runtime flag-loading semantics (timing, retries, fewer re-inits) for file-watched sources; lifecycle edge cases (late callbacks after Close, single-use Sync) need correct consumer handling.
Overview
Introduces
internal/filedata.Reloaderto own multi-file load/merge cycles and movesldfiledata/ldfiledatav2off inlinereload()logic ontoApply/OnErrorcallbacks wired to store init, change-set broadcast, and status updates.Reload behavior (when a reloader factory is configured): ~100ms debounce on change triggers, 1s automatic retry after failures without new events, last-good retention (failed loads skip
Apply), skip-unchanged via content hash (with re-apply after recovery even when bytes match), and deduped identical failure logs/OnError. Sources without a watcher still load once with no debounce/retry.filedata.ReadErrorreplaces v2’s local read error type;ReadFilenow reads thenparseDocument. v2 additionally rejects secondSync, returns Off (no hang) when the reloader factory fails, andClosestops both the watcher channel andReloader.User-visible deltas: change-driven updates settle briefly before apply; transient read/parse failures can self-heal; byte-identical rewrites no longer re-init the store.
Reviewed by Cursor Bugbot for commit 18f1d13. Bugbot is set up for automated code reviews on this repo. Configure here.