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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 45df42e923b2d50be98ff8572ae6c2391faa03c9d604372aabd3bb9ac603507e
4
- data.tar.gz: 84e99503be26c28fef613972a4b8a6335d96cc87e775076def279857513825bf
3
+ metadata.gz: 1738e58c5f40199781a27c2345b20657e63ccc0c5a8b28010c2ac820774c2c2f
4
+ data.tar.gz: f729ef68e929f39fa26b09108f446a9b53ec13013d22d89f22d788b5ac0988e3
5
5
  SHA512:
6
- metadata.gz: d8b2efa46d1e7d43da57afae65a3564e3826801d29a9f142b9217c3bc462f0f48122993c01a8710adbf968733db5c05e35fad081901e824f3b9ff87ca577bca0
7
- data.tar.gz: fbfd86cec20556569803023f9a45483cc73cd7ded12d775dd2684f6693a883b1bff6d69ab61c40d006cd40715d6b696840f894f988001e7c61d8a07080283a44
6
+ metadata.gz: 87a29981a0278755e146cebf6e5e8d7075832665335b54511d5c24734a44040c62e9b2f9ae8fcd2558941d5f8cfe9eefc588efef65db09dc131d6602e2089d40
7
+ data.tar.gz: 9dcbb2ca84072315438dcacfd78d5463563b0ef6f5a940a5ccd12afcef7d2f10fb40cd710ce4bd5ba72142941242ec709f92e4d33927d15ce146538eed93834f
data/CHANGELOG.md CHANGED
@@ -1,6 +1,19 @@
1
1
  # Changelog
2
2
 
3
- ## [Unreleased]
3
+ ## [0.7.0]
4
+
5
+ - Add signals: external events delivered to a workflow instance with `workflow.signal!(:payment_confirmed, payload: {...}, idempotency_key: event["id"])`, and a step primitive that parks until one arrives with `step :capture, wait_for: :payment_confirmed`. Signals are rows in a new `geneva_drive_signals` table, persisted before any processing, so arrival order does not matter: a signal that lands before its waiting step exists is buffered and claimed when the step runs, and one that lands after the step parked wakes it immediately. Both sides of the rendezvous run under the workflow row lock, so no signal can slip through the gap, and delivery is one transaction: the insert, the waking of parked executions and every counter bump land together or not at all, so a crashed `signal!` leaves nothing half-delivered (the job enqueue stays after-commit, with the existing stuck-scheduled housekeeping sweep as its backstop). Waiting is a first-class step execution state (`waiting`) that holds no queue slot and no timer — nothing polls. Matching is by name - `wait_for:` also takes a `GenevaDrive::SignalMatcher`, whose optional block narrows by payload and is evaluated on the workflow the signal was sent to (so `hero` is in scope), or any other object responding to `#matches?(signal)`. Matchers are plain reusable objects, so a rule can live in a constant and be shared across steps. Signals move `pending` → `claimed` → `consumed`, consumed only once every attached execution chain has finished cleanly — where skipping counts as finishing, so any flavor of skip (`skip_if:` at wake, `skip!` in a step, an exception policy, or an operator `skip!` on a ready or paused workflow) settles the signal that step was delivered, while skips of steps that never attached leave buffered signals alone — and in the same transaction as the step's own completion, so a reattempt or a `resume!` re-reads the same payload and the audit trail never claims an event was handled when it was not. Two counters on the row, `claimed` and `consumed`, count attachments and clean resolutions (with `claimed?` / `consumed?` reading those counters rather than the lifecycle state). Redelivery is deduplicated by a unique index on `(workflow_id, name, idempotency_key)` — the duplicate call returns the original row flagged `duplicate_delivery?` without dispatching. Payloads go through ActiveJob serializers, come back with indifferent access, and are bounded by `GenevaDrive.max_signal_payload_size` (128 KB, `nil` disables). `signal!` on a finished or canceled workflow raises `GenevaDrive::WorkflowNotOngoing` unless it is a redelivery of a known event; on a paused workflow the row is buffered and delivered by `resume!`. Timeouts are deliberately out of scope for now; waiting is instead made loud through `waiting_since` and two housekeeping gauges (`geneva_drive.waiting_step_executions`, `geneva_drive.waiting_overdue` past `GenevaDrive.waiting_visibility_threshold`, 7 days by default), with `skip!` and `cancel!` as operator escape hatches. Needs one new installer migration (the signals table plus two columns on step executions); until it runs everything else is unaffected and executing a `wait_for:` step raises a configuration error pointing at the missing migration. Test helpers: `assert_waiting_for_signal`, `assert_signal_state`, and the step-driving helpers now raise instead of spinning on a parked execution. Mixing up `wait:` and `wait_for:` now raises `StepConfigurationError` at class load in both directions, which also tightens `wait:` to reject Strings (previously `wait: "later"` silently meant zero seconds).
6
+
7
+ ## [0.6.0]
8
+
9
+ - 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`.
10
+ - 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.
11
+ - 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.
12
+ - 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.
13
+ - 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.
14
+ - 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.
15
+ - 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.
16
+ - 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.
4
17
 
5
18
  ## [0.5.0]
6
19
 
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,312 @@ 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
+
682
+ ## Waiting for Signals
683
+
684
+ Some steps cannot finish on their own. A payment needs the provider's webhook, a contract needs a countersignature, a shipment needs the carrier to scan the parcel. The usual Rails answer is to poll — schedule the step, check whether the thing happened, `reattempt!(wait: 5.minutes)` if it did not — and that works, but it burns a worker slot on every check and it puts a floor under how fast the workflow can react.
685
+
686
+ A **signal** inverts that. It is an event record addressed to one workflow instance:
687
+
688
+ ```ruby
689
+ # app/controllers/payment_webhooks_controller.rb
690
+ workflow = OrderFulfillmentWorkflow.ongoing.for_hero(order).first
691
+ workflow.signal!(:payment_confirmed,
692
+ payload: {amount_cents: event["amount"]},
693
+ idempotency_key: event["id"])
694
+ ```
695
+
696
+ And a step declares that it waits for one with `wait_for:`:
697
+
698
+ ```ruby
699
+ class OrderFulfillmentWorkflow < GenevaDrive::Workflow
700
+ step :reserve_stock do
701
+ hero.reserve_stock!
702
+ end
703
+
704
+ step :capture_payment, wait_for: :payment_confirmed do
705
+ hero.capture!(received_signal.payload[:amount_cents])
706
+ end
707
+
708
+ step :ship do
709
+ ShippingLabel.create!(order: hero)
710
+ end
711
+ end
712
+ ```
713
+
714
+ When `:capture_payment` runs and no matching signal has arrived, its execution parks in the `waiting` state and the job ends. Nothing is enqueued, nothing polls, and the parked row costs nothing until `signal!` wakes it. When the webhook lands, `signal!` wakes the execution and enqueues its job again.
715
+
716
+ Signals need one migration — it creates the `geneva_drive_signals` table and adds two columns to `geneva_drive_step_executions` — so re-run `bin/rails generate geneva_drive:install` on an existing installation to pick it up. Until then everything else keeps working, and executing a step that declares `wait_for:` fails with a configuration error pointing at the missing migration.
717
+
718
+ ### Arrival Order Does Not Matter
719
+
720
+ The signal row is written before anything is dispatched, which is what makes the timing irrelevant. There are exactly two places where sender and receiver meet, and both run under the workflow row lock:
721
+
722
+ - When the waiting step's job runs, it looks for a matching unconsumed signal. Found: attach and run. Not found: park.
723
+ - When `signal!` is called, it looks for parked executions. Found: attach, reschedule, enqueue. Not found: the row simply sits there.
724
+
725
+ So a webhook that arrives while the workflow is still three steps away from the waiter is not lost and not early — it is buffered, and claimed when the waiting step finally runs. A webhook that arrives a week after the step parked wakes it immediately.
726
+
727
+ Delivery is one database transaction: the signal row, the waking of the parked execution and the bookkeeping around it either all land or none of them do. If `signal!` raises, nothing was written and the sender can simply try again.
728
+
729
+ > [!IMPORTANT]
730
+ > `signal!` is an instance method: you find the workflow the way you find anything in Rails, usually `SomeWorkflow.ongoing.for_hero(record).first`. Delivering a brand-new event to a workflow that has already finished or been canceled raises `GenevaDrive::WorkflowNotOngoing` rather than silently doing nothing.
731
+
732
+ ### Deduplicating Redelivery
733
+
734
+ Webhook providers redeliver. Pass an `idempotency_key:` — the provider's event id is the natural choice — and the second delivery becomes a database-level no-op that returns the row the first one wrote:
735
+
736
+ ```ruby
737
+ signal = workflow.signal!(:payment_confirmed, idempotency_key: event["id"])
738
+ Rails.logger.info("Ignoring redelivered #{event["id"]}") if signal.duplicate_delivery?
739
+ ```
740
+
741
+ Redelivering the very event that finished the workflow is also a no-op, not an error — that specific case is what the deduplication is for. Signals sent without an idempotency key never deduplicate: two calls make two rows, the older one is delivered first, and the newer one stays in the table as an auditable record of what arrived.
742
+
743
+ ### Narrowing What Counts as a Match
744
+
745
+ `wait_for: :document_signed` is shorthand for `wait_for: GenevaDrive::SignalMatcher.new(:document_signed)`, which matches on name alone. Give the matcher a block and it also has to like the payload:
746
+
747
+ ```ruby
748
+ class ContractWorkflow < GenevaDrive::Workflow
749
+ # The block receives the payload and runs on the workflow the signal was
750
+ # sent to, so `hero` and the workflow's own methods are in scope.
751
+ step :await_countersignature,
752
+ wait_for: GenevaDrive::SignalMatcher.new(:document_signed) { |payload|
753
+ payload[:document_id] == hero.contract_id
754
+ } do
755
+ hero.mark_countersigned!
756
+ end
757
+ end
758
+ ```
759
+
760
+ A matcher is a plain object with no ties to the step that uses it, so a rule worth repeating can live in a constant and be shared across steps and workflows:
761
+
762
+ ```ruby
763
+ SETTLED = GenevaDrive::SignalMatcher.new(:payment_confirmed) { |payload| payload[:amount_cents].to_i > 0 }
764
+
765
+ step :capture_payment, wait_for: SETTLED do
766
+ hero.capture!
767
+ end
768
+ ```
769
+
770
+ Anything responding to `#matches?(signal)` works too, and owns the whole predicate — which is how a single step waits on either of two names:
771
+
772
+ ```ruby
773
+ class PaymentSettled
774
+ def initialize(min_cents:) = @min_cents = min_cents
775
+
776
+ def matches?(signal)
777
+ %w[payment_confirmed payment_captured].include?(signal.name) &&
778
+ signal.payload[:amount_cents].to_i >= @min_cents
779
+ end
780
+ end
781
+
782
+ step :capture_payment, wait_for: PaymentSettled.new(min_cents: 100) do
783
+ hero.capture!
784
+ end
785
+ ```
786
+
787
+ Payloads are serialized with ActiveJob serializers, so `Date`, `Time` and ActiveRecord objects survive the round trip. Hashes come back with indifferent access, because a webhook sender produces string keys and a Ruby caller produces symbols and a matcher should not have to care. The serialized payload is limited to 128 KB (`GenevaDrive.max_signal_payload_size`, `nil` disables) — a payload describes the event; the data your workflow works on belongs on the hero.
788
+
789
+ > [!WARNING]
790
+ > Matchers run at the gate *and* inside `signal!`, in the sender's process. Keep them cheap and free of side effects, exactly like `skip_if:`. A matcher that raises during `signal!` raises to whoever sent the signal — and because `signal!` is a single transaction, the delivery rolls back whole: no signal row is recorded, nothing is attached or woken, no counter moves. The sender (or the webhook provider's retry) re-delivers once the matcher is fixed.
791
+
792
+ ### How Waiting Composes
793
+
794
+ - `wait: 2.days, wait_for: :payment_confirmed` means "no earlier than two days from now, and only once signaled". A signal arriving during the delay is buffered and claimed when the step finally runs.
795
+ - `skip_if:` is evaluated before waiting, so "wait for the signature — unless the contract was pre-signed" skips immediately instead of parking forever. Both `skip_if:` and `cancel_if` are re-evaluated when a signal wakes the step; a parked workflow does not re-check them while it sleeps. A step skipped at wake consumes the signal that woke it, so the next waiter does not inherit a stale event.
796
+ - `resumable_step` accepts `wait_for:`. The wait happens once, at the start of the chain, and `received_signal` stays the same across every successor execution.
797
+ - A reattempt — yours or an exception policy's — re-reads the *same* signal. Reattempting means "process this event again", not "wait for another event".
798
+
799
+ The signal itself moves through `pending` → `claimed` → `consumed`. It is claimed when an execution attaches to it, and consumed once every attached execution has finished cleanly, in the same transaction as the step's own completion. A step that fails, pauses, or gets canceled leaves its signal claimed, so the retry picks up the same payload and the audit trail does not claim an event was handled when it was not.
800
+
801
+ Skipping counts as finishing: skipping a step that was delivered a signal consumes that signal, whether the skip came from `skip_if:`, from `skip!` inside the step, from an exception policy, or from an operator calling `workflow.skip!` on a ready or paused workflow. Moving past a step deliberately settles the event it was given. Skipping a step that never attached — one still parked, or one whose `skip_if:` fired before it ever looked for a signal — leaves buffered signals exactly as they were.
802
+
803
+ Two counters on the row make that legible: `signal.claimed` counts attachments (a step that failed and was retried attaches twice), and `signal.consumed` counts cleanly resolved ones. In a linear workflow they end at one apiece. The predicates `signal.claimed?` and `signal.consumed?` read those counters, so they answer "was this event ever picked up" and "was it ever handled" — which is not the same question as `signal.state`, and is usually the one worth asking.
804
+
805
+ > [!NOTE]
806
+ > Two sequential steps waiting on `:payment_confirmed` need two signals. The first step consumes the first signal, so the second step parks until another one arrives.
807
+
808
+ ### Getting Unstuck
809
+
810
+ There is no `timeout:` option. Waiting forever is a legitimate state — a contract may genuinely sit unsigned for months — so instead of a timer, waiting is made loud: a distinct `waiting` state, a `waiting_since` timestamp, and two housekeeping gauges, `geneva_drive.waiting_step_executions` and `geneva_drive.waiting_overdue` (parked longer than `GenevaDrive.waiting_visibility_threshold`, seven days by default). Alert on the overdue gauge.
811
+
812
+ When something does stall, three escape hatches already exist:
813
+
814
+ - `workflow.skip!` abandons the wait and moves to the next step.
815
+ - `workflow.cancel!` gives up on the workflow.
816
+ - The queue is already a clock: schedule a job that signals a deadline, and let the step decide.
817
+
818
+ ```ruby
819
+ # Send the workflow its own deadline; the step reads it off the payload
820
+ class ContractDeadlineJob < ApplicationJob
821
+ def perform(contract)
822
+ workflow = ContractWorkflow.ongoing.for_hero(contract).first
823
+ workflow&.signal!(:document_signed,
824
+ payload: {timed_out: true},
825
+ idempotency_key: "deadline-#{workflow.id}")
826
+ end
827
+ end
828
+
829
+ step :await_countersignature, wait_for: :document_signed do
830
+ cancel! if received_signal.payload[:timed_out]
831
+ hero.mark_countersigned!
832
+ end
833
+ ```
834
+
835
+ ### Testing Waiting Steps
836
+
837
+ The step-driving helpers refuse to spin on a parked execution — they raise and tell you which signal is missing, because a test that hangs on a rendezvous is far worse than one that fails:
838
+
839
+ ```ruby
840
+ test "captures once the payment is confirmed" do
841
+ workflow = OrderFulfillmentWorkflow.create!(hero: order)
842
+ perform_next_step(workflow) # :reserve_stock
843
+ perform_next_step(workflow) # :capture_payment parks
844
+
845
+ assert_waiting_for_signal(workflow, :payment_confirmed)
846
+
847
+ workflow.signal!(:payment_confirmed, payload: {amount_cents: 12_500})
848
+ speedrun_workflow(workflow)
849
+
850
+ assert_signal_state(workflow, :payment_confirmed, :consumed)
851
+ assert workflow.finished?
852
+ end
853
+ ```
854
+
541
855
  ## Exception Handling
542
856
 
543
857
  ### Default Behavior
@@ -644,7 +958,7 @@ step :flaky_api, on_exception: :reattempt!, max_reattempts: 10, terminal_action:
644
958
  end
645
959
  ```
646
960
 
647
- `terminal_action:` accepts `:pause!` (default) or `:cancel!`.
961
+ `terminal_action:` accepts `:pause!` (default), `:cancel!`, or `:skip!`.
648
962
 
649
963
  ### Reusable Exception Policies
650
964
 
@@ -808,6 +1122,63 @@ If the block returns without calling a flow control method, the workflow pauses.
808
1122
 
809
1123
  Imperative handlers cannot be combined with `max_reattempts:`, `wait:`, or `terminal_action:` — manage that logic inside the block.
810
1124
 
1125
+ ### Controlling Error Reporting
1126
+
1127
+ 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.
1128
+
1129
+ The `report:` option controls when `Rails.error.report` is called. It accepts three values:
1130
+
1131
+ - `: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.
1132
+ - `: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.
1133
+ - `: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.
1134
+
1135
+ Use `report:` on both class-level `on_exception` and on `ExceptionPolicy` objects:
1136
+
1137
+ ```ruby
1138
+ class CalendarSyncWorkflow < GenevaDrive::Workflow
1139
+ # Rate limits are expected — never report them
1140
+ on_exception Pecorino::Throttle::Throttled, report: :never do |error|
1141
+ reattempt!(wait: error.retry_after.clamp(10, 600).seconds)
1142
+ end
1143
+
1144
+ # Transient timeouts: only report when we give up
1145
+ on_exception Net::OpenTimeout,
1146
+ action: :reattempt!,
1147
+ wait: 10.seconds,
1148
+ max_reattempts: 5,
1149
+ terminal_action: :cancel!,
1150
+ report: :terminal_only
1151
+
1152
+ step :sync_events do
1153
+ GoogleCalendar.sync(hero)
1154
+ end
1155
+ end
1156
+ ```
1157
+
1158
+ The same option works on `ExceptionPolicy` objects for reusable policies and composable arrays:
1159
+
1160
+ ```ruby
1161
+ RATE_LIMIT_POLICY = GenevaDrive::ExceptionPolicy.new(
1162
+ :reattempt!,
1163
+ matching: RateLimitError,
1164
+ wait: 30.seconds,
1165
+ max_reattempts: 10,
1166
+ terminal_action: :pause!,
1167
+ report: :terminal_only
1168
+ )
1169
+
1170
+ step :call_api, on_exception: [
1171
+ RATE_LIMIT_POLICY,
1172
+ GenevaDrive::ExceptionPolicy.new(:reattempt!, matching: Timeout::Error, report: :never, max_reattempts: 3),
1173
+ GenevaDrive::ExceptionPolicy.new(:pause!) # blanket fallback, reports by default
1174
+ ] do
1175
+ ExternalApi.call(hero)
1176
+ end
1177
+ ```
1178
+
1179
+ > [!IMPORTANT]
1180
+ > 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.
1181
+
811
1182
  ### Manual Exception Handling
812
1183
 
813
1184
  For the most granular control, handle exceptions directly within the step using standard Ruby `rescue`:
@@ -882,6 +1253,7 @@ Step executions have their own state machine:
882
1253
  | State | Meaning |
883
1254
  |-------|---------|
884
1255
  | `scheduled` | Waiting to run |
1256
+ | `waiting` | Parked until a matching signal arrives |
885
1257
  | `in_progress` | Currently executing |
886
1258
  | `completed` | Finished successfully |
887
1259
  | `failed` | Exception occurred |
@@ -1170,19 +1542,65 @@ The method is thread-safe and won't affect other concurrent requests.
1170
1542
 
1171
1543
  ### Custom Job Options
1172
1544
 
1173
- Override the queue or priority for all steps in a workflow:
1545
+ 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
1546
 
1175
1547
  ```ruby
1176
1548
  class HighPriorityWorkflow < GenevaDrive::Workflow
1177
1549
  set_step_job_options queue: :critical, priority: 0
1178
1550
 
1179
- step :urgent_action do
1551
+ step :urgent_action, job_options: {queue: :urgent, priority: -1} do
1180
1552
  UrgentService.process!(hero)
1181
1553
  end
1182
1554
  end
1183
1555
  ```
1184
1556
 
1185
- The options are passed directly to ActiveJob's `set` method.
1557
+ 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.
1558
+
1559
+ 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.
1560
+
1561
+ #### When per-step job options matter: protecting in-flight work from a fresh backlog
1562
+
1563
+ Consider a workflow that downloads, processes, and cleans up a large artifact per hero:
1564
+
1565
+ ```ruby
1566
+ class RubygemsWorkflow < GenevaDrive::Workflow
1567
+ step :extract do
1568
+ # Download a large tarball to local disk
1569
+ end
1570
+
1571
+ step :process do
1572
+ # Do work on the extracted data
1573
+ end
1574
+
1575
+ step :cleanup do
1576
+ # Delete the on-disk artifact
1577
+ end
1578
+ end
1579
+ ```
1580
+
1581
+ 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.
1582
+
1583
+ Bumping the later steps to a higher priority (lower number) fixes this:
1584
+
1585
+ ```ruby
1586
+ class RubygemsWorkflow < GenevaDrive::Workflow
1587
+ step :extract, job_options: {priority: 10} do
1588
+ # Download a large tarball to local disk
1589
+ end
1590
+
1591
+ step :process do # runs at default priority — ahead of :extract
1592
+ # Do work on the extracted data
1593
+ end
1594
+
1595
+ step :cleanup do # also runs ahead of :extract
1596
+ # Delete the on-disk artifact
1597
+ end
1598
+ end
1599
+ ```
1600
+
1601
+ 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.
1602
+
1603
+ 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
1604
 
1187
1605
  ## Housekeeping
1188
1606
 
@@ -1351,6 +1769,18 @@ ActiveSupport::Notifications.subscribe("step.geneva_drive") do |event|
1351
1769
  end
1352
1770
  ```
1353
1771
 
1772
+ ### Metric gauges
1773
+
1774
+ `GenevaDrive::HousekeepingJob` reports gauges via [Measurometer](https://rubygems.org/gems/measurometer) on every run:
1775
+
1776
+ | Gauge | Tags | Value |
1777
+ |-------|------|-------|
1778
+ | `geneva_drive.<state>` | _(none)_ | Absolute count of workflows in `<state>` (e.g. `ready`, `paused`, `finished`), summed across all classes |
1779
+ | `geneva_drive.<state>` | `workflow: <ClassName>` | Absolute count of workflows in `<state>` for a single workflow class |
1780
+ | `geneva_drive.paused_ratio` | `workflow: <ClassName>` | Float `0.0..1.0` — that class's `paused` count divided by its total population (all states) |
1781
+
1782
+ `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.
1783
+
1354
1784
  ---
1355
1785
 
1356
1786
  # Part VI — Appendix
@@ -1704,9 +2134,10 @@ end
1704
2134
  |--------|--------|
1705
2135
  | `cancel!` | Stop workflow, mark canceled |
1706
2136
  | `pause!` | Stop workflow, await manual resume |
1707
- | `reattempt!(wait:)` | Retry current step, optionally after delay |
2137
+ | `reattempt!(wait:, rewind:)` | Retry current step; `rewind: true` clears cursor |
1708
2138
  | `skip!` | Skip current step, proceed to next |
1709
2139
  | `finished!` | Complete workflow early |
2140
+ | `suspend!(wait:)` | Interrupt resumable step, continue via successor after delay |
1710
2141
 
1711
2142
  ### Workflow States
1712
2143
 
@@ -1723,19 +2154,26 @@ end
1723
2154
  | State | Meaning |
1724
2155
  |-------|---------|
1725
2156
  | `scheduled` | Waiting to run |
2157
+ | `waiting` | Parked until a matching signal arrives |
1726
2158
  | `in_progress` | Currently executing |
1727
2159
  | `completed` | Finished successfully |
1728
2160
  | `failed` | Exception occurred |
1729
2161
  | `canceled` | Canceled before execution |
1730
2162
  | `skipped` | Skipped via `skip_if` or `skip!` |
1731
2163
 
2164
+ A resumable step interrupted mid-iteration completes its execution with outcome `continued` and schedules a successor execution — there is no separate state for it.
2165
+
1732
2166
  ### Step Options
1733
2167
 
1734
2168
  | Option | Type | Description |
1735
2169
  |--------|------|-------------|
1736
2170
  | `wait:` | Duration | Delay before step executes |
2171
+ | `wait_for:` | Symbol, String, `SignalMatcher`, matcher object | Park until a matching signal arrives |
2172
+ | `job_options:` | Hash | Options passed to Active Job's `set` method for this step |
1737
2173
  | `skip_if:` | Proc, Symbol, Boolean | Condition to skip step |
1738
2174
  | `on_exception:` | Symbol | Exception handler (`:pause!`, `:cancel!`, `:reattempt!`, `:skip!`) |
1739
2175
  | `max_reattempts:` | Integer, nil | Max consecutive reattempts before pausing (default: 100, `nil` = unlimited) |
1740
2176
  | `before_step:` | Symbol | Insert before this step |
1741
2177
  | `after_step:` | Symbol | Insert after this step |
2178
+ | `max_iterations:` | Integer | (resumable_step) Interrupt after N iterations, continue via successor |
2179
+ | `max_runtime:` | Duration | (resumable_step) Interrupt after duration elapsed, continue via successor |
data/README.md CHANGED
@@ -8,6 +8,8 @@ GenevaDrive provides a clean DSL for defining multi-step workflows that execute
8
8
 
9
9
  - **Hero-oriented**: Workflows are associated with a polymorphic "hero" (the subject of the workflow)
10
10
  - **Step-based execution**: Define workflows as a series of steps with optional wait times
11
+ - **Resumable steps**: Iterate over large collections with a database-checkpointed cursor, surviving interruptions
12
+ - **Signals**: Steps can park until an external event arrives (`wait_for:`), woken by `workflow.signal!` from a webhook or controller
11
13
  - **Durable**: Steps are persisted to the database, surviving process restarts
12
14
  - **Idempotent**: Database constraints ensure each step runs exactly once
13
15
  - **Flow control**: Methods like `cancel!`, `pause!`, `skip!`, `reattempt!`, and `finished!`
@@ -164,12 +166,14 @@ class PremiumWorkflow < BaseWorkflow
164
166
  cancel_if { hero.subscription_expired? }
165
167
  set_step_job_options queue: :premium, priority: 10
166
168
 
167
- step :premium_feature do
169
+ step :premium_feature, job_options: {priority: -1} do
168
170
  # Inherits cancel_if from parent
169
171
  end
170
172
  end
171
173
  ```
172
174
 
175
+ 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.
176
+
173
177
  ### Hero Deletion Handling
174
178
 
175
179
  By default, workflows cancel if their hero is deleted:
@@ -46,10 +46,30 @@ 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
+ )
68
+
69
+ migration_template(
70
+ "add_signals_support.rb",
71
+ "db/migrate/add_signals_support_to_geneva_drive.rb"
72
+ )
53
73
  end
54
74
 
55
75
  # Creates the initializer file.