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,288 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module ConvertSdk
|
|
6
|
+
# The outbound delivery manager — it owns the {VisitorsQueue}, the tracking
|
|
7
|
+
# endpoint, queue release, and THE wire-payload builder.
|
|
8
|
+
#
|
|
9
|
+
# == Wire-translation boundary #2 (the only outbound converter)
|
|
10
|
+
#
|
|
11
|
+
# {Config#to_internal} is the single INBOUND snake_case=>camelCase site; this
|
|
12
|
+
# class's payload builder is the single OUTBOUND one. Everything in between —
|
|
13
|
+
# +StoreData+, the queued events — is ALREADY wire-shaped, string-keyed data.
|
|
14
|
+
# The payload is therefore built EXCLUSIVELY here as string-keyed camelCase
|
|
15
|
+
# hashes and serialized with +JSON.generate+ — never string-concatenated JSON,
|
|
16
|
+
# never symbol keys anywhere in the wire hashes. The result is byte-identical to
|
|
17
|
+
# the JS wire contract (+api-manager.ts:197-234+).
|
|
18
|
+
#
|
|
19
|
+
# == The payload shape
|
|
20
|
+
#
|
|
21
|
+
# {
|
|
22
|
+
# "accountId" => …, "projectId" => …,
|
|
23
|
+
# "enrichData" => false, "source" => "ruby-sdk",
|
|
24
|
+
# "visitors" => [
|
|
25
|
+
# { "visitorId" => …, "segments" => {…}?, "events" => [ {…}, … ] }
|
|
26
|
+
# ]
|
|
27
|
+
# }
|
|
28
|
+
#
|
|
29
|
+
# POSTed to +{track_endpoint with [project_id] replaced}/track/{sdkKey}+ via the
|
|
30
|
+
# single {HttpClient} port (the ConvertAgent User-Agent invariant rides
|
|
31
|
+
# automatically; an +Authorization: Bearer {secret}+ header is passed through the
|
|
32
|
+
# port's +headers+ param when a secret is configured — the port enforces the
|
|
33
|
+
# HTTPS-only guard). An empty queue is a no-op.
|
|
34
|
+
#
|
|
35
|
+
# == enrichData / source (verified against JS source)
|
|
36
|
+
#
|
|
37
|
+
# +enrichData+ is +false+: the JS formula is +!objectDeepValue(config,'dataStore')+
|
|
38
|
+
# (+api-manager.ts:94+), which is +false+ whenever a dataStore is configured; the
|
|
39
|
+
# Ruby SDK always provides at least a MemoryStore, and the research register is
|
|
40
|
+
# silent on treating a MemoryStore-only config as "no store", so JS parity holds.
|
|
41
|
+
# +source+ is +"ruby-sdk"+ — the Ruby analogue of JS +config?.network?.source ||
|
|
42
|
+
# 'js-sdk'+ (+api-manager.ts:115+).
|
|
43
|
+
#
|
|
44
|
+
# == Lock discipline (NFR2/NFR13)
|
|
45
|
+
#
|
|
46
|
+
# {#release_queue} drains the queue with an atomic drain-and-swap INSIDE the
|
|
47
|
+
# queue lock, then builds the payload and performs the HTTP POST OUTSIDE the
|
|
48
|
+
# lock. The enqueue path never blocks the caller on network I/O. A failed POST
|
|
49
|
+
# does NOT raise (the full queue-retention behaviour lands in Story 4.2); it is
|
|
50
|
+
# logged and swallowed so the Client boundary never crashes the host.
|
|
51
|
+
#
|
|
52
|
+
# @api private
|
|
53
|
+
class ApiManager
|
|
54
|
+
# The SDK identifier sent as the tracking payload +source+ (JS analogue of
|
|
55
|
+
# +config?.network?.source || 'js-sdk'+ — api-manager.ts:115).
|
|
56
|
+
SOURCE = "ruby-sdk"
|
|
57
|
+
|
|
58
|
+
# JS parity: +!objectDeepValue(config,'dataStore')+ is false whenever a
|
|
59
|
+
# dataStore is configured, and Ruby always provides one (api-manager.ts:94).
|
|
60
|
+
ENRICH_DATA = false
|
|
61
|
+
|
|
62
|
+
# @param config [Config] the validated configuration (track endpoint, sdk_key,
|
|
63
|
+
# sdk_key_secret).
|
|
64
|
+
# @param data_manager [DataManager] supplies +account_id+ / +project_id+ for
|
|
65
|
+
# the payload and the +[project_id]+ URL substitution.
|
|
66
|
+
# @param http_client [HttpClient] the single hardened HTTP port.
|
|
67
|
+
# @param event_manager [EventManager] fires {SystemEvents::API_QUEUE_RELEASED}
|
|
68
|
+
# after a release (JS parity).
|
|
69
|
+
# @param log_manager [LogManager] the redacting logging surface.
|
|
70
|
+
def initialize(config:, data_manager:, http_client:, event_manager:, log_manager:)
|
|
71
|
+
@config = config
|
|
72
|
+
@data_manager = data_manager
|
|
73
|
+
@http_client = http_client
|
|
74
|
+
@event_manager = event_manager
|
|
75
|
+
@log_manager = log_manager
|
|
76
|
+
@queue = VisitorsQueue.new(log_manager: log_manager)
|
|
77
|
+
# The SECOND and FINAL BackgroundTimer instance (architecture Decision 6 —
|
|
78
|
+
# one class, two instances: the refresh timer is 2.7's, this is the flush
|
|
79
|
+
# timer, owned here). It is built and registered with ForkGuard NOW but
|
|
80
|
+
# NEVER started in the factory (NFR4 — no threads until first use); it is
|
|
81
|
+
# lazily started on the first enqueue. A +nil+ flush_interval is the
|
|
82
|
+
# timer-off mode (BackgroundTimer#start is then a guarded no-op — the
|
|
83
|
+
# Lambda recipe for 4.6: explicit flush + size trigger still deliver).
|
|
84
|
+
@flush_timer = BackgroundTimer.new(
|
|
85
|
+
interval: @config.flush_interval,
|
|
86
|
+
log_manager: log_manager,
|
|
87
|
+
name: "flush"
|
|
88
|
+
) { flush_tick }
|
|
89
|
+
ForkGuard.register_timer(@flush_timer)
|
|
90
|
+
# Story 4.4 — child queue-ownership clear. ForkGuard fires this callback in
|
|
91
|
+
# a forked child (after marking timers dead). The child inherits a COPY of
|
|
92
|
+
# the parent's queued events; clearing it here is what makes the child
|
|
93
|
+
# start EMPTY so it never double-delivers the parent's events (the parent's
|
|
94
|
+
# timer still runs there and delivers them). ForkGuard stays generic — it
|
|
95
|
+
# knows nothing about the queue; ApiManager owns its own clear (architecture
|
|
96
|
+
# Decision 6 callback-registry design).
|
|
97
|
+
ForkGuard.register_child_callback(-> { clear_queue_ownership })
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# @return [VisitorsQueue] the underlying per-visitor event queue.
|
|
101
|
+
attr_reader :queue
|
|
102
|
+
|
|
103
|
+
# Enqueue one wire-shaped event for a visitor (delegates to the queue's
|
|
104
|
+
# per-visitor merge), then drive the two automatic delivery triggers:
|
|
105
|
+
#
|
|
106
|
+
# 1. LAZY-START the flush timer (NFR4 — the first enqueue in each process is
|
|
107
|
+
# "first use"; idempotent + re-arms after a fork via 2.6's BackgroundTimer).
|
|
108
|
+
# 2. SIZE trigger — when the queue reaches +event_batch_size+, release with
|
|
109
|
+
# reason +"size"+ DIRECTLY on this thread (JS api-manager.ts:197-198). The
|
|
110
|
+
# enqueue itself is pure in-memory and the size-trigger release POSTs
|
|
111
|
+
# OUTSIDE the queue lock, so the caller is never blocked on the network
|
|
112
|
+
# (NFR2) — only the brief queue-lock acquisition.
|
|
113
|
+
#
|
|
114
|
+
# @param visitor_id [String] the visitor the event belongs to.
|
|
115
|
+
# @param event [Hash{String=>Object}] a wire-shaped event hash.
|
|
116
|
+
# @param segments [Hash{String=>Object}, nil] report-segments, attached only
|
|
117
|
+
# when this enqueue first creates the visitor's queue entry.
|
|
118
|
+
# @return [void]
|
|
119
|
+
def enqueue(visitor_id, event, segments: nil)
|
|
120
|
+
guard_fork_boundary
|
|
121
|
+
@queue.enqueue(visitor_id, event, segments: segments)
|
|
122
|
+
ensure_flush_timer!
|
|
123
|
+
release_queue("size") if @queue.size >= @config.event_batch_size
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Release the queue — the SINGLE delivery implementation all three triggers
|
|
127
|
+
# (explicit +flush+, size, interval) converge on. Drain-and-swap INSIDE the
|
|
128
|
+
# queue lock, then build the wire payload and POST it OUTSIDE the lock (the
|
|
129
|
+
# enqueue path is never blocked on network I/O — NFR2). An empty queue is a
|
|
130
|
+
# no-op.
|
|
131
|
+
#
|
|
132
|
+
# On SUCCESS: an +info+ line and the {SystemEvents::API_QUEUE_RELEASED}
|
|
133
|
+
# lifecycle event fire with a JS-parity payload (+reason+ + visitor count).
|
|
134
|
+
#
|
|
135
|
+
# On FAILURE (a failed {HttpClient::Response} — story 1.5 returns it WITHOUT
|
|
136
|
+
# raising): the drained visitors are RE-ENQUEUED via {VisitorsQueue#requeue}
|
|
137
|
+
# (preserving per-visitor merge), a +warn+ records the retention, and NO
|
|
138
|
+
# event fires. There is NO inline retry — a frozen divergence from PHP's
|
|
139
|
+
# 3-attempt backoff; the next attempt is the next timer tick or size trigger.
|
|
140
|
+
# The bounded queue (drop-oldest + warn at the 1000 cap) keeps a sustained
|
|
141
|
+
# outage from growing host memory without bound (NFR10).
|
|
142
|
+
#
|
|
143
|
+
# Never raises into the host (NFR9): a +rescue StandardError+ logs and
|
|
144
|
+
# swallows. Note the re-enqueue happens BEFORE the rescue so a transport-layer
|
|
145
|
+
# failed Response retains; a raise from the rescue path itself (after the
|
|
146
|
+
# drain) cannot retain, but the never-crash contract takes precedence there.
|
|
147
|
+
#
|
|
148
|
+
# @param reason [String, nil] a human-readable release reason (logged + fired).
|
|
149
|
+
# @return [void]
|
|
150
|
+
def release_queue(reason = nil)
|
|
151
|
+
# Story 4.4 — the SINGLE fork-safety PID boundary all three flush triggers
|
|
152
|
+
# (explicit flush, size, interval) inherit from one place. A cheap
|
|
153
|
+
# ForkGuard.forked? check (an integer PID comparison — Datadog idiom) covers
|
|
154
|
+
# the Process.daemon path that BYPASSES the _fork hook: a stale process
|
|
155
|
+
# re-arms (marks the inherited dead timers dead, clears the inherited queue,
|
|
156
|
+
# resets owner_pid) BEFORE proceeding. The check fires BEFORE the
|
|
157
|
+
# empty-queue early return so a freshly daemonised process re-arms its
|
|
158
|
+
# timers even when nothing is queued yet.
|
|
159
|
+
guard_fork_boundary
|
|
160
|
+
|
|
161
|
+
visitors = @queue.drain!
|
|
162
|
+
return if visitors.empty?
|
|
163
|
+
|
|
164
|
+
deliver(visitors, reason)
|
|
165
|
+
rescue StandardError => e
|
|
166
|
+
# Never-crash boundary: a delivery failure must not crash the host.
|
|
167
|
+
@log_manager.error("ApiManager#release_queue: #{e.class}: #{e.message}")
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
private
|
|
171
|
+
|
|
172
|
+
# POST the drained per-visitor entries and branch on the result. On SUCCESS:
|
|
173
|
+
# an +info+ line + the {SystemEvents::API_QUEUE_RELEASED} lifecycle event. On
|
|
174
|
+
# FAILURE: re-enqueue the drained visitors (preserving the per-visitor merge)
|
|
175
|
+
# and +warn+ — NO event fires (frozen Ruby divergence from JS, which DOES fire
|
|
176
|
+
# on failure — api-manager.ts:247). There is NO inline retry; the next timer
|
|
177
|
+
# tick / size trigger retries (the bounded queue keeps a sustained outage from
|
|
178
|
+
# growing host memory — NFR10). Caller wraps this in the never-crash rescue.
|
|
179
|
+
def deliver(visitors, reason)
|
|
180
|
+
response = post_payload(build_payload(visitors))
|
|
181
|
+
if response.success?
|
|
182
|
+
@log_manager.info(
|
|
183
|
+
"ApiManager#release_queue: queue released, reason=#{reason}, visitors=#{visitors.size}"
|
|
184
|
+
)
|
|
185
|
+
@event_manager.fire(
|
|
186
|
+
SystemEvents::API_QUEUE_RELEASED,
|
|
187
|
+
{ "reason" => reason, "visitors" => visitors.size }
|
|
188
|
+
)
|
|
189
|
+
else
|
|
190
|
+
@queue.requeue(visitors)
|
|
191
|
+
@log_manager.warn(
|
|
192
|
+
"ApiManager#release_queue: delivery failed, retaining #{count_events(visitors)} events " \
|
|
193
|
+
"(status #{response.status}), reason=#{reason}"
|
|
194
|
+
)
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Story 4.4 — the PID-guarded fork boundary. When +ForkGuard.forked?+ is true
|
|
199
|
+
# (a +Process.daemon+ spawn bypassed the +_fork+ hook so owner_pid is stale),
|
|
200
|
+
# run the shared re-arm path before any delivery: it marks both registered
|
|
201
|
+
# timers dead, fires the child-callbacks (this manager's queue-ownership
|
|
202
|
+
# clear), and resets owner_pid — leaving the process behaving like a fresh
|
|
203
|
+
# child. A free no-op in the owning process and on JRuby (forked? is always
|
|
204
|
+
# false there).
|
|
205
|
+
def guard_fork_boundary
|
|
206
|
+
return unless ForkGuard.forked?
|
|
207
|
+
|
|
208
|
+
@log_manager.debug(
|
|
209
|
+
"ApiManager: stale process detected (fork/daemon bypass), re-arming"
|
|
210
|
+
)
|
|
211
|
+
ForkGuard.rearm!
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# The registered ForkGuard child-callback (Story 4.4): clear this manager's
|
|
215
|
+
# inherited queue so a forked child starts EMPTY and never double-delivers
|
|
216
|
+
# the parent's events. ForkGuard fires this in the child after marking timers
|
|
217
|
+
# dead. Never-crash: a raising callback must not break the fork hook.
|
|
218
|
+
def clear_queue_ownership
|
|
219
|
+
@queue.clear
|
|
220
|
+
@log_manager.debug("ApiManager#clear_queue_ownership: cleared inherited queue ownership in child")
|
|
221
|
+
rescue StandardError => e
|
|
222
|
+
@log_manager.error("ApiManager#clear_queue_ownership: #{e.class}: #{e.message}")
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# Lazily start the flush BackgroundTimer (NFR4 — never in the factory). Called
|
|
226
|
+
# on the first (and every) enqueue; idempotent and re-arms transparently after
|
|
227
|
+
# a fork (2.6 BackgroundTimer#start). A +nil+ flush_interval makes this a
|
|
228
|
+
# guarded no-op — no thread is ever created (timer-off mode).
|
|
229
|
+
def ensure_flush_timer!
|
|
230
|
+
@flush_timer.start
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# The flush-timer tick body: release the queue with reason +"interval"+. Wrapped
|
|
234
|
+
# by BackgroundTimer's never-crash rescue (2.6); {#release_queue} additionally
|
|
235
|
+
# rescues internally so a tick never escapes.
|
|
236
|
+
def flush_tick
|
|
237
|
+
release_queue("interval")
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# Total events across the given per-visitor entries (for the retention warn).
|
|
241
|
+
def count_events(visitors)
|
|
242
|
+
visitors.sum { |entry| entry["events"].size }
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# Build the string-keyed camelCase wire payload (boundary #2). The drained
|
|
246
|
+
# visitor entries are already wire-shaped, so they ride verbatim.
|
|
247
|
+
def build_payload(visitors)
|
|
248
|
+
{
|
|
249
|
+
"accountId" => @data_manager.account_id,
|
|
250
|
+
"projectId" => @data_manager.project_id,
|
|
251
|
+
"enrichData" => ENRICH_DATA,
|
|
252
|
+
"source" => SOURCE,
|
|
253
|
+
"visitors" => visitors
|
|
254
|
+
}
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# POST the payload to the project-scoped track URL through the HTTP port and
|
|
258
|
+
# return the frozen {HttpClient::Response}. The port serializes the body with
|
|
259
|
+
# +JSON.generate+, applies the ConvertAgent UA, and strips a Bearer header on a
|
|
260
|
+
# non-HTTPS endpoint. The port NEVER raises — a transport failure comes back as
|
|
261
|
+
# a failed Response (+success? == false+), so the caller branches on the result.
|
|
262
|
+
def post_payload(payload)
|
|
263
|
+
@http_client.request(method: :post, url: track_url, headers: auth_headers, body: payload)
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# +{track_endpoint with [project_id] replaced}/track/{sdkKey}+ — JS
|
|
267
|
+
# api-manager.ts:221-229. The +sdk_key+ falls back to +"{accountId}/{projectId}"+
|
|
268
|
+
# when none is configured (JS +config?.sdkKey || `${accountId}/${projectId}`+).
|
|
269
|
+
def track_url
|
|
270
|
+
base = @config.track_endpoint.to_s.gsub("[project_id]", @data_manager.project_id.to_s)
|
|
271
|
+
"#{base}/track/#{sdk_key}"
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
# The SDK key path segment, with the JS account/project fallback.
|
|
275
|
+
def sdk_key
|
|
276
|
+
@config.sdk_key || "#{@data_manager.account_id}/#{@data_manager.project_id}"
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
# An +Authorization: Bearer {secret}+ header VALUE when a secret is configured,
|
|
280
|
+
# else none. The port owns the UA / HTTPS / plaintext-stripping mechanics.
|
|
281
|
+
def auth_headers
|
|
282
|
+
secret = @config.sdk_key_secret
|
|
283
|
+
return {} if secret.nil?
|
|
284
|
+
|
|
285
|
+
{ "Authorization" => "Bearer #{secret}" }
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
end
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ConvertSdk
|
|
4
|
+
# The SDK's single background-thread primitive — the ONLY +Thread.new+ site in
|
|
5
|
+
# the gem (architecture Decision 6, thread/fork boundary). One class, two
|
|
6
|
+
# future instances: the config-refresh timer (Story 2.7) and the queue-flush
|
|
7
|
+
# timer (Story 4.2). It is never subclassed.
|
|
8
|
+
#
|
|
9
|
+
# A timer wraps an +interval+, a tick +block+, and a Mutex'd lifecycle state
|
|
10
|
+
# machine. {#start} lazily spawns a single loop thread (NFR4 — no threads
|
|
11
|
+
# until first use); {#stop} signals it to exit and joins it; {#mark_dead}
|
|
12
|
+
# clears the state WITHOUT joining (the fork re-arm hook — a thread reference
|
|
13
|
+
# copied into a forked child is dead and joining it can hang), so the next
|
|
14
|
+
# {#start} transparently re-arms a fresh thread.
|
|
15
|
+
#
|
|
16
|
+
# The loop sleeps for +interval+ on a {Thread::ConditionVariable} (interruptible
|
|
17
|
+
# — {#stop} is responsive instead of waiting out a bare +sleep+), then runs the
|
|
18
|
+
# block. Each tick is wrapped in +rescue StandardError+ and logged (never
|
|
19
|
+
# +rescue Exception+): a raising tick is logged and the loop continues — an
|
|
20
|
+
# exception must never silently kill a timer thread (never-crash contract).
|
|
21
|
+
#
|
|
22
|
+
# A +nil+ or zero +interval+ is the timer-off mode: {#start} is a guarded
|
|
23
|
+
# no-op and no thread is ever created.
|
|
24
|
+
#
|
|
25
|
+
# @api private — not part of the public SDK surface.
|
|
26
|
+
class BackgroundTimer
|
|
27
|
+
# @param interval [Numeric, nil] seconds between ticks; +nil+/zero disables
|
|
28
|
+
# the timer (no thread is ever started).
|
|
29
|
+
# @param log_manager [ConvertSdk::LogManager] sink for debug (thread
|
|
30
|
+
# creation) and error (raising tick) lines.
|
|
31
|
+
# @param name [String] identifies the timer in log lines (e.g. "refresh").
|
|
32
|
+
# @yield the tick block, invoked once per interval.
|
|
33
|
+
def initialize(interval:, log_manager:, name:, &block)
|
|
34
|
+
@interval = interval
|
|
35
|
+
@log_manager = log_manager
|
|
36
|
+
@name = name
|
|
37
|
+
@block = block
|
|
38
|
+
# Thread safety: @thread and @running are guarded by @state_mutex; the
|
|
39
|
+
# condition variable wakes the loop's interruptible sleep on #stop.
|
|
40
|
+
@state_mutex = Thread::Mutex.new
|
|
41
|
+
@sleep_cv = Thread::ConditionVariable.new
|
|
42
|
+
@thread = nil
|
|
43
|
+
@running = false
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Start the loop thread if not already running. Idempotent: concurrent calls
|
|
47
|
+
# and repeat calls produce exactly one thread. Re-arms transparently after
|
|
48
|
+
# {#mark_dead}. A +nil+/zero interval is a no-op (timer-off mode).
|
|
49
|
+
# @return [void]
|
|
50
|
+
def start
|
|
51
|
+
@state_mutex.synchronize do
|
|
52
|
+
# Timer-off guard: a nil/zero interval never starts (and narrows the
|
|
53
|
+
# interval to a concrete positive Numeric for the loop's sleep).
|
|
54
|
+
interval = @interval
|
|
55
|
+
return if interval.nil? || interval <= 0
|
|
56
|
+
return if @running
|
|
57
|
+
|
|
58
|
+
@running = true
|
|
59
|
+
@thread = Thread.new { run_loop(interval.to_f) }
|
|
60
|
+
@log_manager.debug("BackgroundTimer#start: started ##{@name} (interval=#{@interval}s)")
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Signal the loop to exit and join the thread. Idempotent: a no-op when not
|
|
65
|
+
# running (including after {#mark_dead}).
|
|
66
|
+
# @return [void]
|
|
67
|
+
def stop
|
|
68
|
+
thread = nil #: Thread?
|
|
69
|
+
@state_mutex.synchronize do
|
|
70
|
+
return unless @running
|
|
71
|
+
|
|
72
|
+
@running = false
|
|
73
|
+
@sleep_cv.broadcast
|
|
74
|
+
thread = @thread
|
|
75
|
+
@thread = nil
|
|
76
|
+
end
|
|
77
|
+
thread&.join
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Fork re-arm hook: clear the lifecycle state WITHOUT joining the thread.
|
|
81
|
+
# The thread reference is stale in a forked child (fork copies only the
|
|
82
|
+
# calling thread), so joining it can hang. The next {#start} creates a fresh
|
|
83
|
+
# thread.
|
|
84
|
+
# @return [void]
|
|
85
|
+
def mark_dead
|
|
86
|
+
@state_mutex.synchronize do
|
|
87
|
+
@running = false
|
|
88
|
+
@thread = nil
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# @return [Boolean] whether the loop thread is currently running.
|
|
93
|
+
def alive?
|
|
94
|
+
@state_mutex.synchronize { @running && !@thread.nil? }
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
private
|
|
98
|
+
|
|
99
|
+
# The loop body. Interruptible-sleeps for +interval+ (a concrete Float), then
|
|
100
|
+
# runs the tick under the never-crash rescue. Exits when +#stop+ clears the
|
|
101
|
+
# +running+ flag.
|
|
102
|
+
# @param interval [Float] validated sleep duration.
|
|
103
|
+
def run_loop(interval)
|
|
104
|
+
loop do
|
|
105
|
+
@state_mutex.synchronize do
|
|
106
|
+
break unless @running
|
|
107
|
+
|
|
108
|
+
@sleep_cv.wait(@state_mutex, interval)
|
|
109
|
+
end
|
|
110
|
+
break unless running?
|
|
111
|
+
|
|
112
|
+
tick
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# @return [Boolean] a locked read of the running flag.
|
|
117
|
+
def running?
|
|
118
|
+
@state_mutex.synchronize { @running }
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Run the tick block under the never-crash contract: rescue StandardError,
|
|
122
|
+
# log, and let the loop continue. Never rescue Exception.
|
|
123
|
+
def tick
|
|
124
|
+
@block&.call
|
|
125
|
+
rescue StandardError => e
|
|
126
|
+
@log_manager.error("BackgroundTimer##{@name}: tick raised #{e.class}: #{e.message}")
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ConvertSdk
|
|
4
|
+
# A successfully resolved feature — a frozen value object returned when a
|
|
5
|
+
# feature decision is made (the success counterpart to a {Sentinel} miss).
|
|
6
|
+
#
|
|
7
|
+
# Implemented as a frozen +Struct+ subclass (NOT +Data.define+, which requires
|
|
8
|
+
# Ruby 3.2; this gem's floor is 3.1). Members are snake_case, aligned to the JS
|
|
9
|
+
# +BucketedFeature+ shape, verified against
|
|
10
|
+
# javascript-sdk/packages/types/src/BucketedFeature.ts and the vendored
|
|
11
|
+
# spec/fixtures/test-config.json feature entity. +status+ holds a
|
|
12
|
+
# {FeatureStatus} wire value; +variables+ is the feature's variable map.
|
|
13
|
+
class BucketedFeature < Struct.new(
|
|
14
|
+
:experience_id,
|
|
15
|
+
:experience_key,
|
|
16
|
+
:experience_name,
|
|
17
|
+
:id,
|
|
18
|
+
:key,
|
|
19
|
+
:name,
|
|
20
|
+
:status,
|
|
21
|
+
:variables,
|
|
22
|
+
keyword_init: true
|
|
23
|
+
)
|
|
24
|
+
# @return [void] builds the struct then freezes it (immutable value object).
|
|
25
|
+
def initialize(**)
|
|
26
|
+
super
|
|
27
|
+
freeze
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# @return [Boolean] always false — a real decision is never a business miss.
|
|
31
|
+
def error?
|
|
32
|
+
false
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ConvertSdk
|
|
4
|
+
# A successfully bucketed variation — a frozen value object returned when a
|
|
5
|
+
# visitor IS decided into a variation (the success counterpart to a {Sentinel}
|
|
6
|
+
# business miss).
|
|
7
|
+
#
|
|
8
|
+
# Implemented as a frozen +Struct+ subclass (NOT +Data.define+, which requires
|
|
9
|
+
# Ruby 3.2; this gem's floor is 3.1). Members are snake_case, aligned to the JS
|
|
10
|
+
# +BucketedVariation+ shape (+ExperienceVariationConfig+ plus the experience
|
|
11
|
+
# fields), verified against javascript-sdk/packages/types/src/BucketedVariation.ts
|
|
12
|
+
# and the vendored spec/fixtures/test-config.json variation entity.
|
|
13
|
+
#
|
|
14
|
+
# v = ConvertSdk::BucketedVariation.new(key: "variation-a", id: "200381")
|
|
15
|
+
# case v.key
|
|
16
|
+
# when nil then show_default # a sentinel miss
|
|
17
|
+
# else render(v.key) # a real decision — error? is false
|
|
18
|
+
# end
|
|
19
|
+
class BucketedVariation < Struct.new(
|
|
20
|
+
:experience_id,
|
|
21
|
+
:experience_key,
|
|
22
|
+
:experience_name,
|
|
23
|
+
:bucketing_allocation,
|
|
24
|
+
:id,
|
|
25
|
+
:key,
|
|
26
|
+
:name,
|
|
27
|
+
:status,
|
|
28
|
+
:traffic_allocation,
|
|
29
|
+
:changes,
|
|
30
|
+
keyword_init: true
|
|
31
|
+
)
|
|
32
|
+
# @return [void] builds the struct then freezes it (immutable value object).
|
|
33
|
+
def initialize(**)
|
|
34
|
+
super
|
|
35
|
+
freeze
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# @return [Boolean] always false — a real decision is never a business miss.
|
|
39
|
+
def error?
|
|
40
|
+
false
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ConvertSdk
|
|
4
|
+
# Deterministic visitor bucketing — the cross-SDK variation-assignment engine.
|
|
5
|
+
#
|
|
6
|
+
# Given an experience id, a visitor id, and a caller-built +buckets+ hash
|
|
7
|
+
# (variation id => traffic percentage), this resolves a variation
|
|
8
|
+
# BYTE-IDENTICALLY to the JS SDK +bucketing-manager.ts+ and the proven PHP
|
|
9
|
+
# port +BucketingManager.php+. A visitor MUST bucket into the same variation on
|
|
10
|
+
# web (JS), PHP, and Ruby — the cross-SDK distribution spec is the CI proof.
|
|
11
|
+
#
|
|
12
|
+
# The pipeline mirrors JS exactly, link for link:
|
|
13
|
+
# 1. hash input = +experience_id + String(visitor_id)+ (experience FIRST, no
|
|
14
|
+
# delimiter) — JS +bucketing-manager.ts:97+, PHP +BucketingManager.php:89+.
|
|
15
|
+
# 2. +hash = MurmurHash3.hash(input, seed)+ — the proven Story 1.2 module;
|
|
16
|
+
# never reimplemented here.
|
|
17
|
+
# 3. +value = ((hash / 4_294_967_296.0) * max_traffic).to_i+ — float division
|
|
18
|
+
# then multiply then truncate, operation ORDER preserved. Ruby Float is
|
|
19
|
+
# IEEE-754 double like JS Number, and +Integer()+-via-+to_i+ truncates
|
|
20
|
+
# toward zero, matching JS +parseInt(String(val), 10)+ at +bm.ts:99+
|
|
21
|
+
# (behaviourally floor for all non-negative hash values).
|
|
22
|
+
# 4. +select_bucket+ walks variation cumulative ranges in insertion order:
|
|
23
|
+
# +prev += pct * 100 + redistribute+; the first variation satisfying the
|
|
24
|
+
# STRICT upper-bound +value < prev+ wins — JS +bm.ts:60-85+, PHP
|
|
25
|
+
# +BucketingManager.php:50-72+. No covering range => +nil+ (the caller
|
|
26
|
+
# treats +nil+ as VARIATION_NOT_DECIDED).
|
|
27
|
+
#
|
|
28
|
+
# Traffic allocation is NOT this class's concern: the caller
|
|
29
|
+
# (ExperienceManager/DataManager) constructs +buckets+ with only the
|
|
30
|
+
# traffic-allocated variations before invoking. BucketingManager is
|
|
31
|
+
# allocation-agnostic and answers one question deterministically: "given this
|
|
32
|
+
# experience config and this visitor id, which variation?"
|
|
33
|
+
#
|
|
34
|
+
# Pure in-memory computation (NFR1) — no I/O, no store access. Bucketing
|
|
35
|
+
# constants (+max_traffic+, +hash_seed+, +max_hash+) come from the injected
|
|
36
|
+
# {Config}, never inline literals. Logging stays at debug for the decisioning
|
|
37
|
+
# internals (FR56); never-crash is the caller's contract, but the class rescues
|
|
38
|
+
# nothing here because its inputs are caller-validated.
|
|
39
|
+
#
|
|
40
|
+
# @api private
|
|
41
|
+
class BucketingManager
|
|
42
|
+
# Build a bucketing engine bound to a {Config}'s frozen bucketing constants.
|
|
43
|
+
#
|
|
44
|
+
# @param config [Config] supplies +max_traffic+, +hash_seed+, +max_hash+.
|
|
45
|
+
# @param log_manager [LogManager, nil] optional debug logger for decisioning
|
|
46
|
+
# internals; absent in lean unit contexts.
|
|
47
|
+
def initialize(config:, log_manager: nil)
|
|
48
|
+
@max_traffic = config.max_traffic
|
|
49
|
+
@hash_seed = config.hash_seed
|
|
50
|
+
@max_hash = config.max_hash.to_f
|
|
51
|
+
@log_manager = log_manager
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Compute the deterministic bucket value for a visitor.
|
|
55
|
+
#
|
|
56
|
+
# @param visitor_id [#to_s] the visitor identifier (coerced via +String()+
|
|
57
|
+
# before hashing, matching JS +String(visitorId)+).
|
|
58
|
+
# @param experience_id [String] the experience identifier; prefixed to the
|
|
59
|
+
# visitor id to form the hash input. Defaults to +""+.
|
|
60
|
+
# @param seed [Integer] MurmurHash3 seed; defaults to the Config hash seed.
|
|
61
|
+
# @return [Integer] the bucket value in +[0, max_traffic)+.
|
|
62
|
+
def value_visitor_based(visitor_id, experience_id: "", seed: @hash_seed)
|
|
63
|
+
input = "#{experience_id}#{visitor_id}"
|
|
64
|
+
hash = MurmurHash3.hash(input, seed)
|
|
65
|
+
scaled = (hash / @max_hash) * @max_traffic
|
|
66
|
+
result = scaled.to_i
|
|
67
|
+
|
|
68
|
+
@log_manager&.debug(
|
|
69
|
+
"BucketingManager#value_visitor_based: " \
|
|
70
|
+
"experience_id=#{experience_id.inspect} visitor_id=#{visitor_id.inspect} " \
|
|
71
|
+
"seed=#{seed} hash=#{hash} scaled=#{scaled} result=#{result}"
|
|
72
|
+
)
|
|
73
|
+
result
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Select the variation whose cumulative range contains +value+.
|
|
77
|
+
#
|
|
78
|
+
# Walks +buckets+ in insertion order accumulating +pct * 100 + redistribute+
|
|
79
|
+
# per entry, returning the first variation id satisfying the strict
|
|
80
|
+
# upper-bound +value < prev+. Returns +nil+ when no range covers +value+
|
|
81
|
+
# (including an empty +buckets+ hash).
|
|
82
|
+
#
|
|
83
|
+
# @param buckets [Hash{String=>Numeric}] variation id => traffic percentage.
|
|
84
|
+
# @param value [Integer] a bucket value in +[0, max_traffic)+.
|
|
85
|
+
# @param redistribute [Numeric] per-bucket widening offset (default +0+).
|
|
86
|
+
# @return [String, nil] the selected variation id, or +nil+.
|
|
87
|
+
def select_bucket(buckets, value, redistribute = 0)
|
|
88
|
+
variation = nil
|
|
89
|
+
# Float accumulator: JS does `prev += buckets[id]*100 + redistribute` in
|
|
90
|
+
# IEEE-754 double arithmetic (bm.ts:68). Ruby Float is the same double, so
|
|
91
|
+
# accumulating in Float mirrors JS exactly. value (Integer) < prev (Float)
|
|
92
|
+
# compares identically to the JS strict upper-bound check.
|
|
93
|
+
prev = 0.0
|
|
94
|
+
buckets.each do |variation_id, percentage|
|
|
95
|
+
prev += (percentage.to_f * 100) + redistribute
|
|
96
|
+
if value < prev
|
|
97
|
+
variation = variation_id
|
|
98
|
+
break
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
@log_manager&.debug(
|
|
103
|
+
"BucketingManager#select_bucket: " \
|
|
104
|
+
"value=#{value} redistribute=#{redistribute} variation=#{variation.inspect}"
|
|
105
|
+
)
|
|
106
|
+
variation
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Resolve a visitor to a variation, returning the assignment and its bucket
|
|
110
|
+
# value, or +nil+ when no variation range covers the visitor.
|
|
111
|
+
#
|
|
112
|
+
# @param buckets [Hash{String=>Numeric}] variation id => traffic percentage.
|
|
113
|
+
# @param visitor_id [#to_s] the visitor identifier.
|
|
114
|
+
# @param experience_id [String] the experience identifier (default +""+).
|
|
115
|
+
# @param seed [Integer] MurmurHash3 seed (default Config hash seed).
|
|
116
|
+
# @param redistribute [Numeric] per-bucket widening offset (default +0+).
|
|
117
|
+
# @return [Hash{Symbol=>Object}, nil] +{variation_id:, bucketing_allocation:}+
|
|
118
|
+
# or +nil+ (caller treats +nil+ as VARIATION_NOT_DECIDED).
|
|
119
|
+
def bucket_for_visitor(buckets, visitor_id, experience_id: "", seed: @hash_seed, redistribute: 0)
|
|
120
|
+
value = value_visitor_based(visitor_id, experience_id: experience_id, seed: seed)
|
|
121
|
+
selected = select_bucket(buckets, value, redistribute)
|
|
122
|
+
|
|
123
|
+
@log_manager&.debug(
|
|
124
|
+
"BucketingManager#bucket_for_visitor: " \
|
|
125
|
+
"experience_id=#{experience_id.inspect} visitor_id=#{visitor_id.inspect} " \
|
|
126
|
+
"bucket_value=#{value} selected_variation_id=#{selected.inspect}"
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
return nil if selected.nil?
|
|
130
|
+
|
|
131
|
+
{ variation_id: selected, bucketing_allocation: value }
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|