quonfig 0.0.16 → 0.0.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +13 -0
- data/README.md +106 -1
- data/lib/quonfig/client.rb +64 -1
- data/lib/quonfig/datadir.rb +38 -0
- data/lib/quonfig/datadir_watcher.rb +113 -0
- data/lib/quonfig/options.rb +25 -2
- data/lib/quonfig/sse_config_client.rb +16 -2
- data/lib/quonfig/version.rb +1 -1
- data/lib/quonfig.rb +2 -0
- data/quonfig.gemspec +4 -0
- metadata +17 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 125753bc634b155cdae7cf4772705ee74c5ec37f4a612c9c52ea873a94d0c5ca
|
|
4
|
+
data.tar.gz: 2c6e09f01e7ed54cf7e6f0fbb33784ea62e0d2bd069a87e3d74dafe3ed97aeb1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7e9474c7aa96611977db52658ba730efab5aafddb811e68dbd8ce90833d3c3017f6d5bc9b870db54be4b125844f43d46d96c067179a5cfd744880e4ca32cbd79
|
|
7
|
+
data.tar.gz: 26e638dbdeb223f06d9742cd155fd2b5b33073b20ee6e397a3322564979042c9c9dff8a3f893127c30ca70544202fc04ba2e3ce6c6c2f58332613dba884f9c33
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.0.18 - 2026-05-21
|
|
4
|
+
|
|
5
|
+
- **Fix (SSE): give Net `read_timeout` headroom over the watchdog deadline (qfg-6y44).** `stream_once` armed two read deadlines at the identical `sse_read_timeout` value: `Net::HTTP#read_timeout` and the `ReadDeadlineWatchdog`. On the body read both were live, and the watchdog carries up to `POLL_INTERVAL` (0.25 s) of polling latency on top of its deadline — so when Net's (unreliable on the `read_body` path) stdlib timeout did fire, it could beat the watchdog and surface a `Net::ReadTimeout` instead of the `SSEReadDeadlineExceeded` the SDK is instrumented around. A new `READ_TIMEOUT_HEADROOM` (30 s) keeps Net's `read_timeout` as a redundant backstop while guaranteeing the watchdog fires first.
|
|
6
|
+
- **Fix (datadir): coerce int/double config values to numbers at load time (qfg-38sf.8).** Config files store `int`/`double` Value fields as JSON strings (`{"type":"int","value":"123"}`). api-delivery normalizes these to real numbers at config-load time (`Value.UnmarshalJSON`), so every HTTP/SSE envelope already carries JSON numbers — but the datadir loader read the files directly and passed the strings through verbatim. `Quonfig::Datadir` now runs a generic recursive `coerce_numeric_values` walk over each parsed config document before projecting it to a `ConfigResponse`: any Value node (`type` of `"int"`/`"double"` with a String `value`) is coerced via `Integer(value, 10)` / `Float(value)`, covering `default.rules[].value`, environment rules, `criteria[].valueToMatch`, weighted-value arms, and variants. An unparseable numeric string is left untouched (passthrough — never raises). Brings the datadir loader in line with sdk-go and api-delivery so the loaded envelope always carries real numbers regardless of who consumes it.
|
|
7
|
+
- **CI: pin `integration-test-data` to v2026.05.20 and guard against stale generated tests (#12).** The integration suite now resolves its YAML specs from a pinned tag, and a guard fails the build if the generated tests drift from the templates.
|
|
8
|
+
- **CI: skip the Chaos workflow on Dependabot PRs and de-flake the datadir-reload test (8f60d9c).**
|
|
9
|
+
- **Dependency bumps (CI actions):** `actions/setup-go` 5.6.0 → 6.4.0 (#7), `actions/checkout` 4.3.1 → 6.0.2 (#8), `actions/upload-artifact` 4.6.2 → 7.0.1 (#9), `ruby/setup-ruby` 1.306.0 → 1.310.0 (#10).
|
|
10
|
+
|
|
11
|
+
## 0.0.17 - 2026-05-19
|
|
12
|
+
|
|
13
|
+
- **Feat (datadir): opt-in `data_dir_auto_reload` (qfg-mol-2da).** Datadir mode previously loaded the workspace once at construction and served purely from memory. Set `data_dir_auto_reload: true` to have the SDK watch the configured `datadir`, re-read `Quonfig::Datadir.load_envelope`, and fire the existing `on_update` callback whenever files change. Adds `listen ~> 3.8` (FSEvents on macOS, inotify on Linux, polling fallback on Windows) as a runtime dep. Behavior: parse-then-swap (a failed parse keeps the previous envelope and skips the callback), debounced (`data_dir_auto_reload_debounce_ms`, default 200 ms — bursts coalesce to one reload), and gracefully downgrades when watch registration fails (read-only fs, immutable container, missing native backend). Symlinked datadirs are resolved to their real path before watching. Default is `false`; opt-in only.
|
|
14
|
+
- **Feat (datadir + fork): auto-restart the watcher across `fork(2)` (qfg-mol-2da).** The watcher uses a background thread, which does not survive fork. The existing `Process._fork` hook (qfg-ryov, Ruby 3.1+) now also tears the datadir watcher down in the parent before fork and rebuilds a fresh one on the same `Client` in each child — no customer wiring required for Puma clustered mode, Unicorn, Sidekiq's parent-forks-workers model, Resque, or Spring. Ruby 3.0 customers continue to use the documented `Quonfig.fork` pattern in `on_worker_boot`, which rebuilds the watcher alongside the rest of the client.
|
|
15
|
+
|
|
3
16
|
## 0.0.16 - 2026-05-15
|
|
4
17
|
|
|
5
18
|
- **Feat (SSE): replace `ld-eventsource` with an SDK-owned reconnect loop (qfg-35sm).** sdk-ruby was the outlier among the four backend SDKs — sdk-go, sdk-node, and sdk-python all own their reconnect loop, only sdk-ruby handed it off to a library and scraped its log output to observe reconnects. The wire format we actually consume (plain JSON envelopes in single-line `data:` frames, no named events, no retry directives) is trivial enough that an SDK-owned loop is clearer than the library wrapper. New `Quonfig::SSEConfigClient` (~520 LoC, `lib/quonfig/sse_config_client.rb`) handles connect/parse/reconnect end-to-end. `restart_total` is now incremented at exactly one site under a mutex — verifiable, not log-scraped. `ld-eventsource` and the transitive `http` gem are removed from the gemspec. `ReconnectCountingLogger` and the `sse_reconnect_reset_interval` option (both 0.0.15-era defensive scaffolding around upstream behavior) are deleted — the bugs they defended against don't exist when the SDK owns the loop. Chaos: 10/10 in a 36-min run (scenarios 02 silent-stall, 05 sse-down-fallback, 09 flapping kill-storm).
|
data/README.md
CHANGED
|
@@ -107,6 +107,107 @@ export QUONFIG_ENVIRONMENT=production
|
|
|
107
107
|
client = Quonfig::Client.new # reads QUONFIG_DIR + QUONFIG_ENVIRONMENT
|
|
108
108
|
```
|
|
109
109
|
|
|
110
|
+
## Datadir mode: auto-reload on file changes
|
|
111
|
+
|
|
112
|
+
In datadir mode the SDK loads the workspace once at construction time and then
|
|
113
|
+
serves config purely from memory. Opt in to `data_dir_auto_reload: true` to
|
|
114
|
+
have the SDK watch the directory and re-read the envelope whenever files
|
|
115
|
+
change — an editor save, a `git pull`, or a build step that rewrites the
|
|
116
|
+
workspace.
|
|
117
|
+
|
|
118
|
+
```ruby
|
|
119
|
+
client = Quonfig::Client.new(
|
|
120
|
+
datadir: '/path/to/workspace',
|
|
121
|
+
environment: 'development',
|
|
122
|
+
data_dir_auto_reload: true # off by default — must be opted in
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
client.on_update do
|
|
126
|
+
puts 'Quonfig configs reloaded from disk'
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Edit a file under /path/to/workspace and on_update fires within ~200ms.
|
|
130
|
+
|
|
131
|
+
# On shutdown, stop stops the watcher and cancels any pending debounce.
|
|
132
|
+
client.stop
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
### When to enable
|
|
136
|
+
|
|
137
|
+
- Local development with the datadir checked out from git.
|
|
138
|
+
- Self-hosted servers that `git pull` the datadir on a schedule.
|
|
139
|
+
- CI jobs that mutate the datadir between assertions.
|
|
140
|
+
|
|
141
|
+
### When NOT to enable
|
|
142
|
+
|
|
143
|
+
- **Read-only / immutable filesystems** (some containers, scratch images,
|
|
144
|
+
AWS Lambda). Watch registration may fail; the SDK degrades gracefully
|
|
145
|
+
(logs the error and continues serving the envelope it loaded at init time)
|
|
146
|
+
but you're paying for nothing.
|
|
147
|
+
- **Build-time-embedded workflows** where the datadir is bundled into the
|
|
148
|
+
artifact and never changes at runtime. Watching wastes a thread and a
|
|
149
|
+
native-backend handle.
|
|
150
|
+
- **Production paths where reload timing matters** — e.g. you'd rather pin
|
|
151
|
+
the envelope you shipped with and roll forward through a redeploy than
|
|
152
|
+
have it shift under traffic.
|
|
153
|
+
|
|
154
|
+
Default is `false`; datadir mode is silent until you opt in.
|
|
155
|
+
|
|
156
|
+
### Behavior contract
|
|
157
|
+
|
|
158
|
+
- **Parse-then-swap.** If the new envelope fails to parse (truncated write,
|
|
159
|
+
mid-`git pull` state, invalid JSON), the SDK logs the error and **keeps
|
|
160
|
+
serving the previous envelope**. `on_update` is _not_ fired on parse
|
|
161
|
+
failure — only on a successful swap.
|
|
162
|
+
- **Debounced.** Bursts of filesystem events (atomic-rename editor saves,
|
|
163
|
+
`git pull` touching dozens of files) coalesce into a single re-read.
|
|
164
|
+
Default window: **200ms** — long enough to absorb the 3–5 events a typical
|
|
165
|
+
editor emits in <50ms, short enough that interactive edits feel immediate.
|
|
166
|
+
Tune via `data_dir_auto_reload_debounce_ms` if you need a different
|
|
167
|
+
window.
|
|
168
|
+
- **Graceful degrade.** If watch registration fails (read-only fs, immutable
|
|
169
|
+
container, missing native backend), the SDK logs and continues without
|
|
170
|
+
watching — it does **not** raise from the constructor.
|
|
171
|
+
- **Symlinks.** The watcher resolves `datadir` to its real path at start
|
|
172
|
+
time. Editing the file the symlink points at _is_ detected; atomic flips
|
|
173
|
+
that retarget the link itself are **not**.
|
|
174
|
+
- **Shutdown.** `client.stop` stops the watcher and cancels any pending
|
|
175
|
+
debounce. There is no separate handle to manage — the watcher lifecycle
|
|
176
|
+
is tied to the client.
|
|
177
|
+
|
|
178
|
+
### Fork safety (Puma cluster, Unicorn, Resque, Sidekiq)
|
|
179
|
+
|
|
180
|
+
The auto-reload watcher uses a background thread, which — like any Ruby
|
|
181
|
+
thread — does not survive `fork(2)`. **You do not need to wire this up
|
|
182
|
+
manually on Ruby 3.1+.** The SDK's `Process._fork` hook (see [Rails
|
|
183
|
+
integration](#rails-integration) below) stops the watcher in the parent
|
|
184
|
+
before fork and restarts a fresh watcher in each child after fork. This
|
|
185
|
+
covers Puma clustered mode, Unicorn, Sidekiq's parent-forks-workers model,
|
|
186
|
+
Resque, Spring, and manual `fork { ... }` calls.
|
|
187
|
+
|
|
188
|
+
On Ruby 3.0 (no `Process._fork`), follow the manual `before_fork` /
|
|
189
|
+
`on_worker_boot` pattern in the [Rails integration](#rails-integration)
|
|
190
|
+
section — `Quonfig.fork` rebuilds the full client, including the datadir
|
|
191
|
+
watcher, in the child.
|
|
192
|
+
|
|
193
|
+
### Tuning the debounce window
|
|
194
|
+
|
|
195
|
+
```ruby
|
|
196
|
+
Quonfig::Client.new(
|
|
197
|
+
datadir: '/path/to/workspace',
|
|
198
|
+
data_dir_auto_reload: true,
|
|
199
|
+
data_dir_auto_reload_debounce_ms: 1000 # wait a full second after the last event
|
|
200
|
+
)
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
The default (200 ms) is tuned for interactive editing. Raise it if you have
|
|
204
|
+
a noisy producer (continuously regenerating files) and you'd rather see one
|
|
205
|
+
reload per second than per save. Lower it only if you've measured that 200 ms
|
|
206
|
+
is meaningfully too slow for your use case.
|
|
207
|
+
|
|
208
|
+
See the [open-source / local how-to](https://docs.quonfig.com/docs/how-tos/open-source-local)
|
|
209
|
+
for the cross-SDK story (sdk-node, sdk-go, sdk-ruby, sdk-python, sdk-java).
|
|
210
|
+
|
|
110
211
|
## Environment variables
|
|
111
212
|
|
|
112
213
|
| Variable | Purpose |
|
|
@@ -130,7 +231,9 @@ Quonfig::Client.new(
|
|
|
130
231
|
on_no_default: :error,
|
|
131
232
|
global_context: {},
|
|
132
233
|
datadir: '/path/to/workspace',
|
|
133
|
-
environment: 'production'
|
|
234
|
+
environment: 'production',
|
|
235
|
+
data_dir_auto_reload: false,
|
|
236
|
+
data_dir_auto_reload_debounce_ms: 200
|
|
134
237
|
)
|
|
135
238
|
```
|
|
136
239
|
|
|
@@ -147,6 +250,8 @@ Quonfig::Client.new(
|
|
|
147
250
|
| `global_context` | `Hash` | `{}` | Context applied to every evaluation. |
|
|
148
251
|
| `datadir` | `String` | `ENV['QUONFIG_DIR']` | Path to a local workspace. When set, the SDK runs offline from disk. |
|
|
149
252
|
| `environment` | `String` | `ENV['QUONFIG_ENVIRONMENT']` | Environment to evaluate in datadir mode. Required when `datadir` is set. |
|
|
253
|
+
| `data_dir_auto_reload` | `Boolean` | `false` | Datadir mode only. When `true`, the SDK watches the datadir and re-reads the envelope when files change. See [Datadir mode: auto-reload on file changes](#datadir-mode-auto-reload-on-file-changes). |
|
|
254
|
+
| `data_dir_auto_reload_debounce_ms` | `Integer` (ms) | `200` | Debounce window for the auto-reload watcher — events arriving inside the window are coalesced into a single re-read. Ignored when `data_dir_auto_reload` is `false`. |
|
|
150
255
|
| `logger` | Logger-like object | `nil` | Optional host-app logger (e.g. `Rails.logger`). Must respond to `debug`/`info`/`warn`/`error`. When set, all SDK warnings/errors flow through this logger instead of the default stderr / SemanticLogger backend. |
|
|
151
256
|
|
|
152
257
|
## Typed getters
|
data/lib/quonfig/client.rb
CHANGED
|
@@ -78,6 +78,7 @@ module Quonfig
|
|
|
78
78
|
|
|
79
79
|
if @options.datadir
|
|
80
80
|
load_datadir_into_store
|
|
81
|
+
start_datadir_watcher if @options.data_dir_auto_reload
|
|
81
82
|
else
|
|
82
83
|
initialize_network_mode
|
|
83
84
|
end
|
|
@@ -316,7 +317,12 @@ module Quonfig
|
|
|
316
317
|
# or if the client is in datadir mode (no threaded components to start).
|
|
317
318
|
def after_fork_in_child
|
|
318
319
|
return if @stopped
|
|
319
|
-
|
|
320
|
+
|
|
321
|
+
if @options.datadir
|
|
322
|
+
start_datadir_watcher if @options.data_dir_auto_reload
|
|
323
|
+
return
|
|
324
|
+
end
|
|
325
|
+
|
|
320
326
|
return if @config_loader.nil? # never finished network init (e.g. invalid key)
|
|
321
327
|
|
|
322
328
|
# SSE state machine carries flags that no longer apply in the child
|
|
@@ -429,6 +435,13 @@ module Quonfig
|
|
|
429
435
|
LOG.debug "Error stopping telemetry reporter: #{e.message}"
|
|
430
436
|
end
|
|
431
437
|
@telemetry_reporter = nil
|
|
438
|
+
|
|
439
|
+
begin
|
|
440
|
+
@datadir_watcher&.stop
|
|
441
|
+
rescue StandardError => e
|
|
442
|
+
LOG.debug "Error stopping datadir watcher: #{e.message}"
|
|
443
|
+
end
|
|
444
|
+
@datadir_watcher = nil
|
|
432
445
|
end
|
|
433
446
|
|
|
434
447
|
# Rebuild the telemetry reporter in the child after fork. Mirrors the
|
|
@@ -675,10 +688,60 @@ module Quonfig
|
|
|
675
688
|
|
|
676
689
|
def load_datadir_into_store
|
|
677
690
|
envelope = Quonfig::Datadir.load_envelope(@options.datadir, @options.environment)
|
|
691
|
+
apply_datadir_envelope(envelope)
|
|
692
|
+
end
|
|
693
|
+
|
|
694
|
+
# Apply a freshly loaded datadir envelope to the store. Keys that were
|
|
695
|
+
# present before but missing now are deleted, so a `rm configs/foo.json`
|
|
696
|
+
# propagates through the auto-reload path. Records a refresh timestamp.
|
|
697
|
+
# Caller is responsible for firing on_update.
|
|
698
|
+
def apply_datadir_envelope(envelope)
|
|
699
|
+
new_keys = envelope.configs.map { |cfg| cfg['key'] }.compact.to_set
|
|
700
|
+
old_keys = @store.keys.to_set
|
|
701
|
+
(old_keys - new_keys).each { |k| @store.delete(k) }
|
|
678
702
|
envelope.configs.each { |cfg| @store.set(cfg['key'], cfg) }
|
|
679
703
|
record_refresh!
|
|
680
704
|
end
|
|
681
705
|
|
|
706
|
+
# qfg-mol-2da: start the filesystem watcher for datadir auto-reload.
|
|
707
|
+
# On listen-registration failure (read-only fs, missing native backend),
|
|
708
|
+
# log and continue without watching — the SDK keeps serving the envelope
|
|
709
|
+
# captured at init.
|
|
710
|
+
def start_datadir_watcher
|
|
711
|
+
return unless @options.datadir
|
|
712
|
+
|
|
713
|
+
watcher = Quonfig::DatadirWatcher.new(
|
|
714
|
+
datadir: @options.datadir,
|
|
715
|
+
debounce_ms: @options.data_dir_auto_reload_debounce_ms,
|
|
716
|
+
on_change: -> { reload_datadir! },
|
|
717
|
+
on_error: ->(err) { LOG.warn "[quonfig] datadir watcher error: #{err.class}: #{err.message}" }
|
|
718
|
+
)
|
|
719
|
+
unless watcher.start
|
|
720
|
+
LOG.warn '[quonfig] data_dir_auto_reload requested but watcher registration failed; continuing without auto-reload'
|
|
721
|
+
return
|
|
722
|
+
end
|
|
723
|
+
@datadir_watcher = watcher
|
|
724
|
+
end
|
|
725
|
+
|
|
726
|
+
# Re-read the datadir into a fresh envelope and atomically install it.
|
|
727
|
+
# Parse errors (mid-write JSON, garbage file) are logged and swallowed:
|
|
728
|
+
# the previous envelope stays in the store and on_update does NOT fire.
|
|
729
|
+
# qfg-mol-2da.
|
|
730
|
+
def reload_datadir!
|
|
731
|
+
return if @stopped
|
|
732
|
+
return unless @options.datadir
|
|
733
|
+
|
|
734
|
+
begin
|
|
735
|
+
envelope = Quonfig::Datadir.load_envelope(@options.datadir, @options.environment)
|
|
736
|
+
rescue StandardError => e
|
|
737
|
+
LOG.warn "[quonfig] datadir reload failed; keeping previous envelope: #{e.class}: #{e.message}"
|
|
738
|
+
return
|
|
739
|
+
end
|
|
740
|
+
|
|
741
|
+
apply_datadir_envelope(envelope)
|
|
742
|
+
notify_on_update_callback
|
|
743
|
+
end
|
|
744
|
+
|
|
682
745
|
# Initialize network mode: sync HTTP fetch (bounded by
|
|
683
746
|
# initialization_timeout_sec) then start SSE + polling as requested.
|
|
684
747
|
def initialize_network_mode
|
data/lib/quonfig/datadir.rb
CHANGED
|
@@ -42,6 +42,7 @@ module Quonfig
|
|
|
42
42
|
raw = JSON.parse(File.read(path))
|
|
43
43
|
raise ArgumentError, "[quonfig] config has empty key — file is not a Quonfig Config: #{path}" if raw['key'].nil? || raw['key'].to_s.empty?
|
|
44
44
|
|
|
45
|
+
coerce_numeric_values(raw)
|
|
45
46
|
configs << to_config_response(raw, env_id)
|
|
46
47
|
end
|
|
47
48
|
end
|
|
@@ -94,5 +95,42 @@ module Quonfig
|
|
|
94
95
|
|
|
95
96
|
raw || false
|
|
96
97
|
end
|
|
98
|
+
|
|
99
|
+
# Config files store int/double Value fields as JSON strings
|
|
100
|
+
# (`{"type":"int","value":"123"}`). api-delivery normalizes these to real
|
|
101
|
+
# numbers at config-load time (`Value.UnmarshalJSON`), so every envelope it
|
|
102
|
+
# emits over HTTP/SSE already carries JSON numbers. In datadir mode we read
|
|
103
|
+
# the files directly, so we must coerce here to match.
|
|
104
|
+
#
|
|
105
|
+
# Walks the parsed config document in place, coercing every Value node — any
|
|
106
|
+
# Hash with a `type` of `"int"`/`"double"` and a String `value` — to a real
|
|
107
|
+
# number. A generic recursive walk covers `default.rules[].value`,
|
|
108
|
+
# environment rules, `criteria[].valueToMatch`, weighted-value arms, and
|
|
109
|
+
# variants without enumerating each location. On parse failure the original
|
|
110
|
+
# string is left in place (passthrough — never raise).
|
|
111
|
+
def coerce_numeric_values(node)
|
|
112
|
+
case node
|
|
113
|
+
when Hash
|
|
114
|
+
coerce_numeric_value_field(node)
|
|
115
|
+
node.each_value { |child| coerce_numeric_values(child) }
|
|
116
|
+
when Array
|
|
117
|
+
node.each { |child| coerce_numeric_values(child) }
|
|
118
|
+
end
|
|
119
|
+
node
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def coerce_numeric_value_field(hash)
|
|
123
|
+
value = hash['value']
|
|
124
|
+
return unless value.is_a?(String)
|
|
125
|
+
|
|
126
|
+
case hash['type']
|
|
127
|
+
when 'int'
|
|
128
|
+
hash['value'] = Integer(value, 10)
|
|
129
|
+
when 'double'
|
|
130
|
+
hash['value'] = Float(value)
|
|
131
|
+
end
|
|
132
|
+
rescue ArgumentError, TypeError
|
|
133
|
+
# Unparseable numeric string — leave the original value untouched.
|
|
134
|
+
end
|
|
97
135
|
end
|
|
98
136
|
end
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'concurrent'
|
|
4
|
+
|
|
5
|
+
module Quonfig
|
|
6
|
+
# Watches a datadir for changes and fires +on_change+ once per debounced
|
|
7
|
+
# burst. Wraps the `listen` gem (https://github.com/guard/listen), which
|
|
8
|
+
# uses platform-native backends (FSEvents on macOS, inotify on Linux,
|
|
9
|
+
# polling fallback on Windows).
|
|
10
|
+
#
|
|
11
|
+
# The caller owns parse-then-swap: this class only fires the trigger.
|
|
12
|
+
# Registration failures (read-only fs, immutable container, native backend
|
|
13
|
+
# missing) are surfaced via +on_error+; in that case +start+ returns
|
|
14
|
+
# +false+ and no listener is held.
|
|
15
|
+
#
|
|
16
|
+
# Mirrors sdk-node/src/datadirWatcher.ts (qfg-mol-0kr) modulo Ruby idioms:
|
|
17
|
+
# listen does not have an equivalent to Node's `fs.watch({recursive:true})`,
|
|
18
|
+
# but it watches recursively by default.
|
|
19
|
+
class DatadirWatcher
|
|
20
|
+
# Indirection seam for tests. Production code uses ::Listen; tests can
|
|
21
|
+
# swap in a class that raises from `.to` to exercise the registration-
|
|
22
|
+
# failure path without needing a read-only filesystem.
|
|
23
|
+
LISTEN_FACTORY = nil # resolved lazily so the gem can be required late
|
|
24
|
+
|
|
25
|
+
def initialize(datadir:, debounce_ms:, on_change:, on_error:)
|
|
26
|
+
@datadir = datadir
|
|
27
|
+
@debounce_seconds = debounce_ms.to_f / 1000.0
|
|
28
|
+
@on_change = on_change
|
|
29
|
+
@on_error = on_error
|
|
30
|
+
@mutex = Mutex.new
|
|
31
|
+
@scheduled_task = nil
|
|
32
|
+
@listener = nil
|
|
33
|
+
@closed = false
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Start the underlying file watcher. Returns true on success, false if
|
|
37
|
+
# registration failed (in which case +on_error+ has already been called
|
|
38
|
+
# and the caller should continue without auto-reload).
|
|
39
|
+
#
|
|
40
|
+
# Blocks until the listener is in its :processing_events state (or a
|
|
41
|
+
# short safety timeout elapses) so a customer writing to the datadir
|
|
42
|
+
# immediately after the Client constructor returns is detected, rather
|
|
43
|
+
# than racing the listen backend's async setup.
|
|
44
|
+
def start
|
|
45
|
+
resolved = File.realpath(@datadir)
|
|
46
|
+
factory = self.class::LISTEN_FACTORY || ::Listen
|
|
47
|
+
@listener = factory.to(resolved) do |_modified, _added, _removed|
|
|
48
|
+
schedule_reload
|
|
49
|
+
end
|
|
50
|
+
@listener.start
|
|
51
|
+
wait_for_listener_ready
|
|
52
|
+
true
|
|
53
|
+
rescue StandardError => e
|
|
54
|
+
@on_error.call(e)
|
|
55
|
+
false
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Stop the watcher and cancel any pending debounce. Idempotent.
|
|
59
|
+
def stop
|
|
60
|
+
task, listener = @mutex.synchronize do
|
|
61
|
+
@closed = true
|
|
62
|
+
t = @scheduled_task
|
|
63
|
+
l = @listener
|
|
64
|
+
@scheduled_task = nil
|
|
65
|
+
@listener = nil
|
|
66
|
+
[t, l]
|
|
67
|
+
end
|
|
68
|
+
begin
|
|
69
|
+
task&.cancel
|
|
70
|
+
rescue StandardError
|
|
71
|
+
# best-effort; caller already in shutdown
|
|
72
|
+
end
|
|
73
|
+
begin
|
|
74
|
+
listener&.stop
|
|
75
|
+
rescue StandardError
|
|
76
|
+
# best-effort
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
# Block briefly until the listener reports :processing_events. Listen's
|
|
83
|
+
# state machine supports wait_for_state; we cap at 500 ms so a broken
|
|
84
|
+
# backend cannot wedge the SDK boot. (The native FSEvents backend on
|
|
85
|
+
# macOS still has ~100ms latency *after* this returns — that is a
|
|
86
|
+
# property of the OS, not something we can synchronize away. Tests that
|
|
87
|
+
# need to observe the first post-init write should sleep accordingly.)
|
|
88
|
+
def wait_for_listener_ready
|
|
89
|
+
return unless @listener.respond_to?(:wait_for_state)
|
|
90
|
+
|
|
91
|
+
begin
|
|
92
|
+
@listener.wait_for_state(:processing_events, timeout: 0.5)
|
|
93
|
+
rescue StandardError
|
|
94
|
+
# If the FSM doesn't transition, keep going — events may still flow.
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def schedule_reload
|
|
99
|
+
@mutex.synchronize do
|
|
100
|
+
return if @closed
|
|
101
|
+
|
|
102
|
+
@scheduled_task&.cancel
|
|
103
|
+
on_change = @on_change
|
|
104
|
+
@scheduled_task = Concurrent::ScheduledTask.execute(@debounce_seconds) do
|
|
105
|
+
# Re-check closed under the mutex so a stop() landing between cancel
|
|
106
|
+
# and execute cannot resurrect a fired callback.
|
|
107
|
+
should_fire = @mutex.synchronize { !@closed }
|
|
108
|
+
on_change.call if should_fire
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
data/lib/quonfig/options.rb
CHANGED
|
@@ -6,7 +6,8 @@ module Quonfig
|
|
|
6
6
|
# Options passed to Quonfig::Client at construction time.
|
|
7
7
|
class Options
|
|
8
8
|
attr_reader :sdk_key, :environment, :api_urls, :sse_api_urls, :telemetry_destination, :config_api_urls,
|
|
9
|
-
:on_no_default, :initialization_timeout_sec, :on_init_failure, :collect_sync_interval, :datadir, :enable_sse, :enable_polling, :poll_interval, :global_context, :logger_key, :logger, :enable_quonfig_user_context
|
|
9
|
+
:on_no_default, :initialization_timeout_sec, :on_init_failure, :collect_sync_interval, :datadir, :enable_sse, :enable_polling, :poll_interval, :global_context, :logger_key, :logger, :enable_quonfig_user_context,
|
|
10
|
+
:data_dir_auto_reload, :data_dir_auto_reload_debounce_ms
|
|
10
11
|
attr_accessor :is_fork
|
|
11
12
|
|
|
12
13
|
module ON_INITIALIZATION_FAILURE
|
|
@@ -119,6 +120,24 @@ module Quonfig
|
|
|
119
120
|
|
|
120
121
|
private
|
|
121
122
|
|
|
123
|
+
# @!method initialize(options = {})
|
|
124
|
+
# @option options [Boolean] :data_dir_auto_reload (false)
|
|
125
|
+
# Datadir mode only. When +true+, the SDK watches the workspace
|
|
126
|
+
# directory and re-reads the envelope whenever files inside it
|
|
127
|
+
# change. Parse-then-swap: a failed parse keeps the previous
|
|
128
|
+
# envelope. Default debounce window is 200 ms; tune via
|
|
129
|
+
# +:data_dir_auto_reload_debounce_ms+. Listen-registration failure
|
|
130
|
+
# (read-only fs, missing native backend) is logged and the SDK
|
|
131
|
+
# continues serving the envelope captured at init.
|
|
132
|
+
#
|
|
133
|
+
# On Ruby 3.1+ the SDK's +Process._fork+ hook tears the watcher
|
|
134
|
+
# down in the parent before fork and rebuilds it in each child;
|
|
135
|
+
# no customer wiring is required for Puma cluster / Unicorn /
|
|
136
|
+
# Sidekiq / Resque. See README "Fork safety".
|
|
137
|
+
# @option options [Integer] :data_dir_auto_reload_debounce_ms (200)
|
|
138
|
+
# Debounce window in milliseconds. Filesystem events arriving
|
|
139
|
+
# inside the window are coalesced into a single re-read. Ignored
|
|
140
|
+
# when +:data_dir_auto_reload+ is +false+.
|
|
122
141
|
def init(
|
|
123
142
|
api_urls: nil,
|
|
124
143
|
telemetry_url: nil,
|
|
@@ -141,7 +160,9 @@ module Quonfig
|
|
|
141
160
|
global_context: {},
|
|
142
161
|
logger_key: nil,
|
|
143
162
|
logger: nil,
|
|
144
|
-
enable_quonfig_user_context: false
|
|
163
|
+
enable_quonfig_user_context: false,
|
|
164
|
+
data_dir_auto_reload: false,
|
|
165
|
+
data_dir_auto_reload_debounce_ms: 200
|
|
145
166
|
)
|
|
146
167
|
@sdk_key = sdk_key
|
|
147
168
|
@environment = environment
|
|
@@ -163,6 +184,8 @@ module Quonfig
|
|
|
163
184
|
@logger_key = logger_key
|
|
164
185
|
@logger = logger
|
|
165
186
|
@enable_quonfig_user_context = enable_quonfig_user_context
|
|
187
|
+
@data_dir_auto_reload = data_dir_auto_reload
|
|
188
|
+
@data_dir_auto_reload_debounce_ms = data_dir_auto_reload_debounce_ms
|
|
166
189
|
|
|
167
190
|
# defaults that may be overridden by context_upload_mode
|
|
168
191
|
@collect_shapes = false
|
|
@@ -56,6 +56,17 @@ module Quonfig
|
|
|
56
56
|
# Anything else (5xx, 429, network errors) stays on the transient path.
|
|
57
57
|
TERMINAL_HTTP_CODES = [401, 403, 404].freeze
|
|
58
58
|
|
|
59
|
+
# qfg-6y44: headroom added to +sse_read_timeout+ when configuring
|
|
60
|
+
# Net::HTTP#read_timeout. The ReadDeadlineWatchdog already covers both the
|
|
61
|
+
# header and body reads at exactly +sse_read_timeout+; Net's own
|
|
62
|
+
# read_timeout is only a redundant backstop. Arming it at the *same* value
|
|
63
|
+
# as the watchdog makes the two race — and on the body path Net's
|
|
64
|
+
# (unreliable) timeout can fire first, surfacing a Net::ReadTimeout instead
|
|
65
|
+
# of the SSEReadDeadlineExceeded the SDK is instrumented around. Giving
|
|
66
|
+
# Net's read_timeout this much headroom keeps it a backstop without ever
|
|
67
|
+
# letting it win the race.
|
|
68
|
+
READ_TIMEOUT_HEADROOM = 30
|
|
69
|
+
|
|
59
70
|
# +on_error+: optional callable invoked on every SSE error edge. Parent
|
|
60
71
|
# Quonfig::Client wires this to drive @sse_state -> :error so that
|
|
61
72
|
# +connection_state+ reflects the disconnect (qfg-47c2.27).
|
|
@@ -254,8 +265,11 @@ module Quonfig
|
|
|
254
265
|
http.use_ssl = (uri.scheme == 'https')
|
|
255
266
|
http.open_timeout = @options.sse_connect_timeout
|
|
256
267
|
# Keep Net::HTTP's read_timeout as a backstop for the header read
|
|
257
|
-
# (where it does apply reliably). The watchdog covers the body path
|
|
258
|
-
|
|
268
|
+
# (where it does apply reliably). The watchdog covers the body path —
|
|
269
|
+
# so read_timeout gets READ_TIMEOUT_HEADROOM over the watchdog deadline
|
|
270
|
+
# to guarantee the watchdog fires first and we get a deterministic
|
|
271
|
+
# SSEReadDeadlineExceeded rather than a racy Net::ReadTimeout (qfg-6y44).
|
|
272
|
+
http.read_timeout = @options.sse_read_timeout + READ_TIMEOUT_HEADROOM
|
|
259
273
|
|
|
260
274
|
req = Net::HTTP::Get.new(uri.request_uri, headers)
|
|
261
275
|
|
data/lib/quonfig/version.rb
CHANGED
data/lib/quonfig.rb
CHANGED
|
@@ -51,6 +51,8 @@ require 'quonfig/resolver'
|
|
|
51
51
|
require 'quonfig/config_envelope'
|
|
52
52
|
require 'quonfig/config_loader'
|
|
53
53
|
require 'quonfig/datadir'
|
|
54
|
+
require 'listen'
|
|
55
|
+
require 'quonfig/datadir_watcher'
|
|
54
56
|
require 'quonfig/sse_config_client'
|
|
55
57
|
require 'quonfig/http_connection'
|
|
56
58
|
require 'quonfig/caching_http_connection'
|
data/quonfig.gemspec
CHANGED
|
@@ -31,4 +31,8 @@ Gem::Specification.new do |s|
|
|
|
31
31
|
s.add_dependency 'activesupport', '>= 4'
|
|
32
32
|
s.add_dependency 'concurrent-ruby', '~> 1.0', '>= 1.0.5'
|
|
33
33
|
s.add_dependency 'faraday', '>= 1.0'
|
|
34
|
+
# File watching for opt-in data_dir_auto_reload (qfg-mol-2da). 3.x supports
|
|
35
|
+
# Ruby 3.0+. Native backends (rb-fsevent on macOS, rb-inotify on Linux) are
|
|
36
|
+
# transitive deps of `listen`; the polling fallback is used elsewhere.
|
|
37
|
+
s.add_dependency 'listen', '~> 3.8'
|
|
34
38
|
end
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: quonfig
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.0.
|
|
4
|
+
version: 0.0.18
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Jeff Dwyer
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-05-
|
|
11
|
+
date: 2026-05-21 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: activesupport
|
|
@@ -58,6 +58,20 @@ dependencies:
|
|
|
58
58
|
- - ">="
|
|
59
59
|
- !ruby/object:Gem::Version
|
|
60
60
|
version: '1.0'
|
|
61
|
+
- !ruby/object:Gem::Dependency
|
|
62
|
+
name: listen
|
|
63
|
+
requirement: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - "~>"
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: '3.8'
|
|
68
|
+
type: :runtime
|
|
69
|
+
prerelease: false
|
|
70
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
71
|
+
requirements:
|
|
72
|
+
- - "~>"
|
|
73
|
+
- !ruby/object:Gem::Version
|
|
74
|
+
version: '3.8'
|
|
61
75
|
description: Quonfig — feature flags and live config, stored as files in git.
|
|
62
76
|
email: jeff@quonfig.com
|
|
63
77
|
executables: []
|
|
@@ -79,6 +93,7 @@ files:
|
|
|
79
93
|
- lib/quonfig/config_store.rb
|
|
80
94
|
- lib/quonfig/context.rb
|
|
81
95
|
- lib/quonfig/datadir.rb
|
|
96
|
+
- lib/quonfig/datadir_watcher.rb
|
|
82
97
|
- lib/quonfig/dev_context.rb
|
|
83
98
|
- lib/quonfig/duration.rb
|
|
84
99
|
- lib/quonfig/encryption.rb
|