active_durable 0.5.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 +131 -0
- data/LICENSE.txt +21 -0
- data/README.md +630 -0
- data/Rakefile +12 -0
- data/app/controllers/active_durable/application_controller.rb +25 -0
- data/app/controllers/active_durable/executions_controller.rb +59 -0
- data/app/helpers/active_durable/dashboard_helper.rb +163 -0
- data/app/views/active_durable/executions/index.html.erb +90 -0
- data/app/views/active_durable/executions/show.html.erb +197 -0
- data/app/views/layouts/active_durable/application.html.erb +422 -0
- data/config/routes.rb +13 -0
- data/lib/active_durable/configuration.rb +59 -0
- data/lib/active_durable/engine.rb +24 -0
- data/lib/active_durable/errors.rb +61 -0
- data/lib/active_durable/execution.rb +43 -0
- data/lib/active_durable/flow.rb +277 -0
- data/lib/active_durable/flow_parallel.rb +200 -0
- data/lib/active_durable/lease.rb +50 -0
- data/lib/active_durable/notebook.rb +100 -0
- data/lib/active_durable/open_telemetry.rb +94 -0
- data/lib/active_durable/operations.rb +111 -0
- data/lib/active_durable/parallel.rb +54 -0
- data/lib/active_durable/record.rb +13 -0
- data/lib/active_durable/registry.rb +89 -0
- data/lib/active_durable/retry_policy.rb +42 -0
- data/lib/active_durable/run_job.rb +12 -0
- data/lib/active_durable/runner.rb +157 -0
- data/lib/active_durable/serializer.rb +40 -0
- data/lib/active_durable/signal_record.rb +20 -0
- data/lib/active_durable/step.rb +34 -0
- data/lib/active_durable/sweep_job.rb +12 -0
- data/lib/active_durable/sweeper.rb +22 -0
- data/lib/active_durable/testing.rb +118 -0
- data/lib/active_durable/version.rb +5 -0
- data/lib/active_durable.rb +145 -0
- data/lib/generators/active_durable/install/install_generator.rb +27 -0
- data/lib/generators/active_durable/install/templates/create_active_durable_tables.rb.tt +60 -0
- data/lib/tasks/active_durable.rake +24 -0
- data/sig/active_durable.rbs +4 -0
- metadata +134 -0
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveDurable
|
|
4
|
+
# The object a recipe receives. Every call that touches the outside world goes through it, so it can
|
|
5
|
+
# be checkpointed in the notebook and skipped on replay.
|
|
6
|
+
#
|
|
7
|
+
# Durable.define :checkout do |flow, order_id:|
|
|
8
|
+
# flow.transaction :reserve_stock, undo: -> { ... } do ... end
|
|
9
|
+
# flow.step :charge, undo: ->(charge, ticket) { ... } do |ticket| ... end
|
|
10
|
+
# flow.pivot(:ship) { |ticket| ... }
|
|
11
|
+
# flow.step(:email) { ... }
|
|
12
|
+
# end
|
|
13
|
+
class Flow
|
|
14
|
+
UndoEntry = Struct.new(:name, :kind, :result, :undo)
|
|
15
|
+
STEP_OPTIONS = %i[retry undo_on_failure].freeze
|
|
16
|
+
|
|
17
|
+
attr_reader :undo_stack
|
|
18
|
+
|
|
19
|
+
def initialize(runner, compensating:)
|
|
20
|
+
@runner = runner
|
|
21
|
+
@notebook = runner.notebook
|
|
22
|
+
@execution = runner.execution
|
|
23
|
+
@compensating = compensating
|
|
24
|
+
@pivoted = false
|
|
25
|
+
@position = 0
|
|
26
|
+
@seen = {}
|
|
27
|
+
@undo_stack = []
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def execution_id
|
|
31
|
+
@execution.id
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# The ticket (idempotency key) a step receives. It is the same every time the step runs.
|
|
35
|
+
def ticket_for(name)
|
|
36
|
+
"#{execution_id}:#{name}"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def compensating?
|
|
40
|
+
@compensating
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def compensating!
|
|
44
|
+
@compensating = true
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def pivoted?
|
|
48
|
+
@pivoted
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# A step that talks to the outside world. It runs at most until it is recorded; pass the ticket to the
|
|
52
|
+
# service as its idempotency key so a repeat after a crash is recognised.
|
|
53
|
+
def step(name, undo: nil, **options, &block)
|
|
54
|
+
run_step(name, "step", undo, options, block)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# A step that only touches your own database. It runs in the same transaction that records it,
|
|
58
|
+
# so it happens exactly once.
|
|
59
|
+
def transaction(name, undo: nil, **options, &block)
|
|
60
|
+
run_step(name, "transaction", undo, options, block)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# The point of no return. Before it, failures are compensated; after it, steps are retried.
|
|
64
|
+
def pivot(name, **options, &block)
|
|
65
|
+
run_step(name, "pivot", nil, options, block)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Rejects the saga for a business reason: no retries, straight to compensation.
|
|
69
|
+
def abort!(message)
|
|
70
|
+
raise Abort, message
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Waits without holding a worker: the wake-up time is written down and the execution is released.
|
|
74
|
+
def sleep(name, duration)
|
|
75
|
+
name, position = visit!(name, "sleep")
|
|
76
|
+
entry = @notebook[name]
|
|
77
|
+
if entry.nil?
|
|
78
|
+
raise StopForward, name if compensating?
|
|
79
|
+
|
|
80
|
+
wake_at = now + duration
|
|
81
|
+
@notebook.complete!(name, kind: "sleep", position: position,
|
|
82
|
+
result: { "wake_at" => wake_at.utc.iso8601(6) })
|
|
83
|
+
@runner.suspend!(wake_at, "sleeping")
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
wake_at = Time.iso8601(entry.result.fetch("wake_at"))
|
|
87
|
+
return nil if now >= wake_at
|
|
88
|
+
raise StopForward, name if compensating?
|
|
89
|
+
|
|
90
|
+
@runner.suspend!(wake_at, "sleeping")
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Waits for Durable.signal(execution_id, name, payload) and returns the payload.
|
|
94
|
+
def wait_for(name, timeout: nil)
|
|
95
|
+
name, position = visit!(name, "wait")
|
|
96
|
+
entry = @notebook[name]
|
|
97
|
+
return entry.result.deep_dup if entry&.completed?
|
|
98
|
+
raise StepFailed.new(name, entry.error&.fetch("message", nil)) if entry&.failed?
|
|
99
|
+
raise StopForward, name if compensating?
|
|
100
|
+
|
|
101
|
+
signal = SignalRecord.next_for(execution_id, name)
|
|
102
|
+
return consume_signal(signal, name, position).deep_dup if signal
|
|
103
|
+
|
|
104
|
+
if entry.nil?
|
|
105
|
+
deadline = timeout && (now + timeout)
|
|
106
|
+
@notebook.wait!(name, kind: "wait", position: position, wake_at: deadline)
|
|
107
|
+
@runner.suspend!(deadline, "waiting")
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
if entry.wake_at && now >= entry.wake_at
|
|
111
|
+
error = WaitTimeout.new("no :#{name} signal arrived before #{entry.wake_at.utc.iso8601}")
|
|
112
|
+
@notebook.fail!(name, kind: "wait", position: position, attempts: 1,
|
|
113
|
+
error: ActiveDurable.dump_error(error, step: name))
|
|
114
|
+
raise StepFailed.new(name, error)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
@runner.suspend!(entry.wake_at, "waiting")
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Called when the recipe returns: every step the notebook knows about must have been reached.
|
|
121
|
+
def finish!
|
|
122
|
+
return if compensating?
|
|
123
|
+
|
|
124
|
+
missing = @notebook.forward_entries.reject { |entry| @seen.key?(entry.name) }
|
|
125
|
+
return if missing.empty?
|
|
126
|
+
|
|
127
|
+
names = missing.map { |entry| ":#{entry.name}" }.join(", ")
|
|
128
|
+
raise RecipeChanged, "execution #{execution_id} recorded #{names}, but the recipe no longer reaches " \
|
|
129
|
+
"#{missing.size == 1 ? "it" : "them"}. #{RECIPE_CHANGED_HINT}"
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
RECIPE_CHANGED_HINT = "Either the recipe changed while this execution was in flight, or code outside a " \
|
|
133
|
+
"step read data that changed between runs. Keep reads that decide the path inside " \
|
|
134
|
+
"steps, or define a new recipe version."
|
|
135
|
+
|
|
136
|
+
private
|
|
137
|
+
|
|
138
|
+
def now
|
|
139
|
+
ActiveDurable.now
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def config
|
|
143
|
+
ActiveDurable.config
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def run_step(name, kind, undo, options, block)
|
|
147
|
+
raise InvalidRecipe, "flow.#{kind} :#{name} needs a block" unless block
|
|
148
|
+
|
|
149
|
+
unknown = options.keys - STEP_OPTIONS
|
|
150
|
+
raise InvalidRecipe, "unknown option(s) for flow.#{kind}: #{unknown.join(", ")}" if unknown.any?
|
|
151
|
+
|
|
152
|
+
name, position = visit!(name, kind)
|
|
153
|
+
check_undo!(name, kind, undo, options)
|
|
154
|
+
|
|
155
|
+
entry = @notebook[name]
|
|
156
|
+
return remember(name, kind, entry.result, undo) if entry&.completed?
|
|
157
|
+
|
|
158
|
+
if entry&.failed?
|
|
159
|
+
remember_failure(name, kind, undo, options)
|
|
160
|
+
raise StepFailed.new(name, entry.error&.fetch("message", nil))
|
|
161
|
+
end
|
|
162
|
+
raise StopForward, name if compensating?
|
|
163
|
+
|
|
164
|
+
@runner.suspend!(entry.wake_at, "sleeping") if entry&.retrying? && entry.wake_at && entry.wake_at > now
|
|
165
|
+
|
|
166
|
+
result = execute(name, kind, position, entry, undo, options, block)
|
|
167
|
+
remember(name, kind, result, undo)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def execute(name, kind, position, entry, undo, options, block)
|
|
171
|
+
ticket = ticket_for(name)
|
|
172
|
+
ActiveDurable.crash_point(:before_step, name)
|
|
173
|
+
result = ActiveDurable.instrument("step", execution_id: execution_id, step: name, kind: kind) do
|
|
174
|
+
if kind == "transaction"
|
|
175
|
+
@notebook.transaction { record_result(name, kind, position, block.call(ticket)) }
|
|
176
|
+
else
|
|
177
|
+
record_result(name, kind, position, block.call(ticket))
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
ActiveDurable.crash_point(:after_record, name)
|
|
181
|
+
result
|
|
182
|
+
rescue NotSerializable, InvalidRecipe
|
|
183
|
+
raise
|
|
184
|
+
rescue StandardError => e
|
|
185
|
+
handle_failure(name, kind, position, entry, undo, options, e)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def record_result(name, kind, position, value)
|
|
189
|
+
result = Serializer.normalize(value, "the result of :#{name}")
|
|
190
|
+
ActiveDurable.crash_point(:after_call, name)
|
|
191
|
+
@notebook.complete!(name, kind: kind, position: position, result: result)
|
|
192
|
+
result
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def handle_failure(name, kind, position, entry, undo, options, error)
|
|
196
|
+
attempts = (entry&.attempts || 0) + 1
|
|
197
|
+
default = @pivoted ? config.after_pivot_attempts : config.step_attempts
|
|
198
|
+
policy = RetryPolicy.build(options[:retry], default_attempts: default)
|
|
199
|
+
dumped = ActiveDurable.dump_error(error, step: name)
|
|
200
|
+
|
|
201
|
+
if error.is_a?(Abort) || attempts >= policy.attempts
|
|
202
|
+
@notebook.fail!(name, kind: kind, position: position, attempts: attempts, error: dumped)
|
|
203
|
+
remember_failure(name, kind, undo, options)
|
|
204
|
+
raise StepFailed.new(name, error)
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
wake_at = now + policy.delay(attempts)
|
|
208
|
+
@notebook.retry!(name, kind: kind, position: position, attempts: attempts, wake_at: wake_at, error: dumped)
|
|
209
|
+
@runner.suspend!(wake_at, "sleeping")
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def remember(name, kind, result, undo)
|
|
213
|
+
@undo_stack << UndoEntry.new(name, kind, result, undo) if undo
|
|
214
|
+
@pivoted = true if kind == "pivot"
|
|
215
|
+
result.deep_dup
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# A failed step whose outcome may be unknown (a timeout after the charge went through) can ask for
|
|
219
|
+
# its own undo too. It receives nil as the result, plus the ticket to look the outcome up.
|
|
220
|
+
def remember_failure(name, kind, undo, options)
|
|
221
|
+
@undo_stack << UndoEntry.new(name, kind, nil, undo) if undo && options[:undo_on_failure]
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def check_undo!(name, kind, undo, options)
|
|
225
|
+
if options[:undo_on_failure]
|
|
226
|
+
raise InvalidRecipe, "undo_on_failure: on :#{name} needs an undo:" if undo.nil?
|
|
227
|
+
if kind == "transaction"
|
|
228
|
+
raise InvalidRecipe, "undo_on_failure: makes no sense on flow.transaction :#{name}: a failed " \
|
|
229
|
+
"transaction was rolled back"
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
return if undo.nil?
|
|
233
|
+
raise InvalidRecipe, "undo: for :#{name} must respond to #call" unless undo.respond_to?(:call)
|
|
234
|
+
return unless @pivoted
|
|
235
|
+
|
|
236
|
+
raise InvalidRecipe, ":#{name} comes after the point of no return (flow.pivot), so it cannot declare " \
|
|
237
|
+
"undo:. Steps after the pivot are retried, never undone."
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def visit!(name, kind)
|
|
241
|
+
name = name.to_s
|
|
242
|
+
raise InvalidRecipe, "step names cannot be blank" if name.empty?
|
|
243
|
+
raise InvalidRecipe, "step names cannot end in ':undo' (#{name})" if name.end_with?(":undo")
|
|
244
|
+
if @seen.key?(name)
|
|
245
|
+
raise DuplicateStepName, "the recipe uses the step name :#{name} twice. Each step needs its own name: " \
|
|
246
|
+
"it is the step's key in the notebook."
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
@position += 1
|
|
250
|
+
@seen[name] = @position
|
|
251
|
+
check_recipe!(name, kind, @position)
|
|
252
|
+
[name, @position]
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def check_recipe!(name, kind, position)
|
|
256
|
+
recorded = @notebook.at_position(position)
|
|
257
|
+
entry = @notebook[name]
|
|
258
|
+
return if recorded.nil? && entry.nil?
|
|
259
|
+
return if recorded && recorded.name == name && recorded.kind == kind
|
|
260
|
+
|
|
261
|
+
expected = recorded ? ":#{recorded.name} (#{recorded.kind})" : "nothing"
|
|
262
|
+
raise RecipeChanged, "execution #{execution_id} recorded #{expected} at position #{position}, but the " \
|
|
263
|
+
"recipe reached :#{name} (#{kind}). #{RECIPE_CHANGED_HINT}"
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def consume_signal(signal, name, position)
|
|
267
|
+
payload = signal.payload
|
|
268
|
+
@notebook.transaction do
|
|
269
|
+
taken = SignalRecord.where(id: signal.id, consumed_at: nil).update_all(consumed_at: now)
|
|
270
|
+
raise LeaseLost, "signal #{signal.id} for #{execution_id} was already consumed" if taken.zero?
|
|
271
|
+
|
|
272
|
+
@notebook.complete!(name, kind: "wait", position: position, result: payload)
|
|
273
|
+
end
|
|
274
|
+
payload
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
end
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveDurable
|
|
4
|
+
class Flow # rubocop:disable Style/Documentation -- documented in flow.rb
|
|
5
|
+
# flow.parallel: several steps at the same time, each one checkpointed on its own.
|
|
6
|
+
#
|
|
7
|
+
# Branches run in threads (at most config.parallel_concurrency at once), which suits steps that wait on the
|
|
8
|
+
# network. Each branch is a notebook entry named "<parallel>/<branch>", with its own ticket and retries. After
|
|
9
|
+
# a crash only the unfinished branches run again. If a branch runs out of attempts, the saga compensates the
|
|
10
|
+
# branches that completed (last finished, first undone) and every step before the parallel block.
|
|
11
|
+
module Parallel
|
|
12
|
+
def parallel(name, &block)
|
|
13
|
+
raise InvalidRecipe, "flow.parallel :#{name} needs a block" unless block
|
|
14
|
+
|
|
15
|
+
name, position = visit!(name, "parallel")
|
|
16
|
+
group = ParallelGroup.new(name)
|
|
17
|
+
block.call(group)
|
|
18
|
+
branches = group.branches
|
|
19
|
+
raise InvalidRecipe, "flow.parallel :#{name} declared no branches" if branches.empty?
|
|
20
|
+
|
|
21
|
+
prepare_branches!(name, branches)
|
|
22
|
+
entry = @notebook[name]
|
|
23
|
+
return finish_recorded_parallel(name, entry, branches) if entry&.completed? || entry&.failed?
|
|
24
|
+
raise StopForward, name if compensating?
|
|
25
|
+
|
|
26
|
+
run_parallel(name, position, branches)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def prepare_branches!(name, branches)
|
|
32
|
+
branches.each do |branch|
|
|
33
|
+
check_undo!(branch.full_name, branch.kind, branch.undo, branch.options)
|
|
34
|
+
raise DuplicateStepName, "the step name :#{branch.full_name} is used twice" if @seen.key?(branch.full_name)
|
|
35
|
+
|
|
36
|
+
@seen[branch.full_name] = nil
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
recorded = @notebook.branches_of(name).map(&:name)
|
|
40
|
+
missing = recorded - branches.map(&:full_name)
|
|
41
|
+
return if missing.empty?
|
|
42
|
+
|
|
43
|
+
raise RecipeChanged, "flow.parallel :#{name} recorded the branches #{missing.join(", ")}, which the recipe " \
|
|
44
|
+
"no longer declares. #{RECIPE_CHANGED_HINT}"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def finish_recorded_parallel(name, entry, branches)
|
|
48
|
+
if entry.completed?
|
|
49
|
+
unexpected = branches.map(&:name) - entry.result.keys
|
|
50
|
+
if unexpected.any?
|
|
51
|
+
raise RecipeChanged, "flow.parallel :#{name} already completed without the branches " \
|
|
52
|
+
"#{unexpected.join(", ")}. #{RECIPE_CHANGED_HINT}"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
remember_branches(name, branches)
|
|
56
|
+
return entry.result.deep_dup
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
remember_branches(name, branches, include_failed: true)
|
|
60
|
+
raise StepFailed.new(name, entry.error&.fetch("message", nil))
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def run_parallel(name, position, branches)
|
|
64
|
+
outcomes = branch_outcomes(branches)
|
|
65
|
+
failed = outcomes.select { |_, (state, _)| state == :failed }
|
|
66
|
+
if failed.any?
|
|
67
|
+
full_name, (_, error) = failed.first
|
|
68
|
+
remember_branches(name, branches, include_failed: true)
|
|
69
|
+
@notebook.fail!(name, kind: "parallel", position: position, attempts: 1,
|
|
70
|
+
error: ActiveDurable.dump_error(error, step: full_name))
|
|
71
|
+
raise StepFailed.new(full_name, error)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
wakes = outcomes.values.filter_map { |state, value| value if state == :retry }
|
|
75
|
+
@runner.suspend!(wakes.min, "sleeping") if wakes.any?
|
|
76
|
+
|
|
77
|
+
results = branches.to_h { |branch| [branch.name, outcomes.fetch(branch.full_name).last] }
|
|
78
|
+
@notebook.complete!(name, kind: "parallel", position: position, result: results)
|
|
79
|
+
remember_branches(name, branches)
|
|
80
|
+
results.deep_dup
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# { full_name => [:completed, result] | [:retry, wake_at] | [:failed, error] }
|
|
84
|
+
def branch_outcomes(branches)
|
|
85
|
+
outcomes = {}
|
|
86
|
+
runnable = []
|
|
87
|
+
branches.each do |branch|
|
|
88
|
+
entry = @notebook[branch.full_name]
|
|
89
|
+
if entry&.completed? then outcomes[branch.full_name] = [:completed, entry.result]
|
|
90
|
+
elsif entry&.failed? then outcomes[branch.full_name] = [:failed, StepFailed.new(branch.full_name)]
|
|
91
|
+
elsif entry&.retrying? && entry.wake_at && entry.wake_at > now
|
|
92
|
+
outcomes[branch.full_name] = [:retry, entry.wake_at]
|
|
93
|
+
else
|
|
94
|
+
runnable << branch
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
runnable.each_slice(config.parallel_concurrency) { |slice| outcomes.merge!(run_threads(slice)) }
|
|
98
|
+
outcomes
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def run_threads(slice)
|
|
102
|
+
wrappers = ActiveDurable.branch_wrappers.map { |wrapper| [wrapper, wrapper.capture] }
|
|
103
|
+
threads = slice.map do |branch|
|
|
104
|
+
Thread.new do
|
|
105
|
+
Thread.current.report_on_exception = false
|
|
106
|
+
in_branch_context(wrappers) { [branch.full_name, execute_branch(branch)] }
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
values = join_all(threads)
|
|
110
|
+
crash = values.find { |value| value.is_a?(Exception) }
|
|
111
|
+
raise crash if crash
|
|
112
|
+
|
|
113
|
+
values.to_h
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Waits for every thread, even if one of them blew up, so nothing keeps writing behind our back.
|
|
117
|
+
def join_all(threads)
|
|
118
|
+
join = lambda do
|
|
119
|
+
threads.map do |thread|
|
|
120
|
+
thread.value
|
|
121
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
122
|
+
e
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
if defined?(ActiveSupport::Dependencies) && ActiveSupport::Dependencies.respond_to?(:interlock)
|
|
126
|
+
ActiveSupport::Dependencies.interlock.permit_concurrent_loads(&join)
|
|
127
|
+
else
|
|
128
|
+
join.call
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def in_branch_context(wrappers, &block)
|
|
133
|
+
run = lambda do
|
|
134
|
+
Record.connection_pool.with_connection do
|
|
135
|
+
wrappers.reverse.inject(block) { |inner, (wrapper, captured)| -> { wrapper.wrap(captured, &inner) } }.call
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
if defined?(Rails) && Rails.respond_to?(:application) && Rails.application
|
|
139
|
+
Rails.application.executor.wrap(&run)
|
|
140
|
+
else
|
|
141
|
+
run.call
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def execute_branch(branch)
|
|
146
|
+
entry = @notebook[branch.full_name]
|
|
147
|
+
ticket = ticket_for(branch.full_name)
|
|
148
|
+
ActiveDurable.crash_point(:before_step, branch.full_name)
|
|
149
|
+
result = ActiveDurable.instrument("step", execution_id: execution_id, step: branch.full_name,
|
|
150
|
+
kind: branch.kind) do
|
|
151
|
+
if branch.kind == "transaction"
|
|
152
|
+
@notebook.transaction { record_result(branch.full_name, branch.kind, nil, branch.block.call(ticket)) }
|
|
153
|
+
else
|
|
154
|
+
record_result(branch.full_name, branch.kind, nil, branch.block.call(ticket))
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
ActiveDurable.crash_point(:after_record, branch.full_name)
|
|
158
|
+
[:completed, result]
|
|
159
|
+
rescue NotSerializable, InvalidRecipe
|
|
160
|
+
raise
|
|
161
|
+
rescue StandardError => e
|
|
162
|
+
branch_failure(branch, entry, e)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def branch_failure(branch, entry, error)
|
|
166
|
+
attempts = (entry&.attempts || 0) + 1
|
|
167
|
+
default = @pivoted ? config.after_pivot_attempts : config.step_attempts
|
|
168
|
+
policy = RetryPolicy.build(branch.options[:retry], default_attempts: default)
|
|
169
|
+
dumped = ActiveDurable.dump_error(error, step: branch.full_name)
|
|
170
|
+
if error.is_a?(Abort) || attempts >= policy.attempts
|
|
171
|
+
@notebook.fail!(branch.full_name, kind: branch.kind, position: nil, attempts: attempts, error: dumped)
|
|
172
|
+
return [:failed, error]
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
wake_at = now + policy.delay(attempts)
|
|
176
|
+
@notebook.retry!(branch.full_name, kind: branch.kind, position: nil, attempts: attempts, wake_at: wake_at,
|
|
177
|
+
error: dumped)
|
|
178
|
+
[:retry, wake_at]
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Registers the undos of the branches that completed, in the order they finished. With include_failed,
|
|
182
|
+
# also those of branches that failed or were still retrying and asked for undo_on_failure.
|
|
183
|
+
def remember_branches(name, branches, include_failed: false)
|
|
184
|
+
by_name = branches.index_by(&:full_name)
|
|
185
|
+
@notebook.branches_of(name).each do |entry|
|
|
186
|
+
branch = by_name[entry.name]
|
|
187
|
+
next unless branch&.undo
|
|
188
|
+
|
|
189
|
+
if entry.completed?
|
|
190
|
+
@undo_stack << UndoEntry.new(entry.name, branch.kind, entry.result, branch.undo)
|
|
191
|
+
elsif include_failed && branch.options[:undo_on_failure] && (entry.failed? || entry.retrying?)
|
|
192
|
+
@undo_stack << UndoEntry.new(entry.name, branch.kind, nil, branch.undo)
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
include Parallel
|
|
199
|
+
end
|
|
200
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveDurable
|
|
4
|
+
# Who is allowed to run an execution right now.
|
|
5
|
+
#
|
|
6
|
+
# A worker claims an execution with a conditional UPDATE that only succeeds when nobody holds a live
|
|
7
|
+
# lease. The claim writes a fresh random token. Every later write (notebook entries, status changes)
|
|
8
|
+
# is conditional on that token still being there, so a worker whose lease expired and was taken over
|
|
9
|
+
# cannot write anything else: its writes raise LeaseLost and it stops.
|
|
10
|
+
class Lease
|
|
11
|
+
attr_reader :execution_id, :token
|
|
12
|
+
|
|
13
|
+
def self.claim(execution_id)
|
|
14
|
+
now = ActiveDurable.now
|
|
15
|
+
token = SecureRandom.uuid
|
|
16
|
+
claimed = Execution.where(id: execution_id, status: Execution::ACTIVE)
|
|
17
|
+
.where("locked_until IS NULL OR locked_until < ?", now)
|
|
18
|
+
.update_all(lease_token: token, locked_until: now + duration, status: "running",
|
|
19
|
+
updated_at: now)
|
|
20
|
+
claimed == 1 ? new(execution_id, token) : nil
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.duration
|
|
24
|
+
ActiveDurable.config.lease_duration.to_f
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def initialize(execution_id, token)
|
|
28
|
+
@execution_id = execution_id
|
|
29
|
+
@token = token
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Extends the lease (and optionally changes other columns). Raises LeaseLost if it is no longer ours.
|
|
33
|
+
def renew!(attributes = {})
|
|
34
|
+
update!(attributes.merge(locked_until: ActiveDurable.now + self.class.duration))
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Gives the execution back, usually with a new status.
|
|
38
|
+
def release!(attributes = {})
|
|
39
|
+
update!(attributes.merge(locked_until: nil))
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def update!(attributes)
|
|
45
|
+
rows = Execution.where(id: execution_id, lease_token: token)
|
|
46
|
+
.update_all(attributes.merge(updated_at: ActiveDurable.now))
|
|
47
|
+
raise LeaseLost, "lost the lease on #{execution_id}: another worker took it over" if rows.zero?
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveDurable
|
|
4
|
+
# The notebook of one execution: every step that ran, what it returned and its state.
|
|
5
|
+
# Loaded once per run; every write is fenced by the lease.
|
|
6
|
+
class Notebook
|
|
7
|
+
def initialize(execution, lease)
|
|
8
|
+
@execution = execution
|
|
9
|
+
@lease = lease
|
|
10
|
+
@mutex = Mutex.new # parallel branches write from several threads
|
|
11
|
+
@entries = Step.where(execution_id: execution.id).order(:id).index_by(&:name)
|
|
12
|
+
@by_position = {}
|
|
13
|
+
@entries.each_value { |entry| @by_position[entry.position] = entry if entry.position }
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def [](name)
|
|
17
|
+
@mutex.synchronize { @entries[name] }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Completed branch entries of a flow.parallel, in the order they finished.
|
|
21
|
+
def branches_of(parallel_name)
|
|
22
|
+
prefix = "#{parallel_name}/"
|
|
23
|
+
@mutex.synchronize { @entries.values.select { |entry| entry.name.start_with?(prefix) && !entry.undo? } }
|
|
24
|
+
.sort_by { |entry| [entry.updated_at, entry.id] }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def at_position(position)
|
|
28
|
+
@by_position[position]
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def forward_entries
|
|
32
|
+
@mutex.synchronize { @entries.values.reject(&:undo?) }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def complete!(name, kind:, position:, result:)
|
|
36
|
+
write!(name, kind: kind, position: position, status: "completed", result: result, error: nil, wake_at: nil)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def retry!(name, kind:, position:, attempts:, wake_at:, error:)
|
|
40
|
+
write!(name, kind: kind, position: position, status: "retrying", attempts: attempts, wake_at: wake_at,
|
|
41
|
+
error: error)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def fail!(name, kind:, position:, attempts:, error:)
|
|
45
|
+
write!(name, kind: kind, position: position, status: "failed", attempts: attempts, error: error,
|
|
46
|
+
wake_at: nil)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def wait!(name, kind:, position:, wake_at:)
|
|
50
|
+
write!(name, kind: kind, position: position, status: "waiting", wake_at: wake_at)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Runs the block in a database transaction and remembers the notebook writes made inside it only once it
|
|
54
|
+
# commits. flow.transaction steps and their undos use it: a step that rolled back must not look completed.
|
|
55
|
+
def transaction(&)
|
|
56
|
+
pending = []
|
|
57
|
+
Thread.current[pending_key] = pending
|
|
58
|
+
result = Record.transaction(&)
|
|
59
|
+
pending.each { |name, entry| remember(name, entry) }
|
|
60
|
+
result
|
|
61
|
+
ensure
|
|
62
|
+
Thread.current[pending_key] = nil
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
# The database write happens outside the mutex, so a slow transaction in one branch never blocks
|
|
68
|
+
# another branch while it holds the lock (SQLite would deadlock). Existing entries are updated with
|
|
69
|
+
# update_all and read back, so the object other code holds never changes before the write commits.
|
|
70
|
+
def write!(name, **attributes)
|
|
71
|
+
now = ActiveDurable.now
|
|
72
|
+
current = self[name]
|
|
73
|
+
entry = Record.transaction do
|
|
74
|
+
@lease.renew!
|
|
75
|
+
if current
|
|
76
|
+
Step.where(id: current.id).update_all(attributes.merge(updated_at: now))
|
|
77
|
+
Step.find(current.id)
|
|
78
|
+
else
|
|
79
|
+
Step.create!(execution_id: @execution.id, name: name, created_at: now, updated_at: now, **attributes)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
pending = Thread.current[pending_key]
|
|
83
|
+
pending ? pending << [name, entry] : remember(name, entry)
|
|
84
|
+
entry
|
|
85
|
+
rescue ActiveRecord::RecordNotUnique
|
|
86
|
+
raise LeaseLost, "another worker already wrote :#{name} for #{@execution.id}"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def remember(name, entry)
|
|
90
|
+
@mutex.synchronize do
|
|
91
|
+
@entries[name] = entry
|
|
92
|
+
@by_position[entry.position] = entry if entry.position
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def pending_key
|
|
97
|
+
@pending_key ||= :"active_durable_notebook_#{object_id}"
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|