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.
Files changed (32) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +99 -1
  3. data/README.md +238 -12
  4. data/app/controllers/active_durable/executions_controller.rb +2 -1
  5. data/app/helpers/active_durable/dashboard_helper.rb +12 -4
  6. data/app/views/active_durable/executions/index.html.erb +1 -1
  7. data/app/views/active_durable/executions/show.html.erb +9 -6
  8. data/app/views/layouts/active_durable/application.html.erb +3 -3
  9. data/lib/active_durable/configuration.rb +30 -0
  10. data/lib/active_durable/errors.rb +53 -8
  11. data/lib/active_durable/execution.rb +22 -2
  12. data/lib/active_durable/flow.rb +147 -31
  13. data/lib/active_durable/flow_parallel.rb +38 -12
  14. data/lib/active_durable/notebook.rb +17 -4
  15. data/lib/active_durable/operations.rb +55 -17
  16. data/lib/active_durable/pruner.rb +19 -4
  17. data/lib/active_durable/record.rb +9 -1
  18. data/lib/active_durable/runner.rb +45 -10
  19. data/lib/active_durable/serializer.rb +42 -5
  20. data/lib/active_durable/signal_record.rb +18 -2
  21. data/lib/active_durable/step.rb +20 -0
  22. data/lib/active_durable/sweeper.rb +1 -1
  23. data/lib/active_durable/testing.rb +18 -13
  24. data/lib/active_durable/version.rb +1 -1
  25. data/lib/active_durable.rb +79 -11
  26. data/lib/generators/active_durable/install/templates/create_active_durable_tables.rb.tt +15 -6
  27. data/lib/generators/active_durable/upgrade/templates/add_active_durable_context.rb.tt +19 -0
  28. data/lib/generators/active_durable/upgrade/templates/add_active_durable_continued_as.rb.tt +21 -0
  29. data/lib/generators/active_durable/upgrade/templates/add_active_durable_signal_name.rb.tt +22 -0
  30. data/lib/generators/active_durable/upgrade/templates/make_active_durable_ids_case_sensitive.rb.tt +72 -0
  31. data/lib/generators/active_durable/upgrade/upgrade_generator.rb +4 -1
  32. 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; end
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; end
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 NameError or NoMethodError (a typo, a missing class or
55
- # method). Retrying cannot fix it and undoing the saga would punish customers for a bug, so the execution is
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.to_s[0, 2000],
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), or replaced by a rerun.
13
- TERMINAL = %w[completed compensated blocked superseded].freeze
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
@@ -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
- # @param name [Symbol, String]
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
- # @return [Object] the signal's payload
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
- def wait_for(name, timeout: nil)
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
- signal = SignalRecord.next_for(execution_id, name)
207
- return consume_signal(signal, name, position).deep_dup if signal
224
+ signals = pending_signals(signal_name, all)
225
+ return consume_signals(signals, name, position, all).deep_dup if signals.any?
208
226
 
209
- if entry.nil?
210
- deadline = timeout && (now + timeout)
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
- if entry.wake_at && now >= entry.wake_at
216
- error = WaitTimeout.new("no :#{name} signal arrived before #{entry.wake_at.utc.iso8601}")
217
- @notebook.fail!(name, kind: "wait", position: position, attempts: 1,
218
- error: ActiveDurable.dump_error(error, step: name))
219
- raise StepFailed.new(name, error)
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.suspend!(entry.wake_at, "waiting")
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
- raise StopForward, name if compensating?
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 { record_result(name, kind, position, block.call(ticket)) }
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 NotSerializable, InvalidRecipe
291
- raise
292
- rescue NameError => e # NoMethodError too: a bug in the code, not a failure of the outside world
293
- raise CodeError.new(name, e)
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
- @notebook.complete!(name, kind: kind, position: position, result: result)
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 consume_signal(signal, name, position)
378
- payload = signal.payload
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: signal.id, consumed_at: nil).update_all(consumed_at: now)
381
- raise LeaseLost, "signal #{signal.id} for #{execution_id} was already consumed" if taken.zero?
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: payload)
491
+ @notebook.complete!(name, kind: "wait", position: position, result: result)
384
492
  end
385
- payload
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
- raise StopForward, name if compensating?
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
- results = branches.to_h { |branch| [branch.name, outcomes.fetch(branch.full_name).last] }
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
- wrappers.reverse.inject(block) { |inner, (wrapper, captured)| -> { wrapper.wrap(captured, &inner) } }.call
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 { record_result(branch.full_name, branch.kind, nil, branch.block.call(ticket)) }
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 NotSerializable, InvalidRecipe
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.retrying?)
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
- def wait!(name, kind:, position:, wake_at:)
52
- write!(name, kind: kind, position: position, status: "waiting", wake_at: wake_at)
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 and their undos use it: a step that rolled back must not look completed.
57
- def transaction(&)
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