standard_ledger 0.5.1 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +85 -1
- data/README.md +142 -353
- data/lib/generators/standard_ledger/install/templates/initializer.rb.tt +13 -29
- data/lib/standard_ledger/config.rb +14 -29
- data/lib/standard_ledger/entry.rb +6 -10
- data/lib/standard_ledger/errors.rb +6 -26
- data/lib/standard_ledger/event_emitter.rb +2 -2
- data/lib/standard_ledger/matview.rb +162 -0
- data/lib/standard_ledger/projection.rb +17 -24
- data/lib/standard_ledger/result.rb +2 -2
- data/lib/standard_ledger/rspec.rb +6 -28
- data/lib/standard_ledger/version.rb +1 -1
- data/lib/standard_ledger.rb +67 -505
- metadata +9 -48
- data/lib/standard_ledger/engine.rb +0 -28
- data/lib/standard_ledger/jobs/matview_refresh_job.rb +0 -28
- data/lib/standard_ledger/jobs/projection_job.rb +0 -94
- data/lib/standard_ledger/modes/async.rb +0 -141
- data/lib/standard_ledger/modes/inline.rb +0 -180
- data/lib/standard_ledger/modes/matview.rb +0 -146
- data/lib/standard_ledger/modes/sql.rb +0 -132
- data/lib/standard_ledger/modes/trigger.rb +0 -50
- data/lib/standard_ledger/projector.rb +0 -638
- data/lib/standard_ledger/rspec/helpers.rb +0 -15
- data/lib/tasks/standard_ledger.rake +0 -60
|
@@ -1,146 +0,0 @@
|
|
|
1
|
-
module StandardLedger
|
|
2
|
-
module Modes
|
|
3
|
-
# `:matview` mode: backs the projection with a host-owned PostgreSQL
|
|
4
|
-
# materialized view. The host creates and owns the view (via a migration —
|
|
5
|
-
# `scenic` or hand-rolled SQL); the gem owns the refresh schedule and the
|
|
6
|
-
# ad-hoc refresh API.
|
|
7
|
-
#
|
|
8
|
-
# Unlike `:inline`, this strategy does NOT install a per-entry callback —
|
|
9
|
-
# matview projections are scheduled, not entry-driven. The strategy's job
|
|
10
|
-
# is to (a) record the matview registration on the entry class so callers
|
|
11
|
-
# can enumerate `:matview` projections (used by `StandardLedger.rebuild!`
|
|
12
|
-
# and the host's scheduler wiring), and (b) provide the `#refresh!`
|
|
13
|
-
# primitive that issues the actual `REFRESH MATERIALIZED VIEW` SQL.
|
|
14
|
-
#
|
|
15
|
-
# Hosts wire their scheduler (SolidQueue Recurring Tasks, sidekiq-cron,
|
|
16
|
-
# etc.) at `StandardLedger::MatviewRefreshJob` to drive scheduled
|
|
17
|
-
# refreshes. Ad-hoc refreshes go through `StandardLedger.refresh!` for
|
|
18
|
-
# read-your-write semantics after a critical write.
|
|
19
|
-
#
|
|
20
|
-
# See `standard_ledger-design.md` §5.3.5 for the full contract.
|
|
21
|
-
class Matview
|
|
22
|
-
# Mark the entry class as having at least one `:matview` projection
|
|
23
|
-
# registered. The actual matview definition lives on the entry class's
|
|
24
|
-
# `standard_ledger_projections` array; this method exists only to
|
|
25
|
-
# mirror the `Modes::Inline.install!` shape and to mark the class so
|
|
26
|
-
# repeated registrations are recognised as idempotent installs.
|
|
27
|
-
#
|
|
28
|
-
# Idempotent — multiple `:matview` projections on the same entry class
|
|
29
|
-
# do not cause double registration. Unlike `:inline`, no `after_create`
|
|
30
|
-
# callback is installed.
|
|
31
|
-
#
|
|
32
|
-
# @param entry_class [Class] the host entry class.
|
|
33
|
-
# @return [void]
|
|
34
|
-
def self.install!(entry_class)
|
|
35
|
-
return if entry_class.instance_variable_get(:@_standard_ledger_matview_installed)
|
|
36
|
-
|
|
37
|
-
entry_class.instance_variable_set(:@_standard_ledger_matview_installed, true)
|
|
38
|
-
end
|
|
39
|
-
|
|
40
|
-
# Issue `REFRESH MATERIALIZED VIEW [CONCURRENTLY] <view_name>` against
|
|
41
|
-
# the active connection and emit the standard `<prefix>.projection.refreshed`
|
|
42
|
-
# notification on success (or `<prefix>.projection.failed` on raise,
|
|
43
|
-
# before re-raising). The view name is validated against a SQL-identifier
|
|
44
|
-
# regex at the boundary as a defence-in-depth check — the value normally
|
|
45
|
-
# comes from a `view:` DSL keyword in source code, but a careless host
|
|
46
|
-
# could pass through a config value or other untrusted string. Anything
|
|
47
|
-
# that isn't a bare or schema-qualified identifier raises.
|
|
48
|
-
#
|
|
49
|
-
# The default `:concurrent` strategy (and `concurrently: true` per-call)
|
|
50
|
-
# requires a unique index on the matview — Postgres rejects
|
|
51
|
-
# `REFRESH MATERIALIZED VIEW CONCURRENTLY` otherwise. Hosts who haven't
|
|
52
|
-
# added one should pass `concurrently: false` or set
|
|
53
|
-
# `Config#matview_refresh_strategy = :blocking`.
|
|
54
|
-
#
|
|
55
|
-
# @param view_name [String, Symbol] the materialized view to refresh.
|
|
56
|
-
# Must match `/\A[a-zA-Z_][a-zA-Z0-9_.]*\z/` so a single dot is
|
|
57
|
-
# permitted for `schema.view` qualified names.
|
|
58
|
-
# @param concurrently [Boolean] when true, adds `CONCURRENTLY` (which
|
|
59
|
-
# requires a unique index on the view).
|
|
60
|
-
# @return [void]
|
|
61
|
-
# @raise [ArgumentError] when `view_name` is not a valid SQL identifier.
|
|
62
|
-
# @raise [StandardError] anything the connection raises while running
|
|
63
|
-
# the SQL — re-raised after the `failed` event fires.
|
|
64
|
-
def self.refresh!(view_name, concurrently:)
|
|
65
|
-
validate_view_name!(view_name)
|
|
66
|
-
check_transaction_state!(view_name, concurrently: concurrently)
|
|
67
|
-
|
|
68
|
-
prefix = StandardLedger.config.notification_namespace
|
|
69
|
-
sql = build_refresh_sql(view_name, concurrently: concurrently)
|
|
70
|
-
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
71
|
-
|
|
72
|
-
ActiveRecord::Base.connection.execute(sql)
|
|
73
|
-
|
|
74
|
-
duration_ms = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000.0
|
|
75
|
-
StandardLedger::EventEmitter.emit(
|
|
76
|
-
"#{prefix}.projection.refreshed",
|
|
77
|
-
view: view_name.to_s, concurrently: concurrently, duration_ms: duration_ms
|
|
78
|
-
)
|
|
79
|
-
rescue StandardError => e
|
|
80
|
-
# ArgumentError from the validator and RefreshInsideTransaction from
|
|
81
|
-
# the boundary check should propagate without firing the failed
|
|
82
|
-
# notification — the SQL was never issued.
|
|
83
|
-
raise if e.is_a?(ArgumentError) || e.is_a?(StandardLedger::RefreshInsideTransaction)
|
|
84
|
-
|
|
85
|
-
StandardLedger::EventEmitter.emit(
|
|
86
|
-
"#{prefix}.projection.failed",
|
|
87
|
-
view: view_name.to_s, concurrently: concurrently, mode: :matview, error: e
|
|
88
|
-
)
|
|
89
|
-
raise
|
|
90
|
-
end
|
|
91
|
-
|
|
92
|
-
# Reject anything that isn't a bare or schema-qualified SQL identifier
|
|
93
|
-
# — the matching regex allows a leading letter/underscore followed by
|
|
94
|
-
# alphanumerics, underscores, or a single dot for `schema.view`. Names
|
|
95
|
-
# containing semicolons, quotes, comment markers (`--`), whitespace, or
|
|
96
|
-
# other punctuation are rejected at the `refresh!` boundary so SQL
|
|
97
|
-
# injection isn't possible even when a host carelessly pipes a config
|
|
98
|
-
# value into the call.
|
|
99
|
-
def self.validate_view_name!(view_name)
|
|
100
|
-
# Bare identifier OR exactly one schema-qualified `schema.view` part.
|
|
101
|
-
# The previous shape `\A[a-zA-Z_][a-zA-Z0-9_.]*\z` allowed trailing
|
|
102
|
-
# dots (`reporting.`) and unlimited qualification (`a.b.c.d`); both
|
|
103
|
-
# would round-trip to a Postgres syntax error at `connection.execute`
|
|
104
|
-
# time rather than the gem boundary.
|
|
105
|
-
return if view_name.to_s.match?(/\A[a-zA-Z_][a-zA-Z0-9_]*(\.[a-zA-Z_][a-zA-Z0-9_]*)?\z/)
|
|
106
|
-
|
|
107
|
-
raise ArgumentError,
|
|
108
|
-
"view_name must be a valid SQL identifier; got #{view_name.inspect}"
|
|
109
|
-
end
|
|
110
|
-
private_class_method :validate_view_name!
|
|
111
|
-
|
|
112
|
-
# Reject `refresh!` calls issued from inside an open transaction when
|
|
113
|
-
# `concurrently: true`. Postgres rejects
|
|
114
|
-
# `REFRESH MATERIALIZED VIEW CONCURRENTLY` inside a transaction block
|
|
115
|
-
# (and would otherwise raise `PG::ActiveSqlTransaction` mid-call); we
|
|
116
|
-
# catch it at the gem boundary so the failure is attributable.
|
|
117
|
-
#
|
|
118
|
-
# The non-concurrent (`concurrently: false`) form *is* permitted inside
|
|
119
|
-
# a transaction by Postgres, so we only guard the concurrent path.
|
|
120
|
-
#
|
|
121
|
-
# Use `connection.add_transaction_record { … }` to defer to after-commit
|
|
122
|
-
# if you need read-your-write semantics from inside a transactional
|
|
123
|
-
# operation; otherwise, move the refresh outside the transaction.
|
|
124
|
-
def self.check_transaction_state!(view_name, concurrently:)
|
|
125
|
-
return unless concurrently
|
|
126
|
-
return unless ActiveRecord::Base.connection.transaction_open?
|
|
127
|
-
|
|
128
|
-
raise StandardLedger::RefreshInsideTransaction,
|
|
129
|
-
"StandardLedger.refresh!(#{view_name.inspect}) cannot run inside a transaction with " \
|
|
130
|
-
"concurrently: true — Postgres rejects `REFRESH MATERIALIZED VIEW CONCURRENTLY` inside " \
|
|
131
|
-
"transaction blocks. Move the call outside the transaction, or defer it via " \
|
|
132
|
-
"`connection.add_transaction_record { ... }` for after-commit execution."
|
|
133
|
-
end
|
|
134
|
-
private_class_method :check_transaction_state!
|
|
135
|
-
|
|
136
|
-
def self.build_refresh_sql(view_name, concurrently:)
|
|
137
|
-
if concurrently
|
|
138
|
-
"REFRESH MATERIALIZED VIEW CONCURRENTLY #{view_name}"
|
|
139
|
-
else
|
|
140
|
-
"REFRESH MATERIALIZED VIEW #{view_name}"
|
|
141
|
-
end
|
|
142
|
-
end
|
|
143
|
-
private_class_method :build_refresh_sql
|
|
144
|
-
end
|
|
145
|
-
end
|
|
146
|
-
end
|
|
@@ -1,132 +0,0 @@
|
|
|
1
|
-
module StandardLedger
|
|
2
|
-
module Modes
|
|
3
|
-
# `:sql` mode: applies the projection by running a single recompute
|
|
4
|
-
# `UPDATE` against the target table, inside the entry's `after_create`
|
|
5
|
-
# callback. The recompute SQL is supplied via the block-DSL's
|
|
6
|
-
# `recompute "..."` clause; the `:target_id` placeholder is bound to
|
|
7
|
-
# the foreign-key value the entry holds for the target association.
|
|
8
|
-
#
|
|
9
|
-
# Lower-overhead than `:inline` for projections expressible as
|
|
10
|
-
# `UPDATE target SET col = (SELECT aggregate(...) FROM entries WHERE
|
|
11
|
-
# ...)` — there's no Ruby-side handler invocation, no per-counter
|
|
12
|
-
# in-memory mutation, and no AR object load. Naturally rebuildable:
|
|
13
|
-
# `StandardLedger.rebuild!` runs the same statement against each
|
|
14
|
-
# target the log references, so the `after_create` and `rebuild!`
|
|
15
|
-
# paths share the same recompute SQL.
|
|
16
|
-
#
|
|
17
|
-
# Like `:inline`, this fires from `after_create` (in the entry's
|
|
18
|
-
# transaction), so failures roll the entry back alongside the
|
|
19
|
-
# projection. The notification payload's `:target` field is `nil` for
|
|
20
|
-
# `:sql` mode — loading the target object would defeat the point of
|
|
21
|
-
# the mode (it's meant to avoid Ruby-side reads). Subscribers get
|
|
22
|
-
# `entry`, the projection definition, and the timing.
|
|
23
|
-
class Sql
|
|
24
|
-
# Install the `after_create` callback on `entry_class` exactly once.
|
|
25
|
-
# Subsequent calls (e.g. when a second `:sql` projection is added
|
|
26
|
-
# later in the class body) are no-ops — the same callback handles
|
|
27
|
-
# all `:sql` projections registered on the class.
|
|
28
|
-
#
|
|
29
|
-
# @param entry_class [Class] the host entry class.
|
|
30
|
-
# @return [void]
|
|
31
|
-
# @raise [ArgumentError] when `entry_class` is not ActiveRecord-backed
|
|
32
|
-
# (no `after_create` hook available). `:sql` mode requires an
|
|
33
|
-
# AR-backed entry class — the recompute SQL runs through
|
|
34
|
-
# `entry.class.connection.exec_update`.
|
|
35
|
-
def self.install!(entry_class)
|
|
36
|
-
return if entry_class.instance_variable_get(:@_standard_ledger_sql_installed)
|
|
37
|
-
|
|
38
|
-
unless entry_class.respond_to?(:after_create)
|
|
39
|
-
raise ArgumentError,
|
|
40
|
-
"#{entry_class.name || entry_class.inspect} cannot use mode: :sql " \
|
|
41
|
-
"because it does not respond to `after_create`. `:sql` mode requires " \
|
|
42
|
-
"an ActiveRecord-backed entry class."
|
|
43
|
-
end
|
|
44
|
-
|
|
45
|
-
entry_class.after_create { StandardLedger::Modes::Sql.new.call(self) }
|
|
46
|
-
entry_class.instance_variable_set(:@_standard_ledger_sql_installed, true)
|
|
47
|
-
end
|
|
48
|
-
|
|
49
|
-
# Apply every `:sql` projection registered on the entry's class.
|
|
50
|
-
# Called from the `after_create` callback installed by `.install!`.
|
|
51
|
-
#
|
|
52
|
-
# For each definition: resolve the target's foreign key from the
|
|
53
|
-
# entry, evaluate the optional `if:` guard, and run the recompute
|
|
54
|
-
# SQL with `:target_id` bound to the FK value.
|
|
55
|
-
#
|
|
56
|
-
# A nil FK (target unset for this entry) is silently skipped — the
|
|
57
|
-
# entry simply doesn't project onto a target this round. A guard
|
|
58
|
-
# returning false is also silently skipped.
|
|
59
|
-
#
|
|
60
|
-
# Any exception raised by the SQL escapes — the entry's transaction
|
|
61
|
-
# rolls back with the projection. The
|
|
62
|
-
# `<prefix>.projection.failed` notification fires before the
|
|
63
|
-
# re-raise so subscribers see the failure.
|
|
64
|
-
#
|
|
65
|
-
# @param entry [ActiveRecord::Base] the just-created entry.
|
|
66
|
-
# @return [void]
|
|
67
|
-
def call(entry)
|
|
68
|
-
definitions = sql_definitions_for(entry.class)
|
|
69
|
-
return if definitions.empty?
|
|
70
|
-
|
|
71
|
-
definitions.each do |definition|
|
|
72
|
-
next if definition.guard && !entry.instance_exec(&definition.guard)
|
|
73
|
-
|
|
74
|
-
target_id = resolve_target_id(entry, definition)
|
|
75
|
-
next if target_id.nil?
|
|
76
|
-
|
|
77
|
-
run_recompute_sql(entry, definition, target_id)
|
|
78
|
-
end
|
|
79
|
-
end
|
|
80
|
-
|
|
81
|
-
private
|
|
82
|
-
|
|
83
|
-
def sql_definitions_for(entry_class)
|
|
84
|
-
return [] unless entry_class.respond_to?(:standard_ledger_projections_for)
|
|
85
|
-
|
|
86
|
-
entry_class.standard_ledger_projections_for(:sql)
|
|
87
|
-
end
|
|
88
|
-
|
|
89
|
-
# Pull the FK value off the entry without loading the target. The
|
|
90
|
-
# reflection's `foreign_key` is the column name (e.g.
|
|
91
|
-
# `"voucher_scheme_id"`); reading it via `public_send` avoids
|
|
92
|
-
# triggering an AR association load.
|
|
93
|
-
def resolve_target_id(entry, definition)
|
|
94
|
-
reflection = entry.class.reflect_on_association(definition.target_association)
|
|
95
|
-
return nil if reflection.nil?
|
|
96
|
-
|
|
97
|
-
entry.public_send(reflection.foreign_key)
|
|
98
|
-
end
|
|
99
|
-
|
|
100
|
-
# Run the recompute statement, instrumenting success and failure
|
|
101
|
-
# symmetrically with the inline mode's two-event split. The target
|
|
102
|
-
# is intentionally `nil` in the payload — loading it would defeat
|
|
103
|
-
# the mode's "no Ruby-side reads" contract; subscribers that want
|
|
104
|
-
# the target should reload it themselves from the
|
|
105
|
-
# `definition.target_association` + the entry.
|
|
106
|
-
def run_recompute_sql(entry, definition, target_id)
|
|
107
|
-
prefix = StandardLedger.config.notification_namespace
|
|
108
|
-
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
109
|
-
|
|
110
|
-
begin
|
|
111
|
-
sql = ActiveRecord::Base.sanitize_sql_array([ definition.recompute_sql, { target_id: target_id } ])
|
|
112
|
-
entry.class.connection.exec_update(sql)
|
|
113
|
-
rescue StandardError => e
|
|
114
|
-
duration_ms = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000.0
|
|
115
|
-
StandardLedger::EventEmitter.emit(
|
|
116
|
-
"#{prefix}.projection.failed",
|
|
117
|
-
entry: entry, target: nil, projection: definition.target_association,
|
|
118
|
-
mode: :sql, error: e, duration_ms: duration_ms
|
|
119
|
-
)
|
|
120
|
-
raise
|
|
121
|
-
end
|
|
122
|
-
|
|
123
|
-
duration_ms = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000.0
|
|
124
|
-
StandardLedger::EventEmitter.emit(
|
|
125
|
-
"#{prefix}.projection.applied",
|
|
126
|
-
entry: entry, target: nil, projection: definition.target_association,
|
|
127
|
-
mode: :sql, duration_ms: duration_ms
|
|
128
|
-
)
|
|
129
|
-
end
|
|
130
|
-
end
|
|
131
|
-
end
|
|
132
|
-
end
|
|
@@ -1,50 +0,0 @@
|
|
|
1
|
-
module StandardLedger
|
|
2
|
-
module Modes
|
|
3
|
-
# `:trigger` mode: the host owns a database trigger (created in a Rails
|
|
4
|
-
# migration) that updates the projection target's columns on every
|
|
5
|
-
# entry INSERT. The gem records the trigger's name and the equivalent
|
|
6
|
-
# rebuild SQL, but does **not** create or manage the trigger itself —
|
|
7
|
-
# giving a Ruby DSL the power to install/replace triggers is a deploy
|
|
8
|
-
# footgun (silent re-creation on `db:schema:load` against a non-empty
|
|
9
|
-
# DB), and triggers are versioned by `db/schema.rb` like any other DDL.
|
|
10
|
-
#
|
|
11
|
-
# Two consumers read the recorded metadata:
|
|
12
|
-
#
|
|
13
|
-
# - `StandardLedger.rebuild!` runs the rebuild SQL when invoked,
|
|
14
|
-
# binding `:target_id` to each target the log references. The same
|
|
15
|
-
# recompute path as `:sql` mode — the only difference is that the
|
|
16
|
-
# after-create application is performed by the database trigger
|
|
17
|
-
# rather than a Ruby callback.
|
|
18
|
-
# - The `standard_ledger:doctor` rake task verifies that the named
|
|
19
|
-
# trigger exists in the connected schema. Migration drift (a missing
|
|
20
|
-
# or renamed trigger) is caught at deploy time, not at runtime.
|
|
21
|
-
#
|
|
22
|
-
# Unlike `:inline` and `:sql`, this strategy does NOT install an
|
|
23
|
-
# `after_create` callback — the trigger fires from the database. The
|
|
24
|
-
# `install!` no-op only marks the entry class as having at least one
|
|
25
|
-
# `:trigger` projection registered (mirrors the `Modes::Matview` shape).
|
|
26
|
-
#
|
|
27
|
-
# See `standard_ledger-design.md` §5.3.4 for the full contract.
|
|
28
|
-
class Trigger
|
|
29
|
-
# Mark the entry class as having at least one `:trigger` projection
|
|
30
|
-
# registered. The actual trigger metadata lives on the entry class's
|
|
31
|
-
# `standard_ledger_projections` array; this method exists only to
|
|
32
|
-
# mirror the `Modes::*.install!` shape so `Projector#install_mode_callbacks_for`
|
|
33
|
-
# can dispatch uniformly across modes.
|
|
34
|
-
#
|
|
35
|
-
# No `after_create` callback is installed — the trigger runs in the
|
|
36
|
-
# database, not Ruby. Idempotent across multiple `:trigger`
|
|
37
|
-
# projections on the same entry class.
|
|
38
|
-
#
|
|
39
|
-
# @param entry_class [Class] the host entry class (unused — kept for
|
|
40
|
-
# parity with the other strategy classes).
|
|
41
|
-
# @return [void]
|
|
42
|
-
def self.install!(_entry_class)
|
|
43
|
-
# Intentionally empty: trigger projections fire from the DB, so
|
|
44
|
-
# there's no Ruby-side callback to wire. The DSL has already
|
|
45
|
-
# captured `trigger_name` and `recompute_sql` on the Definition;
|
|
46
|
-
# `rebuild!` and the `doctor` rake task consume those directly.
|
|
47
|
-
end
|
|
48
|
-
end
|
|
49
|
-
end
|
|
50
|
-
end
|