active_durable 0.6.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +99 -1
- data/README.md +238 -12
- data/app/controllers/active_durable/executions_controller.rb +2 -1
- data/app/helpers/active_durable/dashboard_helper.rb +12 -4
- data/app/views/active_durable/executions/index.html.erb +1 -1
- data/app/views/active_durable/executions/show.html.erb +9 -6
- data/app/views/layouts/active_durable/application.html.erb +3 -3
- data/lib/active_durable/configuration.rb +30 -0
- data/lib/active_durable/errors.rb +53 -8
- data/lib/active_durable/execution.rb +22 -2
- data/lib/active_durable/flow.rb +147 -31
- data/lib/active_durable/flow_parallel.rb +38 -12
- data/lib/active_durable/notebook.rb +17 -4
- data/lib/active_durable/operations.rb +55 -17
- data/lib/active_durable/pruner.rb +19 -4
- data/lib/active_durable/record.rb +9 -1
- data/lib/active_durable/runner.rb +45 -10
- data/lib/active_durable/serializer.rb +42 -5
- data/lib/active_durable/signal_record.rb +18 -2
- data/lib/active_durable/step.rb +20 -0
- data/lib/active_durable/sweeper.rb +1 -1
- data/lib/active_durable/testing.rb +18 -13
- data/lib/active_durable/version.rb +1 -1
- data/lib/active_durable.rb +79 -11
- data/lib/generators/active_durable/install/templates/create_active_durable_tables.rb.tt +15 -6
- data/lib/generators/active_durable/upgrade/templates/add_active_durable_context.rb.tt +19 -0
- data/lib/generators/active_durable/upgrade/templates/add_active_durable_continued_as.rb.tt +21 -0
- data/lib/generators/active_durable/upgrade/templates/add_active_durable_signal_name.rb.tt +22 -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 +4 -1
- metadata +5 -1
|
@@ -14,22 +14,25 @@ module ActiveDurable
|
|
|
14
14
|
module Operations
|
|
15
15
|
module_function
|
|
16
16
|
|
|
17
|
-
# A blocked execution tries again from where it stopped:
|
|
18
|
-
# 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.
|
|
19
21
|
def retry(execution_id)
|
|
20
22
|
execution = with_idle_execution(execution_id, allowed: %w[blocked]) do |record|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
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)
|
|
24
25
|
reopen!(record, error: nil)
|
|
25
26
|
end
|
|
26
27
|
ActiveDurable.instrument("retried", execution_id: execution.id)
|
|
27
28
|
execution
|
|
28
29
|
end
|
|
29
30
|
|
|
30
|
-
# 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).
|
|
31
34
|
def compensate(execution_id, reason: "compensated by an operator")
|
|
32
|
-
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|
|
|
33
36
|
raise Error, "#{record.id} is already compensating; use ActiveDurable.retry to resume it" if record.compensating
|
|
34
37
|
if record.steps.exists?(kind: "pivot", status: "completed")
|
|
35
38
|
raise Error, "#{record.id} already passed its point of no return; it can only move forward"
|
|
@@ -52,12 +55,10 @@ module ActiveDurable
|
|
|
52
55
|
Record.transaction do
|
|
53
56
|
original = Execution.lock.find(execution_id)
|
|
54
57
|
check_rerunnable!(original)
|
|
55
|
-
start = original
|
|
56
|
-
raise Error, "#{original.id} has no step :#{from} in its notebook" unless start
|
|
57
|
-
|
|
58
|
+
start = rerun_start(original, from)
|
|
58
59
|
execution = Execution.create!(id: rerun_id(original), recipe: original.recipe,
|
|
59
60
|
recipe_version: original.recipe_version, input: original.input,
|
|
60
|
-
status: "pending", forked_from: original.id)
|
|
61
|
+
context: original.context, status: "pending", forked_from: original.id)
|
|
61
62
|
copy_steps(original, execution, before: start.position)
|
|
62
63
|
original.update_columns(status: "superseded", updated_at: ActiveDurable.now) if original.status == "blocked"
|
|
63
64
|
end
|
|
@@ -65,9 +66,19 @@ module ActiveDurable
|
|
|
65
66
|
execution
|
|
66
67
|
end
|
|
67
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
|
+
|
|
68
78
|
def with_idle_execution(execution_id, allowed:)
|
|
69
79
|
execution = Record.transaction do
|
|
70
80
|
record = Execution.lock.find(execution_id)
|
|
81
|
+
raise Error, "#{record.id} continued as #{record.continued_as}; use that one" if record.continued?
|
|
71
82
|
unless allowed.include?(record.status)
|
|
72
83
|
raise Error, "#{record.id} is #{record.status}; this works on #{allowed.join(", ")} executions"
|
|
73
84
|
end
|
|
@@ -88,6 +99,7 @@ module ActiveDurable
|
|
|
88
99
|
end
|
|
89
100
|
|
|
90
101
|
def check_rerunnable!(original)
|
|
102
|
+
raise Error, "#{original.id} continued as #{original.continued_as}; rerun that one" if original.continued?
|
|
91
103
|
return if original.status == "completed"
|
|
92
104
|
return if original.status == "blocked" && !original.compensating
|
|
93
105
|
|
|
@@ -95,18 +107,44 @@ module ActiveDurable
|
|
|
95
107
|
"only completed executions, or blocked ones that are not compensating, can be rerun"
|
|
96
108
|
end
|
|
97
109
|
|
|
110
|
+
# Moving forward: the step named by the error, or the whole flow.parallel it belongs to, and every step that
|
|
111
|
+
# hit a bug. Compensating: every failed undo, since each of them stopped the compensation.
|
|
112
|
+
def blocking_steps(record)
|
|
113
|
+
steps = record.steps
|
|
114
|
+
return steps.where(kind: "undo", status: "failed") if record.compensating
|
|
115
|
+
|
|
116
|
+
name = record.error.is_a?(Hash) ? record.error["step"].to_s : ""
|
|
117
|
+
failed = steps.where(status: "failed").where.not(kind: %w[undo hook])
|
|
118
|
+
parallel = failed.where(kind: "parallel").pluck(:name)
|
|
119
|
+
.find { |group| name == group || name.start_with?("#{group}/") }
|
|
120
|
+
named = if parallel
|
|
121
|
+
failed.where(name: parallel).or(failed.where("name LIKE ?", "#{Step.sanitize_sql_like(parallel)}/%"))
|
|
122
|
+
else
|
|
123
|
+
failed.where(name: name)
|
|
124
|
+
end
|
|
125
|
+
named.or(steps.where(status: "blocked"))
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# A random suffix, never a count: a pruned rerun must not hand its id, and so its tickets, to a new one.
|
|
98
129
|
def rerun_id(original)
|
|
99
|
-
root = original.id.sub(/~rerun-\
|
|
100
|
-
|
|
101
|
-
"#{root}~rerun-#{count + 1}"
|
|
130
|
+
root = original.id.sub(/~rerun-\h+\z/, "")
|
|
131
|
+
"#{root}~rerun-#{SecureRandom.hex(4)}"
|
|
102
132
|
end
|
|
103
133
|
|
|
134
|
+
# The steps before `from` that completed or failed (a failure the recipe handled must stay handled), and the
|
|
135
|
+
# branches of each flow.parallel among them, in the order they finished, so their undos still run.
|
|
104
136
|
def copy_steps(original, execution, before:)
|
|
137
|
+
forward = original.steps.where.not(kind: %w[undo hook]).where(status: %w[completed failed])
|
|
138
|
+
kept = forward.where(position: ...before).order(:position).to_a
|
|
139
|
+
groups = kept.select { |step| step.kind == "parallel" }.map { |step| "#{step.name}/" }
|
|
140
|
+
branches = forward.where(position: nil).order(:updated_at, :id).select do |step|
|
|
141
|
+
groups.any? { |prefix| step.name.start_with?(prefix) }
|
|
142
|
+
end
|
|
105
143
|
now = ActiveDurable.now
|
|
106
|
-
rows =
|
|
107
|
-
.map do |step|
|
|
144
|
+
rows = (kept + branches).map do |step|
|
|
108
145
|
{ execution_id: execution.id, name: step.name, kind: step.kind, position: step.position,
|
|
109
|
-
status:
|
|
146
|
+
status: step.status, attempts: step.attempts, result: step.result, error: step.error,
|
|
147
|
+
created_at: now, updated_at: now }
|
|
110
148
|
end
|
|
111
149
|
Step.insert_all!(rows) if rows.any?
|
|
112
150
|
end
|
|
@@ -2,12 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
module ActiveDurable
|
|
4
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.
|
|
5
|
+
# their signals. Active and blocked executions are never touched: they still need a worker or a person. A
|
|
6
|
+
# continued execution goes together with the one that carried it on, never before: its id may still be in use.
|
|
6
7
|
#
|
|
7
8
|
# @api private
|
|
8
9
|
module Pruner
|
|
9
|
-
FINISHED = %w[completed compensated superseded].freeze
|
|
10
|
-
|
|
11
10
|
module_function
|
|
12
11
|
|
|
13
12
|
# Returns how many executions were deleted. Each batch is deleted in its own transaction, steps and signals
|
|
@@ -18,9 +17,12 @@ module ActiveDurable
|
|
|
18
17
|
cutoff = now - older_than.to_f
|
|
19
18
|
deleted = 0
|
|
20
19
|
loop do
|
|
21
|
-
ids = Execution.where(status: FINISHED).where("updated_at < ?", cutoff)
|
|
20
|
+
ids = Execution.where(status: Execution::FINISHED - ["continued"]).where("updated_at < ?", cutoff)
|
|
21
|
+
.limit(batch_size).pluck(:id)
|
|
22
22
|
break if ids.empty?
|
|
23
23
|
|
|
24
|
+
ids += predecessors(ids)
|
|
25
|
+
|
|
24
26
|
Record.transaction do
|
|
25
27
|
Step.where(execution_id: ids).delete_all
|
|
26
28
|
SignalRecord.where(execution_id: ids).delete_all
|
|
@@ -30,5 +32,18 @@ module ActiveDurable
|
|
|
30
32
|
end
|
|
31
33
|
deleted
|
|
32
34
|
end
|
|
35
|
+
|
|
36
|
+
# The continued executions that led to these ones, all the way back.
|
|
37
|
+
def predecessors(ids)
|
|
38
|
+
return [] unless Execution.continuations?
|
|
39
|
+
|
|
40
|
+
found = []
|
|
41
|
+
layer = ids
|
|
42
|
+
until layer.empty?
|
|
43
|
+
layer = Execution.where(continued_as: layer).pluck(:id)
|
|
44
|
+
found.concat(layer)
|
|
45
|
+
end
|
|
46
|
+
found
|
|
47
|
+
end
|
|
33
48
|
end
|
|
34
49
|
end
|
|
@@ -8,8 +8,16 @@ module ActiveDurable
|
|
|
8
8
|
class Record < ActiveRecord::Base
|
|
9
9
|
self.abstract_class = true
|
|
10
10
|
|
|
11
|
+
# JSON that reads back with its hash keys sorted, whatever order the database stored them in (see
|
|
12
|
+
# Serializer.canonical).
|
|
13
|
+
class CanonicalJson < ActiveRecord::Type::Json
|
|
14
|
+
def deserialize(value)
|
|
15
|
+
Serializer.canonical(super)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
11
19
|
# A JSON type object (not the :json symbol): resolving a symbol needs a database connection,
|
|
12
20
|
# and these classes may load before the app has configured one.
|
|
13
|
-
JSON_TYPE =
|
|
21
|
+
JSON_TYPE = CanonicalJson.new
|
|
14
22
|
end
|
|
15
23
|
end
|
|
@@ -8,7 +8,7 @@ module ActiveDurable
|
|
|
8
8
|
class Runner
|
|
9
9
|
SUSPEND = :active_durable_suspend
|
|
10
10
|
|
|
11
|
-
# Returns :completed, :compensated, :blocked, :sleeping, :waiting, :busy or :lease_lost.
|
|
11
|
+
# Returns :completed, :compensated, :continued, :blocked, :sleeping, :waiting, :busy or :lease_lost.
|
|
12
12
|
def self.run(execution_id)
|
|
13
13
|
lease = Lease.claim(execution_id)
|
|
14
14
|
return :busy unless lease
|
|
@@ -25,8 +25,10 @@ module ActiveDurable
|
|
|
25
25
|
end
|
|
26
26
|
|
|
27
27
|
def run
|
|
28
|
-
ActiveDurable.
|
|
29
|
-
|
|
28
|
+
ActiveDurable.with_context(execution.context) do
|
|
29
|
+
ActiveDurable.instrument("execution", execution_id: execution.id, recipe: execution.recipe) do
|
|
30
|
+
catch(SUSPEND) { run_recipe }
|
|
31
|
+
end
|
|
30
32
|
end
|
|
31
33
|
rescue LeaseLost => e
|
|
32
34
|
ActiveDurable.config.logger.warn("[ActiveDurable] #{e.message}")
|
|
@@ -38,12 +40,35 @@ module ActiveDurable
|
|
|
38
40
|
lease.release!(status: status, wake_at: wake_at)
|
|
39
41
|
ActiveDurable.enqueue(execution.id, wait_until: wake_at) if wake_at
|
|
40
42
|
# A signal may have been committed while we held the lease; its own job found us busy.
|
|
41
|
-
if status == "waiting" && SignalRecord.
|
|
43
|
+
if status == "waiting" && SignalRecord.awaited.exists?(execution_id: execution.id)
|
|
42
44
|
ActiveDurable.enqueue(execution.id)
|
|
43
45
|
end
|
|
44
46
|
throw SUSPEND, status.to_sym
|
|
45
47
|
end
|
|
46
48
|
|
|
49
|
+
# flow.continue_as_new: in one transaction, closes this execution, creates the one that carries on and moves the
|
|
50
|
+
# pending signals to it. Closing it first takes its row lock, so ActiveDurable.signal (which locks the row too)
|
|
51
|
+
# either commits before, and its signal is moved, or waits and then follows continued_as.
|
|
52
|
+
def continue_as_new!(input)
|
|
53
|
+
unless Execution.continuations?
|
|
54
|
+
raise InvalidRecipe, "flow.continue_as_new needs the continued_as column. Run rails generate " \
|
|
55
|
+
"active_durable:upgrade and rails db:migrate."
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
successor_id = ActiveDurable.next_in_chain(execution.id)
|
|
59
|
+
recipe = ActiveDurable.registry.latest(execution.recipe)
|
|
60
|
+
Record.transaction do
|
|
61
|
+
lease.release!(status: "continued", continued_as: successor_id, wake_at: nil)
|
|
62
|
+
ActiveDurable.crash_point(:continue_as_new, successor_id)
|
|
63
|
+
Execution.create!(id: successor_id, recipe: recipe.name, recipe_version: recipe.version, input: input,
|
|
64
|
+
context: execution.context, status: "pending")
|
|
65
|
+
SignalRecord.pending.where(execution_id: execution.id).update_all(execution_id: successor_id)
|
|
66
|
+
end
|
|
67
|
+
ActiveDurable.instrument("continued", execution_id: execution.id, recipe: execution.recipe,
|
|
68
|
+
continued_as: successor_id)
|
|
69
|
+
throw SUSPEND, :continued
|
|
70
|
+
end
|
|
71
|
+
|
|
47
72
|
private
|
|
48
73
|
|
|
49
74
|
def run_recipe
|
|
@@ -58,12 +83,16 @@ module ActiveDurable
|
|
|
58
83
|
block!(e)
|
|
59
84
|
rescue StandardError => e
|
|
60
85
|
fail!(e)
|
|
86
|
+
rescue ScriptError, SystemStackError => e # LoadError, NotImplementedError: bugs that StandardError misses
|
|
87
|
+
block!(@flow&.blocked_by || e)
|
|
61
88
|
end
|
|
62
89
|
|
|
63
90
|
# Only a step that failed for good (StepFailed) or a business rejection (Abort) undoes the saga. Anything else
|
|
64
91
|
# the recipe raises is a bug: the execution is blocked, so fixing the code and calling ActiveDurable.retry
|
|
65
|
-
# carries it forward instead of refunding customers.
|
|
92
|
+
# carries it forward instead of refunding customers. A step that hit a bug blocks the run even if the recipe
|
|
93
|
+
# rescued its error.
|
|
66
94
|
def fail!(error)
|
|
95
|
+
return block!(@flow.blocked_by) if @flow&.blocked_by
|
|
67
96
|
return block!(error) if @flow.nil? || @flow.pivoted? || !compensates?(error)
|
|
68
97
|
|
|
69
98
|
start_compensation!(error) unless @flow.compensating?
|
|
@@ -124,14 +153,14 @@ module ActiveDurable
|
|
|
124
153
|
|
|
125
154
|
ActiveDurable.crash_point(:before_hook, name)
|
|
126
155
|
ActiveDurable.instrument("hook", execution_id: execution.id, step: name, kind: "hook") do
|
|
127
|
-
notebook.transaction do
|
|
156
|
+
notebook.transaction(records: name) do
|
|
128
157
|
hook.call
|
|
129
158
|
ActiveDurable.crash_point(:after_hook_call, name)
|
|
130
159
|
notebook.complete!(name, kind: "hook", position: nil, result: nil)
|
|
131
160
|
end
|
|
132
161
|
end
|
|
133
162
|
ActiveDurable.crash_point(:after_hook_record, name)
|
|
134
|
-
rescue StandardError => e
|
|
163
|
+
rescue StandardError, ScriptError, SystemStackError => e
|
|
135
164
|
raise if e.is_a?(HookFailed)
|
|
136
165
|
|
|
137
166
|
raise HookFailed.new(event, e)
|
|
@@ -148,13 +177,13 @@ module ActiveDurable
|
|
|
148
177
|
ActiveDurable.crash_point(:before_undo, entry.name)
|
|
149
178
|
ActiveDurable.instrument("undo", execution_id: execution.id, step: entry.name) do
|
|
150
179
|
if entry.kind == "transaction"
|
|
151
|
-
notebook.transaction { call_undo(entry, ticket, name) }
|
|
180
|
+
notebook.transaction(records: name) { call_undo(entry, ticket, name) }
|
|
152
181
|
else
|
|
153
182
|
call_undo(entry, ticket, name)
|
|
154
183
|
end
|
|
155
184
|
end
|
|
156
185
|
ActiveDurable.crash_point(:after_undo_record, entry.name)
|
|
157
|
-
rescue StandardError => e
|
|
186
|
+
rescue StandardError, ScriptError, SystemStackError => e
|
|
158
187
|
retry_undo!(entry, name, record, e)
|
|
159
188
|
end
|
|
160
189
|
|
|
@@ -172,13 +201,19 @@ module ActiveDurable
|
|
|
172
201
|
notebook.complete!(name, kind: "undo", position: nil, result: nil)
|
|
173
202
|
end
|
|
174
203
|
|
|
204
|
+
# An undo is retried until config.undo_attempts, then the execution is blocked. A bug blocks it at once.
|
|
175
205
|
def retry_undo!(entry, name, record, error)
|
|
176
206
|
attempts = (record&.attempts || 0) + 1
|
|
177
207
|
dumped = ActiveDurable.dump_error(error, step: name)
|
|
178
208
|
max = ActiveDurable.config.undo_attempts
|
|
209
|
+
if ActiveDurable.code_error?(error)
|
|
210
|
+
notebook.fail!(name, kind: "undo", position: nil, attempts: attempts, error: dumped)
|
|
211
|
+
raise UndoFailed.new("undo of :#{entry.name} cannot run: #{error.class}: #{error.message}", step_name: name)
|
|
212
|
+
end
|
|
179
213
|
if attempts >= max
|
|
180
214
|
notebook.fail!(name, kind: "undo", position: nil, attempts: attempts, error: dumped)
|
|
181
|
-
raise UndoFailed
|
|
215
|
+
raise UndoFailed.new("undo of :#{entry.name} failed #{attempts} times (#{error.class}: #{error.message})",
|
|
216
|
+
step_name: name)
|
|
182
217
|
end
|
|
183
218
|
|
|
184
219
|
wake_at = ActiveDurable.now + RetryPolicy.new(attempts: max).delay(attempts)
|
|
@@ -2,8 +2,9 @@
|
|
|
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
|
|
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, keys sorted), so the
|
|
6
|
+
# first run 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.
|
|
7
8
|
#
|
|
8
9
|
# @api private
|
|
9
10
|
module Serializer
|
|
@@ -11,7 +12,8 @@ module ActiveDurable
|
|
|
11
12
|
|
|
12
13
|
def normalize(value, path = "value")
|
|
13
14
|
case value
|
|
14
|
-
when nil, true, false,
|
|
15
|
+
when nil, true, false, Integer then value
|
|
16
|
+
when String then utf8(value, path)
|
|
15
17
|
when Float then finite_float(value, path)
|
|
16
18
|
when Symbol then value.to_s
|
|
17
19
|
when Array then value.each_with_index.map { |item, index| normalize(item, "#{path}[#{index}]") }
|
|
@@ -25,12 +27,47 @@ module ActiveDurable
|
|
|
25
27
|
end
|
|
26
28
|
|
|
27
29
|
def normalize_hash(hash, path)
|
|
28
|
-
hash.each_with_object({}) do |(key, item),
|
|
30
|
+
out = hash.each_with_object({}) do |(key, item), normalized|
|
|
29
31
|
key = key.to_s if key.is_a?(Symbol)
|
|
30
32
|
raise NotSerializable, "#{path} has a #{key.class} key; use strings or symbols" unless key.is_a?(String)
|
|
31
33
|
|
|
32
|
-
|
|
34
|
+
key = utf8(key, "a key of #{path}")
|
|
35
|
+
normalized[key] = normalize(item, "#{path}[#{key.inspect}]")
|
|
33
36
|
end
|
|
37
|
+
out.sort.to_h
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Databases do not keep the order of hash keys: PostgreSQL's jsonb and MySQL's JSON put shorter keys first.
|
|
41
|
+
# Sorting them, when writing and when reading back, gives a replay the very same hash as the first run, down
|
|
42
|
+
# to the order of its keys. That matters when a result is sent somewhere that compares bytes, such as an AI
|
|
43
|
+
# provider that checks the conversation it is sent again.
|
|
44
|
+
def canonical(value)
|
|
45
|
+
case value
|
|
46
|
+
when Hash then value.sort.to_h { |key, item| [key, canonical(item)] }
|
|
47
|
+
when Array then value.map { |item| canonical(item) }
|
|
48
|
+
else value
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Binary strings (Net::HTTP response bodies) are fine when their bytes are UTF-8; other encodings are converted.
|
|
53
|
+
def utf8(value, path)
|
|
54
|
+
string = value
|
|
55
|
+
if [Encoding::BINARY, Encoding::US_ASCII].include?(string.encoding)
|
|
56
|
+
string = string.dup.force_encoding(Encoding::UTF_8)
|
|
57
|
+
elsif string.encoding != Encoding::UTF_8
|
|
58
|
+
string = string.encode(Encoding::UTF_8)
|
|
59
|
+
end
|
|
60
|
+
unless string.valid_encoding?
|
|
61
|
+
raise NotSerializable, "#{path} has bytes that are not UTF-8. Decode it with the right encoding, or " \
|
|
62
|
+
"Base64-encode binary data."
|
|
63
|
+
end
|
|
64
|
+
if string.include?("\u0000")
|
|
65
|
+
raise NotSerializable, "#{path} contains a NUL character (\\u0000), which PostgreSQL cannot store. " \
|
|
66
|
+
"Strip it, or Base64-encode binary data."
|
|
67
|
+
end
|
|
68
|
+
string
|
|
69
|
+
rescue EncodingError => e
|
|
70
|
+
raise NotSerializable, "#{path} cannot be converted to UTF-8 (#{e.message})"
|
|
34
71
|
end
|
|
35
72
|
|
|
36
73
|
def finite_float(value, path)
|
|
@@ -13,10 +13,26 @@ module ActiveDurable
|
|
|
13
13
|
|
|
14
14
|
scope :pending, -> { where(consumed_at: nil) }
|
|
15
15
|
|
|
16
|
+
# Pending signals that their execution is waiting for right now. Any other pending signal (a duplicate, or one
|
|
17
|
+
# for a later flow.wait_for) must not wake the execution up: it would only go back to waiting, forever.
|
|
18
|
+
scope :awaited, lambda {
|
|
19
|
+
steps = Step.arel_table
|
|
20
|
+
awaits = if Step.signal_names?
|
|
21
|
+
Arel::Nodes::NamedFunction.new("COALESCE", [steps[:signal_name], steps[:name]])
|
|
22
|
+
else
|
|
23
|
+
steps[:name]
|
|
24
|
+
end
|
|
25
|
+
waiting = steps.project(Arel.sql("1"))
|
|
26
|
+
.where(steps[:execution_id].eq(arel_table[:execution_id])
|
|
27
|
+
.and(awaits.eq(arel_table[:name]))
|
|
28
|
+
.and(steps[:status].eq("waiting")))
|
|
29
|
+
pending.where(waiting.exists)
|
|
30
|
+
}
|
|
31
|
+
|
|
16
32
|
after_create_commit { ActiveDurable.enqueue(execution_id) }
|
|
17
33
|
|
|
18
|
-
def self.
|
|
19
|
-
pending.where(execution_id: execution_id, name: name.to_s).order(:id)
|
|
34
|
+
def self.pending_for(execution_id, name)
|
|
35
|
+
pending.where(execution_id: execution_id, name: name.to_s).order(:id)
|
|
20
36
|
end
|
|
21
37
|
end
|
|
22
38
|
end
|
data/lib/active_durable/step.rb
CHANGED
|
@@ -23,10 +23,30 @@ module ActiveDurable
|
|
|
23
23
|
status == "retrying"
|
|
24
24
|
end
|
|
25
25
|
|
|
26
|
+
# It hit a bug, or its result could not be recorded. It runs again when the execution is retried.
|
|
27
|
+
def blocked?
|
|
28
|
+
status == "blocked"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# It ran without recording a result: between attempts, or stopped by a bug. Its effect may have happened.
|
|
32
|
+
def unfinished?
|
|
33
|
+
retrying? || blocked?
|
|
34
|
+
end
|
|
35
|
+
|
|
26
36
|
def waiting?
|
|
27
37
|
status == "waiting"
|
|
28
38
|
end
|
|
29
39
|
|
|
40
|
+
# The name of the signal a waiting step is waiting for.
|
|
41
|
+
def awaits
|
|
42
|
+
(self.class.signal_names? && signal_name) || name
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# @api private
|
|
46
|
+
def self.signal_names?
|
|
47
|
+
column_names.include?("signal_name")
|
|
48
|
+
end
|
|
49
|
+
|
|
30
50
|
def undo?
|
|
31
51
|
kind == "undo"
|
|
32
52
|
end
|
|
@@ -35,7 +35,7 @@ module ActiveDurable
|
|
|
35
35
|
.where("updated_at < ?", now - ActiveDurable.config.sweep_grace.to_f)
|
|
36
36
|
ready = quiet.where(status: %w[pending running])
|
|
37
37
|
.or(quiet.where(wake_at: ..now))
|
|
38
|
-
.or(quiet.where(status: "waiting", id: SignalRecord.
|
|
38
|
+
.or(quiet.where(status: "waiting", id: SignalRecord.awaited.select(:execution_id)))
|
|
39
39
|
ready.pluck(:id)
|
|
40
40
|
end
|
|
41
41
|
end
|
|
@@ -23,16 +23,21 @@ module ActiveDurable
|
|
|
23
23
|
end
|
|
24
24
|
|
|
25
25
|
# Runs an execution synchronously until it finishes, fast-forwarding sleeps, retries and expired
|
|
26
|
-
# leases
|
|
27
|
-
#
|
|
28
|
-
|
|
26
|
+
# leases, and following flow.continue_as_new to the execution that carries on. Signals given as
|
|
27
|
+
# { name => payload } are delivered, once each, when the saga waits for them. Returns the (last) execution; it
|
|
28
|
+
# stays "waiting" if it waits for a signal you did not provide.
|
|
29
|
+
#
|
|
30
|
+
# A wait with a timeout is fast-forwarded to it too, so the saga goes on as if no signal came. Pass
|
|
31
|
+
# `expire_waits: false` to stop at the wait instead, send signals yourself and drain again: that is how to
|
|
32
|
+
# test a conversation turn by turn.
|
|
33
|
+
def drain(execution_id, signals: {}, max_runs: 500, expire_waits: true)
|
|
29
34
|
signals = signals.transform_keys(&:to_s)
|
|
30
35
|
max_runs.times do
|
|
31
|
-
execution =
|
|
36
|
+
execution = ActiveDurable.find(execution_id, latest: true)
|
|
32
37
|
return execution if execution.terminal?
|
|
33
|
-
return execution unless ready_for_next_run?(execution, signals)
|
|
38
|
+
return execution unless ready_for_next_run?(execution, signals, expire_waits: expire_waits)
|
|
34
39
|
|
|
35
|
-
Runner.run(
|
|
40
|
+
Runner.run(execution.id)
|
|
36
41
|
end
|
|
37
42
|
raise Error, "execution #{execution_id} did not settle after #{max_runs} runs"
|
|
38
43
|
end
|
|
@@ -96,12 +101,12 @@ module ActiveDurable
|
|
|
96
101
|
# Decides what has to happen before the next run. Returns false when nothing can move the execution.
|
|
97
102
|
#
|
|
98
103
|
# @api private
|
|
99
|
-
def ready_for_next_run?(execution, signals)
|
|
104
|
+
def ready_for_next_run?(execution, signals, expire_waits: true)
|
|
100
105
|
now = ActiveDurable.now
|
|
101
106
|
if execution.locked_until && execution.locked_until > now
|
|
102
107
|
travel(execution.locked_until - now + 0.001) # a crashed worker still holds the lease
|
|
103
108
|
elsif execution.status == "waiting"
|
|
104
|
-
return waiting_can_move?(execution, signals, now)
|
|
109
|
+
return waiting_can_move?(execution, signals, now, expire_waits: expire_waits)
|
|
105
110
|
elsif execution.wake_at && execution.wake_at > now
|
|
106
111
|
travel(execution.wake_at - now + 0.001)
|
|
107
112
|
end
|
|
@@ -109,13 +114,13 @@ module ActiveDurable
|
|
|
109
114
|
end
|
|
110
115
|
|
|
111
116
|
# @api private
|
|
112
|
-
def waiting_can_move?(execution, signals, now)
|
|
113
|
-
return true if SignalRecord.
|
|
117
|
+
def waiting_can_move?(execution, signals, now, expire_waits: true)
|
|
118
|
+
return true if SignalRecord.awaited.exists?(execution_id: execution.id)
|
|
114
119
|
|
|
115
120
|
waiting = execution.steps.find_by(status: "waiting")
|
|
116
|
-
if waiting && signals.key?(waiting.
|
|
117
|
-
ActiveDurable.signal(execution.id, waiting.
|
|
118
|
-
elsif execution.wake_at
|
|
121
|
+
if waiting && signals.key?(waiting.awaits)
|
|
122
|
+
ActiveDurable.signal(execution.id, waiting.awaits, signals.delete(waiting.awaits))
|
|
123
|
+
elsif execution.wake_at && expire_waits
|
|
119
124
|
travel(execution.wake_at - now + 0.001) if execution.wake_at > now
|
|
120
125
|
else
|
|
121
126
|
return false
|
data/lib/active_durable.rb
CHANGED
|
@@ -119,46 +119,114 @@ module ActiveDurable
|
|
|
119
119
|
# @param input [Hash] keyword arguments for the recipe; stored as JSON
|
|
120
120
|
# @return [Execution]
|
|
121
121
|
# @raise [UnknownRecipe] if no recipe has that name
|
|
122
|
-
# @raise [ArgumentError] if the id already belongs to an execution of another recipe
|
|
122
|
+
# @raise [ArgumentError] if the id already belongs to an execution of another recipe, or is blank, has a "/",
|
|
123
|
+
# a NUL character or bytes that are not UTF-8
|
|
123
124
|
# @raise [NotSerializable] if the input cannot be stored as JSON
|
|
124
125
|
# @example
|
|
125
126
|
# Durable.start(:checkout, id: "checkout-#{order.id}", order_id: order.id)
|
|
126
|
-
def start(name, id: nil, **input)
|
|
127
|
+
def start(name, id: nil, context: nil, **input)
|
|
127
128
|
recipe = registry.latest(name)
|
|
128
129
|
attributes = { recipe: recipe.name, recipe_version: recipe.version, status: "pending",
|
|
129
130
|
input: Serializer.normalize(input, "input") }
|
|
131
|
+
captured = capture_context(context)
|
|
132
|
+
attributes[:context] = captured unless captured.nil?
|
|
130
133
|
return Execution.create!(attributes.merge(id: "#{recipe.name}-#{SecureRandom.uuid}")) if id.nil?
|
|
131
134
|
|
|
132
|
-
|
|
135
|
+
id = check_id!(id)
|
|
136
|
+
execution = begin
|
|
137
|
+
Execution.create_or_find_by!(id: id) { |record| record.assign_attributes(attributes) }
|
|
138
|
+
rescue ActiveRecord::RecordNotFound
|
|
139
|
+
# Rails < 7.1 on MySQL: inside the app's transaction, the plain read after the duplicate insert uses an
|
|
140
|
+
# older snapshot and misses the row another start just committed. A locking read sees it.
|
|
141
|
+
Execution.lock.find(id)
|
|
142
|
+
end
|
|
133
143
|
return execution if execution.recipe == recipe.name
|
|
134
144
|
|
|
135
145
|
raise ArgumentError, "execution #{id} already exists for recipe :#{execution.recipe}"
|
|
136
146
|
end
|
|
137
147
|
|
|
148
|
+
# The context to store with a new execution: the one given to start, or config.context.capture.
|
|
149
|
+
#
|
|
150
|
+
# @api private
|
|
151
|
+
def capture_context(explicit)
|
|
152
|
+
value = explicit.nil? ? config.context&.capture : explicit
|
|
153
|
+
return nil if value.nil?
|
|
154
|
+
|
|
155
|
+
unless Execution.column_names.include?("context")
|
|
156
|
+
raise Error, "the durable_executions table has no context column. Run rails generate " \
|
|
157
|
+
"active_durable:upgrade and rails db:migrate."
|
|
158
|
+
end
|
|
159
|
+
Serializer.normalize(value, "the context")
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Runs the block inside config.context.wrap, with the context the execution started with.
|
|
163
|
+
#
|
|
164
|
+
# @api private
|
|
165
|
+
def with_context(context, &block)
|
|
166
|
+
wrapper = config.context
|
|
167
|
+
return yield if wrapper.nil?
|
|
168
|
+
|
|
169
|
+
ran = false
|
|
170
|
+
result = wrapper.wrap(context || {}) do
|
|
171
|
+
ran = true
|
|
172
|
+
block.call
|
|
173
|
+
end
|
|
174
|
+
raise Error, "config.context.wrap must call its block" unless ran
|
|
175
|
+
|
|
176
|
+
result
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# @api private
|
|
180
|
+
def check_id!(id)
|
|
181
|
+
id = Serializer.normalize(id.to_s, "the execution id")
|
|
182
|
+
raise ArgumentError, "execution ids cannot be blank" if id.strip.empty?
|
|
183
|
+
return id unless id.include?("/")
|
|
184
|
+
|
|
185
|
+
raise ArgumentError, "execution ids cannot contain \"/\" (#{id}): the dashboard could not route them. " \
|
|
186
|
+
"Use - or : instead."
|
|
187
|
+
rescue NotSerializable => e
|
|
188
|
+
raise ArgumentError, e.message
|
|
189
|
+
end
|
|
190
|
+
|
|
138
191
|
# Delivers a signal to a saga waiting, now or later, in flow.wait_for(name). A signal that arrives before the
|
|
139
|
-
# saga waits is kept until it does.
|
|
192
|
+
# saga waits is kept until it does, also while the saga is blocked.
|
|
140
193
|
#
|
|
141
194
|
# @param execution_id [String]
|
|
142
195
|
# @param name [Symbol, String] the name given to {Flow#wait_for}
|
|
143
196
|
# @param payload [Object] what wait_for returns; stored as JSON
|
|
144
197
|
# @return [void]
|
|
145
198
|
# @raise [ActiveRecord::RecordNotFound] if there is no such execution
|
|
146
|
-
# @raise [Error] if the execution already finished
|
|
199
|
+
# @raise [Error] if the execution already finished: completed, compensated or superseded
|
|
147
200
|
# @example In a webhook controller
|
|
148
201
|
# Durable.signal("loan-42", :kyc_done, verified: true)
|
|
149
202
|
def signal(execution_id, name, payload = nil)
|
|
150
|
-
|
|
151
|
-
|
|
203
|
+
payload = Serializer.normalize(payload, "signal payload")
|
|
204
|
+
Record.transaction do
|
|
205
|
+
# Locked, so a flow.continue_as_new committing right now cannot strand the signal on the closed execution.
|
|
206
|
+
execution = Execution.lock.find(execution_id)
|
|
207
|
+
execution = Execution.lock.find(execution.continued_as) while execution.continued?
|
|
208
|
+
raise Error, "execution #{execution_id} already finished (#{execution.status})" if execution.finished?
|
|
152
209
|
|
|
153
|
-
|
|
154
|
-
|
|
210
|
+
SignalRecord.create!(execution_id: execution.id, name: name.to_s, payload: payload)
|
|
211
|
+
end
|
|
155
212
|
end
|
|
156
213
|
|
|
157
214
|
# @param execution_id [String]
|
|
215
|
+
# @param latest [Boolean] follow flow.continue_as_new to the execution that carries on now
|
|
158
216
|
# @return [Execution]
|
|
159
217
|
# @raise [ActiveRecord::RecordNotFound]
|
|
160
|
-
def find(execution_id)
|
|
161
|
-
Execution.find(execution_id)
|
|
218
|
+
def find(execution_id, latest: false)
|
|
219
|
+
execution = Execution.find(execution_id)
|
|
220
|
+
execution = Execution.find(execution.continued_as) while latest && execution.continued?
|
|
221
|
+
execution
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# "order-1" continues as "order-1~2", then "order-1~3".
|
|
225
|
+
#
|
|
226
|
+
# @api private
|
|
227
|
+
def next_in_chain(execution_id)
|
|
228
|
+
base, number = execution_id.match(/\A(.+)~(\d+)\z/)&.captures
|
|
229
|
+
base ? "#{base}~#{number.to_i + 1}" : "#{execution_id}~2"
|
|
162
230
|
end
|
|
163
231
|
|
|
164
232
|
# Resumes a blocked execution where it stopped: failed steps (or failed undos, if it was undoing) get a fresh
|