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
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,241 +84,46 @@ 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
|
-
# failure to drive its retry path).
|
|
95
|
+
# active connection and emits `<prefix>.projection.refreshed` on success
|
|
96
|
+
# (or `<prefix>.projection.failed` on raise, before re-raising — the
|
|
97
|
+
# host's job runner needs to see the failure to drive its retry path).
|
|
323
98
|
#
|
|
324
|
-
#
|
|
325
|
-
#
|
|
326
|
-
# - **Hosts**, after a critical write that needs read-your-write semantics
|
|
327
|
-
# on a `:matview` projection (e.g. luminality's `PromptPacks::DrawOperation`
|
|
328
|
-
# refreshes `user_prompt_inventories` at the end of the operation so the
|
|
329
|
-
# user sees their post-draw count immediately, instead of waiting for
|
|
330
|
-
# the next scheduled refresh).
|
|
331
|
-
# - **`StandardLedger::MatviewRefreshJob`**, the ActiveJob class hosts
|
|
332
|
-
# point their scheduler at; that job is a thin wrapper around this
|
|
333
|
-
# method.
|
|
99
|
+
# @example scheduled job refreshing a list of views
|
|
100
|
+
# VIEWS.each { |view| StandardLedger.refresh!(view, concurrently: :auto) }
|
|
334
101
|
#
|
|
335
102
|
# @param view_name [String, Symbol] the materialized view to refresh.
|
|
336
|
-
#
|
|
337
|
-
#
|
|
338
|
-
# `
|
|
339
|
-
#
|
|
340
|
-
#
|
|
341
|
-
#
|
|
342
|
-
#
|
|
103
|
+
# Must be a bare or `schema.view` SQL identifier.
|
|
104
|
+
# @param concurrently [Boolean, Symbol, nil]
|
|
105
|
+
# - `true` — force `CONCURRENTLY`. Raises `RefreshInsideTransaction`
|
|
106
|
+
# inside an open transaction; Postgres raises if the view is empty
|
|
107
|
+
# or has no suitable unique index.
|
|
108
|
+
# - `false` — force a plain (blocking) refresh.
|
|
109
|
+
# - `:auto` — use `CONCURRENTLY` only when it can succeed: no open
|
|
110
|
+
# transaction, the view is populated, and it has a unique index
|
|
111
|
+
# without a WHERE clause or expressions. Otherwise fall back to a
|
|
112
|
+
# plain refresh. If the catalog probe itself fails, the error is
|
|
113
|
+
# reported via `Rails.error` (handled) and a plain refresh runs.
|
|
114
|
+
# - `nil` (default) — read `Config#matview_refresh_strategy`
|
|
115
|
+
# (`:concurrent` => `true`, `:blocking` => `false`, `:auto` => `:auto`).
|
|
116
|
+
# @return [StandardLedger::Result, Object] success result with
|
|
117
|
+
# `projections[:refreshed] = [{ view:, concurrently: }]`, where
|
|
118
|
+
# `concurrently` is the resolved Boolean. The host's Result type when
|
|
119
|
+
# `Config#custom_result?` is true.
|
|
120
|
+
# @raise [ArgumentError] when `view_name` is not a valid identifier or
|
|
121
|
+
# `concurrently` is not one of the values above.
|
|
122
|
+
# @raise [StandardLedger::RefreshInsideTransaction] for
|
|
123
|
+
# `concurrently: true` inside an open transaction.
|
|
343
124
|
def refresh!(view_name, concurrently: nil)
|
|
344
|
-
effective =
|
|
345
|
-
|
|
125
|
+
effective = Matview.resolve_concurrently(view_name, requested_concurrently(concurrently))
|
|
126
|
+
Matview.refresh!(view_name, concurrently: effective)
|
|
346
127
|
build_result(
|
|
347
128
|
success: true,
|
|
348
129
|
projections: { refreshed: [ { view: view_name.to_s, concurrently: effective } ] }
|
|
@@ -359,11 +140,10 @@ module StandardLedger
|
|
|
359
140
|
config ? config[:kind] : :kind
|
|
360
141
|
end
|
|
361
142
|
|
|
362
|
-
# Translate `targets:` into
|
|
363
|
-
#
|
|
364
|
-
#
|
|
365
|
-
#
|
|
366
|
-
# ids should be passed via `attrs:` instead (`<assoc>_id: ...`).
|
|
143
|
+
# Translate `targets:` into association assignments after confirming via
|
|
144
|
+
# `reflect_on_association` that each key is a real association. Targets
|
|
145
|
+
# must be ActiveRecord instances; raw foreign-key ids should be passed via
|
|
146
|
+
# `attrs:` instead (`<assoc>_id: ...`).
|
|
367
147
|
def build_create_attrs(entry_class, kind_column, kind, targets, attrs)
|
|
368
148
|
assigned = { kind_column => kind }
|
|
369
149
|
|
|
@@ -384,212 +164,26 @@ module StandardLedger
|
|
|
384
164
|
assigned.merge(attrs)
|
|
385
165
|
end
|
|
386
166
|
|
|
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?
|
|
167
|
+
# Normalise the public `concurrently:` argument. `nil` defers to
|
|
168
|
+
# `Config#matview_refresh_strategy`; explicit values are honored verbatim
|
|
169
|
+
# so callers can override the default per call.
|
|
170
|
+
def requested_concurrently(concurrently)
|
|
171
|
+
value = concurrently.nil? ? strategy_to_flag(config.matview_refresh_strategy) : concurrently
|
|
172
|
+
return value if [ true, false, :auto ].include?(value)
|
|
396
173
|
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
end
|
|
174
|
+
raise ArgumentError,
|
|
175
|
+
"concurrently: must be true, false, :auto, or nil; got #{concurrently.inspect}"
|
|
400
176
|
end
|
|
401
177
|
|
|
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}."
|
|
178
|
+
def strategy_to_flag(strategy)
|
|
179
|
+
case strategy
|
|
180
|
+
when :concurrent then true
|
|
181
|
+
when :blocking then false
|
|
182
|
+
when :auto then :auto
|
|
418
183
|
else
|
|
419
184
|
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."
|
|
498
|
-
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)
|
|
185
|
+
"Config#matview_refresh_strategy must be :concurrent, :blocking, or :auto; got #{strategy.inspect}"
|
|
570
186
|
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
187
|
end
|
|
594
188
|
|
|
595
189
|
# Construct a Result via the host's adapter when configured, otherwise
|
|
@@ -607,37 +201,5 @@ module StandardLedger
|
|
|
607
201
|
Result.failure(errors: errors, entry: entry, projections: projections)
|
|
608
202
|
end
|
|
609
203
|
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
204
|
end
|
|
643
205
|
end
|