Class: StandardLedger::Modes::Inline

Inherits:
Object
  • Object
show all
Defined in:
lib/standard_ledger/modes/inline.rb

Overview

‘:inline` mode: applies the projection inside the entry’s ‘after_create` callback, which fires while the host’s outer transaction is still open. If the host’s transaction rolls back, the projection rolls back too.

This is the default for delta-based counter updates. For complex projectors (jsonb shape, multi-row aggregates), use ‘:async` instead.

The strategy is invoked from a single ‘after_create` callback installed once per entry class (see `.install!`). The callback walks every `:inline`-mode projection registered on the class and runs each via `entry.apply_projection!(definition)`.

## Multi-counter coalescing

A single host might register four ‘on(:grant)`/`on(:redeem)`/… handlers against the same target, each calling `target.increment(:some_count)`. ActiveRecord’s ‘#increment` (vs. `#increment!`) only mutates the in-memory attribute — no SQL is issued — so the strategy persists with a single `target.save!` per (entry, target) pair after all handlers for that target have run. This collapses N handlers into one UPDATE.

Definitions targeting different associations get their own apply-then-save cycle, executed in the order the projections were declared.

## Lock semantics

When any projection in a per-target group declares ‘lock: :pessimistic`, the strategy wraps the entire apply-then-save cycle for that target in `target.with_lock { … }`. The lock spans both handler invocation and the coalesced `save!`, so concurrent posts to the same target serialize end-to-end — closing the lost-update window that an inner-only lock would leave open between the lock release and the save. See `standard_ledger-design.md` §5.3.1.

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` callback on `entry_class` exactly once. Subsequent calls (e.g. when a second `:inline` projection is added later in the class body) are no-ops — the same callback handles all `:inline` 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` hook available). `:inline` mode requires AR transactional callbacks; non-AR entry classes must use a different mode (or refrain from declaring `:inline` projections).



49
50
51
52
53
54
55
56
57
58
59
60
61
62
# File 'lib/standard_ledger/modes/inline.rb', line 49

def self.install!(entry_class)
  return if entry_class.instance_variable_get(:@_standard_ledger_inline_installed)

  unless entry_class.respond_to?(:after_create)
    raise ArgumentError,
          "#{entry_class.name || entry_class.inspect} cannot use mode: :inline " \
          "because it does not respond to `after_create`. `:inline` mode requires " \
          "an ActiveRecord-backed entry class — use `:async` (or another mode) for " \
          "non-AR includers."
  end

  entry_class.after_create { StandardLedger::Modes::Inline.new.call(self) }
  entry_class.instance_variable_set(:@_standard_ledger_inline_installed, true)
end

Instance Method Details

#call(entry) ⇒ void

This method returns an undefined value.

Apply every ‘:inline` projection registered on the entry’s class. Called from the ‘after_create` callback installed by `.install!`.

Projections targeting the same association coalesce: all handlers for that target run, then the target is saved once. Different targets get their own apply+save cycle, in declared order. When any definition in a per-target group sets ‘lock: :pessimistic`, the cycle (apply + save) is wrapped in `target.with_lock`.

Records the names of projections that actually ran (after ‘if:` guards filter) on the entry instance under `@_standard_ledger_applied_projections`, so `StandardLedger.post` can surface an accurate `result.projections`.

Any projector exception escapes — the entry’s transaction rolls back along with every counter mutation that ran before the failure. The ‘standard_ledger.projection.failed` notification fires before the re-raise so subscribers see the failed projection in payload.

Parameters:

  • entry (ActiveRecord::Base)

    the just-created entry.



85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
# File 'lib/standard_ledger/modes/inline.rb', line 85

def call(entry)
  definitions = inline_definitions_for(entry.class)
  return if definitions.empty?

  applied = []
  entry.instance_variable_set(:@_standard_ledger_applied_projections, applied)

  # group_by preserves insertion order on Ruby >= 1.9, so declared
  # projection order is preserved across targets. Within a target
  # group, handlers run in declared order as well.
  definitions.group_by(&:target_association).each_value do |group|
    target = entry.public_send(group.first.target_association)
    locked = group.any? { |definition| definition.lock == :pessimistic }

    run_group = lambda do
      group.each do |definition|
        ran = instrument_projection(entry, target, definition) do
          entry.apply_projection!(definition)
        end
        applied << definition.target_association if ran && !applied.include?(definition.target_association)
      end

      # Coalesce: if any handler called `target.increment(col)` (which
      # mutates in-memory only), persist the accumulated changes with a
      # single UPDATE. Skipped when the target is nil (apply_projection!
      # short-circuits) or when no handler dirtied the record. The
      # `target` here always responds to AR's `changed?`/`save!` because
      # it's resolved from a `belongs_to` reflection.
      target.save! if target && target.changed?
    end

    if locked && target.respond_to?(:with_lock)
      target.with_lock(&run_group)
    else
      run_group.call
    end
  end
end