active_durable 0.5.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 +7 -0
  2. data/CHANGELOG.md +131 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +630 -0
  5. data/Rakefile +12 -0
  6. data/app/controllers/active_durable/application_controller.rb +25 -0
  7. data/app/controllers/active_durable/executions_controller.rb +59 -0
  8. data/app/helpers/active_durable/dashboard_helper.rb +163 -0
  9. data/app/views/active_durable/executions/index.html.erb +90 -0
  10. data/app/views/active_durable/executions/show.html.erb +197 -0
  11. data/app/views/layouts/active_durable/application.html.erb +422 -0
  12. data/config/routes.rb +13 -0
  13. data/lib/active_durable/configuration.rb +59 -0
  14. data/lib/active_durable/engine.rb +24 -0
  15. data/lib/active_durable/errors.rb +61 -0
  16. data/lib/active_durable/execution.rb +43 -0
  17. data/lib/active_durable/flow.rb +277 -0
  18. data/lib/active_durable/flow_parallel.rb +200 -0
  19. data/lib/active_durable/lease.rb +50 -0
  20. data/lib/active_durable/notebook.rb +100 -0
  21. data/lib/active_durable/open_telemetry.rb +94 -0
  22. data/lib/active_durable/operations.rb +111 -0
  23. data/lib/active_durable/parallel.rb +54 -0
  24. data/lib/active_durable/record.rb +13 -0
  25. data/lib/active_durable/registry.rb +89 -0
  26. data/lib/active_durable/retry_policy.rb +42 -0
  27. data/lib/active_durable/run_job.rb +12 -0
  28. data/lib/active_durable/runner.rb +157 -0
  29. data/lib/active_durable/serializer.rb +40 -0
  30. data/lib/active_durable/signal_record.rb +20 -0
  31. data/lib/active_durable/step.rb +34 -0
  32. data/lib/active_durable/sweep_job.rb +12 -0
  33. data/lib/active_durable/sweeper.rb +22 -0
  34. data/lib/active_durable/testing.rb +118 -0
  35. data/lib/active_durable/version.rb +5 -0
  36. data/lib/active_durable.rb +145 -0
  37. data/lib/generators/active_durable/install/install_generator.rb +27 -0
  38. data/lib/generators/active_durable/install/templates/create_active_durable_tables.rb.tt +60 -0
  39. data/lib/tasks/active_durable.rake +24 -0
  40. data/sig/active_durable.rbs +4 -0
  41. metadata +134 -0
@@ -0,0 +1,277 @@
1
+ # frozen_string_literal: true
2
+
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.
6
+ #
7
+ # 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) { ... }
12
+ # end
13
+ class Flow
14
+ UndoEntry = Struct.new(:name, :kind, :result, :undo)
15
+ STEP_OPTIONS = %i[retry undo_on_failure].freeze
16
+
17
+ attr_reader :undo_stack
18
+
19
+ def initialize(runner, compensating:)
20
+ @runner = runner
21
+ @notebook = runner.notebook
22
+ @execution = runner.execution
23
+ @compensating = compensating
24
+ @pivoted = false
25
+ @position = 0
26
+ @seen = {}
27
+ @undo_stack = []
28
+ end
29
+
30
+ def execution_id
31
+ @execution.id
32
+ end
33
+
34
+ # The ticket (idempotency key) a step receives. It is the same every time the step runs.
35
+ def ticket_for(name)
36
+ "#{execution_id}:#{name}"
37
+ end
38
+
39
+ def compensating?
40
+ @compensating
41
+ end
42
+
43
+ def compensating!
44
+ @compensating = true
45
+ end
46
+
47
+ def pivoted?
48
+ @pivoted
49
+ end
50
+
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.
53
+ def step(name, undo: nil, **options, &block)
54
+ run_step(name, "step", undo, options, block)
55
+ end
56
+
57
+ # A step that only touches your own database. It runs in the same transaction that records it,
58
+ # so it happens exactly once.
59
+ def transaction(name, undo: nil, **options, &block)
60
+ run_step(name, "transaction", undo, options, block)
61
+ end
62
+
63
+ # The point of no return. Before it, failures are compensated; after it, steps are retried.
64
+ def pivot(name, **options, &block)
65
+ run_step(name, "pivot", nil, options, block)
66
+ end
67
+
68
+ # Rejects the saga for a business reason: no retries, straight to compensation.
69
+ def abort!(message)
70
+ raise Abort, message
71
+ end
72
+
73
+ # Waits without holding a worker: the wake-up time is written down and the execution is released.
74
+ def sleep(name, duration)
75
+ name, position = visit!(name, "sleep")
76
+ entry = @notebook[name]
77
+ if entry.nil?
78
+ raise StopForward, name if compensating?
79
+
80
+ wake_at = now + duration
81
+ @notebook.complete!(name, kind: "sleep", position: position,
82
+ result: { "wake_at" => wake_at.utc.iso8601(6) })
83
+ @runner.suspend!(wake_at, "sleeping")
84
+ end
85
+
86
+ wake_at = Time.iso8601(entry.result.fetch("wake_at"))
87
+ return nil if now >= wake_at
88
+ raise StopForward, name if compensating?
89
+
90
+ @runner.suspend!(wake_at, "sleeping")
91
+ end
92
+
93
+ # Waits for Durable.signal(execution_id, name, payload) and returns the payload.
94
+ def wait_for(name, timeout: nil)
95
+ name, position = visit!(name, "wait")
96
+ entry = @notebook[name]
97
+ return entry.result.deep_dup if entry&.completed?
98
+ raise StepFailed.new(name, entry.error&.fetch("message", nil)) if entry&.failed?
99
+ raise StopForward, name if compensating?
100
+
101
+ signal = SignalRecord.next_for(execution_id, name)
102
+ return consume_signal(signal, name, position).deep_dup if signal
103
+
104
+ if entry.nil?
105
+ deadline = timeout && (now + timeout)
106
+ @notebook.wait!(name, kind: "wait", position: position, wake_at: deadline)
107
+ @runner.suspend!(deadline, "waiting")
108
+ end
109
+
110
+ if entry.wake_at && now >= entry.wake_at
111
+ error = WaitTimeout.new("no :#{name} signal arrived before #{entry.wake_at.utc.iso8601}")
112
+ @notebook.fail!(name, kind: "wait", position: position, attempts: 1,
113
+ error: ActiveDurable.dump_error(error, step: name))
114
+ raise StepFailed.new(name, error)
115
+ end
116
+
117
+ @runner.suspend!(entry.wake_at, "waiting")
118
+ end
119
+
120
+ # Called when the recipe returns: every step the notebook knows about must have been reached.
121
+ def finish!
122
+ return if compensating?
123
+
124
+ missing = @notebook.forward_entries.reject { |entry| @seen.key?(entry.name) }
125
+ return if missing.empty?
126
+
127
+ names = missing.map { |entry| ":#{entry.name}" }.join(", ")
128
+ raise RecipeChanged, "execution #{execution_id} recorded #{names}, but the recipe no longer reaches " \
129
+ "#{missing.size == 1 ? "it" : "them"}. #{RECIPE_CHANGED_HINT}"
130
+ end
131
+
132
+ RECIPE_CHANGED_HINT = "Either the recipe changed while this execution was in flight, or code outside a " \
133
+ "step read data that changed between runs. Keep reads that decide the path inside " \
134
+ "steps, or define a new recipe version."
135
+
136
+ private
137
+
138
+ def now
139
+ ActiveDurable.now
140
+ end
141
+
142
+ def config
143
+ ActiveDurable.config
144
+ end
145
+
146
+ def run_step(name, kind, undo, options, block)
147
+ raise InvalidRecipe, "flow.#{kind} :#{name} needs a block" unless block
148
+
149
+ unknown = options.keys - STEP_OPTIONS
150
+ raise InvalidRecipe, "unknown option(s) for flow.#{kind}: #{unknown.join(", ")}" if unknown.any?
151
+
152
+ name, position = visit!(name, kind)
153
+ check_undo!(name, kind, undo, options)
154
+
155
+ entry = @notebook[name]
156
+ return remember(name, kind, entry.result, undo) if entry&.completed?
157
+
158
+ if entry&.failed?
159
+ remember_failure(name, kind, undo, options)
160
+ raise StepFailed.new(name, entry.error&.fetch("message", nil))
161
+ end
162
+ raise StopForward, name if compensating?
163
+
164
+ @runner.suspend!(entry.wake_at, "sleeping") if entry&.retrying? && entry.wake_at && entry.wake_at > now
165
+
166
+ result = execute(name, kind, position, entry, undo, options, block)
167
+ remember(name, kind, result, undo)
168
+ end
169
+
170
+ def execute(name, kind, position, entry, undo, options, block)
171
+ ticket = ticket_for(name)
172
+ ActiveDurable.crash_point(:before_step, name)
173
+ result = ActiveDurable.instrument("step", execution_id: execution_id, step: name, kind: kind) do
174
+ if kind == "transaction"
175
+ @notebook.transaction { record_result(name, kind, position, block.call(ticket)) }
176
+ else
177
+ record_result(name, kind, position, block.call(ticket))
178
+ end
179
+ end
180
+ ActiveDurable.crash_point(:after_record, name)
181
+ result
182
+ rescue NotSerializable, InvalidRecipe
183
+ raise
184
+ rescue StandardError => e
185
+ handle_failure(name, kind, position, entry, undo, options, e)
186
+ end
187
+
188
+ def record_result(name, kind, position, value)
189
+ result = Serializer.normalize(value, "the result of :#{name}")
190
+ ActiveDurable.crash_point(:after_call, name)
191
+ @notebook.complete!(name, kind: kind, position: position, result: result)
192
+ result
193
+ end
194
+
195
+ def handle_failure(name, kind, position, entry, undo, options, error)
196
+ attempts = (entry&.attempts || 0) + 1
197
+ default = @pivoted ? config.after_pivot_attempts : config.step_attempts
198
+ policy = RetryPolicy.build(options[:retry], default_attempts: default)
199
+ dumped = ActiveDurable.dump_error(error, step: name)
200
+
201
+ if error.is_a?(Abort) || attempts >= policy.attempts
202
+ @notebook.fail!(name, kind: kind, position: position, attempts: attempts, error: dumped)
203
+ remember_failure(name, kind, undo, options)
204
+ raise StepFailed.new(name, error)
205
+ end
206
+
207
+ wake_at = now + policy.delay(attempts)
208
+ @notebook.retry!(name, kind: kind, position: position, attempts: attempts, wake_at: wake_at, error: dumped)
209
+ @runner.suspend!(wake_at, "sleeping")
210
+ end
211
+
212
+ def remember(name, kind, result, undo)
213
+ @undo_stack << UndoEntry.new(name, kind, result, undo) if undo
214
+ @pivoted = true if kind == "pivot"
215
+ result.deep_dup
216
+ end
217
+
218
+ # A failed step whose outcome may be unknown (a timeout after the charge went through) can ask for
219
+ # its own undo too. It receives nil as the result, plus the ticket to look the outcome up.
220
+ def remember_failure(name, kind, undo, options)
221
+ @undo_stack << UndoEntry.new(name, kind, nil, undo) if undo && options[:undo_on_failure]
222
+ end
223
+
224
+ def check_undo!(name, kind, undo, options)
225
+ if options[:undo_on_failure]
226
+ raise InvalidRecipe, "undo_on_failure: on :#{name} needs an undo:" if undo.nil?
227
+ if kind == "transaction"
228
+ raise InvalidRecipe, "undo_on_failure: makes no sense on flow.transaction :#{name}: a failed " \
229
+ "transaction was rolled back"
230
+ end
231
+ end
232
+ return if undo.nil?
233
+ raise InvalidRecipe, "undo: for :#{name} must respond to #call" unless undo.respond_to?(:call)
234
+ return unless @pivoted
235
+
236
+ raise InvalidRecipe, ":#{name} comes after the point of no return (flow.pivot), so it cannot declare " \
237
+ "undo:. Steps after the pivot are retried, never undone."
238
+ end
239
+
240
+ def visit!(name, kind)
241
+ name = name.to_s
242
+ raise InvalidRecipe, "step names cannot be blank" if name.empty?
243
+ raise InvalidRecipe, "step names cannot end in ':undo' (#{name})" if name.end_with?(":undo")
244
+ if @seen.key?(name)
245
+ raise DuplicateStepName, "the recipe uses the step name :#{name} twice. Each step needs its own name: " \
246
+ "it is the step's key in the notebook."
247
+ end
248
+
249
+ @position += 1
250
+ @seen[name] = @position
251
+ check_recipe!(name, kind, @position)
252
+ [name, @position]
253
+ end
254
+
255
+ def check_recipe!(name, kind, position)
256
+ recorded = @notebook.at_position(position)
257
+ entry = @notebook[name]
258
+ return if recorded.nil? && entry.nil?
259
+ return if recorded && recorded.name == name && recorded.kind == kind
260
+
261
+ expected = recorded ? ":#{recorded.name} (#{recorded.kind})" : "nothing"
262
+ raise RecipeChanged, "execution #{execution_id} recorded #{expected} at position #{position}, but the " \
263
+ "recipe reached :#{name} (#{kind}). #{RECIPE_CHANGED_HINT}"
264
+ end
265
+
266
+ def consume_signal(signal, name, position)
267
+ payload = signal.payload
268
+ @notebook.transaction do
269
+ taken = SignalRecord.where(id: signal.id, consumed_at: nil).update_all(consumed_at: now)
270
+ raise LeaseLost, "signal #{signal.id} for #{execution_id} was already consumed" if taken.zero?
271
+
272
+ @notebook.complete!(name, kind: "wait", position: position, result: payload)
273
+ end
274
+ payload
275
+ end
276
+ end
277
+ end
@@ -0,0 +1,200 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveDurable
4
+ class Flow # rubocop:disable Style/Documentation -- documented in flow.rb
5
+ # flow.parallel: several steps at the same time, each one checkpointed on its own.
6
+ #
7
+ # Branches run in threads (at most config.parallel_concurrency at once), which suits steps that wait on the
8
+ # network. Each branch is a notebook entry named "<parallel>/<branch>", with its own ticket and retries. After
9
+ # a crash only the unfinished branches run again. If a branch runs out of attempts, the saga compensates the
10
+ # branches that completed (last finished, first undone) and every step before the parallel block.
11
+ module Parallel
12
+ def parallel(name, &block)
13
+ raise InvalidRecipe, "flow.parallel :#{name} needs a block" unless block
14
+
15
+ name, position = visit!(name, "parallel")
16
+ group = ParallelGroup.new(name)
17
+ block.call(group)
18
+ branches = group.branches
19
+ raise InvalidRecipe, "flow.parallel :#{name} declared no branches" if branches.empty?
20
+
21
+ prepare_branches!(name, branches)
22
+ entry = @notebook[name]
23
+ return finish_recorded_parallel(name, entry, branches) if entry&.completed? || entry&.failed?
24
+ raise StopForward, name if compensating?
25
+
26
+ run_parallel(name, position, branches)
27
+ end
28
+
29
+ private
30
+
31
+ def prepare_branches!(name, branches)
32
+ branches.each do |branch|
33
+ check_undo!(branch.full_name, branch.kind, branch.undo, branch.options)
34
+ raise DuplicateStepName, "the step name :#{branch.full_name} is used twice" if @seen.key?(branch.full_name)
35
+
36
+ @seen[branch.full_name] = nil
37
+ end
38
+
39
+ recorded = @notebook.branches_of(name).map(&:name)
40
+ missing = recorded - branches.map(&:full_name)
41
+ return if missing.empty?
42
+
43
+ raise RecipeChanged, "flow.parallel :#{name} recorded the branches #{missing.join(", ")}, which the recipe " \
44
+ "no longer declares. #{RECIPE_CHANGED_HINT}"
45
+ end
46
+
47
+ def finish_recorded_parallel(name, entry, branches)
48
+ if entry.completed?
49
+ unexpected = branches.map(&:name) - entry.result.keys
50
+ if unexpected.any?
51
+ raise RecipeChanged, "flow.parallel :#{name} already completed without the branches " \
52
+ "#{unexpected.join(", ")}. #{RECIPE_CHANGED_HINT}"
53
+ end
54
+
55
+ remember_branches(name, branches)
56
+ return entry.result.deep_dup
57
+ end
58
+
59
+ remember_branches(name, branches, include_failed: true)
60
+ raise StepFailed.new(name, entry.error&.fetch("message", nil))
61
+ end
62
+
63
+ def run_parallel(name, position, branches)
64
+ outcomes = branch_outcomes(branches)
65
+ failed = outcomes.select { |_, (state, _)| state == :failed }
66
+ if failed.any?
67
+ full_name, (_, error) = failed.first
68
+ remember_branches(name, branches, include_failed: true)
69
+ @notebook.fail!(name, kind: "parallel", position: position, attempts: 1,
70
+ error: ActiveDurable.dump_error(error, step: full_name))
71
+ raise StepFailed.new(full_name, error)
72
+ end
73
+
74
+ wakes = outcomes.values.filter_map { |state, value| value if state == :retry }
75
+ @runner.suspend!(wakes.min, "sleeping") if wakes.any?
76
+
77
+ results = branches.to_h { |branch| [branch.name, outcomes.fetch(branch.full_name).last] }
78
+ @notebook.complete!(name, kind: "parallel", position: position, result: results)
79
+ remember_branches(name, branches)
80
+ results.deep_dup
81
+ end
82
+
83
+ # { full_name => [:completed, result] | [:retry, wake_at] | [:failed, error] }
84
+ def branch_outcomes(branches)
85
+ outcomes = {}
86
+ runnable = []
87
+ branches.each do |branch|
88
+ entry = @notebook[branch.full_name]
89
+ if entry&.completed? then outcomes[branch.full_name] = [:completed, entry.result]
90
+ elsif entry&.failed? then outcomes[branch.full_name] = [:failed, StepFailed.new(branch.full_name)]
91
+ elsif entry&.retrying? && entry.wake_at && entry.wake_at > now
92
+ outcomes[branch.full_name] = [:retry, entry.wake_at]
93
+ else
94
+ runnable << branch
95
+ end
96
+ end
97
+ runnable.each_slice(config.parallel_concurrency) { |slice| outcomes.merge!(run_threads(slice)) }
98
+ outcomes
99
+ end
100
+
101
+ def run_threads(slice)
102
+ wrappers = ActiveDurable.branch_wrappers.map { |wrapper| [wrapper, wrapper.capture] }
103
+ threads = slice.map do |branch|
104
+ Thread.new do
105
+ Thread.current.report_on_exception = false
106
+ in_branch_context(wrappers) { [branch.full_name, execute_branch(branch)] }
107
+ end
108
+ end
109
+ values = join_all(threads)
110
+ crash = values.find { |value| value.is_a?(Exception) }
111
+ raise crash if crash
112
+
113
+ values.to_h
114
+ end
115
+
116
+ # Waits for every thread, even if one of them blew up, so nothing keeps writing behind our back.
117
+ def join_all(threads)
118
+ join = lambda do
119
+ threads.map do |thread|
120
+ thread.value
121
+ rescue Exception => e # rubocop:disable Lint/RescueException
122
+ e
123
+ end
124
+ end
125
+ if defined?(ActiveSupport::Dependencies) && ActiveSupport::Dependencies.respond_to?(:interlock)
126
+ ActiveSupport::Dependencies.interlock.permit_concurrent_loads(&join)
127
+ else
128
+ join.call
129
+ end
130
+ end
131
+
132
+ def in_branch_context(wrappers, &block)
133
+ run = lambda do
134
+ Record.connection_pool.with_connection do
135
+ wrappers.reverse.inject(block) { |inner, (wrapper, captured)| -> { wrapper.wrap(captured, &inner) } }.call
136
+ end
137
+ end
138
+ if defined?(Rails) && Rails.respond_to?(:application) && Rails.application
139
+ Rails.application.executor.wrap(&run)
140
+ else
141
+ run.call
142
+ end
143
+ end
144
+
145
+ def execute_branch(branch)
146
+ entry = @notebook[branch.full_name]
147
+ ticket = ticket_for(branch.full_name)
148
+ ActiveDurable.crash_point(:before_step, branch.full_name)
149
+ result = ActiveDurable.instrument("step", execution_id: execution_id, step: branch.full_name,
150
+ kind: branch.kind) do
151
+ if branch.kind == "transaction"
152
+ @notebook.transaction { record_result(branch.full_name, branch.kind, nil, branch.block.call(ticket)) }
153
+ else
154
+ record_result(branch.full_name, branch.kind, nil, branch.block.call(ticket))
155
+ end
156
+ end
157
+ ActiveDurable.crash_point(:after_record, branch.full_name)
158
+ [:completed, result]
159
+ rescue NotSerializable, InvalidRecipe
160
+ raise
161
+ rescue StandardError => e
162
+ branch_failure(branch, entry, e)
163
+ end
164
+
165
+ def branch_failure(branch, entry, error)
166
+ attempts = (entry&.attempts || 0) + 1
167
+ default = @pivoted ? config.after_pivot_attempts : config.step_attempts
168
+ policy = RetryPolicy.build(branch.options[:retry], default_attempts: default)
169
+ dumped = ActiveDurable.dump_error(error, step: branch.full_name)
170
+ if error.is_a?(Abort) || attempts >= policy.attempts
171
+ @notebook.fail!(branch.full_name, kind: branch.kind, position: nil, attempts: attempts, error: dumped)
172
+ return [:failed, error]
173
+ end
174
+
175
+ wake_at = now + policy.delay(attempts)
176
+ @notebook.retry!(branch.full_name, kind: branch.kind, position: nil, attempts: attempts, wake_at: wake_at,
177
+ error: dumped)
178
+ [:retry, wake_at]
179
+ end
180
+
181
+ # Registers the undos of the branches that completed, in the order they finished. With include_failed,
182
+ # also those of branches that failed or were still retrying and asked for undo_on_failure.
183
+ def remember_branches(name, branches, include_failed: false)
184
+ by_name = branches.index_by(&:full_name)
185
+ @notebook.branches_of(name).each do |entry|
186
+ branch = by_name[entry.name]
187
+ next unless branch&.undo
188
+
189
+ if entry.completed?
190
+ @undo_stack << UndoEntry.new(entry.name, branch.kind, entry.result, branch.undo)
191
+ elsif include_failed && branch.options[:undo_on_failure] && (entry.failed? || entry.retrying?)
192
+ @undo_stack << UndoEntry.new(entry.name, branch.kind, nil, branch.undo)
193
+ end
194
+ end
195
+ end
196
+ end
197
+
198
+ include Parallel
199
+ end
200
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveDurable
4
+ # Who is allowed to run an execution right now.
5
+ #
6
+ # A worker claims an execution with a conditional UPDATE that only succeeds when nobody holds a live
7
+ # lease. The claim writes a fresh random token. Every later write (notebook entries, status changes)
8
+ # is conditional on that token still being there, so a worker whose lease expired and was taken over
9
+ # cannot write anything else: its writes raise LeaseLost and it stops.
10
+ class Lease
11
+ attr_reader :execution_id, :token
12
+
13
+ def self.claim(execution_id)
14
+ now = ActiveDurable.now
15
+ token = SecureRandom.uuid
16
+ claimed = Execution.where(id: execution_id, status: Execution::ACTIVE)
17
+ .where("locked_until IS NULL OR locked_until < ?", now)
18
+ .update_all(lease_token: token, locked_until: now + duration, status: "running",
19
+ updated_at: now)
20
+ claimed == 1 ? new(execution_id, token) : nil
21
+ end
22
+
23
+ def self.duration
24
+ ActiveDurable.config.lease_duration.to_f
25
+ end
26
+
27
+ def initialize(execution_id, token)
28
+ @execution_id = execution_id
29
+ @token = token
30
+ end
31
+
32
+ # Extends the lease (and optionally changes other columns). Raises LeaseLost if it is no longer ours.
33
+ def renew!(attributes = {})
34
+ update!(attributes.merge(locked_until: ActiveDurable.now + self.class.duration))
35
+ end
36
+
37
+ # Gives the execution back, usually with a new status.
38
+ def release!(attributes = {})
39
+ update!(attributes.merge(locked_until: nil))
40
+ end
41
+
42
+ private
43
+
44
+ def update!(attributes)
45
+ rows = Execution.where(id: execution_id, lease_token: token)
46
+ .update_all(attributes.merge(updated_at: ActiveDurable.now))
47
+ raise LeaseLost, "lost the lease on #{execution_id}: another worker took it over" if rows.zero?
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveDurable
4
+ # The notebook of one execution: every step that ran, what it returned and its state.
5
+ # Loaded once per run; every write is fenced by the lease.
6
+ class Notebook
7
+ def initialize(execution, lease)
8
+ @execution = execution
9
+ @lease = lease
10
+ @mutex = Mutex.new # parallel branches write from several threads
11
+ @entries = Step.where(execution_id: execution.id).order(:id).index_by(&:name)
12
+ @by_position = {}
13
+ @entries.each_value { |entry| @by_position[entry.position] = entry if entry.position }
14
+ end
15
+
16
+ def [](name)
17
+ @mutex.synchronize { @entries[name] }
18
+ end
19
+
20
+ # Completed branch entries of a flow.parallel, in the order they finished.
21
+ def branches_of(parallel_name)
22
+ prefix = "#{parallel_name}/"
23
+ @mutex.synchronize { @entries.values.select { |entry| entry.name.start_with?(prefix) && !entry.undo? } }
24
+ .sort_by { |entry| [entry.updated_at, entry.id] }
25
+ end
26
+
27
+ def at_position(position)
28
+ @by_position[position]
29
+ end
30
+
31
+ def forward_entries
32
+ @mutex.synchronize { @entries.values.reject(&:undo?) }
33
+ end
34
+
35
+ def complete!(name, kind:, position:, result:)
36
+ write!(name, kind: kind, position: position, status: "completed", result: result, error: nil, wake_at: nil)
37
+ end
38
+
39
+ def retry!(name, kind:, position:, attempts:, wake_at:, error:)
40
+ write!(name, kind: kind, position: position, status: "retrying", attempts: attempts, wake_at: wake_at,
41
+ error: error)
42
+ end
43
+
44
+ def fail!(name, kind:, position:, attempts:, error:)
45
+ write!(name, kind: kind, position: position, status: "failed", attempts: attempts, error: error,
46
+ wake_at: nil)
47
+ end
48
+
49
+ def wait!(name, kind:, position:, wake_at:)
50
+ write!(name, kind: kind, position: position, status: "waiting", wake_at: wake_at)
51
+ end
52
+
53
+ # Runs the block in a database transaction and remembers the notebook writes made inside it only once it
54
+ # commits. flow.transaction steps and their undos use it: a step that rolled back must not look completed.
55
+ def transaction(&)
56
+ pending = []
57
+ Thread.current[pending_key] = pending
58
+ result = Record.transaction(&)
59
+ pending.each { |name, entry| remember(name, entry) }
60
+ result
61
+ ensure
62
+ Thread.current[pending_key] = nil
63
+ end
64
+
65
+ private
66
+
67
+ # The database write happens outside the mutex, so a slow transaction in one branch never blocks
68
+ # another branch while it holds the lock (SQLite would deadlock). Existing entries are updated with
69
+ # update_all and read back, so the object other code holds never changes before the write commits.
70
+ def write!(name, **attributes)
71
+ now = ActiveDurable.now
72
+ current = self[name]
73
+ entry = Record.transaction do
74
+ @lease.renew!
75
+ if current
76
+ Step.where(id: current.id).update_all(attributes.merge(updated_at: now))
77
+ Step.find(current.id)
78
+ else
79
+ Step.create!(execution_id: @execution.id, name: name, created_at: now, updated_at: now, **attributes)
80
+ end
81
+ end
82
+ pending = Thread.current[pending_key]
83
+ pending ? pending << [name, entry] : remember(name, entry)
84
+ entry
85
+ rescue ActiveRecord::RecordNotUnique
86
+ raise LeaseLost, "another worker already wrote :#{name} for #{@execution.id}"
87
+ end
88
+
89
+ def remember(name, entry)
90
+ @mutex.synchronize do
91
+ @entries[name] = entry
92
+ @by_position[entry.position] = entry if entry.position
93
+ end
94
+ end
95
+
96
+ def pending_key
97
+ @pending_key ||= :"active_durable_notebook_#{object_id}"
98
+ end
99
+ end
100
+ end