standard_ledger 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +260 -0
- data/MIT-LICENSE +21 -0
- data/README.md +287 -0
- data/Rakefile +6 -0
- data/lib/generators/standard_ledger/install/install_generator.rb +34 -0
- data/lib/generators/standard_ledger/install/templates/initializer.rb.tt +66 -0
- data/lib/standard_ledger/config.rb +62 -0
- data/lib/standard_ledger/engine.rb +19 -0
- data/lib/standard_ledger/entry.rb +253 -0
- data/lib/standard_ledger/errors.rb +33 -0
- data/lib/standard_ledger/event_emitter.rb +50 -0
- data/lib/standard_ledger/jobs/matview_refresh_job.rb +28 -0
- data/lib/standard_ledger/modes/inline.rb +180 -0
- data/lib/standard_ledger/modes/matview.rb +115 -0
- data/lib/standard_ledger/modes/sql.rb +132 -0
- data/lib/standard_ledger/projection.rb +41 -0
- data/lib/standard_ledger/projector.rb +361 -0
- data/lib/standard_ledger/result.rb +51 -0
- data/lib/standard_ledger/rspec/helpers.rb +15 -0
- data/lib/standard_ledger/rspec/matchers.rb +148 -0
- data/lib/standard_ledger/rspec.rb +44 -0
- data/lib/standard_ledger/version.rb +3 -0
- data/lib/standard_ledger.rb +620 -0
- metadata +184 -0
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# StandardLedger configuration
|
|
2
|
+
# Generated by: rails g standard_ledger:install
|
|
3
|
+
#
|
|
4
|
+
# StandardLedger captures the recurring "immutable journal entry → N
|
|
5
|
+
# aggregate projections" pattern as a declarative DSL on host ActiveRecord
|
|
6
|
+
# models. See https://github.com/rarebit-one/standard_ledger for the gem
|
|
7
|
+
# README.
|
|
8
|
+
#
|
|
9
|
+
# All commented-out lines below show the public DSL — uncomment and edit
|
|
10
|
+
# the ones that apply to your app.
|
|
11
|
+
|
|
12
|
+
StandardLedger.configure do |c|
|
|
13
|
+
# ---------------------------------------------------------------------------
|
|
14
|
+
# Async mode
|
|
15
|
+
# ---------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
# The ActiveJob class used by `:async` mode projections. Defaults to
|
|
18
|
+
# `StandardLedger::ProjectionJob`. Hosts can supply their own job class
|
|
19
|
+
# (e.g. for custom queue routing or per-projection telemetry).
|
|
20
|
+
# Default: nil (resolved lazily to StandardLedger::ProjectionJob)
|
|
21
|
+
# c.default_async_job = Orders::FulfillableProjectionJob
|
|
22
|
+
|
|
23
|
+
# Number of times an `:async` projection will retry before dead-lettering.
|
|
24
|
+
# Default: 3.
|
|
25
|
+
# c.default_async_retries = 3
|
|
26
|
+
|
|
27
|
+
# ---------------------------------------------------------------------------
|
|
28
|
+
# Matview mode
|
|
29
|
+
# ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
# Scheduler backend for `:matview` refresh jobs.
|
|
32
|
+
# One of :solid_queue, :sidekiq_cron, :custom. Default: :solid_queue.
|
|
33
|
+
# c.scheduler = :solid_queue
|
|
34
|
+
|
|
35
|
+
# Default refresh strategy for `:matview` projections.
|
|
36
|
+
# :concurrent (REFRESH MATERIALIZED VIEW CONCURRENTLY — requires a unique
|
|
37
|
+
# index on the view) or :blocking. Default: :concurrent.
|
|
38
|
+
# c.matview_refresh_strategy = :concurrent
|
|
39
|
+
|
|
40
|
+
# ---------------------------------------------------------------------------
|
|
41
|
+
# Notifications
|
|
42
|
+
# ---------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
# Prefix for ActiveSupport::Notifications events emitted by the gem.
|
|
45
|
+
# Default: "standard_ledger". Events:
|
|
46
|
+
# <prefix>.entry.created
|
|
47
|
+
# <prefix>.projection.applied
|
|
48
|
+
# <prefix>.projection.failed
|
|
49
|
+
# c.notification_namespace = "standard_ledger"
|
|
50
|
+
|
|
51
|
+
# ---------------------------------------------------------------------------
|
|
52
|
+
# Host Result interop
|
|
53
|
+
# ---------------------------------------------------------------------------
|
|
54
|
+
# Optional: return the host's Result type from StandardLedger.post.
|
|
55
|
+
# When both result_class and result_adapter are set, post() returns
|
|
56
|
+
# instances of result_class via the adapter; otherwise it returns
|
|
57
|
+
# StandardLedger::Result.
|
|
58
|
+
#
|
|
59
|
+
# The adapter receives keyword args:
|
|
60
|
+
# success:, value:, errors:, entry:, idempotent:, projections:
|
|
61
|
+
#
|
|
62
|
+
# c.result_class = ApplicationOperation::Result
|
|
63
|
+
# c.result_adapter = ->(success:, value:, errors:, entry:, idempotent:, projections:) {
|
|
64
|
+
# ApplicationOperation::Result.new(success:, value: value || entry, errors:)
|
|
65
|
+
# }
|
|
66
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
module StandardLedger
|
|
2
|
+
# Host-configurable settings, populated via `StandardLedger.configure { |c| ... }`
|
|
3
|
+
# in an initializer. All attributes have sensible defaults; hosts only override
|
|
4
|
+
# what they need.
|
|
5
|
+
#
|
|
6
|
+
# @see StandardLedger.configure
|
|
7
|
+
class Config
|
|
8
|
+
# The ActiveJob class used by `:async` mode projections. Defaults to
|
|
9
|
+
# `StandardLedger::ProjectionJob`. Hosts can supply their own job class
|
|
10
|
+
# (e.g. for custom queue routing or per-projection telemetry) via
|
|
11
|
+
# `c.default_async_job = Orders::FulfillableProjectionJob`.
|
|
12
|
+
attr_accessor :default_async_job
|
|
13
|
+
|
|
14
|
+
# Number of times an `:async` projection will retry before dead-lettering.
|
|
15
|
+
# Default: 3.
|
|
16
|
+
attr_accessor :default_async_retries
|
|
17
|
+
|
|
18
|
+
# Scheduler backend for `:matview` refresh jobs. One of
|
|
19
|
+
# `:solid_queue`, `:sidekiq_cron`, `:custom`. Default: `:solid_queue`
|
|
20
|
+
# (matches all four consuming apps).
|
|
21
|
+
attr_accessor :scheduler
|
|
22
|
+
|
|
23
|
+
# Default refresh strategy for `:matview` projections. Either
|
|
24
|
+
# `:concurrent` (REFRESH MATERIALIZED VIEW CONCURRENTLY — requires a
|
|
25
|
+
# unique index on the view) or `:blocking`. Default: `:concurrent`.
|
|
26
|
+
attr_accessor :matview_refresh_strategy
|
|
27
|
+
|
|
28
|
+
# Optional: the host application's Result class. When set together with
|
|
29
|
+
# `result_adapter`, `StandardLedger.post` returns instances of this class
|
|
30
|
+
# instead of `StandardLedger::Result`.
|
|
31
|
+
attr_accessor :result_class
|
|
32
|
+
|
|
33
|
+
# Optional: a callable that translates the gem's result fields into the
|
|
34
|
+
# host's Result type. Receives keyword args:
|
|
35
|
+
# `success:, value:, errors:, entry:, idempotent:, projections:`.
|
|
36
|
+
# Required when `result_class` is set.
|
|
37
|
+
attr_accessor :result_adapter
|
|
38
|
+
|
|
39
|
+
# Prefix for `ActiveSupport::Notifications` events emitted by the gem.
|
|
40
|
+
# Default: `"standard_ledger"`. Events:
|
|
41
|
+
# `<prefix>.entry.created`, `<prefix>.projection.applied`,
|
|
42
|
+
# `<prefix>.projection.failed`, `<prefix>.projection.refreshed`,
|
|
43
|
+
# `<prefix>.projection.rebuilt`.
|
|
44
|
+
attr_accessor :notification_namespace
|
|
45
|
+
|
|
46
|
+
def initialize
|
|
47
|
+
@default_async_job = nil # resolved lazily to avoid loading the job constant before Rails boots
|
|
48
|
+
@default_async_retries = 3
|
|
49
|
+
@scheduler = :solid_queue
|
|
50
|
+
@matview_refresh_strategy = :concurrent
|
|
51
|
+
@result_class = nil
|
|
52
|
+
@result_adapter = nil
|
|
53
|
+
@notification_namespace = "standard_ledger"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# True when the host has wired up its own Result type. When false, the gem
|
|
57
|
+
# returns its built-in `StandardLedger::Result`.
|
|
58
|
+
def custom_result?
|
|
59
|
+
!result_class.nil? && !result_adapter.nil?
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
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; hosts
|
|
16
|
+
# subscribe directly via ActiveSupport::Notifications.subscribe.
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
require "active_support/concern"
|
|
2
|
+
|
|
3
|
+
module StandardLedger
|
|
4
|
+
# Marks an ActiveRecord model as a ledger entry: an immutable, append-only
|
|
5
|
+
# row that may project onto one or more aggregate targets.
|
|
6
|
+
#
|
|
7
|
+
# Including this concern installs:
|
|
8
|
+
# - the `ledger_entry` class macro (declares immutability + idempotency)
|
|
9
|
+
# - read-only behavior post-creation (when `immutable: true`, the default)
|
|
10
|
+
# - idempotency-by-unique-index (when `idempotency_key:` is non-nil)
|
|
11
|
+
#
|
|
12
|
+
# Projection registration happens via the separate `Projector` concern —
|
|
13
|
+
# the two are decoupled so that an entry can be marked immutable without
|
|
14
|
+
# also opting into projections, and vice versa.
|
|
15
|
+
#
|
|
16
|
+
# @example
|
|
17
|
+
# class VoucherRecord < ApplicationRecord
|
|
18
|
+
# include StandardLedger::Entry
|
|
19
|
+
#
|
|
20
|
+
# ledger_entry kind: :action,
|
|
21
|
+
# idempotency_key: :serial_no,
|
|
22
|
+
# scope: :organisation_id
|
|
23
|
+
# end
|
|
24
|
+
module Entry
|
|
25
|
+
extend ActiveSupport::Concern
|
|
26
|
+
|
|
27
|
+
class_methods do
|
|
28
|
+
# Declare the entry's contract. Stores the configuration on the class
|
|
29
|
+
# for later inspection by `StandardLedger.post`, `Projection.rebuild!`,
|
|
30
|
+
# and the `standard_ledger:doctor` rake task.
|
|
31
|
+
#
|
|
32
|
+
# @param kind [Symbol] the column holding the entry's kind/action
|
|
33
|
+
# discriminator. Defaults to `:kind`.
|
|
34
|
+
# @param idempotency_key [Symbol, nil] the column whose unique index
|
|
35
|
+
# guards against duplicate inserts. `nil` means the entry is not
|
|
36
|
+
# idempotent — explicitly opt-in to that.
|
|
37
|
+
# @param scope [Symbol, Array<Symbol>, nil] additional columns the
|
|
38
|
+
# idempotency index is scoped by (e.g. `:organisation_id`).
|
|
39
|
+
# @param immutable [Boolean] when true (default), `save`/`update`/
|
|
40
|
+
# `destroy` raise after the row is persisted.
|
|
41
|
+
def ledger_entry(kind: :kind, idempotency_key: nil, scope: nil, immutable: true)
|
|
42
|
+
self.standard_ledger_entry_config = {
|
|
43
|
+
kind: kind,
|
|
44
|
+
idempotency_key: idempotency_key,
|
|
45
|
+
scope: Array(scope).compact,
|
|
46
|
+
immutable: immutable
|
|
47
|
+
}
|
|
48
|
+
self.standard_ledger_idempotency_index_validated = false
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def standard_ledger_entry?
|
|
52
|
+
!standard_ledger_entry_config.nil?
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Override AR's `create!` to add idempotency-by-unique-index semantics.
|
|
56
|
+
# When the configured unique constraint trips, look up and return the
|
|
57
|
+
# existing row with `idempotent? == true` instead of raising.
|
|
58
|
+
#
|
|
59
|
+
# @note Block-form `create! { |r| r.field = val }` is not supported for
|
|
60
|
+
# the idempotent rescue: AR passes `attributes = nil` in that path so
|
|
61
|
+
# we can't construct the find_by lookup. The rescue still functions
|
|
62
|
+
# for the rest of the create — a colliding insert from a block-form
|
|
63
|
+
# call simply re-raises `RecordNotUnique` like vanilla ActiveRecord.
|
|
64
|
+
def create!(attributes = nil, &block)
|
|
65
|
+
config = standard_ledger_entry_config
|
|
66
|
+
return super if config.nil? || config[:idempotency_key].nil?
|
|
67
|
+
|
|
68
|
+
validate_standard_ledger_idempotency_index!
|
|
69
|
+
|
|
70
|
+
super
|
|
71
|
+
rescue ActiveRecord::RecordNotUnique => e
|
|
72
|
+
raise unless standard_ledger_idempotency_violation?(e)
|
|
73
|
+
|
|
74
|
+
existing = find_existing_standard_ledger_entry(attributes)
|
|
75
|
+
raise if existing.nil?
|
|
76
|
+
|
|
77
|
+
existing.instance_variable_set(:@_standard_ledger_idempotent, true)
|
|
78
|
+
existing
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Verify that the table has a unique index covering exactly
|
|
82
|
+
# `[*scope, idempotency_key]` (column set equality; order-insensitive).
|
|
83
|
+
# Cached so the introspection runs once per class.
|
|
84
|
+
#
|
|
85
|
+
# The check-then-set on `standard_ledger_idempotency_index_validated`
|
|
86
|
+
# has a benign race: two threads can both observe `false`, both run the
|
|
87
|
+
# introspection, and both flip the flag to `true`. That's intentional
|
|
88
|
+
# — the validation is pure and idempotent, so duplicate work is cheap
|
|
89
|
+
# and the result is identical. No mutex needed; do not add one.
|
|
90
|
+
def validate_standard_ledger_idempotency_index!
|
|
91
|
+
return if standard_ledger_idempotency_index_validated
|
|
92
|
+
|
|
93
|
+
config = standard_ledger_entry_config
|
|
94
|
+
return if config.nil? || config[:idempotency_key].nil?
|
|
95
|
+
|
|
96
|
+
required = (config[:scope] + [ config[:idempotency_key] ]).map(&:to_s).to_set
|
|
97
|
+
indexes = connection.indexes(table_name)
|
|
98
|
+
|
|
99
|
+
match = indexes.any? do |index|
|
|
100
|
+
index.unique && index.columns.map(&:to_s).to_set == required
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
unless match
|
|
104
|
+
raise StandardLedger::MissingIdempotencyIndex,
|
|
105
|
+
"#{name} declares idempotency_key: #{config[:idempotency_key].inspect} " \
|
|
106
|
+
"with scope: #{config[:scope].inspect} but no matching unique index " \
|
|
107
|
+
"covers exactly #{required.to_a.sort.inspect} on `#{table_name}`."
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
self.standard_ledger_idempotency_index_validated = true
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
private
|
|
114
|
+
|
|
115
|
+
def find_existing_standard_ledger_entry(attributes)
|
|
116
|
+
return nil if attributes.nil?
|
|
117
|
+
|
|
118
|
+
config = standard_ledger_entry_config
|
|
119
|
+
lookup_columns = config[:scope] + [ config[:idempotency_key] ]
|
|
120
|
+
attrs = attributes.is_a?(Hash) ? attributes.transform_keys(&:to_sym) : {}
|
|
121
|
+
lookup = lookup_columns.each_with_object({}) do |col, memo|
|
|
122
|
+
memo[col] = attrs[col.to_sym]
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Bail if any lookup value is nil — `find_by` would emit
|
|
126
|
+
# `WHERE col IS NULL` and could match an unrelated row whose column
|
|
127
|
+
# legitimately holds NULL. We require all idempotency columns to be
|
|
128
|
+
# present in `attributes` to make a confident match.
|
|
129
|
+
return nil if lookup.any? { |_, value| value.nil? }
|
|
130
|
+
|
|
131
|
+
find_by(lookup)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Confirm the RecordNotUnique was raised by *our* idempotency index,
|
|
135
|
+
# not some other unique constraint on the table (surrogate key,
|
|
136
|
+
# business column, etc.). The wrapped DB exception's message usually
|
|
137
|
+
# mentions the index name or the column list — a substring match on
|
|
138
|
+
# each idempotency column name is good enough across PostgreSQL and
|
|
139
|
+
# SQLite without parsing vendor-specific formats.
|
|
140
|
+
#
|
|
141
|
+
# Adapter caveat: MySQL's unique-violation message contains only the
|
|
142
|
+
# index name (e.g. `Duplicate entry 'val' for key 'idx_name'`), not
|
|
143
|
+
# the column list. So this check returns false on MySQL unless the
|
|
144
|
+
# index is named after its columns. The fail-closed behavior re-raises
|
|
145
|
+
# the original RecordNotUnique, which is the correct outcome for an
|
|
146
|
+
# unrecognized violation — never the wrong one for a misclassified
|
|
147
|
+
# one. None of the host apps target MySQL today; revisit if that
|
|
148
|
+
# changes.
|
|
149
|
+
def standard_ledger_idempotency_violation?(exception)
|
|
150
|
+
config = standard_ledger_entry_config
|
|
151
|
+
columns = (config[:scope] + [ config[:idempotency_key] ]).map(&:to_s)
|
|
152
|
+
message = String(exception.message) + String(exception.cause&.message)
|
|
153
|
+
|
|
154
|
+
columns.all? { |col| message.include?(col) }
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
included do
|
|
159
|
+
class_attribute :standard_ledger_entry_config, instance_writer: false
|
|
160
|
+
class_attribute :standard_ledger_idempotency_index_validated, instance_writer: false
|
|
161
|
+
self.standard_ledger_entry_config = nil
|
|
162
|
+
self.standard_ledger_idempotency_index_validated = false
|
|
163
|
+
|
|
164
|
+
# The destroy guard only matters for AR includers (the production case);
|
|
165
|
+
# plain Ruby classes that include Entry for testing the DSL surface
|
|
166
|
+
# get the macro registration without the callback. AR's `readonly?`
|
|
167
|
+
# path covers save/update on persisted rows; this catch-all stops
|
|
168
|
+
# `destroy` for the AR case.
|
|
169
|
+
if respond_to?(:before_destroy)
|
|
170
|
+
before_destroy :standard_ledger_raise_readonly, if: :standard_ledger_immutable?
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Emit `<namespace>.entry.created` after the row is durably committed
|
|
174
|
+
# so subscribers (audit logs, metric pipelines) see it only when the
|
|
175
|
+
# entry is real. Idempotent returns from `create!`'s rescue do not
|
|
176
|
+
# fire `after_commit on: :create` (no INSERT happened), which is the
|
|
177
|
+
# correct behavior: the original write fired the event already.
|
|
178
|
+
if respond_to?(:after_commit)
|
|
179
|
+
after_commit :standard_ledger_emit_entry_created, on: :create
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Returns true when this row was returned from an idempotent `create!`
|
|
184
|
+
# rescue — i.e. an existing row matched the unique constraint and was
|
|
185
|
+
# returned instead of inserted.
|
|
186
|
+
def idempotent?
|
|
187
|
+
!!@_standard_ledger_idempotent
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# AR consults `readonly?` from `save`/`update` paths; raising
|
|
191
|
+
# ReadOnlyRecord here matches the ActiveRecord contract for persisted
|
|
192
|
+
# immutable rows. New, unpersisted instances stay writable so the
|
|
193
|
+
# initial INSERT can land.
|
|
194
|
+
def readonly?
|
|
195
|
+
return super unless standard_ledger_immutable?
|
|
196
|
+
|
|
197
|
+
!new_record?
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Returns the entry's belongs_to targets keyed by association name.
|
|
201
|
+
# Used by the `entry.created` notification payload and by
|
|
202
|
+
# `StandardLedger.post`'s telemetry. Skips polymorphic and missing
|
|
203
|
+
# associations so the payload only includes what's actually present.
|
|
204
|
+
#
|
|
205
|
+
# Performance trade-off: this fires from `after_commit`, where AR may
|
|
206
|
+
# have cleared the association cache. Each `public_send(reflection.name)`
|
|
207
|
+
# can therefore issue a SELECT to reload the cached target. For the
|
|
208
|
+
# typical 1–2 belongs_to entry, that's negligible. If profiling on a
|
|
209
|
+
# high-cardinality entry shows this matters, capture targets earlier
|
|
210
|
+
# (e.g. in `before_create`) and stash them on the instance — deferred
|
|
211
|
+
# to a future PR. Notably, an inline-mode caller has already resolved
|
|
212
|
+
# these targets by the time `after_commit` runs, so the SELECTs would
|
|
213
|
+
# only happen for entries with belongs_to associations that are *not*
|
|
214
|
+
# registered as projection targets.
|
|
215
|
+
#
|
|
216
|
+
# @return [Hash{Symbol => ActiveRecord::Base}]
|
|
217
|
+
def standard_ledger_targets
|
|
218
|
+
return {} unless self.class.respond_to?(:reflect_on_all_associations)
|
|
219
|
+
|
|
220
|
+
self.class.reflect_on_all_associations(:belongs_to).each_with_object({}) do |reflection, memo|
|
|
221
|
+
next if reflection.polymorphic?
|
|
222
|
+
|
|
223
|
+
target = public_send(reflection.name)
|
|
224
|
+
memo[reflection.name] = target unless target.nil?
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
private
|
|
229
|
+
|
|
230
|
+
def standard_ledger_immutable?
|
|
231
|
+
config = self.class.standard_ledger_entry_config
|
|
232
|
+
!config.nil? && config[:immutable]
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def standard_ledger_raise_readonly
|
|
236
|
+
raise ActiveRecord::ReadOnlyRecord
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# Publish `<namespace>.entry.created` once the row is durably committed.
|
|
240
|
+
# `after_commit on: :create` only fires for real INSERTs, so idempotent
|
|
241
|
+
# returns from the `create!` rescue path are correctly skipped.
|
|
242
|
+
def standard_ledger_emit_entry_created
|
|
243
|
+
config = self.class.standard_ledger_entry_config
|
|
244
|
+
kind_value = config ? public_send(config[:kind]) : nil
|
|
245
|
+
prefix = StandardLedger.config.notification_namespace
|
|
246
|
+
|
|
247
|
+
StandardLedger::EventEmitter.emit(
|
|
248
|
+
"#{prefix}.entry.created",
|
|
249
|
+
entry: self, kind: kind_value, targets: standard_ledger_targets
|
|
250
|
+
)
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
module StandardLedger
|
|
2
|
+
class Error < StandardError; end
|
|
3
|
+
|
|
4
|
+
# Raised at registration time when a `projects_onto` block declares an
|
|
5
|
+
# `on(:kind)` for a kind that the entry's enum/set does not include, or
|
|
6
|
+
# when an entry is posted with a kind that no projection has registered
|
|
7
|
+
# a handler for. Use `permissive: true` on the projection to opt out.
|
|
8
|
+
class UnhandledKind < Error; end
|
|
9
|
+
|
|
10
|
+
# Raised by `StandardLedger.rebuild!` when the projector does not implement
|
|
11
|
+
# `rebuild` and is therefore not replayable from the entry log. Delta-based
|
|
12
|
+
# projectors (e.g. `increment_counter`-flavored) typically raise this
|
|
13
|
+
# because they cannot be reconstructed without summing the full log.
|
|
14
|
+
class NotRebuildable < Error; end
|
|
15
|
+
|
|
16
|
+
# Raised at boot when an Entry declares `idempotency_key:` but no matching
|
|
17
|
+
# unique index exists on the entry table. Caught early instead of silently
|
|
18
|
+
# admitting duplicates at runtime.
|
|
19
|
+
class MissingIdempotencyIndex < Error; end
|
|
20
|
+
|
|
21
|
+
# Raised by `StandardLedger.post` when the inline portion of fan-out
|
|
22
|
+
# succeeded but enqueuing one or more async projections failed. The entry
|
|
23
|
+
# itself is durable; callers may choose to roll back or accept-and-log.
|
|
24
|
+
class PartialFailure < Error
|
|
25
|
+
attr_reader :enqueued, :failed
|
|
26
|
+
|
|
27
|
+
def initialize(enqueued:, failed:)
|
|
28
|
+
@enqueued = enqueued
|
|
29
|
+
@failed = failed
|
|
30
|
+
super("Enqueued #{enqueued.size} projections; #{failed.size} failed to enqueue")
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
module StandardLedger
|
|
2
|
+
# Internal helper that emits StandardLedger lifecycle events through whichever
|
|
3
|
+
# event reporter is live in the host process.
|
|
4
|
+
#
|
|
5
|
+
# - On Rails 8.1+, `Rails.event.notify(name, **payload)` is the canonical bus.
|
|
6
|
+
# - On older Rails (or any host without the structured reporter), we fall back
|
|
7
|
+
# to `ActiveSupport::Notifications.instrument(name, payload)`.
|
|
8
|
+
#
|
|
9
|
+
# Detection is performed at *call time* — the gem is required before Rails has
|
|
10
|
+
# finished booting, so we cannot cache the decision at load time.
|
|
11
|
+
#
|
|
12
|
+
# @api private
|
|
13
|
+
module EventEmitter
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
# Emit a single event. Both backends are best-effort: any exception raised
|
|
17
|
+
# by a subscriber is swallowed so ledger observability never takes down a
|
|
18
|
+
# host's request path (the projection has already either succeeded or
|
|
19
|
+
# been rolled back by the time we emit).
|
|
20
|
+
def emit(event_name, payload)
|
|
21
|
+
if (bus = rails_event_bus)
|
|
22
|
+
bus.notify(event_name, **payload)
|
|
23
|
+
else
|
|
24
|
+
::ActiveSupport::Notifications.instrument(event_name, payload)
|
|
25
|
+
end
|
|
26
|
+
rescue => e
|
|
27
|
+
warn "[StandardLedger] event emit for #{event_name.inspect} failed: #{e.class}: #{e.message}"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Returns the Rails 8.1+ structured event bus when available, or `nil`
|
|
31
|
+
# to signal the AS::Notifications fallback. Single accessor so `emit`
|
|
32
|
+
# invokes `Rails.event` only once per call.
|
|
33
|
+
def rails_event_bus
|
|
34
|
+
return nil unless defined?(::Rails) &&
|
|
35
|
+
::Rails.respond_to?(:event) &&
|
|
36
|
+
::Rails.event.respond_to?(:notify)
|
|
37
|
+
|
|
38
|
+
::Rails.event
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Boolean shorthand kept for callers (and specs) that just want to know
|
|
42
|
+
# whether the modern bus is live.
|
|
43
|
+
def rails_event_available?
|
|
44
|
+
!rails_event_bus.nil?
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Hides the singleton copy that `module_function` generated above.
|
|
48
|
+
private_class_method :rails_event_bus
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
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
|