Repository navigation
fix: Keep file data and overrides through an in-place rewrite of a file - #2154
kinyoklion wants to merge 8 commits into
Conversation
A file that is rewritten in place is truncated and then written, so a reload can find it empty. The file override source applied an emptied YAML file as a document with no entries, which removed its overrides until the next reload. An emptied JSON file failed to parse and was reported as an error on every in-place rewrite, by the override source and by the file data source. The shared file reloader now remembers, for each file, whether its last read found content. A reload that finds a file empty after it had content keeps the current data, logs at debug level, and reads the file again after the retry delay, without reporting a failure. The next reload parses whatever the file holds, so the data is held back at most once per emptying. A file that is empty from the start is never read again, and with retries off an empty file is parsed right away. An empty JSON file now fails with a message that says the file has no content.
|
bugbot review |
|
@launchdarkly/js-sdk-common size report |
|
@launchdarkly/js-client-sdk size report |
|
@launchdarkly/js-client-sdk-common size report |
…r the retry delay Reading an emptied file again once, keyed on whether the last read found content, still applied an empty file in three in-place rewrite sequences, because a new truncation that no load read in between looks like a file that stayed empty: two quick rewrites in one settle window, a change to another file while one file is mid-rewrite, and polling with two rewrites about a second apart. The reloader now remembers, per file, whether the file contributed content to the data in effect, and the empty episode of a file whose content is in effect: when a load first found it empty, and since when it has been empty without being written, by its modification time. Such a file is held back until it has stayed empty, without being written, for one retry delay, and at the latest five retry delays after a load first found it empty. The retry is armed for the end of that delay. A file whose content is not in effect, such as a placeholder, is parsed right away, as is any empty file when retries are off.
|
bugbot review |
… a slack A reload that holds back one emptied file now keeps reading the others, so that files emptied together start their delays in the same reload instead of one retry delay after another. A file that cannot be read or parsed still fails the reload. The retry that the hold arms comes one retry delay later and a timer can fire slightly early, so the hold is checked against nine tenths of the retry delay; a file that stays empty is applied by that retry. The retry is armed for the usual delay again.
|
bugbot review |
…y, and hold YAML with no document
Reviews of the hold found four problems, and this addresses them:
- Files rewritten in turn were held back without end, because the cap of
five retry delays was per file and reset whenever that file was read
with content. The cap now bounds the run of reloads that hold files
back, from the first such reload until a reload that holds none back.
- The slack of a tenth of the retry delay let a reload apply a file that
had been empty for less than the delay. The hold is now exactly one
retry delay, and its retry is armed for when the first held file's
delay or the cap ends, plus a tenth of the retry delay.
- A partial YAML read with only a comment or a document marker parsed to
no document and was applied as no entries. A file whose parser reads
it as no document now counts as empty for the hold; `{}` is a document
and is applied right away.
- A reload that skipped unchanged content did not remember which file
contributed it, so after a file moved to another configured path an
in-place rewrite of it was applied empty. The skip now remembers it.
The hold now compares the file's modification time and size, read
through any symbolic link, with the poller's file state helper.
|
bugbot review |
|
bugbot review |
…ach file's read A review of the round-4 rule proved three regressions: - The clock was read once per reload, so a slow read of an earlier file made a later file's emptiness look older and shortened its delay. The clock is now read after each file's own read, and again when the hold's retry delay is computed. - A failed reload ended the run of holding reloads, so a file that kept being written while empty was held without end when another file kept failing. - The bound was shared, so one file reaching it applied another file that was a fraction of a delay into an ordinary rewrite. The run cap is replaced by a bound for each file: it starts at the first reload that holds the file back, is kept across reads with content and across failed reloads, and starts over when data is applied or when the file has stayed empty for the whole delay. The hold's retry comes when the first held file's delay or bound ends, plus a tenth of the retry delay. The reloader records the delay of the last retry it armed, and tests pin it.
… its state looks the same
|
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 e8a950c. Configure here.
Summary
Applications and editors rewrite data files in place: they truncate the file and then write the new content. A reload that reads the file in between finds it empty. The file override source applied an emptied YAML file as a document with no entries, so it removed every override from that file until the next reload. With a rewrite that pauses 300 ms after truncating, a client served
a -> default -> b: on every run in watching mode, and whenever a poll fell inside the pause in polling mode. An emptied JSON file failed to parse and kept the overrides, but each rewrite that a reload caught was logged as an error (Unable to load flags: Unexpected end of JSON input). The file data source never served an empty file, but it reported each such rewrite as an error through its error handler and the log. It did the same before it moved onto the shared file data engine.The OVERRIDE spec's retry requirement exists to keep "a partially written or briefly invalid file from emptying the layer" (Requirement 1.3.10). This change makes both consumers of the shared file data engine that reload,
FileDataSourceand the file override source, keep their data through such a rewrite without reporting it.An emptied file is held back until it has stayed empty
A file has no entries when its content is white space only (a byte order mark counts as white space), or when its parser reads it as no document: a YAML parser returns
nullfor a file with only a comment or a---marker, which a read can see of a YAML file that is being written.{}is a document, and is applied right away.FileReloadernow remembers, for each configured file, whether it contributed content to the data in effect, that is, to the last reload that was applied or skipped as unchanged; a missing file, or one with no entries, contributed none. For a file whose content is in effect and that reloads have found with no entries, it remembers since when the file has been so without being written, the file's state then (its modification time and size as the platform reports them, which the Node platform reads through any symbolic link, with the poller's file state helper), and when reloads began holding the file back. The clock is read after each file's own read, so that a slow read of another file does not shorten its delay.A reload that finds such a file with no entries keeps the current data, logs at debug level (
File ... is empty, possibly while it is being rewritten; reading it again before applying it), reports no failure, and arms a retry, whatever the format. The file is held back until it has stayed so, without being written, for exactly the retry delay (1 second):FileDataSourcewithoutautoUpdate, and the FDv2 file initializer, which both load once), nothing would read the file again, so an empty file is parsed right away.A rewrite is covered when the time between truncating and writing is shorter than the retry delay. A writer that pauses longer gets an empty YAML override file applied, or an empty JSON file, or any empty
FileDataSourcefile, reported as a load failure while the data in effect stays. A rewrite within the resolution of the file's modification time that keeps its size is not seen as a write, so its delay does not start over.The commits show how the rule got here. The first read an emptied file again once, keyed on whether the last read found content; that still applied an empty file when no reload read the content between two truncations. The second and third hold a file back by its state, with a cap per file and a slack of a tenth in the delay check. The fourth applies the review findings: the cap per file let files rewritten in turn be held back without end, the slack let a reload apply a file that had been empty for less than the delay, a YAML read with only a comment was applied as no entries, and a reload that skipped unchanged content did not remember which file contributed it, so after a file moved to another configured path an in-place rewrite of it was applied empty. The fifth adds tests for a filesystem whose modification times are coarse. The sixth and seventh apply a second review: the clock read once per reload let a slow read of an earlier file shorten a later file's delay, a failed reload restarted the shared bound so that a file kept written was held without end while another file kept failing, and the shared bound let one file reaching it apply another file a fraction of a delay into an ordinary rewrite; it reads the clock after each file's read and bounds each file on its own. It is the rule of dotnet-core #394.
An empty JSON file says what is wrong
The parser for a file is chosen by its extension, so whether an empty file is valid is up to the format. An empty or white-space JSON file was already invalid:
JSON.parserejects it and the last good data stays. It now fails withthe file has no content, which is not a JSON document(still aSyntaxError) instead ofUnexpected end of JSON input. This applies to a file with no extension and to every extension other than.ymland.yaml, since those files are parsed as JSON. A YAML parser'snullorundefinedfor an empty file is still a document with no entries in the override source.Documentation
FileOverrideSourceOptionssays that a file can be rewritten in place, how to remove a file's overrides (delete it or write{}), that a file whose content is in effect and that is found empty, or with only a YAML comment or document marker, is held back for about a second, and for at most about five seconds while that file keeps being rewritten, that changes to the other files wait with it, and that after that an empty YAML file removes its overrides while an empty JSON file fails to load and the overrides in effect stay. ItsyamlParsersays what anullorundefinedresult means.FileDataSourceOptions.autoUpdatesays that a file can be rewritten in place, that an emptied file is held back in the same way, and that a file that is still empty then fails to load, which is reported, while the previously loaded data stays in effect.Not changed
FileDataSourceand the FDv2 file initializer. Their policy hands a YAML parser'snullto the merge, which fails withCannot read properties of null (reading 'flags'). An empty YAML data file therefore fails to load and the last good data stays, as before the shared engine. With this change, an emptied file, or one with only a comment, is held back before that failure is reported. Making it a document with no entries, as it is for override files, changes released behavior, so it is left for a separate change.flags.YAMLis parsed as JSON, as it always was.Testing
nullorundefinedis returned as it is, and the override document parser makes it a document with no entries.FileReloader.inPlaceRewrite.test.ts, mock filesystem with a modification time on every write, Jest's fake clock):---marker, or both is held back; a file with{}is applied right away.server-node, with real in-place rewrites (truncate, pause, write;rewriteInPlacein__tests__/inPlaceWrites.ts) while the test samples evaluations:FileDataSourceandautoUpdateservesa -> band reports no error, for JSON and YAML, with a 300 ms pause; and never serves the default and reports no error through two rewrites 20 ms apart.a -> band logs no load failure, for JSON and YAML, in watching mode (300 ms pause) and in polling mode at the default 1 second interval. In polling mode the rewrite starts 750 ms after a poll and pauses 600 ms, so the next poll finds the file empty.main, all 12 end-to-end tests fail: the YAML override file servesdefaultin between, and the JSON override file and both data files log an error. 46 of the new unit and parser tests fail too. On the first commit of this branch, the 6 end-to-end tests with two rewrites in a row fail, and 27 unit tests; on the third commit, 20 unit tests; on the fifth commit (the run cap), exactly the 3 tests for the second review: another file failing, another file's bound, and the slow read.{}counted as no document; the override parser returning{}for no document; stop reading after a held-back file; a hold that hides another file's failure; holding back counted as a failure; no retry armed; no carry-over of the reason, a carry-over to any reload, or always the reason of a change; a file state lookup that throws; no check for close; either debug line removed; the defaultJSON.parsemessage for an empty JSON file; white space counted as content. The one that survives drops the explicitretryDelayMs <= 0check from the hold, which is equivalent: with no retry delay the delay is zero long.server-nodetest files with the end-to-end tests (20 tests) passed 12 runs in a row, on a loaded machine. The polling end-to-end tests take 3 to 4 seconds, so the override end-to-end tests have a 15 second timeout.@launchdarkly/js-server-sdk-commonand@launchdarkly/node-server-sdk: full test suites,lint,format:check,buildandbuild:docpass locally.Opened from termag
Note
Overview
File reloads no longer apply or error on transient empty reads when a data or override file is truncated mid-rewrite.
FileReloaderremembers which paths contributed active content and holds back files that suddenly have no entries (blank bytes, YAML with only comments/markers, or parsernull) until they stay unchanged for about one retry delay, capped at five delays while rewrites keep happening. Other configured files wait on the same reload; retries are scheduled without treating the hold as a load failure.Parsing and empty-document semantics are tightened:
hasContentandNO_ENTRIESdistinguish “no document” from{}; empty JSON now fails withthe file has no content, which is not a JSON documentinstead of a generic parse error; override YAMLnull/undefinedmaps toNO_ENTRIESrather than an immediate empty apply.Docs for file data sources and file overrides describe in-place rewrite behavior and YAML atomic-replace guidance. Tests add a large
FileReloaderfake-clock suite, sharedrewriteInPlace/sampleValueshelpers, and Node SDK end-to-end coverage forFileDataSourceand file overrides in watching and polling modes.Reviewed by Cursor Bugbot for commit e8a950c. Bugbot is set up for automated code reviews on this repo. Configure here.