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
@@ -15,7 +15,7 @@
15
15
  Started <%= when_text(@execution.created_at) %>, last change <%= when_text(@execution.updated_at) %>.
16
16
  <% if @execution.wake_at %>Wakes <%= when_text(@execution.wake_at) %>.<% end %>
17
17
  <% if @execution.forked_from %>
18
- Rerun of <%= link_to @execution.forked_from, execution_path(@execution.forked_from), class: "mono" %>.
18
+ Rerun of <%= execution_link(@execution.forked_from, class: "mono") %>.
19
19
  <% end %>
20
20
  </p>
21
21
  </div>
@@ -29,7 +29,7 @@
29
29
  </section>
30
30
  <% end %>
31
31
 
32
- <% if can_retry?(@execution) || can_compensate?(@execution, @forward_steps) || (can_rerun?(@execution) && @forward_steps.any?) %>
32
+ <% if can_retry?(@execution) || can_compensate?(@execution, @forward_steps) || (can_rerun?(@execution) && @rerun_steps.any?) %>
33
33
  <section class="actions" aria-label="Actions">
34
34
  <% if can_retry?(@execution) %>
35
35
  <%= button_to retry_execution_path(@execution), class: "primary", form_class: "inline" do %>Retry<% end %>
@@ -40,10 +40,10 @@
40
40
  Undo everything
41
41
  <% end %>
42
42
  <% end %>
43
- <% if can_rerun?(@execution) && @forward_steps.any? %>
43
+ <% if can_rerun?(@execution) && @rerun_steps.any? %>
44
44
  <%= form_with url: rerun_execution_path(@execution), method: :post, class: "inline", local: true do %>
45
45
  <label for="rerun-from">Run again from</label>
46
- <%= select_tag :from, options_for_select(@forward_steps.map(&:name), rerun_default(@forward_steps)), id: "rerun-from" %>
46
+ <%= select_tag :from, options_for_select(@rerun_steps.map(&:name), rerun_default(@rerun_steps)), id: "rerun-from" %>
47
47
  <button type="submit">Rerun</button>
48
48
  <% end %>
49
49
  <p class="note">A rerun is a new execution. Steps before the one you choose are reused; that step and the ones
@@ -65,7 +65,7 @@
65
65
  <% items.each_with_index do |item, index| %>
66
66
  <% if index.positive? %>
67
67
  <% previous = items[index - 1] %>
68
- <% if previous.kind == "pivot" && previous.state != "failed" %>
68
+ <% if previous.kind == "pivot" && !%w[failed blocked].include?(previous.state) %>
69
69
  <span class="gate" style="--i: <%= index %>"><span>point of no return</span></span>
70
70
  <% end %>
71
71
  <% wire = if item.state == "undone" || (backwards && previous.state == "undone") then "back"
@@ -158,6 +158,16 @@
158
158
  <td class="error"><%= step.error&.dig("message").to_s.truncate(120) %></td>
159
159
  </tr>
160
160
  <% end %>
161
+ <% @hook_steps.each do |step| %>
162
+ <tr>
163
+ <td></td>
164
+ <td><strong>on :<%= step.name.delete_prefix("~") %></strong><span class="k">hook</span></td>
165
+ <td><%= status_pill(step.status) %></td>
166
+ <td><%= step.attempts %></td>
167
+ <td></td>
168
+ <td></td>
169
+ </tr>
170
+ <% end %>
161
171
  </tbody>
162
172
  </table>
163
173
  </div>
@@ -189,7 +199,7 @@
189
199
  <section class="panel">
190
200
  <h2>Reruns</h2>
191
201
  <% @reruns.each do |rerun| %>
192
- <p><%= link_to rerun.id, execution_path(rerun), class: "mono" %> <%= status_pill(rerun.status) %></p>
202
+ <p><%= execution_link(rerun.id, class: "mono") %> <%= status_pill(rerun.status) %></p>
193
203
  <% end %>
194
204
  </section>
195
205
  <% end %>
@@ -33,7 +33,7 @@
33
33
  .tone-good, .st-completed { --c: var(--jade); --cb: var(--jade-bg); }
34
34
  .tone-wait, .st-retrying, .st-waiting, .st-sleeping { --c: var(--amber); --cb: var(--amber-bg); }
35
35
  .tone-busy, .st-running, .st-next { --c: var(--cobalt); --cb: var(--cobalt-bg); }
36
- .tone-bad, .st-failed { --c: var(--ruby); --cb: var(--ruby-bg); }
36
+ .tone-bad, .st-failed, .st-blocked { --c: var(--ruby); --cb: var(--ruby-bg); }
37
37
  .tone-undo, .st-undone { --c: var(--violet); --cb: var(--violet-bg); }
38
38
  .tone-muted { --c: var(--slate); --cb: var(--slate-bg); }
39
39
 
@@ -113,10 +113,12 @@
113
113
  tbody tr { transition: background-color .2s; }
114
114
  tbody tr:hover { background: var(--sunken); }
115
115
  tr.changed { animation: rowflash 1.8s ease; }
116
- .exec-link { font: 700 .95rem/1.2 var(--f-code); white-space: nowrap; text-decoration: none; color: var(--ink); }
116
+ td.exec { min-width: 20ch; max-width: 30ch; }
117
+ .exec-link { font: 700 .95rem/1.2 var(--f-code); overflow-wrap: anywhere; text-decoration: none; color: var(--ink); }
117
118
  .exec-link:hover { text-decoration: underline; }
118
119
  .recipe { display: block; color: var(--soft); font-size: .82rem; margin-top: 2px; }
119
- .problem { color: var(--ruby); max-width: 34ch; }
120
+ .problem { color: var(--ruby); min-width: 24ch; max-width: 40ch; }
121
+ .problem span { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden; }
120
122
  .when { white-space: nowrap; font-variant-numeric: tabular-nums; }
121
123
  .status { display: inline-flex; align-items: center; gap: 7px; padding: 4px 10px 4px 8px; border-radius: 999px;
122
124
  background: var(--cb); color: var(--c); font-weight: 700; font-size: .82rem; white-space: nowrap; }
@@ -138,7 +140,7 @@
138
140
  animation-delay: calc(var(--i, 0) * 45ms); }
139
141
  .mini .b.st-completed { background: var(--c); }
140
142
  .mini .b.k-pivot { transform: rotate(45deg) scale(.86); animation-name: pop-pivot; }
141
- .mini .b.st-failed { animation: pop .35s both, shake .5s .5s ease 2; }
143
+ .mini .b.st-failed, .mini .b.st-blocked { animation: pop .35s both, shake .5s .5s ease 2; }
142
144
  .mini .b.st-undone { background: repeating-linear-gradient(135deg, var(--cb) 0 3px, var(--c) 3px 5px); }
143
145
  .mini .b.st-next { border-style: dashed; background: transparent; animation: pop .35s both, beat 1.2s ease-in-out infinite; }
144
146
  .mini .b.st-retrying, .mini .b.st-sleeping { animation: pop .35s both, beat 2.2s ease-in-out infinite; }
@@ -192,7 +194,7 @@
192
194
  .block.st-completed { --cb: color-mix(in srgb, var(--jade-bg) 85%, var(--surface)); }
193
195
  .block.k-pivot { border-width: 3px; }
194
196
  .block.k-pivot .kind { color: var(--amber); }
195
- .block.st-failed { animation: drop .55s both, shake .5s .7s ease 2; box-shadow: 0 7px 0 var(--c2), 0 0 0 6px color-mix(in srgb, var(--ruby) 18%, transparent); }
197
+ .block.st-failed, .block.st-blocked { animation: drop .55s both, shake .5s .7s ease 2; box-shadow: 0 7px 0 var(--c2), 0 0 0 6px color-mix(in srgb, var(--ruby) 18%, transparent); }
196
198
  .block.st-undone { background: repeating-linear-gradient(135deg, var(--violet-bg) 0 10px, color-mix(in srgb, var(--violet) 22%, var(--violet-bg)) 10px 20px); }
197
199
  .block.st-undone h3 { text-decoration: line-through; text-decoration-thickness: 2px; }
198
200
  .block.st-next { background: transparent; border-style: dashed; box-shadow: none; animation: drop .55s both, beat 1.4s ease-in-out infinite; }
@@ -25,6 +25,9 @@ module ActiveDurable
25
25
  # The sweeper ignores executions touched more recently than this, to leave room for their own job.
26
26
  attr_accessor :sweep_grace
27
27
 
28
+ # How long ActiveDurable.prune, PruneJob and rake active_durable:prune keep finished executions.
29
+ attr_accessor :keep_finished_for
30
+
28
31
  # Maximum threads used by flow.parallel. Your connection pool needs at least this many + 1 connections.
29
32
  attr_accessor :parallel_concurrency
30
33
 
@@ -36,6 +39,19 @@ module ActiveDurable
36
39
  # development and test only: closed in production, staging and any other environment.
37
40
  attr_accessor :dashboard_authorize
38
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
+
39
55
  attr_writer :logger
40
56
 
41
57
  def initialize
@@ -46,9 +62,11 @@ module ActiveDurable
46
62
  @undo_attempts = 10
47
63
  @backoff = ->(attempt) { [2**attempt, 3600].min }
48
64
  @sweep_grace = 60
65
+ @keep_finished_for = 30 * 24 * 3600
49
66
  @parallel_concurrency = 4
50
67
  @clock = -> { Time.current }
51
68
  @dashboard_authorize = nil
69
+ @code_errors = DEFAULT_CODE_ERRORS.dup
52
70
  @logger = nil
53
71
  end
54
72
 
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ActiveDurable
4
- # The dashboard and the rake tasks. Mount it in config/routes.rb:
4
+ # The dashboard. Mount it in config/routes.rb:
5
5
  #
6
6
  # mount ActiveDurable::Engine => "/durable"
7
7
  class Engine < ::Rails::Engine
@@ -17,8 +17,6 @@ module ActiveDurable
17
17
  end
18
18
  end
19
19
 
20
- rake_tasks do
21
- load File.expand_path("../tasks/active_durable.rake", __dir__)
22
- end
20
+ # The rake tasks in lib/tasks are loaded by Rails::Engine itself: loading them here too would run them twice.
23
21
  end
24
22
  end
@@ -2,6 +2,7 @@
2
2
 
3
3
  # Error classes. Everything a recipe may want to rescue inherits from ActiveDurable::Error.
4
4
  module ActiveDurable
5
+ # The base class of every error ActiveDurable raises.
5
6
  class Error < StandardError; end
6
7
 
7
8
  # Raised when a recipe name (or version) has not been defined.
@@ -17,7 +18,10 @@ module ActiveDurable
17
18
  class RecipeChanged < Error; end
18
19
 
19
20
  # A step returned something that cannot be stored in the notebook as JSON.
20
- class NotSerializable < Error; end
21
+ class NotSerializable < Error
22
+ # @api private
23
+ attr_accessor :step_name
24
+ end
21
25
 
22
26
  # Raise it inside a step to reject the work for a business reason: no retries, straight to compensation.
23
27
  class Abort < Error; end
@@ -36,26 +40,97 @@ module ActiveDurable
36
40
  end
37
41
  end
38
42
 
39
- # An undo kept failing after all its attempts. The execution is blocked for a human to review.
40
- 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
52
+
53
+ # A flow.on(:completed) or flow.on(:compensated) hook raised. The execution is blocked; ActiveDurable.retry runs
54
+ # the hook again once it is fixed.
55
+ class HookFailed < Error
56
+ attr_reader :step_name
57
+
58
+ def initialize(event, error)
59
+ @step_name = "~#{event}"
60
+ super("flow.on(:#{event}) failed: #{error.class}: #{error.message}")
61
+ end
62
+ end
63
+
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.
68
+ class CodeError < Error
69
+ attr_reader :step_name
70
+
71
+ def initialize(step_name, error)
72
+ @step_name = step_name
73
+ super("step :#{step_name} cannot run: #{error.class}: #{error.message}")
74
+ end
75
+ end
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
41
87
 
42
88
  # Control flow signals. They inherit from Exception on purpose: a `rescue => e` inside
43
89
  # user code must not swallow them, otherwise a lost lease could keep writing.
90
+ #
91
+ # @api private
44
92
  class ControlFlow < Exception; end # rubocop:disable Lint/InheritException
45
93
 
46
94
  # Another worker took over this execution (our lease expired). Stop without writing anything else.
95
+ #
96
+ # @api private
47
97
  class LeaseLost < ControlFlow; end
48
98
 
49
99
  # While compensating, replay reached a step that never completed: stop moving forward.
100
+ #
101
+ # @api private
50
102
  class StopForward < ControlFlow; end
51
103
 
52
- # 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.
53
106
  def self.dump_error(error, step: nil)
54
107
  {
55
108
  "class" => error.class.name,
56
- "message" => error.message.to_s[0, 2000],
109
+ "message" => storable_text(error.message)[0, 2000],
57
110
  "step" => step,
58
111
  "at" => now.utc.iso8601(6)
59
112
  }.compact
60
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
61
136
  end
@@ -5,8 +5,15 @@ module ActiveDurable
5
5
  class Execution < Record
6
6
  self.table_name = "durable_executions"
7
7
 
8
+ # Statuses of an execution that will move on by itself: waiting for a worker, running, sleeping until a
9
+ # wake-up time (a sleep or a retry) or waiting for a signal.
8
10
  ACTIVE = %w[pending running sleeping waiting].freeze
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.
9
13
  TERMINAL = %w[completed compensated blocked superseded].freeze
14
+ # Statuses that never change again: signals are refused and ActiveDurable.prune may delete them.
15
+ FINISHED = %w[completed compensated superseded].freeze
16
+ # Every status.
10
17
  STATUSES = (ACTIVE + TERMINAL).freeze
11
18
 
12
19
  attribute :input, JSON_TYPE, default: -> { {} }
@@ -27,17 +34,24 @@ module ActiveDurable
27
34
  # the sweeper finds the pending row and enqueues it.
28
35
  after_create_commit { ActiveDurable.enqueue(id) }
29
36
 
37
+ # @return [Boolean] whether it will move on by itself
30
38
  def active?
31
39
  ACTIVE.include?(status)
32
40
  end
33
41
 
42
+ # @return [Boolean] whether it needs a person to move again, or finished
34
43
  def terminal?
35
44
  TERMINAL.include?(status)
36
45
  end
37
46
 
38
- # The forward steps in recipe order (undo entries excluded).
47
+ # @return [Boolean] whether it will never change again (blocked is not finished: it can be retried)
48
+ def finished?
49
+ FINISHED.include?(status)
50
+ end
51
+
52
+ # The forward steps in recipe order (undo and hook entries excluded).
39
53
  def notebook
40
- steps.where.not(kind: "undo").reorder(:position).to_a
54
+ steps.where.not(kind: %w[undo hook]).reorder(:position).to_a
41
55
  end
42
56
  end
43
57
  end
@@ -1,21 +1,44 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ActiveDurable
4
- # The object a recipe receives. Every call that touches the outside world goes through it, so it can
5
- # be checkpointed in the notebook and skipped on replay.
4
+ # The object a recipe receives. Every call that touches the outside world goes through it, so it can be
5
+ # checkpointed in the notebook and skipped when the recipe runs again after a crash.
6
6
  #
7
+ # Step names must be unique in a recipe: they are the steps' keys in the notebook. Every step takes the options
8
+ # `retry:` (an Integer of attempts, `false`, or `{ attempts:, backoff: }`) and, if it has an undo,
9
+ # `undo_on_failure: true`.
10
+ #
11
+ # @example
7
12
  # Durable.define :checkout do |flow, order_id:|
8
- # flow.transaction :reserve_stock, undo: -> { ... } do ... end
9
- # flow.step :charge, undo: ->(charge, ticket) { ... } do |ticket| ... end
10
- # flow.pivot(:ship) { |ticket| ... }
11
- # flow.step(:email) { ... }
13
+ # order = Order.find(order_id)
14
+ # flow.on(:compensated) { order.update!(status: "cancelled") }
15
+ # flow.transaction :reserve_stock, undo: -> { order.release_stock! } do
16
+ # order.reserve_stock!
17
+ # end
18
+ # flow.step :charge, undo: ->(charge, ticket) { Payments.refund(charge, ticket) } do |ticket|
19
+ # Payments.charge(order, ticket)
20
+ # end
21
+ # flow.pivot(:ship) { |ticket| Carrier.ship(order, reference: ticket) }
22
+ # flow.step(:email) { OrderMailer.shipped(order).deliver_now && true }
12
23
  # end
13
24
  class Flow
25
+ # @api private
14
26
  UndoEntry = Struct.new(:name, :kind, :result, :undo)
27
+ # @api private
15
28
  STEP_OPTIONS = %i[retry undo_on_failure].freeze
29
+ # The events {#on} accepts.
30
+ HOOK_EVENTS = %i[completed compensated].freeze
16
31
 
32
+ # @api private
17
33
  attr_reader :undo_stack
18
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
+
41
+ # @api private
19
42
  def initialize(runner, compensating:)
20
43
  @runner = runner
21
44
  @notebook = runner.notebook
@@ -25,52 +48,135 @@ module ActiveDurable
25
48
  @position = 0
26
49
  @seen = {}
27
50
  @undo_stack = []
51
+ @hooks = {}
52
+ @blocked_by = nil
28
53
  end
29
54
 
55
+ # @return [String] the id of the execution this recipe is running for
30
56
  def execution_id
31
57
  @execution.id
32
58
  end
33
59
 
34
- # The ticket (idempotency key) a step receives. It is the same every time the step runs.
60
+ # The ticket (idempotency key) a step receives: "<execution id>:<step name>". It is the same every time the
61
+ # step runs.
62
+ #
63
+ # @param name [Symbol, String]
64
+ # @return [String]
35
65
  def ticket_for(name)
36
66
  "#{execution_id}:#{name}"
37
67
  end
38
68
 
69
+ # @api private
39
70
  def compensating?
40
71
  @compensating
41
72
  end
42
73
 
74
+ # @api private
43
75
  def compensating!
44
76
  @compensating = true
45
77
  end
46
78
 
79
+ # @api private
47
80
  def pivoted?
48
81
  @pivoted
49
82
  end
50
83
 
51
- # A step that talks to the outside world. It runs at most until it is recorded; pass the ticket to the
52
- # service as its idempotency key so a repeat after a crash is recognised.
84
+ # A step that talks to the outside world. It runs until its result is recorded, so after a crash it may run
85
+ # again: pass the ticket to the service as its idempotency key, so the repeat is recognised.
86
+ #
87
+ # @param name [Symbol, String] unique in the recipe
88
+ # @param undo [#call, nil] how to undo it; receives (result, undo_ticket, step_ticket), as many as it declares
89
+ # @param options [Hash] `retry:` and `undo_on_failure:`
90
+ # @yieldparam ticket [String] the idempotency key of this step
91
+ # @yieldreturn [Object] the result, stored as JSON in the notebook
92
+ # @return [Object] the result, also when it was read from the notebook
93
+ # @raise [StepFailed] when it runs out of attempts or calls {#abort!}; rescue it to take another path
94
+ # @example
95
+ # payment = flow.step :charge, undo: ->(charge, ticket) { Payments.refund(charge, ticket) } do |ticket|
96
+ # Payments.charge(order, ticket)
97
+ # end
53
98
  def step(name, undo: nil, **options, &block)
54
99
  run_step(name, "step", undo, options, block)
55
100
  end
56
101
 
57
- # A step that only touches your own database. It runs in the same transaction that records it,
58
- # so it happens exactly once.
102
+ # A step that only touches your own database. It runs in the same transaction that records it, so it happens
103
+ # exactly once. Its undo runs in a transaction too.
104
+ #
105
+ # @param (see #step)
106
+ # @yieldparam ticket [String]
107
+ # @yieldreturn [Object] the result, stored as JSON in the notebook
108
+ # @return [Object] the result
109
+ # @raise [StepFailed] when it runs out of attempts or calls {#abort!}
59
110
  def transaction(name, undo: nil, **options, &block)
60
111
  run_step(name, "transaction", undo, options, block)
61
112
  end
62
113
 
63
- # The point of no return. Before it, failures are compensated; after it, steps are retried.
114
+ # The point of no return, such as shipping a parcel. Before it, a step that fails for good undoes the saga;
115
+ # after it, steps cannot declare an undo and are retried (config.after_pivot_attempts), then the execution is
116
+ # blocked for a person. If the pivot itself fails for good, the saga is undone: the point was never passed.
117
+ #
118
+ # @param name [Symbol, String]
119
+ # @param options [Hash] `retry:`
120
+ # @yieldparam ticket [String]
121
+ # @return [Object] the result
64
122
  def pivot(name, **options, &block)
65
123
  run_step(name, "pivot", nil, options, block)
66
124
  end
67
125
 
68
- # Rejects the saga for a business reason: no retries, straight to compensation.
126
+ # Runs a block once the saga ends that way, to update your own records (the order is paid, the order is
127
+ # cancelled). Declare hooks before the first step, so a saga undone at its first step still knows them. The
128
+ # block runs in a transaction together with the notebook entry that records it: a hook that only touches your
129
+ # database runs exactly once, even across crashes. If it raises, the execution is blocked and
130
+ # {ActiveDurable.retry} runs it again.
131
+ #
132
+ # @param event [Symbol] :completed (after the last step) or :compensated (after the last undo)
133
+ # @return [nil]
134
+ # @raise [InvalidRecipe] after the first step, for another event, or twice for the same event
135
+ # @example
136
+ # flow.on(:completed) { order.update!(status: "delivered") }
137
+ # flow.on(:compensated) { order.update!(status: "cancelled") }
138
+ def on(event, &block)
139
+ raise InvalidRecipe, "flow.on needs a block" unless block
140
+ unless HOOK_EVENTS.include?(event)
141
+ raise InvalidRecipe, "flow.on(:#{event}): the events are :completed and :compensated"
142
+ end
143
+ if @position.positive?
144
+ raise InvalidRecipe, "flow.on(:#{event}) must come before the first step: when a saga is undone early, " \
145
+ "the steps after the failure are never reached"
146
+ end
147
+ raise InvalidRecipe, "flow.on(:#{event}) is declared twice" if @hooks.key?(event)
148
+
149
+ @hooks[event] = block
150
+ nil
151
+ end
152
+
153
+ # @api private
154
+ def hook(event)
155
+ @hooks[event]
156
+ end
157
+
158
+ # Rejects the saga for a business reason, such as a declined card: no retries, straight to undoing what was
159
+ # done. Call it inside a step or in the recipe itself.
160
+ #
161
+ # @param message [String] recorded as the error
162
+ # @raise [Abort] always
163
+ # @example
164
+ # flow.step :charge do |ticket|
165
+ # Payments.charge(order, ticket)
166
+ # rescue Payments::CardDeclined => e
167
+ # flow.abort!(e.message)
168
+ # end
69
169
  def abort!(message)
70
170
  raise Abort, message
71
171
  end
72
172
 
73
- # Waits without holding a worker: the wake-up time is written down and the execution is released.
173
+ # Waits without holding a worker: the wake-up time is written down and the execution is released until then.
174
+ #
175
+ # @param name [Symbol, String]
176
+ # @param duration [ActiveSupport::Duration, Numeric] seconds
177
+ # @return [nil]
178
+ # @example
179
+ # flow.sleep(:wait_for_delivery, 3.days)
74
180
  def sleep(name, duration)
75
181
  name, position = visit!(name, "sleep")
76
182
  entry = @notebook[name]
@@ -90,7 +196,13 @@ module ActiveDurable
90
196
  @runner.suspend!(wake_at, "sleeping")
91
197
  end
92
198
 
93
- # Waits for Durable.signal(execution_id, name, payload) and returns the payload.
199
+ # Waits, without holding a worker, for {ActiveDurable.signal}(execution_id, name, payload). A signal sent
200
+ # before the saga gets here is kept.
201
+ #
202
+ # @param name [Symbol, String]
203
+ # @param timeout [ActiveSupport::Duration, Numeric, nil] seconds; then the step fails and the saga is undone
204
+ # @return [Object] the signal's payload
205
+ # @raise [StepFailed] when the timeout passes first
94
206
  def wait_for(name, timeout: nil)
95
207
  name, position = visit!(name, "wait")
96
208
  entry = @notebook[name]
@@ -101,7 +213,7 @@ module ActiveDurable
101
213
  signal = SignalRecord.next_for(execution_id, name)
102
214
  return consume_signal(signal, name, position).deep_dup if signal
103
215
 
104
- if entry.nil?
216
+ unless entry&.waiting? # first time here, or retried after a timeout: wait again, with a new deadline
105
217
  deadline = timeout && (now + timeout)
106
218
  @notebook.wait!(name, kind: "wait", position: position, wake_at: deadline)
107
219
  @runner.suspend!(deadline, "waiting")
@@ -118,7 +230,10 @@ module ActiveDurable
118
230
  end
119
231
 
120
232
  # Called when the recipe returns: every step the notebook knows about must have been reached.
233
+ #
234
+ # @api private
121
235
  def finish!
236
+ raise blocked_by if blocked_by
122
237
  return if compensating?
123
238
 
124
239
  missing = @notebook.forward_entries.reject { |entry| @seen.key?(entry.name) }
@@ -129,6 +244,7 @@ module ActiveDurable
129
244
  "#{missing.size == 1 ? "it" : "them"}. #{RECIPE_CHANGED_HINT}"
130
245
  end
131
246
 
247
+ # @api private
132
248
  RECIPE_CHANGED_HINT = "Either the recipe changed while this execution was in flight, or code outside a " \
133
249
  "step read data that changed between runs. Keep reads that decide the path inside " \
134
250
  "steps, or define a new recipe version."
@@ -159,7 +275,11 @@ module ActiveDurable
159
275
  remember_failure(name, kind, undo, options)
160
276
  raise StepFailed.new(name, entry.error&.fetch("message", nil))
161
277
  end
162
- raise StopForward, name if compensating?
278
+ if compensating?
279
+ # Stopped between attempts or on a bug: it may have acted, so undo_on_failure applies.
280
+ remember_failure(name, kind, undo, options) if entry&.unfinished?
281
+ raise StopForward, name
282
+ end
163
283
 
164
284
  @runner.suspend!(entry.wake_at, "sleeping") if entry&.retrying? && entry.wake_at && entry.wake_at > now
165
285
 
@@ -172,26 +292,51 @@ module ActiveDurable
172
292
  ActiveDurable.crash_point(:before_step, name)
173
293
  result = ActiveDurable.instrument("step", execution_id: execution_id, step: name, kind: kind) do
174
294
  if kind == "transaction"
175
- @notebook.transaction { record_result(name, kind, position, block.call(ticket)) }
295
+ @notebook.transaction(records: name) { record_result(name, kind, position, block.call(ticket)) }
176
296
  else
177
297
  record_result(name, kind, position, block.call(ticket))
178
298
  end
179
299
  end
180
300
  ActiveDurable.crash_point(:after_record, name)
181
301
  result
182
- rescue NotSerializable, InvalidRecipe
183
- raise
184
- rescue StandardError => e
302
+ rescue Abort => e
303
+ handle_failure(name, kind, position, entry, undo, options, e)
304
+ rescue NotSerializable, InvalidRecipe, CheckpointFailed => e
305
+ block_step!(name, kind, position, entry, e)
306
+ rescue StandardError, ScriptError, SystemStackError => e
307
+ raise if blocked_by # a nested step already blocked the run
308
+
309
+ return block_step!(name, kind, position, entry, CodeError.new(name, e)) if ActiveDurable.code_error?(e)
310
+
185
311
  handle_failure(name, kind, position, entry, undo, options, e)
186
312
  end
187
313
 
314
+ # Outside a transaction the step has already acted when its result is recorded, so a write the database
315
+ # refuses is not a failure of the step: it blocks. Inside flow.transaction it rolled back with the step.
188
316
  def record_result(name, kind, position, value)
189
317
  result = Serializer.normalize(value, "the result of :#{name}")
190
318
  ActiveDurable.crash_point(:after_call, name)
191
- @notebook.complete!(name, kind: kind, position: position, result: result)
319
+ begin
320
+ @notebook.complete!(name, kind: kind, position: position, result: result)
321
+ rescue StandardError => e
322
+ raise if kind == "transaction"
323
+
324
+ raise CheckpointFailed.new(name, e)
325
+ end
192
326
  result
193
327
  end
194
328
 
329
+ # Retrying cannot fix a bug and undoing the saga would punish customers for it: the step is written down as
330
+ # blocked, and nothing else runs until someone fixes the code and calls ActiveDurable.retry. It keeps its
331
+ # attempts, since a bug is not a failure of the outside world.
332
+ def block_step!(name, kind, position, entry, error)
333
+ error.step_name ||= name if error.respond_to?(:step_name=)
334
+ @blocked_by = error
335
+ @notebook.block!(name, kind: kind, position: position, attempts: entry&.attempts || 0,
336
+ error: ActiveDurable.dump_error(error, step: name))
337
+ raise error
338
+ end
339
+
195
340
  def handle_failure(name, kind, position, entry, undo, options, error)
196
341
  attempts = (entry&.attempts || 0) + 1
197
342
  default = @pivoted ? config.after_pivot_attempts : config.step_attempts
@@ -238,9 +383,12 @@ module ActiveDurable
238
383
  end
239
384
 
240
385
  def visit!(name, kind)
386
+ raise blocked_by if blocked_by
387
+
241
388
  name = name.to_s
242
389
  raise InvalidRecipe, "step names cannot be blank" if name.empty?
243
390
  raise InvalidRecipe, "step names cannot end in ':undo' (#{name})" if name.end_with?(":undo")
391
+ raise InvalidRecipe, "step names cannot start with '~' (#{name}): it marks hooks" if name.start_with?("~")
244
392
  if @seen.key?(name)
245
393
  raise DuplicateStepName, "the recipe uses the step name :#{name} twice. Each step needs its own name: " \
246
394
  "it is the step's key in the notebook."