saga_forge 0.1.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.
Files changed (36) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +87 -0
  3. data/LICENSE +21 -0
  4. data/README.md +635 -0
  5. data/lib/generators/saga_forge/install/USAGE +28 -0
  6. data/lib/generators/saga_forge/install/install_generator.rb +60 -0
  7. data/lib/generators/saga_forge/migration_actions.rb +68 -0
  8. data/lib/generators/saga_forge/templates/initializer.rb +23 -0
  9. data/lib/generators/saga_forge/templates/install_saga_forge.rb +84 -0
  10. data/lib/generators/saga_forge/upgrade/USAGE +15 -0
  11. data/lib/generators/saga_forge/upgrade/upgrade_generator.rb +30 -0
  12. data/lib/saga_forge/application_record.rb +15 -0
  13. data/lib/saga_forge/base.rb +61 -0
  14. data/lib/saga_forge/compensation_job.rb +24 -0
  15. data/lib/saga_forge/compensation_runner.rb +142 -0
  16. data/lib/saga_forge/composite_retry_policy.rb +48 -0
  17. data/lib/saga_forge/configuration.rb +32 -0
  18. data/lib/saga_forge/dashboard/graph.rb +20 -0
  19. data/lib/saga_forge/definition.rb +268 -0
  20. data/lib/saga_forge/event.rb +16 -0
  21. data/lib/saga_forge/execution/compensation_facade.rb +21 -0
  22. data/lib/saga_forge/execution/facade.rb +45 -0
  23. data/lib/saga_forge/execution/post_commit.rb +53 -0
  24. data/lib/saga_forge/execution/runner.rb +240 -0
  25. data/lib/saga_forge/execution_job.rb +41 -0
  26. data/lib/saga_forge/publisher.rb +43 -0
  27. data/lib/saga_forge/railtie.rb +17 -0
  28. data/lib/saga_forge/retention_job.rb +28 -0
  29. data/lib/saga_forge/retry_policy.rb +107 -0
  30. data/lib/saga_forge/router.rb +70 -0
  31. data/lib/saga_forge/state.rb +113 -0
  32. data/lib/saga_forge/sweeper_job.rb +80 -0
  33. data/lib/saga_forge/timeout_job.rb +96 -0
  34. data/lib/saga_forge/version.rb +3 -0
  35. data/lib/saga_forge.rb +81 -0
  36. metadata +140 -0
@@ -0,0 +1,268 @@
1
+ module SagaForge
2
+ # Immutable boot-compiled metadata for one saga class: the chain, the
3
+ # event→state stall table, handler registry, compensation catalog.
4
+ class Definition
5
+ START = :__start__
6
+
7
+ Handler = Struct.new(:state, :event, :block, :compensate, :timeout, :on_timeout, :retry_policy)
8
+
9
+ attr_reader :klass, :handlers_by_event, :states, :terminal_states, :compensations, :start_event
10
+
11
+ def self.compile(klass) = new(klass).freeze
12
+
13
+ def initialize(klass)
14
+ @klass = klass
15
+ @handlers_by_event = {}
16
+ @compensations = {}
17
+ @terminal_states = []
18
+ during_states = []
19
+ start_decls = []
20
+
21
+ klass.declarations.each do |d|
22
+ case d[:kind]
23
+ when :start
24
+ start_decls << d
25
+ register_handler(START, d)
26
+ when :during
27
+ during_states << d[:state] unless during_states.include?(d[:state])
28
+ register_handler(d[:state], d)
29
+ when :finish
30
+ @terminal_states << d[:state] unless @terminal_states.include?(d[:state])
31
+ when :compensation
32
+ @compensations[d[:name]] = d[:block]
33
+ end
34
+ end
35
+
36
+ validate_shape!(start_decls)
37
+ @start_event = start_decls.first[:event]
38
+ @states = during_states + @terminal_states
39
+ @successors = build_successors(during_states)
40
+ validate_compensations!
41
+ validate_timeouts!
42
+ deep_freeze!
43
+ end
44
+
45
+ def handler_for(event) = @handlers_by_event[event.to_sym]
46
+
47
+ def state_for_event(event) = handler_for(event)&.state
48
+
49
+ def events = @handlers_by_event.keys
50
+
51
+ def events_for_state(state) = @handlers_by_event.values.select { |h| h.state == state.to_sym }.map(&:event)
52
+
53
+ def successor_of(state) = @successors.fetch(state.to_sym)
54
+
55
+ def terminal?(state)
56
+ s = state.to_sym
57
+ @terminal_states.include?(s) || %i[compensated cancelled].include?(s)
58
+ end
59
+
60
+ def declared?(state)
61
+ s = state.to_sym
62
+ @states.include?(s) || terminal?(s)
63
+ end
64
+
65
+ def correlate(payload, event_name)
66
+ correlator = klass.correlator
67
+ value = (correlator.arity == 1) ? correlator.call(payload) : correlator.call(payload, event_name)
68
+ if value.nil?
69
+ raise MissingCorrelationError, "#{klass} registered #{event_name.inspect} but correlate_by returned nil"
70
+ end
71
+ value.to_s
72
+ end
73
+
74
+ # handler override → class default → step_default. (Compensation blocks
75
+ # use RetryPolicy.compensation_default — see CompensationRunner, Task 8.)
76
+ def retry_policy_for(handler)
77
+ override = handler.retry_policy
78
+ policy =
79
+ case override
80
+ when nil then nil
81
+ when Hash then RetryPolicy.new(**override)
82
+ when Array then CompositeRetryPolicy.new(override)
83
+ else override
84
+ end
85
+ policy || klass.default_retry_policy || RetryPolicy.step_default
86
+ end
87
+
88
+ def to_mermaid
89
+ lines = ["stateDiagram-v2"]
90
+ chain = [START] + @states.reject { |s| @terminal_states.include?(s) } + [@terminal_states.first]
91
+ chain.each_cons(2) do |from, to|
92
+ events_from = (from == START) ? [@start_event] : events_for_state(from)
93
+ label = events_from.join(" / ")
94
+ from_name = (from == START) ? "[*]" : from
95
+ lines << " #{from_name} --> #{to}: #{label}"
96
+ end
97
+ @terminal_states.each { |t| lines << " #{t} --> [*]" }
98
+ jump_targets.each { |(from, to)| lines << " #{from} --> #{to}: jump" }
99
+ lines.join("\n")
100
+ end
101
+
102
+ # Best-effort literal scan for `transition_to :sym` in handler blocks
103
+ # (jumps are opaque Ruby; unresolvable ones are simply not drawn). Each
104
+ # match is attributed to a handler only if the match's line falls within
105
+ # that handler's EXACT block extent (via RubyVM::InstructionSequence's
106
+ # code_location), so two sagas sharing one file never cross-attribute a
107
+ # jump. Anything we can't precisely locate is simply not drawn — a wrong
108
+ # edge is worse than a missing one.
109
+ def jump_targets
110
+ scan_handlers(/transition_to[\s(]+:(\w+)/).filter_map do |(state, captures)|
111
+ target = captures.first
112
+ next unless declared?(target)
113
+ from = (state == START) ? "[*]" : state
114
+ [from, target.to_sym]
115
+ end.uniq
116
+ end
117
+
118
+ # Structured graph (chain + jump), the sibling of to_mermaid.
119
+ def to_graph
120
+ nodes = [SagaForge::Dashboard::Node.new(id: START.to_s, label: "start", kind: :start)]
121
+ (@states - @terminal_states).each do |s|
122
+ nodes << SagaForge::Dashboard::Node.new(id: s.to_s, label: s.to_s, kind: :state)
123
+ end
124
+ # Only @terminal_states.first is on the chain below (build_successors
125
+ # picks it as the chain's sink too). Additional terminals are only
126
+ # wired in when a handler literally `transition_to`s them (jump_targets,
127
+ # below) — the normal multi-terminal pattern. A terminal reached only
128
+ # by a computed/conditional transition, or not reached at all, has no
129
+ # edge and renders as an isolated node: that's deliberate, not a bug —
130
+ # it's the same best-effort honesty jump_targets already carries (a
131
+ # wrong edge is worse than a missing one).
132
+ @terminal_states.each do |s|
133
+ nodes << SagaForge::Dashboard::Node.new(id: s.to_s, label: s.to_s, kind: :terminal)
134
+ end
135
+
136
+ edges = []
137
+ chain = [START] + (@states - @terminal_states) + [@terminal_states.first]
138
+ chain.each_cons(2) do |from, to|
139
+ label = (from == START) ? @start_event.to_s : events_for_state(from).join(" / ")
140
+ edges << SagaForge::Dashboard::Edge.new(from: from.to_s, to: to.to_s, kind: :chain, label: label)
141
+ end
142
+ jump_targets.each do |(from, to)|
143
+ from_id = (from == "[*]") ? START.to_s : from.to_s
144
+ edges << SagaForge::Dashboard::Edge.new(from: from_id, to: to.to_s, kind: :jump, label: "jump")
145
+ end
146
+
147
+ nodes.each(&:freeze)
148
+ edges.each(&:freeze)
149
+ SagaForge::Dashboard::Graph.new(nodes.freeze, edges.freeze).freeze
150
+ end
151
+
152
+ private
153
+
154
+ # Shared block-extent scanner for jump_targets. Yields, for
155
+ # every line within every handler's EXACT block extent, the handler's
156
+ # state paired with whatever String#scan produces for that regex (the
157
+ # full match, or its capture groups) — so two sagas sharing one file
158
+ # never cross-attribute a match. Anything we can't precisely locate is
159
+ # simply not scanned — a wrong edge is worse than a missing one.
160
+ def scan_handlers(regex)
161
+ return [] unless defined?(RubyVM::InstructionSequence)
162
+
163
+ matches = []
164
+ @handlers_by_event.each_value do |h|
165
+ extent = block_extent(h.block)
166
+ next unless extent
167
+ file, first_lineno, last_lineno = extent
168
+ next unless File.exist?(file)
169
+
170
+ lines = File.readlines(file)
171
+ (first_lineno..last_lineno).each do |lineno|
172
+ line = lines[lineno - 1]
173
+ next unless line
174
+ line.scan(regex) { |m| matches << [h.state, m] }
175
+ end
176
+ end
177
+ matches
178
+ end
179
+
180
+ # [file, first_lineno, last_lineno] for a handler's block, or nil if the
181
+ # exact extent can't be determined (no iseq, or no code_location — older
182
+ # Ruby / non-MRI).
183
+ def block_extent(block)
184
+ return nil unless block
185
+ iseq = RubyVM::InstructionSequence.of(block)
186
+ return nil unless iseq
187
+ code_location = iseq.to_a[4].is_a?(Hash) ? iseq.to_a[4][:code_location] : nil
188
+ return nil unless code_location
189
+ first_lineno, _first_col, last_lineno, _last_col = code_location
190
+ [iseq.path, first_lineno, last_lineno]
191
+ rescue TypeError, ArgumentError
192
+ nil
193
+ end
194
+
195
+ def register_handler(state, d)
196
+ event = d[:event]
197
+ if (existing = @handlers_by_event[event])
198
+ if state == START && existing.state == START
199
+ raise DefinitionError, "#{klass} declares start_with more than once"
200
+ end
201
+ raise AmbiguousEventError,
202
+ "#{klass} registers #{event.inspect} under both #{existing.state.inspect} and #{state.inspect}"
203
+ end
204
+ @handlers_by_event[event] = Handler.new(
205
+ state:, event:, block: d[:block], compensate: d[:compensate],
206
+ timeout: d[:timeout], on_timeout: d[:on_timeout], retry_policy: d[:retry_policy]
207
+ )
208
+ end
209
+
210
+ def validate_shape!(start_decls)
211
+ raise DefinitionError, "#{klass} needs exactly one start_with (found #{start_decls.size})" unless start_decls.size == 1
212
+ raise NoTerminalStateError, "#{klass} declares no finish_with" if @terminal_states.empty?
213
+ raise MissingCorrelationError, "#{klass} is missing correlate_by" if klass.correlator.nil?
214
+ end
215
+
216
+ def build_successors(during_states)
217
+ chain = [START] + during_states + [@terminal_states.first]
218
+ chain.each_cons(2).to_h
219
+ end
220
+
221
+ def validate_compensations!
222
+ @handlers_by_event.each_value do |h|
223
+ next if h.compensate.nil? || @compensations.key?(h.compensate)
224
+ raise UnknownCompensationError,
225
+ "#{klass} handler for #{h.event.inspect} compensates with undeclared #{h.compensate.inspect}"
226
+ end
227
+ end
228
+
229
+ # timeout:/on_timeout: are a pair: neither makes sense without the other.
230
+ # A resolvable on_timeout is checked now (boot) so a typo'd or stale
231
+ # target screams at compile time, not months later when a timer fires
232
+ # (TimeoutJob's fire-time declared? check is belt-and-braces for state
233
+ # removed in a later deploy while old timers are still armed).
234
+ def validate_timeouts!
235
+ @handlers_by_event.each_value do |h|
236
+ if h.timeout && h.on_timeout.nil?
237
+ raise DefinitionError,
238
+ "#{klass} handler for #{h.event.inspect} declares timeout: without on_timeout:"
239
+ end
240
+ if h.on_timeout && h.timeout.nil?
241
+ raise DefinitionError,
242
+ "#{klass} handler for #{h.event.inspect} declares on_timeout: without timeout:"
243
+ end
244
+ next unless h.timeout
245
+
246
+ target = h.on_timeout.to_sym
247
+ next if target == :fail!
248
+ next if declared?(target)
249
+ raise DefinitionError,
250
+ "#{klass} handler for #{h.event.inspect} declares on_timeout: #{h.on_timeout.inspect} — not a declared state"
251
+ end
252
+ end
253
+
254
+ # Definition.compile freezes the Definition object itself, but that's
255
+ # shallow: the memoized @definition is shared process-wide, so a stray
256
+ # mutation of one of its collections (or a Handler struct) would
257
+ # permanently corrupt boot metadata for every saga instance. Freeze
258
+ # everything reachable.
259
+ def deep_freeze!
260
+ @handlers_by_event.each_value(&:freeze)
261
+ @handlers_by_event.freeze
262
+ @compensations.freeze
263
+ @states.freeze
264
+ @terminal_states.freeze
265
+ @successors.freeze
266
+ end
267
+ end
268
+ end
@@ -0,0 +1,16 @@
1
+ module SagaForge
2
+ # The ledger: inbound rows only, append-only, mutable status.
3
+ class Event < ApplicationRecord
4
+ self.table_name = "saga_forge_events"
5
+
6
+ belongs_to :state, class_name: "SagaForge::State",
7
+ foreign_key: :saga_forge_state_id, optional: true
8
+
9
+ enum :status, {pending: 0, processed: 1, stalled: 2, failed: 3}
10
+
11
+ scope :for_instance, ->(saga_class, correlation_id) {
12
+ where(saga_class: saga_class.to_s, correlation_id: correlation_id.to_s)
13
+ }
14
+ scope :ledger_order, -> { order(:created_at, :id) }
15
+ end
16
+ end
@@ -0,0 +1,21 @@
1
+ module SagaForge
2
+ module Execution
3
+ # Yielded to compensation blocks: context is the snapshot (§A.4).
4
+ # No transition verbs — rollback has one direction.
5
+ class CompensationFacade
6
+ attr_reader :correlation_id, :current_state, :context, :staged_publishes
7
+
8
+ def initialize(correlation_id:, current_state:, context:)
9
+ @correlation_id = correlation_id
10
+ @current_state = current_state
11
+ @context = context
12
+ @staged_publishes = []
13
+ end
14
+
15
+ def publish(event_name, **payload)
16
+ @staged_publishes.concat(Router.resolve(event_name, payload))
17
+ nil
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,45 @@
1
+ module SagaForge
2
+ module Execution
3
+ # The `saga` object yielded to forward blocks (§A.1 verbs). Everything is
4
+ # staged in memory; the Runner commits it.
5
+ class Facade
6
+ # Verb semantics: last verb call wins — calling transition_to twice
7
+ # simply overwrites @outcome with whatever ran last, and the block
8
+ # keeps executing. fail! is the one exception: it both records its
9
+ # outcome AND throws :saga_forge_fail, short-circuiting the rest of
10
+ # the block immediately. Every other verb is just a plain method call
11
+ # with no control-flow effect.
12
+ attr_reader :correlation_id, :current_state, :context, :outcome, :staged_publishes
13
+
14
+ def initialize(definition:, correlation_id:, current_state:, context:)
15
+ @definition = definition
16
+ @correlation_id = correlation_id
17
+ @current_state = current_state
18
+ @context = context
19
+ @staged_publishes = []
20
+ @outcome = nil
21
+ end
22
+
23
+ def transition_to(state)
24
+ unless @definition.declared?(state)
25
+ raise UnknownStateError, "transition_to #{state.inspect} — undeclared state"
26
+ end
27
+ @outcome = [:transition_to, state.to_sym]
28
+ nil
29
+ end
30
+
31
+ def fail!(reason: nil)
32
+ @outcome = [:fail, reason]
33
+ throw :saga_forge_fail
34
+ end
35
+
36
+ # Staged publish (§A.2): resolve recipients NOW (call-site stack trace,
37
+ # MissingCorrelationError surfaces under the block's retry policy),
38
+ # hold fully-built rows; the Runner inserts them inside its commit.
39
+ def publish(event_name, **payload)
40
+ @staged_publishes.concat(Router.resolve(event_name, payload))
41
+ nil
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,53 @@
1
+ module SagaForge
2
+ module Execution
3
+ # Shared post-commit effects (§A.1 arming, §A.3 re-delivery): entering
4
+ # (or staying in) a state re-delivers any events parked for it and arms
5
+ # its timeout-declaring handlers. Both Runner (after a normal commit) and
6
+ # TimeoutJob (after a live timeout branch transition) land here — neither
7
+ # needs anything but (definition, state): saga_class/correlation_id/
8
+ # current_state/version/id off the state row.
9
+ module PostCommit
10
+ def redeliver_parked(definition, state)
11
+ names = definition.events_for_state(state.current_state).map(&:to_s)
12
+ return if names.empty?
13
+ Event.stalled.for_instance(state.saga_class, state.correlation_id)
14
+ .where(event_name: names).ledger_order.each do |parked|
15
+ # Status-scoped: only flip rows still :stalled. A racing commit
16
+ # (another redeliver_parked, or this same row processed in the
17
+ # meantime) can move a row to :processed between the SELECT above
18
+ # and this write — the scope makes that race lose cleanly instead
19
+ # of regressing a committed row back to :pending.
20
+ updated = Event.where(id: parked.id, status: :stalled)
21
+ .update_all(status: :pending, stall_count: 0, updated_at: Time.current)
22
+ ExecutionJob.perform_later(parked.id) if updated > 0
23
+ end
24
+ end
25
+
26
+ def arm_timeouts(definition, state)
27
+ current = state.current_state.to_sym
28
+ definition.events_for_state(current).each do |event_name|
29
+ handler = definition.handler_for(event_name)
30
+ next unless handler.timeout
31
+ TimeoutJob.set(wait: handler.timeout)
32
+ .perform_later(state.id, event_name.to_s, state.version)
33
+ end
34
+ end
35
+
36
+ # Forward-only: a saga never re-enters a state it has resided in, so it
37
+ # handles each event name at most once (the invariant structural dedup
38
+ # relies on). Visited = every processed event's registered state, plus
39
+ # the current state. Covers fall-through, transition_to, AND timeout
40
+ # branches. Raises ForwardOnlyError on a re-entry.
41
+ def guard_forward_only!(definition, saga_class, correlation_id, current, next_state)
42
+ visited = Event.processed
43
+ .for_instance(saga_class, correlation_id)
44
+ .pluck(:event_name)
45
+ .filter_map { |name| definition.state_for_event(name)&.to_s }
46
+ visited << current.to_s
47
+ return unless visited.include?(next_state)
48
+ raise ForwardOnlyError,
49
+ "#{saga_class}##{correlation_id}: advance to #{next_state} re-enters a visited state — sagas are forward-only"
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,240 @@
1
+ module SagaForge
2
+ module Execution
3
+ # Processes one pending ledger row through the §A.4 pipeline.
4
+ # Returns [:done] | [:respin] | [:retry, seconds].
5
+ class Runner
6
+ include PostCommit
7
+
8
+ ERROR_MESSAGE_LIMIT = 10_000
9
+ BACKTRACE_LINES = 50
10
+
11
+ attr_reader :event
12
+
13
+ def initialize(event)
14
+ @event = event
15
+ end
16
+
17
+ def call
18
+ return [:done] unless event.pending?
19
+ return [:done] if halted?
20
+
21
+ saga_class = event.saga_class.constantize
22
+ definition = saga_class.definition
23
+ state_row = State.find_by(saga_class: event.saga_class, correlation_id: event.correlation_id)
24
+ current = state_row&.current_state&.to_sym || Definition::START
25
+
26
+ return discard_terminal!(current) if definition.terminal?(current)
27
+
28
+ expected = definition.state_for_event(event.event_name)
29
+ return stall! if expected != current
30
+
31
+ execute!(definition, state_row, current)
32
+ end
33
+
34
+ private
35
+
36
+ # Poison-pill halt (§A.3): derived from the ledger at job entry.
37
+ def halted?
38
+ Event.failed.for_instance(event.saga_class, event.correlation_id).exists?
39
+ end
40
+
41
+ # Atomic increment (not read-modify-write): concurrent deliveries outside
42
+ # Solid Queue's serialization must not lose updates (§A.3 — correctness
43
+ # never depends on the concurrency-limit nicety).
44
+ def stall!
45
+ Event.where(id: event.id).update_all("stall_count = stall_count + 1")
46
+ count = event.reload.stall_count
47
+ if count >= SagaForge.config.stall_budget
48
+ event.update!(status: :stalled)
49
+ [:done]
50
+ else
51
+ [:respin]
52
+ end
53
+ end
54
+
55
+ def discard_terminal!(current)
56
+ event.update!(status: :processed, last_processed_at: Time.current, error: {"discarded" => "terminal state #{current}"})
57
+ Rails.logger.info { "[saga_forge] discarded #{event.event_name} for terminal #{event.saga_class}##{event.correlation_id}" }
58
+ [:done]
59
+ end
60
+
61
+ def execute!(definition, state_row, current)
62
+ handler = definition.handler_for(event.event_name)
63
+ entry_version = state_row&.version || 0
64
+ context = (state_row&.context || {}).deep_dup.with_indifferent_access
65
+
66
+ facade = Facade.new(
67
+ definition: definition,
68
+ correlation_id: event.correlation_id,
69
+ current_state: current,
70
+ context: context
71
+ )
72
+
73
+ begin
74
+ catch(:saga_forge_fail) do
75
+ SagaForge.guarding_execution do
76
+ handler.block.call(facade, event.payload.with_indifferent_access)
77
+ end
78
+ end
79
+ rescue => error
80
+ return handle_error(error, definition, handler)
81
+ end
82
+
83
+ state_row = commit!(definition, state_row, current, entry_version, facade)
84
+ after_commit_effects(definition, state_row, facade)
85
+ [:done]
86
+ rescue ConcurrencyConflict
87
+ [:retry, SagaForge.config.stall_wait]
88
+ rescue ForwardOnlyError => e
89
+ record_forward_violation(e)
90
+ [:done]
91
+ end
92
+
93
+ # Routes BLOCK errors through the resolved retry policy (§A.5):
94
+ # handler override -> class default -> RetryPolicy.step_default. Retryable
95
+ # -> event stays pending with bumped attempts/budgets, [:retry, backoff].
96
+ # Exhausted or unmatched -> event failed with captured error, [:done];
97
+ # nothing escapes to ActiveJob's dead-letter path.
98
+ #
99
+ # Structural warning: this hook is for BLOCK errors only. ConcurrencyConflict
100
+ # (raised only from commit!, and itself a SagaForge::Error subclass) is
101
+ # deliberately caught by the method-level `rescue ConcurrencyConflict`
102
+ # on execute! — which sits AFTER this hook in the call chain, catching
103
+ # only what commit! raises, never what the block raises. Do not
104
+ # restructure this so ConcurrencyConflict could reach a generic
105
+ # `rescue => error` here — that would burn retry-policy budget on a
106
+ # version race, which isn't a block failure. Likewise, the fail! verb
107
+ # unwinds via `throw :saga_forge_fail`, not a raised exception, so it
108
+ # never reaches handle_error and must never be routed through
109
+ # retry-policy logic either.
110
+ #
111
+ # The whole read-decide-write sequence runs under a row lock (like
112
+ # stall!'s atomic increment): two concurrent deliveries reading
113
+ # event.attempts from stale in-memory state would otherwise both count
114
+ # as "attempt 1", letting a saga burn more attempts than the policy's
115
+ # max_attempts bounds. with_lock reloads the row before the block runs,
116
+ # so attempts/retry_budgets read inside are fresh as of lock acquisition.
117
+ def handle_error(error, definition, handler)
118
+ policy = definition.retry_policy_for(handler)
119
+
120
+ event.with_lock do
121
+ return [:done] unless event.pending? # another delivery already resolved this row
122
+
123
+ attempts = event.attempts + 1
124
+ budgets = (event.retry_budgets || {}).dup
125
+ updates = {attempts: attempts}
126
+
127
+ backoff = policy.retry_backoff(error, attempts: attempts) do |budget_key|
128
+ budgets[budget_key] = budgets.fetch(budget_key, 0) + 1
129
+ updates[:retry_budgets] = budgets
130
+ budgets[budget_key]
131
+ end
132
+
133
+ if backoff
134
+ event.update!(**updates)
135
+ [:retry, backoff]
136
+ else
137
+ event.update!(**updates, status: :failed, error: {
138
+ "class" => error.class.name,
139
+ "message" => SagaForge.safe_error_message(error.message, ERROR_MESSAGE_LIMIT),
140
+ "backtrace" => Array(error.backtrace).first(BACKTRACE_LINES).map { |l| SagaForge.safe_error_message(l, 500) }
141
+ })
142
+ Rails.logger.error { "[saga_forge] #{event.saga_class}##{event.correlation_id} #{event.event_name} failed: #{error.class}" }
143
+ [:done]
144
+ end
145
+ end
146
+ end
147
+
148
+ def commit!(definition, state_row, current, entry_version, facade)
149
+ failing = facade.outcome.is_a?(Array) && facade.outcome.first == :fail
150
+ next_state = resolve_next_state(definition, current, facade.outcome)
151
+ unless next_state == State::COMPENSATING.to_s
152
+ guard_forward_only!(definition, event.saga_class, event.correlation_id, current, next_state)
153
+ end
154
+ @inserted_rows = []
155
+
156
+ State.transaction do
157
+ if state_row.nil?
158
+ begin
159
+ state_row = State.create!(
160
+ saga_class: event.saga_class, correlation_id: event.correlation_id,
161
+ current_state: next_state, version: entry_version, context: {}
162
+ )
163
+ rescue ActiveRecord::RecordNotUnique
164
+ raise ConcurrencyConflict, "duplicate start for #{event.saga_class}##{event.correlation_id}"
165
+ end
166
+ end
167
+ state_row.lock!
168
+ raise ConcurrencyConflict, "version moved" if state_row.version != entry_version
169
+
170
+ context = facade.context
171
+ if failing
172
+ # NOTE: `context["__saga_forge"] ||= {}` would look right but isn't:
173
+ # HashWithIndifferentAccess#[]= stores its own converted copy
174
+ # internally, while the assignment *expression* always evaluates
175
+ # to the literal right-hand object Ruby wrote (a core `[]=`
176
+ # semantic) — so a `meta = (context[k] ||= {})` alias points at
177
+ # an orphan hash that never lands back in `context`. Merge and
178
+ # reassign in one shot instead.
179
+ meta = (context["__saga_forge"] || {}).merge(
180
+ "failure_reason" => facade.outcome.last,
181
+ "target" => "compensated"
182
+ )
183
+ context["__saga_forge"] = meta
184
+ end
185
+
186
+ now = Time.current
187
+ finalized = definition.terminal?(next_state.to_sym) ? now : nil
188
+ state_row.update!(
189
+ current_state: next_state, version: entry_version + 1, context: context,
190
+ last_active_at: now, finalized_at: finalized
191
+ )
192
+ event.update!(status: :processed, saga_forge_state_id: state_row.id, error: nil, last_processed_at: now)
193
+
194
+ unless failing # fail! discards staged publishes (§A.1)
195
+ # Staged rows can collide benignly now that dedup is structural:
196
+ # two producers publishing the same event to the same recipient,
197
+ # or a redelivery, hit the (saga,correlation,event) unique index.
198
+ # Each insert gets its own savepoint so a duplicate rolls back to
199
+ # the savepoint instead of poisoning this commit's transaction
200
+ # (Postgres abort-on-error), exactly as Publisher#insert_row does.
201
+ @inserted_rows = facade.staged_publishes.filter_map do |attrs|
202
+ ApplicationRecord.transaction(requires_new: true) { Event.create!(attrs) }
203
+ rescue ActiveRecord::RecordNotUnique
204
+ nil
205
+ end
206
+ end
207
+ end
208
+ state_row
209
+ end
210
+
211
+ def resolve_next_state(definition, current, outcome)
212
+ case outcome
213
+ in nil then definition.successor_of(current).to_s
214
+ in [:transition_to, target] then target.to_s
215
+ in [:fail, _] then State::COMPENSATING.to_s
216
+ end
217
+ end
218
+
219
+ def record_forward_violation(error)
220
+ event.update!(status: :failed, error: {
221
+ "class" => error.class.name,
222
+ "message" => SagaForge.safe_error_message(error.message, ERROR_MESSAGE_LIMIT)
223
+ })
224
+ Rails.logger.error { "[saga_forge] #{event.saga_class}##{event.correlation_id} #{event.event_name} rejected: #{error.message}" }
225
+ end
226
+
227
+ def after_commit_effects(definition, state_row, facade)
228
+ @inserted_rows.each { |row| ExecutionJob.perform_later(row.id) }
229
+
230
+ if state_row.current_state == State::COMPENSATING.to_s
231
+ CompensationJob.perform_later(state_row.id)
232
+ return
233
+ end
234
+
235
+ redeliver_parked(definition, state_row)
236
+ arm_timeouts(definition, state_row)
237
+ end
238
+ end
239
+ end
240
+ end
@@ -0,0 +1,41 @@
1
+ module SagaForge
2
+ # One job per ledger row; the row id is the only argument (§A.4).
3
+ class ExecutionJob < ActiveJob::Base
4
+ queue_as { SagaForge.config.job_queue }
5
+
6
+ NOT_FOUND_RETRIES = 5
7
+ NOT_FOUND_WAIT = 2.seconds
8
+
9
+ # Extracted to a constant (rather than inlined into the limits_concurrency
10
+ # call) so it's unit-testable without Solid Queue loaded: declaring
11
+ # limits_concurrency is inert without the adapter active (it just sets
12
+ # class_attributes — see ActiveJob::ConcurrencyControls), but *loading*
13
+ # Solid Queue this late (after Combustion has already booted the test
14
+ # app) doesn't retroactively install its ActiveJob extension. See
15
+ # test/concurrency_controls_test.rb.
16
+ CONCURRENCY_KEY = ->(event_row_id) {
17
+ event = Event.find_by(id: event_row_id)
18
+ event ? "SagaLock:#{event.saga_class}:#{event.correlation_id}" : "SagaLock:none"
19
+ }
20
+
21
+ if defined?(SolidQueue)
22
+ limits_concurrency key: CONCURRENCY_KEY
23
+ end
24
+
25
+ def perform(event_row_id)
26
+ event = Event.find_by(id: event_row_id)
27
+ unless event
28
+ # Pre-commit race from an external publish inside a caller's
29
+ # transaction: brief bounded retry, then silent discard (§A.2).
30
+ retry_job(wait: NOT_FOUND_WAIT) if executions < NOT_FOUND_RETRIES
31
+ return
32
+ end
33
+
34
+ outcome, arg = Execution::Runner.new(event).call
35
+ case outcome
36
+ when :respin then retry_job(wait: SagaForge.config.stall_wait)
37
+ when :retry then retry_job(wait: arg)
38
+ end
39
+ end
40
+ end
41
+ end