geneva_drive 0.5.0 → 0.6.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 (49) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +11 -0
  3. data/MANUAL.md +273 -11
  4. data/README.md +3 -1
  5. data/lib/generators/geneva_drive/install/install_generator.rb +15 -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_started_at_index_to_step_executions.rb +48 -0
  9. data/lib/generators/geneva_drive/install/templates/create_workflows_migration.rb +11 -0
  10. data/lib/generators/geneva_drive/install/templates/initializer.rb.tt +8 -0
  11. data/lib/geneva_drive/combined_exception_policy.rb +1 -1
  12. data/lib/geneva_drive/exception_policy.rb +36 -10
  13. data/lib/geneva_drive/executor.rb +239 -16
  14. data/lib/geneva_drive/flow_control.rb +44 -4
  15. data/lib/geneva_drive/iterable_step.rb +199 -0
  16. data/lib/geneva_drive/job_options.rb +70 -0
  17. data/lib/geneva_drive/jobs/housekeeping_job.rb +37 -4
  18. data/lib/geneva_drive/resumable_step_definition.rb +97 -0
  19. data/lib/geneva_drive/step_definition.rb +14 -1
  20. data/lib/geneva_drive/step_execution.rb +105 -2
  21. data/lib/geneva_drive/test_helpers.rb +123 -4
  22. data/lib/geneva_drive/version.rb +1 -1
  23. data/lib/geneva_drive/workflow/metadata_accessor.rb +85 -0
  24. data/lib/geneva_drive/workflow.rb +245 -34
  25. data/lib/geneva_drive.rb +15 -0
  26. data/test/dsl/step_definition_test.rb +70 -0
  27. data/test/jobs/housekeeping_job_test.rb +119 -0
  28. data/test/jobs/perform_step_job_test.rb +25 -2
  29. data/test/test_helper.rb +2 -0
  30. data/test/test_helper_test.rb +281 -0
  31. data/test/workflow/cursor_size_limit_test.rb +75 -0
  32. data/test/workflow/instrumentation_test.rb +28 -0
  33. data/test/workflow/resumable_step_integration_test.rb +341 -0
  34. data/test/workflow/resumable_step_test.rb +615 -0
  35. data/test/workflow/resumable_without_migration_test.rb +103 -0
  36. data/test/workflow/workflow_test.rb +72 -1
  37. metadata +15 -15
  38. data/test/dummy/config/initializers/geneva_drive.rb +0 -44
  39. data/test/dummy/db/migrate/20260219212321_create_geneva_drive_workflows.rb +0 -74
  40. data/test/dummy/db/migrate/20260219212322_create_geneva_drive_step_executions.rb +0 -108
  41. data/test/dummy/db/migrate/20260219212323_add_finished_at_to_geneva_drive_step_executions.rb +0 -25
  42. data/test/dummy/db/migrate/20260219212324_add_error_class_name_to_geneva_drive_step_executions.rb +0 -7
  43. data/test/dummy/db/migrate/20260316100007_add_metadata_to_geneva_drive_step_executions.rb +0 -17
  44. data/test/dummy/db/migrate/20260327000000_allow_null_hero_on_geneva_drive_workflows.rb +0 -66
  45. data/test/dummy/db/schema.rb +0 -73
  46. data/test/dummy/log/development.log +0 -202
  47. data/test/dummy/log/test.log +0 -79471
  48. data/test/dummy/log/test.log.0 +0 -4
  49. data/test/dummy/tmp/local_secret.txt +0 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 45df42e923b2d50be98ff8572ae6c2391faa03c9d604372aabd3bb9ac603507e
4
- data.tar.gz: 84e99503be26c28fef613972a4b8a6335d96cc87e775076def279857513825bf
3
+ metadata.gz: a4a73216fcf8355550c3b5e26e76eee14468eb301e8eb315a979bc8f34bdb670
4
+ data.tar.gz: a9b9cc0d8998f6c479806158dd12725774431e7dba8486c5781ed68f238175b5
5
5
  SHA512:
6
- metadata.gz: d8b2efa46d1e7d43da57afae65a3564e3826801d29a9f142b9217c3bc462f0f48122993c01a8710adbf968733db5c05e35fad081901e824f3b9ff87ca577bca0
7
- data.tar.gz: fbfd86cec20556569803023f9a45483cc73cd7ded12d775dd2684f6693a883b1bff6d69ab61c40d006cd40715d6b696840f894f988001e7c61d8a07080283a44
6
+ metadata.gz: b77f44d451d256d081af6d7b30d62bcaa46bfd41b0606cdcd1d07529e18db7ff5e2d1753d52f6bd24f600f92b7e14587146072a53e46e1052728c760cf6ded78
7
+ data.tar.gz: 9bdd9f474cd8370ebcc77b4126617833a249b6e1237ed8021463b66d082db63e17bc024fa8718ec3aee1d031731c72b3a9c90903d2ce40ced2f01eb0b1c421ef
data/CHANGELOG.md CHANGED
@@ -2,6 +2,17 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.6.0]
6
+
7
+ - Add resumable steps for cursor-based iteration over large collections. `resumable_step :name do |iter| ... end` passes an `IterableStep` (API-compatible with Rails 8.1's `ActiveJob::Continuation::Step`) whose cursor is checkpointed to the database, so the step can be interrupted — by `max_iterations:`, `max_runtime:`, queue shutdown, external pause/cancel, or explicit `suspend!`/`skip_to!` — and continued in a later job execution from where it left off. Interruption completes the current step execution with outcome `continued` and creates a successor execution linked via `continues_from_id`, keeping a full audit chain instead of introducing a suspended state. Resumable steps run through the same `Executor` as regular steps: exception policies (step- and class-level, `max_reattempts`, `terminal_action:`, `report:`), tagged/injected loggers, `Rails.error` context, and merged job options all apply, with `reattempt!` and `resume!` continuing from the last checkpoint (use `reattempt!(rewind: true)` to start over). `HousekeepingJob` recovery is cursor-aware. The feature needs two new columns (`cursor`, `continues_from_id`) added by the installer migration; until it runs, everything else degrades gracefully (regular steps, pause/resume and housekeeping are unaffected) and executing an actual `resumable_step` raises a configuration error pointing at the missing migration. Cursors are limited to 128 KB of serialized JSON by default — the cursor is a position marker, not a payload — configurable via `GenevaDrive.max_cursor_size` (`nil` disables the check). Test helpers: `speedrun_current_step` follows the execution chain, `run_iterations(workflow, count:)` exercises partial progress, plus `assert_cursor` and `assert_step_has_successor`.
8
+ - Validate `job_options` uniformly across `set_step_job_options`, per-step `job_options:`, and per-instance `workflow.step_job_options=`. Only the keys Active Job's `set` actually consumes (`:queue`, `:priority`, `:wait`, `:wait_until`) are accepted, and values are type-checked. Typos like `piority:` now raise `StepConfigurationError` at configuration time instead of being silently ignored.
9
+ - Add partial index on `geneva_drive_step_executions(started_at) WHERE state = 'in_progress'` and order `HousekeepingJob` recovery batches by the filter column (`started_at` for stuck-in-progress, `scheduled_for` for stuck-scheduled). Fixes `HousekeepingJob` timing out on large in-progress sets where most rows are fresh — the planner previously walked the whole `state = 'in_progress'` set looking for old rows and tripped the caller's `statement_timeout`. Fixes AppSignal incident b9a7d1d1.
10
+ - Add a normalized `geneva_drive.paused_ratio` gauge emitted by `HousekeepingJob`. For each workflow class (STI `type`) it reports a float in `0.0..1.0`, tagged `workflow: <ClassName>`, equal to that class's `paused` count divided by its total population (all states). Unlike absolute paused counts, this is bounded and easy to alert on (50 paused of 50 is a fire; 50 of 500,000 is noise). Derived from the existing single grouped query — no extra database load.
11
+ - Add optional `metadata` JSON column to workflows (mirrors the existing step executions pattern). Exposes `step_job_options` / `step_job_options=` for per-instance job option overrides (queue, priority, etc.) that merge with class-level `set_step_job_options` and persist across step boundaries. Dedupe is unaffected since metadata is not part of the uniqueness constraint.
12
+ - Add GenevaDrive workflow and step metadata to `Rails.error` execution context when steps execute, so Rails error reports include workflow id/class, step execution id/name, and hero identifiers.
13
+ - Add per-step Active Job option overrides with `step :name, job_options: { queue: :critical, priority: 0 }`. Step options take precedence over workflow-level `set_step_job_options` and per-instance options, and are preserved across reattempts and resume re-enqueueing.
14
+ - Add `report:` option to exception policies and class-level `on_exception` for controlling when exceptions are reported to `Rails.error.report`. Accepts `:always` (default — preserves existing behavior), `:never` (suppress reporting for expected exceptions like rate limits), or `:terminal_only` (suppress during reattempts, report only when `terminal_action` fires). The executor now defers error reporting until after policy resolution.
15
+
5
16
  ## [0.5.0]
6
17
 
7
18
  - Add `ongoing?` predicate and model-level uniqueness validation for ongoing workflows. Mirrors the database unique index on `(type, hero_type, hero_id)` so that `Workflow.create` (without bang) returns validation errors instead of raising a database constraint violation.
data/MANUAL.md CHANGED
@@ -274,22 +274,30 @@ The loop bodies execute once when Ruby loads the class. Each iteration adds a ne
274
274
 
275
275
  ### Instance Methods as Steps
276
276
 
277
- For complex steps, you can define instance methods and reference them with `step def`:
277
+ The step definition can be a block, but if you give the step the same name as an instance method of your workflow that method will be called instead:
278
278
 
279
279
  ```ruby
280
280
  class DataExportWorkflow < GenevaDrive::Workflow
281
- step def gather_records
282
- hero.update!(export_data: hero.exportable_records.to_json)
281
+ step :anonymize_records
282
+
283
+ def anonymize_records
284
+ Anonymizer.process_arel(hero.records)
283
285
  end
286
+ end
287
+ ```
288
+
289
+ Since `def` in modern Rubies returns the name of the method you define as a `Symbol` you can use the `step def` shorthand as well. Together with "endless methods" this can give you a very compact description:
290
+
291
+ ```ruby
292
+ class DataExportWorkflow < GenevaDrive::Workflow
293
+ step def gather_records = hero.update!(export_data: hero.exportable_records.to_json)
284
294
 
285
295
  step def write_to_storage
286
296
  Storage.write(hero.export_path, hero.export_data)
287
297
  hero.update!(exported_at: Time.current)
288
298
  end
289
299
 
290
- step def notify_user
291
- ExportMailer.complete(hero).deliver_later
292
- end
300
+ step def notify_user = ExportMailer.complete(hero).deliver_later
293
301
  end
294
302
  ```
295
303
 
@@ -538,6 +546,139 @@ class OrderFulfillmentWorkflow < GenevaDrive::Workflow
538
546
  end
539
547
  ```
540
548
 
549
+ ## Resumable Steps
550
+
551
+ A regular step must finish within a single job execution. When a step has to churn through a large collection — sending a campaign to 200 000 subscribers, syncing a paginated API, backfilling a table — that single execution becomes a liability: a deploy, a worker restart, or a queue timeout loses all progress. Resumable steps solve this with **cursor-based iteration**: the step periodically checkpoints its position into the database, and can be interrupted and continued in a later job execution from exactly where it left off.
552
+
553
+ Resumable steps store their state in two extra columns on `geneva_drive_step_executions` (`cursor` and `continues_from_id`), added by the installer migrations — re-run `bin/rails generate geneva_drive:install` on an existing installation to pick them up. Until the migration runs, everything else keeps working: regular steps, pause/resume and housekeeping are unaffected, and executing an actual `resumable_step` fails with a configuration error pointing at the missing migration.
554
+
555
+ Define one with `resumable_step`. The block receives an `IterableStep` object (API-compatible with Rails 8.1's `ActiveJob::Continuation::Step`):
556
+
557
+ ```ruby
558
+ class CampaignWorkflow < GenevaDrive::Workflow
559
+ step :prepare do
560
+ hero.update!(status: "sending")
561
+ end
562
+
563
+ resumable_step :send_notifications do |iter|
564
+ iter.iterate_over_records(hero.subscribers) do |subscriber|
565
+ CampaignMailer.notify(hero, subscriber).deliver_later
566
+ end
567
+ end
568
+
569
+ step :finalize do
570
+ hero.update!(status: "sent")
571
+ end
572
+ end
573
+ ```
574
+
575
+ ### The Cursor
576
+
577
+ The cursor is a value persisted on the step execution after every checkpoint. It is whatever your iteration needs to pick up where it stopped: a record ID, a page number, an opaque API token, a date. Cursors are serialized with ActiveJob serializers, so anything ActiveJob can serialize works — including `Date` and `Time` — without manual conversion.
578
+
579
+ The cursor is a position marker, not a place to store the data being processed: it is rewritten on every checkpoint and copied to every successor execution. To keep that write path cheap, the serialized JSON is limited to 128 KB by default — exceeding it raises `GenevaDrive::CursorTooLargeError`. The limit is configurable in the initializer via `GenevaDrive.max_cursor_size` (`nil` disables the check).
580
+
581
+ ```ruby
582
+ resumable_step :process_records do |iter|
583
+ hero.records.where("id > ?", iter.cursor || 0).find_each do |record|
584
+ process(record)
585
+ iter.set!(record.id) # persist cursor, check for interruption
586
+ end
587
+ end
588
+ ```
589
+
590
+ The `IterableStep` API:
591
+
592
+ | Method | Effect |
593
+ |--------|--------|
594
+ | `iter.cursor` | Current cursor value (`nil` on first run) |
595
+ | `iter.set!(value)` | Set the cursor, persist it, check for interruption |
596
+ | `iter.advance!` | Increment an integer cursor by 1 (integers only) |
597
+ | `iter.checkpoint!` | Persist cursor and check for interruption |
598
+ | `iter.resumed?` | `true` when continuing from a previous execution |
599
+ | `iter.skip_to!(value, wait: nil)` | Set the cursor and suspend immediately; `wait:` delays the continuation |
600
+
601
+ Helpers for common iteration shapes:
602
+
603
+ ```ruby
604
+ # ActiveRecord relation, one record at a time (find_each under the hood)
605
+ iter.iterate_over_records(hero.subscribers) { |subscriber| ... }
606
+
607
+ # ActiveRecord relation in batches, yielding relations for bulk operations
608
+ iter.iterate_over_subrelations(hero.subscribers, batch_size: 500) do |batch|
609
+ batch.update_all(notified_at: Time.current)
610
+ end
611
+
612
+ # Stable in-memory arrays, index used as cursor
613
+ iter.iterate_over(items) { |item| ... }
614
+ ```
615
+
616
+ ### Chained Executions
617
+
618
+ When a resumable step is interrupted, the current step execution **completes** (with outcome `continued`) and a successor execution is created, linked to its predecessor via `continues_from_id` and carrying the cursor forward. There is no special "suspended" state — each execution is a normal record with a clear start and end, so the full history of a long iteration is visible as a chain:
619
+
620
+ ```ruby
621
+ workflow.step_executions.where(step_name: "send_notifications").order(:created_at)
622
+ # => chunk 1 (completed/continued), chunk 2 (completed/continued), ..., chunk N (completed/success)
623
+ ```
624
+
625
+ A step is interrupted when any of these happen:
626
+
627
+ - `max_iterations:` is reached (`resumable_step :import, max_iterations: 10_000`)
628
+ - `max_runtime:` is exceeded (`resumable_step :import, max_runtime: 5.minutes`)
629
+ - The job queue signals shutdown (e.g. Sidekiq stopping)
630
+ - The workflow is paused or canceled externally
631
+ - The step calls `skip_to!` or `suspend!` explicitly
632
+
633
+ ```ruby
634
+ # Suspend explicitly, e.g. to respect a rate limit
635
+ resumable_step :sync_api do |iter|
636
+ page = iter.cursor || 1
637
+ loop do
638
+ response = ExternalApi.fetch(page: page)
639
+ suspend!(wait: response.retry_after) if response.rate_limited?
640
+ break if response.empty?
641
+ response.items.each { |item| process(item) }
642
+ page += 1
643
+ iter.set!(page)
644
+ end
645
+ end
646
+ ```
647
+
648
+ ### Flow Control and Errors in Resumable Steps
649
+
650
+ All flow control works inside resumable steps, with cursor-aware semantics:
651
+
652
+ - `pause!` completes the current execution keeping the cursor; `resume!` continues from it.
653
+ - `reattempt!` continues from the cursor by default; `reattempt!(rewind: true)` clears the cursor and starts the iteration over.
654
+ - `cancel!`, `skip!` and `finished!` behave as in regular steps.
655
+
656
+ Exception policies (`on_exception:` on the step, class-level `on_exception`, `max_reattempts:`, `terminal_action:`, `report:`) apply exactly as for regular steps. A `:reattempt!` policy continues from the last checkpoint, so a transient failure halfway through a large collection does not redo the completed portion. When an unhandled exception pauses the workflow, `resume!` also retries the failed step from its last checkpoint.
657
+
658
+ Housekeeping recovery is cursor-aware too: a resumable execution stuck `in_progress` (dead worker) is recovered by continuing from its persisted cursor, not by restarting the iteration.
659
+
660
+ ### Writing Restart-Safe Iterations
661
+
662
+ The cursor marks the last *checkpointed* position, and one item may be re-processed if execution stops between doing the work and checkpointing. Make each iteration idempotent (e.g. guard with a uniqueness constraint or a state flag on the processed record) rather than assuming exactly-once delivery.
663
+
664
+ ### Testing Resumable Steps
665
+
666
+ `speedrun_workflow` and `speedrun_current_step` run resumable steps to completion with interruption checks disabled, following the execution chain across explicit suspensions. To exercise partial progress, use `run_iterations`:
667
+
668
+ ```ruby
669
+ test "keeps its place across interruptions" do
670
+ workflow = CampaignWorkflow.create!(hero: campaign)
671
+ perform_next_step(workflow) # :prepare
672
+
673
+ run_iterations(workflow, count: 3) # three iterations, then interrupt
674
+ assert_cursor(workflow, 3)
675
+ assert_step_has_successor(workflow, :send_notifications)
676
+
677
+ speedrun_workflow(workflow) # run the rest
678
+ assert workflow.finished?
679
+ end
680
+ ```
681
+
541
682
  ## Exception Handling
542
683
 
543
684
  ### Default Behavior
@@ -644,7 +785,7 @@ step :flaky_api, on_exception: :reattempt!, max_reattempts: 10, terminal_action:
644
785
  end
645
786
  ```
646
787
 
647
- `terminal_action:` accepts `:pause!` (default) or `:cancel!`.
788
+ `terminal_action:` accepts `:pause!` (default), `:cancel!`, or `:skip!`.
648
789
 
649
790
  ### Reusable Exception Policies
650
791
 
@@ -808,6 +949,63 @@ If the block returns without calling a flow control method, the workflow pauses.
808
949
 
809
950
  Imperative handlers cannot be combined with `max_reattempts:`, `wait:`, or `terminal_action:` — manage that logic inside the block.
810
951
 
952
+ ### Controlling Error Reporting
953
+
954
+ When a step raises an exception, GenevaDrive reports it to your error tracker via `Rails.error.report`. This is the right default — you want visibility into failures. But some exceptions are expected and handled: rate limits, transient timeouts, throttling responses. Reporting these on every reattempt floods your error tracker with noise and obscures the errors that actually need attention.
955
+
956
+ The `report:` option controls when `Rails.error.report` is called. It accepts three values:
957
+
958
+ - `:always` — (default) Report every exception, regardless of what the policy does with it. This is the safest choice and preserves the behavior you're used to.
959
+ - `:never` — Never report the exception. Use this for errors that are fully expected and handled — rate limits, throttles, circuit breaker trips. The exception still triggers the policy action (reattempt, skip, etc.), but your error tracker stays clean.
960
+ - `:terminal_only` — Suppress reports while the step is being reattempted, but report when reattempts are exhausted and the `terminal_action` fires. This is the sweet spot for transient errors: you don't care about individual retries, but you want to know when the retries give up.
961
+
962
+ Use `report:` on both class-level `on_exception` and on `ExceptionPolicy` objects:
963
+
964
+ ```ruby
965
+ class CalendarSyncWorkflow < GenevaDrive::Workflow
966
+ # Rate limits are expected — never report them
967
+ on_exception Pecorino::Throttle::Throttled, report: :never do |error|
968
+ reattempt!(wait: error.retry_after.clamp(10, 600).seconds)
969
+ end
970
+
971
+ # Transient timeouts: only report when we give up
972
+ on_exception Net::OpenTimeout,
973
+ action: :reattempt!,
974
+ wait: 10.seconds,
975
+ max_reattempts: 5,
976
+ terminal_action: :cancel!,
977
+ report: :terminal_only
978
+
979
+ step :sync_events do
980
+ GoogleCalendar.sync(hero)
981
+ end
982
+ end
983
+ ```
984
+
985
+ The same option works on `ExceptionPolicy` objects for reusable policies and composable arrays:
986
+
987
+ ```ruby
988
+ RATE_LIMIT_POLICY = GenevaDrive::ExceptionPolicy.new(
989
+ :reattempt!,
990
+ matching: RateLimitError,
991
+ wait: 30.seconds,
992
+ max_reattempts: 10,
993
+ terminal_action: :pause!,
994
+ report: :terminal_only
995
+ )
996
+
997
+ step :call_api, on_exception: [
998
+ RATE_LIMIT_POLICY,
999
+ GenevaDrive::ExceptionPolicy.new(:reattempt!, matching: Timeout::Error, report: :never, max_reattempts: 3),
1000
+ GenevaDrive::ExceptionPolicy.new(:pause!) # blanket fallback, reports by default
1001
+ ] do
1002
+ ExternalApi.call(hero)
1003
+ end
1004
+ ```
1005
+
1006
+ > [!IMPORTANT]
1007
+ > The `report:` option only controls `Rails.error.report`. The exception is still re-raised after the executor commits its state transitions — your background job framework (Sidekiq, SolidQueue, etc.) will see it. If your error tracker also hooks into the job framework's error handler, you may need to configure that separately.
1008
+
811
1009
  ### Manual Exception Handling
812
1010
 
813
1011
  For the most granular control, handle exceptions directly within the step using standard Ruby `rescue`:
@@ -1170,19 +1368,65 @@ The method is thread-safe and won't affect other concurrent requests.
1170
1368
 
1171
1369
  ### Custom Job Options
1172
1370
 
1173
- Override the queue or priority for all steps in a workflow:
1371
+ You can override the Active Job settings used to enqueue a workflow's step jobs at three levels: for the entire workflow class, for an individual step, or for a specific workflow instance. All three accept the same keys that Active Job's `set` method understands: `:queue`, `:priority`, `:wait`, and `:wait_until`. Unknown keys are rejected at configuration time so that typos surface immediately instead of silently falling back to defaults.
1174
1372
 
1175
1373
  ```ruby
1176
1374
  class HighPriorityWorkflow < GenevaDrive::Workflow
1177
1375
  set_step_job_options queue: :critical, priority: 0
1178
1376
 
1179
- step :urgent_action do
1377
+ step :urgent_action, job_options: {queue: :urgent, priority: -1} do
1180
1378
  UrgentService.process!(hero)
1181
1379
  end
1182
1380
  end
1183
1381
  ```
1184
1382
 
1185
- The options are passed directly to ActiveJob's `set` method.
1383
+ Options merge from lowest to highest precedence: **class defaults** (`set_step_job_options`) are overridden by **per-instance options** (`workflow.step_job_options = {...}`), which are in turn overridden by **per-step options** (`step ..., job_options: {...}`). The merged hash is passed straight to `PerformStepJob.set(...)` for every enqueue of that step, including reattempts and resume re-enqueueing.
1384
+
1385
+ Per-instance options are stored in the workflow's `metadata` column, so they survive across step boundaries and are excluded from the dedupe uniqueness check — two workflows for the same hero cannot coexist just because their job options differ.
1386
+
1387
+ #### When per-step job options matter: protecting in-flight work from a fresh backlog
1388
+
1389
+ Consider a workflow that downloads, processes, and cleans up a large artifact per hero:
1390
+
1391
+ ```ruby
1392
+ class RubygemsWorkflow < GenevaDrive::Workflow
1393
+ step :extract do
1394
+ # Download a large tarball to local disk
1395
+ end
1396
+
1397
+ step :process do
1398
+ # Do work on the extracted data
1399
+ end
1400
+
1401
+ step :cleanup do
1402
+ # Delete the on-disk artifact
1403
+ end
1404
+ end
1405
+ ```
1406
+
1407
+ If you enqueue 50,000 of these at once, all 50,000 `:extract` jobs land on the queue at the default priority — ahead of the `:process` and `:cleanup` steps of workflows that have already downloaded something. On queue adapters where lower numbers run first, the workers happily drain `:extract` jobs first, filling the disk with unprocessed artifacts until the volume runs out and everything starts failing.
1408
+
1409
+ Bumping the later steps to a higher priority (lower number) fixes this:
1410
+
1411
+ ```ruby
1412
+ class RubygemsWorkflow < GenevaDrive::Workflow
1413
+ step :extract, job_options: {priority: 10} do
1414
+ # Download a large tarball to local disk
1415
+ end
1416
+
1417
+ step :process do # runs at default priority — ahead of :extract
1418
+ # Do work on the extracted data
1419
+ end
1420
+
1421
+ step :cleanup do # also runs ahead of :extract
1422
+ # Delete the on-disk artifact
1423
+ end
1424
+ end
1425
+ ```
1426
+
1427
+ Workflows that have already extracted their artifact can now drain through `:process` and `:cleanup` — releasing disk — while new `:extract` jobs wait their turn. The fleet reaches steady state instead of collapsing under its own backlog.
1428
+
1429
+ The same pattern is useful whenever a later step releases a scarce resource (disk, external quota, a database lock) that earlier steps consume: give the release-work a higher priority than the acquire-work.
1186
1430
 
1187
1431
  ## Housekeeping
1188
1432
 
@@ -1351,6 +1595,18 @@ ActiveSupport::Notifications.subscribe("step.geneva_drive") do |event|
1351
1595
  end
1352
1596
  ```
1353
1597
 
1598
+ ### Metric gauges
1599
+
1600
+ `GenevaDrive::HousekeepingJob` reports gauges via [Measurometer](https://rubygems.org/gems/measurometer) on every run:
1601
+
1602
+ | Gauge | Tags | Value |
1603
+ |-------|------|-------|
1604
+ | `geneva_drive.<state>` | _(none)_ | Absolute count of workflows in `<state>` (e.g. `ready`, `paused`, `finished`), summed across all classes |
1605
+ | `geneva_drive.<state>` | `workflow: <ClassName>` | Absolute count of workflows in `<state>` for a single workflow class |
1606
+ | `geneva_drive.paused_ratio` | `workflow: <ClassName>` | Float `0.0..1.0` — that class's `paused` count divided by its total population (all states) |
1607
+
1608
+ `paused_ratio` is normalized on purpose: absolute paused counts are hard to alert on (50 paused of 50 is a fire; 50 of 500,000 is noise), whereas the ratio is bounded and comparable across classes. The denominator is the whole population (paused ÷ total), so it stays within `0..1` even when nothing is ongoing.
1609
+
1354
1610
  ---
1355
1611
 
1356
1612
  # Part VI — Appendix
@@ -1704,9 +1960,10 @@ end
1704
1960
  |--------|--------|
1705
1961
  | `cancel!` | Stop workflow, mark canceled |
1706
1962
  | `pause!` | Stop workflow, await manual resume |
1707
- | `reattempt!(wait:)` | Retry current step, optionally after delay |
1963
+ | `reattempt!(wait:, rewind:)` | Retry current step; `rewind: true` clears cursor |
1708
1964
  | `skip!` | Skip current step, proceed to next |
1709
1965
  | `finished!` | Complete workflow early |
1966
+ | `suspend!(wait:)` | Interrupt resumable step, continue via successor after delay |
1710
1967
 
1711
1968
  ### Workflow States
1712
1969
 
@@ -1729,13 +1986,18 @@ end
1729
1986
  | `canceled` | Canceled before execution |
1730
1987
  | `skipped` | Skipped via `skip_if` or `skip!` |
1731
1988
 
1989
+ A resumable step interrupted mid-iteration completes its execution with outcome `continued` and schedules a successor execution — there is no separate state for it.
1990
+
1732
1991
  ### Step Options
1733
1992
 
1734
1993
  | Option | Type | Description |
1735
1994
  |--------|------|-------------|
1736
1995
  | `wait:` | Duration | Delay before step executes |
1996
+ | `job_options:` | Hash | Options passed to Active Job's `set` method for this step |
1737
1997
  | `skip_if:` | Proc, Symbol, Boolean | Condition to skip step |
1738
1998
  | `on_exception:` | Symbol | Exception handler (`:pause!`, `:cancel!`, `:reattempt!`, `:skip!`) |
1739
1999
  | `max_reattempts:` | Integer, nil | Max consecutive reattempts before pausing (default: 100, `nil` = unlimited) |
1740
2000
  | `before_step:` | Symbol | Insert before this step |
1741
2001
  | `after_step:` | Symbol | Insert after this step |
2002
+ | `max_iterations:` | Integer | (resumable_step) Interrupt after N iterations, continue via successor |
2003
+ | `max_runtime:` | Duration | (resumable_step) Interrupt after duration elapsed, continue via successor |
data/README.md CHANGED
@@ -164,12 +164,14 @@ class PremiumWorkflow < BaseWorkflow
164
164
  cancel_if { hero.subscription_expired? }
165
165
  set_step_job_options queue: :premium, priority: 10
166
166
 
167
- step :premium_feature do
167
+ step :premium_feature, job_options: {priority: -1} do
168
168
  # Inherits cancel_if from parent
169
169
  end
170
170
  end
171
171
  ```
172
172
 
173
+ Step job options override workflow defaults when the step is initially enqueued, reattempted, or resumed. Options are passed directly to Active Job's `set` method.
174
+
173
175
  ### Hero Deletion Handling
174
176
 
175
177
  By default, workflows cancel if their hero is deleted:
@@ -46,10 +46,25 @@ module GenevaDrive
46
46
  "db/migrate/add_metadata_to_geneva_drive_step_executions.rb"
47
47
  )
48
48
 
49
+ migration_template(
50
+ "add_metadata_to_workflows.rb",
51
+ "db/migrate/add_metadata_to_geneva_drive_workflows.rb"
52
+ )
53
+
49
54
  migration_template(
50
55
  "allow_null_hero_on_workflows.rb",
51
56
  "db/migrate/allow_null_hero_on_geneva_drive_workflows.rb"
52
57
  )
58
+
59
+ migration_template(
60
+ "add_started_at_index_to_step_executions.rb",
61
+ "db/migrate/add_started_at_index_to_geneva_drive_step_executions.rb"
62
+ )
63
+
64
+ migration_template(
65
+ "add_resumable_step_support.rb",
66
+ "db/migrate/add_resumable_step_support_to_geneva_drive_step_executions.rb"
67
+ )
53
68
  end
54
69
 
55
70
  # Creates the initializer file.
@@ -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,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