quonfig 1.2.0 → 1.4.0
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 +35 -0
- data/README.md +154 -49
- data/lib/quonfig/client.rb +547 -65
- data/lib/quonfig/config_loader.rb +6 -0
- data/lib/quonfig/options.rb +34 -1
- data/lib/quonfig/sse_config_client.rb +9 -0
- data/lib/quonfig/telemetry/telemetry_reporter.rb +51 -0
- data/lib/quonfig/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5e8807ad54199b5ee24245cdf140a72aed0644e712308d867457d729df1bb122
|
|
4
|
+
data.tar.gz: 20b1d12fb441394f4d31198854b55dd90f78c925115982a32548415993c77958
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: cda8d4a8d332710a58f3be0e1e068e767858c9806f199bc23a5a2f010e7540d4fd9a159b20ac23f9d34f1a7f60b5aac10fcffdeda030356ace8536779048126a
|
|
7
|
+
data.tar.gz: 33dcc1b5201298630936e743f5e44b188393b810ee94287623dce7598b98ab68ee0a2eb8bddb70f92da2fb3419f6a0b7f2a3081e2510b7f2d144087765b541b6
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,40 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.4.0 - 2026-09-10
|
|
4
|
+
|
|
5
|
+
> **If you fork worker processes and keep evaluating in the parent, upgrade.** On 1.0.0–1.3.0 the parent process went permanently dark after any `fork(2)` — it kept serving whatever config snapshot it held at the moment of the fork, forever, while `connection_state` reported `:connected`.
|
|
6
|
+
|
|
7
|
+
- **Fix (fork): the `Process._fork` hook is now child-only — the parent is never touched (qfg-lv4n.1).** Since 0.0.16 the hook tore the SSE worker, fallback poller, telemetry reporter, and datadir watcher down **in the parent** before the fork syscall, and restarted them only in the child. Any topology where the parent keeps evaluating after a fork — a Sidekiq process using the `parallel` gem, a `fork { ... }` inside a job, a rake task that forks — lost live updates permanently. A customer's Sidekiq process served a 13-day-old snapshot through four rollout changes this way. The hook now does **nothing** on the parent side: the parent keeps its stream, its poller, its telemetry reporter, and its live config straight through any number of forks. This restores the design every other SDK in this space uses (Reforge's `Reforge.fork`, LaunchDarkly's `postfork`, dd-trace-rb / redis-client / connection_pool, which all branch on the child stage of `_fork` only). No customer wiring is required, and no public API was removed.
|
|
8
|
+
- **Change (fork): a forked child re-initializes on its FIRST use of the client, and never inherits the parent's config snapshot.** The hook does no I/O at all: it drops what the child inherited — including the parent's store — and arms a flag. The first `get` / `defined?` / `keys` / details lookup in the child then does exactly what `Client.new` does: its own blocking config fetch under the configured `init_timeout_ms` and `on_init_failure`, then its own SSE stream (or fallback poller) and its own telemetry reporter. Three consequences worth knowing: the first call in a forked child pays one fetch; a child that never uses the client costs nothing (no fetch, no socket, no thread, and `stop` returns immediately); and because the child's store starts empty, its first envelope is *installed* rather than dropped by the reject-older guard as same-generation. This is full parity with Reforge, where a forked process simply builds a new client. `connection_state` does not trigger the re-initialization — a diagnostic must never open a socket — and answers `:initializing` while one is pending.
|
|
9
|
+
- **Fix (fork): concurrent first use in a child waits for the one rebuild (qfg-lv4n.1).** The re-initialization used an unlocked fast path and cleared its pending flag *before* doing the work, so only the thread that won the mutex actually blocked: every other thread read the cleared flag, skipped the mutex, and evaluated against the brand-new **empty** store. Sixteen threads hitting a freshly forked client against a 400ms config endpoint returned `{nil=>15, "v1"=>1}`. The flag now stays set for the whole rebuild, so every other caller blocks on it and then sees the fetched config — one fetch, one stream dial, one reporter per child, however many threads race the first request. A same-thread guard keeps a customer logger (a `semantic_logger_filter` / `stdlib_formatter` that evaluates a config) from deadlocking on the non-reentrant lock if it fires from inside the rebuild.
|
|
10
|
+
- **Fix (fork): a hard timeout mid-rebuild no longer leaves the child dark forever (qfg-lv4n.1).** `Timeout::ExitException` (Ruby 3.3's `timeout` 0.4.1), rack-timeout's `RequestTimeoutException`, and `Thread#kill` are all `Exception`, not `StandardError`, so they crossed the rebuild's rescue untouched — and with the pending flag already cleared, every later call in that child returned `nil` over an empty store with no stream, no poller and no reporter, permanently. The flag is now cleared at the point the child actually has a live path to config, so anything that escapes before that leaves the rebuild armed and the next call retries it.
|
|
11
|
+
- **Fix (fork): `stop` racing an in-flight rebuild no longer orphans an SSE worker (qfg-lv4n.1).** `stop` cleared the pending flag and tore down without taking the rebuild lock, so a rebuild already past that point went on to build a stream nothing held a reference to and nothing could close. `stop` now raises its stopped flag **before** queueing for the rebuild lock — an in-flight rebuild sees it and starts neither an update channel nor a telemetry reporter — and the teardown itself runs under that lock, so it can never interleave with construction.
|
|
12
|
+
- **Fix (fork): a datadir child whose rebuild fails no longer dials SSE (qfg-lv4n.1).** The failure path started the update channel regardless of mode, so a child of a purely **offline** (datadir) client opened a stream to `stream.primary.quonfig.com` on its first lookup and then logged ``Error applying SSE envelope: undefined method `apply_envelope' for nil`` for every envelope that arrived — with nothing re-armed, repairing the workspace on disk never helped. A datadir client's healing path is the filesystem: the failure path now starts the datadir watcher when `data_dir_auto_reload` is on, and otherwise re-arms the rebuild so the next use retries the load.
|
|
13
|
+
- **Fix (fork): `after_fork_in_child` called in the PARENT is now a no-op (qfg-lv4n.1).** Releases 1.0.0–1.3.0 documented calling `Quonfig.instance.after_fork_in_child` in the parent after `fork` returned as the workaround for the parent going dark. On 1.4.0 the parent's components are alive, so each such call orphaned a live SSE worker and its stream, zeroed the store, and stopped the **owner's** telemetry reporter — three calls took a process from 2 worker threads and 1 live stream to 8 and 4, and the orphans outlived `stop`. The hook now early-returns (one debug line) in the process that **owns** the client, decided by comparing the current pid against the one stamped when the client was built. **If you added that call in the parent as a 1.3.0 workaround, remove it; it does nothing there now.**
|
|
14
|
+
- **Fix (fork): a child forked from inside an `on_update` callback rebuilds like any other child (qfg-lv4n.1).** Parent-vs-child detection asked whether the inherited SSE worker thread was alive. `on_update` runs *on* that worker thread, so a customer who forks from the callback forks on it — making it the child's one surviving thread, and making the inherited `@worker.alive?` answer **true in a real child**. Such a child was classified as the parent: the hook ignored it, it served the parent's snapshot for the rest of its life, reported `:connected`, and resumed the parent's SSE loop on the shared file descriptor. Ownership is now a pid stamp taken at construction (and re-taken when a child rebuilds), so a pid mismatch is proof of a fork child whatever the inherited `Thread` objects claim. The same stamp closes the other end of the hole: a parent-side `after_fork_in_child` on a client with no threads at all (datadir + `data_dir_auto_reload: false` + no SDK key) sailed straight past the old liveness guard and wiped the live store; it is now correctly a no-op.
|
|
15
|
+
- **Fix (fork): a retried rebuild no longer dials a second stream (qfg-lv4n.1).** The rebuild disarms its pending flag only *after* the update channel is up, so a non-`StandardError` landing in that window (rack-timeout, `Timeout::ExitException`, `Thread#kill`) left the flag armed **with a live stream**. The retry re-ran network init, opened a second SSE stream, and overwrote `@sse_client` — orphaning the first worker where `stop` could never reach it, and leaving the child holding two streams against the delivery service. `start_update_channel` is now idempotent: it returns immediately if an SSE worker or poll supervisor is already alive.
|
|
16
|
+
- **Fix (fork): `on_init_failure: :raise` behaves the same in a forked child as in a fresh client (qfg-lv4n.1).** A failed re-initialization was always swallowed and logged, so the documented "exactly like a newly constructed client" was false for the one option whose entire job is raise-vs-return. Under `:raise` the first use in a child now raises the init error out of the lookup — Reforge does the same, raising the init error out of `get` itself — and later lookups keep raising, without re-fetching, until the update channel lands an envelope. The update channel is still started on the way out so the child can heal. **Note that `:raise` is the default**, so with default options a forked child whose first lookup lands in a total delivery outage (primary and secondary both unreachable) raises where 1.3.0 silently served the parent's snapshot; set `on_init_failure: :return` if you would rather a child serve defaults through an outage. `:return` is unchanged: one line logged, defaults served.
|
|
17
|
+
- **Fix (fork): `Quonfig.fork` / `Client#fork` in a child the hook already prepared returns the same client (qfg-4t5o).** The 1.0–1.3 README taught `on_worker_boot { Quonfig.fork }`, and on Ruby 3.1+ that call ran *after* the `Process._fork` hook had already prepared the client in the worker. Before first use it discarded the prepared client and built a second one with an eager fetch; after first use it left the worker holding two live SSE streams and two telemetry reporters, the first pair orphaned where `stop` could never reach them. `Client#fork` now returns `self` when the hook has already prepared it in the current process, so a leftover call from older docs is harmless. Outside a hook-prepared child (the owning process, Ruby 3.0, a client stopped before the fork) it still builds a fresh client, which is the Ruby 3.0 manual-wiring path; the old client is never stopped.
|
|
18
|
+
- **Fix (fork): the public `store` / `resolver` / `evaluator` / `config_loader` readers route through the post-fork re-initialization (qfg-lv4n.1).** They bypassed it entirely, so in a forked child that had not been used yet `client.store.get(key)` answered `nil` and `client.resolver.get(key, {})` raised `MissingDefaultError` against the empty store. They stay public (semver) and now re-initialize before handing the component back; reading one in a forked child can therefore block on the child's own fetch.
|
|
19
|
+
- **Fix (fork): the child drops inherited references instead of closing them.** `fork(2)` duplicates file descriptors, so the child's copy of the SSE socket points at the connection the **parent** is still streaming on — closing it would write a TLS `close_notify` onto that shared connection and kill the parent's stream. The child now nils `@sse_client`, `@poll_supervisor`, `@telemetry_reporter`, `@datadir_watcher`, and the pending fallback-engage timer without calling `close` / `stop` / `join` on any of them (joining an inherited thread blocks forever, since the thread does not exist in the child), then builds everything fresh.
|
|
20
|
+
- **Fix (fork): the child's telemetry starts empty.** The forked child gets brand-new context-shape, example-context, evaluation-summary, and failover aggregators rather than inheriting the parent's half-full ones (the reporter is not *started* until the child's first use). The parent flushes the data it collected; the child reports only its own, so a fork no longer double-counts a window of evaluations.
|
|
21
|
+
- **Fix (fork/telemetry): the inherited `at_exit` drain no longer speaks for the parent.** `TelemetryReporter#start` registers a process-wide `Kernel.at_exit` closure over the reporter; `fork(2)` copies it, and dropping the client's reference in the child does not unregister it — so any child that exited the normal way (block-form `fork` + `exit`, which is what the `parallel` gem does) POSTed a full copy of the **parent's** un-flushed window under the parent's `instanceHash`, and the parent then POSTed it again. The reporter now records an owner pid on `start`, and `sync`, `stop`, and the `at_exit` drain are no-ops (one debug line) in any other process; the child additionally discards the inherited aggregators. Measured on a three-child `Parallel.map`: 404 evaluations reported for 101 performed, now 101. Nothing is closed, stopped, or joined — the thread does not exist in the child and the HTTP connection's fd is shared with the parent.
|
|
22
|
+
- **Fix (fork): one client failing to rebuild no longer takes the rest of the registry down with it.** The child-side fan-out was covered by a single hook-wide rescue, so the first `after_fork_in_child` to raise (thread exhaustion, a customer logger that raises) aborted the loop and every client behind it in the registry stayed dark. Each instance now has its own rescue and the fan-out continues, logging the failure at error.
|
|
23
|
+
- **Fix (fork): a forked datadir child gets its own telemetry reporter (qfg-vquv).** The datadir branch of `after_fork_in_child` returned before the aggregator/reporter rebuild, so a datadir + SDK-key child — an emitting combination since 1.3.0 — recorded nothing of its own for the rest of its life. It now rebuilds aggregators and reporter under exactly the SDK-key gating a fresh client applies.
|
|
24
|
+
- **Fix (diagnostics): `connection_state` derives from liveness, not from a stored flag.** It previously answered `:connected` off `@sse_state` alone, which the teardown path never reset — so a client with no SSE worker and no poller alive still reported healthy. That is why the incident above stayed invisible for 13 days. A network client that is supposed to hold an SSE stream and has no live worker now reports `:disconnected`. The documented value set (`:initializing`, `:connected`, `:disconnected`, `:falling_back`) is unchanged, and `:connected` still covers datadir mode and post-fetch clients with SSE disabled.
|
|
25
|
+
- **Deprecated: `Client#before_fork_in_parent`.** Still public and still works, but the fork hook no longer calls it. There is no longer any reason to tear a parent down before forking; call `stop` if you want a client dead. Slated for removal in 2.0.0.
|
|
26
|
+
- **Docs:** "one telemetry POST at exit" for a per-job forking worker is now qualified — the at-exit drain only happens when the child exits *normally*. `Parallel` children do; **Resque children call `exit!` by default**, which skips every `at_exit` handler, so there is no drain and no telemetry POST unless `RUN_AT_EXIT_HOOKS=1` is set.
|
|
27
|
+
- **Docs:** the README's fork section previously claimed the SDK covered "Sidekiq's parent-forks-workers model" (Sidekiq OSS does not fork — it runs jobs on threads) and documented "does not auto-restart the parent" as intentional. Both are corrected. The Puma snippet no longer suggests `before_fork { Quonfig.instance.stop }`, and the cases that actually fork inside Sidekiq (the `parallel` gem, an explicit `fork { }`, Enterprise swarm) are named as covered. The Ruby 3.0 `parallel` snippet no longer calls `Quonfig.fork` once per row (that builds a client per item; it is now a pid-memoized rebuild once per child process, with a note that 3.0 is EOL), and the Puma/Unicorn worker-boot snippets show `SemanticLogger.reopen` alone on 3.1+ — calling `Quonfig.fork` there after the hook has already rebuilt leaves the worker with two live SSE streams and two reporters.
|
|
28
|
+
|
|
29
|
+
## 1.3.0 - 2026-08-18
|
|
30
|
+
|
|
31
|
+
> **Heads up — datadir users will start emitting telemetry.** Datadir mode with a valid SDK key now emits usage telemetry as documented; it previously sent nothing. If you run the SDK with a `datadir:` (or `QUONFIG_DIR`) **and** an SDK key, upgrading to 1.3.0 means evaluation summaries and context telemetry begin flowing to the telemetry service from that deployment — data you were not sending on 1.2.0. Disable with the standard telemetry options if unwanted (`collect_evaluation_summaries: false` and `context_upload_mode: :none`). A datadir client with **no** SDK key still sends nothing at all.
|
|
32
|
+
|
|
33
|
+
- **Fix (telemetry): the telemetry gate is SDK-key presence, not delivery mode (qfg-5x9x).** `Options#telemetry_allowed?` consulted `local_only?` (i.e. "is a datadir set?"), which forced every `collect_max_*` to `0` the moment a datadir was configured. With no collector limits the client built no aggregators, took `initialize_telemetry`'s early return, and never constructed a `TelemetryReporter` — so a datadir client holding a perfectly valid SDK key emitted **no** evaluation summaries, **no** context telemetry, nothing at all. Datadir is a config-**delivery** mode: it decides where config comes from, not whether usage is reported. The gate is now the SDK key alone, which is what identifies the workspace telemetry is attributed to. This aligns sdk-ruby with sdk-node, sdk-go, sdk-python, sdk-java, and sdk-net, and is the inverse of the defect fixed for sdk-go / sdk-python in 1.2.1 (those *over*-sent without a key). No wire-format change, no new dependencies, no public API removed.
|
|
34
|
+
- **Fix (telemetry): a keyless client now collects nothing at all.** Without an SDK key there is no workspace to attribute telemetry to, so the aggregators are no longer even allocated and the reporter never starts (previously the collectors filled up in delivery mode and were discarded at flush time). Behavior for the open-source / no-account path is unchanged from the outside — still zero telemetry HTTP — but nothing is accumulated in memory on the way there.
|
|
35
|
+
- **Deprecated: `allow_telemetry_in_local_mode:` is now a no-op.** It existed only to re-enable telemetry that datadir mode had suppressed; with mode out of the decision there is nothing left for it to unlock. The kwarg is still accepted so existing callers keep working, and it can neither enable telemetry without a key nor suppress it with one. Slated for removal in 2.0.0 — use `collect_evaluation_summaries: false` / `context_upload_mode: :none` to opt out.
|
|
36
|
+
- **Docs:** the README's datadir section previously claimed the mode "performs no network I/O" unconditionally. It now states the delivery-side guarantee precisely (no config fetch, no SSE, no polling) and documents the telemetry gate, including how to run datadir + key with telemetry off.
|
|
37
|
+
|
|
3
38
|
## 1.2.0 - 2026-07-08
|
|
4
39
|
|
|
5
40
|
- **Feat (options): warn when an explicit `api_urls` disables automatic failover (qfg-41nh.26).** The default (and every `QUONFIG_DOMAIN`-derived) `api_urls` list carries a primary AND a secondary leg, and the HTTP config-fetch hedges/fails over between them (the secondary runs on separate infrastructure). An explicit `api_urls:` replaces that list wholesale, so a single-entry override silently dropped the secondary and disabled automatic failover. The client now logs one WARN at init when the caller explicitly set `api_urls` and the resolved list has fewer than two legs, pointing at the fix (pass both a primary and a secondary URL). Behavior is otherwise unchanged; no new dependencies. A new README "Failover & `QUONFIG_DOMAIN`" section documents the URL derivation and the failover model.
|
data/README.md
CHANGED
|
@@ -86,7 +86,8 @@ bound.get_int('rate-limit')
|
|
|
86
86
|
|
|
87
87
|
For tests, CI, or air-gapped environments, point the client at a local workspace
|
|
88
88
|
directory instead of the Quonfig API. In datadir mode the SDK loads JSON config
|
|
89
|
-
files from disk
|
|
89
|
+
files from disk — config delivery does no network I/O at all: no config fetch,
|
|
90
|
+
no SSE stream, no polling.
|
|
90
91
|
|
|
91
92
|
```ruby
|
|
92
93
|
client = Quonfig::Client.new(
|
|
@@ -111,6 +112,32 @@ export QUONFIG_ENVIRONMENT=production
|
|
|
111
112
|
client = Quonfig::Client.new # reads QUONFIG_DIR + QUONFIG_ENVIRONMENT
|
|
112
113
|
```
|
|
113
114
|
|
|
115
|
+
### Telemetry in datadir mode
|
|
116
|
+
|
|
117
|
+
Usage telemetry is gated on **SDK-key presence, not on mode**. A datadir client
|
|
118
|
+
with an `sdk_key:` configured still reports evaluation summaries and context
|
|
119
|
+
telemetry to the telemetry service, exactly as a delivery-mode client does —
|
|
120
|
+
that combination is what makes flag usage visible in the Quonfig UI for services
|
|
121
|
+
that read config from a checked-out workspace.
|
|
122
|
+
|
|
123
|
+
A datadir client with **no** SDK key has no workspace to attribute telemetry to,
|
|
124
|
+
so it collects and sends nothing: fully offline, zero network I/O.
|
|
125
|
+
|
|
126
|
+
To run with a key but without telemetry, use the standard opt-outs:
|
|
127
|
+
|
|
128
|
+
```ruby
|
|
129
|
+
client = Quonfig::Client.new(
|
|
130
|
+
datadir: '/path/to/workspace',
|
|
131
|
+
environment: 'production',
|
|
132
|
+
sdk_key: ENV['QUONFIG_BACKEND_SDK_KEY'],
|
|
133
|
+
collect_evaluation_summaries: false,
|
|
134
|
+
context_upload_mode: :none
|
|
135
|
+
)
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
> Changed in 1.3.0: before 1.3.0 a datadir sent nothing even with a valid SDK
|
|
139
|
+
> key. See the CHANGELOG.
|
|
140
|
+
|
|
114
141
|
## Datadir mode: auto-reload on file changes
|
|
115
142
|
|
|
116
143
|
In datadir mode the SDK loads the workspace once at construction time and then
|
|
@@ -183,16 +210,16 @@ Default is `false`; datadir mode is silent until you opt in.
|
|
|
183
210
|
|
|
184
211
|
The auto-reload watcher uses a background thread, which — like any Ruby
|
|
185
212
|
thread — does not survive `fork(2)`. **You do not need to wire this up
|
|
186
|
-
manually on Ruby 3.1+.**
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
Resque, Spring, and manual
|
|
213
|
+
manually on Ruby 3.1+.** After a fork, the child re-loads the workspace from
|
|
214
|
+
disk and registers a fresh watcher on its first use of the client (see [Rails
|
|
215
|
+
integration](#rails-integration) below); a child that never uses the client
|
|
216
|
+
starts no watcher at all. The parent's watcher is left alone and keeps
|
|
217
|
+
working. This covers Puma clustered mode, Unicorn, Resque, Spring, and manual
|
|
218
|
+
`fork { ... }` calls — including a `fork` inside a Sidekiq job.
|
|
191
219
|
|
|
192
|
-
On Ruby 3.0 (no `Process._fork`), follow the manual `
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
watcher, in the child.
|
|
220
|
+
On Ruby 3.0 (no `Process._fork`), follow the manual `on_worker_boot` pattern
|
|
221
|
+
in the [Rails integration](#rails-integration) section — `Quonfig.fork`
|
|
222
|
+
rebuilds the full client, including the datadir watcher, in the child.
|
|
196
223
|
|
|
197
224
|
### Tuning the debounce window
|
|
198
225
|
|
|
@@ -387,16 +414,41 @@ If both are supplied, the explicit `logger_name:` wins.
|
|
|
387
414
|
|
|
388
415
|
## Rails integration
|
|
389
416
|
|
|
390
|
-
The SDK runs a background SSE thread (and optional polling thread)
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
the child silently stops receiving live updates.
|
|
417
|
+
The SDK runs a background SSE thread (and optional polling thread). Ruby
|
|
418
|
+
threads do not survive `fork(2)`, so a child process inherits references to
|
|
419
|
+
threads that no longer exist and silently stops receiving live updates.
|
|
394
420
|
|
|
395
421
|
**On Ruby 3.1+ the SDK installs a `Process._fork` hook at load time** that
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
422
|
+
handles this for you. It covers any `Process.fork` / `Kernel#fork` path —
|
|
423
|
+
Puma's clustered mode, Unicorn, Spring, Resque, a `fork { ... }` inside a
|
|
424
|
+
Sidekiq job, and the `parallel` gem. **No customer wiring is required.**
|
|
425
|
+
|
|
426
|
+
**The hook is child-only. A fork never touches the process that forked.**
|
|
427
|
+
The parent keeps its SSE stream, its poller, its telemetry reporter, and its
|
|
428
|
+
live config straight through any number of forks — so a long-lived process
|
|
429
|
+
that forks workers *and keeps evaluating* (a Sidekiq process using the
|
|
430
|
+
`parallel` gem, a rake task that calls `fork`) stays current.
|
|
431
|
+
In the child, the SDK drops the inherited references without touching the
|
|
432
|
+
objects — it never closes the inherited socket, because `fork(2)` duplicates
|
|
433
|
+
the file descriptor and closing the child's copy of a TLS connection would
|
|
434
|
+
tear down the stream the **parent** is still using.
|
|
435
|
+
|
|
436
|
+
> **Upgrading from 1.3.0 or earlier:** if you added a manual
|
|
437
|
+
> `Quonfig.instance.after_fork_in_child` call **in the parent** as a
|
|
438
|
+
> workaround for the parent going dark, remove it. As of 1.4.0 that call is a
|
|
439
|
+
> no-op in the process that owns the client — the SDK decides that by
|
|
440
|
+
> comparing the current pid against the one it stamped when the client was
|
|
441
|
+
> built, so it is exact whether or not the parent has any threads running. It
|
|
442
|
+
> will not hurt you, but it is no longer doing anything, and the parent needs
|
|
443
|
+
> no call.
|
|
444
|
+
|
|
445
|
+
**After a fork, the child re-initializes on its first use of the client,
|
|
446
|
+
exactly like a newly constructed client — including its `on_init_failure`
|
|
447
|
+
policy: it fetches its own config and starts its own threads. It does not
|
|
448
|
+
evaluate from the parent's snapshot.** The hook itself does no I/O — it drops
|
|
449
|
+
what the child inherited and arms the re-initialization. So the first call in
|
|
450
|
+
a forked child pays one fetch, and a child that never uses the client costs
|
|
451
|
+
nothing: no fetch, no stream, no thread, no telemetry.
|
|
400
452
|
|
|
401
453
|
Caveats:
|
|
402
454
|
|
|
@@ -404,13 +456,44 @@ Caveats:
|
|
|
404
456
|
- `system("fork-and-exec ...")` and `Process.spawn` are not covered (they do
|
|
405
457
|
not go through `Process._fork`), but those execute a new program, so the
|
|
406
458
|
in-process SSE state is moot.
|
|
407
|
-
- The
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
the
|
|
459
|
+
- The first lookup in a forked child **blocks** on that child's own config
|
|
460
|
+
fetch, under the same `init_timeout_ms` and `on_init_failure` options a
|
|
461
|
+
fresh client uses. **The default is `on_init_failure: :raise`**, so if the
|
|
462
|
+
child's fetch fails against every `api_urls` leg (primary and secondary
|
|
463
|
+
both unreachable) the failure **raises out of that first lookup**, exactly
|
|
464
|
+
as `Client.new` would at boot, and later lookups keep raising — without
|
|
465
|
+
re-fetching — until the update channel lands an envelope, at which point
|
|
466
|
+
the client serves config normally again. On 1.3.0 and earlier a child in
|
|
467
|
+
that situation silently served the parent's snapshot instead. If you would
|
|
468
|
+
rather a forked child serve defaults through an outage, set
|
|
469
|
+
`on_init_failure: :return`: a failed fetch then logs one line and the child
|
|
470
|
+
serves defaults until its stream or poller lands the first envelope.
|
|
471
|
+
- **Other threads wait.** Every thread that reaches the client while that
|
|
472
|
+
first fetch is in flight blocks on it and then sees the fetched config. One
|
|
473
|
+
fetch, one stream dial, and one telemetry reporter per child, however many
|
|
474
|
+
threads race the first request.
|
|
475
|
+
- **`connection_state` never triggers the re-initialization** — a diagnostic
|
|
476
|
+
must not open a socket. A child that has not used the client yet answers
|
|
477
|
+
`:initializing`, which is exactly what it is; it flips to `:connected` on
|
|
478
|
+
first use.
|
|
479
|
+
- The child's telemetry aggregators start empty. The parent flushes the data
|
|
480
|
+
it collected before the fork; the child reports only its own.
|
|
481
|
+
- **Per-job forking pays per job.** A Resque-style worker that forks a child
|
|
482
|
+
per job (or `Parallel.map` with one row per process) pays, in each child
|
|
483
|
+
that touches the client, one config fetch, one SSE dial, and — **when the
|
|
484
|
+
child exits normally** — one telemetry POST at exit. That is the price of
|
|
485
|
+
the child holding its own current config and its own telemetry window, and
|
|
486
|
+
it is deliberate — the delivery service counts each of those connections as
|
|
487
|
+
a real client. A child that never uses the client pays none of it.
|
|
488
|
+
|
|
489
|
+
The at-exit drain depends on the child running `at_exit` handlers at all.
|
|
490
|
+
`Parallel` children do. **Resque children call `exit!` by default**, which
|
|
491
|
+
skips every `at_exit` handler — so there is no drain and no telemetry POST
|
|
492
|
+
unless you set `RUN_AT_EXIT_HOOKS=1`. Nothing else about the child changes;
|
|
493
|
+
it just never flushes the evaluations it collected.
|
|
494
|
+
- In datadir mode a child whose workspace fails to load never dials the
|
|
495
|
+
network: it logs the failure and, if `data_dir_auto_reload` is on, watches
|
|
496
|
+
for a repaired workspace; otherwise the next use retries the load.
|
|
414
497
|
|
|
415
498
|
### Puma (clustered mode)
|
|
416
499
|
|
|
@@ -423,52 +506,72 @@ handle the rest:
|
|
|
423
506
|
Quonfig.init(Quonfig::Options.new(sdk_key: ENV.fetch('QUONFIG_BACKEND_SDK_KEY')))
|
|
424
507
|
```
|
|
425
508
|
|
|
426
|
-
If you
|
|
509
|
+
If you use SemanticLogger you still need to reopen it in each worker — but
|
|
510
|
+
`Quonfig.fork` is not needed in that block on 3.1+. The SDK has already
|
|
511
|
+
handled the fork by the time `on_worker_boot` runs, and since 1.4.0 a
|
|
512
|
+
`Quonfig.fork` call in a child the hook has already prepared simply returns
|
|
513
|
+
the same client (so a leftover call from older docs is harmless):
|
|
427
514
|
|
|
428
515
|
```ruby
|
|
429
|
-
# config/puma.rb (Ruby 3.
|
|
430
|
-
|
|
431
|
-
|
|
516
|
+
# config/puma.rb (Ruby 3.1+)
|
|
517
|
+
on_worker_boot do
|
|
518
|
+
SemanticLogger.reopen
|
|
432
519
|
end
|
|
520
|
+
```
|
|
521
|
+
|
|
522
|
+
If you're on Ruby 3.0 (no `Process._fork`), wire the worker boot hook
|
|
523
|
+
manually:
|
|
433
524
|
|
|
525
|
+
```ruby
|
|
526
|
+
# config/puma.rb (Ruby 3.0 only)
|
|
434
527
|
on_worker_boot do
|
|
435
|
-
Quonfig.fork
|
|
528
|
+
Quonfig.fork # rebuild a fresh client per worker
|
|
529
|
+
SemanticLogger.reopen # if you use SemanticLogger
|
|
436
530
|
end
|
|
437
531
|
```
|
|
438
532
|
|
|
533
|
+
Do **not** add a `before_fork { Quonfig.instance.stop }` — the master's
|
|
534
|
+
client does not need to be torn down for the workers to be healthy, and
|
|
535
|
+
stopping it means the master stops receiving config.
|
|
536
|
+
|
|
439
537
|
### Sidekiq
|
|
440
538
|
|
|
441
|
-
|
|
442
|
-
`
|
|
539
|
+
Sidekiq OSS does not fork: it runs jobs on threads inside one process, so
|
|
540
|
+
`Quonfig.init` in your initializer is all you need on any Ruby version.
|
|
443
541
|
|
|
444
|
-
|
|
542
|
+
Some jobs *do* fork — the `parallel` gem, an explicit `fork { ... }`, or
|
|
543
|
+
Sidekiq Enterprise's swarm mode. On Ruby 3.1+ those are covered
|
|
544
|
+
automatically, with nothing to call, and (since 1.4.0) the Sidekiq process
|
|
545
|
+
itself keeps streaming config the whole time.
|
|
445
546
|
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
Quonfig.
|
|
547
|
+
Ruby 3.0 is end-of-life and has no `Process._fork` hook. The `parallel` gem
|
|
548
|
+
has no per-worker boot hook to wire a rebuild into either — `Parallel.each`
|
|
549
|
+
just runs your block in each child, once per row — so calling `Quonfig.fork`
|
|
550
|
+
at the top of the block builds a **new client per row**, each with its own
|
|
551
|
+
SSE stream and telemetry reporter. Upgrade to 3.1+ if you can. If you must
|
|
552
|
+
stay on 3.0, rebuild once per child process by memoizing on the pid:
|
|
449
553
|
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
554
|
+
```ruby
|
|
555
|
+
# Ruby 3.0 only — one rebuild per child process, not one per row.
|
|
556
|
+
Parallel.each(batch, in_processes: 4) do |row|
|
|
557
|
+
Quonfig.fork if $quonfig_pid != Process.pid
|
|
558
|
+
$quonfig_pid = Process.pid
|
|
559
|
+
# ...
|
|
454
560
|
end
|
|
455
561
|
```
|
|
456
562
|
|
|
457
|
-
For Sidekiq web/CLI processes that don't fork (default `concurrency: 1`),
|
|
458
|
-
`Quonfig.init` in the initializer is sufficient on any Ruby version.
|
|
459
|
-
|
|
460
563
|
### Spring / Bootsnap preloaders
|
|
461
564
|
|
|
462
|
-
Spring forks the preloader for each command.
|
|
463
|
-
|
|
464
|
-
|
|
565
|
+
Spring forks the preloader for each command. On Ruby 3.1+ the automatic hook
|
|
566
|
+
already rebuilds the client in each spawned command, and the preloader itself
|
|
567
|
+
keeps streaming. On Ruby 3.0, either:
|
|
465
568
|
|
|
466
569
|
1. **Recommended:** initialize lazily — wrap `Quonfig.init` so it only runs
|
|
467
570
|
the first time `Quonfig.instance` is called from a non-preloader process.
|
|
468
571
|
2. **Or:** call `Quonfig.fork` from a `Spring.after_fork` hook.
|
|
469
572
|
|
|
470
573
|
```ruby
|
|
471
|
-
# config/spring.rb
|
|
574
|
+
# config/spring.rb (Ruby 3.0 only)
|
|
472
575
|
Spring.after_fork do
|
|
473
576
|
Quonfig.fork if defined?(Quonfig) && Quonfig.instance_variable_get(:@singleton)
|
|
474
577
|
end
|
|
@@ -501,8 +604,10 @@ envelope is intentional: a reader concurrent with envelope application may
|
|
|
501
604
|
observe the new value for some keys and the old value for others, then
|
|
502
605
|
converge once the envelope finishes applying.
|
|
503
606
|
|
|
504
|
-
|
|
505
|
-
|
|
607
|
+
Forking is handled for you on Ruby 3.1+: the child rebuilds automatically and
|
|
608
|
+
the parent is left running (see [Rails integration](#rails-integration)). On
|
|
609
|
+
Ruby 3.0, `Quonfig.fork` is the way to "carry" a client into a child — do not
|
|
610
|
+
reuse the parent's client object in a child process without it.
|
|
506
611
|
|
|
507
612
|
## Diagnostic health signals
|
|
508
613
|
|