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 10 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. |

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 behavior of file-based flag loading (timing, retries, fewer re-inits on identical files) on a core SDK data path, though semantics on success/failure are preserved via callbacks.
Overview
Introduces
internal/filedata.Reloaderto own multi-file load cycles: serialized reloads, ~100ms debounce on change signals, 1s automatic retry after failures, last-good retention (noApplyon error), skip-unchanged via content hashing, and deduplicated error logging on repeated failures.ReadErroris added infiledata(withparseDocumentsplit out ofReadFile) so per-file read/parse failures are distinguishable from merge errors.ldfiledataandldfiledatav2replace inlinereloadlogic withReloadercallbacks (applyData/handleError); file watchers now callTriggerinstead of reloading directly. Debounce/retry apply only when a reloader factory is configured; one-shot sources still load once. v2 drops its local read error type in favor offiledata.ReadError(read →UNKNOWN, merge →INVALID_DATA). A v2 recovery test tolerates extra interrupted results from the retry loop.Reviewed by Cursor Bugbot for commit 18fc662. Bugbot is set up for automated code reviews on this repo. Configure here.