stablemates-workhorse 0.7.1 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e72b7d14542014971d81d818e073acaadbaef569c2516ba66427583e607fd0f3
4
- data.tar.gz: 0e1d31974f65086473c750137249edd45722d75aa3b630e6c0a4c6c66bf7eb2e
3
+ metadata.gz: b030c71db8e2fa0f3b2e14e4ac34442270e1643ea1105efe904bc965ab6e4823
4
+ data.tar.gz: 90256bbd761dfa37265e9e0fce4f5f49534e378361a625bbc6bc27cf24b7634b
5
5
  SHA512:
6
- metadata.gz: 3de1cc0e31810df55903ec87b96e553edc96bfa2e7a699e6b926b669a995d6231ae03d5bd1ccfa62c8908d1ed6095e3a775998d092ac27b28fd92c8c9288cd20
7
- data.tar.gz: 4683b2cebb636655c2c5c608d56059cedb5e6d837c0eb96735443aa78d2abad27371c3231737ae5be3d8cdfa504bf179a468f70cd15a038cf4516f0388e02459
6
+ metadata.gz: 9d580a9360fe1772b51567673be3d787ea287ba7df8693d93d8ea4b153620f0a4c0b4c5ad492593830127ccd2c78a1063b5ac5f9174bbbea6aae3f1ca2880b70
7
+ data.tar.gz: d353d8e0ce3d2b03b869212dee916bcead4c41034de9403c83ab1f3e3a74eaaadfa36bf0c0c0c03f22728efdee770ef437c3de369f51d1f36765f5861948679f
data/CHANGELOG.md CHANGED
@@ -3,6 +3,323 @@
3
3
  `stablemates-workhorse` gem versions and release notes live here. The gem carries the version the
4
4
  other SDKs carry, because every tag names one release of all of them.
5
5
 
6
+ ## 0.9.0 — 2026-10-20
7
+
8
+ The npm packages, Python distribution, Go module, Rust crate, Ruby gem, and JVM artifacts release from one source commit.
9
+
10
+ Requires **schema v73**, Ruby **3.3** or newer, and PostgreSQL **15** or newer.
11
+
12
+ ### Upgrade steps
13
+
14
+ **Migrate the schema before starting updated processes.** Run `workhorse schema migrate` before any
15
+ process of this release starts, producers too. Migrations 0073 and 0074 move the schema to version
16
+ **73**. The SDK compatibility floor rises from 69 to **73**, because every SDK statement catalogue
17
+ now names `heartbeat_many_v2` and `heartbeat_v2`. An updated process refuses a schema below version
18
+ 73 at startup. A 0.8 process keeps working on version 73, because a migration inside a major line
19
+ only adds.
20
+
21
+ ### Heartbeats do not wait for a locked task row
22
+
23
+ Before this release, a heartbeat waited for a task row that another transaction locked. If a
24
+ settlement held the row, the heartbeat then kept the row locked after the settlement committed, and
25
+ a claim skipped the ready task (SM-1434). The worker now renews leases through `heartbeat_many_v2`
26
+ (SM-1443, ADR 0105). It skips a locked row and reports that lease as `busy`: no renewal and no
27
+ ownership verdict. The worker keeps a busy lease, retries only the busy leases after a short delay
28
+ with jitter, and does not move the regular heartbeat schedule. A busy answer never extends the local
29
+ lease watchdog, so a lease that stays busy until the watchdog ends is lost, as before. Old and new
30
+ workers can run together: an old worker keeps calling `heartbeat_many_v1`, which does not change.
31
+
32
+ **One-lease heartbeat without a row lock wait.** Migration 0074 adds `heartbeat_v2` (SM-1461). It
33
+ renews one lease by the rule of `heartbeat_many_v2`, so it reports a locked row as `busy`. This SDK
34
+ names it in its statement catalogue but does not call it. `heartbeat_v1` does not change.
35
+
36
+ ### Heartbeat rounds keep a renewal margin and end on the server
37
+
38
+ Before this release, the dedicated heartbeat connection bounded each heartbeat statement by one full
39
+ interval. After a failed round, the heartbeat thread started a new schedule on its new connection.
40
+ With the defaults, one timed-out round thus left about 0 s before the lease watchdog expired
41
+ (SM-1444). Also, when the worker stopped waiting for a round, PostgreSQL continued to run the
42
+ heartbeat statement, and that statement kept its row locks (SM-1442). These changes apply to each
43
+ round on the dedicated heartbeat connection:
44
+
45
+ - **Rounds start on a fixed schedule.** A round falls due one interval after the previous round
46
+ started, not after it ended. The schedule uses a monotonic clock. If a round ends after its next
47
+ slot, the worker starts one overdue round at once and sends no round for the other missed slots.
48
+ - **The round bound is shorter.** A round ends by
49
+ `max(1 ms, interval × 3 / 4, min(50 ms, interval))`. For an interval of 200 ms or more, this is
50
+ three quarters of the interval. With the defaults (lease 30 s, interval 10 s), the bound is 7.5 s,
51
+ so one timed-out round leaves a margin of 2.5 s. An attempt keeps its lease after one timed-out
52
+ round if `2 × interval + round bound < lease`. The worker does not reject an interval that breaks
53
+ this rule. Before this release, each statement of a round had its own bound of one interval. Now
54
+ all statements of one round share one deadline, and a statement that would start after it waits
55
+ for the next round. Each round starts with the next statement in turn, so a statement that is
56
+ always slow cannot keep a later statement from its turn.
57
+ - **Each round sets server timeouts.** The round sends `BEGIN`, then one `set_config` statement for
58
+ `statement_timeout` and `idle_in_transaction_session_timeout`, then `heartbeat_many_v2`, then
59
+ `COMMIT`. Both settings are transaction-local. The heartbeat statement gets one half of the time
60
+ that remains, and each idle gap one eighth. If less than 8 ms remains at the settings statement,
61
+ the round fails before the heartbeat. The worker counts a renewal only after a `COMMIT` that ends
62
+ before the deadline. No setting bounds `COMMIT` on PostgreSQL 15 and later.
63
+ - **A failed round discards its connection only when a statement may still run** (SM-1492). A
64
+ round that expires before it sends the heartbeat statement rolls back and keeps its connection.
65
+ Under load, PostgreSQL can log
66
+ `canceling statement due to statement timeout` for a heartbeat statement. It can also end the
67
+ session with `terminating connection due to idle-in-transaction timeout`. Each message means that
68
+ one round failed. Every task continues, and the next round tries again. A deadlock (`40P01`) also
69
+ fails the round.
70
+ - **A busy retry with less than 20 ms left sends nothing** (SM-1492). A bounded busy retry runs
71
+ only if its deadline is at least 20 ms after its start: the 8 ms that a round needs at its
72
+ settings statement, plus a 12 ms margin. Otherwise the next regular round renews the busy leases.
73
+ - **Replacement connections.** The heartbeat thread keeps the start of the latest round when it
74
+ replaces a failed connection.
75
+ - **100 ms is the smallest supported heartbeat interval** (SM-1471). This applies to `heartbeat:` of
76
+ `Worker`, in seconds: the smallest supported value is 0.1. At 100 ms, the round bound is 75 ms.
77
+ The worker still accepts a shorter interval, and it does not warn. On a loaded host, a shorter
78
+ interval can make each round miss its deadline, and attempts can then lose their leases.
79
+
80
+ Rounds under `shared_heartbeats: true` have no client deadline and set no server timeouts, as
81
+ before. They follow the new schedule. The [heartbeat round
82
+ deadline](../docs/architecture/lifecycle.md#heartbeat-round-deadline) and the [heartbeat
83
+ schedule](../docs/architecture/lifecycle.md#heartbeat-schedule) state each rule.
84
+
85
+ ### A payload contract compares the decimals of numbers
86
+
87
+ Before this release, `ContractSchema` compared an Integer with the binary value of a Float in the
88
+ bound keywords, `const`, `enum`, and `uniqueItems` (SM-1539). Thus `1152921504606846980` failed
89
+ `{"maximum": 1.152921504606847e18}`, although PostgreSQL stores the constant as
90
+ `1152921504606847000`. A contract now compares each Float through the decimal of `Float#to_s`
91
+ (ADR 0039). Ruby still reads a stored schema constant that a double cannot hold as a Float
92
+ (SM-1541).
93
+
94
+ ### A bad worker interval raises ArgumentError
95
+
96
+ Before this release, a bad value for `heartbeat`, `poll_interval`, `maintenance_interval`,
97
+ `maintenance_routine_interval`, or `registry_interval` raised `NoMethodError`, because these options
98
+ have no upper limit (SM-1511). For example, `heartbeat: 0` did this. Now `Worker.new` raises
99
+ `ArgumentError`, and the message names the option and its lower limit.
100
+
101
+ ### The `workhorse.tasks.retried` description matches `api/telemetry.txt`
102
+
103
+ The Ruby worker now describes `workhorse.tasks.retried` as "Failed or expired tasks returned to live
104
+ work", as the contract does (SM-1514). The counter already counted expired tasks. Only the
105
+ description changes.
106
+
107
+ ### A wrapped durable replay conflict is terminal
108
+
109
+ Before this release, the Ruby worker checked only the outermost error for a durable replay conflict
110
+ (ADR 0098). If a handler wrapped a conflict in its own error, the worker recorded a retryable
111
+ failure, and the conflict repeated until `max_attempts` (SM-1512). The worker now follows the cause
112
+ chain of the error, as the Go and Rust workers do. It reads at most 16 errors, so a cyclic chain
113
+ cannot stop it. It reads `Exception#cause`, so an error raised in a `rescue` block also counts. A
114
+ wrapped conflict fails the task at once. The failure envelope records the name of the conflict. Its
115
+ fields `message` and `stack` stay those of the outer error.
116
+
117
+ ### A heartbeat round sends at most 100 leases in each statement
118
+
119
+ `heartbeat_many_v2` accepts at most 100 leases. A fused completion claim registers its tasks before
120
+ the completing attempts unregister, so a round can hold more leases than the concurrency limit. That
121
+ round raised an error, and no lease in it renewed (SM-1495). The worker now splits a round into
122
+ statements of at most 100 leases. Each statement counts on its own. A failed statement renews none
123
+ of its leases, and the statements before it still count, as before for the statements of each
124
+ worker.
125
+
126
+ ## 0.8.0 — 2026-10-13
127
+
128
+ The npm packages, Python distribution, Go module, Rust crate, and Ruby gem release from one source commit.
129
+
130
+ Requires **schema v69**, Ruby **3.3** or newer, and PostgreSQL **15** or newer.
131
+
132
+ ### Upgrade steps
133
+
134
+ **Migrate the schema before starting updated processes.** The upgrade is a rolling deployment:
135
+
136
+ 1. Run `workhorse schema migrate`. The final schema version is **71**.
137
+ 2. Roll out the updated processes.
138
+
139
+ The SDK compatibility floor is schema version **69**, which migration 0070 reaches. The floor rises
140
+ from 54. Every SDK statement catalogue now names the task group functions that migration 0066 adds,
141
+ and `save_checkpoint_v2`, which migration 0069 adds. The dashboard of each SDK calls
142
+ `dashboard_task_groups_v1`, which migration 0067 adds, and `list_newest_task_group_events_v1`, which
143
+ migration 0068 adds. Migration 0070 fences a member add, so an updated process does not add tasks to
144
+ a group without that check. An updated process refuses a schema below version 69 at startup. A 0.7
145
+ process keeps working on version 71, because a migration inside a major line only adds.
146
+
147
+ **Give the eighth member when you build a `TaskCheckpoint`.** `TaskCheckpoint` is a `Data` class.
148
+ 0.8.0 adds `input_fingerprint` as its eighth member (SM-1264). A call to `TaskCheckpoint.new` with
149
+ seven positional arguments raises `ArgumentError`. A call with keywords and no
150
+ `input_fingerprint:` also raises it. The gem has no compatible constructor. If your code builds a
151
+ `TaskCheckpoint`, for example in a test, give the eighth value. For a checkpoint without an input,
152
+ give `input_fingerprint: nil` or a positional `nil`.
153
+
154
+ Migration 0066 (`0066-add-task-groups-with-completion-callbacks.sql`) adds task groups (SM-1231,
155
+ [ADR 0095](https://github.com/stablemates/workhorse/blob/main/docs/decisions/0095-add-task-groups-with-completion-callbacks.md)).
156
+ A task group is a set of full-tier tasks that Workhorse tracks as one unit. When the group is
157
+ sealed and every member task is finished, Workhorse enqueues its `onFinish`, `onSuccess`, or
158
+ `onFailure` callback task. The migration adds the `task_group`, `task_group_member`,
159
+ `task_group_callback`, and `task_group_event` tables, and a trigger on `task_outcome` that counts
160
+ each member outcome in the transaction that writes it. A queue purge counts a purged member task as
161
+ `canceled`. The `terminal_storage` routine keeps member tasks while their group is not finished,
162
+ reports a new `task_groups` phase after `terminal_tasks`, and deletes a finished group after its
163
+ callback tasks are terminal. New SQLSTATEs: `P1008` (the task group does not accept tasks), `P1009`
164
+ (member limit of 100,000), and `P1010` (an enqueue idempotency hit returns a task that is not a
165
+ member of the group).
166
+
167
+ Migration 0067 (`0067-count-task-group-outcomes-in-counter-shards.sql`) counts task group outcomes
168
+ in counter shards (SM-1245,
169
+ [ADR 0096](https://github.com/stablemates/workhorse/blob/main/docs/decisions/0096-count-task-group-outcomes-in-counter-shards.md)).
170
+ Each group gets 32 rows in the new `task_group_shard` table, and a member outcome locks only its
171
+ shard row at commit. The outcome locks the group row only when the pending count of its shard
172
+ becomes 0. The migration moves the counts of existing groups into the shards. It locks `task_group`
173
+ in `EXCLUSIVE` mode while it runs, so task group changes wait for it. The counts on a `task_group`
174
+ row stay 0 until the group finishes, so read the counts through `get_task_group_v1`. The migration
175
+ also adds `dashboard_task_groups_v1`, which lists task groups one page at a time. The dashboard of
176
+ each SDK reads the task group list through it (SM-1237).
177
+
178
+ Migration 0068 (`0068-read-the-newest-task-group-events.sql`) adds
179
+ `list_newest_task_group_events_v1`, which returns the newest events of a task group first
180
+ (SM-1254). The task group detail of the dashboard reads its events through it. A group with more
181
+ than 1,000 events now shows its newest 100 events. `eventsTruncated` is true when the group has
182
+ older events that the detail does not show. `list_task_group_events_v1` does not change. The
183
+ migration changes no stored data.
184
+
185
+ Migration 0069 (`0069-check-the-input-of-a-checkpoint-on-replay.sql`) adds the nullable column
186
+ `task_checkpoint.input_fingerprint` and `save_checkpoint_v2` (SM-1211,
187
+ [ADR 0098](https://github.com/stablemates/workhorse/blob/main/docs/decisions/0098-check-the-input-of-a-checkpoint-on-replay.md)).
188
+ If a checkpoint name exists, `save_checkpoint_v2` returns `input_conflict` when the stored and the
189
+ given input fingerprints are both present and differ. `save_checkpoint_v1` now calls
190
+ `save_checkpoint_v2` with no fingerprint, so its results do not change. The checkpoint reads of each
191
+ statement catalogue return the new column. Existing rows keep a NULL fingerprint. The migration adds
192
+ the column without a table scan.
193
+
194
+ Migration 0070 (`0070-fence-a-task-group-member-add-and-index-the-group-list.sql`) fences a task
195
+ group member add (SM-1273). `add_task_group_members_v1` now locks the runtime row of the member task
196
+ first, before the shard rows and the group row, as a completion does. Then it refuses the add with
197
+ `P1008` and the reason `not_running_member` if the lease of the member task expired, its deadline or
198
+ attempt timeout passed, or a cancel was requested. Before, a worker whose lease expired could add
199
+ tasks after Workhorse gave the member task to a new worker. The signature does not change. The
200
+ migration also adds `task_group_state_created_idx`, so `dashboard_task_groups_v1` reads one page of
201
+ a state without a read of the groups of other states. The migration is a non-transactional step: it
202
+ builds the index with `CREATE INDEX CONCURRENTLY`, so writes to `task_group` continue. If it fails
203
+ part-way, drop the invalid index before the rerun, as `docs/schema-lifecycle.md` describes. It
204
+ changes no stored data.
205
+
206
+ Migration 0071 (`0071-lock-purged-runtime-rows-in-identity-order.sql`) makes a queue purge lock its
207
+ tasks in identity order (SM-1282). Before, `purge_queue_internal_v1` locked the runtime rows and the
208
+ task rows of the queue in scan order. An enqueue that named two of those tasks as prerequisites
209
+ locks them in identity order. Thus the purge and the enqueue could wait for each other, and
210
+ PostgreSQL aborted one of them with `40P01`. The purge now locks every runtime row, then every task
211
+ row, each in identity order. The signature and the deleted count do not change. The migration does
212
+ not raise the SDK compatibility floor, because no SDK statement changes and every process gets the
213
+ new lock order when the migration commits. It changes no stored data.
214
+
215
+ Migration 0072 (`0072-purge-a-queue-that-holds-a-prerequisite-of-a-live-dependent.sql`) lets a
216
+ queue purge succeed when it holds a prerequisite of a waiting dependent (SM-1284,
217
+ [ADR 0101](https://github.com/stablemates/workhorse/blob/main/docs/decisions/0101-cancel-a-purged-prerequisite-whose-dependent-stays.md)).
218
+ Before, the purge deleted that prerequisite, and PostgreSQL refused the delete with `23001`. The
219
+ whole purge then failed, also when the dependent was in the same queue. Now the purge deletes a
220
+ dependent in the same purge with its prerequisite. If the dependent stays, for example in another
221
+ queue, the purge cancels the prerequisite instead of a delete. The dependent's own
222
+ `on_cancellation` policy then decides its outcome, as after a cancel. The canceled task keeps its
223
+ history and its keys. The purge count, `deleted_count` and `deletedCount`, includes the canceled
224
+ tasks. The purge locks the dependents after its own tasks, as a cancel does, so it has no
225
+ deadlock that a cancel of the same tasks does not also have. Two purges run one after the
226
+ other, also for different queues. The migration does not raise the SDK compatibility
227
+ floor, because no SDK statement or result shape changes. It changes no stored data.
228
+
229
+ ### Task groups
230
+
231
+ The Ruby SDK adds task groups (SM-1236). Use the block form to create a group, add its member
232
+ tasks, and seal it in one statement, so in one transaction:
233
+
234
+ ```ruby
235
+ result = queue.task_group(on_finish: Stablemates::Workhorse::EnqueueRequest.new(
236
+ task_type: "import.report", payload: {"importId" => 42}
237
+ )) do |group|
238
+ rows.each { |row| group.enqueue("import.row", {"row" => row}) }
239
+ end
240
+ ```
241
+
242
+ - `Queue#task_group` takes the `on_finish:`, `on_success:`, and `on_failure:` callbacks. Each
243
+ callback is an `EnqueueRequest` without idempotency, debounce, or throttle. The block receives a
244
+ `TaskGroupBuilder` with `add` and `enqueue`. If the block raises, nothing is enqueued.
245
+ - `Queue#create_task_group`, `Queue#add_to_task_group`, and `Queue#seal_task_group` build a group in
246
+ more than one transaction.
247
+ - `Queue#get_task_group`, `Queue#list_task_group_members`, and `Queue#find_task_group` read a
248
+ group. `Admin` has `get_task_group`, `list_task_group_members`, `list_task_group_events`,
249
+ `cancel_task_group`, `list_task_group_drift`, and `repair_task_group_drift`.
250
+ - `HandlerContext#task_group` and `BatchHandlerContext#task_group` return a `TaskGroupContext`.
251
+ Its `get` returns the `TaskGroupLink` of the task, with the group ID and the counts. Its `add`
252
+ adds tasks from a running member task, also after the seal. If a request names no queue, `add`
253
+ puts the task in the queue of the member task, not in the first queue of the worker (SM-1251,
254
+ [ADR 0097](https://github.com/stablemates/workhorse/blob/main/docs/decisions/0097-put-work-that-a-handler-creates-on-the-queue-of-its-task.md)).
255
+ - `Stablemates::Workhorse::ActiveJob.task_group` runs a block in which each job that the
256
+ Workhorse adapter enqueues becomes a member task. Its callbacks can be Active Job instances,
257
+ such as `ReportJob.new(import.id)`. A job reads its group through
258
+ `Stablemates::Workhorse::ActiveJob.current_task_group`.
259
+ - A job class with `enqueue_after_transaction_commit` set to true cannot join a task group. Rails
260
+ would enqueue that job after the transaction commits, so outside the group. If a task group block
261
+ enqueues such a job through the Workhorse adapter, the SDK raises `ArgumentError` before Rails
262
+ defers the job. Set `enqueue_after_transaction_commit` to false on that job class. The group
263
+ statement runs in the caller's transaction, so the member tasks still commit with it.
264
+ - `TaskGroupClosedError` (`P1008`), `TaskGroupLimitExceededError` (`P1009`), and
265
+ `TaskGroupMembershipConflictError` (`P1010`) report the task group refusals.
266
+ - `Queue#list_task_group_members` and `Admin#list_task_group_members` return a nil `next_after`
267
+ on the last page, also when the page is full (SM-1277). Before, a full last page gave a
268
+ `next_after` that read an empty page. A full page now costs one more read of one member.
269
+ `TaskGroup#callbacks` keeps the order `on_finish`, `on_success`, `on_failure`, and a test now
270
+ asserts it. The change adds no SQL function and no migration.
271
+
272
+ ### Checkpoint input
273
+
274
+ A checkpoint finds its stored result by name only. If a deployment changes the input of a checkpoint
275
+ operation, a replay returns the result of the old input and gives no error. A Ruby handler can now
276
+ give the input of a checkpoint (SM-1264,
277
+ [ADR 0098](https://github.com/stablemates/workhorse/blob/main/docs/decisions/0098-check-the-input-of-a-checkpoint-on-replay.md)):
278
+
279
+ ```ruby
280
+ context.checkpoint("charge-card", input: {"amountCents" => amount}) { gateway.charge(amount) }
281
+ ```
282
+
283
+ - `HandlerContext#checkpoint` and `BatchHandlerContext#checkpoint` take the `input:` keyword, a JSON
284
+ value. The checkpoint stores the fingerprint of the input with its value, through
285
+ `save_checkpoint_v2`. A call without `input:` keeps its current behavior. `input: nil` gives the
286
+ JSON input null.
287
+ - If the stored checkpoint, or an overlapping call with the same name, has the fingerprint of a
288
+ different input, the call raises `CheckpointInputConflictError` and the block does not run. If the
289
+ call or the stored checkpoint has no input, the call compares nothing.
290
+ - `CheckpointInputConflictError` is a subclass of `ConflictError`. The worker fails the task at once
291
+ and keeps the current attempt, as for every replay conflict. The task error names the class.
292
+ - `Stablemates::Workhorse.checkpoint_input_fingerprint(input)` returns the fingerprint: the SHA-256
293
+ digest of the RFC 8785 canonical JSON of the input, as 64 lowercase hexadecimal characters. It
294
+ refuses an Integer or a Float larger than 2**53 - 1 in magnitude, such as `1e21`, because RFC 8785
295
+ writes each number as an IEEE 754 double. Every SDK refuses the same inputs (SM-1274).
296
+ - `TaskCheckpoint#input_fingerprint` returns the stored fingerprint, or nil. `Admin#get_checkpoint`,
297
+ `Admin#list_checkpoints`, and `HandlerContext#get_checkpoint` return it.
298
+
299
+ ### Behaviour changes
300
+
301
+ - If a child task request names no queue, `run_child`, `run_children`, and `run_children_all` put
302
+ the child in the queue of the parent task (SM-1260, [ADR 0097](https://github.com/stablemates/workhorse/blob/main/docs/decisions/0097-put-work-that-a-handler-creates-on-the-queue-of-its-task.md)).
303
+ Before, the child went to the first queue in the `queues:` list of the worker. To keep the old
304
+ queue, name that queue on the request, for example `queue: "default"`.
305
+ A parent task that created a child before the upgrade can replay that request after the upgrade.
306
+ If the replayed request names no queue, it keeps the queue of the stored child, so the parent
307
+ task joins its child.
308
+ - `Worker#run_once` does not change: a pass that released every task that it claimed returns
309
+ `false`, and the next call claims at once (SM-1259, [ADR 0099](https://github.com/stablemates/workhorse/blob/main/docs/decisions/0099-report-a-released-only-single-pass-as-no-progress.md)). TypeScript
310
+ `runOnce()` now does the same. If you call the single-pass call in a loop, wait after `false`.
311
+ - A continuous worker that released every task of a claim now waits for its next poll before it
312
+ claims again (SM-1247). A worker releases a task when it has no handler for the task type, for
313
+ example during a rolling deployment. Before, the release notification ended the empty wait of the
314
+ same worker, so it claimed the task again after 0 to 50 ms. One listening worker released one task
315
+ about 30 times each second. Now a notification does not end this release wait. Only its deadline,
316
+ a stop, or a pause ends it. The deadline is the poll interval while the listener is active, or the
317
+ poll backoff without a listener. Thus a worker claims a task that it cannot run at most once in
318
+ each poll interval.
319
+ - The Rack dashboard refuses a mutation whose `audit.requestId` is more than 512 UTF-8 bytes
320
+ with `BAD_REQUEST`, before any SQL runs (SM-1253). It measures the value as sent, before trim.
321
+ Before, the SQL audit function refused the value, and the client got an internal server error.
322
+
6
323
  ## 0.7.1 — 2026-10-09
7
324
 
8
325
  The npm packages, Python distribution, Go module, Rust crate, and Ruby gem release from one source commit.
@@ -316,7 +633,7 @@ Requires **schema v43**, Ruby **3.3** or newer, and PostgreSQL **15** or newer.
316
633
  renews the lease, `Worker#stop` still wakes the dispatcher, and a second signal still exits the
317
634
  process. The first failure of each logger is written to standard error.
318
635
  - Add the Active Job adapter, selected with `config.active_job.queue_adapter =
319
- :stablemates_workhorse`. It requires Active Job 8.0 or later. A default job runs under the
636
+ :stablemates_workhorse`. It requires Active Job 8.0 or later. A default job runs under the
320
637
  `active_job` task type. A typed job declares its task type with `workhorse_options` and carries
321
638
  one JSON `Hash`, so another SDK can enqueue or run it. `Stablemates::Workhorse::ActiveJob.handle`
322
639
  registers both formats on a `Worker`.
data/README.md CHANGED
@@ -3,6 +3,13 @@
3
3
  The Ruby queue client, operator client, and worker runtime for the Workhorse durable task queue for
4
4
  PostgreSQL.
5
5
 
6
+ <picture>
7
+ <source media="(max-width: 600px) and (prefers-color-scheme: dark)" srcset="https://workhorse.run/diagrams/readme-overview-narrow-dark.png">
8
+ <source media="(max-width: 600px)" srcset="https://workhorse.run/diagrams/readme-overview-narrow-light.png">
9
+ <source media="(prefers-color-scheme: dark)" srcset="https://workhorse.run/diagrams/readme-overview-dark.png">
10
+ <img src="https://workhorse.run/diagrams/readme-overview-light.png" width="960" alt="One protocol for your workers and your dashboard">
11
+ </picture>
12
+
6
13
  > **Public beta:** Workhorse is usable for evaluation and early production adoption. A 0.x minor
7
14
  > release may change behaviour, so read the
8
15
  > [changelog](https://github.com/stablemates/workhorse/blob/main/ruby/CHANGELOG.md) before you
@@ -23,7 +30,7 @@ bundle add connection_pool
23
30
  Install the schema once, as a deployment step. The application never installs or migrates it.
24
31
 
25
32
  ```bash
26
- npx --package @stablemates/workhorse@0.7.1 workhorse schema install
33
+ npx --package @stablemates/workhorse@0.9.0 workhorse schema install
27
34
  ```
28
35
 
29
36
  The machine that runs that deployment step needs Node.js 22 or newer. The application itself needs
@@ -70,6 +77,22 @@ effects. `ruby/examples/` also holds a transactional enqueue, a dedicated worker
70
77
  orchestration of child tasks, signals, and human decisions. An integration spec runs every one of
71
78
  them against PostgreSQL.
72
79
 
80
+ ## Task groups, checkpoint inputs, and single passes
81
+
82
+ - A [task group](https://workhorse.run/docs/task-groups) tracks many tasks as one unit. After the
83
+ group is sealed and every member task is finished, Workhorse enqueues the callback tasks of the
84
+ group. The `Queue#task_group` block creates a group, adds its tasks, and seals it in one
85
+ transaction.
86
+ - A handler can give the [input of a
87
+ checkpoint](https://workhorse.run/docs/durable-execution#check-the-input-of-a-checkpoint). Give it
88
+ as `context.checkpoint(name, input: input) { ... }`. If a later run gives a different input, the
89
+ call raises `CheckpointInputConflictError`, the task fails, and the operation does not run again.
90
+ If the stored checkpoint or the call has no input, Workhorse compares nothing.
91
+ - `Worker#run_once` returns true only when a handler ran. It returns false after a pass that only
92
+ [released the tasks that it
93
+ claimed](https://workhorse.run/docs/workers#return-a-task-that-the-worker-cannot-run). If you call
94
+ it in a loop, wait after false.
95
+
73
96
  ## Package boundary
74
97
 
75
98
  The gem is `stablemates-workhorse`, and its namespace is `Stablemates::Workhorse`.
@@ -25,8 +25,9 @@ module Stablemates
25
25
  # The keywords +workhorse_options+ accepts. A job class that passes any other key raises.
26
26
  OPTION_KEYS = %i[task_type max_attempts tags concurrency_key].freeze
27
27
  WORKER_KEY = :stablemates_workhorse_active_job_worker
28
+ CONTEXT_KEY = :stablemates_workhorse_active_job_context
28
29
  private_constant :JOB_ID_TAG, :CLASS_TAG, :MAX_OPTION_TAGS, :MAX_TAG_LENGTH, :MAX_TASK_TYPE_BYTES,
29
- :WORKER_KEY
30
+ :WORKER_KEY, :CONTEXT_KEY
30
31
 
31
32
  # Declares Workhorse options on a job class. Include it and call +workhorse_options+.
32
33
  module Options
@@ -77,13 +78,50 @@ module Stablemates
77
78
  typed[task_type] = job_class
78
79
  end
79
80
  admin = Admin.new(worker.executor)
80
- worker.handle(TASK_TYPE) { |payload, context| running(worker) { run_default(payload, context.task) } }
81
+ worker.handle(TASK_TYPE) { |payload, context| running(worker, context) { run_default(payload, context.task) } }
81
82
  typed.each do |task_type, job_class|
82
- worker.handle(task_type) { |payload, context| running(worker) { run_typed(job_class, payload, context.task, admin) } }
83
+ worker.handle(task_type) do |payload, context|
84
+ running(worker, context) { run_typed(job_class, payload, context.task, admin) }
85
+ end
83
86
  end
84
87
  worker
85
88
  end
86
89
 
90
+ # Creates a task group (ADR 0095), runs the block, and seals the group, in one statement. Every
91
+ # job the block enqueues through the Workhorse adapter, with +perform_later+ or
92
+ # +perform_all_later+, becomes a member task. The jobs are enqueued when the block returns,
93
+ # and each job's +provider_job_id+ is set then. If the block raises, nothing is enqueued.
94
+ #
95
+ # A job class with +enqueue_after_transaction_commit+ set to true cannot join a group. Rails
96
+ # would enqueue it after the transaction commits, so after the block ends and outside the
97
+ # group. If the block enqueues such a job through the Workhorse adapter, the call raises
98
+ # ArgumentError before Rails defers the job. Set +enqueue_after_transaction_commit+ to false on
99
+ # that job class. The group statement runs in the caller's transaction, so the member tasks
100
+ # still commit with it.
101
+ #
102
+ # Each callback is an Active Job instance, such as +ReportJob.new(import.id)+, an
103
+ # EnqueueRequest, or nil. +on_finish+ runs when every member task is finished, +on_success+
104
+ # when every member task succeeded, and +on_failure+ when one or more did not succeed.
105
+ # +queue+ is the Queue that creates the group. It defaults to the queue of the Workhorse
106
+ # adapter that ActiveJob::Base uses. Returns a TaskGroupBlockResult.
107
+ def task_group(on_finish: nil, on_success: nil, on_failure: nil, queue: nil, &)
108
+ raise ArgumentError, "task_group needs a block" unless block_given?
109
+ raise ArgumentError, "queue must be a Queue or nil" unless queue.nil? || queue.is_a?(Queue)
110
+
111
+ queue ||= adapter_queue
112
+ queue.task_group(on_finish: callback(on_finish), on_success: callback(on_success),
113
+ on_failure: callback(on_failure), &)
114
+ end
115
+
116
+ # The TaskGroupLink of the job that runs now, as a member task or as a callback task, or nil
117
+ # for a job with no group. Raises ArgumentError outside a job that a Workhorse worker runs.
118
+ def current_task_group
119
+ context = Thread.current[CONTEXT_KEY]
120
+ raise ArgumentError, "current_task_group needs a job that a Workhorse worker runs" if context.nil?
121
+
122
+ context.task_group.get
123
+ end
124
+
87
125
  # The request that enqueues +job+, or ArgumentError before any statement runs. An invalid
88
126
  # priority raises ActiveJob::EnqueueError, which Active Job records on the job.
89
127
  def request(job, run_at) # :nodoc:
@@ -104,6 +142,22 @@ module Stablemates
104
142
  # The worker running the current job, or nil outside a worker.
105
143
  def current_worker = Thread.current[WORKER_KEY] # :nodoc:
106
144
 
145
+ # Raises ArgumentError when a task group block is open and one of +jobs+ goes to the Workhorse
146
+ # adapter with +enqueue_after_transaction_commit+ set. Rails would enqueue that job after the
147
+ # commit, outside the group.
148
+ def refuse_deferral(jobs) # :nodoc:
149
+ return if TaskGroupBuilder.current.nil?
150
+
151
+ jobs.each do |job|
152
+ next unless job.class.queue_adapter.is_a?(::ActiveJob::QueueAdapters::StablematesWorkhorseAdapter)
153
+ next unless DEFERRING.include?(job.class.enqueue_after_transaction_commit)
154
+
155
+ raise ArgumentError, "#{job.class.name} sets enqueue_after_transaction_commit, so Rails would enqueue " \
156
+ "it after the transaction commits and outside the task group; set " \
157
+ "enqueue_after_transaction_commit to false on #{job.class.name}"
158
+ end
159
+ end
160
+
107
161
  def validate_options(class_name, options) # :nodoc:
108
162
  unknown = options.keys - OPTION_KEYS
109
163
  raise ArgumentError, "workhorse_options does not accept #{unknown.map(&:inspect).join(", ")}" unless
@@ -165,13 +219,31 @@ module Stablemates
165
219
  adapter_tags + (option || [])
166
220
  end
167
221
 
168
- def running(worker)
169
- previous = Thread.current[WORKER_KEY]
222
+ def running(worker, context)
223
+ previous = [Thread.current[WORKER_KEY], Thread.current[CONTEXT_KEY]]
170
224
  Thread.current[WORKER_KEY] = worker
225
+ Thread.current[CONTEXT_KEY] = context
171
226
  yield
172
227
  nil
173
228
  ensure
174
- Thread.current[WORKER_KEY] = previous
229
+ Thread.current[WORKER_KEY], Thread.current[CONTEXT_KEY] = previous
230
+ end
231
+
232
+ def callback(job)
233
+ case job
234
+ when nil, EnqueueRequest then job
235
+ when ::ActiveJob::Base then request(job, job.scheduled_at && Time.at(job.scheduled_at.to_f))
236
+ else raise ArgumentError, "a task group callback must be an Active Job, an EnqueueRequest, or nil"
237
+ end
238
+ end
239
+
240
+ def adapter_queue
241
+ adapter = ::ActiveJob::Base.queue_adapter
242
+ unless adapter.is_a?(::ActiveJob::QueueAdapters::StablematesWorkhorseAdapter)
243
+ raise ArgumentError, "task_group needs a queue: ActiveJob::Base does not use the Workhorse adapter"
244
+ end
245
+
246
+ adapter.workhorse_queue
175
247
  end
176
248
 
177
249
  def run_default(payload, task)
@@ -204,6 +276,42 @@ module Stablemates
204
276
  concurrency_key: "must be a non-empty String or respond to call"
205
277
  }.freeze
206
278
  private_constant :OPTION_RULES
279
+
280
+ # The +enqueue_after_transaction_commit+ values that defer an enqueue. Active Job 8.0 also
281
+ # accepts the deprecated :always.
282
+ DEFERRING = [true, :always].freeze
283
+ private_constant :DEFERRING
284
+
285
+ # Checks one job before Rails can defer its enqueue. A prepend on ActiveJob::Base runs before
286
+ # the module that Rails includes for the deferral. Internal to the SDK.
287
+ module JobDeferralCheck # :nodoc:
288
+ private
289
+
290
+ def raw_enqueue(...)
291
+ ::Stablemates::Workhorse::ActiveJob.refuse_deferral([self])
292
+ super
293
+ end
294
+ end
295
+
296
+ # Checks the jobs of +perform_all_later+ before Rails can defer them. Internal to the SDK.
297
+ module BatchDeferralCheck # :nodoc:
298
+ def perform_all_later(*jobs)
299
+ ::Stablemates::Workhorse::ActiveJob.refuse_deferral(jobs.flatten)
300
+ super
301
+ end
302
+ end
303
+ private_constant :JobDeferralCheck, :BatchDeferralCheck
304
+
305
+ # The checks wait for ActiveJob::Base. Loading it here would run its load hooks, and Rails
306
+ # would then look up this adapter before this file defines it.
307
+ ::ActiveSupport.on_load(:active_job) { prepend(JobDeferralCheck) }
308
+ ::ActiveJob.singleton_class.prepend(BatchDeferralCheck)
309
+ # Active Job 8.1 splits +perform_all_later+ in a module that it prepends when Rails includes
310
+ # the deferral, which can be after this file loads. The check therefore also goes into that
311
+ # module, so it runs before the split.
312
+ if ::ActiveJob::EnqueueAfterTransactionCommit.const_defined?(:ActiveJobMethods, false)
313
+ ::ActiveJob::EnqueueAfterTransactionCommit::ActiveJobMethods.prepend(BatchDeferralCheck)
314
+ end
207
315
  end
208
316
  end
209
317
  end
@@ -226,8 +334,9 @@ module ActiveJob
226
334
 
227
335
  # Enqueues +jobs+ in atomic chunks of the shared batch limit and returns how many it enqueued.
228
336
  # A job with an invalid priority records the error and is skipped. Each job reports only the
229
- # error of this call.
337
+ # error of this call. Inside the block form of a task group, the jobs join the group instead.
230
338
  def enqueue_all(jobs)
339
+ builder = ::Stablemates::Workhorse::TaskGroupBuilder.current
231
340
  pending = jobs.filter_map do |job|
232
341
  job.enqueue_error = nil
233
342
  [job, workhorse.request(job, job.scheduled_at && Time.at(job.scheduled_at.to_f))]
@@ -236,6 +345,8 @@ module ActiveJob
236
345
  job.successfully_enqueued = false
237
346
  nil
238
347
  end
348
+ return grouped(builder, pending) if builder
349
+
239
350
  pending.each_slice(::Stablemates::Workhorse::SqlCatalogue::MAX_ENQUEUE_BATCH_SIZE) do |chunk|
240
351
  results = queue.enqueue_many(chunk.map(&:last))
241
352
  chunk.zip(results) do |(job, _request), result|
@@ -252,20 +363,37 @@ module ActiveJob
252
363
  !worker.nil? && worker.stopping?
253
364
  end
254
365
 
366
+ # The Queue this adapter enqueues through. Internal to the SDK.
367
+ def workhorse_queue # :nodoc:
368
+ @queue || @lock.synchronize do
369
+ @queue ||= ::Stablemates::Workhorse::Queue.new(
370
+ @executor || ::Stablemates::Workhorse::ActiveRecordExecutor.new(::ActiveRecord::Base)
371
+ )
372
+ end
373
+ end
374
+
255
375
  private
256
376
 
257
377
  def workhorse = ::Stablemates::Workhorse::ActiveJob
258
378
 
379
+ def queue = workhorse_queue
380
+
381
+ # Inside the block form of a task group, a job joins the group. The group enqueues it when the
382
+ # block returns.
259
383
  def enqueue_one(job, run_at)
260
- job.provider_job_id = queue.enqueue_many([workhorse.request(job, run_at)]).first.task_id
384
+ request = workhorse.request(job, run_at)
385
+ builder = ::Stablemates::Workhorse::TaskGroupBuilder.current
386
+ return job.provider_job_id = queue.enqueue_many([request]).first.task_id if builder.nil?
387
+
388
+ builder.add_request(request) { |result| job.provider_job_id = result.task_id }
261
389
  end
262
390
 
263
- def queue
264
- @queue || @lock.synchronize do
265
- @queue ||= ::Stablemates::Workhorse::Queue.new(
266
- @executor || ::Stablemates::Workhorse::ActiveRecordExecutor.new(::ActiveRecord::Base)
267
- )
391
+ def grouped(builder, pending)
392
+ pending.each do |job, request|
393
+ builder.add_request(request) { |result| job.provider_job_id = result.task_id }
394
+ job.successfully_enqueued = true
268
395
  end
396
+ pending.length
269
397
  end
270
398
  end
271
399
  end