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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e72b7d14542014971d81d818e073acaadbaef569c2516ba66427583e607fd0f3
4
- data.tar.gz: 0e1d31974f65086473c750137249edd45722d75aa3b630e6c0a4c6c66bf7eb2e
3
+ metadata.gz: 807d43f9b16d79430d292a3e6c70acdc55bb8cc3f2010b7e03f0356fb0731c90
4
+ data.tar.gz: f84ab68d2d6d03450983e74dc337880546815472ece79d8cdf1d650679d3912a
5
5
  SHA512:
6
- metadata.gz: 3de1cc0e31810df55903ec87b96e553edc96bfa2e7a699e6b926b669a995d6231ae03d5bd1ccfa62c8908d1ed6095e3a775998d092ac27b28fd92c8c9288cd20
7
- data.tar.gz: 4683b2cebb636655c2c5c608d56059cedb5e6d837c0eb96735443aa78d2abad27371c3231737ae5be3d8cdfa504bf179a468f70cd15a038cf4516f0388e02459
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.7.1 workhorse schema install
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) { |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
@@ -72,7 +72,11 @@ module Stablemates
72
72
 
73
73
  BulkRedrivePage = Data.define(:results, :next_cursor)
74
74
 
75
- TaskCheckpoint = Data.define(:task_id, :name, :value, :attempt, :fence_token, :worker_id, :created_at)
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 PostgreSQL deleted.
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
- def checkpoint(name, &) = @context.checkpoint(name, &)
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