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
data/lib/dials/cache.rb
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dials
|
|
4
|
+
# Per-process, in-memory snapshot cache.
|
|
5
|
+
#
|
|
6
|
+
# Reads never query the store per-dial: they read from the current
|
|
7
|
+
# Snapshot. The snapshot is (re)built from the store on first use, and
|
|
8
|
+
# thereafter freshness is maintained two ways:
|
|
9
|
+
#
|
|
10
|
+
# 1. Local writes bust the cache immediately — the process that made a
|
|
11
|
+
# change reads its own write on the next get.
|
|
12
|
+
# 2. Other processes converge via a throttled staleness probe: at most
|
|
13
|
+
# once per `ttl` seconds, a read asks the store for its version (a
|
|
14
|
+
# single cheap query) and rebuilds only when it moved.
|
|
15
|
+
#
|
|
16
|
+
# ttl = 0 probes on every read (strong consistency, one extra query per
|
|
17
|
+
# read); ttl = nil never probes (bust!/reload! only). Default is 5 seconds.
|
|
18
|
+
#
|
|
19
|
+
# Concurrency rules (each one is a production lesson, not a style choice):
|
|
20
|
+
#
|
|
21
|
+
# - The store is NEVER queried while holding a lock. Holding a mutex
|
|
22
|
+
# across a database call can deadlock a multi-threaded server: the
|
|
23
|
+
# builder waits for a pooled connection while every connection is held
|
|
24
|
+
# by threads blocked on the mutex.
|
|
25
|
+
# - A stale refresh is single-flight (try_lock); threads that lose the
|
|
26
|
+
# race serve the still-valid current snapshot instead of stampeding
|
|
27
|
+
# the database. The probe timestamp is claimed BEFORE the version
|
|
28
|
+
# query, so a burst of readers crossing the TTL boundary doesn't fan
|
|
29
|
+
# out into a probe stampede either.
|
|
30
|
+
# - A bust! that lands while a rebuild is in flight WINS: the rebuild
|
|
31
|
+
# read state before the bust's write, so publishing it would hide that
|
|
32
|
+
# write for a full TTL. The generation counter detects this and drops
|
|
33
|
+
# the stale publish (the requesting reader still gets the built
|
|
34
|
+
# snapshot; the next reader rebuilds fresh).
|
|
35
|
+
# - Once a snapshot has EVER been built, probe and rebuild failures
|
|
36
|
+
# serve last-known-good (with a warning) rather than raising — a
|
|
37
|
+
# database blip must not take down every dial read, including the read
|
|
38
|
+
# right after a local write busted the current snapshot. Only a cold
|
|
39
|
+
# process that has never built one raises: nothing honest exists to
|
|
40
|
+
# serve there.
|
|
41
|
+
class Cache
|
|
42
|
+
def initialize(store:, ttl: 5.0)
|
|
43
|
+
@store = store
|
|
44
|
+
@ttl = ttl
|
|
45
|
+
@build_mutex = Mutex.new
|
|
46
|
+
@state_mutex = Mutex.new # guards @generation/@snapshot writes; never held across store calls
|
|
47
|
+
@snapshot = nil
|
|
48
|
+
@last_good = nil
|
|
49
|
+
@probed_at = nil
|
|
50
|
+
@generation = 0
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
attr_accessor :ttl
|
|
54
|
+
|
|
55
|
+
def snapshot
|
|
56
|
+
current = @snapshot
|
|
57
|
+
return build_or_last_good if current.nil?
|
|
58
|
+
return current unless probe_due?
|
|
59
|
+
|
|
60
|
+
# Claim the probe slot up front: concurrent readers crossing the TTL
|
|
61
|
+
# boundary see a fresh timestamp and skip their own probes.
|
|
62
|
+
@probed_at = monotonic_now
|
|
63
|
+
|
|
64
|
+
begin
|
|
65
|
+
return current if @store.version == current.version
|
|
66
|
+
rescue StandardError => e
|
|
67
|
+
warn "[dials] staleness probe failed; serving the cached snapshot (#{e.class}: #{e.message})"
|
|
68
|
+
return current
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
refresh(current)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# A fresh, UNPUBLISHED snapshot straight from the store. Used for reads
|
|
75
|
+
# that must not pollute (or be served from) the shared cache — e.g. a
|
|
76
|
+
# thread that wrote a dial inside a still-open database transaction and
|
|
77
|
+
# must see its own uncommitted state without leaking it to other threads.
|
|
78
|
+
def uncached_snapshot
|
|
79
|
+
Snapshot.new(**@store.state)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Busting discards the published snapshot but NOT the last-known-good
|
|
83
|
+
# copy: if the rebuild after a write fails, reads degrade to slightly
|
|
84
|
+
# stale values instead of exceptions.
|
|
85
|
+
def bust!
|
|
86
|
+
@state_mutex.synchronize do
|
|
87
|
+
@generation += 1
|
|
88
|
+
@snapshot = nil
|
|
89
|
+
@probed_at = nil
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
private
|
|
94
|
+
|
|
95
|
+
def probe_due?
|
|
96
|
+
return false if @ttl.nil?
|
|
97
|
+
return true if @ttl.zero?
|
|
98
|
+
|
|
99
|
+
last = @probed_at
|
|
100
|
+
last.nil? || (monotonic_now - last) >= @ttl
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# No published snapshot (cold start, or just busted by a write): build,
|
|
104
|
+
# and on failure fall back to the last snapshot this process ever built —
|
|
105
|
+
# a database blip right after a write must not turn every dial read into
|
|
106
|
+
# an exception. A truly cold process (nothing ever built) raises.
|
|
107
|
+
def build_or_last_good
|
|
108
|
+
build_and_publish
|
|
109
|
+
rescue StandardError => e
|
|
110
|
+
last = @last_good
|
|
111
|
+
raise if last.nil?
|
|
112
|
+
|
|
113
|
+
warn "[dials] snapshot rebuild failed; serving last-known-good (#{e.class}: #{e.message})"
|
|
114
|
+
last
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Build without any lock. Concurrent cold readers each build once (a
|
|
118
|
+
# bounded, once-per-boot herd against one small table); the last
|
|
119
|
+
# assignment wins with a valid snapshot either way.
|
|
120
|
+
def build_and_publish
|
|
121
|
+
generation = @state_mutex.synchronize { @generation }
|
|
122
|
+
built = Snapshot.new(**@store.state)
|
|
123
|
+
|
|
124
|
+
# Publish only if no bust! landed while we were reading the store —
|
|
125
|
+
# otherwise this snapshot predates a write and must not become the
|
|
126
|
+
# shared state. Check and assignment share the state mutex so a bust!
|
|
127
|
+
# cannot slip between them. The caller still gets the built snapshot.
|
|
128
|
+
# Either way the build becomes last-known-good: even a snapshot that
|
|
129
|
+
# predates a concurrent write is honest data — exactly what LKG serves.
|
|
130
|
+
@state_mutex.synchronize do
|
|
131
|
+
@last_good = built
|
|
132
|
+
if generation == @generation
|
|
133
|
+
@probed_at = monotonic_now
|
|
134
|
+
@snapshot = built
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
built
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def refresh(current)
|
|
142
|
+
return current unless @build_mutex.try_lock
|
|
143
|
+
|
|
144
|
+
begin
|
|
145
|
+
build_and_publish
|
|
146
|
+
rescue StandardError => e
|
|
147
|
+
warn "[dials] snapshot rebuild failed; serving the cached snapshot (#{e.class}: #{e.message})"
|
|
148
|
+
current
|
|
149
|
+
ensure
|
|
150
|
+
@build_mutex.unlock
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def monotonic_now
|
|
155
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dials
|
|
4
|
+
# One entry in the change log, in a store-independent shape. `scope` is nil
|
|
5
|
+
# for global changes; `old_value` is nil when no override existed before
|
|
6
|
+
# (the write introduced the override); `new_value` is nil for clears.
|
|
7
|
+
#
|
|
8
|
+
# The change log is append-only and doubles as the store's version counter,
|
|
9
|
+
# so every mutation the public API performs lands here by construction.
|
|
10
|
+
ChangeRecord = Data.define(:key, :scope, :action, :old_value, :new_value,
|
|
11
|
+
:actor_type, :actor_id, :actor_label, :created_at) do
|
|
12
|
+
def global?
|
|
13
|
+
scope.nil?
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
data/lib/dials/config.rb
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dials
|
|
4
|
+
# App-wide configuration, set once at boot via Dials.configure.
|
|
5
|
+
class Config
|
|
6
|
+
# Seconds between staleness probes (see Cache). 0 probes every read;
|
|
7
|
+
# nil never probes.
|
|
8
|
+
attr_reader :cache_ttl
|
|
9
|
+
|
|
10
|
+
# Builds the human label stored on every change-log entry.
|
|
11
|
+
attr_accessor :actor_label
|
|
12
|
+
|
|
13
|
+
# Fallback attribution for writes that pass no actor: — for apps without
|
|
14
|
+
# user identity (no User model, single-operator tools, scripts). A
|
|
15
|
+
# string/object, or a callable evaluated per write
|
|
16
|
+
# (`-> { ENV.fetch("USER", "console") }`). nil (the default) keeps
|
|
17
|
+
# actor: required on every write. This is a declared app-level fallback,
|
|
18
|
+
# not discovery — the gem still never guesses (no Current.user magic),
|
|
19
|
+
# and an explicit actor: always wins.
|
|
20
|
+
attr_accessor :default_actor
|
|
21
|
+
|
|
22
|
+
def initialize
|
|
23
|
+
@store = nil
|
|
24
|
+
@cache_ttl = 5.0
|
|
25
|
+
@actor_label = Actor::DEFAULT_LABEL
|
|
26
|
+
@default_actor = nil
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def cache_ttl=(seconds)
|
|
30
|
+
@cache_ttl = seconds
|
|
31
|
+
Dials.cache.ttl = seconds
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Accepts a store instance, or the symbols :memory / :active_record.
|
|
35
|
+
def store=(store)
|
|
36
|
+
@store = case store
|
|
37
|
+
when :memory then Stores::Memory.new
|
|
38
|
+
when :active_record
|
|
39
|
+
require "dials/active_record"
|
|
40
|
+
Stores::ActiveRecordStore.new
|
|
41
|
+
else store
|
|
42
|
+
end
|
|
43
|
+
Dials.reset_cache!
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def store
|
|
47
|
+
@store ||= Stores::Memory.new
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dials
|
|
4
|
+
# A dial's declaration: its identity, its code default, and the rules a
|
|
5
|
+
# stored value must satisfy. Definitions live in code (the registry), never
|
|
6
|
+
# in the database — the database stores only values.
|
|
7
|
+
#
|
|
8
|
+
# type:: :boolean, :integer, :float, :string, or :json (any
|
|
9
|
+
# JSON-serializable structure).
|
|
10
|
+
# constraints:: optional keywords on top of the type, in JSON Schema's
|
|
11
|
+
# vocabulary — `minimum: 1, maximum: 10_000`, `enum: %w[low high]`,
|
|
12
|
+
# `pattern: /.../`, `properties:`/`required:` for :json objects.
|
|
13
|
+
# See Schema for the full keyword set per type.
|
|
14
|
+
# validate:: optional callable returning truthy when a value is storable —
|
|
15
|
+
# the escape hatch for rules a schema cannot express. Unlike the
|
|
16
|
+
# schema keywords it cannot be rendered or serialized; prefer the
|
|
17
|
+
# keywords whenever they can say it.
|
|
18
|
+
# dimensions:: the axes the dial can vary along (per market, per
|
|
19
|
+
# platform, ...). Declaring dimensions is the arming gate: a dial
|
|
20
|
+
# with none is global-only by construction, and adding the
|
|
21
|
+
# declaration belongs in the same change as the code that reads
|
|
22
|
+
# the dimensioned value.
|
|
23
|
+
class Definition
|
|
24
|
+
TYPES = %i[boolean integer float string json].freeze
|
|
25
|
+
|
|
26
|
+
# Keys land in an indexed VARCHAR(100) that shares a composite unique
|
|
27
|
+
# index with the 255-byte canonical scope; the explicit cap keeps that
|
|
28
|
+
# index inside every supported database's budget (and generated method
|
|
29
|
+
# names sane).
|
|
30
|
+
MAX_KEY_LENGTH = 100
|
|
31
|
+
|
|
32
|
+
# Keys become method names (the bare reader, adjust_<key>, clear_<key>),
|
|
33
|
+
# so they must be plain callable identifiers — no spaces, hyphens,
|
|
34
|
+
# question marks, or leading digits.
|
|
35
|
+
KEY_FORMAT = /\A[a-zA-Z_][a-zA-Z0-9_]*\z/
|
|
36
|
+
|
|
37
|
+
attr_reader :key, :default, :type, :label, :unit, :description, :dimensions, :schema
|
|
38
|
+
|
|
39
|
+
def initialize(key, default:, type:, label: nil, unit: nil, description: nil,
|
|
40
|
+
dimensions: nil, validate: nil, **constraints)
|
|
41
|
+
@key = key.to_sym
|
|
42
|
+
@type = type.to_sym
|
|
43
|
+
@label = label || @key.to_s.tr("_", " ").capitalize
|
|
44
|
+
@unit = unit
|
|
45
|
+
@description = description
|
|
46
|
+
@validate = validate
|
|
47
|
+
|
|
48
|
+
# Type first: Schema derives its allowed keywords from it.
|
|
49
|
+
raise InvalidDefinition, "#{@key}: unknown type #{@type.inspect} (use one of #{TYPES.join(', ')})" unless TYPES.include?(@type)
|
|
50
|
+
|
|
51
|
+
@schema = Schema.new(@key, @type, constraints)
|
|
52
|
+
@dimensions = build_dimensions(dimensions)
|
|
53
|
+
@default = default
|
|
54
|
+
|
|
55
|
+
# Validate BEFORE freezing: a default that fails validation (including
|
|
56
|
+
# a cyclic structure, which the JSON round-trip rejects) must raise
|
|
57
|
+
# InvalidDefinition without having frozen the caller's object — and
|
|
58
|
+
# Freeze.deep would recurse forever on a cycle.
|
|
59
|
+
validate_definition!
|
|
60
|
+
|
|
61
|
+
# Deep-frozen: resolution returns the default directly when nothing is
|
|
62
|
+
# stored, and a caller must not be able to mutate the code default for
|
|
63
|
+
# every other reader in the process.
|
|
64
|
+
@default = Freeze.deep(default)
|
|
65
|
+
freeze
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def dimensions?
|
|
69
|
+
!dimensions.empty?
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def dimension_names
|
|
73
|
+
dimensions.map(&:name)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# The declaration as a JSON Schema fragment — what an admin surface (or
|
|
77
|
+
# an agent reading the dial catalog) needs to render inputs and validate
|
|
78
|
+
# client-side. A `validate:` callable is not representable and is simply
|
|
79
|
+
# absent; the server-side check still runs on every write.
|
|
80
|
+
def to_json_schema
|
|
81
|
+
out = {}
|
|
82
|
+
out["type"] = json_schema_type if json_schema_type
|
|
83
|
+
out["title"] = label
|
|
84
|
+
out["description"] = description if description
|
|
85
|
+
out.merge!(schema.to_json_schema)
|
|
86
|
+
out["default"] = default
|
|
87
|
+
out
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Validation problems for a candidate stored value; [] when storable.
|
|
91
|
+
# `nil` is never storable — removing an override is a clear, so a stored
|
|
92
|
+
# nil could only ever be an accident.
|
|
93
|
+
def problems_for(value)
|
|
94
|
+
return ["cannot be nil (use clear to remove an override)"] if value.nil?
|
|
95
|
+
|
|
96
|
+
problem = type_problem(value)
|
|
97
|
+
return [problem] if problem
|
|
98
|
+
|
|
99
|
+
problems = schema.problems_for(value)
|
|
100
|
+
problems << "fails its validate check" if @validate && !@validate.call(value)
|
|
101
|
+
problems
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def validate_value!(value)
|
|
105
|
+
problems = problems_for(value)
|
|
106
|
+
return value if problems.empty?
|
|
107
|
+
|
|
108
|
+
raise InvalidValue, "#{key}: value #{value.inspect} #{problems.join('; ')}"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
private
|
|
112
|
+
|
|
113
|
+
def json_schema_type
|
|
114
|
+
case type
|
|
115
|
+
when :float then "number"
|
|
116
|
+
when :json then schema.object? ? "object" : nil
|
|
117
|
+
else type.to_s
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def build_dimensions(declared)
|
|
122
|
+
case declared
|
|
123
|
+
when nil then [].freeze
|
|
124
|
+
when Array
|
|
125
|
+
declared.map { |name| Dimension.new(name) }.freeze
|
|
126
|
+
when Hash
|
|
127
|
+
declared.map { |name, spec| Dimension.new(name, enum: dimension_enum(name, spec)) }.freeze
|
|
128
|
+
else
|
|
129
|
+
raise InvalidDefinition, "#{key}: dimensions must be a Hash or Array, got #{declared.class}"
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Strict on shape: a typo like `{ "enum" => [...] }` (string key) or
|
|
134
|
+
# `{ market: "KE" }` must raise, not silently become an OPEN dimension
|
|
135
|
+
# that accepts any value.
|
|
136
|
+
def dimension_enum(name, spec)
|
|
137
|
+
case spec
|
|
138
|
+
when nil then nil
|
|
139
|
+
when Hash
|
|
140
|
+
unknown = spec.keys - [:enum]
|
|
141
|
+
unless unknown.empty?
|
|
142
|
+
raise InvalidDefinition,
|
|
143
|
+
"#{key}: dimension #{name} has unknown keys #{unknown.inspect} (use enum: with a symbol key)"
|
|
144
|
+
end
|
|
145
|
+
spec[:enum]
|
|
146
|
+
when Array then spec
|
|
147
|
+
else
|
|
148
|
+
return spec if spec.respond_to?(:call)
|
|
149
|
+
|
|
150
|
+
raise InvalidDefinition, "#{key}: dimension #{name} spec must be an Array, a callable, or { enum: ... }"
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def validate_definition!
|
|
155
|
+
if key.length > MAX_KEY_LENGTH
|
|
156
|
+
raise InvalidDefinition, "#{key}: key exceeds #{MAX_KEY_LENGTH} characters"
|
|
157
|
+
end
|
|
158
|
+
unless KEY_FORMAT.match?(key.to_s)
|
|
159
|
+
raise InvalidDefinition,
|
|
160
|
+
"#{key.inspect}: key must be a plain identifier (letters, digits, underscores; " \
|
|
161
|
+
"it becomes the dial's method names)"
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
names = dimension_names
|
|
165
|
+
raise InvalidDefinition, "#{key}: duplicate dimension" unless names.uniq.length == names.length
|
|
166
|
+
|
|
167
|
+
# Generated adjust_/clear_ methods take scope as bare keywords next to
|
|
168
|
+
# actor: and expected_version:, so dimensions by those names could
|
|
169
|
+
# never be passed to them.
|
|
170
|
+
if names.include?(:actor)
|
|
171
|
+
raise InvalidDefinition, "#{key}: actor is a reserved dimension name (it means attribution on every write)"
|
|
172
|
+
end
|
|
173
|
+
if names.include?(:expected_version)
|
|
174
|
+
raise InvalidDefinition,
|
|
175
|
+
"#{key}: expected_version is a reserved dimension name (it means stale-write protection on every write)"
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
if @validate && !@validate.respond_to?(:call)
|
|
179
|
+
raise InvalidDefinition, "#{key}: validate must be a callable"
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
problems = problems_for(default)
|
|
183
|
+
return if problems.empty?
|
|
184
|
+
|
|
185
|
+
raise InvalidDefinition, "#{key}: default #{default.inspect} #{problems.join('; ')}"
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def type_problem(value)
|
|
189
|
+
case type
|
|
190
|
+
when :boolean
|
|
191
|
+
# `false` is a first-class storable value. Anything presence-shaped
|
|
192
|
+
# that rejects false makes a kill switch impossible to turn off.
|
|
193
|
+
"must be true or false" unless [true, false].include?(value)
|
|
194
|
+
when :integer
|
|
195
|
+
"must be an integer" unless value.is_a?(Integer)
|
|
196
|
+
when :float
|
|
197
|
+
# Only Integer and Float survive a JSON round-trip as numbers —
|
|
198
|
+
# BigDecimal and Rational would come back from the store as strings.
|
|
199
|
+
# Non-finite floats (NaN, Infinity) are not representable in JSON.
|
|
200
|
+
if !(value.is_a?(Integer) || value.is_a?(Float))
|
|
201
|
+
"must be an Integer or Float"
|
|
202
|
+
elsif value.is_a?(Float) && !value.finite?
|
|
203
|
+
"must be finite"
|
|
204
|
+
end
|
|
205
|
+
when :string
|
|
206
|
+
"must be a string" unless value.is_a?(String)
|
|
207
|
+
when :json
|
|
208
|
+
json_problem(value)
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# A :json value must survive the JSON round-trip UNCHANGED. Ruby's JSON
|
|
213
|
+
# generator happily stringifies symbols, Times, and arbitrary objects —
|
|
214
|
+
# which means a write would succeed and the very next read would return
|
|
215
|
+
# a different value. Requiring round-trip equality rejects those at
|
|
216
|
+
# write time (use string keys and JSON-native types).
|
|
217
|
+
def json_problem(value)
|
|
218
|
+
decoded = JSON.parse(JSON.generate(value))
|
|
219
|
+
return nil if decoded == value
|
|
220
|
+
|
|
221
|
+
"must round-trip through JSON unchanged (use string keys and JSON-native types)"
|
|
222
|
+
rescue StandardError
|
|
223
|
+
"must be JSON-serializable"
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dials
|
|
4
|
+
# One dimension of a dial: a name (:market, :platform, ...) and an
|
|
5
|
+
# optional set of allowed values — `enum`, the same word JSON Schema and
|
|
6
|
+
# the dial value constraints use. The enum may be given as an Array or as a
|
|
7
|
+
# callable (for values that are expensive to build or defined elsewhere,
|
|
8
|
+
# e.g. `-> { ISO3166::Country.codes }`); a callable is resolved once, on
|
|
9
|
+
# first use, under a lock (so a stateful or expensive callable cannot be
|
|
10
|
+
# raced into running twice by concurrent first reads).
|
|
11
|
+
#
|
|
12
|
+
# Dimension values are always compared as strings — "KE" and :KE name the
|
|
13
|
+
# same market. A dimension without an enum accepts any non-empty string up
|
|
14
|
+
# to MAX_VALUE_LENGTH characters (canonical scopes land in an indexed
|
|
15
|
+
# VARCHAR column; unbounded values would overflow or collide there).
|
|
16
|
+
class Dimension
|
|
17
|
+
MAX_VALUE_LENGTH = 128
|
|
18
|
+
|
|
19
|
+
attr_reader :name
|
|
20
|
+
|
|
21
|
+
def initialize(name, enum: nil)
|
|
22
|
+
@name = name.to_sym
|
|
23
|
+
@raw_enum = enum
|
|
24
|
+
@enum = nil
|
|
25
|
+
@mutex = Mutex.new
|
|
26
|
+
validate_shape!
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Allowed values as an Array of strings, or nil when the dimension is
|
|
30
|
+
# open (accepts any value).
|
|
31
|
+
def enum
|
|
32
|
+
return nil if @raw_enum.nil?
|
|
33
|
+
|
|
34
|
+
# Double-checked so the hot read path (every scope validation) skips
|
|
35
|
+
# the mutex once resolved.
|
|
36
|
+
resolved = @enum
|
|
37
|
+
return resolved if resolved
|
|
38
|
+
|
|
39
|
+
@mutex.synchronize do
|
|
40
|
+
@enum ||= Array(@raw_enum.respond_to?(:call) ? @raw_enum.call : @raw_enum)
|
|
41
|
+
.map { |o| o.to_s.freeze }.freeze
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def valid_value?(value)
|
|
46
|
+
value = value.to_s
|
|
47
|
+
return false if value.empty? || value.length > MAX_VALUE_LENGTH
|
|
48
|
+
|
|
49
|
+
enum.nil? || enum.include?(value)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def validate_shape!
|
|
55
|
+
return if @raw_enum.nil? || @raw_enum.is_a?(Array) || @raw_enum.respond_to?(:call)
|
|
56
|
+
|
|
57
|
+
raise InvalidDefinition, "dimension #{@name}: enum must be an Array or a callable, got #{@raw_enum.class}"
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
data/lib/dials/errors.rb
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dials
|
|
4
|
+
# Base class for every error the gem raises deliberately.
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
|
|
7
|
+
# Raised when a key is read or written that no `dial` declaration defined.
|
|
8
|
+
class UnknownDial < Error; end
|
|
9
|
+
|
|
10
|
+
# Raised when the same key is declared twice. A dial's declaration is its
|
|
11
|
+
# identity; a silent second declaration would make "which bounds apply?"
|
|
12
|
+
# ambiguous.
|
|
13
|
+
class DuplicateDial < Error; end
|
|
14
|
+
|
|
15
|
+
# Raised when a definition itself is malformed (bad type, bad dimensions
|
|
16
|
+
# shape, unknown schema keyword, default that fails its own schema).
|
|
17
|
+
# Definitions fail at boot, not at first read in production.
|
|
18
|
+
class InvalidDefinition < Error; end
|
|
19
|
+
|
|
20
|
+
# Raised when a candidate value is not storable for its dial: wrong type,
|
|
21
|
+
# schema violation, or nil (nil is never a value — use clear to remove an
|
|
22
|
+
# override).
|
|
23
|
+
class InvalidValue < Error; end
|
|
24
|
+
|
|
25
|
+
# Raised when a scope does not match the dial's declared dimensions:
|
|
26
|
+
# unknown dimension, missing dimension, or a value outside a dimension's
|
|
27
|
+
# declared enum. Also raised when a scope is given for a dial that
|
|
28
|
+
# declares no dimensions at all.
|
|
29
|
+
class InvalidScope < Error; end
|
|
30
|
+
|
|
31
|
+
# Raised when a write arrives without an actor. Every write is attributed;
|
|
32
|
+
# there is no anonymous mutation path through the public API.
|
|
33
|
+
class MissingActor < Error; end
|
|
34
|
+
|
|
35
|
+
# Raised when a write carries `expected_version:` and the override it
|
|
36
|
+
# targets has changed (or appeared, or vanished) since that version was
|
|
37
|
+
# read — the caller acted on a stale picture. The write is not applied and
|
|
38
|
+
# nothing is appended to the change log. Deliberately NOT retried by the
|
|
39
|
+
# stores (a retried compare-and-swap would recompute against the new
|
|
40
|
+
# version and succeed, silently defeating the mechanism): the surface
|
|
41
|
+
# should re-render from Dials.overview and let the operator decide again.
|
|
42
|
+
class StaleWrite < Error; end
|
|
43
|
+
|
|
44
|
+
# Raised when concurrent UNCONDITIONAL writes to the same override race
|
|
45
|
+
# each other faster than the store's bounded retries can absorb —
|
|
46
|
+
# essentially never at operator write rates. Safe to retry; carries no
|
|
47
|
+
# staleness meaning (that is StaleWrite).
|
|
48
|
+
class WriteConflict < Error; end
|
|
49
|
+
end
|
data/lib/dials/freeze.rb
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dials
|
|
4
|
+
# Recursive freeze for JSON-shaped values (hashes, arrays, scalars). Used on
|
|
5
|
+
# snapshot contents and on declaration defaults, so no code path can hand a
|
|
6
|
+
# caller a mutable reference into shared dial state.
|
|
7
|
+
module Freeze
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def deep(object)
|
|
11
|
+
case object
|
|
12
|
+
when Hash
|
|
13
|
+
object.each { |k, v| deep(k) && deep(v) }
|
|
14
|
+
when Array
|
|
15
|
+
object.each { |v| deep(v) }
|
|
16
|
+
end
|
|
17
|
+
object.freeze
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dials
|
|
4
|
+
# Per-dial generated methods. Declaring `dial :base_fee, ...` defines
|
|
5
|
+
#
|
|
6
|
+
# Dials.base_fee(**scope) # read (Dials.get)
|
|
7
|
+
# Dials.adjust_base_fee(value, actor:, **scope) # write (Dials.set)
|
|
8
|
+
# Dials.clear_base_fee(actor:, **scope) # clear (Dials.clear)
|
|
9
|
+
#
|
|
10
|
+
# The reader is the bare dial name — reading is what you do with a dial
|
|
11
|
+
# all day, so it pays no prefix tax; the writers carry their verbs. These
|
|
12
|
+
# are real methods defined at declaration time — never method_missing — so
|
|
13
|
+
# respond_to?, tab completion, and a grep for `base_fee` all work. A dial
|
|
14
|
+
# whose name collides with an existing Dials method (:store, :cache,
|
|
15
|
+
# :changes, ...) fails at boot rather than shadowing the API.
|
|
16
|
+
#
|
|
17
|
+
# Scope travels as bare keywords here (`market: "KE"`), which is why
|
|
18
|
+
# `actor` and `expected_version` are reserved dimension names: on
|
|
19
|
+
# adjust_/clear_ they must always mean attribution and stale-write
|
|
20
|
+
# protection, never scope. Definition enforces the reservation.
|
|
21
|
+
#
|
|
22
|
+
# The methods live on this module (which Dials extends) rather than on
|
|
23
|
+
# Dials directly so Registry#reset! can strip every generated method
|
|
24
|
+
# without touching the core API.
|
|
25
|
+
module Generated
|
|
26
|
+
class << self
|
|
27
|
+
# Define the three methods for a definition. Collisions are checked
|
|
28
|
+
# first — all three names, including against private methods, since a
|
|
29
|
+
# method on Dials itself would shadow anything defined here — so a
|
|
30
|
+
# raise leaves nothing half-installed.
|
|
31
|
+
def install!(definition)
|
|
32
|
+
key = definition.key
|
|
33
|
+
names = [key, :"adjust_#{key}", :"clear_#{key}"]
|
|
34
|
+
|
|
35
|
+
names.each do |name|
|
|
36
|
+
next unless Dials.respond_to?(name, true)
|
|
37
|
+
|
|
38
|
+
raise InvalidDefinition, "dial #{key} would define Dials.#{name}, which already exists"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# actor: defaults to nil rather than being a required keyword so that
|
|
42
|
+
# apps declaring config.default_actor can write without one; with no
|
|
43
|
+
# default configured, Actor.normalize still raises MissingActor.
|
|
44
|
+
define_method(names[0]) { |**scope| get(key, **scope) }
|
|
45
|
+
define_method(names[1]) do |value, actor: nil, expected_version: nil, **scope|
|
|
46
|
+
set(key, value, actor: actor, scope: scope, expected_version: expected_version)
|
|
47
|
+
end
|
|
48
|
+
define_method(names[2]) do |actor: nil, expected_version: nil, **scope|
|
|
49
|
+
clear(key, actor: actor, scope: scope, expected_version: expected_version)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def uninstall_all!
|
|
54
|
+
instance_methods(false).each { |name| remove_method(name) }
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
extend Generated
|
|
60
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dials
|
|
4
|
+
# The complete stored state of every registered dial, read from ONE
|
|
5
|
+
# snapshot in one call — so an admin page renders a coherent picture.
|
|
6
|
+
#
|
|
7
|
+
# overview.version # the store's write-clock token (informational:
|
|
8
|
+
# # "rendered as of"; freshness displays, cheap
|
|
9
|
+
# # did-anything-change checks)
|
|
10
|
+
# overview.dials # [DialState, ...] in registry order
|
|
11
|
+
#
|
|
12
|
+
# Stale-write tokens are PER OVERRIDE — see DialState#global_version and
|
|
13
|
+
# #scoped_override_versions; feed those back as `expected_version:` on writes.
|
|
14
|
+
Overview = Data.define(:version, :dials)
|
|
15
|
+
|
|
16
|
+
# One dial's declaration plus its stored overrides at the snapshot moment.
|
|
17
|
+
#
|
|
18
|
+
# `global_override?` is explicit — a boolean dial overridden to `false`
|
|
19
|
+
# must never be confusable with "no override" (`global_value` alone could
|
|
20
|
+
# not distinguish them; it is nil when no global override exists).
|
|
21
|
+
#
|
|
22
|
+
# `global_version` and `scoped_override_versions` are the per-override
|
|
23
|
+
# stale-write tokens: echo the one for the override you are writing as
|
|
24
|
+
# `expected_version:`. Cleared overrides keep a TOMBSTONE token (the
|
|
25
|
+
# stream's clear stamp) — so `scoped_override_versions` can carry entries
|
|
26
|
+
# for scopes with no value in `scoped_overrides`, and `global_version` is
|
|
27
|
+
# Dials::ABSENT_VERSION only when the global was never written at all.
|
|
28
|
+
# For an override with no token listed anywhere, pass
|
|
29
|
+
# Dials::ABSENT_VERSION to assert it has never been written.
|
|
30
|
+
DialState = Data.define(:definition, :global_override, :global_value, :global_version,
|
|
31
|
+
:scoped_overrides, :scoped_override_versions) do
|
|
32
|
+
def key
|
|
33
|
+
definition.key
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def global_override?
|
|
37
|
+
global_override
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# The declaration as a JSON Schema fragment (see Definition#to_json_schema).
|
|
41
|
+
def json_schema
|
|
42
|
+
definition.to_json_schema
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|