geneva_drive 0.7.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +21 -0
- data/MANUAL.md +299 -5
- data/README.md +14 -1
- data/Rakefile +28 -1
- data/bin/matrix +76 -0
- data/bin/md2html +114 -0
- data/lib/generators/geneva_drive/install/templates/add_resumable_step_support.rb +5 -9
- data/lib/generators/geneva_drive/install/templates/add_signals_support.rb +5 -10
- data/lib/generators/geneva_drive/install/templates/create_workflows_migration.rb +1 -1
- data/lib/generators/geneva_drive/install/templates/initializer.rb.tt +33 -2
- data/lib/geneva_drive/dispatchers/active_job_dispatcher.rb +33 -0
- data/lib/geneva_drive/dispatchers/sidekiq_dispatcher.rb +112 -0
- data/lib/geneva_drive/engine.rb +13 -0
- data/lib/geneva_drive/executor.rb +23 -3
- data/lib/geneva_drive/job_options.rb +7 -3
- data/lib/geneva_drive/jobs/perform_step_job.rb +2 -35
- data/lib/geneva_drive/jobs/sidekiq_housekeeping_job.rb +32 -0
- data/lib/geneva_drive/jobs/sidekiq_perform_step_job.rb +35 -0
- data/lib/geneva_drive/jobs/step_execution_lookup.rb +42 -0
- data/lib/geneva_drive/json_column.rb +72 -0
- data/lib/geneva_drive/migration_helpers.rb +60 -3
- data/lib/geneva_drive/removed_step_definition.rb +34 -0
- data/lib/geneva_drive/signal.rb +4 -2
- data/lib/geneva_drive/step_collection.rb +31 -7
- data/lib/geneva_drive/step_definition.rb +9 -0
- data/lib/geneva_drive/step_execution.rb +3 -2
- data/lib/geneva_drive/version.rb +1 -1
- data/lib/geneva_drive/workflow.rb +200 -39
- data/lib/geneva_drive.rb +65 -0
- data/test/dummy/config/database.yml +40 -5
- data/test/dummy/config/initializers/geneva_drive.rb +78 -0
- data/test/dummy/db/mysql/schema-8.1.rb +95 -0
- data/test/dummy/db/postgres/schema-8.1.rb +99 -0
- data/test/dummy/db/sqlite/schema-7.2.rb +96 -0
- data/test/dummy/db/sqlite/schema-8.0.rb +96 -0
- data/test/dummy/db/sqlite/schema-8.1.rb +96 -0
- data/test/dummy/db/sqlite/test.sqlite3 +0 -0
- data/test/dummy/db/sqlite/test.sqlite3-0 +0 -0
- data/test/dummy/db/sqlite/test.sqlite3-1 +0 -0
- data/test/dummy/db/sqlite/test.sqlite3-2 +0 -0
- data/test/dummy/db/sqlite/test.sqlite3-3 +0 -0
- data/test/dummy/db/sqlite/test.sqlite3-4 +0 -0
- data/test/dummy/db/sqlite/test.sqlite3-5 +0 -0
- data/test/dummy/db/sqlite/test.sqlite3-6 +0 -0
- data/test/dummy/db/sqlite/test.sqlite3-7 +0 -0
- data/test/dummy/db/sqlite/test.sqlite3-8 +0 -0
- data/test/dummy/db/sqlite/test.sqlite3-9 +0 -0
- data/test/dummy/db/sqlite/test.sqlite3_0 +0 -0
- data/test/dummy/db/sqlite/test.sqlite3_1 +0 -0
- data/test/dummy/db/sqlite/test.sqlite3_2 +0 -0
- data/test/dummy/db/sqlite/test.sqlite3_3 +0 -0
- data/test/dummy/db/sqlite/test.sqlite3_4 +0 -0
- data/test/dummy/db/sqlite/test.sqlite3_5 +0 -0
- data/test/dummy/db/sqlite/test.sqlite3_6 +0 -0
- data/test/dummy/db/sqlite/test.sqlite3_7 +0 -0
- data/test/dummy/db/sqlite/test.sqlite3_8 +0 -0
- data/test/dummy/db/sqlite/test.sqlite3_9 +0 -0
- data/test/dummy/db/sqlite3/schema-7.2.rb +96 -0
- data/test/dummy/db/sqlite3/schema-8.1.rb +96 -0
- data/test/dummy/db/sqlite3/test.sqlite3 +0 -0
- data/test/dummy/db/sqlite3/test.sqlite3_0 +0 -0
- data/test/dummy/db/sqlite3/test.sqlite3_1 +0 -0
- data/test/dummy/db/sqlite3/test.sqlite3_2 +0 -0
- data/test/dummy/db/sqlite3/test.sqlite3_3 +0 -0
- data/test/dummy/db/sqlite3/test.sqlite3_4 +0 -0
- data/test/dummy/db/sqlite3/test.sqlite3_5 +0 -0
- data/test/dummy/db/sqlite3/test.sqlite3_6 +0 -0
- data/test/dummy/db/sqlite3/test.sqlite3_7 +0 -0
- data/test/dummy/db/sqlite3/test.sqlite3_8 +0 -0
- data/test/dummy/db/sqlite3/test.sqlite3_9 +0 -0
- data/test/dummy_install/db/migrate/20260217112949_create_geneva_drive_workflows.rb +76 -0
- data/test/dummy_install/db/migrate/20260217112950_create_geneva_drive_step_executions.rb +99 -0
- data/test/dummy_install/db/migrate/20260217112951_add_finished_at_to_geneva_drive_step_executions.rb +27 -0
- data/test/dummy_install/db/migrate/20260217112952_add_error_class_name_to_geneva_drive_step_executions.rb +9 -0
- data/test/dummy_install/db/migrate/20260217112953_add_resumable_step_support_to_geneva_drive_step_executions.rb +128 -0
- data/test/dummy_install/db/migrate/20260217112954_add_chained_step_executions_to_geneva_drive_step_executions.rb +136 -0
- data/test/dummy_install/db/schema.rb +75 -0
- data/test/dummy_install/log/test.log +1852 -0
- data/test/generators/install_generator_test.rb +56 -0
- data/test/geneva_drive_test.rb +10 -0
- data/test/migration_helpers_test.rb +62 -0
- data/test/sidekiq/sidekiq_dispatcher_test.rb +322 -0
- data/test/sidekiq/sidekiq_redis_test.rb +115 -0
- data/test/test_helper.rb +51 -13
- data/test/workflow/pause_resume_test.rb +15 -0
- data/test/workflow/removed_step_test.rb +294 -0
- data/test/workflow/resume_at_test.rb +170 -0
- metadata +62 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b415480aa40bc610a79bdc6802a636d70ac54dd6adddbd3d8b44d8afbb4a1e44
|
|
4
|
+
data.tar.gz: a7e729983cf52ad726d12ea83661b17da8d34413b03a8f349919e342dbfdac52
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1c8c27663ca3f19cb671b0c5c16c62c96487059f823ce5a9dfc35a4366a31d01582ba9ced0fc4140f6fba542e0ede4046b0b5f92518555b1fac575875440fb71
|
|
7
|
+
data.tar.gz: b5d56f17405a2e032b3ef4f977bd38dc7dc071a7d2160811b996dabe0301bf6275ebb9c0b23aaee4c5cfce86403d8e76ce8b438e716bddd022d02248e9b95b5b
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,26 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## Unreleased
|
|
4
|
+
|
|
5
|
+
## [0.9.0]
|
|
6
|
+
|
|
7
|
+
- Run on Sidekiq without Active Job. `GenevaDrive.dispatcher = :sidekiq` pushes every step as `GenevaDrive::SidekiqPerformStepJob`, a plain `Sidekiq::Job` carrying the step execution id, through `Sidekiq::Client`. Client middleware applies, the jid lands in `job_id`, and jobs are tagged with the workflow class. Supported are Sidekiq 7.3 and later 7.x, and 8.x; with any other version the dispatcher raises at boot. The default dispatcher is still `:active_job`.
|
|
8
|
+
- Sidekiq retries are off for step jobs, since exception policies already decide reattempts. The step's exception is still raised, so Sidekiq's error handlers report it.
|
|
9
|
+
- The Sidekiq dispatcher pushes synchronously, bypassing `Sidekiq.transactional_push!`, which would defer the push a second time from inside GenevaDrive's own after-commit callback - the double deferral already fixed for Solid Queue.
|
|
10
|
+
- `priority:` in job options raises under the Sidekiq dispatcher, as Sidekiq has queues with weights rather than priorities.
|
|
11
|
+
- Add `GenevaDrive::SidekiqHousekeepingJob` for running housekeeping from Sidekiq Enterprise periodic jobs or sidekiq-cron.
|
|
12
|
+
- The install generator writes `GenevaDrive.dispatcher = :sidekiq` into a new initializer when Active Job's adapter is `:sidekiq`. Nothing is detected at boot, so upgrading never switches an existing app.
|
|
13
|
+
- `resume!` now records the id of the job it pushes for an execution that was paused while scheduled. The row used to keep the previous job's id, which could already have run and done nothing, so the job behind the execution could not be found from it.
|
|
14
|
+
- Fix resumable steps not handing off when Sidekiq shuts down. The executor asked `Sidekiq::CLI.instance.stopping?`, a method the CLI does not have (only its launcher does), so inside a `sidekiq` process every interruption check of a resumable step raised `NoMethodError`. The engine now subscribes to Sidekiq's `:quiet` event, which fires on TSTP (what Enterprise rolling restarts send first) and at the start of every shutdown, and resumable steps checkpoint and continue in a successor execution. This applies with either dispatcher, including Active Job on the Sidekiq adapter.
|
|
15
|
+
- Create `hero_id` as a string column when the application's tables use string primary keys. Key type detection only recognized `uuid`, `char(36)` and `varchar(36)` id columns, but on SQLite an `id: :string` table (for example with `primary_key_type: :string` and UUIDv7 ids) reports a bare `varchar` with no length. Detection fell through to `:bigint`, the workflows migration created an integer `hero_id`, and every string hero id was silently cast to an integer on assignment, pointing workflows at the wrong hero and colliding in the ongoing-uniqueness index. The workflows migration now uses a separate `geneva_drive_hero_key_type` helper that returns `:uuid`, `:string` or `:bigint` and treats `varchar`/`char(n)`/`text` ids of any length as string keys. GenevaDrive's own tables keep their bigint ids. Existing installs affected by this need to change `hero_id` to a string column themselves.
|
|
16
|
+
|
|
17
|
+
## [0.8.0]
|
|
18
|
+
|
|
19
|
+
- Add `removed_step :name` for taking a step out of a workflow that has instances in flight. Deleting a `step` outright strands every workflow holding a scheduled execution for it: the executor finds no definition and pauses with `StepNotDefinedError`, which during a rolling deploy can hit every workflow sitting on that step at once. The gem cannot recover from this on its own — it knows the step is gone, but not whether the steps after it were ever meant to run, and a step's position says nothing about reachability (a `capture_payment` that normally ends the workflow by calling `finished!` sits directly before `refund`, so skipping forward would refund a payment that was never captured). `removed_step` is how the author of the removal records that judgement in advance: declared in the position the step used to occupy, it keeps the name resolvable so executions scheduled before the removal are skipped and the workflow continues to the next runnable step, while scheduling never spools an execution for the name again and `before_step:`/`after_step:` references keep working. An execution parked on the step waiting for a signal is released on `resume!` rather than waiting for an event that can no longer match. `Workflow.removed_steps_in_flight` reports which gravestones step execution rows still reference (ignoring finished and canceled workflows), so a `removed_step` line can be deleted once it comes back empty. The default for an undeclared removal is unchanged: the workflow still pauses.
|
|
20
|
+
- Add `workflow.resume_at!(:step_name)` for workflows already stranded on a step that was deleted without a `removed_step` gravestone. Plain `resume!` had nowhere to send them and died with a `NoMethodError` on nil deep inside `create_step_execution`; it now raises `StepNotDefinedError` naming the missing step, the steps still defined, and the way out. Where to land is deliberately the operator's call, for the same reason the executor will not guess it.
|
|
21
|
+
- Support MariaDB for the JSON columns (resumable step `cursor`, signal `payload`). MariaDB has no JSON type: `json` there is an alias for LONGTEXT with an auto-generated CHECK constraint named after the column, and since `cursor` is reserved in MariaDB, loading a schema dump that re-emits that constraint died on a syntax error. Rails also types such a column as text, so a Hash written to it was stored as `#inspect` output, which the same CHECK constraint then rejected. The installer migrations now ask for plain LONGTEXT on MariaDB via the new `geneva_drive_json_column` migration helper, and the models encode and decode JSON themselves whenever the column is text. The check is on the column, not the adapter, so installs that already ran the old migration on MariaDB get the encoding without having to rewrite the column.
|
|
22
|
+
- Test against Rails 7.2, 8.0 and 8.1 on SQLite, PostgreSQL, MySQL and MariaDB. Previously the appraisals stopped at Rails 8.0, and the MySQL CI job could not build `mysql2`.
|
|
23
|
+
|
|
3
24
|
## [0.7.0]
|
|
4
25
|
|
|
5
26
|
- 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).
|
data/MANUAL.md
CHANGED
|
@@ -85,6 +85,8 @@ If your application uses UUID primary keys, the migrations will detect this and
|
|
|
85
85
|
|
|
86
86
|
Note that you don't want to mix integer IDs and UUIDs in the same application. geneva_drive uses a polymorphic relation for the `hero` of the workflow, which has a typed `hero_id` column. You will thus want either all of your models to be used as heroes to have UUID primary keys or bigint primary keys, but not mix the two.
|
|
87
87
|
|
|
88
|
+
String primary keys (`id: :string`, for example with `primary_key_type: :string` in your generator config) are detected too: `hero_id` is then created as a string column, while the geneva_drive tables themselves keep bigint ids. If detection picks the wrong type for your heroes, edit the `hero_id` column in the generated workflows migration before running it.
|
|
89
|
+
|
|
88
90
|
> [!WARNING]
|
|
89
91
|
> If you are using UUIDs, we also strongly recommend adopting a lexicographically ordered UUID flavour, at least for the geneva_drive tables. Such flavours include [UUIDv7](https://github.com/seouri/rails-uuid-pk) and [tou](https://github.com/cheddar-me/tou) - you will want your primary keys to be pre-sorted for using the admin effectively, as well as for efficient `find_each` usage with the geneva_drive tables.
|
|
90
92
|
|
|
@@ -325,6 +327,80 @@ end
|
|
|
325
327
|
|
|
326
328
|
The referenced step must already be defined — you can only insert before or after steps that appear earlier in the class body.
|
|
327
329
|
|
|
330
|
+
### Removing a Step with `removed_step`
|
|
331
|
+
|
|
332
|
+
Deleting a `step` is not safe while workflows are in flight. Step executions are rows scheduled ahead of time, so at the moment a deploy lands there are workflows holding a row that names a step the new code no longer has. When such a row's job runs, the executor finds no definition and pauses the workflow with `StepNotDefinedError`. During a rolling deploy this can happen to every workflow sitting on that step at once.
|
|
333
|
+
|
|
334
|
+
GenevaDrive cannot recover from this by itself, and the reason is worth being precise about. It knows the step is gone. It does not know whether the work that step did still needs doing, and — more importantly — it does not know which of the remaining steps would have run had it executed. A step's position in the sequence says nothing about reachability: a `capture_payment` that normally ends the workflow by calling `finished!` sits directly before `refund`, and nothing in the database distinguishes "carry on to the next one" from "stop here". Skipping forward on the workflow's behalf would, in exactly that case, issue a refund for a payment that was never captured. So the executor pauses and waits for a human, which is the only correct thing it can do on its own.
|
|
335
|
+
|
|
336
|
+
`removed_step` is how you tell it the answer in advance. Declare it in the position the step used to occupy:
|
|
337
|
+
|
|
338
|
+
```ruby
|
|
339
|
+
class PaymentWorkflow < GenevaDrive::Workflow
|
|
340
|
+
step :authorize do
|
|
341
|
+
PaymentGateway.authorize(hero)
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
removed_step :capture_payment
|
|
345
|
+
|
|
346
|
+
step :send_receipt do
|
|
347
|
+
ReceiptMailer.receipt(hero).deliver_later
|
|
348
|
+
end
|
|
349
|
+
end
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
The declaration is an assertion only you can make: *this step no longer needs to happen, carry on past it.* Having been told, GenevaDrive can act without guessing:
|
|
353
|
+
|
|
354
|
+
- Executions already scheduled under the old name resolve, are marked `skipped`, and the workflow proceeds to the next runnable step.
|
|
355
|
+
- Scheduling never spools a new execution for the name again — a new `PaymentWorkflow` goes `authorize` → `send_receipt`.
|
|
356
|
+
- `before_step:` / `after_step:` references to the name keep working.
|
|
357
|
+
- An execution parked on the step waiting for a signal is released on `resume!` instead of waiting for an event that can no longer match.
|
|
358
|
+
|
|
359
|
+
A removed step takes a name and nothing else. There is nothing to configure about a step that does not run.
|
|
360
|
+
|
|
361
|
+
Because the gravestone is in the source, in order, the decision is visible in the diff that removes the step — which is where a reviewer can still object to it.
|
|
362
|
+
|
|
363
|
+
### Retiring the Gravestone
|
|
364
|
+
|
|
365
|
+
A `removed_step` line is temporary. It can go once no step execution row can land on it any more, which is a question you can ask directly:
|
|
366
|
+
|
|
367
|
+
```ruby
|
|
368
|
+
PaymentWorkflow.removed_steps_in_flight
|
|
369
|
+
# => {"capture_payment" => 3} # three rows still point at it — leave the line in place
|
|
370
|
+
# => {} # drained — the removed_step line can be deleted
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
Rows belonging to finished and canceled workflows are ignored, since they will never execute again. The full lifecycle is: replace `step` with `removed_step` and deploy; let in-flight workflows drain past it; delete the line in a later deploy once `removed_steps_in_flight` comes back empty.
|
|
374
|
+
|
|
375
|
+
### Recovering a Workflow Stranded by a Deleted Step
|
|
376
|
+
|
|
377
|
+
If a step was deleted without leaving a gravestone, the workflows that were sitting on it are paused and pointed at a name that no longer resolves. Plain `resume!` has nowhere to send them and says so:
|
|
378
|
+
|
|
379
|
+
```ruby
|
|
380
|
+
workflow.resume!
|
|
381
|
+
# GenevaDrive::StepNotDefinedError: Cannot resume PaymentWorkflow #42: step 'capture_payment'
|
|
382
|
+
# is no longer defined in PaymentWorkflow. Pick the step to continue from with
|
|
383
|
+
# resume_at!(:step_name) — the steps still defined are authorize, send_receipt — or cancel!
|
|
384
|
+
# the workflow. ...
|
|
385
|
+
```
|
|
386
|
+
|
|
387
|
+
`resume_at!` is the escape hatch. You name the step to continue from, because only you know what the deleted step would have done:
|
|
388
|
+
|
|
389
|
+
```ruby
|
|
390
|
+
workflow.resume_at!(:send_receipt)
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
It cancels any stray execution, points the workflow at the named step, and continues from there. It refuses names that are not defined, and refuses to land on a `removed_step`. To find the affected workflows:
|
|
394
|
+
|
|
395
|
+
```ruby
|
|
396
|
+
GenevaDrive::Workflow.paused
|
|
397
|
+
.joins(:step_executions)
|
|
398
|
+
.where(geneva_drive_step_executions: {error_class_name: "GenevaDrive::StepNotDefinedError"})
|
|
399
|
+
.distinct
|
|
400
|
+
```
|
|
401
|
+
|
|
402
|
+
If the workflow should not continue at all, `cancel!` it.
|
|
403
|
+
|
|
328
404
|
## Conditional Execution
|
|
329
405
|
|
|
330
406
|
### Skipping Steps with `skip_if:`
|
|
@@ -626,7 +702,7 @@ A step is interrupted when any of these happen:
|
|
|
626
702
|
|
|
627
703
|
- `max_iterations:` is reached (`resumable_step :import, max_iterations: 10_000`)
|
|
628
704
|
- `max_runtime:` is exceeded (`resumable_step :import, max_runtime: 5.minutes`)
|
|
629
|
-
- The job queue signals shutdown (
|
|
705
|
+
- The job queue signals shutdown (Sidekiq going quiet or stopping — see [Shutdowns and rolling restarts](#shutdowns-and-rolling-restarts))
|
|
630
706
|
- The workflow is paused or canceled externally
|
|
631
707
|
- The step calls `skip_to!` or `suspend!` explicitly
|
|
632
708
|
|
|
@@ -1227,8 +1303,13 @@ end
|
|
|
1227
1303
|
# Resume a specific workflow
|
|
1228
1304
|
workflow = GenevaDrive::Workflow.find(id)
|
|
1229
1305
|
workflow.resume! # Re-enqueues existing scheduled step or creates new one
|
|
1306
|
+
|
|
1307
|
+
# Or continue from a specific step, when the step it was pointed at is gone
|
|
1308
|
+
workflow.resume_at!(:send_receipt)
|
|
1230
1309
|
```
|
|
1231
1310
|
|
|
1311
|
+
`resume_at!` exists for workflows paused on a step that no longer exists in the code. See [Recovering a Workflow Stranded by a Deleted Step](#recovering-a-workflow-stranded-by-a-deleted-step).
|
|
1312
|
+
|
|
1232
1313
|
## Workflow States
|
|
1233
1314
|
|
|
1234
1315
|
### State Machine Diagram
|
|
@@ -1504,7 +1585,7 @@ We recommend using a queue adapter that co-commits with your database:
|
|
|
1504
1585
|
|
|
1505
1586
|
Co-committing matters because GenevaDrive relies on transactional guarantees. When a step completes and schedules the next step, both the state change and the job enqueue should be atomic. With co-committing adapters, if the transaction rolls back, the job is never enqueued.
|
|
1506
1587
|
|
|
1507
|
-
With non-transactional
|
|
1588
|
+
With a non-transactional adapter such as Resque, or Sidekiq behind Active Job, the job and the database are two systems, and the step execution row is what makes that safe: GenevaDrive enqueues only after the row has committed, and housekeeping re-enqueues any execution whose job got lost on the way. If your app writes Sidekiq jobs rather than Active Job ones, you do not have to go through Active Job at all — see [Running on Sidekiq](#running-on-sidekiq).
|
|
1508
1589
|
|
|
1509
1590
|
### Inline Enqueueing for Bulk Operations
|
|
1510
1591
|
|
|
@@ -1542,7 +1623,7 @@ The method is thread-safe and won't affect other concurrent requests.
|
|
|
1542
1623
|
|
|
1543
1624
|
### Custom Job Options
|
|
1544
1625
|
|
|
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.
|
|
1626
|
+
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`. (On the Sidekiq dispatcher `:priority` is rejected, since Sidekiq has none — see [Queues instead of priorities](#queues-instead-of-priorities).) Unknown keys are rejected at configuration time so that typos surface immediately instead of silently falling back to defaults.
|
|
1546
1627
|
|
|
1547
1628
|
```ruby
|
|
1548
1629
|
class HighPriorityWorkflow < GenevaDrive::Workflow
|
|
@@ -1554,7 +1635,7 @@ class HighPriorityWorkflow < GenevaDrive::Workflow
|
|
|
1554
1635
|
end
|
|
1555
1636
|
```
|
|
1556
1637
|
|
|
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
|
|
1638
|
+
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 handed to the dispatcher — with the default one, straight to `PerformStepJob.set(...)` — for every enqueue of that step, including reattempts and resume re-enqueueing.
|
|
1558
1639
|
|
|
1559
1640
|
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
1641
|
|
|
@@ -1602,6 +1683,197 @@ Workflows that have already extracted their artifact can now drain through `:pro
|
|
|
1602
1683
|
|
|
1603
1684
|
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.
|
|
1604
1685
|
|
|
1686
|
+
## Running on Sidekiq
|
|
1687
|
+
|
|
1688
|
+
Plenty of Rails apps never adopted Active Job. They have a `app/sidekiq` directory full of `include Sidekiq::Job` classes, a Sidekiq Pro or Enterprise license, a Web UI the on-call engineer knows by heart, and a set of client middleware that encrypts arguments or deduplicates pushes. For those apps, "steps run through Active Job" means a second job system bolted onto the first: another serialization format, another set of retry semantics, and jobs in the Busy page called `Sidekiq::ActiveJob::Wrapper` instead of anything you recognise.
|
|
1689
|
+
|
|
1690
|
+
GenevaDrive does not actually need much from a job system. The step execution row is the durable record of what has to happen next; the job is only a wake-up call that carries the row's id. So we ship a dispatcher that pushes that wake-up call to Sidekiq directly:
|
|
1691
|
+
|
|
1692
|
+
```ruby
|
|
1693
|
+
# config/initializers/geneva_drive.rb
|
|
1694
|
+
GenevaDrive.dispatcher = :sidekiq
|
|
1695
|
+
```
|
|
1696
|
+
|
|
1697
|
+
That is the whole switch. Every step of every workflow is now pushed as a `GenevaDrive::SidekiqPerformStepJob` — a plain `Sidekiq::Job` — through `Sidekiq::Client`, with no Active Job in between. Set it in an initializer, so that it is in place before any workflow class loads: job options are checked against the dispatcher when a workflow class is defined.
|
|
1698
|
+
|
|
1699
|
+
We support **Sidekiq 7.3 and later 7.x, and Sidekiq 8.x** — OSS, Pro and Enterprise alike — and test the oldest supported Sidekiq on the oldest supported Rails and the newest on the newest, against a real Redis. Sidekiq 9 is not supported until we have tested it. With any other version loaded, `GenevaDrive.dispatcher = :sidekiq` raises at boot, naming the version it found.
|
|
1700
|
+
|
|
1701
|
+
#### What the installer picks
|
|
1702
|
+
|
|
1703
|
+
When `bin/rails generate geneva_drive:install` writes `config/initializers/geneva_drive.rb`, it checks Active Job's adapter once. If the adapter is `:sidekiq` and a supported Sidekiq is loaded, the initializer gets an uncommented `GenevaDrive.dispatcher = :sidekiq`. Otherwise the line stays commented out. An unset adapter does not count: the generator runs in development, and an app that leaves development on `:async` may well run Solid Queue in production.
|
|
1704
|
+
|
|
1705
|
+
Nothing is detected at boot. Without that line the dispatcher is `:active_job`, so upgrading the gem never switches an app that is already running workflows.
|
|
1706
|
+
|
|
1707
|
+
### What the jobs look like
|
|
1708
|
+
|
|
1709
|
+
A step job carries one argument, the step execution id, which is a plain integer (or string, with UUID keys) and therefore passes Sidekiq's strict argument checks. It is tagged `geneva_drive` plus the workflow class name, and the Web UI shows those tags next to each job:
|
|
1710
|
+
|
|
1711
|
+
```ruby
|
|
1712
|
+
{
|
|
1713
|
+
"class" => "GenevaDrive::SidekiqPerformStepJob",
|
|
1714
|
+
"args" => [48213],
|
|
1715
|
+
"queue" => "default",
|
|
1716
|
+
"retry" => false,
|
|
1717
|
+
"tags" => ["geneva_drive", "SubscriptionRenewalWorkflow"]
|
|
1718
|
+
}
|
|
1719
|
+
```
|
|
1720
|
+
|
|
1721
|
+
While a step runs, the workflow id, step name and step execution id are added to `Sidekiq::Context`, so Sidekiq's own log lines for that job carry them. The jid is written to the step execution's `job_id`, the same as the Active Job id is with the default dispatcher, so you can go from a row in the database to the job in the Web UI and back.
|
|
1722
|
+
|
|
1723
|
+
A step with `wait:` is pushed with an `at` timestamp and waits in Sidekiq's Scheduled set; a step that is due now goes straight onto its queue.
|
|
1724
|
+
|
|
1725
|
+
### Retries belong to the step, not the job
|
|
1726
|
+
|
|
1727
|
+
Sidekiq's default is to retry a failing job 25 times over three weeks. The step job switches that off with `retry: false`, and this is deliberate.
|
|
1728
|
+
|
|
1729
|
+
GenevaDrive already decides what happens when a step raises: that is what [exception policies](#exception-handling) are for. A policy might reattempt the step in five minutes, pause the workflow for a human, or cancel it. Every one of those outcomes is committed to the step execution row before the job finishes. A reattempt is a *new* step execution with a job of its own. If Sidekiq also retried the original job, the retry would find the execution already settled and do nothing — so all a Sidekiq retry would add is ghost entries in the Retry set, and a Web UI that disagrees with the workflow about what failed.
|
|
1730
|
+
|
|
1731
|
+
The exception is still raised out of the job once the executor has committed the step's state, so Sidekiq's error handlers — Sentry, Honeybadger, AppSignal, whatever you have configured — report it exactly as for any other failing job. Because the job is not retried, Sidekiq's death handlers see it too. Treat that as "this job is done", not "this workflow is dead": the step execution row is the ledger, and the workflow's state is the answer to "is this broken?".
|
|
1732
|
+
|
|
1733
|
+
### Why we bypass transactional push
|
|
1734
|
+
|
|
1735
|
+
If your app calls `Sidekiq.transactional_push!`, every `perform_async` is deferred until the surrounding Active Record transaction commits. That is a good default for application jobs, and the dispatcher deliberately does not use it.
|
|
1736
|
+
|
|
1737
|
+
GenevaDrive already defers its push: it hands the step to the dispatcher from an `after_all_transactions_commit` callback, once the step execution row is visible to other connections. That callback runs after the SQL `COMMIT`, but possibly while the transaction is still unwinding on the Ruby side. A second deferral registered from inside it can be dropped on the floor — the step execution then has no job and sits in `scheduled` until housekeeping notices, 15 minutes later by default. We hit exactly this with Solid Queue (it is described at length in `PerformStepJob`), and the remedy is the same here: since we are already past the commit, push synchronously. The dispatcher builds its own `Sidekiq::Client` for that.
|
|
1738
|
+
|
|
1739
|
+
Client middleware still runs on that push, so argument encryption, Enterprise unique jobs, and your own middleware all see GenevaDrive's jobs like any others. `Sidekiq::Client.via` sharding is honoured as well.
|
|
1740
|
+
|
|
1741
|
+
### Queues instead of priorities
|
|
1742
|
+
|
|
1743
|
+
Sidekiq has no job priority. It has queues, and a process works its queues either in strict order or by weight. So under the Sidekiq dispatcher `job_options: {priority: ...}` raises `StepConfigurationError` when the workflow class loads, rather than being silently ignored. `queue:`, `wait:` and `wait_until:` work as usual.
|
|
1744
|
+
|
|
1745
|
+
The backlog problem from [Custom Job Options](#custom-job-options) — 50,000 fresh `:extract` steps crowding out the `:process` and `:cleanup` steps that free up disk — is solved with a queue that is worked first:
|
|
1746
|
+
|
|
1747
|
+
```ruby
|
|
1748
|
+
class RubygemsWorkflow < GenevaDrive::Workflow
|
|
1749
|
+
step :extract do
|
|
1750
|
+
# Download a large tarball to local disk
|
|
1751
|
+
end
|
|
1752
|
+
|
|
1753
|
+
step :process, job_options: {queue: :workflows_drain} do
|
|
1754
|
+
# Do work on the extracted data
|
|
1755
|
+
end
|
|
1756
|
+
|
|
1757
|
+
step :cleanup, job_options: {queue: :workflows_drain} do
|
|
1758
|
+
# Delete the on-disk artifact
|
|
1759
|
+
end
|
|
1760
|
+
end
|
|
1761
|
+
```
|
|
1762
|
+
|
|
1763
|
+
```yaml
|
|
1764
|
+
# config/sidekiq.yml
|
|
1765
|
+
:queues:
|
|
1766
|
+
- [workflows_drain, 5]
|
|
1767
|
+
- [default, 1]
|
|
1768
|
+
```
|
|
1769
|
+
|
|
1770
|
+
With weights, the drain queue is picked five times as often as `default`; list the queues without weights and Sidekiq works them strictly in order, so `default` only gets attention once the drain queue is empty.
|
|
1771
|
+
|
|
1772
|
+
### Your own job class
|
|
1773
|
+
|
|
1774
|
+
To carry options of your own — a dedicated queue, extra tags, Enterprise's `unique_for`, Pro's `expires_in` — subclass the step job and hand the subclass to the dispatcher:
|
|
1775
|
+
|
|
1776
|
+
```ruby
|
|
1777
|
+
# app/sidekiq/workflow_step_job.rb
|
|
1778
|
+
class WorkflowStepJob < GenevaDrive::SidekiqPerformStepJob
|
|
1779
|
+
sidekiq_options queue: "workflows", tags: ["geneva_drive", "billing"]
|
|
1780
|
+
end
|
|
1781
|
+
|
|
1782
|
+
# config/initializers/geneva_drive.rb
|
|
1783
|
+
Rails.application.config.to_prepare do
|
|
1784
|
+
GenevaDrive.dispatcher = GenevaDrive::SidekiqDispatcher.new(job_class: WorkflowStepJob)
|
|
1785
|
+
end
|
|
1786
|
+
```
|
|
1787
|
+
|
|
1788
|
+
The `to_prepare` block is there because `WorkflowStepJob` lives in `app/`, and Rails does not let an initializer autoload it. `to_prepare` runs after the initializers but before eager loading, so workflow classes still see the dispatcher when they are defined. In development it also runs again on every code reload, so the dispatcher picks up the reloaded class. The plain `GenevaDrive.dispatcher = :sidekiq` needs none of this, as it refers to nothing in `app/`.
|
|
1789
|
+
|
|
1790
|
+
Keep `retry: false` on the subclass for the reasons above. A queue set through `job_options:` on a workflow or step still wins over the class's queue.
|
|
1791
|
+
|
|
1792
|
+
### Shutdowns and rolling restarts
|
|
1793
|
+
|
|
1794
|
+
When Sidekiq is told to stop — or to go quiet, which is what Enterprise rolling restarts and most deploy scripts do first — it fires its `:quiet` event. GenevaDrive's engine subscribes to it whichever dispatcher you use, including Active Job on the Sidekiq adapter. From that moment every [resumable step](#resumable-steps) running in the process checkpoints its cursor at the end of the current iteration and hands off to a successor execution, which the next process picks up from where this one stopped.
|
|
1795
|
+
|
|
1796
|
+
That only helps if an iteration is short enough to finish before Sidekiq gives up waiting, which is 25 seconds by default (`-t`). So the rule of thumb is: a step must either finish well within the shutdown timeout, or be a `resumable_step` whose single iteration does. A plain step that is still running when the timeout expires is killed; Sidekiq puts its job back on the queue (Sidekiq Pro's `super_fetch` does this even after a hard crash), but the step execution stays `in_progress`. When the re-delivered job arrives, the executor sees an execution that is no longer `scheduled` and skips it as a duplicate. Housekeeping then recovers the execution once it has been `in_progress` for longer than `stuck_in_progress_threshold`.
|
|
1797
|
+
|
|
1798
|
+
Sidekiq 7.3 has iterable jobs of its own, and the two are worth comparing if you are deciding where a long iteration should live:
|
|
1799
|
+
|
|
1800
|
+
| | `Sidekiq::IterableJob` | GenevaDrive `resumable_step` |
|
|
1801
|
+
|---|---|---|
|
|
1802
|
+
| Where the cursor lives | Redis, flushed about every 5 seconds and on error | the step execution row, written by `checkpoint!` or on every iteration |
|
|
1803
|
+
| After a crash | resumes from the last Redis flush | resumes from the last checkpoint, in the database your work writes to |
|
|
1804
|
+
| On shutdown | raises `Interrupted` and re-enqueues the same job | completes the execution as `continued` and schedules a successor |
|
|
1805
|
+
| What you can see | the job in the Web UI | every chunk as its own step execution, with the cursor it stopped at |
|
|
1806
|
+
|
|
1807
|
+
### Pro and Enterprise features
|
|
1808
|
+
|
|
1809
|
+
Several Sidekiq features fit workflows naturally.
|
|
1810
|
+
|
|
1811
|
+
**Batches (Pro)** fan work out; [signals](#waiting-for-signals) bring the result back into a workflow. Park a step on a signal, run the batch, and let the batch's success callback deliver it:
|
|
1812
|
+
|
|
1813
|
+
```ruby
|
|
1814
|
+
class InvoiceRunWorkflow < GenevaDrive::Workflow
|
|
1815
|
+
step :render_invoices do
|
|
1816
|
+
batch = Sidekiq::Batch.new
|
|
1817
|
+
batch.description = "Invoices for #{hero.billing_period}"
|
|
1818
|
+
batch.on(:success, InvoiceRunCallbacks, "workflow_id" => id)
|
|
1819
|
+
batch.jobs do
|
|
1820
|
+
hero.accounts.find_each { |account| RenderInvoiceJob.perform_async(account.id, hero.id) }
|
|
1821
|
+
end
|
|
1822
|
+
end
|
|
1823
|
+
|
|
1824
|
+
step :send_invoices, wait_for: :invoices_rendered do
|
|
1825
|
+
InvoiceMailer.batch_ready(hero).deliver_later
|
|
1826
|
+
end
|
|
1827
|
+
end
|
|
1828
|
+
|
|
1829
|
+
class InvoiceRunCallbacks
|
|
1830
|
+
def on_success(status, options)
|
|
1831
|
+
workflow = GenevaDrive::Workflow.find(options["workflow_id"])
|
|
1832
|
+
workflow.signal!(:invoices_rendered, idempotency_key: status.bid)
|
|
1833
|
+
end
|
|
1834
|
+
end
|
|
1835
|
+
```
|
|
1836
|
+
|
|
1837
|
+
The batch id makes the signal idempotent, so a callback Sidekiq delivers twice is harmless.
|
|
1838
|
+
|
|
1839
|
+
**Rate limiters (Enterprise)** raise `Sidekiq::Limiter::OverLimit` when the limit is hit. Inside a step, turn that into a reattempt instead of a failure:
|
|
1840
|
+
|
|
1841
|
+
```ruby
|
|
1842
|
+
class CrmSyncWorkflow < GenevaDrive::Workflow
|
|
1843
|
+
CRM_LIMIT = Sidekiq::Limiter.window("crm-api", 100, :minute, wait_timeout: 0)
|
|
1844
|
+
|
|
1845
|
+
on_exception Sidekiq::Limiter::OverLimit, action: :reattempt!, wait: 1.minute, report: :never
|
|
1846
|
+
|
|
1847
|
+
step :push_contact do
|
|
1848
|
+
CRM_LIMIT.within_limit { CrmClient.upsert_contact(hero) }
|
|
1849
|
+
end
|
|
1850
|
+
end
|
|
1851
|
+
```
|
|
1852
|
+
|
|
1853
|
+
**Periodic jobs (Enterprise)** and **sidekiq-cron** can schedule housekeeping through `GenevaDrive::SidekiqHousekeepingJob` — see [Running Housekeeping](#running-housekeeping).
|
|
1854
|
+
|
|
1855
|
+
### Testing
|
|
1856
|
+
|
|
1857
|
+
The [test helpers](#test-helpers) drive the executor directly, so `speedrun_workflow` and friends work the same with either dispatcher. To exercise the real push path, use Sidekiq's fake mode and drain the step job:
|
|
1858
|
+
|
|
1859
|
+
```ruby
|
|
1860
|
+
test "renewal runs end to end through Sidekiq" do
|
|
1861
|
+
workflow = SubscriptionRenewalWorkflow.create!(hero: subscriptions(:annual))
|
|
1862
|
+
|
|
1863
|
+
GenevaDrive::SidekiqPerformStepJob.drain
|
|
1864
|
+
|
|
1865
|
+
assert workflow.reload.finished?
|
|
1866
|
+
end
|
|
1867
|
+
```
|
|
1868
|
+
|
|
1869
|
+
The fake queue ignores `at`, so steps with `wait:` run as soon as you drain.
|
|
1870
|
+
|
|
1871
|
+
### Switching an existing app over
|
|
1872
|
+
|
|
1873
|
+
Upgrading GenevaDrive leaves your dispatcher alone. When you re-run the installer to pick up new migrations, it asks before overwriting the initializer you already have - answer no, or pass `--skip`. To switch, add `GenevaDrive.dispatcher = :sidekiq` yourself. Tests that assert on enqueued `PerformStepJob`s will then need to look for `GenevaDrive::SidekiqPerformStepJob` in Sidekiq's fake mode instead.
|
|
1874
|
+
|
|
1875
|
+
Changing the dispatcher only affects jobs pushed from then on. `PerformStepJob`s already queued through Active Job keep running on whatever adapter delivered them, and new steps go out as native Sidekiq jobs. Deploy the change, let the Active Job queue drain, and that is the migration.
|
|
1876
|
+
|
|
1605
1877
|
## Housekeeping
|
|
1606
1878
|
|
|
1607
1879
|
### The Housekeeping Job
|
|
@@ -1643,6 +1915,24 @@ GoodJob::Cron.schedule(
|
|
|
1643
1915
|
GenevaDrive::HousekeepingJob.perform_later
|
|
1644
1916
|
```
|
|
1645
1917
|
|
|
1918
|
+
On Sidekiq without Active Job, schedule `GenevaDrive::SidekiqHousekeepingJob` instead. It runs the same code in-process:
|
|
1919
|
+
|
|
1920
|
+
```ruby
|
|
1921
|
+
# Sidekiq Enterprise periodic jobs
|
|
1922
|
+
Sidekiq.configure_server do |config|
|
|
1923
|
+
config.periodic do |mgr|
|
|
1924
|
+
mgr.register("*/30 * * * *", "GenevaDrive::SidekiqHousekeepingJob")
|
|
1925
|
+
end
|
|
1926
|
+
end
|
|
1927
|
+
|
|
1928
|
+
# sidekiq-cron
|
|
1929
|
+
Sidekiq::Cron::Job.create(
|
|
1930
|
+
name: "GenevaDrive housekeeping",
|
|
1931
|
+
cron: "*/30 * * * *",
|
|
1932
|
+
class: "GenevaDrive::SidekiqHousekeepingJob"
|
|
1933
|
+
)
|
|
1934
|
+
```
|
|
1935
|
+
|
|
1646
1936
|
## Testing
|
|
1647
1937
|
|
|
1648
1938
|
### Test Helpers
|
|
@@ -1957,7 +2247,11 @@ The workflow will sit in `paused` state — holding the slot, preventing duplica
|
|
|
1957
2247
|
|
|
1958
2248
|
### Pause on missing step definitions
|
|
1959
2249
|
|
|
1960
|
-
There is one more scenario where pause happens automatically. If a workflow references a step name that no longer exists in the class definition — typically after a deploy that removed or renamed a step — the executor pauses the workflow rather than silently skipping or crashing.
|
|
2250
|
+
There is one more scenario where pause happens automatically. If a workflow references a step name that no longer exists in the class definition — typically after a deploy that removed or renamed a step — the executor pauses the workflow rather than silently skipping or crashing.
|
|
2251
|
+
|
|
2252
|
+
This is not a limitation to be worked around. The executor knows the step is gone but not whether the steps after it were ever meant to run, and guessing wrong means performing work the missing step would have prevented. Pausing hands that judgement to someone who has it.
|
|
2253
|
+
|
|
2254
|
+
Declare `removed_step :name` when you take a step out and the judgement is recorded ahead of time, so nothing pauses; see [Removing a Step with `removed_step`](#removing-a-step-with-removed_step). For workflows already stranded by a step deleted without one, `resume_at!` is the way back — see [Recovering a Workflow Stranded by a Deleted Step](#recovering-a-workflow-stranded-by-a-deleted-step).
|
|
1961
2255
|
|
|
1962
2256
|
## Complete Example Workflows
|
|
1963
2257
|
|
data/README.md
CHANGED
|
@@ -41,6 +41,19 @@ geneva_drive_housekeeping:
|
|
|
41
41
|
class: "GenevaDrive::HousekeepingJob"
|
|
42
42
|
```
|
|
43
43
|
|
|
44
|
+
### Sidekiq without Active Job
|
|
45
|
+
|
|
46
|
+
If your app writes `Sidekiq::Job` classes rather than Active Job ones, steps can be pushed to Sidekiq directly:
|
|
47
|
+
|
|
48
|
+
```ruby
|
|
49
|
+
# config/initializers/geneva_drive.rb
|
|
50
|
+
GenevaDrive.dispatcher = :sidekiq
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
The install generator writes this line for you when Active Job is already set up for Sidekiq. Supported: Sidekiq 7.3 and later 7.x, and 8.x, including Pro and Enterprise.
|
|
54
|
+
|
|
55
|
+
Step jobs then run as `GenevaDrive::SidekiqPerformStepJob`, a plain Sidekiq job with Sidekiq retries switched off (exception policies decide reattempts), and housekeeping can be scheduled as `GenevaDrive::SidekiqHousekeepingJob`. See "Running on Sidekiq" in the manual for the details, including rolling restarts, Batches and rate limiters.
|
|
56
|
+
|
|
44
57
|
|
|
45
58
|
|
|
46
59
|
## Usage
|
|
@@ -172,7 +185,7 @@ class PremiumWorkflow < BaseWorkflow
|
|
|
172
185
|
end
|
|
173
186
|
```
|
|
174
187
|
|
|
175
|
-
Step job options override workflow defaults when the step is initially enqueued, reattempted, or resumed.
|
|
188
|
+
Step job options override workflow defaults when the step is initially enqueued, reattempted, or resumed. With the default dispatcher, options are passed directly to Active Job's `set` method.
|
|
176
189
|
|
|
177
190
|
### Hero Deletion Handling
|
|
178
191
|
|
data/Rakefile
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
require "bundler/setup"
|
|
2
2
|
require "bundler/gem_tasks"
|
|
3
3
|
|
|
4
|
-
require "rake/testtask"
|
|
5
4
|
require "standard/rake"
|
|
6
5
|
require "yard"
|
|
7
6
|
|
|
@@ -15,3 +14,31 @@ end
|
|
|
15
14
|
|
|
16
15
|
APP_RAKEFILE = File.expand_path("test/dummy/Rakefile", __dir__)
|
|
17
16
|
load "rails/tasks/engine.rake"
|
|
17
|
+
|
|
18
|
+
# Rails' own plugin test runner rather than a Rake::TestTask: it is what sets up
|
|
19
|
+
# and tears down the per-worker databases that ActiveSupport::TestCase.parallelize
|
|
20
|
+
# creates, and a plain Rake::TestTask leaves those workers hanging at exit.
|
|
21
|
+
desc "Run the test suite"
|
|
22
|
+
task :test do
|
|
23
|
+
ruby "-Itest", "bin/test"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# The Sidekiq dispatcher's tests, and the installer that recommends it. Sidekiq
|
|
27
|
+
# is only bundled by the sidekiq_* appraisals, so run this under one of them:
|
|
28
|
+
#
|
|
29
|
+
# BUNDLE_GEMFILE=gemfiles/sidekiq_8.gemfile bundle exec rake test:sidekiq
|
|
30
|
+
#
|
|
31
|
+
# Set GENEVA_DRIVE_SIDEKIQ_REDIS_URL to include the tests against a real Redis.
|
|
32
|
+
namespace :test do
|
|
33
|
+
desc "Run the Sidekiq tests (needs a sidekiq_* appraisal)"
|
|
34
|
+
task :sidekiq do
|
|
35
|
+
abort "Sidekiq is not in this bundle - use BUNDLE_GEMFILE=gemfiles/sidekiq_8.gemfile" unless Gem.loaded_specs["sidekiq"]
|
|
36
|
+
ruby "-Itest", "bin/test", "test/sidekiq", "test/generators"
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# The root Gemfile resolves to the newest released Rails and the dummy app
|
|
41
|
+
# defaults to SQLite, so a plain `rake` is the "latest Rails on SQLite" run.
|
|
42
|
+
# The appraisal gemfiles cover the older Rails minors, and CI additionally
|
|
43
|
+
# points DATABASE_URL at PostgreSQL and MySQL.
|
|
44
|
+
task default: [:standard, :test]
|
data/bin/matrix
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Local CI: run the suite for every Rails appraisal against every database
|
|
3
|
+
# engine the CI matrix covers, so that a one-engine failure can be reproduced
|
|
4
|
+
# without pushing. Brings compose.yaml up first and leaves it running.
|
|
5
|
+
#
|
|
6
|
+
# bin/matrix # the whole matrix
|
|
7
|
+
# bin/matrix rails_8_1 # one Rails version, all engines
|
|
8
|
+
# bin/matrix rails_8_1 sqlite # one cell
|
|
9
|
+
set -uo pipefail
|
|
10
|
+
cd "$(dirname "$0")/.."
|
|
11
|
+
|
|
12
|
+
# The gemfiles are generated from Appraisals, which makes them the one place
|
|
13
|
+
# the supported Rails versions are written down. CI reads the same list off
|
|
14
|
+
# them, so neither side can drift from the other.
|
|
15
|
+
RAILS_VERSIONS=${1:-$(ls gemfiles/rails_*.gemfile | xargs -n1 basename | sed 's/\.gemfile$//' | tr '\n' ' ')}
|
|
16
|
+
ENGINES=${2:-"sqlite postgres mysql mariadb"}
|
|
17
|
+
|
|
18
|
+
for engine in $ENGINES; do
|
|
19
|
+
case "$engine" in
|
|
20
|
+
sqlite|postgres|mysql|mariadb) ;;
|
|
21
|
+
*) echo "Unknown engine: $engine (pick from sqlite, postgres, mysql, mariadb)" >&2; exit 1 ;;
|
|
22
|
+
esac
|
|
23
|
+
done
|
|
24
|
+
|
|
25
|
+
# Only the engines actually asked for - CI runs one cell per job and should not
|
|
26
|
+
# be pulling a MySQL image to test against PostgreSQL.
|
|
27
|
+
services=""
|
|
28
|
+
for engine in $ENGINES; do
|
|
29
|
+
case "$engine" in
|
|
30
|
+
postgres|mysql|mariadb) services="$services $engine" ;;
|
|
31
|
+
esac
|
|
32
|
+
done
|
|
33
|
+
if [ -n "$services" ]; then
|
|
34
|
+
docker compose up -d --wait $services || exit 1
|
|
35
|
+
fi
|
|
36
|
+
|
|
37
|
+
# Compose publishes to an ephemeral host port, so ask it which one it got.
|
|
38
|
+
port_of() {
|
|
39
|
+
docker compose port "$1" "$2" | sed 's/.*://'
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
# SQLite gets no DATABASE_URL, exactly as in CI: the dummy app defaults to it
|
|
43
|
+
# and resolves its own path relative to Rails.root.
|
|
44
|
+
url_for() {
|
|
45
|
+
case "$1" in
|
|
46
|
+
postgres) echo "postgresql://postgres:postgres@127.0.0.1:$(port_of postgres 5432)/geneva_drive_test" ;;
|
|
47
|
+
mysql) echo "mysql2://root:root@127.0.0.1:$(port_of mysql 3306)/geneva_drive_test?encoding=utf8mb4" ;;
|
|
48
|
+
mariadb) echo "mysql2://root:root@127.0.0.1:$(port_of mariadb 3306)/geneva_drive_test?encoding=utf8mb4" ;;
|
|
49
|
+
esac
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
failed=0
|
|
53
|
+
results=()
|
|
54
|
+
for rails in $RAILS_VERSIONS; do
|
|
55
|
+
gemfile="gemfiles/$rails.gemfile"
|
|
56
|
+
[ -f "$gemfile" ] || { echo "No such appraisal: $gemfile" >&2; exit 1; }
|
|
57
|
+
BUNDLE_GEMFILE=$gemfile bundle check >/dev/null 2>&1 || BUNDLE_GEMFILE=$gemfile bundle install || exit 1
|
|
58
|
+
for engine in $ENGINES; do
|
|
59
|
+
echo "==> $rails / $engine"
|
|
60
|
+
if [ "$engine" = sqlite ]; then
|
|
61
|
+
env -u DATABASE_URL BUNDLE_GEMFILE=$gemfile RAILS_ENV=test GENEVA_DRIVE_ENGINE=$engine bundle exec rake test
|
|
62
|
+
else
|
|
63
|
+
env BUNDLE_GEMFILE=$gemfile RAILS_ENV=test GENEVA_DRIVE_ENGINE=$engine DATABASE_URL="$(url_for "$engine")" bundle exec rake test
|
|
64
|
+
fi
|
|
65
|
+
if [ $? -eq 0 ]; then
|
|
66
|
+
results+=("ok $rails / $engine")
|
|
67
|
+
else
|
|
68
|
+
results+=("FAIL $rails / $engine")
|
|
69
|
+
failed=1
|
|
70
|
+
fi
|
|
71
|
+
done
|
|
72
|
+
done
|
|
73
|
+
|
|
74
|
+
printf '\n'
|
|
75
|
+
printf '%s\n' "${results[@]}"
|
|
76
|
+
exit $failed
|