active_durable 0.5.0 → 0.6.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 (41) hide show
  1. checksums.yaml +4 -4
  2. data/.yardopts +8 -0
  3. data/CHANGELOG.md +52 -2
  4. data/README.md +97 -17
  5. data/app/controllers/active_durable/executions_controller.rb +2 -1
  6. data/app/helpers/active_durable/dashboard_helper.rb +1 -1
  7. data/app/views/active_durable/executions/index.html.erb +3 -2
  8. data/app/views/active_durable/executions/show.html.erb +10 -0
  9. data/app/views/layouts/active_durable/application.html.erb +4 -2
  10. data/lib/active_durable/configuration.rb +4 -0
  11. data/lib/active_durable/engine.rb +2 -4
  12. data/lib/active_durable/errors.rb +30 -0
  13. data/lib/active_durable/execution.rb +9 -2
  14. data/lib/active_durable/flow.rb +126 -15
  15. data/lib/active_durable/flow_parallel.rb +17 -0
  16. data/lib/active_durable/lease.rb +2 -0
  17. data/lib/active_durable/notebook.rb +4 -2
  18. data/lib/active_durable/open_telemetry.rb +14 -2
  19. data/lib/active_durable/operations.rb +5 -2
  20. data/lib/active_durable/parallel.rb +15 -0
  21. data/lib/active_durable/prune_job.rb +16 -0
  22. data/lib/active_durable/pruner.rb +34 -0
  23. data/lib/active_durable/record.rb +2 -0
  24. data/lib/active_durable/registry.rb +4 -0
  25. data/lib/active_durable/retry_policy.rb +2 -0
  26. data/lib/active_durable/run_job.rb +3 -0
  27. data/lib/active_durable/runner.rb +34 -2
  28. data/lib/active_durable/serializer.rb +2 -0
  29. data/lib/active_durable/signal_record.rb +2 -0
  30. data/lib/active_durable/step.rb +10 -0
  31. data/lib/active_durable/sweep_job.rb +3 -0
  32. data/lib/active_durable/sweeper.rb +26 -6
  33. data/lib/active_durable/testing.rb +8 -0
  34. data/lib/active_durable/version.rb +2 -1
  35. data/lib/active_durable.rb +115 -11
  36. data/lib/generators/active_durable/install/install_generator.rb +2 -0
  37. data/lib/generators/active_durable/install/templates/create_active_durable_tables.rb.tt +1 -0
  38. data/lib/generators/active_durable/upgrade/templates/add_active_durable_prune_index.rb.tt +14 -0
  39. data/lib/generators/active_durable/upgrade/upgrade_generator.rb +39 -0
  40. data/lib/tasks/active_durable.rake +13 -2
  41. metadata +30 -11
@@ -1,21 +1,38 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ActiveDurable
4
- # The object a recipe receives. Every call that touches the outside world goes through it, so it can
5
- # be checkpointed in the notebook and skipped on replay.
4
+ # The object a recipe receives. Every call that touches the outside world goes through it, so it can be
5
+ # checkpointed in the notebook and skipped when the recipe runs again after a crash.
6
6
  #
7
+ # Step names must be unique in a recipe: they are the steps' keys in the notebook. Every step takes the options
8
+ # `retry:` (an Integer of attempts, `false`, or `{ attempts:, backoff: }`) and, if it has an undo,
9
+ # `undo_on_failure: true`.
10
+ #
11
+ # @example
7
12
  # Durable.define :checkout do |flow, order_id:|
8
- # flow.transaction :reserve_stock, undo: -> { ... } do ... end
9
- # flow.step :charge, undo: ->(charge, ticket) { ... } do |ticket| ... end
10
- # flow.pivot(:ship) { |ticket| ... }
11
- # flow.step(:email) { ... }
13
+ # order = Order.find(order_id)
14
+ # flow.on(:compensated) { order.update!(status: "cancelled") }
15
+ # flow.transaction :reserve_stock, undo: -> { order.release_stock! } do
16
+ # order.reserve_stock!
17
+ # end
18
+ # flow.step :charge, undo: ->(charge, ticket) { Payments.refund(charge, ticket) } do |ticket|
19
+ # Payments.charge(order, ticket)
20
+ # end
21
+ # flow.pivot(:ship) { |ticket| Carrier.ship(order, reference: ticket) }
22
+ # flow.step(:email) { OrderMailer.shipped(order).deliver_now && true }
12
23
  # end
13
24
  class Flow
25
+ # @api private
14
26
  UndoEntry = Struct.new(:name, :kind, :result, :undo)
27
+ # @api private
15
28
  STEP_OPTIONS = %i[retry undo_on_failure].freeze
29
+ # The events {#on} accepts.
30
+ HOOK_EVENTS = %i[completed compensated].freeze
16
31
 
32
+ # @api private
17
33
  attr_reader :undo_stack
18
34
 
35
+ # @api private
19
36
  def initialize(runner, compensating:)
20
37
  @runner = runner
21
38
  @notebook = runner.notebook
@@ -25,52 +42,134 @@ module ActiveDurable
25
42
  @position = 0
26
43
  @seen = {}
27
44
  @undo_stack = []
45
+ @hooks = {}
28
46
  end
29
47
 
48
+ # @return [String] the id of the execution this recipe is running for
30
49
  def execution_id
31
50
  @execution.id
32
51
  end
33
52
 
34
- # The ticket (idempotency key) a step receives. It is the same every time the step runs.
53
+ # The ticket (idempotency key) a step receives: "<execution id>:<step name>". It is the same every time the
54
+ # step runs.
55
+ #
56
+ # @param name [Symbol, String]
57
+ # @return [String]
35
58
  def ticket_for(name)
36
59
  "#{execution_id}:#{name}"
37
60
  end
38
61
 
62
+ # @api private
39
63
  def compensating?
40
64
  @compensating
41
65
  end
42
66
 
67
+ # @api private
43
68
  def compensating!
44
69
  @compensating = true
45
70
  end
46
71
 
72
+ # @api private
47
73
  def pivoted?
48
74
  @pivoted
49
75
  end
50
76
 
51
- # A step that talks to the outside world. It runs at most until it is recorded; pass the ticket to the
52
- # service as its idempotency key so a repeat after a crash is recognised.
77
+ # A step that talks to the outside world. It runs until its result is recorded, so after a crash it may run
78
+ # again: pass the ticket to the service as its idempotency key, so the repeat is recognised.
79
+ #
80
+ # @param name [Symbol, String] unique in the recipe
81
+ # @param undo [#call, nil] how to undo it; receives (result, undo_ticket, step_ticket), as many as it declares
82
+ # @param options [Hash] `retry:` and `undo_on_failure:`
83
+ # @yieldparam ticket [String] the idempotency key of this step
84
+ # @yieldreturn [Object] the result, stored as JSON in the notebook
85
+ # @return [Object] the result, also when it was read from the notebook
86
+ # @raise [StepFailed] when it runs out of attempts or calls {#abort!}; rescue it to take another path
87
+ # @example
88
+ # payment = flow.step :charge, undo: ->(charge, ticket) { Payments.refund(charge, ticket) } do |ticket|
89
+ # Payments.charge(order, ticket)
90
+ # end
53
91
  def step(name, undo: nil, **options, &block)
54
92
  run_step(name, "step", undo, options, block)
55
93
  end
56
94
 
57
- # A step that only touches your own database. It runs in the same transaction that records it,
58
- # so it happens exactly once.
95
+ # A step that only touches your own database. It runs in the same transaction that records it, so it happens
96
+ # exactly once. Its undo runs in a transaction too.
97
+ #
98
+ # @param (see #step)
99
+ # @yieldparam ticket [String]
100
+ # @yieldreturn [Object] the result, stored as JSON in the notebook
101
+ # @return [Object] the result
102
+ # @raise [StepFailed] when it runs out of attempts or calls {#abort!}
59
103
  def transaction(name, undo: nil, **options, &block)
60
104
  run_step(name, "transaction", undo, options, block)
61
105
  end
62
106
 
63
- # The point of no return. Before it, failures are compensated; after it, steps are retried.
107
+ # The point of no return, such as shipping a parcel. Before it, a step that fails for good undoes the saga;
108
+ # after it, steps cannot declare an undo and are retried (config.after_pivot_attempts), then the execution is
109
+ # blocked for a person. If the pivot itself fails for good, the saga is undone: the point was never passed.
110
+ #
111
+ # @param name [Symbol, String]
112
+ # @param options [Hash] `retry:`
113
+ # @yieldparam ticket [String]
114
+ # @return [Object] the result
64
115
  def pivot(name, **options, &block)
65
116
  run_step(name, "pivot", nil, options, block)
66
117
  end
67
118
 
68
- # Rejects the saga for a business reason: no retries, straight to compensation.
119
+ # Runs a block once the saga ends that way, to update your own records (the order is paid, the order is
120
+ # cancelled). Declare hooks before the first step, so a saga undone at its first step still knows them. The
121
+ # block runs in a transaction together with the notebook entry that records it: a hook that only touches your
122
+ # database runs exactly once, even across crashes. If it raises, the execution is blocked and
123
+ # {ActiveDurable.retry} runs it again.
124
+ #
125
+ # @param event [Symbol] :completed (after the last step) or :compensated (after the last undo)
126
+ # @return [nil]
127
+ # @raise [InvalidRecipe] after the first step, for another event, or twice for the same event
128
+ # @example
129
+ # flow.on(:completed) { order.update!(status: "delivered") }
130
+ # flow.on(:compensated) { order.update!(status: "cancelled") }
131
+ def on(event, &block)
132
+ raise InvalidRecipe, "flow.on needs a block" unless block
133
+ unless HOOK_EVENTS.include?(event)
134
+ raise InvalidRecipe, "flow.on(:#{event}): the events are :completed and :compensated"
135
+ end
136
+ if @position.positive?
137
+ raise InvalidRecipe, "flow.on(:#{event}) must come before the first step: when a saga is undone early, " \
138
+ "the steps after the failure are never reached"
139
+ end
140
+ raise InvalidRecipe, "flow.on(:#{event}) is declared twice" if @hooks.key?(event)
141
+
142
+ @hooks[event] = block
143
+ nil
144
+ end
145
+
146
+ # @api private
147
+ def hook(event)
148
+ @hooks[event]
149
+ end
150
+
151
+ # Rejects the saga for a business reason, such as a declined card: no retries, straight to undoing what was
152
+ # done. Call it inside a step or in the recipe itself.
153
+ #
154
+ # @param message [String] recorded as the error
155
+ # @raise [Abort] always
156
+ # @example
157
+ # flow.step :charge do |ticket|
158
+ # Payments.charge(order, ticket)
159
+ # rescue Payments::CardDeclined => e
160
+ # flow.abort!(e.message)
161
+ # end
69
162
  def abort!(message)
70
163
  raise Abort, message
71
164
  end
72
165
 
73
- # Waits without holding a worker: the wake-up time is written down and the execution is released.
166
+ # Waits without holding a worker: the wake-up time is written down and the execution is released until then.
167
+ #
168
+ # @param name [Symbol, String]
169
+ # @param duration [ActiveSupport::Duration, Numeric] seconds
170
+ # @return [nil]
171
+ # @example
172
+ # flow.sleep(:wait_for_delivery, 3.days)
74
173
  def sleep(name, duration)
75
174
  name, position = visit!(name, "sleep")
76
175
  entry = @notebook[name]
@@ -90,7 +189,13 @@ module ActiveDurable
90
189
  @runner.suspend!(wake_at, "sleeping")
91
190
  end
92
191
 
93
- # Waits for Durable.signal(execution_id, name, payload) and returns the payload.
192
+ # Waits, without holding a worker, for {ActiveDurable.signal}(execution_id, name, payload). A signal sent
193
+ # before the saga gets here is kept.
194
+ #
195
+ # @param name [Symbol, String]
196
+ # @param timeout [ActiveSupport::Duration, Numeric, nil] seconds; then the step fails and the saga is undone
197
+ # @return [Object] the signal's payload
198
+ # @raise [StepFailed] when the timeout passes first
94
199
  def wait_for(name, timeout: nil)
95
200
  name, position = visit!(name, "wait")
96
201
  entry = @notebook[name]
@@ -118,6 +223,8 @@ module ActiveDurable
118
223
  end
119
224
 
120
225
  # Called when the recipe returns: every step the notebook knows about must have been reached.
226
+ #
227
+ # @api private
121
228
  def finish!
122
229
  return if compensating?
123
230
 
@@ -129,6 +236,7 @@ module ActiveDurable
129
236
  "#{missing.size == 1 ? "it" : "them"}. #{RECIPE_CHANGED_HINT}"
130
237
  end
131
238
 
239
+ # @api private
132
240
  RECIPE_CHANGED_HINT = "Either the recipe changed while this execution was in flight, or code outside a " \
133
241
  "step read data that changed between runs. Keep reads that decide the path inside " \
134
242
  "steps, or define a new recipe version."
@@ -181,6 +289,8 @@ module ActiveDurable
181
289
  result
182
290
  rescue NotSerializable, InvalidRecipe
183
291
  raise
292
+ rescue NameError => e # NoMethodError too: a bug in the code, not a failure of the outside world
293
+ raise CodeError.new(name, e)
184
294
  rescue StandardError => e
185
295
  handle_failure(name, kind, position, entry, undo, options, e)
186
296
  end
@@ -241,6 +351,7 @@ module ActiveDurable
241
351
  name = name.to_s
242
352
  raise InvalidRecipe, "step names cannot be blank" if name.empty?
243
353
  raise InvalidRecipe, "step names cannot end in ':undo' (#{name})" if name.end_with?(":undo")
354
+ raise InvalidRecipe, "step names cannot start with '~' (#{name}): it marks hooks" if name.start_with?("~")
244
355
  if @seen.key?(name)
245
356
  raise DuplicateStepName, "the recipe uses the step name :#{name} twice. Each step needs its own name: " \
246
357
  "it is the step's key in the notebook."
@@ -9,6 +9,21 @@ module ActiveDurable
9
9
  # a crash only the unfinished branches run again. If a branch runs out of attempts, the saga compensates the
10
10
  # branches that completed (last finished, first undone) and every step before the parallel block.
11
11
  module Parallel
12
+ # Runs several steps at the same time. Declare the branches on the group the block receives; they run
13
+ # after the block returns.
14
+ #
15
+ # @param name [Symbol, String]
16
+ # @yieldparam branches [ParallelGroup]
17
+ # @return [Hash{String => Object}] each branch's result by branch name
18
+ # @raise [StepFailed] when a branch runs out of attempts
19
+ # @example
20
+ # flow.parallel(:reserve) do |branches|
21
+ # warehouses.each do |warehouse|
22
+ # branches.step(warehouse.code, undo: ->(r, ticket) { warehouse.release(r["id"], key: ticket) }) do |ticket|
23
+ # { "id" => warehouse.reserve(order.items_for(warehouse), key: ticket) }
24
+ # end
25
+ # end
26
+ # end
12
27
  def parallel(name, &block)
13
28
  raise InvalidRecipe, "flow.parallel :#{name} needs a block" unless block
14
29
 
@@ -158,6 +173,8 @@ module ActiveDurable
158
173
  [:completed, result]
159
174
  rescue NotSerializable, InvalidRecipe
160
175
  raise
176
+ rescue NameError => e
177
+ raise CodeError.new(branch.full_name, e)
161
178
  rescue StandardError => e
162
179
  branch_failure(branch, entry, e)
163
180
  end
@@ -7,6 +7,8 @@ module ActiveDurable
7
7
  # lease. The claim writes a fresh random token. Every later write (notebook entries, status changes)
8
8
  # is conditional on that token still being there, so a worker whose lease expired and was taken over
9
9
  # cannot write anything else: its writes raise LeaseLost and it stops.
10
+ #
11
+ # @api private
10
12
  class Lease
11
13
  attr_reader :execution_id, :token
12
14
 
@@ -3,6 +3,8 @@
3
3
  module ActiveDurable
4
4
  # The notebook of one execution: every step that ran, what it returned and its state.
5
5
  # Loaded once per run; every write is fenced by the lease.
6
+ #
7
+ # @api private
6
8
  class Notebook
7
9
  def initialize(execution, lease)
8
10
  @execution = execution
@@ -20,7 +22,7 @@ module ActiveDurable
20
22
  # Completed branch entries of a flow.parallel, in the order they finished.
21
23
  def branches_of(parallel_name)
22
24
  prefix = "#{parallel_name}/"
23
- @mutex.synchronize { @entries.values.select { |entry| entry.name.start_with?(prefix) && !entry.undo? } }
25
+ @mutex.synchronize { @entries.values.select { |entry| entry.name.start_with?(prefix) && entry.forward? } }
24
26
  .sort_by { |entry| [entry.updated_at, entry.id] }
25
27
  end
26
28
 
@@ -29,7 +31,7 @@ module ActiveDurable
29
31
  end
30
32
 
31
33
  def forward_entries
32
- @mutex.synchronize { @entries.values.reject(&:undo?) }
34
+ @mutex.synchronize { @entries.values.select(&:forward?) }
33
35
  end
34
36
 
35
37
  def complete!(name, kind:, position:, result:)
@@ -4,7 +4,7 @@ require "opentelemetry"
4
4
  require "active_durable"
5
5
 
6
6
  module ActiveDurable
7
- # Traces executions, steps, compensations and undos as OpenTelemetry spans.
7
+ # Traces executions, steps, compensations, undos and hooks as OpenTelemetry spans.
8
8
  #
9
9
  # # config/initializers/active_durable.rb
10
10
  # require "active_durable/open_telemetry"
@@ -13,9 +13,14 @@ module ActiveDurable
13
13
  # Spans nest: a worker run ("active_durable.execution checkout") contains its steps, and flow.parallel
14
14
  # branches stay under it even though they run in other threads. Failed steps record the exception.
15
15
  module OpenTelemetry
16
- EVENTS = %w[execution step compensation undo].freeze
16
+ # The ActiveSupport::Notifications events that become spans.
17
+ EVENTS = %w[execution step compensation undo hook].freeze
17
18
 
18
19
  class << self
20
+ # Starts tracing. Call it once, after configuring OpenTelemetry::SDK.
21
+ #
22
+ # @param tracer_provider [::OpenTelemetry::Trace::TracerProvider]
23
+ # @return [self]
19
24
  def install!(tracer_provider: ::OpenTelemetry.tracer_provider)
20
25
  uninstall!
21
26
  subscriber = Subscriber.new(tracer_provider.tracer("active_durable", ActiveDurable::VERSION))
@@ -26,6 +31,9 @@ module ActiveDurable
26
31
  self
27
32
  end
28
33
 
34
+ # Stops tracing.
35
+ #
36
+ # @return [void]
29
37
  def uninstall!
30
38
  Array(@subscriptions).each { |subscription| ActiveSupport::Notifications.unsubscribe(subscription) }
31
39
  @subscriptions = nil
@@ -34,6 +42,8 @@ module ActiveDurable
34
42
  end
35
43
 
36
44
  # Starts a span when an event starts and ends it when the event finishes, so spans nest naturally.
45
+ #
46
+ # @api private
37
47
  class Subscriber
38
48
  def initialize(tracer)
39
49
  @tracer = tracer
@@ -81,6 +91,8 @@ module ActiveDurable
81
91
  end
82
92
 
83
93
  # Carries the current span into flow.parallel branch threads.
94
+ #
95
+ # @api private
84
96
  module ContextPropagation
85
97
  def self.capture
86
98
  ::OpenTelemetry::Context.current
@@ -9,6 +9,8 @@ module ActiveDurable
9
9
  #
10
10
  # Every operation takes the execution only when no worker holds it, and rotates the lease token so a
11
11
  # stale worker can never write again.
12
+ #
13
+ # @api private
12
14
  module Operations
13
15
  module_function
14
16
 
@@ -50,7 +52,7 @@ module ActiveDurable
50
52
  Record.transaction do
51
53
  original = Execution.lock.find(execution_id)
52
54
  check_rerunnable!(original)
53
- start = original.steps.where.not(kind: "undo").find_by(name: from)
55
+ start = original.steps.where.not(kind: %w[undo hook]).find_by(name: from)
54
56
  raise Error, "#{original.id} has no step :#{from} in its notebook" unless start
55
57
 
56
58
  execution = Execution.create!(id: rerun_id(original), recipe: original.recipe,
@@ -101,7 +103,8 @@ module ActiveDurable
101
103
 
102
104
  def copy_steps(original, execution, before:)
103
105
  now = ActiveDurable.now
104
- rows = original.steps.where.not(kind: "undo").where(status: "completed").where(position: ...before).map do |step|
106
+ rows = original.steps.where.not(kind: %w[undo hook]).where(status: "completed").where(position: ...before)
107
+ .map do |step|
105
108
  { execution_id: execution.id, name: step.name, kind: step.kind, position: step.position,
106
109
  status: "completed", attempts: step.attempts, result: step.result, created_at: now, updated_at: now }
107
110
  end
@@ -13,20 +13,35 @@ module ActiveDurable
13
13
  # end
14
14
  # results # => { "MEX" => {...}, "GDL" => {...} }
15
15
  class ParallelGroup
16
+ # @api private
16
17
  Branch = Struct.new(:name, :full_name, :kind, :undo, :options, :block)
18
+ # @api private
17
19
  OPTIONS = %i[retry undo_on_failure].freeze
18
20
 
21
+ # @api private
19
22
  attr_reader :branches
20
23
 
24
+ # @api private
21
25
  def initialize(parallel_name)
22
26
  @parallel_name = parallel_name
23
27
  @branches = []
24
28
  end
25
29
 
30
+ # A branch that talks to the outside world, like {Flow#step}.
31
+ #
32
+ # @param name [Symbol, String] unique in this parallel block
33
+ # @param undo [#call, nil]
34
+ # @param options [Hash] `retry:` and `undo_on_failure:`
35
+ # @yieldparam ticket [String]
36
+ # @return [void]
26
37
  def step(name, undo: nil, **options, &block)
27
38
  add(name, "step", undo, options, block)
28
39
  end
29
40
 
41
+ # A branch that only touches your own database, like {Flow#transaction}.
42
+ #
43
+ # @param (see #step)
44
+ # @return [void]
30
45
  def transaction(name, undo: nil, **options, &block)
31
46
  add(name, "transaction", undo, options, block)
32
47
  end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveDurable
4
+ # Deletes finished executions older than config.keep_finished_for. Schedule it once a day (Solid Queue
5
+ # recurring tasks, cron, sidekiq-cron...).
6
+ class PruneJob < ActiveJob::Base
7
+ queue_as { ActiveDurable.config.queue_name }
8
+
9
+ # Deletes finished executions older than config.keep_finished_for.
10
+ #
11
+ # @return [void]
12
+ def perform
13
+ Pruner.call
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveDurable
4
+ # Deletes finished executions (completed, compensated, superseded) older than a cutoff, with their notebook and
5
+ # their signals. Active and blocked executions are never touched: they still need a worker or a person.
6
+ #
7
+ # @api private
8
+ module Pruner
9
+ FINISHED = %w[completed compensated superseded].freeze
10
+
11
+ module_function
12
+
13
+ # Returns how many executions were deleted. Each batch is deleted in its own transaction, steps and signals
14
+ # first, so it never depends on the database cascading the foreign keys.
15
+ def call(older_than: ActiveDurable.config.keep_finished_for, batch_size: 1_000, now: ActiveDurable.now)
16
+ raise ArgumentError, "older_than must be set, for example 30.days" if older_than.nil?
17
+
18
+ cutoff = now - older_than.to_f
19
+ deleted = 0
20
+ loop do
21
+ ids = Execution.where(status: FINISHED).where("updated_at < ?", cutoff).limit(batch_size).pluck(:id)
22
+ break if ids.empty?
23
+
24
+ Record.transaction do
25
+ Step.where(execution_id: ids).delete_all
26
+ SignalRecord.where(execution_id: ids).delete_all
27
+ Execution.where(id: ids).delete_all
28
+ end
29
+ deleted += ids.size
30
+ end
31
+ deleted
32
+ end
33
+ end
34
+ end
@@ -3,6 +3,8 @@
3
3
  module ActiveDurable
4
4
  # Base class for the gem's tables. It shares ActiveRecord::Base's connection on purpose:
5
5
  # flow.transaction is only atomic when the notebook lives in the same database as your data.
6
+ #
7
+ # @api private
6
8
  class Record < ActiveRecord::Base
7
9
  self.abstract_class = true
8
10
 
@@ -2,6 +2,8 @@
2
2
 
3
3
  module ActiveDurable
4
4
  # A named recipe: the block that describes the steps of a saga.
5
+ #
6
+ # @api private
5
7
  class Recipe
6
8
  attr_reader :name, :version, :block
7
9
 
@@ -25,6 +27,8 @@ module ActiveDurable
25
27
  # Recipes are looked up by name when a worker picks up an execution, possibly in a fresh process.
26
28
  # In a Rails app, put each recipe in app/sagas/<name>_saga.rb and assign it to a constant
27
29
  # (CheckoutSaga = Durable.define(:checkout) { ... }). The registry autoloads that constant on a miss.
30
+ #
31
+ # @api private
28
32
  class Registry
29
33
  def initialize
30
34
  @recipes = {}
@@ -6,6 +6,8 @@ module ActiveDurable
6
6
  # flow.step :charge, retry: 5 { ... }
7
7
  # flow.step :charge, retry: { attempts: 5, backoff: [1, 10, 60] } { ... }
8
8
  # flow.step :charge, retry: false { ... } # a single attempt
9
+ #
10
+ # @api private
9
11
  class RetryPolicy
10
12
  attr_reader :attempts
11
13
 
@@ -5,6 +5,9 @@ module ActiveDurable
5
5
  class RunJob < ActiveJob::Base
6
6
  queue_as { ActiveDurable.config.queue_name }
7
7
 
8
+ # Runs the execution until it finishes, sleeps, waits or blocks.
9
+ #
10
+ # @param execution_id [String]
8
11
  def perform(execution_id)
9
12
  Runner.run(execution_id)
10
13
  end
@@ -3,6 +3,8 @@
3
3
  module ActiveDurable
4
4
  # Runs one execution as far as it can go: claims the lease, replays the recipe against the notebook,
5
5
  # and ends by completing, suspending (sleep, wait, retry), compensating or blocking.
6
+ #
7
+ # @api private
6
8
  class Runner
7
9
  SUSPEND = :active_durable_suspend
8
10
 
@@ -58,15 +60,23 @@ module ActiveDurable
58
60
  fail!(e)
59
61
  end
60
62
 
63
+ # Only a step that failed for good (StepFailed) or a business rejection (Abort) undoes the saga. Anything else
64
+ # 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.
61
66
  def fail!(error)
62
- return block!(error) if @flow.nil? || @flow.pivoted?
67
+ return block!(error) if @flow.nil? || @flow.pivoted? || !compensates?(error)
63
68
 
64
69
  start_compensation!(error) unless @flow.compensating?
65
70
  compensate!
66
71
  end
67
72
 
73
+ def compensates?(error)
74
+ error.is_a?(StepFailed) || error.is_a?(Abort)
75
+ end
76
+
68
77
  def complete!(output)
69
78
  output = Serializer.normalize(output, "the recipe's return value")
79
+ run_hook(:completed)
70
80
  lease.release!(status: "completed", output: output, wake_at: nil)
71
81
  ActiveDurable.instrument("completed", execution_id: execution.id, recipe: execution.recipe)
72
82
  :completed
@@ -98,13 +108,35 @@ module ActiveDurable
98
108
  ActiveDurable.instrument("compensation", execution_id: execution.id) do
99
109
  @flow.undo_stack.reverse_each { |entry| undo!(entry) }
100
110
  end
111
+ run_hook(:compensated)
101
112
  lease.release!(status: "compensated", wake_at: nil)
102
113
  ActiveDurable.instrument("compensated", execution_id: execution.id, recipe: execution.recipe)
103
114
  :compensated
104
- rescue UndoFailed => e
115
+ rescue UndoFailed, HookFailed => e
105
116
  block!(e)
106
117
  end
107
118
 
119
+ # Runs a flow.on hook once: its notebook entry is written in the same transaction as the hook's own changes.
120
+ def run_hook(event)
121
+ hook = @flow.hook(event)
122
+ name = "~#{event}"
123
+ return if hook.nil? || notebook[name]&.completed?
124
+
125
+ ActiveDurable.crash_point(:before_hook, name)
126
+ ActiveDurable.instrument("hook", execution_id: execution.id, step: name, kind: "hook") do
127
+ notebook.transaction do
128
+ hook.call
129
+ ActiveDurable.crash_point(:after_hook_call, name)
130
+ notebook.complete!(name, kind: "hook", position: nil, result: nil)
131
+ end
132
+ end
133
+ ActiveDurable.crash_point(:after_hook_record, name)
134
+ rescue StandardError => e
135
+ raise if e.is_a?(HookFailed)
136
+
137
+ raise HookFailed.new(event, e)
138
+ end
139
+
108
140
  def undo!(entry)
109
141
  name = "#{entry.name}:undo"
110
142
  record = notebook[name]
@@ -4,6 +4,8 @@ module ActiveDurable
4
4
  # Step results, inputs and signal payloads live in the notebook as JSON. This module turns a value
5
5
  # into exactly what a later replay will read back (string keys, no symbols), so the first run and a
6
6
  # replay behave the same. Anything that is not plain JSON is rejected with a message that says where.
7
+ #
8
+ # @api private
7
9
  module Serializer
8
10
  module_function
9
11
 
@@ -2,6 +2,8 @@
2
2
 
3
3
  module ActiveDurable
4
4
  # A message for a saga that is (or will be) waiting in flow.wait_for.
5
+ #
6
+ # @api private
5
7
  class SignalRecord < Record
6
8
  self.table_name = "durable_signals"
7
9
 
@@ -30,5 +30,15 @@ module ActiveDurable
30
30
  def undo?
31
31
  kind == "undo"
32
32
  end
33
+
34
+ # Written once a flow.on(:completed) or flow.on(:compensated) hook ran.
35
+ def hook?
36
+ kind == "hook"
37
+ end
38
+
39
+ # A step of the recipe or a parallel branch: neither an undo nor a hook.
40
+ def forward?
41
+ !undo? && !hook?
42
+ end
33
43
  end
34
44
  end
@@ -5,6 +5,9 @@ module ActiveDurable
5
5
  class SweepJob < ActiveJob::Base
6
6
  queue_as { ActiveDurable.config.queue_name }
7
7
 
8
+ # Enqueues the executions that should be running but have no job.
9
+ #
10
+ # @return [void]
8
11
  def perform
9
12
  Sweeper.call
10
13
  end