Repository navigation
fix: Keep overrides through an in-place rewrite of their files - #479
Draft
kinyoklion wants to merge 8 commits into
Draft
kinyoklion wants to merge 8 commits into
kinyoklion wants to merge 8 commits into
Conversation
Applications and editors rewrite files in place: they truncate the file, then write the new content. A reload in between found the override file empty and applied it as a document with no entries, so the client briefly served LaunchDarkly data for every overridden flag. The parser is now chosen by the file name: a name that ends in .yaml or .yml, in any case, is YAML, and any other file is JSON. An empty or white space JSON file is not a JSON document and fails to parse, so the last good overrides stay. Files are read as UTF-8 without a byte order mark. A load that finds a file empty while the applied data has content from it puts the file off: it keeps the current data and arms the retry without reporting a failure. The file is put off for one retry delay from the first load that found it empty; a write while it is empty (a new rewrite, seen through the modification time) starts the delay again, up to five retry delays in all. A file that contributed no content to the applied data, such as an empty placeholder, is parsed at once.
Member
Author
|
bugbot review |
…olding loads
A file whose content is in effect and that a load finds with no content
is held back. Four cases still let a rewrite through or held too long:
- A read of a YAML file that sees only a comment or "---" is not white
space, but the parser reads it as nothing, and it was applied as no
entries. Content that the YAML parser reads as a null now counts as no
content. "{}" is a document and is applied at once.
- The five retry delay cap was kept per file and restarted whenever the
file was read with content, so files rewritten in turn held every load.
The cap now applies to the run of loads that hold files back, which any
load that holds nothing back ends, including a failed one.
- The delay check had a tenth of slack, so another load could apply a file
a little before its delay was over. The check is now exact, and the
retry of a hold is armed for the rest of the first held file's delay, or
of the run's limit, plus a tenth of the retry delay.
- The file's state is now its modification time and size, read through a
symbolic link with the poller's helper.
The tests of the delays run on a controlled clock with synchronous loads.
Member
Author
|
bugbot review |
Three cases of the hold of an empty file went wrong: - The clock was read once per load, 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 for the retry. - The bound of five retry delays applied to the run of holding loads, and a failed load ended the run, so a file kept written while empty was held without end while another file failed now and then. A file that reached the bound also let through another file's ordinary rewrite. Each file now has its own bound, counted from the first load that held it back. It goes on across reads that find the file with content and across failed loads, and starts over when data is applied or when the file stayed empty for the whole delay. The retry of a hold comes when the first held file's delay or bound is over, plus a tenth of the retry delay, and the retry logs that it reads the files again because a file was found empty.
Member
Author
|
bugbot review |
… back Two cases of the per-file bound held data back too long or let a rewrite through: - A file that stayed empty for the whole delay started its own bound over while another file kept every load holding, so its next truncation got a fresh bound. Two files kept empty and truncated again in turn were held without end. - A file held back during a long failure of another file kept its bound, so a later load that fixed the other file while this one was being rewritten applied this one empty. A file's bound now starts over only at a load that holds nothing back, whether it applies, is skipped as unchanged, or fails, for every file that load read. A file that the load did not get to keeps its bound.
Member
Author
|
bugbot review |
A file saved in place with an error, then fixed in place more than five retry delays later, had the fix applied empty: the load that failed on the file returned before the file was counted among the files it read, so the file kept the bound of the hold before the error. A load now records every file it gets to and does not hold back, including the file whose read or parse fails and a missing file, and a load that holds nothing back starts the bound over for all of them. A file after the failing one keeps its bound. A read that finds a file with content, even content that fails to parse, or finds it missing, ends the file's empty state, so its next emptying starts a new delay.
Member
Author
|
bugbot review |
A file with a UTF-16 or UTF-32 byte order mark, which Windows PowerShell 5.1 writes with `>`, made the read raise an error that no load rescued. At startup the data system stopped the source; at runtime the worker thread ended, so no change was ever loaded again. - Files are read as bytes, a UTF-8 byte order mark is removed, and the content is taken as UTF-8. Content that is not valid UTF-8, in either format, fails to parse like any other invalid content: the failure is logged and the data stays. A UTF-16 or UTF-32 file is reported as such. - An error that a load does not expect is logged and retried like a failed load, and the worker rescues errors inside its loop, so that it keeps running. - YAML with only white space, including a tab, a vertical tab or a form feed, is a document with no entries, as documented; the YAML parser rejected some of it. - Tests record the delay of the retry that is armed, not the one that was asked for, so that the arming paths are checked.
…resence A file configured after a failing file kept the bound of a hold from before the failure: every load stopped at the failing file, so the later file was never read and its bound never started over. When a deploy fixed the failing file while rewriting the later one in place, the later one's truncation was applied empty. A load now reads and classifies every configured file even after a read or parse failure, and fails with the first failure after them, before it holds a file back or merges. A load that holds nothing back then starts the bound over for every file. The skip-unchanged digest now covers each path and whether it is present, not only the bytes of the files that are present. The format comes from the name, so the same bytes moved from a JSON file to a YAML file parse differently and are applied.
Member
Author
|
bugbot review |
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 2b4266a. Configure here.
This branch has not been deployed
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 override 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 this stack, that reload applied the empty file as a document with no entries, so the override source briefly removed every override, and the client served LaunchDarkly data for every overridden flag until the next reload. A slow rewrite (truncate, pause 300 ms, write) made a client serve
override-a -> ld-value -> override-b, for JSON and YAML files, in polling and watching modes.The OVERRIDE spec's retry requirement exists to keep "a partially written or briefly invalid file from emptying the layer" (section 1.3). This change keeps the overrides through such a rewrite. It changes only the shared file data code under
FileOverrideSource(Impl::FileData); the file data sources (FileData.data_sourceandFileData.data_source_v2) do not use it and are unchanged.The format comes from the file name, and an empty JSON file is invalid
Every override file was parsed with the YAML parser, which also reads JSON, so an empty file was always a YAML document with no entries. The parser is now chosen by the file name, as the spec recommends (sdk-specs #272): a name that ends in
.yamlor.yml, in any case, is YAML, and any other file, including one with no extension, is JSON. Whether an empty file is valid is then up to the format, like the rest of the content:the file has no content, which is not a JSON document, and the last good overrides stay, as for any other content that cannot be parsed. To remove every override, write{}or delete the file.Other effects of parsing JSON files as JSON, for a file named
.jsonor any other name that is not YAML, including one with no extension:#comment, or a trailing comma.1e3is1000.0, where the YAML parser read it as the string"1e3".jsongem: 3.x rejects both, 2.x accepts them (2.13 and later warn about a duplicate key and keep the last value). The SDK parses all of its other JSON the same way, and theoverride_sourcedocs say so.For both formats, files are read as bytes and taken as UTF-8, without a UTF-8 byte order mark, whatever the process's default encoding. Content that is not valid UTF-8 fails to parse, like any other invalid content: the failure is logged once and the data stays. On MRI the YAML parser accepted a Latin-1 file, and the strings it produced later made event serialization fail. A file with a UTF-16 or UTF-32 byte order mark, which Windows PowerShell 5.1 writes with
>, is reported asencoded in UTF-16 or UTF-32; data files must be UTF-8; it is not decoded.Since the format comes from the name, the same bytes can parse differently in another file, so the skip-unchanged digest now covers each configured path, whether its file is present, and its content; before, it covered only the content of the files that were present, and the same bytes moved from a JSON file to a YAML file were skipped as unchanged.
I checked the rest of the file data code for other rules that decide by content where the name should, and found none. A missing file is handled before parsing, so it never reaches the JSON rule. The
skip_missing_pathsdocumentation now says a missing file "contributes no data" instead of calling it "a file with no content".A file that is emptied is held back before it is applied
With the JSON rule alone, a JSON file that is empty for a moment fails to parse and is retried, and that failure is logged at error level. A YAML file can legitimately be empty, so its parser cannot tell an emptied file from one in the middle of a rewrite. So the reloader now holds back a file that a load finds with no content while the data currently applied has content from that file. No content means empty, white space only, or YAML that the parser reads as a null: a read in the middle of a rewrite can see only a leading comment or
---. A YAML document such as{}is content and is applied at once; so isflagValues:alone, which the parser reads as a mapping (see "Not changed" below). Content with no JSON document is only parsed when the file is not held back, and then fails.A load that holds a file back keeps the current overrides, logs at debug level that it will read the file again, and arms a retry, whatever the format. It reports no failure: it does not log at error level, call
on_error, or change which failure the reloader last reported.reload_nowreturns false for it, as for a failure.For each file, the reloader keeps whether it contributed content to the data currently applied (updated when a load is applied or skipped as unchanged), and, once a load has found the file with no content: since when it has been so without being written, its state then (modification time and size, through any symbolic link, with the poller's helper), and when a load first held it back.
FileOverrideSourcealways retries.Process.clock_gettime(Process::CLOCK_MONOTONIC), in floating-point seconds, which does not overflow. Tests inject a controlled clock for the hold; scheduling always uses the system's clock.A rewrite is covered when the gap between truncating and writing is shorter than the retry delay. A writer that pauses longer gets the empty file applied (YAML) or reported (JSON), as before. On a file system that records modification times in whole seconds, a second truncation within the same second is not seen as a new rewrite.
Errors that a load does not expect
An error that a load does not expect is logged as
Unexpected error in file data reloaderand retried like a failed load, and the worker thread rescues errors inside its loop, so that no error stops reloading for good. An earlier version of this branch read files in a way that raised on a UTF-16 byte order mark, and that error ended the worker at runtime, or made the data system stop the source at startup.Documentation
FileData.override_sourcenow says how a file's format is chosen and how it differs from the file data source, 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 with no content (empty, or YAML with only a comment or---) is held back for about a second, at most about five while files keep being rewritten, that changes to the other files wait too because a reload applies all files or none, and that after the wait only YAML is applied empty while JSON keeps the data. It recommends replacing a YAML file in one operation (see below). TheReloaderandDocument.parsecomments describe the rules above.Not changed: YAML that is cut off
A read that overlaps a write can see the first part of a YAML file. A part with only a comment or
---is held back (above), but most other parts are valid YAML documents: a cut between two entries gives fewer entries, a cut inside a value gives the value cut short (on-valuebecomeson-v, oron, which istrue), and a cut right afterflagValues:gives a mapping whoseflagValuesis null, a document with no entries. The part is applied until the next reload. JSON that is cut off fails to parse. 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
DocumentandFileDatatests: the format of each file name (.yaml,.yml, any case; JSON for.json, no extension,.txtand.yaml.json); an empty or white-space JSON document fails with the message above, including for a file with no extension; empty YAML (empty, white space, a comment,---,~) is a document with no entries; a JSONnulland a YAMLfalseare not objects; JSON numbers and trailing content; a YAML document in a JSON file fails; a file with a byte order mark reads, and one with only a byte order mark is empty.holding back a file, on a controlled clock):---is held back, and{}is applied at once, in YAML and in JSON.a file that is found empty), with a 1.5 s retry delay where a test must finish a rewrite before the retry, waiting on the debug line:reload_nowreturns false for a hold; a reloader stopped during the read neither logs nor arms a retry; with retries off an emptied file is applied right away; at the initial load an empty YAML file has no entries and an empty JSON file fails.Poller.observereads the state of the file a symbolic link leads to.InPlaceWrites.rewriteinspec/in_place_writes.rb):override-a -> override-band never the LaunchDarkly value, for JSON and YAML.retries a failed reload after the retry delay and recovers when the file is fixedno longer accepts an empty application as the recovery.expected: ["override-a", "override-b"], got: ["override-a", "ld-value", "override-b"]), 60 reloader tests (the new ones that pass cover behavior the base already has, such as a placeholder, a file deleted and created empty, retries off, a stop during the read, an empty YAML file at the initial load, and a parse failure of another file), 21 document tests (many of them becauseDocument.parsenow takes the format), and the new poller test.bundle exec rspec specon Ruby 3.2.3: 1502 examples, 1 failure; on JRuby 9.4.6 with--tag ~flaky, as CI runs it: 1469 examples, 1 failure, 1 pending. The failure iscan be automatically loaded by Bundler.require, which needs write access to the system gem directory on this machine and fails the same way on the base of this branch. Earlier JRuby runs also failed one of two FDv2 data system timing tests now and then; this PR does not touch them.bundle exec rubocop: no offenses.flag overridessuite of sdk-test-harness (3.2.0-alpha.13, built from #515, whose override files are rewritten in place and have no extension) passes against this branch's test service: 79 tests ran, all passed.Place in the stack
This PR is based on #454, the top of the Ruby overrides stack, and changes code that #449 (the parser and the reloader) and #453 (the override source docs) add. It can be merged on top of the stack or folded into those PRs.
Revisions
---seen mid-write) is held back; the five-delay cap applies to the run of holding loads, which any load that holds nothing back ends, instead of to each file; the delay check is exact and the retry of a hold is armed for the rest of the delay plus a tenth; the file's state is read through symbolic links with the poller's helper; the delay tests run on a controlled clock. With the first commit alone, the new end-to-end test with a leading YAML comment supplies empty snapshots in both modes, and a load halfway through the delay pushes the application from about 2.2 s to 3 s.Opened from termag
Note
Overview
Fixes a gap where in-place file rewrites (truncate then write) could briefly load an empty override file and drop all overrides, so clients served LaunchDarkly values until the next reload.
Parsing by filename: Override files ending in
.yaml/.ymluse YAML; everything else uses JSON (no longer YAML-for-all). Empty JSON fails to parse and keeps last good data; empty YAML is still a valid empty document. Files are read as UTF-8 (BOM stripped), with explicit errors for invalid UTF-8 and UTF-16/UTF-32. Skip-unchanged digests now include path and presence, not just bytes.Hold-back in
Reloader: When a file that currently contributes overrides is read as empty (whitespace, or YAML that parses as null), the reload does not apply—it keeps existing overrides, logs at debug, and schedules a retry (~1s delay, capped at ~5 delays per file, coordinated across multi-file reloads). JSON{}and similar real content still apply immediately; placeholders and never-had-content files are not held back.Supporting changes:
Poller.observefor symlink-aware mtime/size; worker loop survives unexpected errors and retries them.Reviewed by Cursor Bugbot for commit 2b4266a. Bugbot is set up for automated code reviews on this repo. Configure here.