Class: ConvertSdk::DataStoreManager

Inherits:
Object
  • Object
show all
Defined in:
lib/convert_sdk/data_store_manager.rb

Overview

The single persistence port every manager flows through.

DataStoreManager wraps a duck-typed store (anything responding to #get(key) / #set(key, value)) and is the ONLY object that holds a raw store reference — managers (config caching in Story 2.7, sticky bucketing in 2.11, goal dedup in 4.3) never touch a store directly. This gives the SDK one place to enforce three guarantees:

  1. Validation at wiring time. The supplied store is duck-type-checked once, at construction. A non-conforming store is rejected with a logged error and replaced by a Stores::MemoryStore — wiring NEVER raises and NEVER accepts a broken store. (The JS SDK's isValidDataStore checks only that +get+/+set+ are functions, with no arity enforcement; this port matches that contract exactly. Unlike JS — which leaves its data store undefined on invalid input — this Ruby port intentionally falls back to a working MemoryStore, because a Ruby process must never crash on SDK wiring errors.)

  2. Never-crash passthrough. #get / #set rescue StandardError from a user-supplied store and log it; a raising store degrades to nil (get) or a no-op (set) instead of crashing the host.

  3. Atomic visitor-data merge. #merge_visitor_data runs the whole read-modify-write cycle inside a manager-level mutex, so a compound "read current state, decide, write" operation is atomic by construction. Goal dedup (Story 4.3) builds its check-then-mark on this guarantee.

One store, two tenants

A single store instance backs both config caching and visitor data. Keys are namespaced so the two never collide: config entries use convert_sdk.config.{sdk_key} (#config_key) and visitor entries use {account_id}-{project_id}-{visitor_id} (#visitor_key, byte-identical to the JS getStoreKey format). The two key shapes are structurally disjoint.

StoreData

Visitor data is a string-keyed hash of the JS StoreData shape — {"bucketing" => {...}, "segments" => {...}, "goals" => {...}} (plus "locations"). Everything stored is string-keyed (wire-world); no symbols appear in stored structures.

Thread safety

The merge cycle is guarded by @merge_mutex. The default Stores::MemoryStore adds its own internal lock, so in-process merges are atomic. For external stores (e.g. RedisStore, Story 2.2) the same code path runs, but cross-process merge atomicity is store-dependent and must be provided by the backing store.

Constant Summary collapse

REQUIRED_STORE_METHODS =

Methods a store must respond to (JS isValidDataStore contract — presence only, no arity check).

%i[get set].freeze

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(log_manager:, store: nil) ⇒ DataStoreManager

Returns a new instance of DataStoreManager.

Parameters:

  • store (Object, nil) (defaults to: nil)

    a duck-typed store responding to +get+/+set+. nil or an invalid store falls back to a new Stores::MemoryStore.

  • log_manager (LogManager)

    injected logger for validation/passthrough diagnostics.



65
66
67
68
69
70
# File 'lib/convert_sdk/data_store_manager.rb', line 65

def initialize(log_manager:, store: nil)
  @log_manager = log_manager
  @store = resolve_store(store)
  # Thread safety: guarded by @merge_mutex.
  @merge_mutex = Thread::Mutex.new
end

Instance Attribute Details

#storeObject (readonly)

Returns the validated backing store (the supplied store, or a Stores::MemoryStore fallback).

Returns:

  • (Object)

    the validated backing store (the supplied store, or a Stores::MemoryStore fallback).



59
60
61
# File 'lib/convert_sdk/data_store_manager.rb', line 59

def store
  @store
end

Instance Method Details

#config_key(sdk_key) ⇒ String

Build the config-cache store key. SINGLE construction site for config keys.

Parameters:

  • sdk_key (String)

Returns:

  • (String)


114
115
116
# File 'lib/convert_sdk/data_store_manager.rb', line 114

def config_key(sdk_key)
  "convert_sdk.config.#{sdk_key}"
end

#get(key) ⇒ Object?

Read the value stored under key. A raising store is contained: the error is logged and nil is returned.

Parameters:

  • key (String)

Returns:

  • (Object, nil)


77
78
79
80
81
82
# File 'lib/convert_sdk/data_store_manager.rb', line 77

def get(key)
  @store.get(key)
rescue StandardError => e
  @log_manager.error("DataStoreManager#get: store raised (#{e.message})")
  nil
end

#merge_visitor_data(account_id, project_id, visitor_id) {|current| ... } ⇒ Hash

Atomically read-modify-write a visitor's StoreData.

The entire cycle — read current data, yield it to the block, deep-merge the block's returned partial, write the result — runs inside @merge_mutex, so it is atomic by construction. The block receives the current stored data (or {} for a first write) and returns a StoreData partial to merge in; this lets a caller inspect current state and decide what to write atomically (the substrate for Story 4.3's check-then-mark goal dedup).

Merge semantics match the JS objectDeepMerge: nested string-keyed hashes merge recursively, arrays union (deduped, new values first), and scalars from the partial win.

Parameters:

  • account_id (String)
  • project_id (String)
  • visitor_id (String)

Yield Parameters:

  • current (Hash)

    the current stored StoreData (or {}).

Yield Returns:

  • (Hash)

    the StoreData partial to merge in.

Returns:

  • (Hash)

    the merged, persisted StoreData.



138
139
140
141
142
143
144
145
146
147
# File 'lib/convert_sdk/data_store_manager.rb', line 138

def merge_visitor_data(, project_id, visitor_id)
  key = visitor_key(, project_id, visitor_id)
  @merge_mutex.synchronize do
    current = get(key) || {}
    partial = yield(current)
    merged = deep_merge(current, partial || {})
    set(key, merged)
    merged
  end
end

#set(key, value) ⇒ void

This method returns an undefined value.

Store value under key. A raising store is contained: the error is logged and the call is a no-op.

Parameters:

  • key (String)
  • value (Object)


90
91
92
93
94
95
96
# File 'lib/convert_sdk/data_store_manager.rb', line 90

def set(key, value)
  @store.set(key, value)
  nil
rescue StandardError => e
  @log_manager.error("DataStoreManager#set: store raised (#{e.message})")
  nil
end

#visitor_key(account_id, project_id, visitor_id) ⇒ String

Build the visitor-data store key — byte-identical to the JS getStoreKey format <code>${accountId}-${projectId}-${visitorId}</code>. This is the SINGLE construction site for visitor keys.

Parameters:

  • account_id (String)
  • project_id (String)
  • visitor_id (String)

Returns:

  • (String)


106
107
108
# File 'lib/convert_sdk/data_store_manager.rb', line 106

def visitor_key(, project_id, visitor_id)
  "#{}-#{project_id}-#{visitor_id}"
end