Class: StandardLedger::Modes::Async

Inherits:
Object
  • Object
show all
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

Instance Method Summary collapse

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.

Parameters:

  • entry_class (Class)

    the host entry class.

Raises:

  • (ArgumentError)

    when ‘entry_class` is not ActiveRecord-backed (no `after_create_commit` hook available). `:async` mode requires AR transactional callbacks; non-AR entry classes can’t dispatch the post-commit enqueue.



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.)

Parameters:

  • entry (ActiveRecord::Base)

    the just-committed entry.



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