standard_ledger 0.5.1 → 0.7.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 +130 -1
- data/README.md +152 -356
- 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 +163 -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 +92 -506
- 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
data/lib/standard_ledger.rb
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
require "active_support"
|
|
2
2
|
require "active_support/notifications"
|
|
3
3
|
require "active_support/core_ext/string/inflections"
|
|
4
|
-
require "concurrent"
|
|
5
4
|
|
|
6
5
|
require "standard_ledger/version"
|
|
7
6
|
require "standard_ledger/errors"
|
|
@@ -10,30 +9,24 @@ require "standard_ledger/result"
|
|
|
10
9
|
require "standard_ledger/config"
|
|
11
10
|
require "standard_ledger/entry"
|
|
12
11
|
require "standard_ledger/projection"
|
|
13
|
-
require "standard_ledger/
|
|
14
|
-
require "standard_ledger/modes/inline"
|
|
15
|
-
require "standard_ledger/modes/sql"
|
|
16
|
-
require "standard_ledger/modes/matview"
|
|
17
|
-
require "standard_ledger/modes/trigger"
|
|
18
|
-
require "standard_ledger/modes/async"
|
|
19
|
-
require "standard_ledger/jobs/matview_refresh_job"
|
|
20
|
-
require "standard_ledger/jobs/projection_job"
|
|
21
|
-
require "standard_ledger/engine" if defined?(::Rails::Engine)
|
|
12
|
+
require "standard_ledger/matview"
|
|
22
13
|
|
|
23
|
-
# StandardLedger captures the
|
|
24
|
-
#
|
|
25
|
-
#
|
|
26
|
-
#
|
|
14
|
+
# StandardLedger captures the "immutable, append-only journal entry" pattern
|
|
15
|
+
# for host ActiveRecord models, plus two small helpers that sit next to it:
|
|
16
|
+
# a `post` sugar for writing entries and a `refresh!` wrapper for host-owned
|
|
17
|
+
# materialized views. See `standard_ledger-design.md` for the design notes.
|
|
27
18
|
#
|
|
28
19
|
# Public surface:
|
|
29
20
|
#
|
|
30
21
|
# StandardLedger.configure { |c| ... } # configure once at boot
|
|
31
22
|
# StandardLedger.config # read configured values
|
|
32
|
-
# StandardLedger.post(EntryClass, ...) # write an entry
|
|
33
|
-
# StandardLedger.
|
|
34
|
-
# StandardLedger.
|
|
35
|
-
#
|
|
36
|
-
#
|
|
23
|
+
# StandardLedger.post(EntryClass, ...) # write an entry, return a Result
|
|
24
|
+
# StandardLedger.refresh!(:view_name) # refresh a materialized view
|
|
25
|
+
# StandardLedger.reset! # test helper (wipes config)
|
|
26
|
+
#
|
|
27
|
+
# 0.6.0 removed the projection engine (`projects_onto`, the projection
|
|
28
|
+
# modes, `rebuild!`, `with_modes`, the jobs and the `doctor` task) — no
|
|
29
|
+
# consuming app used it. See CHANGELOG.md for the upgrade note.
|
|
37
30
|
module StandardLedger
|
|
38
31
|
class << self
|
|
39
32
|
# Configure the gem once per app, typically from
|
|
@@ -47,31 +40,15 @@ module StandardLedger
|
|
|
47
40
|
@config ||= Config.new
|
|
48
41
|
end
|
|
49
42
|
|
|
50
|
-
#
|
|
51
|
-
#
|
|
52
|
-
#
|
|
53
|
-
# `@config` is harmless). Hosts that *do* configure the gem in an
|
|
54
|
-
# initializer should not call this between examples — use
|
|
55
|
-
# `reset_mode_overrides!` instead, which the auto-cleanup hook already
|
|
56
|
-
# invokes.
|
|
43
|
+
# Clears the cached `Config`. Intended for specs that exercise the gem's
|
|
44
|
+
# boot path; hosts that configure the gem from an initializer should not
|
|
45
|
+
# call this between examples (it would undo their initializer config).
|
|
57
46
|
def reset!
|
|
58
47
|
@config = nil
|
|
59
|
-
reset_mode_overrides!
|
|
60
|
-
end
|
|
61
|
-
|
|
62
|
-
# Test-friendly reset that only clears the thread-local `with_modes`
|
|
63
|
-
# override map, leaving `Config` intact. The `standard_ledger/rspec`
|
|
64
|
-
# auto-cleanup hook calls this in `before(:each)` so a host's initializer
|
|
65
|
-
# config (e.g. a configured `result_adapter`) survives across examples
|
|
66
|
-
# while per-example mode overrides still get torn down cleanly.
|
|
67
|
-
def reset_mode_overrides!
|
|
68
|
-
Thread.current[:standard_ledger_mode_overrides] = nil
|
|
69
48
|
end
|
|
70
49
|
|
|
71
50
|
# Sugar over `EntryClass.create!` that maps `targets:` onto the entry's
|
|
72
|
-
# `belongs_to`
|
|
73
|
-
# with the assignments folded together — the inline projection callback
|
|
74
|
-
# fires from the same code path either way.
|
|
51
|
+
# `belongs_to` associations and wraps the outcome in a Result.
|
|
75
52
|
#
|
|
76
53
|
# @example
|
|
77
54
|
# StandardLedger.post(VoucherRecord,
|
|
@@ -94,11 +71,10 @@ module StandardLedger
|
|
|
94
71
|
# foreign key directly via `attrs:` (e.g. `voucher_scheme_id: 42`).
|
|
95
72
|
# @param attrs [Hash] additional attributes merged into the create call.
|
|
96
73
|
# @return [StandardLedger::Result, Object] the gem's Result, or the
|
|
97
|
-
# host's Result type when `Config#custom_result?` is true.
|
|
98
|
-
#
|
|
99
|
-
#
|
|
100
|
-
#
|
|
101
|
-
# returns an empty array (no projections fire on the rescue path).
|
|
74
|
+
# host's Result type when `Config#custom_result?` is true. `idempotent?`
|
|
75
|
+
# is true when the create matched an existing row via the entry's
|
|
76
|
+
# idempotency key. `projections` is always `{}` since 0.6.0; the key is
|
|
77
|
+
# kept so existing `result_adapter` lambdas keep their signature.
|
|
102
78
|
def post(entry_class, kind:, targets: {}, attrs: {})
|
|
103
79
|
kind_column = resolve_kind_column(entry_class)
|
|
104
80
|
create_attrs = build_create_attrs(entry_class, kind_column, kind, targets, attrs)
|
|
@@ -108,247 +84,76 @@ module StandardLedger
|
|
|
108
84
|
build_result(
|
|
109
85
|
success: true,
|
|
110
86
|
entry: entry,
|
|
111
|
-
idempotent: entry.respond_to?(:idempotent?) && entry.idempotent
|
|
112
|
-
projections: { inline: applied_projections_for(entry) }
|
|
87
|
+
idempotent: entry.respond_to?(:idempotent?) && entry.idempotent?
|
|
113
88
|
)
|
|
114
89
|
rescue ActiveRecord::RecordInvalid => e
|
|
115
90
|
build_result(success: false, entry: e.record, errors: e.record.errors.full_messages)
|
|
116
91
|
end
|
|
117
92
|
|
|
118
|
-
# Force specific entry classes' projections to run in the supplied mode
|
|
119
|
-
# for the duration of the block. Intended for tests that want to drive an
|
|
120
|
-
# async-mode projection inline so the spec doesn't need a job runner.
|
|
121
|
-
#
|
|
122
|
-
# The override map is stored thread-locally so concurrent specs (or the
|
|
123
|
-
# gem's own `:async` workers) don't observe each other's overrides. Mode
|
|
124
|
-
# strategies consult `StandardLedger.mode_override_for(entry_class)`
|
|
125
|
-
# before falling back to the projection's declared mode.
|
|
126
|
-
#
|
|
127
|
-
# The block's prior override map is restored on exit, including on
|
|
128
|
-
# exception, so nested `with_modes` calls compose cleanly: the inner
|
|
129
|
-
# block's keys win during its scope, then the outer map is restored
|
|
130
|
-
# untouched.
|
|
131
|
-
#
|
|
132
|
-
# Today only `:inline` exists as a real mode, so this is a no-op for
|
|
133
|
-
# already-inline projections. The hook lands now so async projections
|
|
134
|
-
# can opt into the inline path the moment `Modes::Async` ships.
|
|
135
|
-
#
|
|
136
|
-
# @example
|
|
137
|
-
# StandardLedger.with_modes(PaymentRecord => :inline) do
|
|
138
|
-
# Orders::CheckoutOperation.call(...)
|
|
139
|
-
# end
|
|
140
|
-
#
|
|
141
|
-
# @example string keys (resolved via const_get)
|
|
142
|
-
# StandardLedger.with_modes("PaymentRecord" => :inline) do
|
|
143
|
-
# ...
|
|
144
|
-
# end
|
|
145
|
-
#
|
|
146
|
-
# @param overrides [Hash{Class, String, Symbol => Symbol}] entry class (or
|
|
147
|
-
# constant name / underscored symbol) → forced mode symbol.
|
|
148
|
-
def with_modes(overrides)
|
|
149
|
-
resolved = resolve_mode_overrides(overrides)
|
|
150
|
-
|
|
151
|
-
prior = Thread.current[:standard_ledger_mode_overrides]
|
|
152
|
-
merged = (prior || {}).merge(resolved)
|
|
153
|
-
Thread.current[:standard_ledger_mode_overrides] = merged
|
|
154
|
-
|
|
155
|
-
yield
|
|
156
|
-
ensure
|
|
157
|
-
Thread.current[:standard_ledger_mode_overrides] = prior
|
|
158
|
-
end
|
|
159
|
-
|
|
160
|
-
# Read the active override (if any) for `entry_class`. Mode strategies
|
|
161
|
-
# call this in their `install!` / `#call` paths before deciding whether
|
|
162
|
-
# to dispatch to the declared mode or the override mode. Returns `nil`
|
|
163
|
-
# outside any `with_modes` block.
|
|
164
|
-
#
|
|
165
|
-
# @param entry_class [Class] the host entry class.
|
|
166
|
-
# @return [Symbol, nil] the override mode, or `nil` for "no override".
|
|
167
|
-
def mode_override_for(entry_class)
|
|
168
|
-
overrides = Thread.current[:standard_ledger_mode_overrides]
|
|
169
|
-
return nil if overrides.nil?
|
|
170
|
-
|
|
171
|
-
overrides[entry_class]
|
|
172
|
-
end
|
|
173
|
-
|
|
174
|
-
# Recompute projections from the entry log for one or more targets.
|
|
175
|
-
# The deterministic counterpart to `post`: instead of applying the
|
|
176
|
-
# delta from a single new entry, this replays the full log onto the
|
|
177
|
-
# target by delegating to the projector class's `rebuild(target)`.
|
|
178
|
-
#
|
|
179
|
-
# Scope (mutually exclusive — pass at most one):
|
|
180
|
-
#
|
|
181
|
-
# - `target:` — rebuild every projection whose `target_association`
|
|
182
|
-
# resolves to `target.class`, for that single instance.
|
|
183
|
-
# - `target_class:` — rebuild every matching projection for every
|
|
184
|
-
# target referenced by the log for that AR class, in `find_each`
|
|
185
|
-
# batches. Targets with zero log entries are skipped (rebuilding a
|
|
186
|
-
# target the log never touched would zero its counters — destructive
|
|
187
|
-
# rather than corrective).
|
|
188
|
-
# - neither — rebuild every projection on `entry_class` for every
|
|
189
|
-
# target referenced by the log.
|
|
190
|
-
#
|
|
191
|
-
# Per-mode rules:
|
|
192
|
-
#
|
|
193
|
-
# - `:inline` projections must be class-form (`via: ProjectorClass`)
|
|
194
|
-
# AND that class must implement `rebuild`. Block-form projections
|
|
195
|
-
# are delta-based — they cannot be reconstructed from the log
|
|
196
|
-
# without the host providing a recompute path — so they raise
|
|
197
|
-
# `StandardLedger::NotRebuildable` here.
|
|
198
|
-
# - `:matview` projections rebuild by issuing a single
|
|
199
|
-
# `REFRESH MATERIALIZED VIEW [CONCURRENTLY] <view>` — for matview,
|
|
200
|
-
# refresh *is* rebuild. Postgres has no partial-refresh primitive,
|
|
201
|
-
# so `target:` / `target_class:` scope arguments are ignored for
|
|
202
|
-
# `:matview` projections and the full view is always refreshed.
|
|
203
|
-
# - `:sql` and `:trigger` projections rebuild by running their
|
|
204
|
-
# recorded rebuild SQL with `:target_id` bound to each target's
|
|
205
|
-
# id. For `:trigger`, the database trigger fires on entry INSERT;
|
|
206
|
-
# `rebuild!` runs the same logical recompute against each target
|
|
207
|
-
# the log references. The gem does NOT verify or recreate the
|
|
208
|
-
# trigger here — `standard_ledger:doctor` is the deploy-time
|
|
209
|
-
# check for trigger presence.
|
|
210
|
-
# - `:async` projections rebuild via the same per-target semantics as
|
|
211
|
-
# `:inline` (delegates to `definition.projector_class.new.rebuild(target)`).
|
|
212
|
-
# The mode difference is only in the after-create path (in-transaction
|
|
213
|
-
# vs. post-commit job), not in the rebuild path, which always runs
|
|
214
|
-
# synchronously.
|
|
215
|
-
#
|
|
216
|
-
# Atomicity: each (target, projection) pair runs in its own
|
|
217
|
-
# transaction. A failure mid-loop is **not** rolled back — earlier
|
|
218
|
-
# successful rebuilds remain applied. Concurrent posts to the entry
|
|
219
|
-
# log during rebuild produce eventually-correct state: the rebuild
|
|
220
|
-
# operates on a snapshot of the log up to the projector's own
|
|
221
|
-
# SELECT, and any entries written after that snapshot project
|
|
222
|
-
# normally via the entry's own callback path. See design doc §5.5.
|
|
223
|
-
#
|
|
224
|
-
# @example rebuild a single target
|
|
225
|
-
# StandardLedger.rebuild!(VoucherRecord, target: scheme)
|
|
226
|
-
#
|
|
227
|
-
# @example rebuild every scheme
|
|
228
|
-
# StandardLedger.rebuild!(VoucherRecord, target_class: VoucherScheme)
|
|
229
|
-
#
|
|
230
|
-
# @example rebuild every projection across every target
|
|
231
|
-
# StandardLedger.rebuild!(VoucherRecord)
|
|
232
|
-
#
|
|
233
|
-
# @param entry_class [Class] an `ActiveRecord::Base` subclass that
|
|
234
|
-
# includes `StandardLedger::Projector`.
|
|
235
|
-
# @param target [ActiveRecord::Base, nil] one specific projection
|
|
236
|
-
# target instance.
|
|
237
|
-
# @param target_class [Class, nil] rebuild for every target of this
|
|
238
|
-
# AR class that the log references. Targets with zero log entries
|
|
239
|
-
# are skipped.
|
|
240
|
-
# @param batch_size [Integer] passed to `find_each` when iterating
|
|
241
|
-
# targets. Default 1000.
|
|
242
|
-
# @return [StandardLedger::Result, Object] success result with
|
|
243
|
-
# `projections[:rebuilt] = [{ target_class:, target_id:,
|
|
244
|
-
# projection: }, ...]`, one entry per (target, projection) pair
|
|
245
|
-
# that ran. Failure result with `errors:` when any rebuild raises.
|
|
246
|
-
# Returns the host's Result type when `Config#custom_result?` is
|
|
247
|
-
# true, otherwise `StandardLedger::Result`.
|
|
248
|
-
# @raise [StandardLedger::NotRebuildable] when an applicable
|
|
249
|
-
# projection has no rebuildable projector (block-form, or class
|
|
250
|
-
# form whose `rebuild` raises `NotRebuildable`).
|
|
251
|
-
# @raise [StandardLedger::Error] when an applicable projection
|
|
252
|
-
# declares a mode `rebuild!` does not yet support.
|
|
253
|
-
# @raise [ArgumentError] when both `target:` and `target_class:`
|
|
254
|
-
# are supplied, when the entry class does not respond to
|
|
255
|
-
# `standard_ledger_projections`, or when a non-nil scope
|
|
256
|
-
# (`target:` / `target_class:`) matches no registered projection.
|
|
257
|
-
# @note Memory: when neither `target:` nor `target_class:` is given,
|
|
258
|
-
# the no-scope and `target_class:` paths first load every distinct
|
|
259
|
-
# foreign-key value from the log into memory via `distinct.pluck`
|
|
260
|
-
# before batching the targets themselves. For very large logs,
|
|
261
|
-
# prefer `target:` to scope to a single target rather than
|
|
262
|
-
# rebuilding the full set.
|
|
263
|
-
def rebuild!(entry_class, target: nil, target_class: nil, batch_size: 1000)
|
|
264
|
-
if target && target_class
|
|
265
|
-
raise ArgumentError,
|
|
266
|
-
"rebuild! accepts at most one of `target:` or `target_class:` — got both"
|
|
267
|
-
end
|
|
268
|
-
|
|
269
|
-
unless entry_class.respond_to?(:standard_ledger_projections)
|
|
270
|
-
raise ArgumentError,
|
|
271
|
-
"#{entry_class.name || entry_class.inspect} does not include StandardLedger::Projector; " \
|
|
272
|
-
"rebuild! requires registered projections"
|
|
273
|
-
end
|
|
274
|
-
|
|
275
|
-
definitions = applicable_definitions_for_rebuild(entry_class, target: target, target_class: target_class)
|
|
276
|
-
validate_definitions_present!(entry_class, definitions, target: target, target_class: target_class)
|
|
277
|
-
rebuilt = []
|
|
278
|
-
|
|
279
|
-
definitions.each do |definition|
|
|
280
|
-
validate_rebuildable_mode!(entry_class, definition)
|
|
281
|
-
|
|
282
|
-
if definition.mode == :matview
|
|
283
|
-
rebuild_matview_definition(definition)
|
|
284
|
-
rebuilt << {
|
|
285
|
-
target_class: nil,
|
|
286
|
-
target_id: nil,
|
|
287
|
-
projection: definition.target_association,
|
|
288
|
-
view: definition.view
|
|
289
|
-
}
|
|
290
|
-
next
|
|
291
|
-
end
|
|
292
|
-
|
|
293
|
-
validate_rebuildable_projector!(entry_class, definition)
|
|
294
|
-
|
|
295
|
-
each_rebuild_target(entry_class, definition, target: target, batch_size: batch_size) do |t|
|
|
296
|
-
if definition.mode == :sql || definition.mode == :trigger
|
|
297
|
-
rebuild_one_sql(entry_class, definition, t)
|
|
298
|
-
else
|
|
299
|
-
rebuild_one(entry_class, definition, t)
|
|
300
|
-
end
|
|
301
|
-
rebuilt << { target_class: t.class, target_id: t.id, projection: definition.target_association }
|
|
302
|
-
end
|
|
303
|
-
end
|
|
304
|
-
|
|
305
|
-
build_result(success: true, projections: { rebuilt: rebuilt })
|
|
306
|
-
rescue StandardLedger::Error, ArgumentError
|
|
307
|
-
# Programmer-error / unsupported-mode / not-rebuildable raises bubble
|
|
308
|
-
# up unchanged — these are deterministic, not data-dependent failures.
|
|
309
|
-
raise
|
|
310
|
-
rescue StandardError => e
|
|
311
|
-
# A projector raised mid-rebuild. Earlier successful rebuilds are
|
|
312
|
-
# NOT unwound (the contract is per-target transactional, not
|
|
313
|
-
# cross-target atomic) — we surface the failure but return.
|
|
314
|
-
build_result(success: false, errors: [ e.message ], projections: { rebuilt: rebuilt })
|
|
315
|
-
end
|
|
316
|
-
|
|
317
93
|
# Refresh a host-owned materialized view. Issues
|
|
318
94
|
# `REFRESH MATERIALIZED VIEW [CONCURRENTLY] <view_name>` against the
|
|
319
|
-
# active connection and emits
|
|
320
|
-
#
|
|
321
|
-
#
|
|
322
|
-
#
|
|
323
|
-
#
|
|
324
|
-
#
|
|
325
|
-
#
|
|
326
|
-
#
|
|
327
|
-
#
|
|
328
|
-
#
|
|
329
|
-
#
|
|
330
|
-
#
|
|
331
|
-
#
|
|
332
|
-
# point their scheduler at; that job is a thin wrapper around this
|
|
333
|
-
# method.
|
|
95
|
+
# active connection and emits `<prefix>.projection.refreshed` on success.
|
|
96
|
+
# When the refresh SQL raises, it emits `<prefix>.projection.failed`,
|
|
97
|
+
# reports the error through `Rails.error` (`handled: false`,
|
|
98
|
+
# `severity: :error`, `context: { view:, concurrently: }`,
|
|
99
|
+
# `source: "standard_ledger"`) and re-raises, so the host's job runner
|
|
100
|
+
# still drives its retry path. Hosts don't need their own
|
|
101
|
+
# report-and-re-raise; Rails skips an exception it has already reported,
|
|
102
|
+
# so an existing one is harmless. Argument errors and
|
|
103
|
+
# `RefreshInsideTransaction` are programming errors raised before any
|
|
104
|
+
# SQL runs; they are not reported by the gem.
|
|
105
|
+
#
|
|
106
|
+
# @example scheduled job refreshing a list of views
|
|
107
|
+
# VIEWS.each { |view| StandardLedger.refresh!(view, concurrently: :auto) }
|
|
334
108
|
#
|
|
335
109
|
# @param view_name [String, Symbol] the materialized view to refresh.
|
|
336
|
-
#
|
|
337
|
-
#
|
|
338
|
-
# `
|
|
339
|
-
#
|
|
340
|
-
#
|
|
341
|
-
#
|
|
342
|
-
#
|
|
110
|
+
# Must be a bare or `schema.view` SQL identifier.
|
|
111
|
+
# @param concurrently [Boolean, Symbol, nil]
|
|
112
|
+
# - `true` — force `CONCURRENTLY`. Raises `RefreshInsideTransaction`
|
|
113
|
+
# inside an open transaction; Postgres raises if the view is empty
|
|
114
|
+
# or has no suitable unique index.
|
|
115
|
+
# - `false` — force a plain (blocking) refresh.
|
|
116
|
+
# - `:auto` — use `CONCURRENTLY` only when it can succeed: no open
|
|
117
|
+
# transaction, the view is populated, and it has a unique index
|
|
118
|
+
# without a WHERE clause or expressions. Otherwise fall back to a
|
|
119
|
+
# plain refresh. If the catalog probe itself fails, the error is
|
|
120
|
+
# reported via `Rails.error` (handled) and a plain refresh runs.
|
|
121
|
+
# - `nil` (default) — read `Config#matview_refresh_strategy`
|
|
122
|
+
# (`:concurrent` => `true`, `:blocking` => `false`, `:auto` => `:auto`).
|
|
123
|
+
# @return [StandardLedger::Result, Object] success result with
|
|
124
|
+
# `projections[:refreshed] = [{ view:, concurrently: }]`, where
|
|
125
|
+
# `concurrently` is the resolved Boolean. The host's Result type when
|
|
126
|
+
# `Config#custom_result?` is true.
|
|
127
|
+
# @raise [ArgumentError] when `view_name` is not a valid identifier or
|
|
128
|
+
# `concurrently` is not one of the values above.
|
|
129
|
+
# @raise [StandardLedger::RefreshInsideTransaction] for
|
|
130
|
+
# `concurrently: true` inside an open transaction.
|
|
343
131
|
def refresh!(view_name, concurrently: nil)
|
|
344
|
-
effective =
|
|
345
|
-
|
|
132
|
+
effective = Matview.resolve_concurrently(view_name, requested_concurrently(concurrently))
|
|
133
|
+
Matview.refresh!(view_name, concurrently: effective)
|
|
346
134
|
build_result(
|
|
347
135
|
success: true,
|
|
348
136
|
projections: { refreshed: [ { view: view_name.to_s, concurrently: effective } ] }
|
|
349
137
|
)
|
|
350
138
|
end
|
|
351
139
|
|
|
140
|
+
# Report +error+ through `Rails.error` (or `ActiveSupport.error_reporter`
|
|
141
|
+
# outside Rails) with `source: "standard_ledger"`. A reporter failure is
|
|
142
|
+
# swallowed so it can never mask the error being reported.
|
|
143
|
+
#
|
|
144
|
+
# @api private
|
|
145
|
+
def report_error(error, handled:, severity:, context:)
|
|
146
|
+
reporter =
|
|
147
|
+
if defined?(::Rails) && ::Rails.respond_to?(:error)
|
|
148
|
+
::Rails.error
|
|
149
|
+
elsif ActiveSupport.respond_to?(:error_reporter)
|
|
150
|
+
ActiveSupport.error_reporter
|
|
151
|
+
end
|
|
152
|
+
reporter&.report(error, handled: handled, severity: severity, context: context, source: "standard_ledger")
|
|
153
|
+
rescue StandardError
|
|
154
|
+
nil
|
|
155
|
+
end
|
|
156
|
+
|
|
352
157
|
private
|
|
353
158
|
|
|
354
159
|
# Resolve the kind column name for an entry class. Falls back to `:kind`
|
|
@@ -359,11 +164,10 @@ module StandardLedger
|
|
|
359
164
|
config ? config[:kind] : :kind
|
|
360
165
|
end
|
|
361
166
|
|
|
362
|
-
# Translate `targets:` into
|
|
363
|
-
#
|
|
364
|
-
#
|
|
365
|
-
#
|
|
366
|
-
# ids should be passed via `attrs:` instead (`<assoc>_id: ...`).
|
|
167
|
+
# Translate `targets:` into association assignments after confirming via
|
|
168
|
+
# `reflect_on_association` that each key is a real association. Targets
|
|
169
|
+
# must be ActiveRecord instances; raw foreign-key ids should be passed via
|
|
170
|
+
# `attrs:` instead (`<assoc>_id: ...`).
|
|
367
171
|
def build_create_attrs(entry_class, kind_column, kind, targets, attrs)
|
|
368
172
|
assigned = { kind_column => kind }
|
|
369
173
|
|
|
@@ -384,212 +188,26 @@ module StandardLedger
|
|
|
384
188
|
assigned.merge(attrs)
|
|
385
189
|
end
|
|
386
190
|
|
|
387
|
-
#
|
|
388
|
-
#
|
|
389
|
-
#
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
def applicable_definitions_for_rebuild(entry_class, target:, target_class:)
|
|
394
|
-
requested_class = target_class || target&.class
|
|
395
|
-
return entry_class.standard_ledger_projections.dup if requested_class.nil?
|
|
191
|
+
# Normalise the public `concurrently:` argument. `nil` defers to
|
|
192
|
+
# `Config#matview_refresh_strategy`; explicit values are honored verbatim
|
|
193
|
+
# so callers can override the default per call.
|
|
194
|
+
def requested_concurrently(concurrently)
|
|
195
|
+
value = concurrently.nil? ? strategy_to_flag(config.matview_refresh_strategy) : concurrently
|
|
196
|
+
return value if [ true, false, :auto ].include?(value)
|
|
396
197
|
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
end
|
|
198
|
+
raise ArgumentError,
|
|
199
|
+
"concurrently: must be true, false, :auto, or nil; got #{concurrently.inspect}"
|
|
400
200
|
end
|
|
401
201
|
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
# `Result.success` with `rebuilt: []` would let the mistake go
|
|
408
|
-
# undetected. Raise so the caller hears about it.
|
|
409
|
-
def validate_definitions_present!(entry_class, definitions, target:, target_class:)
|
|
410
|
-
return unless definitions.empty?
|
|
411
|
-
|
|
412
|
-
requested_class = target_class || target&.class
|
|
413
|
-
|
|
414
|
-
if requested_class
|
|
415
|
-
raise ArgumentError,
|
|
416
|
-
"#{entry_class.name} has no projections matching #{requested_class.name}; " \
|
|
417
|
-
"check the `projects_onto` declarations on #{entry_class.name}."
|
|
202
|
+
def strategy_to_flag(strategy)
|
|
203
|
+
case strategy
|
|
204
|
+
when :concurrent then true
|
|
205
|
+
when :blocking then false
|
|
206
|
+
when :auto then :auto
|
|
418
207
|
else
|
|
419
208
|
raise ArgumentError,
|
|
420
|
-
"#
|
|
421
|
-
"add a `projects_onto` declaration before calling rebuild!."
|
|
422
|
-
end
|
|
423
|
-
end
|
|
424
|
-
|
|
425
|
-
# Resolve the AR class on the far side of a projection's
|
|
426
|
-
# `target_association`. Used to match `target:` / `target_class:`
|
|
427
|
-
# against registered projections.
|
|
428
|
-
def association_target_class(entry_class, definition)
|
|
429
|
-
reflection = entry_class.reflect_on_association(definition.target_association)
|
|
430
|
-
return nil if reflection.nil?
|
|
431
|
-
|
|
432
|
-
reflection.klass
|
|
433
|
-
end
|
|
434
|
-
|
|
435
|
-
# All five projection modes (`:inline`, `:async`, `:sql`, `:matview`,
|
|
436
|
-
# `:trigger`) implement a log-replay path through this method.
|
|
437
|
-
#
|
|
438
|
-
# `:async` and `:inline` share the same per-target rebuild semantics —
|
|
439
|
-
# both delegate to `definition.projector_class.new.rebuild(target)`.
|
|
440
|
-
# The difference between the two modes is only in the after-create
|
|
441
|
-
# path (in-transaction vs. post-commit job), not in the rebuild path,
|
|
442
|
-
# which always runs synchronously.
|
|
443
|
-
def validate_rebuildable_mode!(entry_class, definition)
|
|
444
|
-
return if definition.mode == :inline
|
|
445
|
-
return if definition.mode == :async
|
|
446
|
-
return if definition.mode == :manual
|
|
447
|
-
return if definition.mode == :sql
|
|
448
|
-
return if definition.mode == :matview
|
|
449
|
-
return if definition.mode == :trigger
|
|
450
|
-
|
|
451
|
-
raise StandardLedger::Error,
|
|
452
|
-
"rebuild! does not yet support mode: #{definition.mode.inspect} " \
|
|
453
|
-
"on #{entry_class.name}##{definition.target_association}; " \
|
|
454
|
-
"this mode's rebuild path lands in its own PR"
|
|
455
|
-
end
|
|
456
|
-
|
|
457
|
-
# Rebuild a `:matview` projection by issuing a single REFRESH against
|
|
458
|
-
# the registered view. There's no per-target loop — the matview holds
|
|
459
|
-
# state for every target in a single relation, so one refresh is the
|
|
460
|
-
# entire rebuild.
|
|
461
|
-
def rebuild_matview_definition(definition)
|
|
462
|
-
concurrently = definition.refresh_options[:concurrently]
|
|
463
|
-
effective = effective_concurrent_flag(concurrently)
|
|
464
|
-
Modes::Matview.refresh!(definition.view, concurrently: effective)
|
|
465
|
-
end
|
|
466
|
-
|
|
467
|
-
# Reduce the public `concurrently:` parameter to a Boolean by reading
|
|
468
|
-
# `Config#matview_refresh_strategy` only when the caller passed `nil`.
|
|
469
|
-
# `true`/`false` are honored verbatim so callers can override the
|
|
470
|
-
# default per-call (e.g. an ad-hoc blocking refresh on a view whose
|
|
471
|
-
# default is concurrent).
|
|
472
|
-
def effective_concurrent_flag(concurrently)
|
|
473
|
-
return concurrently unless concurrently.nil?
|
|
474
|
-
|
|
475
|
-
config.matview_refresh_strategy == :concurrent
|
|
476
|
-
end
|
|
477
|
-
|
|
478
|
-
# Block-form `:inline` projections register per-kind handlers
|
|
479
|
-
# (e.g. `on(:grant) { increment(...) }`) that describe a delta.
|
|
480
|
-
# There's no general way to recompute the aggregate from the log
|
|
481
|
-
# without the host providing a recompute path — so we refuse
|
|
482
|
-
# rather than guess. Hosts who want this projection to be
|
|
483
|
-
# rebuildable should extract a `Projection` subclass and implement
|
|
484
|
-
# `rebuild(target)`.
|
|
485
|
-
def validate_rebuildable_projector!(entry_class, definition)
|
|
486
|
-
# `:sql` and `:trigger` modes carry their rebuild path in the
|
|
487
|
-
# recompute / rebuild SQL itself — no projector class is required
|
|
488
|
-
# (and `via:` is rejected at registration). Skip the class-form
|
|
489
|
-
# preflight checks below.
|
|
490
|
-
return if definition.mode == :sql
|
|
491
|
-
return if definition.mode == :trigger
|
|
492
|
-
|
|
493
|
-
if definition.projector_class.nil?
|
|
494
|
-
raise StandardLedger::NotRebuildable,
|
|
495
|
-
"#{entry_class.name}##{definition.target_association} is a block-form projection " \
|
|
496
|
-
"and cannot be rebuilt from the entry log. Implement a Projection subclass with " \
|
|
497
|
-
"`rebuild(target)` and pass it via `via:` to make this projection rebuildable."
|
|
209
|
+
"Config#matview_refresh_strategy must be :concurrent, :blocking, or :auto; got #{strategy.inspect}"
|
|
498
210
|
end
|
|
499
|
-
|
|
500
|
-
# Best-effort early detection: catches the common "host forgot to
|
|
501
|
-
# override `rebuild`" case before we iterate any targets. The owner
|
|
502
|
-
# check is fragile for projectors that inherit `rebuild` from an
|
|
503
|
-
# intermediate mixin/superclass — the inherited `rebuild` may still
|
|
504
|
-
# raise `NotRebuildable` at runtime. The authoritative gate is the
|
|
505
|
-
# base `Projection#rebuild` implementation, which raises
|
|
506
|
-
# `NotRebuildable` itself; the rescue clause in `rebuild!` re-raises
|
|
507
|
-
# it unchanged. So a fragility miss here just means the failure
|
|
508
|
-
# surfaces at iteration-time instead of pre-flight, which is
|
|
509
|
-
# acceptable for v0.1.
|
|
510
|
-
return if definition.projector_class.instance_method(:rebuild).owner != StandardLedger::Projection
|
|
511
|
-
|
|
512
|
-
raise StandardLedger::NotRebuildable,
|
|
513
|
-
"#{definition.projector_class.name}#rebuild is not implemented; " \
|
|
514
|
-
"override it to recompute #{entry_class.name}##{definition.target_association} " \
|
|
515
|
-
"from the entry log."
|
|
516
|
-
end
|
|
517
|
-
|
|
518
|
-
# Yield each target in scope for this projection's rebuild. With
|
|
519
|
-
# an explicit `target:` we yield once; with `target_class:` or no
|
|
520
|
-
# scope, we walk every distinct foreign-key value in the log and
|
|
521
|
-
# `find_each` the corresponding rows in batches.
|
|
522
|
-
def each_rebuild_target(entry_class, definition, target:, batch_size:)
|
|
523
|
-
if target
|
|
524
|
-
yield target
|
|
525
|
-
return
|
|
526
|
-
end
|
|
527
|
-
|
|
528
|
-
reflection = entry_class.reflect_on_association(definition.target_association)
|
|
529
|
-
target_klass = reflection.klass
|
|
530
|
-
foreign_key = reflection.foreign_key
|
|
531
|
-
|
|
532
|
-
# Pluck the distinct ids referenced by the log so we don't
|
|
533
|
-
# rebuild for targets that have no entries against them. Cast
|
|
534
|
-
# through `compact` to skip null FKs (legitimate when the entry
|
|
535
|
-
# has an `if:` guard that may not apply).
|
|
536
|
-
ids = entry_class.where.not(foreign_key => nil).distinct.pluck(foreign_key)
|
|
537
|
-
return if ids.empty?
|
|
538
|
-
|
|
539
|
-
target_klass.where(id: ids).find_each(batch_size: batch_size) do |t|
|
|
540
|
-
yield t
|
|
541
|
-
end
|
|
542
|
-
end
|
|
543
|
-
|
|
544
|
-
# Run a single (target, projection) rebuild inside its own
|
|
545
|
-
# transaction, then fire `<prefix>.projection.rebuilt` on success
|
|
546
|
-
# so observers can track per-target rebuild progress.
|
|
547
|
-
def rebuild_one(entry_class, definition, target)
|
|
548
|
-
target.class.transaction do
|
|
549
|
-
definition.projector_class.new.rebuild(target)
|
|
550
|
-
end
|
|
551
|
-
|
|
552
|
-
prefix = config.notification_namespace
|
|
553
|
-
StandardLedger::EventEmitter.emit(
|
|
554
|
-
"#{prefix}.projection.rebuilt",
|
|
555
|
-
entry_class: entry_class, target: target,
|
|
556
|
-
projection: definition.target_association, mode: definition.mode
|
|
557
|
-
)
|
|
558
|
-
end
|
|
559
|
-
|
|
560
|
-
# `:sql` / `:trigger` mode rebuild path: run the recorded recompute
|
|
561
|
-
# SQL bound to this target's id. For `:sql` mode this is the same
|
|
562
|
-
# statement the `after_create` callback runs; for `:trigger` mode
|
|
563
|
-
# it's the rebuild SQL the host registered (the database trigger
|
|
564
|
-
# itself owns the after-INSERT path). Either way there's no
|
|
565
|
-
# projector class to invoke — the SQL is the entire contract.
|
|
566
|
-
def rebuild_one_sql(entry_class, definition, target)
|
|
567
|
-
target.class.transaction do
|
|
568
|
-
sql = ActiveRecord::Base.sanitize_sql_array([ definition.recompute_sql, { target_id: target.id } ])
|
|
569
|
-
entry_class.connection.exec_update(sql)
|
|
570
|
-
end
|
|
571
|
-
|
|
572
|
-
prefix = config.notification_namespace
|
|
573
|
-
StandardLedger::EventEmitter.emit(
|
|
574
|
-
"#{prefix}.projection.rebuilt",
|
|
575
|
-
entry_class: entry_class, target: target,
|
|
576
|
-
projection: definition.target_association, mode: definition.mode
|
|
577
|
-
)
|
|
578
|
-
end
|
|
579
|
-
|
|
580
|
-
# Names of the `:inline`-mode projections that actually ran for this
|
|
581
|
-
# entry — surfaced in `result.projections[:inline]` so callers can
|
|
582
|
-
# distinguish "applied now" from "queued" from "scheduled" (§7).
|
|
583
|
-
#
|
|
584
|
-
# `Modes::Inline#call` populates `@_standard_ledger_applied_projections`
|
|
585
|
-
# on the entry instance with the target_association names that ran
|
|
586
|
-
# (skipping projections whose `if:` guard returned false, whose target
|
|
587
|
-
# was nil, or whose permissive miss didn't hit a `:_` wildcard). When
|
|
588
|
-
# the ivar isn't present — e.g. an idempotent rescue returned an
|
|
589
|
-
# existing row without firing `after_create` — we report an empty
|
|
590
|
-
# list, which accurately reflects that no projections ran on this call.
|
|
591
|
-
def applied_projections_for(entry)
|
|
592
|
-
Array(entry.instance_variable_get(:@_standard_ledger_applied_projections))
|
|
593
211
|
end
|
|
594
212
|
|
|
595
213
|
# Construct a Result via the host's adapter when configured, otherwise
|
|
@@ -607,37 +225,5 @@ module StandardLedger
|
|
|
607
225
|
Result.failure(errors: errors, entry: entry, projections: projections)
|
|
608
226
|
end
|
|
609
227
|
end
|
|
610
|
-
|
|
611
|
-
# Resolve override-map keys to actual class constants so callers can
|
|
612
|
-
# write `with_modes(PaymentRecord => :inline)` *or*
|
|
613
|
-
# `with_modes(:payment_record => :inline)`. The String/Symbol form uses
|
|
614
|
-
# `String#classify` then `Object.const_get`; the Class form is passed
|
|
615
|
-
# through verbatim. Anything else raises so the caller fixes the typo
|
|
616
|
-
# rather than silently storing a key that nothing will ever match.
|
|
617
|
-
#
|
|
618
|
-
# An unresolvable String/Symbol key (typo: `:payment_recrd`) is caught
|
|
619
|
-
# and re-raised as `ArgumentError` with a `with_modes:`-prefixed message
|
|
620
|
-
# naming the offending key, rather than leaking `const_get`'s bare
|
|
621
|
-
# `NameError: uninitialized constant ...`.
|
|
622
|
-
def resolve_mode_overrides(overrides)
|
|
623
|
-
overrides.each_with_object({}) do |(key, mode), memo|
|
|
624
|
-
klass =
|
|
625
|
-
case key
|
|
626
|
-
when Class
|
|
627
|
-
key
|
|
628
|
-
when String, Symbol
|
|
629
|
-
begin
|
|
630
|
-
Object.const_get(key.to_s.classify)
|
|
631
|
-
rescue NameError
|
|
632
|
-
raise ArgumentError,
|
|
633
|
-
"with_modes: could not resolve #{key.inspect} to a constant"
|
|
634
|
-
end
|
|
635
|
-
else
|
|
636
|
-
raise ArgumentError,
|
|
637
|
-
"with_modes: expected Class, String, or Symbol key; got #{key.inspect}"
|
|
638
|
-
end
|
|
639
|
-
memo[klass] = mode
|
|
640
|
-
end
|
|
641
|
-
end
|
|
642
228
|
end
|
|
643
229
|
end
|