convert_sdk 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.rspec +3 -0
- data/.rubocop.yml +191 -0
- data/.yardopts +16 -0
- data/CONTRIBUTING.md +131 -0
- data/LICENSE +201 -0
- data/README.md +183 -0
- data/RELEASE.md +313 -0
- data/Rakefile +16 -0
- data/convert_sdk.gemspec +50 -0
- data/lib/convert_sdk/api_manager.rb +288 -0
- data/lib/convert_sdk/background_timer.rb +129 -0
- data/lib/convert_sdk/bucketed_feature.rb +35 -0
- data/lib/convert_sdk/bucketed_variation.rb +43 -0
- data/lib/convert_sdk/bucketing_manager.rb +134 -0
- data/lib/convert_sdk/client.rb +417 -0
- data/lib/convert_sdk/comparisons.rb +257 -0
- data/lib/convert_sdk/config.rb +214 -0
- data/lib/convert_sdk/config_validator.rb +127 -0
- data/lib/convert_sdk/context.rb +618 -0
- data/lib/convert_sdk/data_manager.rb +897 -0
- data/lib/convert_sdk/data_store_manager.rb +185 -0
- data/lib/convert_sdk/enums/bucketing_error.rb +18 -0
- data/lib/convert_sdk/enums/feature_status.rb +13 -0
- data/lib/convert_sdk/enums/goal_data_key.rb +62 -0
- data/lib/convert_sdk/enums/log_level.rb +22 -0
- data/lib/convert_sdk/enums/rule_error.rb +19 -0
- data/lib/convert_sdk/enums/system_events.rb +29 -0
- data/lib/convert_sdk/event_manager.rb +125 -0
- data/lib/convert_sdk/experience_manager.rb +69 -0
- data/lib/convert_sdk/feature_manager.rb +367 -0
- data/lib/convert_sdk/fork_guard.rb +144 -0
- data/lib/convert_sdk/http_client.rb +198 -0
- data/lib/convert_sdk/log_manager.rb +168 -0
- data/lib/convert_sdk/murmur_hash3.rb +129 -0
- data/lib/convert_sdk/redactor.rb +93 -0
- data/lib/convert_sdk/rule_manager.rb +242 -0
- data/lib/convert_sdk/segments_manager.rb +241 -0
- data/lib/convert_sdk/sentinel.rb +57 -0
- data/lib/convert_sdk/stores/memory_store.rb +55 -0
- data/lib/convert_sdk/stores/redis_store.rb +126 -0
- data/lib/convert_sdk/version.rb +14 -0
- data/lib/convert_sdk/visitors_queue.rb +190 -0
- data/lib/convert_sdk.rb +218 -0
- data/scripts/check-generated-rbs-header.sh +41 -0
- data/steep/config_contract_probe.rb +154 -0
- metadata +93 -0
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module ConvertSdk
|
|
6
|
+
module Stores
|
|
7
|
+
# A first-party, in-tree store adapter backed by Redis — the cross-process
|
|
8
|
+
# answer to {MemoryStore}'s per-process limitation.
|
|
9
|
+
#
|
|
10
|
+
# == Why Redis (FR49)
|
|
11
|
+
#
|
|
12
|
+
# {MemoryStore} keeps state in a single process. Puma clusters, Sidekiq
|
|
13
|
+
# worker fleets, and Lambda invocations each run in separate processes, so
|
|
14
|
+
# sticky bucketing (Story 2.11) and goal deduplication (Story 4.3) that
|
|
15
|
+
# round-trip through a +MemoryStore+ are inconsistent across the fleet.
|
|
16
|
+
# +RedisStore+ shares that state through a Redis instance, giving every
|
|
17
|
+
# process the same view.
|
|
18
|
+
#
|
|
19
|
+
# == Zero gemspec footprint
|
|
20
|
+
#
|
|
21
|
+
# The +redis+ gem is the *user's* dependency, never the SDK's: it is NOT a
|
|
22
|
+
# gemspec runtime dependency and is +require+-d *lazily* inside {#initialize}
|
|
23
|
+
# — and only when a client is built from connection options. Requiring this
|
|
24
|
+
# file (which +lib/convert_sdk.rb+ does unconditionally) therefore never
|
|
25
|
+
# pulls in +redis+, so +require "convert_sdk"+ stays green for users who do
|
|
26
|
+
# not install it. If a caller asks +RedisStore+ to build its own client
|
|
27
|
+
# without +redis+ installed, instantiation raises an actionable error naming
|
|
28
|
+
# the gem to add — a wiring-time programmer error, sanctioned in the same
|
|
29
|
+
# class as +ConvertSdk.create+'s argument validation, NOT a business path.
|
|
30
|
+
#
|
|
31
|
+
# == Construction
|
|
32
|
+
#
|
|
33
|
+
# # Preferred: inject an existing client (connection reuse / pooling).
|
|
34
|
+
# # No `require "redis"`, no `Redis.new` — works even where the adapter
|
|
35
|
+
# # file is loaded without the gem present.
|
|
36
|
+
# store = ConvertSdk::Stores::RedisStore.new(redis: Redis.new(url: ...))
|
|
37
|
+
#
|
|
38
|
+
# # Or pass connection options; the adapter lazily requires `redis` and
|
|
39
|
+
# # constructs the client itself.
|
|
40
|
+
# store = ConvertSdk::Stores::RedisStore.new(url: "redis://localhost:6379/0")
|
|
41
|
+
#
|
|
42
|
+
# An optional +key_prefix+ namespaces every key (default +"convert:"+) so the
|
|
43
|
+
# SDK's keys do not collide with other tenants of the same Redis database.
|
|
44
|
+
#
|
|
45
|
+
# == Thin adapter — resilience lives upstream
|
|
46
|
+
#
|
|
47
|
+
# This adapter is serialization + connection only. It does NOT rescue Redis
|
|
48
|
+
# client exceptions: {DataStoreManager} (Story 2.1) already wraps every
|
|
49
|
+
# +get+/+set+ in a rescue-log passthrough, degrading a raising store to
|
|
50
|
+
# +nil+/no-op instead of crashing the host. Duplicating that rescue here
|
|
51
|
+
# would swallow errors the manager is responsible for logging.
|
|
52
|
+
#
|
|
53
|
+
# == Cross-process consistency caveat
|
|
54
|
+
#
|
|
55
|
+
# Visitor-data merges are a read-modify-write. In-process that sequence is
|
|
56
|
+
# atomic under {DataStoreManager}'s mutex, but across processes sharing one
|
|
57
|
+
# Redis there is no such lock: concurrent writers race and the last write
|
|
58
|
+
# wins. This matches the JS SDK contract. The SDK does NOT use Lua scripts
|
|
59
|
+
# or +WATCH+/+MULTI+ to close that race — that is deliberately out of scope.
|
|
60
|
+
#
|
|
61
|
+
# Sidekiq / Lambda deployment guidance: see the Epic 5 documentation.
|
|
62
|
+
class RedisStore
|
|
63
|
+
# Default namespace prepended to every key written to Redis.
|
|
64
|
+
DEFAULT_KEY_PREFIX = "convert:"
|
|
65
|
+
|
|
66
|
+
# @param redis [Object, nil] an existing redis-rb-compatible client
|
|
67
|
+
# responding to +#get+/+#set+. When supplied, +redis+ is NOT required and
|
|
68
|
+
# no new client is constructed (preferred — enables connection reuse).
|
|
69
|
+
# @param key_prefix [String] namespace prepended to every key
|
|
70
|
+
# (default +"convert:"+).
|
|
71
|
+
# @param options [Hash] connection options (e.g. +url:+) forwarded to
|
|
72
|
+
# +Redis.new+ when no +redis:+ client is injected. Triggers the lazy
|
|
73
|
+
# +require "redis"+.
|
|
74
|
+
# @raise [LoadError] re-raised as an actionable error when +redis:+ is
|
|
75
|
+
# omitted and the +redis+ gem is not installed.
|
|
76
|
+
def initialize(redis: nil, key_prefix: DEFAULT_KEY_PREFIX, **options)
|
|
77
|
+
@key_prefix = key_prefix
|
|
78
|
+
@client = redis || build_client(options)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Read and deserialize the value stored under +key+.
|
|
82
|
+
#
|
|
83
|
+
# @param key [String] the (unprefixed) lookup key.
|
|
84
|
+
# @return [Object, nil] the JSON-parsed value (string-keyed hashes,
|
|
85
|
+
# numbers, arrays, booleans), or +nil+ when the key is absent.
|
|
86
|
+
def get(key)
|
|
87
|
+
raw = @client.get(namespaced(key))
|
|
88
|
+
raw.nil? ? nil : JSON.parse(raw)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Serialize +value+ to JSON and store it under +key+, overwriting any
|
|
92
|
+
# existing value.
|
|
93
|
+
#
|
|
94
|
+
# @param key [String] the (unprefixed) storage key.
|
|
95
|
+
# @param value [Object] a JSON-serializable value (StoreData shape).
|
|
96
|
+
# @return [Object] the client's +set+ return value.
|
|
97
|
+
def set(key, value)
|
|
98
|
+
@client.set(namespaced(key), JSON.generate(value))
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
private
|
|
102
|
+
|
|
103
|
+
# Lazily require the +redis+ gem and construct a client from +options+.
|
|
104
|
+
# Called ONLY when no client was injected; this is the single site that
|
|
105
|
+
# depends on the gem being installed.
|
|
106
|
+
#
|
|
107
|
+
# @param options [Hash] connection options forwarded to +Redis.new+.
|
|
108
|
+
# @return [Object] a new redis-rb client.
|
|
109
|
+
# @raise [LoadError] with an actionable message when the gem is absent.
|
|
110
|
+
def build_client(options)
|
|
111
|
+
require "redis"
|
|
112
|
+
Redis.new(**options)
|
|
113
|
+
rescue LoadError
|
|
114
|
+
raise LoadError,
|
|
115
|
+
"RedisStore requires the 'redis' gem — add `gem 'redis'` to your Gemfile " \
|
|
116
|
+
"(or inject an existing client via `redis:`)."
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# @param key [String] the unprefixed key.
|
|
120
|
+
# @return [String] the key with the configured namespace prepended.
|
|
121
|
+
def namespaced(key)
|
|
122
|
+
"#{@key_prefix}#{key}"
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ConvertSdk
|
|
4
|
+
# The gem version string (semantic version).
|
|
5
|
+
#
|
|
6
|
+
# This is a DEV PLACEHOLDER. The real version is written here at release time
|
|
7
|
+
# by the semantic-release `@semantic-release/exec` prepareCmd (release.config.mjs),
|
|
8
|
+
# as an UNCOMMITTED working-tree edit — the gem builds carrying the computed
|
|
9
|
+
# version, but `main` never receives a version-bump commit. The next release
|
|
10
|
+
# derives its version from this run's git tag, not from this file (FR66).
|
|
11
|
+
# Mirrors the Android SDK's `0.0.0` placeholder in gradle/libs.versions.toml.
|
|
12
|
+
# @return [String]
|
|
13
|
+
VERSION = "1.0.0"
|
|
14
|
+
end
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ConvertSdk
|
|
4
|
+
# The per-visitor event queue — the in-memory buffer between the decision flow
|
|
5
|
+
# (which enqueues bucketing/conversion events) and {ApiManager} (which drains
|
|
6
|
+
# and POSTs them in the Convert wire format).
|
|
7
|
+
#
|
|
8
|
+
# == Per-visitor merge (structural invariant, FR36)
|
|
9
|
+
#
|
|
10
|
+
# The queue holds ONE entry per visitor — a string-keyed wire-shaped hash
|
|
11
|
+
# +{"visitorId" => id, "segments" => {...}?, "events" => [...]}+. Enqueuing an
|
|
12
|
+
# event for a visitor already in the queue APPENDS to that visitor's +events+
|
|
13
|
+
# array; it never adds a duplicate visitor entry and never flattens to a bare
|
|
14
|
+
# event list. The platform attributes events by walking +visitors[].events+, so
|
|
15
|
+
# flattening or duplicating corrupts report attribution. The structure itself
|
|
16
|
+
# enforces the invariant — there is no public path that bypasses the merge.
|
|
17
|
+
# (JS parity: +api-manager.ts:117-144+; PHP +VisitorsQueue.php:64-70+.)
|
|
18
|
+
#
|
|
19
|
+
# +segments+ ride on the visitor entry and are captured ONLY when the entry is
|
|
20
|
+
# first created (omitted entirely when none are supplied) — a later enqueue for
|
|
21
|
+
# the same visitor never overwrites them (JS +if (segments) visitor.segments = …+).
|
|
22
|
+
#
|
|
23
|
+
# == Bounded memory (FR39/NFR10)
|
|
24
|
+
#
|
|
25
|
+
# The queue is bounded at {MAX_EVENTS} EVENTS (events, not visitors). On
|
|
26
|
+
# overflow the OLDEST event is dropped — and the visitor entry is removed once
|
|
27
|
+
# its last event is gone — with a +warn+ log per drop. An endpoint outage can
|
|
28
|
+
# never grow host memory without bound; dropping the oldest (not the newest)
|
|
29
|
+
# keeps the most recent traffic. (Optimizely +DEFAULT_QUEUE_CAPACITY = 1000+
|
|
30
|
+
# precedent; research frozen register #7.)
|
|
31
|
+
#
|
|
32
|
+
# == Thread safety (NFR2/NFR13)
|
|
33
|
+
#
|
|
34
|
+
# Every operation is serialized by +@queue_mutex+. {#enqueue} is pure in-memory
|
|
35
|
+
# and never blocks on I/O, so the calling request thread is never held on the
|
|
36
|
+
# network. {#drain!} is an atomic drain-and-swap inside the lock returning the
|
|
37
|
+
# drained visitors array — {ApiManager} builds the payload and POSTs OUTSIDE the
|
|
38
|
+
# lock, so network I/O never holds the queue. The drained array is re-enqueueable
|
|
39
|
+
# without violating the per-visitor merge (the retention path Story 4.2 needs).
|
|
40
|
+
#
|
|
41
|
+
# @api private
|
|
42
|
+
class VisitorsQueue
|
|
43
|
+
# The hard upper bound on buffered events (events, not visitors). Research
|
|
44
|
+
# frozen register #7; the JS SDK has no equivalent memory cap.
|
|
45
|
+
MAX_EVENTS = 1000
|
|
46
|
+
|
|
47
|
+
# @param log_manager [LogManager] the redacting logging surface (warn on overflow).
|
|
48
|
+
def initialize(log_manager:)
|
|
49
|
+
@log_manager = log_manager
|
|
50
|
+
# Thread safety: guarded by @queue_mutex. @items is the ordered list of
|
|
51
|
+
# per-visitor entries; @size is the total event count (the cap dimension).
|
|
52
|
+
@queue_mutex = Thread::Mutex.new
|
|
53
|
+
@items = [] #: Array[Hash[String, untyped]]
|
|
54
|
+
@size = 0
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Enqueue one wire-shaped event for +visitor_id+, merging into the visitor's
|
|
58
|
+
# existing entry (append) or creating a new one. Pure in-memory — never blocks
|
|
59
|
+
# on I/O. On overflow past {MAX_EVENTS} the oldest event is dropped (+warn+).
|
|
60
|
+
#
|
|
61
|
+
# @param visitor_id [String] the visitor the event belongs to.
|
|
62
|
+
# @param event [Hash{String=>Object}] a wire-shaped (string-keyed camelCase)
|
|
63
|
+
# event hash, e.g. +{"eventType"=>"bucketing", "data"=>{...}}+.
|
|
64
|
+
# @param segments [Hash{String=>Object}, nil] the visitor's report-segments,
|
|
65
|
+
# attached ONLY when this enqueue first creates the visitor's entry.
|
|
66
|
+
# @return [void]
|
|
67
|
+
def enqueue(visitor_id, event, segments: nil)
|
|
68
|
+
@queue_mutex.synchronize do
|
|
69
|
+
entry = @items.find { |item| item["visitorId"] == visitor_id }
|
|
70
|
+
if entry
|
|
71
|
+
entry["events"] << event
|
|
72
|
+
else
|
|
73
|
+
entry = { "visitorId" => visitor_id, "events" => [event] } #: Hash[String, untyped]
|
|
74
|
+
entry["segments"] = segments unless segments.nil?
|
|
75
|
+
@items << entry
|
|
76
|
+
end
|
|
77
|
+
@size += 1
|
|
78
|
+
trim_to_cap
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Atomically drain the queue: swap out the current per-visitor entries and
|
|
83
|
+
# reset to empty inside the lock, returning the drained array. The caller
|
|
84
|
+
# (ApiManager) builds the payload and POSTs OUTSIDE the lock.
|
|
85
|
+
#
|
|
86
|
+
# @return [Array<Hash{String=>Object}>] the drained per-visitor entries
|
|
87
|
+
# (empty when nothing was queued); re-enqueueable verbatim.
|
|
88
|
+
def drain!
|
|
89
|
+
@queue_mutex.synchronize do
|
|
90
|
+
drained = @items
|
|
91
|
+
@items = []
|
|
92
|
+
@size = 0
|
|
93
|
+
drained
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Re-enqueue previously drained per-visitor entries after a failed delivery
|
|
98
|
+
# (Story 4.2 failure retention), PRESERVING the per-visitor merge. Runs as one
|
|
99
|
+
# atomic compound operation inside +@queue_mutex+.
|
|
100
|
+
#
|
|
101
|
+
# The drained events are OLDER than anything the queue received during the
|
|
102
|
+
# failed POST, so they are placed BEFORE newer events: a drained visitor that
|
|
103
|
+
# already has a live entry (new events arrived for it mid-failure) has its
|
|
104
|
+
# drained events PREPENDED to that entry — never a duplicate visitor entry;
|
|
105
|
+
# a drained visitor with no live entry is inserted at the FRONT of the queue
|
|
106
|
+
# (its events are the oldest). Segments ride from whichever entry has them
|
|
107
|
+
# (the live entry wins; otherwise the drained entry's segments are adopted).
|
|
108
|
+
#
|
|
109
|
+
# Re-enqueued events count toward {MAX_EVENTS}: a sustained outage that keeps
|
|
110
|
+
# requeuing drops the OLDEST events (+warn+ per drop), bounding host memory
|
|
111
|
+
# without bound (NFR10).
|
|
112
|
+
#
|
|
113
|
+
# @param visitors [Array<Hash{String=>Object}>] drained per-visitor entries
|
|
114
|
+
# (as returned by {#drain!}); an empty array is a no-op.
|
|
115
|
+
# @return [void]
|
|
116
|
+
def requeue(visitors)
|
|
117
|
+
return if visitors.empty?
|
|
118
|
+
|
|
119
|
+
@queue_mutex.synchronize do
|
|
120
|
+
# Walk the drained entries in reverse so that successive front-inserts
|
|
121
|
+
# preserve their original relative order at the head of the queue.
|
|
122
|
+
visitors.reverse_each { |drained| merge_drained(drained) }
|
|
123
|
+
trim_to_cap
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# @return [Integer] the total number of buffered EVENTS (not visitors).
|
|
128
|
+
def size
|
|
129
|
+
@queue_mutex.synchronize { @size }
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Atomically empty the queue WITHOUT returning the entries (Story 4.4 child
|
|
133
|
+
# queue-ownership clear). A forked child inherits a COPY of the parent's
|
|
134
|
+
# queued events; clearing the child's copy ensures the child never
|
|
135
|
+
# double-delivers the parent's events (the parent's timer still runs there
|
|
136
|
+
# and delivers them). Distinct from {#drain!} (which allocates and returns
|
|
137
|
+
# the entries for delivery) — this just discards. Idempotent.
|
|
138
|
+
# @return [void]
|
|
139
|
+
def clear
|
|
140
|
+
@queue_mutex.synchronize do
|
|
141
|
+
@items = []
|
|
142
|
+
@size = 0
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
private
|
|
147
|
+
|
|
148
|
+
# Merge ONE drained per-visitor entry back into @items, preserving the
|
|
149
|
+
# per-visitor merge. Caller holds @queue_mutex.
|
|
150
|
+
#
|
|
151
|
+
# When a live entry exists for the visitor, the drained (older) events are
|
|
152
|
+
# PREPENDED to it and the live entry adopts the drained segments only if it
|
|
153
|
+
# has none. Otherwise the drained entry is inserted at the FRONT of the queue
|
|
154
|
+
# (its events are older than all live traffic). @size grows by the drained
|
|
155
|
+
# event count; {#trim_to_cap} (run by the caller after all merges) bounds it.
|
|
156
|
+
def merge_drained(drained)
|
|
157
|
+
visitor_id = drained["visitorId"]
|
|
158
|
+
drained_events = drained["events"]
|
|
159
|
+
existing = @items.find { |item| item["visitorId"] == visitor_id }
|
|
160
|
+
if existing
|
|
161
|
+
existing["events"].unshift(*drained_events)
|
|
162
|
+
existing["segments"] = drained["segments"] if !existing.key?("segments") && drained.key?("segments")
|
|
163
|
+
else
|
|
164
|
+
@items.unshift(drained)
|
|
165
|
+
end
|
|
166
|
+
@size += drained_events.size
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Drop oldest events until the event count is within {MAX_EVENTS}. Removes a
|
|
170
|
+
# visitor entry once its last event is gone. Caller holds @queue_mutex.
|
|
171
|
+
# Emits ONE aggregated warn after the loop (never one per dropped event) to
|
|
172
|
+
# avoid a warn burst when +requeue+ overshoots the cap by a full drained batch
|
|
173
|
+
# during a sustained outage.
|
|
174
|
+
def trim_to_cap
|
|
175
|
+
dropped_count = 0
|
|
176
|
+
while @size > MAX_EVENTS
|
|
177
|
+
oldest = @items.first
|
|
178
|
+
break if oldest.nil?
|
|
179
|
+
|
|
180
|
+
oldest["events"].shift
|
|
181
|
+
@items.shift if oldest["events"].empty?
|
|
182
|
+
@size -= 1
|
|
183
|
+
dropped_count += 1
|
|
184
|
+
end
|
|
185
|
+
return if dropped_count.zero?
|
|
186
|
+
|
|
187
|
+
@log_manager.warn("VisitorsQueue#trim_to_cap: queue full, dropped #{dropped_count} oldest event(s)")
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
end
|
data/lib/convert_sdk.rb
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "convert_sdk/version"
|
|
4
|
+
require_relative "convert_sdk/murmur_hash3"
|
|
5
|
+
require_relative "convert_sdk/sentinel"
|
|
6
|
+
require_relative "convert_sdk/enums/rule_error"
|
|
7
|
+
require_relative "convert_sdk/enums/bucketing_error"
|
|
8
|
+
require_relative "convert_sdk/enums/feature_status"
|
|
9
|
+
require_relative "convert_sdk/enums/log_level"
|
|
10
|
+
require_relative "convert_sdk/enums/system_events"
|
|
11
|
+
require_relative "convert_sdk/enums/goal_data_key"
|
|
12
|
+
require_relative "convert_sdk/bucketed_variation"
|
|
13
|
+
require_relative "convert_sdk/bucketed_feature"
|
|
14
|
+
require_relative "convert_sdk/redactor"
|
|
15
|
+
require_relative "convert_sdk/log_manager"
|
|
16
|
+
require_relative "convert_sdk/config_validator"
|
|
17
|
+
require_relative "convert_sdk/config"
|
|
18
|
+
require_relative "convert_sdk/bucketing_manager"
|
|
19
|
+
require_relative "convert_sdk/comparisons"
|
|
20
|
+
require_relative "convert_sdk/rule_manager"
|
|
21
|
+
require_relative "convert_sdk/http_client"
|
|
22
|
+
require_relative "convert_sdk/stores/memory_store"
|
|
23
|
+
require_relative "convert_sdk/stores/redis_store"
|
|
24
|
+
require_relative "convert_sdk/data_store_manager"
|
|
25
|
+
require_relative "convert_sdk/event_manager"
|
|
26
|
+
require_relative "convert_sdk/fork_guard"
|
|
27
|
+
require_relative "convert_sdk/background_timer"
|
|
28
|
+
require_relative "convert_sdk/data_manager"
|
|
29
|
+
require_relative "convert_sdk/visitors_queue"
|
|
30
|
+
require_relative "convert_sdk/api_manager"
|
|
31
|
+
require_relative "convert_sdk/experience_manager"
|
|
32
|
+
require_relative "convert_sdk/feature_manager"
|
|
33
|
+
require_relative "convert_sdk/segments_manager"
|
|
34
|
+
require_relative "convert_sdk/context"
|
|
35
|
+
require_relative "convert_sdk/client"
|
|
36
|
+
|
|
37
|
+
# Install the SDK's only global mutation — the Process._fork prepend — at load
|
|
38
|
+
# (it must exist before any fork; installing it is cheap and thread-free, so it
|
|
39
|
+
# respects the NFR4 zero-threads-until-use rule, which concerns THREADS, not the
|
|
40
|
+
# hook). A no-op on JRuby by construction.
|
|
41
|
+
ConvertSdk::ForkGuard.install!
|
|
42
|
+
|
|
43
|
+
# The Convert Experiences full-stack SDK for Ruby.
|
|
44
|
+
#
|
|
45
|
+
# {ConvertSdk.create} is THE public entry point (frozen API name): it builds the
|
|
46
|
+
# validated {Config}, wires the managers, and returns a ready-to-use {Client}.
|
|
47
|
+
module ConvertSdk
|
|
48
|
+
# The default config-cache TTL in seconds, used by the timer-off (Lambda/CLI)
|
|
49
|
+
# decision-time staleness check when +data_refresh_interval+ is +nil+
|
|
50
|
+
# (timer-off ≠ TTL-off). 300s converges on the same cadence the background
|
|
51
|
+
# timer uses, on demand. A Ruby-SDK design constant (PHP on-demand TTL
|
|
52
|
+
# semantics) — the JS SDK has no timer-off TTL concept. See Story 2.7.
|
|
53
|
+
DEFAULT_CONFIG_TTL = 300
|
|
54
|
+
|
|
55
|
+
# The SDK's base error type. Note the SDK has NO custom exception hierarchy for
|
|
56
|
+
# runtime/infra failures (Decision 3 — it degrades gracefully with cached
|
|
57
|
+
# config / sentinels); misconfiguration surfaces as a plain +ArgumentError+
|
|
58
|
+
# from {Config}. This type exists only as a namespace anchor.
|
|
59
|
+
class Error < StandardError; end
|
|
60
|
+
|
|
61
|
+
# Whether {Client} registers its PID-guarded +at_exit+ flush handler at
|
|
62
|
+
# construction (Story 4.4 AC#5). Always +true+ in production. The TEST HARNESS
|
|
63
|
+
# alone flips this off (a +spec/support+ hook) so unit specs that build clients
|
|
64
|
+
# do NOT register live +at_exit+ handlers — which would fire +flush+ during
|
|
65
|
+
# RSpec suite teardown. The handler BODY is unit-tested directly via
|
|
66
|
+
# +Client#run_at_exit_flush+; the live-registration path is proven end-to-end
|
|
67
|
+
# in a SUBPROCESS in spec/integration/fork_safety_spec.rb.
|
|
68
|
+
# @return [Boolean]
|
|
69
|
+
def self.at_exit_registration_enabled?
|
|
70
|
+
@at_exit_registration_enabled = true unless defined?(@at_exit_registration_enabled)
|
|
71
|
+
@at_exit_registration_enabled
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Set the {at_exit_registration_enabled?} flag (test harness only).
|
|
75
|
+
# @param value [Boolean]
|
|
76
|
+
# @return [Boolean]
|
|
77
|
+
def self.at_exit_registration_enabled=(value)
|
|
78
|
+
@at_exit_registration_enabled = value
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Build an SDK client from an SDK key (live config fetch) or a pre-fetched
|
|
82
|
+
# +data:+ object (direct data mode). THE public entry point.
|
|
83
|
+
#
|
|
84
|
+
# Wiring order: a {LogManager} is built first, then {Config} (which registers
|
|
85
|
+
# any +sdk_key+ / +sdk_key_secret+ with the manager's Redactor before any log
|
|
86
|
+
# line can carry them and raises +ArgumentError+ on misconfiguration — the
|
|
87
|
+
# SDK's only raising surface), then the {HttpClient}, {DataStoreManager},
|
|
88
|
+
# {EventManager}, and {DataManager} ports, then the {Client} (which fetches /
|
|
89
|
+
# installs config and fires +ready+). No background threads are started here
|
|
90
|
+
# (NFR4 — lazy start; the refresh / flush timers are wired by their own
|
|
91
|
+
# stories).
|
|
92
|
+
#
|
|
93
|
+
# @param sdk_key [String, nil] the account/project SDK key (fetch mode).
|
|
94
|
+
# @param data [Hash, nil] a pre-fetched config object (direct data mode); when
|
|
95
|
+
# supplied, no fetch occurs.
|
|
96
|
+
# @param store [Object, nil] an optional duck-typed data store (get/set);
|
|
97
|
+
# defaults to an in-memory store.
|
|
98
|
+
# @param clock [#call, nil] an optional monotonic time source (seconds) for
|
|
99
|
+
# the config-cache TTL math (Story 2.7); defaults to the SDK's monotonic
|
|
100
|
+
# clock. Injectable so tests control staleness without real waits. NOT a
|
|
101
|
+
# {Config} option — extracted here before validation.
|
|
102
|
+
# @param sink [Object, nil] an optional initial log sink (anything responding
|
|
103
|
+
# to debug/info/warn/error). Forwarded to the internally-built {LogManager}
|
|
104
|
+
# so the FULL lifecycle — including the construction-time config fetch
|
|
105
|
+
# (+HttpClient#request+ debug line, which carries the sdk_key in the config
|
|
106
|
+
# URL) — is observable through the public entry point. Without this seam a
|
|
107
|
+
# host could only attach a sink AFTER {create}, missing every init-time line
|
|
108
|
+
# (and therefore the init-time redaction proof). NOT a {Config} option —
|
|
109
|
+
# extracted here before validation (like +clock+). Invalid sinks are rejected
|
|
110
|
+
# by {LogManager#add_sink}, not raised.
|
|
111
|
+
# @param options [Hash{Symbol=>Object}] any other {Config::DEFAULTS} option
|
|
112
|
+
# (+sdk_key_secret+, +environment+, +log_level+, timeouts, …).
|
|
113
|
+
# @raise [ArgumentError] on misconfiguration (missing sdk_key+data, bad types,
|
|
114
|
+
# unknown option) — the only exception {create} lets escape.
|
|
115
|
+
# @return [Client] the wired SDK client.
|
|
116
|
+
def self.create(sdk_key: nil, data: nil, store: nil, clock: nil, sink: nil, **options)
|
|
117
|
+
config_options = options.merge(sdk_key: sdk_key, data: data)
|
|
118
|
+
log_manager = LogManager.new(
|
|
119
|
+
level: options.fetch(:log_level, Config::DEFAULTS[:log_level]), sink: sink
|
|
120
|
+
)
|
|
121
|
+
# Wire the ForkGuard re-arm logger (Story 2.7) so fork-detection debug lines
|
|
122
|
+
# flow through the redacting LogManager. nil-safe before wiring.
|
|
123
|
+
ForkGuard.logger = log_manager
|
|
124
|
+
config = Config.new(log_manager: log_manager, **config_options)
|
|
125
|
+
|
|
126
|
+
Client.new(config: config, log_manager: log_manager, **build_managers(config, log_manager, store, clock))
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Build the full collaborator graph wired around a validated {Config}: the HTTP
|
|
130
|
+
# port, the store/event ports, the Story 2.9/2.10 decision engines, the
|
|
131
|
+
# {DataManager} (decision flow), the {ApiManager} (delivery), and the per-context
|
|
132
|
+
# decisioning surfaces (Story 2.11 / 3.1 / 3.2). All thread-free (NFR4 — no
|
|
133
|
+
# timers start here). Returned as the keyword map {Client#initialize} consumes.
|
|
134
|
+
# @api private
|
|
135
|
+
def self.build_managers(config, log_manager, store, clock)
|
|
136
|
+
http_client = HttpClient.new(
|
|
137
|
+
log_manager: log_manager, open_timeout: config.open_timeout, read_timeout: config.read_timeout
|
|
138
|
+
)
|
|
139
|
+
data_store_manager = DataStoreManager.new(log_manager: log_manager, store: store)
|
|
140
|
+
event_manager = EventManager.new(log_manager: log_manager)
|
|
141
|
+
# The pure-math decision engines (Story 2.9 / 2.10) — thread-free, config-bound.
|
|
142
|
+
bucketing_manager = BucketingManager.new(config: config, log_manager: log_manager)
|
|
143
|
+
rule_manager = RuleManager.new(config: config, comparisons: Comparisons, log_manager: log_manager)
|
|
144
|
+
# The DataManager owns the ordered decision flow; it needs the two engines to
|
|
145
|
+
# bucket and walk rules (without them it is config-read-only — the 2.5/2.7 use).
|
|
146
|
+
data_manager = build_data_manager(
|
|
147
|
+
config, log_manager, data_store_manager, clock, bucketing_manager, rule_manager
|
|
148
|
+
)
|
|
149
|
+
api_manager = build_api_manager(config, log_manager, http_client, event_manager, data_manager)
|
|
150
|
+
decisioning = build_decisioning_managers(log_manager, data_store_manager, data_manager, rule_manager)
|
|
151
|
+
{
|
|
152
|
+
http_client: http_client, data_store_manager: data_store_manager,
|
|
153
|
+
event_manager: event_manager, data_manager: data_manager, api_manager: api_manager,
|
|
154
|
+
experience_manager: decisioning[:experience_manager],
|
|
155
|
+
feature_manager: decisioning[:feature_manager],
|
|
156
|
+
segments_manager: decisioning[:segments_manager]
|
|
157
|
+
}
|
|
158
|
+
end
|
|
159
|
+
private_class_method :build_managers
|
|
160
|
+
|
|
161
|
+
# Build the per-context decisioning surfaces (Story 2.11 / 3.1 / 3.2) as a
|
|
162
|
+
# thread-free trio (NFR4 — no timers start here). SegmentsManager reuses the
|
|
163
|
+
# rule engine and resolves the store-key halves from the DataManager readers
|
|
164
|
+
# (its account/project resolvers).
|
|
165
|
+
# @api private
|
|
166
|
+
def self.build_decisioning_managers(log_manager, data_store_manager, data_manager, rule_manager)
|
|
167
|
+
{
|
|
168
|
+
experience_manager: ExperienceManager.new(data_manager: data_manager, log_manager: log_manager),
|
|
169
|
+
feature_manager: FeatureManager.new(data_manager: data_manager, log_manager: log_manager),
|
|
170
|
+
segments_manager: SegmentsManager.new(
|
|
171
|
+
data_manager: data_manager, data_store_manager: data_store_manager,
|
|
172
|
+
account_resolver: -> { data_manager.account_id }, project_resolver: -> { data_manager.project_id },
|
|
173
|
+
rule_manager: rule_manager, log_manager: log_manager
|
|
174
|
+
)
|
|
175
|
+
}
|
|
176
|
+
end
|
|
177
|
+
private_class_method :build_decisioning_managers
|
|
178
|
+
|
|
179
|
+
# Build the outbound delivery surface (Story 4.1): the {ApiManager} owns the
|
|
180
|
+
# per-visitor event queue and the wire-payload builder. No thread is started
|
|
181
|
+
# here (NFR4 — the background flush timer lands in Story 4.2); construction is
|
|
182
|
+
# thread-free.
|
|
183
|
+
# @api private
|
|
184
|
+
def self.build_api_manager(config, log_manager, http_client, event_manager, data_manager)
|
|
185
|
+
ApiManager.new(
|
|
186
|
+
config: config, data_manager: data_manager, http_client: http_client,
|
|
187
|
+
event_manager: event_manager, log_manager: log_manager
|
|
188
|
+
)
|
|
189
|
+
end
|
|
190
|
+
private_class_method :build_api_manager
|
|
191
|
+
|
|
192
|
+
# Build the {DataManager} wired with the Story 2.7 config-cache surface AND the
|
|
193
|
+
# Story 2.9/2.10 decision engines: the cache lives under
|
|
194
|
+
# +convert_sdk.config.{sdkKey}+ (the DataManager writes through on every install
|
|
195
|
+
# and runs the timer-off TTL check against it). A nil sdk_key (direct-data mode)
|
|
196
|
+
# leaves the cache key nil, so no cache write happens. The +clock+ (monotonic
|
|
197
|
+
# TTL source) is injected only when supplied. The +bucketing_manager+ /
|
|
198
|
+
# +rule_manager+ make the ordered decision flow operative (without them the
|
|
199
|
+
# DataManager is config-read-only). The account/project resolvers are left to
|
|
200
|
+
# the DataManager's own readers (its constructor defaults to +#account_id+ /
|
|
201
|
+
# +#project_id+) — the live config IS the source of those store-key halves here.
|
|
202
|
+
# @api private
|
|
203
|
+
def self.build_data_manager(config, log_manager, data_store_manager, clock,
|
|
204
|
+
bucketing_manager, rule_manager)
|
|
205
|
+
config_key = config.sdk_key.nil? ? nil : data_store_manager.config_key(config.sdk_key)
|
|
206
|
+
clock_option = clock.nil? ? {} : { clock: clock } #: Hash[Symbol, ^() -> Float]
|
|
207
|
+
DataManager.new(
|
|
208
|
+
log_manager: log_manager,
|
|
209
|
+
data_store_manager: data_store_manager,
|
|
210
|
+
config_key: config_key,
|
|
211
|
+
ttl: config.data_refresh_interval,
|
|
212
|
+
bucketing_manager: bucketing_manager,
|
|
213
|
+
rule_manager: rule_manager,
|
|
214
|
+
**clock_option
|
|
215
|
+
)
|
|
216
|
+
end
|
|
217
|
+
private_class_method :build_data_manager
|
|
218
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# check-generated-rbs-header.sh — PR-blocking guard (qs-03 / B5).
|
|
3
|
+
#
|
|
4
|
+
# Mirrors the android-sdk "Enforce generated-file header (OpenAPI types)" guard
|
|
5
|
+
# (android-sdk/.github/workflows/ci.yml:51). Every .rbs file under
|
|
6
|
+
# sig/convert_sdk/config/generated/ MUST start with the generated-marker
|
|
7
|
+
# comment. Files that lack the header are either hand-edited or mistakenly
|
|
8
|
+
# added to the directory — both are PR blockers. Regenerate via the backend
|
|
9
|
+
# serving workflow and re-sync; never edit generated files in place.
|
|
10
|
+
#
|
|
11
|
+
# Usage: ./scripts/check-generated-rbs-header.sh
|
|
12
|
+
# Run from the ruby-sdk repo root. Exits 0 when all files carry the marker;
|
|
13
|
+
# exits 1 listing every offending file.
|
|
14
|
+
|
|
15
|
+
set -euo pipefail
|
|
16
|
+
|
|
17
|
+
GEN_DIR="sig/convert_sdk/config/generated"
|
|
18
|
+
MARKER="AUTO-GENERATED FROM backend apiDoc/serving"
|
|
19
|
+
|
|
20
|
+
if [ ! -d "$GEN_DIR" ]; then
|
|
21
|
+
echo "ERROR: generated directory not found: $GEN_DIR"
|
|
22
|
+
echo " Expected the directory to exist after Task B1 (qs-03)."
|
|
23
|
+
exit 1
|
|
24
|
+
fi
|
|
25
|
+
|
|
26
|
+
missing=0
|
|
27
|
+
while IFS= read -r -d '' file; do
|
|
28
|
+
if ! head -1 "$file" | grep -q "$MARKER"; then
|
|
29
|
+
echo "ERROR: $file is missing the auto-generated header."
|
|
30
|
+
echo " Expected line 1 to contain: $MARKER"
|
|
31
|
+
echo " Regenerate via 'yarn generateRubyRbs' in backend/apiDoc/serving"
|
|
32
|
+
echo " and re-sync per sig/convert_sdk/config/generated/. Do not hand-edit."
|
|
33
|
+
missing=1
|
|
34
|
+
fi
|
|
35
|
+
done < <(find "$GEN_DIR" -name '*.rbs' -print0)
|
|
36
|
+
|
|
37
|
+
if [ "$missing" -ne 0 ]; then
|
|
38
|
+
exit 1
|
|
39
|
+
fi
|
|
40
|
+
|
|
41
|
+
echo "OK: every .rbs file under $GEN_DIR carries the auto-generated header."
|