convert_sdk 1.0.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.
Files changed (47) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/.rubocop.yml +191 -0
  4. data/.yardopts +16 -0
  5. data/CONTRIBUTING.md +131 -0
  6. data/LICENSE +201 -0
  7. data/README.md +183 -0
  8. data/RELEASE.md +313 -0
  9. data/Rakefile +16 -0
  10. data/convert_sdk.gemspec +50 -0
  11. data/lib/convert_sdk/api_manager.rb +288 -0
  12. data/lib/convert_sdk/background_timer.rb +129 -0
  13. data/lib/convert_sdk/bucketed_feature.rb +35 -0
  14. data/lib/convert_sdk/bucketed_variation.rb +43 -0
  15. data/lib/convert_sdk/bucketing_manager.rb +134 -0
  16. data/lib/convert_sdk/client.rb +417 -0
  17. data/lib/convert_sdk/comparisons.rb +257 -0
  18. data/lib/convert_sdk/config.rb +214 -0
  19. data/lib/convert_sdk/config_validator.rb +127 -0
  20. data/lib/convert_sdk/context.rb +618 -0
  21. data/lib/convert_sdk/data_manager.rb +897 -0
  22. data/lib/convert_sdk/data_store_manager.rb +185 -0
  23. data/lib/convert_sdk/enums/bucketing_error.rb +18 -0
  24. data/lib/convert_sdk/enums/feature_status.rb +13 -0
  25. data/lib/convert_sdk/enums/goal_data_key.rb +62 -0
  26. data/lib/convert_sdk/enums/log_level.rb +22 -0
  27. data/lib/convert_sdk/enums/rule_error.rb +19 -0
  28. data/lib/convert_sdk/enums/system_events.rb +29 -0
  29. data/lib/convert_sdk/event_manager.rb +125 -0
  30. data/lib/convert_sdk/experience_manager.rb +69 -0
  31. data/lib/convert_sdk/feature_manager.rb +367 -0
  32. data/lib/convert_sdk/fork_guard.rb +144 -0
  33. data/lib/convert_sdk/http_client.rb +198 -0
  34. data/lib/convert_sdk/log_manager.rb +168 -0
  35. data/lib/convert_sdk/murmur_hash3.rb +129 -0
  36. data/lib/convert_sdk/redactor.rb +93 -0
  37. data/lib/convert_sdk/rule_manager.rb +242 -0
  38. data/lib/convert_sdk/segments_manager.rb +241 -0
  39. data/lib/convert_sdk/sentinel.rb +57 -0
  40. data/lib/convert_sdk/stores/memory_store.rb +55 -0
  41. data/lib/convert_sdk/stores/redis_store.rb +126 -0
  42. data/lib/convert_sdk/version.rb +14 -0
  43. data/lib/convert_sdk/visitors_queue.rb +190 -0
  44. data/lib/convert_sdk.rb +218 -0
  45. data/scripts/check-generated-rbs-header.sh +41 -0
  46. data/steep/config_contract_probe.rb +154 -0
  47. metadata +93 -0
@@ -0,0 +1,897 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module ConvertSdk
6
+ # The in-memory home of the project configuration snapshot and the ONLY
7
+ # surface through which config is read.
8
+ #
9
+ # +DataManager+ owns the project config as a *deep-frozen, string-keyed*
10
+ # snapshot (architecture Decision 5). Config arrives from one of two places —
11
+ # a live fetch (+GET {config_endpoint}/config/{sdkKey}+) or a developer-supplied
12
+ # +data:+ object — and in BOTH cases it is installed identically through
13
+ # {#install_config}: recursively frozen, then atomically swapped behind
14
+ # +@config_mutex+. Because each installed snapshot is a brand-new frozen object
15
+ # graph, decision paths read it LOCK-FREE (no per-read mutex): a reader either
16
+ # sees the whole previous snapshot or the whole new one, never a torn mix.
17
+ # Only install/swap takes the mutex.
18
+ #
19
+ # == No raw config hash crosses the boundary
20
+ #
21
+ # The parsed config envelope is wrapped here and exposed ONLY through
22
+ # hand-written reader methods (+#experiences+, +#feature_by_key(key)+, …) that
23
+ # return frozen sub-hashes / arrays. There is no public accessor for the raw
24
+ # snapshot and no OpenAPI codegen — the reader inventory is derived by hand
25
+ # from the actual config wire shape (the vendored +test-config.json+ fixture).
26
+ #
27
+ # == Wire shape
28
+ #
29
+ # The config envelope is +{"environment" => ..., "data" => {...}}+; the entity
30
+ # collections (+experiences+, +features+, +goals+, +audiences+, +segments+,
31
+ # optional +locations+) plus +account_id+ and the +project+ sub-hash live under
32
+ # +"data"+. +#project_id+ is +data.project.id+. Readers tolerate sparse or
33
+ # absent keys (return +nil+ / +[]+) so a partial config never crashes a reader.
34
+ #
35
+ # == Degrade-gracefully (NFR12)
36
+ #
37
+ # Before any config is installed every reader returns a sentinel (+nil+ for
38
+ # scalars / by-key lookups, +[]+ for collections) and {#config_available?} is
39
+ # +false+. The client constructs successfully even when the first fetch fails;
40
+ # decision methods (Story 2.11) key off these sentinels.
41
+ #
42
+ # == Config caching & TTL bookkeeping (Story 2.7)
43
+ #
44
+ # Every successful install ALSO writes the config through to the injected
45
+ # {DataStoreManager} under +convert_sdk.config.{sdkKey}+ (2.1's single key
46
+ # builder) wrapped as +{"config" => envelope, "fetched_at" => wall_clock}+.
47
+ # The store has no native TTL, so a *wall-clock* +fetched_at+ is stored for
48
+ # cross-process staleness (a Redis-backed cold start can serve a fresh shared
49
+ # entry without fetching). Independently, an in-process *monotonic* timestamp
50
+ # ({#install_config} records it via the injected +clock+) drives the
51
+ # decision-time TTL check ({#ensure_fresh_config!}) so wall-clock jumps can
52
+ # never expire a live snapshot. Monotonic for in-process TTL, wall-clock for
53
+ # the cross-process cache entry — two clocks, two purposes.
54
+ #
55
+ # == Lazy-TTL fallback (timer-off mode)
56
+ #
57
+ # When the background refresh timer is disabled (+data_refresh_interval: nil+),
58
+ # {#ensure_fresh_config!} performs an on-demand staleness check at decision
59
+ # entry points (PHP semantics): a snapshot older than +ttl+ triggers a
60
+ # synchronous refetch (via the injected +refetch+ callable) BEFORE deciding;
61
+ # a failed refetch keeps serving the stale snapshot (the callable warns). The
62
+ # refetch is guarded by a SEPARATE +@fetch_mutex+ (NOT the config mutex), so
63
+ # concurrent stale deciders collapse to ONE fetch (thundering-herd guard) and
64
+ # the HTTP I/O never holds the config mutex.
65
+ class DataManager
66
+ # @param log_manager [LogManager] injected logger for install diagnostics.
67
+ # @param data_store_manager [DataStoreManager, nil] persistence port for the
68
+ # config cache write; nil disables the write (standalone unit construction).
69
+ # @param config_key [String, nil] the cache key +convert_sdk.config.{sdkKey}+
70
+ # (built once by {DataStoreManager#config_key}); nil disables the cache.
71
+ # @param ttl [Numeric, nil] the configured +data_refresh_interval+ in seconds.
72
+ # A non-nil value is timer-ON mode (the background timer keeps config fresh,
73
+ # so {#ensure_fresh_config!} is a no-op); +nil+ is timer-OFF mode (Lambda /
74
+ # CLI), which enables the decision-time on-demand refetch and falls the
75
+ # effective staleness threshold back to {ConvertSdk::DEFAULT_CONFIG_TTL}
76
+ # (timer-off ≠ TTL-off). The timer-off mode is thus derived from +ttl.nil?+.
77
+ # @param clock [#call] a monotonic time source (seconds, Float) for in-process
78
+ # TTL math; defaults to +Process.clock_gettime(Process::CLOCK_MONOTONIC)+.
79
+ # @param refetch [#call, nil] a callable performing one full refresh cycle
80
+ # (HTTP refetch + install + warn-on-failure) for the synchronous timer-off
81
+ # path; injected by {Client} after construction (it owns the HTTP I/O and
82
+ # the lifecycle event). Invoked under the thundering-herd fetch mutex.
83
+ # @param bucketing_manager [BucketingManager, nil] the pure-math variation
84
+ # selector (Story 2.9); the decision flow's traffic-allocation step uses it.
85
+ # nil leaves the manager config-read-only (Story 2.5/2.7 standalone use).
86
+ # @param rule_manager [RuleManager, nil] the audience/location rule walker
87
+ # (Story 2.10). nil leaves the manager config-read-only.
88
+ # @param account_resolver [#call, nil] returns the account id for the visitor
89
+ # store key; defaults to {#account_id} (the live config reader). Injectable
90
+ # so a Context can supply its own resolution without re-reading config.
91
+ # @param project_resolver [#call, nil] returns the project id for the visitor
92
+ # store key; defaults to {#project_id}.
93
+ def initialize(log_manager:, data_store_manager: nil, config_key: nil, ttl: nil,
94
+ clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }, refetch: nil,
95
+ bucketing_manager: nil, rule_manager: nil,
96
+ account_resolver: nil, project_resolver: nil)
97
+ @log_manager = log_manager
98
+ @data_store_manager = data_store_manager
99
+ @config_key = config_key
100
+ @ttl = ttl
101
+ # Timer-off (Lambda/CLI) mode is exactly "no refresh interval configured".
102
+ @timer_off = ttl.nil?
103
+ @clock = clock
104
+ @refetch = refetch
105
+ # Decision-flow collaborators (Story 2.11). Config-read-only when absent.
106
+ @bucketing_manager = bucketing_manager
107
+ @rule_manager = rule_manager
108
+ @account_resolver = account_resolver || -> { account_id }
109
+ @project_resolver = project_resolver || -> { project_id }
110
+ # The deep-frozen config envelope, or nil before the first install. Read
111
+ # lock-free by every reader; replaced atomically under @config_mutex.
112
+ @config = nil #: Hash[String, untyped]?
113
+ # The monotonic timestamp of the live snapshot's install, or nil pre-config.
114
+ @fetched_at = nil #: Float?
115
+ # Thread safety: guarded by @config_mutex (install/swap + @fetched_at).
116
+ @config_mutex = Thread::Mutex.new
117
+ # Thundering-herd guard for the synchronous timer-off refetch — a SEPARATE
118
+ # mutex so the HTTP refetch never holds the config mutex.
119
+ @fetch_mutex = Thread::Mutex.new
120
+ end
121
+
122
+ # The synchronous timer-off refresh callable, injected by {Client} after
123
+ # construction (Client owns the single HTTP port and the lifecycle event).
124
+ # Performs one full refresh cycle (refetch + install + warn-on-failure).
125
+ # @return [#call, nil]
126
+ attr_accessor :refetch
127
+
128
+ # Install a parsed config envelope as the live snapshot.
129
+ #
130
+ # The hash is deep-frozen (a fresh recursively-frozen copy — the caller's
131
+ # input is never mutated) and atomically swapped in behind +@config_mutex+.
132
+ # A nil/non-Hash argument is rejected (logged) and leaves the current
133
+ # snapshot intact — install must never crash the host.
134
+ #
135
+ # The first-vs-subsequent determination is made ATOMICALLY inside
136
+ # +@config_mutex+ alongside the swap: the +ready+-once guard (Story 2.5) and
137
+ # the +config.updated+ refresh signal (Story 2.7) both key off the returned
138
+ # marker, so exactly one install in the manager's lifetime is +:first+ even
139
+ # under concurrent installs.
140
+ #
141
+ # @param hash [Hash{String=>Object}] the parsed config envelope
142
+ # (+{"environment" => ..., "data" => {...}}+).
143
+ # @return [Symbol, false] +:first+ on the first successful install,
144
+ # +:updated+ on any subsequent install, or +false+ when the argument was
145
+ # rejected (non-Hash) and no swap happened.
146
+ def install_config(hash)
147
+ unless hash.is_a?(Hash)
148
+ @log_manager.warn("DataManager#install_config: ignored non-Hash config (#{hash.class})")
149
+ return false
150
+ end
151
+
152
+ frozen = deep_freeze(hash)
153
+ now = @clock.call
154
+ first = @config_mutex.synchronize do
155
+ was_absent = @config.nil?
156
+ @config = frozen
157
+ @fetched_at = now
158
+ was_absent
159
+ end
160
+ cache_config(frozen)
161
+ @log_manager.info("DataManager#install_config: config installed")
162
+ first ? :first : :updated
163
+ end
164
+
165
+ # Install a non-stale cached config entry from the store as the live snapshot
166
+ # — the cross-process warm-start fallback used by {Client} when the initial
167
+ # fetch fails. The entry is +{"config" => envelope, "fetched_at" => wall}+;
168
+ # it is only installed when its WALL-CLOCK age is within +ttl+ (or the
169
+ # default TTL when +ttl+ is nil — timer-off mode). A stale or absent entry
170
+ # is ignored (returns nil). On a successful install an info line records the
171
+ # cache hit.
172
+ #
173
+ # @return [Symbol, nil] the {#install_config} marker on a fresh cache hit,
174
+ # or nil when no fresh entry was available.
175
+ def install_from_cache_if_fresh
176
+ entry = cached_entry
177
+ return nil unless entry
178
+
179
+ fetched_at = entry["fetched_at"]
180
+ config = entry["config"]
181
+ return nil unless fetched_at.is_a?(Numeric) && config.is_a?(Hash)
182
+ return nil if (Time.now.to_f - fetched_at) > effective_ttl
183
+
184
+ marker = install_config(config)
185
+ return nil unless marker.is_a?(Symbol)
186
+
187
+ @log_manager.info("DataManager#install_from_cache_if_fresh: serving cached config")
188
+ marker
189
+ end
190
+
191
+ # Decision-time TTL check for timer-off mode (AC#3, PHP semantics). When a
192
+ # +ttl+ is configured and the live snapshot is older than it (by the
193
+ # monotonic clock), synchronously refetch via the injected callable BEFORE
194
+ # the caller decides. Guarded by the SEPARATE @fetch_mutex so concurrent
195
+ # stale deciders collapse to ONE fetch; the refetch (HTTP I/O) runs OUTSIDE
196
+ # the config mutex. A failed refetch keeps the stale snapshot (the callable
197
+ # warns). A no-op when no ttl/refetch is wired or the snapshot is fresh.
198
+ # @return [void]
199
+ def ensure_fresh_config!
200
+ return unless @timer_off
201
+
202
+ refetch = @refetch
203
+ return if refetch.nil?
204
+ return unless config_stale?
205
+
206
+ @fetch_mutex.synchronize do
207
+ # Re-check inside the lock: a racing decider may have refreshed already.
208
+ return unless config_stale?
209
+
210
+ # The callable performs the full cycle (refetch + install + warn). On
211
+ # success it installs (advancing @fetched_at, so racing deciders that
212
+ # re-check see fresh); on failure it warns and the stale snapshot stays.
213
+ refetch.call
214
+ end
215
+ end
216
+
217
+ # @return [Boolean] true when a snapshot exists and its monotonic age exceeds
218
+ # the configured ttl (or the default ttl when ttl is nil).
219
+ def config_stale?
220
+ fetched_at = @config_mutex.synchronize { @fetched_at }
221
+ return false if fetched_at.nil?
222
+
223
+ (@clock.call - fetched_at) > effective_ttl
224
+ end
225
+
226
+ # @return [Boolean] true once a config snapshot has been installed.
227
+ def config_available?
228
+ !@config.nil?
229
+ end
230
+
231
+ # @return [String, nil] the account id (+data.account_id+), or nil pre-config.
232
+ def account_id
233
+ data&.fetch("account_id", nil)
234
+ end
235
+
236
+ # @return [String, nil] the project id (+data.project.id+), or nil pre-config.
237
+ def project_id
238
+ project&.fetch("id", nil)
239
+ end
240
+
241
+ # @return [Hash, nil] the frozen +data.project+ sub-hash, or nil pre-config.
242
+ def project
243
+ data&.fetch("project", nil)
244
+ end
245
+
246
+ # @return [Array<Hash>] the frozen experiences array ([] pre-config/absent).
247
+ def experiences
248
+ collection("experiences")
249
+ end
250
+
251
+ # @return [Array<Hash>] the frozen features array ([] pre-config/absent).
252
+ def features
253
+ collection("features")
254
+ end
255
+
256
+ # @return [Array<Hash>] the frozen goals array ([] pre-config/absent).
257
+ def goals
258
+ collection("goals")
259
+ end
260
+
261
+ # @return [Array<Hash>] the frozen audiences array ([] pre-config/absent).
262
+ def audiences
263
+ collection("audiences")
264
+ end
265
+
266
+ # @return [Array<Hash>] the frozen segments array ([] pre-config/absent).
267
+ def segments
268
+ collection("segments")
269
+ end
270
+
271
+ # @return [Array<Hash>] the frozen locations array ([] pre-config/absent).
272
+ # Absent in some projects (e.g. the vendored fixture) — nil-safe to [].
273
+ def locations
274
+ collection("locations")
275
+ end
276
+
277
+ # @param key [String] the experience +key+ to find.
278
+ # @return [Hash, nil] the frozen experience with that key, or nil.
279
+ def experience_by_key(key)
280
+ find_by_key(experiences, key)
281
+ end
282
+
283
+ # @param key [String] the feature +key+ to find.
284
+ # @return [Hash, nil] the frozen feature with that key, or nil.
285
+ def feature_by_key(key)
286
+ find_by_key(features, key)
287
+ end
288
+
289
+ # @param key [String] the goal +key+ to find.
290
+ # @return [Hash, nil] the frozen goal with that key, or nil.
291
+ def goal_by_key(key)
292
+ find_by_key(goals, key)
293
+ end
294
+
295
+ # @return [Array<String>] the frozen archived-experiences id list ([] absent).
296
+ # IDs may be Integer or String in the wire shape; compared via +to_s+.
297
+ def archived_experiences
298
+ collection("archived_experiences")
299
+ end
300
+
301
+ # ============================ DECISION FLOW =============================
302
+ # The ordered JS decision flow (data-manager.ts:227-720). ENTRY point for a
303
+ # single-experience decision; the across-all-experiences map lives in
304
+ # {ExperienceManager#select_variations}. The step ORDER is JS-pinned (research
305
+ # §Decision-Flow / data-manager.ts:302) and must NOT be reordered:
306
+ # 1. entity lookup (miss -> RuleError::NO_DATA_FOUND)
307
+ # 2. archived check (archived -> NO_DATA_FOUND)
308
+ # 3. environment match (mismatch -> NO_DATA_FOUND)
309
+ # 4. stored-bucketing lookup (sticky: sets is_bucketed)
310
+ # 5. locations / site_area (EMPTY = unrestricted)
311
+ # 6. audiences (permanent skipped when bucketed; transient always)
312
+ # 7. custom segments
313
+ # 8. traffic allocation + 9. variation selection
314
+ # (no variation -> BucketingError::VARIATION_NOT_DECIDED)
315
+ # Every miss returns its JS-parity {Sentinel} PAIRED with a debug reason log.
316
+ #
317
+ # @param visitor_id [String] the visitor identifier.
318
+ # @param experience_key [String] the experience +key+ to decide.
319
+ # @param attributes [Hash] +:visitor_properties+, +:location_properties+,
320
+ # +:environment+, +:update_visitor_properties+.
321
+ # @return [BucketedVariation, Sentinel] a frozen variation or a sentinel miss.
322
+ def get_bucketing(visitor_id, experience_key, attributes = {})
323
+ experience = match_rules_by_field(visitor_id, experience_key, attributes)
324
+ return experience if experience.is_a?(Sentinel)
325
+ return RuleError::NO_DATA_FOUND if experience.nil?
326
+
327
+ retrieve_bucketing(visitor_id, experience, attributes)
328
+ end
329
+
330
+ # ========================== CONVERSION TRACKING =========================
331
+ # Track a conversion for +visitor_id+ on +goal_key+ with optional revenue /
332
+ # transaction +goal_data+, applying two-level goal dedup (Story 4.3).
333
+ #
334
+ # == Two-level dedup + the Android qs-01 structural fix
335
+ #
336
+ # Dedup is keyed at TWO levels: the visitor lives in the STORE KEY
337
+ # (+{accountId}-{projectId}-{visitorId}+) and the goal lives in the
338
+ # +goals[goalId]+ map inside that visitor's +StoreData+. The CHECK (has this
339
+ # goal already converted?) and the MARK (record it) both run inside ONE
340
+ # {DataStoreManager#merge_visitor_data} block — i.e. inside the store's merge
341
+ # mutex — so a check-then-mark race cannot double-count (the Android qs-01
342
+ # defect class). The block computes the enqueue verdict into a closure flag;
343
+ # the caller enqueues only when that flag came back true.
344
+ #
345
+ # == force_multiple_transactions (accepted parity break)
346
+ #
347
+ # +force_multiple_transactions: true+ BYPASSES the dedup check entirely (the
348
+ # conversion is always returned for enqueue) but does NOT re-mark the goal —
349
+ # the prior mark, if any, persists. Conservative default: force is for
350
+ # legitimate multiple transactions, not to reset dedup state; re-marking
351
+ # would corrupt dedup for a subsequent non-forced call on the same goal.
352
+ #
353
+ # == Return contract
354
+ #
355
+ # Returns the wire-shaped conversion +data+ hash to enqueue —
356
+ # +{"goalId" => id, "goalData" => [{key,value}...]? (omitted when none),
357
+ # "bucketingData" => {experienceId => variationId}? (omitted when the visitor
358
+ # has no stored bucketing)}+ — or +nil+ when nothing should be enqueued
359
+ # (unknown goal key, or a deduplicated repeat). Each non-enqueue path emits a
360
+ # debug line (sentinel/nil + silent is forbidden). The Context wraps the
361
+ # returned data hash into the +{eventType:'conversion', data:{...}}+ envelope.
362
+ #
363
+ # @param visitor_id [String] the converting visitor.
364
+ # @param goal_key [String] the goal +key+ (resolved to its id via the reader).
365
+ # @param goal_data [Hash, nil] optional revenue/transaction data; keys are the
366
+ # snake_case Ruby forms (or wire forms) of the eight {GoalDataKey} platform
367
+ # keys — unknown keys are rejected with a debug line.
368
+ # @param force_multiple_transactions [Boolean] bypass the dedup check.
369
+ # @return [Hash, nil] the conversion data hash to enqueue, or nil.
370
+ def convert(visitor_id, goal_key, goal_data: nil, force_multiple_transactions: false)
371
+ goal = goal_by_key(goal_key)
372
+ if goal.nil?
373
+ @log_manager&.debug("DataManager#convert: no goal found for key=#{goal_key}")
374
+ return nil
375
+ end
376
+
377
+ goal_id = goal["id"].to_s
378
+ return nil unless dedup_and_mark(visitor_id, goal_id, force_multiple_transactions)
379
+
380
+ build_conversion_data(visitor_id, goal_id, goal_data)
381
+ end
382
+
383
+ private
384
+
385
+ # The atomic check-then-mark (Story 4.3 / qs-01). Runs the dedup decision AND
386
+ # the mark inside ONE store-merge block (the merge mutex). Returns true when
387
+ # the caller should enqueue, false when the conversion is deduplicated.
388
+ #
389
+ # - force: enqueue=true, write NOTHING (no re-mark — conservative default).
390
+ # - already marked (no force): enqueue=false (debug log), write nothing.
391
+ # - unmarked: enqueue=true, write +{goals: {goalId => true}}+ (the mark).
392
+ #
393
+ # The verdict is captured in a closure flag because the merge block returns
394
+ # the merged StoreData, not the decision. A nil store manager (standalone
395
+ # construction) degrades to enqueue=true with no persistence.
396
+ def dedup_and_mark(visitor_id, goal_id, force)
397
+ manager = @data_store_manager
398
+ return true if manager.nil?
399
+
400
+ should_enqueue = false
401
+ manager.merge_visitor_data(@account_resolver.call.to_s, @project_resolver.call.to_s, visitor_id) do |current|
402
+ already = goal_marked?(current, goal_id)
403
+ should_enqueue, partial = resolve_dedup(goal_id, already, force)
404
+ partial
405
+ end
406
+ should_enqueue
407
+ end
408
+
409
+ # The dedup verdict for one (goal, already-marked?, force) triple: returns
410
+ # +[should_enqueue, store_partial]+. Kept pure (no I/O) so the merge block
411
+ # stays a thin atomic wrapper around it.
412
+ def resolve_dedup(goal_id, already, force)
413
+ if force
414
+ [true, {}] # bypass check; do NOT re-mark (prior mark persists)
415
+ elsif already
416
+ @log_manager&.debug("DataManager#convert: goal #{goal_id} already converted — skipping (dedup)")
417
+ [false, {}]
418
+ else
419
+ [true, { "goals" => { goal_id => true } }] # first conversion → mark
420
+ end
421
+ end
422
+
423
+ # True when the visitor's stored +goals+ map carries a truthy mark for goal.
424
+ def goal_marked?(current, goal_id)
425
+ goals = current["goals"]
426
+ goals.is_a?(Hash) && goals[goal_id]
427
+ end
428
+
429
+ # Build the wire-shaped conversion +data+ hash: +goalId+ always; +goalData+
430
+ # only when at least one valid pair was supplied; +bucketingData+ only when
431
+ # the visitor has a non-empty stored bucketing map (JS parity — omitted for
432
+ # an unbucketed visitor).
433
+ def build_conversion_data(visitor_id, goal_id, goal_data)
434
+ data_hash = { "goalId" => goal_id } #: Hash[String, untyped]
435
+ pairs = goal_data_pairs(goal_data)
436
+ data_hash["goalData"] = pairs unless pairs.empty?
437
+ bucketing = stored_bucketing_map(visitor_id)
438
+ data_hash["bucketingData"] = bucketing unless bucketing.empty?
439
+ data_hash
440
+ end
441
+
442
+ # Translate the caller's +goal_data+ hash into the wire +[{key, value}]+ pair
443
+ # array (the JS wire shape — NOT a flat map). Each key is validated through
444
+ # {GoalDataKey.wire_key_for}; unknown keys are dropped with a debug line.
445
+ # Insertion order follows the caller's hash (Ruby hashes are ordered).
446
+ def goal_data_pairs(goal_data)
447
+ return [] unless goal_data.is_a?(Hash)
448
+
449
+ pairs = [] #: Array[Hash[String, untyped]]
450
+ goal_data.each do |key, value|
451
+ wire_key = GoalDataKey.wire_key_for(key)
452
+ if wire_key.nil?
453
+ @log_manager&.debug("DataManager#convert: ignoring unknown goalData key=#{key}")
454
+ next
455
+ end
456
+ pairs << { "key" => wire_key, "value" => value }
457
+ end
458
+ pairs
459
+ end
460
+
461
+ # The visitor's stored bucketing map (+StoreData["bucketing"]+,
462
+ # +{experienceId => variationId}+) for +bucketingData+ attribution, or +{}+.
463
+ def stored_bucketing_map(visitor_id)
464
+ bucketing = visitor_store_data(visitor_id)["bucketing"]
465
+ bucketing.is_a?(Hash) ? bucketing : {}
466
+ end
467
+
468
+ # Steps 1-7: resolve the experience and run every eligibility gate up to (but
469
+ # not including) traffic allocation. Returns the matched experience Hash, a
470
+ # {Sentinel} (a propagated {RuleError} from a rule walk), or +nil+ (a plain
471
+ # eligibility miss the caller maps to NO_DATA_FOUND). Mirrors JS
472
+ # +matchRulesByField+ (data-manager.ts:202-471).
473
+ def match_rules_by_field(visitor_id, experience_key, attributes)
474
+ # Steps 1-3 — entity / archived / environment gates (each miss -> nil).
475
+ experience = eligible_experience(experience_key, attributes[:environment])
476
+ return nil if experience.nil?
477
+
478
+ # Step 4 — stored-bucketing lookup (sticky). Drives permanent-audience skip.
479
+ is_bucketed = visitor_bucketed?(visitor_id, experience)
480
+
481
+ # Step 5 — locations / site_area (empty = unrestricted).
482
+ location_outcome = match_locations(attributes[:location_properties], experience)
483
+ return location_outcome if location_outcome.is_a?(Sentinel)
484
+ return reason_miss(experience, "location not match") unless location_outcome
485
+
486
+ # Step 6 — audiences (permanent skipped when bucketed; transient always).
487
+ audiences_outcome = match_audiences(experience, attributes[:visitor_properties], is_bucketed)
488
+ return audiences_outcome if audiences_outcome.is_a?(Sentinel)
489
+
490
+ # Step 7 — custom segments. Both must pass to reach variation selection.
491
+ eligible_for_variation(experience, audiences_outcome && custom_segments_matched?(experience, visitor_id))
492
+ end
493
+
494
+ # Steps 1-3: the entity / archived / environment eligibility gates. Returns
495
+ # the matched experience Hash, or nil on any gate miss (each miss logs the
496
+ # failed step). Splitting these guards out keeps the step-walk above flat.
497
+ def eligible_experience(experience_key, environment)
498
+ experience = experience_by_key(experience_key)
499
+ if experience.nil?
500
+ @log_manager&.debug("DataManager#match_rules_by_field: no experience found for key=#{experience_key}")
501
+ return nil
502
+ end
503
+ return reason_miss(experience, "experience archived") if archived?(experience)
504
+ return reason_miss(experience, "environment not match") unless environment_match?(experience, environment)
505
+
506
+ experience
507
+ end
508
+
509
+ # The post-audience/segment terminal: the experience is returned only when the
510
+ # gates passed AND it has variations; otherwise a reason-logged nil.
511
+ def eligible_for_variation(experience, gates_passed)
512
+ return reason_miss(experience, "audience not match") unless gates_passed
513
+ return reason_miss(experience, "variations not found") if variation_list(experience).empty?
514
+
515
+ @log_manager&.debug("DataManager#match_rules_by_field: rules matched id=#{experience["id"]}")
516
+ experience
517
+ end
518
+
519
+ # Log a flow-step miss naming the failed step and return nil — the single
520
+ # "sentinel + silent is forbidden" pairing site for the plain (non-RuleError)
521
+ # eligibility misses.
522
+ def reason_miss(experience, step)
523
+ @log_manager&.debug("DataManager#match_rules_by_field: #{step} id=#{experience["id"]}")
524
+ nil
525
+ end
526
+
527
+ # Steps 4/8/9: return the stored sticky variation if usable, else bucket fresh.
528
+ # Mirrors JS +_retrieveBucketing+ (data-manager.ts:558-720).
529
+ def retrieve_bucketing(visitor_id, experience, attributes)
530
+ sticky = sticky_variation(visitor_id, experience)
531
+ return sticky if sticky
532
+
533
+ bucket_fresh(visitor_id, experience, attributes)
534
+ end
535
+
536
+ # Step 4 (return path): the stored sticky variation rehydrated from CURRENT
537
+ # config, or nil when there is no stored decision OR the stored variation has
538
+ # drifted out of config (config-drift fallthrough -> caller re-buckets).
539
+ def sticky_variation(visitor_id, experience)
540
+ stored = stored_variation_id(visitor_id, experience)
541
+ return nil if stored.nil?
542
+
543
+ experience_id = experience["id"].to_s
544
+ variation = retrieve_variation(experience, stored)
545
+ if variation
546
+ @log_manager&.debug("DataManager#retrieve_bucketing: sticky hit exp=#{experience_id} var=#{stored}")
547
+ return build_bucketed_variation(experience, variation, nil)
548
+ end
549
+
550
+ @log_manager&.debug("DataManager#retrieve_bucketing: stored var #{stored} drifted from config — re-bucketing")
551
+ nil
552
+ end
553
+
554
+ # Steps 8/9: fresh bucketing through the engine + persistence + rehydration.
555
+ # No covering bucket OR a drifted-out selected id -> VARIATION_NOT_DECIDED.
556
+ def bucket_fresh(visitor_id, experience, attributes)
557
+ experience_id = experience["id"].to_s
558
+ buckets = build_buckets(experience)
559
+ decision = @bucketing_manager&.bucket_for_visitor(buckets, visitor_id, experience_id: experience_id)
560
+ variation_id = decision&.fetch(:variation_id, nil)
561
+ variation = variation_id && retrieve_variation(experience, variation_id)
562
+ if variation.nil?
563
+ @log_manager&.debug("DataManager#retrieve_bucketing: unable to select bucket exp=#{experience_id}")
564
+ return BucketingError::VARIATION_NOT_DECIDED
565
+ end
566
+
567
+ persist_bucketing(visitor_id, experience_id, variation_id, attributes)
568
+ @log_manager&.debug("DataManager#retrieve_bucketing: bucketed exp=#{experience_id} var=#{variation_id}")
569
+ build_bucketed_variation(experience, variation, decision&.fetch(:bucketing_allocation, nil))
570
+ end
571
+
572
+ # True when +experience.id+ is in the archived-experiences list (to_s match).
573
+ def archived?(experience)
574
+ id = experience["id"].to_s
575
+ archived_experiences.any? { |archived| archived.to_s == id }
576
+ end
577
+
578
+ # JS environment-match: +experience.environment ? experience.environment ===
579
+ # env : true+ (singular scalar; skip when the experience declares none).
580
+ def environment_match?(experience, environment)
581
+ experience_env = experience["environment"]
582
+ return true if experience_env.nil? || experience_env == ""
583
+
584
+ experience_env == environment
585
+ end
586
+
587
+ # Step 4: a visitor is "bucketed" for this experience when a stored variation
588
+ # id exists AND still resolves to a variation in the CURRENT config (a drifted
589
+ # stored id does NOT count as bucketed). Mirrors JS data-manager.ts:280-289.
590
+ def visitor_bucketed?(visitor_id, experience)
591
+ stored = stored_variation_id(visitor_id, experience)
592
+ return false if stored.nil?
593
+
594
+ !retrieve_variation(experience, stored).nil?
595
+ end
596
+
597
+ # The variation id stored in the visitor's bucketing map for this experience,
598
+ # or nil. Reads the visitor StoreData via the store seam (in-memory, NFR1).
599
+ def stored_variation_id(visitor_id, experience)
600
+ bucketing = visitor_store_data(visitor_id)["bucketing"]
601
+ return nil unless bucketing.is_a?(Hash)
602
+
603
+ value = bucketing[experience["id"].to_s]
604
+ value&.to_s
605
+ end
606
+
607
+ # Step 5: locations array (by id) OR site_area rules OR unrestricted. Returns
608
+ # true/false, or a propagated {RuleError} sentinel from the site_area walk.
609
+ def match_locations(location_properties, experience)
610
+ return true unless location_properties
611
+
612
+ location_ids = experience["locations"]
613
+ return match_location_list(location_properties, location_ids) if location_ids.is_a?(Array) && !location_ids.empty?
614
+ return match_site_area(location_properties, experience["site_area"]) if experience["site_area"]
615
+
616
+ @log_manager&.info("DataManager#match_locations: location not restricted")
617
+ true
618
+ end
619
+
620
+ # Locations-by-id branch: any attached location whose rules match wins. An
621
+ # empty resolved set is unrestricted (true), mirroring JS data-manager.ts:316.
622
+ def match_location_list(location_properties, location_ids)
623
+ located = items_by_ids(location_ids, locations)
624
+ return true if located.empty?
625
+
626
+ located.any? do |location|
627
+ @rule_manager&.is_rule_matched(location_properties, location["rules"], "Location ##{location["id"]}") == true
628
+ end
629
+ end
630
+
631
+ # site_area branch: a single rule walk; a propagated {RuleError} sentinel
632
+ # surfaces unchanged, otherwise the boolean match.
633
+ def match_site_area(location_properties, site_area)
634
+ matched = @rule_manager&.is_rule_matched(location_properties, site_area, "SiteArea")
635
+ return matched if matched.is_a?(Sentinel)
636
+
637
+ matched == true
638
+ end
639
+
640
+ # Step 6: audiences. Empty experience audiences -> unrestricted. Permanent
641
+ # audiences are filtered out once the visitor is bucketed; transient always
642
+ # re-evaluated. +matching_options.audiences == "all"+ requires every checked
643
+ # audience to match; otherwise any match suffices. Returns true/false or a
644
+ # propagated {RuleError} sentinel. Mirrors JS data-manager.ts:350-416.
645
+ def match_audiences(experience, visitor_properties, is_bucketed)
646
+ # JS parity (data-manager.ts:356-416): +audiencesMatched+ defaults to FALSE
647
+ # and is only ever set true INSIDE +if (visitorProperties)+. A nil/absent
648
+ # visitor-properties bag therefore GATES the experience (false), even when
649
+ # the experience attaches no audiences — the no-properties visitor is never
650
+ # eligible. A PRESENT-but-empty bag (+{}+, truthy in both languages) does
651
+ # proceed to the empty-audiences-unrestricted / rule-evaluation logic below.
652
+ return false if visitor_properties.nil?
653
+
654
+ to_check = audiences_to_check(experience, is_bucketed)
655
+ return true if to_check.empty? # unrestricted (no audiences, or all permanent+bucketed)
656
+
657
+ matched = matched_audiences(to_check, visitor_properties)
658
+ return matched if matched.is_a?(Sentinel)
659
+
660
+ audiences_verdict?(experience, matched, to_check)
661
+ end
662
+
663
+ # Resolve the audiences that gate this call: the experience's attached
664
+ # audiences MINUS permanent ones once the visitor is bucketed (transient
665
+ # always re-evaluated — decided behavior 2026-06-07). Returns [] for the two
666
+ # unrestricted cases (no attached audiences, or every attached audience is
667
+ # permanent and the visitor is bucketed), logging which case applied.
668
+ def audiences_to_check(experience, is_bucketed)
669
+ attached = items_by_ids(experience["audiences"], audiences)
670
+ if attached.empty?
671
+ @log_manager&.info("DataManager#match_audiences: audience not restricted")
672
+ return []
673
+ end
674
+
675
+ to_check = attached.reject { |a| is_bucketed && a["type"] == "permanent" }
676
+ @log_manager&.info("DataManager#match_audiences: non-permanent audience not restricted") if to_check.empty?
677
+ to_check
678
+ end
679
+
680
+ # Walk each checked audience's rules; collect the matches. A propagated
681
+ # {RuleError} sentinel from any walk short-circuits and is returned as-is.
682
+ def matched_audiences(to_check, visitor_properties)
683
+ matched = [] #: Array[Hash[String, untyped]]
684
+ to_check.each do |audience|
685
+ next unless audience["rules"]
686
+
687
+ result = @rule_manager&.is_rule_matched(visitor_properties, audience["rules"], "audience ##{audience["id"]}")
688
+ return result if result.is_a?(Sentinel)
689
+
690
+ matched << audience if result == true
691
+ end
692
+ matched
693
+ end
694
+
695
+ # ALL mode requires every checked audience matched; otherwise any match passes.
696
+ def audiences_verdict?(experience, matched, to_check)
697
+ if all_match_required?(experience)
698
+ matched.length == to_check.length
699
+ else
700
+ !matched.empty?
701
+ end
702
+ end
703
+
704
+ # Step 7: custom segments. The experience's audience ids are matched against
705
+ # the segments collection; a present segment must be in the visitor's stored
706
+ # customSegments list. Empty segments -> unrestricted. Mirrors JS
707
+ # data-manager.ts:417-440 + filterMatchedCustomSegments.
708
+ def custom_segments_matched?(experience, visitor_id)
709
+ audience_ids = experience["audiences"]
710
+ segs = audience_ids.is_a?(Array) ? items_by_ids(audience_ids, segments) : [] #: Array[Hash[String, untyped]]
711
+ if segs.empty?
712
+ @log_manager&.info("DataManager#custom_segments_matched?: segmentation not restricted")
713
+ return true
714
+ end
715
+
716
+ custom = custom_segments(visitor_id)
717
+ segs.any? { |seg| seg["id"] && custom.include?(seg["id"]) }
718
+ end
719
+
720
+ # The visitor's stored customSegments list (under StoreData segments).
721
+ def custom_segments(visitor_id)
722
+ segments_map = visitor_store_data(visitor_id)["segments"]
723
+ return [] unless segments_map.is_a?(Hash)
724
+
725
+ list = segments_map["customSegments"]
726
+ list.is_a?(Array) ? list : []
727
+ end
728
+
729
+ # +true+ when the experience requires ALL checked audiences to match.
730
+ def all_match_required?(experience)
731
+ experience.dig("settings", "matching_options", "audiences") == "all"
732
+ end
733
+
734
+ # Build the variation=>traffic buckets for the bucketing engine: running
735
+ # variations with positive (or absent -> 100) traffic allocation only. Mirrors
736
+ # JS data-manager.ts:622-637.
737
+ def build_buckets(experience)
738
+ buckets = {} #: Hash[String, (Integer | Float)]
739
+ variation_list(experience).each do |variation|
740
+ next unless bucketable_variation?(variation)
741
+
742
+ buckets[variation["id"]] = variation["traffic_allocation"] || 100.0
743
+ end
744
+ buckets
745
+ end
746
+
747
+ # The experience's variations as a typed array (untyped elements), or [].
748
+ # Centralizes the +Array(experience["variations"])+ coercion so Steep sees a
749
+ # concrete element type (avoiding a +bot+ block parameter).
750
+ def variation_list(experience)
751
+ list = experience["variations"]
752
+ list.is_a?(Array) ? list : []
753
+ end
754
+
755
+ # A variation is bucketable when it is a Hash with an id, is running (or has
756
+ # no status), and has positive (or absent -> 100%) traffic allocation. A
757
+ # zero/negative allocation means a stopped variation (excluded). Mirrors the
758
+ # JS status + traffic filters (data-manager.ts:622-635).
759
+ def bucketable_variation?(variation)
760
+ return false unless variation.is_a?(Hash) && variation["id"]
761
+
762
+ status = variation["status"]
763
+ return false if status && status != "running"
764
+
765
+ allocation = variation["traffic_allocation"]
766
+ allocation.nil? || (allocation.is_a?(Numeric) && allocation.positive?)
767
+ end
768
+
769
+ # Persist the bucketing decision into the visitor's StoreData bucketing map
770
+ # (atomic merge via DataStoreManager). Optionally also stores visitor
771
+ # properties as segments (JS updateVisitorProperties path). In-memory store
772
+ # ops only (NFR1; user-supplied Redis trades the no-disk contract).
773
+ def persist_bucketing(visitor_id, experience_id, variation_id, attributes)
774
+ manager = @data_store_manager
775
+ return if manager.nil?
776
+
777
+ visitor_properties = attributes[:visitor_properties]
778
+ update = attributes[:update_visitor_properties]
779
+ manager.merge_visitor_data(@account_resolver.call.to_s, @project_resolver.call.to_s, visitor_id) do |_current|
780
+ partial = { "bucketing" => { experience_id => variation_id } }
781
+ partial["segments"] = visitor_properties if update && visitor_properties.is_a?(Hash)
782
+ partial
783
+ end
784
+ end
785
+
786
+ # Read the visitor's StoreData via the store seam, or the empty shape.
787
+ def visitor_store_data(visitor_id)
788
+ manager = @data_store_manager
789
+ return {} if manager.nil?
790
+
791
+ key = manager.visitor_key(@account_resolver.call.to_s, @project_resolver.call.to_s, visitor_id)
792
+ stored = manager.get(key)
793
+ stored.is_a?(Hash) ? stored : {}
794
+ end
795
+
796
+ # Resolve a variation Hash by id within an experience's variations, or nil.
797
+ def retrieve_variation(experience, variation_id)
798
+ target = variation_id.to_s
799
+ variation_list(experience).find do |variation|
800
+ variation.is_a?(Hash) && variation["id"].to_s == target
801
+ end
802
+ end
803
+
804
+ # Build the frozen {BucketedVariation} from the experience + variation config
805
+ # entities (never a raw config hash). Mirrors JS data-manager.ts:706-717.
806
+ def build_bucketed_variation(experience, variation, bucketing_allocation)
807
+ BucketedVariation.new(
808
+ experience_id: experience["id"],
809
+ experience_key: experience["key"],
810
+ experience_name: experience["name"],
811
+ bucketing_allocation: bucketing_allocation,
812
+ id: variation["id"],
813
+ key: variation["key"],
814
+ name: variation["name"],
815
+ status: variation["status"],
816
+ traffic_allocation: variation["traffic_allocation"],
817
+ changes: variation["changes"]
818
+ )
819
+ end
820
+
821
+ # Select the entities in +list+ whose +id+ is in +ids+ (to_s match). Mirrors
822
+ # JS getItemsByIds (data-manager.ts:1339-1359).
823
+ def items_by_ids(ids, list)
824
+ return [] unless ids.is_a?(Array)
825
+
826
+ wanted = ids.map(&:to_s)
827
+ list.select { |entity| entity.is_a?(Hash) && wanted.include?(entity["id"].to_s) }
828
+ end
829
+
830
+ # Write the freshly-installed config through to the store, wrapped with a
831
+ # WALL-CLOCK +fetched_at+ for cross-process staleness. A no-op when no store
832
+ # or key is wired (standalone unit construction). The DataStoreManager
833
+ # contains any store failure (logged), so this never crashes an install.
834
+ def cache_config(frozen)
835
+ store = @data_store_manager
836
+ key = @config_key
837
+ return if store.nil? || key.nil?
838
+
839
+ store.set(key, { "config" => frozen, "fetched_at" => Time.now.to_f })
840
+ end
841
+
842
+ # Read the raw cache entry from the store, or nil when no store/key is wired
843
+ # or nothing is cached.
844
+ def cached_entry
845
+ store = @data_store_manager
846
+ key = @config_key
847
+ return nil if store.nil? || key.nil?
848
+
849
+ entry = store.get(key)
850
+ entry.is_a?(Hash) ? entry : nil
851
+ end
852
+
853
+ # The staleness threshold: the configured ttl, or the SDK default (300s) in
854
+ # timer-off mode (ttl nil ≠ TTL-off — Lambda converges on the same cadence).
855
+ def effective_ttl
856
+ @ttl || ConvertSdk::DEFAULT_CONFIG_TTL
857
+ end
858
+
859
+ # The frozen +"data"+ sub-hash of the live snapshot, or nil pre-config.
860
+ # Read lock-free: @config is either nil or a fully-frozen graph.
861
+ def data
862
+ @config&.fetch("data", nil)
863
+ end
864
+
865
+ # Fetch a frozen collection under +"data"+, defaulting to a frozen empty
866
+ # array when the snapshot or the key is absent.
867
+ def collection(name)
868
+ found = data&.fetch(name, nil)
869
+ found.is_a?(Array) ? found : []
870
+ end
871
+
872
+ # Linear scan for the entity whose +"key"+ matches +key+. Entities without a
873
+ # +"key"+ (sparse fixture rows) simply never match. Returns the frozen entity.
874
+ def find_by_key(list, key)
875
+ list.find { |entity| entity.is_a?(Hash) && entity["key"] == key }
876
+ end
877
+
878
+ # Build a recursively-frozen copy of +node+. Hashes and arrays are rebuilt
879
+ # with frozen children then frozen; strings are duped-and-frozen; immutable
880
+ # scalars (Integer/Float/Symbol/true/false/nil) pass through unchanged. The
881
+ # caller's original object graph is never mutated.
882
+ def deep_freeze(node)
883
+ case node
884
+ when Hash
885
+ result = {} #: Hash[untyped, untyped]
886
+ node.each { |k, v| result[deep_freeze(k)] = deep_freeze(v) }
887
+ result.freeze
888
+ when Array
889
+ node.map { |element| deep_freeze(element) }.freeze
890
+ when String
891
+ node.frozen? ? node : node.dup.freeze
892
+ else
893
+ node
894
+ end
895
+ end
896
+ end
897
+ end