Skip to content

feat: Add a shared file data reloader, poller, and watcher - #361

Merged
kinyoklion merged 18 commits into
dotnet-server-sdk-v9from
rlamb/overrides-dotnet-filedata-reloader
Oct 27, 2026
Merged

kinyoklion merged 18 commits into
dotnet-server-sdk-v9from
rlamb/overrides-dotnet-filedata-reloader

Conversation

@kinyoklion

@kinyoklion kinyoklion commented Sep 25, 2026 •

Copy link
Copy Markdown
Member

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/FileLoading namespace holds that code. It is purely additive: nothing in the SDK uses it yet, and the existing file data source keeps its current behavior.

  • FileDataParser parses the file data document format (flags, flagValues, segments) into a FileDataDocument. 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: 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. FindYamlPathWithoutParser lets 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. MakeFallthroughFlagWithValue expands a flagValues entry into a flag that is on and serves its single value by fallthrough, which is the form the override source uses.
  • The flag and segment deserializers gain a ReadFileEntry path for data file entries. It shares the property loop with the strict read that LaunchDarkly data uses, which is unchanged.
  • FileDataMerger combines documents in configured order with Fail or Ignore duplicate keys handling and reports how many entries it kept from each document.
  • FileDataReloader owns 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.Production holds 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.
  • FileDataReader reads 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.
  • FileDataPoller detects 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.
  • FileDataWatcher detects changes through FileSystemWatcher on each file's directory, so a file that appears later or is deleted is reported. Because FileSystemWatcher reports 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. FileDataSourceExistingBehaviorTest adds 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 with SkipMissingPaths, 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

@kinyoklion
kinyoklion force-pushed the rlamb/overrides-dotnet-filedata-reloader branch from 883fd4b to 353e748 Compare September 28, 2026 20:39
@kinyoklion

Copy link
Copy Markdown
Member Author

bugbot review

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

@kinyoklion

Copy link
Copy Markdown
Member Author

bugbot review

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

@kinyoklion

Copy link
Copy Markdown
Member Author

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.

@kinyoklion

Copy link
Copy Markdown
Member Author

The ubuntu build-and-test failure on bcbc25a was FileDataReloaderTest.StopsRetryingAfterSuccess, the same flaw as the poller test, in a test this PR had not touched. The test fixes a failing file with File.WriteAllText while the 10 ms retry is running and then requires that nothing further is reported. The run's log shows the retry reading the file empty nine times (The input does not contain any JSON tokens) before the content landed; each of those reads was a failure the reloader correctly reported, and the queued reports tripped the final RequireQuiet. SuccessfulReloadNowDisarmsThePendingRetry has the same shape.

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 (-c DebugLocalReferences -f net8.0): FileDataReloaderTest 29/29 in each of 10 runs, FileLoading 95, the FileLoading plus Overrides tests at the top of the stack (d1cd013) 208, and the migration branch (3a7d70e) 158. The stack and #376 are rebased onto this commit.

Written by Claude Code on behalf of @kinyoklion.

@kinyoklion
kinyoklion marked this pull request as ready for review October 16, 2026 21:03
@kinyoklion
kinyoklion requested a review from a team as a code owner October 16, 2026 21:03
/// 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>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This function is unexpected in this file. Why does it exist here?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@kinyoklion
kinyoklion force-pushed the rlamb/overrides-dotnet-filedata-reloader branch from acb8c0e to 357f615 Compare October 26, 2026 22:46
@kinyoklion
kinyoklion changed the base branch from feat/overrides to dotnet-server-sdk-v9 October 26, 2026 22:48
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.
@kinyoklion
kinyoklion force-pushed the rlamb/overrides-dotnet-filedata-reloader branch from fd6ae05 to 5a6003e Compare October 27, 2026 20:23
@kinyoklion
kinyoklion merged commit 4eed241 into dotnet-server-sdk-v9 Oct 27, 2026
7 checks passed
@kinyoklion
kinyoklion deleted the rlamb/overrides-dotnet-filedata-reloader branch October 27, 2026 20:42
kinyoklion pushed a commit that referenced this pull request Oct 29, 2026
…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>
kinyoklion added a commit that referenced this pull request Oct 30, 2026
## 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 -->
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants