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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +87 -0
- data/LICENSE +21 -0
- data/README.md +635 -0
- data/lib/generators/saga_forge/install/USAGE +28 -0
- data/lib/generators/saga_forge/install/install_generator.rb +60 -0
- data/lib/generators/saga_forge/migration_actions.rb +68 -0
- data/lib/generators/saga_forge/templates/initializer.rb +23 -0
- data/lib/generators/saga_forge/templates/install_saga_forge.rb +84 -0
- data/lib/generators/saga_forge/upgrade/USAGE +15 -0
- data/lib/generators/saga_forge/upgrade/upgrade_generator.rb +30 -0
- data/lib/saga_forge/application_record.rb +15 -0
- data/lib/saga_forge/base.rb +61 -0
- data/lib/saga_forge/compensation_job.rb +24 -0
- data/lib/saga_forge/compensation_runner.rb +142 -0
- data/lib/saga_forge/composite_retry_policy.rb +48 -0
- data/lib/saga_forge/configuration.rb +32 -0
- data/lib/saga_forge/dashboard/graph.rb +20 -0
- data/lib/saga_forge/definition.rb +268 -0
- data/lib/saga_forge/event.rb +16 -0
- data/lib/saga_forge/execution/compensation_facade.rb +21 -0
- data/lib/saga_forge/execution/facade.rb +45 -0
- data/lib/saga_forge/execution/post_commit.rb +53 -0
- data/lib/saga_forge/execution/runner.rb +240 -0
- data/lib/saga_forge/execution_job.rb +41 -0
- data/lib/saga_forge/publisher.rb +43 -0
- data/lib/saga_forge/railtie.rb +17 -0
- data/lib/saga_forge/retention_job.rb +28 -0
- data/lib/saga_forge/retry_policy.rb +107 -0
- data/lib/saga_forge/router.rb +70 -0
- data/lib/saga_forge/state.rb +113 -0
- data/lib/saga_forge/sweeper_job.rb +80 -0
- data/lib/saga_forge/timeout_job.rb +96 -0
- data/lib/saga_forge/version.rb +3 -0
- data/lib/saga_forge.rb +81 -0
- 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
|