active_durable 0.5.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.yardopts +8 -0
- data/CHANGELOG.md +114 -3
- data/README.md +174 -22
- data/app/controllers/active_durable/executions_controller.rb +4 -2
- data/app/helpers/active_durable/dashboard_helper.rb +11 -3
- data/app/views/active_durable/executions/index.html.erb +4 -3
- data/app/views/active_durable/executions/show.html.erb +16 -6
- data/app/views/layouts/active_durable/application.html.erb +7 -5
- data/lib/active_durable/configuration.rb +18 -0
- data/lib/active_durable/engine.rb +2 -4
- data/lib/active_durable/errors.rb +80 -5
- data/lib/active_durable/execution.rb +16 -2
- data/lib/active_durable/flow.rb +170 -22
- data/lib/active_durable/flow_parallel.rb +46 -7
- data/lib/active_durable/lease.rb +2 -0
- data/lib/active_durable/notebook.rb +17 -4
- data/lib/active_durable/open_telemetry.rb +14 -2
- data/lib/active_durable/operations.rb +54 -15
- data/lib/active_durable/parallel.rb +15 -0
- data/lib/active_durable/prune_job.rb +16 -0
- data/lib/active_durable/pruner.rb +32 -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 +48 -6
- data/lib/active_durable/serializer.rb +29 -3
- data/lib/active_durable/signal_record.rb +13 -0
- data/lib/active_durable/step.rb +20 -0
- data/lib/active_durable/sweep_job.rb +3 -0
- data/lib/active_durable/sweeper.rb +26 -6
- data/lib/active_durable/testing.rb +9 -1
- data/lib/active_durable/version.rb +2 -1
- data/lib/active_durable.rb +137 -13
- data/lib/generators/active_durable/install/install_generator.rb +2 -0
- data/lib/generators/active_durable/install/templates/create_active_durable_tables.rb.tt +12 -6
- data/lib/generators/active_durable/upgrade/templates/add_active_durable_prune_index.rb.tt +14 -0
- data/lib/generators/active_durable/upgrade/templates/make_active_durable_ids_case_sensitive.rb.tt +72 -0
- data/lib/generators/active_durable/upgrade/upgrade_generator.rb +39 -0
- data/lib/tasks/active_durable.rake +13 -2
- metadata +31 -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
|
|
|
@@ -11,6 +13,17 @@ module ActiveDurable
|
|
|
11
13
|
|
|
12
14
|
scope :pending, -> { where(consumed_at: nil) }
|
|
13
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
|
+
|
|
14
27
|
after_create_commit { ActiveDurable.enqueue(execution_id) }
|
|
15
28
|
|
|
16
29
|
def self.next_for(execution_id, name)
|
data/lib/active_durable/step.rb
CHANGED
|
@@ -23,6 +23,16 @@ module ActiveDurable
|
|
|
23
23
|
status == "retrying"
|
|
24
24
|
end
|
|
25
25
|
|
|
26
|
+
# It hit a bug, or its result could not be recorded. It runs again when the execution is retried.
|
|
27
|
+
def blocked?
|
|
28
|
+
status == "blocked"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# It ran without recording a result: between attempts, or stopped by a bug. Its effect may have happened.
|
|
32
|
+
def unfinished?
|
|
33
|
+
retrying? || blocked?
|
|
34
|
+
end
|
|
35
|
+
|
|
26
36
|
def waiting?
|
|
27
37
|
status == "waiting"
|
|
28
38
|
end
|
|
@@ -30,5 +40,15 @@ module ActiveDurable
|
|
|
30
40
|
def undo?
|
|
31
41
|
kind == "undo"
|
|
32
42
|
end
|
|
43
|
+
|
|
44
|
+
# Written once a flow.on(:completed) or flow.on(:compensated) hook ran.
|
|
45
|
+
def hook?
|
|
46
|
+
kind == "hook"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# A step of the recipe or a parallel branch: neither an undo nor a hook.
|
|
50
|
+
def forward?
|
|
51
|
+
!undo? && !hook?
|
|
52
|
+
end
|
|
33
53
|
end
|
|
34
54
|
end
|
|
@@ -4,19 +4,39 @@ module ActiveDurable
|
|
|
4
4
|
# The safety net. Finds executions that should be running but have no job: the process died between
|
|
5
5
|
# the commit and the enqueue, a timer job was lost, or a worker crashed and its lease expired.
|
|
6
6
|
# Enqueuing twice is harmless: only one worker can claim the lease.
|
|
7
|
+
#
|
|
8
|
+
# @api private
|
|
7
9
|
module Sweeper
|
|
8
10
|
module_function
|
|
9
11
|
|
|
10
12
|
def call(now: ActiveDurable.now)
|
|
13
|
+
ids = due(now: now)
|
|
14
|
+
ids.each { |id| ActiveDurable.enqueue(id) }
|
|
15
|
+
ids
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# The rake task. With the :async adapter (the Rails default in development) a job lives in the memory of the
|
|
19
|
+
# process that enqueued it, and this process ends as soon as the task does: run the executions here instead.
|
|
20
|
+
def call_from_task(now: ActiveDurable.now)
|
|
21
|
+
return [call(now: now), :enqueued] unless in_process_adapter?
|
|
22
|
+
|
|
23
|
+
ids = due(now: now)
|
|
24
|
+
ids.each { |id| RunJob.perform_now(id) }
|
|
25
|
+
[ids, :ran]
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def in_process_adapter?
|
|
29
|
+
RunJob.queue_adapter.is_a?(ActiveJob::QueueAdapters::AsyncAdapter)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def due(now: ActiveDurable.now)
|
|
11
33
|
quiet = Execution.active
|
|
12
34
|
.where("locked_until IS NULL OR locked_until < ?", now)
|
|
13
35
|
.where("updated_at < ?", now - ActiveDurable.config.sweep_grace.to_f)
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
ids.each { |id| ActiveDurable.enqueue(id) }
|
|
19
|
-
ids
|
|
36
|
+
ready = quiet.where(status: %w[pending running])
|
|
37
|
+
.or(quiet.where(wake_at: ..now))
|
|
38
|
+
.or(quiet.where(status: "waiting", id: SignalRecord.awaited.select(:execution_id)))
|
|
39
|
+
ready.pluck(:id)
|
|
20
40
|
end
|
|
21
41
|
end
|
|
22
42
|
end
|
|
@@ -57,6 +57,7 @@ module ActiveDurable
|
|
|
57
57
|
end.unshift(discovery)
|
|
58
58
|
end
|
|
59
59
|
|
|
60
|
+
# @api private
|
|
60
61
|
def crash_at(recipe, input, hit_number, signals)
|
|
61
62
|
id = start_quietly(recipe, input)
|
|
62
63
|
hits = 0
|
|
@@ -72,6 +73,9 @@ module ActiveDurable
|
|
|
72
73
|
drain(id, signals: signals)
|
|
73
74
|
end
|
|
74
75
|
|
|
76
|
+
# Starts an execution without enqueuing its job, so a test drives it with {drain}.
|
|
77
|
+
#
|
|
78
|
+
# @return [String] the execution id
|
|
75
79
|
def start_quietly(recipe, input)
|
|
76
80
|
previous = ActiveDurable.enqueue_disabled
|
|
77
81
|
ActiveDurable.enqueue_disabled = true
|
|
@@ -80,6 +84,7 @@ module ActiveDurable
|
|
|
80
84
|
ActiveDurable.enqueue_disabled = previous
|
|
81
85
|
end
|
|
82
86
|
|
|
87
|
+
# @api private
|
|
83
88
|
def with_crash_hook(hook)
|
|
84
89
|
previous = ActiveDurable.crash_hook
|
|
85
90
|
ActiveDurable.crash_hook = hook
|
|
@@ -89,6 +94,8 @@ module ActiveDurable
|
|
|
89
94
|
end
|
|
90
95
|
|
|
91
96
|
# Decides what has to happen before the next run. Returns false when nothing can move the execution.
|
|
97
|
+
#
|
|
98
|
+
# @api private
|
|
92
99
|
def ready_for_next_run?(execution, signals)
|
|
93
100
|
now = ActiveDurable.now
|
|
94
101
|
if execution.locked_until && execution.locked_until > now
|
|
@@ -101,8 +108,9 @@ module ActiveDurable
|
|
|
101
108
|
true
|
|
102
109
|
end
|
|
103
110
|
|
|
111
|
+
# @api private
|
|
104
112
|
def waiting_can_move?(execution, signals, now)
|
|
105
|
-
return true if SignalRecord.
|
|
113
|
+
return true if SignalRecord.awaited.exists?(execution_id: execution.id)
|
|
106
114
|
|
|
107
115
|
waiting = execution.steps.find_by(status: "waiting")
|
|
108
116
|
if waiting && signals.key?(waiting.name)
|
data/lib/active_durable.rb
CHANGED
|
@@ -22,9 +22,27 @@ require_relative "active_durable/parallel"
|
|
|
22
22
|
require_relative "active_durable/flow_parallel"
|
|
23
23
|
require_relative "active_durable/runner"
|
|
24
24
|
require_relative "active_durable/sweeper"
|
|
25
|
+
require_relative "active_durable/pruner"
|
|
25
26
|
require_relative "active_durable/operations"
|
|
26
27
|
|
|
27
|
-
# Durable sagas for Rails
|
|
28
|
+
# Durable sagas for Rails: finish the work or undo it in order, even if the server dies.
|
|
29
|
+
#
|
|
30
|
+
# Define a recipe with {define}, start it with {start}, and operate it with {retry}, {compensate}, {rerun} and
|
|
31
|
+
# {prune}. Inside a recipe, every call goes through a {Flow}. `Durable` is a short alias of this module.
|
|
32
|
+
#
|
|
33
|
+
# @example
|
|
34
|
+
# CheckoutSaga = Durable.define(:checkout) do |flow, order_id:|
|
|
35
|
+
# order = Order.find(order_id)
|
|
36
|
+
# flow.transaction(:reserve_stock, undo: -> { order.release_stock! }) { order.reserve_stock! }
|
|
37
|
+
# flow.step(:charge, undo: ->(charge, ticket) { Payments.refund(charge, ticket) }) do |ticket|
|
|
38
|
+
# Payments.charge(order, ticket)
|
|
39
|
+
# end
|
|
40
|
+
# end
|
|
41
|
+
#
|
|
42
|
+
# Order.transaction do
|
|
43
|
+
# order = Order.create!(order_params)
|
|
44
|
+
# Durable.start(:checkout, id: "checkout-#{order.id}", order_id: order.id)
|
|
45
|
+
# end
|
|
28
46
|
module ActiveDurable
|
|
29
47
|
# Loaded on first use: defining them at require time would load ActiveRecord::Base and
|
|
30
48
|
# ActiveJob::Base before a Rails app has applied its configuration.
|
|
@@ -34,93 +52,196 @@ module ActiveDurable
|
|
|
34
52
|
autoload :SignalRecord, File.expand_path("active_durable/signal_record", __dir__)
|
|
35
53
|
autoload :RunJob, File.expand_path("active_durable/run_job", __dir__)
|
|
36
54
|
autoload :SweepJob, File.expand_path("active_durable/sweep_job", __dir__)
|
|
55
|
+
autoload :PruneJob, File.expand_path("active_durable/prune_job", __dir__)
|
|
37
56
|
|
|
38
57
|
class << self
|
|
58
|
+
# @api private
|
|
39
59
|
attr_writer :time_offset
|
|
60
|
+
# @api private
|
|
40
61
|
attr_accessor :crash_hook, :enqueue_disabled
|
|
41
62
|
|
|
63
|
+
# The settings of the whole gem.
|
|
64
|
+
#
|
|
65
|
+
# @return [Configuration]
|
|
42
66
|
def config
|
|
43
67
|
@config ||= Configuration.new
|
|
44
68
|
end
|
|
45
69
|
|
|
70
|
+
# Changes the settings, usually in config/initializers/active_durable.rb.
|
|
71
|
+
#
|
|
72
|
+
# @yieldparam config [Configuration]
|
|
73
|
+
# @return [void]
|
|
74
|
+
# @example
|
|
75
|
+
# ActiveDurable.configure do |config|
|
|
76
|
+
# config.queue_name = :sagas
|
|
77
|
+
# config.lease_duration = 10.minutes
|
|
78
|
+
# end
|
|
46
79
|
def configure
|
|
47
80
|
yield config
|
|
48
81
|
end
|
|
49
82
|
|
|
83
|
+
# @api private
|
|
50
84
|
def registry
|
|
51
85
|
@registry ||= Registry.new
|
|
52
86
|
end
|
|
53
87
|
|
|
54
|
-
# Defines a recipe. Assign the result to a constant (CheckoutSaga = Durable.define(:checkout)
|
|
55
|
-
# so Rails can autoload it in any process.
|
|
88
|
+
# Defines a recipe: the steps of a saga. Assign the result to a constant (CheckoutSaga = Durable.define(:checkout)
|
|
89
|
+
# { ... }) in app/sagas/checkout_saga.rb, so Rails can autoload it in any process.
|
|
90
|
+
#
|
|
91
|
+
# To change a recipe while executions are in flight, keep the old block and add a new version: new executions
|
|
92
|
+
# use the highest version, and every execution keeps running the version it started with.
|
|
56
93
|
#
|
|
57
|
-
#
|
|
58
|
-
#
|
|
94
|
+
# @param name [Symbol, String] the recipe name, used by {start}
|
|
95
|
+
# @param version [Integer] the recipe version
|
|
96
|
+
# @yieldparam flow [Flow] the object every step goes through
|
|
97
|
+
# @yieldparam input [Hash] the keyword arguments given to {start}, with string keys turned into keywords
|
|
98
|
+
# @return [Recipe] assign it to a constant
|
|
99
|
+
# @raise [ArgumentError] without a block or a name
|
|
59
100
|
def define(name, version: 1, &)
|
|
60
101
|
registry.define(name, version: version, &)
|
|
61
102
|
end
|
|
62
103
|
|
|
63
|
-
#
|
|
64
|
-
#
|
|
104
|
+
# Executions that are not finished yet (active or blocked) per recipe version. A version can be deleted once it
|
|
105
|
+
# no longer appears here. `bin/rails active_durable:versions` prints it.
|
|
106
|
+
#
|
|
107
|
+
# @return [Hash{Array(String, Integer) => Integer}] for example { ["checkout", 1] => 12, ["checkout", 2] => 340 }
|
|
65
108
|
def versions_in_use
|
|
66
109
|
Execution.where(status: Execution::ACTIVE + %w[blocked]).group(:recipe, :recipe_version).count
|
|
67
110
|
.transform_keys { |(recipe, version)| [recipe, version.to_i] }
|
|
68
111
|
end
|
|
69
112
|
|
|
70
|
-
# Starts a saga. Call it inside the transaction that creates your record: the saga row is committed
|
|
71
|
-
#
|
|
113
|
+
# Starts a saga. Call it inside the transaction that creates your record: the saga row is committed with it,
|
|
114
|
+
# and its job is enqueued only after the commit, so a crash in between cannot lose it.
|
|
115
|
+
#
|
|
116
|
+
# @param name [Symbol, String] a recipe given to {define}
|
|
117
|
+
# @param id [String, nil] the execution id. With one, the call is idempotent: starting the same id twice returns
|
|
118
|
+
# the first execution. Without one, a random id is used.
|
|
119
|
+
# @param input [Hash] keyword arguments for the recipe; stored as JSON
|
|
120
|
+
# @return [Execution]
|
|
121
|
+
# @raise [UnknownRecipe] if no recipe has that name
|
|
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
|
|
124
|
+
# @raise [NotSerializable] if the input cannot be stored as JSON
|
|
125
|
+
# @example
|
|
126
|
+
# Durable.start(:checkout, id: "checkout-#{order.id}", order_id: order.id)
|
|
72
127
|
def start(name, id: nil, **input)
|
|
73
128
|
recipe = registry.latest(name)
|
|
74
129
|
attributes = { recipe: recipe.name, recipe_version: recipe.version, status: "pending",
|
|
75
130
|
input: Serializer.normalize(input, "input") }
|
|
76
131
|
return Execution.create!(attributes.merge(id: "#{recipe.name}-#{SecureRandom.uuid}")) if id.nil?
|
|
77
132
|
|
|
78
|
-
|
|
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
|
|
79
141
|
return execution if execution.recipe == recipe.name
|
|
80
142
|
|
|
81
143
|
raise ArgumentError, "execution #{id} already exists for recipe :#{execution.recipe}"
|
|
82
144
|
end
|
|
83
145
|
|
|
84
|
-
#
|
|
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
|
+
|
|
158
|
+
# Delivers a signal to a saga waiting, now or later, in flow.wait_for(name). A signal that arrives before the
|
|
159
|
+
# saga waits is kept until it does, also while the saga is blocked.
|
|
160
|
+
#
|
|
161
|
+
# @param execution_id [String]
|
|
162
|
+
# @param name [Symbol, String] the name given to {Flow#wait_for}
|
|
163
|
+
# @param payload [Object] what wait_for returns; stored as JSON
|
|
164
|
+
# @return [void]
|
|
165
|
+
# @raise [ActiveRecord::RecordNotFound] if there is no such execution
|
|
166
|
+
# @raise [Error] if the execution already finished: completed, compensated or superseded
|
|
167
|
+
# @example In a webhook controller
|
|
168
|
+
# Durable.signal("loan-42", :kyc_done, verified: true)
|
|
85
169
|
def signal(execution_id, name, payload = nil)
|
|
86
170
|
execution = Execution.find(execution_id)
|
|
87
|
-
raise Error, "execution #{execution_id} already finished (#{execution.status})" if execution.
|
|
171
|
+
raise Error, "execution #{execution_id} already finished (#{execution.status})" if execution.finished?
|
|
88
172
|
|
|
89
173
|
SignalRecord.create!(execution_id: execution.id, name: name.to_s,
|
|
90
174
|
payload: Serializer.normalize(payload, "signal payload"))
|
|
91
175
|
end
|
|
92
176
|
|
|
177
|
+
# @param execution_id [String]
|
|
178
|
+
# @return [Execution]
|
|
179
|
+
# @raise [ActiveRecord::RecordNotFound]
|
|
93
180
|
def find(execution_id)
|
|
94
181
|
Execution.find(execution_id)
|
|
95
182
|
end
|
|
96
183
|
|
|
97
|
-
#
|
|
184
|
+
# Resumes a blocked execution where it stopped: failed steps (or failed undos, if it was undoing) get a fresh
|
|
185
|
+
# set of attempts. Use it after fixing what blocked it, such as a bug in the recipe or a failing hook.
|
|
186
|
+
#
|
|
187
|
+
# @param execution_id [String]
|
|
188
|
+
# @return [Execution]
|
|
189
|
+
# @raise [Error] if the execution is not blocked, or a worker is running it right now
|
|
98
190
|
def retry(execution_id)
|
|
99
191
|
Operations.retry(execution_id)
|
|
100
192
|
end
|
|
101
193
|
|
|
194
|
+
# Undoes every finished step of an execution that has not passed its point of no return, last one first, and
|
|
195
|
+
# then runs its flow.on(:compensated) hook.
|
|
196
|
+
#
|
|
197
|
+
# @param execution_id [String]
|
|
198
|
+
# @param reason [String] recorded as the execution's error
|
|
199
|
+
# @return [Execution]
|
|
200
|
+
# @raise [Error] if it already passed flow.pivot, is already undoing, or a worker is running it right now
|
|
102
201
|
def compensate(execution_id, reason: "compensated by an operator")
|
|
103
202
|
Operations.compensate(execution_id, reason: reason)
|
|
104
203
|
end
|
|
105
204
|
|
|
205
|
+
# Starts a new execution that reuses the original's steps before `from` and runs `from` and the following ones
|
|
206
|
+
# again, with new tickets (so they have effects again). A blocked original becomes superseded.
|
|
207
|
+
#
|
|
208
|
+
# @param execution_id [String]
|
|
209
|
+
# @param from [Symbol, String] a step in the original's notebook
|
|
210
|
+
# @return [Execution] the new execution
|
|
211
|
+
# @raise [Error] if the original has no such step, is active, or a worker is running it right now
|
|
106
212
|
def rerun(execution_id, from:)
|
|
107
213
|
Operations.rerun(execution_id, from: from)
|
|
108
214
|
end
|
|
109
215
|
|
|
216
|
+
# Deletes finished executions (completed, compensated, superseded) older than `older_than`, with their notebook
|
|
217
|
+
# and signals. Active and blocked executions are never deleted. {PruneJob} calls it with the default.
|
|
218
|
+
#
|
|
219
|
+
# @param older_than [ActiveSupport::Duration, Numeric] seconds; config.keep_finished_for by default
|
|
220
|
+
# @return [Integer] how many executions were deleted
|
|
221
|
+
# @raise [ArgumentError] if older_than is nil
|
|
222
|
+
def prune(older_than: config.keep_finished_for)
|
|
223
|
+
Pruner.call(older_than: older_than)
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# @api private
|
|
110
227
|
def now
|
|
111
228
|
config.clock.call + time_offset
|
|
112
229
|
end
|
|
113
230
|
|
|
231
|
+
# @api private
|
|
114
232
|
def time_offset
|
|
115
233
|
@time_offset ||= 0.0
|
|
116
234
|
end
|
|
117
235
|
|
|
118
236
|
# Objects with #capture (called in the worker thread) and #wrap(captured) { } (called around each
|
|
119
237
|
# flow.parallel branch thread). Used to carry context such as OpenTelemetry spans into the branches.
|
|
238
|
+
#
|
|
239
|
+
# @api private
|
|
120
240
|
def branch_wrappers
|
|
121
241
|
@branch_wrappers ||= []
|
|
122
242
|
end
|
|
123
243
|
|
|
244
|
+
# @api private
|
|
124
245
|
def enqueue(execution_id, wait_until: nil)
|
|
125
246
|
return if enqueue_disabled
|
|
126
247
|
|
|
@@ -128,11 +249,14 @@ module ActiveDurable
|
|
|
128
249
|
job.perform_later(execution_id)
|
|
129
250
|
end
|
|
130
251
|
|
|
252
|
+
# @api private
|
|
131
253
|
def instrument(event, payload = {}, &)
|
|
132
254
|
ActiveSupport::Notifications.instrument("#{event}.active_durable", payload, &)
|
|
133
255
|
end
|
|
134
256
|
|
|
135
257
|
# Hook used by ActiveDurable::Testing to simulate crashes at precise points.
|
|
258
|
+
#
|
|
259
|
+
# @api private
|
|
136
260
|
def crash_point(kind, name)
|
|
137
261
|
crash_hook&.call(kind, name)
|
|
138
262
|
end
|
|
@@ -4,6 +4,7 @@ require "rails/generators"
|
|
|
4
4
|
require "rails/generators/active_record"
|
|
5
5
|
|
|
6
6
|
module ActiveDurable
|
|
7
|
+
# Rails generators: `active_durable:install` for new apps, `active_durable:upgrade` after updating the gem.
|
|
7
8
|
module Generators
|
|
8
9
|
# rails generate active_durable:install
|
|
9
10
|
class InstallGenerator < Rails::Generators::Base
|
|
@@ -12,6 +13,7 @@ module ActiveDurable
|
|
|
12
13
|
source_root File.expand_path("templates", __dir__)
|
|
13
14
|
desc "Creates the migration for ActiveDurable's tables (sagas, notebook and signals)."
|
|
14
15
|
|
|
16
|
+
# @api private
|
|
15
17
|
def create_migration_file
|
|
16
18
|
migration_template "create_active_durable_tables.rb.tt", "db/migrate/create_active_durable_tables.rb",
|
|
17
19
|
migration_version: migration_version
|
|
@@ -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,17 +15,18 @@ 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]
|
|
22
|
+
add_index :durable_executions, %i[status updated_at]
|
|
22
23
|
add_index :durable_executions, %i[recipe status]
|
|
23
24
|
add_index :durable_executions, :forked_from
|
|
24
25
|
|
|
25
26
|
# The notebook: one row per step (and per undo) of an execution.
|
|
26
27
|
create_table :durable_steps do |t|
|
|
27
|
-
t.string :execution_id, null: false
|
|
28
|
-
t.string :name, null: false
|
|
28
|
+
t.string :execution_id, null: false, **exact
|
|
29
|
+
t.string :name, null: false, **exact
|
|
29
30
|
t.string :kind, null: false
|
|
30
31
|
t.integer :position
|
|
31
32
|
t.string :status, null: false
|
|
@@ -41,8 +42,8 @@ class CreateActiveDurableTables < ActiveRecord::Migration<%= migration_version %
|
|
|
41
42
|
|
|
42
43
|
# Signals for flow.wait_for. They can arrive before the saga starts waiting.
|
|
43
44
|
create_table :durable_signals do |t|
|
|
44
|
-
t.string :execution_id, null: false
|
|
45
|
-
t.string :name, null: false
|
|
45
|
+
t.string :execution_id, null: false, **exact
|
|
46
|
+
t.string :name, null: false, **exact
|
|
46
47
|
t.column :payload, json_type
|
|
47
48
|
t.datetime :consumed_at, precision: 6
|
|
48
49
|
t.datetime :created_at, null: false, precision: 6
|
|
@@ -57,4 +58,9 @@ class CreateActiveDurableTables < ActiveRecord::Migration<%= migration_version %
|
|
|
57
58
|
def json_type
|
|
58
59
|
connection.adapter_name.downcase.include?("postg") ? :jsonb : :json
|
|
59
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
|
|
60
66
|
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# ActiveDurable 0.6: finds finished executions to prune, and quiet ones to sweep, without scanning the table.
|
|
4
|
+
class AddActiveDurablePruneIndex < ActiveRecord::Migration<%= migration_version %>
|
|
5
|
+
def up
|
|
6
|
+
add_index :durable_executions, %i[status updated_at] unless index_exists?(:durable_executions, %i[status updated_at])
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def down
|
|
10
|
+
return unless index_exists?(:durable_executions, %i[status updated_at])
|
|
11
|
+
|
|
12
|
+
remove_index :durable_executions, column: %i[status updated_at]
|
|
13
|
+
end
|
|
14
|
+
end
|
data/lib/generators/active_durable/upgrade/templates/make_active_durable_ids_case_sensitive.rb.tt
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# ActiveDurable 0.7: MySQL compared ids and step names ignoring case and accents, so "order-abc" and "order-ÁBC"
|
|
4
|
+
# shared one saga. These columns now compare byte by byte, as on PostgreSQL and SQLite. Elsewhere it does nothing.
|
|
5
|
+
#
|
|
6
|
+
# On MySQL it rebuilds the three tables and blocks writes to them while it runs: run it at a quiet time, or prune
|
|
7
|
+
# first. MySQL cannot roll DDL back, so if it stops halfway, run it again: it finishes the columns and puts the
|
|
8
|
+
# foreign keys back.
|
|
9
|
+
class MakeActiveDurableIdsCaseSensitive < ActiveRecord::Migration<%= migration_version %>
|
|
10
|
+
COLUMNS = {
|
|
11
|
+
durable_executions: %w[id forked_from],
|
|
12
|
+
durable_steps: %w[execution_id name],
|
|
13
|
+
durable_signals: %w[execution_id name]
|
|
14
|
+
}.freeze
|
|
15
|
+
|
|
16
|
+
def up
|
|
17
|
+
change_collation { |_table| "utf8mb4_bin" }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def down
|
|
21
|
+
change_collation { |table| table_collation(table) }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def change_collation
|
|
27
|
+
return unless connection.adapter_name.match?(/mysql|trilogy/i)
|
|
28
|
+
|
|
29
|
+
changes = COLUMNS.flat_map do |table, names|
|
|
30
|
+
wanted = yield(table)
|
|
31
|
+
connection.columns(table).select { |column| names.include?(column.name) && column.collation != wanted }
|
|
32
|
+
.map { |column| [table, column, wanted] }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Both ends of a foreign key must share a collation, so the keys come off while the columns change.
|
|
36
|
+
on_delete = remove_foreign_keys if changes.any?
|
|
37
|
+
changes.each do |table, column, collation|
|
|
38
|
+
change_column table, column.name, :string, limit: column.limit, null: column.null, collation: collation
|
|
39
|
+
end
|
|
40
|
+
restore_foreign_keys(on_delete || {})
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
FOREIGN_KEY_TABLES = %i[durable_steps durable_signals].freeze
|
|
44
|
+
|
|
45
|
+
def foreign_key(table)
|
|
46
|
+
connection.foreign_keys(table).find { |key| key.column == "execution_id" }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def remove_foreign_keys
|
|
50
|
+
FOREIGN_KEY_TABLES.each_with_object({}) do |table, on_delete|
|
|
51
|
+
key = foreign_key(table)
|
|
52
|
+
next unless key
|
|
53
|
+
|
|
54
|
+
on_delete[table] = key.options[:on_delete]
|
|
55
|
+
remove_foreign_key table, column: :execution_id
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Also puts back keys that an earlier run, stopped halfway, left off.
|
|
60
|
+
def restore_foreign_keys(on_delete)
|
|
61
|
+
FOREIGN_KEY_TABLES.each do |table|
|
|
62
|
+
next if foreign_key(table)
|
|
63
|
+
|
|
64
|
+
add_foreign_key table, :durable_executions, column: :execution_id, on_delete: on_delete.fetch(table, :cascade)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def table_collation(table)
|
|
69
|
+
select_value("SELECT TABLE_COLLATION FROM information_schema.TABLES " \
|
|
70
|
+
"WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = #{connection.quote(table.to_s)}")
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
require "rails/generators/active_record"
|
|
5
|
+
|
|
6
|
+
module ActiveDurable
|
|
7
|
+
module Generators
|
|
8
|
+
# rails generate active_durable:upgrade
|
|
9
|
+
#
|
|
10
|
+
# Adds the migrations a newer ActiveDurable needs to an app that installed an older one. It skips the ones the
|
|
11
|
+
# app already has, and each migration checks the database first, so running it twice changes nothing.
|
|
12
|
+
class UpgradeGenerator < Rails::Generators::Base
|
|
13
|
+
include ActiveRecord::Generators::Migration
|
|
14
|
+
|
|
15
|
+
source_root File.expand_path("templates", __dir__)
|
|
16
|
+
desc "Adds the migrations that newer ActiveDurable versions need. Run it after updating the gem."
|
|
17
|
+
|
|
18
|
+
# In the order they were released. Never edit one that shipped: add a new one.
|
|
19
|
+
MIGRATIONS = %w[add_active_durable_prune_index make_active_durable_ids_case_sensitive].freeze
|
|
20
|
+
|
|
21
|
+
# @api private
|
|
22
|
+
def create_migration_files
|
|
23
|
+
MIGRATIONS.each do |name|
|
|
24
|
+
if self.class.migration_exists?(File.join(destination_root, "db/migrate"), name)
|
|
25
|
+
say_status :skip, "db/migrate/*_#{name}.rb (already there)", :yellow
|
|
26
|
+
else
|
|
27
|
+
migration_template "#{name}.rb.tt", "db/migrate/#{name}.rb", migration_version: migration_version
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def migration_version
|
|
35
|
+
"[#{ActiveRecord::Migration.current_version}]"
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -16,9 +16,20 @@ namespace :active_durable do
|
|
|
16
16
|
end
|
|
17
17
|
end
|
|
18
18
|
|
|
19
|
+
desc "Delete finished executions older than config.keep_finished_for, with their notebook (run it daily)"
|
|
20
|
+
task prune: :environment do
|
|
21
|
+
days = (ActiveDurable.config.keep_finished_for.to_f / 86_400).round(2)
|
|
22
|
+
days = days.to_i if days == days.to_i
|
|
23
|
+
puts "ActiveDurable: deleted #{ActiveDurable.prune} finished execution(s) older than #{days} days"
|
|
24
|
+
end
|
|
25
|
+
|
|
19
26
|
desc "Enqueue executions that should be running but have no job (run it every minute)"
|
|
20
27
|
task sweep: :environment do
|
|
21
|
-
ids = ActiveDurable::Sweeper.
|
|
22
|
-
|
|
28
|
+
ids, how = ActiveDurable::Sweeper.call_from_task
|
|
29
|
+
if how == :ran
|
|
30
|
+
puts "ActiveDurable: ran #{ids.size} execution(s) in this process (the :async adapter would lose their jobs)"
|
|
31
|
+
else
|
|
32
|
+
puts "ActiveDurable: enqueued #{ids.size} execution(s)"
|
|
33
|
+
end
|
|
23
34
|
end
|
|
24
35
|
end
|