Repository navigation
fix: Keep the overrides in effect while an override file is rewritten in place - #248
Closed
kinyoklion wants to merge 30 commits into
Closed
kinyoklion wants to merge 30 commits into
kinyoklion wants to merge 30 commits into
Conversation
… evaluations The OVERRIDE specification requires an evaluation to be marked as override-affected when any definition it read carried the override marker: the evaluated flag, a prerequisite at any depth, or a segment consulted during matching, whether or not the segment matched. The marking propagates upward only. A prerequisite's own record reflects only the definitions that its own subtree read. An evaluation that fails is still marked when it read a marked definition. DataModel.FeatureFlag and DataModel.Segment carry a transient isOverride marker that is never serialized and never read from JSON, with a markedAsOverride() shallow copy that the override layer will use without modifying the source's entity. The evaluator tracks the marking per evaluation scope: the scope starts from its own flag's marker, each segment read can set it, and the value is saved, reset to the prerequisite's marker, and restored around each prerequisite evaluation. The result and every prerequisite record carry the marking on their reason. EvalResult exposes isOverrideAffected() and withOverrideAffected(), which copies rather than mutating the shared precomputed results. A requested-type mismatch keeps the marking on its error reason. The server SDK now depends on launchdarkly-java-sdk-common 2.6.0, which adds the overrideAffected indicator to EvaluationReason. CI for this change cannot pass until that version is released. Flag overrides are currently experimental and subject to change.
…gnized stage migrationVariation replaces the result of a flag that serves an unrecognized stage with a WRONG_TYPE error. The evaluation read the same definitions, so the replacement keeps the override-affected marking, as the type mismatch of an ordinary variation call already does, and the migration tracker receives a marked reason for an overridden flag.
When the evaluation of a flag throws, the error result now carries the override-affected marking of the flag's own definition, which was read before the failure. The spec marks a failed evaluation when a definition it read came from the override store.
…rce configuration The OVERRIDE specification defines an override layer: a runtime-mutable collection of flag and segment definitions, supplied by an override source as complete snapshots, that takes precedence over LaunchDarkly data on a per-key basis at the store read boundary. Overrides are not a data source. They have no effect on initialization status, data availability, or data source status, and they are never persisted. Public surface, all experimental and subject to change: DataSystemBuilder.overrides(ComponentConfigurer<OverrideSource>), DataSystemConfiguration.getOverrideSource(), and the subsystems.OverrideSource and subsystems.OverrideSink interfaces. The SDK builds the source like any other component, starts it before the data source so its initial load completes during client construction, and closes it with the client. An offline client starts no override source. A source that cannot be built fails client construction. OverrideLayer holds marked shallow copies in an immutable map swapped on each update. OverrideOverlayStore implements the read boundary with override precedence for per-key reads and enumeration, and serves the layer alone when the base store fails. OverrideSinkImpl serializes snapshot application and fires the normal flag change notifications for every flag whose merged-view evaluation may have changed. The not-initialized short-circuit consults the layer first, so a flag that the layer holds is served before the client has LaunchDarkly data. The all-flags state does the same and presents an override-affected flag with event tracking off. The specification's test vectors run as a unit test through the full client stack.
…n changes, and let isFlagKnown see overrides A flag override can reference LaunchDarkly data: a rule that matches a LaunchDarkly segment, or a prerequisite that is a LaunchDarkly flag. DataSourceUpdatesImpl computes flag change notifications from a dependency tracker built from LaunchDarkly data alone, so when such a segment or flag changed, the flags whose overrides depend on it were not notified. The override layer now keeps the dependency edges of its own entries, rebuilt on every replacement, and the data source updates ask it for the override entries that depend on a changed item. The chain can cross in both directions, so each newly reached item is expanded through both sets of edges until nothing new is found. isFlagKnown consults the override layer before the not-initialized short circuit, the same as evaluation does, so a caller that gates on it does not skip an override during an outage.
…iling source from stopping the client allFlagsState returns a state of overrides alone only when the store is not initialized and the override layer holds flags, and it reads those flags from the layer itself. Before, any layer contents were enough: a layer of segments only over a store that held LaunchDarkly data without an init marker produced a valid state of that data, and with override flags present the same data entered the state. The overlay no longer serves the layer's entries alone when the base store fails to list its items, so a client whose store fails reports an invalid state and logs the failure, as it does without a source. An exception from the source's start no longer fails client construction. The SDK logs it, closes the source, clears what the source supplied before failing, and the client runs without overrides. Closing the client stops the sink from accepting snapshots before it closes the source, so a snapshot supplied during shutdown is ignored; an exception from the source's close is still logged rather than thrown. An offline client still does not build or start the override source, and now logs a warning that it does not.
A source logs and retries a failed load, and reports a configuration error from build. If start throws anyway, the SDK logs it, closes the source, removes its snapshot, and the client runs without overrides.
When the store cannot list its flags, the state without overrides is invalid and empty. Overrides only add to it, so the state now holds the override layer's flags and stays invalid, and the failure is logged as before. A client-side SDK bootstrapped from the state uses its flags whatever $valid says, so the overridden values still reach it.
… state When the evaluation of an overridden flag throws during allFlagsState, the error result now carries the marking of the flag's own definition, so the state presents its marked reason with event tracking off, as for any override-affected flag.
…ide-affected evaluations The OVERRIDE specification requires an override-affected evaluation to produce no individual feature event and no debug event, whatever the flag requests, while still being counted in summary events under a separate counter that carries the overrideAffected marker. A prerequisite record carries the prerequisite's own marking. EventProcessor gains an overload of recordEvaluationEvent with the marking. It is a default method that delegates to the existing method, so custom implementations keep compiling. The SDK's implementations override it and the evaluator's event recorder passes the evaluation result's marking for top-level, prerequisite, and requested-type mismatch records. Event generation keys on that value alone. The server SDK now depends on launchdarkly-java-sdk-internal 1.13.0, which carries the marker through Event.FeatureRequest, the summarizer, the output formatter, and the event processor. CI for this change cannot pass until that version is released. The specification's vector runner now also asserts the per-evaluation summary marking. Flag overrides are currently experimental and subject to change.
The error event recorded when an evaluation throws now carries the marking of the flag's own definition, which was read before the failure, the same marking as the error result. A marked error event produces no individual event and is counted under the marked summary counter.
The OVERRIDE specification defines a file-based override source that reads override entries from one or more local files and reloads them when the files change. FileOverrides.source() provides it, configured through DataSystemBuilder.overrides(...). The source accepts the file data source document format (flags, flagValues, segments) in JSON or YAML, merges the files in the configured order with the configured duplicate keys handling (fail by default, or ignore all but the first), and keeps the versions that the documents specify. A flagValues entry becomes a flag that is off and serves the value as its single variation. Change detection is polling (the default, one second by default and at minimum) or watching. A configured file that does not exist contributes no overrides. A file that exists but cannot be read or parsed fails that reload, keeps the last good overrides, logs the failure, and is retried. The initial load completes during client construction. Every applied change is logged at Info with the overrides in effect and what each file supplied. Missing file paths, a null change detection mode, or a null poll interval fail client construction. Flag overrides are currently experimental and subject to change.
The poller logs a change callback that throws, so it needs the source's logger. This compiles once the branch includes the poller constructor that takes a logger.
…ntries as duplicate keys A path listed more than once would make every entry of that file a duplicate of itself: under the default fail handling the load failed, and under ignore the first listing won by accident. The builder now drops the repeats, keeps the first occurrence in its place in the order, and logs a warning once at build time. This is the behavior the Node SDK has.
The watcher uses the default file system's watch service, which cannot watch a path of another provider such as a zip file system. Registering one threw an unchecked exception from start, so a configuration error surfaced as a failed start. The builder now rejects such a path when watching is selected, so the error is reported when the client is created. Polling reads such a path through its own provider and still accepts it.
When watching was selected and the watch service could not be created, for example because the limit on inotify instances was reached, the source logged the failure and ran with no change detection, so a later edit never took effect. It now polls the files instead, at no less than the minimum poll interval, and the log says so.
… SDKs A flagValues entry becomes a flag without client-side availability, so allFlagsState with CLIENT_SIDE_ONLY leaves it out even when the flag it overrides is client-side. A full definition with clientSide is the way to override such a flag. A test pins both.
The contract test service advertises the flag-overrides capability and accepts the overrides object in the SDK configuration: filePaths, duplicateKeysHandling, changeDetection, and pollIntervalMs. The block configures FileOverrides.source() on the data system builder. An overrides block without a dataSystem block is rejected, because the override source is an option of the FDv2 data system. The service reads its listening port from the PORT environment variable when set, which the Makefile already passes, so several test services can run side by side. The default stays 8000.
… in place
An application or editor that rewrites an override file in place truncates
it and then writes the new content. A reload in between found the file
empty, chose YAML for it because it did not start with '{', and applied a
document with no entries, so the client briefly served LaunchDarkly data
for every overridden flag.
The override source now chooses the parser by the file's extension (.yaml
and .yml are YAML, any other file is JSON), and an empty or white-space
JSON file fails to parse, so the last good overrides stay. A file whose
content is in effect and is found empty is deferred, whatever its format:
the load still reads the other files, the overrides in effect stay, no
failure is reported, and the files are read again after the retry delay.
The file is applied once it has stayed empty, without being written, for
about one retry delay, or after five retry delays at most when it keeps
being rewritten. The file data source keeps choosing the parser by content.
Member
Author
|
bugbot review |
…oads
The cap on how long an emptied file is held back counted from when that
file was first found empty, and a read with content reset it, so files
rewritten in turn were held back without end. Loads now stop holding files
back once they have held some back for five retry delays in a row; any load
that holds none back ends the run.
A file is held back until it has stayed empty, without being written, for
exactly the retry delay, and the retry that a hold arms comes when the
first delay or the run ends, plus a tenth of the retry delay. This replaces
the nine-tenths slack, which let another load apply a shorter gap. YAML
content that holds no document, such as a partial read with only a comment
or a document marker, counts as empty, while {} is applied right away. The
state of a file, its modification time and size, is read through any
symbolic link.
Member
Author
|
bugbot review |
… per file The bound on how long emptied files are held back covered the whole run of holding loads, so a failing other file restarted it, and one file reaching it cut short another file's ordinary rewrite. Each file now has its own bound of five retry delays from the first load that held it back. A read with content and a failed load keep it; applying data, or a load skipped as unchanged, starts every bound over, and a file that stayed empty for the whole delay gets a fresh one. The clock is read after each file's own read, and again for the hold's retry, so a slow read of another file no longer shortens a file's delay. The retry after a hold logs that a file was found empty.
Member
Author
|
bugbot review |
… holds nothing back A file that stayed empty for the whole delay cleared its own bound while another file kept the loads holding, so two files emptied in staggered turns were held back without end. A file held back during a long failure of another file kept its bound, so a later deploy that fixed the other file while rewriting this one applied it empty. A file's bound now starts over only at a load that holds nothing back, for every file that load read, whether the load is applied, skipped or fails. A file that a failing load did not get to keeps its bound.
Member
Author
|
bugbot review |
A load that held nothing back started the bound over only for the files it read without a failure, so a file that failed to read or parse right after a hold kept its old bound. Saving a YAML file in place with a mistake and then the fix in place a few seconds later applied the fix's gap empty. A load now records every file it gets to and does not hold back, including the one whose read or parse fails, and starts their bounds over when it holds nothing back. A read with content, even content that fails to parse, and a missing file end 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 |
…ext encoding A load stopped at the first file that failed to read or parse, and the entries were merged while the files were read, so a duplicate key also stopped it. A file configured after the failing one was never read, so its bound never started over, and its next in-place rewrite was applied empty. A load now reads and classifies every file, fails with the first failure after them, before a hold, and merges the entries only after every file is read. The skip-unchanged digest now covers each file's path and whether it exists: the format comes from the path, so the same bytes moved from a .yaml to a .json file are different data. Override JSON was decoded in the platform's default encoding, so UTF-16 files, which loaded as YAML before the format came from the extension, failed. Both formats now detect a byte order mark and read UTF-8 otherwise; the file data source keeps its behavior. A JSON document that is not an object, such as null, fails with a clear message instead of a NullPointerException. The docs say that every public override API is experimental, state the extension rule on both filePaths methods, and note that the YAML parser rejects some JSON.
Member
Author
|
bugbot review |
…tion needs The public docs described how long an emptied file is held back, the bound on it, and the other files waiting meanwhile. They now say what an application needs: which extension means YAML, how a file is decoded, that files can be rewritten in place, how to remove a file's overrides, that an empty JSON file is not a valid document, and that replacing a YAML file in one operation avoids briefly applying a partly written file. The mechanics stay in the internal comments.
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 0c37016. Configure here.
kinyoklion
force-pushed
the
rlamb/overrides-java-contract-tests
branch
from
November 3, 2026 23:55
d9ec2af to
a2dbdfc
Compare
This was referenced Nov 3, 2026
Member
Author
|
The changes of this PR moved into the stack:
The branches above were rebased with their commits unchanged. The new #225 head (a2dbdfc) has the same tree as this PR's head (0c37016). Closing this PR; its branch stays in place. |
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 an override file in between finds it empty. On this stack the override loader chose the parser by content, so the empty file did not start with
{, was parsed as YAML, and loaded as a document with no entries: the override source briefly removed every override until the next reload. A rewrite with a 300 ms pause between truncating and writing made a client servea -> launchdarkly-value -> bin watching mode on every run (8 of 8), and in polling mode whenever a poll landed in the pause (5 of 24 runs with varied timing).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 makes the file-based override source keep its overrides through such a rewrite. It is the Java counterpart of dotnet-core #394.
The later commits follow the js-core and .NET reviews: the second makes the delay exact with the retry armed for the time remaining, counts YAML with no document as empty, and reads the file's state through symbolic links; the third gives each held file its own bound and reads the clock after each file's read; the fourth starts a file's bound over only at a load that holds nothing back; the fifth includes the file whose read or parse fails in that, and ends a file's empty state when it is read with content, even content that fails to parse, or found missing; the sixth, after the general and adversarial reviews, reads every file before failing a load, merges only after every file is read, puts the paths in the skip-unchanged digest, and detects the text encoding. A last commit trims the public docs to what an application needs. The description below is the result of all of them.
The format comes from the extension, and an empty JSON file is invalid
The override loader now chooses the parser by the file name:
.yamland.yml, in any case, are YAML, and any other file, including one with no extension, is JSON. The OVERRIDE spec (requirement 1.3.3, sdk-specs #272) recommends the extension and lets an SDK whose file data source inspects the content keep doing so for override files; it says nothing about empty files. The override source is unreleased, so it now follows the extension, which lets the format decide whether an empty file is valid, 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 a file's overrides, write{}or delete the file.touch, therefore fails the whole reload: the other files' changes are not applied until it holds a document such as{}or is deleted. An empty YAML placeholder is fine.nullor[], fails withthe JSON document is not an object.Before, a
.jsonfile with YAML content loaded as YAML, and a YAML file that happened to start with{loaded as JSON. Neither is released. The test harness names its YAML override file.yamland its JSON files with no extension, so the contract tests are unaffected. Other compatibility cases with the content-based choice:{and loaded as YAML, which detects the encoding. The override source now detects the encoding from a byte order mark for both formats, with SnakeYAML'sUnicodeReader, and reads UTF-8 otherwise, so those files still load, and JSON no longer depends on the platform's default encoding. The file data source, which shares the JSON parser, keeps reading JSON in the platform's default encoding.\/, loaded as JSON and still does in a.jsonfile. In a.yamlfile SnakeYAML, which reads YAML 1.1, rejects it; the docs say so.The file data source (
FileData.dataSource(),FileData.initializer(),FileData.synchronizer()) is released and keeps choosing by content: it still callsselectForContent, and it never passes empty content to the JSON parser, because it parses a file as JSON only when it starts with{. TheFileOverridesdocs note the difference.A file that is emptied is held back until it has stayed empty
A YAML file can legitimately be empty, so its parser cannot tell an emptied file from one in the middle of a rewrite. A file counts as empty when it holds only white space or a byte order mark, or when its parser reads it as no document: SnakeYAML returns null for a part of a file with only a comment or a
---marker, which a read can see while the file is being written.{}is a document and is applied right away. Content that is only white space is parsed only when the file is not held back, so an empty JSON file is reported only then.The loader keeps, for each file:
Loader.accepted. The skip-unchanged digest covers each file's path, whether it exists, and its content: the format comes from the path, so the same bytes moved fromx.yamltox.jsonare different data and are applied. Moving a file between configured paths (mv a.yaml b.yaml) is therefore applied too, which moves the record to the new path, so a later in-place rewrite of it is held back;FileDataPoller.observe, now package-private, whoseFiles.readAttributesfollows links on every platform), compared only for equality, and when a load first held it back.A load that finds a file empty parses it right away when the file's content is not in effect (a placeholder that never had content, or a file whose empty state was applied) or when retries are off (a zero retry delay gives no grace). Otherwise a new state restarts the file's delay. The file is held back while it has stayed empty for less than the retry delay (1 second), and for at most five retry delays from the first load that held it back. The bound is the file's own: one file reaching it does not cut short another file's rewrite. It starts over only at a load that holds nothing back, for every file that load read (with content, parsed empty, found missing, or failing to read or parse), whether the load is applied, skipped as unchanged, or fails. So loads that hold another file back keep a file's bound, which bounds files rewritten in turn, files kept empty and truncated again in staggered turns, and a file kept written while another file fails now and then. A file saved in place with a mistake and fixed later has a fresh bound when the fix is saved, a file read during a long failure of another file has a fresh bound when that failure ends, and a file that a tool rewrites in place now and then with the same content is held back again on a later rewrite, because the load skipped as unchanged in between held nothing back. The clock is read after each file's own read, so a slow read of another file does not shorten a file's delay.
A load reads and classifies every configured file, also after a read or parse failure, so files emptied together start their delays in that load and every file that is not held back is reached. It then fails with the first failure, before a hold; then it holds files back; and only then merges the entries, so a duplicate key or an entry that the data model rejects cannot keep a later file from being read. A holding load keeps the overrides in effect, including changes to the other files, because a reload applies all the files or none. It logs at debug level (
File ... is empty, possibly while it is being rewritten; reading it again before applying it), reports no failure, and arms the retry for when the first held file's delay or bound is over, plus a tenth of the retry delay, so that a timer that fires a little early still finds the time over; the clock is read again for that. A failure still arms the normal retry delay. Elapsed times are differences ofSystem.nanoTime()readings, which do not overflow; the loader tests start their clock just belowLong.MAX_VALUEto check that.So:
The reloader passes its retry delay (or zero) to
Loader.loadand handles a newEmptiedFileException, which names the held files and when to load again. A retry armed by a hold logsReading flag data again after a file was found emptyinstead of the failure message. The reloader records the delay of each retry it is asked to arm, for tests.Earlier versions of this change, and of dotnet-core #394 and js-core, used rules that reviews proved wrong: one keyed on whether the last read found content applied the empty file in the three rewrite sequences above; a per-file cap reset by reads with content, with a nine-tenths slack, held files rewritten in turn without end and let another load apply a gap shorter than the delay; a cap on the run of holding loads was restarted by a failing other file and cut short another file's rewrite, while one clock reading per load let a slow read of an earlier file shorten a later file's delay; a per-file bound that a file cleared itself when it had stayed empty for the delay held files emptied in staggered turns without end, while one kept through a long failure of another file applied a later rewrite empty; a reset that left out the file whose own read or parse failed applied the fix of a file saved in place with a mistake while it was empty; and a load that stopped at the first failure, or at a duplicate key while it merged, never read a later file, which kept a stale bound and had its next rewrite applied empty.
Accepted and documented in the reviews, not changed: with several files each kept written while empty and started 4.5 retry delays apart, the first application comes after about 4.5 retry delays per file, because the per-file bounds do not overlap; and a contrived failure of another file at every bound's end can hold the files back without end.
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 reported (JSON) or applied (YAML), as before.
Documentation
The public docs say only what an application needs. The hold's delay, its bound, the other files waiting and the retries stay in the internal comments and in this description.
FileOverrides.source(): a.yamlor.ymlfile is YAML and any other file is JSON, and the YAML parser does not accept every JSON document; a file is read as UTF-8, or in the encoding that a byte order mark names; files can be rewritten in place, and if the source finds a file empty while it is being rewritten, it keeps the overrides in effect until the new content is written; to remove a file's overrides, delete the file or write{}; an empty JSON file is not a valid document; replacing a YAML file in one operation avoids briefly applying a partly written file.FileOverrideSourceBuilder.filePathsmethods state the extension rule and the encoding.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, or after
flagValues:, the part is valid YAML with fewer entries; if it falls inside an unquoted value (flag: trforflag: true), the part is valid YAML with a different value. Either is applied until the next reload. JSON cut off anywhere fails to parse. 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.Difference from the other SDKs
Go still detects the format from the content (
internal/filedata/document.go), so an empty file of any name loads as no overrides there. Node chooses by extension case-sensitively (.yml,.yaml); this change, like .NET, ignores case.Testing
OverrideFileLoaderTest(a test clock that crosses thelongoverflow, on real files rewritten in place):.yaml,.yml,.YAML,.Ymlare YAML;.json,.txt, no extension and.yaml.bakare JSON).---has no entries and no content;{}has content.---is held back;{}is applied right away.\/in a.yamlfile are rejected; the content digest covers paths and presence.FileDataReloaderTest(scripted loader): a hold applies nothing, reports nothing, and is retried after the time the loader asks for, even when the retry delay is an hour; a failure after a hold arms the normal retry delay; the loader is given the retry delay, or zero without retries; the loader is told of applied and unchanged results only, not of failures, holds or results the consumer rejects; a hold does not end a recovery; a failure after a hold is retried and logged as a failure.FileDataReloaderEmptiedFileTest(reloader with the real loader on real files, rewritten in place withInPlaceWrites):reloadNowor a change signal: the hold ends exactly at the retry delay; a change to another file waits while a file is held; a file rewritten in place with the same content (skipped as unchanged) is held back again six retry delays later; the requested retry is the delay plus a tenth after the first hold, the rest of the delay after a later hold, cut short by the bound (12 minutes plus a tenth), decided by the first-due of several files (40 minutes plus a tenth), the same when the hold comes from a change signal, and the retry delay after a failure..yamlto a.jsonfile are applied, not skipped as unchanged.FileOverrideSourceTest: at startup an empty YAML file has no entries and an empty JSON file (also with no extension) fails to load.FileOverrideSourceInPlaceRewriteTest, end to end with real in-place rewrites (truncate, pause, write through the same stream):a -> band never the LaunchDarkly value, for JSON and YAML.FileOverrideSourceImpl, so it survives a loaded CI machine.acceptedrecording presence instead of content; not accepted when unchanged or when applied; accepted before apply; a hold reported as a failure; a hold arming no retry; a negative retry delay passed to the loader; the retry log not chosen; its flag never reset; a case-sensitive extension;.ymlnot recognized; stopping at the first held file; the reloader ignoring the hold's retry time; YAML with no document counted as content; an empty mapping counted as no document; the state read without following links; an overflowing deadline comparison (now >= since + delay); the clock read once per load; a stale clock for the hold's retry; a read with content dropping the bound or keeping the delay; the bound started over by loads that hold a file back, never started over, started over by a file that stayed empty, started over after a hold and a failure, or not for a file found missing, a file that fails to read or parse, or a file parsed empty; a load stopping at the first parse failure; the last failure reported instead of the first; a hold reported before a failure; the digest without paths; JSON decoded in the platform's encoding; a JSON document that is not an object accepted; a missing file or content that fails to parse not ending the empty state; ending the empty state dropping the bound; the bound restarted by every hold; no bound; an exclusive bound or delay check; the nine-tenths slack; a state change not restarting the delay; the hold's retry at the whole delay, without the margin, not clamped at zero, ignoring the bound, or decided by the last file; and either arming path (reloadNow, change signal) ignoring the requested time. A guard that skipped holds when retries are off, and clearing the held-file records when data is applied, became redundant (a zero delay gives no grace; every file of an applied load was read by a load that held nothing back), so both were removed rather than kept as equivalent mutants.FileOverrideSourceTest.closeIsIdempotentAndStopsThePoller, a test of the stack (feat: Add the file-based override source #224) that this PR does not change: its baseline count of poller threads can include a poller of an earlier test that exits afterwards../gradlew build javadoc(Java 17) passes: 2262 tests, 0 failures, checkstyle and javadoc clean. CI stays red until the common and internal library PRs (feat: Add the overrideAffected indicator to EvaluationReason #218, feat: Count override-affected evaluations separately and send no individual events for them #219) are released, as for the rest of the stack.flag overridessuite (sdk-test-harness #515 branch) passes against the contract test service built from this branch: 38 tests.PollingProcessorTest.oneSuccessfulPollDoesNotLeaveExtendedCadence(a timing assertion),FileSynchronizerReloadBehaviorTest.everyFailedReloadIsReportedAndLoggedAsThePlainDescription(the file data source read its file mid-rewrite and applied it as an empty YAML document, the same defect onmain), andFileOverrideSourceTest.closeIsIdempotentAndStopsThePoller(a poller thread of an earlier test counted in the baseline).This PR is based on #225 and is not part of the stack's history; it can be folded into #220 and #224 or merged after them.
SDK-3246
Opened from termag
Note
Overview
Fixes a gap where in-place file rewrites (truncate-then-write) could briefly drop all flag overrides because an empty read was parsed as YAML with no entries.
The file-based override source now defers applying a file that was previously in effect and is found empty:
OverrideFileLoaderthrowsEmptiedFileException,FileDataReloaderkeeps the last good snapshot, schedules a re-read (notonError), and only applies once the file has stayed empty for the retry delay or hits a per-file cap (five retry delays). Multi-file reloads still read every path before merge/fail/hold, and the content hash includes paths so format moves are not skipped as unchanged.Parsing and format rules for overrides (not the released file data source): choose YAML vs JSON by
.yaml/.ymlextension (case-insensitive); detect text encoding from a BOM (UTF-8 otherwise); treat empty/whitespace JSON as invalid; treat empty YAML as a document with no entries viaFlagFileRep.NO_DOCUMENT. PublicFileOverrides/ builder docs describe rewrite behavior,{}vs empty JSON, and that overrides remain experimental.Large test additions cover hold timing, concurrent files, watching/polling, and end-to-end client behavior using
InPlaceWrites.Reviewed by Cursor Bugbot for commit 0c37016. Bugbot is set up for automated code reviews on this repo. Configure here.