confeature 0.0.0 → 0.1.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/README.md +26 -4
- data/lib/confeature/client.rb +397 -0
- data/lib/confeature/evaluation_details.rb +89 -0
- data/lib/confeature/flag_overrides.rb +186 -0
- data/lib/confeature/hooks.rb +84 -0
- data/lib/confeature/internal/cache.rb +166 -0
- data/lib/confeature/internal/comparators.rb +483 -0
- data/lib/confeature/internal/config_fetcher.rb +254 -0
- data/lib/confeature/internal/eval_log.rb +334 -0
- data/lib/confeature/internal/eval_logger.rb +140 -0
- data/lib/confeature/internal/evaluator.rb +866 -0
- data/lib/confeature/internal/hashing.rb +89 -0
- data/lib/confeature/internal/override_eval.rb +184 -0
- data/lib/confeature/internal/poll_service.rb +218 -0
- data/lib/confeature/internal/prerequisites.rb +88 -0
- data/lib/confeature/internal/sdk_key.rb +121 -0
- data/lib/confeature/internal/segments.rb +79 -0
- data/lib/confeature/internal/semver.rb +163 -0
- data/lib/confeature/log_level.rb +20 -0
- data/lib/confeature/logger.rb +59 -0
- data/lib/confeature/polling_mode.rb +62 -0
- data/lib/confeature/snapshot.rb +158 -0
- data/lib/confeature/user.rb +52 -0
- data/lib/confeature/version.rb +11 -0
- data/lib/confeature.rb +39 -1
- metadata +70 -7
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# SPDX-License-Identifier: MIT
|
|
4
|
+
|
|
5
|
+
require "digest"
|
|
6
|
+
|
|
7
|
+
module Confeature
|
|
8
|
+
module Internal
|
|
9
|
+
# ConfigCat evaluation hashes — SHA-1 (unsalted %) + SHA-256 (salted
|
|
10
|
+
# sensitive). Ported field-for-field from the spec-117 TS reference
|
|
11
|
+
# (`packages/sdk-shared/src/hash.ts`) and the spec-118 Python port
|
|
12
|
+
# (`apps/sdk-python/src/confeature_client/hash.py`). The flag evaluator
|
|
13
|
+
# needs two synchronous hashes:
|
|
14
|
+
#
|
|
15
|
+
# - **SHA-1, UNSALTED** for percentage bucketing —
|
|
16
|
+
# `Digest::SHA1.hexdigest(key + attr_value)[0, 7].to_i(16) % 100`.
|
|
17
|
+
# - **SHA-256, SALTED** for sensitive comparators —
|
|
18
|
+
# `sha256(utf8(value) + utf8(config_salt) + utf8(context_salt))`.
|
|
19
|
+
#
|
|
20
|
+
# Unlike the TS reference (which vendors a pure-JS implementation because
|
|
21
|
+
# `crypto.subtle` is async on every runtime), Ruby's stdlib `Digest` is
|
|
22
|
+
# synchronous and present everywhere, so we use it directly. The bytes
|
|
23
|
+
# hashed are identical to the TS / Python implementations — UTF-8-encoded
|
|
24
|
+
# text for the text paths, raw byte slices for the startsWith/endsWith
|
|
25
|
+
# slice scheme — so every bucket / hash comes out byte-identical across the
|
|
26
|
+
# federation (the parity corpus requires this).
|
|
27
|
+
# Named `Hashing` (not `Hash`) to avoid shadowing Ruby's core `::Hash`
|
|
28
|
+
# class for any unqualified `Hash` reference elsewhere in
|
|
29
|
+
# `Confeature::Internal` (e.g. `body.is_a?(Hash)` in `config_fetcher.rb`).
|
|
30
|
+
module Hashing
|
|
31
|
+
module_function
|
|
32
|
+
|
|
33
|
+
# Lowercase-hex SHA-1 of raw bytes (mirrors `hash.ts::sha1`).
|
|
34
|
+
def sha1_hex(data)
|
|
35
|
+
Digest::SHA1.hexdigest(data)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Lowercase-hex SHA-256 of raw bytes (mirrors `hash.ts::sha256Bytes`).
|
|
39
|
+
def sha256_hex(data)
|
|
40
|
+
Digest::SHA256.hexdigest(data)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Hash a user-attribute value for a **sensitive** comparator. Mirrors
|
|
44
|
+
# `hash.ts::hashComparisonValue` (common-js `hashComparisonValue`):
|
|
45
|
+
#
|
|
46
|
+
# sha256( utf8(value) + utf8(config_json_salt) + utf8(context_salt) )
|
|
47
|
+
#
|
|
48
|
+
# concatenated with no separators, output as lowercase hex.
|
|
49
|
+
#
|
|
50
|
+
# @param value [String] attribute value as text (after conversion).
|
|
51
|
+
# @param config_json_salt [String] top-level config salt (`config["p"]["s"]`).
|
|
52
|
+
# @param context_salt [String] per-evaluation discriminator — the setting
|
|
53
|
+
# key for a user condition, the segment name for a segment condition.
|
|
54
|
+
def hash_comparison_value(value, config_json_salt, context_salt)
|
|
55
|
+
sha256_hex(b("#{value}#{config_json_salt}#{context_salt}"))
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Hash a UTF-8 byte slice for a sensitive startsWith/endsWith comparator.
|
|
59
|
+
# Mirrors `comparators.ts::hashComparisonValueSlice`:
|
|
60
|
+
#
|
|
61
|
+
# sha256( slice_bytes ++ utf8(config_salt) ++ utf8(context_salt) )
|
|
62
|
+
#
|
|
63
|
+
# The slice is **raw bytes** — a byte-boundary cut of the attribute, which
|
|
64
|
+
# may land mid-codepoint — so the hash runs over the concatenated byte
|
|
65
|
+
# sequence (re-encoding a binary string as UTF-8 would corrupt multibyte
|
|
66
|
+
# attributes, the `testmatrix_unicode` `*Hashed` rows). `slice_utf8` is an
|
|
67
|
+
# ASCII-8BIT (binary) string.
|
|
68
|
+
def hash_comparison_value_slice(slice_utf8, config_json_salt, context_salt)
|
|
69
|
+
sha256_hex(slice_utf8 + b(config_json_salt) + b(context_salt))
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Compute a ConfigCat percentage bucket in `[0, 99]` for
|
|
73
|
+
# `key + attr_value`. Mirrors `hash.ts::percentageBucket`:
|
|
74
|
+
#
|
|
75
|
+
# sha1(key + attr_value).hexdigest[0, 7].to_i(16) % 100
|
|
76
|
+
#
|
|
77
|
+
# SHA-1, **unsalted**. 7 hex chars = 28 bits, well within a safe integer.
|
|
78
|
+
def percentage_bucket(key, attr_value)
|
|
79
|
+
sha1_hex(b("#{key}#{attr_value}"))[0, 7].to_i(16) % 100
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Force a string to its UTF-8 bytes as a binary blob so `Digest` hashes
|
|
83
|
+
# the exact byte sequence (and concatenation with binary slices works).
|
|
84
|
+
def b(str)
|
|
85
|
+
str.encode(Encoding::UTF_8).b
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# SPDX-License-Identifier: MIT
|
|
4
|
+
|
|
5
|
+
require_relative "../flag_overrides"
|
|
6
|
+
require_relative "evaluator"
|
|
7
|
+
|
|
8
|
+
module Confeature
|
|
9
|
+
module Internal
|
|
10
|
+
# Synchronous override evaluation — Ruby port of `EvaluateSync`.
|
|
11
|
+
#
|
|
12
|
+
# Byte-identical 7-branch decision tree from
|
|
13
|
+
# `apps/sdk-go/internal/overrideeval/eval.go` (Go) and
|
|
14
|
+
# `packages/sdk-shared/src/override-eval.ts` (TS canonical). The
|
|
15
|
+
# Python port lives at
|
|
16
|
+
# `apps/sdk-python/src/confeature_client/override_eval.py`. The
|
|
17
|
+
# parity corpus is the canonical gate.
|
|
18
|
+
#
|
|
19
|
+
# The helper is intentionally pure: no logger access, no SDK-key
|
|
20
|
+
# access, no I/O. Pure data in → tuple `[found, value]` out.
|
|
21
|
+
#
|
|
22
|
+
# Returns:
|
|
23
|
+
# - `[false, nil]` when overrides are absent, behaviour is
|
|
24
|
+
# non-LocalOnly, or no synchronous data source is available
|
|
25
|
+
# (Branches 1-3: caller falls through to the cache).
|
|
26
|
+
# - `[true, <value>]` when overrides + LocalOnly + key match
|
|
27
|
+
# (Branch 7 — typed override value); also
|
|
28
|
+
# `[true, default_value]` for the LocalOnly fallback branches
|
|
29
|
+
# 4-6 (key absent / empty slot / TYPE_MISMATCH).
|
|
30
|
+
module OverrideEval
|
|
31
|
+
module_function
|
|
32
|
+
|
|
33
|
+
def evaluate_sync(flag_overrides, key, default_value)
|
|
34
|
+
# Branch 1: no overrides configured → caller falls through.
|
|
35
|
+
return [false, nil] if flag_overrides.nil?
|
|
36
|
+
|
|
37
|
+
# Branch 2: non-LocalOnly behaviours short-circuit (spec 031 §R8).
|
|
38
|
+
return [false, nil] if flag_overrides.behaviour != OverrideBehaviour::LOCAL_ONLY
|
|
39
|
+
|
|
40
|
+
# Branch 3: no synchronous data source available → fall through.
|
|
41
|
+
data_source = flag_overrides.data_source
|
|
42
|
+
return [false, nil] if data_source.nil?
|
|
43
|
+
return [false, nil] unless data_source.respond_to?(:get_overrides_sync)
|
|
44
|
+
|
|
45
|
+
overrides_map = data_source.get_overrides_sync
|
|
46
|
+
return [false, nil] if overrides_map.nil?
|
|
47
|
+
|
|
48
|
+
# From here we are committed to the LocalOnly contract: NEVER
|
|
49
|
+
# fall through to the cache — return either the typed override
|
|
50
|
+
# or the supplied default.
|
|
51
|
+
|
|
52
|
+
# Branch 4: key absent → return default.
|
|
53
|
+
raw = overrides_map[key]
|
|
54
|
+
return [true, default_value] if raw.nil?
|
|
55
|
+
|
|
56
|
+
# Branch 5: override slot empty → return default.
|
|
57
|
+
candidate = raw.picked
|
|
58
|
+
return [true, default_value] if candidate.nil?
|
|
59
|
+
|
|
60
|
+
# Branch 6: TYPE_MISMATCH — primitive types diverge → return default.
|
|
61
|
+
# A `nil` default_value intentionally bypasses the check (matches
|
|
62
|
+
# configcat-js's `defaultValue !== null` ternary). In Ruby
|
|
63
|
+
# `true.class` and `false.class` differ (TrueClass / FalseClass)
|
|
64
|
+
# — treat both as the same "boolean" bucket so a `true`
|
|
65
|
+
# override matched against a `false` default is NOT a type
|
|
66
|
+
# mismatch.
|
|
67
|
+
if !default_value.nil? && !boolean_kind_match?(candidate, default_value) &&
|
|
68
|
+
candidate.class != default_value.class
|
|
69
|
+
return [true, default_value]
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Branch 7: happy path — typed override value.
|
|
73
|
+
[true, candidate]
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def boolean_kind_match?(candidate, default_value)
|
|
77
|
+
boolean?(candidate) && boolean?(default_value)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def boolean?(value)
|
|
81
|
+
value.is_a?(TrueClass) || value.is_a?(FalseClass)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Full-config override resolution — Ruby port of
|
|
85
|
+
# `evaluateFullConfigOverrideSync` (spec 135 / #431).
|
|
86
|
+
#
|
|
87
|
+
# When the override source is a `ConfigOverrideDataSource`, resolve
|
|
88
|
+
# `key` through the real Ruby evaluator so targeting rules /
|
|
89
|
+
# segments / prerequisites / percentage options apply offline — the
|
|
90
|
+
# rule-bearing sibling of `evaluate_sync`'s flat lookup.
|
|
91
|
+
#
|
|
92
|
+
# Returns:
|
|
93
|
+
# - `[true, <value>]` when overrides + LocalOnly + a
|
|
94
|
+
# `ConfigOverrideDataSource` apply (LocalOnly: never fall
|
|
95
|
+
# through to the cache — the evaluator already returns
|
|
96
|
+
# `default_value` for any non-clean resolution, so this
|
|
97
|
+
# preserves the contract);
|
|
98
|
+
# - `[false, nil]` when overrides are absent, behaviour is
|
|
99
|
+
# non-LocalOnly, or the source is NOT a full-config source
|
|
100
|
+
# (caller falls through to the flat-map helper / normal eval).
|
|
101
|
+
#
|
|
102
|
+
# INV-4: the evaluator receives the FULL config Hash (`{ p, f, s }`)
|
|
103
|
+
# — salt (`p.s`) + segments (`s`), never just `f` (the spec-129
|
|
104
|
+
# dropped-salt regression class).
|
|
105
|
+
def evaluate_full_config_override_sync(flag_overrides, key, default_value, user)
|
|
106
|
+
return [false, nil] if flag_overrides.nil?
|
|
107
|
+
return [false, nil] if flag_overrides.behaviour != OverrideBehaviour::LOCAL_ONLY
|
|
108
|
+
|
|
109
|
+
data_source = flag_overrides.data_source
|
|
110
|
+
return [false, nil] unless data_source.is_a?(ConfigOverrideDataSource)
|
|
111
|
+
|
|
112
|
+
# Pass the FULL config Hash (salt + segments + f), never just `f`
|
|
113
|
+
# (INV-4).
|
|
114
|
+
result = Evaluator.evaluate(data_source.config, key, default_value, user)
|
|
115
|
+
[true, result.value]
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Details-returning sibling of `evaluate_full_config_override_sync`
|
|
119
|
+
# (spec 152 / #475, US2 — Ruby port of Python's
|
|
120
|
+
# `evaluate_full_config_override_details_sync`).
|
|
121
|
+
#
|
|
122
|
+
# Resolves `key` through the real evaluator and returns the full
|
|
123
|
+
# `EvaluationResult` (value + served `variation_id` + matched
|
|
124
|
+
# rule / option) so the PUBLIC `get_value_details` surfaces the
|
|
125
|
+
# override's identity. Closes the #395 gap class: the snapshot's
|
|
126
|
+
# LocalOnly details branch previously routed a
|
|
127
|
+
# `ConfigOverrideDataSource` through the value-only flat-map helper
|
|
128
|
+
# (which cannot read rule-bearing settings and never carries a
|
|
129
|
+
# `variation_id`).
|
|
130
|
+
#
|
|
131
|
+
# Returns:
|
|
132
|
+
# - `[true, <EvaluationResult>]` when overrides + LocalOnly + a
|
|
133
|
+
# `ConfigOverrideDataSource` apply;
|
|
134
|
+
# - `[false, nil]` when overrides are absent, behaviour is
|
|
135
|
+
# non-LocalOnly, or the source is NOT a full-config source
|
|
136
|
+
# (caller falls through to the flat-map details branch —
|
|
137
|
+
# unchanged value-only override semantics).
|
|
138
|
+
#
|
|
139
|
+
# INV-4: the evaluator receives the FULL config Hash (`{ p, f, s }`)
|
|
140
|
+
# — salt (`p.s`) + segments (`s`), never just `f`.
|
|
141
|
+
def evaluate_full_config_override_details_sync(flag_overrides, key, default_value, user)
|
|
142
|
+
return [false, nil] if flag_overrides.nil?
|
|
143
|
+
return [false, nil] if flag_overrides.behaviour != OverrideBehaviour::LOCAL_ONLY
|
|
144
|
+
|
|
145
|
+
data_source = flag_overrides.data_source
|
|
146
|
+
return [false, nil] unless data_source.is_a?(ConfigOverrideDataSource)
|
|
147
|
+
|
|
148
|
+
# Pass the FULL config Hash (salt + segments + f), never just `f`
|
|
149
|
+
# (INV-4).
|
|
150
|
+
result = Evaluator.evaluate(data_source.config, key, default_value, user)
|
|
151
|
+
[true, result]
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Shared override router — Ruby port of `evaluateOverride`
|
|
155
|
+
# (spec 135 / #431).
|
|
156
|
+
#
|
|
157
|
+
# Dispatches between the flat-map path and the full-config path so
|
|
158
|
+
# the `get_value` call site changes minimally: a
|
|
159
|
+
# `ConfigOverrideDataSource` routes through the real evaluator;
|
|
160
|
+
# every other source (map / custom) keeps the existing
|
|
161
|
+
# byte-identical flat-map resolution.
|
|
162
|
+
#
|
|
163
|
+
# Returns `[found, value]` — `[false, nil]` when no override applies
|
|
164
|
+
# (the caller then falls through to the normal cache-driven eval).
|
|
165
|
+
# Preserves the LocalOnly no-fall-through contract on both paths.
|
|
166
|
+
def evaluate(flag_overrides, key, default_value, user)
|
|
167
|
+
# Full-config source → ALWAYS resolve through the evaluator; NEVER
|
|
168
|
+
# fall through to the flat-map path (which would mis-read
|
|
169
|
+
# rule-bearing settings as value-only). Dispatch on the source
|
|
170
|
+
# TYPE, not the resolved value, so a full-config source whose
|
|
171
|
+
# evaluator returns the default still stays on the rule path.
|
|
172
|
+
if !flag_overrides.nil? &&
|
|
173
|
+
flag_overrides.behaviour == OverrideBehaviour::LOCAL_ONLY &&
|
|
174
|
+
flag_overrides.data_source.is_a?(ConfigOverrideDataSource)
|
|
175
|
+
return evaluate_full_config_override_sync(flag_overrides, key, default_value, user)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Every other source (map / custom) keeps the byte-identical
|
|
179
|
+
# flat-map path.
|
|
180
|
+
evaluate_sync(flag_overrides, key, default_value)
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# SPDX-License-Identifier: MIT
|
|
4
|
+
|
|
5
|
+
require_relative "../polling_mode"
|
|
6
|
+
|
|
7
|
+
module Confeature
|
|
8
|
+
module Internal
|
|
9
|
+
# Polling strategies: AutoPoll (daemon thread), LazyLoad (on-demand
|
|
10
|
+
# staleness), ManualPoll (caller-driven).
|
|
11
|
+
#
|
|
12
|
+
# Lifecycle:
|
|
13
|
+
# - constructor accepts a `fetch_callback` (no args, returns
|
|
14
|
+
# ConfigFetcher::FetchResult) bound to the client's
|
|
15
|
+
# ConfigFetcher.
|
|
16
|
+
# - `start(callback)` is idempotent. AutoPoll spawns its daemon
|
|
17
|
+
# thread here. LazyLoad / ManualPoll record the callback but
|
|
18
|
+
# spawn nothing.
|
|
19
|
+
# - `force_refresh` invokes the callback synchronously and
|
|
20
|
+
# returns a `RefreshResult`.
|
|
21
|
+
# - `stop(timeout: 5.0)` signals the thread and joins (no-op for
|
|
22
|
+
# non-AutoPoll modes).
|
|
23
|
+
#
|
|
24
|
+
# AutoPoll uses `Thread.new` + `Mutex` + `ConditionVariable` for
|
|
25
|
+
# cancel signalling. Threads are daemon so process shutdown never
|
|
26
|
+
# hangs on a poll-thread join.
|
|
27
|
+
module PollService
|
|
28
|
+
RefreshResult = Struct.new(:success, :error, keyword_init: true) do
|
|
29
|
+
def self.from_fetch(fetch_result)
|
|
30
|
+
if %i[fetched not_modified].include?(fetch_result.status)
|
|
31
|
+
new(success: true)
|
|
32
|
+
else
|
|
33
|
+
new(success: false, error: fetch_result.error || "FETCH_FAILED")
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Abstract base — subclasses provide the per-mode behaviour.
|
|
39
|
+
class Base
|
|
40
|
+
def start(_callback)
|
|
41
|
+
raise NotImplementedError
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def stop(timeout: 5.0)
|
|
45
|
+
raise NotImplementedError
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def force_refresh
|
|
49
|
+
raise NotImplementedError
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Hook for LazyLoad's on-demand staleness-driven fetch.
|
|
53
|
+
# AutoPoll + ManualPoll return immediately.
|
|
54
|
+
def maybe_fetch_for_get_value
|
|
55
|
+
nil
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
class AutoPollService < Base
|
|
60
|
+
def initialize(options)
|
|
61
|
+
super()
|
|
62
|
+
@options = options
|
|
63
|
+
@callback = nil
|
|
64
|
+
@started = false
|
|
65
|
+
@mutex = Mutex.new
|
|
66
|
+
@stop_mutex = Mutex.new
|
|
67
|
+
@stop_cond = ConditionVariable.new
|
|
68
|
+
@stopped = false
|
|
69
|
+
@thread = nil
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def start(callback)
|
|
73
|
+
@mutex.synchronize do
|
|
74
|
+
return if @started
|
|
75
|
+
|
|
76
|
+
@callback = callback
|
|
77
|
+
@started = true
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Eager initial fetch so first get_value lands on real config.
|
|
81
|
+
safe_invoke(@callback)
|
|
82
|
+
|
|
83
|
+
@thread = Thread.new { run_loop }
|
|
84
|
+
@thread.name = "confeature-autopoll" if @thread.respond_to?(:name=)
|
|
85
|
+
@thread.report_on_exception = false if @thread.respond_to?(:report_on_exception=)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def stop(timeout: 5.0)
|
|
89
|
+
@stop_mutex.synchronize do
|
|
90
|
+
@stopped = true
|
|
91
|
+
@stop_cond.broadcast
|
|
92
|
+
end
|
|
93
|
+
thread = @thread
|
|
94
|
+
@thread = nil
|
|
95
|
+
thread&.join(timeout)
|
|
96
|
+
nil
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def force_refresh
|
|
100
|
+
cb = @callback
|
|
101
|
+
return RefreshResult.new(success: false, error: "NOT_STARTED") if cb.nil?
|
|
102
|
+
|
|
103
|
+
result = safe_invoke(cb)
|
|
104
|
+
return RefreshResult.new(success: false, error: "CALLBACK_RAISED") if result.nil?
|
|
105
|
+
|
|
106
|
+
RefreshResult.from_fetch(result)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
private
|
|
110
|
+
|
|
111
|
+
def run_loop
|
|
112
|
+
interval = @options.poll_interval_seconds
|
|
113
|
+
loop do
|
|
114
|
+
@stop_mutex.synchronize do
|
|
115
|
+
return if @stopped
|
|
116
|
+
|
|
117
|
+
@stop_cond.wait(@stop_mutex, interval)
|
|
118
|
+
return if @stopped
|
|
119
|
+
end
|
|
120
|
+
cb = @callback
|
|
121
|
+
return if cb.nil?
|
|
122
|
+
|
|
123
|
+
safe_invoke(cb)
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Returns the callback's result, or nil if the callback raised.
|
|
128
|
+
# The shipped `ConfigFetcher.fetch` is fail-closed; this guard
|
|
129
|
+
# exists so a misbehaving custom callback can't crash the
|
|
130
|
+
# daemon thread or the force_refresh path.
|
|
131
|
+
def safe_invoke(cb)
|
|
132
|
+
cb.call
|
|
133
|
+
rescue StandardError
|
|
134
|
+
nil
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
class LazyLoadService < Base
|
|
139
|
+
def initialize(options)
|
|
140
|
+
super()
|
|
141
|
+
@options = options
|
|
142
|
+
@callback = nil
|
|
143
|
+
@last_fetched_at = 0.0
|
|
144
|
+
@mutex = Mutex.new
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def start(callback)
|
|
148
|
+
@mutex.synchronize { @callback = callback }
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def stop(timeout: 5.0) # rubocop:disable Lint/UnusedMethodArgument
|
|
152
|
+
nil
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def force_refresh
|
|
156
|
+
cb = @callback
|
|
157
|
+
return RefreshResult.new(success: false, error: "NOT_STARTED") if cb.nil?
|
|
158
|
+
|
|
159
|
+
result = RefreshResult.from_fetch(cb.call)
|
|
160
|
+
@mutex.synchronize { @last_fetched_at = Time.now.to_f } if result.success
|
|
161
|
+
result
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def maybe_fetch_for_get_value
|
|
165
|
+
cb = @callback
|
|
166
|
+
return nil if cb.nil?
|
|
167
|
+
|
|
168
|
+
now = Time.now.to_f
|
|
169
|
+
stale = (now - @last_fetched_at) >= @options.cache_refresh_interval_seconds
|
|
170
|
+
return nil unless stale
|
|
171
|
+
|
|
172
|
+
begin
|
|
173
|
+
cb.call
|
|
174
|
+
@mutex.synchronize { @last_fetched_at = Time.now.to_f }
|
|
175
|
+
rescue StandardError
|
|
176
|
+
# Swallow — fetch errors are logged inside ConfigFetcher.
|
|
177
|
+
end
|
|
178
|
+
nil
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
class ManualPollService < Base
|
|
183
|
+
def initialize
|
|
184
|
+
super
|
|
185
|
+
@callback = nil
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def start(callback)
|
|
189
|
+
@callback = callback
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def stop(timeout: 5.0) # rubocop:disable Lint/UnusedMethodArgument
|
|
193
|
+
nil
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def force_refresh
|
|
197
|
+
cb = @callback
|
|
198
|
+
return RefreshResult.new(success: false, error: "NOT_STARTED") if cb.nil?
|
|
199
|
+
|
|
200
|
+
RefreshResult.from_fetch(cb.call)
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# Build the service matching the supplied PollingMode value.
|
|
205
|
+
def self.build(mode)
|
|
206
|
+
case mode
|
|
207
|
+
when PollingMode::AutoPoll then AutoPollService.new(mode)
|
|
208
|
+
when PollingMode::LazyLoad then LazyLoadService.new(mode)
|
|
209
|
+
when PollingMode::ManualPoll then ManualPollService.new
|
|
210
|
+
else
|
|
211
|
+
raise ArgumentError,
|
|
212
|
+
"Unsupported polling mode: #{mode.inspect}. " \
|
|
213
|
+
"Use PollingMode.auto_poll, .lazy_load, or .manual_poll."
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# SPDX-License-Identifier: MIT
|
|
4
|
+
|
|
5
|
+
module Confeature
|
|
6
|
+
module Internal
|
|
7
|
+
# Prerequisite-flag condition resolution + circular-dependency detection.
|
|
8
|
+
# Ported from the spec-117 TS reference
|
|
9
|
+
# (`packages/sdk-shared/src/prerequisites.ts`) and the spec-118 Python port,
|
|
10
|
+
# itself from common-js `RolloutEvaluator.evaluatePrerequisiteFlagCondition`.
|
|
11
|
+
#
|
|
12
|
+
# A prerequisite condition (`{ "f" => key, "c" => comparator, "v" => env }`)
|
|
13
|
+
# is resolved by **re-evaluating the prerequisite flag** in a child context
|
|
14
|
+
# (same config / user, SHARED `visited_flags` list), then comparing its
|
|
15
|
+
# resolved value to the expected `v` via the comparator (`Equals` /
|
|
16
|
+
# `NotEquals`). Both the resolved value and `v` are read through the
|
|
17
|
+
# prerequisite flag's own declared `SettingType`.
|
|
18
|
+
#
|
|
19
|
+
# **Cycle detection** uses the shared `visited_flags` list: push the CURRENT
|
|
20
|
+
# key, check whether the prerequisite key is already on the list BEFORE
|
|
21
|
+
# recursing — if so, push the prerequisite key too and raise
|
|
22
|
+
# `CircularDependencyError`. The outer `evaluate` rescue-all turns that into
|
|
23
|
+
# a fail-closed fallback + error, so a cycle is **bounded** (the key set is
|
|
24
|
+
# finite; the second visit raises) — never a hang, never a `SystemStackError`
|
|
25
|
+
# escape (FR-007, SC-004). On the non-cycle path the current key is popped
|
|
26
|
+
# after the child evaluation returns, restoring the list for siblings.
|
|
27
|
+
module Prerequisites
|
|
28
|
+
PREREQUISITE_EQUALS = 0
|
|
29
|
+
PREREQUISITE_NOT_EQUALS = 1
|
|
30
|
+
|
|
31
|
+
# Raised on a detected prerequisite cycle (caught by `evaluate`'s frame).
|
|
32
|
+
# `cycle` carries the `key -> … -> key` chain so the trace logger can emit
|
|
33
|
+
# ConfigCat's distinct ERROR 1002 line (`[<cycle>]`) without re-parsing the
|
|
34
|
+
# message (mirrors the .NET / Rust ports).
|
|
35
|
+
class CircularDependencyError < StandardError
|
|
36
|
+
attr_reader :cycle
|
|
37
|
+
|
|
38
|
+
def initialize(cycle)
|
|
39
|
+
@cycle = cycle
|
|
40
|
+
super("Circular dependency detected between the following depending flags: #{cycle}.")
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# A missing / invalid prerequisite flag — a fatal config error
|
|
45
|
+
# (fail-closed, caught by the evaluator's rescue-all).
|
|
46
|
+
class PrerequisiteReferenceError < StandardError; end
|
|
47
|
+
|
|
48
|
+
module_function
|
|
49
|
+
|
|
50
|
+
# Resolve one prerequisite-flag condition. The setting re-evaluator +
|
|
51
|
+
# value selector are injected so this module has no back-reference to the
|
|
52
|
+
# evaluator. Returns the comparison result (`true` / `false`). Raises
|
|
53
|
+
# `CircularDependencyError` on a detected cycle or
|
|
54
|
+
# `PrerequisiteReferenceError` on a missing prerequisite flag.
|
|
55
|
+
def evaluate_prerequisite_flag_condition(
|
|
56
|
+
condition, current_key, visited_flags, prereq_type, evaluate_prereq_flag, select_value
|
|
57
|
+
)
|
|
58
|
+
prereq_key = condition["f"]
|
|
59
|
+
raise PrerequisiteReferenceError, "Prerequisite flag key is missing or invalid." unless prereq_key.is_a?(String)
|
|
60
|
+
|
|
61
|
+
declared_type = prereq_type.call(prereq_key)
|
|
62
|
+
raise PrerequisiteReferenceError, "Prerequisite flag is missing or invalid." if declared_type.nil?
|
|
63
|
+
|
|
64
|
+
# The expected comparison value `v`, read through the prereq flag's type.
|
|
65
|
+
comparison_value = select_value.call(condition["v"], declared_type)
|
|
66
|
+
|
|
67
|
+
# Cycle detection: push current key, detect a revisit BEFORE recursing.
|
|
68
|
+
visited_flags.push(current_key)
|
|
69
|
+
if visited_flags.include?(prereq_key)
|
|
70
|
+
visited_flags.push(prereq_key)
|
|
71
|
+
raise CircularDependencyError, visited_flags.join(" -> ")
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
resolved_envelope = evaluate_prereq_flag.call(prereq_key)
|
|
75
|
+
# Restore the list for sibling prerequisites (common-js pops here).
|
|
76
|
+
visited_flags.pop
|
|
77
|
+
|
|
78
|
+
resolved_value = select_value.call(resolved_envelope, declared_type)
|
|
79
|
+
|
|
80
|
+
case condition["c"]
|
|
81
|
+
when PREREQUISITE_EQUALS then resolved_value == comparison_value
|
|
82
|
+
when PREREQUISITE_NOT_EQUALS then resolved_value != comparison_value
|
|
83
|
+
else raise PrerequisiteReferenceError, "Comparison operator is missing or invalid."
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|