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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6f167f3b60db07394dc7c49b85c3dbc196e0b5c82f3426b35695c0f212339b8b
4
- data.tar.gz: 4b79e1196c4625359943255a348d907c28865a5cd85432ac464737406d7a6169
3
+ metadata.gz: 125753bc634b155cdae7cf4772705ee74c5ec37f4a612c9c52ea873a94d0c5ca
4
+ data.tar.gz: 2c6e09f01e7ed54cf7e6f0fbb33784ea62e0d2bd069a87e3d74dafe3ed97aeb1
5
5
  SHA512:
6
- metadata.gz: 5aa3a23774245bf31752e4c9918de8bf37cc865e15b6ed160b222181d805a0fe477064cc5cf27dc6810b87cdd1c250f8558b650c98fd5e7a354c3e2e70090c53
7
- data.tar.gz: 82c4561817b40e4dd0ecfd1b5267e6a5f41ea2e774c2d2537400aaf04886eb579807da457f6964915a065a32dc7beea043a2797d83ff04dba3c9fb4e46c39cb3
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
@@ -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
- return if @options.datadir
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
@@ -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
@@ -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
- http.read_timeout = @options.sse_read_timeout
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
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Quonfig
4
- VERSION = '0.0.16'
4
+ VERSION = '0.0.18'
5
5
  end
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.16
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-15 00:00:00.000000000 Z
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