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
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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.
|
|
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
|
data/lib/active_durable/lease.rb
CHANGED
|
@@ -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) &&
|
|
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.
|
|
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
|
|
55
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
16
|
-
# compensating)
|
|
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
|
-
|
|
20
|
-
|
|
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
|
|
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
|
|
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-\
|
|
98
|
-
|
|
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 =
|
|
142
|
+
rows = (kept + branches).map do |step|
|
|
105
143
|
{ execution_id: execution.id, name: step.name, kind: step.kind, position: step.position,
|
|
106
|
-
status:
|
|
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.
|
|
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!(
|
|
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
|
|
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
|
|
6
|
-
# replay behave the same. Anything that is not plain JSON
|
|
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,
|
|
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
|
|