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.
@@ -0,0 +1,180 @@
1
+ module StandardLedger
2
+ module Modes
3
+ # `:inline` mode: applies the projection inside the entry's `after_create`
4
+ # callback, which fires while the host's outer transaction is still open.
5
+ # If the host's transaction rolls back, the projection rolls back too.
6
+ #
7
+ # This is the default for delta-based counter updates. For complex
8
+ # projectors (jsonb shape, multi-row aggregates), use `:async` instead.
9
+ #
10
+ # The strategy is invoked from a single `after_create` callback installed
11
+ # once per entry class (see `.install!`). The callback walks every
12
+ # `:inline`-mode projection registered on the class and runs each via
13
+ # `entry.apply_projection!(definition)`.
14
+ #
15
+ # ## Multi-counter coalescing
16
+ #
17
+ # A single host might register four `on(:grant)`/`on(:redeem)`/... handlers
18
+ # against the same target, each calling `target.increment(:some_count)`.
19
+ # ActiveRecord's `#increment` (vs. `#increment!`) only mutates the
20
+ # in-memory attribute — no SQL is issued — so the strategy persists with
21
+ # a single `target.save!` per (entry, target) pair after all handlers for
22
+ # that target have run. This collapses N handlers into one UPDATE.
23
+ #
24
+ # Definitions targeting different associations get their own
25
+ # apply-then-save cycle, executed in the order the projections were
26
+ # declared.
27
+ #
28
+ # ## Lock semantics
29
+ #
30
+ # When any projection in a per-target group declares `lock: :pessimistic`,
31
+ # the strategy wraps the **entire** apply-then-save cycle for that target
32
+ # in `target.with_lock { ... }`. The lock spans both handler invocation
33
+ # and the coalesced `save!`, so concurrent posts to the same target
34
+ # serialize end-to-end — closing the lost-update window that an
35
+ # inner-only lock would leave open between the lock release and the save.
36
+ # See `standard_ledger-design.md` §5.3.1.
37
+ class Inline
38
+ # Install the `after_create` callback on `entry_class` exactly once.
39
+ # Subsequent calls (e.g. when a second `:inline` projection is added
40
+ # later in the class body) are no-ops — the same callback handles all
41
+ # `:inline` projections registered on the class.
42
+ #
43
+ # @param entry_class [Class] the host entry class.
44
+ # @return [void]
45
+ # @raise [ArgumentError] when `entry_class` is not ActiveRecord-backed
46
+ # (no `after_create` hook available). `:inline` mode requires AR
47
+ # transactional callbacks; non-AR entry classes must use a different
48
+ # mode (or refrain from declaring `:inline` projections).
49
+ def self.install!(entry_class)
50
+ return if entry_class.instance_variable_get(:@_standard_ledger_inline_installed)
51
+
52
+ unless entry_class.respond_to?(:after_create)
53
+ raise ArgumentError,
54
+ "#{entry_class.name || entry_class.inspect} cannot use mode: :inline " \
55
+ "because it does not respond to `after_create`. `:inline` mode requires " \
56
+ "an ActiveRecord-backed entry class — use `:async` (or another mode) for " \
57
+ "non-AR includers."
58
+ end
59
+
60
+ entry_class.after_create { StandardLedger::Modes::Inline.new.call(self) }
61
+ entry_class.instance_variable_set(:@_standard_ledger_inline_installed, true)
62
+ end
63
+
64
+ # Apply every `:inline` projection registered on the entry's class.
65
+ # Called from the `after_create` callback installed by `.install!`.
66
+ #
67
+ # Projections targeting the same association coalesce: all handlers
68
+ # for that target run, then the target is saved once. Different
69
+ # targets get their own apply+save cycle, in declared order. When any
70
+ # definition in a per-target group sets `lock: :pessimistic`, the
71
+ # cycle (apply + save) is wrapped in `target.with_lock`.
72
+ #
73
+ # Records the names of projections that actually ran (after `if:`
74
+ # guards filter) on the entry instance under
75
+ # `@_standard_ledger_applied_projections`, so `StandardLedger.post`
76
+ # can surface an accurate `result.projections[:inline]`.
77
+ #
78
+ # Any projector exception escapes — the entry's transaction rolls
79
+ # back along with every counter mutation that ran before the failure.
80
+ # The `standard_ledger.projection.failed` notification fires before
81
+ # the re-raise so subscribers see the failed projection in payload.
82
+ #
83
+ # @param entry [ActiveRecord::Base] the just-created entry.
84
+ # @return [void]
85
+ def call(entry)
86
+ definitions = inline_definitions_for(entry.class)
87
+ return if definitions.empty?
88
+
89
+ applied = []
90
+ entry.instance_variable_set(:@_standard_ledger_applied_projections, applied)
91
+
92
+ # group_by preserves insertion order on Ruby >= 1.9, so declared
93
+ # projection order is preserved across targets. Within a target
94
+ # group, handlers run in declared order as well.
95
+ definitions.group_by(&:target_association).each_value do |group|
96
+ target = entry.public_send(group.first.target_association)
97
+ locked = group.any? { |definition| definition.lock == :pessimistic }
98
+
99
+ run_group = lambda do
100
+ group.each do |definition|
101
+ ran = instrument_projection(entry, target, definition) do
102
+ entry.apply_projection!(definition)
103
+ end
104
+ applied << definition.target_association if ran && !applied.include?(definition.target_association)
105
+ end
106
+
107
+ # Coalesce: if any handler called `target.increment(col)` (which
108
+ # mutates in-memory only), persist the accumulated changes with a
109
+ # single UPDATE. Skipped when the target is nil (apply_projection!
110
+ # short-circuits) or when no handler dirtied the record. The
111
+ # `target` here always responds to AR's `changed?`/`save!` because
112
+ # it's resolved from a `belongs_to` reflection.
113
+ target.save! if target && target.changed?
114
+ end
115
+
116
+ if locked && target.respond_to?(:with_lock)
117
+ target.with_lock(&run_group)
118
+ else
119
+ run_group.call
120
+ end
121
+ end
122
+ end
123
+
124
+ private
125
+
126
+ def inline_definitions_for(entry_class)
127
+ return [] unless entry_class.respond_to?(:standard_ledger_projections_for)
128
+
129
+ entry_class.standard_ledger_projections_for(:inline)
130
+ end
131
+
132
+ # Wrap each handler invocation so subscribers see exactly one
133
+ # `applied` event per projection on success or one `failed` event on
134
+ # raise. Two distinct events (rather than letting `instrument`
135
+ # package success-or-failure into a single event) because the design
136
+ # splits the two outcomes — see §5.7.
137
+ #
138
+ # When the wrapped `apply_projection!` short-circuits (returns false
139
+ # because of a guard, nil target, or no-handler permissive miss), no
140
+ # `applied` event fires — there's no work to report. The exception
141
+ # path always fires `failed` so observability of failures isn't
142
+ # contingent on guard logic.
143
+ #
144
+ # The exception is re-raised so the entry's transaction rolls back —
145
+ # the notification's payload carries the error for observers
146
+ # regardless of whether the listener swallows or re-raises.
147
+ #
148
+ # @return [Boolean] the truthy/falsy result of the wrapped block —
149
+ # used by the caller to decide whether to record this projection
150
+ # in the entry's `applied` list.
151
+ def instrument_projection(entry, target, definition)
152
+ prefix = StandardLedger.config.notification_namespace
153
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
154
+
155
+ ran =
156
+ begin
157
+ yield
158
+ rescue StandardError => e
159
+ duration_ms = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000.0
160
+ StandardLedger::EventEmitter.emit(
161
+ "#{prefix}.projection.failed",
162
+ entry: entry, target: target, projection: definition.target_association,
163
+ mode: :inline, error: e, duration_ms: duration_ms
164
+ )
165
+ raise
166
+ end
167
+
168
+ return false unless ran
169
+
170
+ duration_ms = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000.0
171
+ StandardLedger::EventEmitter.emit(
172
+ "#{prefix}.projection.applied",
173
+ entry: entry, target: target, projection: definition.target_association,
174
+ mode: :inline, duration_ms: duration_ms
175
+ )
176
+ true
177
+ end
178
+ end
179
+ end
180
+ end
@@ -0,0 +1,115 @@
1
+ module StandardLedger
2
+ module Modes
3
+ # `:matview` mode: backs the projection with a host-owned PostgreSQL
4
+ # materialized view. The host creates and owns the view (via a migration —
5
+ # `scenic` or hand-rolled SQL); the gem owns the refresh schedule and the
6
+ # ad-hoc refresh API.
7
+ #
8
+ # Unlike `:inline`, this strategy does NOT install a per-entry callback —
9
+ # matview projections are scheduled, not entry-driven. The strategy's job
10
+ # is to (a) record the matview registration on the entry class so callers
11
+ # can enumerate `:matview` projections (used by `StandardLedger.rebuild!`
12
+ # and the host's scheduler wiring), and (b) provide the `#refresh!`
13
+ # primitive that issues the actual `REFRESH MATERIALIZED VIEW` SQL.
14
+ #
15
+ # Hosts wire their scheduler (SolidQueue Recurring Tasks, sidekiq-cron,
16
+ # etc.) at `StandardLedger::MatviewRefreshJob` to drive scheduled
17
+ # refreshes. Ad-hoc refreshes go through `StandardLedger.refresh!` for
18
+ # read-your-write semantics after a critical write.
19
+ #
20
+ # See `standard_ledger-design.md` §5.3.5 for the full contract.
21
+ class Matview
22
+ # Mark the entry class as having at least one `:matview` projection
23
+ # registered. The actual matview definition lives on the entry class's
24
+ # `standard_ledger_projections` array; this method exists only to
25
+ # mirror the `Modes::Inline.install!` shape and to mark the class so
26
+ # repeated registrations are recognised as idempotent installs.
27
+ #
28
+ # Idempotent — multiple `:matview` projections on the same entry class
29
+ # do not cause double registration. Unlike `:inline`, no `after_create`
30
+ # callback is installed.
31
+ #
32
+ # @param entry_class [Class] the host entry class.
33
+ # @return [void]
34
+ def self.install!(entry_class)
35
+ return if entry_class.instance_variable_get(:@_standard_ledger_matview_installed)
36
+
37
+ entry_class.instance_variable_set(:@_standard_ledger_matview_installed, true)
38
+ end
39
+
40
+ # Issue `REFRESH MATERIALIZED VIEW [CONCURRENTLY] <view_name>` against
41
+ # the active connection and emit the standard `<prefix>.projection.refreshed`
42
+ # notification on success (or `<prefix>.projection.failed` on raise,
43
+ # before re-raising). The view name is validated against a SQL-identifier
44
+ # regex at the boundary as a defence-in-depth check — the value normally
45
+ # comes from a `view:` DSL keyword in source code, but a careless host
46
+ # could pass through a config value or other untrusted string. Anything
47
+ # that isn't a bare or schema-qualified identifier raises.
48
+ #
49
+ # The default `:concurrent` strategy (and `concurrently: true` per-call)
50
+ # requires a unique index on the matview — Postgres rejects
51
+ # `REFRESH MATERIALIZED VIEW CONCURRENTLY` otherwise. Hosts who haven't
52
+ # added one should pass `concurrently: false` or set
53
+ # `Config#matview_refresh_strategy = :blocking`.
54
+ #
55
+ # @param view_name [String, Symbol] the materialized view to refresh.
56
+ # Must match `/\A[a-zA-Z_][a-zA-Z0-9_.]*\z/` so a single dot is
57
+ # permitted for `schema.view` qualified names.
58
+ # @param concurrently [Boolean] when true, adds `CONCURRENTLY` (which
59
+ # requires a unique index on the view).
60
+ # @return [void]
61
+ # @raise [ArgumentError] when `view_name` is not a valid SQL identifier.
62
+ # @raise [StandardError] anything the connection raises while running
63
+ # the SQL — re-raised after the `failed` event fires.
64
+ def self.refresh!(view_name, concurrently:)
65
+ validate_view_name!(view_name)
66
+
67
+ prefix = StandardLedger.config.notification_namespace
68
+ sql = build_refresh_sql(view_name, concurrently: concurrently)
69
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
70
+
71
+ ActiveRecord::Base.connection.execute(sql)
72
+
73
+ duration_ms = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000.0
74
+ StandardLedger::EventEmitter.emit(
75
+ "#{prefix}.projection.refreshed",
76
+ view: view_name.to_s, concurrently: concurrently, duration_ms: duration_ms
77
+ )
78
+ rescue StandardError => e
79
+ # ArgumentError from the validator should propagate without firing
80
+ # the failed notification — the SQL was never issued.
81
+ raise if e.is_a?(ArgumentError)
82
+
83
+ StandardLedger::EventEmitter.emit(
84
+ "#{prefix}.projection.failed",
85
+ view: view_name.to_s, concurrently: concurrently, mode: :matview, error: e
86
+ )
87
+ raise
88
+ end
89
+
90
+ # Reject anything that isn't a bare or schema-qualified SQL identifier
91
+ # — the matching regex allows a leading letter/underscore followed by
92
+ # alphanumerics, underscores, or a single dot for `schema.view`. Names
93
+ # containing semicolons, quotes, comment markers (`--`), whitespace, or
94
+ # other punctuation are rejected at the `refresh!` boundary so SQL
95
+ # injection isn't possible even when a host carelessly pipes a config
96
+ # value into the call.
97
+ def self.validate_view_name!(view_name)
98
+ return if view_name.to_s.match?(/\A[a-zA-Z_][a-zA-Z0-9_.]*\z/)
99
+
100
+ raise ArgumentError,
101
+ "view_name must be a valid SQL identifier; got #{view_name.inspect}"
102
+ end
103
+ private_class_method :validate_view_name!
104
+
105
+ def self.build_refresh_sql(view_name, concurrently:)
106
+ if concurrently
107
+ "REFRESH MATERIALIZED VIEW CONCURRENTLY #{view_name}"
108
+ else
109
+ "REFRESH MATERIALIZED VIEW #{view_name}"
110
+ end
111
+ end
112
+ private_class_method :build_refresh_sql
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,132 @@
1
+ module StandardLedger
2
+ module Modes
3
+ # `:sql` mode: applies the projection by running a single recompute
4
+ # `UPDATE` against the target table, inside the entry's `after_create`
5
+ # callback. The recompute SQL is supplied via the block-DSL's
6
+ # `recompute "..."` clause; the `:target_id` placeholder is bound to
7
+ # the foreign-key value the entry holds for the target association.
8
+ #
9
+ # Lower-overhead than `:inline` for projections expressible as
10
+ # `UPDATE target SET col = (SELECT aggregate(...) FROM entries WHERE
11
+ # ...)` — there's no Ruby-side handler invocation, no per-counter
12
+ # in-memory mutation, and no AR object load. Naturally rebuildable:
13
+ # `StandardLedger.rebuild!` runs the same statement against each
14
+ # target the log references, so the `after_create` and `rebuild!`
15
+ # paths share the same recompute SQL.
16
+ #
17
+ # Like `:inline`, this fires from `after_create` (in the entry's
18
+ # transaction), so failures roll the entry back alongside the
19
+ # projection. The notification payload's `:target` field is `nil` for
20
+ # `:sql` mode — loading the target object would defeat the point of
21
+ # the mode (it's meant to avoid Ruby-side reads). Subscribers get
22
+ # `entry`, the projection definition, and the timing.
23
+ class Sql
24
+ # Install the `after_create` callback on `entry_class` exactly once.
25
+ # Subsequent calls (e.g. when a second `:sql` projection is added
26
+ # later in the class body) are no-ops — the same callback handles
27
+ # all `:sql` projections registered on the class.
28
+ #
29
+ # @param entry_class [Class] the host entry class.
30
+ # @return [void]
31
+ # @raise [ArgumentError] when `entry_class` is not ActiveRecord-backed
32
+ # (no `after_create` hook available). `:sql` mode requires an
33
+ # AR-backed entry class — the recompute SQL runs through
34
+ # `entry.class.connection.exec_update`.
35
+ def self.install!(entry_class)
36
+ return if entry_class.instance_variable_get(:@_standard_ledger_sql_installed)
37
+
38
+ unless entry_class.respond_to?(:after_create)
39
+ raise ArgumentError,
40
+ "#{entry_class.name || entry_class.inspect} cannot use mode: :sql " \
41
+ "because it does not respond to `after_create`. `:sql` mode requires " \
42
+ "an ActiveRecord-backed entry class."
43
+ end
44
+
45
+ entry_class.after_create { StandardLedger::Modes::Sql.new.call(self) }
46
+ entry_class.instance_variable_set(:@_standard_ledger_sql_installed, true)
47
+ end
48
+
49
+ # Apply every `:sql` projection registered on the entry's class.
50
+ # Called from the `after_create` callback installed by `.install!`.
51
+ #
52
+ # For each definition: resolve the target's foreign key from the
53
+ # entry, evaluate the optional `if:` guard, and run the recompute
54
+ # SQL with `:target_id` bound to the FK value.
55
+ #
56
+ # A nil FK (target unset for this entry) is silently skipped — the
57
+ # entry simply doesn't project onto a target this round. A guard
58
+ # returning false is also silently skipped.
59
+ #
60
+ # Any exception raised by the SQL escapes — the entry's transaction
61
+ # rolls back with the projection. The
62
+ # `<prefix>.projection.failed` notification fires before the
63
+ # re-raise so subscribers see the failure.
64
+ #
65
+ # @param entry [ActiveRecord::Base] the just-created entry.
66
+ # @return [void]
67
+ def call(entry)
68
+ definitions = sql_definitions_for(entry.class)
69
+ return if definitions.empty?
70
+
71
+ definitions.each do |definition|
72
+ next if definition.guard && !entry.instance_exec(&definition.guard)
73
+
74
+ target_id = resolve_target_id(entry, definition)
75
+ next if target_id.nil?
76
+
77
+ run_recompute_sql(entry, definition, target_id)
78
+ end
79
+ end
80
+
81
+ private
82
+
83
+ def sql_definitions_for(entry_class)
84
+ return [] unless entry_class.respond_to?(:standard_ledger_projections_for)
85
+
86
+ entry_class.standard_ledger_projections_for(:sql)
87
+ end
88
+
89
+ # Pull the FK value off the entry without loading the target. The
90
+ # reflection's `foreign_key` is the column name (e.g.
91
+ # `"voucher_scheme_id"`); reading it via `public_send` avoids
92
+ # triggering an AR association load.
93
+ def resolve_target_id(entry, definition)
94
+ reflection = entry.class.reflect_on_association(definition.target_association)
95
+ return nil if reflection.nil?
96
+
97
+ entry.public_send(reflection.foreign_key)
98
+ end
99
+
100
+ # Run the recompute statement, instrumenting success and failure
101
+ # symmetrically with the inline mode's two-event split. The target
102
+ # is intentionally `nil` in the payload — loading it would defeat
103
+ # the mode's "no Ruby-side reads" contract; subscribers that want
104
+ # the target should reload it themselves from the
105
+ # `definition.target_association` + the entry.
106
+ def run_recompute_sql(entry, definition, target_id)
107
+ prefix = StandardLedger.config.notification_namespace
108
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
109
+
110
+ begin
111
+ sql = ActiveRecord::Base.sanitize_sql_array([ definition.recompute_sql, { target_id: target_id } ])
112
+ entry.class.connection.exec_update(sql)
113
+ rescue StandardError => e
114
+ duration_ms = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000.0
115
+ StandardLedger::EventEmitter.emit(
116
+ "#{prefix}.projection.failed",
117
+ entry: entry, target: nil, projection: definition.target_association,
118
+ mode: :sql, error: e, duration_ms: duration_ms
119
+ )
120
+ raise
121
+ end
122
+
123
+ duration_ms = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000.0
124
+ StandardLedger::EventEmitter.emit(
125
+ "#{prefix}.projection.applied",
126
+ entry: entry, target: nil, projection: definition.target_association,
127
+ mode: :sql, duration_ms: duration_ms
128
+ )
129
+ end
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,41 @@
1
+ module StandardLedger
2
+ # Base class for projector classes registered via
3
+ # `projects_onto :target, via: ProjectorClass`. Subclasses implement
4
+ # `apply` (and optionally `rebuild`) to mutate the target based on a new
5
+ # entry.
6
+ #
7
+ # For projections expressible as block DSL (counter increments, simple
8
+ # delta updates), prefer the block form on `Projector#projects_onto`
9
+ # instead — extracting a class is for non-trivial projectors.
10
+ #
11
+ # @example
12
+ # class Orders::FulfillableProjector < StandardLedger::Projection
13
+ # # Called inside the async job, with target locked.
14
+ # def apply(order, _entry)
15
+ # order.update!(
16
+ # fulfillable_balance: order.fulfillment_records.group(:key).sum(:amount),
17
+ # fulfillable_status: order.fulfillment_records.group(:key).sum(:amount).values.all?(&:zero?) ? :fulfilled : :pending
18
+ # )
19
+ # end
20
+ #
21
+ # # Called by Projection.rebuild! to recompute from the full log.
22
+ # def rebuild(order)
23
+ # apply(order, nil)
24
+ # end
25
+ # end
26
+ class Projection
27
+ # Apply a single entry's effect to the target. Called inside the
28
+ # transactional or async boundary of the chosen mode.
29
+ def apply(_target, _entry)
30
+ raise NotImplementedError, "#{self.class}#apply must be implemented"
31
+ end
32
+
33
+ # Recompute the target's projection from the full entry log. Called by
34
+ # `StandardLedger.rebuild!`. Projectors that cannot be rebuilt (e.g.
35
+ # delta-only `increment_counter` flavored ones) should raise
36
+ # `StandardLedger::NotRebuildable`.
37
+ def rebuild(_target)
38
+ raise NotRebuildable, "#{self.class}#rebuild not implemented; this projector cannot be rebuilt from the entry log"
39
+ end
40
+ end
41
+ end