Repository navigation
feat: Add file loading code for flag overrides - #449
kinyoklion wants to merge 13 commits into
Conversation
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.
eb26dd7 to
72d5776
Compare
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.
|
bugbot review |
| value.map { |v| symbolize_keys(v) } | ||
| else | ||
| value | ||
| end |
There was a problem hiding this comment.
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)
Reviewed by Cursor Bugbot for commit 2e31db3. Configure here.
There was a problem hiding this comment.
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.
|
bugbot review |
There was a problem hiding this comment.
✅ Bugbot reviewed your changes and found no new issues!
1 issue from previous review remains unresolved.
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.
|
Follow-up to the poller-test change in the other ports: the reloader spec 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). 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.


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:.yamlor.yml, in any case (a file named.yamlincluded), is YAML, and any other file, including one with no extension, is JSON. The parser checks that the document and itsflags,flagValues, andsegmentsmembers are objects; a JSONnull, array or scalar is an error.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.encoded in UTF-16 or UTF-32; data files must be UTF-8).JSON.parse, so JSON numbers are numbers (1e3is1000.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'sjsongem: 3.x rejects both.failorignore, expansion of eachflagValuesentry 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.rb-inotifyon Linux (a dependency of the optionallistengem) to watch the directory of each file without descending into subdirectories, andlistenelsewhere. 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 becauselistenscans 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 offlagValues, 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 oflistenwhen 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
InPlaceWrites.rewriteinspec/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.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