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
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 616a841d2067499823a017f9b83bf151c2a7df9128ac044154c37a473a275770
|
|
4
|
+
data.tar.gz: 6d32168d1c2c5bf45742db53b95d1eace0a145ec046e911a21eba1231660812b
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 149a10e661a7fc3ca2504d06f33e5c69294c9445373542d66970bb340e6313968170486c1d2184a927b7c08d223bbd20864474b4811c18272adb5b8cbe2459f4
|
|
7
|
+
data.tar.gz: 74f42bde26e31ed6c2f8ee2f0591c100d921d4282072718c5650237e59027a0981d7c3e454436047f20b6536fb241772c0ec37d93c02c1b52608fe4a73b53a87
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [0.1.0] - 2026-09-02
|
|
4
|
+
|
|
5
|
+
Initial release.
|
|
6
|
+
|
|
7
|
+
- **Code-declared registry.** `Dials.define` with
|
|
8
|
+
`dial :checkout_fee_bps, default: 250, type: :integer, minimum: 1, maximum: 10_000`
|
|
9
|
+
— types (boolean, integer, float, string, json) plus labels, units, and
|
|
10
|
+
descriptions. Constraints speak JSON Schema directly (snake_cased keywords:
|
|
11
|
+
`minimum:`/`maximum:`/`exclusive_minimum:`/`exclusive_maximum:`/
|
|
12
|
+
`multiple_of:` for numbers, `min_length:`/`max_length:`/`pattern:` for
|
|
13
|
+
strings, `enum:` for any type, `properties:`/`required:` for `:json`
|
|
14
|
+
objects), with `validate:` (a callable) as the escape hatch for rules a
|
|
15
|
+
schema cannot express. `Definition#to_json_schema` emits each declaration
|
|
16
|
+
as a real JSON Schema fragment for admin surfaces and agents.
|
|
17
|
+
- **Generated per-dial methods are the primary API.** Declaring a dial
|
|
18
|
+
defines real methods at declaration time: readers are the bare dial name
|
|
19
|
+
(`Dials.checkout_fee_bps(market: "KE")`), writers keep their verbs
|
|
20
|
+
(`Dials.adjust_checkout_fee_bps(value, actor:, **scope)` and
|
|
21
|
+
`Dials.clear_checkout_fee_bps(actor:, **scope)`), so a bare name is always
|
|
22
|
+
a read. The key-taking primitives (`Dials.get` / `Dials.set` /
|
|
23
|
+
`Dials.clear`) remain public as the dynamic-access layer for code that
|
|
24
|
+
receives the key at runtime. Name collisions with existing `Dials` methods
|
|
25
|
+
raise `InvalidDefinition`; `actor` and `expected_version` are reserved
|
|
26
|
+
dimension names.
|
|
27
|
+
- **Dimensions and scoped overrides.** `dimensions:` declares a dial's axes
|
|
28
|
+
(closed `enum:` option lists or open, length-capped values). The database
|
|
29
|
+
stores overrides only; resolution is scoped override → global override →
|
|
30
|
+
code default, with a most-specific-wins matcher. Clearing an override
|
|
31
|
+
returns resolution to the layer below.
|
|
32
|
+
- **The log is the state — one append-only table.** Every write INSERTs
|
|
33
|
+
exactly one row into a single `dials` table; the newest row per
|
|
34
|
+
(key, scope) stream is the current override (`set` carries a value,
|
|
35
|
+
`clear` ends it). Current state, attributed history (`Dials.changes`),
|
|
36
|
+
and the cache's version counter are the same rows, so history can never
|
|
37
|
+
disagree with state, and `changes` derives old values from the previous
|
|
38
|
+
row instead of trusting a stored copy.
|
|
39
|
+
- **Stale-write protection (compare-and-swap).** Every write path accepts
|
|
40
|
+
`expected_version:` — an opaque per-override token from `Dials.overview`
|
|
41
|
+
or a previous CAS write's return value. Tokens are stream `seq` numbers
|
|
42
|
+
claimed under `UNIQUE(key, scope, seq)`, so of two concurrent claims the
|
|
43
|
+
database rejects one: the comparison is atomic with the write, holds
|
|
44
|
+
against every concurrent writer without anyone opting in, and unrelated
|
|
45
|
+
overrides can never false-conflict. A mismatch raises `Dials::StaleWrite`
|
|
46
|
+
with the write unapplied and nothing logged, and is deliberately never
|
|
47
|
+
auto-retried. Cleared overrides keep a tombstone token, so
|
|
48
|
+
`Dials::ABSENT_VERSION` strictly means "never written" and absent
|
|
49
|
+
assertions cannot be fooled by set-then-clear activity (no ABA).
|
|
50
|
+
- **Attributed writes.** Writers require `actor:` and every write lands in
|
|
51
|
+
the append-only log. Apps without a user identity can declare
|
|
52
|
+
`config.default_actor` (a value, or a callable evaluated per write) once;
|
|
53
|
+
an explicit `actor:` always wins.
|
|
54
|
+
- **Enumeration API.** `Dials.overview` returns every registered dial's full
|
|
55
|
+
state — definition (with its JSON Schema), global override, scoped
|
|
56
|
+
overrides, and CAS tokens — from one snapshot stamped with a single
|
|
57
|
+
version token; `Dials.scoped_overrides(key)` returns one dial's stored
|
|
58
|
+
overrides keyed by parsed scope. Both read through the same path as the
|
|
59
|
+
generated readers and return frozen structures.
|
|
60
|
+
- **Per-process snapshot cache** with a throttled staleness probe
|
|
61
|
+
(`cache_ttl`), single-flight refreshes, and a last-known-good snapshot
|
|
62
|
+
served (with a warning) when the store blips. Writes inside an application
|
|
63
|
+
database transaction never leak uncommitted state into the shared cache —
|
|
64
|
+
the writing thread reads its own view until commit, and the cache busts
|
|
65
|
+
again on commit.
|
|
66
|
+
- **Hardened by adversarial review.** Corrupt rows written around the gem
|
|
67
|
+
are quarantined with a warning instead of failing reads; values
|
|
68
|
+
JSON-round-trip identically in both stores (no retained caller references,
|
|
69
|
+
no store-dependent shapes); declaration defaults are deep-frozen; write
|
|
70
|
+
retries cover deadlocks and serialization failures but never run inside an
|
|
71
|
+
outer application transaction.
|
|
72
|
+
- **Stores:** in-memory (default, zero dependencies) and ActiveRecord
|
|
73
|
+
(Rails/AR >= 7.2, portable JSON-text columns), with a
|
|
74
|
+
`rails g dials:install` generator (migration + initializer).
|
|
75
|
+
- **`Dials::Testing.with_overrides`** for client test suites.
|
|
76
|
+
- **Zero runtime dependencies.**
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ZAR Labs Inc.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# dials
|
|
2
|
+
|
|
3
|
+
Operator-adjustable values with per-scope overrides, attribution, and
|
|
4
|
+
caching.
|
|
5
|
+
|
|
6
|
+
You wrote a constant. Then you needed to change it without a deploy, so you
|
|
7
|
+
built an admin surface. Then you needed a different value per market. Dials is
|
|
8
|
+
that whole arc as one small library:
|
|
9
|
+
|
|
10
|
+
```ruby
|
|
11
|
+
# config/initializers/dials.rb
|
|
12
|
+
Dials.define do
|
|
13
|
+
dial :merchant_fee_bps, default: 100,
|
|
14
|
+
type: :integer, minimum: 1, maximum: 10_000, unit: "bps",
|
|
15
|
+
dimensions: { market: { enum: %w[KE NG BD] } }
|
|
16
|
+
|
|
17
|
+
dial :signups_enabled, default: true, type: :boolean,
|
|
18
|
+
description: "Global kill switch for new signups."
|
|
19
|
+
end
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
The constraint keywords are **JSON Schema**, snake_cased (`minimum:`,
|
|
23
|
+
`maximum:`, `enum:`, `pattern:`, `properties:`/`required:` for `:json`
|
|
24
|
+
values) — if you've written an OpenAPI spec or a JSON Schema, you already
|
|
25
|
+
know this vocabulary, and `definition.to_json_schema` hands the real
|
|
26
|
+
fragment to admin UIs and client-side validators.
|
|
27
|
+
|
|
28
|
+
Each declaration generates the dial's methods:
|
|
29
|
+
|
|
30
|
+
```ruby
|
|
31
|
+
Dials.merchant_fee_bps(market: "KE") # => 100 (the code default)
|
|
32
|
+
|
|
33
|
+
Dials.adjust_merchant_fee_bps(90, actor: current_admin, market: "KE")
|
|
34
|
+
Dials.merchant_fee_bps(market: "KE") # => 90
|
|
35
|
+
Dials.merchant_fee_bps(market: "NG") # => 100
|
|
36
|
+
|
|
37
|
+
Dials.clear_merchant_fee_bps(actor: current_admin, market: "KE")
|
|
38
|
+
Dials.merchant_fee_bps(market: "KE") # => 100 again
|
|
39
|
+
|
|
40
|
+
Dials.changes(key: :merchant_fee_bps) # attributed, append-only history
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
The key-taking primitives (`Dials.get`, `Dials.set`, `Dials.clear`) stay
|
|
44
|
+
public underneath, for code that receives the key at runtime — an admin
|
|
45
|
+
surface iterating the registry, a console one-liner.
|
|
46
|
+
|
|
47
|
+
Resolution is always **scoped override → global override → code default**.
|
|
48
|
+
The database is one append-only table storing only overrides — state,
|
|
49
|
+
attributed history, and the cache's version counter are the same rows, and
|
|
50
|
+
clearing every override returns you to exactly what the code says. Reads
|
|
51
|
+
come from a per-process cache with a throttled staleness probe, so a dial
|
|
52
|
+
read costs a hash lookup, not a query.
|
|
53
|
+
|
|
54
|
+
## Installation
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
bundle add dials
|
|
58
|
+
bin/rails generate dials:install # migration (3 tables) + initializer
|
|
59
|
+
bin/rails db:migrate
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Full documentation: <https://zarpay.github.io/dials/> — including the design
|
|
63
|
+
decisions, the caching model, retrofit guides for existing apps, and when a
|
|
64
|
+
value should *not* be a dial.
|
|
65
|
+
|
|
66
|
+
## Development
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
bundle install
|
|
70
|
+
bundle exec rake # minitest + rubocop
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
The sibling `demo/` package in this repository is a Rails app whose test
|
|
74
|
+
suite exercises the entire public API against a real database.
|
|
75
|
+
|
|
76
|
+
## License
|
|
77
|
+
|
|
78
|
+
MIT.
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dials
|
|
4
|
+
module ActiveRecord
|
|
5
|
+
# The one gem-owned table. Values are stored as JSON text (not jsonb)
|
|
6
|
+
# so the schema is portable across PostgreSQL, MySQL, and SQLite; nothing
|
|
7
|
+
# ever queries inside a value or a scope — reads go through the
|
|
8
|
+
# in-process cache, so the database is durable storage, not a query
|
|
9
|
+
# surface.
|
|
10
|
+
#
|
|
11
|
+
# This model is internal plumbing for Stores::ActiveRecordStore.
|
|
12
|
+
# Application code reads and writes through the Dials facade, which is
|
|
13
|
+
# where validation, attribution, and cache busting live. Writing to it
|
|
14
|
+
# directly bypasses all of that.
|
|
15
|
+
|
|
16
|
+
# One row per WRITE — the table is append-only, so the change log IS the
|
|
17
|
+
# state. The newest row per (key, scope) stream is the current override:
|
|
18
|
+
# action "set" carries the value; action "clear" says the override is
|
|
19
|
+
# gone (resolution falls to the next layer). A global override is simply
|
|
20
|
+
# the stream at the empty scope, stored under the canonical encoding
|
|
21
|
+
# "{}". History cannot disagree with state because they are the same
|
|
22
|
+
# rows, and an override's previous value is literally its previous row.
|
|
23
|
+
#
|
|
24
|
+
# `seq` numbers each stream's rows 1, 2, 3...; UNIQUE(key, scope, seq)
|
|
25
|
+
# is what makes writes atomic without locks or guarded updates: every
|
|
26
|
+
# writer claims the stream's next slot, and of two concurrent claims the
|
|
27
|
+
# database rejects one. Rows are immutable and seq only grows, so a
|
|
28
|
+
# stale-write token (the live row's seq) can never be revisited.
|
|
29
|
+
#
|
|
30
|
+
# NOTE: uniqueness is textual, under the column's collation. Scope
|
|
31
|
+
# strings are canonical (sorted keys, string values) so gem writes can
|
|
32
|
+
# never collide cosmetically; on MySQL, a case-insensitive default
|
|
33
|
+
# collation additionally treats scopes differing only by case ("KE" vs
|
|
34
|
+
# "ke") as one stream — don't declare dimension enums that differ only
|
|
35
|
+
# by case, or give the table a binary collation.
|
|
36
|
+
class Entry < ::ActiveRecord::Base
|
|
37
|
+
self.table_name = "dials"
|
|
38
|
+
|
|
39
|
+
validates :key, :scope, :seq, presence: true
|
|
40
|
+
validates :action, presence: true, inclusion: { in: %w[set clear] }
|
|
41
|
+
validates :value, presence: true, if: -> { action == "set" }
|
|
42
|
+
|
|
43
|
+
# App-level append-only: a persisted row refuses update and destroy
|
|
44
|
+
# through ActiveRecord. (Raw SQL and delete_all can still bypass this —
|
|
45
|
+
# rows are state, history, AND the cache's version counter, so don't.)
|
|
46
|
+
def readonly?
|
|
47
|
+
persisted?
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dials
|
|
4
|
+
module Stores
|
|
5
|
+
# The production store: ONE ActiveRecord-backed, append-only table (see
|
|
6
|
+
# Dials::ActiveRecord::Entry). Every write INSERTs a row; the newest row
|
|
7
|
+
# per (key, scope) stream is the current override, and the same rows are
|
|
8
|
+
# the attributed history and the cache's version counter. The global
|
|
9
|
+
# override is the stream at Scope::GLOBAL (the canonical empty scope).
|
|
10
|
+
# Implements the same interface as Stores::Memory.
|
|
11
|
+
#
|
|
12
|
+
# Concurrency control is the stream sequence: each row claims its
|
|
13
|
+
# stream's next `seq` under UNIQUE(key, scope, seq), so of two concurrent
|
|
14
|
+
# writes the database rejects one — atomic, with no lock table, no
|
|
15
|
+
# advisory locks, and no guarded updates. A rejected unconditional write
|
|
16
|
+
# re-runs with fresh reads; a rejected compare-and-swap re-runs, sees the
|
|
17
|
+
# interleaved write, and raises StaleWrite. Rows are immutable and seq
|
|
18
|
+
# only grows, so a stale-write token (the live row's seq) can never be
|
|
19
|
+
# revisited by a later delete-and-recreate.
|
|
20
|
+
class ActiveRecordStore
|
|
21
|
+
Entry = Dials::ActiveRecord::Entry
|
|
22
|
+
|
|
23
|
+
# Sentinel for "this row could not be decoded; skip it".
|
|
24
|
+
SKIP = Object.new
|
|
25
|
+
|
|
26
|
+
# Database races a write can lose and safely re-run: two writers
|
|
27
|
+
# claiming the same seq (RecordNotUnique — the re-run reads the new
|
|
28
|
+
# newest row), and adapter-reported deadlocks / serialization failures
|
|
29
|
+
# (TransactionRollbackError covers both). StaleWrite is deliberately
|
|
30
|
+
# NOT here — a retried CAS would recompute against the new state and
|
|
31
|
+
# silently defeat the mechanism (the re-run raises it AFTER re-reading,
|
|
32
|
+
# which is the correct outcome, not a retry of the comparison).
|
|
33
|
+
RETRYABLE = [
|
|
34
|
+
::ActiveRecord::RecordNotUnique,
|
|
35
|
+
::ActiveRecord::TransactionRollbackError
|
|
36
|
+
].freeze
|
|
37
|
+
|
|
38
|
+
# Attempts per write: the first, plus retries for lost seq claims.
|
|
39
|
+
# Operator write rates make even the second attempt rare.
|
|
40
|
+
WRITE_ATTEMPTS = 3
|
|
41
|
+
|
|
42
|
+
def state
|
|
43
|
+
# Version first: if a write lands between these reads, the snapshot
|
|
44
|
+
# carries an older version than its data, and the next probe sees the
|
|
45
|
+
# version move and rebuilds — stale in the safe direction only.
|
|
46
|
+
current_version = version
|
|
47
|
+
|
|
48
|
+
globals = {}
|
|
49
|
+
scoped = {}
|
|
50
|
+
row_versions = {}
|
|
51
|
+
newest_rows.each do |row|
|
|
52
|
+
case row.action
|
|
53
|
+
when "clear"
|
|
54
|
+
# Tombstones carry no value but DO carry the stream's stale-write
|
|
55
|
+
# stamp — an "absent" token must go stale when set/clear activity
|
|
56
|
+
# happened since it was read. Their scopes are validated like any
|
|
57
|
+
# other row's: a malformed tombstone scope must be quarantined
|
|
58
|
+
# here, not crash Dials.overview when it tries to parse the stamp
|
|
59
|
+
# map later.
|
|
60
|
+
if row.scope == Scope::GLOBAL || valid_scope_string?(row.key, row.scope)
|
|
61
|
+
(row_versions[row.key.to_sym] ||= {})[row.scope] = row.seq
|
|
62
|
+
end
|
|
63
|
+
next
|
|
64
|
+
when "set"
|
|
65
|
+
nil # fall through to the value path
|
|
66
|
+
else
|
|
67
|
+
quarantine("dials(#{row.key}, #{row.scope})", "unknown action #{row.action.inspect}")
|
|
68
|
+
next
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
value = decode_row(row.value, "dials(#{row.key}, #{row.scope})")
|
|
72
|
+
next if value.equal?(SKIP)
|
|
73
|
+
|
|
74
|
+
if row.scope == Scope::GLOBAL
|
|
75
|
+
globals[row.key.to_sym] = value
|
|
76
|
+
elsif valid_scope_string?(row.key, row.scope)
|
|
77
|
+
(scoped[row.key.to_sym] ||= {})[row.scope] = value
|
|
78
|
+
else
|
|
79
|
+
next
|
|
80
|
+
end
|
|
81
|
+
(row_versions[row.key.to_sym] ||= {})[row.scope] = row.seq
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
{ globals: globals, scoped_overrides: scoped, version: current_version, row_versions: row_versions }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# The table is append-only, so its row count moves on every committed
|
|
88
|
+
# write and only ever grows. Count alone would be enough; max id is
|
|
89
|
+
# included as a belt against direct table surgery. Max id alone would
|
|
90
|
+
# NOT be enough: transaction A can claim id 10, B claim and commit id
|
|
91
|
+
# 11, and only then A commits — MAX(id) never moves for a process that
|
|
92
|
+
# already saw 11, so A's write would stay invisible until the next
|
|
93
|
+
# unrelated write. Count catches it (N → N+1). Both aggregates come
|
|
94
|
+
# from ONE statement so they describe one committed state, never two.
|
|
95
|
+
def version
|
|
96
|
+
count, max = Entry.pick(Arel.sql("COUNT(*)"), Arel.sql("COALESCE(MAX(id), 0)"))
|
|
97
|
+
[count, max]
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# The stale-write stamp of one override stream: the seq of its newest
|
|
101
|
+
# row, LIVE OR TOMBSTONE — a cleared override keeps its clear row's
|
|
102
|
+
# stamp, so an absent → set → clear cycle can never make an old
|
|
103
|
+
# "absent" token current again (no ABA). Only a stream with no rows at
|
|
104
|
+
# all is 0, the StoreVersion::ABSENT state.
|
|
105
|
+
def override_version(key, canonical_scope)
|
|
106
|
+
newest(key, canonical_scope)&.seq || 0
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# True when the current thread's connection is inside an open
|
|
110
|
+
# transaction (typically an application transaction wrapping a dial
|
|
111
|
+
# write). The facade uses this to keep uncommitted dial state out of
|
|
112
|
+
# the shared cache.
|
|
113
|
+
def transaction_open?
|
|
114
|
+
pool = Entry.connection_pool
|
|
115
|
+
return false unless pool.active_connection?
|
|
116
|
+
|
|
117
|
+
connection = pool.respond_to?(:lease_connection) ? pool.lease_connection : pool.connection
|
|
118
|
+
connection.transaction_open?
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Runs the block after the current application transaction commits
|
|
122
|
+
# (immediately when no transaction is open). Discarded on rollback.
|
|
123
|
+
# The AR >= 7.2 floor enforced at require time guarantees the hook
|
|
124
|
+
# exists.
|
|
125
|
+
def after_commit(&)
|
|
126
|
+
if transaction_open?
|
|
127
|
+
::ActiveRecord.after_all_transactions_commit(&)
|
|
128
|
+
else
|
|
129
|
+
yield
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# The two mutations. `canonical_scope` is always a canonical string —
|
|
134
|
+
# Scope::GLOBAL for the global override. Both return [result, seq]:
|
|
135
|
+
# the usual result (previous value / did-anything-exist) plus the
|
|
136
|
+
# stream's seq after the write, so the facade can mint the caller's
|
|
137
|
+
# next token from the row it KNOWS was written — never from a second
|
|
138
|
+
# read that a concurrent writer could slip in front of.
|
|
139
|
+
def set_override(key, canonical_scope, value, actor, expected_version: nil)
|
|
140
|
+
write(expected_version) do
|
|
141
|
+
row = newest(key, canonical_scope)
|
|
142
|
+
assert_version!(expected_version, row&.seq || 0)
|
|
143
|
+
|
|
144
|
+
old = live?(row) ? decode(row.value) : nil
|
|
145
|
+
seq = (row&.seq || 0) + 1
|
|
146
|
+
append(key, canonical_scope, seq, "set", encode(value), actor)
|
|
147
|
+
[old, seq]
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Clearing appends a "clear" row, ending the stream's live override —
|
|
152
|
+
# resolution falls to the next layer, and the history of how it got
|
|
153
|
+
# there is preserved. Clearing what is not live is a no-op and appends
|
|
154
|
+
# nothing (but the version comparison, if requested, already ran: a
|
|
155
|
+
# stale no-op is still stale).
|
|
156
|
+
def clear_override(key, canonical_scope, actor, expected_version: nil)
|
|
157
|
+
write(expected_version) do
|
|
158
|
+
row = newest(key, canonical_scope)
|
|
159
|
+
assert_version!(expected_version, row&.seq || 0)
|
|
160
|
+
next [false, row&.seq || 0] unless live?(row)
|
|
161
|
+
|
|
162
|
+
seq = row.seq + 1
|
|
163
|
+
append(key, canonical_scope, seq, "clear", nil, actor)
|
|
164
|
+
[true, seq]
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def changes(key: nil, limit: 50)
|
|
169
|
+
relation = Entry.order(id: :desc).limit(limit)
|
|
170
|
+
relation = relation.where(key: key.to_s) if key
|
|
171
|
+
rows = relation.to_a
|
|
172
|
+
previous = predecessors_of(rows)
|
|
173
|
+
|
|
174
|
+
rows.filter_map do |row|
|
|
175
|
+
# History applies the same quarantine rules as state: an unknown
|
|
176
|
+
# action or a noncanonical scope is a row written around the gem,
|
|
177
|
+
# and the two views must agree on which rows are valid.
|
|
178
|
+
unless %w[set clear].include?(row.action)
|
|
179
|
+
next quarantine("dials(id #{row.id})", "unknown action #{row.action.inspect}")
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
parsed_scope =
|
|
183
|
+
if row.scope == Scope::GLOBAL
|
|
184
|
+
nil
|
|
185
|
+
elsif valid_scope_string?(row.key, row.scope)
|
|
186
|
+
Scope.parse(row.scope)
|
|
187
|
+
else
|
|
188
|
+
next nil
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
pred = previous[[row.key, row.scope, row.seq - 1]]
|
|
192
|
+
ChangeRecord.new(
|
|
193
|
+
key: row.key.to_sym,
|
|
194
|
+
scope: parsed_scope,
|
|
195
|
+
action: row.action,
|
|
196
|
+
old_value: pred && pred.action == "set" ? decode(pred.value) : nil,
|
|
197
|
+
new_value: row.action == "set" ? decode(row.value) : nil,
|
|
198
|
+
actor_type: row.actor_type,
|
|
199
|
+
actor_id: row.actor_id,
|
|
200
|
+
actor_label: row.actor_label,
|
|
201
|
+
created_at: row.created_at
|
|
202
|
+
)
|
|
203
|
+
rescue StandardError => e
|
|
204
|
+
# Same quarantine rule as state: one corrupt row (written around
|
|
205
|
+
# the gem) must not take down the whole history listing.
|
|
206
|
+
quarantine("dials(id #{row.id})", "row does not decode (#{e.class})")
|
|
207
|
+
nil
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
private
|
|
212
|
+
|
|
213
|
+
# The newest row of one (key, scope) stream, live or not.
|
|
214
|
+
def newest(key, canonical_scope)
|
|
215
|
+
Entry.where(key: key.to_s, scope: canonical_scope).order(seq: :desc).first
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def live?(row)
|
|
219
|
+
!row.nil? && row.action == "set"
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# Every stream's newest row, in one query. The correlated NOT EXISTS
|
|
223
|
+
# is portable across PostgreSQL, MySQL, and SQLite (no window
|
|
224
|
+
# functions) and walks the (key, scope, seq) index.
|
|
225
|
+
def newest_rows
|
|
226
|
+
Entry.where(<<~SQL.squish)
|
|
227
|
+
NOT EXISTS (
|
|
228
|
+
SELECT 1 FROM #{Entry.table_name} newer
|
|
229
|
+
WHERE newer.key = #{Entry.table_name}.key
|
|
230
|
+
AND newer.scope = #{Entry.table_name}.scope
|
|
231
|
+
AND newer.seq > #{Entry.table_name}.seq
|
|
232
|
+
)
|
|
233
|
+
SQL
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def append(key, canonical_scope, seq, action, encoded_value, actor)
|
|
237
|
+
Entry.create!(
|
|
238
|
+
key: key.to_s,
|
|
239
|
+
scope: canonical_scope,
|
|
240
|
+
seq: seq,
|
|
241
|
+
action: action,
|
|
242
|
+
value: encoded_value,
|
|
243
|
+
actor_type: actor[:actor_type],
|
|
244
|
+
actor_id: actor[:actor_id],
|
|
245
|
+
actor_label: actor[:actor_label]
|
|
246
|
+
)
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# The predecessor row (seq - 1) for each listed change, fetched with
|
|
250
|
+
# one exact predicate per stream (no Cartesian over-fetch across
|
|
251
|
+
# unrelated keys/scopes) — old_value is derived from history itself,
|
|
252
|
+
# so history cannot disagree with what was actually replaced.
|
|
253
|
+
def predecessors_of(rows)
|
|
254
|
+
wanted = rows.filter_map { |r| [r.key, r.scope, r.seq - 1] if r.seq > 1 }
|
|
255
|
+
return {} if wanted.empty?
|
|
256
|
+
|
|
257
|
+
wanted.group_by { |k, s, _| [k, s] }
|
|
258
|
+
.map { |(k, s), triples| Entry.where(key: k, scope: s, seq: triples.map(&:last)) }
|
|
259
|
+
.reduce(:or)
|
|
260
|
+
.index_by { |r| [r.key, r.scope, r.seq] }
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# The compare half of compare-and-swap; `current` is the stream's
|
|
264
|
+
# newest seq, tombstones included (ABSENT strictly means "this stream
|
|
265
|
+
# was never written"). The comparison itself is a read; atomicity
|
|
266
|
+
# comes from the seq claim under UNIQUE(key, scope, seq) — a writer
|
|
267
|
+
# that interleaves between this check and our INSERT takes our slot,
|
|
268
|
+
# our INSERT raises RecordNotUnique, and write() converts that
|
|
269
|
+
# directly to StaleWrite for CAS callers (a lost claim PROVES an
|
|
270
|
+
# interleaver). Nothing applied, nothing logged.
|
|
271
|
+
def assert_version!(expected, current)
|
|
272
|
+
return if expected.nil? || expected == StoreVersion.token(current)
|
|
273
|
+
|
|
274
|
+
raise StaleWrite,
|
|
275
|
+
"the override has changed since version #{expected} was read — " \
|
|
276
|
+
"re-read (Dials.overview) and retry deliberately"
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
# A CAS write that loses its seq claim is STALE by definition — the
|
|
280
|
+
# lost claim proves a concurrent write landed after the version was
|
|
281
|
+
# read — so RecordNotUnique converts straight to StaleWrite, with no
|
|
282
|
+
# retry and no re-read (correct even inside an aborted outer
|
|
283
|
+
# transaction, where re-reading is impossible). Unconditional writes
|
|
284
|
+
# re-run the WHOLE transaction with fresh reads — but only when we are
|
|
285
|
+
# NOT inside an application transaction: after a database error there,
|
|
286
|
+
# the outer transaction is in an aborted state (PostgreSQL) and
|
|
287
|
+
# re-running statements would fail differently — the error must
|
|
288
|
+
# propagate to whoever owns that transaction. A seq claim that loses
|
|
289
|
+
# every attempt surfaces as WriteConflict (unconditional writes racing
|
|
290
|
+
# each other — essentially never at operator rates).
|
|
291
|
+
def write(expected_version, &)
|
|
292
|
+
attempts = 0
|
|
293
|
+
begin
|
|
294
|
+
Entry.transaction(&)
|
|
295
|
+
rescue ::ActiveRecord::RecordNotUnique
|
|
296
|
+
if expected_version
|
|
297
|
+
raise StaleWrite,
|
|
298
|
+
"a concurrent write landed after version #{expected_version} was read — re-read (Dials.overview) and retry deliberately"
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
attempts += 1
|
|
302
|
+
retry if attempts < WRITE_ATTEMPTS && !transaction_open?
|
|
303
|
+
raise WriteConflict, "concurrent writes kept racing this override — safe to retry"
|
|
304
|
+
rescue ::ActiveRecord::TransactionRollbackError
|
|
305
|
+
attempts += 1
|
|
306
|
+
retry if attempts < WRITE_ATTEMPTS && !transaction_open?
|
|
307
|
+
raise
|
|
308
|
+
end
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
def encode(value)
|
|
312
|
+
JSON.generate(value)
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
# Values round-trip through JSON, so a :json dial's hash keys come back
|
|
316
|
+
# as strings — the same value a JSON API would hand you. Scalar types
|
|
317
|
+
# (boolean, integer, float, string) round-trip exactly.
|
|
318
|
+
def decode(raw)
|
|
319
|
+
JSON.parse(raw)
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
# Rows written around the gem (console surgery, bad imports) must not
|
|
323
|
+
# take down every dial read in the process: a row that does not decode
|
|
324
|
+
# to a legal stored value is skipped with a warning, and every other
|
|
325
|
+
# dial keeps resolving.
|
|
326
|
+
def decode_row(raw, where)
|
|
327
|
+
value = decode(raw)
|
|
328
|
+
if value.nil?
|
|
329
|
+
quarantine(where, "stored value is JSON null")
|
|
330
|
+
return SKIP
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
value
|
|
334
|
+
rescue TypeError, JSON::ParserError
|
|
335
|
+
quarantine(where, "stored value is not valid JSON")
|
|
336
|
+
SKIP
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
# For non-global streams only — the global's "{}" was matched before
|
|
340
|
+
# this runs, so an empty object here is corrupt (a hand-written row).
|
|
341
|
+
# Canonical exactness is required, not just shape: a noncanonical
|
|
342
|
+
# spelling ({"b":1,"a":2}, spaces, non-string values) would be a
|
|
343
|
+
# stream the resolver can never match against a canonicalized request.
|
|
344
|
+
def valid_scope_string?(key, scope)
|
|
345
|
+
parsed = JSON.parse(scope)
|
|
346
|
+
unless parsed.is_a?(Hash) && !parsed.empty?
|
|
347
|
+
quarantine("dials(#{key})", "scope #{scope.inspect} is not a non-empty JSON object")
|
|
348
|
+
return false
|
|
349
|
+
end
|
|
350
|
+
return true if Scope.canonical(Scope.parse(scope)) == scope
|
|
351
|
+
|
|
352
|
+
quarantine("dials(#{key})", "scope #{scope.inspect} is not canonical")
|
|
353
|
+
false
|
|
354
|
+
rescue JSON::ParserError, InvalidScope
|
|
355
|
+
quarantine("dials(#{key})", "scope #{scope.inspect} is not a valid canonical scope")
|
|
356
|
+
false
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
def quarantine(where, reason)
|
|
360
|
+
warn "[dials] skipping corrupt row in #{where}: #{reason} (fix or delete the row; it was not written through the Dials API)"
|
|
361
|
+
nil
|
|
362
|
+
end
|
|
363
|
+
end
|
|
364
|
+
end
|
|
365
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_record"
|
|
4
|
+
require "dials"
|
|
5
|
+
|
|
6
|
+
# The adapter's cache-coherence contract for writes inside application
|
|
7
|
+
# transactions depends on ActiveRecord.after_all_transactions_commit
|
|
8
|
+
# (added in 7.2). On older ActiveRecord there is no safe commit-time bust:
|
|
9
|
+
# a mid-transaction republish of pre-commit state could outlive the commit
|
|
10
|
+
# indefinitely. Failing loudly here beats shipping that silent gap.
|
|
11
|
+
if Gem::Version.new(ActiveRecord::VERSION::STRING) < Gem::Version.new("7.2")
|
|
12
|
+
raise Dials::Error,
|
|
13
|
+
"dials/active_record requires ActiveRecord >= 7.2 " \
|
|
14
|
+
"(found #{ActiveRecord::VERSION::STRING}); the :memory store works on any version"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
require_relative "active_record/models"
|
|
18
|
+
require_relative "active_record/store"
|
data/lib/dials/actor.rb
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dials
|
|
4
|
+
# Normalizes whatever the caller passes as `actor:` into the three strings
|
|
5
|
+
# the change log stores. An ActiveRecord-ish object contributes its class
|
|
6
|
+
# name and id; a plain string is stored as the label; anything else uses
|
|
7
|
+
# its class and to_s. The label can be customized app-wide via
|
|
8
|
+
# `Dials.configure { |c| c.actor_label = ->(actor) { actor.email } }`.
|
|
9
|
+
#
|
|
10
|
+
# A nil actor falls back to `config.default_actor` (for apps without user
|
|
11
|
+
# identity); when that is also nil/absent, MissingActor — there is no
|
|
12
|
+
# anonymous mutation path the app didn't explicitly declare.
|
|
13
|
+
module Actor
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
def normalize(actor)
|
|
17
|
+
actor = default_actor if actor.nil?
|
|
18
|
+
if actor.nil?
|
|
19
|
+
raise MissingActor, "every write requires an actor: (who is making this change?) — " \
|
|
20
|
+
"pass actor:, or set config.default_actor for apps without user identity"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
{
|
|
24
|
+
actor_type: actor_type(actor),
|
|
25
|
+
actor_id: actor_id(actor),
|
|
26
|
+
actor_label: Dials.config.actor_label.call(actor).to_s
|
|
27
|
+
}
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# The configured fallback; a callable is evaluated per write (so
|
|
31
|
+
# `-> { ENV.fetch("USER", "console") }` names whoever runs the console).
|
|
32
|
+
def default_actor
|
|
33
|
+
configured = Dials.config.default_actor
|
|
34
|
+
configured.respond_to?(:call) ? configured.call : configured
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def actor_type(actor)
|
|
38
|
+
actor.is_a?(String) ? nil : actor.class.name
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def actor_id(actor)
|
|
42
|
+
actor.respond_to?(:id) ? actor.id.to_s : nil
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
DEFAULT_LABEL = lambda do |actor|
|
|
46
|
+
if actor.is_a?(String)
|
|
47
|
+
actor
|
|
48
|
+
elsif actor.respond_to?(:email) && actor.email
|
|
49
|
+
actor.email
|
|
50
|
+
elsif actor.respond_to?(:name) && actor.name
|
|
51
|
+
actor.name
|
|
52
|
+
else
|
|
53
|
+
[actor.class.name, actor.respond_to?(:id) ? actor.id : nil].compact.join("#")
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|