standard_ledger 0.2.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 +260 -0
- data/MIT-LICENSE +21 -0
- data/README.md +287 -0
- data/Rakefile +6 -0
- data/lib/generators/standard_ledger/install/install_generator.rb +34 -0
- data/lib/generators/standard_ledger/install/templates/initializer.rb.tt +66 -0
- data/lib/standard_ledger/config.rb +62 -0
- data/lib/standard_ledger/engine.rb +19 -0
- data/lib/standard_ledger/entry.rb +253 -0
- data/lib/standard_ledger/errors.rb +33 -0
- data/lib/standard_ledger/event_emitter.rb +50 -0
- data/lib/standard_ledger/jobs/matview_refresh_job.rb +28 -0
- data/lib/standard_ledger/modes/inline.rb +180 -0
- data/lib/standard_ledger/modes/matview.rb +115 -0
- data/lib/standard_ledger/modes/sql.rb +132 -0
- data/lib/standard_ledger/projection.rb +41 -0
- data/lib/standard_ledger/projector.rb +361 -0
- data/lib/standard_ledger/result.rb +51 -0
- data/lib/standard_ledger/rspec/helpers.rb +15 -0
- data/lib/standard_ledger/rspec/matchers.rb +148 -0
- data/lib/standard_ledger/rspec.rb +44 -0
- data/lib/standard_ledger/version.rb +3 -0
- data/lib/standard_ledger.rb +620 -0
- metadata +184 -0
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
module StandardLedger
|
|
2
|
+
module Modes
|
|
3
|
+
# `:inline` mode: applies the projection inside the entry's `after_create`
|
|
4
|
+
# callback, which fires while the host's outer transaction is still open.
|
|
5
|
+
# If the host's transaction rolls back, the projection rolls back too.
|
|
6
|
+
#
|
|
7
|
+
# This is the default for delta-based counter updates. For complex
|
|
8
|
+
# projectors (jsonb shape, multi-row aggregates), use `:async` instead.
|
|
9
|
+
#
|
|
10
|
+
# The strategy is invoked from a single `after_create` callback installed
|
|
11
|
+
# once per entry class (see `.install!`). The callback walks every
|
|
12
|
+
# `:inline`-mode projection registered on the class and runs each via
|
|
13
|
+
# `entry.apply_projection!(definition)`.
|
|
14
|
+
#
|
|
15
|
+
# ## Multi-counter coalescing
|
|
16
|
+
#
|
|
17
|
+
# A single host might register four `on(:grant)`/`on(:redeem)`/... handlers
|
|
18
|
+
# against the same target, each calling `target.increment(:some_count)`.
|
|
19
|
+
# ActiveRecord's `#increment` (vs. `#increment!`) only mutates the
|
|
20
|
+
# in-memory attribute — no SQL is issued — so the strategy persists with
|
|
21
|
+
# a single `target.save!` per (entry, target) pair after all handlers for
|
|
22
|
+
# that target have run. This collapses N handlers into one UPDATE.
|
|
23
|
+
#
|
|
24
|
+
# Definitions targeting different associations get their own
|
|
25
|
+
# apply-then-save cycle, executed in the order the projections were
|
|
26
|
+
# declared.
|
|
27
|
+
#
|
|
28
|
+
# ## Lock semantics
|
|
29
|
+
#
|
|
30
|
+
# When any projection in a per-target group declares `lock: :pessimistic`,
|
|
31
|
+
# the strategy wraps the **entire** apply-then-save cycle for that target
|
|
32
|
+
# in `target.with_lock { ... }`. The lock spans both handler invocation
|
|
33
|
+
# and the coalesced `save!`, so concurrent posts to the same target
|
|
34
|
+
# serialize end-to-end — closing the lost-update window that an
|
|
35
|
+
# inner-only lock would leave open between the lock release and the save.
|
|
36
|
+
# See `standard_ledger-design.md` §5.3.1.
|
|
37
|
+
class Inline
|
|
38
|
+
# Install the `after_create` callback on `entry_class` exactly once.
|
|
39
|
+
# Subsequent calls (e.g. when a second `:inline` projection is added
|
|
40
|
+
# later in the class body) are no-ops — the same callback handles all
|
|
41
|
+
# `:inline` projections registered on the class.
|
|
42
|
+
#
|
|
43
|
+
# @param entry_class [Class] the host entry class.
|
|
44
|
+
# @return [void]
|
|
45
|
+
# @raise [ArgumentError] when `entry_class` is not ActiveRecord-backed
|
|
46
|
+
# (no `after_create` hook available). `:inline` mode requires AR
|
|
47
|
+
# transactional callbacks; non-AR entry classes must use a different
|
|
48
|
+
# mode (or refrain from declaring `:inline` projections).
|
|
49
|
+
def self.install!(entry_class)
|
|
50
|
+
return if entry_class.instance_variable_get(:@_standard_ledger_inline_installed)
|
|
51
|
+
|
|
52
|
+
unless entry_class.respond_to?(:after_create)
|
|
53
|
+
raise ArgumentError,
|
|
54
|
+
"#{entry_class.name || entry_class.inspect} cannot use mode: :inline " \
|
|
55
|
+
"because it does not respond to `after_create`. `:inline` mode requires " \
|
|
56
|
+
"an ActiveRecord-backed entry class — use `:async` (or another mode) for " \
|
|
57
|
+
"non-AR includers."
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
entry_class.after_create { StandardLedger::Modes::Inline.new.call(self) }
|
|
61
|
+
entry_class.instance_variable_set(:@_standard_ledger_inline_installed, true)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Apply every `:inline` projection registered on the entry's class.
|
|
65
|
+
# Called from the `after_create` callback installed by `.install!`.
|
|
66
|
+
#
|
|
67
|
+
# Projections targeting the same association coalesce: all handlers
|
|
68
|
+
# for that target run, then the target is saved once. Different
|
|
69
|
+
# targets get their own apply+save cycle, in declared order. When any
|
|
70
|
+
# definition in a per-target group sets `lock: :pessimistic`, the
|
|
71
|
+
# cycle (apply + save) is wrapped in `target.with_lock`.
|
|
72
|
+
#
|
|
73
|
+
# Records the names of projections that actually ran (after `if:`
|
|
74
|
+
# guards filter) on the entry instance under
|
|
75
|
+
# `@_standard_ledger_applied_projections`, so `StandardLedger.post`
|
|
76
|
+
# can surface an accurate `result.projections[:inline]`.
|
|
77
|
+
#
|
|
78
|
+
# Any projector exception escapes — the entry's transaction rolls
|
|
79
|
+
# back along with every counter mutation that ran before the failure.
|
|
80
|
+
# The `standard_ledger.projection.failed` notification fires before
|
|
81
|
+
# the re-raise so subscribers see the failed projection in payload.
|
|
82
|
+
#
|
|
83
|
+
# @param entry [ActiveRecord::Base] the just-created entry.
|
|
84
|
+
# @return [void]
|
|
85
|
+
def call(entry)
|
|
86
|
+
definitions = inline_definitions_for(entry.class)
|
|
87
|
+
return if definitions.empty?
|
|
88
|
+
|
|
89
|
+
applied = []
|
|
90
|
+
entry.instance_variable_set(:@_standard_ledger_applied_projections, applied)
|
|
91
|
+
|
|
92
|
+
# group_by preserves insertion order on Ruby >= 1.9, so declared
|
|
93
|
+
# projection order is preserved across targets. Within a target
|
|
94
|
+
# group, handlers run in declared order as well.
|
|
95
|
+
definitions.group_by(&:target_association).each_value do |group|
|
|
96
|
+
target = entry.public_send(group.first.target_association)
|
|
97
|
+
locked = group.any? { |definition| definition.lock == :pessimistic }
|
|
98
|
+
|
|
99
|
+
run_group = lambda do
|
|
100
|
+
group.each do |definition|
|
|
101
|
+
ran = instrument_projection(entry, target, definition) do
|
|
102
|
+
entry.apply_projection!(definition)
|
|
103
|
+
end
|
|
104
|
+
applied << definition.target_association if ran && !applied.include?(definition.target_association)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Coalesce: if any handler called `target.increment(col)` (which
|
|
108
|
+
# mutates in-memory only), persist the accumulated changes with a
|
|
109
|
+
# single UPDATE. Skipped when the target is nil (apply_projection!
|
|
110
|
+
# short-circuits) or when no handler dirtied the record. The
|
|
111
|
+
# `target` here always responds to AR's `changed?`/`save!` because
|
|
112
|
+
# it's resolved from a `belongs_to` reflection.
|
|
113
|
+
target.save! if target && target.changed?
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
if locked && target.respond_to?(:with_lock)
|
|
117
|
+
target.with_lock(&run_group)
|
|
118
|
+
else
|
|
119
|
+
run_group.call
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
private
|
|
125
|
+
|
|
126
|
+
def inline_definitions_for(entry_class)
|
|
127
|
+
return [] unless entry_class.respond_to?(:standard_ledger_projections_for)
|
|
128
|
+
|
|
129
|
+
entry_class.standard_ledger_projections_for(:inline)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Wrap each handler invocation so subscribers see exactly one
|
|
133
|
+
# `applied` event per projection on success or one `failed` event on
|
|
134
|
+
# raise. Two distinct events (rather than letting `instrument`
|
|
135
|
+
# package success-or-failure into a single event) because the design
|
|
136
|
+
# splits the two outcomes — see §5.7.
|
|
137
|
+
#
|
|
138
|
+
# When the wrapped `apply_projection!` short-circuits (returns false
|
|
139
|
+
# because of a guard, nil target, or no-handler permissive miss), no
|
|
140
|
+
# `applied` event fires — there's no work to report. The exception
|
|
141
|
+
# path always fires `failed` so observability of failures isn't
|
|
142
|
+
# contingent on guard logic.
|
|
143
|
+
#
|
|
144
|
+
# The exception is re-raised so the entry's transaction rolls back —
|
|
145
|
+
# the notification's payload carries the error for observers
|
|
146
|
+
# regardless of whether the listener swallows or re-raises.
|
|
147
|
+
#
|
|
148
|
+
# @return [Boolean] the truthy/falsy result of the wrapped block —
|
|
149
|
+
# used by the caller to decide whether to record this projection
|
|
150
|
+
# in the entry's `applied` list.
|
|
151
|
+
def instrument_projection(entry, target, definition)
|
|
152
|
+
prefix = StandardLedger.config.notification_namespace
|
|
153
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
154
|
+
|
|
155
|
+
ran =
|
|
156
|
+
begin
|
|
157
|
+
yield
|
|
158
|
+
rescue StandardError => e
|
|
159
|
+
duration_ms = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000.0
|
|
160
|
+
StandardLedger::EventEmitter.emit(
|
|
161
|
+
"#{prefix}.projection.failed",
|
|
162
|
+
entry: entry, target: target, projection: definition.target_association,
|
|
163
|
+
mode: :inline, error: e, duration_ms: duration_ms
|
|
164
|
+
)
|
|
165
|
+
raise
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
return false unless ran
|
|
169
|
+
|
|
170
|
+
duration_ms = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000.0
|
|
171
|
+
StandardLedger::EventEmitter.emit(
|
|
172
|
+
"#{prefix}.projection.applied",
|
|
173
|
+
entry: entry, target: target, projection: definition.target_association,
|
|
174
|
+
mode: :inline, duration_ms: duration_ms
|
|
175
|
+
)
|
|
176
|
+
true
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
@@ -0,0 +1,115 @@
|
|
|
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
|
+
|
|
67
|
+
prefix = StandardLedger.config.notification_namespace
|
|
68
|
+
sql = build_refresh_sql(view_name, concurrently: concurrently)
|
|
69
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
70
|
+
|
|
71
|
+
ActiveRecord::Base.connection.execute(sql)
|
|
72
|
+
|
|
73
|
+
duration_ms = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000.0
|
|
74
|
+
StandardLedger::EventEmitter.emit(
|
|
75
|
+
"#{prefix}.projection.refreshed",
|
|
76
|
+
view: view_name.to_s, concurrently: concurrently, duration_ms: duration_ms
|
|
77
|
+
)
|
|
78
|
+
rescue StandardError => e
|
|
79
|
+
# ArgumentError from the validator should propagate without firing
|
|
80
|
+
# the failed notification — the SQL was never issued.
|
|
81
|
+
raise if e.is_a?(ArgumentError)
|
|
82
|
+
|
|
83
|
+
StandardLedger::EventEmitter.emit(
|
|
84
|
+
"#{prefix}.projection.failed",
|
|
85
|
+
view: view_name.to_s, concurrently: concurrently, mode: :matview, error: e
|
|
86
|
+
)
|
|
87
|
+
raise
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Reject anything that isn't a bare or schema-qualified SQL identifier
|
|
91
|
+
# — the matching regex allows a leading letter/underscore followed by
|
|
92
|
+
# alphanumerics, underscores, or a single dot for `schema.view`. Names
|
|
93
|
+
# containing semicolons, quotes, comment markers (`--`), whitespace, or
|
|
94
|
+
# other punctuation are rejected at the `refresh!` boundary so SQL
|
|
95
|
+
# injection isn't possible even when a host carelessly pipes a config
|
|
96
|
+
# value into the call.
|
|
97
|
+
def self.validate_view_name!(view_name)
|
|
98
|
+
return if view_name.to_s.match?(/\A[a-zA-Z_][a-zA-Z0-9_.]*\z/)
|
|
99
|
+
|
|
100
|
+
raise ArgumentError,
|
|
101
|
+
"view_name must be a valid SQL identifier; got #{view_name.inspect}"
|
|
102
|
+
end
|
|
103
|
+
private_class_method :validate_view_name!
|
|
104
|
+
|
|
105
|
+
def self.build_refresh_sql(view_name, concurrently:)
|
|
106
|
+
if concurrently
|
|
107
|
+
"REFRESH MATERIALIZED VIEW CONCURRENTLY #{view_name}"
|
|
108
|
+
else
|
|
109
|
+
"REFRESH MATERIALIZED VIEW #{view_name}"
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
private_class_method :build_refresh_sql
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
@@ -0,0 +1,132 @@
|
|
|
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
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
module StandardLedger
|
|
2
|
+
# Base class for projector classes registered via
|
|
3
|
+
# `projects_onto :target, via: ProjectorClass`. Subclasses implement
|
|
4
|
+
# `apply` (and optionally `rebuild`) to mutate the target based on a new
|
|
5
|
+
# entry.
|
|
6
|
+
#
|
|
7
|
+
# For projections expressible as block DSL (counter increments, simple
|
|
8
|
+
# delta updates), prefer the block form on `Projector#projects_onto`
|
|
9
|
+
# instead — extracting a class is for non-trivial projectors.
|
|
10
|
+
#
|
|
11
|
+
# @example
|
|
12
|
+
# class Orders::FulfillableProjector < StandardLedger::Projection
|
|
13
|
+
# # Called inside the async job, with target locked.
|
|
14
|
+
# def apply(order, _entry)
|
|
15
|
+
# order.update!(
|
|
16
|
+
# fulfillable_balance: order.fulfillment_records.group(:key).sum(:amount),
|
|
17
|
+
# fulfillable_status: order.fulfillment_records.group(:key).sum(:amount).values.all?(&:zero?) ? :fulfilled : :pending
|
|
18
|
+
# )
|
|
19
|
+
# end
|
|
20
|
+
#
|
|
21
|
+
# # Called by Projection.rebuild! to recompute from the full log.
|
|
22
|
+
# def rebuild(order)
|
|
23
|
+
# apply(order, nil)
|
|
24
|
+
# end
|
|
25
|
+
# end
|
|
26
|
+
class Projection
|
|
27
|
+
# Apply a single entry's effect to the target. Called inside the
|
|
28
|
+
# transactional or async boundary of the chosen mode.
|
|
29
|
+
def apply(_target, _entry)
|
|
30
|
+
raise NotImplementedError, "#{self.class}#apply must be implemented"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Recompute the target's projection from the full entry log. Called by
|
|
34
|
+
# `StandardLedger.rebuild!`. Projectors that cannot be rebuilt (e.g.
|
|
35
|
+
# delta-only `increment_counter` flavored ones) should raise
|
|
36
|
+
# `StandardLedger::NotRebuildable`.
|
|
37
|
+
def rebuild(_target)
|
|
38
|
+
raise NotRebuildable, "#{self.class}#rebuild not implemented; this projector cannot be rebuilt from the entry log"
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|