stablemates-workhorse 0.7.1 → 0.8.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 +198 -1
- data/README.md +17 -1
- data/lib/stablemates/workhorse/active_job.rb +141 -13
- data/lib/stablemates/workhorse/admin.rb +78 -3
- data/lib/stablemates/workhorse/batch_context.rb +6 -2
- data/lib/stablemates/workhorse/checkpoint_fingerprint.rb +118 -0
- data/lib/stablemates/workhorse/context.rb +77 -12
- data/lib/stablemates/workhorse/dashboard/backend.rb +20 -1
- data/lib/stablemates/workhorse/dashboard/bundle.json +1 -1
- data/lib/stablemates/workhorse/dashboard/read-surface-1.tar.gz +0 -0
- data/lib/stablemates/workhorse/dashboard/v1_generated.rb +149 -1
- data/lib/stablemates/workhorse/dashboard.rb +18 -1
- data/lib/stablemates/workhorse/errors.rb +77 -6
- data/lib/stablemates/workhorse/executor.rb +3 -0
- data/lib/stablemates/workhorse/queue.rb +169 -16
- data/lib/stablemates/workhorse/sql_catalogue_generated.rb +43 -4
- data/lib/stablemates/workhorse/task_group.rb +240 -0
- data/lib/stablemates/workhorse/telemetry.rb +13 -2
- data/lib/stablemates/workhorse/version.rb +1 -1
- data/lib/stablemates/workhorse/worker.rb +13 -2
- data/lib/stablemates/workhorse.rb +3 -0
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 807d43f9b16d79430d292a3e6c70acdc55bb8cc3f2010b7e03f0356fb0731c90
|
|
4
|
+
data.tar.gz: f84ab68d2d6d03450983e74dc337880546815472ece79d8cdf1d650679d3912a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 706d9cfd755c1bc971116245009433dba6d4880dc7b7dec081f486d95f5395395a69b55527527b33252f4cf4bbc5e1a1b94c74ad91b267fe6893884c3bdb09ef
|
|
7
|
+
data.tar.gz: dd7e13f971e790cc10c4f07843a4b7ec9f086f32365c665f681206bf1376d1d7d03bf11e6dfa78aacc75ea8cb2ce292da3246b9471431d7fa6b1630c58c46a17
|
data/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,203 @@
|
|
|
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.8.0 — 2026-10-13
|
|
7
|
+
|
|
8
|
+
The npm packages, Python distribution, Go module, Rust crate, and Ruby gem release from one source commit.
|
|
9
|
+
|
|
10
|
+
Requires **schema v69**, Ruby **3.3** or newer, and PostgreSQL **15** or newer.
|
|
11
|
+
|
|
12
|
+
### Upgrade steps
|
|
13
|
+
|
|
14
|
+
**Migrate the schema before starting updated processes.** The upgrade is a rolling deployment:
|
|
15
|
+
|
|
16
|
+
1. Run `workhorse schema migrate`. The final schema version is **71**.
|
|
17
|
+
2. Roll out the updated processes.
|
|
18
|
+
|
|
19
|
+
The SDK compatibility floor is schema version **69**, which migration 0070 reaches. The floor rises
|
|
20
|
+
from 54. Every SDK statement catalogue now names the task group functions that migration 0066 adds,
|
|
21
|
+
and `save_checkpoint_v2`, which migration 0069 adds. The dashboard of each SDK calls
|
|
22
|
+
`dashboard_task_groups_v1`, which migration 0067 adds, and `list_newest_task_group_events_v1`, which
|
|
23
|
+
migration 0068 adds. Migration 0070 fences a member add, so an updated process does not add tasks to
|
|
24
|
+
a group without that check. An updated process refuses a schema below version 69 at startup. A 0.7
|
|
25
|
+
process keeps working on version 71, because a migration inside a major line only adds.
|
|
26
|
+
|
|
27
|
+
**Give the eighth member when you build a `TaskCheckpoint`.** `TaskCheckpoint` is a `Data` class.
|
|
28
|
+
0.8.0 adds `input_fingerprint` as its eighth member (SM-1264). A call to `TaskCheckpoint.new` with
|
|
29
|
+
seven positional arguments raises `ArgumentError`. A call with keywords and no
|
|
30
|
+
`input_fingerprint:` also raises it. The gem has no compatible constructor. If your code builds a
|
|
31
|
+
`TaskCheckpoint`, for example in a test, give the eighth value. For a checkpoint without an input,
|
|
32
|
+
give `input_fingerprint: nil` or a positional `nil`.
|
|
33
|
+
|
|
34
|
+
Migration 0066 (`0066-add-task-groups-with-completion-callbacks.sql`) adds task groups (SM-1231,
|
|
35
|
+
[ADR 0095](https://github.com/stablemates/workhorse/blob/main/docs/decisions/0095-add-task-groups-with-completion-callbacks.md)).
|
|
36
|
+
A task group is a set of full-tier tasks that Workhorse tracks as one unit. When the group is
|
|
37
|
+
sealed and every member task is finished, Workhorse enqueues its `onFinish`, `onSuccess`, or
|
|
38
|
+
`onFailure` callback task. The migration adds the `task_group`, `task_group_member`,
|
|
39
|
+
`task_group_callback`, and `task_group_event` tables, and a trigger on `task_outcome` that counts
|
|
40
|
+
each member outcome in the transaction that writes it. A queue purge counts a purged member task as
|
|
41
|
+
`canceled`. The `terminal_storage` routine keeps member tasks while their group is not finished,
|
|
42
|
+
reports a new `task_groups` phase after `terminal_tasks`, and deletes a finished group after its
|
|
43
|
+
callback tasks are terminal. New SQLSTATEs: `P1008` (the task group does not accept tasks), `P1009`
|
|
44
|
+
(member limit of 100,000), and `P1010` (an enqueue idempotency hit returns a task that is not a
|
|
45
|
+
member of the group).
|
|
46
|
+
|
|
47
|
+
Migration 0067 (`0067-count-task-group-outcomes-in-counter-shards.sql`) counts task group outcomes
|
|
48
|
+
in counter shards (SM-1245,
|
|
49
|
+
[ADR 0096](https://github.com/stablemates/workhorse/blob/main/docs/decisions/0096-count-task-group-outcomes-in-counter-shards.md)).
|
|
50
|
+
Each group gets 32 rows in the new `task_group_shard` table, and a member outcome locks only its
|
|
51
|
+
shard row at commit. The outcome locks the group row only when the pending count of its shard
|
|
52
|
+
becomes 0. The migration moves the counts of existing groups into the shards. It locks `task_group`
|
|
53
|
+
in `EXCLUSIVE` mode while it runs, so task group changes wait for it. The counts on a `task_group`
|
|
54
|
+
row stay 0 until the group finishes, so read the counts through `get_task_group_v1`. The migration
|
|
55
|
+
also adds `dashboard_task_groups_v1`, which lists task groups one page at a time. The dashboard of
|
|
56
|
+
each SDK reads the task group list through it (SM-1237).
|
|
57
|
+
|
|
58
|
+
Migration 0068 (`0068-read-the-newest-task-group-events.sql`) adds
|
|
59
|
+
`list_newest_task_group_events_v1`, which returns the newest events of a task group first
|
|
60
|
+
(SM-1254). The task group detail of the dashboard reads its events through it. A group with more
|
|
61
|
+
than 1,000 events now shows its newest 100 events. `eventsTruncated` is true when the group has
|
|
62
|
+
older events that the detail does not show. `list_task_group_events_v1` does not change. The
|
|
63
|
+
migration changes no stored data.
|
|
64
|
+
|
|
65
|
+
Migration 0069 (`0069-check-the-input-of-a-checkpoint-on-replay.sql`) adds the nullable column
|
|
66
|
+
`task_checkpoint.input_fingerprint` and `save_checkpoint_v2` (SM-1211,
|
|
67
|
+
[ADR 0098](https://github.com/stablemates/workhorse/blob/main/docs/decisions/0098-check-the-input-of-a-checkpoint-on-replay.md)).
|
|
68
|
+
If a checkpoint name exists, `save_checkpoint_v2` returns `input_conflict` when the stored and the
|
|
69
|
+
given input fingerprints are both present and differ. `save_checkpoint_v1` now calls
|
|
70
|
+
`save_checkpoint_v2` with no fingerprint, so its results do not change. The checkpoint reads of each
|
|
71
|
+
statement catalogue return the new column. Existing rows keep a NULL fingerprint. The migration adds
|
|
72
|
+
the column without a table scan.
|
|
73
|
+
|
|
74
|
+
Migration 0070 (`0070-fence-a-task-group-member-add-and-index-the-group-list.sql`) fences a task
|
|
75
|
+
group member add (SM-1273). `add_task_group_members_v1` now locks the runtime row of the member task
|
|
76
|
+
first, before the shard rows and the group row, as a completion does. Then it refuses the add with
|
|
77
|
+
`P1008` and the reason `not_running_member` if the lease of the member task expired, its deadline or
|
|
78
|
+
attempt timeout passed, or a cancel was requested. Before, a worker whose lease expired could add
|
|
79
|
+
tasks after Workhorse gave the member task to a new worker. The signature does not change. The
|
|
80
|
+
migration also adds `task_group_state_created_idx`, so `dashboard_task_groups_v1` reads one page of
|
|
81
|
+
a state without a read of the groups of other states. The migration is a non-transactional step: it
|
|
82
|
+
builds the index with `CREATE INDEX CONCURRENTLY`, so writes to `task_group` continue. If it fails
|
|
83
|
+
part-way, drop the invalid index before the rerun, as `docs/schema-lifecycle.md` describes. It
|
|
84
|
+
changes no stored data.
|
|
85
|
+
|
|
86
|
+
Migration 0071 (`0071-lock-purged-runtime-rows-in-identity-order.sql`) makes a queue purge lock its
|
|
87
|
+
tasks in identity order (SM-1282). Before, `purge_queue_internal_v1` locked the runtime rows and the
|
|
88
|
+
task rows of the queue in scan order. An enqueue that named two of those tasks as prerequisites
|
|
89
|
+
locks them in identity order. Thus the purge and the enqueue could wait for each other, and
|
|
90
|
+
PostgreSQL aborted one of them with `40P01`. The purge now locks every runtime row, then every task
|
|
91
|
+
row, each in identity order. The signature and the deleted count do not change. The migration does
|
|
92
|
+
not raise the SDK compatibility floor, because no SDK statement changes and every process gets the
|
|
93
|
+
new lock order when the migration commits. It changes no stored data.
|
|
94
|
+
|
|
95
|
+
Migration 0072 (`0072-purge-a-queue-that-holds-a-prerequisite-of-a-live-dependent.sql`) lets a
|
|
96
|
+
queue purge succeed when it holds a prerequisite of a waiting dependent (SM-1284,
|
|
97
|
+
[ADR 0101](https://github.com/stablemates/workhorse/blob/main/docs/decisions/0101-cancel-a-purged-prerequisite-whose-dependent-stays.md)).
|
|
98
|
+
Before, the purge deleted that prerequisite, and PostgreSQL refused the delete with `23001`. The
|
|
99
|
+
whole purge then failed, also when the dependent was in the same queue. Now the purge deletes a
|
|
100
|
+
dependent in the same purge with its prerequisite. If the dependent stays, for example in another
|
|
101
|
+
queue, the purge cancels the prerequisite instead of a delete. The dependent's own
|
|
102
|
+
`on_cancellation` policy then decides its outcome, as after a cancel. The canceled task keeps its
|
|
103
|
+
history and its keys. The purge count, `deleted_count` and `deletedCount`, includes the canceled
|
|
104
|
+
tasks. The purge locks the dependents after its own tasks, as a cancel does, so it has no
|
|
105
|
+
deadlock that a cancel of the same tasks does not also have. Two purges run one after the
|
|
106
|
+
other, also for different queues. The migration does not raise the SDK compatibility
|
|
107
|
+
floor, because no SDK statement or result shape changes. It changes no stored data.
|
|
108
|
+
|
|
109
|
+
### Task groups
|
|
110
|
+
|
|
111
|
+
The Ruby SDK adds task groups (SM-1236). Use the block form to create a group, add its member
|
|
112
|
+
tasks, and seal it in one statement, so in one transaction:
|
|
113
|
+
|
|
114
|
+
```ruby
|
|
115
|
+
result = queue.task_group(on_finish: Stablemates::Workhorse::EnqueueRequest.new(
|
|
116
|
+
task_type: "import.report", payload: {"importId" => 42}
|
|
117
|
+
)) do |group|
|
|
118
|
+
rows.each { |row| group.enqueue("import.row", {"row" => row}) }
|
|
119
|
+
end
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
- `Queue#task_group` takes the `on_finish:`, `on_success:`, and `on_failure:` callbacks. Each
|
|
123
|
+
callback is an `EnqueueRequest` without idempotency, debounce, or throttle. The block receives a
|
|
124
|
+
`TaskGroupBuilder` with `add` and `enqueue`. If the block raises, nothing is enqueued.
|
|
125
|
+
- `Queue#create_task_group`, `Queue#add_to_task_group`, and `Queue#seal_task_group` build a group in
|
|
126
|
+
more than one transaction.
|
|
127
|
+
- `Queue#get_task_group`, `Queue#list_task_group_members`, and `Queue#find_task_group` read a
|
|
128
|
+
group. `Admin` has `get_task_group`, `list_task_group_members`, `list_task_group_events`,
|
|
129
|
+
`cancel_task_group`, `list_task_group_drift`, and `repair_task_group_drift`.
|
|
130
|
+
- `HandlerContext#task_group` and `BatchHandlerContext#task_group` return a `TaskGroupContext`.
|
|
131
|
+
Its `get` returns the `TaskGroupLink` of the task, with the group ID and the counts. Its `add`
|
|
132
|
+
adds tasks from a running member task, also after the seal. If a request names no queue, `add`
|
|
133
|
+
puts the task in the queue of the member task, not in the first queue of the worker (SM-1251,
|
|
134
|
+
[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)).
|
|
135
|
+
- `Stablemates::Workhorse::ActiveJob.task_group` runs a block in which each job that the
|
|
136
|
+
Workhorse adapter enqueues becomes a member task. Its callbacks can be Active Job instances,
|
|
137
|
+
such as `ReportJob.new(import.id)`. A job reads its group through
|
|
138
|
+
`Stablemates::Workhorse::ActiveJob.current_task_group`.
|
|
139
|
+
- A job class with `enqueue_after_transaction_commit` set to true cannot join a task group. Rails
|
|
140
|
+
would enqueue that job after the transaction commits, so outside the group. If a task group block
|
|
141
|
+
enqueues such a job through the Workhorse adapter, the SDK raises `ArgumentError` before Rails
|
|
142
|
+
defers the job. Set `enqueue_after_transaction_commit` to false on that job class. The group
|
|
143
|
+
statement runs in the caller's transaction, so the member tasks still commit with it.
|
|
144
|
+
- `TaskGroupClosedError` (`P1008`), `TaskGroupLimitExceededError` (`P1009`), and
|
|
145
|
+
`TaskGroupMembershipConflictError` (`P1010`) report the task group refusals.
|
|
146
|
+
- `Queue#list_task_group_members` and `Admin#list_task_group_members` return a nil `next_after`
|
|
147
|
+
on the last page, also when the page is full (SM-1277). Before, a full last page gave a
|
|
148
|
+
`next_after` that read an empty page. A full page now costs one more read of one member.
|
|
149
|
+
`TaskGroup#callbacks` keeps the order `on_finish`, `on_success`, `on_failure`, and a test now
|
|
150
|
+
asserts it. The change adds no SQL function and no migration.
|
|
151
|
+
|
|
152
|
+
### Checkpoint input
|
|
153
|
+
|
|
154
|
+
A checkpoint finds its stored result by name only. If a deployment changes the input of a checkpoint
|
|
155
|
+
operation, a replay returns the result of the old input and gives no error. A Ruby handler can now
|
|
156
|
+
give the input of a checkpoint (SM-1264,
|
|
157
|
+
[ADR 0098](https://github.com/stablemates/workhorse/blob/main/docs/decisions/0098-check-the-input-of-a-checkpoint-on-replay.md)):
|
|
158
|
+
|
|
159
|
+
```ruby
|
|
160
|
+
context.checkpoint("charge-card", input: {"amountCents" => amount}) { gateway.charge(amount) }
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
- `HandlerContext#checkpoint` and `BatchHandlerContext#checkpoint` take the `input:` keyword, a JSON
|
|
164
|
+
value. The checkpoint stores the fingerprint of the input with its value, through
|
|
165
|
+
`save_checkpoint_v2`. A call without `input:` keeps its current behavior. `input: nil` gives the
|
|
166
|
+
JSON input null.
|
|
167
|
+
- If the stored checkpoint, or an overlapping call with the same name, has the fingerprint of a
|
|
168
|
+
different input, the call raises `CheckpointInputConflictError` and the block does not run. If the
|
|
169
|
+
call or the stored checkpoint has no input, the call compares nothing.
|
|
170
|
+
- `CheckpointInputConflictError` is a subclass of `ConflictError`. The worker fails the task at once
|
|
171
|
+
and keeps the current attempt, as for every replay conflict. The task error names the class.
|
|
172
|
+
- `Stablemates::Workhorse.checkpoint_input_fingerprint(input)` returns the fingerprint: the SHA-256
|
|
173
|
+
digest of the RFC 8785 canonical JSON of the input, as 64 lowercase hexadecimal characters. It
|
|
174
|
+
refuses an Integer or a Float larger than 2**53 - 1 in magnitude, such as `1e21`, because RFC 8785
|
|
175
|
+
writes each number as an IEEE 754 double. Every SDK refuses the same inputs (SM-1274).
|
|
176
|
+
- `TaskCheckpoint#input_fingerprint` returns the stored fingerprint, or nil. `Admin#get_checkpoint`,
|
|
177
|
+
`Admin#list_checkpoints`, and `HandlerContext#get_checkpoint` return it.
|
|
178
|
+
|
|
179
|
+
### Behaviour changes
|
|
180
|
+
|
|
181
|
+
- If a child task request names no queue, `run_child`, `run_children`, and `run_children_all` put
|
|
182
|
+
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)).
|
|
183
|
+
Before, the child went to the first queue in the `queues:` list of the worker. To keep the old
|
|
184
|
+
queue, name that queue on the request, for example `queue: "default"`.
|
|
185
|
+
A parent task that created a child before the upgrade can replay that request after the upgrade.
|
|
186
|
+
If the replayed request names no queue, it keeps the queue of the stored child, so the parent
|
|
187
|
+
task joins its child.
|
|
188
|
+
- `Worker#run_once` does not change: a pass that released every task that it claimed returns
|
|
189
|
+
`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
|
|
190
|
+
`runOnce()` now does the same. If you call the single-pass call in a loop, wait after `false`.
|
|
191
|
+
- A continuous worker that released every task of a claim now waits for its next poll before it
|
|
192
|
+
claims again (SM-1247). A worker releases a task when it has no handler for the task type, for
|
|
193
|
+
example during a rolling deployment. Before, the release notification ended the empty wait of the
|
|
194
|
+
same worker, so it claimed the task again after 0 to 50 ms. One listening worker released one task
|
|
195
|
+
about 30 times each second. Now a notification does not end this release wait. Only its deadline,
|
|
196
|
+
a stop, or a pause ends it. The deadline is the poll interval while the listener is active, or the
|
|
197
|
+
poll backoff without a listener. Thus a worker claims a task that it cannot run at most once in
|
|
198
|
+
each poll interval.
|
|
199
|
+
- The Rack dashboard refuses a mutation whose `audit.requestId` is more than 512 UTF-8 bytes
|
|
200
|
+
with `BAD_REQUEST`, before any SQL runs (SM-1253). It measures the value as sent, before trim.
|
|
201
|
+
Before, the SQL audit function refused the value, and the client got an internal server error.
|
|
202
|
+
|
|
6
203
|
## 0.7.1 — 2026-10-09
|
|
7
204
|
|
|
8
205
|
The npm packages, Python distribution, Go module, Rust crate, and Ruby gem release from one source commit.
|
|
@@ -316,7 +513,7 @@ Requires **schema v43**, Ruby **3.3** or newer, and PostgreSQL **15** or newer.
|
|
|
316
513
|
renews the lease, `Worker#stop` still wakes the dispatcher, and a second signal still exits the
|
|
317
514
|
process. The first failure of each logger is written to standard error.
|
|
318
515
|
- 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
|
|
516
|
+
:stablemates_workhorse`. It requires Active Job 8.0 or later. A default job runs under the
|
|
320
517
|
`active_job` task type. A typed job declares its task type with `workhorse_options` and carries
|
|
321
518
|
one JSON `Hash`, so another SDK can enqueue or run it. `Stablemates::Workhorse::ActiveJob.handle`
|
|
322
519
|
registers both formats on a `Worker`.
|
data/README.md
CHANGED
|
@@ -23,7 +23,7 @@ bundle add connection_pool
|
|
|
23
23
|
Install the schema once, as a deployment step. The application never installs or migrates it.
|
|
24
24
|
|
|
25
25
|
```bash
|
|
26
|
-
npx --package @stablemates/workhorse@0.
|
|
26
|
+
npx --package @stablemates/workhorse@0.8.0 workhorse schema install
|
|
27
27
|
```
|
|
28
28
|
|
|
29
29
|
The machine that runs that deployment step needs Node.js 22 or newer. The application itself needs
|
|
@@ -70,6 +70,22 @@ effects. `ruby/examples/` also holds a transactional enqueue, a dedicated worker
|
|
|
70
70
|
orchestration of child tasks, signals, and human decisions. An integration spec runs every one of
|
|
71
71
|
them against PostgreSQL.
|
|
72
72
|
|
|
73
|
+
## Task groups, checkpoint inputs, and single passes
|
|
74
|
+
|
|
75
|
+
- A [task group](https://workhorse.run/docs/task-groups) tracks many tasks as one unit. After the
|
|
76
|
+
group is sealed and every member task is finished, Workhorse enqueues the callback tasks of the
|
|
77
|
+
group. The `Queue#task_group` block creates a group, adds its tasks, and seals it in one
|
|
78
|
+
transaction.
|
|
79
|
+
- A handler can give the [input of a
|
|
80
|
+
checkpoint](https://workhorse.run/docs/durable-execution#check-the-input-of-a-checkpoint). Give it
|
|
81
|
+
as `context.checkpoint(name, input: input) { ... }`. If a later run gives a different input, the
|
|
82
|
+
call raises `CheckpointInputConflictError`, the task fails, and the operation does not run again.
|
|
83
|
+
If the stored checkpoint or the call has no input, Workhorse compares nothing.
|
|
84
|
+
- `Worker#run_once` returns true only when a handler ran. It returns false after a pass that only
|
|
85
|
+
[released the tasks that it
|
|
86
|
+
claimed](https://workhorse.run/docs/workers#return-a-task-that-the-worker-cannot-run). If you call
|
|
87
|
+
it in a loop, wait after false.
|
|
88
|
+
|
|
73
89
|
## Package boundary
|
|
74
90
|
|
|
75
91
|
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)
|
|
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
|
-
|
|
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
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
|
@@ -72,7 +72,11 @@ module Stablemates
|
|
|
72
72
|
|
|
73
73
|
BulkRedrivePage = Data.define(:results, :next_cursor)
|
|
74
74
|
|
|
75
|
-
|
|
75
|
+
# +input_fingerprint+ is the fingerprint of the input that produced +value+, or nil when the write
|
|
76
|
+
# gave no input (ADR 0098).
|
|
77
|
+
TaskCheckpoint = Data.define(
|
|
78
|
+
:task_id, :name, :value, :attempt, :fence_token, :worker_id, :created_at, :input_fingerprint
|
|
79
|
+
)
|
|
76
80
|
|
|
77
81
|
TaskWait = Data.define(
|
|
78
82
|
:task_id, :name, :mode, :duration_ms, :requested_wake_at, :wake_at, :attempt, :fence_token,
|
|
@@ -290,7 +294,12 @@ module Stablemates
|
|
|
290
294
|
|
|
291
295
|
def resume_queue(queue, audit:) = set_queue_paused(queue, false, audit)
|
|
292
296
|
|
|
293
|
-
# Deletes every pending task in the queue and returns how many
|
|
297
|
+
# Deletes every pending task in the queue and returns how many tasks it deleted or canceled.
|
|
298
|
+
# A task that a dependent in another queue waits for is canceled, not deleted.
|
|
299
|
+
#
|
|
300
|
+
# Do not call it in a transaction that already holds task row locks, for example after a
|
|
301
|
+
# cancel or an enqueue. Two purges run one after the other, so such a transaction can
|
|
302
|
+
# deadlock with another purge.
|
|
294
303
|
def purge_queue(queue, audit:)
|
|
295
304
|
name(queue, "queue")
|
|
296
305
|
audit(audit)
|
|
@@ -301,6 +310,71 @@ module Stablemates
|
|
|
301
310
|
Values.parse_integer(rows.first.fetch("deleted_count"))
|
|
302
311
|
end
|
|
303
312
|
|
|
313
|
+
# The TaskGroup, or nil when no such group exists.
|
|
314
|
+
def get_task_group(group_id)
|
|
315
|
+
group_id = TaskGroups.group_id(group_id)
|
|
316
|
+
assert_compatible
|
|
317
|
+
row = @executor.rows(SqlCatalogue::GET_TASK_GROUP_V1, [group_id]).first
|
|
318
|
+
row && TaskGroups.group(row)
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
# One TaskGroupMemberPage of at most +limit+ member tasks after the position +after+, in the
|
|
322
|
+
# order they joined.
|
|
323
|
+
def list_task_group_members(group_id, after: 0, limit: 100)
|
|
324
|
+
group_id = TaskGroups.group_id(group_id)
|
|
325
|
+
limit(limit, TaskGroups::MAX_PAGE_SIZE, "list_task_group_members limit")
|
|
326
|
+
raise ArgumentError, "after must be a non-negative Integer" unless after.is_a?(Integer) && after >= 0
|
|
327
|
+
|
|
328
|
+
assert_compatible
|
|
329
|
+
rows = @executor.rows(SqlCatalogue::LIST_TASK_GROUP_MEMBERS_V1, [group_id, after.to_s, limit.to_s])
|
|
330
|
+
last = TaskGroups.probe_after(rows, limit)
|
|
331
|
+
more = !last.nil? && @executor.rows(SqlCatalogue::LIST_TASK_GROUP_MEMBERS_V1, [group_id, last.to_s, "1"]).any?
|
|
332
|
+
TaskGroups.member_page(rows, more)
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
# The group's TaskGroupEvent history in time order: create, seal, cancel, finish, callbacks,
|
|
336
|
+
# and repairs.
|
|
337
|
+
def list_task_group_events(group_id, limit: 1000)
|
|
338
|
+
group_id = TaskGroups.group_id(group_id)
|
|
339
|
+
limit(limit, TaskGroups::MAX_PAGE_SIZE, "list_task_group_events limit")
|
|
340
|
+
assert_compatible
|
|
341
|
+
@executor.rows(SqlCatalogue::LIST_TASK_GROUP_EVENTS_V1, [group_id, limit.to_s])
|
|
342
|
+
.map { |row| TaskGroups.event(row) }
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
# Seals an open group, so no task can join it after this call, not even from a running member
|
|
346
|
+
# task. Then cancels every member task that is not terminal. The canceled member tasks count as
|
|
347
|
+
# not succeeded, so the group finishes as usual and +on_finish+ and +on_failure+ run. A
|
|
348
|
+
# finished group stays unchanged. Returns the TaskGroup, or nil for an unknown group.
|
|
349
|
+
def cancel_task_group(group_id, audit:)
|
|
350
|
+
group_id = TaskGroups.group_id(group_id)
|
|
351
|
+
audit(audit)
|
|
352
|
+
assert_compatible
|
|
353
|
+
row = @executor.rows(SqlCatalogue::CANCEL_TASK_GROUP_V1,
|
|
354
|
+
[group_id, audit.actor, audit.reason, audit.request_id]).first
|
|
355
|
+
row && TaskGroups.group(row)
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
# The groups that are not finished and whose counters disagree with their member outcomes,
|
|
359
|
+
# and the sealed groups with no pending member, as TaskGroupDrift. It writes nothing, so it is
|
|
360
|
+
# the dry run of +repair_task_group_drift+.
|
|
361
|
+
def list_task_group_drift(limit: 1000)
|
|
362
|
+
limit(limit, TaskGroups::MAX_DRIFT_LIMIT, "list_task_group_drift limit")
|
|
363
|
+
assert_compatible
|
|
364
|
+
@executor.rows(SqlCatalogue::LIST_TASK_GROUP_DRIFT_V1, [limit.to_s]).map { |row| TaskGroups.drift(row) }
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
# Recounts each group +list_task_group_drift+ reports from its member outcomes, and finishes
|
|
368
|
+
# a sealed group with no pending member. Returns one TaskGroupRepair for each group. The
|
|
369
|
+
# +counter_repaired+ event of each group records the audit.
|
|
370
|
+
def repair_task_group_drift(audit:, limit: 1000)
|
|
371
|
+
audit(audit)
|
|
372
|
+
limit(limit, TaskGroups::MAX_DRIFT_LIMIT, "repair_task_group_drift limit")
|
|
373
|
+
assert_compatible
|
|
374
|
+
@executor.rows(SqlCatalogue::REPAIR_TASK_GROUP_DRIFT_V1,
|
|
375
|
+
[limit.to_s, audit.actor, audit.reason, audit.request_id]).map { |row| TaskGroups.repair(row) }
|
|
376
|
+
end
|
|
377
|
+
|
|
304
378
|
private
|
|
305
379
|
|
|
306
380
|
def set_queue_paused(queue, paused, audit)
|
|
@@ -561,7 +635,8 @@ module Stablemates
|
|
|
561
635
|
attempt: Values.parse_integer(row.fetch("attempt")),
|
|
562
636
|
fence_token: Values.parse_integer(row.fetch("fence_token")),
|
|
563
637
|
worker_id: row.fetch("worker_id"),
|
|
564
|
-
created_at: Values.parse_time(row.fetch("created_at"))
|
|
638
|
+
created_at: Values.parse_time(row.fetch("created_at")),
|
|
639
|
+
input_fingerprint: row.fetch("input_fingerprint")
|
|
565
640
|
)
|
|
566
641
|
end
|
|
567
642
|
|
|
@@ -16,6 +16,9 @@ module Stablemates
|
|
|
16
16
|
# The member's ClaimedTask.
|
|
17
17
|
def task = @context.task
|
|
18
18
|
|
|
19
|
+
# The TaskGroupContext of the member's task.
|
|
20
|
+
def task_group = @context.task_group
|
|
21
|
+
|
|
19
22
|
# The member's CancellationToken.
|
|
20
23
|
def cancellation = @context.cancellation
|
|
21
24
|
|
|
@@ -23,8 +26,9 @@ module Stablemates
|
|
|
23
26
|
def get_checkpoint(name) = @context.get_checkpoint(name)
|
|
24
27
|
|
|
25
28
|
# Returns the value the member's checkpoint named +name+ saved, running the block to produce
|
|
26
|
-
# and save it only when no such checkpoint exists.
|
|
27
|
-
|
|
29
|
+
# and save it only when no such checkpoint exists. The +input+ keyword works as in
|
|
30
|
+
# HandlerContext#checkpoint.
|
|
31
|
+
def checkpoint(name, input: HandlerContext::NO_INPUT, &) = @context.checkpoint(name, input: input, &)
|
|
28
32
|
|
|
29
33
|
# The progress the member reported last, as TaskProgress, or nil.
|
|
30
34
|
def get_progress = @context.get_progress
|