dials 0.3.0 → 0.4.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 +4 -4
- data/CHANGELOG.md +57 -0
- data/README.md +15 -0
- data/lib/dials/active_record/models.rb +23 -13
- data/lib/dials/active_record/store.rb +37 -28
- data/lib/dials/actor.rb +5 -5
- data/lib/dials/config.rb +84 -39
- data/lib/dials/errors.rb +18 -0
- data/lib/dials/generated.rb +22 -19
- data/lib/dials/namespace.rb +402 -0
- data/lib/dials/registry.rb +14 -12
- data/lib/dials/storage.rb +153 -0
- data/lib/dials/testing.rb +25 -13
- data/lib/dials/version.rb +1 -1
- data/lib/dials.rb +133 -201
- data/lib/generators/dials/install/install_generator.rb +1 -1
- data/lib/generators/dials/install/templates/initializer.rb.tt +2 -2
- metadata +3 -1
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dials
|
|
4
|
+
# A full dials instance: its own registry, config, store, table, cache,
|
|
5
|
+
# change log, generated readers and test overrides. A subsystem that owns
|
|
6
|
+
# a namespace owns its operator settings end to end — nothing it declares
|
|
7
|
+
# or writes touches the host app's dials, and nothing the host writes
|
|
8
|
+
# touches its own.
|
|
9
|
+
#
|
|
10
|
+
# The root namespace (name :default) is the one the `Dials` module itself
|
|
11
|
+
# delegates to, so an app that never mentions namespaces uses exactly one.
|
|
12
|
+
# Every other namespace is created with Dials.namespace:
|
|
13
|
+
#
|
|
14
|
+
# Shipping = Dials.namespace(:shipping, label: "Shipping") do |config|
|
|
15
|
+
# config.store = :active_record # table: "shipping_dials"
|
|
16
|
+
# end
|
|
17
|
+
#
|
|
18
|
+
# Shipping.define { dial :max_parcel_kg, default: 20, type: :integer }
|
|
19
|
+
# Shipping.max_parcel_kg # => 20
|
|
20
|
+
#
|
|
21
|
+
# A dial resolves inside its namespace only: there is no cross-namespace
|
|
22
|
+
# fallback, and the same key may be declared in two namespaces.
|
|
23
|
+
class Namespace
|
|
24
|
+
ROOT_NAME = :default
|
|
25
|
+
|
|
26
|
+
# A name becomes a table name and (for an ActiveRecord store) a model
|
|
27
|
+
# class name. Every segment starts with a letter, so camelizing the
|
|
28
|
+
# segments is reversible and two names can never derive one model class:
|
|
29
|
+
# "flat_rate" -> FlatRateEntry, and nothing else does.
|
|
30
|
+
NAME_FORMAT = /\A[a-z][a-z0-9]*(_[a-z][a-z0-9]*)*\z/
|
|
31
|
+
|
|
32
|
+
attr_reader :name, :registry, :config, :txn_write_key
|
|
33
|
+
|
|
34
|
+
# The module holding this namespace's generated per-dial methods.
|
|
35
|
+
attr_reader :generated_module
|
|
36
|
+
|
|
37
|
+
def initialize(name, label: nil, parent: nil)
|
|
38
|
+
@name = name.to_sym
|
|
39
|
+
unless NAME_FORMAT.match?(@name.to_s)
|
|
40
|
+
raise InvalidNamespace, "#{name.inspect}: a namespace name must be lowercase segments of " \
|
|
41
|
+
"letters and digits, each starting with a letter, joined by single " \
|
|
42
|
+
"underscores (it becomes a table name and a model class name)"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
@parent = parent
|
|
46
|
+
@children = []
|
|
47
|
+
@generated_module = Module.new
|
|
48
|
+
@cache_lock = Mutex.new
|
|
49
|
+
@cache = nil
|
|
50
|
+
# Per namespace, so a write in one namespace never changes how another
|
|
51
|
+
# reads: the marker is keyed by name, not shared.
|
|
52
|
+
@txn_write_key = :"dials_wrote_in_open_transaction_#{@name}"
|
|
53
|
+
|
|
54
|
+
@registry = Registry.new(self)
|
|
55
|
+
@storage = Storage.new(self, parent: parent&.storage)
|
|
56
|
+
@config = Config.new(self, @storage, parent: parent&.config)
|
|
57
|
+
@config.label = label if label
|
|
58
|
+
extend @generated_module
|
|
59
|
+
|
|
60
|
+
parent&.adopt(self)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def root?
|
|
64
|
+
@name == ROOT_NAME
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def label
|
|
68
|
+
config.label
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def default_label
|
|
72
|
+
root? ? "Dials" : @name.to_s.tr("_", " ").capitalize
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def inspect
|
|
76
|
+
"#<Dials::Namespace #{@name} #{registry.keys.length} dials>"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# -- declaration ---------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
# Declare dials in this namespace:
|
|
82
|
+
#
|
|
83
|
+
# ns.define do
|
|
84
|
+
# dial :merchant_fee_bps, default: 100, type: :integer,
|
|
85
|
+
# minimum: 1, maximum: 10_000, unit: "bps",
|
|
86
|
+
# dimensions: { market: { enum: %w[KE NG BD] } }
|
|
87
|
+
# end
|
|
88
|
+
#
|
|
89
|
+
# Each declaration generates the dial's methods on this namespace:
|
|
90
|
+
# merchant_fee_bps (the reader), adjust_merchant_fee_bps,
|
|
91
|
+
# clear_merchant_fee_bps (see Generated).
|
|
92
|
+
def define(&)
|
|
93
|
+
registry.instance_eval(&)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Internal, called by the registry; see Generated.install!.
|
|
97
|
+
def install_generated!(definition)
|
|
98
|
+
Generated.install!(definition, into: @generated_module, owners: collision_owners)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def uninstall_generated!
|
|
102
|
+
Generated.uninstall_all!(from: @generated_module)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# -- configuration -------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
def configure
|
|
108
|
+
yield config
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def store
|
|
112
|
+
config.store
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def cache
|
|
116
|
+
@cache || @cache_lock.synchronize { @cache ||= Cache.new(store: store, ttl: config.cache_ttl) }
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Discard the cache object entirely (used when the store is swapped).
|
|
120
|
+
def reset_cache!
|
|
121
|
+
@cache_lock.synchronize { @cache = nil }
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Force the next read to rebuild from the store — e.g. after writing
|
|
125
|
+
# through a console in another process, or in a test. Also clears this
|
|
126
|
+
# thread's in-transaction-write marker (test suites that wrap examples
|
|
127
|
+
# in transactions call this between examples).
|
|
128
|
+
def reload!
|
|
129
|
+
Thread.current[@txn_write_key] = nil
|
|
130
|
+
cache.bust!
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Test hook: forget every option this namespace was configured with,
|
|
134
|
+
# and the store built from them, so an example starts from the shipped
|
|
135
|
+
# defaults.
|
|
136
|
+
def reset_config!
|
|
137
|
+
@storage = Storage.new(self, parent: @parent&.storage)
|
|
138
|
+
@config = Config.new(self, @storage, parent: @parent&.config)
|
|
139
|
+
reset_cache!
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Test hook for Dials.reset_namespaces!: a discarded namespace stops
|
|
143
|
+
# inheriting config changes.
|
|
144
|
+
def forget_children!
|
|
145
|
+
@children.clear
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Internal: a cache_ttl change reaches the namespace's own cache, and
|
|
149
|
+
# every namespace that inherits the value rather than declaring one.
|
|
150
|
+
def apply_cache_ttl
|
|
151
|
+
@cache&.ttl = config.cache_ttl
|
|
152
|
+
@children.each(&:inherit_cache_ttl)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Internal: same for a store swap. A namespace that inherits the kind
|
|
156
|
+
# discards the store it built from the OLD kind — an app that configures
|
|
157
|
+
# Dials after an engine declared its namespace must not leave that
|
|
158
|
+
# engine on the default memory store.
|
|
159
|
+
def apply_store
|
|
160
|
+
reset_cache!
|
|
161
|
+
@children.each(&:inherit_store)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# -- reads ---------------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
# Resolve a dial by key — the primitive under the generated readers,
|
|
167
|
+
# for callers that receive the key at runtime. Scope is passed
|
|
168
|
+
# as keyword arguments and must name every dimension the dial declares —
|
|
169
|
+
# no more, no less:
|
|
170
|
+
#
|
|
171
|
+
# ns.get(:signups_enabled) # global-only dial
|
|
172
|
+
# ns.get(:merchant_fee_bps, market: "KE") # varied dial
|
|
173
|
+
#
|
|
174
|
+
# Raises UnknownDial / InvalidScope on misuse; never raises for a merely
|
|
175
|
+
# missing override (that is what defaults are for).
|
|
176
|
+
def get(key, **scope)
|
|
177
|
+
definition = registry.fetch(key)
|
|
178
|
+
normalized = Scope.validate!(definition, scope, exact: true)
|
|
179
|
+
|
|
180
|
+
# After scope validation, so a test override can never mask a read that
|
|
181
|
+
# would raise in production.
|
|
182
|
+
pinned = Testing.override_for(self, definition.key)
|
|
183
|
+
return pinned.first if pinned
|
|
184
|
+
|
|
185
|
+
Resolver.resolve(definition, normalized, current_snapshot)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Read a dial's Global layer by key: the stored global override when
|
|
189
|
+
# present, else the code default — the tail every un-overridden scope
|
|
190
|
+
# falls through to. This is the front door for the caller that has NO
|
|
191
|
+
# scope to give — resolving a value for a subject whose dimension is
|
|
192
|
+
# unknowable (a recipient with no resolvable market) — not a way around
|
|
193
|
+
# exact-scope reads: a caller that knows its scope must still pass it
|
|
194
|
+
# to get, which raises InvalidScope precisely so a lazy read cannot
|
|
195
|
+
# skip a scoped override. For a dial with no dimensions this is
|
|
196
|
+
# equivalent to get. Raises UnknownDial; honors Testing pins.
|
|
197
|
+
def global(key)
|
|
198
|
+
definition = registry.fetch(key)
|
|
199
|
+
|
|
200
|
+
pinned = Testing.override_for(self, definition.key)
|
|
201
|
+
return pinned.first if pinned
|
|
202
|
+
|
|
203
|
+
# The empty scope matches no stored scoped override, so Resolver
|
|
204
|
+
# takes exactly the global-override → code-default tail.
|
|
205
|
+
Resolver.resolve(definition, {}, current_snapshot)
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# One dial's stored scoped overrides as { parsed scope => value }, e.g.
|
|
209
|
+
# { { market: "BD" } => 24, { market: "NG" } => 48 } — "which markets
|
|
210
|
+
# override this dial?". Scopes come back as parsed hashes, never
|
|
211
|
+
# canonical scope strings. A dial with nothing scoped stored (or no
|
|
212
|
+
# dimensions at all) returns {}; the global override is not included
|
|
213
|
+
# (see overview). Reads from the same snapshot path as the generated
|
|
214
|
+
# readers, including the in-transaction rule. The result is deep-frozen —
|
|
215
|
+
# it shares structure with the process-wide snapshot.
|
|
216
|
+
def scoped_overrides(key)
|
|
217
|
+
definition = registry.fetch(key)
|
|
218
|
+
parsed_scoped_overrides(current_snapshot, definition.key)
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# Every dial registered in this namespace, with its full state —
|
|
222
|
+
# definition (with its JSON Schema), global override (explicitly
|
|
223
|
+
# present-or-absent), scoped overrides, and the per-override stale-write
|
|
224
|
+
# tokens — read from ONE snapshot, so the picture is coherent. Feed an
|
|
225
|
+
# override's token back as `expected_version:` when writing it
|
|
226
|
+
# (Dials::ABSENT_VERSION for overrides the page showed as not stored).
|
|
227
|
+
def overview
|
|
228
|
+
snapshot = current_snapshot
|
|
229
|
+
dials = registry.map do |definition|
|
|
230
|
+
stamps = snapshot.row_versions[definition.key] || {}
|
|
231
|
+
DialState.new(
|
|
232
|
+
definition: definition,
|
|
233
|
+
global_override: snapshot.globals.key?(definition.key),
|
|
234
|
+
global_value: snapshot.globals[definition.key],
|
|
235
|
+
global_version: StoreVersion.token(stamps[Scope::GLOBAL] || 0),
|
|
236
|
+
scoped_overrides: parsed_scoped_overrides(snapshot, definition.key),
|
|
237
|
+
scoped_override_versions: parsed_versions(snapshot, definition.key)
|
|
238
|
+
)
|
|
239
|
+
end.freeze
|
|
240
|
+
Overview.new(version: StoreVersion.token(snapshot.version), dials: dials)
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# This namespace's change log, newest first. `key:` filters to one dial.
|
|
244
|
+
def changes(key: nil, limit: 50)
|
|
245
|
+
key = registry.fetch(key).key if key
|
|
246
|
+
store.changes(key: key, limit: limit)
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# -- writes --------------------------------------------------------------
|
|
250
|
+
|
|
251
|
+
# Store an override by key — the primitive under the generated
|
|
252
|
+
# adjust_<key> methods. With no scope, overrides the global; with a
|
|
253
|
+
# scope, creates or updates the override for exactly that scope. The
|
|
254
|
+
# value is validated against the dial's type and schema; `actor:` is
|
|
255
|
+
# required and lands in the change log.
|
|
256
|
+
#
|
|
257
|
+
# `expected_version:` makes the write compare-and-swap against THIS
|
|
258
|
+
# override (the global when no scope keywords, the named scoped override
|
|
259
|
+
# otherwise): pass the override's token from ns.overview (or a
|
|
260
|
+
# previous CAS write; Dials::ABSENT_VERSION when the page showed no
|
|
261
|
+
# override) and the write is refused with StaleWrite — unapplied,
|
|
262
|
+
# unlogged — if that override has changed since. A CAS write returns the
|
|
263
|
+
# override's NEW token (chain it into the next write); an unconditional
|
|
264
|
+
# write returns the value, as always.
|
|
265
|
+
def set(key, value, actor:, scope: nil, expected_version: nil)
|
|
266
|
+
definition = registry.fetch(key)
|
|
267
|
+
actor_attrs = Actor.normalize(actor, config)
|
|
268
|
+
definition.validate_value!(value)
|
|
269
|
+
|
|
270
|
+
if scope.nil? || scope.empty?
|
|
271
|
+
canonical = Scope::GLOBAL
|
|
272
|
+
else
|
|
273
|
+
raise InvalidScope, "dial #{definition.key} declares no dimensions" unless definition.dimensions?
|
|
274
|
+
|
|
275
|
+
normalized = Scope.validate!(definition, scope, exact: true)
|
|
276
|
+
canonical = Scope.canonical(normalized)
|
|
277
|
+
end
|
|
278
|
+
_old, written = store.set_override(definition.key, canonical, value, actor_attrs,
|
|
279
|
+
expected_version: expected_version)
|
|
280
|
+
|
|
281
|
+
after_write
|
|
282
|
+
# The token comes from the write we KNOW happened — never from a
|
|
283
|
+
# second read a concurrent writer could slip in front of.
|
|
284
|
+
expected_version ? StoreVersion.token(written) : value
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
# Remove an override by key — the primitive under the generated
|
|
288
|
+
# clear_<key> methods — returning resolution to the next layer down: a
|
|
289
|
+
# cleared scoped override inherits the global; a cleared global inherits the
|
|
290
|
+
# code default. Returns true if an override existed. Clearing what is not
|
|
291
|
+
# there is a no-op (and logs nothing).
|
|
292
|
+
#
|
|
293
|
+
# `expected_version:` works exactly as on set — the staleness check runs
|
|
294
|
+
# even when the clear would be a no-op (a page that shows an override
|
|
295
|
+
# which no longer exists IS stale), and a CAS clear returns the
|
|
296
|
+
# tombstone's token instead of the boolean (chainable: a later set
|
|
297
|
+
# carrying it succeeds; an "absent" assertion from an older page does
|
|
298
|
+
# not — cleared is not the same as never-written).
|
|
299
|
+
def clear(key, actor:, scope: nil, expected_version: nil)
|
|
300
|
+
definition = registry.fetch(key)
|
|
301
|
+
actor_attrs = Actor.normalize(actor, config)
|
|
302
|
+
|
|
303
|
+
if scope.nil? || scope.empty?
|
|
304
|
+
canonical = Scope::GLOBAL
|
|
305
|
+
else
|
|
306
|
+
normalized = Scope.validate!(definition, scope, exact: true)
|
|
307
|
+
canonical = Scope.canonical(normalized)
|
|
308
|
+
end
|
|
309
|
+
removed, written = store.clear_override(definition.key, canonical, actor_attrs,
|
|
310
|
+
expected_version: expected_version)
|
|
311
|
+
|
|
312
|
+
after_write
|
|
313
|
+
expected_version ? StoreVersion.token(written) : removed
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
# -- test overrides ------------------------------------------------------
|
|
317
|
+
|
|
318
|
+
# Pin this namespace's dial values for the duration of a block, without
|
|
319
|
+
# touching the store, the cache, or the change log — see Testing.
|
|
320
|
+
def with_overrides(overrides, &)
|
|
321
|
+
Testing.with_overrides(overrides, self, &)
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
def adopt(child)
|
|
325
|
+
@children << child
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
protected attr_reader :storage
|
|
329
|
+
|
|
330
|
+
# Internal: this namespace reads its parent's cache_ttl, so a change
|
|
331
|
+
# there reaches a cache that was already built.
|
|
332
|
+
def inherit_cache_ttl
|
|
333
|
+
return if config.explicitly_set?(:cache_ttl)
|
|
334
|
+
|
|
335
|
+
@cache&.ttl = config.cache_ttl
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
def inherit_store
|
|
339
|
+
return if @storage.declared?
|
|
340
|
+
|
|
341
|
+
@storage.discard_store!
|
|
342
|
+
reset_cache!
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
private
|
|
346
|
+
|
|
347
|
+
def collision_owners
|
|
348
|
+
root? ? { "Dials" => Dials, "Dials.default" => self } : { "Dials.namespace(:#{@name})" => self }
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
# { canonical scope string => value } from the snapshot, re-keyed by
|
|
352
|
+
# parsed scope hash. Values are already frozen snapshot references; the
|
|
353
|
+
# freshly built hashes are frozen so no caller can mutate shared state.
|
|
354
|
+
def parsed_scoped_overrides(snapshot, key)
|
|
355
|
+
stored = snapshot.scoped_overrides[key] || {}
|
|
356
|
+
stored.to_h { |canonical, value| [Freeze.deep(Scope.parse(canonical)), value] }.freeze
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
# { parsed scope hash => version token } for a dial's scoped overrides.
|
|
360
|
+
def parsed_versions(snapshot, key)
|
|
361
|
+
stamps = snapshot.row_versions[key] || {}
|
|
362
|
+
stamps.except(Scope::GLOBAL)
|
|
363
|
+
.to_h { |canonical, stamp| [Freeze.deep(Scope.parse(canonical)), StoreVersion.token(stamp)] }.freeze
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
def after_write
|
|
367
|
+
cache.bust!
|
|
368
|
+
return unless store_transaction_open?
|
|
369
|
+
|
|
370
|
+
# The write is inside an application transaction and not committed
|
|
371
|
+
# yet. Two things follow. This thread's reads must bypass the shared
|
|
372
|
+
# cache until the transaction closes (see current_snapshot). And the
|
|
373
|
+
# bust above happened PRE-commit — another thread can legitimately
|
|
374
|
+
# republish the pre-transaction state before the commit lands — so the
|
|
375
|
+
# cache must be busted again ON commit, or a writer that never reads
|
|
376
|
+
# again would leave every process serving the old value until the TTL
|
|
377
|
+
# probe notices (forever, with ttl = nil). On rollback the hook is
|
|
378
|
+
# discarded: the shared cache never held the transaction's data.
|
|
379
|
+
Thread.current[@txn_write_key] = true
|
|
380
|
+
store.after_commit { cache.bust! } if store.respond_to?(:after_commit)
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
def current_snapshot
|
|
384
|
+
if Thread.current[@txn_write_key]
|
|
385
|
+
return cache.uncached_snapshot if store_transaction_open?
|
|
386
|
+
|
|
387
|
+
# The transaction closed (committed or rolled back). Rejoin the
|
|
388
|
+
# shared cache, busting first so the next snapshot reflects the
|
|
389
|
+
# outcome rather than anything published mid-transaction.
|
|
390
|
+
Thread.current[@txn_write_key] = nil
|
|
391
|
+
cache.bust!
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
cache.snapshot
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
def store_transaction_open?
|
|
398
|
+
s = store
|
|
399
|
+
s.respond_to?(:transaction_open?) && s.transaction_open?
|
|
400
|
+
end
|
|
401
|
+
end
|
|
402
|
+
end
|
data/lib/dials/registry.rb
CHANGED
|
@@ -1,33 +1,35 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Dials
|
|
4
|
-
# The in-code catalog of every dial
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
4
|
+
# The in-code catalog of every dial a namespace declares. A key's presence
|
|
5
|
+
# here is what makes it a dial at all: reads, writes, and scope validation
|
|
6
|
+
# all consult the registry, and an admin surface renders exactly these
|
|
7
|
+
# entries.
|
|
8
8
|
#
|
|
9
9
|
# Declarations accumulate across `Dials.define` blocks (so large apps can
|
|
10
10
|
# split declarations by domain), but a key declared twice raises — a dial's
|
|
11
|
-
# declaration is its single source of truth.
|
|
11
|
+
# declaration is its single source of truth. A key is unique inside its
|
|
12
|
+
# namespace only: two namespaces may each declare :timeout_seconds.
|
|
12
13
|
class Registry
|
|
13
14
|
include Enumerable
|
|
14
15
|
|
|
15
|
-
def initialize
|
|
16
|
+
def initialize(namespace)
|
|
17
|
+
@namespace = namespace
|
|
16
18
|
@definitions = {}
|
|
17
19
|
@mutex = Mutex.new
|
|
18
20
|
end
|
|
19
21
|
|
|
20
22
|
# DSL entry point used by `Dials.define { dial ... }`. Registering a key
|
|
21
|
-
# also generates its per-dial methods (the
|
|
22
|
-
# adjust_/clear_ writers);
|
|
23
|
-
#
|
|
24
|
-
#
|
|
23
|
+
# also generates its per-dial methods on the namespace (the reader and
|
|
24
|
+
# the adjust_/clear_ writers); the namespace checks for name collisions
|
|
25
|
+
# before defining anything, so a raise here leaves neither a definition
|
|
26
|
+
# nor a stray method behind.
|
|
25
27
|
def dial(key, **)
|
|
26
28
|
definition = Definition.new(key, **)
|
|
27
29
|
@mutex.synchronize do
|
|
28
30
|
raise DuplicateDial, "dial #{definition.key} is already defined" if @definitions.key?(definition.key)
|
|
29
31
|
|
|
30
|
-
|
|
32
|
+
@namespace.install_generated!(definition)
|
|
31
33
|
@definitions[definition.key] = definition
|
|
32
34
|
end
|
|
33
35
|
definition
|
|
@@ -61,7 +63,7 @@ module Dials
|
|
|
61
63
|
def reset!
|
|
62
64
|
@mutex.synchronize do
|
|
63
65
|
@definitions.clear
|
|
64
|
-
|
|
66
|
+
@namespace.uninstall_generated!
|
|
65
67
|
end
|
|
66
68
|
end
|
|
67
69
|
end
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dials
|
|
4
|
+
# Where one namespace's values live: the kind of store, the table the
|
|
5
|
+
# namespace owns, and the store object built from the two.
|
|
6
|
+
#
|
|
7
|
+
# A namespace owns its rows, so a namespace that declares no kind inherits
|
|
8
|
+
# the root's KIND and gets a table of its own. A store OBJECT is never
|
|
9
|
+
# inherited: two namespaces on one store would share its key space, so a
|
|
10
|
+
# namespace under a store object must name its own.
|
|
11
|
+
class Storage
|
|
12
|
+
# The root's table, and the suffix every other namespace's table carries.
|
|
13
|
+
DEFAULT_TABLE_NAME = "dials"
|
|
14
|
+
|
|
15
|
+
# A table name reaches the model's table_name and the correlated
|
|
16
|
+
# NOT EXISTS subquery the store builds by hand, so it must be a plain
|
|
17
|
+
# unquoted identifier: no reserved-word surprises, no schema
|
|
18
|
+
# qualification, no case folding.
|
|
19
|
+
TABLE_NAME_FORMAT = /\A[a-z][a-z0-9_]*\z/
|
|
20
|
+
|
|
21
|
+
# PostgreSQL truncates identifiers past 63 bytes, which would silently
|
|
22
|
+
# merge two tables whose names differ only past the cut.
|
|
23
|
+
MAX_TABLE_NAME_LENGTH = 63
|
|
24
|
+
|
|
25
|
+
attr_reader :table_name_prefix
|
|
26
|
+
|
|
27
|
+
def initialize(namespace, parent: nil)
|
|
28
|
+
@namespace = namespace
|
|
29
|
+
@parent = parent
|
|
30
|
+
@kind = nil
|
|
31
|
+
@declared = false
|
|
32
|
+
@store = nil
|
|
33
|
+
@table_name = nil
|
|
34
|
+
@table_name_prefix = nil
|
|
35
|
+
validate_table_name!(table_name)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# A store instance, or the symbols :memory / :active_record.
|
|
39
|
+
def kind=(kind)
|
|
40
|
+
@declared = true
|
|
41
|
+
@kind = kind
|
|
42
|
+
@store = build_store(kind)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# False when this storage reads the root's kind rather than naming one.
|
|
46
|
+
def declared?
|
|
47
|
+
@declared
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def store
|
|
51
|
+
@store ||= build_store(kind)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Drop the built store: the next read builds one from the current kind
|
|
55
|
+
# and table name.
|
|
56
|
+
def discard_store!
|
|
57
|
+
@store = nil
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# The prefixed "dials" for the root, "<name>_dials" for every other
|
|
61
|
+
# namespace, or whatever table_name= says.
|
|
62
|
+
def table_name
|
|
63
|
+
resolve_table_name(@table_name, @table_name_prefix)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Both name setters are order-independent with kind=: whichever runs
|
|
67
|
+
# second applies the name, and nil on either restores the derived one.
|
|
68
|
+
# Each claims the name it would produce BEFORE taking it, so a rejected
|
|
69
|
+
# setter leaves the namespace on the table it already had.
|
|
70
|
+
def table_name=(name)
|
|
71
|
+
name = name&.to_s
|
|
72
|
+
claim_table!(resolve_table_name(name, @table_name_prefix))
|
|
73
|
+
@table_name = name
|
|
74
|
+
rename_table
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def table_name_prefix=(prefix)
|
|
78
|
+
claim_table!(resolve_table_name(@table_name, prefix))
|
|
79
|
+
@table_name_prefix = prefix
|
|
80
|
+
rename_table
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
protected
|
|
84
|
+
|
|
85
|
+
# What a namespace's storage inherits when it declares no kind of its own.
|
|
86
|
+
def kind
|
|
87
|
+
return @kind if @declared
|
|
88
|
+
return :memory unless @parent
|
|
89
|
+
|
|
90
|
+
inherited = @parent.kind
|
|
91
|
+
return inherited if inherited.is_a?(Symbol)
|
|
92
|
+
|
|
93
|
+
raise Error, "namespace #{@namespace.name} cannot inherit a store object " \
|
|
94
|
+
"(two namespaces sharing one store share its rows); set config.store for it"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
private
|
|
98
|
+
|
|
99
|
+
def resolve_table_name(name, prefix)
|
|
100
|
+
return name if name
|
|
101
|
+
return "#{prefix}#{DEFAULT_TABLE_NAME}" if @namespace.root?
|
|
102
|
+
|
|
103
|
+
"#{@namespace.name}_#{DEFAULT_TABLE_NAME}"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Renaming an already-declared namespace's table: the shape is this
|
|
107
|
+
# object's business, the claim is the module's, because only the module
|
|
108
|
+
# knows every namespace.
|
|
109
|
+
def claim_table!(name)
|
|
110
|
+
validate_table_name!(name)
|
|
111
|
+
Dials.assert_table_unclaimed!(@namespace, name)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def validate_table_name!(name)
|
|
115
|
+
return if TABLE_NAME_FORMAT.match?(name) && name.length <= MAX_TABLE_NAME_LENGTH
|
|
116
|
+
|
|
117
|
+
raise InvalidTableName,
|
|
118
|
+
"#{name.inspect}: a dials table name must be lowercase letters, digits and " \
|
|
119
|
+
"underscores, at most #{MAX_TABLE_NAME_LENGTH} characters"
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def build_store(kind)
|
|
123
|
+
case kind
|
|
124
|
+
when :memory then Stores::Memory.new
|
|
125
|
+
when :active_record
|
|
126
|
+
require "dials/active_record"
|
|
127
|
+
Stores::ActiveRecordStore.new(model: active_record_model)
|
|
128
|
+
else kind
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# The root keeps the well-known Dials::ActiveRecord::Entry; every other
|
|
133
|
+
# namespace gets a model class of its own, because its rows live in its
|
|
134
|
+
# own table.
|
|
135
|
+
def active_record_model
|
|
136
|
+
model = @namespace.root? ? Dials::ActiveRecord::Entry : Dials::ActiveRecord.model(@namespace.name)
|
|
137
|
+
model.table_name = table_name
|
|
138
|
+
model
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Renaming the root's table renames the model other code already holds
|
|
142
|
+
# (Dials::ActiveRecord::Entry, since 0.2.0). Any other namespace has no
|
|
143
|
+
# such published constant, so dropping the store is enough — the next
|
|
144
|
+
# read builds one whose model carries the new name.
|
|
145
|
+
def rename_table
|
|
146
|
+
if @namespace.root?
|
|
147
|
+
Dials::ActiveRecord::Entry.table_name = table_name if defined?(Dials::ActiveRecord::Entry)
|
|
148
|
+
else
|
|
149
|
+
discard_store!
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
data/lib/dials/testing.rb
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Dials
|
|
4
|
-
# Test
|
|
5
|
-
# block without touching the store, the cache, or the change
|
|
6
|
-
# inside the block (on the same thread) see the pinned value for
|
|
7
|
-
# scope of that dial. Nesting composes; inner blocks win.
|
|
4
|
+
# Test-override mechanics. `with_overrides` pins dial values for the
|
|
5
|
+
# duration of a block without touching the store, the cache, or the change
|
|
6
|
+
# log — reads inside the block (on the same thread) see the pinned value for
|
|
7
|
+
# every scope of that dial. Nesting composes; inner blocks win.
|
|
8
8
|
#
|
|
9
9
|
# Dials::Testing.with_overrides(merchant_fee_bps: 250) do
|
|
10
10
|
# Dials.merchant_fee_bps(market: "KE") # => 250
|
|
@@ -12,29 +12,41 @@ module Dials
|
|
|
12
12
|
#
|
|
13
13
|
# Values are validated against the dial's declaration, so a test cannot
|
|
14
14
|
# pin a value production could never hold.
|
|
15
|
+
#
|
|
16
|
+
# Pins belong to ONE namespace: they are keyed by its name, so pinning the
|
|
17
|
+
# app's :timeout_seconds leaves an engine's dial of the same name resolving
|
|
18
|
+
# normally. `Dials::Testing.with_overrides` pins the default namespace;
|
|
19
|
+
# every namespace also pins through itself (`Shipping.with_overrides`).
|
|
15
20
|
module Testing
|
|
16
|
-
THREAD_KEY = :dials_testing_overrides
|
|
17
|
-
|
|
18
21
|
module_function
|
|
19
22
|
|
|
20
|
-
def with_overrides(overrides)
|
|
23
|
+
def with_overrides(overrides, namespace = Dials.default)
|
|
21
24
|
validated = overrides.to_h do |key, value|
|
|
22
|
-
definition =
|
|
25
|
+
definition = namespace.registry.fetch(key)
|
|
23
26
|
[definition.key, definition.validate_value!(value)]
|
|
24
27
|
end
|
|
25
28
|
|
|
26
|
-
|
|
27
|
-
Thread.current[
|
|
29
|
+
thread_key = thread_key_for(namespace)
|
|
30
|
+
previous = Thread.current[thread_key]
|
|
31
|
+
Thread.current[thread_key] = (previous || {}).merge(validated)
|
|
28
32
|
yield
|
|
29
33
|
ensure
|
|
30
|
-
|
|
34
|
+
# thread_key is nil when validation raised — nothing was pinned, and
|
|
35
|
+
# restoring must not mask the caller's error.
|
|
36
|
+
Thread.current[thread_key] = previous if thread_key
|
|
31
37
|
end
|
|
32
38
|
|
|
33
|
-
|
|
34
|
-
|
|
39
|
+
# One dial's pin, wrapped in an array so a pinned `false` is still a pin;
|
|
40
|
+
# nil when the dial is not pinned. Called on every read.
|
|
41
|
+
def override_for(namespace, key)
|
|
42
|
+
overrides = Thread.current[thread_key_for(namespace)]
|
|
35
43
|
return nil unless overrides
|
|
36
44
|
|
|
37
45
|
overrides.key?(key) ? [overrides[key]] : nil
|
|
38
46
|
end
|
|
47
|
+
|
|
48
|
+
def thread_key_for(namespace)
|
|
49
|
+
:"dials_testing_overrides_#{namespace.name}"
|
|
50
|
+
end
|
|
39
51
|
end
|
|
40
52
|
end
|
data/lib/dials/version.rb
CHANGED