Class: StandardLedger::Modes::Async
- Inherits:
-
Object
- Object
- StandardLedger::Modes::Async
- Defined in:
- lib/standard_ledger/modes/async.rb
Overview
‘:async` mode: applies the projection in a background job enqueued from the entry’s ‘after_create_commit` callback. The job runs after the outer transaction has committed, so the entry is durable before the projection runs — and a job failure does NOT roll back the entry.
Used when the projection is too expensive or stateful for the entry’s transaction (jsonb rebuild, multi-row aggregate). The canonical example is nutripod’s ‘Order#payable_balance` / `Order#fulfillable_balance` jsonb columns, which need to be recomputed from every PaymentRecord / FulfillmentRecord against the order — work that’s safe to defer past the originating transaction.
Class-form only: ‘:async` projections must declare `via: ProjectorClass`, whose `apply(target, entry)` should recompute from the log inside `with_lock` rather than apply a delta. Block-form per-kind handlers aren’t safe under retry — incrementing a counter twice on retry is a silent data corruption bug — so block-form is rejected at registration time (see ‘Projector#projects_onto`).
The strategy installs an ‘after_create_commit` callback once per entry class. The callback walks every `:async`-mode projection registered on the class and enqueues `StandardLedger::ProjectionJob` per (entry, projection) pair, honoring the optional `if:` guard.
## with_modes interop
‘StandardLedger.with_modes(EntryClass => :inline) { … }` forces async projections to run synchronously inside the block — useful in unit specs that want end-to-end coverage without standing up a job runner. Inline-override mode skips the enqueue and runs `target.with_lock { projector.apply(target, entry) }` directly. The override is read in `#call`; specs can capture the empty job queue via `have_enqueued_job` / `perform_enqueued_jobs` and still observe the projection’s effects.
Class Method Summary collapse
-
.install!(entry_class) ⇒ void
Install the ‘after_create_commit` callback on `entry_class` exactly once.
Instance Method Summary collapse
-
#call(entry) ⇒ void
Enqueue ‘ProjectionJob` for every `:async` projection registered on the entry’s class.
Class Method Details
.install!(entry_class) ⇒ void
This method returns an undefined value.
Install the ‘after_create_commit` callback on `entry_class` exactly once. Subsequent calls (e.g. when a second `:async` projection is added later in the class body) are no-ops — the same callback handles all `:async` projections registered on the class.
48 49 50 51 52 53 54 55 56 57 58 59 60 |
# File 'lib/standard_ledger/modes/async.rb', line 48 def self.install!(entry_class) return if entry_class.instance_variable_get(:@_standard_ledger_async_installed) unless entry_class.respond_to?(:after_create_commit) raise ArgumentError, "Modes::Async requires an ActiveRecord-backed entry class on " \ "#{entry_class.name || entry_class.inspect} — the entry class must inherit " \ "from ActiveRecord::Base. Use :inline mode for plain-Ruby entry classes." end entry_class.after_create_commit { StandardLedger::Modes::Async.new.call(self) } entry_class.instance_variable_set(:@_standard_ledger_async_installed, true) end |
Instance Method Details
#call(entry) ⇒ void
This method returns an undefined value.
Enqueue ‘ProjectionJob` for every `:async` projection registered on the entry’s class. Called from the ‘after_create_commit` callback installed by `.install!`.
Honors the optional ‘if:` guard (skips enqueue when guard returns false) and `StandardLedger.mode_override_for(entry_class)` (when set to `:inline`, runs the projection synchronously inside `with_lock` instead of enqueueing).
A nil target at enqueue time skips silently — the entry’s FK was unset for this projection’s association, so there’s nothing to project onto. (The job has its own nil-target guard; this short- circuit just avoids the wasted enqueue.)
78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 |
# File 'lib/standard_ledger/modes/async.rb', line 78 def call(entry) definitions = async_definitions_for(entry.class) return if definitions.empty? override = StandardLedger.mode_override_for(entry.class) definitions.each do |definition| next if definition.guard && !entry.instance_exec(&definition.guard) if override == :inline run_inline(entry, definition) else target = entry.public_send(definition.target_association) next if target.nil? job_class = StandardLedger.config.default_async_job || StandardLedger::ProjectionJob job_class.perform_later(entry, definition.target_association.to_s) end end end |