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
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ class AddMetadataToGenevaDriveWorkflows < ActiveRecord::Migration[7.2]
4
+ def change
5
+ return if column_exists?(:geneva_drive_workflows, :metadata)
6
+
7
+ adapter = connection.adapter_name.downcase
8
+
9
+ if adapter.include?("postgresql")
10
+ add_column :geneva_drive_workflows, :metadata, :jsonb
11
+ elsif adapter.include?("mysql")
12
+ add_column :geneva_drive_workflows, :metadata, :text, limit: 4_294_967_295
13
+ else
14
+ add_column :geneva_drive_workflows, :metadata, :text
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ class AddResumableStepSupportToGenevaDriveStepExecutions < ActiveRecord::Migration[7.2]
4
+ include GenevaDrive::MigrationHelpers
5
+
6
+ def change
7
+ unless column_exists?(:geneva_drive_step_executions, :cursor)
8
+ # Cursor for resumable steps. Use database-native JSON type:
9
+ # - PostgreSQL: jsonb (indexed, efficient, supports containment queries)
10
+ # - MySQL 5.7+: json (native validation and storage)
11
+ # - SQLite: json (Rails handles as TEXT with serialization)
12
+ if connection.adapter_name.downcase.include?("postgresql")
13
+ add_column :geneva_drive_step_executions, :cursor, :jsonb
14
+ else
15
+ add_column :geneva_drive_step_executions, :cursor, :json
16
+ end
17
+ end
18
+
19
+ unless column_exists?(:geneva_drive_step_executions, :continues_from_id)
20
+ # Link successor executions to their predecessor, chaining the execution
21
+ # records of a resumable step. Match the primary key type (bigint or uuid)
22
+ # of the step_executions table.
23
+ # No foreign key constraint - SQLite rewrites the table on add_foreign_key,
24
+ # which can destroy data.
25
+ add_column :geneva_drive_step_executions, :continues_from_id, geneva_drive_key_type
26
+ add_index :geneva_drive_step_executions, :continues_from_id
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Signals: external events delivered to a workflow, and the two columns on
4
+ # step executions that let one park until a matching event arrives. Both halves
5
+ # ship together - a signals table nobody can wait on is useless, and a waiting
6
+ # step with nowhere to read its event from is worse - so they are one migration.
7
+ class AddSignalsSupportToGenevaDrive < ActiveRecord::Migration[7.2]
8
+ include GenevaDrive::MigrationHelpers
9
+
10
+ def change
11
+ key_type = geneva_drive_key_type
12
+ adapter = connection.adapter_name.downcase
13
+
14
+ # Build reference options - we add the foreign key separately to avoid
15
+ # MySQL type mismatch (see below).
16
+ reference_options = {
17
+ null: false,
18
+ index: true
19
+ }
20
+ reference_options[:type] = key_type if key_type == :uuid
21
+
22
+ create_table :geneva_drive_signals, **geneva_drive_table_options do |t|
23
+ # Link to workflow (cascade delete when the workflow is deleted)
24
+ t.references :workflow, **reference_options
25
+
26
+ # The event name, as passed to Workflow#signal!
27
+ t.string :name, null: false
28
+
29
+ # Optional caller-supplied deduplication key. NULLs are distinct in
30
+ # unique indexes on PostgreSQL, MySQL and SQLite alike, so signals
31
+ # without an idempotency key always insert.
32
+ t.string :idempotency_key
33
+
34
+ # Lifecycle: pending -> claimed -> consumed
35
+ t.string :state, null: false, default: "pending"
36
+
37
+ # Serialized payload. Use the database-native JSON type, same flavor
38
+ # logic as the resumable step cursor:
39
+ # - PostgreSQL: jsonb
40
+ # - MySQL 5.7+: json
41
+ # - SQLite: json (Rails handles as TEXT with serialization)
42
+ if adapter.include?("postgresql")
43
+ t.jsonb :payload
44
+ else
45
+ t.json :payload
46
+ end
47
+
48
+ t.datetime :claimed_at
49
+ t.datetime :consumed_at
50
+
51
+ # How many executions have attached to this signal, and how many of
52
+ # those attached chains have resolved cleanly. For a linear workflow
53
+ # both end at 1 (more claims if the step was retried); when one signal
54
+ # is dispatched onto several concurrent executions the pair is the
55
+ # progress readout of that fan-out. Counters, not the lifecycle state -
56
+ # the `state` column above is what says claimed or consumed.
57
+ t.bigint :claimed, null: false, default: 0
58
+ t.bigint :consumed, null: false, default: 0
59
+
60
+ t.timestamps
61
+ end
62
+
63
+ # Deduplication: one signal per (workflow, name, idempotency_key)
64
+ add_index :geneva_drive_signals, [:workflow_id, :name, :idempotency_key],
65
+ unique: true, name: "index_geneva_drive_signals_dedup"
66
+
67
+ # Scanning a workflow's unconsumed signals
68
+ add_index :geneva_drive_signals, [:workflow_id, :state]
69
+
70
+ # Add foreign key separately to avoid MySQL type mismatch (UNSIGNED vs SIGNED bigint)
71
+ # MySQL creates primary keys as UNSIGNED but references as SIGNED, causing FK constraint failure
72
+ unless adapter.include?("mysql")
73
+ add_foreign_key :geneva_drive_signals, :geneva_drive_workflows,
74
+ column: :workflow_id, on_delete: :cascade
75
+ end
76
+
77
+ unless column_exists?(:geneva_drive_step_executions, :signal_id)
78
+ # The attachment pin: which signal this execution is processing. One
79
+ # signal may be pinned by many executions, so this lives on the
80
+ # execution side. Match the primary key type (bigint or uuid) of the
81
+ # signals table.
82
+ # No foreign key constraint - SQLite rewrites the table on
83
+ # add_foreign_key, which can destroy data.
84
+ add_column :geneva_drive_step_executions, :signal_id, key_type
85
+ add_index :geneva_drive_step_executions, :signal_id
86
+ end
87
+
88
+ unless column_exists?(:geneva_drive_step_executions, :waiting_since)
89
+ # When the execution parked waiting for a signal, so "waiting for N
90
+ # days" is queryable without abusing updated_at.
91
+ add_column :geneva_drive_step_executions, :waiting_since, :datetime
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ class AddStartedAtIndexToGenevaDriveStepExecutions < ActiveRecord::Migration[7.2]
4
+ # Partial index scoped to state = 'in_progress'. HousekeepingJob's
5
+ # recover_stuck_in_progress! filters `state = 'in_progress' AND
6
+ # started_at < cutoff LIMIT N`; without this the planner walks the whole
7
+ # in-progress set looking for old rows and trips the caller's
8
+ # statement_timeout on large / mostly-fresh workloads.
9
+ INDEX_NAME = :index_geneva_drive_step_executions_in_progress_started_at
10
+
11
+ disable_ddl_transaction!
12
+
13
+ def up
14
+ return if index_name_exists?(:geneva_drive_step_executions, INDEX_NAME)
15
+
16
+ adapter = connection.adapter_name.downcase
17
+
18
+ if adapter.include?("postgresql")
19
+ add_index :geneva_drive_step_executions, :started_at,
20
+ name: INDEX_NAME,
21
+ where: "state = 'in_progress'",
22
+ algorithm: :concurrently
23
+ elsif adapter.include?("sqlite")
24
+ # SQLite supports partial indexes but has no CONCURRENTLY.
25
+ add_index :geneva_drive_step_executions, :started_at,
26
+ name: INDEX_NAME,
27
+ where: "state = 'in_progress'"
28
+ else
29
+ # MySQL does not support partial indexes; fall back to a plain index.
30
+ # Composite (state, started_at) so the planner can seek by state first.
31
+ add_index :geneva_drive_step_executions, [:state, :started_at],
32
+ name: INDEX_NAME
33
+ end
34
+ end
35
+
36
+ def down
37
+ return unless index_name_exists?(:geneva_drive_step_executions, INDEX_NAME)
38
+
39
+ adapter = connection.adapter_name.downcase
40
+ if adapter.include?("postgresql")
41
+ remove_index :geneva_drive_step_executions,
42
+ name: INDEX_NAME,
43
+ algorithm: :concurrently
44
+ else
45
+ remove_index :geneva_drive_step_executions, name: INDEX_NAME
46
+ end
47
+ end
48
+ end
@@ -4,6 +4,8 @@ class CreateGenevaDriveWorkflows < ActiveRecord::Migration[7.2]
4
4
  include GenevaDrive::MigrationHelpers
5
5
 
6
6
  def change
7
+ adapter = connection.adapter_name.downcase
8
+
7
9
  create_table :geneva_drive_workflows, **geneva_drive_table_options do |t|
8
10
  # Core identification (STI)
9
11
  t.string :type, null: false, index: true
@@ -22,6 +24,15 @@ class CreateGenevaDriveWorkflows < ActiveRecord::Migration[7.2]
22
24
  # Multiple workflows of same type for same hero
23
25
  t.boolean :allow_multiple, default: false, null: false
24
26
 
27
+ # Freeform JSON metadata
28
+ if adapter.include?("postgresql")
29
+ t.jsonb :metadata
30
+ elsif adapter.include?("mysql")
31
+ t.text :metadata, limit: 4_294_967_295
32
+ else
33
+ t.text :metadata
34
+ end
35
+
25
36
  # Timestamps
26
37
  t.datetime :started_at
27
38
  t.datetime :transitioned_at
@@ -31,6 +31,14 @@
31
31
  #
32
32
  # GenevaDrive.housekeeping_batch_size = 1000
33
33
 
34
+ # Maximum size in bytes of a resumable step cursor once serialized to JSON.
35
+ # The cursor is a position marker (an id, page number, or pagination token),
36
+ # not a place to store the data being processed - it is rewritten on every
37
+ # checkpoint and copied to every successor execution. Exceeding the limit
38
+ # raises GenevaDrive::CursorTooLargeError. Set to nil to disable the check.
39
+ #
40
+ # GenevaDrive.max_cursor_size = 128.kilobytes
41
+
34
42
  # Whether to defer job enqueueing until after the database transaction commits.
35
43
  # In production this ensures step execution records are visible to job workers
36
44
  # before the job runs. In test environments (the default when Rails.env.test?)
@@ -88,7 +88,7 @@ class GenevaDrive::CombinedExceptionPolicy
88
88
  cap = max_reattempts
89
89
  if cap && result[:action] == :reattempt && reattempt_count >= cap
90
90
  terminal = child.terminal_action.to_s.chomp("!").to_sym
91
- return {action: terminal, error: error}
91
+ return {action: terminal, error: error, report: result[:report], terminal: true}
92
92
  end
93
93
 
94
94
  result
@@ -85,6 +85,9 @@ class GenevaDrive::ExceptionPolicy
85
85
  # Each entry can be an Exception subclass or any object responding to #===.
86
86
  attr_reader :exception_matchers
87
87
 
88
+ # @return [Symbol] when to report the exception via Rails.error.report (:always, :never, or :terminal_only)
89
+ attr_reader :report
90
+
88
91
  # @return [Proc, nil] the handler block (imperative mode)
89
92
  attr_reader :handler
90
93
 
@@ -93,7 +96,10 @@ class GenevaDrive::ExceptionPolicy
93
96
  # Valid terminal_action values
94
97
  VALID_TERMINAL_ACTIONS = %i[pause! cancel! skip!].freeze
95
98
 
96
- # @overload initialize(action, matching: nil, wait: nil, max_reattempts: nil, terminal_action: :pause!)
99
+ # Valid report values
100
+ VALID_REPORT_OPTIONS = %i[always never terminal_only].freeze
101
+
102
+ # @overload initialize(action, matching: nil, wait: nil, max_reattempts: nil, terminal_action: :pause!, report: :always)
97
103
  # Declarative mode — specify action and options.
98
104
  # @param action [Symbol] the flow control action (:pause!, :cancel!, :reattempt!, :skip!)
99
105
  # @param matching [Class, String, #===, Array<Class, String, #===>, nil] exception classes
@@ -104,12 +110,19 @@ class GenevaDrive::ExceptionPolicy
104
110
  # @param wait [ActiveSupport::Duration, nil] wait time before reattempt
105
111
  # @param max_reattempts [Integer, nil] max consecutive reattempts (nil = unlimited)
106
112
  # @param terminal_action [Symbol] what to do when max_reattempts is exceeded (:pause!, :cancel!, or :skip!)
113
+ # @param report [Symbol] when to report the exception to +Rails.error.report+.
114
+ # - +:always+ (default) — report every exception regardless of what action is taken
115
+ # - +:never+ — never report; the exception is expected and handled by the policy
116
+ # - +:terminal_only+ — suppress reports while the step is being reattempted, but
117
+ # report when reattempts are exhausted and the +terminal_action+ fires
107
118
  #
108
- # @overload initialize(matching: nil, &block)
119
+ # @overload initialize(matching: nil, report: :always, &block)
109
120
  # Imperative mode — block receives exception, runs in workflow context.
110
121
  # Must call a flow control method (reattempt!, cancel!, pause!, skip!).
111
- # Can be combined with +matching:+ to target specific exception classes.
122
+ # Can be combined with +matching:+ and +report:+ to target specific exception classes
123
+ # and control error reporting.
112
124
  # @param matching [Class, String, #===, Array<Class, String, #===>, nil] exception classes
125
+ # @param report [Symbol] when to report the exception (+:always+, +:never+, or +:terminal_only+)
113
126
  # @yield [error] the exception that was raised
114
127
  #
115
128
  # @example Blanket reattempt policy
@@ -126,7 +139,18 @@ class GenevaDrive::ExceptionPolicy
126
139
  #
127
140
  # @example Imperative policy with exception matching
128
141
  # ExceptionPolicy.new(matching: Timeout::Error) { |error| reattempt!(wait: error.retry_after) }
129
- def initialize(action = nil, wait: nil, max_reattempts: nil, terminal_action: :pause!, matching: nil, &block)
142
+ #
143
+ # @example Suppress error reporting for expected exceptions
144
+ # ExceptionPolicy.new(:reattempt!, matching: RateLimitError, report: :never)
145
+ #
146
+ # @example Only report when reattempts are exhausted
147
+ # ExceptionPolicy.new(:reattempt!, matching: Net::OpenTimeout, max_reattempts: 5, report: :terminal_only)
148
+ def initialize(action = nil, wait: nil, max_reattempts: nil, terminal_action: :pause!, matching: nil, report: :always, &block)
149
+ @report = report
150
+ unless VALID_REPORT_OPTIONS.include?(@report)
151
+ raise ArgumentError,
152
+ "report: must be one of #{VALID_REPORT_OPTIONS.join(", ")}, got #{@report.inspect}"
153
+ end
130
154
  if block
131
155
  if action || wait || max_reattempts || terminal_action != :pause!
132
156
  raise ArgumentError,
@@ -191,8 +215,10 @@ class GenevaDrive::ExceptionPolicy
191
215
  # in the workflow context and translates the resulting flow control signal
192
216
  # into a result.
193
217
  #
194
- # The returned Hash always contains +:action+ (Symbol without +!+) and
195
- # +:error+ (the original exception). Reattempt results also include +:wait+.
218
+ # The returned Hash always contains +:action+ (Symbol without +!+),
219
+ # +:error+ (the original exception), and +:report+ (the reporting mode).
220
+ # Reattempt results also include +:wait+. When the terminal action fires
221
+ # (reattempt limit exceeded), +:terminal+ is set to +true+.
196
222
  #
197
223
  # @param error [Exception] the exception that was raised
198
224
  # @param reattempt_count [Integer] consecutive reattempts so far for this step
@@ -241,9 +267,9 @@ class GenevaDrive::ExceptionPolicy
241
267
  end
242
268
 
243
269
  if signal.is_a?(GenevaDrive::FlowControlSignal)
244
- {action: signal.action, wait: signal.options[:wait], error: error}
270
+ {action: signal.action, wait: signal.options[:wait], error: error, report: @report}
245
271
  else
246
- {action: :pause, error: error}
272
+ {action: :pause, error: error, report: @report}
247
273
  end
248
274
  end
249
275
 
@@ -256,9 +282,9 @@ class GenevaDrive::ExceptionPolicy
256
282
  def apply_declarative(error, reattempt_count)
257
283
  if action == :reattempt! && max_reattempts && reattempt_count >= max_reattempts
258
284
  terminal = terminal_action.to_s.chomp("!").to_sym
259
- {action: terminal, error: error}
285
+ {action: terminal, error: error, report: @report, terminal: true}
260
286
  else
261
- {action: action.to_s.chomp("!").to_sym, wait: wait, error: error}
287
+ {action: action.to_s.chomp("!").to_sym, wait: wait, error: error, report: @report}
262
288
  end
263
289
  end
264
290