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 +=> 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_id+ on +goal_key+ with optional revenue / transaction +goal_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_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).
-
#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) ⇒ 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 +project+ sub-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) ⇒ DataManager
Returns a new instance of DataManager.
95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 |
# File 'lib/convert_sdk/data_manager.rb', line 95 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) @log_manager = log_manager @data_store_manager = data_store_manager @config_key = config_key @ttl = ttl # Timer-off (Lambda/CLI) mode is exactly "no refresh interval configured". @timer_off = ttl.nil? @clock = clock @refetch = refetch # Decision-flow collaborators (Story 2.11). Config-read-only when absent. @bucketing_manager = bucketing_manager @rule_manager = rule_manager @account_resolver = account_resolver || -> { account_id } @project_resolver = project_resolver || -> { project_id } # 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).
128 129 130 |
# File 'lib/convert_sdk/data_manager.rb', line 128 def refetch @refetch end |
Instance Method Details
#account_id ⇒ String?
Returns the account id (+account_id+ at the config root), or nil pre-config.
238 239 240 |
# File 'lib/convert_sdk/data_manager.rb', line 238 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+.
308 309 310 |
# File 'lib/convert_sdk/data_manager.rb', line 308 def archived_experiences collection("archived_experiences") end |
#audiences ⇒ Array<Hash>
Returns the frozen audiences array ([] pre-config/absent).
273 274 275 |
# File 'lib/convert_sdk/data_manager.rb', line 273 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.
233 234 235 |
# File 'lib/convert_sdk/data_manager.rb', line 233 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).
221 222 223 224 225 226 |
# File 'lib/convert_sdk/data_manager.rb', line 221 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 +data:{...}+ envelope.
381 382 383 384 385 386 387 388 389 390 391 392 |
# File 'lib/convert_sdk/data_manager.rb', line 381 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.
201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 |
# File 'lib/convert_sdk/data_manager.rb', line 201 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_key(key) ⇒ Hash?
Returns the frozen experience with that key, or nil.
290 291 292 |
# File 'lib/convert_sdk/data_manager.rb', line 290 def experience_by_key(key) find_by_key(experiences, key) end |
#experiences ⇒ Array<Hash>
Returns the frozen experiences array ([] pre-config/absent).
258 259 260 |
# File 'lib/convert_sdk/data_manager.rb', line 258 def experiences collection("experiences") end |
#feature_by_key(key) ⇒ Hash?
Returns the frozen feature with that key, or nil.
296 297 298 |
# File 'lib/convert_sdk/data_manager.rb', line 296 def feature_by_key(key) find_by_key(features, key) end |
#features ⇒ Array<Hash>
Returns the frozen features array ([] pre-config/absent).
263 264 265 |
# File 'lib/convert_sdk/data_manager.rb', line 263 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:
- entity lookup (miss -> RuleError::NO_DATA_FOUND)
- archived check (archived -> NO_DATA_FOUND)
- environment match (mismatch -> NO_DATA_FOUND)
- stored-bucketing lookup (sticky: sets is_bucketed)
- locations / site_area (EMPTY = unrestricted)
- audiences (permanent skipped when bucketed; transient always)
- custom segments
- traffic allocation + 9. variation selection (no variation -> BucketingError::VARIATION_NOT_DECIDED) Every miss returns its JS-parity Sentinel PAIRED with a debug reason log.
333 334 335 336 337 338 339 |
# File 'lib/convert_sdk/data_manager.rb', line 333 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 |
#goal_by_key(key) ⇒ Hash?
Returns the frozen goal with that key, or nil.
302 303 304 |
# File 'lib/convert_sdk/data_manager.rb', line 302 def goal_by_key(key) find_by_key(goals, key) end |
#goals ⇒ Array<Hash>
Returns the frozen goals array ([] pre-config/absent).
268 269 270 |
# File 'lib/convert_sdk/data_manager.rb', line 268 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.
148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 |
# File 'lib/convert_sdk/data_manager.rb', line 148 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 +=> 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.
177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 |
# File 'lib/convert_sdk/data_manager.rb', line 177 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 [].
284 285 286 |
# File 'lib/convert_sdk/data_manager.rb', line 284 def locations collection("locations") end |
#project ⇒ Hash?
Returns the frozen +project+ sub-hash at the config root, or nil pre-config.
253 254 255 |
# File 'lib/convert_sdk/data_manager.rb', line 253 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+).
247 248 249 250 |
# File 'lib/convert_sdk/data_manager.rb', line 247 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).
278 279 280 |
# File 'lib/convert_sdk/data_manager.rb', line 278 def segments collection("segments") end |