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
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: standard_ledger
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.7.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Jaryl Sim
|
|
@@ -37,20 +37,6 @@ dependencies:
|
|
|
37
37
|
- - ">="
|
|
38
38
|
- !ruby/object:Gem::Version
|
|
39
39
|
version: '8.0'
|
|
40
|
-
- !ruby/object:Gem::Dependency
|
|
41
|
-
name: activejob
|
|
42
|
-
requirement: !ruby/object:Gem::Requirement
|
|
43
|
-
requirements:
|
|
44
|
-
- - ">="
|
|
45
|
-
- !ruby/object:Gem::Version
|
|
46
|
-
version: '8.0'
|
|
47
|
-
type: :runtime
|
|
48
|
-
prerelease: false
|
|
49
|
-
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
-
requirements:
|
|
51
|
-
- - ">="
|
|
52
|
-
- !ruby/object:Gem::Version
|
|
53
|
-
version: '8.0'
|
|
54
40
|
- !ruby/object:Gem::Dependency
|
|
55
41
|
name: activesupport
|
|
56
42
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -65,20 +51,6 @@ dependencies:
|
|
|
65
51
|
- - ">="
|
|
66
52
|
- !ruby/object:Gem::Version
|
|
67
53
|
version: '8.0'
|
|
68
|
-
- !ruby/object:Gem::Dependency
|
|
69
|
-
name: concurrent-ruby
|
|
70
|
-
requirement: !ruby/object:Gem::Requirement
|
|
71
|
-
requirements:
|
|
72
|
-
- - "~>"
|
|
73
|
-
- !ruby/object:Gem::Version
|
|
74
|
-
version: '1.3'
|
|
75
|
-
type: :runtime
|
|
76
|
-
prerelease: false
|
|
77
|
-
version_requirements: !ruby/object:Gem::Requirement
|
|
78
|
-
requirements:
|
|
79
|
-
- - "~>"
|
|
80
|
-
- !ruby/object:Gem::Version
|
|
81
|
-
version: '1.3'
|
|
82
54
|
- !ruby/object:Gem::Dependency
|
|
83
55
|
name: brakeman
|
|
84
56
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -121,12 +93,11 @@ dependencies:
|
|
|
121
93
|
- - ">="
|
|
122
94
|
- !ruby/object:Gem::Version
|
|
123
95
|
version: '0'
|
|
124
|
-
description: StandardLedger
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
and immutability at the entry level.
|
|
96
|
+
description: 'StandardLedger marks ActiveRecord models as immutable, append-only journal
|
|
97
|
+
entries with idempotency-by-unique-index, provides a StandardLedger.post helper
|
|
98
|
+
that returns a Result (optionally the host''s own Result type), a Projection base
|
|
99
|
+
class for host-side projectors, and StandardLedger.refresh! for host-owned PostgreSQL
|
|
100
|
+
materialized views (including concurrently: :auto).'
|
|
130
101
|
email:
|
|
131
102
|
- code@jaryl.dev
|
|
132
103
|
executables: []
|
|
@@ -141,25 +112,15 @@ files:
|
|
|
141
112
|
- lib/generators/standard_ledger/install/templates/initializer.rb.tt
|
|
142
113
|
- lib/standard_ledger.rb
|
|
143
114
|
- lib/standard_ledger/config.rb
|
|
144
|
-
- lib/standard_ledger/engine.rb
|
|
145
115
|
- lib/standard_ledger/entry.rb
|
|
146
116
|
- lib/standard_ledger/errors.rb
|
|
147
117
|
- lib/standard_ledger/event_emitter.rb
|
|
148
|
-
- lib/standard_ledger/
|
|
149
|
-
- lib/standard_ledger/jobs/projection_job.rb
|
|
150
|
-
- lib/standard_ledger/modes/async.rb
|
|
151
|
-
- lib/standard_ledger/modes/inline.rb
|
|
152
|
-
- lib/standard_ledger/modes/matview.rb
|
|
153
|
-
- lib/standard_ledger/modes/sql.rb
|
|
154
|
-
- lib/standard_ledger/modes/trigger.rb
|
|
118
|
+
- lib/standard_ledger/matview.rb
|
|
155
119
|
- lib/standard_ledger/projection.rb
|
|
156
|
-
- lib/standard_ledger/projector.rb
|
|
157
120
|
- lib/standard_ledger/result.rb
|
|
158
121
|
- lib/standard_ledger/rspec.rb
|
|
159
|
-
- lib/standard_ledger/rspec/helpers.rb
|
|
160
122
|
- lib/standard_ledger/rspec/matchers.rb
|
|
161
123
|
- lib/standard_ledger/version.rb
|
|
162
|
-
- lib/tasks/standard_ledger.rake
|
|
163
124
|
homepage: https://github.com/rarebit-one/standard_ledger
|
|
164
125
|
licenses:
|
|
165
126
|
- MIT
|
|
@@ -184,6 +145,6 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
184
145
|
requirements: []
|
|
185
146
|
rubygems_version: 4.0.3
|
|
186
147
|
specification_version: 4
|
|
187
|
-
summary: Immutable journal entries
|
|
188
|
-
apps.
|
|
148
|
+
summary: Immutable, append-only journal entries and materialized-view refresh for
|
|
149
|
+
Rails apps.
|
|
189
150
|
test_files: []
|
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
module StandardLedger
|
|
2
|
-
# Boot hook for Rails apps. The engine registers no routes and provides no
|
|
3
|
-
# tables — its only role is to ensure the gem's notification subscribers
|
|
4
|
-
# (if any are registered by the host) are wired up after the host's
|
|
5
|
-
# initializers have finished running.
|
|
6
|
-
#
|
|
7
|
-
# We hook `after: :load_config_initializers` so any host-side
|
|
8
|
-
# `StandardLedger.configure` block in `config/initializers/*` has finished
|
|
9
|
-
# before subscribers are attached.
|
|
10
|
-
class Engine < ::Rails::Engine
|
|
11
|
-
isolate_namespace StandardLedger
|
|
12
|
-
|
|
13
|
-
initializer "standard_ledger.notifications", after: :load_config_initializers do
|
|
14
|
-
# Notification wiring lands here in a follow-up PR. Today this is a
|
|
15
|
-
# no-op — the gem emits events but ships no internal subscribers. On
|
|
16
|
-
# Rails 8.1+ events go through `Rails.event.notify`, so hosts subscribe
|
|
17
|
-
# with `Rails.event.subscribe`; on older Rails they fall back to
|
|
18
|
-
# ActiveSupport::Notifications (see StandardLedger::EventEmitter).
|
|
19
|
-
end
|
|
20
|
-
|
|
21
|
-
# Engines auto-discover `lib/tasks/*.rake` in most Rails versions, but
|
|
22
|
-
# we register explicitly for defence-in-depth. Hosts get
|
|
23
|
-
# `standard_ledger:doctor` available under `bin/rails -T standard_ledger`.
|
|
24
|
-
rake_tasks do
|
|
25
|
-
load File.expand_path("../tasks/standard_ledger.rake", __dir__)
|
|
26
|
-
end
|
|
27
|
-
end
|
|
28
|
-
end
|
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
require "active_job"
|
|
2
|
-
|
|
3
|
-
module StandardLedger
|
|
4
|
-
# Thin ActiveJob wrapper that delegates to `StandardLedger.refresh!`. Hosts
|
|
5
|
-
# point their scheduler (SolidQueue Recurring Tasks, sidekiq-cron, etc.) at
|
|
6
|
-
# this job class with the view name as the argument. The gem deliberately
|
|
7
|
-
# does not auto-schedule — schedule cadence and backend selection is a host
|
|
8
|
-
# concern (the host's scheduler config has the wider context: queue routing,
|
|
9
|
-
# recurring task DSL, etc.).
|
|
10
|
-
#
|
|
11
|
-
# The job runs on ActiveJob's `:default` queue. Hosts running high-frequency
|
|
12
|
-
# refreshes (e.g. every minute) on a shared `:default` queue may want to
|
|
13
|
-
# isolate matview refreshes onto a dedicated queue so a slow refresh doesn't
|
|
14
|
-
# starve other latency-sensitive jobs — subclass and override `queue_as`
|
|
15
|
-
# (e.g. `queue_as :standard_ledger`) and point the scheduler at the
|
|
16
|
-
# subclass.
|
|
17
|
-
#
|
|
18
|
-
# @example SolidQueue Recurring Tasks (config/recurring.yml)
|
|
19
|
-
# refresh_user_prompt_inventories:
|
|
20
|
-
# class: StandardLedger::MatviewRefreshJob
|
|
21
|
-
# args: ["user_prompt_inventories", { concurrently: true }]
|
|
22
|
-
# schedule: "every 5 minutes"
|
|
23
|
-
class MatviewRefreshJob < ::ActiveJob::Base
|
|
24
|
-
def perform(view_name, concurrently: nil)
|
|
25
|
-
StandardLedger.refresh!(view_name, concurrently: concurrently)
|
|
26
|
-
end
|
|
27
|
-
end
|
|
28
|
-
end
|
|
@@ -1,94 +0,0 @@
|
|
|
1
|
-
require "active_job"
|
|
2
|
-
|
|
3
|
-
module StandardLedger
|
|
4
|
-
# ActiveJob class that runs a single `:async`-mode projection after the
|
|
5
|
-
# entry's outer transaction has committed. Enqueued by
|
|
6
|
-
# `StandardLedger::Modes::Async` from an `after_create_commit` callback.
|
|
7
|
-
#
|
|
8
|
-
# The job resolves the projection definition by `target_association` (the
|
|
9
|
-
# only stable handle — the Definition struct doesn't serialize cleanly
|
|
10
|
-
# through ActiveJob), looks up the target via the entry's `belongs_to`
|
|
11
|
-
# setter, and runs `target.with_lock { projector_class.new.apply(target,
|
|
12
|
-
# entry) }`. The projector must be class-form (`via: ProjectorClass`) and
|
|
13
|
-
# should recompute the aggregate from the log inside `apply` for retry
|
|
14
|
-
# safety — async projections can run more than once when the job retries.
|
|
15
|
-
#
|
|
16
|
-
# Retries are configurable per-projection via subclassing this job and
|
|
17
|
-
# overriding `retry_on`, or globally via `Config#default_async_retries`
|
|
18
|
-
# (default 3). When retries are exhausted, ActiveJob's dead-letter behavior
|
|
19
|
-
# takes over — the job still emits `<prefix>.projection.failed` on every
|
|
20
|
-
# attempt so subscribers see the full retry history.
|
|
21
|
-
#
|
|
22
|
-
# Notification payloads include `attempt:` (drawn from ActiveJob's
|
|
23
|
-
# `executions` accessor — 1 on first attempt, increments per retry) so
|
|
24
|
-
# subscribers can distinguish first-try success from retry-success.
|
|
25
|
-
class ProjectionJob < ::ActiveJob::Base
|
|
26
|
-
# Hand-rolled retry path so the attempt cap reads
|
|
27
|
-
# `Config#default_async_retries` at perform time. ActiveJob's
|
|
28
|
-
# `retry_on attempts:` requires a constant Integer (or `:unlimited`)
|
|
29
|
-
# and is captured at class-definition time — that's incompatible with
|
|
30
|
-
# the gem's pattern of letting hosts reconfigure `default_async_retries`
|
|
31
|
-
# in their initializer (or specs flipping it temporarily). We rescue
|
|
32
|
-
# `StandardError` and re-enqueue manually until the cap is reached.
|
|
33
|
-
rescue_from(StandardError) do |error|
|
|
34
|
-
attempts = StandardLedger.config.default_async_retries
|
|
35
|
-
if executions < attempts
|
|
36
|
-
# Compute a polynomial backoff inline. Mirrors ActiveJob's
|
|
37
|
-
# `:polynomially_longer` algorithm (`executions**4 + 2`) without
|
|
38
|
-
# depending on its private `determine_delay` API.
|
|
39
|
-
delay = (executions**4) + 2
|
|
40
|
-
retry_job(wait: delay, error: error)
|
|
41
|
-
else
|
|
42
|
-
raise error
|
|
43
|
-
end
|
|
44
|
-
end
|
|
45
|
-
|
|
46
|
-
# Discard programmer errors immediately — they're deterministic and
|
|
47
|
-
# retrying just burns the budget. `StandardLedger::Error` is raised by
|
|
48
|
-
# `#perform` on missing/renamed projection definitions and similar
|
|
49
|
-
# bookkeeping mistakes; the next attempt would raise the same error.
|
|
50
|
-
# Declared AFTER the `rescue_from(StandardError)` block above because
|
|
51
|
-
# `ActiveSupport::Rescuable` searches handlers from the most-recently-
|
|
52
|
-
# registered first — so this more-specific `StandardLedger::Error`
|
|
53
|
-
# handler wins over the catch-all retry path for its matching errors.
|
|
54
|
-
discard_on StandardLedger::Error
|
|
55
|
-
|
|
56
|
-
def perform(entry, target_association)
|
|
57
|
-
definition = entry.class.standard_ledger_projections.find { |d|
|
|
58
|
-
d.mode == :async && d.target_association == target_association.to_sym
|
|
59
|
-
}
|
|
60
|
-
if definition.nil?
|
|
61
|
-
raise StandardLedger::Error,
|
|
62
|
-
"no :async projection #{target_association.inspect} on #{entry.class.name}"
|
|
63
|
-
end
|
|
64
|
-
|
|
65
|
-
target = entry.public_send(definition.target_association)
|
|
66
|
-
return if target.nil?
|
|
67
|
-
|
|
68
|
-
prefix = StandardLedger.config.notification_namespace
|
|
69
|
-
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
70
|
-
|
|
71
|
-
begin
|
|
72
|
-
target.with_lock do
|
|
73
|
-
definition.projector_class.new.apply(target, entry)
|
|
74
|
-
end
|
|
75
|
-
rescue StandardError => e
|
|
76
|
-
duration_ms = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000.0
|
|
77
|
-
StandardLedger::EventEmitter.emit(
|
|
78
|
-
"#{prefix}.projection.failed",
|
|
79
|
-
entry: entry, target: target, projection: definition.target_association,
|
|
80
|
-
mode: :async, error: e, duration_ms: duration_ms,
|
|
81
|
-
attempt: executions
|
|
82
|
-
)
|
|
83
|
-
raise
|
|
84
|
-
end
|
|
85
|
-
|
|
86
|
-
duration_ms = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000.0
|
|
87
|
-
StandardLedger::EventEmitter.emit(
|
|
88
|
-
"#{prefix}.projection.applied",
|
|
89
|
-
entry: entry, target: target, projection: definition.target_association,
|
|
90
|
-
mode: :async, duration_ms: duration_ms, attempt: executions
|
|
91
|
-
)
|
|
92
|
-
end
|
|
93
|
-
end
|
|
94
|
-
end
|
|
@@ -1,141 +0,0 @@
|
|
|
1
|
-
module StandardLedger
|
|
2
|
-
module Modes
|
|
3
|
-
# `:async` mode: applies the projection in a background job enqueued
|
|
4
|
-
# from the entry's `after_create_commit` callback. The job runs after
|
|
5
|
-
# the outer transaction has committed, so the entry is durable before
|
|
6
|
-
# the projection runs — and a job failure does NOT roll back the entry.
|
|
7
|
-
#
|
|
8
|
-
# Used when the projection is too expensive or stateful for the entry's
|
|
9
|
-
# transaction (jsonb rebuild, multi-row aggregate). The canonical
|
|
10
|
-
# example is nutripod's `Order#payable_balance` / `Order#fulfillable_balance`
|
|
11
|
-
# jsonb columns, which need to be recomputed from every PaymentRecord /
|
|
12
|
-
# FulfillmentRecord against the order — work that's safe to defer past
|
|
13
|
-
# the originating transaction.
|
|
14
|
-
#
|
|
15
|
-
# Class-form only: `:async` projections must declare `via: ProjectorClass`,
|
|
16
|
-
# whose `apply(target, entry)` should recompute from the log inside
|
|
17
|
-
# `with_lock` rather than apply a delta. Block-form per-kind handlers
|
|
18
|
-
# aren't safe under retry — incrementing a counter twice on retry is a
|
|
19
|
-
# silent data corruption bug — so block-form is rejected at registration
|
|
20
|
-
# time (see `Projector#projects_onto`).
|
|
21
|
-
#
|
|
22
|
-
# The strategy installs an `after_create_commit` callback once per entry
|
|
23
|
-
# class. The callback walks every `:async`-mode projection registered on
|
|
24
|
-
# the class and enqueues `StandardLedger::ProjectionJob` per (entry,
|
|
25
|
-
# projection) pair, honoring the optional `if:` guard.
|
|
26
|
-
#
|
|
27
|
-
# ## with_modes interop
|
|
28
|
-
#
|
|
29
|
-
# `StandardLedger.with_modes(EntryClass => :inline) { ... }` forces async
|
|
30
|
-
# projections to run synchronously inside the block — useful in unit
|
|
31
|
-
# specs that want end-to-end coverage without standing up a job runner.
|
|
32
|
-
# Inline-override mode skips the enqueue and runs `target.with_lock {
|
|
33
|
-
# projector.apply(target, entry) }` directly. The override is read in
|
|
34
|
-
# `#call`; specs can capture the empty job queue via `have_enqueued_job`
|
|
35
|
-
# / `perform_enqueued_jobs` and still observe the projection's effects.
|
|
36
|
-
class Async
|
|
37
|
-
# Install the `after_create_commit` callback on `entry_class` exactly
|
|
38
|
-
# once. Subsequent calls (e.g. when a second `:async` projection is
|
|
39
|
-
# added later in the class body) are no-ops — the same callback
|
|
40
|
-
# handles all `:async` projections registered on the class.
|
|
41
|
-
#
|
|
42
|
-
# @param entry_class [Class] the host entry class.
|
|
43
|
-
# @return [void]
|
|
44
|
-
# @raise [ArgumentError] when `entry_class` is not ActiveRecord-backed
|
|
45
|
-
# (no `after_create_commit` hook available). `:async` mode requires
|
|
46
|
-
# AR transactional callbacks; non-AR entry classes can't dispatch
|
|
47
|
-
# the post-commit enqueue.
|
|
48
|
-
def self.install!(entry_class)
|
|
49
|
-
return if entry_class.instance_variable_get(:@_standard_ledger_async_installed)
|
|
50
|
-
|
|
51
|
-
unless entry_class.respond_to?(:after_create_commit)
|
|
52
|
-
raise ArgumentError,
|
|
53
|
-
"Modes::Async requires an ActiveRecord-backed entry class on " \
|
|
54
|
-
"#{entry_class.name || entry_class.inspect} — the entry class must inherit " \
|
|
55
|
-
"from ActiveRecord::Base. Use :inline mode for plain-Ruby entry classes."
|
|
56
|
-
end
|
|
57
|
-
|
|
58
|
-
entry_class.after_create_commit { StandardLedger::Modes::Async.new.call(self) }
|
|
59
|
-
entry_class.instance_variable_set(:@_standard_ledger_async_installed, true)
|
|
60
|
-
end
|
|
61
|
-
|
|
62
|
-
# Enqueue `ProjectionJob` for every `:async` projection registered on
|
|
63
|
-
# the entry's class. Called from the `after_create_commit` callback
|
|
64
|
-
# installed by `.install!`.
|
|
65
|
-
#
|
|
66
|
-
# Honors the optional `if:` guard (skips enqueue when guard returns
|
|
67
|
-
# false) and `StandardLedger.mode_override_for(entry_class)` (when set
|
|
68
|
-
# to `:inline`, runs the projection synchronously inside `with_lock`
|
|
69
|
-
# instead of enqueueing).
|
|
70
|
-
#
|
|
71
|
-
# A nil target at enqueue time skips silently — the entry's FK was
|
|
72
|
-
# unset for this projection's association, so there's nothing to
|
|
73
|
-
# project onto. (The job has its own nil-target guard; this short-
|
|
74
|
-
# circuit just avoids the wasted enqueue.)
|
|
75
|
-
#
|
|
76
|
-
# @param entry [ActiveRecord::Base] the just-committed entry.
|
|
77
|
-
# @return [void]
|
|
78
|
-
def call(entry)
|
|
79
|
-
definitions = async_definitions_for(entry.class)
|
|
80
|
-
return if definitions.empty?
|
|
81
|
-
|
|
82
|
-
override = StandardLedger.mode_override_for(entry.class)
|
|
83
|
-
|
|
84
|
-
definitions.each do |definition|
|
|
85
|
-
next if definition.guard && !entry.instance_exec(&definition.guard)
|
|
86
|
-
|
|
87
|
-
if override == :inline
|
|
88
|
-
run_inline(entry, definition)
|
|
89
|
-
else
|
|
90
|
-
target = entry.public_send(definition.target_association)
|
|
91
|
-
next if target.nil?
|
|
92
|
-
|
|
93
|
-
job_class = StandardLedger.config.default_async_job || StandardLedger::ProjectionJob
|
|
94
|
-
job_class.perform_later(entry, definition.target_association.to_s)
|
|
95
|
-
end
|
|
96
|
-
end
|
|
97
|
-
end
|
|
98
|
-
|
|
99
|
-
private
|
|
100
|
-
|
|
101
|
-
def async_definitions_for(entry_class)
|
|
102
|
-
return [] unless entry_class.respond_to?(:standard_ledger_projections_for)
|
|
103
|
-
|
|
104
|
-
entry_class.standard_ledger_projections_for(:async)
|
|
105
|
-
end
|
|
106
|
-
|
|
107
|
-
# `with_modes(EntryClass => :inline)` short-circuit: run the projector
|
|
108
|
-
# synchronously inside `with_lock`, mirroring the job's behavior
|
|
109
|
-
# without the enqueue. Skips silently when the target is nil so the
|
|
110
|
-
# nil-FK contract matches the enqueue path.
|
|
111
|
-
def run_inline(entry, definition)
|
|
112
|
-
target = entry.public_send(definition.target_association)
|
|
113
|
-
return if target.nil?
|
|
114
|
-
|
|
115
|
-
prefix = StandardLedger.config.notification_namespace
|
|
116
|
-
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
117
|
-
|
|
118
|
-
begin
|
|
119
|
-
target.with_lock do
|
|
120
|
-
definition.projector_class.new.apply(target, entry)
|
|
121
|
-
end
|
|
122
|
-
rescue StandardError => e
|
|
123
|
-
duration_ms = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000.0
|
|
124
|
-
StandardLedger::EventEmitter.emit(
|
|
125
|
-
"#{prefix}.projection.failed",
|
|
126
|
-
entry: entry, target: target, projection: definition.target_association,
|
|
127
|
-
mode: :async, error: e, duration_ms: duration_ms, attempt: 1
|
|
128
|
-
)
|
|
129
|
-
raise
|
|
130
|
-
end
|
|
131
|
-
|
|
132
|
-
duration_ms = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000.0
|
|
133
|
-
StandardLedger::EventEmitter.emit(
|
|
134
|
-
"#{prefix}.projection.applied",
|
|
135
|
-
entry: entry, target: target, projection: definition.target_association,
|
|
136
|
-
mode: :async, duration_ms: duration_ms, attempt: 1
|
|
137
|
-
)
|
|
138
|
-
end
|
|
139
|
-
end
|
|
140
|
-
end
|
|
141
|
-
end
|
|
@@ -1,180 +0,0 @@
|
|
|
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
|