Class: SagaForge::State

Inherits:
ApplicationRecord show all
Defined in:
lib/saga_forge/state.rb

Overview

The saga's ground truth, and only the truth. current_state is always the real workflow position; stalls/failures live on Event rows.

Constant Summary collapse

COMPENSATING =
:compensating
COMPENSATED =
:compensated
CANCELLED =
:cancelled

Instance Method Summary collapse

Instance Method Details

#cancel!(reason:) ⇒ Object



90
91
92
# File 'lib/saga_forge/state.rb', line 90

def cancel!(reason:)
  compensate!(target: CANCELLED, reason: reason)
end

#compensate!(target: COMPENSATED, reason: nil) ⇒ Object

Resume-then-compensate (§A.4): a failed step's side effects may have happened, but its event never processed and its context never committed — so it implies no compensation and its compensation's guard sees nothing. Fix the code, resume!, then compensate if still desired.

Returns true if this call actually transitioned the saga into :compensating, false on a no-op (already terminal/compensating) — a small deliberate API nicety for the dashboard phase.



67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
# File 'lib/saga_forge/state.rb', line 67

def compensate!(target: COMPENSATED, reason: nil)
  if !recovery_blocked? && events.failed.exists?
    Rails.logger.warn do
      "[saga_forge] compensate! on #{saga_class}##{correlation_id} with failed events — " \
        "failed steps imply no compensation and left no context; resume!, then compensate"
    end
  end

  transitioned = false
  with_lock do
    break if recovery_blocked?

    context_copy = context.deep_dup
    meta = (context_copy["__saga_forge"] || {}).merge("target" => target.to_s)
    meta["failure_reason"] = reason if reason
    context_copy["__saga_forge"] = meta
    update!(current_state: COMPENSATING.to_s, version: version + 1, context: context_copy, last_active_at: Time.current)
    transitioned = true
  end
  CompensationJob.perform_later(id) if transitioned
  transitioned
end

#historyObject



22
# File 'lib/saga_forge/state.rb', line 22

def history = events.ledger_order

#resume!Object

Same status-scoping as retry_stalled! — see there.



46
47
48
49
50
51
52
53
54
55
56
57
# File 'lib/saga_forge/state.rb', line 46

def resume!
  if recovery_blocked?
    Rails.logger.warn { "[saga_forge] resume! no-op on #{saga_class}##{correlation_id}: saga is #{current_state}" }
    return false
  end

  ids = events.failed.ledger_order.ids
  count = Event.where(id: ids, status: :failed)
    .update_all(status: :pending, attempts: 0, retry_budgets: {}, error: nil, updated_at: Time.current)
  enqueue_execution(ids)
  count > 0
end

#retry_stalled!Object

Status-scoped: the UPDATE only touches rows still :stalled, so a concurrent redeliver_parked landing a row on :processed between our SELECT and this write can never be regressed back to :pending (a step is compensable iff it committed — Task 8's invariant). Enqueueing an id that raced ahead to :processed is harmless (ExecutionJob/Runner no-op on an already-processed row).



32
33
34
35
36
37
38
39
40
41
42
43
# File 'lib/saga_forge/state.rb', line 32

def retry_stalled!
  if recovery_blocked?
    Rails.logger.warn { "[saga_forge] retry_stalled! no-op on #{saga_class}##{correlation_id}: saga is #{current_state}" }
    return false
  end

  ids = events.stalled.ledger_order.ids
  count = Event.where(id: ids, status: :stalled)
    .update_all(status: :pending, stall_count: 0, updated_at: Time.current)
  enqueue_execution(ids)
  count > 0
end

#saga_definitionObject



24
# File 'lib/saga_forge/state.rb', line 24

def saga_definition = saga_class.constantize.definition