Skip to content

feat: Add file loading code for flag overrides - #449

Open
kinyoklion wants to merge 13 commits into
feat/overridesfrom
rlamb/overrides-ruby-filedata
Open

kinyoklion wants to merge 13 commits into
feat/overridesfrom
rlamb/overrides-ruby-filedata

Conversation

@kinyoklion

@kinyoklion kinyoklion commented Sep 25, 2026 •

Copy link
Copy Markdown
Member

Summary

This is the first of a series that adds flag overrides, as defined by the OVERRIDE specification, to the Ruby server SDK. The existing FDv1 and FDv2 file data sources keep their current behavior: this PR does not change lib/ldclient-rb/impl/integrations/file_data_source.rb, file_data_source_v2.rb, or their specs, and the override feature is additive throughout the series.

The file-based override source must tolerate a missing file, a file that is being rewritten in place, and a burst of change notifications, and it must keep the last good data when a reload fails. This change adds LaunchDarkly::Impl::FileData, the file reading, parsing, merging, and reloading code that source needs:

  • Document parsing. The format comes from the file name: a name that ends in .yaml or .yml, in any case (a file named .yaml included), is YAML, and any other file, including one with no extension, is JSON. The parser checks that the document and its flags, flagValues, and segments members are objects; a JSON null, array or scalar is an error.
    • Whether an empty file is valid depends on the format. An empty or white-space JSON file fails to parse (the file has no content, which is not a JSON document). Empty or white-space YAML (a tab included), or YAML that is only a comment, --- or a null, is a document with no entries.
    • 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 in either format; a file with a UTF-16 or UTF-32 byte order mark is reported as such (encoded in UTF-16 or UTF-32; data files must be UTF-8).
    • JSON is parsed with JSON.parse, so JSON numbers are numbers (1e3 is 1000.0), and content after the document, a # comment or a trailing comma is an error. Comments and duplicate keys inside one object follow the application's json gem: 3.x rejects both.
  • An ordered merge of several documents with duplicate keys handling of fail or ignore, expansion of each flagValues entry into a flag that is on and serves the value by fallthrough (so an evaluation reports the FALLTHROUGH reason kind, as the specification describes), deserialization into the data model classes, and per-document entry counts.
  • A reloader that serializes reloads, debounces change signals, keeps the last good result when a reload fails, retries after a bounded delay, reports each distinct failure once, and can skip a reload whose paths, presence and content are identical to the last applied ones. A configured file that does not exist can be made to contribute no data. An exception raised by the consumer's apply is treated as a failed reload: nothing from that load is remembered, the failure is reported, and the reload is retried. An error that a load does not expect is logged and retried, and the worker thread survives it.
  • In-place rewrites. Applications and editors truncate a file and then write the new content, and a load in between finds the file empty. A load that finds a file with no content (empty, white space, or YAML read as nothing) while the data in effect has content from that file holds the file back: it keeps the current data, logs at debug level, and arms the retry, without reporting a failure.
    • A file is held back until it has stayed so, unwritten, for one retry delay (1 second), timed from the clock read after that file's own read. A change of its state (modification time and size, read through any symbolic link with the poller's helper) is a new rewrite and starts the delay over, as does a read that finds it with content, even content that fails to parse, or missing.
    • Each file is held for at most five retry delays from its first hold; its bound starts over only at a load that holds nothing back, whether that load applies, is skipped, or fails.
    • A load reads and classifies every file, even after one fails to read or parse, so files emptied together are held and applied together; it then fails with the first failure, holds files back, or merges, in that order.
    • The retry of a hold is armed for the rest of the first due file's delay or bound, plus a tenth of the retry delay.
    • A file that contributed no content to the data in effect (a placeholder that has always been empty, a file that was missing, a file whose emptying has been applied) is parsed at once, and so is every file when retries are off.
    • After the delay, an empty JSON file fails to parse and keeps the data; an empty YAML file has no entries.
  • A stat-based poller that compares existence, modification time, and size, through symbolic links. A file that appears or disappears is a change.
  • A watcher that uses rb-inotify on Linux (a dependency of the optional listen gem) to watch the directory of each file without descending into subdirectories, and listen elsewhere. It picks up a file that does not exist yet when it appears, and it watches each directory on its own: a directory that does not exist is retried once per second while the others stay watched, and one change is signaled when a retry sets up a watch. Non-recursive watching matters because listen scans the whole tree under a watched directory and fails when a subdirectory cannot be read, as happens for a file in the system temporary directory. On Linux, a watched directory that is deleted is watched again once it exists, and one change is signaled so the reload picks up what was written meanwhile.

A new spec, spec/integrations/file_data_source_compatibility_spec.rb, pins the behaviors of the existing file data sources that their other specs did not assert (per-file version numbering, keying by the entry's own key, the on-plus-fallthrough expansion of flagValues, no retry of a failed load, log messages, modification-time-only polling, no reaction to a deleted file when polling, the FDv1 poller's reload cadence after a change, and use of listen when it is present), so that they stay as they are while this code exists beside them.

Not changed: YAML that is cut off mid-write can still parse, with fewer entries or a value cut short, so the override source docs recommend replacing a YAML file in one operation. A writer that pauses longer than the retry delay gets the empty file applied (YAML) or reported (JSON).

Testing

  • Unit specs for the document parser (format by name, empty JSON and YAML, UTF-8 and byte order marks, JSON numbers and trailing content), merge, poller (including symbolic links), reloader (debounce, retry, last-good retention, sequential reloads, stop, and the hold of an emptied file), and watcher (including a directory with an unreadable subdirectory and the inotify thread lifecycle), the new compatibility pins, and the unchanged existing file data source specs.
  • The reloader specs rewrite their files in place with real, deliberately slow writes (InPlaceWrites.rewrite in spec/in_place_writes.rb), and the hold's delays, bounds and retries are tested on a controlled clock with synchronous loads, with a few real-clock tests for the retry firing.
  • The in-place rewrite handling was reviewed in eight rounds as fix: Keep overrides through an in-place rewrite of their files #479, with multi-agent and Bugbot reviews, and is shared with the other SDKs' ports. On that code: a mutation check reverted each part of the fix on its own (67 mutants); 60 fail at least one test, and the other 7 are equivalent (the retries-off guard, the clearing of held-file records on apply or skip, starting over every file instead of the files read, and the path or the presence alone in the digest). The changed spec files ran 10 times in a row on Ruby 3.2 and on JRuby 9.4 with no failures.
  • Full suite and RuboCop are clean, apart from can be automatically loaded by Bundler.require, which needs write access to the system gem directory on the test machine. Each new spec was checked against a deliberate defect in the code it covers, and each compatibility pin against a deliberate change to the existing source it pins.

SDK-3249

Opened from termag

Adds `LaunchDarkly::Impl::FileData`, the file reading, parsing, merging,
and reloading code that the file-based override source defined by the
OVERRIDE specification needs. That source must tolerate a missing file,
a file that is being written, and a burst of change notifications, and
it must keep the last good data when a reload fails. The module provides:

- Document parsing for JSON and YAML with validation that the document
  and its flags, flagValues, and segments members are objects.
- An ordered merge of several documents with duplicate keys handling of
  fail or ignore, flagValues expansion into off flags that serve the
  value, model deserialization, and per-document entry counts.
- A reloader that serializes reloads, debounces change signals, retains
  the last good result when a reload fails, retries after a bounded
  delay, reports each distinct failure once, and can skip byte-identical
  reloads. A configured file that does not exist can be treated as a
  file with no content.
- A stat-based poller that compares existence, modification time, and
  size, so a file that appears or disappears is a change.
- A watcher that uses rb-inotify on Linux to watch the directory of each
  file without descending into subdirectories, and the listen gem
  elsewhere, so a file that does not exist yet is picked up when it
  appears. It retries when a directory does not exist.

The existing FDv1 and FDv2 file data sources are not changed. They keep
their own implementation and behavior. A new spec pins the behaviors of
those sources that their other specs did not assert, so that they stay
as they are while this code exists beside them.
@kinyoklion
kinyoklion force-pushed the rlamb/overrides-ruby-filedata branch from eb26dd7 to 72d5776 Compare September 28, 2026 20:36
@kinyoklion kinyoklion changed the title feat: Make file data loading reliable and shared feat: Add file loading code for flag overrides Sep 28, 2026
The result is not remembered as applied, on_error receives the exception, the reload is retried, and the worker thread continues.
The inotify watcher tears its watches down when a watched directory is deleted or moved, logs it, retries setup once per second, and signals one change when the watches are in place again.
Compare absolute paths through File.absolute_path, close temp files before deleting them, and backdate modification times instead of sleeping before a write that the pollers must see.
@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.

value.map { |v| symbolize_keys(v) }
else
value
end

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

YAML on key silently disables flags

Medium Severity

YAML.safe_load treats unquoted on as a boolean, and symbolize_keys then stores that field as :true. Full flag definitions never see :on, so FeatureFlag treats them as off. Natural YAML like on: true therefore loads as a disabled flag instead of failing or preserving the on field.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 2e31db3. Configure here.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

This reply was written by an AI agent (Claude Code) working on behalf of @kinyoklion.

Leaving this as is. Under YAML 1.1 an unquoted on key parses as the boolean true. Ruby's existing file data sources read YAML the same way (YAML.safe_load followed by symbolize_all_keys in file_data_source.rb and file_data_source_v2.rb on main), as do the Go and Python ports. The override docs (launchdarkly/sdk-meta#714, launchdarkly/ld-docs-private#8869) quote "on" in the YAML example and tell readers to do the same.

…pop timeouts

Queue#pop accepts a timeout only from Ruby 3.2 on. The sync examples also wait for the poller thread or the listen call before they act, because the source starts change detection after it yields the initial data.
The watcher set up its watches all or nothing. If any directory of the configured paths did not exist, it logged the problem, watched nothing, and retried once per second until every directory existed. A configured file in a directory that never exists is a valid steady state for the override source, which treats a missing file as contributing nothing, so one permanently missing directory meant that changes to files in the directories that do exist were never detected.

Each directory is now watched on its own. At start, the directories that exist are watched at once and the others are remembered as missing and retried once per second. A retry adds the watches for the directories that exist by then, and the callback runs once when it added at least one, whether or not other directories are still missing. The directories that are still missing, and any watch that could not be set up, are logged together, so that an unchanged situation repeats at debug level as before. When a watched directory is lost, only its watch is removed and it goes back to the missing set; the other directories keep their watches. With rb-inotify, one notifier and one thread take the watches as they are added, and a lost directory's watch is closed. With the listen gem, there is one listener per directory, and stop ends each of them.

The spec that lost a directory as soon as its watches were set up waited for the inotify thread to end as its sign that the loss had been handled. The thread outlives one directory now, so it waits for the warning in the log instead. New specs cover a directory missing at start beside one that exists, a lost directory watched again while another is still missing, and stop while a directory is missing.
@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!

1 issue from previous review remains unresolved.

Fix All in Cursor

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 73afbaa. Configure here.

The spec fixed a failing file while the automatic retry was pending and expected the retry to
apply the fixed content. The write is ordinary, not atomic, so the retry can read the file while
it is still empty; an empty document is valid and is applied as the recovery, and the fixed
content is never loaded. The spec now accepts the recovery the retry made and loads the fixed
content on demand when the retry did not see it.
@kinyoklion

Copy link
Copy Markdown
Member Author

Follow-up to the poller-test change in the other ports: the reloader spec retries a failed reload after the retry delay and recovers when the file is fixed had the same flaw. It fixed the file with an ordinary File.write while the 0.1 s retry was pending and expected the retry to apply the fixed content. If the retry reads the file between the truncation and the write it sees an empty file, which is a valid document, so the retry applies an empty result and stops; the fixed content is never loaded and the spec fails with got: {}.

Reproduced locally with a deliberately slow write (truncate, sleep 0.3 s, write): the old expectation failed 3/3 and the new one passed 3/3. The spec now accepts the recovery the retry made and loads the fixed content on demand when the retry did not see it (5babf80). reloader_spec 5/5 runs green, spec/impl/file_data 76 examples green, and the file data plus override specs at the top of the stack (3758acf) 165 examples green. The stack above is rebased onto this commit.

Written by Claude Code on behalf of @kinyoklion.

…by file name

Applications and editors rewrite files in place: they truncate the file,
then write the new content. A load in between found the file empty and
applied it as a document with no entries.

- The format comes from 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; empty YAML,
  or YAML that is only a comment or "---", has no entries. Files are read
  as bytes and taken as UTF-8 without a byte order mark, and content that
  is not valid UTF-8 fails to parse.
- A load that finds a file with no content while the data in effect has
  content from it holds the file back: it keeps the current data and arms
  the retry without reporting a failure. The file is held until it has
  stayed so, unwritten (its modification time and size, through any
  symbolic link), for one retry delay, timed from its own read; a new
  rewrite starts the delay over. Each file is held for at most five retry
  delays from its first hold, and its bound starts over at a load that
  holds nothing back. The retry of a hold is armed for the rest of the
  delay plus a tenth of it.
- A load reads every file even after one fails, and fails with the first
  failure. Content, even content that fails to parse, or a missing file
  ends a file's empty state. The skip-unchanged digest covers each path
  and whether its file is present.
- An error that a load does not expect is logged and retried, and the
  worker survives it.

The tests rewrite files in place with real, deliberately slow writes, and
time the holds on a controlled clock.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant