Skip to content

feat: Add reload infrastructure for file-based flag overrides - #220

Open
kinyoklion wants to merge 10 commits into
feat/overridesfrom
rlamb/overrides-java-filedata-reloader
Open

kinyoklion wants to merge 10 commits into
feat/overridesfrom
rlamb/overrides-java-filedata-reloader

Conversation

@kinyoklion

@kinyoklion kinyoklion commented Sep 25, 2026 •

Copy link
Copy Markdown
Member

Update, 2026-11-13

  • Rebased onto feat/overrides, which now includes feat: Add the overrideAffected indicator to EvaluationReason #218 and feat: Count override-affected evaluations separately and send no individual events for them #219 and has main merged into it. From main it gets the 7.18.0 release, CI on the latest v3 contract tests, flag change listener support in the contract test service, and the java-sdk-internal 1.12.1 and 1.12.2 fixes. A common 2.6.0 and internal 1.13.0 release from this branch needs those fixes.
  • New commit, "fix: Follow symbolic links when watching the override files". In watching mode, the watcher counted only events that named a configured file. A mounted Kubernetes ConfigMap updates its files by swapping a ..data link, so no event named the file and the update was never picked up. A configured link to a file in another directory wasn't followed either. Polling picked up both, and the Node, .NET, Python and Ruby watchers follow these links.
    • An event for another entry in a watched directory now counts when a configured file, read through any symbolic link, leads to another file, or its file's modification time or size changed. A busy sibling file still costs no reload.
    • The directory of each link's final target is watched too, and is found again after every event. One that is deleted, like a ConfigMap's previous version, is dropped instead of being retried with a warning.
    • New FileDataWatcherTest cases cover a ConfigMap update, a change to a link target in another directory, and a link pointed at a file in a third directory. Three mutants each fail at least one of them: other entries never count, target directories aren't watched, and a deleted target directory is retried.
  • Verified at the top of the stack, with common and internal published locally from feat/overrides:

Summary

The flag overrides feature defined by the OVERRIDE specification needs a file source that reloads reliably: coalesced change notifications, retry after a failed read, retention of the last good data, sequential reloads, support for files that do not exist yet, and files that are rewritten in place. This change adds that infrastructure to the integrations package for the file-based override source that follows. The existing file data source keeps its current behavior.

  • FileDataReloader serializes reloads, debounces change signals with a 100 ms settle window, keeps the last good data on failure by not applying, retries a failed load after one second, reports an identical repeated failure once, and skips a reload whose content did not change. Close never waits for an in-flight read. An exception from the apply callback is reported like any other load failure and retried, and nothing from that load is remembered. A load that holds an emptied file back (below) applies nothing, reports no failure, and arms its retry for the time the loader asks for; a failure still arms the normal retry. The loader is told of every result that becomes the data in effect, applied or skipped as unchanged, through Loader.accepted.
  • FileDataPoller detects changes by examining each file's modification time and size on a fixed interval, including a file that appears or disappears. A change callback that throws is logged and polling continues. Its observe is package-private, so that the loader reads a file's state the same way, through symbolic links.
  • FileDataWatcher watches parent directories through the file system watch service and signals once after start so a change made between an initial load and the start of watching is not missed. A directory that is missing at start or deleted later is registered again on a one-second schedule once it exists, and one change is signaled so the reload picks up what was written meanwhile.
  • OverrideFileLoader reads a list of files with the override source's rules: a file that does not exist contributes no entries, entries keep the versions that the documents specify, a value-only entry becomes a flag that is on and serves the value by fallthrough, a document that parses but holds a flag the model rejects is reported as a file data error, and the result carries a per-file summary and a content digest of each file's path, whether it exists, and its content.

Format, encoding and empty files

The override loader chooses the parser by the file name: .yaml and .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; the override source is unreleased, so it follows the extension, which lets the format decide whether an empty file is valid.

  • The text encoding is detected from a byte order mark (UTF-8, UTF-16 or UTF-32) and is UTF-8 otherwise, for both formats, through SnakeYAML's UnicodeReader.
  • An empty or white-space JSON file fails with the file has no content, which is not a JSON document, and the last good overrides stay. An empty JSON placeholder therefore fails the whole reload. A JSON document that is not an object, such as null or [], fails with the JSON document is not an object.
  • An empty YAML file, or YAML with only a comment or a --- marker, is a document with no entries.
  • Compatibility with choosing by content: a JSON file in UTF-16, or in UTF-8 with a byte order mark, still loads. JSON indented with tabs, or with the escape \/, loads from a .json file; SnakeYAML, which reads YAML 1.1, rejects it in a .yaml file.

In-place rewrites

Applications and editors rewrite a file in place: they truncate it and then write the new content. A load in between finds the file empty. A YAML file can legitimately be empty, so its parser cannot tell an emptied file from one in the middle of a rewrite. The loader keeps, for each file, whether it contributed content to the data in effect (updated by accepted), and, for a file whose content is in effect and that loads have found empty, since when it has been empty without being written, its state then (modification time and size, read through any symbolic link, compared only for equality), and when a load first held it back.

  • A file whose content is not in effect, such as a placeholder or a file whose empty state was applied, is parsed right away, and so is any file when retries are off.
  • Otherwise a new state restarts the file's delay, and the file is held back while it has stayed empty for less than the retry delay R, and for at most 5 R from the first load that held it back. The clock is read after the file's own read, so a slow read of another file does not shorten its delay. The hold asks for its retry when the first held file's delay or bound is over, plus R/10, so that a timer that fires a little early still finds the time over.
  • A file's bound starts over only at a load that holds nothing back, for every file that load read, whether the load is applied, skipped as unchanged, or fails. A read with content, even content that fails to parse, or a missing file 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.
  • A load reads and classifies every configured file, also after a read or parse failure, so files emptied together start their delays in the same load. It then fails with the first failure; then 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 data in effect, including changes to the other files, because a reload applies all the files or none.
  • Elapsed times are differences of System.nanoTime() readings, which do not overflow.

A rewrite is covered when the gap between truncating and writing is shorter than the retry delay. Not changed: a read that sees YAML cut off between two entries, after flagValues:, or inside an unquoted value parses as valid YAML and is applied until the next reload; JSON cut off anywhere fails to parse. Go still detects the format from the content; Node chooses by extension case-sensitively, while this change, like .NET, ignores case.

The existing file data source

FileDataSourceImpl, FileSynchronizer, FileInitializer, FileDataSourceBase, and FileDataSourceParsing.FileDataException are unchanged from feat/overrides. FileDataSourceParsing gains, for the override source: two FlagFactory methods that keep document versions; selectForPath and parseForPath (extension and encoding); the NO_DOCUMENT result that the YAML parser returns for content with no document, which has all-null maps as before; JsonFlagFileParser.parse(Reader); and the two JSON rules above, which the file data source cannot reach because it parses a file as JSON only when it starts with {. parse(InputStream) keeps reading JSON in the platform's default encoding for the file data source. The file data source still requires every configured file to exist, assigns a load version to every entry, expands a value-only entry to an on flag with a single fallthrough variation, reloads on every watch event with no debounce, no retry timer, and no skip, and reports every failure. Two new test classes pin that behavior: FileSynchronizerReloadBehaviorTest (identical rewrite emits another change set, every failed reload is reported and logged as the plain description at error level, no retry without a file change, missing file logged with the path) and FileDataLoadingBehaviorTest (a model-rejected document escapes as a SerializationException, and FileDataException.getDescription requires a cause). The baseline test files pass unmodified.

Testing

  • OverrideFileLoaderTest, on real files rewritten in place (InPlaceWrites), with a test clock that crosses the long overflow: the format by extension, empty and non-object JSON, YAML with no document, the encoding (UTF-8, UTF-16LE and UTF-16BE, with and without a byte order mark, a non-ASCII value), the hold at 0.95 R and its end at exactly R, the requested retries, a new write restarting the delay (also through a symbolic link), the per-file bound with one file kept written and with files rewritten in turn or emptied in staggered turns, the bound starting over at a load that holds nothing back and not otherwise, a file read during another file's failure, the fix of a file saved with a mistake or a duplicate key, the first failure reported, the digest covering paths and presence.
  • FileDataReloaderTest (scripted loader): a hold applies nothing, reports nothing, and is retried after the time the loader asks for; a failure arms the normal retry delay; accepted is called for applied and unchanged results only.
  • FileDataReloaderEmptiedFileTest (the reloader with the real loader on real files): with a test clock and an hour-long retry delay, the hold's end and the requested retry delays on both arming paths; with the real clock, an emptied file held back and the rewrite applied with no empty application in between, a file that stays empty applied by the retry, two quick rewrites, a change to another file while one is empty, a retry finding a second truncation, a file moved between configured paths, the same bytes moved from .yaml to .json.
  • The in-place rewrite handling was reviewed in eight rounds as fix: Keep the overrides in effect while an override file is rewritten in place #248 (general and adversarial reviews, Bugbot). Mutation check with a clean build per mutant: 57 mutants, each failing at least one test. The override test classes ran 12 times in a row with no failure.

SDK-3246

Opened from termag


Note

Overview
Adds reload infrastructure for the upcoming file-based flag override source: FileDataReloader (serialized loads, 100ms debounce, 1s failure retry, content-hash skip, last-good-on-failure), FileDataWatcher (directory watches with symbolic-link/ConfigMap handling and missing-directory retry), FileDataPoller (mtime/size polling fallback), and OverrideFileLoader (extension-based JSON/YAML, BOM-aware encoding, merge rules, and hold-back for files emptied during in-place rewrites).

FileDataSourceParsing gains override-oriented parsing (selectForPath / parseForPath, stricter empty/non-object JSON, YAML NO_DOCUMENT) and FlagFactory helpers that preserve document versions. The existing file data source is left as-is; new tests lock its reload/error behavior.

Coverage is heavy: poller/watcher/reloader unit tests, in-place rewrite scenarios via InPlaceWrites, and synchronizer behavior regression tests.

Reviewed by Cursor Bugbot for commit 68f868e. Bugbot is set up for automated code reviews on this repo. Configure here.

@kinyoklion
kinyoklion force-pushed the rlamb/overrides-java-filedata-reloader branch from f0038ed to 79250a7 Compare September 28, 2026 21:03
@kinyoklion kinyoklion changed the title feat: Add debounce, retry, and last-good retention to file data loading feat: Add reload infrastructure for file-based flag overrides Sep 28, 2026
@kinyoklion

Copy link
Copy Markdown
Member Author

bugbot review

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

@kinyoklion

Copy link
Copy Markdown
Member Author

The file data poller test that expected exactly one callback per rewrite is replaced by one that checks the poller goes quiet once the file is stable, following launchdarkly/go-server-sdk#473. A plain rewrite is not atomic (the write and the modification-time update are separate steps), so a poll that lands between them legitimately reports an extra change; the poller promises generous detection and the reloader absorbs the excess. The test writes in the ordinary way, drains the callbacks of the write, and then requires silence, which still fails for a poller that does not update its baseline.

Written by Claude Code on behalf of @kinyoklion.

@kinyoklion

Copy link
Copy Markdown
Member Author

bugbot review

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ 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 3b81e47. Configure here.

The flag overrides feature needs a file source that reloads reliably:
coalesced change notifications, retry after a failed read, retention of
the last good data, sequential reloads, and support for files that do
not exist yet. This adds that infrastructure to the integrations package
as a purely additive change. The existing file data source keeps its
current behavior.

- FileDataReloader serializes reloads, debounces change signals, keeps
  the last good data on failure by not applying, retries a failed load
  after a bounded delay, reports an identical repeated failure once, and
  skips a reload whose raw content did not change. Close never waits for
  an in-flight read.
- FileDataPoller detects changes by examining each file's modification
  time and size on a fixed interval, including a file that appears or
  disappears.
- FileDataWatcher watches parent directories and signals once after
  start so a change between an initial load and the start of watching is
  not missed.
- OverrideFileLoader reads a list of files with the override source's
  rules: a missing file contributes no entries, entries keep the
  versions that the documents specify, a value-only entry becomes a flag
  that is off and serves the value, a document that the model rejects is
  a file data error, and the result carries a per-file summary and a
  content digest. Two additive FlagFactory methods keep document
  versions.

FileDataSourceImpl, FileSynchronizer, FileInitializer,
FileDataSourceBase, and FileDataException are unchanged. Two new test
classes pin the file data source's reload and error behavior: a reload
on every watch event with no debounce, no retry, and no skip, every
failure reported and logged as the plain description at error level, a
model-rejected document escaping as a SerializationException, and a
description that requires a cause.
A directory that does not exist at start, or whose watch key becomes invalid because it was deleted, is remembered as missing and registered again on a fixed schedule from the worker thread. A recovered directory signals one change so that files written while it was not watched are picked up. The worker ends on close.
A RuntimeException from the callback is logged at error level instead of ending the repeating task.
The last good hash and the failure state are recorded only after the handler accepts the result. An exception from apply goes through the same failure path as a read or parse failure, so it is reported once and retried after the retry delay.
The time of the next registration attempt and the wait before it come from System.nanoTime, so a step of the system clock neither delays nor advances the retry.
A plain rewrite is not atomic: the write and the modification-time update are separate steps, and a poll that lands between them legitimately reports an extra change. The poller promises generous detection, not one callback per logical change; the reloader coalesces and skips the excess. The test that expected exactly one callback could therefore fail on a busy machine. It now waits for the write to be fully observed and then requires that the count stays put, which still fails for a poller that does not update its baseline.
…write

Applications and editors rewrite a file in place: they truncate it and then
write the new content. A reload in between found the override file empty,
chose YAML for it because it did not start with '{', and applied a document
with no entries, so every override was briefly removed.

The override loader now chooses the parser by the file's extension (.yaml
and .yml are YAML, any other file is JSON), detects the text encoding from
a byte order mark and reads UTF-8 otherwise, and rejects an empty JSON file
and a JSON document that is not an object. A file whose content is in
effect and that a load finds empty, white space, or YAML with no document
is held back until it has stayed so, without being written, for the retry
delay; the hold's retry is armed for the time left, and each file's hold is
bounded at five retry delays from its first hold, starting over at a load
that holds nothing back. A load reads every file, also after a failure,
fails with the first failure, and merges the entries only after every file
is read. The skip-unchanged digest covers each file's path and presence.
The file data source keeps choosing the parser by content.
In watching mode the watcher registered each configured file's
directory and counted only events that named a configured file. A
mounted Kubernetes ConfigMap links each file through a ..data link that
is swapped to a new directory on every update, so its updates named no
configured file and were never picked up. A configured link to a file
in another directory was not followed either, because nothing watched
that directory. Polling picked up both. The Node, .NET, Python and Ruby
watchers follow these links.

An event for another entry in a watched directory now counts when a
configured file, read through any symbolic link, leads to another file
or its file's modification time or size changed, so a busy sibling
file still costs no reload. The directory of each configured link's
final target is watched as well and found again after every event. A
target directory that is deleted, as the previous version of a
ConfigMap is, is dropped rather than registered again with a warning.
A configured directory that is itself a link and is pointed elsewhere
is not followed; polling covers that.
@kinyoklion
kinyoklion force-pushed the rlamb/overrides-java-filedata-reloader branch from 3b81e47 to 68f868e Compare November 13, 2026 00:14

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Risk: medium.

Automated risk review (dry run): Medium

This PR would not be automatically approved. Human approval required.

Why: It adds substantial new SDK reload infrastructure (FileDataReloader, FileDataWatcher, FileDataPoller, OverrideFileLoader) plus symlink/ConfigMap watch behavior and empty-file holdback for flag overrides, which is more than a narrow, easy-to-review change even with the accompanying tests.

If you disagree with this review, share this PR in #feedback-agent-approvals or submit a changes-requested review explaining why.

Open in Web View Automation 

Sent by Cursor Approval Agent: Dry-Run PR Approver

@cursor cursor Bot added the risk:medium label Nov 13, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant