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 envelope is wrapped here and 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
The config envelope is {"environment" => ..., "data" => {...}}; the entity
collections (+experiences+, features, goals, audiences, segments,
optional locations) plus account_id and the project sub-hash live under
"data". #project_id is data.project.id. 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 (+data.account_id+), 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 config snapshot has been 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_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
data.projectsub-hash, or nil pre-config. -
#project_id ⇒ String?
The project id (+data.project.id+), 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.
93 94 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 |
# File 'lib/convert_sdk/data_manager.rb', line 93 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).
126 127 128 |
# File 'lib/convert_sdk/data_manager.rb', line 126 def refetch @refetch end |
Instance Method Details
#account_id ⇒ String?
Returns the account id (+data.account_id+), or nil pre-config.
232 233 234 |
# File 'lib/convert_sdk/data_manager.rb', line 232 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.
297 298 299 |
# File 'lib/convert_sdk/data_manager.rb', line 297 def archived_experiences collection("archived_experiences") end |
#audiences ⇒ Array<Hash>
Returns the frozen audiences array ([] pre-config/absent).
262 263 264 |
# File 'lib/convert_sdk/data_manager.rb', line 262 def audiences collection("audiences") end |
#config_available? ⇒ Boolean
Returns true once a config snapshot has been installed.
227 228 229 |
# File 'lib/convert_sdk/data_manager.rb', line 227 def config_available? !@config.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).
219 220 221 222 223 224 |
# File 'lib/convert_sdk/data_manager.rb', line 219 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.
370 371 372 373 374 375 376 377 378 379 380 381 |
# File 'lib/convert_sdk/data_manager.rb', line 370 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.
199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 |
# File 'lib/convert_sdk/data_manager.rb', line 199 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.
279 280 281 |
# File 'lib/convert_sdk/data_manager.rb', line 279 def experience_by_key(key) find_by_key(experiences, key) end |
#experiences ⇒ Array<Hash>
Returns the frozen experiences array ([] pre-config/absent).
247 248 249 |
# File 'lib/convert_sdk/data_manager.rb', line 247 def experiences collection("experiences") end |
#feature_by_key(key) ⇒ Hash?
Returns the frozen feature with that key, or nil.
285 286 287 |
# File 'lib/convert_sdk/data_manager.rb', line 285 def feature_by_key(key) find_by_key(features, key) end |
#features ⇒ Array<Hash>
Returns the frozen features array ([] pre-config/absent).
252 253 254 |
# File 'lib/convert_sdk/data_manager.rb', line 252 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.
322 323 324 325 326 327 328 |
# File 'lib/convert_sdk/data_manager.rb', line 322 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.
291 292 293 |
# File 'lib/convert_sdk/data_manager.rb', line 291 def goal_by_key(key) find_by_key(goals, key) end |
#goals ⇒ Array<Hash>
Returns the frozen goals array ([] pre-config/absent).
257 258 259 |
# File 'lib/convert_sdk/data_manager.rb', line 257 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.
146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 |
# File 'lib/convert_sdk/data_manager.rb', line 146 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.
175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 |
# File 'lib/convert_sdk/data_manager.rb', line 175 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 [].
273 274 275 |
# File 'lib/convert_sdk/data_manager.rb', line 273 def locations collection("locations") end |
#project ⇒ Hash?
Returns the frozen data.project sub-hash, or nil pre-config.
242 243 244 |
# File 'lib/convert_sdk/data_manager.rb', line 242 def project data&.fetch("project", nil) end |
#project_id ⇒ String?
Returns the project id (+data.project.id+), or nil pre-config.
237 238 239 |
# File 'lib/convert_sdk/data_manager.rb', line 237 def project_id project&.fetch("id", nil) end |
#segments ⇒ Array<Hash>
Returns the frozen segments array ([] pre-config/absent).
267 268 269 |
# File 'lib/convert_sdk/data_manager.rb', line 267 def segments collection("segments") end |