active_durable 0.5.0 → 0.7.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 (42) hide show
  1. checksums.yaml +4 -4
  2. data/.yardopts +8 -0
  3. data/CHANGELOG.md +114 -3
  4. data/README.md +174 -22
  5. data/app/controllers/active_durable/executions_controller.rb +4 -2
  6. data/app/helpers/active_durable/dashboard_helper.rb +11 -3
  7. data/app/views/active_durable/executions/index.html.erb +4 -3
  8. data/app/views/active_durable/executions/show.html.erb +16 -6
  9. data/app/views/layouts/active_durable/application.html.erb +7 -5
  10. data/lib/active_durable/configuration.rb +18 -0
  11. data/lib/active_durable/engine.rb +2 -4
  12. data/lib/active_durable/errors.rb +80 -5
  13. data/lib/active_durable/execution.rb +16 -2
  14. data/lib/active_durable/flow.rb +170 -22
  15. data/lib/active_durable/flow_parallel.rb +46 -7
  16. data/lib/active_durable/lease.rb +2 -0
  17. data/lib/active_durable/notebook.rb +17 -4
  18. data/lib/active_durable/open_telemetry.rb +14 -2
  19. data/lib/active_durable/operations.rb +54 -15
  20. data/lib/active_durable/parallel.rb +15 -0
  21. data/lib/active_durable/prune_job.rb +16 -0
  22. data/lib/active_durable/pruner.rb +32 -0
  23. data/lib/active_durable/record.rb +2 -0
  24. data/lib/active_durable/registry.rb +4 -0
  25. data/lib/active_durable/retry_policy.rb +2 -0
  26. data/lib/active_durable/run_job.rb +3 -0
  27. data/lib/active_durable/runner.rb +48 -6
  28. data/lib/active_durable/serializer.rb +29 -3
  29. data/lib/active_durable/signal_record.rb +13 -0
  30. data/lib/active_durable/step.rb +20 -0
  31. data/lib/active_durable/sweep_job.rb +3 -0
  32. data/lib/active_durable/sweeper.rb +26 -6
  33. data/lib/active_durable/testing.rb +9 -1
  34. data/lib/active_durable/version.rb +2 -1
  35. data/lib/active_durable.rb +137 -13
  36. data/lib/generators/active_durable/install/install_generator.rb +2 -0
  37. data/lib/generators/active_durable/install/templates/create_active_durable_tables.rb.tt +12 -6
  38. data/lib/generators/active_durable/upgrade/templates/add_active_durable_prune_index.rb.tt +14 -0
  39. data/lib/generators/active_durable/upgrade/templates/make_active_durable_ids_case_sensitive.rb.tt +72 -0
  40. data/lib/generators/active_durable/upgrade/upgrade_generator.rb +39 -0
  41. data/lib/tasks/active_durable.rake +13 -2
  42. metadata +31 -11
@@ -9,6 +9,21 @@ module ActiveDurable
9
9
  # a crash only the unfinished branches run again. If a branch runs out of attempts, the saga compensates the
10
10
  # branches that completed (last finished, first undone) and every step before the parallel block.
11
11
  module Parallel
12
+ # Runs several steps at the same time. Declare the branches on the group the block receives; they run
13
+ # after the block returns.
14
+ #
15
+ # @param name [Symbol, String]
16
+ # @yieldparam branches [ParallelGroup]
17
+ # @return [Hash{String => Object}] each branch's result by branch name
18
+ # @raise [StepFailed] when a branch runs out of attempts
19
+ # @example
20
+ # flow.parallel(:reserve) do |branches|
21
+ # warehouses.each do |warehouse|
22
+ # branches.step(warehouse.code, undo: ->(r, ticket) { warehouse.release(r["id"], key: ticket) }) do |ticket|
23
+ # { "id" => warehouse.reserve(order.items_for(warehouse), key: ticket) }
24
+ # end
25
+ # end
26
+ # end
12
27
  def parallel(name, &block)
13
28
  raise InvalidRecipe, "flow.parallel :#{name} needs a block" unless block
14
29
 
@@ -21,7 +36,11 @@ module ActiveDurable
21
36
  prepare_branches!(name, branches)
22
37
  entry = @notebook[name]
23
38
  return finish_recorded_parallel(name, entry, branches) if entry&.completed? || entry&.failed?
24
- 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
25
44
 
26
45
  run_parallel(name, position, branches)
27
46
  end
@@ -62,6 +81,12 @@ module ActiveDurable
62
81
 
63
82
  def run_parallel(name, position, branches)
64
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
+
65
90
  failed = outcomes.select { |_, (state, _)| state == :failed }
66
91
  if failed.any?
67
92
  full_name, (_, error) = failed.first
@@ -80,7 +105,7 @@ module ActiveDurable
80
105
  results.deep_dup
81
106
  end
82
107
 
83
- # { full_name => [:completed, result] | [:retry, wake_at] | [:failed, error] }
108
+ # { full_name => [:completed, result] | [:retry, wake_at] | [:failed, error] | [:blocked, error] }
84
109
  def branch_outcomes(branches)
85
110
  outcomes = {}
86
111
  runnable = []
@@ -149,17 +174,31 @@ module ActiveDurable
149
174
  result = ActiveDurable.instrument("step", execution_id: execution_id, step: branch.full_name,
150
175
  kind: branch.kind) do
151
176
  if branch.kind == "transaction"
152
- @notebook.transaction { record_result(branch.full_name, branch.kind, nil, branch.block.call(ticket)) }
177
+ @notebook.transaction(records: branch.full_name) do
178
+ record_result(branch.full_name, branch.kind, nil, branch.block.call(ticket))
179
+ end
153
180
  else
154
181
  record_result(branch.full_name, branch.kind, nil, branch.block.call(ticket))
155
182
  end
156
183
  end
157
184
  ActiveDurable.crash_point(:after_record, branch.full_name)
158
185
  [:completed, result]
159
- rescue NotSerializable, InvalidRecipe
160
- raise
161
- rescue StandardError => e
186
+ rescue Abort => e
162
187
  branch_failure(branch, entry, e)
188
+ rescue NotSerializable, InvalidRecipe, CheckpointFailed => e
189
+ block_branch(branch, entry, e)
190
+ rescue StandardError, ScriptError, SystemStackError => e
191
+ return block_branch(branch, entry, CodeError.new(branch.full_name, e)) if ActiveDurable.code_error?(e)
192
+
193
+ branch_failure(branch, entry, e)
194
+ end
195
+
196
+ # Like block_step!, from inside a branch thread: the run blocks once every branch has finished.
197
+ def block_branch(branch, entry, error)
198
+ error.step_name ||= branch.full_name if error.respond_to?(:step_name=)
199
+ @notebook.block!(branch.full_name, kind: branch.kind, position: nil, attempts: entry&.attempts || 0,
200
+ error: ActiveDurable.dump_error(error, step: branch.full_name))
201
+ [:blocked, error]
163
202
  end
164
203
 
165
204
  def branch_failure(branch, entry, error)
@@ -188,7 +227,7 @@ module ActiveDurable
188
227
 
189
228
  if entry.completed?
190
229
  @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?)
230
+ elsif include_failed && branch.options[:undo_on_failure] && (entry.failed? || entry.unfinished?)
192
231
  @undo_stack << UndoEntry.new(entry.name, branch.kind, nil, branch.undo)
193
232
  end
194
233
  end
@@ -7,6 +7,8 @@ module ActiveDurable
7
7
  # lease. The claim writes a fresh random token. Every later write (notebook entries, status changes)
8
8
  # is conditional on that token still being there, so a worker whose lease expired and was taken over
9
9
  # cannot write anything else: its writes raise LeaseLost and it stops.
10
+ #
11
+ # @api private
10
12
  class Lease
11
13
  attr_reader :execution_id, :token
12
14
 
@@ -3,6 +3,8 @@
3
3
  module ActiveDurable
4
4
  # The notebook of one execution: every step that ran, what it returned and its state.
5
5
  # Loaded once per run; every write is fenced by the lease.
6
+ #
7
+ # @api private
6
8
  class Notebook
7
9
  def initialize(execution, lease)
8
10
  @execution = execution
@@ -20,7 +22,7 @@ module ActiveDurable
20
22
  # Completed branch entries of a flow.parallel, in the order they finished.
21
23
  def branches_of(parallel_name)
22
24
  prefix = "#{parallel_name}/"
23
- @mutex.synchronize { @entries.values.select { |entry| entry.name.start_with?(prefix) && !entry.undo? } }
25
+ @mutex.synchronize { @entries.values.select { |entry| entry.name.start_with?(prefix) && entry.forward? } }
24
26
  .sort_by { |entry| [entry.updated_at, entry.id] }
25
27
  end
26
28
 
@@ -29,7 +31,7 @@ module ActiveDurable
29
31
  end
30
32
 
31
33
  def forward_entries
32
- @mutex.synchronize { @entries.values.reject(&:undo?) }
34
+ @mutex.synchronize { @entries.values.select(&:forward?) }
33
35
  end
34
36
 
35
37
  def complete!(name, kind:, position:, result:)
@@ -46,17 +48,28 @@ module ActiveDurable
46
48
  wake_at: nil)
47
49
  end
48
50
 
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
+
49
56
  def wait!(name, kind:, position:, wake_at:)
50
57
  write!(name, kind: kind, position: position, status: "waiting", wake_at: wake_at)
51
58
  end
52
59
 
53
60
  # 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(&)
61
+ # commits. flow.transaction steps, their undos and hooks use it: a step that rolled back must not look
62
+ # completed. With `records:`, the block must have recorded that entry: Active Record swallows
63
+ # ActiveRecord::Rollback, so a block that raised it would otherwise look done.
64
+ def transaction(records: nil, &)
56
65
  pending = []
57
66
  Thread.current[pending_key] = pending
58
67
  result = Record.transaction(&)
59
68
  pending.each { |name, entry| remember(name, entry) }
69
+ if records && !self[records]&.completed?
70
+ raise Error, ":#{records} rolled back (ActiveRecord::Rollback), so nothing was recorded. Raise an error " \
71
+ "or call flow.abort! to make it fail."
72
+ end
60
73
  result
61
74
  ensure
62
75
  Thread.current[pending_key] = nil
@@ -4,7 +4,7 @@ require "opentelemetry"
4
4
  require "active_durable"
5
5
 
6
6
  module ActiveDurable
7
- # Traces executions, steps, compensations and undos as OpenTelemetry spans.
7
+ # Traces executions, steps, compensations, undos and hooks as OpenTelemetry spans.
8
8
  #
9
9
  # # config/initializers/active_durable.rb
10
10
  # require "active_durable/open_telemetry"
@@ -13,9 +13,14 @@ module ActiveDurable
13
13
  # Spans nest: a worker run ("active_durable.execution checkout") contains its steps, and flow.parallel
14
14
  # branches stay under it even though they run in other threads. Failed steps record the exception.
15
15
  module OpenTelemetry
16
- EVENTS = %w[execution step compensation undo].freeze
16
+ # The ActiveSupport::Notifications events that become spans.
17
+ EVENTS = %w[execution step compensation undo hook].freeze
17
18
 
18
19
  class << self
20
+ # Starts tracing. Call it once, after configuring OpenTelemetry::SDK.
21
+ #
22
+ # @param tracer_provider [::OpenTelemetry::Trace::TracerProvider]
23
+ # @return [self]
19
24
  def install!(tracer_provider: ::OpenTelemetry.tracer_provider)
20
25
  uninstall!
21
26
  subscriber = Subscriber.new(tracer_provider.tracer("active_durable", ActiveDurable::VERSION))
@@ -26,6 +31,9 @@ module ActiveDurable
26
31
  self
27
32
  end
28
33
 
34
+ # Stops tracing.
35
+ #
36
+ # @return [void]
29
37
  def uninstall!
30
38
  Array(@subscriptions).each { |subscription| ActiveSupport::Notifications.unsubscribe(subscription) }
31
39
  @subscriptions = nil
@@ -34,6 +42,8 @@ module ActiveDurable
34
42
  end
35
43
 
36
44
  # Starts a span when an event starts and ends it when the event finishes, so spans nest naturally.
45
+ #
46
+ # @api private
37
47
  class Subscriber
38
48
  def initialize(tracer)
39
49
  @tracer = tracer
@@ -81,6 +91,8 @@ module ActiveDurable
81
91
  end
82
92
 
83
93
  # Carries the current span into flow.parallel branch threads.
94
+ #
95
+ # @api private
84
96
  module ContextPropagation
85
97
  def self.capture
86
98
  ::OpenTelemetry::Context.current
@@ -9,25 +9,30 @@ module ActiveDurable
9
9
  #
10
10
  # Every operation takes the execution only when no worker holds it, and rotates the lease token so a
11
11
  # stale worker can never write again.
12
+ #
13
+ # @api private
12
14
  module Operations
13
15
  module_function
14
16
 
15
- # A blocked execution tries again from where it stopped: failed steps (or failed undos, if it was
16
- # compensating) get a fresh set of attempts. Deploy the fix first when the cause was a bug.
17
+ # A blocked execution tries again from where it stopped: the step that blocked it (or the failed undos, if it
18
+ # was compensating) gets a fresh set of attempts. Failed steps the recipe already handled, such as a payment
19
+ # provider it replaced with another one, stay failed: running them again would pay twice. Deploy the fix
20
+ # first when the cause was a bug.
17
21
  def retry(execution_id)
18
22
  execution = with_idle_execution(execution_id, allowed: %w[blocked]) do |record|
19
- failed = record.steps.where(status: "failed")
20
- failed = record.compensating ? failed.where(kind: "undo") : failed.where.not(kind: "undo")
21
- failed.update_all(status: "retrying", attempts: 0, wake_at: nil, updated_at: ActiveDurable.now)
23
+ blocking_steps(record).update_all(status: "retrying", attempts: 0, wake_at: nil,
24
+ updated_at: ActiveDurable.now)
22
25
  reopen!(record, error: nil)
23
26
  end
24
27
  ActiveDurable.instrument("retried", execution_id: execution.id)
25
28
  execution
26
29
  end
27
30
 
28
- # Undoes every completed step, last one first. Not possible once the point of no return was passed.
31
+ # Undoes every completed step, last one first. Not possible once the point of no return was passed, nor while
32
+ # the execution is running: the lease is only renewed on notebook writes, so a worker may still be inside a
33
+ # slow step after its lease ran out (the sweeper resumes it if the worker died).
29
34
  def compensate(execution_id, reason: "compensated by an operator")
30
- execution = with_idle_execution(execution_id, allowed: %w[blocked pending running sleeping waiting]) do |record|
35
+ execution = with_idle_execution(execution_id, allowed: %w[blocked pending sleeping waiting]) do |record|
31
36
  raise Error, "#{record.id} is already compensating; use ActiveDurable.retry to resume it" if record.compensating
32
37
  if record.steps.exists?(kind: "pivot", status: "completed")
33
38
  raise Error, "#{record.id} already passed its point of no return; it can only move forward"
@@ -50,9 +55,7 @@ module ActiveDurable
50
55
  Record.transaction do
51
56
  original = Execution.lock.find(execution_id)
52
57
  check_rerunnable!(original)
53
- start = original.steps.where.not(kind: "undo").find_by(name: from)
54
- raise Error, "#{original.id} has no step :#{from} in its notebook" unless start
55
-
58
+ start = rerun_start(original, from)
56
59
  execution = Execution.create!(id: rerun_id(original), recipe: original.recipe,
57
60
  recipe_version: original.recipe_version, input: original.input,
58
61
  status: "pending", forked_from: original.id)
@@ -63,6 +66,15 @@ module ActiveDurable
63
66
  execution
64
67
  end
65
68
 
69
+ def rerun_start(original, from)
70
+ start = original.steps.where.not(kind: %w[undo hook]).find_by(name: from)
71
+ raise Error, "#{original.id} has no step :#{from} in its notebook" unless start
72
+ return start if start.position
73
+
74
+ parallel = from.split("/", 2).first
75
+ raise Error, ":#{from} is a branch of flow.parallel :#{parallel}; rerun from :#{parallel} instead"
76
+ end
77
+
66
78
  def with_idle_execution(execution_id, allowed:)
67
79
  execution = Record.transaction do
68
80
  record = Execution.lock.find(execution_id)
@@ -93,17 +105,44 @@ module ActiveDurable
93
105
  "only completed executions, or blocked ones that are not compensating, can be rerun"
94
106
  end
95
107
 
108
+ # Moving forward: the step named by the error, or the whole flow.parallel it belongs to, and every step that
109
+ # hit a bug. Compensating: every failed undo, since each of them stopped the compensation.
110
+ def blocking_steps(record)
111
+ steps = record.steps
112
+ return steps.where(kind: "undo", status: "failed") if record.compensating
113
+
114
+ name = record.error.is_a?(Hash) ? record.error["step"].to_s : ""
115
+ failed = steps.where(status: "failed").where.not(kind: %w[undo hook])
116
+ parallel = failed.where(kind: "parallel").pluck(:name)
117
+ .find { |group| name == group || name.start_with?("#{group}/") }
118
+ named = if parallel
119
+ failed.where(name: parallel).or(failed.where("name LIKE ?", "#{Step.sanitize_sql_like(parallel)}/%"))
120
+ else
121
+ failed.where(name: name)
122
+ end
123
+ named.or(steps.where(status: "blocked"))
124
+ end
125
+
126
+ # A random suffix, never a count: a pruned rerun must not hand its id, and so its tickets, to a new one.
96
127
  def rerun_id(original)
97
- root = original.id.sub(/~rerun-\d+\z/, "")
98
- count = Execution.where("id LIKE ?", "#{Execution.sanitize_sql_like(root)}~rerun-%").count
99
- "#{root}~rerun-#{count + 1}"
128
+ root = original.id.sub(/~rerun-\h+\z/, "")
129
+ "#{root}~rerun-#{SecureRandom.hex(4)}"
100
130
  end
101
131
 
132
+ # The steps before `from` that completed or failed (a failure the recipe handled must stay handled), and the
133
+ # branches of each flow.parallel among them, in the order they finished, so their undos still run.
102
134
  def copy_steps(original, execution, before:)
135
+ forward = original.steps.where.not(kind: %w[undo hook]).where(status: %w[completed failed])
136
+ kept = forward.where(position: ...before).order(:position).to_a
137
+ groups = kept.select { |step| step.kind == "parallel" }.map { |step| "#{step.name}/" }
138
+ branches = forward.where(position: nil).order(:updated_at, :id).select do |step|
139
+ groups.any? { |prefix| step.name.start_with?(prefix) }
140
+ end
103
141
  now = ActiveDurable.now
104
- rows = original.steps.where.not(kind: "undo").where(status: "completed").where(position: ...before).map do |step|
142
+ rows = (kept + branches).map do |step|
105
143
  { execution_id: execution.id, name: step.name, kind: step.kind, position: step.position,
106
- status: "completed", attempts: step.attempts, result: step.result, created_at: now, updated_at: now }
144
+ status: step.status, attempts: step.attempts, result: step.result, error: step.error,
145
+ created_at: now, updated_at: now }
107
146
  end
108
147
  Step.insert_all!(rows) if rows.any?
109
148
  end
@@ -13,20 +13,35 @@ module ActiveDurable
13
13
  # end
14
14
  # results # => { "MEX" => {...}, "GDL" => {...} }
15
15
  class ParallelGroup
16
+ # @api private
16
17
  Branch = Struct.new(:name, :full_name, :kind, :undo, :options, :block)
18
+ # @api private
17
19
  OPTIONS = %i[retry undo_on_failure].freeze
18
20
 
21
+ # @api private
19
22
  attr_reader :branches
20
23
 
24
+ # @api private
21
25
  def initialize(parallel_name)
22
26
  @parallel_name = parallel_name
23
27
  @branches = []
24
28
  end
25
29
 
30
+ # A branch that talks to the outside world, like {Flow#step}.
31
+ #
32
+ # @param name [Symbol, String] unique in this parallel block
33
+ # @param undo [#call, nil]
34
+ # @param options [Hash] `retry:` and `undo_on_failure:`
35
+ # @yieldparam ticket [String]
36
+ # @return [void]
26
37
  def step(name, undo: nil, **options, &block)
27
38
  add(name, "step", undo, options, block)
28
39
  end
29
40
 
41
+ # A branch that only touches your own database, like {Flow#transaction}.
42
+ #
43
+ # @param (see #step)
44
+ # @return [void]
30
45
  def transaction(name, undo: nil, **options, &block)
31
46
  add(name, "transaction", undo, options, block)
32
47
  end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveDurable
4
+ # Deletes finished executions older than config.keep_finished_for. Schedule it once a day (Solid Queue
5
+ # recurring tasks, cron, sidekiq-cron...).
6
+ class PruneJob < ActiveJob::Base
7
+ queue_as { ActiveDurable.config.queue_name }
8
+
9
+ # Deletes finished executions older than config.keep_finished_for.
10
+ #
11
+ # @return [void]
12
+ def perform
13
+ Pruner.call
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveDurable
4
+ # Deletes finished executions (completed, compensated, superseded) older than a cutoff, with their notebook and
5
+ # their signals. Active and blocked executions are never touched: they still need a worker or a person.
6
+ #
7
+ # @api private
8
+ module Pruner
9
+ module_function
10
+
11
+ # Returns how many executions were deleted. Each batch is deleted in its own transaction, steps and signals
12
+ # first, so it never depends on the database cascading the foreign keys.
13
+ def call(older_than: ActiveDurable.config.keep_finished_for, batch_size: 1_000, now: ActiveDurable.now)
14
+ raise ArgumentError, "older_than must be set, for example 30.days" if older_than.nil?
15
+
16
+ cutoff = now - older_than.to_f
17
+ deleted = 0
18
+ loop do
19
+ ids = Execution.where(status: Execution::FINISHED).where("updated_at < ?", cutoff).limit(batch_size).pluck(:id)
20
+ break if ids.empty?
21
+
22
+ Record.transaction do
23
+ Step.where(execution_id: ids).delete_all
24
+ SignalRecord.where(execution_id: ids).delete_all
25
+ Execution.where(id: ids).delete_all
26
+ end
27
+ deleted += ids.size
28
+ end
29
+ deleted
30
+ end
31
+ end
32
+ end
@@ -3,6 +3,8 @@
3
3
  module ActiveDurable
4
4
  # Base class for the gem's tables. It shares ActiveRecord::Base's connection on purpose:
5
5
  # flow.transaction is only atomic when the notebook lives in the same database as your data.
6
+ #
7
+ # @api private
6
8
  class Record < ActiveRecord::Base
7
9
  self.abstract_class = true
8
10
 
@@ -2,6 +2,8 @@
2
2
 
3
3
  module ActiveDurable
4
4
  # A named recipe: the block that describes the steps of a saga.
5
+ #
6
+ # @api private
5
7
  class Recipe
6
8
  attr_reader :name, :version, :block
7
9
 
@@ -25,6 +27,8 @@ module ActiveDurable
25
27
  # Recipes are looked up by name when a worker picks up an execution, possibly in a fresh process.
26
28
  # In a Rails app, put each recipe in app/sagas/<name>_saga.rb and assign it to a constant
27
29
  # (CheckoutSaga = Durable.define(:checkout) { ... }). The registry autoloads that constant on a miss.
30
+ #
31
+ # @api private
28
32
  class Registry
29
33
  def initialize
30
34
  @recipes = {}
@@ -6,6 +6,8 @@ module ActiveDurable
6
6
  # flow.step :charge, retry: 5 { ... }
7
7
  # flow.step :charge, retry: { attempts: 5, backoff: [1, 10, 60] } { ... }
8
8
  # flow.step :charge, retry: false { ... } # a single attempt
9
+ #
10
+ # @api private
9
11
  class RetryPolicy
10
12
  attr_reader :attempts
11
13
 
@@ -5,6 +5,9 @@ module ActiveDurable
5
5
  class RunJob < ActiveJob::Base
6
6
  queue_as { ActiveDurable.config.queue_name }
7
7
 
8
+ # Runs the execution until it finishes, sleeps, waits or blocks.
9
+ #
10
+ # @param execution_id [String]
8
11
  def perform(execution_id)
9
12
  Runner.run(execution_id)
10
13
  end
@@ -3,6 +3,8 @@
3
3
  module ActiveDurable
4
4
  # Runs one execution as far as it can go: claims the lease, replays the recipe against the notebook,
5
5
  # and ends by completing, suspending (sleep, wait, retry), compensating or blocking.
6
+ #
7
+ # @api private
6
8
  class Runner
7
9
  SUSPEND = :active_durable_suspend
8
10
 
@@ -36,7 +38,7 @@ module ActiveDurable
36
38
  lease.release!(status: status, wake_at: wake_at)
37
39
  ActiveDurable.enqueue(execution.id, wait_until: wake_at) if wake_at
38
40
  # A signal may have been committed while we held the lease; its own job found us busy.
39
- if status == "waiting" && SignalRecord.pending.exists?(execution_id: execution.id)
41
+ if status == "waiting" && SignalRecord.awaited.exists?(execution_id: execution.id)
40
42
  ActiveDurable.enqueue(execution.id)
41
43
  end
42
44
  throw SUSPEND, status.to_sym
@@ -56,17 +58,29 @@ module ActiveDurable
56
58
  block!(e)
57
59
  rescue StandardError => e
58
60
  fail!(e)
61
+ rescue ScriptError, SystemStackError => e # LoadError, NotImplementedError: bugs that StandardError misses
62
+ block!(@flow&.blocked_by || e)
59
63
  end
60
64
 
65
+ # Only a step that failed for good (StepFailed) or a business rejection (Abort) undoes the saga. Anything else
66
+ # the recipe raises is a bug: the execution is blocked, so fixing the code and calling ActiveDurable.retry
67
+ # carries it forward instead of refunding customers. A step that hit a bug blocks the run even if the recipe
68
+ # rescued its error.
61
69
  def fail!(error)
62
- return block!(error) if @flow.nil? || @flow.pivoted?
70
+ return block!(@flow.blocked_by) if @flow&.blocked_by
71
+ return block!(error) if @flow.nil? || @flow.pivoted? || !compensates?(error)
63
72
 
64
73
  start_compensation!(error) unless @flow.compensating?
65
74
  compensate!
66
75
  end
67
76
 
77
+ def compensates?(error)
78
+ error.is_a?(StepFailed) || error.is_a?(Abort)
79
+ end
80
+
68
81
  def complete!(output)
69
82
  output = Serializer.normalize(output, "the recipe's return value")
83
+ run_hook(:completed)
70
84
  lease.release!(status: "completed", output: output, wake_at: nil)
71
85
  ActiveDurable.instrument("completed", execution_id: execution.id, recipe: execution.recipe)
72
86
  :completed
@@ -98,13 +112,35 @@ module ActiveDurable
98
112
  ActiveDurable.instrument("compensation", execution_id: execution.id) do
99
113
  @flow.undo_stack.reverse_each { |entry| undo!(entry) }
100
114
  end
115
+ run_hook(:compensated)
101
116
  lease.release!(status: "compensated", wake_at: nil)
102
117
  ActiveDurable.instrument("compensated", execution_id: execution.id, recipe: execution.recipe)
103
118
  :compensated
104
- rescue UndoFailed => e
119
+ rescue UndoFailed, HookFailed => e
105
120
  block!(e)
106
121
  end
107
122
 
123
+ # Runs a flow.on hook once: its notebook entry is written in the same transaction as the hook's own changes.
124
+ def run_hook(event)
125
+ hook = @flow.hook(event)
126
+ name = "~#{event}"
127
+ return if hook.nil? || notebook[name]&.completed?
128
+
129
+ ActiveDurable.crash_point(:before_hook, name)
130
+ ActiveDurable.instrument("hook", execution_id: execution.id, step: name, kind: "hook") do
131
+ notebook.transaction(records: name) do
132
+ hook.call
133
+ ActiveDurable.crash_point(:after_hook_call, name)
134
+ notebook.complete!(name, kind: "hook", position: nil, result: nil)
135
+ end
136
+ end
137
+ ActiveDurable.crash_point(:after_hook_record, name)
138
+ rescue StandardError, ScriptError, SystemStackError => e
139
+ raise if e.is_a?(HookFailed)
140
+
141
+ raise HookFailed.new(event, e)
142
+ end
143
+
108
144
  def undo!(entry)
109
145
  name = "#{entry.name}:undo"
110
146
  record = notebook[name]
@@ -116,13 +152,13 @@ module ActiveDurable
116
152
  ActiveDurable.crash_point(:before_undo, entry.name)
117
153
  ActiveDurable.instrument("undo", execution_id: execution.id, step: entry.name) do
118
154
  if entry.kind == "transaction"
119
- notebook.transaction { call_undo(entry, ticket, name) }
155
+ notebook.transaction(records: name) { call_undo(entry, ticket, name) }
120
156
  else
121
157
  call_undo(entry, ticket, name)
122
158
  end
123
159
  end
124
160
  ActiveDurable.crash_point(:after_undo_record, entry.name)
125
- rescue StandardError => e
161
+ rescue StandardError, ScriptError, SystemStackError => e
126
162
  retry_undo!(entry, name, record, e)
127
163
  end
128
164
 
@@ -140,13 +176,19 @@ module ActiveDurable
140
176
  notebook.complete!(name, kind: "undo", position: nil, result: nil)
141
177
  end
142
178
 
179
+ # An undo is retried until config.undo_attempts, then the execution is blocked. A bug blocks it at once.
143
180
  def retry_undo!(entry, name, record, error)
144
181
  attempts = (record&.attempts || 0) + 1
145
182
  dumped = ActiveDurable.dump_error(error, step: name)
146
183
  max = ActiveDurable.config.undo_attempts
184
+ if ActiveDurable.code_error?(error)
185
+ notebook.fail!(name, kind: "undo", position: nil, attempts: attempts, error: dumped)
186
+ raise UndoFailed.new("undo of :#{entry.name} cannot run: #{error.class}: #{error.message}", step_name: name)
187
+ end
147
188
  if attempts >= max
148
189
  notebook.fail!(name, kind: "undo", position: nil, attempts: attempts, error: dumped)
149
- raise UndoFailed, "undo of :#{entry.name} failed #{attempts} times (#{error.class}: #{error.message})"
190
+ raise UndoFailed.new("undo of :#{entry.name} failed #{attempts} times (#{error.class}: #{error.message})",
191
+ step_name: name)
150
192
  end
151
193
 
152
194
  wake_at = ActiveDurable.now + RetryPolicy.new(attempts: max).delay(attempts)
@@ -2,14 +2,18 @@
2
2
 
3
3
  module ActiveDurable
4
4
  # Step results, inputs and signal payloads live in the notebook as JSON. This module turns a value
5
- # into exactly what a later replay will read back (string keys, no symbols), so the first run and a
6
- # replay behave the same. Anything that is not plain JSON is rejected with a message that says where.
5
+ # into exactly what a later replay will read back (string keys, no symbols, UTF-8 strings), so the first run
6
+ # and a replay behave the same. Anything that is not plain JSON, or that some database cannot store (a NUL
7
+ # character, bytes that are not UTF-8), is rejected with a message that says where.
8
+ #
9
+ # @api private
7
10
  module Serializer
8
11
  module_function
9
12
 
10
13
  def normalize(value, path = "value")
11
14
  case value
12
- when nil, true, false, String, Integer then value
15
+ when nil, true, false, Integer then value
16
+ when String then utf8(value, path)
13
17
  when Float then finite_float(value, path)
14
18
  when Symbol then value.to_s
15
19
  when Array then value.each_with_index.map { |item, index| normalize(item, "#{path}[#{index}]") }
@@ -27,10 +31,32 @@ module ActiveDurable
27
31
  key = key.to_s if key.is_a?(Symbol)
28
32
  raise NotSerializable, "#{path} has a #{key.class} key; use strings or symbols" unless key.is_a?(String)
29
33
 
34
+ key = utf8(key, "a key of #{path}")
30
35
  out[key] = normalize(item, "#{path}[#{key.inspect}]")
31
36
  end
32
37
  end
33
38
 
39
+ # Binary strings (Net::HTTP response bodies) are fine when their bytes are UTF-8; other encodings are converted.
40
+ def utf8(value, path)
41
+ string = value
42
+ if [Encoding::BINARY, Encoding::US_ASCII].include?(string.encoding)
43
+ string = string.dup.force_encoding(Encoding::UTF_8)
44
+ elsif string.encoding != Encoding::UTF_8
45
+ string = string.encode(Encoding::UTF_8)
46
+ end
47
+ unless string.valid_encoding?
48
+ raise NotSerializable, "#{path} has bytes that are not UTF-8. Decode it with the right encoding, or " \
49
+ "Base64-encode binary data."
50
+ end
51
+ if string.include?("\u0000")
52
+ raise NotSerializable, "#{path} contains a NUL character (\\u0000), which PostgreSQL cannot store. " \
53
+ "Strip it, or Base64-encode binary data."
54
+ end
55
+ string
56
+ rescue EncodingError => e
57
+ raise NotSerializable, "#{path} cannot be converted to UTF-8 (#{e.message})"
58
+ end
59
+
34
60
  def finite_float(value, path)
35
61
  raise NotSerializable, "#{path} is #{value}, which JSON cannot store" unless value.finite?
36
62