Class: ConvertSdk::DataManager
- Inherits:
-
Object
- Object
- ConvertSdk::DataManager
- Defined in:
- lib/convert_sdk/data_manager.rb
Overview
The in-memory home of the project configuration snapshot and the ONLY surface through which config is read.
DataManager owns the project config as a deep-frozen, string-keyed
snapshot (architecture Decision 5). Config arrives from one of two places —
a live fetch (+GET config_endpoint/config/sdkKey+) or a developer-supplied
data: object — and in BOTH cases it is installed identically through
#install_config: recursively frozen, then atomically swapped behind
@config_mutex. Because each installed snapshot is a brand-new frozen object
graph, decision paths read it LOCK-FREE (no per-read mutex): a reader either
sees the whole previous snapshot or the whole new one, never a torn mix.
Only install/swap takes the mutex.
No raw config hash crosses the boundary
The parsed config snapshot is exposed ONLY through hand-written reader methods
(+#experiences+, #feature_by_key(key), …) that return frozen sub-hashes /
arrays. There is no public accessor for the raw snapshot and no OpenAPI codegen
— the reader inventory is derived by hand from the actual config wire shape
(the vendored test-config.json fixture).
Wire shape (flat/root — JS/PHP/Android parity)
Entities live at the ROOT of the config snapshot: account_id, project,
experiences, features, goals, audiences, segments,
archived_experiences, and optional locations. #project_id is
root["project"]["id"]. This matches the live endpoint shape (no "data"
wrapper), the JS SDK, the PHP SDK, and the Android OpenAPI-generated schema.
Readers tolerate sparse or absent keys (return nil / []) so a partial
config never crashes a reader.
Degrade-gracefully (NFR12)
Before any config is installed every reader returns a sentinel (+nil+ for
scalars / by-key lookups, [] for collections) and #config_available? is
false. The client constructs successfully even when the first fetch fails;
decision methods (Story 2.11) key off these sentinels.
Config caching & TTL bookkeeping (Story 2.7)
Every successful install ALSO writes the config through to the injected
DataStoreManager under convert_sdk.config.{sdkKey} (2.1's single key
builder) wrapped as {"config" => envelope, "fetched_at" => wall_clock}.
The store has no native TTL, so a wall-clock fetched_at is stored for
cross-process staleness (a Redis-backed cold start can serve a fresh shared
entry without fetching). Independently, an in-process monotonic timestamp
(#install_config records it via the injected clock) drives the
decision-time TTL check (#ensure_fresh_config!) so wall-clock jumps can
never expire a live snapshot. Monotonic for in-process TTL, wall-clock for
the cross-process cache entry — two clocks, two purposes.
Lazy-TTL fallback (timer-off mode)
When the background refresh timer is disabled (+data_refresh_interval: nil+),
#ensure_fresh_config! performs an on-demand staleness check at decision
entry points (PHP semantics): a snapshot older than ttl triggers a
synchronous refetch (via the injected refetch callable) BEFORE deciding;
a failed refetch keeps serving the stale snapshot (the callable warns). The
refetch is guarded by a SEPARATE @fetch_mutex (NOT the config mutex), so
concurrent stale deciders collapse to ONE fetch (thundering-herd guard) and
the HTTP I/O never holds the config mutex.
Instance Attribute Summary collapse
-
#refetch ⇒ #call?
The synchronous timer-off refresh callable, injected by Client after construction (Client owns the single HTTP port and the lifecycle event).
Instance Method Summary collapse
-
#account_id ⇒ String?
The account id (+account_id+ at the config root), or nil pre-config.
-
#archived_experiences ⇒ Array<String>
The frozen archived-experiences id list ([] absent).
-
#audiences ⇒ Array<Hash>
The frozen audiences array ([] pre-config/absent).
-
#config_available? ⇒ Boolean
True once a USABLE config (account_id + project.id both present) is installed.
-
#config_stale? ⇒ Boolean
True when a snapshot exists and its monotonic age exceeds the configured ttl (or the default ttl when ttl is nil).
-
#convert(visitor_id, goal_key, goal_data: nil, force_multiple_transactions: false) ⇒ Hash?
CONVERSION TRACKING ========================= Track a conversion for
visitor_idongoal_keywith optional revenue / transactiongoal_data, applying two-level goal dedup (Story 4.3). -
#ensure_fresh_config! ⇒ void
Decision-time TTL check for timer-off mode (AC#3, PHP semantics).
-
#experience_by_id(experience_id) ⇒ Hash?
The frozen experience with that id, or nil.
-
#experience_by_key(key) ⇒ Hash?
The frozen experience with that key, or nil.
-
#experiences ⇒ Array<Hash>
The frozen experiences array ([] pre-config/absent).
-
#feature_by_key(key) ⇒ Hash?
The frozen feature with that key, or nil.
-
#features ⇒ Array<Hash>
The frozen features array ([] pre-config/absent).
-
#get_bucketing(visitor_id, experience_key, attributes = {}) ⇒ BucketedVariation, Sentinel
DECISION FLOW ============================= The ordered JS decision flow (data-manager.ts:227-720).
-
#get_preview_decision(experience, variation_id) ⇒ BucketedVariation?
PREVIEW (qs-03) =========================== Force-decide a specific variation on a caller-supplied experience, bypassing the ENTIRE decision flow above: audiences, segments, locations, the environment check, experience status, variation status/traffic filters, stored decisions, and the bucketing hash.
-
#goal_by_key(key) ⇒ Hash?
The frozen goal with that key, or nil.
-
#goals ⇒ Array<Hash>
The frozen goals array ([] pre-config/absent).
-
#initialize(log_manager:, data_store_manager: nil, config_key: nil, ttl: nil, clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }, refetch: nil, bucketing_manager: nil, rule_manager: nil, account_resolver: nil, project_resolver: nil, config_cache_disabled: false) ⇒ DataManager
constructor
A new instance of DataManager.
-
#install_config(hash) ⇒ Symbol, false
Install a parsed config envelope as the live snapshot.
-
#install_from_cache_if_fresh ⇒ Symbol?
Install a non-stale cached config entry from the store as the live snapshot — the cross-process warm-start fallback used by Client when the initial fetch fails.
-
#locations ⇒ Array<Hash>
The frozen locations array ([] pre-config/absent).
-
#project ⇒ Hash?
The frozen
projectsub-hash at the config root, or nil pre-config. -
#project_id ⇒ String?
The project id (+project.id+ at the config root), or nil pre-config.
-
#segments ⇒ Array<Hash>
The frozen segments array ([] pre-config/absent).
Constructor Details
#initialize(log_manager:, data_store_manager: nil, config_key: nil, ttl: nil, clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }, refetch: nil, bucketing_manager: nil, rule_manager: nil, account_resolver: nil, project_resolver: nil, config_cache_disabled: false) ⇒ DataManager
Returns a new instance of DataManager.
102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 |
# File 'lib/convert_sdk/data_manager.rb', line 102 def initialize(log_manager:, data_store_manager: nil, config_key: nil, ttl: nil, clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }, refetch: nil, bucketing_manager: nil, rule_manager: nil, account_resolver: nil, project_resolver: nil, config_cache_disabled: false) @log_manager = log_manager @data_store_manager = data_store_manager @config_key = config_key @ttl = ttl @config_cache_disabled = config_cache_disabled # Timer-off (Lambda/CLI) mode is exactly "no refresh interval configured". @timer_off = ttl.nil? @clock = clock @refetch = refetch assign_collaborators(bucketing_manager, rule_manager, account_resolver, project_resolver) # The deep-frozen config envelope, or nil before the first install. Read # lock-free by every reader; replaced atomically under @config_mutex. @config = nil #: Hash[String, untyped]? # The monotonic timestamp of the live snapshot's install, or nil pre-config. @fetched_at = nil #: Float? # Thread safety: guarded by @config_mutex (install/swap + @fetched_at). @config_mutex = Thread::Mutex.new # Thundering-herd guard for the synchronous timer-off refetch — a SEPARATE # mutex so the HTTP refetch never holds the config mutex. @fetch_mutex = Thread::Mutex.new end |
Instance Attribute Details
#refetch ⇒ #call?
The synchronous timer-off refresh callable, injected by Client after construction (Client owns the single HTTP port and the lifecycle event). Performs one full refresh cycle (refetch + install + warn-on-failure).
132 133 134 |
# File 'lib/convert_sdk/data_manager.rb', line 132 def refetch @refetch end |
Instance Method Details
#account_id ⇒ String?
Returns the account id (+account_id+ at the config root), or nil pre-config.
242 243 244 |
# File 'lib/convert_sdk/data_manager.rb', line 242 def account_id data&.fetch("account_id", nil) end |
#archived_experiences ⇒ Array<String>
Returns the frozen archived-experiences id list ([] absent).
IDs may be Integer or String in the wire shape; compared via to_s.
323 324 325 |
# File 'lib/convert_sdk/data_manager.rb', line 323 def archived_experiences collection("archived_experiences") end |
#audiences ⇒ Array<Hash>
Returns the frozen audiences array ([] pre-config/absent).
277 278 279 |
# File 'lib/convert_sdk/data_manager.rb', line 277 def audiences collection("audiences") end |
#config_available? ⇒ Boolean
Returns true once a USABLE config (account_id + project.id both
present) is installed. Mirrors JS SDK's isValidConfigData check
(+account_id && project.id+). A snapshot that carries a project key but
no id (e.g. project: {}) or a malformed project value is NOT considered
available — avoids silently serving nil reads to decision paths.
237 238 239 |
# File 'lib/convert_sdk/data_manager.rb', line 237 def config_available? !account_id.nil? && !project_id.nil? end |
#config_stale? ⇒ Boolean
Returns true when a snapshot exists and its monotonic age exceeds the configured ttl (or the default ttl when ttl is nil).
225 226 227 228 229 230 |
# File 'lib/convert_sdk/data_manager.rb', line 225 def config_stale? fetched_at = @config_mutex.synchronize { @fetched_at } return false if fetched_at.nil? (@clock.call - fetched_at) > effective_ttl end |
#convert(visitor_id, goal_key, goal_data: nil, force_multiple_transactions: false) ⇒ Hash?
CONVERSION TRACKING =========================
Track a conversion for visitor_id on goal_key with optional revenue /
transaction goal_data, applying two-level goal dedup (Story 4.3).
Two-level dedup + the Android qs-01 structural fix
Dedup is keyed at TWO levels: the visitor lives in the STORE KEY
(+accountId-projectId-visitorId+) and the goal lives in the
goals[goalId] map inside that visitor's StoreData. The CHECK (has this
goal already converted?) and the MARK (record it) both run inside ONE
ConvertSdk::DataStoreManager#merge_visitor_data block — i.e. inside the store's merge
mutex — so a check-then-mark race cannot double-count (the Android qs-01
defect class). The block computes the enqueue verdict into a closure flag;
the caller enqueues only when that flag came back true.
force_multiple_transactions (accepted parity break)
force_multiple_transactions: true BYPASSES the dedup check entirely (the
conversion is always returned for enqueue) but does NOT re-mark the goal —
the prior mark, if any, persists. Conservative default: force is for
legitimate multiple transactions, not to reset dedup state; re-marking
would corrupt dedup for a subsequent non-forced call on the same goal.
Return contract
Returns the wire-shaped conversion data hash to enqueue —
+=> id, "goalData" => [{key,value...]? (omitted when none),
"bucketingData" => => variationId? (omitted when the visitor
has no stored bucketing)}+ — or nil when nothing should be enqueued
(unknown goal key, or a deduplicated repeat). Each non-enqueue path emits a
debug line (sentinel/nil + silent is forbidden). The Context wraps the
returned data hash into the {eventType:'conversion', data:{...}} envelope.
422 423 424 425 426 427 428 429 430 431 432 433 |
# File 'lib/convert_sdk/data_manager.rb', line 422 def convert(visitor_id, goal_key, goal_data: nil, force_multiple_transactions: false) goal = goal_by_key(goal_key) if goal.nil? @log_manager&.debug("DataManager#convert: no goal found for key=#{goal_key}") return nil end goal_id = goal["id"].to_s return nil unless dedup_and_mark(visitor_id, goal_id, force_multiple_transactions) build_conversion_data(visitor_id, goal_id, goal_data) end |
#ensure_fresh_config! ⇒ void
This method returns an undefined value.
Decision-time TTL check for timer-off mode (AC#3, PHP semantics). When a
ttl is configured and the live snapshot is older than it (by the
monotonic clock), synchronously refetch via the injected callable BEFORE
the caller decides. Guarded by the SEPARATE @fetch_mutex so concurrent
stale deciders collapse to ONE fetch; the refetch (HTTP I/O) runs OUTSIDE
the config mutex. A failed refetch keeps the stale snapshot (the callable
warns). A no-op when no ttl/refetch is wired or the snapshot is fresh.
205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 |
# File 'lib/convert_sdk/data_manager.rb', line 205 def ensure_fresh_config! return unless @timer_off refetch = @refetch return if refetch.nil? return unless config_stale? @fetch_mutex.synchronize do # Re-check inside the lock: a racing decider may have refreshed already. return unless config_stale? # The callable performs the full cycle (refetch + install + warn). On # success it installs (advancing @fetched_at, so racing deciders that # re-check see fresh); on failure it warns and the stale snapshot stays. refetch.call end end |
#experience_by_id(experience_id) ⇒ Hash?
Returns the frozen experience with that id, or nil.
305 306 307 |
# File 'lib/convert_sdk/data_manager.rb', line 305 def experience_by_id(experience_id) find_by_id(experiences, experience_id) end |
#experience_by_key(key) ⇒ Hash?
Returns the frozen experience with that key, or nil.
294 295 296 |
# File 'lib/convert_sdk/data_manager.rb', line 294 def experience_by_key(key) find_by_key(experiences, key) end |
#experiences ⇒ Array<Hash>
Returns the frozen experiences array ([] pre-config/absent).
262 263 264 |
# File 'lib/convert_sdk/data_manager.rb', line 262 def experiences collection("experiences") end |
#feature_by_key(key) ⇒ Hash?
Returns the frozen feature with that key, or nil.
311 312 313 |
# File 'lib/convert_sdk/data_manager.rb', line 311 def feature_by_key(key) find_by_key(features, key) end |
#features ⇒ Array<Hash>
Returns the frozen features array ([] pre-config/absent).
267 268 269 |
# File 'lib/convert_sdk/data_manager.rb', line 267 def features collection("features") end |
#get_bucketing(visitor_id, experience_key, attributes = {}) ⇒ BucketedVariation, Sentinel
DECISION FLOW =============================
The ordered JS decision flow (data-manager.ts:227-720). ENTRY point for a single-experience decision; the across-all-experiences map lives in ExperienceManager#select_variations. The step ORDER is JS-pinned (research §Decision-Flow / data-manager.ts:302) and must NOT be reordered:
1. entity lookup (miss -> RuleError::NO_DATA_FOUND)
2. archived check (archived -> NO_DATA_FOUND)
3. environment match (mismatch -> NO_DATA_FOUND)
4. stored-bucketing lookup (sticky: sets is_bucketed)
5. locations / site_area (EMPTY = unrestricted)
6. audiences (permanent skipped when bucketed; transient always)
7. custom segments
8. traffic allocation + 9. variation selection
(no variation -> BucketingError::VARIATION_NOT_DECIDED)
Every miss returns its JS-parity Sentinel PAIRED with a debug reason log.
348 349 350 351 352 353 354 |
# File 'lib/convert_sdk/data_manager.rb', line 348 def get_bucketing(visitor_id, experience_key, attributes = {}) experience = match_rules_by_field(visitor_id, experience_key, attributes) return experience if experience.is_a?(Sentinel) return RuleError::NO_DATA_FOUND if experience.nil? retrieve_bucketing(visitor_id, experience, attributes) end |
#get_preview_decision(experience, variation_id) ⇒ BucketedVariation?
PREVIEW (qs-03) ===========================
Force-decide a specific variation on a caller-supplied experience,
bypassing the ENTIRE decision flow above: audiences, segments, locations,
the environment check, experience status, variation status/traffic
filters, stored decisions, and the bucketing hash. Mirrors JS
data-manager.ts getPreviewDecision (qs-03 / SDK-4).
PURE by construction: reads ONLY experience["variations"] (via
#retrieve_variation) -- never the installed config -- because a preview
experience fetched via ?exp= may not be registered there. Has ZERO side
effects: never calls #persist_bucketing / merge_visitor_data and never
enqueues anything (this method has no store or transport collaborator to
call in the first place).
375 376 377 378 379 380 |
# File 'lib/convert_sdk/data_manager.rb', line 375 def get_preview_decision(experience, variation_id) variation = retrieve_variation(experience, variation_id) return nil if variation.nil? build_bucketed_variation(experience, variation, nil) end |
#goal_by_key(key) ⇒ Hash?
Returns the frozen goal with that key, or nil.
317 318 319 |
# File 'lib/convert_sdk/data_manager.rb', line 317 def goal_by_key(key) find_by_key(goals, key) end |
#goals ⇒ Array<Hash>
Returns the frozen goals array ([] pre-config/absent).
272 273 274 |
# File 'lib/convert_sdk/data_manager.rb', line 272 def goals collection("goals") end |
#install_config(hash) ⇒ Symbol, false
Install a parsed config envelope as the live snapshot.
The hash is deep-frozen (a fresh recursively-frozen copy — the caller's
input is never mutated) and atomically swapped in behind @config_mutex.
A nil/non-Hash argument is rejected (logged) and leaves the current
snapshot intact — install must never crash the host.
The first-vs-subsequent determination is made ATOMICALLY inside
@config_mutex alongside the swap: the +ready+-once guard (Story 2.5) and
the config.updated refresh signal (Story 2.7) both key off the returned
marker, so exactly one install in the manager's lifetime is :first even
under concurrent installs.
152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 |
# File 'lib/convert_sdk/data_manager.rb', line 152 def install_config(hash) unless hash.is_a?(Hash) @log_manager.warn("DataManager#install_config: ignored non-Hash config (#{hash.class})") return false end frozen = deep_freeze(hash) now = @clock.call first = @config_mutex.synchronize do was_absent = @config.nil? @config = frozen @fetched_at = now was_absent end cache_config(frozen) @log_manager.info("DataManager#install_config: config installed") first ? :first : :updated end |
#install_from_cache_if_fresh ⇒ Symbol?
Install a non-stale cached config entry from the store as the live snapshot
— the cross-process warm-start fallback used by Client when the initial
fetch fails. The entry is {"config" => envelope, "fetched_at" => wall};
it is only installed when its WALL-CLOCK age is within ttl (or the
default TTL when ttl is nil — timer-off mode). A stale or absent entry
is ignored (returns nil). On a successful install an info line records the
cache hit.
181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 |
# File 'lib/convert_sdk/data_manager.rb', line 181 def install_from_cache_if_fresh entry = cached_entry return nil unless entry fetched_at = entry["fetched_at"] config = entry["config"] return nil unless fetched_at.is_a?(Numeric) && config.is_a?(Hash) return nil if (Time.now.to_f - fetched_at) > effective_ttl marker = install_config(config) return nil unless marker.is_a?(Symbol) @log_manager.info("DataManager#install_from_cache_if_fresh: serving cached config") marker end |
#locations ⇒ Array<Hash>
Returns the frozen locations array ([] pre-config/absent). Absent in some projects (e.g. the vendored fixture) — nil-safe to [].
288 289 290 |
# File 'lib/convert_sdk/data_manager.rb', line 288 def locations collection("locations") end |
#project ⇒ Hash?
Returns the frozen project sub-hash at the config root, or nil pre-config.
257 258 259 |
# File 'lib/convert_sdk/data_manager.rb', line 257 def project data&.fetch("project", nil) end |
#project_id ⇒ String?
Returns the project id (+project.id+ at the config root), or nil pre-config.
Type-safe against a non-Hash project value (e.g. a String in a malformed
direct-data config): &. guards nil only; a non-nil non-Hash would raise
NoMethodError on #fetch. Mirrors JS optional-chaining safety
(+data?.project?.id+).
251 252 253 254 |
# File 'lib/convert_sdk/data_manager.rb', line 251 def project_id p = project p.is_a?(Hash) ? p.fetch("id", nil) : nil end |
#segments ⇒ Array<Hash>
Returns the frozen segments array ([] pre-config/absent).
282 283 284 |
# File 'lib/convert_sdk/data_manager.rb', line 282 def segments collection("segments") end |