Repository navigation
feat: Add a shared file data reloader, poller, and watcher - #361
Conversation
883fd4b to
353e748
Compare
|
bugbot review |
|
bugbot review |
There was a problem hiding this comment.
✅ Bugbot reviewed your changes and found no new issues!
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit e737bd5. Configure here.
|
The file data poller test that expected exactly one callback per rewrite is replaced by one that checks the poller goes quiet once the file is stable, following launchdarkly/go-server-sdk#473. A plain rewrite is not atomic (the write and the modification-time update are separate steps), so a poll that lands between them legitimately reports an extra change; the poller promises generous detection and the reloader absorbs the excess. The test writes in the ordinary way, drains the callbacks of the write, and then requires silence, which still fails for a poller that does not update its baseline. Written by Claude Code on behalf of @kinyoklion. |
|
The ubuntu Reproduced locally with a deliberately slow write (truncate, sleep 60 ms, write): both tests failed 3/3 before the change and passed 3/3 after it. The tests now discard the reports that arrived before the recovery was applied and require silence only for what follows (90159a0). Verified with the local project references ( Written by Claude Code on behalf of @kinyoklion. |
| /// single variation and serves it as its fallthrough, so it evaluates with the | ||
| /// <see cref="EvaluationReasonKind.Fallthrough"/> reason. This is the form the override source | ||
| /// uses for <c>flagValues</c> entries. | ||
| /// </summary> |
There was a problem hiding this comment.
This function is unexpected in this file. Why does it exist here?
There was a problem hiding this comment.
For me this is expected. Because one of the formats is the simplified format and it loads to the full format.
| private static readonly Func<FileInfo, FileSystemInfo> DefaultLinkResolver = | ||
| info => info.ResolveLinkTarget(returnFinalTarget: true); | ||
| #else | ||
| private static readonly Func<FileInfo, FileSystemInfo> DefaultLinkResolver = null; |
There was a problem hiding this comment.
AI review comment: Medium (coverage gap on shipped TFMs). On netstandard2.0/net462 the resolver is null, so Observe content-hashes every reparse point — including the mounted-ConfigMap case this type exists for: a full read + SHA-256 per file per poll tick, per watcher notification, and per 1 s watcher check (inside the watcher's _lock). The doc comment above ("a reparse point that is not a link ... is observed by its own metadata") only holds when a resolver exists. And the fallback ships with zero coverage: the resolveLinkTarget: null tests in FileStateTest sit inside #if NET6_0_OR_GREATER, and CI runs tests on net8.0 only, so the path never executes on the TFMs where it is live. The symlink-dependent tests also skip silently (log + return green) when link creation is unavailable, which makes the two ConfigMap-swap pinning tests vacuous on the Windows CI leg. Suggest compiling the no-resolver tests on all TFMs (they need no .NET 6 APIs) and/or adding a net462 test leg.
There was a problem hiding this comment.
The test of a regular file without link resolution now compiles on every framework (fd6ae05), and the remark describes the content fallback for every reparse point on a framework that cannot tell a link from another reparse point. The test of a link without link resolution needs the link creation API, so it stays under the framework condition. A net462 test leg in CI is a separate decision; leaving open for that.
Written by Claude Code on behalf of @kinyoklion.
acb8c0e to
357f615
Compare
Adds the Internal/FileLoading namespace with the file reading code that the flag overrides feature needs: a document parser and merger that keep each entry's own version and report per-document counts, a reloader that serializes reloads, debounces change signals, retains the last good result on failure, retries after a bounded delay, reports an identical repeated failure once, and skips unchanged content, a stat-based poller, and a directory watcher that handles files that do not exist yet. The existing file data source keeps its current behavior and does not use the shared code. Tests that pin that behavior are added so later changes to the shared code cannot alter it.
… change detection The poller and the watcher observe a configured file through any symbolic link, so a replaced link target or a swapped ConfigMap data directory is a change. Frameworks without link resolution compare the content of a link. The watcher retries a directory it cannot watch every second while it watches the others, drops the watch on a directory that no longer exists and sets it up again when it appears, compares the configured files on a notification for another entry, guards its handlers, computes each full path once, and accepts a null logger.
… failures Requests that arrive while a reload runs are served by one follow-up reload instead of each waiting for the lock. An exception from Apply or from the flag value expander is a load failure that is reported and retried, and the result is remembered as applied only after Apply returns. An exception from OnError is logged and does not stop the retry. The immediate reload path is guarded like the timer paths.
…ets a live watch The platform reports neither the deletion of a watched directory nor a new directory of the same name, so a watch on a replaced directory delivers nothing. The watcher now checks every second that each directory exists and that each configured file's state matches its last notification. A change without a notification is reported and the directory's watch is set up again.
Link resolution returns no target for a reparse point that is not a link, such as a file kept by a cloud storage provider. Such a file was reported as absent. The link resolver is now a parameter, so the case is tested.
…a notification The periodic check compares the configured files' states before it examines whether the directory exists, so the absence of the files of a directory that was removed or moved away is reported, and the reload clears the data they supplied. The watch is still dropped and set up again when the directory appears.
On Windows, deleting a watched directory raises the file system watcher's Error event with an access-denied exception. The error handler dropped the watch and retried its setup, so the periodic check skipped the directory and the warning that the directory no longer exists was never logged, while the first retry logged the deletion as an error. The outcome depended on whether the check or the error event ran first, which made DeletedDirectoryIsWatchedAgainWhenItAppears fail intermittently on Windows. The error handler now checks whether the directory still exists. When it does not, it handles the directory as the periodic check does: the deletion is logged once as a warning, the files' absence is reported when it was not already, and the setup failures that follow are logged at debug level until the directory exists again.
…ence On macOS, a notification for a write made shortly before the watch was set up can be delivered after it, tens of milliseconds later. A notification that names a configured file is reported without comparing its state, so the tests that assert that nothing is reported saw a change from the write that created their file. Those tests now wait out and discard such notifications first.
On Windows, a file that is open for reading with the default sharing mode cannot be written, replaced by a rename, or deleted until the read ends. The reloader reads a file right after it changed, so an editor that saves again, or a tool that replaces the file, could fail while a reload was in progress. The reloader test that fixes a file during a 20 ms retry loop hit this on windows-latest: its own write was refused while the retry was reading. FileDataReader opens files so that others may read, write, rename, and delete them, and otherwise behaves like the file data source's reader, including its wait when a writer denies reading. The reloader uses it by default. The file data source's reader is unchanged.
…t never settles The settle window moved out with every change notification, so a file that was written more often than the window never reloaded until the writing stopped. The window is now bounded: a reload runs no later than MaxDebounceDelay, one second by default, after the first notification of a stream, and the next notification starts a new stream. The Node engine has the same bound.
…le, with an ordinary write A plain rewrite is not atomic: the write and the modification-time update are separate steps, and a poll that lands between them legitimately reports an extra change. The poller promises generous detection, not one callback per logical change; the reloader coalesces and skips the excess. The test that wrote through a temporary file and a rename hid that behavior. The helper writes in the ordinary way again, and the test drains the callbacks of the write and then requires silence, which still fails for a poller that does not update its baseline.
…te before requiring silence Two reloader tests fix a failing file while the automatic retry is running and then require that nothing further is reported. The write is ordinary, not atomic, so a retry can read the file while it is empty or half written and report that failure; the report is correct for what the retry saw. The tests now discard the reports that arrived before the recovery was applied and require silence only for what follows it.
No consumer set it: the override source and the migrated file data source both rely on the reloader's own logging, and the legacy file data source never reported a failed load programmatically. The hook was carried over from the Node engine, where the engine does not log and every consumer handles failures itself. The tests now observe failures through the captured log, which is the only channel the reloader reports them on.
The file data parser no longer inspects the content to tell JSON from another format. The parser is chosen by the file's extension: an extension with a registered parser uses that parser, and every other file is parsed as JSON. The reloader configuration takes the parsers by extension in place of one alternate parser. A path with a YAML extension and no registered parser can never load, so a helper finds such a path for the builders to report when the client is created. A flag or segment is listed under its key. A key property in the definition is optional and must be equal to the key it is listed under, and a version property is optional. A definition that is marked as deleted is rejected. A file with no content is a document with no entries, and content after the end of the document is an error. The flag and segment deserializers gain a file entry read path that shares the property loop with the strict read.
The reader no longer waits up to 30 seconds for a writer that denies reading; such a read fails and the reloader retries it. The delays of the reload cycle are no longer per-consumer options: FileDataReloaderTiming holds the production values (100 ms settle window, 1 s bound on postponing, 1 s retry) and rejects a settle window longer than its bound, and tests pass shorter delays through the reloader constructor. RetryOnFailure is the one consumer choice left. The immediate reload path for a zero settle window is gone. The skip-unchanged hash is computed per file as the files are read and frames each file with its path, its presence, and the length of its content, so the same bytes in another file is a change. Hash comparison uses SequenceEqual here and in FileState. The merger names a key repeated inside one file as such. The poller runs the callback before it moves its baseline, so a callback that throws has the change reported again. The watcher's Dispose takes no lock, a watch created while Dispose ran is dropped, and the periodic check keeps a watch that delivered a notification since the previous check instead of setting it up again.
fd6ae05 to
5a6003e
Compare
…ha.1 (#382) 🤖 I have created a release *beep* *boop* --- ## [9.0.0-alpha.1](LaunchDarkly.ServerSdk-v9.0.0-alpha.0...LaunchDarkly.ServerSdk-v9.0.0-alpha.1) (2026-10-29) ### Features * Add a shared file data reloader, poller, and watcher ([#361](#361)) ([4eed241](4eed241)) * Add the file-based override source ([#367](#367)) ([b834131](b834131)) * Add the override marker to the flag and segment models and mark evaluations ([#363](#363)) ([21f666a](21f666a)) * Add the override store, overlay, source interfaces, and data system wiring ([#364](#364)) ([e1748a1](e1748a1)) * Mark evaluation events as override-affected ([#366](#366)) ([7c873a0](7c873a0)) * Split summary counters by overrideAffected and suppress individual events for marked evaluations ([#365](#365)) ([c9b327b](c9b327b)) * Support the flag-overrides capability in the contract test service ([#368](#368)) ([b80d6aa](b80d6aa)) --- This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please). <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Overview** > This is a **Release Please** cut that publishes **LaunchDarkly.ServerSdk `9.0.0-alpha.1`**. The diff only updates release metadata: `.release-please-manifest.json`, `LaunchDarkly.ServerSdk.csproj` `<Version>`, `PROVENANCE.md` verification example, and a new **`9.0.0-alpha.1`** section in `CHANGELOG.md`. > > The changelog entry documents what ships in this alpha (already merged elsewhere): **local flag overrides** via override store/overlay/source wiring and a **file-based override source** with shared file reloader/poller/watcher; **override markers** on flag/segment models and evaluations; **analytics** changes that mark override-affected evaluations, split summary counters by `overrideAffected`, and suppress individual events when appropriate; plus **contract-test** support for the flag-overrides capability. > > Reviewers can treat this as a **versioning and release-notes sanity check**—no application logic changes appear in the diff itself. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 81f0588. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
## Summary This change moves the file data source onto the shared file loading code that #361 adds, so that it and the file-based override source that a later change adds share one reading, reloading, merging, and change detection implementation. It targets `dotnet-server-sdk-v9`, the branch that prepares the next major version, so the data source takes the shared code's file format rules instead of keeping its own. The data source keeps its options, its default file reader, and its data semantics; what is particular to it stays in `FileDataSource`, and the loading machinery it had of its own (`FlagFileParser`, `FlagFileDataMerger`, `FileWatchingReloader`) is removed. ### File format changes for the next major version - `FileDataSourceBuilder.Parser` takes the file extension the parser is for. The parser for a file is chosen by its extension; a file whose extension has no registered parser is parsed as JSON, and the content is no longer inspected to guess its format. A configured file with a `.yaml` or `.yml` extension and no registered parser can never load, so it is reported when the client is created. - A flag or segment is listed under its key. A `key` property in the definition is optional and must be equal to the key it is listed under; before, the definition's own key was kept even when it differed. A `version` property is optional; before, a definition without one failed the load. The data source assigns the version of each load to every item, as it always did. - A definition that is marked as deleted fails the load. A file with no content is a document with no entries, where before it failed to parse. Content after the end of the document fails the load, where before it was ignored. The shared code gains these additions, each with a default that leaves the override source unchanged: - `FileDataReloaderConfig.MaxLoadAttempts`, a bound on the consecutive failed attempts after which the reloader stops retrying until the next detected change. The file data source uses it to keep its documented promise of giving up after repeated failures. The line that ends the budget names the failure. - `FileDataReloaderConfig.FailOnMissingDirectory`, which makes a path whose directory does not exist fail the load even when missing files are skipped. The file data source uses it to keep its rule that `SkipMissingPaths` covers a file that is missing from a directory that exists. - A failure that repeats is demoted to debug level only on an automatic retry that fails the same way as the attempt before it. A load that a change notification or `Start` begins is a new event and is always logged at error level. Before, the demotion compared messages regardless of what started the load. - A retry whose timer fires while a reload that then succeeds is in progress applies nothing afterwards: nothing has failed since, so there is nothing to retry. Before, it loaded the same data again. ### What stays with the file data source `FileDataSource` configures a `FileDataReloader` and, with auto-update, a `FileDataWatcher`, and supplies what the shared code does not decide: - Every item of a successful load is stamped with one version that counts the successful loads, and the full data set is applied with `Init` on every successful load, including when the content is unchanged and on a repeated `Start`. - `Start` loads synchronously and returns a completed task that reports whether any load has ever succeeded; `Initialized` stays true afterwards. After `Dispose`, `Start` does nothing. - `DuplicateKeysHandling.Throw` fails the whole load and `Ignore` keeps the first occurrence across files; `SkipMissingPaths` skips a file that is missing from a directory that exists, and a path whose directory does not exist fails the load with or without it, so a directory that disappears cannot empty the store; the registered parsers and the configured `FileReader` are passed through; without auto-update, files are loaded once, with no retry and no watching. - The `flagValues` expansion is the same flag as before: on, one variation, served by fallthrough. - Each change notification still logs `detected file modification, reloading`. ### Robustness the file data source gains The observable data semantics are unchanged: the same documents produce the same flags and versions, `Start` reports the same result, and the last good data stays in place when a load fails. With auto-update on, the data source now behaves differently in these ways: - Any failed load is retried, not only a parse failure: a read error, a duplicate key, or a failure to apply the data is retried too, after the shared code's one second delay rather than 600 ms, up to 5 attempts, and then given up until the next detected change. The budget covers the run of attempts, where it was counted per file before. Previously a read error on a load started by a change notification was not retried at all, and the data source waited for another notification that was not guaranteed to come. - Change notifications settle for 100 ms before a reload, and notifications that arrive during a reload coalesce into one follow-up, so the burst of notifications one edit produces loads once. A stream of notifications that never settles reloads no later than one second after its first notification. - The default reader is the shared code's. It opens a file while allowing other processes to write, rename, or delete it, so a load in progress never fails the editor or tool that is updating the file on Windows, and it does not wait for a process that denies reading: that load fails, and with auto-update it is retried after a second. Before, the reader held the file exclusively while reading and waited up to 30 seconds for a process that held it. `FlagFileReader` is removed. - A deleted configured file is detected. Without `SkipMissingPaths` the reload fails and the data is kept, as a missing file always was; with `SkipMissingPaths` the remaining files are applied, so the deleted file's flags leave the store. - A directory that does not exist disables nothing: each directory is watched when it exists and attempted again every second, where before one such path turned auto-update off for every file. The load of a path in a missing directory fails as before until the directory and the file exist; then the watcher picks them up. A watched directory that is deleted has its watch dropped and set up again when it returns, with one reload then. A watcher error is handled the same way. Configured files are compared by their metadata, read through symbolic links, so a replaced link is detected while other files in the directory cost nothing, and a change that arrives without a notification is found by a check once a second. - The log messages are the shared code's. For failed loads: `Unable to load flags: unable to read file: ... [path]`, `Unable to load flags: error parsing file: ... [path]`, `Unable to load flags: flag "key" is specified by multiple files`; they carry the exception message without the exception type name, and no debug-level stack trace follows them. The first failure of a run is logged at error level, a retry that fails the same way as the attempt before it at debug level, and the end of the retry budget once at error level with the reason; a load started by a change notification or `Start` is always logged at error level. Before, every attempt logged a warning. New lines: `Reloading file data after detecting a change` at info level on each reload, in addition to the kept line; `Retrying file data load after earlier failure` at debug level; `File {path} does not exist; it contributes no data` at debug level for a skipped file, replacing `{path}: File not found`. The watcher logs its own messages (`Unable to watch directory {dir}; will retry`, `Now watching directory {dir}`, `Directory {dir} no longer exists; its watch is set up again when it appears`, `Error from file system watcher for directory {dir}`), which replace `Unable to watch files for auto-updating` for a directory that is missing; that line remains for a watcher that cannot be constructed at all, and is logged once. The debug line that logged the full content of every file read is gone. - The version counter advances only on successful loads, so versions no longer skip numbers for failed attempts. Each applied load still has one version, the first is 1, and they increase. ### Documented-only differences - Under `DuplicateKeysHandling.Ignore`, a key that appears in both `flags` and `flagValues` of one file resolves to the `flags` entry. Before, the entry that came first in the document won. With `Throw` both fail the load, as before. The `FileData` docs already call an in-file duplicate an error. - `FileReader(null)` means the default reader. Before, every load failed with a null reference. - An exception from `Init` is a load failure: it is logged as `Unable to load flags: error applying file data: ...`, retried with auto-update, and makes `Start` return false, where before it escaped from `Start` or the watcher thread. It does not consume a version. - The failure to construct the watcher is logged when `Start` runs, once, instead of when the client is built. - A rename that replaces a configured file logs the detected-change line twice, once for each name, and reloads once. - With auto-update, the reload after a change runs on a timer thread after the settle window, not on the file system watcher's thread. The only public API change is the `Parser` signature described above. The doc comment of `FileDataSourceBuilder.AutoUpdate` describes the directory watching and the retained data, and the `FileData` docs name the `flagValues` property correctly and describe the key and version rules. ### Tests The baseline tests pass with these changes, each marked in the test with its reason. In `FileDataSourceExistingBehaviorTest`: three failed-load pins and the missing-directory pin keep their behavior assertions and get the shared code's message texts; the pin of a warning per failed parse attempt is replaced by the error-then-debug rule and the end of the budget. In `FileDataSourceTest`: the two tests of the per-file retry budget are replaced by one for the per-run budget; the test that loads good data after a bad start sees version 1, because a failed load no longer consumes a version; the test that a modified file is not reloaded while another is missing waits for the failed reload before it observes, because its observation window equaled the settle window; the tests whose scripted reader ignores the path use a path in a directory that does not exist, so that activity in the shared temp directory cannot reach the watcher on Windows and macOS, and the one that needs a notification uses a private directory; two comments that described the old mechanism are updated. `FileDataSourceBuilderTest` covers the parser registration by extension. `FlagFileDataMergerTest` goes with the merger; the shared `FileDataMergerTest` covers the same semantics. New tests pin the configuration error for a YAML file without a parser, that files with other extensions need none, and that the retry applies to a file read with a registered parser. New tests in `FileDataSourceTest` pin the version stamping, that an `Init` that throws consumes no version, the application of identical content, the `flagValues` shape, `Ignore` across files, auto-update off meaning no watcher and no retry, `Start` after `Dispose`, that `Dispose` stops the watcher and a `Start` after it creates none, that a change made during the initial load is detected, the empty data set with both kinds, deletion with and without `SkipMissingPaths`, a directory created after start, the one second retry delay, the coalescing of a burst of notifications, that a retry firing during a successful `Start` does not apply again, and the per-run retry budget. New tests in `FileDataSourceExistingBehaviorTest` pin that every failed `Start` is logged at error level. New tests in `FileDataReloaderTest` pin `MaxLoadAttempts`, `FailOnMissingDirectory`, the repeat rule, the reason on the give-up line, no give-up without retries, and the stale retry. Each new test was run against a one-line mutation of the code that makes it fail; the mutations are listed in the commit message. [SDK-3248](https://launchdarkly.atlassian.net/browse/SDK-3248) [SDK-3248]: https://launchdarkly.atlassian.net/browse/SDK-3248?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Overview** > **Moves the server file data source onto the shared `FileLoading` stack** (`FileDataReloader`, `FileDataWatcher`, `FileDataParser`, `FileDataReader`) and **deletes** the old in-source pieces (`FlagFileParser`, `FlagFileReader`, `FlagFileDataMerger`, `FileWatchingReloader`). `FileDataSource` now configures the reloader/watcher and stamps versions in `Apply`; load, merge, debounce, retry, and watch behavior come from the shared engine. > > **Public API (v9):** `FileDataSourceBuilder.Parser` is now **`Parser(extension, parseFn)`** with a dictionary of parsers by extension (JSON when none registered). **Build fails** if a `.yaml`/`.yml` path has no parser. Default reader is **`FileDataReader`**; `FileReader(null)` resets to default. Docs fix **`flagValues`** naming and describe optional `key`/`version` in files. > > **Shared reloader additions used by the data source:** `MaxLoadAttempts` (5 with auto-update), `FailOnMissingDirectory` (with `SkipMissingPaths`), per-run retry budget, stale-retry skip after success, and updated failure logging (error on new loads, debug on identical retries, give-up line with reason). > > **Observable behavior shifts with auto-update** (called out in tests/docs): 1s retry delay vs 600ms; debounced reloads; broader retry on read/merge/apply failures; deletion/skip-missing-path semantics; directory watching when paths don’t exist yet; unified log message prefixes; versions advance only on successful loads; several edge cases around `Dispose`/`Start`/watcher setup. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 656c726. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
Summary
This is the first of the file-based flag overrides changes for the server SDK, as defined by the OVERRIDE specification. It targets
dotnet-server-sdk-v9, the branch that prepares the next major version, so the shared file loading code does not carry the format compatibility rules of the existing file data source. The override source that a later change adds needs file reading code with the reliability the specification requires of a source that operators edit during an incident: a reload that keeps the last good data when a file is unreadable or malformed, a retry after such a failure that does not wait for another change notification, coalescing of the burst of notifications that one edit produces, a file that does not exist yet contributing no data, and a change detector that does not depend on notification delivery.The new
Internal/FileLoadingnamespace holds that code. It is purely additive: nothing in the SDK uses it yet, and the existing file data source keeps its current behavior.FileDataParserparses the file data document format (flags,flagValues,segments) into aFileDataDocument. The parser for a file is chosen by the file's extension: an extension with a registered parser, such as a YAML parser for.yaml, uses that parser, and every other file is parsed as JSON; the content is never inspected to guess its format. A flag or segment is listed under its key: akeyproperty in the definition is optional and must be equal to the key it is listed under, and aversionproperty is optional. A definition that is marked as deleted is rejected, a file with no content is a document with no entries, and content after the end of the document is an error.FindYamlPathWithoutParserlets a builder report a YAML file that has no registered parser as a configuration error when the client is created, since such a file can never load.MakeFallthroughFlagWithValueexpands aflagValuesentry into a flag that is on and serves its single value by fallthrough, which is the form the override source uses.ReadFileEntrypath for data file entries. It shares the property loop with the strict read that LaunchDarkly data uses, which is unchanged.FileDataMergercombines documents in configured order withFailorIgnoreduplicate keys handling and reports how many entries it kept from each document.FileDataReloaderowns the reload cycle: it serializes reloads, debounces change signals with a settle window that each signal extends, bounded so that a stream of signals which never settles still reloads within a second, retains the last good result on failure by not applying anything, retries a failed reload after a bounded delay, reports an identical repeated failure once, skips a reload whose files are unchanged, and optionally treats a missing file as a file with no content. The delays of the cycle are not per-consumer options:FileDataReloaderTiming.Productionholds them (100 ms settle window, 1 s bound, 1 s retry), tests pass shorter delays through the constructor, and a settle window longer than its bound is rejected. Whether a failed reload is retried is the one consumer choice. The skip-unchanged hash is computed per file as the files are read and frames each file with its path, its presence, and the length of its content, so the same bytes moving to another file is a change. Dispose never waits for an in-flight reload. Requests that arrive while a reload runs coalesce into one follow-up reload. An exception from Apply or the value expander is reported like any other failure and retried, and nothing from that load is remembered.FileDataReaderreads a file while allowing others to write, rename, or delete it, so that a reload in progress never fails the editor or tool that is updating the file on Windows. A read that a writer denies fails like any other read and is retried by the reloader; the reader does not wait for the writer. The file data source's reader is unchanged.FileDataPollerdetects changes by examining each file's modification time, size, and existence on a fixed interval. It moves its baseline only after the callback has taken a change, so a callback that throws has the change reported again. A configured path that is a symbolic link is observed through its final target; on frameworks without link resolution a link is observed by its content.FileDataWatcherdetects changes throughFileSystemWatcheron each file's directory, so a file that appears later or is deleted is reported. BecauseFileSystemWatcherreports the deletion of its directory as an error on some platforms and not at all on others, and reports nothing when the directory is replaced, the watcher also checks its directories once per second: a missing directory, whether reported or found by the check, is watched again once it exists, a configured file that changed without a notification is reported and its directory watch re-created unless that watch delivered a notification since the previous check, and an event for another entry compares the configured files through any link, so a swap of a linked file is detected. Dispose takes no lock, so a handler examining files on an unresponsive file system cannot block shutdown.FileDataSource,FlagFileParser, and the file data source builder are unchanged, and their existing tests are unchanged.FileDataSourceExistingBehaviorTestadds tests that pin behavior of the file data source that its existing tests did not assert directly, so that later changes to the shared code cannot alter it: every successful load is applied even when the content is unchanged, the 600 ms retry delay and its warning on every failed attempt, the log messages and levels for a failed load, a path in a missing directory being a failure even withSkipMissingPaths, and a deleted file producing no reload.Every new component has unit tests, including the debounce settle window, retry without further signals, last-good retention and recovery, serialized reloads, and non-blocking dispose. No public API changes.
SDK-3248