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.
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.5.1
4
+ version: 0.6.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 captures the recurring 'append-only entry → N projection
125
- updates' pattern as a declarative DSL on host ActiveRecord models. Supports five
126
- projection modes — :inline, :async, :sql, :matview, :trigger — plus a deterministic
127
- rebuild path from the entry log, ad-hoc materialized view refresh, and a doctor
128
- rake task that verifies host-owned trigger presence. Enforces idempotency-by-unique-index
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/jobs/matview_refresh_job.rb
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 with declarative aggregate projections for Rails
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