Class: ConvertSdk::DataManager

Inherits:
Object
  • Object
show all
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 +=> ..., "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 +=> 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

Instance Method Summary collapse

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.

Parameters:

  • log_manager (LogManager)

    injected logger for install diagnostics.

  • data_store_manager (DataStoreManager, nil) (defaults to: nil)

    persistence port for the config cache write; nil disables the write (standalone unit construction).

  • config_key (String, nil) (defaults to: nil)

    the cache key +convert_sdk.config.sdkKey+ (built once by ConvertSdk::DataStoreManager#config_key); nil disables the cache.

  • ttl (Numeric, nil) (defaults to: nil)

    the configured +data_refresh_interval+ in seconds. A non-nil value is timer-ON mode (the background timer keeps config fresh, so #ensure_fresh_config! is a no-op); +nil+ is timer-OFF mode (Lambda / CLI), which enables the decision-time on-demand refetch and falls the effective staleness threshold back to ConvertSdk::DEFAULT_CONFIG_TTL (timer-off ≠ TTL-off). The timer-off mode is thus derived from +ttl.nil?+.

  • clock (#call) (defaults to: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })

    a monotonic time source (seconds, Float) for in-process TTL math; defaults to +Process.clock_gettime(Process::CLOCK_MONOTONIC)+.

  • refetch (#call, nil) (defaults to: nil)

    a callable performing one full refresh cycle (HTTP refetch + install + warn-on-failure) for the synchronous timer-off path; injected by Client after construction (it owns the HTTP I/O and the lifecycle event). Invoked under the thundering-herd fetch mutex.

  • bucketing_manager (BucketingManager, nil) (defaults to: nil)

    the pure-math variation selector (Story 2.9); the decision flow's traffic-allocation step uses it. nil leaves the manager config-read-only (Story 2.5/2.7 standalone use).

  • rule_manager (RuleManager, nil) (defaults to: nil)

    the audience/location rule walker (Story 2.10). nil leaves the manager config-read-only.

  • account_resolver (#call, nil) (defaults to: nil)

    returns the account id for the visitor store key; defaults to #account_id (the live config reader). Injectable so a Context can supply its own resolution without re-reading config.

  • project_resolver (#call, nil) (defaults to: nil)

    returns the project id for the visitor store key; defaults to #project_id.



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 =  || -> {  }
  @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).

Returns:

  • (#call, nil)


126
127
128
# File 'lib/convert_sdk/data_manager.rb', line 126

def refetch
  @refetch
end

Instance Method Details

#account_idString?

Returns the account id (+data.account_id+), or nil pre-config.

Returns:

  • (String, nil)

    the account id (+data.account_id+), or nil pre-config.



232
233
234
# File 'lib/convert_sdk/data_manager.rb', line 232

def 
  data&.fetch("account_id", nil)
end

#archived_experiencesArray<String>

Returns the frozen archived-experiences id list ([] absent). IDs may be Integer or String in the wire shape; compared via +to_s+.

Returns:

  • (Array<String>)

    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

#audiencesArray<Hash>

Returns the frozen audiences array ([] pre-config/absent).

Returns:

  • (Array<Hash>)

    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.

Returns:

  • (Boolean)

    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).

Returns:

  • (Boolean)

    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 +data:{...}+ envelope.

Parameters:

  • visitor_id (String)

    the converting visitor.

  • goal_key (String)

    the goal +key+ (resolved to its id via the reader).

  • goal_data (Hash, nil) (defaults to: nil)

    optional revenue/transaction data; keys are the snake_case Ruby forms (or wire forms) of the eight GoalDataKey platform keys — unknown keys are rejected with a debug line.

  • force_multiple_transactions (Boolean) (defaults to: false)

    bypass the dedup check.

Returns:

  • (Hash, nil)

    the conversion data hash to enqueue, or nil.



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.

Parameters:

  • key (String)

    the experience +key+ to find.

Returns:

  • (Hash, nil)

    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

#experiencesArray<Hash>

Returns the frozen experiences array ([] pre-config/absent).

Returns:

  • (Array<Hash>)

    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.

Parameters:

  • key (String)

    the feature +key+ to find.

Returns:

  • (Hash, nil)

    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

#featuresArray<Hash>

Returns the frozen features array ([] pre-config/absent).

Returns:

  • (Array<Hash>)

    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.

Parameters:

  • visitor_id (String)

    the visitor identifier.

  • experience_key (String)

    the experience +key+ to decide.

  • attributes (Hash) (defaults to: {})

    +:visitor_properties+, +:location_properties+, +:environment+, +:update_visitor_properties+.

Returns:



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.

Parameters:

  • key (String)

    the goal +key+ to find.

Returns:

  • (Hash, nil)

    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

#goalsArray<Hash>

Returns the frozen goals array ([] pre-config/absent).

Returns:

  • (Array<Hash>)

    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.

Parameters:

  • hash (Hash{String=>Object})

    the parsed config envelope (+=> ..., "data" => {...}+).

Returns:

  • (Symbol, false)

    +:first+ on the first successful install, +:updated+ on any subsequent install, or +false+ when the argument was rejected (non-Hash) and no swap happened.



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_freshSymbol?

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.

Returns:

  • (Symbol, nil)

    the #install_config marker on a fresh cache hit, or nil when no fresh entry was available.



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

#locationsArray<Hash>

Returns the frozen locations array ([] pre-config/absent). Absent in some projects (e.g. the vendored fixture) — nil-safe to [].

Returns:

  • (Array<Hash>)

    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

#projectHash?

Returns the frozen +data.project+ sub-hash, or nil pre-config.

Returns:

  • (Hash, nil)

    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_idString?

Returns the project id (+data.project.id+), or nil pre-config.

Returns:

  • (String, nil)

    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

#segmentsArray<Hash>

Returns the frozen segments array ([] pre-config/absent).

Returns:

  • (Array<Hash>)

    the frozen segments array ([] pre-config/absent).



267
268
269
# File 'lib/convert_sdk/data_manager.rb', line 267

def segments
  collection("segments")
end