Repository navigation
feat: Move the file data source onto the shared file data engine - #376
Conversation
|
bugbot review |
b352447 to
b2c3d0e
Compare
52efdce to
3a7d70e
Compare
3a7d70e to
e7ae2c5
Compare
acb8c0e to
357f615
Compare
e7ae2c5 to
6c983cf
Compare
|
bugbot review |
6c983cf to
5ed6735
Compare
|
bugbot review |
fd6ae05 to
5a6003e
Compare
5ed6735 to
417f1ce
Compare
417f1ce to
8ae7d79
Compare
|
bugbot review |
9c3276b to
468453d
Compare
|
bugbot review |
The file data source now loads, retries, and watches its files through the shared FileDataReloader and FileDataWatcher instead of its own copies of that logic. FileDataSource keeps what is particular to it: one version number per successful load stamped on every flag, segment, and item descriptor, consumed only once Init has accepted the data; a full Init on every successful load, including unchanged content; the result of Start reporting whether any load has succeeded; the rule that a path in a missing directory fails the load even when missing files are skipped; and the mapping of the builder options onto the engine's configuration. FlagFileParser, FlagFileDataMerger, and FileWatchingReloader are deleted. The builder's default reader is still FlagFileReader, and the public API is unchanged apart from the doc comment on AutoUpdate, which describes the new watching behavior. The engine gains FileDataReloaderConfig.MaxLoadAttempts, a bound on consecutive failed load attempts after which the reloader stops retrying until the next change signal, with the failure named on the line that ends the budget, and FileDataReloaderConfig.FailOnMissingDirectory, which keeps a missing directory a read failure when missing files are skipped. Both default to off, which keeps the behavior the override source relies on. Two engine rules change for every consumer: a repeated failure is demoted to debug level, and not reported again, only on an automatic retry; and a retry that fires during a reload that then succeeds applies nothing afterwards. The behavior changes that come with the move, such as retrying every kind of load failure, debouncing change notifications, treating a deleted file as a change, and the new log texts, are deliberate and are listed in the PR body. The tests that pinned the old texts and rules say so inline. Negative controls Each new test fails under the one-line mutation named after it and passes without it. FileDataReloaderTest: - GivesUpAfterMaxLoadAttempts: the give-up check ">=" replaced by ">" - TriggerAfterGivingUpStartsOverWithAFreshBudget: no reset of the attempt count on a load that is not a retry - RetriesWithoutBoundByDefault: the bound applied with MaxLoadAttempts zero - MissingDirectoryFailsWhenFailOnMissingDirectoryIsSet: FailOnMissingDirectory ignored - FailureThatRepeatsOnANewReloadIsReportedAgain: a repeated failure demoted regardless of whether the load is a retry - GiveUpLineNamesTheFailure: the give-up line without the failure message - RetryThatFiresDuringASuccessfulReloadDoesNotApplyAgain: the stale retry guard removed - NoGiveUpWithoutRetries: the RetryDelay term of the give-up check removed FileDataSourceTest: - EveryItemGetsTheVersionOfItsLoadAndTheVersionCountsSuccessfulLoads: the segment restamp dropped - IdenticalContentIsAppliedAgainByEachStart: SkipUnchanged set to true - FlagValuesEntryBecomesAFlagThatIsOnAndServesItsOneVariationAsFallthrough: the expander given a null value - DuplicateKeyAcrossFilesWithIgnoreKeepsTheFirstOccurrence: Ignore mapped to Fail - LoadWithNoPathsAppliesAnEmptyDataSetWithBothKinds: the segments kind not added to the data set - AutoUpdateOffMeansNoWatcherAndNoRetry: the retry delay set without auto-update; the watcher created without auto-update - StartAfterDisposeDoesNothingAndReturnsTheLastResult: the Dispose guard inverted, so that Dispose does nothing - InitThatThrowsDoesNotConsumeAVersion: the version incremented before Init - DeletingAWatchedFileWithSkipMissingPathsRemovesItsItems: SkipMissingPaths forced to false - DeletingAWatchedFileWithoutSkipMissingPathsFailsTheReloadAndKeepsTheData: the Trigger call removed from the change callback - FileInADirectoryCreatedAfterStartIsLoadedWhenAutoUpdateIsOn: the watcher not created - BurstOfChangeNotificationsLoadsOnce: DebounceDelay set to zero - DisposeStopsTheWatcher: the watcher not disposed - StartAfterDisposeStartsNoWatcher: the disposed flag not set - ChangeMadeDuringTheInitialLoadIsLoaded: the watcher created after the initial load - RetryFollowsTheFailureAfter600Milliseconds: the retry delay set to 1000 ms - RetryThatFiresDuringASuccessfulStartDoesNotInitAgain: the stale retry guard removed - RetryBudgetIsPerEpisodeNotPerPath: MaxLoadAttempts set to zero FileDataSourceExistingBehaviorTest: - FailedLoadAttemptsLogOneErrorThenDebugAndOneErrorWhenGivingUp: repeated failures logged at error level - EveryFailedStartLogsTheFailureAtError: a repeated failure demoted regardless of whether the load is a retry - PathInAMissingDirectoryFailsTheLoadEvenWithSkipMissingPaths (restored pin): FailOnMissingDirectory set to false
The file data source builder's Parser method takes the file extension the parser is for. The parser for a file is chosen by its extension; every other file is parsed as JSON. A configured file with a YAML extension and no registered parser is a configuration error reported when the client is created. The FileData documentation describes the entry rules: a flag or segment is listed under its key, the key property is optional and must match, and the version property is optional because the data source assigns the version of each load.
The file data source's default reader is the shared code's FileDataReader, and FlagFileReader is removed. The shared reader opens a file while allowing other processes to write, rename, or delete it, and it does not wait for a process that denies reading: that load fails, and with auto-update it is retried. Passing null to FileReader selects the default reader by construction.
468453d to
56becd5
Compare
…ose takes Setting up the watcher examines the files and watches their directories, which can block on a hung path. It ran while holding the lock that Dispose takes, so shutdown could wait for it. It now runs outside the lock, and a watcher that finishes setting up after Dispose is disposed at once.
|
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 840c5e4. Configure here.
| .SetItem(DataModel.Features, new KeyedItems<ItemDescriptor>(flags)) | ||
| .SetItem(DataModel.Segments, new KeyedItems<ItemDescriptor>(segments)) | ||
| ); | ||
| _dataSourceUpdates.Init(allData); |
There was a problem hiding this comment.
An application that pairs the file data source with a persistent store gets told the client initialized when the store never received the flags, and auto-update never tries again. DataSourceUpdatesImpl.InitWithHeaders catches a store exception and returns false rather than throwing (DataSourceUpdatesImpl.cs:563-566), and this line ignores the result, so the new "an exception from Init is a load failure" path never runs with the SDK's own updates sink.
With a real DataSourceUpdatesImpl over a store whose Init throws:
Start()returnstrueandInitializedistrue.- With
AutoUpdate(true), no secondInithappens within 3 seconds, so the store stays empty until someone edits a file.
The old code ignored the result too, but this PR is where the data source gains a retry path for exactly this case. Treating false as a failure routes it there:
if (!_dataSourceUpdates.Init(allData))
{
throw new InvalidOperationException("the data store did not accept the data");
}| ArmRetry(keepExistingDeadline: true); | ||
| // A retry armed by an earlier failure is redundant after this success, and after | ||
| // giving up nothing runs until the next change. | ||
| DisarmRetry(); |
There was a problem hiding this comment.
This DisarmRetry runs after Reload has released _reloadLock, so it can cancel a retry that a different reload armed in the meantime. If a change notification arrives while Start is loading, the triggered reload waits on the lock. If it then fails, it arms a retry, and this line can disarm it. The reloader is left with a failed load and nothing scheduled.
A partial write recovers anyway, because finishing the write changes the file and the watcher reloads. A failure that leaves the file untouched does not, for example a scanner or backup tool holding a deny-read lock. The application then serves the data from Start until the next edit, which is the case the timed retry is there to cover.
The window is narrow: a stress test that holds the triggered reload on the lock lost the retry once in 3000 runs. It is cheap to close by making the decision with the lock held, for example by disarming only when _failedAttempts == 0 under _reloadLock, so a success never cancels a retry for a failure that came after it.
…l load ReloadNow disarmed the retry after a successful load, outside the reload lock. A reload that a change notification started during the initial load could fail after it and arm a retry, and the disarm then canceled that retry. The reloader was left with a failed load and nothing scheduled. ReloadNow no longer disarms on success. A retry left over from an earlier failure does no work, because Reload skips a retry when nothing has failed since the last success.
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 inFileDataSource, and the loading machinery it had of its own (FlagFileParser,FlagFileDataMerger,FileWatchingReloader) is removed.File format changes for the next major version
FileDataSourceBuilder.Parsertakes 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.yamlor.ymlextension and no registered parser can never load, so it is reported when the client is created.keyproperty 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. Aversionproperty 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.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 thatSkipMissingPathscovers a file that is missing from a directory that exists.Startbegins is a new event and is always logged at error level. Before, the demotion compared messages regardless of what started the load.What stays with the file data source
FileDataSourceconfigures aFileDataReloaderand, with auto-update, aFileDataWatcher, and supplies what the shared code does not decide:Initon every successful load, including when the content is unchanged and on a repeatedStart.Startloads synchronously and returns a completed task that reports whether any load has ever succeeded;Initializedstays true afterwards. AfterDispose,Startdoes nothing.DuplicateKeysHandling.Throwfails the whole load andIgnorekeeps the first occurrence across files;SkipMissingPathsskips 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 configuredFileReaderare passed through; without auto-update, files are loaded once, with no retry and no watching.flagValuesexpansion is the same flag as before: on, one variation, served by fallthrough.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,
Startreports 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:FlagFileReaderis removed.SkipMissingPathsthe reload fails and the data is kept, as a missing file always was; withSkipMissingPathsthe remaining files are applied, so the deleted file's flags leave the store.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 orStartis always logged at error level. Before, every attempt logged a warning. New lines:Reloading file data after detecting a changeat info level on each reload, in addition to the kept line;Retrying file data load after earlier failureat debug level;File {path} does not exist; it contributes no dataat 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 replaceUnable to watch files for auto-updatingfor 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.Documented-only differences
DuplicateKeysHandling.Ignore, a key that appears in bothflagsandflagValuesof one file resolves to theflagsentry. Before, the entry that came first in the document won. WithThrowboth fail the load, as before. TheFileDatadocs already call an in-file duplicate an error.FileReader(null)means the default reader. Before, every load failed with a null reference.Initis a load failure: it is logged asUnable to load flags: error applying file data: ..., retried with auto-update, and makesStartreturn false, where before it escaped fromStartor the watcher thread. It does not consume a version.Startruns, once, instead of when the client is built.The only public API change is the
Parsersignature described above. The doc comment ofFileDataSourceBuilder.AutoUpdatedescribes the directory watching and the retained data, and theFileDatadocs name theflagValuesproperty 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. InFileDataSourceTest: 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.FileDataSourceBuilderTestcovers the parser registration by extension.FlagFileDataMergerTestgoes with the merger; the sharedFileDataMergerTestcovers 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
FileDataSourceTestpin the version stamping, that anInitthat throws consumes no version, the application of identical content, theflagValuesshape,Ignoreacross files, auto-update off meaning no watcher and no retry,StartafterDispose, thatDisposestops the watcher and aStartafter it creates none, that a change made during the initial load is detected, the empty data set with both kinds, deletion with and withoutSkipMissingPaths, a directory created after start, the one second retry delay, the coalescing of a burst of notifications, that a retry firing during a successfulStartdoes not apply again, and the per-run retry budget. New tests inFileDataSourceExistingBehaviorTestpin that every failedStartis logged at error level. New tests inFileDataReloaderTestpinMaxLoadAttempts,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
Note
Overview
Moves the server file data source onto the shared
FileLoadingstack (FileDataReloader,FileDataWatcher,FileDataParser,FileDataReader) and deletes the old in-source pieces (FlagFileParser,FlagFileReader,FlagFileDataMerger,FileWatchingReloader).FileDataSourcenow configures the reloader/watcher and stamps versions inApply; load, merge, debounce, retry, and watch behavior come from the shared engine.Public API (v9):
FileDataSourceBuilder.Parseris nowParser(extension, parseFn)with a dictionary of parsers by extension (JSON when none registered). Build fails if a.yaml/.ymlpath has no parser. Default reader isFileDataReader;FileReader(null)resets to default. Docs fixflagValuesnaming and describe optionalkey/versionin files.Shared reloader additions used by the data source:
MaxLoadAttempts(5 with auto-update),FailOnMissingDirectory(withSkipMissingPaths), 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.Reviewed by Cursor Bugbot for commit 656c726. Bugbot is set up for automated code reviews on this repo. Configure here.