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.
@@ -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
- raise StopForward, name if compensating?
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 { record_result(branch.full_name, branch.kind, nil, branch.block.call(ticket)) }
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 NotSerializable, InvalidRecipe
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.retrying?)
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 and their undos use it: a step that rolled back must not look completed.
57
- def transaction(&)
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: failed steps (or failed undos, if it was
18
- # compensating) get a fresh set of attempts. Deploy the fix first when the cause was a bug.
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
- failed = record.steps.where(status: "failed")
22
- failed = record.compensating ? failed.where(kind: "undo") : failed.where.not(kind: "undo")
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 running sleeping waiting]) do |record|
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.steps.where.not(kind: %w[undo hook]).find_by(name: from)
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-\d+\z/, "")
100
- count = Execution.where("id LIKE ?", "#{Execution.sanitize_sql_like(root)}~rerun-%").count
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 = original.steps.where.not(kind: %w[undo hook]).where(status: "completed").where(position: ...before)
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: "completed", attempts: step.attempts, result: step.result, created_at: now, updated_at: now }
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.pending.exists?(execution_id: execution.id)
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, "undo of :#{entry.name} failed #{attempts} times (#{error.class}: #{error.message})"
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 and a
6
- # replay behave the same. Anything that is not plain JSON is rejected with a message that says where.
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, String, Integer then value
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)
@@ -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.pending.select(:execution_id)))
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.pending.exists?(execution_id: execution.id)
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)
@@ -2,5 +2,5 @@
2
2
 
3
3
  module ActiveDurable
4
4
  # The gem's version.
5
- VERSION = "0.6.0"
5
+ VERSION = "0.7.0"
6
6
  end
@@ -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
- execution = Execution.create_or_find_by!(id: id.to_s) { |record| record.assign_attributes(attributes) }
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.terminal?
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
@@ -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.6.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