geneva_drive 0.8.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.
Files changed (68) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +14 -0
  3. data/MANUAL.md +215 -4
  4. data/README.md +14 -1
  5. data/Rakefile +14 -0
  6. data/bin/matrix +1 -1
  7. data/lib/generators/geneva_drive/install/templates/create_workflows_migration.rb +1 -1
  8. data/lib/generators/geneva_drive/install/templates/initializer.rb.tt +33 -2
  9. data/lib/geneva_drive/dispatchers/active_job_dispatcher.rb +33 -0
  10. data/lib/geneva_drive/dispatchers/sidekiq_dispatcher.rb +112 -0
  11. data/lib/geneva_drive/engine.rb +13 -0
  12. data/lib/geneva_drive/executor.rb +5 -3
  13. data/lib/geneva_drive/job_options.rb +7 -3
  14. data/lib/geneva_drive/jobs/perform_step_job.rb +2 -35
  15. data/lib/geneva_drive/jobs/sidekiq_housekeeping_job.rb +32 -0
  16. data/lib/geneva_drive/jobs/sidekiq_perform_step_job.rb +35 -0
  17. data/lib/geneva_drive/jobs/step_execution_lookup.rb +42 -0
  18. data/lib/geneva_drive/migration_helpers.rb +33 -3
  19. data/lib/geneva_drive/version.rb +1 -1
  20. data/lib/geneva_drive/workflow.rb +30 -37
  21. data/lib/geneva_drive.rb +63 -0
  22. data/test/dummy/config/initializers/geneva_drive.rb +35 -3
  23. data/test/dummy/db/mysql/schema-8.1.rb +95 -0
  24. data/test/dummy/db/postgres/schema-8.1.rb +99 -0
  25. data/test/dummy/db/sqlite/schema-7.2.rb +96 -0
  26. data/test/dummy/db/sqlite/schema-8.0.rb +96 -0
  27. data/test/dummy/db/sqlite/schema-8.1.rb +96 -0
  28. data/test/dummy/db/sqlite/test.sqlite3 +0 -0
  29. data/test/dummy/db/sqlite/test.sqlite3-0 +0 -0
  30. data/test/dummy/db/sqlite/test.sqlite3-1 +0 -0
  31. data/test/dummy/db/sqlite/test.sqlite3-2 +0 -0
  32. data/test/dummy/db/sqlite/test.sqlite3-3 +0 -0
  33. data/test/dummy/db/sqlite/test.sqlite3-4 +0 -0
  34. data/test/dummy/db/sqlite/test.sqlite3-5 +0 -0
  35. data/test/dummy/db/sqlite/test.sqlite3-6 +0 -0
  36. data/test/dummy/db/sqlite/test.sqlite3-7 +0 -0
  37. data/test/dummy/db/sqlite/test.sqlite3-8 +0 -0
  38. data/test/dummy/db/sqlite/test.sqlite3-9 +0 -0
  39. data/test/dummy/db/sqlite/test.sqlite3_0 +0 -0
  40. data/test/dummy/db/sqlite/test.sqlite3_1 +0 -0
  41. data/test/dummy/db/sqlite/test.sqlite3_2 +0 -0
  42. data/test/dummy/db/sqlite/test.sqlite3_3 +0 -0
  43. data/test/dummy/db/sqlite/test.sqlite3_4 +0 -0
  44. data/test/dummy/db/sqlite/test.sqlite3_5 +0 -0
  45. data/test/dummy/db/sqlite/test.sqlite3_6 +0 -0
  46. data/test/dummy/db/sqlite/test.sqlite3_7 +0 -0
  47. data/test/dummy/db/sqlite/test.sqlite3_8 +0 -0
  48. data/test/dummy/db/sqlite/test.sqlite3_9 +0 -0
  49. data/test/dummy/db/sqlite3/schema-7.2.rb +96 -0
  50. data/test/dummy/db/sqlite3/schema-8.1.rb +96 -0
  51. data/test/dummy/db/sqlite3/test.sqlite3 +0 -0
  52. data/test/dummy/db/sqlite3/test.sqlite3_0 +0 -0
  53. data/test/dummy/db/sqlite3/test.sqlite3_1 +0 -0
  54. data/test/dummy/db/sqlite3/test.sqlite3_2 +0 -0
  55. data/test/dummy/db/sqlite3/test.sqlite3_3 +0 -0
  56. data/test/dummy/db/sqlite3/test.sqlite3_4 +0 -0
  57. data/test/dummy/db/sqlite3/test.sqlite3_5 +0 -0
  58. data/test/dummy/db/sqlite3/test.sqlite3_6 +0 -0
  59. data/test/dummy/db/sqlite3/test.sqlite3_7 +0 -0
  60. data/test/dummy/db/sqlite3/test.sqlite3_8 +0 -0
  61. data/test/dummy/db/sqlite3/test.sqlite3_9 +0 -0
  62. data/test/generators/install_generator_test.rb +56 -0
  63. data/test/geneva_drive_test.rb +10 -0
  64. data/test/migration_helpers_test.rb +62 -0
  65. data/test/sidekiq/sidekiq_dispatcher_test.rb +322 -0
  66. data/test/sidekiq/sidekiq_redis_test.rb +115 -0
  67. data/test/workflow/pause_resume_test.rb +15 -0
  68. metadata +49 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: dd1756c2bb26aeb5fcf15ef77232f06314335ce3427aa6053085476e3cd78042
4
- data.tar.gz: 0a445074432320b0040d6e9c69f6b77586cd3275a7bbf034096a5ef0dedc3334
3
+ metadata.gz: b415480aa40bc610a79bdc6802a636d70ac54dd6adddbd3d8b44d8afbb4a1e44
4
+ data.tar.gz: a7e729983cf52ad726d12ea83661b17da8d34413b03a8f349919e342dbfdac52
5
5
  SHA512:
6
- metadata.gz: 1113718f861068971a284fcfd54ccabb2b9cdfc3fb7bbff6a75d505f9d5741ba0cdcbe0b3ce62d50b4441d5be8901e505d68bef2e95e197d7c00d7297a27e8b6
7
- data.tar.gz: bbc4dc5c73a5c77d19725b60c2951fa1a7d4192cf97739d3cda4f021c4d892e05458df5feb6313372541d1f2a36e840ca51dfe7add125bb0b215cc5d9539392f
6
+ metadata.gz: 1c8c27663ca3f19cb671b0c5c16c62c96487059f823ce5a9dfc35a4366a31d01582ba9ced0fc4140f6fba542e0ede4046b0b5f92518555b1fac575875440fb71
7
+ data.tar.gz: b5d56f17405a2e032b3ef4f977bd38dc7dc071a7d2160811b996dabe0301bf6275ebb9c0b23aaee4c5cfce86403d8e76ce8b438e716bddd022d02248e9b95b5b
data/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
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
+
3
17
  ## [0.8.0]
4
18
 
5
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.
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
 
@@ -700,7 +702,7 @@ A step is interrupted when any of these happen:
700
702
 
701
703
  - `max_iterations:` is reached (`resumable_step :import, max_iterations: 10_000`)
702
704
  - `max_runtime:` is exceeded (`resumable_step :import, max_runtime: 5.minutes`)
703
- - The job queue signals shutdown (e.g. Sidekiq stopping)
705
+ - The job queue signals shutdown (Sidekiq going quiet or stopping — see [Shutdowns and rolling restarts](#shutdowns-and-rolling-restarts))
704
706
  - The workflow is paused or canceled externally
705
707
  - The step calls `skip_to!` or `suspend!` explicitly
706
708
 
@@ -1583,7 +1585,7 @@ We recommend using a queue adapter that co-commits with your database:
1583
1585
 
1584
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.
1585
1587
 
1586
- With non-transactional adapters (Sidekiq, Resque), there's a small window where the job is enqueued but the transaction hasn't committed. GenevaDrive handles this gracefully — the job will see the step execution in the wrong state and skip it — but you may see occasional log warnings.
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).
1587
1589
 
1588
1590
  ### Inline Enqueueing for Bulk Operations
1589
1591
 
@@ -1621,7 +1623,7 @@ The method is thread-safe and won't affect other concurrent requests.
1621
1623
 
1622
1624
  ### Custom Job Options
1623
1625
 
1624
- 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.
1625
1627
 
1626
1628
  ```ruby
1627
1629
  class HighPriorityWorkflow < GenevaDrive::Workflow
@@ -1633,7 +1635,7 @@ class HighPriorityWorkflow < GenevaDrive::Workflow
1633
1635
  end
1634
1636
  ```
1635
1637
 
1636
- 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.
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.
1637
1639
 
1638
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.
1639
1641
 
@@ -1681,6 +1683,197 @@ Workflows that have already extracted their artifact can now drain through `:pro
1681
1683
 
1682
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.
1683
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
+
1684
1877
  ## Housekeeping
1685
1878
 
1686
1879
  ### The Housekeeping Job
@@ -1722,6 +1915,24 @@ GoodJob::Cron.schedule(
1722
1915
  GenevaDrive::HousekeepingJob.perform_later
1723
1916
  ```
1724
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
+
1725
1936
  ## Testing
1726
1937
 
1727
1938
  ### Test Helpers
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. Options are passed directly to Active Job's `set` method.
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
@@ -23,6 +23,20 @@ task :test do
23
23
  ruby "-Itest", "bin/test"
24
24
  end
25
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
+
26
40
  # The root Gemfile resolves to the newest released Rails and the dummy app
27
41
  # defaults to SQLite, so a plain `rake` is the "latest Rails on SQLite" run.
28
42
  # The appraisal gemfiles cover the older Rails minors, and CI additionally
data/bin/matrix CHANGED
@@ -12,7 +12,7 @@ cd "$(dirname "$0")/.."
12
12
  # The gemfiles are generated from Appraisals, which makes them the one place
13
13
  # the supported Rails versions are written down. CI reads the same list off
14
14
  # them, so neither side can drift from the other.
15
- RAILS_VERSIONS=${1:-$(ls gemfiles/*.gemfile | xargs -n1 basename | sed 's/\.gemfile$//' | tr '\n' ' ')}
15
+ RAILS_VERSIONS=${1:-$(ls gemfiles/rails_*.gemfile | xargs -n1 basename | sed 's/\.gemfile$//' | tr '\n' ' ')}
16
16
  ENGINES=${2:-"sqlite postgres mysql mariadb"}
17
17
 
18
18
  for engine in $ENGINES; do
@@ -12,7 +12,7 @@ class CreateGenevaDriveWorkflows < ActiveRecord::Migration[7.2]
12
12
 
13
13
  # Polymorphic association to the hero of the workflow (optional)
14
14
  t.string :hero_type
15
- t.column :hero_id, geneva_drive_key_type
15
+ t.column :hero_id, geneva_drive_hero_key_type
16
16
 
17
17
  # State machine
18
18
  t.string :state, null: false, default: "ready", index: true
@@ -1,5 +1,20 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # How step jobs reach your queue.
4
+ # - :active_job (the default) enqueues GenevaDrive::PerformStepJob through
5
+ # Active Job, onto whatever adapter the app uses.
6
+ # - :sidekiq pushes GenevaDrive::SidekiqPerformStepJob, a plain Sidekiq::Job,
7
+ # straight through Sidekiq::Client - for apps that write Sidekiq jobs rather
8
+ # than Active Job ones. Schedule GenevaDrive::SidekiqHousekeepingJob then.
9
+ # Supports Sidekiq 7.3 and later 7.x, and 8.x.
10
+ #
11
+ <% if GenevaDrive::SidekiqDispatcher.recommended? -%>
12
+ # Active Job was set up for Sidekiq when this file was generated.
13
+ GenevaDrive.dispatcher = :sidekiq
14
+ <% else -%>
15
+ # GenevaDrive.dispatcher = :active_job
16
+ <% end -%>
17
+
3
18
  # How long to keep completed/canceled workflows before automatic deletion.
4
19
  # Set to nil to disable automatic cleanup.
5
20
  # When enabled, finished and canceled workflows older than this threshold
@@ -7,12 +22,12 @@
7
22
  #
8
23
  # GenevaDrive.delete_completed_workflows_after = 30.days
9
24
 
10
- # How long a step execution can be in "executing" state before being
25
+ # How long a step execution can be in "in_progress" state before being
11
26
  # considered stuck. This typically happens when a worker process crashes
12
27
  # mid-execution. The HousekeepingJob will recover these based on the
13
28
  # stuck_recovery_action setting.
14
29
  #
15
- # GenevaDrive.stuck_executing_threshold = 1.hour
30
+ # GenevaDrive.stuck_in_progress_threshold = 1.hour
16
31
 
17
32
  # How long a step execution can be past its scheduled_for time while
18
33
  # still in "scheduled" state before being considered stuck. This can
@@ -39,6 +54,22 @@
39
54
  #
40
55
  # GenevaDrive.max_cursor_size = 128.kilobytes
41
56
 
57
+ # Maximum size in bytes of a signal payload once serialized to JSON.
58
+ # A payload describes an event (an order id, an amount, a decision), not the
59
+ # data the step is going to work on - that belongs on the hero. Exceeding the
60
+ # limit raises GenevaDrive::SignalPayloadTooLargeError from Workflow#signal!,
61
+ # before anything is persisted. Set to nil to disable the check.
62
+ #
63
+ # GenevaDrive.max_signal_payload_size = 128.kilobytes
64
+
65
+ # How long a step execution may sit in the "waiting" state before
66
+ # housekeeping counts it in the geneva_drive.waiting_overdue gauge.
67
+ # Waiting indefinitely is legitimate, so this is not a timeout - it keeps
68
+ # a stalled rendezvous from going unnoticed. Alert on the overdue gauge.
69
+ # Set to nil to disable the gauge.
70
+ #
71
+ # GenevaDrive.waiting_visibility_threshold = 7.days
72
+
42
73
  # Whether to defer job enqueueing until after the database transaction commits.
43
74
  # In production this ensures step execution records are visible to job workers
44
75
  # before the job runs. In test environments (the default when Rails.env.test?)
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Enqueues step jobs through Active Job, onto whatever adapter the app uses.
4
+ # This is the default dispatcher.
5
+ #
6
+ # GenevaDrive calls the dispatcher from an `after_all_transactions_commit`
7
+ # callback, once the step execution row is visible to other connections.
8
+ # PerformStepJob opts out of Active Job's own transaction deferral for that
9
+ # reason - see the comment there.
10
+ #
11
+ # @see GenevaDrive.dispatcher
12
+ class GenevaDrive::ActiveJobDispatcher
13
+ # Enqueues the job that performs a step execution.
14
+ #
15
+ # @param step_execution_id [Integer, String] the step execution to perform
16
+ # @param job_options [Hash] validated `queue`, `priority`, `wait`, `wait_until`
17
+ # @param workflow_class_name [String, nil] unused here
18
+ # @return [String, nil] the Active Job job_id, or nil if the enqueue was aborted
19
+ def enqueue_step(step_execution_id, job_options, workflow_class_name: nil)
20
+ job = GenevaDrive::PerformStepJob.set(**job_options).perform_later(step_execution_id)
21
+ # perform_later returns false, not nil, when a callback aborts the enqueue
22
+ return nil unless job
23
+ job.job_id
24
+ end
25
+
26
+ # Every key GenevaDrive::JobOptions allows maps onto Active Job's `set`.
27
+ #
28
+ # @param options [Hash{Symbol=>Object}]
29
+ # @param context [String]
30
+ # @return [void]
31
+ def validate_job_options!(options, context:)
32
+ end
33
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Pushes step jobs straight to Sidekiq as GenevaDrive::SidekiqPerformStepJob,
4
+ # a plain Sidekiq::Job, for apps that use Sidekiq without Active Job.
5
+ #
6
+ # Pushes go through a fresh Sidekiq::Client, never `perform_async`, so that
7
+ # `Sidekiq.transactional_push!` cannot defer them a second time from inside
8
+ # GenevaDrive's own after-commit callback - see PerformStepJob for why that
9
+ # loses jobs. Client middleware still runs.
10
+ #
11
+ # @example config/initializers/geneva_drive.rb
12
+ # GenevaDrive.dispatcher = :sidekiq
13
+ #
14
+ # @see GenevaDrive.dispatcher
15
+ class GenevaDrive::SidekiqDispatcher
16
+ # 7.3 and later 7.x, and 8.x - the versions CI runs. 9.0 stays out until tested.
17
+ SUPPORTED_SIDEKIQ_VERSIONS = Gem::Requirement.new(">= 7.3", "< 9")
18
+
19
+ # @return [Boolean] whether the loaded Sidekiq is a supported version
20
+ def self.supported_sidekiq_version?
21
+ SUPPORTED_SIDEKIQ_VERSIONS.satisfied_by?(Gem::Version.new(::Sidekiq::VERSION))
22
+ end
23
+
24
+ # Whether the install generator should write `GenevaDrive.dispatcher = :sidekiq`
25
+ # into the initializer it creates: a supported Sidekiq is loaded, and the app
26
+ # has pointed Active Job at Sidekiq. An unset adapter does not count - the
27
+ # generator runs in development, and production may well use another backend.
28
+ #
29
+ # @param adapter [Symbol, String, Object, nil] Active Job's configured adapter
30
+ # @return [Boolean]
31
+ def self.recommended?(adapter = Rails.application&.config&.active_job&.queue_adapter)
32
+ return false unless defined?(::Sidekiq::Client) && supported_sidekiq_version?
33
+
34
+ adapter.to_s == "sidekiq" || adapter.class.name.to_s.end_with?("SidekiqAdapter")
35
+ end
36
+
37
+ # @param job_class [Class, nil] a Sidekiq::Job that performs step executions,
38
+ # GenevaDrive::SidekiqPerformStepJob by default
39
+ # @raise [LoadError] if Sidekiq is not loaded
40
+ # @raise [ArgumentError] if the loaded Sidekiq is not a supported version
41
+ def initialize(job_class: nil)
42
+ unless defined?(::Sidekiq::Client)
43
+ raise LoadError, "GenevaDrive::SidekiqDispatcher needs the sidekiq gem. Add it to your Gemfile."
44
+ end
45
+
46
+ unless self.class.supported_sidekiq_version?
47
+ raise ArgumentError,
48
+ "GenevaDrive::SidekiqDispatcher supports Sidekiq #{SUPPORTED_SIDEKIQ_VERSIONS}, " \
49
+ "but Sidekiq #{::Sidekiq::VERSION} is loaded. Use GenevaDrive.dispatcher = :active_job with this Sidekiq."
50
+ end
51
+
52
+ @job_class = job_class
53
+ end
54
+
55
+ # @return [Class] the Sidekiq::Job class step jobs are pushed as
56
+ def job_class
57
+ @job_class || GenevaDrive::SidekiqPerformStepJob
58
+ end
59
+
60
+ # Pushes the job that performs a step execution.
61
+ #
62
+ # @param step_execution_id [Integer, String] the step execution to perform
63
+ # @param job_options [Hash] validated `queue`, `wait`, `wait_until`
64
+ # @param workflow_class_name [String, nil] added to the job's tags, for the Web UI
65
+ # @return [String, nil] the jid, or nil if client middleware stopped the push
66
+ def enqueue_step(step_execution_id, job_options, workflow_class_name: nil)
67
+ item = {"class" => job_class, "args" => [step_execution_id]}
68
+ item["queue"] = job_options[:queue].to_s if job_options[:queue]
69
+
70
+ if (at = run_at(job_options))
71
+ item["at"] = at.to_f
72
+ end
73
+
74
+ if workflow_class_name
75
+ class_tags = Array(job_class.get_sidekiq_options["tags"])
76
+ item["tags"] = (class_tags + [workflow_class_name]).uniq
77
+ end
78
+
79
+ ::Sidekiq::Client.new.push(item)
80
+ end
81
+
82
+ # Sidekiq has queues with weights, not job priorities. Rejecting
83
+ # `priority:` keeps it from being ignored without anyone noticing.
84
+ #
85
+ # @param options [Hash{Symbol=>Object}]
86
+ # @param context [String]
87
+ # @return [void]
88
+ # @raise [GenevaDrive::StepConfigurationError] if `priority` is given
89
+ def validate_job_options!(options, context:)
90
+ return if options[:priority].nil?
91
+
92
+ raise GenevaDrive::StepConfigurationError,
93
+ "#{context} sets job_options[:priority], but Sidekiq has no job priority - it has queues with weights. " \
94
+ "Put the steps that must run first on a queue with a higher weight, e.g. `queue: :workflows_urgent`."
95
+ end
96
+
97
+ private
98
+
99
+ # Workflow already drops an overdue wait_until; this also covers callers that
100
+ # pass a time in the past, which would otherwise wait for the scheduled-set poller.
101
+ def run_at(job_options)
102
+ at = if job_options[:wait_until]
103
+ job_options[:wait_until]
104
+ elsif job_options[:wait]
105
+ Time.current + job_options[:wait]
106
+ end
107
+ return nil unless at
108
+
109
+ at = at.respond_to?(:to_time) ? at.to_time : Time.at(at.to_f)
110
+ (at > Time.current) ? at : nil
111
+ end
112
+ end
@@ -1,3 +1,16 @@
1
1
  class GenevaDrive::Engine < ::Rails::Engine
2
2
  isolate_namespace GenevaDrive
3
+
4
+ # Sidekiq fires :quiet on TSTP (what Enterprise rolling restarts send first)
5
+ # and at the start of every shutdown. Resumable steps then checkpoint and
6
+ # hand off to a successor execution instead of being killed mid-iteration
7
+ # when the shutdown timeout runs out. Installed whichever dispatcher is in
8
+ # use - Active Job on the Sidekiq adapter needs it just as much.
9
+ initializer "geneva_drive.sidekiq_lifecycle" do
10
+ if defined?(::Sidekiq) && ::Sidekiq.respond_to?(:configure_server)
11
+ ::Sidekiq.configure_server do |config|
12
+ config.on(:quiet) { GenevaDrive.request_exit! }
13
+ end
14
+ end
15
+ end
3
16
  end
@@ -1079,11 +1079,13 @@ class GenevaDrive::Executor
1079
1079
 
1080
1080
  # Checks if the job should exit (e.g., Sidekiq shutdown).
1081
1081
  #
1082
+ # The process-wide flag is raised by Sidekiq's :quiet event (see the
1083
+ # engine). Asking Sidekiq::CLI directly does not work: the CLI has no
1084
+ # stopping? of its own, only its launcher does.
1085
+ #
1082
1086
  # @return [Boolean]
1083
1087
  def job_should_exit?
1084
- Thread.current[:geneva_drive_should_exit] ||
1085
- (defined?(Sidekiq) && Sidekiq.const_defined?(:CLI) &&
1086
- Sidekiq::CLI.instance&.stopping?)
1088
+ Thread.current[:geneva_drive_should_exit] || GenevaDrive.exit_requested?
1087
1089
  end
1088
1090
 
1089
1091
  # Checks if the workflow has been externally paused or canceled.