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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +11 -0
- data/MANUAL.md +273 -11
- data/README.md +3 -1
- data/lib/generators/geneva_drive/install/install_generator.rb +15 -0
- data/lib/generators/geneva_drive/install/templates/add_metadata_to_workflows.rb +17 -0
- data/lib/generators/geneva_drive/install/templates/add_resumable_step_support.rb +29 -0
- data/lib/generators/geneva_drive/install/templates/add_started_at_index_to_step_executions.rb +48 -0
- data/lib/generators/geneva_drive/install/templates/create_workflows_migration.rb +11 -0
- data/lib/generators/geneva_drive/install/templates/initializer.rb.tt +8 -0
- data/lib/geneva_drive/combined_exception_policy.rb +1 -1
- data/lib/geneva_drive/exception_policy.rb +36 -10
- data/lib/geneva_drive/executor.rb +239 -16
- data/lib/geneva_drive/flow_control.rb +44 -4
- data/lib/geneva_drive/iterable_step.rb +199 -0
- data/lib/geneva_drive/job_options.rb +70 -0
- data/lib/geneva_drive/jobs/housekeeping_job.rb +37 -4
- data/lib/geneva_drive/resumable_step_definition.rb +97 -0
- data/lib/geneva_drive/step_definition.rb +14 -1
- data/lib/geneva_drive/step_execution.rb +105 -2
- data/lib/geneva_drive/test_helpers.rb +123 -4
- data/lib/geneva_drive/version.rb +1 -1
- data/lib/geneva_drive/workflow/metadata_accessor.rb +85 -0
- data/lib/geneva_drive/workflow.rb +245 -34
- data/lib/geneva_drive.rb +15 -0
- data/test/dsl/step_definition_test.rb +70 -0
- data/test/jobs/housekeeping_job_test.rb +119 -0
- data/test/jobs/perform_step_job_test.rb +25 -2
- data/test/test_helper.rb +2 -0
- data/test/test_helper_test.rb +281 -0
- data/test/workflow/cursor_size_limit_test.rb +75 -0
- data/test/workflow/instrumentation_test.rb +28 -0
- data/test/workflow/resumable_step_integration_test.rb +341 -0
- data/test/workflow/resumable_step_test.rb +615 -0
- data/test/workflow/resumable_without_migration_test.rb +103 -0
- data/test/workflow/workflow_test.rb +72 -1
- metadata +15 -15
- data/test/dummy/config/initializers/geneva_drive.rb +0 -44
- data/test/dummy/db/migrate/20260219212321_create_geneva_drive_workflows.rb +0 -74
- data/test/dummy/db/migrate/20260219212322_create_geneva_drive_step_executions.rb +0 -108
- data/test/dummy/db/migrate/20260219212323_add_finished_at_to_geneva_drive_step_executions.rb +0 -25
- data/test/dummy/db/migrate/20260219212324_add_error_class_name_to_geneva_drive_step_executions.rb +0 -7
- data/test/dummy/db/migrate/20260316100007_add_metadata_to_geneva_drive_step_executions.rb +0 -17
- data/test/dummy/db/migrate/20260327000000_allow_null_hero_on_geneva_drive_workflows.rb +0 -66
- data/test/dummy/db/schema.rb +0 -73
- data/test/dummy/log/development.log +0 -202
- data/test/dummy/log/test.log +0 -79471
- data/test/dummy/log/test.log.0 +0 -4
- data/test/dummy/tmp/local_secret.txt +0 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a4a73216fcf8355550c3b5e26e76eee14468eb301e8eb315a979bc8f34bdb670
|
|
4
|
+
data.tar.gz: a9b9cc0d8998f6c479806158dd12725774431e7dba8486c5781ed68f238175b5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
|
282
|
-
|
|
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 `:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|