Repository navigation
fix: Keep flags and overrides while a data file is rewritten in place - #504
Draft
kinyoklion wants to merge 9 commits into
Draft
kinyoklion wants to merge 9 commits into
kinyoklion wants to merge 9 commits into
Conversation
An application or editor that rewrites a data file in place truncates it and then writes the new content. A reload in between read the file empty, and an empty file is a YAML document with no entries, so the override source briefly removed every override and the file data sources briefly served no flags. The shared reloader now remembers whether each file's last read found content. A load that finds a file empty after it had content keeps the current data and reads the file again after the retry delay, without reporting a failure. The next load parses whatever the file holds, so the data is held back at most once per emptying, and a file that is empty from the start is never read again. With retries off, an empty file is parsed right away.
Member
Author
|
bugbot review |
Holding an emptied file back for the next load only was bounded by a count of loads: any other load in the write gap, such as a change to another configured file, a touch, a poll or a watcher error, parsed the emptied file and applied it. The reloader now records, per file, whether the file contributed content to the data in effect, and when a load first found it empty together with its modification time. Every load holds such a file back until it has stayed empty, without being written, for nine tenths of the retry delay. A new modification time restarts the delay, and a file that keeps being written so that every load finds it empty is applied after at most five retry delays. A load reads every file before it holds any back, so files emptied together start their delays in the same load, and a file that fails to parse still fails the load. Also adds a closed check before holding back, restores the retry log text, asserts in the end-to-end tests that a reload found the file empty, and says in the file data source docs that the retry and the hold apply only with a reloader.
Member
Author
|
bugbot review |
Member
Author
|
bugbot review |
… its retry
Round 3 bounded the hold of each file by five retry delays from when a
load first found that file empty, and a read with content reset it, so
two files rewritten in turn held every load back without end. The bound
now applies to the run of loads that hold files back: it starts with the
first load that holds a file back and ends with any load that holds none,
whether it succeeds or fails.
A file is held back until it has stayed empty, without being written,
for exactly the retry delay, rather than nine tenths of it, which let
another load apply a shorter gap. The hold arms its retry for when the
first held file's delay or the run's bound is over, plus a tenth of the
retry delay, instead of a whole retry delay after the last load.
A file whose content is no YAML document, such as only a comment or
"---", has no entries, and is held back like an empty file. {} is
applied right away. The file's state, its modification time and size
read through any symbolic link, comes from the poller's helper.
Member
Author
|
bugbot review |
Member
Author
|
bugbot review |
…e clock per file The bound on a run of holding loads was shared and was restarted by any load that held nothing back. A failed load of another file restarted it, so a file kept written while empty was held forever, and one file reaching the bound cut short another file's ordinary rewrite. Each file now has its own bound from when a load first held it back. A read with content or a failed load keeps it; applying data, or the file staying empty for the whole delay, ends it. The clock is read after each file's own read, and again for the hold's retry, so a slow read of another file does not make a file's emptiness look older than it is. The retry after a hold logs that it reads the files again because a file was found empty.
Member
Author
|
bugbot review |
… back A file that stayed empty for the whole delay cleared its own bound while another file kept every load holding, so two files kept empty and truncated again in turn were held back without end. A file held back during a long failure of another file kept its bound, so a later rewrite of it was applied empty once the other file was fixed. A file's bound now starts over only at a load that holds nothing back, for every file that load read, whether the load applies, is skipped or fails. A file that a failed load did not get to read keeps its bound.
Member
Author
|
bugbot review |
… empty state A file whose read or parse failed right after it was held back was not among the files that a load holding nothing back starts over, because the load returned before recording it. An operator who saves a file in place with a typo and then saves the fix in place got the fix applied empty once the old bound had run out. A load now records every file it gets to and does not hold back, including the file it fails on and files found missing, and starts their bounds over when it holds nothing back. A read with content, even content that fails to parse, or a missing file also ends the file's empty state, so its next emptying starts a new delay even where a coarse modification time makes it look the same.
Member
Author
|
bugbot review |
A load stopped at the first file that failed to read or parse, so a file configured after it was not read, and its bound never started over. More than five retry delays later, a deploy that fixed the failing file while rewriting the later one in place applied the later file empty. A load now reads and classifies every configured file even after a failure, and fails with the first failure after the loop. A failure comes first, then a hold, then the merge, which runs only once every file has been read.
Member
Author
|
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 1fd735b. Configure here.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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. On
v7, that reload applied the empty file. The format is chosen by the content (a file that does not start with{is YAML), so an empty file of any name is a YAML document with no entries. A slow rewrite (truncate, pause 300 ms, write) made a client servea -> default -> b, for JSON and YAML files alike:ldoverrides.FileSource) briefly removed every override, in watching and in polling mode;ldfiledatawithldfilewatch.WatchFiles, andldfiledatav2as an FDv2 synchronizer) briefly served no flags.The file data source has behaved this way since before the shared engine: 7.17.1 serves the same sequence for the same rewrite.
The OVERRIDE spec's retry requirement exists to keep "a partially written or briefly invalid file from emptying the layer" (requirement 1.3.10). The three sources share
internal/filedata.Reloader, so this change covers all of them.An emptied file is held back until it has stayed empty for the retry delay
The reloader now remembers, for each configured file, whether the file contributed content to the data in effect (the last load that was applied, or skipped as unchanged; the comparison for skipping covers only the content of the files that are present). A load that finds such a file with no entries holds it back: it keeps the current data, logs at debug level (
File <path> is empty, possibly while it is being rewritten; reading it again before applying it), reports nothing, and arms a retry. A file has no entries when it is empty or white space only, or when its content is no YAML document, such as only a comment or---, which a read can see at the start of a YAML file that is being written.{}is a document and is applied right away.The hold is bounded by time, not by a count of loads:
os.Stat, which follows symbolic links on every platform Go supports; the poller uses the same helper) when a load finds it so. If the state differs at a later load, the file was written in between (for example rewritten and truncated again between two loads), so its delay starts over; so does a read with content, even content that fails to parse, or finding the file missing. States are only compared with each other, never with the clock.Reading file data again after a file was found emptyat debug level, and a retry after a failure keepsRetrying flag data load after earlier failure.What is not held back:
SkipMissingPaths(the override source), a file that is deleted and then created empty: the deletion was applied, so the file's content is no longer in effect. The file data sources fail a load on a missing file and keep their data, so an empty file created in its place is held back like an emptied one.ldfiledatav2'sFetch, used as an initializer, reads once and is unchanged.Holding back reports nothing: no
OnErrorcall (soldfiledatadoes not move toInterrupted), no error log, and the record of the last reported failure is left alone, so a failure that comes back afterwards is still a repeat and is not reported twice.A rewrite is covered when the file stays empty for less than the retry delay after the first load that finds it empty, however many loads read it in that time. A gap shorter than a second between truncating and writing is therefore always covered, unless the file has already been held back for about five seconds without a load in between that held nothing back. The bounds of different files do not overlap: with n files that each keep being written while empty, started about 4.5 retry delays apart, the first load that applies comes after about 4.5 n + 0.5 retry delays, and a failure timed to every expiry of a bound could hold the loads back without end. Both are accepted. A writer that pauses longer gets the empty file applied once the delay is over.
Not changed: the format is chosen by the content
An empty
.jsonfile is still a YAML document with no entries, so a JSON file that stays empty removes its overrides (or the data source's flags) once it is no longer held back, about a second after it was emptied. This is released behavior, and sdk-specs #272 allows it: it recommends choosing the parser by extension, and lets an SDK whose file data source already inspects the content keep doing so for override files. The .NET SDK chooses by extension and treats an empty JSON file as invalid, so there the last good data stays. The docs now say how an empty file is read.Documentation
ldoverrides.FileSourcesays 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---is held back for about a second, at most about five while it keeps being rewritten, that changes to the other files wait while a file is held back, how the format is chosen, that a file that stays empty, even one named.json, removes its overrides once it is no longer held back, and that JSON cut off fails to parse and keeps the overrides.ldfiledataandldfiledatav2package docs say the same for flags, and that the retry and the hold apply only with a reloader; without one the files are loaded once.Not changed: YAML cut off part way
A read that overlaps a write can see the first part of a YAML file. If the cut falls between two entries, the part is valid YAML with fewer entries; if it falls inside an unquoted value, the value is cut short (
flag1: faforflag1: falseis the string"fa"). Either is applied until the change signal for the rest of the write reloads the file. So is a read that sees only a first key, such asflagValues:before any entry: it is a YAML document with a member and no entries, so it is not held back like a comment or---. JSON cut off anywhere fails to parse and is retried. This predates this change. Telling a part from a whole file needs a content rule this change does not add, so the docs recommend replacing a YAML file in one operation.Testing
internal/filedata/reloader_test.go), waiting on the debug line rather than fixed sleeps:SkipMissingPaths, a file that is deleted and then created empty is applied right away; with retries off, an emptied file is applied right away.---is held back;{}is applied right away.SkipMissingPaths) for six retry delays is held back when it is created empty again; and withSkipMissingPaths, a file found missing gets a new delay when it is created empty again with the first emptying's modification time.ReloadNow, arm what the hold asks for.mv second.yaml data.yaml), so the load is skipped as unchanged; a later in-place rewrite of the file it was moved to is held back.sharedtest.RewriteFileInPlacethat truncates the file, pauses, and writes it through the same handle:ldoverrides/file_source_test.go; the polling test lowers the interval inside the package so a poll lands in the gap).ldclient_file_in_place_rewrite_test.go), a client samplesStringVariationevery 2 ms during a 300 ms rewrite and servesa -> b, never the default, for JSON and YAML, withldfiledata+ldfilewatch, withldfiledatav2as a synchronizer, and with the override source in watching mode.v7without the fix, 37 of the 43 new tests fail, including every in-place rewrite test. The 6 that pass check the limits of the rule (placeholder, initial load, deleted file, retries off, parsing after the delay, the first failure reported). The earlier versions of this PR fail 27 (one load), 16 (0.9 slack, per-file bound from the first emptying), 12 (shared bound for a run of holding loads), 6 (per-file bound restarted by a file staying empty for the whole delay), 4 (bound not started over for a file that fails) and 1 (loads stop at the first failure, so a later file keeps a stale bound).>instead of>=4, no restart on a state change 9, a read with content keepingsince2, the record deleted once the delay is over 2.{}treated as no document 1.ReloadNowarming the retry delay 1.Closethe worker exits and the retry it would arm never runs); keeping the records when data is applied, and starting over the bounds of every recorded file rather than of the files the load read (every load reads every file, so a load that holds nothing back covers them all anyway).go test -race -count=12, 516 passes) on a loaded machine.go test -race ./...passes (31 packages), as do the twoproxytestruns,go vet ./..., andgolangci-lintv2.14.0 over the SDK,ldotelandldmiddleware(themake linttargets, 0 issues). The test service builds. One earlier full run failed intesthelpers/ldtestdatav2(TestSupportsMultipleSynchronizers, a 1 ms channel timeout); that package does not depend on this change, and the same test fails 1 run in 10 onv7.Opened from termag
Note
Overview
Reloads that read a truncated data file (common during in-place saves) no longer apply an empty document and briefly drop flags or overrides.
internal/filedata.Reloadernow tracks whether each path previously contributed content; when a file with active data reads as having no entries (empty, whitespace-only, or YAML with only a comment/---via newparseFileContent), it holds back the load—keeps the last good merge, logs at debug, and schedules a retry—until the file stays unchanged forRetryDelayor hits a 5× delay cap, with multi-file loads applying all paths or none.Retry scheduling now carries variable delays (
retryRequestChsendstime.Duration); loads still read every configured file before failing or holding.observe/fileState.equalsupport mod-time/size detection for restarts. Docs forldfiledata,ldfiledatav2, andldoverridesdescribe the behavior;sharedtest.RewriteFileInPlaceplus broad unit and client E2E tests cover JSON/YAML rewrites.Reviewed by Cursor Bugbot for commit 1fd735b. Bugbot is set up for automated code reviews on this repo. Configure here.