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.
- checksums.yaml +4 -4
- data/.yardopts +8 -0
- data/CHANGELOG.md +52 -2
- data/README.md +97 -17
- data/app/controllers/active_durable/executions_controller.rb +2 -1
- data/app/helpers/active_durable/dashboard_helper.rb +1 -1
- data/app/views/active_durable/executions/index.html.erb +3 -2
- data/app/views/active_durable/executions/show.html.erb +10 -0
- data/app/views/layouts/active_durable/application.html.erb +4 -2
- data/lib/active_durable/configuration.rb +4 -0
- data/lib/active_durable/engine.rb +2 -4
- data/lib/active_durable/errors.rb +30 -0
- data/lib/active_durable/execution.rb +9 -2
- data/lib/active_durable/flow.rb +126 -15
- data/lib/active_durable/flow_parallel.rb +17 -0
- data/lib/active_durable/lease.rb +2 -0
- data/lib/active_durable/notebook.rb +4 -2
- data/lib/active_durable/open_telemetry.rb +14 -2
- data/lib/active_durable/operations.rb +5 -2
- data/lib/active_durable/parallel.rb +15 -0
- data/lib/active_durable/prune_job.rb +16 -0
- data/lib/active_durable/pruner.rb +34 -0
- data/lib/active_durable/record.rb +2 -0
- data/lib/active_durable/registry.rb +4 -0
- data/lib/active_durable/retry_policy.rb +2 -0
- data/lib/active_durable/run_job.rb +3 -0
- data/lib/active_durable/runner.rb +34 -2
- data/lib/active_durable/serializer.rb +2 -0
- data/lib/active_durable/signal_record.rb +2 -0
- data/lib/active_durable/step.rb +10 -0
- data/lib/active_durable/sweep_job.rb +3 -0
- data/lib/active_durable/sweeper.rb +26 -6
- data/lib/active_durable/testing.rb +8 -0
- data/lib/active_durable/version.rb +2 -1
- data/lib/active_durable.rb +115 -11
- data/lib/generators/active_durable/install/install_generator.rb +2 -0
- data/lib/generators/active_durable/install/templates/create_active_durable_tables.rb.tt +1 -0
- data/lib/generators/active_durable/upgrade/templates/add_active_durable_prune_index.rb.tt +14 -0
- data/lib/generators/active_durable/upgrade/upgrade_generator.rb +39 -0
- data/lib/tasks/active_durable.rake +13 -2
- metadata +30 -11
data/lib/active_durable/flow.rb
CHANGED
|
@@ -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
|
-
#
|
|
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
|
-
#
|
|
9
|
-
# flow.
|
|
10
|
-
# flow.
|
|
11
|
-
#
|
|
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
|
|
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
|
|
52
|
-
# service as its idempotency key so
|
|
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
|
-
#
|
|
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,
|
|
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
|
-
#
|
|
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
|
|
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
|
data/lib/active_durable/lease.rb
CHANGED
|
@@ -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) &&
|
|
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.
|
|
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
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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
|
|
data/lib/active_durable/step.rb
CHANGED
|
@@ -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
|