geneva_drive 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/CHANGELOG.md +14 -1
- data/MANUAL.md +449 -11
- data/README.md +5 -1
- data/lib/generators/geneva_drive/install/install_generator.rb +20 -0
- data/lib/generators/geneva_drive/install/templates/add_metadata_to_workflows.rb +17 -0
- data/lib/generators/geneva_drive/install/templates/add_resumable_step_support.rb +29 -0
- data/lib/generators/geneva_drive/install/templates/add_signals_support.rb +94 -0
- data/lib/generators/geneva_drive/install/templates/add_started_at_index_to_step_executions.rb +48 -0
- data/lib/generators/geneva_drive/install/templates/create_workflows_migration.rb +11 -0
- data/lib/generators/geneva_drive/install/templates/initializer.rb.tt +8 -0
- data/lib/geneva_drive/combined_exception_policy.rb +1 -1
- data/lib/geneva_drive/exception_policy.rb +36 -10
- data/lib/geneva_drive/executor.rb +352 -26
- data/lib/geneva_drive/flow_control.rb +87 -7
- data/lib/geneva_drive/iterable_step.rb +199 -0
- data/lib/geneva_drive/job_options.rb +70 -0
- data/lib/geneva_drive/jobs/housekeeping_job.rb +120 -7
- data/lib/geneva_drive/resumable_step_definition.rb +97 -0
- data/lib/geneva_drive/signal.rb +243 -0
- data/lib/geneva_drive/signal_matcher.rb +60 -0
- data/lib/geneva_drive/step_definition.rb +96 -2
- data/lib/geneva_drive/step_execution.rb +141 -2
- data/lib/geneva_drive/test_helpers.rb +212 -6
- data/lib/geneva_drive/version.rb +1 -1
- data/lib/geneva_drive/workflow/metadata_accessor.rb +85 -0
- data/lib/geneva_drive/workflow.rb +559 -43
- data/lib/geneva_drive.rb +37 -0
- data/test/dsl/signal_step_definition_test.rb +132 -0
- data/test/dsl/step_definition_test.rb +70 -0
- data/test/jobs/housekeeping_job_test.rb +119 -0
- data/test/jobs/housekeeping_signals_test.rb +198 -0
- data/test/jobs/perform_step_job_test.rb +25 -2
- data/test/migration_helpers_test.rb +1 -1
- data/test/test_helper.rb +4 -0
- data/test/test_helper_test.rb +281 -0
- data/test/workflow/cursor_size_limit_test.rb +75 -0
- data/test/workflow/instrumentation_test.rb +28 -0
- data/test/workflow/resumable_step_integration_test.rb +341 -0
- data/test/workflow/resumable_step_test.rb +615 -0
- data/test/workflow/resumable_without_migration_test.rb +103 -0
- data/test/workflow/signal_consumption_test.rb +397 -0
- data/test/workflow/signal_delivery_test.rb +255 -0
- data/test/workflow/signal_durability_test.rb +164 -0
- data/test/workflow/signal_rendezvous_test.rb +581 -0
- data/test/workflow/signals_without_migration_test.rb +97 -0
- data/test/workflow/workflow_test.rb +72 -1
- metadata +25 -15
- data/test/dummy/config/initializers/geneva_drive.rb +0 -44
- data/test/dummy/db/migrate/20260219212321_create_geneva_drive_workflows.rb +0 -74
- data/test/dummy/db/migrate/20260219212322_create_geneva_drive_step_executions.rb +0 -108
- data/test/dummy/db/migrate/20260219212323_add_finished_at_to_geneva_drive_step_executions.rb +0 -25
- data/test/dummy/db/migrate/20260219212324_add_error_class_name_to_geneva_drive_step_executions.rb +0 -7
- data/test/dummy/db/migrate/20260316100007_add_metadata_to_geneva_drive_step_executions.rb +0 -17
- data/test/dummy/db/migrate/20260327000000_allow_null_hero_on_geneva_drive_workflows.rb +0 -66
- data/test/dummy/db/schema.rb +0 -73
- data/test/dummy/log/development.log +0 -202
- data/test/dummy/log/test.log +0 -79471
- data/test/dummy/log/test.log.0 +0 -4
- data/test/dummy/tmp/local_secret.txt +0 -1
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
#
|
|
6
6
|
# @api private
|
|
7
7
|
class GenevaDrive::FlowControlSignal
|
|
8
|
-
# @return [Symbol] the action to take (:cancel, :pause, :reattempt, :skip, :finished)
|
|
8
|
+
# @return [Symbol] the action to take (:cancel, :pause, :reattempt, :skip, :finished, :suspend)
|
|
9
9
|
attr_reader :action
|
|
10
10
|
|
|
11
11
|
# @return [Hash] additional options for the flow control action
|
|
@@ -33,6 +33,22 @@ class GenevaDrive::InvalidStateError < StandardError; end
|
|
|
33
33
|
# raise StepConfigurationError, "Step requires either a block or method name"
|
|
34
34
|
class GenevaDrive::StepConfigurationError < StandardError; end
|
|
35
35
|
|
|
36
|
+
# Raised when a resumable step cursor exceeds GenevaDrive.max_cursor_size
|
|
37
|
+
# once serialized to JSON. The cursor is a position marker, not a payload -
|
|
38
|
+
# it is rewritten on every checkpoint and copied to every successor execution.
|
|
39
|
+
class GenevaDrive::CursorTooLargeError < StandardError; end
|
|
40
|
+
|
|
41
|
+
# Raised when a signal payload exceeds GenevaDrive.max_signal_payload_size
|
|
42
|
+
# once serialized to JSON. A payload describes the event; the data the step
|
|
43
|
+
# works on belongs on the hero.
|
|
44
|
+
class GenevaDrive::SignalPayloadTooLargeError < StandardError; end
|
|
45
|
+
|
|
46
|
+
# Raised when a signal is delivered to a workflow that has already finished
|
|
47
|
+
# or been canceled. Delivering the very same event twice (same idempotency
|
|
48
|
+
# key) is a no-op rather than an error, so callers who want lenience can
|
|
49
|
+
# rescue just this one class.
|
|
50
|
+
class GenevaDrive::WorkflowNotOngoing < StandardError; end
|
|
51
|
+
|
|
36
52
|
# Base class for errors that occur during step execution.
|
|
37
53
|
# These errors are raised after recovery actions have been performed,
|
|
38
54
|
# so the workflow/step states are already updated when the exception propagates.
|
|
@@ -130,16 +146,24 @@ module GenevaDrive::FlowControl
|
|
|
130
146
|
# Reschedules the current step for another attempt.
|
|
131
147
|
# Useful for handling temporary failures or rate limiting.
|
|
132
148
|
#
|
|
149
|
+
# For resumable steps, by default continues from the current cursor position.
|
|
150
|
+
# Use `rewind: true` to restart the step from the beginning.
|
|
151
|
+
#
|
|
133
152
|
# @param wait [ActiveSupport::Duration, nil] optional delay before retry
|
|
153
|
+
# @param rewind [Boolean] if true, resets cursor to beginning (resumable steps only)
|
|
134
154
|
# @return [void]
|
|
135
155
|
# @raise [UncaughtThrowError] if called outside of step execution context
|
|
136
156
|
#
|
|
137
157
|
# @example Retry after rate limit
|
|
138
158
|
# reattempt!(wait: 5.minutes)
|
|
139
|
-
|
|
159
|
+
#
|
|
160
|
+
# @example Retry resumable step from the beginning
|
|
161
|
+
# reattempt!(rewind: true)
|
|
162
|
+
def reattempt!(wait: nil, rewind: false)
|
|
140
163
|
wait_msg = wait ? " with wait #{wait.inspect}" : ""
|
|
141
|
-
|
|
142
|
-
|
|
164
|
+
rewind_msg = rewind ? " (rewinding cursor)" : ""
|
|
165
|
+
logger.info("Flow control: reattempt! called from step#{wait_msg}#{rewind_msg}")
|
|
166
|
+
throw :flow_control, GenevaDrive::FlowControlSignal.new(:reattempt, wait: wait, rewind: rewind)
|
|
143
167
|
end
|
|
144
168
|
|
|
145
169
|
# Skips the current step and proceeds to the next one.
|
|
@@ -168,6 +192,33 @@ module GenevaDrive::FlowControl
|
|
|
168
192
|
throw :flow_control, GenevaDrive::FlowControlSignal.new(:finished)
|
|
169
193
|
end
|
|
170
194
|
|
|
195
|
+
# Suspends the current resumable step and re-enqueues for later continuation.
|
|
196
|
+
# The step will resume from its current cursor position.
|
|
197
|
+
#
|
|
198
|
+
# This is primarily for use within resumable steps when you need to explicitly
|
|
199
|
+
# yield control (e.g., to respect rate limits or system load).
|
|
200
|
+
#
|
|
201
|
+
# @param wait [ActiveSupport::Duration, nil] optional delay before re-enqueue
|
|
202
|
+
# @return [void]
|
|
203
|
+
# @raise [UncaughtThrowError] if called outside of step execution context
|
|
204
|
+
#
|
|
205
|
+
# @example Suspend for rate limiting
|
|
206
|
+
# suspend!(wait: 5.minutes)
|
|
207
|
+
#
|
|
208
|
+
# @example Suspend to yield control
|
|
209
|
+
# suspend!
|
|
210
|
+
def suspend!(wait: nil)
|
|
211
|
+
executing_step = current_step_name && steps.named(current_step_name)
|
|
212
|
+
unless executing_step&.resumable?
|
|
213
|
+
raise GenevaDrive::InvalidStateError,
|
|
214
|
+
"suspend! can only be called from inside a resumable_step (it continues from the persisted cursor)"
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
wait_msg = wait ? " with wait #{wait.inspect}" : ""
|
|
218
|
+
logger.info("Flow control: suspend! called from step#{wait_msg}")
|
|
219
|
+
throw :flow_control, GenevaDrive::FlowControlSignal.new(:suspend, wait: wait)
|
|
220
|
+
end
|
|
221
|
+
|
|
171
222
|
private
|
|
172
223
|
|
|
173
224
|
# Pauses the workflow from outside a step execution.
|
|
@@ -236,14 +287,43 @@ module GenevaDrive::FlowControl
|
|
|
236
287
|
end
|
|
237
288
|
|
|
238
289
|
if state == "paused"
|
|
239
|
-
# Workflow was paused (e.g., due to failed step). Resume and skip to
|
|
290
|
+
# Workflow was paused (e.g., due to failed step). Resume and skip to
|
|
291
|
+
# next step. The step being skipped past may hold a signal claim on a
|
|
292
|
+
# failed execution - no further execution of it is coming, so the
|
|
293
|
+
# operator's skip is what settles that event.
|
|
294
|
+
holder = execution_holding_signal_claim(next_step_name)
|
|
240
295
|
update!(state: "ready", transitioned_at: nil)
|
|
296
|
+
settle_signal_for_skipped!(holder)
|
|
241
297
|
else
|
|
242
|
-
# Workflow is ready with a scheduled step - mark it as skipped
|
|
243
|
-
|
|
298
|
+
# Workflow is ready with a scheduled step - mark it as skipped. A
|
|
299
|
+
# dispatched-but-not-yet-run execution carries an attached signal;
|
|
300
|
+
# skipping it settles that signal too.
|
|
301
|
+
skipped_execution = current_execution
|
|
302
|
+
skipped_execution&.mark_skipped!(outcome: "skipped")
|
|
303
|
+
settle_signal_for_skipped!(skipped_execution)
|
|
244
304
|
end
|
|
245
305
|
|
|
246
306
|
schedule_next_step!
|
|
247
307
|
end
|
|
248
308
|
end
|
|
309
|
+
|
|
310
|
+
# Finds the execution that still holds a signal claim for a step we are
|
|
311
|
+
# about to skip past on a paused workflow: the current one if it was
|
|
312
|
+
# dispatched before the pause, otherwise the failed attempt that paused the
|
|
313
|
+
# workflow in the first place (same lookup shape resume! uses).
|
|
314
|
+
#
|
|
315
|
+
# @param step_name [String, nil] the step being skipped past
|
|
316
|
+
# @return [GenevaDrive::StepExecution, nil]
|
|
317
|
+
def execution_holding_signal_claim(step_name)
|
|
318
|
+
return nil unless GenevaDrive::StepExecution.signal_columns?
|
|
319
|
+
|
|
320
|
+
candidate = current_execution
|
|
321
|
+
return candidate if candidate&.signal_id.present?
|
|
322
|
+
return nil if step_name.blank?
|
|
323
|
+
|
|
324
|
+
step_executions
|
|
325
|
+
.where(step_name: step_name, state: "failed")
|
|
326
|
+
.order(created_at: :desc, id: :desc)
|
|
327
|
+
.first
|
|
328
|
+
end
|
|
249
329
|
end
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Object passed to resumable step blocks for cursor-based iteration.
|
|
4
|
+
#
|
|
5
|
+
# Provides an API compatible with Rails 8.1's ActiveJob::Continuation::Step,
|
|
6
|
+
# plus GenevaDrive-specific extensions for iterating over records and batches.
|
|
7
|
+
#
|
|
8
|
+
# @example Manual cursor control
|
|
9
|
+
# resumable_step :process_records do |iter|
|
|
10
|
+
# hero.records.where("id > ?", iter.cursor || 0).find_each do |record|
|
|
11
|
+
# process(record)
|
|
12
|
+
# iter.set!(record.id)
|
|
13
|
+
# end
|
|
14
|
+
# end
|
|
15
|
+
#
|
|
16
|
+
# @example Using iterate_over_records helper
|
|
17
|
+
# resumable_step :process_records do |iter|
|
|
18
|
+
# iter.iterate_over_records(hero.records) do |record|
|
|
19
|
+
# process(record)
|
|
20
|
+
# end
|
|
21
|
+
# end
|
|
22
|
+
#
|
|
23
|
+
class GenevaDrive::IterableStep
|
|
24
|
+
attr_reader :name, :cursor
|
|
25
|
+
|
|
26
|
+
# Creates a new IterableStep for a resumable step execution.
|
|
27
|
+
#
|
|
28
|
+
# @param name [String, Symbol] the step name
|
|
29
|
+
# @param cursor [Object, nil] the current cursor value
|
|
30
|
+
# @param execution [GenevaDrive::StepExecution] the step execution record
|
|
31
|
+
# @param resumed [Boolean] whether this is a resumed execution
|
|
32
|
+
# @param interrupter [#should_interrupt?] object to check for interruption
|
|
33
|
+
def initialize(name, cursor, execution:, resumed:, interrupter:)
|
|
34
|
+
@name = name.to_sym
|
|
35
|
+
@cursor = cursor
|
|
36
|
+
@execution = execution
|
|
37
|
+
@resumed = resumed
|
|
38
|
+
@interrupter = interrupter
|
|
39
|
+
@advanced = false
|
|
40
|
+
@iterations = 0
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Returns the number of iterations completed during this execution
|
|
44
|
+
# (checkpoints via set!, advance! or checkpoint!).
|
|
45
|
+
#
|
|
46
|
+
# @return [Integer]
|
|
47
|
+
attr_reader :iterations
|
|
48
|
+
|
|
49
|
+
# === Rails 8.1 ActiveJob::Continuation::Step compatible API ===
|
|
50
|
+
|
|
51
|
+
# Returns true if this execution is resuming from a prior suspension.
|
|
52
|
+
#
|
|
53
|
+
# @return [Boolean]
|
|
54
|
+
def resumed?
|
|
55
|
+
@resumed
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Returns true if the cursor was changed during this execution.
|
|
59
|
+
#
|
|
60
|
+
# @return [Boolean]
|
|
61
|
+
def advanced?
|
|
62
|
+
@advanced
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Sets the cursor to a new value and checkpoints.
|
|
66
|
+
# The cursor can be any value that ActiveJob can serialize.
|
|
67
|
+
#
|
|
68
|
+
# @param value [Object] the new cursor value
|
|
69
|
+
# @return [void]
|
|
70
|
+
def set!(value)
|
|
71
|
+
@cursor = value
|
|
72
|
+
@advanced = true
|
|
73
|
+
checkpoint!
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Increments an integer cursor by 1 and checkpoints.
|
|
77
|
+
# Only works with integer cursors.
|
|
78
|
+
#
|
|
79
|
+
# @raise [ArgumentError] if cursor is not an Integer
|
|
80
|
+
# @return [void]
|
|
81
|
+
def advance!
|
|
82
|
+
raise ArgumentError, "advance! requires integer cursor, got #{@cursor.inspect}" unless @cursor.is_a?(Integer)
|
|
83
|
+
set!(@cursor + 1)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Persists the current cursor, counts the iteration, and checks for
|
|
87
|
+
# interruption (max_iterations, max_runtime, shutdown, external pause/cancel).
|
|
88
|
+
# Called automatically by set! and advance!.
|
|
89
|
+
#
|
|
90
|
+
# @return [void]
|
|
91
|
+
def checkpoint!
|
|
92
|
+
@iterations += 1
|
|
93
|
+
persist_cursor!
|
|
94
|
+
check_interruption!
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Returns array representation [name, cursor] for compatibility.
|
|
98
|
+
#
|
|
99
|
+
# @return [Array]
|
|
100
|
+
def to_a
|
|
101
|
+
[name.to_s, cursor]
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Returns a human-readable description of current position.
|
|
105
|
+
#
|
|
106
|
+
# @return [String]
|
|
107
|
+
def description
|
|
108
|
+
"at '#{name}', cursor '#{cursor.inspect}'"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# === GenevaDrive Extensions ===
|
|
112
|
+
|
|
113
|
+
# Skips to a new cursor position and suspends execution.
|
|
114
|
+
# The job will be re-enqueued to continue from the new cursor.
|
|
115
|
+
#
|
|
116
|
+
# @param value [Object] the new cursor value
|
|
117
|
+
# @param wait [ActiveSupport::Duration, Numeric, nil] optional delay before re-enqueue
|
|
118
|
+
# @return [void] (never returns - throws :interrupt)
|
|
119
|
+
def skip_to!(value, wait: nil)
|
|
120
|
+
@cursor = value
|
|
121
|
+
@advanced = true
|
|
122
|
+
persist_cursor!
|
|
123
|
+
throw :interrupt, wait
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Iterates over an enumerable using index as cursor.
|
|
127
|
+
# Resumes from the stored cursor position on subsequent runs.
|
|
128
|
+
#
|
|
129
|
+
# @param enumerable [Enumerable] the collection to iterate over
|
|
130
|
+
# @yield [Object] each element in the collection
|
|
131
|
+
# @return [void]
|
|
132
|
+
def iterate_over(enumerable)
|
|
133
|
+
raise ArgumentError, "Must respond to #each" unless enumerable.respond_to?(:each)
|
|
134
|
+
|
|
135
|
+
start_index = @cursor || 0
|
|
136
|
+
enumerable.each_with_index do |element, index|
|
|
137
|
+
next if index < start_index
|
|
138
|
+
yield element
|
|
139
|
+
set!(index + 1)
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Iterates over an ActiveRecord relation using a column as cursor.
|
|
144
|
+
# Uses find_each for memory-efficient batching.
|
|
145
|
+
#
|
|
146
|
+
# @param relation [ActiveRecord::Relation] the relation to iterate over
|
|
147
|
+
# @param cursor [Symbol] the column to use as cursor (default: :id)
|
|
148
|
+
# @yield [ActiveRecord::Base] each record in the relation
|
|
149
|
+
# @return [void]
|
|
150
|
+
def iterate_over_records(relation, cursor: :id)
|
|
151
|
+
filtered = if @cursor
|
|
152
|
+
relation.where(relation.arel_table[cursor].gt(@cursor)).order(cursor => :asc)
|
|
153
|
+
else
|
|
154
|
+
relation.order(cursor => :asc)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
filtered.find_each do |record|
|
|
158
|
+
yield record
|
|
159
|
+
set!(record.public_send(cursor))
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Iterates over an ActiveRecord relation in subrelations (batches).
|
|
164
|
+
# Each subrelation is yielded as an ActiveRecord relation with offset applied,
|
|
165
|
+
# useful for bulk operations like updates or exports.
|
|
166
|
+
#
|
|
167
|
+
# @param relation [ActiveRecord::Relation] the relation to iterate over
|
|
168
|
+
# @param batch_size [Integer] the number of records per subrelation
|
|
169
|
+
# @param cursor [Symbol] the column to use as cursor (default: :id)
|
|
170
|
+
# @yield [ActiveRecord::Relation] each subrelation with offset applied
|
|
171
|
+
# @return [void]
|
|
172
|
+
def iterate_over_subrelations(relation, batch_size:, cursor: :id)
|
|
173
|
+
filtered = if @cursor
|
|
174
|
+
relation.where(relation.arel_table[cursor].gt(@cursor)).order(cursor => :asc)
|
|
175
|
+
else
|
|
176
|
+
relation.order(cursor => :asc)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
filtered.in_batches(of: batch_size) do |relation_with_offset|
|
|
180
|
+
yield relation_with_offset
|
|
181
|
+
last_cursor = relation_with_offset.maximum(cursor)
|
|
182
|
+
set!(last_cursor) if last_cursor
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
private
|
|
187
|
+
|
|
188
|
+
def persist_cursor!
|
|
189
|
+
serialized = GenevaDrive::StepExecution.serialize_cursor(@cursor)
|
|
190
|
+
|
|
191
|
+
GenevaDrive::StepExecution
|
|
192
|
+
.where(id: @execution.id)
|
|
193
|
+
.update_all(cursor: serialized)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def check_interruption!
|
|
197
|
+
throw :interrupt if @interrupter&.should_interrupt?(iterations: @iterations)
|
|
198
|
+
end
|
|
199
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Validation helper for Active Job option hashes passed through the workflow DSL
|
|
4
|
+
# (`set_step_job_options`, `step(job_options: ...)`, and `workflow.step_job_options=`).
|
|
5
|
+
#
|
|
6
|
+
# Active Job itself silently ignores unknown keys in `set` — a typo like
|
|
7
|
+
# `piority:` would never raise, and the workflow would just run at the default
|
|
8
|
+
# priority. This helper enforces the known key set so misconfigurations surface
|
|
9
|
+
# at definition time, or at assignment time for per-instance overrides.
|
|
10
|
+
#
|
|
11
|
+
# The allowed keys mirror what `ActiveJob::Core#set` actually consumes.
|
|
12
|
+
#
|
|
13
|
+
# @api private
|
|
14
|
+
module GenevaDrive::JobOptions
|
|
15
|
+
# Keys that Active Job's `set` recognizes. See `ActiveJob::Core#set` in
|
|
16
|
+
# activejob/lib/active_job/core.rb.
|
|
17
|
+
ALLOWED_KEYS = %i[wait wait_until queue priority].freeze
|
|
18
|
+
|
|
19
|
+
# Validates a job options hash and returns a symbolized copy.
|
|
20
|
+
#
|
|
21
|
+
# @param options [#to_h, Hash, nil] the value to validate — anything that
|
|
22
|
+
# coerces to a Hash via `#to_h` is accepted (Hash, keyword-args splat,
|
|
23
|
+
# ActiveSupport::HashWithIndifferentAccess, structs, etc.)
|
|
24
|
+
# @param context [String] human-readable identifier used in error messages
|
|
25
|
+
# (e.g. "Step 'foo'" or "MyWorkflow.step_job_options")
|
|
26
|
+
# @return [Hash{Symbol=>Object}] symbolized copy safe to pass to `.set`
|
|
27
|
+
# @raise [GenevaDrive::StepConfigurationError] if options can't be coerced to
|
|
28
|
+
# a Hash, contains unknown keys, or has values of the wrong type
|
|
29
|
+
def self.validate!(options, context:)
|
|
30
|
+
hash = begin
|
|
31
|
+
options.to_h
|
|
32
|
+
rescue NoMethodError, TypeError, ArgumentError
|
|
33
|
+
raise GenevaDrive::StepConfigurationError,
|
|
34
|
+
"#{context} has invalid job_options: cannot coerce #{options.class} to a Hash via #to_h"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
symbolized = hash.symbolize_keys
|
|
38
|
+
|
|
39
|
+
unknown = symbolized.keys - ALLOWED_KEYS
|
|
40
|
+
unless unknown.empty?
|
|
41
|
+
raise GenevaDrive::StepConfigurationError,
|
|
42
|
+
"#{context} has unknown job_options key(s): #{unknown.map(&:inspect).join(", ")}. " \
|
|
43
|
+
"Active Job only recognizes: #{ALLOWED_KEYS.map(&:inspect).join(", ")}"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
symbolized.each { |k, v| validate_value!(k, v, context) }
|
|
47
|
+
symbolized
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# @api private
|
|
51
|
+
def self.validate_value!(key, value, context)
|
|
52
|
+
return if value.nil?
|
|
53
|
+
|
|
54
|
+
valid = case key
|
|
55
|
+
when :wait
|
|
56
|
+
value.respond_to?(:to_i) && value.to_i >= 0
|
|
57
|
+
when :wait_until
|
|
58
|
+
value.respond_to?(:to_time) || value.respond_to?(:to_f)
|
|
59
|
+
when :queue
|
|
60
|
+
value.is_a?(String) || value.is_a?(Symbol)
|
|
61
|
+
when :priority
|
|
62
|
+
value.is_a?(Integer)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
return if valid
|
|
66
|
+
|
|
67
|
+
raise GenevaDrive::StepConfigurationError,
|
|
68
|
+
"#{context} has invalid job_options[#{key.inspect}]: #{value.inspect} (#{value.class})"
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -33,6 +33,7 @@ class GenevaDrive::HousekeepingJob < ActiveJob::Base
|
|
|
33
33
|
results = {
|
|
34
34
|
workflows_cleaned_up: 0,
|
|
35
35
|
step_executions_cleaned_up: 0,
|
|
36
|
+
signals_cleaned_up: 0,
|
|
36
37
|
stuck_in_progress_recovered: 0,
|
|
37
38
|
stuck_scheduled_recovered: 0
|
|
38
39
|
}
|
|
@@ -40,6 +41,7 @@ class GenevaDrive::HousekeepingJob < ActiveJob::Base
|
|
|
40
41
|
cleanup_completed_workflows!(results)
|
|
41
42
|
recover_stuck_step_executions!(results)
|
|
42
43
|
report_workflow_gauges!
|
|
44
|
+
report_waiting_gauges!
|
|
43
45
|
|
|
44
46
|
logger.info("Completed: #{results}")
|
|
45
47
|
results
|
|
@@ -48,9 +50,16 @@ class GenevaDrive::HousekeepingJob < ActiveJob::Base
|
|
|
48
50
|
private
|
|
49
51
|
|
|
50
52
|
# Reports workflow count gauges to Measurometer.
|
|
51
|
-
# For each state (ready, paused,
|
|
52
|
-
#
|
|
53
|
-
# -
|
|
53
|
+
# For each state present in the workflows table (e.g. ready, paused,
|
|
54
|
+
# performing, finished, canceled), sets:
|
|
55
|
+
# - A total gauge `geneva_drive.<state>` without tags (summed across all classes)
|
|
56
|
+
# - Per-workflow-class gauges `geneva_drive.<state>` tagged with `workflow: ClassName`
|
|
57
|
+
#
|
|
58
|
+
# Additionally emits a normalized `geneva_drive.paused_ratio` gauge per
|
|
59
|
+
# workflow class — a float in `0.0..1.0` equal to that class's `paused` count
|
|
60
|
+
# divided by that class's total population (all states). This is easier to
|
|
61
|
+
# alert on than absolute paused counts (50 paused of 50 is a fire; 50 of
|
|
62
|
+
# 500,000 is noise). All gauges are derived from a single grouped query.
|
|
54
63
|
#
|
|
55
64
|
# @return [void]
|
|
56
65
|
def report_workflow_gauges!
|
|
@@ -64,6 +73,58 @@ class GenevaDrive::HousekeepingJob < ActiveJob::Base
|
|
|
64
73
|
counts.each do |(state, type), count|
|
|
65
74
|
Measurometer.set_gauge("geneva_drive.#{state}", count, workflow: type)
|
|
66
75
|
end
|
|
76
|
+
|
|
77
|
+
# Per-class paused ratio (paused ÷ total population), deliberately bounded 0..1.
|
|
78
|
+
# Both accumulators default to 0 so a class with no paused workflows yields a clean 0.0.
|
|
79
|
+
totals_by_type = Hash.new(0)
|
|
80
|
+
paused_by_type = Hash.new(0)
|
|
81
|
+
counts.each do |(state, type), count|
|
|
82
|
+
totals_by_type[type] += count
|
|
83
|
+
paused_by_type[type] += count if state == "paused"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
totals_by_type.each do |type, total|
|
|
87
|
+
# A type only appears here when it has >= 1 row, so total is always >= 1.
|
|
88
|
+
# Guard against division by zero anyway as belt-and-suspenders.
|
|
89
|
+
next if total.zero?
|
|
90
|
+
Measurometer.set_gauge("geneva_drive.paused_ratio", paused_by_type[type].to_f / total, workflow: type)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Reports gauges for step executions parked waiting for a signal.
|
|
95
|
+
#
|
|
96
|
+
# Waiting indefinitely is a legitimate state, not a stuck one, so the stuck
|
|
97
|
+
# sweeps ignore parked rows. These gauges are what keeps a stalled
|
|
98
|
+
# rendezvous from being silent:
|
|
99
|
+
# - `geneva_drive.waiting_step_executions` - parked rows, total and per class
|
|
100
|
+
# - `geneva_drive.waiting_overdue` - parked longer than
|
|
101
|
+
# GenevaDrive.waiting_visibility_threshold, total and per class
|
|
102
|
+
#
|
|
103
|
+
# @return [void]
|
|
104
|
+
def report_waiting_gauges!
|
|
105
|
+
return unless GenevaDrive::StepExecution.signal_columns?
|
|
106
|
+
|
|
107
|
+
report_waiting_gauge!("geneva_drive.waiting_step_executions", GenevaDrive::StepExecution.waiting)
|
|
108
|
+
|
|
109
|
+
threshold = GenevaDrive.waiting_visibility_threshold
|
|
110
|
+
return if threshold.blank?
|
|
111
|
+
|
|
112
|
+
overdue = GenevaDrive::StepExecution.waiting.where(waiting_since: ..threshold.ago)
|
|
113
|
+
report_waiting_gauge!("geneva_drive.waiting_overdue", overdue)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Sets one gauge per workflow class plus an untagged total for the scope.
|
|
117
|
+
#
|
|
118
|
+
# @param gauge_name [String] the Measurometer gauge name
|
|
119
|
+
# @param scope [ActiveRecord::Relation] a step execution scope
|
|
120
|
+
# @return [void]
|
|
121
|
+
def report_waiting_gauge!(gauge_name, scope)
|
|
122
|
+
counts = scope.joins(:workflow).group("#{GenevaDrive::Workflow.table_name}.type").count
|
|
123
|
+
|
|
124
|
+
Measurometer.set_gauge(gauge_name, counts.values.sum)
|
|
125
|
+
counts.each do |type, count|
|
|
126
|
+
Measurometer.set_gauge(gauge_name, count, workflow: type)
|
|
127
|
+
end
|
|
67
128
|
end
|
|
68
129
|
|
|
69
130
|
# Cleans up completed/canceled workflows older than the configured threshold.
|
|
@@ -91,7 +152,17 @@ class GenevaDrive::HousekeepingJob < ActiveJob::Base
|
|
|
91
152
|
break if deleted_count < batch_size
|
|
92
153
|
end
|
|
93
154
|
|
|
94
|
-
# Second pass: delete the workflows
|
|
155
|
+
# Second pass: delete the signals delivered to old workflows
|
|
156
|
+
if GenevaDrive::Signal.table_available?
|
|
157
|
+
loop do
|
|
158
|
+
deleted_count = delete_signals_batch(cutoff_time, batch_size)
|
|
159
|
+
logger.info("Deleted #{deleted_count} signals")
|
|
160
|
+
results[:signals_cleaned_up] += deleted_count
|
|
161
|
+
break if deleted_count < batch_size
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Third pass: delete the workflows themselves
|
|
95
166
|
loop do
|
|
96
167
|
deleted_count = delete_workflows_batch(cutoff_time, batch_size)
|
|
97
168
|
logger.info("Deleted #{deleted_count} workflows")
|
|
@@ -100,11 +171,43 @@ class GenevaDrive::HousekeepingJob < ActiveJob::Base
|
|
|
100
171
|
end
|
|
101
172
|
|
|
102
173
|
logger.info(
|
|
103
|
-
"Cleaned up #{results[:workflows_cleaned_up]} workflows " \
|
|
104
|
-
"
|
|
174
|
+
"Cleaned up #{results[:workflows_cleaned_up]} workflows, " \
|
|
175
|
+
"#{results[:step_executions_cleaned_up]} step executions and " \
|
|
176
|
+
"#{results[:signals_cleaned_up]} signals older than #{cutoff_time}"
|
|
105
177
|
)
|
|
106
178
|
end
|
|
107
179
|
|
|
180
|
+
# Deletes a batch of signals belonging to old workflows.
|
|
181
|
+
#
|
|
182
|
+
# @param cutoff_time [Time] workflows transitioned before this time are eligible
|
|
183
|
+
# @param batch_size [Integer] maximum records to delete in this batch
|
|
184
|
+
# @return [Integer] number of records deleted
|
|
185
|
+
def delete_signals_batch(cutoff_time, batch_size)
|
|
186
|
+
GenevaDrive::Signal.connection_pool.with_connection do |conn|
|
|
187
|
+
signals_table = conn.quote_table_name(GenevaDrive::Signal.table_name)
|
|
188
|
+
workflows_table = conn.quote_table_name(GenevaDrive::Workflow.table_name)
|
|
189
|
+
limit = batch_size.to_i
|
|
190
|
+
|
|
191
|
+
# MySQL doesn't support LIMIT in subqueries with IN, so we wrap it in another SELECT
|
|
192
|
+
# Also, MySQL doesn't handle bind parameters for LIMIT properly, so we interpolate directly
|
|
193
|
+
sql = <<~SQL.squish
|
|
194
|
+
DELETE FROM #{signals_table}
|
|
195
|
+
WHERE id IN (
|
|
196
|
+
SELECT id FROM (
|
|
197
|
+
SELECT s.id
|
|
198
|
+
FROM #{signals_table} s
|
|
199
|
+
INNER JOIN #{workflows_table} w ON w.id = s.workflow_id
|
|
200
|
+
WHERE w.state IN ('finished', 'canceled')
|
|
201
|
+
AND w.transitioned_at < ?
|
|
202
|
+
LIMIT #{limit}
|
|
203
|
+
) AS batch_to_delete
|
|
204
|
+
)
|
|
205
|
+
SQL
|
|
206
|
+
|
|
207
|
+
conn.delete(GenevaDrive::Signal.sanitize_sql([sql, cutoff_time]))
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
|
|
108
211
|
# Deletes a batch of step executions belonging to old workflows.
|
|
109
212
|
#
|
|
110
213
|
# @param cutoff_time [Time] workflows transitioned before this time are eligible
|
|
@@ -190,6 +293,7 @@ class GenevaDrive::HousekeepingJob < ActiveJob::Base
|
|
|
190
293
|
stuck_executions = GenevaDrive::StepExecution
|
|
191
294
|
.in_progress
|
|
192
295
|
.where("started_at < ?", cutoff_time)
|
|
296
|
+
.order(started_at: :asc)
|
|
193
297
|
.limit(batch_size)
|
|
194
298
|
.to_a
|
|
195
299
|
|
|
@@ -219,6 +323,7 @@ class GenevaDrive::HousekeepingJob < ActiveJob::Base
|
|
|
219
323
|
stuck_executions = GenevaDrive::StepExecution
|
|
220
324
|
.scheduled
|
|
221
325
|
.where("scheduled_for < ?", cutoff_time)
|
|
326
|
+
.order(scheduled_for: :asc)
|
|
222
327
|
.limit(batch_size)
|
|
223
328
|
.to_a
|
|
224
329
|
|
|
@@ -256,6 +361,9 @@ class GenevaDrive::HousekeepingJob < ActiveJob::Base
|
|
|
256
361
|
|
|
257
362
|
# Reattempts a stuck step execution by marking it as completed and scheduling a retry.
|
|
258
363
|
#
|
|
364
|
+
# Resumable step executions carry a cursor; those are continued via a
|
|
365
|
+
# successor execution so recovery does not restart the iteration from zero.
|
|
366
|
+
#
|
|
259
367
|
# @param step_execution [GenevaDrive::StepExecution]
|
|
260
368
|
# @param workflow [GenevaDrive::Workflow]
|
|
261
369
|
# @return [void]
|
|
@@ -273,7 +381,12 @@ class GenevaDrive::HousekeepingJob < ActiveJob::Base
|
|
|
273
381
|
)
|
|
274
382
|
|
|
275
383
|
workflow.update!(state: "ready") if workflow.performing?
|
|
276
|
-
|
|
384
|
+
|
|
385
|
+
if step_execution.step_definition&.resumable? && GenevaDrive::StepExecution.resumable_columns?
|
|
386
|
+
workflow.create_successor_execution!(step_execution)
|
|
387
|
+
else
|
|
388
|
+
workflow.reschedule_current_step!
|
|
389
|
+
end
|
|
277
390
|
end
|
|
278
391
|
end
|
|
279
392
|
end
|