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.
data/lib/dials.rb CHANGED
@@ -19,7 +19,9 @@ require_relative "dials/cache"
19
19
  require_relative "dials/change_record"
20
20
  require_relative "dials/actor"
21
21
  require_relative "dials/stores/memory"
22
+ require_relative "dials/storage"
22
23
  require_relative "dials/config"
24
+ require_relative "dials/namespace"
23
25
  require_relative "dials/testing"
24
26
 
25
27
  # Dials: operator-adjustable values with per-scope overrides.
@@ -42,15 +44,19 @@ require_relative "dials/testing"
42
44
  # The key-taking primitives (get, set, clear) stay public underneath — they
43
45
  # are the dynamic-access layer for code that receives the key at runtime
44
46
  # (an admin surface iterating the registry, a console one-liner).
47
+ #
48
+ # Every method here belongs to the DEFAULT namespace (see Namespace): the
49
+ # app's own dials, in the app's own table. A subsystem that owns its
50
+ # settings end to end declares a namespace of its own instead:
51
+ #
52
+ # Shipping = Dials.namespace(:shipping) { |config| config.store = :active_record }
53
+ #
54
+ # and gets the same API on that object, against a table of its own.
45
55
  module Dials
46
- # Thread-local marker: this thread performed a dial write inside a
47
- # database transaction that is still open. While set, the thread's reads
48
- # come from fresh, UNPUBLISHED snapshots — it sees its own uncommitted
49
- # write, but the uncommitted value never lands in the shared cache (where
50
- # other threads would read it, and where it would survive a rollback).
51
- TXN_WRITE_KEY = :dials_wrote_in_open_transaction
52
-
53
- CACHE_LOCK = Mutex.new
56
+ # Guards the check-then-set in `namespace`, so two engine initializers
57
+ # declaring at once cannot both win. Declarations only; a fetch reads the
58
+ # table without it.
59
+ NAMESPACE_LOCK = Mutex.new
54
60
 
55
61
  # The stale-write token of an override that is not stored. Pass it as
56
62
  # `expected_version:` to assert "there was no override here when I looked"
@@ -58,9 +64,85 @@ module Dials
58
64
  ABSENT_VERSION = StoreVersion::ABSENT
59
65
 
60
66
  class << self
61
- # -- declaration ---------------------------------------------------------
67
+ # -- namespaces ----------------------------------------------------------
68
+
69
+ # The root namespace, the one every method on this module delegates to.
70
+ attr_reader :default
71
+
72
+ # Every namespace, root first, then registration order — what an admin
73
+ # surface iterates to group dials by subsystem without naming one.
74
+ def namespaces
75
+ @namespaces.values
76
+ end
62
77
 
63
- attr_reader :registry, :config
78
+ # Declare a namespace (with a block or a label), or fetch one by name:
79
+ #
80
+ # Shipping = Dials.namespace(:shipping, label: "Shipping") do |config|
81
+ # config.store = :active_record # table: "shipping_dials"
82
+ # end
83
+ #
84
+ # Dials.namespace(:shipping) # the same object, later
85
+ #
86
+ # Options the block leaves alone inherit the root's config. Declaring a
87
+ # name twice raises DuplicateNamespace; fetching one that was never
88
+ # declared raises UnknownNamespace.
89
+ def namespace(name, label: nil, &block)
90
+ key = name.to_sym
91
+ return fetch_namespace(key, name) if label.nil? && block.nil?
92
+
93
+ NAMESPACE_LOCK.synchronize { assert_undeclared!(key) }
94
+ namespace = Namespace.new(key, label: label, parent: default)
95
+
96
+ # The block runs application code (it can build a store, touch
97
+ # ActiveRecord, even declare dials), so it runs outside the lock — and
98
+ # before the namespace is published, so no other thread can reach one
99
+ # that is still on the inherited store.
100
+ namespace.configure(&block) if block
101
+ NAMESPACE_LOCK.synchronize do
102
+ assert_undeclared!(key)
103
+ assert_unclaimed_table!(namespace, namespace.config.table_name)
104
+ @namespaces[key] = namespace
105
+ end
106
+ namespace
107
+ end
108
+
109
+ # Internal, called by Storage when a declared namespace is renamed. A
110
+ # namespace owns its table outright: two namespaces on one would
111
+ # interleave their keys, history and stale-write sequences with nothing
112
+ # to tell them apart again.
113
+ def assert_table_unclaimed!(namespace, table_name)
114
+ NAMESPACE_LOCK.synchronize { assert_unclaimed_table!(namespace, table_name) }
115
+ end
116
+
117
+ # Test hook: discard every namespace but the root, and with them their
118
+ # registries and generated methods. A suite that declares namespaces
119
+ # needs a blank slate per example.
120
+ def reset_namespaces!
121
+ discarded = NAMESPACE_LOCK.synchronize do
122
+ dropped = @namespaces.except(Namespace::ROOT_NAME).values
123
+ @namespaces = { Namespace::ROOT_NAME => @default }
124
+ dropped
125
+ end
126
+ @default.forget_children!
127
+ discarded.each { |namespace| Thread.current[namespace.txn_write_key] = nil }
128
+ end
129
+
130
+ # Force every namespace's next read to rebuild from its store, and clear
131
+ # every in-transaction-write marker — one call for a test suite that
132
+ # wraps examples in transactions.
133
+ def reload_all!
134
+ namespaces.each(&:reload!)
135
+ end
136
+
137
+ # -- the default namespace -----------------------------------------------
138
+
139
+ def registry
140
+ default.registry
141
+ end
142
+
143
+ def config
144
+ default.config
145
+ end
64
146
 
65
147
  # Declare dials:
66
148
  #
@@ -75,245 +157,95 @@ module Dials
75
157
  # reader), adjust_merchant_fee_bps, clear_merchant_fee_bps (see
76
158
  # Generated).
77
159
  def define(&)
78
- registry.instance_eval(&)
160
+ default.define(&)
79
161
  end
80
162
 
81
- # -- configuration -------------------------------------------------------
82
-
83
- def configure
84
- yield config
163
+ def configure(&)
164
+ default.configure(&)
85
165
  end
86
166
 
87
167
  def store
88
- config.store
168
+ default.store
89
169
  end
90
170
 
91
171
  def cache
92
- @cache || CACHE_LOCK.synchronize { @cache ||= Cache.new(store: store, ttl: config.cache_ttl) }
172
+ default.cache
93
173
  end
94
174
 
95
- # Discard the cache object entirely (used when the store is swapped).
96
175
  def reset_cache!
97
- CACHE_LOCK.synchronize { @cache = nil }
176
+ default.reset_cache!
98
177
  end
99
178
 
100
- # Force the next read to rebuild from the store — e.g. after writing
101
- # through a console in another process, or in a test. Also clears this
102
- # thread's in-transaction-write marker (test suites that wrap examples
103
- # in transactions call this between examples).
104
179
  def reload!
105
- Thread.current[TXN_WRITE_KEY] = nil
106
- cache.bust!
180
+ default.reload!
107
181
  end
108
182
 
109
- # -- reads ---------------------------------------------------------------
110
-
111
- # Resolve a dial by key — the primitive under the generated readers,
112
- # for callers that receive the key at runtime. Scope is passed
113
- # as keyword arguments and must name every dimension the dial declares —
114
- # no more, no less:
115
- #
116
- # Dials.get(:signups_enabled) # global-only dial
117
- # Dials.get(:merchant_fee_bps, market: "KE") # varied dial
118
- #
119
- # Raises UnknownDial / InvalidScope on misuse; never raises for a merely
120
- # missing override (that is what defaults are for).
121
183
  def get(key, **scope)
122
- definition = registry.fetch(key)
123
- normalized = Scope.validate!(definition, scope, exact: true)
124
-
125
- # After scope validation, so a test override can never mask a read that
126
- # would raise in production.
127
- pinned = Testing.override_for(definition.key)
128
- return pinned.first if pinned
129
-
130
- Resolver.resolve(definition, normalized, current_snapshot)
184
+ default.get(key, **scope)
131
185
  end
132
186
 
133
- # Read a dial's Global layer by key: the stored global override when
134
- # present, else the code default — the tail every un-overridden scope
135
- # falls through to. This is the front door for the caller that has NO
136
- # scope to give — resolving a value for a subject whose dimension is
137
- # unknowable (a recipient with no resolvable market) — not a way around
138
- # exact-scope reads: a caller that knows its scope must still pass it
139
- # to get, which raises InvalidScope precisely so a lazy read cannot
140
- # skip a scoped override. For a dial with no dimensions this is
141
- # equivalent to get. Raises UnknownDial; honors Testing pins.
142
187
  def global(key)
143
- definition = registry.fetch(key)
144
-
145
- pinned = Testing.override_for(definition.key)
146
- return pinned.first if pinned
147
-
148
- # The empty scope matches no stored scoped override, so Resolver
149
- # takes exactly the global-override → code-default tail.
150
- Resolver.resolve(definition, {}, current_snapshot)
188
+ default.global(key)
151
189
  end
152
190
 
153
- # One dial's stored scoped overrides as { parsed scope => value }, e.g.
154
- # { { market: "BD" } => 24, { market: "NG" } => 48 } — "which markets
155
- # override this dial?". Scopes come back as parsed hashes, never
156
- # canonical scope strings. A dial with nothing scoped stored (or no
157
- # dimensions at all) returns {}; the global override is not included
158
- # (see overview). Reads from the same snapshot path as the generated
159
- # readers, including the in-transaction rule. The result is deep-frozen —
160
- # it shares structure with the process-wide snapshot.
161
191
  def scoped_overrides(key)
162
- definition = registry.fetch(key)
163
- parsed_scoped_overrides(current_snapshot, definition.key)
192
+ default.scoped_overrides(key)
164
193
  end
165
194
 
166
- # Every registered dial's full state — definition (with its JSON Schema),
167
- # global override (explicitly present-or-absent), scoped overrides, and
168
- # the per-override stale-write tokens — read from ONE snapshot, so the
169
- # picture is coherent. Feed an override's token back as
170
- # `expected_version:` when writing it (Dials::ABSENT_VERSION for
171
- # overrides the page showed as not stored).
172
195
  def overview
173
- snapshot = current_snapshot
174
- dials = registry.map do |definition|
175
- stamps = snapshot.row_versions[definition.key] || {}
176
- DialState.new(
177
- definition: definition,
178
- global_override: snapshot.globals.key?(definition.key),
179
- global_value: snapshot.globals[definition.key],
180
- global_version: StoreVersion.token(stamps[Scope::GLOBAL] || 0),
181
- scoped_overrides: parsed_scoped_overrides(snapshot, definition.key),
182
- scoped_override_versions: parsed_versions(snapshot, definition.key)
183
- )
184
- end.freeze
185
- Overview.new(version: StoreVersion.token(snapshot.version), dials: dials)
196
+ default.overview
186
197
  end
187
198
 
188
- # The full change log, newest first. `key:` filters to one dial.
189
199
  def changes(key: nil, limit: 50)
190
- key = registry.fetch(key).key if key
191
- store.changes(key: key, limit: limit)
200
+ default.changes(key: key, limit: limit)
192
201
  end
193
202
 
194
- # -- writes --------------------------------------------------------------
195
-
196
- # Store an override by key — the primitive under the generated
197
- # adjust_<key> methods. With no scope, overrides the global; with a
198
- # scope, creates or updates the override for exactly that scope. The
199
- # value is validated against the dial's type and schema; `actor:` is
200
- # required and lands in the change log.
201
- #
202
- # `expected_version:` makes the write compare-and-swap against THIS
203
- # override (the global when no scope keywords, the named scoped override
204
- # otherwise): pass the override's token from Dials.overview (or a
205
- # previous CAS write; Dials::ABSENT_VERSION when the page showed no
206
- # override) and the write is refused with StaleWrite — unapplied,
207
- # unlogged — if that override has changed since. A CAS write returns the
208
- # override's NEW token (chain it into the next write); an unconditional
209
- # write returns the value, as always.
210
203
  def set(key, value, actor:, scope: nil, expected_version: nil)
211
- definition = registry.fetch(key)
212
- actor_attrs = Actor.normalize(actor)
213
- definition.validate_value!(value)
214
-
215
- if scope.nil? || scope.empty?
216
- canonical = Scope::GLOBAL
217
- else
218
- raise InvalidScope, "dial #{definition.key} declares no dimensions" unless definition.dimensions?
219
-
220
- normalized = Scope.validate!(definition, scope, exact: true)
221
- canonical = Scope.canonical(normalized)
222
- end
223
- _old, written = store.set_override(definition.key, canonical, value, actor_attrs,
224
- expected_version: expected_version)
225
-
226
- after_write
227
- # The token comes from the write we KNOW happened — never from a
228
- # second read a concurrent writer could slip in front of.
229
- expected_version ? StoreVersion.token(written) : value
204
+ default.set(key, value, actor: actor, scope: scope, expected_version: expected_version)
230
205
  end
231
206
 
232
- # Remove an override by key — the primitive under the generated
233
- # clear_<key> methods — returning resolution to the next layer down: a
234
- # cleared scoped override inherits the global; a cleared global inherits the
235
- # code default. Returns true if an override existed. Clearing what is not
236
- # there is a no-op (and logs nothing).
237
- #
238
- # `expected_version:` works exactly as on set — the staleness check runs
239
- # even when the clear would be a no-op (a page that shows an override
240
- # which no longer exists IS stale), and a CAS clear returns the
241
- # tombstone's token instead of the boolean (chainable: a later set
242
- # carrying it succeeds; an "absent" assertion from an older page does
243
- # not — cleared is not the same as never-written).
244
207
  def clear(key, actor:, scope: nil, expected_version: nil)
245
- definition = registry.fetch(key)
246
- actor_attrs = Actor.normalize(actor)
247
-
248
- if scope.nil? || scope.empty?
249
- canonical = Scope::GLOBAL
250
- else
251
- normalized = Scope.validate!(definition, scope, exact: true)
252
- canonical = Scope.canonical(normalized)
253
- end
254
- removed, written = store.clear_override(definition.key, canonical, actor_attrs,
255
- expected_version: expected_version)
256
-
257
- after_write
258
- expected_version ? StoreVersion.token(written) : removed
208
+ default.clear(key, actor: actor, scope: scope, expected_version: expected_version)
259
209
  end
260
210
 
261
211
  private
262
212
 
263
- # { canonical scope string => value } from the snapshot, re-keyed by
264
- # parsed scope hash. Values are already frozen snapshot references; the
265
- # freshly built hashes are frozen so no caller can mutate shared state.
266
- def parsed_scoped_overrides(snapshot, key)
267
- stored = snapshot.scoped_overrides[key] || {}
268
- stored.to_h { |canonical, value| [Freeze.deep(Scope.parse(canonical)), value] }.freeze
269
- end
270
-
271
- # { parsed scope hash => version token } for a dial's scoped overrides.
272
- def parsed_versions(snapshot, key)
273
- stamps = snapshot.row_versions[key] || {}
274
- stamps.except(Scope::GLOBAL)
275
- .to_h { |canonical, stamp| [Freeze.deep(Scope.parse(canonical)), StoreVersion.token(stamp)] }.freeze
213
+ def fetch_namespace(key, name)
214
+ @namespaces.fetch(key) do
215
+ raise UnknownNamespace,
216
+ "no namespace named #{name.inspect} (declared: #{@namespaces.keys.join(', ')})"
217
+ end
276
218
  end
277
219
 
278
- def after_write
279
- cache.bust!
280
- return unless store_transaction_open?
281
-
282
- # The write is inside an application transaction and not committed
283
- # yet. Two things follow. This thread's reads must bypass the shared
284
- # cache until the transaction closes (see current_snapshot). And the
285
- # bust above happened PRE-commit — another thread can legitimately
286
- # republish the pre-transaction state before the commit lands — so the
287
- # cache must be busted again ON commit, or a writer that never reads
288
- # again would leave every process serving the old value until the TTL
289
- # probe notices (forever, with ttl = nil). On rollback the hook is
290
- # discarded: the shared cache never held the transaction's data.
291
- Thread.current[TXN_WRITE_KEY] = true
292
- store.after_commit { cache.bust! } if store.respond_to?(:after_commit)
220
+ def assert_undeclared!(key)
221
+ raise DuplicateNamespace, "namespace #{key.inspect} is already declared" if @namespaces.key?(key)
293
222
  end
294
223
 
295
- def current_snapshot
296
- if Thread.current[TXN_WRITE_KEY]
297
- return cache.uncached_snapshot if store_transaction_open?
298
-
299
- # The transaction closed (committed or rolled back). Rejoin the
300
- # shared cache, busting first so the next snapshot reflects the
301
- # outcome rather than anything published mid-transaction.
302
- Thread.current[TXN_WRITE_KEY] = nil
303
- cache.bust!
224
+ # Callers hold NAMESPACE_LOCK, so a name is claimed and published
225
+ # without another declaration slipping between the two.
226
+ def assert_unclaimed_table!(namespace, table_name)
227
+ claimed = @namespaces.each_value.find do |other|
228
+ !other.equal?(namespace) && other.config.table_name == table_name
304
229
  end
230
+ return unless claimed
305
231
 
306
- cache.snapshot
307
- end
308
-
309
- def store_transaction_open?
310
- s = store
311
- s.respond_to?(:transaction_open?) && s.transaction_open?
232
+ raise InvalidTableName,
233
+ "namespace #{namespace.name} would share the #{table_name.inspect} table with " \
234
+ "namespace #{claimed.name}; a namespace owns its table (config.table_name)"
312
235
  end
313
236
  end
314
237
 
315
- @registry = Registry.new
316
- @config = Config.new
238
+ @default = Namespace.new(Namespace::ROOT_NAME)
239
+ @namespaces = { Namespace::ROOT_NAME => @default }
240
+
241
+ # The root namespace's in-transaction marker (see Namespace#after_write);
242
+ # every namespace has one of its own.
243
+ TXN_WRITE_KEY = @default.txn_write_key
244
+
245
+ # The generated readers of the root namespace answer on this module too,
246
+ # so `Dials.merchant_fee_bps` keeps working: the methods are defined once,
247
+ # in the namespace's module, and `self` decides whose dials they resolve.
248
+ extend @default.generated_module
317
249
  end
318
250
 
319
251
  begin
@@ -17,7 +17,7 @@ module Dials
17
17
  source_root File.expand_path("templates", __dir__)
18
18
 
19
19
  class_option :table_name_prefix, type: :string, default: "",
20
- desc: 'Prefix for the gem-owned table, used verbatim ("zar_" creates zar_dials)'
20
+ desc: 'Prefix for the gem-owned table, used verbatim ("ops_" creates ops_dials)'
21
21
 
22
22
  def create_migration_file
23
23
  migration_template "migration.rb.tt", "db/migrate/create_#{table_name}_table.rb"
@@ -11,8 +11,8 @@ Dials.configure do |config|
11
11
  <% if options[:table_name_prefix].empty? -%>
12
12
  # Prefix the gem-owned table when "dials" collides with an existing one.
13
13
  # Used verbatim, so include the trailing underscore. Must match the
14
- # migration (`rails g dials:install --table-name-prefix=zar_`).
15
- # config.table_name_prefix = "zar_"
14
+ # migration (`rails g dials:install --table-name-prefix=ops_`).
15
+ # config.table_name_prefix = "ops_"
16
16
  <% else -%>
17
17
  # Names the gem-owned table <%= table_name %>, as created by the install
18
18
  # migration.
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: dials
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Keith Gould
@@ -40,6 +40,7 @@ files:
40
40
  - lib/dials/errors.rb
41
41
  - lib/dials/freeze.rb
42
42
  - lib/dials/generated.rb
43
+ - lib/dials/namespace.rb
43
44
  - lib/dials/overview.rb
44
45
  - lib/dials/railtie.rb
45
46
  - lib/dials/registry.rb
@@ -47,6 +48,7 @@ files:
47
48
  - lib/dials/schema.rb
48
49
  - lib/dials/scope.rb
49
50
  - lib/dials/snapshot.rb
51
+ - lib/dials/storage.rb
50
52
  - lib/dials/store_version.rb
51
53
  - lib/dials/stores/memory.rb
52
54
  - lib/dials/testing.rb