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.
Files changed (32) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +99 -1
  3. data/README.md +238 -12
  4. data/app/controllers/active_durable/executions_controller.rb +2 -1
  5. data/app/helpers/active_durable/dashboard_helper.rb +12 -4
  6. data/app/views/active_durable/executions/index.html.erb +1 -1
  7. data/app/views/active_durable/executions/show.html.erb +9 -6
  8. data/app/views/layouts/active_durable/application.html.erb +3 -3
  9. data/lib/active_durable/configuration.rb +30 -0
  10. data/lib/active_durable/errors.rb +53 -8
  11. data/lib/active_durable/execution.rb +22 -2
  12. data/lib/active_durable/flow.rb +147 -31
  13. data/lib/active_durable/flow_parallel.rb +38 -12
  14. data/lib/active_durable/notebook.rb +17 -4
  15. data/lib/active_durable/operations.rb +55 -17
  16. data/lib/active_durable/pruner.rb +19 -4
  17. data/lib/active_durable/record.rb +9 -1
  18. data/lib/active_durable/runner.rb +45 -10
  19. data/lib/active_durable/serializer.rb +42 -5
  20. data/lib/active_durable/signal_record.rb +18 -2
  21. data/lib/active_durable/step.rb +20 -0
  22. data/lib/active_durable/sweeper.rb +1 -1
  23. data/lib/active_durable/testing.rb +18 -13
  24. data/lib/active_durable/version.rb +1 -1
  25. data/lib/active_durable.rb +79 -11
  26. data/lib/generators/active_durable/install/templates/create_active_durable_tables.rb.tt +15 -6
  27. data/lib/generators/active_durable/upgrade/templates/add_active_durable_context.rb.tt +19 -0
  28. data/lib/generators/active_durable/upgrade/templates/add_active_durable_continued_as.rb.tt +21 -0
  29. data/lib/generators/active_durable/upgrade/templates/add_active_durable_signal_name.rb.tt +22 -0
  30. data/lib/generators/active_durable/upgrade/templates/make_active_durable_ids_case_sensitive.rb.tt +72 -0
  31. data/lib/generators/active_durable/upgrade/upgrade_generator.rb +4 -1
  32. 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: 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,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.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
- 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-\d+\z/, "")
100
- count = Execution.where("id LIKE ?", "#{Execution.sanitize_sql_like(root)}~rerun-%").count
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 = original.steps.where.not(kind: %w[undo hook]).where(status: "completed").where(position: ...before)
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: "completed", attempts: step.attempts, result: step.result, created_at: now, updated_at: now }
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).limit(batch_size).pluck(:id)
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 = ActiveRecord::Type::Json.new
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.instrument("execution", execution_id: execution.id, recipe: execution.recipe) do
29
- catch(SUSPEND) { run_recipe }
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.pending.exists?(execution_id: execution.id)
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, "undo of :#{entry.name} failed #{attempts} times (#{error.class}: #{error.message})"
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), 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, 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, 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}]") }
@@ -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), out|
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
- out[key] = normalize(item, "#{path}[#{key.inspect}]")
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.next_for(execution_id, name)
19
- pending.where(execution_id: execution_id, name: name.to_s).order(:id).first
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
@@ -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.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
@@ -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. Signals given as { name => payload } are delivered when the saga waits for them.
27
- # Returns the execution; it stays "waiting" if it waits for a signal you did not provide.
28
- def drain(execution_id, signals: {}, max_runs: 500)
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 = Execution.find(execution_id)
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(execution_id)
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.pending.exists?(execution_id: execution.id)
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.name)
117
- ActiveDurable.signal(execution.id, waiting.name, signals[waiting.name])
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
@@ -2,5 +2,5 @@
2
2
 
3
3
  module ActiveDurable
4
4
  # The gem's version.
5
- VERSION = "0.6.0"
5
+ VERSION = "0.8.0"
6
6
  end
@@ -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
- execution = Execution.create_or_find_by!(id: id.to_s) { |record| record.assign_attributes(attributes) }
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
- execution = Execution.find(execution_id)
151
- raise Error, "execution #{execution_id} already finished (#{execution.status})" if execution.terminal?
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
- SignalRecord.create!(execution_id: execution.id, name: name.to_s,
154
- payload: Serializer.normalize(payload, "signal payload"))
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