active_durable 0.6.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/CHANGELOG.md +62 -1
- data/README.md +82 -10
- data/app/controllers/active_durable/executions_controller.rb +2 -1
- data/app/helpers/active_durable/dashboard_helper.rb +10 -2
- data/app/views/active_durable/executions/index.html.erb +1 -1
- data/app/views/active_durable/executions/show.html.erb +6 -6
- data/app/views/layouts/active_durable/application.html.erb +3 -3
- data/lib/active_durable/configuration.rb +14 -0
- data/lib/active_durable/errors.rb +53 -8
- data/lib/active_durable/execution.rb +7 -0
- data/lib/active_durable/flow.rb +46 -9
- data/lib/active_durable/flow_parallel.rb +31 -9
- data/lib/active_durable/notebook.rb +13 -2
- data/lib/active_durable/operations.rb +52 -16
- data/lib/active_durable/pruner.rb +1 -3
- data/lib/active_durable/runner.rb +17 -7
- data/lib/active_durable/serializer.rb +27 -3
- data/lib/active_durable/signal_record.rb +11 -0
- data/lib/active_durable/step.rb +10 -0
- data/lib/active_durable/sweeper.rb +1 -1
- data/lib/active_durable/testing.rb +1 -1
- data/lib/active_durable/version.rb +1 -1
- data/lib/active_durable.rb +25 -5
- data/lib/generators/active_durable/install/templates/create_active_durable_tables.rb.tt +11 -6
- 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 +1 -1
- metadata +2 -1
|
@@ -36,7 +36,11 @@ module ActiveDurable
|
|
|
36
36
|
prepare_branches!(name, branches)
|
|
37
37
|
entry = @notebook[name]
|
|
38
38
|
return finish_recorded_parallel(name, entry, branches) if entry&.completed? || entry&.failed?
|
|
39
|
-
|
|
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
|
|
40
44
|
|
|
41
45
|
run_parallel(name, position, branches)
|
|
42
46
|
end
|
|
@@ -77,6 +81,12 @@ module ActiveDurable
|
|
|
77
81
|
|
|
78
82
|
def run_parallel(name, position, branches)
|
|
79
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
|
+
|
|
80
90
|
failed = outcomes.select { |_, (state, _)| state == :failed }
|
|
81
91
|
if failed.any?
|
|
82
92
|
full_name, (_, error) = failed.first
|
|
@@ -95,7 +105,7 @@ module ActiveDurable
|
|
|
95
105
|
results.deep_dup
|
|
96
106
|
end
|
|
97
107
|
|
|
98
|
-
# { full_name => [:completed, result] | [:retry, wake_at] | [:failed, error] }
|
|
108
|
+
# { full_name => [:completed, result] | [:retry, wake_at] | [:failed, error] | [:blocked, error] }
|
|
99
109
|
def branch_outcomes(branches)
|
|
100
110
|
outcomes = {}
|
|
101
111
|
runnable = []
|
|
@@ -164,19 +174,31 @@ module ActiveDurable
|
|
|
164
174
|
result = ActiveDurable.instrument("step", execution_id: execution_id, step: branch.full_name,
|
|
165
175
|
kind: branch.kind) do
|
|
166
176
|
if branch.kind == "transaction"
|
|
167
|
-
@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
|
|
168
180
|
else
|
|
169
181
|
record_result(branch.full_name, branch.kind, nil, branch.block.call(ticket))
|
|
170
182
|
end
|
|
171
183
|
end
|
|
172
184
|
ActiveDurable.crash_point(:after_record, branch.full_name)
|
|
173
185
|
[:completed, result]
|
|
174
|
-
rescue
|
|
175
|
-
raise
|
|
176
|
-
rescue NameError => e
|
|
177
|
-
raise CodeError.new(branch.full_name, e)
|
|
178
|
-
rescue StandardError => e
|
|
186
|
+
rescue Abort => e
|
|
179
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]
|
|
180
202
|
end
|
|
181
203
|
|
|
182
204
|
def branch_failure(branch, entry, error)
|
|
@@ -205,7 +227,7 @@ module ActiveDurable
|
|
|
205
227
|
|
|
206
228
|
if entry.completed?
|
|
207
229
|
@undo_stack << UndoEntry.new(entry.name, branch.kind, entry.result, branch.undo)
|
|
208
|
-
elsif include_failed && branch.options[:undo_on_failure] && (entry.failed? || entry.
|
|
230
|
+
elsif include_failed && branch.options[:undo_on_failure] && (entry.failed? || entry.unfinished?)
|
|
209
231
|
@undo_stack << UndoEntry.new(entry.name, branch.kind, nil, branch.undo)
|
|
210
232
|
end
|
|
211
233
|
end
|
|
@@ -48,17 +48,28 @@ module ActiveDurable
|
|
|
48
48
|
wake_at: nil)
|
|
49
49
|
end
|
|
50
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
|
+
|
|
51
56
|
def wait!(name, kind:, position:, wake_at:)
|
|
52
57
|
write!(name, kind: kind, position: position, status: "waiting", wake_at: wake_at)
|
|
53
58
|
end
|
|
54
59
|
|
|
55
60
|
# Runs the block in a database transaction and remembers the notebook writes made inside it only once it
|
|
56
|
-
# commits. flow.transaction steps
|
|
57
|
-
|
|
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, &)
|
|
58
65
|
pending = []
|
|
59
66
|
Thread.current[pending_key] = pending
|
|
60
67
|
result = Record.transaction(&)
|
|
61
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
|
|
62
73
|
result
|
|
63
74
|
ensure
|
|
64
75
|
Thread.current[pending_key] = nil
|
|
@@ -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,9 +55,7 @@ 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
61
|
status: "pending", forked_from: original.id)
|
|
@@ -65,6 +66,15 @@ 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)
|
|
@@ -95,18 +105,44 @@ module ActiveDurable
|
|
|
95
105
|
"only completed executions, or blocked ones that are not compensating, can be rerun"
|
|
96
106
|
end
|
|
97
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.
|
|
98
127
|
def rerun_id(original)
|
|
99
|
-
root = original.id.sub(/~rerun-\
|
|
100
|
-
|
|
101
|
-
"#{root}~rerun-#{count + 1}"
|
|
128
|
+
root = original.id.sub(/~rerun-\h+\z/, "")
|
|
129
|
+
"#{root}~rerun-#{SecureRandom.hex(4)}"
|
|
102
130
|
end
|
|
103
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.
|
|
104
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
|
|
105
141
|
now = ActiveDurable.now
|
|
106
|
-
rows =
|
|
107
|
-
.map do |step|
|
|
142
|
+
rows = (kept + branches).map do |step|
|
|
108
143
|
{ execution_id: execution.id, name: step.name, kind: step.kind, position: step.position,
|
|
109
|
-
status:
|
|
144
|
+
status: step.status, attempts: step.attempts, result: step.result, error: step.error,
|
|
145
|
+
created_at: now, updated_at: now }
|
|
110
146
|
end
|
|
111
147
|
Step.insert_all!(rows) if rows.any?
|
|
112
148
|
end
|
|
@@ -6,8 +6,6 @@ module ActiveDurable
|
|
|
6
6
|
#
|
|
7
7
|
# @api private
|
|
8
8
|
module Pruner
|
|
9
|
-
FINISHED = %w[completed compensated superseded].freeze
|
|
10
|
-
|
|
11
9
|
module_function
|
|
12
10
|
|
|
13
11
|
# Returns how many executions were deleted. Each batch is deleted in its own transaction, steps and signals
|
|
@@ -18,7 +16,7 @@ module ActiveDurable
|
|
|
18
16
|
cutoff = now - older_than.to_f
|
|
19
17
|
deleted = 0
|
|
20
18
|
loop do
|
|
21
|
-
ids = Execution.where(status: FINISHED).where("updated_at < ?", cutoff).limit(batch_size).pluck(:id)
|
|
19
|
+
ids = Execution.where(status: Execution::FINISHED).where("updated_at < ?", cutoff).limit(batch_size).pluck(:id)
|
|
22
20
|
break if ids.empty?
|
|
23
21
|
|
|
24
22
|
Record.transaction do
|
|
@@ -38,7 +38,7 @@ module ActiveDurable
|
|
|
38
38
|
lease.release!(status: status, wake_at: wake_at)
|
|
39
39
|
ActiveDurable.enqueue(execution.id, wait_until: wake_at) if wake_at
|
|
40
40
|
# A signal may have been committed while we held the lease; its own job found us busy.
|
|
41
|
-
if status == "waiting" && SignalRecord.
|
|
41
|
+
if status == "waiting" && SignalRecord.awaited.exists?(execution_id: execution.id)
|
|
42
42
|
ActiveDurable.enqueue(execution.id)
|
|
43
43
|
end
|
|
44
44
|
throw SUSPEND, status.to_sym
|
|
@@ -58,12 +58,16 @@ module ActiveDurable
|
|
|
58
58
|
block!(e)
|
|
59
59
|
rescue StandardError => e
|
|
60
60
|
fail!(e)
|
|
61
|
+
rescue ScriptError, SystemStackError => e # LoadError, NotImplementedError: bugs that StandardError misses
|
|
62
|
+
block!(@flow&.blocked_by || e)
|
|
61
63
|
end
|
|
62
64
|
|
|
63
65
|
# Only a step that failed for good (StepFailed) or a business rejection (Abort) undoes the saga. Anything else
|
|
64
66
|
# 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.
|
|
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.
|
|
66
69
|
def fail!(error)
|
|
70
|
+
return block!(@flow.blocked_by) if @flow&.blocked_by
|
|
67
71
|
return block!(error) if @flow.nil? || @flow.pivoted? || !compensates?(error)
|
|
68
72
|
|
|
69
73
|
start_compensation!(error) unless @flow.compensating?
|
|
@@ -124,14 +128,14 @@ module ActiveDurable
|
|
|
124
128
|
|
|
125
129
|
ActiveDurable.crash_point(:before_hook, name)
|
|
126
130
|
ActiveDurable.instrument("hook", execution_id: execution.id, step: name, kind: "hook") do
|
|
127
|
-
notebook.transaction do
|
|
131
|
+
notebook.transaction(records: name) do
|
|
128
132
|
hook.call
|
|
129
133
|
ActiveDurable.crash_point(:after_hook_call, name)
|
|
130
134
|
notebook.complete!(name, kind: "hook", position: nil, result: nil)
|
|
131
135
|
end
|
|
132
136
|
end
|
|
133
137
|
ActiveDurable.crash_point(:after_hook_record, name)
|
|
134
|
-
rescue StandardError => e
|
|
138
|
+
rescue StandardError, ScriptError, SystemStackError => e
|
|
135
139
|
raise if e.is_a?(HookFailed)
|
|
136
140
|
|
|
137
141
|
raise HookFailed.new(event, e)
|
|
@@ -148,13 +152,13 @@ module ActiveDurable
|
|
|
148
152
|
ActiveDurable.crash_point(:before_undo, entry.name)
|
|
149
153
|
ActiveDurable.instrument("undo", execution_id: execution.id, step: entry.name) do
|
|
150
154
|
if entry.kind == "transaction"
|
|
151
|
-
notebook.transaction { call_undo(entry, ticket, name) }
|
|
155
|
+
notebook.transaction(records: name) { call_undo(entry, ticket, name) }
|
|
152
156
|
else
|
|
153
157
|
call_undo(entry, ticket, name)
|
|
154
158
|
end
|
|
155
159
|
end
|
|
156
160
|
ActiveDurable.crash_point(:after_undo_record, entry.name)
|
|
157
|
-
rescue StandardError => e
|
|
161
|
+
rescue StandardError, ScriptError, SystemStackError => e
|
|
158
162
|
retry_undo!(entry, name, record, e)
|
|
159
163
|
end
|
|
160
164
|
|
|
@@ -172,13 +176,19 @@ module ActiveDurable
|
|
|
172
176
|
notebook.complete!(name, kind: "undo", position: nil, result: nil)
|
|
173
177
|
end
|
|
174
178
|
|
|
179
|
+
# An undo is retried until config.undo_attempts, then the execution is blocked. A bug blocks it at once.
|
|
175
180
|
def retry_undo!(entry, name, record, error)
|
|
176
181
|
attempts = (record&.attempts || 0) + 1
|
|
177
182
|
dumped = ActiveDurable.dump_error(error, step: name)
|
|
178
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
|
|
179
188
|
if attempts >= max
|
|
180
189
|
notebook.fail!(name, kind: "undo", position: nil, attempts: attempts, error: dumped)
|
|
181
|
-
raise UndoFailed
|
|
190
|
+
raise UndoFailed.new("undo of :#{entry.name} failed #{attempts} times (#{error.class}: #{error.message})",
|
|
191
|
+
step_name: name)
|
|
182
192
|
end
|
|
183
193
|
|
|
184
194
|
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), 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.
|
|
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}]") }
|
|
@@ -29,10 +31,32 @@ module ActiveDurable
|
|
|
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
|
|
|
34
|
+
key = utf8(key, "a key of #{path}")
|
|
32
35
|
out[key] = normalize(item, "#{path}[#{key.inspect}]")
|
|
33
36
|
end
|
|
34
37
|
end
|
|
35
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
|
+
|
|
36
60
|
def finite_float(value, path)
|
|
37
61
|
raise NotSerializable, "#{path} is #{value}, which JSON cannot store" unless value.finite?
|
|
38
62
|
|
|
@@ -13,6 +13,17 @@ 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
|
+
waiting = steps.project(Arel.sql("1"))
|
|
21
|
+
.where(steps[:execution_id].eq(arel_table[:execution_id])
|
|
22
|
+
.and(steps[:name].eq(arel_table[:name]))
|
|
23
|
+
.and(steps[:status].eq("waiting")))
|
|
24
|
+
pending.where(waiting.exists)
|
|
25
|
+
}
|
|
26
|
+
|
|
16
27
|
after_create_commit { ActiveDurable.enqueue(execution_id) }
|
|
17
28
|
|
|
18
29
|
def self.next_for(execution_id, name)
|
data/lib/active_durable/step.rb
CHANGED
|
@@ -23,6 +23,16 @@ 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
|
|
@@ -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
|
|
@@ -110,7 +110,7 @@ module ActiveDurable
|
|
|
110
110
|
|
|
111
111
|
# @api private
|
|
112
112
|
def waiting_can_move?(execution, signals, now)
|
|
113
|
-
return true if SignalRecord.
|
|
113
|
+
return true if SignalRecord.awaited.exists?(execution_id: execution.id)
|
|
114
114
|
|
|
115
115
|
waiting = execution.steps.find_by(status: "waiting")
|
|
116
116
|
if waiting && signals.key?(waiting.name)
|
data/lib/active_durable.rb
CHANGED
|
@@ -119,7 +119,8 @@ 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)
|
|
@@ -129,26 +130,45 @@ module ActiveDurable
|
|
|
129
130
|
input: Serializer.normalize(input, "input") }
|
|
130
131
|
return Execution.create!(attributes.merge(id: "#{recipe.name}-#{SecureRandom.uuid}")) if id.nil?
|
|
131
132
|
|
|
132
|
-
|
|
133
|
+
id = check_id!(id)
|
|
134
|
+
execution = begin
|
|
135
|
+
Execution.create_or_find_by!(id: id) { |record| record.assign_attributes(attributes) }
|
|
136
|
+
rescue ActiveRecord::RecordNotFound
|
|
137
|
+
# Rails < 7.1 on MySQL: inside the app's transaction, the plain read after the duplicate insert uses an
|
|
138
|
+
# older snapshot and misses the row another start just committed. A locking read sees it.
|
|
139
|
+
Execution.lock.find(id)
|
|
140
|
+
end
|
|
133
141
|
return execution if execution.recipe == recipe.name
|
|
134
142
|
|
|
135
143
|
raise ArgumentError, "execution #{id} already exists for recipe :#{execution.recipe}"
|
|
136
144
|
end
|
|
137
145
|
|
|
146
|
+
# @api private
|
|
147
|
+
def check_id!(id)
|
|
148
|
+
id = Serializer.normalize(id.to_s, "the execution id")
|
|
149
|
+
raise ArgumentError, "execution ids cannot be blank" if id.strip.empty?
|
|
150
|
+
return id unless id.include?("/")
|
|
151
|
+
|
|
152
|
+
raise ArgumentError, "execution ids cannot contain \"/\" (#{id}): the dashboard could not route them. " \
|
|
153
|
+
"Use - or : instead."
|
|
154
|
+
rescue NotSerializable => e
|
|
155
|
+
raise ArgumentError, e.message
|
|
156
|
+
end
|
|
157
|
+
|
|
138
158
|
# 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.
|
|
159
|
+
# saga waits is kept until it does, also while the saga is blocked.
|
|
140
160
|
#
|
|
141
161
|
# @param execution_id [String]
|
|
142
162
|
# @param name [Symbol, String] the name given to {Flow#wait_for}
|
|
143
163
|
# @param payload [Object] what wait_for returns; stored as JSON
|
|
144
164
|
# @return [void]
|
|
145
165
|
# @raise [ActiveRecord::RecordNotFound] if there is no such execution
|
|
146
|
-
# @raise [Error] if the execution already finished
|
|
166
|
+
# @raise [Error] if the execution already finished: completed, compensated or superseded
|
|
147
167
|
# @example In a webhook controller
|
|
148
168
|
# Durable.signal("loan-42", :kyc_done, verified: true)
|
|
149
169
|
def signal(execution_id, name, payload = nil)
|
|
150
170
|
execution = Execution.find(execution_id)
|
|
151
|
-
raise Error, "execution #{execution_id} already finished (#{execution.status})" if execution.
|
|
171
|
+
raise Error, "execution #{execution_id} already finished (#{execution.status})" if execution.finished?
|
|
152
172
|
|
|
153
173
|
SignalRecord.create!(execution_id: execution.id, name: name.to_s,
|
|
154
174
|
payload: Serializer.normalize(payload, "signal payload"))
|
|
@@ -4,7 +4,7 @@ class CreateActiveDurableTables < ActiveRecord::Migration<%= migration_version %
|
|
|
4
4
|
def change
|
|
5
5
|
# One row per saga run. It is also the buzón: create it in the same transaction as your record.
|
|
6
6
|
create_table :durable_executions, id: false do |t|
|
|
7
|
-
t.string :id, null: false, primary_key: true
|
|
7
|
+
t.string :id, null: false, primary_key: true, **exact
|
|
8
8
|
t.string :recipe, null: false
|
|
9
9
|
t.integer :recipe_version, null: false, default: 1
|
|
10
10
|
t.string :status, null: false
|
|
@@ -15,7 +15,7 @@ class CreateActiveDurableTables < ActiveRecord::Migration<%= migration_version %
|
|
|
15
15
|
t.datetime :wake_at, precision: 6
|
|
16
16
|
t.datetime :locked_until, precision: 6
|
|
17
17
|
t.string :lease_token
|
|
18
|
-
t.string :forked_from
|
|
18
|
+
t.string :forked_from, **exact
|
|
19
19
|
t.timestamps precision: 6
|
|
20
20
|
end
|
|
21
21
|
add_index :durable_executions, %i[status wake_at]
|
|
@@ -25,8 +25,8 @@ class CreateActiveDurableTables < ActiveRecord::Migration<%= migration_version %
|
|
|
25
25
|
|
|
26
26
|
# The notebook: one row per step (and per undo) of an execution.
|
|
27
27
|
create_table :durable_steps do |t|
|
|
28
|
-
t.string :execution_id, null: false
|
|
29
|
-
t.string :name, null: false
|
|
28
|
+
t.string :execution_id, null: false, **exact
|
|
29
|
+
t.string :name, null: false, **exact
|
|
30
30
|
t.string :kind, null: false
|
|
31
31
|
t.integer :position
|
|
32
32
|
t.string :status, null: false
|
|
@@ -42,8 +42,8 @@ class CreateActiveDurableTables < ActiveRecord::Migration<%= migration_version %
|
|
|
42
42
|
|
|
43
43
|
# Signals for flow.wait_for. They can arrive before the saga starts waiting.
|
|
44
44
|
create_table :durable_signals do |t|
|
|
45
|
-
t.string :execution_id, null: false
|
|
46
|
-
t.string :name, null: false
|
|
45
|
+
t.string :execution_id, null: false, **exact
|
|
46
|
+
t.string :name, null: false, **exact
|
|
47
47
|
t.column :payload, json_type
|
|
48
48
|
t.datetime :consumed_at, precision: 6
|
|
49
49
|
t.datetime :created_at, null: false, precision: 6
|
|
@@ -58,4 +58,9 @@ class CreateActiveDurableTables < ActiveRecord::Migration<%= migration_version %
|
|
|
58
58
|
def json_type
|
|
59
59
|
connection.adapter_name.downcase.include?("postg") ? :jsonb : :json
|
|
60
60
|
end
|
|
61
|
+
|
|
62
|
+
# MySQL compares strings ignoring case and accents by default; ids and step names must match exactly.
|
|
63
|
+
def exact
|
|
64
|
+
connection.adapter_name.match?(/mysql|trilogy/i) ? { collation: "utf8mb4_bin" } : {}
|
|
65
|
+
end
|
|
61
66
|
end
|
data/lib/generators/active_durable/upgrade/templates/make_active_durable_ids_case_sensitive.rb.tt
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# ActiveDurable 0.7: MySQL compared ids and step names ignoring case and accents, so "order-abc" and "order-ÁBC"
|
|
4
|
+
# shared one saga. These columns now compare byte by byte, as on PostgreSQL and SQLite. Elsewhere it does nothing.
|
|
5
|
+
#
|
|
6
|
+
# On MySQL it rebuilds the three tables and blocks writes to them while it runs: run it at a quiet time, or prune
|
|
7
|
+
# first. MySQL cannot roll DDL back, so if it stops halfway, run it again: it finishes the columns and puts the
|
|
8
|
+
# foreign keys back.
|
|
9
|
+
class MakeActiveDurableIdsCaseSensitive < ActiveRecord::Migration<%= migration_version %>
|
|
10
|
+
COLUMNS = {
|
|
11
|
+
durable_executions: %w[id forked_from],
|
|
12
|
+
durable_steps: %w[execution_id name],
|
|
13
|
+
durable_signals: %w[execution_id name]
|
|
14
|
+
}.freeze
|
|
15
|
+
|
|
16
|
+
def up
|
|
17
|
+
change_collation { |_table| "utf8mb4_bin" }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def down
|
|
21
|
+
change_collation { |table| table_collation(table) }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def change_collation
|
|
27
|
+
return unless connection.adapter_name.match?(/mysql|trilogy/i)
|
|
28
|
+
|
|
29
|
+
changes = COLUMNS.flat_map do |table, names|
|
|
30
|
+
wanted = yield(table)
|
|
31
|
+
connection.columns(table).select { |column| names.include?(column.name) && column.collation != wanted }
|
|
32
|
+
.map { |column| [table, column, wanted] }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Both ends of a foreign key must share a collation, so the keys come off while the columns change.
|
|
36
|
+
on_delete = remove_foreign_keys if changes.any?
|
|
37
|
+
changes.each do |table, column, collation|
|
|
38
|
+
change_column table, column.name, :string, limit: column.limit, null: column.null, collation: collation
|
|
39
|
+
end
|
|
40
|
+
restore_foreign_keys(on_delete || {})
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
FOREIGN_KEY_TABLES = %i[durable_steps durable_signals].freeze
|
|
44
|
+
|
|
45
|
+
def foreign_key(table)
|
|
46
|
+
connection.foreign_keys(table).find { |key| key.column == "execution_id" }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def remove_foreign_keys
|
|
50
|
+
FOREIGN_KEY_TABLES.each_with_object({}) do |table, on_delete|
|
|
51
|
+
key = foreign_key(table)
|
|
52
|
+
next unless key
|
|
53
|
+
|
|
54
|
+
on_delete[table] = key.options[:on_delete]
|
|
55
|
+
remove_foreign_key table, column: :execution_id
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Also puts back keys that an earlier run, stopped halfway, left off.
|
|
60
|
+
def restore_foreign_keys(on_delete)
|
|
61
|
+
FOREIGN_KEY_TABLES.each do |table|
|
|
62
|
+
next if foreign_key(table)
|
|
63
|
+
|
|
64
|
+
add_foreign_key table, :durable_executions, column: :execution_id, on_delete: on_delete.fetch(table, :cascade)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def table_collation(table)
|
|
69
|
+
select_value("SELECT TABLE_COLLATION FROM information_schema.TABLES " \
|
|
70
|
+
"WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = #{connection.quote(table.to_s)}")
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -16,7 +16,7 @@ module ActiveDurable
|
|
|
16
16
|
desc "Adds the migrations that newer ActiveDurable versions need. Run it after updating the gem."
|
|
17
17
|
|
|
18
18
|
# In the order they were released. Never edit one that shipped: add a new one.
|
|
19
|
-
MIGRATIONS = %w[add_active_durable_prune_index].freeze
|
|
19
|
+
MIGRATIONS = %w[add_active_durable_prune_index make_active_durable_ids_case_sensitive].freeze
|
|
20
20
|
|
|
21
21
|
# @api private
|
|
22
22
|
def create_migration_files
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: active_durable
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.7.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- William Romero
|
|
@@ -121,6 +121,7 @@ files:
|
|
|
121
121
|
- lib/generators/active_durable/install/install_generator.rb
|
|
122
122
|
- lib/generators/active_durable/install/templates/create_active_durable_tables.rb.tt
|
|
123
123
|
- lib/generators/active_durable/upgrade/templates/add_active_durable_prune_index.rb.tt
|
|
124
|
+
- lib/generators/active_durable/upgrade/templates/make_active_durable_ids_case_sensitive.rb.tt
|
|
124
125
|
- lib/generators/active_durable/upgrade/upgrade_generator.rb
|
|
125
126
|
- lib/tasks/active_durable.rake
|
|
126
127
|
- sig/active_durable.rbs
|