dials 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/CHANGELOG.md +76 -0
- data/LICENSE.txt +21 -0
- data/README.md +78 -0
- data/lib/dials/active_record/models.rb +51 -0
- data/lib/dials/active_record/store.rb +365 -0
- data/lib/dials/active_record.rb +18 -0
- data/lib/dials/actor.rb +57 -0
- data/lib/dials/cache.rb +158 -0
- data/lib/dials/change_record.rb +16 -0
- data/lib/dials/config.rb +50 -0
- data/lib/dials/definition.rb +226 -0
- data/lib/dials/dimension.rb +60 -0
- data/lib/dials/errors.rb +49 -0
- data/lib/dials/freeze.rb +20 -0
- data/lib/dials/generated.rb +60 -0
- data/lib/dials/overview.rb +45 -0
- data/lib/dials/railtie.rb +10 -0
- data/lib/dials/registry.rb +68 -0
- data/lib/dials/resolver.rb +45 -0
- data/lib/dials/schema.rb +247 -0
- data/lib/dials/scope.rb +106 -0
- data/lib/dials/snapshot.rb +30 -0
- data/lib/dials/store_version.rb +34 -0
- data/lib/dials/stores/memory.rb +175 -0
- data/lib/dials/testing.rb +40 -0
- data/lib/dials/version.rb +5 -0
- data/lib/dials.rb +308 -0
- data/lib/generators/dials/install/install_generator.rb +39 -0
- data/lib/generators/dials/install/templates/initializer.rb.tt +49 -0
- data/lib/generators/dials/install/templates/migration.rb.tt +39 -0
- metadata +82 -0
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dials
|
|
4
|
+
module Stores
|
|
5
|
+
# The reference store: plain hashes behind a mutex. It is the default
|
|
6
|
+
# store so the gem works out of the box (and in client test suites)
|
|
7
|
+
# without a database, and it doubles as the executable specification of
|
|
8
|
+
# the store interface:
|
|
9
|
+
#
|
|
10
|
+
# state → { globals:, scoped_overrides:, version:, row_versions: }
|
|
11
|
+
# version → monotonic value, moves on every write
|
|
12
|
+
# override_version(key, canonical) → the stream's stamp (tombstones
|
|
13
|
+
# included); 0 only when never written
|
|
14
|
+
# set_override(key, canonical, value, actor, expected_version: nil) → [previous value or nil, new stamp]
|
|
15
|
+
# clear_override(key, canonical, actor, expected_version: nil) → [true if an override existed, stamp]
|
|
16
|
+
# changes(key: nil, limit: 50) → newest-first [ChangeRecord]
|
|
17
|
+
#
|
|
18
|
+
# `canonical` is always a canonical scope string; Scope::GLOBAL names the
|
|
19
|
+
# global override (the override at the empty scope).
|
|
20
|
+
#
|
|
21
|
+
# `actor` is the normalized hash from Dials::Actor. Value validation and
|
|
22
|
+
# scope validation happen above the store; a store only persists.
|
|
23
|
+
#
|
|
24
|
+
# Concurrency control is per-override optimistic locking: every stream
|
|
25
|
+
# carries a version stamp (here, the write counter at its last write; in
|
|
26
|
+
# the ActiveRecord store, the stream seq — both only ever grow).
|
|
27
|
+
# Tombstones KEEP their stamp, so an absent → set → clear cycle can never
|
|
28
|
+
# make an old "absent" token current again; StoreVersion::ABSENT strictly
|
|
29
|
+
# means "never written". `expected_version:` (an opaque StoreVersion
|
|
30
|
+
# token) compares against THAT stream's stamp atomically with the write;
|
|
31
|
+
# a mismatch raises StaleWrite with nothing applied or logged. The check
|
|
32
|
+
# runs before the existence check on clears: a no-op clear against a
|
|
33
|
+
# stale picture is still stale.
|
|
34
|
+
#
|
|
35
|
+
# Values are round-tripped through JSON on write, exactly like the
|
|
36
|
+
# ActiveRecord store. That buys two guarantees at once: the store never
|
|
37
|
+
# retains a reference to a caller-owned mutable object (mutating a hash
|
|
38
|
+
# after a write cannot silently change the stored override or rewrite
|
|
39
|
+
# change-log history), and both stores return byte-identical shapes
|
|
40
|
+
# (symbol keys become strings here too, so a test suite on the memory
|
|
41
|
+
# store proves what production on ActiveRecord will do).
|
|
42
|
+
class Memory
|
|
43
|
+
def initialize
|
|
44
|
+
@globals = {}
|
|
45
|
+
@scoped = Hash.new { |h, k| h[k] = {} }
|
|
46
|
+
@row_versions = Hash.new { |h, k| h[k] = {} }
|
|
47
|
+
@changes = []
|
|
48
|
+
@version = 0
|
|
49
|
+
@mutex = Mutex.new
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def state
|
|
53
|
+
@mutex.synchronize do
|
|
54
|
+
{
|
|
55
|
+
globals: @globals.transform_values { |v| dup_value(v) },
|
|
56
|
+
scoped_overrides: @scoped.to_h { |k, scopes| [k, scopes.transform_values { |v| dup_value(v) }] },
|
|
57
|
+
version: @version,
|
|
58
|
+
row_versions: @row_versions.to_h { |k, scopes| [k, scopes.dup] }
|
|
59
|
+
}
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def version
|
|
64
|
+
@mutex.synchronize { @version }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def override_version(key, canonical_scope)
|
|
68
|
+
@mutex.synchronize { row_version(key, canonical_scope) }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def set_override(key, canonical_scope, value, actor, expected_version: nil)
|
|
72
|
+
@mutex.synchronize do
|
|
73
|
+
assert_version!(expected_version, row_version(key, canonical_scope))
|
|
74
|
+
stored = roundtrip(value)
|
|
75
|
+
if canonical_scope == Scope::GLOBAL
|
|
76
|
+
old = @globals[key]
|
|
77
|
+
@globals[key] = stored
|
|
78
|
+
record(key, nil, "set", old, stored, actor)
|
|
79
|
+
else
|
|
80
|
+
old = @scoped[key][canonical_scope]
|
|
81
|
+
@scoped[key][canonical_scope] = stored
|
|
82
|
+
record(key, canonical_scope, "set", old, stored, actor)
|
|
83
|
+
end
|
|
84
|
+
[old, stamp(key, canonical_scope)]
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def clear_override(key, canonical_scope, actor, expected_version: nil)
|
|
89
|
+
@mutex.synchronize do
|
|
90
|
+
current = row_version(key, canonical_scope)
|
|
91
|
+
assert_version!(expected_version, current)
|
|
92
|
+
if canonical_scope == Scope::GLOBAL
|
|
93
|
+
next [false, current] unless @globals.key?(key)
|
|
94
|
+
|
|
95
|
+
old = @globals.delete(key)
|
|
96
|
+
record(key, nil, "clear", old, nil, actor)
|
|
97
|
+
else
|
|
98
|
+
next [false, current] unless @scoped.key?(key) && @scoped[key].key?(canonical_scope)
|
|
99
|
+
|
|
100
|
+
old = @scoped[key].delete(canonical_scope)
|
|
101
|
+
@scoped.delete(key) if @scoped[key].empty?
|
|
102
|
+
record(key, canonical_scope, "clear", old, nil, actor)
|
|
103
|
+
end
|
|
104
|
+
# The tombstone keeps a (new) stamp: "absent because cleared" must
|
|
105
|
+
# never compare equal to "absent because never written".
|
|
106
|
+
[true, stamp(key, canonical_scope)]
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def changes(key: nil, limit: 50)
|
|
111
|
+
@mutex.synchronize do
|
|
112
|
+
selected = key ? @changes.select { |c| c.key == key.to_sym } : @changes
|
|
113
|
+
selected.last(limit).reverse
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
private
|
|
118
|
+
|
|
119
|
+
# Callers hold the mutex.
|
|
120
|
+
def row_version(key, canonical_scope)
|
|
121
|
+
@row_versions[key][canonical_scope] || 0
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Callers hold the mutex. Raises StaleWrite before anything is touched,
|
|
125
|
+
# so the transactionless memory store still guarantees "unapplied and
|
|
126
|
+
# unlogged" on a version mismatch.
|
|
127
|
+
def assert_version!(expected, current)
|
|
128
|
+
return if expected.nil? || expected == StoreVersion.token(current)
|
|
129
|
+
|
|
130
|
+
raise StaleWrite,
|
|
131
|
+
"the override has changed since version #{expected} was read — " \
|
|
132
|
+
"re-read (Dials.overview) and retry deliberately"
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Callers hold the mutex; record has already bumped @version, which
|
|
136
|
+
# serves as the stream stamp (the ActiveRecord analog is the seq).
|
|
137
|
+
def stamp(key, canonical_scope)
|
|
138
|
+
@row_versions[key][canonical_scope] = @version
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Callers hold the mutex. Old/new values are duplicated (so a change
|
|
142
|
+
# record never shares structure with the live store state) and frozen
|
|
143
|
+
# (so a caller mutating what Dials.changes returned cannot rewrite the
|
|
144
|
+
# retained history — the ActiveRecord store decodes fresh per call and
|
|
145
|
+
# has no equivalent hazard).
|
|
146
|
+
def record(key, canonical_scope, action, old_value, new_value, actor)
|
|
147
|
+
@version += 1
|
|
148
|
+
@changes << ChangeRecord.new(
|
|
149
|
+
key: key,
|
|
150
|
+
scope: canonical_scope && Freeze.deep(Scope.parse(canonical_scope)),
|
|
151
|
+
action: action,
|
|
152
|
+
old_value: Freeze.deep(dup_value(old_value)),
|
|
153
|
+
new_value: Freeze.deep(dup_value(new_value)),
|
|
154
|
+
actor_type: actor[:actor_type],
|
|
155
|
+
actor_id: actor[:actor_id],
|
|
156
|
+
actor_label: actor[:actor_label],
|
|
157
|
+
created_at: Time.now.utc
|
|
158
|
+
)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def roundtrip(value)
|
|
162
|
+
JSON.parse(JSON.generate(value))
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Containers are deep-duplicated (they are JSON-pure after roundtrip);
|
|
166
|
+
# scalars are safe to share.
|
|
167
|
+
def dup_value(value)
|
|
168
|
+
case value
|
|
169
|
+
when Hash, Array then roundtrip(value)
|
|
170
|
+
else value
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dials
|
|
4
|
+
# Test helpers. `with_overrides` pins dial values for the duration of a
|
|
5
|
+
# block without touching the store, the cache, or the change log — reads
|
|
6
|
+
# inside the block (on the same thread) see the pinned value for every
|
|
7
|
+
# scope of that dial. Nesting composes; inner blocks win.
|
|
8
|
+
#
|
|
9
|
+
# Dials::Testing.with_overrides(merchant_fee_bps: 250) do
|
|
10
|
+
# Dials.merchant_fee_bps(market: "KE") # => 250
|
|
11
|
+
# end
|
|
12
|
+
#
|
|
13
|
+
# Values are validated against the dial's declaration, so a test cannot
|
|
14
|
+
# pin a value production could never hold.
|
|
15
|
+
module Testing
|
|
16
|
+
THREAD_KEY = :dials_testing_overrides
|
|
17
|
+
|
|
18
|
+
module_function
|
|
19
|
+
|
|
20
|
+
def with_overrides(overrides)
|
|
21
|
+
validated = overrides.to_h do |key, value|
|
|
22
|
+
definition = Dials.registry.fetch(key)
|
|
23
|
+
[definition.key, definition.validate_value!(value)]
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
previous = Thread.current[THREAD_KEY]
|
|
27
|
+
Thread.current[THREAD_KEY] = (previous || {}).merge(validated)
|
|
28
|
+
yield
|
|
29
|
+
ensure
|
|
30
|
+
Thread.current[THREAD_KEY] = previous
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def override_for(key)
|
|
34
|
+
overrides = Thread.current[THREAD_KEY]
|
|
35
|
+
return nil unless overrides
|
|
36
|
+
|
|
37
|
+
overrides.key?(key) ? [overrides[key]] : nil
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
data/lib/dials.rb
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
require_relative "dials/version"
|
|
6
|
+
require_relative "dials/errors"
|
|
7
|
+
require_relative "dials/generated"
|
|
8
|
+
require_relative "dials/freeze"
|
|
9
|
+
require_relative "dials/schema"
|
|
10
|
+
require_relative "dials/dimension"
|
|
11
|
+
require_relative "dials/definition"
|
|
12
|
+
require_relative "dials/registry"
|
|
13
|
+
require_relative "dials/scope"
|
|
14
|
+
require_relative "dials/snapshot"
|
|
15
|
+
require_relative "dials/store_version"
|
|
16
|
+
require_relative "dials/overview"
|
|
17
|
+
require_relative "dials/resolver"
|
|
18
|
+
require_relative "dials/cache"
|
|
19
|
+
require_relative "dials/change_record"
|
|
20
|
+
require_relative "dials/actor"
|
|
21
|
+
require_relative "dials/stores/memory"
|
|
22
|
+
require_relative "dials/config"
|
|
23
|
+
require_relative "dials/testing"
|
|
24
|
+
|
|
25
|
+
# Dials: operator-adjustable values with per-scope overrides.
|
|
26
|
+
#
|
|
27
|
+
# A dial is a value that starts life as a code default, can be overridden
|
|
28
|
+
# globally at runtime, and can be overridden again per scope along its
|
|
29
|
+
# declared dimensions (per market, per platform, ...). Resolution is always:
|
|
30
|
+
#
|
|
31
|
+
# scoped override → global override → code default
|
|
32
|
+
#
|
|
33
|
+
# Declarations live in code (Dials.define); values live in a store; reads
|
|
34
|
+
# come from a per-process cache. Every write is attributed and logged.
|
|
35
|
+
#
|
|
36
|
+
# Declaring a dial generates its methods (see Generated):
|
|
37
|
+
#
|
|
38
|
+
# Dials.base_fee(market: "KE") # read
|
|
39
|
+
# Dials.adjust_base_fee(25, actor: ops, market: "KE") # write
|
|
40
|
+
# Dials.clear_base_fee(actor: ops, market: "KE") # remove an override
|
|
41
|
+
#
|
|
42
|
+
# The key-taking primitives (get, set, clear) stay public underneath — they
|
|
43
|
+
# are the dynamic-access layer for code that receives the key at runtime
|
|
44
|
+
# (an admin surface iterating the registry, a console one-liner).
|
|
45
|
+
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
|
|
54
|
+
|
|
55
|
+
# The stale-write token of an override that is not stored. Pass it as
|
|
56
|
+
# `expected_version:` to assert "there was no override here when I looked"
|
|
57
|
+
# — the write succeeds only if that is still true.
|
|
58
|
+
ABSENT_VERSION = StoreVersion::ABSENT
|
|
59
|
+
|
|
60
|
+
class << self
|
|
61
|
+
# -- declaration ---------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
attr_reader :registry, :config
|
|
64
|
+
|
|
65
|
+
# Declare dials:
|
|
66
|
+
#
|
|
67
|
+
# Dials.define do
|
|
68
|
+
# dial :merchant_fee_bps, default: 100, type: :integer,
|
|
69
|
+
# minimum: 1, maximum: 10_000, unit: "bps",
|
|
70
|
+
# dimensions: { market: { enum: %w[KE NG BD] } }
|
|
71
|
+
# dial :signups_enabled, default: true, type: :boolean
|
|
72
|
+
# end
|
|
73
|
+
#
|
|
74
|
+
# Each declaration generates the dial's methods: merchant_fee_bps (the
|
|
75
|
+
# reader), adjust_merchant_fee_bps, clear_merchant_fee_bps (see
|
|
76
|
+
# Generated).
|
|
77
|
+
def define(&)
|
|
78
|
+
registry.instance_eval(&)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# -- configuration -------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
def configure
|
|
84
|
+
yield config
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def store
|
|
88
|
+
config.store
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def cache
|
|
92
|
+
@cache || CACHE_LOCK.synchronize { @cache ||= Cache.new(store: store, ttl: config.cache_ttl) }
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Discard the cache object entirely (used when the store is swapped).
|
|
96
|
+
def reset_cache!
|
|
97
|
+
CACHE_LOCK.synchronize { @cache = nil }
|
|
98
|
+
end
|
|
99
|
+
|
|
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
|
+
def reload!
|
|
105
|
+
Thread.current[TXN_WRITE_KEY] = nil
|
|
106
|
+
cache.bust!
|
|
107
|
+
end
|
|
108
|
+
|
|
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
|
+
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)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# One dial's stored scoped overrides as { parsed scope => value }, e.g.
|
|
134
|
+
# { { market: "BD" } => 24, { market: "NG" } => 48 } — "which markets
|
|
135
|
+
# override this dial?". Scopes come back as parsed hashes, never
|
|
136
|
+
# canonical scope strings. A dial with nothing scoped stored (or no
|
|
137
|
+
# dimensions at all) returns {}; the global override is not included
|
|
138
|
+
# (see overview). Reads from the same snapshot path as the generated
|
|
139
|
+
# readers, including the in-transaction rule. The result is deep-frozen —
|
|
140
|
+
# it shares structure with the process-wide snapshot.
|
|
141
|
+
def scoped_overrides(key)
|
|
142
|
+
definition = registry.fetch(key)
|
|
143
|
+
parsed_scoped_overrides(current_snapshot, definition.key)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Every registered dial's full state — definition (with its JSON Schema),
|
|
147
|
+
# global override (explicitly present-or-absent), scoped overrides, and
|
|
148
|
+
# the per-override stale-write tokens — read from ONE snapshot, so the
|
|
149
|
+
# picture is coherent. Feed an override's token back as
|
|
150
|
+
# `expected_version:` when writing it (Dials::ABSENT_VERSION for
|
|
151
|
+
# overrides the page showed as not stored).
|
|
152
|
+
def overview
|
|
153
|
+
snapshot = current_snapshot
|
|
154
|
+
dials = registry.map do |definition|
|
|
155
|
+
stamps = snapshot.row_versions[definition.key] || {}
|
|
156
|
+
DialState.new(
|
|
157
|
+
definition: definition,
|
|
158
|
+
global_override: snapshot.globals.key?(definition.key),
|
|
159
|
+
global_value: snapshot.globals[definition.key],
|
|
160
|
+
global_version: StoreVersion.token(stamps[Scope::GLOBAL] || 0),
|
|
161
|
+
scoped_overrides: parsed_scoped_overrides(snapshot, definition.key),
|
|
162
|
+
scoped_override_versions: parsed_versions(snapshot, definition.key)
|
|
163
|
+
)
|
|
164
|
+
end.freeze
|
|
165
|
+
Overview.new(version: StoreVersion.token(snapshot.version), dials: dials)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# The full change log, newest first. `key:` filters to one dial.
|
|
169
|
+
def changes(key: nil, limit: 50)
|
|
170
|
+
key = registry.fetch(key).key if key
|
|
171
|
+
store.changes(key: key, limit: limit)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# -- writes --------------------------------------------------------------
|
|
175
|
+
|
|
176
|
+
# Store an override by key — the primitive under the generated
|
|
177
|
+
# adjust_<key> methods. With no scope, overrides the global; with a
|
|
178
|
+
# scope, creates or updates the override for exactly that scope. The
|
|
179
|
+
# value is validated against the dial's type and schema; `actor:` is
|
|
180
|
+
# required and lands in the change log.
|
|
181
|
+
#
|
|
182
|
+
# `expected_version:` makes the write compare-and-swap against THIS
|
|
183
|
+
# override (the global when no scope keywords, the named scoped override
|
|
184
|
+
# otherwise): pass the override's token from Dials.overview (or a
|
|
185
|
+
# previous CAS write; Dials::ABSENT_VERSION when the page showed no
|
|
186
|
+
# override) and the write is refused with StaleWrite — unapplied,
|
|
187
|
+
# unlogged — if that override has changed since. A CAS write returns the
|
|
188
|
+
# override's NEW token (chain it into the next write); an unconditional
|
|
189
|
+
# write returns the value, as always.
|
|
190
|
+
def set(key, value, actor:, scope: nil, expected_version: nil)
|
|
191
|
+
definition = registry.fetch(key)
|
|
192
|
+
actor_attrs = Actor.normalize(actor)
|
|
193
|
+
definition.validate_value!(value)
|
|
194
|
+
|
|
195
|
+
if scope.nil? || scope.empty?
|
|
196
|
+
canonical = Scope::GLOBAL
|
|
197
|
+
else
|
|
198
|
+
raise InvalidScope, "dial #{definition.key} declares no dimensions" unless definition.dimensions?
|
|
199
|
+
|
|
200
|
+
normalized = Scope.validate!(definition, scope, exact: true)
|
|
201
|
+
canonical = Scope.canonical(normalized)
|
|
202
|
+
end
|
|
203
|
+
_old, written = store.set_override(definition.key, canonical, value, actor_attrs,
|
|
204
|
+
expected_version: expected_version)
|
|
205
|
+
|
|
206
|
+
after_write
|
|
207
|
+
# The token comes from the write we KNOW happened — never from a
|
|
208
|
+
# second read a concurrent writer could slip in front of.
|
|
209
|
+
expected_version ? StoreVersion.token(written) : value
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# Remove an override by key — the primitive under the generated
|
|
213
|
+
# clear_<key> methods — returning resolution to the next layer down: a
|
|
214
|
+
# cleared scoped override inherits the global; a cleared global inherits the
|
|
215
|
+
# code default. Returns true if an override existed. Clearing what is not
|
|
216
|
+
# there is a no-op (and logs nothing).
|
|
217
|
+
#
|
|
218
|
+
# `expected_version:` works exactly as on set — the staleness check runs
|
|
219
|
+
# even when the clear would be a no-op (a page that shows an override
|
|
220
|
+
# which no longer exists IS stale), and a CAS clear returns the
|
|
221
|
+
# tombstone's token instead of the boolean (chainable: a later set
|
|
222
|
+
# carrying it succeeds; an "absent" assertion from an older page does
|
|
223
|
+
# not — cleared is not the same as never-written).
|
|
224
|
+
def clear(key, actor:, scope: nil, expected_version: nil)
|
|
225
|
+
definition = registry.fetch(key)
|
|
226
|
+
actor_attrs = Actor.normalize(actor)
|
|
227
|
+
|
|
228
|
+
if scope.nil? || scope.empty?
|
|
229
|
+
canonical = Scope::GLOBAL
|
|
230
|
+
else
|
|
231
|
+
normalized = Scope.validate!(definition, scope, exact: true)
|
|
232
|
+
canonical = Scope.canonical(normalized)
|
|
233
|
+
end
|
|
234
|
+
removed, written = store.clear_override(definition.key, canonical, actor_attrs,
|
|
235
|
+
expected_version: expected_version)
|
|
236
|
+
|
|
237
|
+
after_write
|
|
238
|
+
expected_version ? StoreVersion.token(written) : removed
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
private
|
|
242
|
+
|
|
243
|
+
# { canonical scope string => value } from the snapshot, re-keyed by
|
|
244
|
+
# parsed scope hash. Values are already frozen snapshot references; the
|
|
245
|
+
# freshly built hashes are frozen so no caller can mutate shared state.
|
|
246
|
+
def parsed_scoped_overrides(snapshot, key)
|
|
247
|
+
stored = snapshot.scoped_overrides[key] || {}
|
|
248
|
+
stored.to_h { |canonical, value| [Freeze.deep(Scope.parse(canonical)), value] }.freeze
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# { parsed scope hash => version token } for a dial's scoped overrides.
|
|
252
|
+
def parsed_versions(snapshot, key)
|
|
253
|
+
stamps = snapshot.row_versions[key] || {}
|
|
254
|
+
stamps.except(Scope::GLOBAL)
|
|
255
|
+
.to_h { |canonical, stamp| [Freeze.deep(Scope.parse(canonical)), StoreVersion.token(stamp)] }.freeze
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def after_write
|
|
259
|
+
cache.bust!
|
|
260
|
+
return unless store_transaction_open?
|
|
261
|
+
|
|
262
|
+
# The write is inside an application transaction and not committed
|
|
263
|
+
# yet. Two things follow. This thread's reads must bypass the shared
|
|
264
|
+
# cache until the transaction closes (see current_snapshot). And the
|
|
265
|
+
# bust above happened PRE-commit — another thread can legitimately
|
|
266
|
+
# republish the pre-transaction state before the commit lands — so the
|
|
267
|
+
# cache must be busted again ON commit, or a writer that never reads
|
|
268
|
+
# again would leave every process serving the old value until the TTL
|
|
269
|
+
# probe notices (forever, with ttl = nil). On rollback the hook is
|
|
270
|
+
# discarded: the shared cache never held the transaction's data.
|
|
271
|
+
Thread.current[TXN_WRITE_KEY] = true
|
|
272
|
+
store.after_commit { cache.bust! } if store.respond_to?(:after_commit)
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def current_snapshot
|
|
276
|
+
if Thread.current[TXN_WRITE_KEY]
|
|
277
|
+
return cache.uncached_snapshot if store_transaction_open?
|
|
278
|
+
|
|
279
|
+
# The transaction closed (committed or rolled back). Rejoin the
|
|
280
|
+
# shared cache, busting first so the next snapshot reflects the
|
|
281
|
+
# outcome rather than anything published mid-transaction.
|
|
282
|
+
Thread.current[TXN_WRITE_KEY] = nil
|
|
283
|
+
cache.bust!
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
cache.snapshot
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def store_transaction_open?
|
|
290
|
+
s = store
|
|
291
|
+
s.respond_to?(:transaction_open?) && s.transaction_open?
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
@registry = Registry.new
|
|
296
|
+
@config = Config.new
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
begin
|
|
300
|
+
require "rails/railtie"
|
|
301
|
+
require_relative "dials/railtie"
|
|
302
|
+
rescue LoadError
|
|
303
|
+
nil
|
|
304
|
+
rescue StandardError => e
|
|
305
|
+
# A broken or incompatible Rails installation must not stop the core gem
|
|
306
|
+
# from loading — Rails integration is opportunistic, never required.
|
|
307
|
+
warn "[dials] skipping Rails integration (#{e.class}: #{e.message})"
|
|
308
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
require "rails/generators/active_record"
|
|
5
|
+
|
|
6
|
+
module Dials
|
|
7
|
+
module Generators
|
|
8
|
+
# `bin/rails generate dials:install`
|
|
9
|
+
#
|
|
10
|
+
# Creates the migration for the three gem-owned tables and an initializer
|
|
11
|
+
# with a commented starter registry.
|
|
12
|
+
class InstallGenerator < Rails::Generators::Base
|
|
13
|
+
include ::ActiveRecord::Generators::Migration
|
|
14
|
+
|
|
15
|
+
source_root File.expand_path("templates", __dir__)
|
|
16
|
+
|
|
17
|
+
def create_migration_file
|
|
18
|
+
migration_template "migration.rb.tt", "db/migrate/create_dials_tables.rb"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def create_initializer
|
|
22
|
+
template "initializer.rb.tt", "config/initializers/dials.rb"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def show_readme
|
|
26
|
+
say <<~TEXT
|
|
27
|
+
|
|
28
|
+
Dials installed. Next steps:
|
|
29
|
+
|
|
30
|
+
1. bin/rails db:migrate
|
|
31
|
+
2. Declare your dials in config/initializers/dials.rb
|
|
32
|
+
3. Declaring dial :base_fee generates Dials.base_fee,
|
|
33
|
+
Dials.adjust_base_fee, and Dials.clear_base_fee
|
|
34
|
+
|
|
35
|
+
TEXT
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dials/active_record"
|
|
4
|
+
|
|
5
|
+
Dials.configure do |config|
|
|
6
|
+
# Where values live. :active_record uses the tables created by
|
|
7
|
+
# `rails g dials:install`; :memory keeps everything in-process (handy in
|
|
8
|
+
# test environments that never exercise persistence).
|
|
9
|
+
config.store = :active_record
|
|
10
|
+
|
|
11
|
+
# Seconds between cache staleness probes (one cheap query per process per
|
|
12
|
+
# interval). 0 probes on every read; nil never probes.
|
|
13
|
+
# config.cache_ttl = 5.0
|
|
14
|
+
|
|
15
|
+
# How an actor is rendered in the change log.
|
|
16
|
+
# config.actor_label = ->(actor) { actor.email }
|
|
17
|
+
|
|
18
|
+
# No User model? Attribution does not need one — actor: takes any object,
|
|
19
|
+
# and strings are first-class (actor: "console"). To make actor: optional,
|
|
20
|
+
# declare an app-level fallback (a value, or a callable evaluated per
|
|
21
|
+
# write). Leave unset to require actor: on every write.
|
|
22
|
+
# config.default_actor = -> { ENV.fetch("USER", "console") }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Declare your dials. A dial with no `dimensions:` is global-only by
|
|
26
|
+
# construction. Declaring `dimensions:` is the arming gate — add it in the same
|
|
27
|
+
# change as the code that reads the varied value.
|
|
28
|
+
#
|
|
29
|
+
# Value constraints are JSON Schema keywords, snake_cased (minimum:,
|
|
30
|
+
# maximum:, enum:, pattern:, min_length:, properties:/required: for :json) —
|
|
31
|
+
# the vocabulary you already know from JSON Schema and OpenAPI. `validate:`
|
|
32
|
+
# takes a callable for rules a schema cannot express.
|
|
33
|
+
#
|
|
34
|
+
# Each declaration generates the dial's methods, e.g. for :merchant_fee_bps:
|
|
35
|
+
# Dials.merchant_fee_bps(market: "KE")
|
|
36
|
+
# Dials.adjust_merchant_fee_bps(90, actor: current_admin, market: "KE")
|
|
37
|
+
# Dials.clear_merchant_fee_bps(actor: current_admin, market: "KE")
|
|
38
|
+
Dials.define do
|
|
39
|
+
# dial :merchant_fee_bps, default: 100,
|
|
40
|
+
# type: :integer,
|
|
41
|
+
# minimum: 1,
|
|
42
|
+
# maximum: 10_000,
|
|
43
|
+
# unit: "bps",
|
|
44
|
+
# description: "Fee charged to merchants, in basis points.",
|
|
45
|
+
# dimensions: { market: { enum: %w[KE NG BD] } }
|
|
46
|
+
|
|
47
|
+
# dial :signups_enabled, default: true, type: :boolean,
|
|
48
|
+
# description: "Global kill switch for new signups."
|
|
49
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class CreateDialsTables < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
|
|
4
|
+
def change
|
|
5
|
+
# The gem's ONE table, append-only: every write INSERTs a row, and the
|
|
6
|
+
# newest row per (key, scope) stream is the current override — action
|
|
7
|
+
# "set" carries a value, action "clear" ends the override. The change
|
|
8
|
+
# log IS the state (history cannot disagree with it), the row count is
|
|
9
|
+
# the cache's version counter, and UNIQUE(key, scope, seq) makes writes
|
|
10
|
+
# atomic: every writer claims the stream's next seq, and of two
|
|
11
|
+
# concurrent claims the database rejects one — no lock table, no
|
|
12
|
+
# advisory locks, no race.
|
|
13
|
+
#
|
|
14
|
+
# A global override is the stream at the canonical empty scope "{}".
|
|
15
|
+
# `value` is JSON-encoded text, never queried in SQL — reads go through
|
|
16
|
+
# the in-process cache. No updated_at: rows are immutable facts.
|
|
17
|
+
#
|
|
18
|
+
# Explicit column limits keep the composite unique index inside every
|
|
19
|
+
# supported database's index budget (MySQL utf8mb4 in particular).
|
|
20
|
+
# On MySQL, the default case-insensitive collation would merge streams
|
|
21
|
+
# whose scopes differ only by case ("KE" vs "ke") — identity columns get
|
|
22
|
+
# a binary collation there. PostgreSQL and SQLite compare bytes already.
|
|
23
|
+
identity_collation = ("utf8mb4_bin" if connection.adapter_name.match?(/mysql/i))
|
|
24
|
+
|
|
25
|
+
create_table :dials do |t|
|
|
26
|
+
t.string :key, null: false, limit: 100, collation: identity_collation
|
|
27
|
+
t.string :scope, null: false, limit: 255, collation: identity_collation
|
|
28
|
+
t.bigint :seq, null: false
|
|
29
|
+
t.string :action, null: false
|
|
30
|
+
t.text :value
|
|
31
|
+
t.string :actor_type
|
|
32
|
+
t.string :actor_id
|
|
33
|
+
t.string :actor_label
|
|
34
|
+
t.datetime :created_at, null: false
|
|
35
|
+
end
|
|
36
|
+
add_index :dials, [:key, :scope, :seq], unique: true
|
|
37
|
+
add_index :dials, :key
|
|
38
|
+
end
|
|
39
|
+
end
|