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

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, config_cache_disabled: false) ⇒ 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.

  • config_cache_disabled (Boolean) (defaults to: false)

    when true, #cache_config is a no-op — the config-cache WRITE is suppressed. Ruby-SDK-only hardening (qs-03, NOT a JS-parity concern): while a debug_token is configured, a shared store (e.g. Redis) must never be poisoned with a debug-widened config that a production reader could later pick up. Sourced by ConvertSdk.build_data_manager from !config.debug_token.nil?. Defaults to false (cache writes enabled) for standalone construction.



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

Returns:

  • (#call, nil)


132
133
134
# File 'lib/convert_sdk/data_manager.rb', line 132

def refetch
  @refetch
end

Instance Method Details

#account_idString?

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

Returns:

  • (String, nil)

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



323
324
325
# File 'lib/convert_sdk/data_manager.rb', line 323

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



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.

Returns:

  • (Boolean)

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

Returns:

  • (Boolean)

    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.

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.



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.

Parameters:

  • id (String)

    the experience id to find (+to_s+ compared — ids may arrive as Integer or String across the wire, e.g. a caller-supplied experience_id vs. the config's JSON-parsed id). Used by the qs-03 preview resolution (Context#set_preview) to look the previewed experience up against the CURRENT installed config before falling back to the ?exp= live fetch.

Returns:

  • (Hash, nil)

    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.

Parameters:

  • key (String)

    the experience key to find.

Returns:

  • (Hash, nil)

    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

#experiencesArray<Hash>

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

Returns:

  • (Array<Hash>)

    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.

Parameters:

  • key (String)

    the feature key to find.

Returns:

  • (Hash, nil)

    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

#featuresArray<Hash>

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

Returns:

  • (Array<Hash>)

    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.

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:



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

Parameters:

  • experience (Hash)

    the experience config entity to preview (may be absent from the installed config).

  • variation_id (String)

    the variation id to force (+to_s+ compared).

Returns:

  • (BucketedVariation, nil)

    the forced decision, or nil when variation_id does not match any variation on experience.



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.

Parameters:

  • key (String)

    the goal key to find.

Returns:

  • (Hash, nil)

    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

#goalsArray<Hash>

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

Returns:

  • (Array<Hash>)

    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.

Parameters:

  • hash (Hash{String=>Object})

    the parsed flat config snapshot (entities at the root — account_id, project, experiences, etc.).

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.



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_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 {"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.

Returns:

  • (Symbol, nil)

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



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

#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 [].



288
289
290
# File 'lib/convert_sdk/data_manager.rb', line 288

def locations
  collection("locations")
end

#projectHash?

Returns the frozen project sub-hash at the config root, or nil pre-config.

Returns:

  • (Hash, nil)

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

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

Returns:

  • (String, nil)

    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

#segmentsArray<Hash>

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

Returns:

  • (Array<Hash>)

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



282
283
284
# File 'lib/convert_sdk/data_manager.rb', line 282

def segments
  collection("segments")
end