geneva_drive 0.7.0 → 0.9.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 (89) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +21 -0
  3. data/MANUAL.md +299 -5
  4. data/README.md +14 -1
  5. data/Rakefile +28 -1
  6. data/bin/matrix +76 -0
  7. data/bin/md2html +114 -0
  8. data/lib/generators/geneva_drive/install/templates/add_resumable_step_support.rb +5 -9
  9. data/lib/generators/geneva_drive/install/templates/add_signals_support.rb +5 -10
  10. data/lib/generators/geneva_drive/install/templates/create_workflows_migration.rb +1 -1
  11. data/lib/generators/geneva_drive/install/templates/initializer.rb.tt +33 -2
  12. data/lib/geneva_drive/dispatchers/active_job_dispatcher.rb +33 -0
  13. data/lib/geneva_drive/dispatchers/sidekiq_dispatcher.rb +112 -0
  14. data/lib/geneva_drive/engine.rb +13 -0
  15. data/lib/geneva_drive/executor.rb +23 -3
  16. data/lib/geneva_drive/job_options.rb +7 -3
  17. data/lib/geneva_drive/jobs/perform_step_job.rb +2 -35
  18. data/lib/geneva_drive/jobs/sidekiq_housekeeping_job.rb +32 -0
  19. data/lib/geneva_drive/jobs/sidekiq_perform_step_job.rb +35 -0
  20. data/lib/geneva_drive/jobs/step_execution_lookup.rb +42 -0
  21. data/lib/geneva_drive/json_column.rb +72 -0
  22. data/lib/geneva_drive/migration_helpers.rb +60 -3
  23. data/lib/geneva_drive/removed_step_definition.rb +34 -0
  24. data/lib/geneva_drive/signal.rb +4 -2
  25. data/lib/geneva_drive/step_collection.rb +31 -7
  26. data/lib/geneva_drive/step_definition.rb +9 -0
  27. data/lib/geneva_drive/step_execution.rb +3 -2
  28. data/lib/geneva_drive/version.rb +1 -1
  29. data/lib/geneva_drive/workflow.rb +200 -39
  30. data/lib/geneva_drive.rb +65 -0
  31. data/test/dummy/config/database.yml +40 -5
  32. data/test/dummy/config/initializers/geneva_drive.rb +78 -0
  33. data/test/dummy/db/mysql/schema-8.1.rb +95 -0
  34. data/test/dummy/db/postgres/schema-8.1.rb +99 -0
  35. data/test/dummy/db/sqlite/schema-7.2.rb +96 -0
  36. data/test/dummy/db/sqlite/schema-8.0.rb +96 -0
  37. data/test/dummy/db/sqlite/schema-8.1.rb +96 -0
  38. data/test/dummy/db/sqlite/test.sqlite3 +0 -0
  39. data/test/dummy/db/sqlite/test.sqlite3-0 +0 -0
  40. data/test/dummy/db/sqlite/test.sqlite3-1 +0 -0
  41. data/test/dummy/db/sqlite/test.sqlite3-2 +0 -0
  42. data/test/dummy/db/sqlite/test.sqlite3-3 +0 -0
  43. data/test/dummy/db/sqlite/test.sqlite3-4 +0 -0
  44. data/test/dummy/db/sqlite/test.sqlite3-5 +0 -0
  45. data/test/dummy/db/sqlite/test.sqlite3-6 +0 -0
  46. data/test/dummy/db/sqlite/test.sqlite3-7 +0 -0
  47. data/test/dummy/db/sqlite/test.sqlite3-8 +0 -0
  48. data/test/dummy/db/sqlite/test.sqlite3-9 +0 -0
  49. data/test/dummy/db/sqlite/test.sqlite3_0 +0 -0
  50. data/test/dummy/db/sqlite/test.sqlite3_1 +0 -0
  51. data/test/dummy/db/sqlite/test.sqlite3_2 +0 -0
  52. data/test/dummy/db/sqlite/test.sqlite3_3 +0 -0
  53. data/test/dummy/db/sqlite/test.sqlite3_4 +0 -0
  54. data/test/dummy/db/sqlite/test.sqlite3_5 +0 -0
  55. data/test/dummy/db/sqlite/test.sqlite3_6 +0 -0
  56. data/test/dummy/db/sqlite/test.sqlite3_7 +0 -0
  57. data/test/dummy/db/sqlite/test.sqlite3_8 +0 -0
  58. data/test/dummy/db/sqlite/test.sqlite3_9 +0 -0
  59. data/test/dummy/db/sqlite3/schema-7.2.rb +96 -0
  60. data/test/dummy/db/sqlite3/schema-8.1.rb +96 -0
  61. data/test/dummy/db/sqlite3/test.sqlite3 +0 -0
  62. data/test/dummy/db/sqlite3/test.sqlite3_0 +0 -0
  63. data/test/dummy/db/sqlite3/test.sqlite3_1 +0 -0
  64. data/test/dummy/db/sqlite3/test.sqlite3_2 +0 -0
  65. data/test/dummy/db/sqlite3/test.sqlite3_3 +0 -0
  66. data/test/dummy/db/sqlite3/test.sqlite3_4 +0 -0
  67. data/test/dummy/db/sqlite3/test.sqlite3_5 +0 -0
  68. data/test/dummy/db/sqlite3/test.sqlite3_6 +0 -0
  69. data/test/dummy/db/sqlite3/test.sqlite3_7 +0 -0
  70. data/test/dummy/db/sqlite3/test.sqlite3_8 +0 -0
  71. data/test/dummy/db/sqlite3/test.sqlite3_9 +0 -0
  72. data/test/dummy_install/db/migrate/20260217112949_create_geneva_drive_workflows.rb +76 -0
  73. data/test/dummy_install/db/migrate/20260217112950_create_geneva_drive_step_executions.rb +99 -0
  74. data/test/dummy_install/db/migrate/20260217112951_add_finished_at_to_geneva_drive_step_executions.rb +27 -0
  75. data/test/dummy_install/db/migrate/20260217112952_add_error_class_name_to_geneva_drive_step_executions.rb +9 -0
  76. data/test/dummy_install/db/migrate/20260217112953_add_resumable_step_support_to_geneva_drive_step_executions.rb +128 -0
  77. data/test/dummy_install/db/migrate/20260217112954_add_chained_step_executions_to_geneva_drive_step_executions.rb +136 -0
  78. data/test/dummy_install/db/schema.rb +75 -0
  79. data/test/dummy_install/log/test.log +1852 -0
  80. data/test/generators/install_generator_test.rb +56 -0
  81. data/test/geneva_drive_test.rb +10 -0
  82. data/test/migration_helpers_test.rb +62 -0
  83. data/test/sidekiq/sidekiq_dispatcher_test.rb +322 -0
  84. data/test/sidekiq/sidekiq_redis_test.rb +115 -0
  85. data/test/test_helper.rb +51 -13
  86. data/test/workflow/pause_resume_test.rb +15 -0
  87. data/test/workflow/removed_step_test.rb +294 -0
  88. data/test/workflow/resume_at_test.rb +170 -0
  89. metadata +62 -1
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ # JSON encoding for columns the database does not type as JSON itself.
4
+ #
5
+ # PostgreSQL and MySQL both hand Rails a real JSON attribute, so a Hash
6
+ # assigned to one is encoded by the adapter and comes back decoded. MariaDB
7
+ # has no JSON type - see GenevaDrive::MigrationHelpers#geneva_drive_json_column
8
+ # for why - so the column is plain LONGTEXT, Rails types it as text, and a Hash
9
+ # written to it would be stored as `#inspect` output. Encoding it here keeps
10
+ # the models writing and reading the same Ruby values everywhere.
11
+ #
12
+ # The branch is on the column, not on the adapter, deliberately: an install
13
+ # that already ran the old migration against MariaDB has a LONGTEXT column and
14
+ # gets the encoding too, without needing its column rewritten first.
15
+ #
16
+ # @api private
17
+ module GenevaDrive::JsonColumn
18
+ extend ActiveSupport::Concern
19
+
20
+ class_methods do
21
+ # Whether the column holds JSON as text rather than as a JSON type, and so
22
+ # needs encoding and decoding here. Lazily detected: never touches the
23
+ # database at class definition time, since the column may not exist yet.
24
+ #
25
+ # @param name [String, Symbol] the column name
26
+ # @return [Boolean]
27
+ def json_column_as_text?(name)
28
+ name = name.to_s
29
+ @_json_column_as_text ||= {}
30
+ return @_json_column_as_text[name] if @_json_column_as_text.key?(name)
31
+
32
+ @_json_column_as_text[name] = begin
33
+ column = table_exists? && columns_hash[name]
34
+ column ? column.type == :text : false
35
+ rescue ActiveRecord::ActiveRecordError
36
+ false
37
+ end
38
+ end
39
+
40
+ # Clears the cached detection. Call after migrating in-process.
41
+ #
42
+ # @return [void]
43
+ def reset_json_column_cache!
44
+ remove_instance_variable(:@_json_column_as_text) if defined?(@_json_column_as_text)
45
+ end
46
+
47
+ # Prepares an already-serialized value for writing to the column.
48
+ #
49
+ # @param name [String, Symbol] the column name
50
+ # @param value [Object, nil] a JSON-safe Ruby value
51
+ # @return [Object, nil] the value, encoded to a JSON string where needed
52
+ def encode_json_column(name, value)
53
+ return value unless json_column_as_text?(name)
54
+ return nil if value.nil?
55
+
56
+ JSON.generate(value)
57
+ end
58
+
59
+ # Reverses {encode_json_column} for a value read off the column.
60
+ #
61
+ # @param name [String, Symbol] the column name
62
+ # @param raw [Object, nil] the raw attribute value
63
+ # @return [Object, nil] the decoded value
64
+ def decode_json_column(name, raw)
65
+ return raw unless json_column_as_text?(name)
66
+ return nil if raw.nil?
67
+ return raw unless raw.is_a?(String)
68
+
69
+ JSON.parse(raw)
70
+ end
71
+ end
72
+ end
@@ -9,13 +9,17 @@
9
9
  #
10
10
  # def change
11
11
  # create_table :geneva_drive_workflows, **geneva_drive_table_options do |t|
12
- # t.references :hero, polymorphic: true, type: geneva_drive_key_type
12
+ # t.references :hero, polymorphic: true, type: geneva_drive_hero_key_type
13
13
  # # ...
14
14
  # end
15
15
  # end
16
16
  # end
17
17
  #
18
18
  module GenevaDrive::MigrationHelpers
19
+ UUID_SQL_TYPE = /uuid|char\(36\)|varchar\(36\)/
20
+ # SQLite reports `id: :string` as a bare "varchar", without a length
21
+ STRING_SQL_TYPE = /char|text|clob/
22
+
19
23
  # Detects the appropriate key type for GenevaDrive tables.
20
24
  # Returns :uuid if the schema predominantly uses UUIDs, otherwise :bigint.
21
25
  #
@@ -25,13 +29,39 @@ module GenevaDrive::MigrationHelpers
25
29
  id_columns = _geneva_drive_app_id_columns
26
30
  return :bigint if id_columns.empty?
27
31
 
28
- uuid_count = id_columns.count { |col| col.sql_type.downcase.match?(/uuid|char\(36\)|varchar\(36\)/) }
32
+ uuid_count = id_columns.count { |col| col.sql_type.downcase.match?(UUID_SQL_TYPE) }
29
33
  other_count = id_columns.size - uuid_count
30
34
 
31
35
  (uuid_count > other_count) ? :uuid : :bigint
32
36
  end
33
37
  end
34
38
 
39
+ # Detects the column type for `hero_id` on the workflows table. Unlike
40
+ # {#geneva_drive_key_type}, this also recognizes string primary keys of any
41
+ # length (`varchar`, `char(n)`, `text`), so that heroes with string ids do not
42
+ # get a `hero_id` that casts their ids to integers.
43
+ #
44
+ # Returns :uuid if the schema predominantly uses UUIDs, :string if it predominantly
45
+ # uses UUIDs and string ids taken together, otherwise :bigint.
46
+ #
47
+ # @return [Symbol] :uuid, :string or :bigint
48
+ def geneva_drive_hero_key_type
49
+ @_geneva_drive_hero_key_type ||= begin
50
+ sql_types = _geneva_drive_app_id_columns.map { |col| col.sql_type.downcase }
51
+ uuid_count = sql_types.count { |t| t.match?(UUID_SQL_TYPE) }
52
+ string_count = sql_types.count { |t| !t.match?(UUID_SQL_TYPE) && t.match?(STRING_SQL_TYPE) }
53
+ integer_count = sql_types.size - uuid_count - string_count
54
+
55
+ if uuid_count > string_count + integer_count
56
+ :uuid
57
+ elsif uuid_count + string_count > integer_count
58
+ :string # a string column holds UUIDs just as well
59
+ else
60
+ :bigint
61
+ end
62
+ end
63
+ end
64
+
35
65
  # Returns options hash for create_table based on detected primary key type.
36
66
  # When UUIDs are detected, includes the most common default function from
37
67
  # existing tables so that new tables match the application's convention
@@ -48,6 +78,33 @@ module GenevaDrive::MigrationHelpers
48
78
  options
49
79
  end
50
80
 
81
+ # Column type and options for a column that holds a JSON document.
82
+ #
83
+ # PostgreSQL gets jsonb and MySQL gets its native json. MariaDB gets neither:
84
+ # it has no JSON type at all, `json` there being an alias for LONGTEXT with an
85
+ # auto-generated CHECK constraint named after the column. Two things go wrong
86
+ # with that alias. The schema dump comes back as `t.text` plus
87
+ # `t.check_constraint name: "cursor"`, and loading that dump re-emits the
88
+ # constraint name unquoted - `cursor` is reserved in MariaDB, so the load dies
89
+ # on a syntax error, which takes out `db:schema:load` and every parallel test
90
+ # worker with it. And because Rails types the column as text either way, a
91
+ # Hash assigned to it is written out as `#inspect` rather than JSON, which the
92
+ # very same CHECK constraint then rejects. Asking for plain LONGTEXT and doing
93
+ # the JSON encoding in the model (see GenevaDrive::JsonColumn) sidesteps both.
94
+ #
95
+ # @return [Array(Symbol, Hash)] the column type and its options
96
+ def geneva_drive_json_column
97
+ adapter = connection.adapter_name.downcase
98
+
99
+ if adapter.include?("postgresql")
100
+ [:jsonb, {}]
101
+ elsif connection.respond_to?(:mariadb?) && connection.mariadb?
102
+ [:text, {limit: 4_294_967_295}]
103
+ else
104
+ [:json, {}]
105
+ end
106
+ end
107
+
51
108
  private
52
109
 
53
110
  # Collects the id column from every application table (excludes system
@@ -69,7 +126,7 @@ module GenevaDrive::MigrationHelpers
69
126
  def _geneva_drive_dominant_uuid_default
70
127
  id_columns = _geneva_drive_app_id_columns
71
128
  uuid_defaults = id_columns.filter_map do |col|
72
- next unless col.sql_type.downcase.match?(/uuid|char\(36\)|varchar\(36\)/)
129
+ next unless col.sql_type.downcase.match?(UUID_SQL_TYPE)
73
130
  col.default_function
74
131
  end
75
132
 
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ # A gravestone left in place of a step that has been taken out of a workflow.
4
+ #
5
+ # Deleting a `step` outright is not safe while workflows are in flight: an
6
+ # execution row already scheduled for that name finds no definition when its
7
+ # job runs, and the workflow pauses with StepNotDefinedError. Declaring
8
+ # `removed_step :name` in the position the step used to occupy keeps the name
9
+ # resolvable, so those executions are skipped instead of stranding the
10
+ # workflow, while scheduling never spools a new execution for it again.
11
+ #
12
+ # A removed step holds nothing but a name - no block, no wait, no conditions.
13
+ # There is nothing left to configure about a step that does not run.
14
+ #
15
+ # @api private
16
+ class GenevaDrive::RemovedStepDefinition < GenevaDrive::StepDefinition
17
+ # Creates a gravestone for a step that no longer exists.
18
+ #
19
+ # The parent is handed a callable that never runs: a removed step is
20
+ # intercepted by the executor before it would be performed.
21
+ #
22
+ # @param name [String, Symbol] the name of the step that was removed
23
+ # @param call_location [Array<String, Integer>, nil] source location of the removed_step call
24
+ def initialize(name:, call_location: nil)
25
+ super(name: name, callable: :__removed_step_never_runs, call_location: call_location)
26
+ end
27
+
28
+ # Returns true to mark this as a gravestone rather than a runnable step.
29
+ #
30
+ # @return [Boolean]
31
+ def removed?
32
+ true
33
+ end
34
+ end
@@ -25,6 +25,8 @@
25
25
  # workflow.signal!(:payment_confirmed, payload: {amount_cents: 12_500})
26
26
  #
27
27
  class GenevaDrive::Signal < ActiveRecord::Base
28
+ include GenevaDrive::JsonColumn
29
+
28
30
  self.table_name = "geneva_drive_signals"
29
31
 
30
32
  # Signal lifecycle states as enum with string values.
@@ -109,7 +111,7 @@ class GenevaDrive::Signal < ActiveRecord::Base
109
111
  end
110
112
  end
111
113
 
112
- serialized
114
+ encode_json_column(:payload, serialized)
113
115
  end
114
116
  end
115
117
 
@@ -119,7 +121,7 @@ class GenevaDrive::Signal < ActiveRecord::Base
119
121
  #
120
122
  # @return [Object, nil] the payload
121
123
  def payload
122
- raw = self[:payload]
124
+ raw = self.class.decode_json_column(:payload, self[:payload])
123
125
  return nil if raw.nil?
124
126
 
125
127
  value = ActiveJob::Arguments.deserialize([raw]).first
@@ -65,30 +65,54 @@ class GenevaDrive::StepCollection
65
65
  ordered_steps[position]
66
66
  end
67
67
 
68
- # Returns the next step after the given step name.
68
+ # Returns the next runnable step after the given step name.
69
+ #
70
+ # Steps declared with `removed_step` are stepped over: they hold a position
71
+ # in the sequence so that executions scheduled before their removal still
72
+ # resolve, but no execution is ever scheduled for one.
73
+ #
74
+ # A nil current_name means "the beginning", so the first runnable step is
75
+ # returned - which is how the first step of a workflow is chosen.
69
76
  #
70
77
  # @param current_name [String, Symbol, nil] the current step name
71
- # @return [StepDefinition, nil] the next step or nil if at end
78
+ # @return [StepDefinition, nil] the next runnable step or nil if at end
72
79
  def next_after(current_name)
73
- return first if current_name.nil?
80
+ return ordered_steps.find { |s| !s.removed? } if current_name.nil?
74
81
 
75
82
  current_index = ordered_steps.index { |s| s.name == current_name.to_s }
76
83
  return nil unless current_index
77
84
 
78
- self[current_index + 1]
85
+ ordered_steps[(current_index + 1)..].find { |s| !s.removed? }
79
86
  end
80
87
 
81
- # Returns the step before the given step name in the ordered sequence.
88
+ # Returns the previous runnable step before the given step name in the
89
+ # ordered sequence. Removed steps are stepped over, for the same reason
90
+ # {#next_after} steps over them.
82
91
  #
83
92
  # @param current_name [String, Symbol, nil] the current step name
84
- # @return [StepDefinition, nil] the previous step or nil if at beginning
93
+ # @return [StepDefinition, nil] the previous runnable step or nil if at beginning
85
94
  def previous_before(current_name)
86
95
  return nil if current_name.nil?
87
96
 
88
97
  current_index = find_index { |s| s.name == current_name.to_s }
89
98
  return nil unless current_index && current_index > 0
90
99
 
91
- self[current_index - 1]
100
+ ordered_steps[0...current_index].reverse_each.find { |s| !s.removed? }
101
+ end
102
+
103
+ # Returns the steps that can actually run, in order, with `removed_step`
104
+ # gravestones filtered out.
105
+ #
106
+ # @return [Array<StepDefinition>] the runnable step definitions
107
+ def runnable
108
+ ordered_steps.reject(&:removed?)
109
+ end
110
+
111
+ # Returns the `removed_step` gravestones, in order.
112
+ #
113
+ # @return [Array<StepDefinition>] the removed step definitions
114
+ def removed
115
+ ordered_steps.select(&:removed?)
92
116
  end
93
117
 
94
118
  # Checks if a step exists with the given name.
@@ -131,6 +131,15 @@ class GenevaDrive::StepDefinition
131
131
  false
132
132
  end
133
133
 
134
+ # Returns whether this step is a gravestone left by `removed_step`.
135
+ # Removed steps keep their name resolvable for executions scheduled before
136
+ # the step was taken out, but are never scheduled and never run.
137
+ #
138
+ # @return [Boolean] false for standard steps
139
+ def removed?
140
+ false
141
+ end
142
+
134
143
  # Whether this step parks until a matching signal arrives.
135
144
  #
136
145
  # @return [Boolean]
@@ -20,6 +20,7 @@ class GenevaDrive::StepExecution < ActiveRecord::Base
20
20
 
21
21
  require_relative "step_execution/metadata_accessor"
22
22
  include MetadataAccessor
23
+ include GenevaDrive::JsonColumn
23
24
 
24
25
  # Step execution states as enum with string values
25
26
  # Provides: scheduled?, in_progress?, etc. predicates
@@ -162,7 +163,7 @@ class GenevaDrive::StepExecution < ActiveRecord::Base
162
163
  end
163
164
  end
164
165
 
165
- serialized
166
+ encode_json_column(:cursor, serialized)
166
167
  end
167
168
  end
168
169
 
@@ -250,7 +251,7 @@ class GenevaDrive::StepExecution < ActiveRecord::Base
250
251
  def cursor_value
251
252
  return nil unless self.class.resumable_columns?
252
253
  return nil if cursor.blank?
253
- ActiveJob::Arguments.deserialize([cursor]).first
254
+ ActiveJob::Arguments.deserialize([self.class.decode_json_column(:cursor, cursor)]).first
254
255
  end
255
256
 
256
257
  # Sets the cursor value for resumable steps.
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module GenevaDrive
4
- VERSION = "0.7.0"
4
+ VERSION = "0.9.0"
5
5
  end
@@ -206,6 +206,92 @@ class GenevaDrive::Workflow < ActiveRecord::Base
206
206
  step_def
207
207
  end
208
208
 
209
+ # Marks a step as removed, leaving a gravestone in the position the step
210
+ # used to occupy.
211
+ #
212
+ # Deleting a `step` outright is unsafe while workflows are in flight.
213
+ # Executions scheduled under the old name find no definition when their
214
+ # job runs, and the workflow pauses with {StepNotDefinedError} - stuck
215
+ # until an operator intervenes. The gem cannot recover from this on its
216
+ # own: it knows the step is gone, but not whether the work it did still
217
+ # needs doing, and not which of the remaining steps would have run had it
218
+ # executed. Only the author of the removal knows that.
219
+ #
220
+ # `removed_step` is how that knowledge gets written down. Declaring it
221
+ # where the step used to be says "this step no longer needs to happen,
222
+ # carry on past it", and the gem can then act without guessing:
223
+ #
224
+ # - Executions already scheduled for the name resolve, are skipped, and
225
+ # the workflow proceeds to the next runnable step.
226
+ # - Scheduling never spools a new execution for the name again.
227
+ # - `before_step:` / `after_step:` references to it keep working.
228
+ #
229
+ # Ship the `removed_step`, let in-flight workflows drain past it, then
230
+ # delete the line once {.removed_steps_in_flight} comes back empty.
231
+ #
232
+ # A removed step takes a name and nothing else. There is nothing to
233
+ # configure about a step that does not run.
234
+ #
235
+ # @param name [String, Symbol] the name of the step that was removed
236
+ # @return [RemovedStepDefinition] the gravestone
237
+ #
238
+ # @example Taking a step out of a live workflow
239
+ # class PaymentWorkflow < GenevaDrive::Workflow
240
+ # step :authorize do
241
+ # # ...
242
+ # end
243
+ #
244
+ # removed_step :capture_payment
245
+ #
246
+ # step :send_receipt do
247
+ # # ...
248
+ # end
249
+ # end
250
+ def removed_step(name)
251
+ raise ArgumentError, "removed_step requires a step name" if name.nil?
252
+
253
+ caller_loc = caller_locations(1, 1).first
254
+ call_location = caller_loc ? [caller_loc.path, caller_loc.lineno] : nil
255
+
256
+ step_name = prepare_step_registration!(name, {})
257
+
258
+ step_def = GenevaDrive::RemovedStepDefinition.new(
259
+ name: step_name,
260
+ call_location: call_location
261
+ )
262
+
263
+ _step_definitions << step_def
264
+
265
+ step_def
266
+ end
267
+
268
+ # Returns the names of this workflow's removed steps that step execution
269
+ # rows still reference, so a `removed_step` gravestone is only deleted
270
+ # once nothing can land on it any more.
271
+ #
272
+ # Each name maps to the number of rows still referencing it. An empty
273
+ # hash means every in-flight workflow has drained past the removal and
274
+ # the `removed_step` lines can go.
275
+ #
276
+ # Rows belonging to finished and canceled workflows are ignored - they
277
+ # will never execute again.
278
+ #
279
+ # @return [Hash{String => Integer}] removed step names to referencing row counts
280
+ #
281
+ # @example
282
+ # PaymentWorkflow.removed_steps_in_flight
283
+ # # => {"capture_payment" => 3}
284
+ def removed_steps_in_flight
285
+ removed_names = steps.removed.map(&:name)
286
+ return {} if removed_names.empty?
287
+
288
+ GenevaDrive::StepExecution
289
+ .where(step_name: removed_names)
290
+ .where(workflow_id: where.not(state: %w[finished canceled]).select(:id))
291
+ .group(:step_name)
292
+ .count
293
+ end
294
+
209
295
  # Defines a blanket cancellation condition for the workflow.
210
296
  # Checked before every step execution.
211
297
  #
@@ -226,8 +312,8 @@ class GenevaDrive::Workflow < ActiveRecord::Base
226
312
  _cancel_conditions << block if block_given?
227
313
  end
228
314
 
229
- # Sets job options for step execution jobs.
230
- # Options are passed to ActiveJob's set method.
315
+ # Sets job options for step execution jobs. See GenevaDrive::JobOptions
316
+ # for the keys allowed, and the dispatcher for how they are applied.
231
317
  #
232
318
  # @param options [Hash] job options (queue, priority, etc.)
233
319
  # @return [void]
@@ -566,6 +652,73 @@ class GenevaDrive::Workflow < ActiveRecord::Base
566
652
 
567
653
  # No scheduled execution exists - create one for the next step
568
654
  step_def = steps.named(next_step_name)
655
+
656
+ # The step this workflow is pointed at can be gone from the code - that is
657
+ # what paused it in the first place if it paused with StepNotDefinedError.
658
+ # Say so plainly instead of dying on nil deeper in create_step_execution,
659
+ # and name the way out.
660
+ unless step_def
661
+ raise GenevaDrive::StepNotDefinedError.new(
662
+ "Cannot resume #{self.class.name} ##{id}: step '#{next_step_name}' is no longer defined in " \
663
+ "#{self.class.name}. Pick the step to continue from with resume_at!(:step_name) - the steps " \
664
+ "still defined are #{steps.runnable.map(&:name).join(", ")} - or cancel! the workflow. To stop " \
665
+ "future removals from stranding workflows this way, leave a removed_step :#{next_step_name} " \
666
+ "gravestone in place of the deleted step.",
667
+ step_execution: nil,
668
+ workflow: self
669
+ )
670
+ end
671
+
672
+ create_step_execution(step_def, wait: nil)
673
+ end
674
+
675
+ # Resumes a paused workflow at a specific step, instead of at whatever step
676
+ # it was pointed at when it paused.
677
+ #
678
+ # This is the operator's escape hatch for a workflow stranded on a step that
679
+ # no longer exists - the step was deleted without leaving a
680
+ # {GenevaDrive::Workflow.removed_step} gravestone, so the workflow paused
681
+ # with {StepNotDefinedError} and plain {#resume!} has nowhere to go.
682
+ #
683
+ # Where to land is deliberately a human decision. A deleted step's position
684
+ # in the sequence says nothing about whether the steps that follow it were
685
+ # ever meant to run: a `capture_payment` that normally ends the workflow by
686
+ # calling `finished!` sits directly before `refund`, and nothing in the
687
+ # database distinguishes "carry on" from "stop here". Only the person who
688
+ # removed the step knows which, so this method asks them.
689
+ #
690
+ # Any stray scheduled execution is canceled first, so the workflow continues
691
+ # from the named step and nowhere else.
692
+ #
693
+ # @param step_name [String, Symbol] the step to continue from
694
+ # @return [StepExecution] the created step execution
695
+ # @raise [InvalidStateError] if the workflow is not paused
696
+ # @raise [StepNotDefinedError] if the named step is not defined, or is a removed_step
697
+ #
698
+ # @example Continue a stranded workflow past a deleted step
699
+ # workflow = GenevaDrive::Workflow.find(id)
700
+ # workflow.resume_at!(:send_receipt)
701
+ def resume_at!(step_name)
702
+ raise GenevaDrive::InvalidStateError, "Cannot resume a #{state} workflow" unless state == "paused"
703
+
704
+ step_def = steps.named(step_name.to_s)
705
+
706
+ if step_def.nil? || step_def.removed?
707
+ reason = step_def ? "a removed_step" : "not defined"
708
+ raise GenevaDrive::StepNotDefinedError.new(
709
+ "Cannot resume #{self.class.name} ##{id} at '#{step_name}': that step is #{reason} in " \
710
+ "#{self.class.name}. The steps it can be resumed at are #{steps.runnable.map(&:name).join(", ")}.",
711
+ step_execution: nil,
712
+ workflow: self
713
+ )
714
+ end
715
+
716
+ logger.info("Resuming paused workflow at step #{step_def.name.inspect} (was pointed at #{next_step_name.inspect})")
717
+
718
+ with_lock do
719
+ update!(state: "ready", current_step_name: nil, transitioned_at: nil)
720
+ end
721
+
569
722
  create_step_execution(step_def, wait: nil)
570
723
  end
571
724
 
@@ -920,7 +1073,7 @@ class GenevaDrive::Workflow < ActiveRecord::Base
920
1073
  #
921
1074
  # @return [void]
922
1075
  def log_workflow_created
923
- step_count = self.class.step_definitions.size
1076
+ step_count = steps.runnable.size
924
1077
  logger.info("Created workflow with #{step_count} step(s) defined")
925
1078
  end
926
1079
 
@@ -928,7 +1081,10 @@ class GenevaDrive::Workflow < ActiveRecord::Base
928
1081
  #
929
1082
  # @return [StepExecution, nil] the created step execution
930
1083
  def schedule_first_step!
931
- first_step = self.class.step_definitions.first
1084
+ # next_after(nil) means "from the beginning" and honours both the
1085
+ # before_step:/after_step: ordering and removed_step gravestones, neither
1086
+ # of which the raw _step_definitions array knows about.
1087
+ first_step = steps.next_after(nil)
932
1088
  unless first_step
933
1089
  logger.info("No steps defined, finishing workflow immediately")
934
1090
  return finish_workflow!
@@ -948,7 +1104,7 @@ class GenevaDrive::Workflow < ActiveRecord::Base
948
1104
  signal
949
1105
  end
950
1106
 
951
- # Enqueues the PerformStepJob for an execution that dispatch just flipped
1107
+ # Enqueues the step job for an execution that dispatch just flipped
952
1108
  # from waiting to scheduled. Uses the same enqueue discipline (merged job
953
1109
  # options, after-commit deferral, job_id writeback) as create_step_execution.
954
1110
  #
@@ -961,15 +1117,7 @@ class GenevaDrive::Workflow < ActiveRecord::Base
961
1117
  workflow_logger = logger
962
1118
 
963
1119
  run_after_commit do
964
- job = GenevaDrive::PerformStepJob
965
- .set(**job_options)
966
- .perform_later(execution_id)
967
-
968
- workflow_logger.debug("Enqueued PerformStepJob with job_id=#{job.job_id} for woken step execution #{execution_id}")
969
-
970
- GenevaDrive::StepExecution
971
- .where(id: execution_id)
972
- .update_all(job_id: job.job_id)
1120
+ dispatch_step_job(execution_id, job_options, logger: workflow_logger)
973
1121
  end
974
1122
  end
975
1123
 
@@ -981,6 +1129,18 @@ class GenevaDrive::Workflow < ActiveRecord::Base
981
1129
  # @return [StepExecution] the same execution (rescheduled if a signal matched)
982
1130
  def rendezvous_waiting_execution!(step_execution)
983
1131
  step_def = step_execution.step_definition
1132
+
1133
+ # The step was removed while this execution sat parked. It waits for an
1134
+ # event that nothing will ever match now, so release it here rather than
1135
+ # leaving it parked for good.
1136
+ if step_def&.removed?
1137
+ logger.info("Step #{step_execution.step_name} was parked but is now a removed_step — releasing it and continuing")
1138
+
1139
+ step_execution.mark_skipped!
1140
+ settle_signal_for_skipped!(step_execution)
1141
+ return schedule_next_step!
1142
+ end
1143
+
984
1144
  matcher = step_def&.signal_matcher
985
1145
 
986
1146
  if matcher
@@ -1022,12 +1182,9 @@ class GenevaDrive::Workflow < ActiveRecord::Base
1022
1182
  # PerformStepJob sets `enqueue_after_transaction_commit = :never` -- without it,
1023
1183
  # ActiveJob/SolidQueue would see the transaction as "open" and defer the queue
1024
1184
  # INSERT to a second "after commit" that never fires (the double-deferral bug).
1185
+ # SidekiqDispatcher bypasses Sidekiq's transactional push for the same reason.
1025
1186
  run_after_commit do
1026
- job = GenevaDrive::PerformStepJob
1027
- .set(**job_options)
1028
- .perform_later(execution_id)
1029
-
1030
- workflow_logger.debug("Enqueued job #{job.job_id} for step execution #{execution_id}")
1187
+ dispatch_step_job(execution_id, job_options, logger: workflow_logger)
1031
1188
  end
1032
1189
 
1033
1190
  step_execution
@@ -1077,15 +1234,7 @@ class GenevaDrive::Workflow < ActiveRecord::Base
1077
1234
  workflow_logger = logger
1078
1235
 
1079
1236
  run_after_commit do
1080
- job = GenevaDrive::PerformStepJob
1081
- .set(**job_options)
1082
- .perform_later(successor_id)
1083
-
1084
- workflow_logger.debug("Enqueued PerformStepJob with job_id=#{job.job_id} for successor execution ##{successor_id}")
1085
-
1086
- GenevaDrive::StepExecution
1087
- .where(id: successor_id)
1088
- .update_all(job_id: job.job_id)
1237
+ dispatch_step_job(successor_id, job_options, logger: workflow_logger)
1089
1238
  end
1090
1239
 
1091
1240
  successor
@@ -1133,17 +1282,9 @@ class GenevaDrive::Workflow < ActiveRecord::Base
1133
1282
  # without it, ActiveJob/SolidQueue would see the transaction as "open" and defer
1134
1283
  # the queue INSERT to a second "after commit" that never fires (the double-deferral
1135
1284
  # bug). See the extensive comment in PerformStepJob for the full explanation.
1285
+ # SidekiqDispatcher bypasses Sidekiq's transactional push for the same reason.
1136
1286
  run_after_commit do
1137
- job = GenevaDrive::PerformStepJob
1138
- .set(**job_options)
1139
- .perform_later(execution_id)
1140
-
1141
- workflow_logger.debug("Enqueued PerformStepJob with job_id=#{job.job_id} for step execution #{execution_id}")
1142
-
1143
- # Update job_id for debugging purposes (this is a separate transaction)
1144
- GenevaDrive::StepExecution
1145
- .where(id: execution_id)
1146
- .update_all(job_id: job.job_id)
1287
+ dispatch_step_job(execution_id, job_options, logger: workflow_logger)
1147
1288
  end
1148
1289
 
1149
1290
  step_execution
@@ -1179,6 +1320,26 @@ class GenevaDrive::Workflow < ActiveRecord::Base
1179
1320
  nil
1180
1321
  end
1181
1322
 
1323
+ # Hands a step execution to the configured dispatcher (GenevaDrive.dispatcher)
1324
+ # and writes the returned job id onto the execution, so that the job can be
1325
+ # found from the row. Always called from inside run_after_commit.
1326
+ #
1327
+ # @param execution_id [Integer, String] the step execution to perform
1328
+ # @param job_options [Hash] merged, validated job options
1329
+ # @param logger [Logger] the workflow logger captured before the commit
1330
+ # @return [void]
1331
+ def dispatch_step_job(execution_id, job_options, logger:)
1332
+ job_id = GenevaDrive.dispatcher.enqueue_step(execution_id, job_options, workflow_class_name: self.class.name)
1333
+
1334
+ logger.debug("Enqueued step job with job_id=#{job_id} for step execution #{execution_id}")
1335
+
1336
+ # A separate statement, outside any transaction - the job may already be running.
1337
+ # resume! re-enqueues an existing execution, so this overwrites the old job's id.
1338
+ GenevaDrive::StepExecution
1339
+ .where(id: execution_id)
1340
+ .update_all(job_id: job_id)
1341
+ end
1342
+
1182
1343
  # Runs a block either after all transactions commit or immediately.
1183
1344
  #
1184
1345
  # The behavior depends on GenevaDrive.enqueues_deferred?, which checks:
@@ -1192,7 +1353,7 @@ class GenevaDrive::Workflow < ActiveRecord::Base
1192
1353
  # In tests (enqueue_after_commit = false), or inside a
1193
1354
  # without_deferred_enqueues block, the block runs immediately.
1194
1355
  #
1195
- # @yield the block to run (typically contains perform_later call)
1356
+ # @yield the block to run (typically hands a step to the dispatcher)
1196
1357
  # @return [void]
1197
1358
  def run_after_commit(&block)
1198
1359
  if GenevaDrive.enqueues_deferred?