active_durable 0.6.0 → 0.8.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 +4 -4
- data/CHANGELOG.md +99 -1
- data/README.md +238 -12
- data/app/controllers/active_durable/executions_controller.rb +2 -1
- data/app/helpers/active_durable/dashboard_helper.rb +12 -4
- data/app/views/active_durable/executions/index.html.erb +1 -1
- data/app/views/active_durable/executions/show.html.erb +9 -6
- data/app/views/layouts/active_durable/application.html.erb +3 -3
- data/lib/active_durable/configuration.rb +30 -0
- data/lib/active_durable/errors.rb +53 -8
- data/lib/active_durable/execution.rb +22 -2
- data/lib/active_durable/flow.rb +147 -31
- data/lib/active_durable/flow_parallel.rb +38 -12
- data/lib/active_durable/notebook.rb +17 -4
- data/lib/active_durable/operations.rb +55 -17
- data/lib/active_durable/pruner.rb +19 -4
- data/lib/active_durable/record.rb +9 -1
- data/lib/active_durable/runner.rb +45 -10
- data/lib/active_durable/serializer.rb +42 -5
- data/lib/active_durable/signal_record.rb +18 -2
- data/lib/active_durable/step.rb +20 -0
- data/lib/active_durable/sweeper.rb +1 -1
- data/lib/active_durable/testing.rb +18 -13
- data/lib/active_durable/version.rb +1 -1
- data/lib/active_durable.rb +79 -11
- data/lib/generators/active_durable/install/templates/create_active_durable_tables.rb.tt +15 -6
- data/lib/generators/active_durable/upgrade/templates/add_active_durable_context.rb.tt +19 -0
- data/lib/generators/active_durable/upgrade/templates/add_active_durable_continued_as.rb.tt +21 -0
- data/lib/generators/active_durable/upgrade/templates/add_active_durable_signal_name.rb.tt +22 -0
- data/lib/generators/active_durable/upgrade/templates/make_active_durable_ids_case_sensitive.rb.tt +72 -0
- data/lib/generators/active_durable/upgrade/upgrade_generator.rb +4 -1
- metadata +5 -1
|
@@ -39,6 +39,34 @@ module ActiveDurable
|
|
|
39
39
|
# development and test only: closed in production, staging and any other environment.
|
|
40
40
|
attr_accessor :dashboard_authorize
|
|
41
41
|
|
|
42
|
+
# Errors that mean the code is wrong, not the outside world. A step that raises one of them is neither retried
|
|
43
|
+
# nor undone: the execution is blocked until the code is fixed and someone calls ActiveDurable.retry. Classes
|
|
44
|
+
# or class names; a name also matches subclasses and needs no loaded gem. Add your own with
|
|
45
|
+
# `config.code_errors << "Payments::Misconfigured"`, or drop one with `config.code_errors -= ["ArgumentError"]`.
|
|
46
|
+
# Errors outside StandardError (LoadError, NotImplementedError, SystemStackError) always count as bugs.
|
|
47
|
+
attr_accessor :code_errors
|
|
48
|
+
|
|
49
|
+
# The default {#code_errors}. NameError covers NoMethodError, and IndexError covers KeyError.
|
|
50
|
+
DEFAULT_CODE_ERRORS = %w[
|
|
51
|
+
NameError ArgumentError TypeError IndexError FrozenError ZeroDivisionError RangeError
|
|
52
|
+
NoMatchingPatternError LocalJumpError RegexpError EncodingError
|
|
53
|
+
].freeze
|
|
54
|
+
|
|
55
|
+
# Who the work is for (the tenant, the user, the request id), carried from where an execution starts to every
|
|
56
|
+
# run of it. An object with two methods:
|
|
57
|
+
#
|
|
58
|
+
# module TenantContext
|
|
59
|
+
# def self.capture = { "tenant_id" => Current.tenant_id } # when the execution starts
|
|
60
|
+
# def self.wrap(context, &run) = Current.set(tenant_id: context["tenant_id"], &run) # around every run
|
|
61
|
+
# end
|
|
62
|
+
# config.context = TenantContext
|
|
63
|
+
#
|
|
64
|
+
# capture returns plain JSON (or nil), stored with the execution; ActiveDurable.start(..., context: {...})
|
|
65
|
+
# gives it explicitly instead. wrap runs around the recipe, the undos, the flow.on hooks and each
|
|
66
|
+
# flow.parallel branch thread, and must call the block exactly once. Executions started without a context
|
|
67
|
+
# get {}. Needs the context column (rails generate active_durable:upgrade).
|
|
68
|
+
attr_accessor :context
|
|
69
|
+
|
|
42
70
|
attr_writer :logger
|
|
43
71
|
|
|
44
72
|
def initialize
|
|
@@ -53,6 +81,8 @@ module ActiveDurable
|
|
|
53
81
|
@parallel_concurrency = 4
|
|
54
82
|
@clock = -> { Time.current }
|
|
55
83
|
@dashboard_authorize = nil
|
|
84
|
+
@code_errors = DEFAULT_CODE_ERRORS.dup
|
|
85
|
+
@context = nil
|
|
56
86
|
@logger = nil
|
|
57
87
|
end
|
|
58
88
|
|
|
@@ -18,7 +18,10 @@ module ActiveDurable
|
|
|
18
18
|
class RecipeChanged < Error; end
|
|
19
19
|
|
|
20
20
|
# A step returned something that cannot be stored in the notebook as JSON.
|
|
21
|
-
class NotSerializable < Error
|
|
21
|
+
class NotSerializable < Error
|
|
22
|
+
# @api private
|
|
23
|
+
attr_accessor :step_name
|
|
24
|
+
end
|
|
22
25
|
|
|
23
26
|
# Raise it inside a step to reject the work for a business reason: no retries, straight to compensation.
|
|
24
27
|
class Abort < Error; end
|
|
@@ -37,8 +40,15 @@ module ActiveDurable
|
|
|
37
40
|
end
|
|
38
41
|
end
|
|
39
42
|
|
|
40
|
-
# An undo kept failing after all its attempts. The execution is blocked for a human to review.
|
|
41
|
-
class UndoFailed < Error
|
|
43
|
+
# An undo kept failing after all its attempts, or hit a bug. The execution is blocked for a human to review.
|
|
44
|
+
class UndoFailed < Error
|
|
45
|
+
attr_reader :step_name
|
|
46
|
+
|
|
47
|
+
def initialize(message = nil, step_name: nil)
|
|
48
|
+
@step_name = step_name
|
|
49
|
+
super(message)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
42
52
|
|
|
43
53
|
# A flow.on(:completed) or flow.on(:compensated) hook raised. The execution is blocked; ActiveDurable.retry runs
|
|
44
54
|
# the hook again once it is fixed.
|
|
@@ -51,9 +61,10 @@ module ActiveDurable
|
|
|
51
61
|
end
|
|
52
62
|
end
|
|
53
63
|
|
|
54
|
-
# A step whose code cannot run as written: it raised
|
|
55
|
-
#
|
|
56
|
-
# blocked until the code is fixed and someone calls ActiveDurable.retry.
|
|
64
|
+
# A step whose code cannot run as written: it raised one of {Configuration#code_errors} (a typo, a missing
|
|
65
|
+
# key, a wrong argument). Retrying cannot fix it and undoing the saga would punish customers for a bug, so the
|
|
66
|
+
# execution is blocked until the code is fixed and someone calls ActiveDurable.retry. Rescuing it in the recipe
|
|
67
|
+
# does not help: the saga stops at the next step and blocks anyway.
|
|
57
68
|
class CodeError < Error
|
|
58
69
|
attr_reader :step_name
|
|
59
70
|
|
|
@@ -63,6 +74,17 @@ module ActiveDurable
|
|
|
63
74
|
end
|
|
64
75
|
end
|
|
65
76
|
|
|
77
|
+
# A step ran, but the database refused to record its result. Running it again could repeat its effect and
|
|
78
|
+
# undoing the saga would skip it, so the execution is blocked at that step.
|
|
79
|
+
class CheckpointFailed < Error
|
|
80
|
+
attr_reader :step_name
|
|
81
|
+
|
|
82
|
+
def initialize(step_name, error)
|
|
83
|
+
@step_name = step_name
|
|
84
|
+
super("step :#{step_name} ran, but its result could not be recorded: #{error.class}: #{error.message}")
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
66
88
|
# Control flow signals. They inherit from Exception on purpose: a `rescue => e` inside
|
|
67
89
|
# user code must not swallow them, otherwise a lost lease could keep writing.
|
|
68
90
|
#
|
|
@@ -79,13 +101,36 @@ module ActiveDurable
|
|
|
79
101
|
# @api private
|
|
80
102
|
class StopForward < ControlFlow; end
|
|
81
103
|
|
|
82
|
-
# Serializes an exception for the notebook and the dashboard.
|
|
104
|
+
# Serializes an exception for the notebook and the dashboard. The message is cleaned up first: it may quote the
|
|
105
|
+
# very bytes the database refused.
|
|
83
106
|
def self.dump_error(error, step: nil)
|
|
84
107
|
{
|
|
85
108
|
"class" => error.class.name,
|
|
86
|
-
"message" => error.message
|
|
109
|
+
"message" => storable_text(error.message)[0, 2000],
|
|
87
110
|
"step" => step,
|
|
88
111
|
"at" => now.utc.iso8601(6)
|
|
89
112
|
}.compact
|
|
90
113
|
end
|
|
114
|
+
|
|
115
|
+
# Whether an error means the code is wrong rather than the outside world: it is one of config.code_errors (or a
|
|
116
|
+
# subclass), or one of the errors Ruby raises outside StandardError, such as LoadError or SystemStackError.
|
|
117
|
+
#
|
|
118
|
+
# @api private
|
|
119
|
+
def self.code_error?(error)
|
|
120
|
+
return true unless error.is_a?(StandardError)
|
|
121
|
+
|
|
122
|
+
names = config.code_errors.map { |item| item.is_a?(Module) ? item.name : item.to_s }
|
|
123
|
+
error.class.ancestors.any? { |ancestor| names.include?(ancestor.name) }
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# @api private
|
|
127
|
+
def self.storable_text(text)
|
|
128
|
+
text = text.to_s
|
|
129
|
+
text = if [Encoding::UTF_8, Encoding::BINARY, Encoding::US_ASCII].include?(text.encoding)
|
|
130
|
+
text.dup.force_encoding(Encoding::UTF_8).scrub("?")
|
|
131
|
+
else
|
|
132
|
+
text.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: "?")
|
|
133
|
+
end
|
|
134
|
+
text.delete("\u0000")
|
|
135
|
+
end
|
|
91
136
|
end
|
|
@@ -9,12 +9,17 @@ module ActiveDurable
|
|
|
9
9
|
# wake-up time (a sleep or a retry) or waiting for a signal.
|
|
10
10
|
ACTIVE = %w[pending running sleeping waiting].freeze
|
|
11
11
|
# Statuses where nothing happens without a person: done, undone, blocked (needs {ActiveDurable.retry},
|
|
12
|
-
# {ActiveDurable.compensate} or a fix),
|
|
13
|
-
|
|
12
|
+
# {ActiveDurable.compensate} or a fix), replaced by a rerun, or carried on by another execution
|
|
13
|
+
# (flow.continue_as_new, see {#continued_as}).
|
|
14
|
+
TERMINAL = %w[completed compensated blocked superseded continued].freeze
|
|
15
|
+
# Statuses that never change again. Signals are refused (a continued execution passes them on), and
|
|
16
|
+
# ActiveDurable.prune may delete them (a continued one only together with the execution that carried it on).
|
|
17
|
+
FINISHED = %w[completed compensated superseded continued].freeze
|
|
14
18
|
# Every status.
|
|
15
19
|
STATUSES = (ACTIVE + TERMINAL).freeze
|
|
16
20
|
|
|
17
21
|
attribute :input, JSON_TYPE, default: -> { {} }
|
|
22
|
+
attribute :context, JSON_TYPE
|
|
18
23
|
attribute :output, JSON_TYPE
|
|
19
24
|
attribute :error, JSON_TYPE
|
|
20
25
|
|
|
@@ -32,6 +37,16 @@ module ActiveDurable
|
|
|
32
37
|
# the sweeper finds the pending row and enqueues it.
|
|
33
38
|
after_create_commit { ActiveDurable.enqueue(id) }
|
|
34
39
|
|
|
40
|
+
# @return [Boolean] whether flow.continue_as_new closed it; {#continued_as} is the id that carries on
|
|
41
|
+
def continued?
|
|
42
|
+
status == "continued"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# @api private
|
|
46
|
+
def self.continuations?
|
|
47
|
+
column_names.include?("continued_as")
|
|
48
|
+
end
|
|
49
|
+
|
|
35
50
|
# @return [Boolean] whether it will move on by itself
|
|
36
51
|
def active?
|
|
37
52
|
ACTIVE.include?(status)
|
|
@@ -42,6 +57,11 @@ module ActiveDurable
|
|
|
42
57
|
TERMINAL.include?(status)
|
|
43
58
|
end
|
|
44
59
|
|
|
60
|
+
# @return [Boolean] whether it will never change again (blocked is not finished: it can be retried)
|
|
61
|
+
def finished?
|
|
62
|
+
FINISHED.include?(status)
|
|
63
|
+
end
|
|
64
|
+
|
|
45
65
|
# The forward steps in recipe order (undo and hook entries excluded).
|
|
46
66
|
def notebook
|
|
47
67
|
steps.where.not(kind: %w[undo hook]).reorder(:position).to_a
|
data/lib/active_durable/flow.rb
CHANGED
|
@@ -32,6 +32,12 @@ module ActiveDurable
|
|
|
32
32
|
# @api private
|
|
33
33
|
attr_reader :undo_stack
|
|
34
34
|
|
|
35
|
+
# The bug (or unrecorded result) that stopped this run. Once set, no other step runs and the execution is
|
|
36
|
+
# blocked, even if the recipe rescued the error.
|
|
37
|
+
#
|
|
38
|
+
# @api private
|
|
39
|
+
attr_reader :blocked_by
|
|
40
|
+
|
|
35
41
|
# @api private
|
|
36
42
|
def initialize(runner, compensating:)
|
|
37
43
|
@runner = runner
|
|
@@ -43,6 +49,7 @@ module ActiveDurable
|
|
|
43
49
|
@seen = {}
|
|
44
50
|
@undo_stack = []
|
|
45
51
|
@hooks = {}
|
|
52
|
+
@blocked_by = nil
|
|
46
53
|
end
|
|
47
54
|
|
|
48
55
|
# @return [String] the id of the execution this recipe is running for
|
|
@@ -192,40 +199,84 @@ module ActiveDurable
|
|
|
192
199
|
# Waits, without holding a worker, for {ActiveDurable.signal}(execution_id, name, payload). A signal sent
|
|
193
200
|
# before the saga gets here is kept.
|
|
194
201
|
#
|
|
195
|
-
#
|
|
202
|
+
# By default the signal has the step's name. With `signal:`, several waits share one signal name, like a
|
|
203
|
+
# mailbox: a conversation can wait for :message on every turn, and a message that arrives while the saga is
|
|
204
|
+
# busy waits there for the next turn. With `all: true`, the wait takes every pending signal of that name at
|
|
205
|
+
# once and returns their payloads in the order they arrived, so a burst of five messages is one turn, not five.
|
|
206
|
+
#
|
|
207
|
+
# @param name [Symbol, String] the step's name
|
|
196
208
|
# @param timeout [ActiveSupport::Duration, Numeric, nil] seconds; then the step fails and the saga is undone
|
|
197
|
-
# @
|
|
209
|
+
# @param signal [Symbol, String, nil] the signal to wait for, when it is not the step's name
|
|
210
|
+
# @param all [Boolean] take every pending signal and return an Array of payloads
|
|
211
|
+
# @return [Object] the signal's payload, or an Array of payloads with `all: true`
|
|
198
212
|
# @raise [StepFailed] when the timeout passes first
|
|
199
|
-
|
|
213
|
+
# @example A chat that waits for the customer on every turn
|
|
214
|
+
# messages = flow.wait_for("reply.#{turn}", signal: :message, all: true, timeout: 1.day)
|
|
215
|
+
def wait_for(name, timeout: nil, signal: nil, all: false)
|
|
200
216
|
name, position = visit!(name, "wait")
|
|
217
|
+
signal_name = signal.nil? ? name : signal.to_s
|
|
218
|
+
check_signal_name!(name, signal_name)
|
|
201
219
|
entry = @notebook[name]
|
|
202
220
|
return entry.result.deep_dup if entry&.completed?
|
|
203
221
|
raise StepFailed.new(name, entry.error&.fetch("message", nil)) if entry&.failed?
|
|
204
222
|
raise StopForward, name if compensating?
|
|
205
223
|
|
|
206
|
-
|
|
207
|
-
return
|
|
224
|
+
signals = pending_signals(signal_name, all)
|
|
225
|
+
return consume_signals(signals, name, position, all).deep_dup if signals.any?
|
|
208
226
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
@notebook.wait!(name, kind: "wait", position: position, wake_at: deadline)
|
|
212
|
-
@runner.suspend!(deadline, "waiting")
|
|
213
|
-
end
|
|
227
|
+
keep_waiting(name, position, entry, timeout, signal_name)
|
|
228
|
+
end
|
|
214
229
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
230
|
+
# Closes this execution and starts a new one of the same recipe (its latest version) with `input`, the same
|
|
231
|
+
# context and the signals still waiting to be read. It keeps a long-lived saga, such as a conversation, from
|
|
232
|
+
# replaying an ever longer notebook: carry over a summary of what matters, not the whole history.
|
|
233
|
+
#
|
|
234
|
+
# The new execution's id is this one's plus "~2" (then "~3"...), so its steps get new tickets.
|
|
235
|
+
# {ActiveDurable.signal} sent to any id of the chain reaches the newest one; {ActiveDurable.find} with
|
|
236
|
+
# `latest: true` returns it. Call it in the recipe, not inside a step, and compute `input` from step results
|
|
237
|
+
# so a replay computes the same. Hooks of this execution do not run.
|
|
238
|
+
#
|
|
239
|
+
# @param input [Hash] the input of the new execution
|
|
240
|
+
# @raise [InvalidRecipe] inside a step or a parallel branch, or while undos are pending: those steps
|
|
241
|
+
# could never be undone. Call it before them, or after flow.pivot.
|
|
242
|
+
# @example A conversation that starts a fresh notebook every 20 turns
|
|
243
|
+
# flow.continue_as_new(summary: summary, turn: turn) if turn % 20 == 19
|
|
244
|
+
def continue_as_new(**input)
|
|
245
|
+
raise InvalidRecipe, "flow.continue_as_new must be called in the recipe, not inside a step" if inside_step?
|
|
246
|
+
raise blocked_by if blocked_by
|
|
247
|
+
raise StopForward, "continue_as_new" if compensating?
|
|
248
|
+
|
|
249
|
+
if @undo_stack.any? && !@pivoted
|
|
250
|
+
names = @undo_stack.map { |entry| ":#{entry.name}" }.join(", ")
|
|
251
|
+
raise InvalidRecipe, "flow.continue_as_new would leave #{names} without their undo. Continue before " \
|
|
252
|
+
"steps with undo:, or after flow.pivot."
|
|
220
253
|
end
|
|
221
254
|
|
|
222
|
-
@runner.
|
|
255
|
+
@runner.continue_as_new!(Serializer.normalize(input, "the input of continue_as_new"))
|
|
223
256
|
end
|
|
224
257
|
|
|
258
|
+
# @api private
|
|
259
|
+
def inside_step?
|
|
260
|
+
Thread.current[INSIDE_STEP] == true
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# @api private
|
|
264
|
+
def inside_step
|
|
265
|
+
previous = Thread.current[INSIDE_STEP]
|
|
266
|
+
Thread.current[INSIDE_STEP] = true
|
|
267
|
+
yield
|
|
268
|
+
ensure
|
|
269
|
+
Thread.current[INSIDE_STEP] = previous
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# @api private
|
|
273
|
+
INSIDE_STEP = :active_durable_inside_step
|
|
274
|
+
|
|
225
275
|
# Called when the recipe returns: every step the notebook knows about must have been reached.
|
|
226
276
|
#
|
|
227
277
|
# @api private
|
|
228
278
|
def finish!
|
|
279
|
+
raise blocked_by if blocked_by
|
|
229
280
|
return if compensating?
|
|
230
281
|
|
|
231
282
|
missing = @notebook.forward_entries.reject { |entry| @seen.key?(entry.name) }
|
|
@@ -267,7 +318,11 @@ module ActiveDurable
|
|
|
267
318
|
remember_failure(name, kind, undo, options)
|
|
268
319
|
raise StepFailed.new(name, entry.error&.fetch("message", nil))
|
|
269
320
|
end
|
|
270
|
-
|
|
321
|
+
if compensating?
|
|
322
|
+
# Stopped between attempts or on a bug: it may have acted, so undo_on_failure applies.
|
|
323
|
+
remember_failure(name, kind, undo, options) if entry&.unfinished?
|
|
324
|
+
raise StopForward, name
|
|
325
|
+
end
|
|
271
326
|
|
|
272
327
|
@runner.suspend!(entry.wake_at, "sleeping") if entry&.retrying? && entry.wake_at && entry.wake_at > now
|
|
273
328
|
|
|
@@ -280,28 +335,53 @@ module ActiveDurable
|
|
|
280
335
|
ActiveDurable.crash_point(:before_step, name)
|
|
281
336
|
result = ActiveDurable.instrument("step", execution_id: execution_id, step: name, kind: kind) do
|
|
282
337
|
if kind == "transaction"
|
|
283
|
-
@notebook.transaction
|
|
338
|
+
@notebook.transaction(records: name) do
|
|
339
|
+
record_result(name, kind, position, inside_step { block.call(ticket) })
|
|
340
|
+
end
|
|
284
341
|
else
|
|
285
|
-
record_result(name, kind, position, block.call(ticket))
|
|
342
|
+
record_result(name, kind, position, inside_step { block.call(ticket) })
|
|
286
343
|
end
|
|
287
344
|
end
|
|
288
345
|
ActiveDurable.crash_point(:after_record, name)
|
|
289
346
|
result
|
|
290
|
-
rescue
|
|
291
|
-
|
|
292
|
-
rescue
|
|
293
|
-
|
|
294
|
-
rescue StandardError => e
|
|
347
|
+
rescue Abort => e
|
|
348
|
+
handle_failure(name, kind, position, entry, undo, options, e)
|
|
349
|
+
rescue NotSerializable, InvalidRecipe, CheckpointFailed => e
|
|
350
|
+
block_step!(name, kind, position, entry, e)
|
|
351
|
+
rescue StandardError, ScriptError, SystemStackError => e
|
|
352
|
+
raise if blocked_by # a nested step already blocked the run
|
|
353
|
+
|
|
354
|
+
return block_step!(name, kind, position, entry, CodeError.new(name, e)) if ActiveDurable.code_error?(e)
|
|
355
|
+
|
|
295
356
|
handle_failure(name, kind, position, entry, undo, options, e)
|
|
296
357
|
end
|
|
297
358
|
|
|
359
|
+
# Outside a transaction the step has already acted when its result is recorded, so a write the database
|
|
360
|
+
# refuses is not a failure of the step: it blocks. Inside flow.transaction it rolled back with the step.
|
|
298
361
|
def record_result(name, kind, position, value)
|
|
299
362
|
result = Serializer.normalize(value, "the result of :#{name}")
|
|
300
363
|
ActiveDurable.crash_point(:after_call, name)
|
|
301
|
-
|
|
364
|
+
begin
|
|
365
|
+
@notebook.complete!(name, kind: kind, position: position, result: result)
|
|
366
|
+
rescue StandardError => e
|
|
367
|
+
raise if kind == "transaction"
|
|
368
|
+
|
|
369
|
+
raise CheckpointFailed.new(name, e)
|
|
370
|
+
end
|
|
302
371
|
result
|
|
303
372
|
end
|
|
304
373
|
|
|
374
|
+
# Retrying cannot fix a bug and undoing the saga would punish customers for it: the step is written down as
|
|
375
|
+
# blocked, and nothing else runs until someone fixes the code and calls ActiveDurable.retry. It keeps its
|
|
376
|
+
# attempts, since a bug is not a failure of the outside world.
|
|
377
|
+
def block_step!(name, kind, position, entry, error)
|
|
378
|
+
error.step_name ||= name if error.respond_to?(:step_name=)
|
|
379
|
+
@blocked_by = error
|
|
380
|
+
@notebook.block!(name, kind: kind, position: position, attempts: entry&.attempts || 0,
|
|
381
|
+
error: ActiveDurable.dump_error(error, step: name))
|
|
382
|
+
raise error
|
|
383
|
+
end
|
|
384
|
+
|
|
305
385
|
def handle_failure(name, kind, position, entry, undo, options, error)
|
|
306
386
|
attempts = (entry&.attempts || 0) + 1
|
|
307
387
|
default = @pivoted ? config.after_pivot_attempts : config.step_attempts
|
|
@@ -348,6 +428,8 @@ module ActiveDurable
|
|
|
348
428
|
end
|
|
349
429
|
|
|
350
430
|
def visit!(name, kind)
|
|
431
|
+
raise blocked_by if blocked_by
|
|
432
|
+
|
|
351
433
|
name = name.to_s
|
|
352
434
|
raise InvalidRecipe, "step names cannot be blank" if name.empty?
|
|
353
435
|
raise InvalidRecipe, "step names cannot end in ':undo' (#{name})" if name.end_with?(":undo")
|
|
@@ -374,15 +456,49 @@ module ActiveDurable
|
|
|
374
456
|
"recipe reached :#{name} (#{kind}). #{RECIPE_CHANGED_HINT}"
|
|
375
457
|
end
|
|
376
458
|
|
|
377
|
-
def
|
|
378
|
-
|
|
459
|
+
def pending_signals(signal_name, all)
|
|
460
|
+
signals = SignalRecord.pending_for(execution_id, signal_name)
|
|
461
|
+
all ? signals.to_a : signals.limit(1).to_a
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
# Suspends the execution until a signal arrives, or fails the wait once its deadline has passed.
|
|
465
|
+
def keep_waiting(name, position, entry, timeout, signal_name)
|
|
466
|
+
unless entry&.waiting? # first time here, or retried after a timeout: wait again, with a new deadline
|
|
467
|
+
deadline = timeout && (now + timeout)
|
|
468
|
+
@notebook.wait!(name, kind: "wait", position: position, wake_at: deadline,
|
|
469
|
+
signal_name: (signal_name unless signal_name == name))
|
|
470
|
+
@runner.suspend!(deadline, "waiting")
|
|
471
|
+
end
|
|
472
|
+
|
|
473
|
+
if entry.wake_at && now >= entry.wake_at
|
|
474
|
+
error = WaitTimeout.new("no :#{signal_name} signal arrived before #{entry.wake_at.utc.iso8601}")
|
|
475
|
+
@notebook.fail!(name, kind: "wait", position: position, attempts: 1,
|
|
476
|
+
error: ActiveDurable.dump_error(error, step: name))
|
|
477
|
+
raise StepFailed.new(name, error)
|
|
478
|
+
end
|
|
479
|
+
|
|
480
|
+
@runner.suspend!(entry.wake_at, "waiting")
|
|
481
|
+
end
|
|
482
|
+
|
|
483
|
+
# Takes the signals and completes the wait in one transaction: either both happen or neither does.
|
|
484
|
+
def consume_signals(signals, name, position, all)
|
|
485
|
+
result = all ? signals.map(&:payload) : signals.first.payload
|
|
486
|
+
ids = signals.map(&:id)
|
|
379
487
|
@notebook.transaction do
|
|
380
|
-
taken = SignalRecord.where(id:
|
|
381
|
-
raise LeaseLost, "
|
|
488
|
+
taken = SignalRecord.where(id: ids, consumed_at: nil).update_all(consumed_at: now)
|
|
489
|
+
raise LeaseLost, "a signal for #{execution_id} was already consumed" if taken != ids.size
|
|
382
490
|
|
|
383
|
-
@notebook.complete!(name, kind: "wait", position: position, result:
|
|
491
|
+
@notebook.complete!(name, kind: "wait", position: position, result: result)
|
|
384
492
|
end
|
|
385
|
-
|
|
493
|
+
result
|
|
494
|
+
end
|
|
495
|
+
|
|
496
|
+
def check_signal_name!(name, signal_name)
|
|
497
|
+
raise InvalidRecipe, "flow.wait_for :#{name} needs a signal name that is not blank" if signal_name.empty?
|
|
498
|
+
return if signal_name == name || Step.signal_names?
|
|
499
|
+
|
|
500
|
+
raise InvalidRecipe, "flow.wait_for :#{name}, signal: :#{signal_name} needs the signal_name column. Run " \
|
|
501
|
+
"rails generate active_durable:upgrade and rails db:migrate."
|
|
386
502
|
end
|
|
387
503
|
end
|
|
388
504
|
end
|
|
@@ -36,7 +36,11 @@ module ActiveDurable
|
|
|
36
36
|
prepare_branches!(name, branches)
|
|
37
37
|
entry = @notebook[name]
|
|
38
38
|
return finish_recorded_parallel(name, entry, branches) if entry&.completed? || entry&.failed?
|
|
39
|
-
|
|
39
|
+
|
|
40
|
+
if compensating? # stopped halfway: undo the branches that finished
|
|
41
|
+
remember_branches(name, branches, include_failed: true)
|
|
42
|
+
raise StopForward, name
|
|
43
|
+
end
|
|
40
44
|
|
|
41
45
|
run_parallel(name, position, branches)
|
|
42
46
|
end
|
|
@@ -77,6 +81,12 @@ module ActiveDurable
|
|
|
77
81
|
|
|
78
82
|
def run_parallel(name, position, branches)
|
|
79
83
|
outcomes = branch_outcomes(branches)
|
|
84
|
+
blocked = outcomes.find { |_, (state, _)| state == :blocked }
|
|
85
|
+
if blocked
|
|
86
|
+
@blocked_by = blocked.last.last
|
|
87
|
+
raise @blocked_by
|
|
88
|
+
end
|
|
89
|
+
|
|
80
90
|
failed = outcomes.select { |_, (state, _)| state == :failed }
|
|
81
91
|
if failed.any?
|
|
82
92
|
full_name, (_, error) = failed.first
|
|
@@ -89,13 +99,14 @@ module ActiveDurable
|
|
|
89
99
|
wakes = outcomes.values.filter_map { |state, value| value if state == :retry }
|
|
90
100
|
@runner.suspend!(wakes.min, "sleeping") if wakes.any?
|
|
91
101
|
|
|
92
|
-
|
|
102
|
+
# By branch name in sorted order, which is also how a replay reads it back from the notebook.
|
|
103
|
+
results = Serializer.canonical(branches.to_h { |branch| [branch.name, outcomes.fetch(branch.full_name).last] })
|
|
93
104
|
@notebook.complete!(name, kind: "parallel", position: position, result: results)
|
|
94
105
|
remember_branches(name, branches)
|
|
95
106
|
results.deep_dup
|
|
96
107
|
end
|
|
97
108
|
|
|
98
|
-
# { full_name => [:completed, result] | [:retry, wake_at] | [:failed, error] }
|
|
109
|
+
# { full_name => [:completed, result] | [:retry, wake_at] | [:failed, error] | [:blocked, error] }
|
|
99
110
|
def branch_outcomes(branches)
|
|
100
111
|
outcomes = {}
|
|
101
112
|
runnable = []
|
|
@@ -145,9 +156,12 @@ module ActiveDurable
|
|
|
145
156
|
end
|
|
146
157
|
|
|
147
158
|
def in_branch_context(wrappers, &block)
|
|
159
|
+
context = @runner.execution.context
|
|
148
160
|
run = lambda do
|
|
149
161
|
Record.connection_pool.with_connection do
|
|
150
|
-
|
|
162
|
+
ActiveDurable.with_context(context) do
|
|
163
|
+
wrappers.reverse.inject(block) { |inner, (wrapper, captured)| -> { wrapper.wrap(captured, &inner) } }.call
|
|
164
|
+
end
|
|
151
165
|
end
|
|
152
166
|
end
|
|
153
167
|
if defined?(Rails) && Rails.respond_to?(:application) && Rails.application
|
|
@@ -164,19 +178,31 @@ module ActiveDurable
|
|
|
164
178
|
result = ActiveDurable.instrument("step", execution_id: execution_id, step: branch.full_name,
|
|
165
179
|
kind: branch.kind) do
|
|
166
180
|
if branch.kind == "transaction"
|
|
167
|
-
@notebook.transaction
|
|
181
|
+
@notebook.transaction(records: branch.full_name) do
|
|
182
|
+
record_result(branch.full_name, branch.kind, nil, inside_step { branch.block.call(ticket) })
|
|
183
|
+
end
|
|
168
184
|
else
|
|
169
|
-
record_result(branch.full_name, branch.kind, nil, branch.block.call(ticket))
|
|
185
|
+
record_result(branch.full_name, branch.kind, nil, inside_step { branch.block.call(ticket) })
|
|
170
186
|
end
|
|
171
187
|
end
|
|
172
188
|
ActiveDurable.crash_point(:after_record, branch.full_name)
|
|
173
189
|
[:completed, result]
|
|
174
|
-
rescue
|
|
175
|
-
raise
|
|
176
|
-
rescue NameError => e
|
|
177
|
-
raise CodeError.new(branch.full_name, e)
|
|
178
|
-
rescue StandardError => e
|
|
190
|
+
rescue Abort => e
|
|
179
191
|
branch_failure(branch, entry, e)
|
|
192
|
+
rescue NotSerializable, InvalidRecipe, CheckpointFailed => e
|
|
193
|
+
block_branch(branch, entry, e)
|
|
194
|
+
rescue StandardError, ScriptError, SystemStackError => e
|
|
195
|
+
return block_branch(branch, entry, CodeError.new(branch.full_name, e)) if ActiveDurable.code_error?(e)
|
|
196
|
+
|
|
197
|
+
branch_failure(branch, entry, e)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Like block_step!, from inside a branch thread: the run blocks once every branch has finished.
|
|
201
|
+
def block_branch(branch, entry, error)
|
|
202
|
+
error.step_name ||= branch.full_name if error.respond_to?(:step_name=)
|
|
203
|
+
@notebook.block!(branch.full_name, kind: branch.kind, position: nil, attempts: entry&.attempts || 0,
|
|
204
|
+
error: ActiveDurable.dump_error(error, step: branch.full_name))
|
|
205
|
+
[:blocked, error]
|
|
180
206
|
end
|
|
181
207
|
|
|
182
208
|
def branch_failure(branch, entry, error)
|
|
@@ -205,7 +231,7 @@ module ActiveDurable
|
|
|
205
231
|
|
|
206
232
|
if entry.completed?
|
|
207
233
|
@undo_stack << UndoEntry.new(entry.name, branch.kind, entry.result, branch.undo)
|
|
208
|
-
elsif include_failed && branch.options[:undo_on_failure] && (entry.failed? || entry.
|
|
234
|
+
elsif include_failed && branch.options[:undo_on_failure] && (entry.failed? || entry.unfinished?)
|
|
209
235
|
@undo_stack << UndoEntry.new(entry.name, branch.kind, nil, branch.undo)
|
|
210
236
|
end
|
|
211
237
|
end
|
|
@@ -48,17 +48,30 @@ module ActiveDurable
|
|
|
48
48
|
wake_at: nil)
|
|
49
49
|
end
|
|
50
50
|
|
|
51
|
-
|
|
52
|
-
|
|
51
|
+
# The step hit a bug or its result could not be recorded: it runs again after ActiveDurable.retry.
|
|
52
|
+
def block!(name, kind:, position:, attempts:, error:)
|
|
53
|
+
write!(name, kind: kind, position: position, status: "blocked", attempts: attempts, error: error, wake_at: nil)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def wait!(name, kind:, position:, wake_at:, signal_name: nil)
|
|
57
|
+
attributes = { kind: kind, position: position, status: "waiting", wake_at: wake_at }
|
|
58
|
+
attributes[:signal_name] = signal_name if Step.signal_names?
|
|
59
|
+
write!(name, **attributes)
|
|
53
60
|
end
|
|
54
61
|
|
|
55
62
|
# Runs the block in a database transaction and remembers the notebook writes made inside it only once it
|
|
56
|
-
# commits. flow.transaction steps
|
|
57
|
-
|
|
63
|
+
# commits. flow.transaction steps, their undos and hooks use it: a step that rolled back must not look
|
|
64
|
+
# completed. With `records:`, the block must have recorded that entry: Active Record swallows
|
|
65
|
+
# ActiveRecord::Rollback, so a block that raised it would otherwise look done.
|
|
66
|
+
def transaction(records: nil, &)
|
|
58
67
|
pending = []
|
|
59
68
|
Thread.current[pending_key] = pending
|
|
60
69
|
result = Record.transaction(&)
|
|
61
70
|
pending.each { |name, entry| remember(name, entry) }
|
|
71
|
+
if records && !self[records]&.completed?
|
|
72
|
+
raise Error, ":#{records} rolled back (ActiveRecord::Rollback), so nothing was recorded. Raise an error " \
|
|
73
|
+
"or call flow.abort! to make it fail."
|
|
74
|
+
end
|
|
62
75
|
result
|
|
63
76
|
ensure
|
|
64
77
|
Thread.current[pending_key] = nil
|