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.
- checksums.yaml +4 -4
- data/.yardopts +8 -0
- data/CHANGELOG.md +114 -3
- data/README.md +174 -22
- data/app/controllers/active_durable/executions_controller.rb +4 -2
- data/app/helpers/active_durable/dashboard_helper.rb +11 -3
- data/app/views/active_durable/executions/index.html.erb +4 -3
- data/app/views/active_durable/executions/show.html.erb +16 -6
- data/app/views/layouts/active_durable/application.html.erb +7 -5
- data/lib/active_durable/configuration.rb +18 -0
- data/lib/active_durable/engine.rb +2 -4
- data/lib/active_durable/errors.rb +80 -5
- data/lib/active_durable/execution.rb +16 -2
- data/lib/active_durable/flow.rb +170 -22
- data/lib/active_durable/flow_parallel.rb +46 -7
- data/lib/active_durable/lease.rb +2 -0
- data/lib/active_durable/notebook.rb +17 -4
- data/lib/active_durable/open_telemetry.rb +14 -2
- data/lib/active_durable/operations.rb +54 -15
- data/lib/active_durable/parallel.rb +15 -0
- data/lib/active_durable/prune_job.rb +16 -0
- data/lib/active_durable/pruner.rb +32 -0
- data/lib/active_durable/record.rb +2 -0
- data/lib/active_durable/registry.rb +4 -0
- data/lib/active_durable/retry_policy.rb +2 -0
- data/lib/active_durable/run_job.rb +3 -0
- data/lib/active_durable/runner.rb +48 -6
- data/lib/active_durable/serializer.rb +29 -3
- data/lib/active_durable/signal_record.rb +13 -0
- data/lib/active_durable/step.rb +20 -0
- data/lib/active_durable/sweep_job.rb +3 -0
- data/lib/active_durable/sweeper.rb +26 -6
- data/lib/active_durable/testing.rb +9 -1
- data/lib/active_durable/version.rb +2 -1
- data/lib/active_durable.rb +137 -13
- data/lib/generators/active_durable/install/install_generator.rb +2 -0
- data/lib/generators/active_durable/install/templates/create_active_durable_tables.rb.tt +12 -6
- data/lib/generators/active_durable/upgrade/templates/add_active_durable_prune_index.rb.tt +14 -0
- data/lib/generators/active_durable/upgrade/templates/make_active_durable_ids_case_sensitive.rb.tt +72 -0
- data/lib/generators/active_durable/upgrade/upgrade_generator.rb +39 -0
- data/lib/tasks/active_durable.rake +13 -2
- 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 <%=
|
|
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) && @
|
|
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) && @
|
|
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(@
|
|
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
|
|
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><%=
|
|
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
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
#
|
|
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:
|
|
54
|
+
steps.where.not(kind: %w[undo hook]).reorder(:position).to_a
|
|
41
55
|
end
|
|
42
56
|
end
|
|
43
57
|
end
|
data/lib/active_durable/flow.rb
CHANGED
|
@@ -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
|
-
#
|
|
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
|
-
#
|
|
9
|
-
# flow.
|
|
10
|
-
# flow.
|
|
11
|
-
#
|
|
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
|
|
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
|
|
52
|
-
# service as its idempotency key so
|
|
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
|
-
#
|
|
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,
|
|
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
|
-
#
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
183
|
-
|
|
184
|
-
rescue
|
|
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
|
-
|
|
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."
|