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.
- checksums.yaml +7 -0
- data/.rspec +3 -0
- data/.rubocop.yml +191 -0
- data/.yardopts +16 -0
- data/CONTRIBUTING.md +131 -0
- data/LICENSE +201 -0
- data/README.md +183 -0
- data/RELEASE.md +313 -0
- data/Rakefile +16 -0
- data/convert_sdk.gemspec +50 -0
- data/lib/convert_sdk/api_manager.rb +288 -0
- data/lib/convert_sdk/background_timer.rb +129 -0
- data/lib/convert_sdk/bucketed_feature.rb +35 -0
- data/lib/convert_sdk/bucketed_variation.rb +43 -0
- data/lib/convert_sdk/bucketing_manager.rb +134 -0
- data/lib/convert_sdk/client.rb +417 -0
- data/lib/convert_sdk/comparisons.rb +257 -0
- data/lib/convert_sdk/config.rb +214 -0
- data/lib/convert_sdk/config_validator.rb +127 -0
- data/lib/convert_sdk/context.rb +618 -0
- data/lib/convert_sdk/data_manager.rb +897 -0
- data/lib/convert_sdk/data_store_manager.rb +185 -0
- data/lib/convert_sdk/enums/bucketing_error.rb +18 -0
- data/lib/convert_sdk/enums/feature_status.rb +13 -0
- data/lib/convert_sdk/enums/goal_data_key.rb +62 -0
- data/lib/convert_sdk/enums/log_level.rb +22 -0
- data/lib/convert_sdk/enums/rule_error.rb +19 -0
- data/lib/convert_sdk/enums/system_events.rb +29 -0
- data/lib/convert_sdk/event_manager.rb +125 -0
- data/lib/convert_sdk/experience_manager.rb +69 -0
- data/lib/convert_sdk/feature_manager.rb +367 -0
- data/lib/convert_sdk/fork_guard.rb +144 -0
- data/lib/convert_sdk/http_client.rb +198 -0
- data/lib/convert_sdk/log_manager.rb +168 -0
- data/lib/convert_sdk/murmur_hash3.rb +129 -0
- data/lib/convert_sdk/redactor.rb +93 -0
- data/lib/convert_sdk/rule_manager.rb +242 -0
- data/lib/convert_sdk/segments_manager.rb +241 -0
- data/lib/convert_sdk/sentinel.rb +57 -0
- data/lib/convert_sdk/stores/memory_store.rb +55 -0
- data/lib/convert_sdk/stores/redis_store.rb +126 -0
- data/lib/convert_sdk/version.rb +14 -0
- data/lib/convert_sdk/visitors_queue.rb +190 -0
- data/lib/convert_sdk.rb +218 -0
- data/scripts/check-generated-rbs-header.sh +41 -0
- data/steep/config_contract_probe.rb +154 -0
- 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
|