lucerna 0.1.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/README.md +88 -0
- data/lib/lucerna/config.rb +37 -0
- data/lib/lucerna/errors.rb +19 -0
- data/lib/lucerna/gates/client.rb +355 -0
- data/lib/lucerna/gates/evaluate.rb +309 -0
- data/lib/lucerna/gates/exposures.rb +105 -0
- data/lib/lucerna/gates/hashing.rb +71 -0
- data/lib/lucerna/gates/runtime.rb +23 -0
- data/lib/lucerna/http.rb +77 -0
- data/lib/lucerna/identity/client.rb +159 -0
- data/lib/lucerna/identity.rb +72 -0
- data/lib/lucerna/railtie.rb +22 -0
- data/lib/lucerna/reporting.rb +23 -0
- data/lib/lucerna/traits.rb +32 -0
- data/lib/lucerna/version.rb +8 -0
- data/lib/lucerna.rb +136 -0
- metadata +57 -0
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "hashing"
|
|
4
|
+
|
|
5
|
+
module Lucerna
|
|
6
|
+
module Gates
|
|
7
|
+
# The Gates evaluation engine — pure functions over a compiled runtime,
|
|
8
|
+
# no I/O. Ported line-for-line from @lucerna/gates-core
|
|
9
|
+
# (sdks/core/src/evaluate.ts) onto the frozen bucketing contract in
|
|
10
|
+
# Hashing; the golden vectors in sdks/core/src/vectors.json pin parity.
|
|
11
|
+
#
|
|
12
|
+
# The runtime document keeps its wire shape (string keys, camelCase);
|
|
13
|
+
# decisions come out symbol-keyed with reason strings identical to the
|
|
14
|
+
# wire codes.
|
|
15
|
+
#
|
|
16
|
+
# Fail-safe rule: a gate carrying any rule element this engine version
|
|
17
|
+
# doesn't recognize (unknown condition operator, unknown override kind)
|
|
18
|
+
# is answered whole with "unsupported_rule" — flag off, experiment
|
|
19
|
+
# unassigned. An engine never half-evaluates a document it doesn't
|
|
20
|
+
# fully understand.
|
|
21
|
+
module Evaluate
|
|
22
|
+
OPERATORS = %w[is is_not contains gt lt is_set].freeze
|
|
23
|
+
OVERRIDE_BY = %w[user_id domain].freeze
|
|
24
|
+
OVERRIDE_SERVE = %w[on off].freeze
|
|
25
|
+
|
|
26
|
+
module_function
|
|
27
|
+
|
|
28
|
+
# Flag precedence, first hit wins:
|
|
29
|
+
# 1. environment master switch off -> off
|
|
30
|
+
# 2. overrides, in stored order (by user id or email domain)
|
|
31
|
+
# 3. conditions — all must match (a flag's rule is a conjunction)
|
|
32
|
+
# 4. percentage rollout, sticky by user id via the stored salt
|
|
33
|
+
# A rollout between 0 and 100 with no user id serves off — there is
|
|
34
|
+
# nothing to be sticky by.
|
|
35
|
+
def flag(flag, user_id: nil, traits: nil, trace: nil)
|
|
36
|
+
unless flag["enabled"]
|
|
37
|
+
note(trace, "enabled", "flag is off in this environment")
|
|
38
|
+
return { on: false, reason: "flag_off" }
|
|
39
|
+
end
|
|
40
|
+
note(trace, "enabled", "flag is on in this environment")
|
|
41
|
+
|
|
42
|
+
bad_override = flag["overrides"].find do |override|
|
|
43
|
+
!OVERRIDE_BY.include?(override["by"]) || !OVERRIDE_SERVE.include?(override["serve"])
|
|
44
|
+
end
|
|
45
|
+
bad_operator = unsupported_operator(flag["conditions"])
|
|
46
|
+
if bad_override || bad_operator
|
|
47
|
+
note(
|
|
48
|
+
trace,
|
|
49
|
+
"unsupported",
|
|
50
|
+
if bad_operator
|
|
51
|
+
%(condition operator "#{bad_operator}" is not supported by this engine)
|
|
52
|
+
else
|
|
53
|
+
%(override kind "#{bad_override["by"]}/#{bad_override["serve"]}" is not supported by this engine)
|
|
54
|
+
end,
|
|
55
|
+
)
|
|
56
|
+
return { on: false, reason: "unsupported_rule" }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
traits ||= {}
|
|
60
|
+
domain = identity_domain(traits)&.downcase
|
|
61
|
+
flag["overrides"].each do |override|
|
|
62
|
+
matched =
|
|
63
|
+
if override["by"] == "user_id"
|
|
64
|
+
!user_id.nil? && override["who"] == user_id
|
|
65
|
+
else
|
|
66
|
+
!domain.nil? && override["who"].downcase == domain
|
|
67
|
+
end
|
|
68
|
+
note(
|
|
69
|
+
trace,
|
|
70
|
+
"override",
|
|
71
|
+
%(#{override["by"]} "#{override["who"]}" serves #{override["serve"]}: #{matched ? "matched" : "no match"}),
|
|
72
|
+
)
|
|
73
|
+
return { on: override["serve"] == "on", reason: "override" } if matched
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
unless flag["conditions"].empty?
|
|
77
|
+
all = true
|
|
78
|
+
flag["conditions"].each do |condition|
|
|
79
|
+
matched = matches_condition?(traits, condition)
|
|
80
|
+
note(
|
|
81
|
+
trace,
|
|
82
|
+
"condition",
|
|
83
|
+
%(#{condition["trait"]} #{condition["operator"]} "#{condition["value"]}": #{matched ? "matched" : "no match"}),
|
|
84
|
+
)
|
|
85
|
+
unless matched
|
|
86
|
+
all = false
|
|
87
|
+
break
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
return { on: true, reason: "conditions" } if all
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
percentage = flag["rollout"]["percentage"]
|
|
94
|
+
if percentage >= 100
|
|
95
|
+
note(trace, "rollout", "rollout at 100% — everyone is in")
|
|
96
|
+
return { on: true, reason: "rollout" }
|
|
97
|
+
end
|
|
98
|
+
if percentage <= 0
|
|
99
|
+
note(trace, "rollout", "rollout at 0% — no one is in")
|
|
100
|
+
return { on: false, reason: "rollout" }
|
|
101
|
+
end
|
|
102
|
+
if user_id.nil? || user_id.empty?
|
|
103
|
+
note(trace, "rollout", "partial rollout with no userId — nothing to be sticky by")
|
|
104
|
+
return { on: false, reason: "missing_user_id" }
|
|
105
|
+
end
|
|
106
|
+
roll = Hashing.bucket(flag["rollout"]["salt"], user_id)
|
|
107
|
+
on = roll < percentage * 100
|
|
108
|
+
note(
|
|
109
|
+
trace,
|
|
110
|
+
"rollout",
|
|
111
|
+
"bucket #{roll} #{on ? "<" : ">="} threshold #{percentage * 100} (basis points of 10000)",
|
|
112
|
+
)
|
|
113
|
+
{ on: on, reason: "rollout" }
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Experiment assignment, sticky per (salt, user): eligibility gates
|
|
117
|
+
# first (running, environment, audience), then the holdout and traffic
|
|
118
|
+
# dice, then an allocation-weighted walk over the variants. The salt is
|
|
119
|
+
# per iteration, so restarting an iteration reshuffles every bucket by
|
|
120
|
+
# design.
|
|
121
|
+
def experiment(experiment, audiences, user_id: nil, traits: nil, trace: nil)
|
|
122
|
+
iteration = experiment["iteration"]
|
|
123
|
+
|
|
124
|
+
unless experiment["running"]
|
|
125
|
+
note(trace, "status", "experiment is not running")
|
|
126
|
+
return no_variant(iteration, "not_running")
|
|
127
|
+
end
|
|
128
|
+
note(trace, "status", "experiment is running")
|
|
129
|
+
unless experiment["enabled"]
|
|
130
|
+
note(trace, "environment", "experiment is off in this environment")
|
|
131
|
+
return no_variant(iteration, "environment_off")
|
|
132
|
+
end
|
|
133
|
+
note(trace, "environment", "experiment is on in this environment")
|
|
134
|
+
|
|
135
|
+
traits ||= {}
|
|
136
|
+
audience_id = experiment["audienceId"]
|
|
137
|
+
if audience_id.nil?
|
|
138
|
+
note(trace, "audience", "targets everyone")
|
|
139
|
+
else
|
|
140
|
+
audience = audiences[audience_id]
|
|
141
|
+
unless audience
|
|
142
|
+
note(trace, "audience", %(audience "#{audience_id}" is not in the runtime))
|
|
143
|
+
return no_variant(iteration, "audience_not_found")
|
|
144
|
+
end
|
|
145
|
+
bad_operator = unsupported_operator(audience["conditions"])
|
|
146
|
+
if bad_operator
|
|
147
|
+
note(
|
|
148
|
+
trace,
|
|
149
|
+
"unsupported",
|
|
150
|
+
%(audience condition operator "#{bad_operator}" is not supported by this engine),
|
|
151
|
+
)
|
|
152
|
+
return no_variant(iteration, "unsupported_rule")
|
|
153
|
+
end
|
|
154
|
+
in_audience = matches_audience?(traits, audience)
|
|
155
|
+
note(
|
|
156
|
+
trace,
|
|
157
|
+
"audience",
|
|
158
|
+
%(audience "#{audience_id}" (#{audience["match"]}): #{in_audience ? "matched" : "no match"}),
|
|
159
|
+
)
|
|
160
|
+
return no_variant(iteration, "not_in_audience") unless in_audience
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
if user_id.nil? || user_id.empty?
|
|
164
|
+
note(trace, "identity", "no userId — assignments have nothing to be sticky by")
|
|
165
|
+
return no_variant(iteration, "missing_user_id")
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
holdout = experiment["holdout"]
|
|
169
|
+
if holdout.positive?
|
|
170
|
+
roll = Hashing.bucket("#{experiment["salt"]}:holdout", user_id)
|
|
171
|
+
held = roll < holdout * 100
|
|
172
|
+
note(trace, "holdout", "bucket #{roll} vs holdout #{holdout * 100}: #{held ? "held out" : "in"}")
|
|
173
|
+
return no_variant(iteration, "holdout") if held
|
|
174
|
+
end
|
|
175
|
+
traffic = experiment["traffic"]
|
|
176
|
+
if traffic < 100
|
|
177
|
+
roll = Hashing.bucket("#{experiment["salt"]}:traffic", user_id)
|
|
178
|
+
out = roll >= traffic * 100
|
|
179
|
+
note(trace, "traffic", "bucket #{roll} vs traffic #{traffic * 100}: #{out ? "out" : "in"}")
|
|
180
|
+
return no_variant(iteration, "not_in_traffic") if out
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Allocations sum to 100 (enforced on write); the last variant
|
|
184
|
+
# absorbs any rounding gap so the walk always lands.
|
|
185
|
+
roll = Hashing.bucket("#{experiment["salt"]}:variant", user_id)
|
|
186
|
+
cumulative = 0
|
|
187
|
+
experiment["variants"].each do |variant|
|
|
188
|
+
cumulative += variant["allocation"] * 100
|
|
189
|
+
if roll < cumulative
|
|
190
|
+
note(trace, "variant", %(bucket #{roll} lands in "#{variant["name"]}"))
|
|
191
|
+
return assigned(variant, iteration)
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
last = experiment["variants"].last
|
|
195
|
+
unless last
|
|
196
|
+
note(trace, "variant", "experiment has no variants")
|
|
197
|
+
return no_variant(iteration, "not_running")
|
|
198
|
+
end
|
|
199
|
+
note(trace, "variant", %(bucket #{roll} absorbed by last variant "#{last["name"]}"))
|
|
200
|
+
assigned(last, iteration)
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# One identity's decisions over the entire runtime.
|
|
204
|
+
def all(runtime, user_id: nil, traits: nil)
|
|
205
|
+
{
|
|
206
|
+
kills: runtime["kills"].dup,
|
|
207
|
+
flags: runtime["flags"].to_h do |key, value|
|
|
208
|
+
[key, flag(value, user_id: user_id, traits: traits)]
|
|
209
|
+
end,
|
|
210
|
+
experiments: runtime["experiments"].to_h do |key, value|
|
|
211
|
+
[key, experiment(value, runtime["audiences"], user_id: user_id, traits: traits)]
|
|
212
|
+
end,
|
|
213
|
+
}
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# -- internals ------------------------------------------------------
|
|
217
|
+
|
|
218
|
+
def no_variant(iteration, reason)
|
|
219
|
+
{ variant: nil, iteration: iteration, reason: reason }
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def assigned(variant, iteration)
|
|
223
|
+
{
|
|
224
|
+
variant: { id: variant["id"], name: variant["name"], is_control: variant["isControl"] },
|
|
225
|
+
iteration: iteration,
|
|
226
|
+
reason: "assigned",
|
|
227
|
+
}
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# Ordering for gt/lt: numeric when both sides parse as numbers,
|
|
231
|
+
# otherwise lexicographic by UTF-16 code units (ISO dates sort
|
|
232
|
+
# correctly). Code-unit comparison — not locale collation — is part
|
|
233
|
+
# of the frozen contract.
|
|
234
|
+
def compare_values(a, b)
|
|
235
|
+
numeric_a = js_number(a)
|
|
236
|
+
numeric_b = js_number(b)
|
|
237
|
+
return numeric_a <=> numeric_b if numeric_a && numeric_b
|
|
238
|
+
|
|
239
|
+
a.encode(Encoding::UTF_16BE).b <=> b.encode(Encoding::UTF_16BE).b
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# Mirrors JavaScript Number(string) for the realistic trait space:
|
|
243
|
+
# whitespace-trimmed, empty string is 0, otherwise Float parsing.
|
|
244
|
+
def js_number(value)
|
|
245
|
+
stripped = value.strip
|
|
246
|
+
return 0.0 if stripped.empty?
|
|
247
|
+
|
|
248
|
+
Float(stripped)
|
|
249
|
+
rescue ArgumentError
|
|
250
|
+
nil
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def matches_condition?(traits, condition)
|
|
254
|
+
raw = traits[condition["trait"]]
|
|
255
|
+
value = condition["value"]
|
|
256
|
+
case condition["operator"]
|
|
257
|
+
when "is_set"
|
|
258
|
+
!raw.nil? && raw != ""
|
|
259
|
+
when "is"
|
|
260
|
+
raw == value
|
|
261
|
+
when "is_not"
|
|
262
|
+
raw != value
|
|
263
|
+
when "contains"
|
|
264
|
+
(raw || "").downcase.include?(value.downcase)
|
|
265
|
+
when "gt"
|
|
266
|
+
!raw.nil? && compare_values(raw, value).positive?
|
|
267
|
+
when "lt"
|
|
268
|
+
!raw.nil? && compare_values(raw, value).negative?
|
|
269
|
+
else
|
|
270
|
+
false # unreachable — callers gate on unsupported_operator
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
# The first operator in the list this engine doesn't know, if any.
|
|
275
|
+
def unsupported_operator(conditions)
|
|
276
|
+
conditions.find { |condition| !OPERATORS.include?(condition["operator"]) }&.fetch("operator")
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
# The domain an override's by: "domain" matches: an explicit `domain`
|
|
280
|
+
# trait, falling back to the part of the `email` trait after the
|
|
281
|
+
# last "@".
|
|
282
|
+
def identity_domain(traits)
|
|
283
|
+
domain = traits["domain"]
|
|
284
|
+
return domain if domain && !domain.empty?
|
|
285
|
+
|
|
286
|
+
email = traits["email"]
|
|
287
|
+
return nil if email.nil? || email.empty?
|
|
288
|
+
|
|
289
|
+
at = email.rindex("@")
|
|
290
|
+
at ? email[(at + 1)..] : nil
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def matches_audience?(traits, audience)
|
|
294
|
+
conditions = audience["conditions"]
|
|
295
|
+
return true if conditions.empty?
|
|
296
|
+
|
|
297
|
+
if audience["match"] == "all"
|
|
298
|
+
conditions.all? { |condition| matches_condition?(traits, condition) }
|
|
299
|
+
else
|
|
300
|
+
conditions.any? { |condition| matches_condition?(traits, condition) }
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
def note(trace, step, detail)
|
|
305
|
+
trace << { step: step, detail: detail } if trace
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
end
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
require_relative "../http"
|
|
6
|
+
|
|
7
|
+
module Lucerna
|
|
8
|
+
module Gates
|
|
9
|
+
# Exposure delivery: recorded when the app reads a variant
|
|
10
|
+
# (Client#experiment), deduped, batched, and posted fire-and-forget to
|
|
11
|
+
# POST /sdk/v1/events. Nothing here may ever raise into a read or do
|
|
12
|
+
# network on one — record is a pure queue operation; delivery happens
|
|
13
|
+
# on the poller tick (or close). Failures report through on_error and
|
|
14
|
+
# the batch drops on the floor; the server dedupes idempotently per
|
|
15
|
+
# experiment+iteration+user, so anything we resend is harmless.
|
|
16
|
+
class Exposures
|
|
17
|
+
# Local dedupe window (seconds) — the server dedupes forever; this
|
|
18
|
+
# just saves traffic.
|
|
19
|
+
DEDUPE_TTL = 600.0
|
|
20
|
+
# Queue length at which record hints the caller to wake the poller.
|
|
21
|
+
FLUSH_AT = 20
|
|
22
|
+
# The endpoint accepts at most 100 events per request.
|
|
23
|
+
MAX_REQUEST_BATCH = 100
|
|
24
|
+
# Dedupe-map bound: past this, expired entries are pruned on record.
|
|
25
|
+
MAX_SEEN = 10_000
|
|
26
|
+
|
|
27
|
+
def initialize(url:, headers:, transport:, timeout:, on_error:, dedupe_ttl: DEDUPE_TTL, clock: nil)
|
|
28
|
+
@url = url
|
|
29
|
+
@headers = headers.merge("Content-Type" => "application/json")
|
|
30
|
+
@transport = transport
|
|
31
|
+
@timeout = timeout
|
|
32
|
+
@on_error = on_error
|
|
33
|
+
@dedupe_ttl = dedupe_ttl
|
|
34
|
+
@clock = clock || -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
|
|
35
|
+
@mutex = Mutex.new
|
|
36
|
+
@seen = {} # dedupe key -> monotonic time the entry expires
|
|
37
|
+
@pending = []
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Called from app threads on the read path: sync, never raises, no
|
|
41
|
+
# network. Returns true when the queue has reached FLUSH_AT and the
|
|
42
|
+
# caller should wake the delivery tick.
|
|
43
|
+
def record(event)
|
|
44
|
+
now = @clock.call
|
|
45
|
+
key = "#{event["experimentKey"]}\n#{event["iteration"]}\n#{event["userId"]}\n#{event["variantId"]}"
|
|
46
|
+
@mutex.synchronize do
|
|
47
|
+
expiry = @seen[key]
|
|
48
|
+
return false if expiry && expiry > now
|
|
49
|
+
|
|
50
|
+
prune(now) if @seen.size >= MAX_SEEN
|
|
51
|
+
@seen[key] = now + @dedupe_ttl
|
|
52
|
+
@pending << event
|
|
53
|
+
@pending.size >= FLUSH_AT
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def pending_count
|
|
58
|
+
@mutex.synchronize { @pending.size }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Forked children inherit the parent's queue; they must not replay it.
|
|
62
|
+
def clear
|
|
63
|
+
@mutex.synchronize { @pending.clear }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Poller/close threads only. Sends everything queued in ≤100-event
|
|
67
|
+
# requests; a failed request reports and its batch drops.
|
|
68
|
+
def flush
|
|
69
|
+
loop do
|
|
70
|
+
batch = @mutex.synchronize { @pending.shift(MAX_REQUEST_BATCH) }
|
|
71
|
+
break if batch.empty?
|
|
72
|
+
|
|
73
|
+
deliver(batch)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
def deliver(batch)
|
|
80
|
+
request = HTTP::Request.new(:post, @url, @headers, JSON.generate({ "events" => batch }))
|
|
81
|
+
response = @transport.call(request, timeout: @timeout)
|
|
82
|
+
if response.status >= 400
|
|
83
|
+
@on_error.call(
|
|
84
|
+
RequestError.new("exposure flush failed with status #{response.status}", status: response.status),
|
|
85
|
+
)
|
|
86
|
+
end
|
|
87
|
+
rescue StandardError => error
|
|
88
|
+
@on_error.call(error)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def prune(now)
|
|
92
|
+
@seen.delete_if { |_, expiry| expiry <= now }
|
|
93
|
+
return if @seen.size < MAX_SEEN
|
|
94
|
+
|
|
95
|
+
# Every entry still live: the map is genuinely at capacity, so shed
|
|
96
|
+
# the oldest entries (Hash preserves insertion order) rather than
|
|
97
|
+
# letting one hot process grow without bound.
|
|
98
|
+
@seen.keys.each do |key|
|
|
99
|
+
@seen.delete(key)
|
|
100
|
+
break if @seen.size < MAX_SEEN / 2
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lucerna
|
|
4
|
+
module Gates
|
|
5
|
+
# The frozen bucketing contract (never change this):
|
|
6
|
+
#
|
|
7
|
+
# bucket = murmur3_x86_32(utf8("#{salt}:#{unit_id}"), seed 0) % 10000
|
|
8
|
+
#
|
|
9
|
+
# Buckets are basis points in [0, 10000). Salts are stored in the
|
|
10
|
+
# runtime, never derived from keys. Every SDK in every language must
|
|
11
|
+
# reproduce these values bit-for-bit — the golden vectors in
|
|
12
|
+
# sdks/core/src/vectors.json enforce it.
|
|
13
|
+
module Hashing
|
|
14
|
+
MASK = 0xFFFFFFFF
|
|
15
|
+
C1 = 0xcc9e2d51
|
|
16
|
+
C2 = 0x1b873593
|
|
17
|
+
|
|
18
|
+
module_function
|
|
19
|
+
|
|
20
|
+
# MurmurHash3 x86 32-bit over the UTF-8 bytes of +input+, as uint32.
|
|
21
|
+
# 32-bit wraparound is emulated by masking after every multiply/add;
|
|
22
|
+
# Ruby's right shift on the masked non-negative value matches the
|
|
23
|
+
# reference implementation's unsigned shift.
|
|
24
|
+
def murmur3(input, seed = 0)
|
|
25
|
+
data = input.encode(Encoding::UTF_8).bytes
|
|
26
|
+
length = data.length
|
|
27
|
+
h1 = seed & MASK
|
|
28
|
+
|
|
29
|
+
block_end = length - (length % 4)
|
|
30
|
+
index = 0
|
|
31
|
+
while index < block_end
|
|
32
|
+
k1 = data[index] |
|
|
33
|
+
(data[index + 1] << 8) |
|
|
34
|
+
(data[index + 2] << 16) |
|
|
35
|
+
(data[index + 3] << 24)
|
|
36
|
+
k1 = (k1 * C1) & MASK
|
|
37
|
+
k1 = ((k1 << 15) | (k1 >> 17)) & MASK
|
|
38
|
+
k1 = (k1 * C2) & MASK
|
|
39
|
+
h1 ^= k1
|
|
40
|
+
h1 = ((h1 << 13) | (h1 >> 19)) & MASK
|
|
41
|
+
h1 = ((h1 * 5) + 0xe6546b64) & MASK
|
|
42
|
+
index += 4
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
k1 = 0
|
|
46
|
+
remaining = length % 4
|
|
47
|
+
k1 ^= data[index + 2] << 16 if remaining == 3
|
|
48
|
+
k1 ^= data[index + 1] << 8 if remaining >= 2
|
|
49
|
+
if remaining >= 1
|
|
50
|
+
k1 ^= data[index]
|
|
51
|
+
k1 = (k1 * C1) & MASK
|
|
52
|
+
k1 = ((k1 << 15) | (k1 >> 17)) & MASK
|
|
53
|
+
k1 = (k1 * C2) & MASK
|
|
54
|
+
h1 ^= k1
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
h1 ^= length
|
|
58
|
+
h1 ^= h1 >> 16
|
|
59
|
+
h1 = (h1 * 0x85ebca6b) & MASK
|
|
60
|
+
h1 ^= h1 >> 13
|
|
61
|
+
h1 = (h1 * 0xc2b2ae35) & MASK
|
|
62
|
+
h1 ^ (h1 >> 16)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# The unit's bucket for a salt, in basis points [0, 10000).
|
|
66
|
+
def bucket(salt, unit_id)
|
|
67
|
+
murmur3("#{salt}:#{unit_id}") % 10_000
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lucerna
|
|
4
|
+
module Gates
|
|
5
|
+
module Runtime
|
|
6
|
+
# The newest runtime document shape this engine understands. A
|
|
7
|
+
# document with a higher schemaVersion means the SDK needs an
|
|
8
|
+
# upgrade — keep the current runtime instead of half-reading it.
|
|
9
|
+
SCHEMA_VERSION = 1
|
|
10
|
+
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def acceptable?(document)
|
|
14
|
+
return false unless document.is_a?(Hash)
|
|
15
|
+
|
|
16
|
+
version = document["schemaVersion"]
|
|
17
|
+
return false unless version.is_a?(Numeric) && version <= SCHEMA_VERSION
|
|
18
|
+
|
|
19
|
+
%w[kills flags experiments audiences].all? { |key| document[key].is_a?(Hash) }
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
data/lib/lucerna/http.rb
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "openssl"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
require_relative "errors"
|
|
8
|
+
|
|
9
|
+
module Lucerna
|
|
10
|
+
module HTTP
|
|
11
|
+
Request = Struct.new(:method, :url, :headers, :body)
|
|
12
|
+
Response = Struct.new(:status, :headers, :body)
|
|
13
|
+
|
|
14
|
+
RETRY_BASE = 0.25
|
|
15
|
+
DEFAULT_SLEEPER = ->(seconds) { sleep(seconds) }
|
|
16
|
+
|
|
17
|
+
# Transport failures worth retrying — the request may never have
|
|
18
|
+
# reached the server.
|
|
19
|
+
RETRYABLE_EXCEPTIONS = [
|
|
20
|
+
SystemCallError,
|
|
21
|
+
SocketError,
|
|
22
|
+
IOError,
|
|
23
|
+
EOFError,
|
|
24
|
+
Timeout::Error, # covers Net::OpenTimeout / Net::ReadTimeout / Net::WriteTimeout
|
|
25
|
+
OpenSSL::SSL::SSLError,
|
|
26
|
+
].freeze
|
|
27
|
+
|
|
28
|
+
# Default transport: one fresh Net::HTTP connection per call. Fine at
|
|
29
|
+
# a 10s poll cadence; connection reuse is a future optimization. Any
|
|
30
|
+
# object responding to call(request, timeout:) and returning a
|
|
31
|
+
# Response can replace it (the test seam).
|
|
32
|
+
class NetHttpTransport
|
|
33
|
+
def call(request, timeout:)
|
|
34
|
+
uri = URI.parse(request.url)
|
|
35
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
36
|
+
http.use_ssl = uri.scheme == "https"
|
|
37
|
+
http.open_timeout = timeout
|
|
38
|
+
http.read_timeout = timeout
|
|
39
|
+
http.write_timeout = timeout
|
|
40
|
+
|
|
41
|
+
klass = request.method == :post ? Net::HTTP::Post : Net::HTTP::Get
|
|
42
|
+
net_request = klass.new(uri, request.headers)
|
|
43
|
+
net_request.body = request.body if request.body
|
|
44
|
+
|
|
45
|
+
net_response = http.request(net_request)
|
|
46
|
+
headers = {}
|
|
47
|
+
net_response.each_header { |key, value| headers[key.downcase] = value }
|
|
48
|
+
Response.new(net_response.code.to_i, headers, net_response.body)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
module_function
|
|
53
|
+
|
|
54
|
+
# Runs the block (which must return a Response) up to +attempts+ times
|
|
55
|
+
# with jittered exponential backoff. Only network failures and 5xx are
|
|
56
|
+
# retried; any 4xx is a stable answer and raises immediately —
|
|
57
|
+
# AuthError for 401/403. Exhausted attempts raise the last error.
|
|
58
|
+
def with_retry(attempts: 3, sleeper: DEFAULT_SLEEPER)
|
|
59
|
+
attempt = 0
|
|
60
|
+
loop do
|
|
61
|
+
attempt += 1
|
|
62
|
+
begin
|
|
63
|
+
response = yield
|
|
64
|
+
status = response.status
|
|
65
|
+
return response if status < 400
|
|
66
|
+
|
|
67
|
+
error_class = status == 401 || status == 403 ? AuthError : RequestError
|
|
68
|
+
error = error_class.new("request failed with status #{status}", status: status)
|
|
69
|
+
raise error if status < 500 || attempt >= attempts
|
|
70
|
+
rescue *RETRYABLE_EXCEPTIONS => exception
|
|
71
|
+
raise RequestError, "#{exception.class}: #{exception.message}" if attempt >= attempts
|
|
72
|
+
end
|
|
73
|
+
sleeper.call((RETRY_BASE * (2**(attempt - 1))) + (rand * 0.1))
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|