quonfig 1.7.2 → 1.8.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 +26 -0
- data/README.md +24 -0
- data/lib/quonfig/client.rb +146 -42
- data/lib/quonfig/config_loader.rb +129 -26
- data/lib/quonfig/errors/env_var_parse_error.rb +10 -3
- data/lib/quonfig/evaluator.rb +72 -14
- data/lib/quonfig/resolver.rb +22 -7
- data/lib/quonfig/semver.rb +11 -1
- data/lib/quonfig/sse_config_client.rb +101 -54
- data/lib/quonfig/stdlib_formatter.rb +12 -1
- data/lib/quonfig/telemetry/example_contexts_aggregator.rb +55 -1
- data/lib/quonfig/version.rb +1 -1
- data/lib/quonfig/waits.rb +30 -0
- data/lib/quonfig/worker_supervisor.rb +5 -0
- data/lib/quonfig.rb +1 -0
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c41b5b972edb95e3718fc1b46f693cf7a1800900e9228c9fd71542b6a775a001
|
|
4
|
+
data.tar.gz: c0d0f5f0c25d44b3d580285d0459a73e829827744c89cb5d8d4ab584fb13bc9a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b215ffbd82dc9bc5aef5211a11e8072ab5556637106dc85c65de50652c81849578b4b738d25d0fd4f0113d12ec39ed7aedd0f71bcf461646499e9b50b6001ede
|
|
7
|
+
data.tar.gz: 16836a6666e7e6648baa99f73ad4bf9c491638ad9e31d67a66821040f004ba4382ce6d7532fe40674f40c88ab0ccb467f94641b329e4ac5387590647df5be97f
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,31 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.8.0 - 2026-10-16
|
|
4
|
+
|
|
5
|
+
- **UPGRADE: retry cadence and log levels change after a rejected 401/403/404/410 fetch cycle; a revoked key logs one config ERROR. `terminal_failure?` changes meaning. Minor (1.8.0): one auth-failure rule across the SDKs (qfg-goi1.10.26). This reverses the qfg-i5xv latch.**
|
|
6
|
+
- **No latch.** Before, the first SSE 401, 403 or 404 stopped the stream for the life of the client and kept the fallback poller off, so a key that was rejected once (or a transient primary 401) left the client on stale config until it was rebuilt. Now an auth-class stream response (401, 403, 404, and now 410) keeps the normal reconnect ladder, and the fallback poller engages on its usual schedule (at once if SSE never connected, 2x the poll interval after a mid-life drop).
|
|
7
|
+
- **Auth-failed.** A poll cycle (the fallback poller's, or each poll with `enable_sse: false`) that gets no 200 or 304 on any leg and a 401/403/404/410 on the primary leg puts the client in auth-failed. Primary rejected + secondary 503 or timeout enters; primary 503 + secondary rejected does not. While auth-failed: no stream, no polling, one fetch probe across every leg every 150-300s (with `enable_sse: false`, the larger of that and the poll interval), with no doubling and no give-up. The first probe that gets a valid 200 or a 304 on any leg installs it, logs one INFO and resumes streaming (from its base backoff) and polling. `connection_state` is `:disconnected` while auth-failed, never `:falling_back`.
|
|
8
|
+
- **Logging.** On entry: one ERROR, `config fetch rejected (HTTP <status> from <host:port>) ...`, naming the status and host, never the key, in place of that cycle's per-leg lines. Probes log at DEBUG. Before entry, an SSE 401/403/404/410 logs one WARN per status (re-armed by a 200) and DEBUG after that; before, a 401/403/404 logged one ERROR and a 410 logged two ERRORs per attempt. Other SSE errors (5xx) log as before.
|
|
9
|
+
- **`terminal_failure?` now means "auth-failed right now".** It turns true when a poll cycle is rejected as above (about 120s after a mid-life stream drop, at once if SSE never connected), not on the first SSE 401, and it goes back to false when a probe is accepted. Code that rebuilt the client when it saw `true` can stop doing so; the client recovers by itself. Name, signature and return type are unchanged.
|
|
10
|
+
- `SSEConfigClient::SSEHTTPTerminalError` and `TERMINAL_HTTP_CODES` are deprecated aliases of the new `SSEHTTPAuthError` and `AUTH_HTTP_CODES` (`[401, 403, 404, 410]`).
|
|
11
|
+
- Tests: supervisor contract Test 8 (a)-(g) (integration-test-data `v2026.10.16`, already contained in the pinned `v2026.10.16.1`) in `test/test_supervisor_auth_failure.rb`, plus the repro8 regression (a revoked key on every leg: one poll cycle, then probes, then recovery).
|
|
12
|
+
- **UPGRADE: may change who matches a rule. Minor: one set of rule-operator semantics across the SDKs (qfg-goi1.5.7).** Pass dates to `PROP_BEFORE` / `PROP_AFTER` rules as epoch milliseconds (an Integer, a Float, or an all-digit string) or as an ISO 8601 / RFC3339 timestamp with `Z` or a numeric offset, such as `2025-01-01T00:00:00Z` or `2025-01-01T09:00:00+09:00`. Three changes:
|
|
13
|
+
- **Dates are strict on both sides.** The context value and the rule's date are now read with one grammar: epoch ms, or RFC3339 with an uppercase `T` and a `Z` or `+hh:mm` / `-hh:mm` offset (fractional seconds allowed, truncated to ms). `DateTime.parse` is gone, so a date-only string (`"2020-01-01"`), an offset-less one (`"2020-01-01T00:00:00"`, which was read as UTC) and free-form text (`"Jan 1 2020"`) are not dates any more, and a rule comparing them does not match. Digit strings no longer accept `Integer()` extras such as `"0x10"` or `"1_000"`. A `Float::NAN` or `Float::INFINITY` context date no longer raises `FloatDomainError`; it does not match.
|
|
14
|
+
- **Whole-number floats compare as plain digits.** In `PROP_IS_ONE_OF` / `PROP_IS_NOT_ONE_OF`, the starts-with / ends-with / contains operators and `HIERARCHICAL_MATCH`, a context value of `1.0`, `1234567.0` or `5e9` now reads as `"1"`, `"1234567"`, `"5000000000"` (it was `"1.0"`, `"1234567.0"`, `"5.0e9"`), so `is one of ["1234567"]` matches it and a list holding `"1.0"` no longer does. Non-integral floats (`1.5`) and Integers are unchanged; booleans still read `"true"` / `"false"`.
|
|
15
|
+
- **Abbreviated versions.** `PROP_SEMVER_*` reads `"2"` as `2.0.0` and `"2.0"` / `"4.1"` as `2.0.0` / `4.1.0`, on the context and the rule side, so an app reporting `"4.1"` now matches a `4.1.0` rule. A prerelease or build suffix still needs the full `x.y.z`, and a `v` prefix is still not a version. `SemanticVersion.parse` itself stays strict.
|
|
16
|
+
Matches integration-test-data `v2026.10.16.1` and the other SDKs.
|
|
17
|
+
- **UPGRADE: may change returned values. Minor: JSON values are returned as a fresh copy on every call (qfg-goi1.10.3).** `get`, `get_json` and `get_json_details` (also on `BoundClient`) used to return the SDK's own Hash or Array for a JSON config. If your code changed the result (for example added a key or appended to a list), every later read in the process saw the change. Each call now returns its own copy of the Hashes and Arrays, so changes stay local. What changes for you: two calls no longer return the same object (`equal?` is false; `==` is still true), and code that relied on mutating the result to change config no longer does. Values are not frozen, so code that mutates its own copy keeps working. Strings are not copied: `get_string` and the strings inside a JSON value are the same objects as before. Matches sdk-go, sdk-net and sdk-java.
|
|
18
|
+
- **CI: unit and release tests now run integration-test-data `v2026.10.16.1` (qfg-goi1.5.7).** It adds the OPERATOR EDGES cases (bool and digit-string one-of, int ranges, prerelease and abbreviated semver, strict dates). The chaos workflows stay on `v2026.10.03`.
|
|
19
|
+
- **Fix (patch): `EnvVarParseError` messages now use the cross-SDK wording (qfg-goi1.10.21).** An ENV_VAR value that cannot be converted to the config's type now raises with `environment variable "<NAME>" cannot be converted to <type> for config "<key>"` (for example `... cannot be converted to int for config "my.key"`), the template pinned by integration-test-data `v2026.10.12`. It was `Evaluating <key> couldn't coerce <NAME> to <type>`. A malformed stored duration reads `stored value cannot be converted to duration for config "<key>"`. The value is still never in the message. Only the message text changes; the exception class and when it is raised are unchanged.
|
|
20
|
+
- **CI: unit, contract and release tests now run integration-test-data `v2026.10.12` (qfg-goi1.10.21).** It adds the coercion-message cases above, confidential weighted-variant and ENV_VAR telemetry redaction cases, and an env-scoped decryption key case. The chaos workflows stay on `v2026.10.03`.
|
|
21
|
+
- **Docs: the README now says that the SDK adds the `qfg login` email to every evaluation as `quonfig-user.email` by default, and how to turn it off (`enable_quonfig_user_context: false` or `QUONFIG_DEV_CONTEXT=false`) (qfg-goi1.2.40).** No code change.
|
|
22
|
+
- **Fix (security, patch): a `confidential` or `decryptWith` variant inside a weighted rollout is now redacted in telemetry (qfg-goi1.2.39).** The evaluation-summary redaction looked only at the outer `weighted_values` wrapper, which is never flagged, so the selected variant went out as `selectedValue` in plaintext (for `decryptWith`, the decrypted secret). The selected variant is now sent as `*****<md5[0,5]>` over its stored value (the ciphertext for `decryptWith`), the same as a non-rollout secret and the same as sdk-go, sdk-node and sdk-java.
|
|
23
|
+
- **Fix (patch): `Client#stdlib_formatter` no longer raises out of the host app's `::Logger` calls (qfg-goi1.2.28).** `Client#get` raises on a resolution error (for example `MissingEnvVarError` when the log level is provided by an env var that is unset on this host, or `DecryptionError`) even when a default is passed, and the formatter let that escape, so every `logger.info` raised. The formatter now emits the line in that case, the same as `SemanticLoggerFilter` since 1.7.2. `Client#should_log?` itself is unchanged.
|
|
24
|
+
- **Fix (patch): example-context telemetry now copies the context when it is recorded (qfg-goi1.2.35).** The SDK kept the caller's nested Hashes and Arrays by reference and serialized them later on the telemetry thread, so changing them after `get` changed what was sent. A `Float::NAN` or `Float::INFINITY`, or a Hash or Array that contains itself, made the whole telemetry window fail to serialize, so that window's evaluation summaries, context shapes and example contexts were all lost. Now, at record time: nested Hashes and Arrays are copied (keys as strings, up to 32 levels deep), strings are copied, symbols become strings, NaN and Infinity become `null`, the edge that closes a cycle is left out, and any other object is sent as its `to_s`, as before (so a UUID-like key object still makes the example reportable). Only a value whose `to_s` raises is left out. Context shapes are unchanged. Evaluation is unchanged.
|
|
25
|
+
- **Tests: the datadir auto-reload burst test no longer sleeps between writes (qfg-goi1.2.29).** A 10ms sleep between five rewrites could outlast the 150ms debounce on a starved CI runner, so the test saw more than one reload (it failed once on Ruby 3.2 CI). The writes are now back to back, and a new filesystem-free test drives `DatadirWatcher` directly: events in one window fire once, a later event fires again, and `stop` cancels a pending fire. No library change.
|
|
26
|
+
- **Fix (patch): an install interrupted part-way through the store write is now repaired by the next delivery of the same envelope (qfg-goi1.10.2).** The SDK recorded the new config generation before it wrote the configs into the store, so an interrupt during the write (for example `Thread#raise` or `Timeout`) left a partly updated store marked as fully installed, and the same-generation redelivery that follows (SSE re-sends the current envelope on every connect) was ignored as a duplicate. The generation now advances only after the store write.
|
|
27
|
+
- **Fix (patch): the SSE line reader no longer rescans its whole buffer on every chunk (qfg-goi1.10.2).** A long SSE line (a large config envelope) delivered in small chunks cost O(n^2) time: 100,000 one-byte chunks took about 9 seconds. Scanning now resumes where the previous chunk stopped, and the consumed bytes are dropped once per chunk instead of once per line. Parsing results are unchanged; there is still no size cap.
|
|
28
|
+
|
|
3
29
|
## 1.7.2 - 2026-10-09
|
|
4
30
|
|
|
5
31
|
- **Fix (security): `TypeMismatchError` and `EnvVarParseError` messages no longer include the value (qfg-goi1.2.11).** Reading a `confidential` or `decryptWith` config through the wrong typed getter (for example `get_int` on a secret) put the decrypted plaintext into the exception message and into `*_details.error_message`, which end up in logs and error trackers. An unparseable ENV_VAR or a malformed stored duration put the raw value into `EnvVarParseError`. The messages now name the key, the expected type and the actual class (`TypeMismatchError`), or the key, the env var name (or `stored value`) and the target type (`EnvVarParseError`). The doubled word in `expected expected Integer` is fixed. Only the message text changes; the exception classes and when they are raised are unchanged.
|
data/README.md
CHANGED
|
@@ -82,6 +82,16 @@ bound.get_int('rate-limit')
|
|
|
82
82
|
> `in_context` is a deprecated alias of `with_context` kept for backward
|
|
83
83
|
> compatibility through 1.0.0. New code should use `with_context`.
|
|
84
84
|
|
|
85
|
+
### Developer context (on by default)
|
|
86
|
+
|
|
87
|
+
On a machine where someone ran `qfg login`, the SDK reads the signed-in email
|
|
88
|
+
from `~/.quonfig/tokens.json` and adds it to every evaluation as
|
|
89
|
+
`quonfig-user.email`, so rules can target it and it reaches telemetry example
|
|
90
|
+
contexts; pass `enable_quonfig_user_context: false` or set
|
|
91
|
+
`QUONFIG_DEV_CONTEXT=false` to turn it off. Servers without that file are
|
|
92
|
+
unaffected, and a `quonfig-user` context you pass in `global_context:`
|
|
93
|
+
replaces it.
|
|
94
|
+
|
|
85
95
|
## Datadir / offline mode
|
|
86
96
|
|
|
87
97
|
For tests, CI, or air-gapped environments, point the client at a local workspace
|
|
@@ -246,6 +256,7 @@ for the cross-SDK story (sdk-node, sdk-go, sdk-ruby, sdk-python, sdk-java).
|
|
|
246
256
|
| `QUONFIG_BACKEND_SDK_KEY` | SDK key used to authenticate against the Quonfig API. Used when `sdk_key:` is omitted. |
|
|
247
257
|
| `QUONFIG_DIR` | Path to a workspace directory. When set, the SDK runs in datadir/offline mode. |
|
|
248
258
|
| `QUONFIG_ENVIRONMENT` | Environment name (`production`, `staging`, `development`) evaluated in datadir mode. |
|
|
259
|
+
| `QUONFIG_DEV_CONTEXT` | `false` turns off the developer context (`quonfig-user.email` from `qfg login`). An explicit `enable_quonfig_user_context:` wins. See [Developer context](#developer-context-on-by-default). |
|
|
249
260
|
| `QUONFIG_DOMAIN` | Base domain used to derive api, sse, and telemetry URLs. Defaults to `quonfig.com`. Set to `quonfig-staging.com` to point at staging. Explicit `api_urls:` / `telemetry_url:` kwargs override this. |
|
|
250
261
|
|
|
251
262
|
## Constructor options
|
|
@@ -279,6 +290,7 @@ Quonfig::Client.new(
|
|
|
279
290
|
| `init_timeout_ms` | `Integer` (ms) | `10_000` | Maximum time to wait for the initial config load, in milliseconds. Deprecated alias: `initialization_timeout_sec` (seconds, multiplied by 1000 internally). |
|
|
280
291
|
| `on_no_default` | `Symbol` | `:raise` | Behavior when a key has no value and no default: `:raise` (raise `Quonfig::Errors::MissingDefaultError`) or `:return_nil`. |
|
|
281
292
|
| `global_context` | `Hash` | `{}` | Context applied to every evaluation. |
|
|
293
|
+
| `enable_quonfig_user_context` | `Boolean` or `nil` | `nil` (on, unless `QUONFIG_DEV_CONTEXT=false`) | Add the `qfg login` email as `quonfig-user.email`. See [Developer context](#developer-context-on-by-default). |
|
|
282
294
|
| `datadir` | `String` | `ENV['QUONFIG_DIR']` | Path to a local workspace. When set, the SDK runs offline from disk. |
|
|
283
295
|
| `environment` | `String` | `ENV['QUONFIG_ENVIRONMENT']` | Environment to evaluate in datadir mode. Required when `datadir` is set. |
|
|
284
296
|
| `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). |
|
|
@@ -664,6 +676,18 @@ process.
|
|
|
664
676
|
|
|
665
677
|
There is intentionally no `client.healthy?` primitive.
|
|
666
678
|
|
|
679
|
+
### A rejected SDK key
|
|
680
|
+
|
|
681
|
+
When a whole config fetch cycle is rejected (no 200 or 304 on any leg, and a
|
|
682
|
+
401, 403, 404 or 410 on the primary), the SDK key is invalid or revoked. The
|
|
683
|
+
SDK logs one ERROR naming the status and host, stops streaming and polling,
|
|
684
|
+
and retries with one fetch every 150-300 seconds for as long as the process
|
|
685
|
+
runs. The first accepted fetch installs the config, logs one INFO, and resumes
|
|
686
|
+
streaming and polling on its own; you do not need to rebuild the client. While
|
|
687
|
+
this lasts `client.connection_state` is `:disconnected` and
|
|
688
|
+
`client.terminal_failure?` is `true`. A rejection on the stream alone never
|
|
689
|
+
does this: the SDK keeps reconnecting and its fallback poller decides.
|
|
690
|
+
|
|
667
691
|
## Telemetry
|
|
668
692
|
|
|
669
693
|
With an SDK key the client sends usage telemetry to `telemetry_url` so the
|
data/lib/quonfig/client.rb
CHANGED
|
@@ -21,6 +21,10 @@ module Quonfig
|
|
|
21
21
|
class Client
|
|
22
22
|
LOG = Quonfig::InternalLogger.new(self)
|
|
23
23
|
|
|
24
|
+
# Auth-failed probe period bounds, rule R3 (qfg-goi1.10.26).
|
|
25
|
+
AUTH_PROBE_MIN_SECONDS = 150.0
|
|
26
|
+
AUTH_PROBE_MAX_SECONDS = 300.0
|
|
27
|
+
|
|
24
28
|
# qfg-ryov: instance registry for the Process._fork hook. Every live
|
|
25
29
|
# Client is tracked here so the hook can fan out after_fork_in_child
|
|
26
30
|
# across all of them without the customer needing to name a specific
|
|
@@ -86,7 +90,11 @@ module Quonfig
|
|
|
86
90
|
@sse_state = :idle
|
|
87
91
|
@sse_ever_connected = false
|
|
88
92
|
@fallback_engage_timer = nil
|
|
89
|
-
|
|
93
|
+
# Auth-failed regime (rule R2-R4, qfg-goi1.10.26). Set by the poller's
|
|
94
|
+
# fetch cycle on entry, cleared by the first accepted probe. While set:
|
|
95
|
+
# no SSE, no polling, one probe per [150s, 300s]. Read by
|
|
96
|
+
# #terminal_failure? and #connection_state.
|
|
97
|
+
@auth_failed = false
|
|
90
98
|
# The process that owns this client's threads, sockets, and store.
|
|
91
99
|
# Re-stamped when a child takes ownership in #rebuild_in_child!. A pid
|
|
92
100
|
# mismatch is PROOF that we are looking at a fork(2) child, which is
|
|
@@ -493,7 +501,7 @@ module Quonfig
|
|
|
493
501
|
# (it had connected, it had errored, ...). None of them apply here.
|
|
494
502
|
@sse_state = :idle
|
|
495
503
|
@sse_ever_connected = false
|
|
496
|
-
@
|
|
504
|
+
@auth_failed = false
|
|
497
505
|
@sse_error_callback = nil
|
|
498
506
|
|
|
499
507
|
# A client that never finished network init has nothing to rebuild.
|
|
@@ -581,6 +589,9 @@ module Quonfig
|
|
|
581
589
|
# running. Saying so is the honest answer, and a diagnostic must not
|
|
582
590
|
# be what triggers a blocking fetch.
|
|
583
591
|
next :initializing if @fork_rebuild_pending
|
|
592
|
+
# Auth-failed is not a state of its own (R6): SSE and the poller are
|
|
593
|
+
# off, so the client is disconnected, never falling_back.
|
|
594
|
+
next :disconnected if @auth_failed
|
|
584
595
|
next :falling_back if @poll_supervisor&.alive?
|
|
585
596
|
# Liveness beats the stored flag (qfg-lv4n.1). A client whose SSE
|
|
586
597
|
# session was torn down keeps a stale @sse_state; answering
|
|
@@ -1079,31 +1090,27 @@ module Quonfig
|
|
|
1079
1090
|
@sse_error_callback ||= ->(error) { handle_sse_error(error) }
|
|
1080
1091
|
end
|
|
1081
1092
|
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
# @sse_terminal_failure latches: a buggy customer retry loop cannot
|
|
1088
|
-
# un-classify the failure by driving the state machine.
|
|
1089
|
-
@state_mutex.synchronize { @sse_terminal_failure = true } if error.is_a?(Quonfig::SSEConfigClient::SSEHTTPTerminalError)
|
|
1093
|
+
# Every SSE error, auth-class included, is an ordinary drop: SSE alone
|
|
1094
|
+
# never enters auth-failed (rule R2, qfg-goi1.10.26; this reverses the
|
|
1095
|
+
# qfg-i5xv latch). The fallback poller engages on its usual schedule and
|
|
1096
|
+
# its fetch cycle decides.
|
|
1097
|
+
def handle_sse_error(_error)
|
|
1090
1098
|
handle_sse_state_change(:error)
|
|
1091
1099
|
end
|
|
1092
1100
|
|
|
1093
1101
|
def handle_sse_state_change(new_state)
|
|
1094
1102
|
state = new_state.to_sym
|
|
1095
|
-
ever_connected,
|
|
1103
|
+
ever_connected, auth_failed = @state_mutex.synchronize do
|
|
1096
1104
|
@sse_state = state
|
|
1097
1105
|
@sse_ever_connected = true if state == :connected
|
|
1098
|
-
[@sse_ever_connected, @
|
|
1106
|
+
[@sse_ever_connected, @auth_failed]
|
|
1099
1107
|
end
|
|
1100
1108
|
|
|
1101
1109
|
return unless @options.respond_to?(:fallback_poll_enabled) && @options.fallback_poll_enabled
|
|
1102
1110
|
return if @stopped
|
|
1103
|
-
#
|
|
1104
|
-
#
|
|
1105
|
-
|
|
1106
|
-
return if terminal
|
|
1111
|
+
# While auth-failed SSE is stopped; a late edge from the stream being
|
|
1112
|
+
# torn down changes nothing (the probe loop owns the schedule).
|
|
1113
|
+
return if auth_failed
|
|
1107
1114
|
|
|
1108
1115
|
case state
|
|
1109
1116
|
when :connected
|
|
@@ -1120,15 +1127,22 @@ module Quonfig
|
|
|
1120
1127
|
|
|
1121
1128
|
public
|
|
1122
1129
|
|
|
1123
|
-
#
|
|
1124
|
-
#
|
|
1125
|
-
# or
|
|
1126
|
-
#
|
|
1127
|
-
#
|
|
1128
|
-
#
|
|
1129
|
-
#
|
|
1130
|
+
# True while the client is auth-failed (since 1.8.0, qfg-goi1.10.26): a
|
|
1131
|
+
# whole config fetch cycle got no 200/304 on any leg and a 401, 403, 404
|
|
1132
|
+
# or 410 on the primary leg, which means the SDK key is invalid or
|
|
1133
|
+
# revoked. It turns true when the fallback poller's cycle (or, with
|
|
1134
|
+
# +enable_sse: false+, a poll) is rejected: about 120s after a mid-life
|
|
1135
|
+
# stream drop, at once if SSE never connected. While true, SSE and polling
|
|
1136
|
+
# are off and the client sends one fetch probe every 150-300s. It goes back
|
|
1137
|
+
# to false on the first probe that gets a valid 200 or a 304, and the
|
|
1138
|
+
# client resumes streaming and polling on its own; there is no need to
|
|
1139
|
+
# rebuild the client.
|
|
1140
|
+
#
|
|
1141
|
+
# Before 1.8.0 this latched true on the first SSE 401/403/404 for the life
|
|
1142
|
+
# of the client (qfg-i5xv). +connection_state+ reports +:disconnected+
|
|
1143
|
+
# while this is true.
|
|
1130
1144
|
def terminal_failure?
|
|
1131
|
-
@state_mutex.synchronize { @
|
|
1145
|
+
@state_mutex.synchronize { @auth_failed }
|
|
1132
1146
|
end
|
|
1133
1147
|
|
|
1134
1148
|
private
|
|
@@ -1179,7 +1193,7 @@ module Quonfig
|
|
|
1179
1193
|
|
|
1180
1194
|
@fallback_engage_timer = Thread.new do
|
|
1181
1195
|
Thread.current.report_on_exception = false
|
|
1182
|
-
sleep grace_seconds
|
|
1196
|
+
Quonfig::Waits.sleep(:engage, grace_seconds)
|
|
1183
1197
|
@state_mutex.synchronize { @fallback_engage_timer = nil }
|
|
1184
1198
|
start_polling unless @stopped
|
|
1185
1199
|
end
|
|
@@ -1522,30 +1536,41 @@ module Quonfig
|
|
|
1522
1536
|
# engage() (immediate Fetch, then ticker). A sleep-first worker made the
|
|
1523
1537
|
# first post-engage data arrive a full extra interval late (~180s after
|
|
1524
1538
|
# SSE loss with the default 60s interval, vs ~120s in go).
|
|
1539
|
+
#
|
|
1540
|
+
# Auth-failure rule (qfg-goi1.10.26, R2-R4): a poll cycle with no 200/304
|
|
1541
|
+
# on any leg and an auth-class status on the primary enters auth-failed.
|
|
1542
|
+
# The same worker then probes once per T in [150s, 300s] (polling mode:
|
|
1543
|
+
# max(T, interval)) until a probe is accepted. The flag lives on the
|
|
1544
|
+
# client, so a supervisor restart of this worker keeps probing.
|
|
1525
1545
|
worker = lambda do |notify_delivered|
|
|
1526
1546
|
loop do
|
|
1527
1547
|
break if stopped_ref.call
|
|
1528
1548
|
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
record_refresh!
|
|
1542
|
-
notify_delivered.call
|
|
1549
|
+
if auth_failed?
|
|
1550
|
+
Quonfig::Waits.sleep(:probe, auth_probe_wait(poll_seconds))
|
|
1551
|
+
break if stopped_ref.call
|
|
1552
|
+
|
|
1553
|
+
case run_auth_probe(notify_delivered)
|
|
1554
|
+
when :rejected then next
|
|
1555
|
+
when :handed_to_sse then break
|
|
1556
|
+
end
|
|
1557
|
+
# Accepted in polling mode: resume at the interval, no immediate
|
|
1558
|
+
# fetch (the probe just fetched).
|
|
1559
|
+
Quonfig::Waits.sleep(:poll, poll_seconds)
|
|
1560
|
+
next
|
|
1543
1561
|
end
|
|
1544
|
-
|
|
1562
|
+
|
|
1563
|
+
cycle = @config_loader.fetch_cycle!(log_mode: :poll)
|
|
1564
|
+
if cycle.auth_rejected?
|
|
1565
|
+
enter_auth_failed!(cycle) unless stopped_ref.call
|
|
1566
|
+
next
|
|
1567
|
+
end
|
|
1568
|
+
|
|
1569
|
+
after_poll_result(cycle.result, notify_delivered)
|
|
1545
1570
|
|
|
1546
1571
|
break if stopped_ref.call
|
|
1547
1572
|
|
|
1548
|
-
sleep poll_seconds
|
|
1573
|
+
Quonfig::Waits.sleep(:poll, poll_seconds)
|
|
1549
1574
|
end
|
|
1550
1575
|
end
|
|
1551
1576
|
|
|
@@ -1560,6 +1585,85 @@ module Quonfig
|
|
|
1560
1585
|
LOG.warn "[quonfig] Layer 2 fallback poller engaged (interval=#{poll_ms}ms)"
|
|
1561
1586
|
end
|
|
1562
1587
|
|
|
1588
|
+
# Liveness honesty (qfg-41nh.6): stamp freshness only when the fetch
|
|
1589
|
+
# actually SUCCEEDED. :updated and :not_modified (a 304, or a
|
|
1590
|
+
# guard-rejected-but-successful response) are successes — the channel is
|
|
1591
|
+
# healthy; :failed must NOT advance last_successful_refresh (pre-fix every
|
|
1592
|
+
# tick stamped + notified regardless of outcome, so ready?/freshness
|
|
1593
|
+
# reported healthy over an empty store through a total outage). on_update
|
|
1594
|
+
# fires only on a real install, matching sdk-go (OnConfigUpdate fires in
|
|
1595
|
+
# installEnvelope only).
|
|
1596
|
+
def after_poll_result(result, notify_delivered)
|
|
1597
|
+
if result != :failed
|
|
1598
|
+
sync_evaluator_env_id!
|
|
1599
|
+
record_refresh!
|
|
1600
|
+
notify_delivered.call
|
|
1601
|
+
end
|
|
1602
|
+
notify_on_update_callback if result == :updated
|
|
1603
|
+
end
|
|
1604
|
+
|
|
1605
|
+
def auth_failed?
|
|
1606
|
+
@state_mutex.synchronize { @auth_failed }
|
|
1607
|
+
end
|
|
1608
|
+
|
|
1609
|
+
# Rule R3: T uniform in [150s, 300s], re-drawn per probe, never doubled.
|
|
1610
|
+
# With SSE disabled the wait is max(T, poll interval). Internal: tests
|
|
1611
|
+
# reach it through Quonfig::Waits, it is not a customer option.
|
|
1612
|
+
def auth_probe_wait(poll_seconds)
|
|
1613
|
+
t = AUTH_PROBE_MIN_SECONDS + (rand * (AUTH_PROBE_MAX_SECONDS - AUTH_PROBE_MIN_SECONDS))
|
|
1614
|
+
t = poll_seconds if !@options.enable_sse && poll_seconds > t
|
|
1615
|
+
t
|
|
1616
|
+
end
|
|
1617
|
+
|
|
1618
|
+
# Entry (R2-R5), on the poller's thread, in this order: SSE off (stream
|
|
1619
|
+
# closed and its worker joined, the engage timer cancelled), the flag up,
|
|
1620
|
+
# then one ERROR naming the status and the primary host, never the key.
|
|
1621
|
+
def enter_auth_failed!(cycle)
|
|
1622
|
+
cancel_fallback_engage_timer
|
|
1623
|
+
sse = @state_mutex.synchronize do
|
|
1624
|
+
@auth_failed = true
|
|
1625
|
+
@sse_state = :idle
|
|
1626
|
+
s = @sse_client
|
|
1627
|
+
@sse_client = nil
|
|
1628
|
+
s
|
|
1629
|
+
end
|
|
1630
|
+
begin
|
|
1631
|
+
sse&.close
|
|
1632
|
+
rescue StandardError => e
|
|
1633
|
+
LOG.debug "Error closing SSE client on auth-failed entry: #{e.message}"
|
|
1634
|
+
end
|
|
1635
|
+
LOG.error "[quonfig] config fetch rejected (HTTP #{cycle.primary_status} from #{cycle.primary_host}): " \
|
|
1636
|
+
'the SDK key is invalid or revoked (check the SDK key); streaming and polling stopped, ' \
|
|
1637
|
+
'retrying every few minutes until it is accepted'
|
|
1638
|
+
end
|
|
1639
|
+
|
|
1640
|
+
# One auth-failed probe: a fetch cycle across every leg (R3), logged at
|
|
1641
|
+
# DEBUG only. The first valid 200 or a 304 on any leg exits (R4): one
|
|
1642
|
+
# INFO, and SSE restarts from its base backoff (a brand-new stream
|
|
1643
|
+
# client), handing the update channel back from this poller. With SSE
|
|
1644
|
+
# disabled the poller resumes polling. Returns :rejected, :handed_to_sse
|
|
1645
|
+
# or :resume_polling.
|
|
1646
|
+
def run_auth_probe(notify_delivered)
|
|
1647
|
+
cycle = @config_loader.fetch_cycle!(log_mode: :probe)
|
|
1648
|
+
if cycle.result == :failed
|
|
1649
|
+
LOG.debug "[quonfig] auth-failed probe rejected (HTTP #{cycle.primary_status} from #{cycle.primary_host}); " \
|
|
1650
|
+
'retrying later'
|
|
1651
|
+
return :rejected
|
|
1652
|
+
end
|
|
1653
|
+
|
|
1654
|
+
after_poll_result(cycle.result, notify_delivered)
|
|
1655
|
+
handing_to_sse = @options.enable_sse && !@stopped
|
|
1656
|
+
# Stop this poller first (from its own thread: WorkerSupervisor#stop only
|
|
1657
|
+
# flags it) so connection_state never reads falling_back on the way out.
|
|
1658
|
+
stop_fallback_poller('auth-recovered') if handing_to_sse
|
|
1659
|
+
@state_mutex.synchronize { @auth_failed = false }
|
|
1660
|
+
LOG.info '[quonfig] config fetch accepted; leaving auth-failed and resuming streaming and polling'
|
|
1661
|
+
return :resume_polling unless handing_to_sse
|
|
1662
|
+
|
|
1663
|
+
start_sse
|
|
1664
|
+
:handed_to_sse
|
|
1665
|
+
end
|
|
1666
|
+
|
|
1563
1667
|
# Invoke the customer-supplied on_update callback under a rescue. A raise
|
|
1564
1668
|
# here is the customer's bug, but it must NOT take down the SSE listener
|
|
1565
1669
|
# or polling supervisor. Log at ERROR with a message containing
|
|
@@ -1794,7 +1898,7 @@ module Quonfig
|
|
|
1794
1898
|
end
|
|
1795
1899
|
|
|
1796
1900
|
def malformed_stored_duration_error(result)
|
|
1797
|
-
Quonfig::Errors::EnvVarParseError.new(result.raw_value, result.config || {},
|
|
1901
|
+
Quonfig::Errors::EnvVarParseError.new(result.raw_value, result.config || {}, nil, stored: true)
|
|
1798
1902
|
end
|
|
1799
1903
|
|
|
1800
1904
|
def malformed_duration(key, default, error)
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'json'
|
|
4
|
+
require 'uri'
|
|
4
5
|
|
|
5
6
|
module Quonfig
|
|
6
7
|
# Fetches config envelopes from the Quonfig delivery API and installs them
|
|
@@ -18,6 +19,79 @@ module Quonfig
|
|
|
18
19
|
|
|
19
20
|
CONFIGS_PATH = '/api/v2/configs'
|
|
20
21
|
|
|
22
|
+
# Rule R1 (qfg-goi1.10.26): the auth class. A fetch cycle with no 200/304
|
|
23
|
+
# on any leg and one of these on the primary leg is rejected; the client's
|
|
24
|
+
# poller then enters auth-failed (Client#start_polling).
|
|
25
|
+
AUTH_STATUSES = [401, 403, 404, 410].freeze
|
|
26
|
+
|
|
27
|
+
# Result of one fetch cycle across the configured legs. +statuses+ maps a
|
|
28
|
+
# config_api_urls index to the HTTP status that leg returned (0 = no
|
|
29
|
+
# response: timeout, connection failure). +primary_host+ is host:port of
|
|
30
|
+
# the primary leg, for the entry ERROR (never the key).
|
|
31
|
+
class FetchCycle
|
|
32
|
+
attr_reader :result, :statuses, :primary_host
|
|
33
|
+
|
|
34
|
+
def initialize(result, statuses, primary_host)
|
|
35
|
+
@result = result
|
|
36
|
+
@statuses = statuses
|
|
37
|
+
@primary_host = primary_host
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def primary_status
|
|
41
|
+
@statuses[0] || 0
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Rule R2: no leg returned 200/304 and the primary returned an
|
|
45
|
+
# auth-class status. A 200 the guard dropped, or a 304, never qualifies.
|
|
46
|
+
def auth_rejected?
|
|
47
|
+
return false unless @result == :failed
|
|
48
|
+
return false if @statuses.values.any? { |s| [200, 304].include?(s) }
|
|
49
|
+
|
|
50
|
+
AUTH_STATUSES.include?(primary_status)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Collects one cycle's per-leg log lines. +mode+:
|
|
55
|
+
# :normal - log at once, as always (init fetch, refresh, legacy callers)
|
|
56
|
+
# :poll - a poller cycle: hold the auth-class lines until the cycle is
|
|
57
|
+
# classified, so an entering cycle logs its one ERROR in their
|
|
58
|
+
# place (rule R5); otherwise they are logged unchanged
|
|
59
|
+
# :probe - an auth-failed probe: every line at DEBUG (rule R5)
|
|
60
|
+
class CycleLog
|
|
61
|
+
def initialize(logger, mode)
|
|
62
|
+
@logger = logger
|
|
63
|
+
@mode = mode
|
|
64
|
+
@held = []
|
|
65
|
+
@released = false
|
|
66
|
+
@mutex = Mutex.new
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def log(level, msg, auth: false)
|
|
70
|
+
return @logger.debug(msg) if @mode == :probe
|
|
71
|
+
|
|
72
|
+
if @mode == :poll && auth
|
|
73
|
+
hold = @mutex.synchronize do
|
|
74
|
+
@held << [level, msg] unless @released
|
|
75
|
+
!@released
|
|
76
|
+
end
|
|
77
|
+
return if hold
|
|
78
|
+
end
|
|
79
|
+
@logger.public_send(level, msg)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Log (or with +drop+, discard) the held lines. Lines from a leg that
|
|
83
|
+
# settles later are logged as they come.
|
|
84
|
+
def release(drop: false)
|
|
85
|
+
held = @mutex.synchronize do
|
|
86
|
+
@released = true
|
|
87
|
+
h = @held
|
|
88
|
+
@held = []
|
|
89
|
+
h
|
|
90
|
+
end
|
|
91
|
+
held.each { |level, msg| @logger.public_send(level, msg) } unless drop
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
21
95
|
attr_reader :version, :environment_id
|
|
22
96
|
|
|
23
97
|
# qfg-7h5d.1.9 (canonical ordering). Diagnostic surface read by the failover/
|
|
@@ -127,17 +201,32 @@ module Quonfig
|
|
|
127
201
|
# :not_modified -- a leg answered 304 (no change) and nothing installed
|
|
128
202
|
# :failed -- every fired leg failed
|
|
129
203
|
def fetch!
|
|
204
|
+
fetch_cycle!.result
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# One fetch cycle, as #fetch!, returning a FetchCycle that carries each
|
|
208
|
+
# leg's status (rule R2, qfg-goi1.10.26). +log_mode+ is one of CycleLog's
|
|
209
|
+
# modes; the poller passes :poll for an ordinary cycle and :probe while
|
|
210
|
+
# auth-failed.
|
|
211
|
+
def fetch_cycle!(log_mode: :normal)
|
|
130
212
|
urls = Array(@options.config_api_urls)
|
|
131
|
-
|
|
213
|
+
statuses = Concurrent::Map.new
|
|
214
|
+
return FetchCycle.new(:failed, statuses, '') if urls.empty?
|
|
132
215
|
|
|
216
|
+
cycle_log = CycleLog.new(@logger, log_mode)
|
|
133
217
|
# Single leg (or no secondary configured): no hedge, just fetch on the
|
|
134
218
|
# calling thread under the SEQUENTIAL per-URL timeout (config_fetch_timeout_ms
|
|
135
219
|
# is unchanged and still governs any non-hedged path). Preserves the
|
|
136
220
|
# synchronous, single-request-per-call shape the legacy/mock callers depend
|
|
137
221
|
# on.
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
222
|
+
result = if urls.length < 2
|
|
223
|
+
fetch_from(urls[0], 0, timeout_ms: config_fetch_timeout_ms, statuses: statuses, cycle_log: cycle_log)
|
|
224
|
+
else
|
|
225
|
+
fetch_hedged(urls, statuses: statuses, cycle_log: cycle_log)
|
|
226
|
+
end
|
|
227
|
+
cycle = FetchCycle.new(result, statuses, host_of(urls[0]))
|
|
228
|
+
cycle_log.release(drop: cycle.auth_rejected?)
|
|
229
|
+
cycle
|
|
141
230
|
end
|
|
142
231
|
|
|
143
232
|
# Apply a ConfigEnvelope (from SSE) to the store. Called by the SSE client
|
|
@@ -176,7 +265,7 @@ module Quonfig
|
|
|
176
265
|
# the hedge abort inside fetch_from, and heals forward through the
|
|
177
266
|
# reject-older guard. We never join the slow leg — a hung primary must not
|
|
178
267
|
# block a successful secondary install.
|
|
179
|
-
def fetch_hedged(urls)
|
|
268
|
+
def fetch_hedged(urls, statuses: nil, cycle_log: nil)
|
|
180
269
|
hedge_delay_s = hedge_delay_ms / 1000.0
|
|
181
270
|
abort_ms = hedge_abort_ms
|
|
182
271
|
|
|
@@ -204,7 +293,7 @@ module Quonfig
|
|
|
204
293
|
Thread.new do
|
|
205
294
|
result = :failed
|
|
206
295
|
begin
|
|
207
|
-
result = fetch_from(urls[index], index, timeout_ms: abort_ms)
|
|
296
|
+
result = fetch_from(urls[index], index, timeout_ms: abort_ms, statuses: statuses, cycle_log: cycle_log)
|
|
208
297
|
rescue StandardError => e
|
|
209
298
|
@logger.debug "Hedge leg #{index} failed: #{e.class}: #{e.message}"
|
|
210
299
|
ensure
|
|
@@ -306,7 +395,15 @@ module Quonfig
|
|
|
306
395
|
@options.respond_to?(:config_fetch_timeout_ms) ? @options.config_fetch_timeout_ms : nil
|
|
307
396
|
end
|
|
308
397
|
|
|
309
|
-
def
|
|
398
|
+
def host_of(url)
|
|
399
|
+
uri = URI(url.to_s)
|
|
400
|
+
uri.host ? "#{uri.host}:#{uri.port}" : url.to_s
|
|
401
|
+
rescue URI::InvalidURIError
|
|
402
|
+
''
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
def fetch_from(source, index = nil, timeout_ms: nil, statuses: nil, cycle_log: nil)
|
|
406
|
+
cycle_log ||= CycleLog.new(@logger, :normal)
|
|
310
407
|
# qfg-7h5d.1.9 / .1.14: bound this single per-leg attempt so a hung upstream
|
|
311
408
|
# aborts (Faraday::TimeoutError, caught below as :failed). On the hedged
|
|
312
409
|
# path the caller passes config_fetch_hedge_abort_ms; on the sequential /
|
|
@@ -318,6 +415,7 @@ module Quonfig
|
|
|
318
415
|
etag = etag_for(index)
|
|
319
416
|
headers['If-None-Match'] = etag if etag
|
|
320
417
|
response = conn.get(CONFIGS_PATH, headers)
|
|
418
|
+
statuses[index || 0] = response.status.to_i if statuses
|
|
321
419
|
|
|
322
420
|
case response.status
|
|
323
421
|
when 200
|
|
@@ -337,23 +435,24 @@ module Quonfig
|
|
|
337
435
|
@logger.debug "Configs not modified (304) from #{source}"
|
|
338
436
|
:not_modified
|
|
339
437
|
when 401, 403
|
|
340
|
-
|
|
438
|
+
cycle_log.log(:warn, "Config fetch rejected (#{response.status}) from #{source}: #{short_body(response)}", auth: true)
|
|
341
439
|
:failed
|
|
342
440
|
else
|
|
343
|
-
|
|
441
|
+
cycle_log.log(:info, "Config fetch failed: status #{response.status} from #{source}",
|
|
442
|
+
auth: AUTH_STATUSES.include?(response.status.to_i))
|
|
344
443
|
:failed
|
|
345
444
|
end
|
|
346
445
|
rescue NonEnvelopeError => e
|
|
347
446
|
# qfg-9dxb.3: a non-envelope 200 is a leg error, NOT an install — the
|
|
348
447
|
# hedge/failover proceeds and this leg's ETag is never stored (so a junk
|
|
349
448
|
# 200 cannot pin itself through later 304s).
|
|
350
|
-
|
|
449
|
+
cycle_log.log(:warn, "Config fetch from #{source} returned a non-envelope 200 (#{e.message}); treating as a failed leg")
|
|
351
450
|
:failed
|
|
352
451
|
rescue Faraday::ConnectionFailed => e
|
|
353
452
|
@logger.debug "Connection failure fetching configs from #{source}: #{e.message}"
|
|
354
453
|
:failed
|
|
355
454
|
rescue StandardError => e
|
|
356
|
-
|
|
455
|
+
cycle_log.log(:warn, "Unexpected error fetching configs from #{source}: #{e.message}")
|
|
357
456
|
:failed
|
|
358
457
|
end
|
|
359
458
|
|
|
@@ -461,6 +560,25 @@ module Quonfig
|
|
|
461
560
|
@version = meta['version'] || meta[:version] || @version
|
|
462
561
|
@environment_id = meta['environment'] || meta[:environment] || @environment_id
|
|
463
562
|
|
|
563
|
+
# Replace the live store first, then advance the generation
|
|
564
|
+
# (qfg-goi1.10.2). An interrupt part-way through the store write
|
|
565
|
+
# (Thread#raise, Timeout) then leaves the held generation where it
|
|
566
|
+
# was, so the same-generation redelivery that follows installs again
|
|
567
|
+
# and repairs the store instead of being ignored as a duplicate.
|
|
568
|
+
unless @store.nil?
|
|
569
|
+
new_keys = next_map.keys.to_set
|
|
570
|
+
old_keys = @store.keys.to_set
|
|
571
|
+
# Drop keys that disappeared server-side.
|
|
572
|
+
(old_keys - new_keys).each { |k| @store.delete(k) } if @store.respond_to?(:delete)
|
|
573
|
+
|
|
574
|
+
envelope.configs.each do |cfg|
|
|
575
|
+
key = config_key(cfg)
|
|
576
|
+
next if key.nil?
|
|
577
|
+
|
|
578
|
+
@store.set(key, cfg)
|
|
579
|
+
end
|
|
580
|
+
end
|
|
581
|
+
|
|
464
582
|
# qfg-9dxb.3 Fix A: an unversioned install (generation <= 0) carries no
|
|
465
583
|
# ordering info and must never LOWER a positive held generation. Under
|
|
466
584
|
# the qfg-9dxb.9 rule it only installs when held is nil/0 anyway, so
|
|
@@ -473,21 +591,6 @@ module Quonfig
|
|
|
473
591
|
# SSE / datadir installs pass source_index nil and are ignored by the
|
|
474
592
|
# aggregator, so they are never counted.
|
|
475
593
|
@failover_aggregator&.record_resolved_from(source_index)
|
|
476
|
-
|
|
477
|
-
# Replace the live store atomically.
|
|
478
|
-
return if @store.nil?
|
|
479
|
-
|
|
480
|
-
new_keys = next_map.keys.to_set
|
|
481
|
-
old_keys = @store.keys.to_set
|
|
482
|
-
# Drop keys that disappeared server-side.
|
|
483
|
-
(old_keys - new_keys).each { |k| @store.delete(k) } if @store.respond_to?(:delete)
|
|
484
|
-
|
|
485
|
-
envelope.configs.each do |cfg|
|
|
486
|
-
key = config_key(cfg)
|
|
487
|
-
next if key.nil?
|
|
488
|
-
|
|
489
|
-
@store.set(key, cfg)
|
|
490
|
-
end
|
|
491
594
|
end
|
|
492
595
|
end
|
|
493
596
|
|