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.
Files changed (60) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +14 -1
  3. data/MANUAL.md +449 -11
  4. data/README.md +5 -1
  5. data/lib/generators/geneva_drive/install/install_generator.rb +20 -0
  6. data/lib/generators/geneva_drive/install/templates/add_metadata_to_workflows.rb +17 -0
  7. data/lib/generators/geneva_drive/install/templates/add_resumable_step_support.rb +29 -0
  8. data/lib/generators/geneva_drive/install/templates/add_signals_support.rb +94 -0
  9. data/lib/generators/geneva_drive/install/templates/add_started_at_index_to_step_executions.rb +48 -0
  10. data/lib/generators/geneva_drive/install/templates/create_workflows_migration.rb +11 -0
  11. data/lib/generators/geneva_drive/install/templates/initializer.rb.tt +8 -0
  12. data/lib/geneva_drive/combined_exception_policy.rb +1 -1
  13. data/lib/geneva_drive/exception_policy.rb +36 -10
  14. data/lib/geneva_drive/executor.rb +352 -26
  15. data/lib/geneva_drive/flow_control.rb +87 -7
  16. data/lib/geneva_drive/iterable_step.rb +199 -0
  17. data/lib/geneva_drive/job_options.rb +70 -0
  18. data/lib/geneva_drive/jobs/housekeeping_job.rb +120 -7
  19. data/lib/geneva_drive/resumable_step_definition.rb +97 -0
  20. data/lib/geneva_drive/signal.rb +243 -0
  21. data/lib/geneva_drive/signal_matcher.rb +60 -0
  22. data/lib/geneva_drive/step_definition.rb +96 -2
  23. data/lib/geneva_drive/step_execution.rb +141 -2
  24. data/lib/geneva_drive/test_helpers.rb +212 -6
  25. data/lib/geneva_drive/version.rb +1 -1
  26. data/lib/geneva_drive/workflow/metadata_accessor.rb +85 -0
  27. data/lib/geneva_drive/workflow.rb +559 -43
  28. data/lib/geneva_drive.rb +37 -0
  29. data/test/dsl/signal_step_definition_test.rb +132 -0
  30. data/test/dsl/step_definition_test.rb +70 -0
  31. data/test/jobs/housekeeping_job_test.rb +119 -0
  32. data/test/jobs/housekeeping_signals_test.rb +198 -0
  33. data/test/jobs/perform_step_job_test.rb +25 -2
  34. data/test/migration_helpers_test.rb +1 -1
  35. data/test/test_helper.rb +4 -0
  36. data/test/test_helper_test.rb +281 -0
  37. data/test/workflow/cursor_size_limit_test.rb +75 -0
  38. data/test/workflow/instrumentation_test.rb +28 -0
  39. data/test/workflow/resumable_step_integration_test.rb +341 -0
  40. data/test/workflow/resumable_step_test.rb +615 -0
  41. data/test/workflow/resumable_without_migration_test.rb +103 -0
  42. data/test/workflow/signal_consumption_test.rb +397 -0
  43. data/test/workflow/signal_delivery_test.rb +255 -0
  44. data/test/workflow/signal_durability_test.rb +164 -0
  45. data/test/workflow/signal_rendezvous_test.rb +581 -0
  46. data/test/workflow/signals_without_migration_test.rb +97 -0
  47. data/test/workflow/workflow_test.rb +72 -1
  48. metadata +25 -15
  49. data/test/dummy/config/initializers/geneva_drive.rb +0 -44
  50. data/test/dummy/db/migrate/20260219212321_create_geneva_drive_workflows.rb +0 -74
  51. data/test/dummy/db/migrate/20260219212322_create_geneva_drive_step_executions.rb +0 -108
  52. data/test/dummy/db/migrate/20260219212323_add_finished_at_to_geneva_drive_step_executions.rb +0 -25
  53. data/test/dummy/db/migrate/20260219212324_add_error_class_name_to_geneva_drive_step_executions.rb +0 -7
  54. data/test/dummy/db/migrate/20260316100007_add_metadata_to_geneva_drive_step_executions.rb +0 -17
  55. data/test/dummy/db/migrate/20260327000000_allow_null_hero_on_geneva_drive_workflows.rb +0 -66
  56. data/test/dummy/db/schema.rb +0 -73
  57. data/test/dummy/log/development.log +0 -202
  58. data/test/dummy/log/test.log +0 -79471
  59. data/test/dummy/log/test.log.0 +0 -4
  60. 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
- def reattempt!(wait: nil)
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
- logger.info("Flow control: reattempt! called from step#{wait_msg}")
142
- throw :flow_control, GenevaDrive::FlowControlSignal.new(:reattempt, wait: wait)
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 next step.
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
- current_execution&.mark_skipped!(outcome: "skipped")
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, finished), sets:
52
- # - A total gauge without tags
53
- # - Per-workflow-class gauges tagged with `workflow: ClassName`
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 themselves
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
- "and #{results[:step_executions_cleaned_up]} step executions older than #{cutoff_time}"
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
- workflow.reschedule_current_step!
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