quonfig 1.4.0 → 1.4.1

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: 5e8807ad54199b5ee24245cdf140a72aed0644e712308d867457d729df1bb122
4
- data.tar.gz: 20b1d12fb441394f4d31198854b55dd90f78c925115982a32548415993c77958
3
+ metadata.gz: 8ecc1b6f2a6c327c36d7da4cf091623b8c44b81d6949b887356301961229a684
4
+ data.tar.gz: bf357130f83dc29baf032c8dd0db652c8910797aac2fc6a499ec3461e5b56ff6
5
5
  SHA512:
6
- metadata.gz: cda8d4a8d332710a58f3be0e1e068e767858c9806f199bc23a5a2f010e7540d4fd9a159b20ac23f9d34f1a7f60b5aac10fcffdeda030356ace8536779048126a
7
- data.tar.gz: 33dcc1b5201298630936e743f5e44b188393b810ee94287623dce7598b98ab68ee0a2eb8bddb70f92da2fb3419f6a0b7f2a3081e2510b7f2d144087765b541b6
6
+ metadata.gz: '08951e8e2261dce91f0308de7920ea46eef5077979efdd0bd8df55b403719d8fb1f2e7f6ed863a12d38108b30cdef8b92d08832059a68c45b96425a5ced249f6'
7
+ data.tar.gz: 81738455b310e66693801fe2b2aa3ff027e676b28b032398271d1de17c9421212d5e0c9d3b20c37164a7299c74f2347a52f371abfabc36d8ebca3c75492d17d0
data/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.4.1 - 2026-09-11
4
+
5
+ - **Fix (fork/telemetry): a forked child now reports under its OWN `instanceHash` (qfg-xcym).** `@instance_hash` was minted once in `Client#initialize` and survived `fork(2)`, so every forked child POSTed its telemetry under the **parent's** hash. The Quonfig Debugger groups SDK last-seen by that hash, so an 8-worker Puma cluster (or a `parallel`-gem batch) collapsed into a single instance row with the parent's and the children's windows interleaved on top of each other. A child now mints a fresh hash as part of the same post-fork rebuild that gives it fresh aggregators and a fresh reporter — before the reporter is constructed, so the hash the child POSTs under is its own. This is parity with Reforge, where a forked process simply builds a whole new client. The parent's hash is never touched, and a child's row count is unchanged (it already reported its own window; it just did so under the wrong identity). Present since 0.0.16.
6
+ - **Fix (telemetry): only a STRICTLY older payload counts as `guardRejected` (qfg-rr5b).** The reject-older install guard treated an **equal**-generation payload as "older" and counted it, but two server behaviors re-deliver the envelope a client already holds in perfectly normal steady state: api-delivery's SSE `sendInitialConfig` re-sends the current envelope on every connect regardless of the `Last-Event-Id` the client sends, and a config poll against an empty per-leg ETag slot — a fresh transport, a reconnect, the fallback poller's engage fetch — answers with a full 200 at the same generation. A healthy client therefore reported `guardRejected >= 1` from initialization alone and picked up another on every SSE reconnect, which is noise in a field whose entire purpose is to say "a leg tried to move us **backwards**". **Semantic narrowing worth noting if you query this field: equal-generation re-deliveries are no longer counted, so `guardRejected` will read lower than on 1.4.0 for the same traffic — that is the fix, not a regression.** Nothing else about the guard changed: an equal-generation envelope is still not installed, still returns `:not_modified`, and still advances the liveness stamp exactly where it did before, and the unversioned (`generation <= 0`) carve-out is untouched. No wire, ClickHouse, or dashboard-query change is required. This is the Ruby half of a change being applied across all six backend SDKs (sdk-python ships it in 1.4.1; Go, Java, .NET and Node follow in their next releases), so counts may differ across languages until then.
7
+ - **Docs (fork): a child forked from inside an `on_update` callback must exit (qfg-ec2q).** `on_update` runs on the SDK's SSE reader thread, so a child created with non-block `fork` (no block) from inside that callback inherits that thread as its one surviving thread. If the child does its work and *returns* from the callback instead of exiting, the inherited reader resumes its read loop on the **parent's** socket and consumes frames the parent never sees. The README fork section now says to `exit!` (or `exec`) from such a child. Block-form `fork { ... }`, any child that exits, the documented patterns, and the `parallel` gem are all unaffected — no code change.
8
+
3
9
  ## 1.4.0 - 2026-09-10
4
10
 
5
11
  > **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`.
data/README.md CHANGED
@@ -494,6 +494,13 @@ Caveats:
494
494
  - In datadir mode a child whose workspace fails to load never dials the
495
495
  network: it logs the failure and, if `data_dir_auto_reload` is on, watches
496
496
  for a repaired workspace; otherwise the next use retries the load.
497
+ - **A child forked from inside an `on_update` callback must exit.**
498
+ `on_update` runs on the SDK's SSE reader thread, so a child created with
499
+ non-block `fork` (no block) from inside that callback must `exit!` (or
500
+ `exec`) rather than return from the callback — a child that returns lets the
501
+ inherited reader thread resume on the parent's socket, where it consumes
502
+ frames the parent never sees. Block-form `fork { ... }` and any child that
503
+ exits are unaffected.
497
504
 
498
505
  ### Puma (clustered mode)
499
506
 
@@ -437,6 +437,18 @@ module Quonfig
437
437
  # fetches (or loads) its own on first use.
438
438
  reset_store_in_child!
439
439
 
440
+ # A fresh instance hash (qfg-xcym). `@instance_hash` identifies this
441
+ # SDK instance in every telemetry payload, and app-quonfig's Debugger
442
+ # groups SDK last-seen by it — so a child that keeps the parent's hash
443
+ # collapses an 8-worker Puma cluster into ONE row with the parent's and
444
+ # the children's windows interleaved. Reforge does not have this
445
+ # problem: `Reforge.fork` builds a whole new Client, which mints its
446
+ # own. This MUST run before +rebuild_aggregators_in_child!+: the
447
+ # reporter captures the hash at construction, so minting after the
448
+ # rebuild would leave the child POSTing under the parent's identity.
449
+ # The parent's hash is untouched — the hook never runs there.
450
+ @instance_hash = SecureRandom.uuid
451
+
440
452
  # Fresh aggregators. The parent flushes its own copy; a child that
441
453
  # flushed inherited data would double-report it. The reporter is BUILT
442
454
  # here (so the child's config loader points at the child's failover
@@ -392,11 +392,29 @@ module Quonfig
392
392
  # ordering info, so it is never rejected as "older"; freezing the client
393
393
  # on stale config would be worse (mirrors sdk-node).
394
394
  unless @held_generation.nil? || incoming_gen <= 0 || incoming_gen > @held_generation
395
- @logger.debug "Reject-older guard: dropping incoming generation #{incoming_gen} <= held #{@held_generation} (source=#{source})"
396
- # Failover observability (qfg-41nh.18): count the guard rejection. This
397
- # single guard covers BOTH the HTTP config-fetch path and the SSE
398
- # message path (apply_envelope) every network install funnels here.
399
- @failover_aggregator&.record_guard_rejected
395
+ if incoming_gen < @held_generation
396
+ @logger.debug "Reject-older guard: dropping incoming generation #{incoming_gen} < held #{@held_generation} (source=#{source})"
397
+ # Failover observability (qfg-41nh.18): count the guard rejection.
398
+ # This single guard covers BOTH the HTTP config-fetch path and the
399
+ # SSE message path (apply_envelope) — every network install funnels
400
+ # here. Only a STRICTLY older payload is counted (qfg-rr5b): that is
401
+ # the one thing `guardRejected` is meant to report, "a leg tried to
402
+ # move us backwards", which is what the sdk_failover dashboard panel
403
+ # is for.
404
+ @failover_aggregator&.record_guard_rejected
405
+ else
406
+ # Equal generation: a re-delivery of the envelope we already hold, and
407
+ # a silent no-op (qfg-rr5b). Two server behaviors produce it in normal
408
+ # steady state — api-delivery's SSE `sendInitialConfig` re-sends the
409
+ # current envelope on every connect regardless of the Last-Event-Id
410
+ # we send, and a config poll on an empty per-leg ETag slot (a
411
+ # fresh transport, a reconnect, the fallback poller's engage fetch)
412
+ # returns a full 200 at the same generation. Counting those as
413
+ # `guardRejected` made a healthy client report failover activity from
414
+ # init alone. Not installed, not counted — but still :not_modified, so
415
+ # the caller's liveness stamp is unchanged.
416
+ @logger.debug "Same-generation re-delivery: ignoring incoming generation #{incoming_gen} (source=#{source})"
417
+ end
400
418
  return :not_modified
401
419
  end
402
420
 
@@ -45,8 +45,10 @@ module Quonfig
45
45
  end
46
46
  end
47
47
 
48
- # Count one install dropped by the reject-older ordering guard (an
49
- # equal-or-older snapshot on any install path HTTP config-fetch or SSE).
48
+ # Count one install dropped by the reject-older ordering guard because it
49
+ # was STRICTLY older than the held generation, on any install path (HTTP
50
+ # config-fetch or SSE). An equal-generation re-delivery is dropped too but
51
+ # is not counted (qfg-rr5b).
50
52
  def record_guard_rejected
51
53
  @mutex.synchronize do
52
54
  @start_at_ms ||= Quonfig::TimeHelpers.now_in_ms
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Quonfig
4
- VERSION = '1.4.0'
4
+ VERSION = '1.4.1'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: quonfig
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.4.0
4
+ version: 1.4.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jeff Dwyer