stablemates-workhorse 0.6.1 → 0.7.1
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 +166 -0
- data/README.md +9 -9
- data/lib/stablemates/workhorse/context.rb +3 -3
- 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.rb +28 -7
- data/lib/stablemates/workhorse/errors.rb +8 -1
- data/lib/stablemates/workhorse/sql_catalogue_generated.rb +29 -4
- data/lib/stablemates/workhorse/telemetry.rb +5 -0
- data/lib/stablemates/workhorse/version.rb +1 -1
- data/lib/stablemates/workhorse/worker.rb +30 -4
- metadata +21 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e72b7d14542014971d81d818e073acaadbaef569c2516ba66427583e607fd0f3
|
|
4
|
+
data.tar.gz: 0e1d31974f65086473c750137249edd45722d75aa3b630e6c0a4c6c66bf7eb2e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3de1cc0e31810df55903ec87b96e553edc96bfa2e7a699e6b926b669a995d6231ae03d5bd1ccfa62c8908d1ed6095e3a775998d092ac27b28fd92c8c9288cd20
|
|
7
|
+
data.tar.gz: 4683b2cebb636655c2c5c608d56059cedb5e6d837c0eb96735443aa78d2abad27371c3231737ae5be3d8cdfa504bf179a468f70cd15a038cf4516f0388e02459
|
data/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,172 @@
|
|
|
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.7.1 — 2026-10-09
|
|
7
|
+
|
|
8
|
+
The npm packages, Python distribution, Go module, Rust crate, and Ruby gem release from one source commit.
|
|
9
|
+
|
|
10
|
+
Requires **schema v54**, Ruby **3.3** or newer, and PostgreSQL **15** or newer.
|
|
11
|
+
|
|
12
|
+
The gem has no changes in 0.7.1. It releases at 0.7.1 to keep one version across the
|
|
13
|
+
registries ([ADR 0050](../docs/decisions/0050-release-0-1-0-without-a-prerelease-suffix.md)).
|
|
14
|
+
The release exists because the npm publish of 0.7.0 stopped partway.
|
|
15
|
+
The gem never published at 0.7.0, so 0.7.1 is the first release with the 0.7.0 changes below.
|
|
16
|
+
|
|
17
|
+
**A 0.7.0 database needs no migration.** 0.7.1 adds no migration, so the final schema version is
|
|
18
|
+
**64** and the SDK compatibility floor stays at schema version **54**. An installation on 0.6
|
|
19
|
+
follows the [0.7.0 upgrade steps](https://github.com/stablemates/workhorse/blob/main/CHANGELOG.md#070--2026-10-09).
|
|
20
|
+
|
|
21
|
+
## 0.7.0 — 2026-10-09
|
|
22
|
+
|
|
23
|
+
The npm packages, Python distribution, Go module, Rust crate, and Ruby gem release from one source commit.
|
|
24
|
+
|
|
25
|
+
Requires **schema v54**, Ruby **3.3** or newer, and PostgreSQL **15** or newer.
|
|
26
|
+
|
|
27
|
+
### Upgrade steps
|
|
28
|
+
|
|
29
|
+
**Migrate the schema before starting updated processes.** The upgrade is a rolling deployment: run `workhorse schema migrate`, then roll out the 0.7
|
|
30
|
+
processes. The final schema version is **64**. The SDK compatibility floor is schema version
|
|
31
|
+
**54**, which migration 0055 reaches. A 0.7 process refuses a schema below version 54 at startup,
|
|
32
|
+
and a 0.6 process keeps working on version 64. Each migration commits on its own, and each SQL
|
|
33
|
+
change takes effect when its migration commits.
|
|
34
|
+
|
|
35
|
+
**Migration 0059 clears incomplete rate settings.** A per-key rate limit or a budget rate with one
|
|
36
|
+
or two of its three fields null used to pass its CHECK constraint. The migration clears such a
|
|
37
|
+
setting, and deletes a budget that the clearing leaves without a limit. After the migration,
|
|
38
|
+
PostgreSQL rejects a synchronization that writes an incomplete rate setting. To keep a limit the
|
|
39
|
+
migration cleared, complete it in your synchronized definitions and synchronize after the
|
|
40
|
+
migration.
|
|
41
|
+
|
|
42
|
+
**Accept the new `terminal-cleanup-backlog` health reason.** Queue health can now report it as
|
|
43
|
+
degraded, so code that switches on reason codes should accept it. Its budget,
|
|
44
|
+
`terminal_cleanup_backlog_ms`, starts from your `row_retention_lag_ms` setting, so existing alerts
|
|
45
|
+
evaluate as before. After migration 0065 the two budgets are independent.
|
|
46
|
+
|
|
47
|
+
**Update `dashboard/v1` clients that read `humanWait.context` from a task listing.**
|
|
48
|
+
`dashboard.tasks` and `dashboard.tasksCursor` rows carry `humanWait.quickAction` instead, which is
|
|
49
|
+
`{ label }` or `null`. `dashboard.taskDetail` still returns the full context (SM-1171).
|
|
50
|
+
|
|
51
|
+
**Handler authors: a durable replay conflict now fails its task on the first occurrence.** The
|
|
52
|
+
behaviour changes below describe it. A 0.6 worker still retries a conflict under the task's retry
|
|
53
|
+
policy until the 0.6 processes are gone.
|
|
54
|
+
|
|
55
|
+
### Breaking changes
|
|
56
|
+
|
|
57
|
+
- **`dashboard/v1`:** task listing rows carry `humanWait.quickAction` instead of
|
|
58
|
+
`humanWait.context`, as the upgrade steps describe. The break is taken in place under the ADR 0064
|
|
59
|
+
exception in [`docs/compatibility.md`](../docs/compatibility.md) (SM-1171).
|
|
60
|
+
|
|
61
|
+
### Behaviour changes
|
|
62
|
+
|
|
63
|
+
Durable execution and workers:
|
|
64
|
+
|
|
65
|
+
- Durable checkpoint, timer, child, child-set, and human-decision replay conflicts fail the task on
|
|
66
|
+
their first occurrence. The worker preserves the current attempt and records the conflict class.
|
|
67
|
+
Before, a conflict retried under the task's retry policy, and every retry met the same conflict.
|
|
68
|
+
Redaction still hides error details. Transient failures, lease loss, child-limit errors, and
|
|
69
|
+
already-waiting signal errors keep their existing behaviour. Conflict settlement bypasses the
|
|
70
|
+
worker's retry-delay callback (SM-1107, migration 0055).
|
|
71
|
+
- A renamed individual child on replay raises `ConflictError` with the stored and requested names,
|
|
72
|
+
and `ConflictError.new` takes a `stored_name:` keyword. Before, it raised a child-limit error. A
|
|
73
|
+
second child after joining the retained child in the same handler run still exceeds the child
|
|
74
|
+
limit (SM-1106, migration 0054).
|
|
75
|
+
- A handler that raises the `CancelledError` of its `:shutdown` cancellation no longer charges an
|
|
76
|
+
attempt. The worker hands its task to `release_owned_v1`, as it does for a task without a handler.
|
|
77
|
+
Before, `CancelledError(:shutdown)` reached `fail_v1`, so a task on its last attempt failed
|
|
78
|
+
because its worker stopped (SM-1164).
|
|
79
|
+
- The `workhorse.handler` span descends only from the task's stored trace context. A task without
|
|
80
|
+
one starts a new trace instead of joining a span that a context-propagating executor carries into
|
|
81
|
+
the handler thread (SM-1179).
|
|
82
|
+
|
|
83
|
+
Dashboard host:
|
|
84
|
+
|
|
85
|
+
- `Dashboard.new(audit_actor: ...)` no longer replaces the actor of a `Principal` that `authorize`
|
|
86
|
+
returns. The dashboard records the principal's actor, as the `dashboard/v1` protocol requires.
|
|
87
|
+
`audit_actor` still names the actor when `authorize` returns `true`, and defaults to `dashboard`
|
|
88
|
+
there (SM-1152).
|
|
89
|
+
- `Dashboard` refuses an RPC body over 2 MiB, or a malformed declared length, with `413` before
|
|
90
|
+
parsing. An unexpected schema-compatibility failure, such as a connection error, answers a generic
|
|
91
|
+
`503` and is written to `rack.errors` instead of escaping the Rack app (SM-1168).
|
|
92
|
+
|
|
93
|
+
Maintenance, health, and administration, from the SQL changes:
|
|
94
|
+
|
|
95
|
+
- Terminal cleanup repeats its batch while each one fills, for up to one second per pass. A pass
|
|
96
|
+
that still ends with a full batch makes its follow-up due five seconds later. Full-tier and
|
|
97
|
+
fast-tier tasks share every batch (SM-1160, migration 0058).
|
|
98
|
+
- `tick_v1` runs the expired-lease scan only when no tick ran it within half the shortest
|
|
99
|
+
maintenance interval of the live registered workers. A worker that opts out of the registry with
|
|
100
|
+
`disable_registry: true` does not shorten that spacing. Recovery of an expired lease can then take
|
|
101
|
+
longer (SM-1167, SM-1192, migration 0061).
|
|
102
|
+
- Row retention lag counts from the scheduled history pass that released the row, so health no
|
|
103
|
+
longer reports task records and finished results as late after every daily pass (SM-1134,
|
|
104
|
+
migration 0056).
|
|
105
|
+
- Queue health raises `terminal-cleanup-backlog` once `terminal_cleanup_backlog_since` is older than
|
|
106
|
+
its budget (SM-1178, SM-1197, migrations 0063 and 0065).
|
|
107
|
+
- `run_task_now_v1` reports `not_scheduled` for a blocked task instead of raising an error. A rate
|
|
108
|
+
synchronization refills each changed bucket at the old rate up to one clock reading. An update
|
|
109
|
+
can no longer change a dependency edge's endpoints or outcome policies (SM-1165, migration 0059).
|
|
110
|
+
- A full-tier task that a worker without a handler released can be canceled, and deadline recovery
|
|
111
|
+
terminalizes it. Both close it like never-started work (SM-1158, migration 0057).
|
|
112
|
+
|
|
113
|
+
Dashboard, from the shared browser bundle:
|
|
114
|
+
|
|
115
|
+
- Pausing a schedule asks for confirmation, as resuming one already did (SM-1147).
|
|
116
|
+
- The Events pager stops at the last page the server accepts and offers older events through a
|
|
117
|
+
custom range. The activity chart shows loading, error, and stale states with a Retry button
|
|
118
|
+
(SM-1171).
|
|
119
|
+
- The Schedules page says how many schedules past the first 50 it does not show, and the System
|
|
120
|
+
page lists the terminal cleanup backlog check (SM-1171, SM-1178).
|
|
121
|
+
|
|
122
|
+
### Migrations
|
|
123
|
+
|
|
124
|
+
Migration 0054 is the first since 0.6.1. The
|
|
125
|
+
[root changelog](https://github.com/stablemates/workhorse/blob/main/CHANGELOG.md) describes each in
|
|
126
|
+
full.
|
|
127
|
+
|
|
128
|
+
- **0054** adds versioned child functions and the nullable `task_child.last_seen_fence_token`
|
|
129
|
+
fence marker. Older clients keep their v1 functions (SM-1106).
|
|
130
|
+
- **0055** reserves a retry delay of -1 in `fail_v1` and `fast_fail_v1` as the terminal failure
|
|
131
|
+
override. It is the SDK compatibility floor, schema version 54 (SM-1107).
|
|
132
|
+
- **0056** changes `queue_health_v1` to count row retention lag from the releasing history pass
|
|
133
|
+
(SM-1134).
|
|
134
|
+
- **0057** changes `cancel_v1` and `terminalize_deadline_v1` to close a released task like
|
|
135
|
+
never-started work (SM-1158).
|
|
136
|
+
- **0058** lets terminal cleanup keep pace and share its batches across tiers, and adds two
|
|
137
|
+
`maintenance_state` columns (SM-1160).
|
|
138
|
+
- **0059** closes five SQL integrity gaps. It adds `rate_limit_policy_per_key_complete_check`,
|
|
139
|
+
`budget_rate_complete_check`, and a trigger that rejects a dependency edge update (SM-1165).
|
|
140
|
+
- **0060** judges `fast_complete_many_v1` and `fast_acknowledge_cancel_v1` at the time they act
|
|
141
|
+
(SM-1163).
|
|
142
|
+
- **0061** adds `fast_task_outcome_failed_finished_idx`, bounds `aggregate_stats_v1`, and spaces the
|
|
143
|
+
expired-lease scan in `tick_v1` with the new `maintenance_state.lease_recovery_started_at` column
|
|
144
|
+
(SM-1167).
|
|
145
|
+
- **0062** adds `dashboard_human_wait_quick_action_v1` and bounds four dashboard read functions
|
|
146
|
+
(SM-1171).
|
|
147
|
+
- **0063** raises the `terminal-cleanup-backlog` health reason and adds
|
|
148
|
+
`terminal_cleanup_follow_up_delay_ms_v1` (SM-1178).
|
|
149
|
+
- **0064** disables JIT for `aggregate_stats_v1` (SM-1193).
|
|
150
|
+
- **0065** gives `terminal-cleanup-backlog` its own budget in `queue_health_policy`, adds
|
|
151
|
+
`sync_queue_health_policy_v2`, and keeps `sync_queue_health_policy_v1` (SM-1197).
|
|
152
|
+
|
|
153
|
+
### Added
|
|
154
|
+
|
|
155
|
+
- `Queue#health` reports the backlog budget as `budgets.terminalCleanupBacklogMs` with schema 64,
|
|
156
|
+
and `status.reasons` can carry `terminal-cleanup-backlog` (SM-1178, SM-1197).
|
|
157
|
+
|
|
158
|
+
### Dependencies
|
|
159
|
+
|
|
160
|
+
- The gemspec declares `logger` at least 1.6 and below 2. Ruby 4.0 no longer ships `logger` as a
|
|
161
|
+
default gem. On Ruby 4.0, 0.6.1 raises `LoadError` at `require "stablemates/workhorse"` unless
|
|
162
|
+
the application already installs `logger` (SM-1138).
|
|
163
|
+
- The dashboard requires `cgi/escape` instead of `cgi`, which Ruby 4.0 reduces to a stub that warns
|
|
164
|
+
(SM-1138).
|
|
165
|
+
|
|
166
|
+
### Recipes
|
|
167
|
+
|
|
168
|
+
- A new tested recipe enqueues through Sequel on its native `pg` connection (SM-1114).
|
|
169
|
+
- The quick start and the agent playbook print their results as JSON (SM-1139), and a batch handler
|
|
170
|
+
example shows how batch-handler tasks are enqueued (SM-1135).
|
|
171
|
+
|
|
6
172
|
## 0.6.1 — 2026-10-02
|
|
7
173
|
|
|
8
174
|
The npm packages, Python distribution, Go module, Rust crate, and Ruby gem release from one source commit.
|
data/README.md
CHANGED
|
@@ -7,10 +7,7 @@ PostgreSQL.
|
|
|
7
7
|
> release may change behaviour, so read the
|
|
8
8
|
> [changelog](https://github.com/stablemates/workhorse/blob/main/ruby/CHANGELOG.md) before you
|
|
9
9
|
> upgrade. It will not ask you to recreate your database: migrations are ordered, and inside a major
|
|
10
|
-
> line a migration only adds, so a running deployment upgrades in place.
|
|
11
|
-
> migration 0025: a database from before 0.5.0 crosses it offline, with the
|
|
12
|
-
> [0.5.0 upgrade steps](https://github.com/stablemates/workhorse/blob/main/CHANGELOG.md#050--2026-09-28).
|
|
13
|
-
> The upgrade from 0.5 to 0.6 only adds.
|
|
10
|
+
> line a migration only adds, so a running deployment upgrades in place.
|
|
14
11
|
|
|
15
12
|
An AI agent should read [the Workhorse documentation index](https://workhorse.run/llms.txt) first.
|
|
16
13
|
|
|
@@ -26,15 +23,17 @@ bundle add connection_pool
|
|
|
26
23
|
Install the schema once, as a deployment step. The application never installs or migrates it.
|
|
27
24
|
|
|
28
25
|
```bash
|
|
29
|
-
npx --package @stablemates/workhorse@0.
|
|
26
|
+
npx --package @stablemates/workhorse@0.7.1 workhorse schema install
|
|
30
27
|
```
|
|
31
28
|
|
|
32
29
|
The machine that runs that deployment step needs Node.js 22 or newer. The application itself needs
|
|
33
30
|
no Node.js.
|
|
34
31
|
|
|
35
|
-
Pin that version to the gem version the application depends on. The two come from one commit of
|
|
36
|
-
|
|
37
|
-
|
|
32
|
+
Pin that version to the gem version the application depends on. The two come from one commit of this
|
|
33
|
+
repository, so the numbers match. The application refuses a schema below its schema floor, the
|
|
34
|
+
oldest schema version it runs against. A schema at or above the floor is compatible while it still
|
|
35
|
+
serves the application's protocol. A schema tool from an older release can leave a schema below that
|
|
36
|
+
floor.
|
|
38
37
|
|
|
39
38
|
Runtime processes verify compatibility instead of changing the schema. Call
|
|
40
39
|
`Queue#assert_compatible` or `Admin#assert_compatible` at startup. A refusal is
|
|
@@ -46,6 +45,7 @@ Requires Ruby 3.3 or newer and PostgreSQL 15 through 18.
|
|
|
46
45
|
|
|
47
46
|
```ruby
|
|
48
47
|
require "connection_pool"
|
|
48
|
+
require "json"
|
|
49
49
|
require "pg"
|
|
50
50
|
require "stablemates/workhorse"
|
|
51
51
|
|
|
@@ -62,7 +62,7 @@ end
|
|
|
62
62
|
worker.run_once # production uses Stablemates::Workhorse.run_worker_process(worker)
|
|
63
63
|
|
|
64
64
|
task = Stablemates::Workhorse::Admin.new(pool).get_task(enqueued.task_id)
|
|
65
|
-
puts "#{task.state} #{task.result}" if task
|
|
65
|
+
puts "#{task.state} #{JSON.generate(task.result)}" if task
|
|
66
66
|
```
|
|
67
67
|
|
|
68
68
|
Handlers receive at-least-once delivery. Use stable provider idempotency keys around external
|
|
@@ -215,12 +215,12 @@ module Stablemates
|
|
|
215
215
|
encoded = initial(build)
|
|
216
216
|
once(:run_child, name, encoded) do
|
|
217
217
|
live!(:run_child)
|
|
218
|
-
row = write(SqlCatalogue::
|
|
219
|
-
row = replayed(row, encoded, build) { |accepted| write(SqlCatalogue::
|
|
218
|
+
row = write(SqlCatalogue::CREATE_CHILD_V2, name, encoded)
|
|
219
|
+
row = replayed(row, encoded, build) { |accepted| write(SqlCatalogue::CREATE_CHILD_V2, name, accepted) }
|
|
220
220
|
status = row.fetch("status")
|
|
221
221
|
case status
|
|
222
222
|
when "stale" then raise LeaseLostError.new(@task.id, :run_child)
|
|
223
|
-
when "conflict" then raise ConflictError.new(:run_child, name)
|
|
223
|
+
when "conflict" then raise ConflictError.new(:run_child, name, stored_name: row["stored_child_name"])
|
|
224
224
|
when "limit_exceeded" then raise LimitExceededError.new(:run_child, name)
|
|
225
225
|
when "created", "completed"
|
|
226
226
|
processed(status == "created" && :suspended_for_child) do
|
|
Binary file
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "cgi"
|
|
3
|
+
require "cgi/escape"
|
|
4
4
|
require "json"
|
|
5
5
|
require "rubygems/package"
|
|
6
6
|
require "stringio"
|
|
@@ -62,6 +62,10 @@ module Stablemates
|
|
|
62
62
|
".woff2" => "font/woff2"
|
|
63
63
|
}.freeze
|
|
64
64
|
JSON_TYPE = "application/json; charset=utf-8"
|
|
65
|
+
# The largest RPC request body the dashboard reads, the same bound the Go and Rust hosts apply.
|
|
66
|
+
MAX_REQUEST_BYTES = 2 << 20
|
|
67
|
+
UNVERIFIED_COMPATIBILITY =
|
|
68
|
+
"Unable to verify Workhorse schema compatibility because the database query failed."
|
|
65
69
|
DEFAULT_PORTS = {"http" => ":80", "https" => ":443"}.freeze
|
|
66
70
|
# Characters escaped in the runtime configuration so it cannot close its inline script.
|
|
67
71
|
SCRIPT_ESCAPES = {"<" => "\\u003c", ">" => "\\u003e", "&" => "\\u0026",
|
|
@@ -74,8 +78,8 @@ module Stablemates
|
|
|
74
78
|
"issues" => [{"code" => "custom", "path" => ["feature"],
|
|
75
79
|
"message" => "The feature demo kind requires a feature family"}]
|
|
76
80
|
}.freeze
|
|
77
|
-
private_constant :MUTATIONS, :OPTIONAL_MUTATIONS, :CONTENT_TYPES, :JSON_TYPE, :
|
|
78
|
-
:SCRIPT_ESCAPES, :TOO_SMALL_PAGE, :MISSING_FEATURE
|
|
81
|
+
private_constant :MUTATIONS, :OPTIONAL_MUTATIONS, :CONTENT_TYPES, :JSON_TYPE, :MAX_REQUEST_BYTES,
|
|
82
|
+
:UNVERIFIED_COMPATIBILITY, :DEFAULT_PORTS, :SCRIPT_ESCAPES, :TOO_SMALL_PAGE, :MISSING_FEATURE
|
|
79
83
|
|
|
80
84
|
attr_reader :base_path
|
|
81
85
|
|
|
@@ -122,12 +126,16 @@ module Stablemates
|
|
|
122
126
|
return authorization if authorization.is_a?(Array)
|
|
123
127
|
return json(401, {"error" => "Unauthorized"}) if authorization == false
|
|
124
128
|
|
|
125
|
-
principal
|
|
126
|
-
actor = @audit_actor ||
|
|
129
|
+
# A verified principal names the actor; the audit actor stands in only for a bare true.
|
|
130
|
+
actor = (authorization == true) ? (@audit_actor || "dashboard") : authorization.actor
|
|
127
131
|
begin
|
|
128
132
|
assert_compatible
|
|
129
|
-
rescue
|
|
133
|
+
rescue CompatibilityError => e
|
|
130
134
|
return json(503, {"error" => e.message})
|
|
135
|
+
rescue => e
|
|
136
|
+
# A driver error can name the database host, user, or path, so it stays in the log.
|
|
137
|
+
env["rack.errors"]&.puts("workhorse dashboard: schema compatibility check failed: #{e.class}: #{e.message}")
|
|
138
|
+
return json(503, {"error" => UNVERIFIED_COMPATIBILITY})
|
|
131
139
|
end
|
|
132
140
|
|
|
133
141
|
# A mounting stack may hand over the mount point itself as SCRIPT_NAME plus a PATH_INFO of "/".
|
|
@@ -201,9 +209,11 @@ module Stablemates
|
|
|
201
209
|
return rpc_error(404, "NOT_FOUND", "Procedure not found")
|
|
202
210
|
end
|
|
203
211
|
|
|
212
|
+
body = read_body(env)
|
|
213
|
+
return rpc_error(413, "PAYLOAD_TOO_LARGE", "Payload Too Large") if body.nil?
|
|
214
|
+
|
|
204
215
|
input = nil
|
|
205
216
|
begin
|
|
206
|
-
body = env["rack.input"]&.read.to_s
|
|
207
217
|
envelope = JSON.parse(body.empty? ? "{}" : body)
|
|
208
218
|
raise InputValidationError, "request envelope must be an object" unless envelope.is_a?(Hash)
|
|
209
219
|
|
|
@@ -231,6 +241,17 @@ module Stablemates
|
|
|
231
241
|
json(200, result.nil? ? {} : {"json" => result})
|
|
232
242
|
end
|
|
233
243
|
|
|
244
|
+
# At most MAX_REQUEST_BYTES of the request body, or nil for a body that is larger.
|
|
245
|
+
def read_body(env)
|
|
246
|
+
declared = env["CONTENT_LENGTH"].to_s
|
|
247
|
+
unless declared.empty?
|
|
248
|
+
return nil unless declared.match?(/\A\d+\z/) && declared.to_i <= MAX_REQUEST_BYTES
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
body = env["rack.input"]&.read(MAX_REQUEST_BYTES + 1).to_s
|
|
252
|
+
(body.bytesize > MAX_REQUEST_BYTES) ? nil : body
|
|
253
|
+
end
|
|
254
|
+
|
|
234
255
|
def same_origin?(env)
|
|
235
256
|
origin = env["HTTP_ORIGIN"]
|
|
236
257
|
return false unless origin.is_a?(String)
|
|
@@ -41,7 +41,14 @@ module Stablemates
|
|
|
41
41
|
|
|
42
42
|
# A named primitive received a different request under its retained identity.
|
|
43
43
|
class ConflictError < NamedOperationError
|
|
44
|
-
def initialize(operation, name
|
|
44
|
+
def initialize(operation, name, stored_name: nil)
|
|
45
|
+
message = if stored_name && stored_name != name
|
|
46
|
+
"conflicts: stored child #{stored_name.inspect}, requested child #{name.inspect}"
|
|
47
|
+
else
|
|
48
|
+
"conflicts with a different retained request"
|
|
49
|
+
end
|
|
50
|
+
super(operation, name, message)
|
|
51
|
+
end
|
|
45
52
|
end
|
|
46
53
|
|
|
47
54
|
# A named primitive exceeded its limit.
|
|
@@ -10,8 +10,8 @@ module Stablemates
|
|
|
10
10
|
CLIENT_PROTOCOL_VERSION = 5
|
|
11
11
|
MINIMUM_PROTOCOL_VERSION = 5
|
|
12
12
|
MAXIMUM_PROTOCOL_VERSION = 5
|
|
13
|
-
MINIMUM_SCHEMA_VERSION =
|
|
14
|
-
MAXIMUM_SCHEMA_VERSION =
|
|
13
|
+
MINIMUM_SCHEMA_VERSION = 54
|
|
14
|
+
MAXIMUM_SCHEMA_VERSION = 64
|
|
15
15
|
# PostgreSQL's atomic enqueue batch limit.
|
|
16
16
|
MAX_ENQUEUE_BATCH_SIZE = 1000
|
|
17
17
|
DEFAULT_TASK_VALUE_MAX_BYTES = 1048576
|
|
@@ -40,6 +40,9 @@ module Stablemates
|
|
|
40
40
|
# `create_child_v1` (protocol)
|
|
41
41
|
CREATE_CHILD_V1 = 'SELECT status, child_task_id, child_type, created_at, joined_at, result FROM workhorse.create_child_v1($1::uuid, $2::text, $3::bigint, $4::text, $5::jsonb)'
|
|
42
42
|
|
|
43
|
+
# `create_child_v2` (protocol)
|
|
44
|
+
CREATE_CHILD_V2 = 'SELECT status, child_task_id, child_type, created_at, joined_at, result, stored_child_name FROM workhorse.create_child_v2($1::uuid, $2::text, $3::bigint, $4::text, $5::jsonb)'
|
|
45
|
+
|
|
43
46
|
# `create_children_v1` (protocol)
|
|
44
47
|
CREATE_CHILDREN_V1 = 'SELECT status, children, results, result_bytes, result_limit_bytes FROM workhorse.create_children_v1($1::uuid, $2::text, $3::bigint, $4::jsonb, $5::text)'
|
|
45
48
|
|
|
@@ -867,6 +870,8 @@ module Stablemates
|
|
|
867
870
|
WITH queue_names AS (
|
|
868
871
|
SELECT queue_name FROM workhorse.task_runtime
|
|
869
872
|
UNION
|
|
873
|
+
SELECT queue_name FROM workhorse.fast_task_runtime
|
|
874
|
+
UNION
|
|
870
875
|
SELECT queue_name FROM workhorse.queue_control
|
|
871
876
|
), depth AS (
|
|
872
877
|
SELECT names.queue_name,
|
|
@@ -886,7 +891,17 @@ module Stablemates
|
|
|
886
891
|
WHERE runtime.state = \'active\' AND runtime.attempt_timeout_at <= clock_timestamp()
|
|
887
892
|
)::text AS overdue_execution_timeouts
|
|
888
893
|
FROM queue_names names
|
|
889
|
-
LEFT JOIN
|
|
894
|
+
LEFT JOIN (
|
|
895
|
+
SELECT task_id, queue_name, state, ready_at, expires_at, deadline_at, attempt_timeout_at
|
|
896
|
+
FROM workhorse.task_runtime
|
|
897
|
+
UNION ALL
|
|
898
|
+
SELECT task_id, queue_name,
|
|
899
|
+
CASE WHEN state = \'ready\' AND run_at > clock_timestamp() THEN \'scheduled\'
|
|
900
|
+
ELSE state END,
|
|
901
|
+
CASE WHEN state = \'ready\' THEN run_at END, expires_at, deadline_at,
|
|
902
|
+
attempt_timeout_at
|
|
903
|
+
FROM workhorse.fast_task_runtime
|
|
904
|
+
) runtime
|
|
890
905
|
ON runtime.queue_name = names.queue_name
|
|
891
906
|
GROUP BY names.queue_name
|
|
892
907
|
)
|
|
@@ -956,6 +971,7 @@ module Stablemates
|
|
|
956
971
|
QUEUE_METRIC_SNAPSHOT = 'WITH queue_names AS (
|
|
957
972
|
SELECT $1::text AS queue_name
|
|
958
973
|
UNION SELECT queue_name FROM workhorse.task_runtime
|
|
974
|
+
UNION SELECT queue_name FROM workhorse.fast_task_runtime
|
|
959
975
|
UNION SELECT queue_name FROM workhorse.queue_control
|
|
960
976
|
UNION SELECT queue_name FROM workhorse.concurrency_policy
|
|
961
977
|
UNION SELECT queue_name FROM workhorse.rate_limit_policy
|
|
@@ -977,7 +993,16 @@ module Stablemates
|
|
|
977
993
|
WHERE runtime.state = \'ready\'
|
|
978
994
|
)) * 1000 AS oldest_ready_age_ms
|
|
979
995
|
FROM queue_names names
|
|
980
|
-
LEFT JOIN
|
|
996
|
+
LEFT JOIN (
|
|
997
|
+
SELECT task_id, queue_name, state, ready_at, expires_at
|
|
998
|
+
FROM workhorse.task_runtime
|
|
999
|
+
UNION ALL
|
|
1000
|
+
SELECT task_id, queue_name,
|
|
1001
|
+
CASE WHEN state = \'ready\' AND run_at > clock_timestamp() THEN \'scheduled\'
|
|
1002
|
+
ELSE state END,
|
|
1003
|
+
CASE WHEN state = \'ready\' THEN run_at END, expires_at
|
|
1004
|
+
FROM workhorse.fast_task_runtime
|
|
1005
|
+
) runtime
|
|
981
1006
|
ON runtime.queue_name = names.queue_name
|
|
982
1007
|
GROUP BY names.queue_name
|
|
983
1008
|
)
|
|
@@ -63,9 +63,14 @@ module Stablemates
|
|
|
63
63
|
|
|
64
64
|
# Yields a span, or nil without OpenTelemetry. A +trace_context+ carrier takes precedence
|
|
65
65
|
# over +parent+. The span records no exception by itself; +record_error+ marks a failure.
|
|
66
|
+
#
|
|
67
|
+
# A consumer span runs a claimed task, which descends only from the trace its enqueue stored.
|
|
68
|
+
# A span current where the worker runs is unrelated to the task, so a consumer span starts
|
|
69
|
+
# from an empty context, and a task without a stored context starts a new trace.
|
|
66
70
|
def span(name, attributes, trace_context: nil, parent: nil, consumer: false)
|
|
67
71
|
return yield(nil) unless tracing?
|
|
68
72
|
|
|
73
|
+
parent = ::OpenTelemetry::Context.empty if consumer
|
|
69
74
|
if trace_context.is_a?(Hash)
|
|
70
75
|
carrier = trace_context.each_with_object({}) do |(key, value), result|
|
|
71
76
|
result[key.downcase] = value if key.is_a?(String) && value.is_a?(String)
|
|
@@ -12,7 +12,8 @@ module Stablemates
|
|
|
12
12
|
# Durations are Numeric seconds. +run+ blocks until +stop+; +run_once+ claims and runs one
|
|
13
13
|
# batch. A stopping worker claims nothing new, waits up to +shutdown_grace+ for running
|
|
14
14
|
# handlers, cancels the rest with +:shutdown+, and raises ShutdownIncompleteError when any
|
|
15
|
-
# handler still runs after one short unwind window.
|
|
15
|
+
# handler still runs after one short unwind window. A handler that raises the CancelledError of
|
|
16
|
+
# that cancellation, as +check!+ does, returns its task to the queue without charging the attempt.
|
|
16
17
|
class Worker
|
|
17
18
|
MAX_EMPTY_POLL_MS = 5_000
|
|
18
19
|
NOTIFICATION_POLL_MS = 5_000
|
|
@@ -1182,10 +1183,16 @@ module Stablemates
|
|
|
1182
1183
|
attributes.merge("workhorse.handler.outcome" => outcome))
|
|
1183
1184
|
end
|
|
1184
1185
|
|
|
1185
|
-
# Hands a task with no handler back to PostgreSQL
|
|
1186
|
-
#
|
|
1186
|
+
# Hands a task with no handler back to PostgreSQL. Returns the release status for the handler
|
|
1187
|
+
# span.
|
|
1187
1188
|
def release(task, arbiter, attributes)
|
|
1188
1189
|
log(:warn, "workhorse.handler.missing", "No handler registered for the claimed task type", attributes)
|
|
1190
|
+
release_owned(task, arbiter, attributes)
|
|
1191
|
+
end
|
|
1192
|
+
|
|
1193
|
+
# Hands an owned task back to PostgreSQL, which makes it claimable again without charging the
|
|
1194
|
+
# attempt. Returns the release status.
|
|
1195
|
+
def release_owned(task, arbiter, attributes)
|
|
1189
1196
|
status = fenced_row(SqlCatalogue::RELEASE_OWNED_V1, task)["status"]
|
|
1190
1197
|
log(:info, "workhorse.task.release_processed", "Owned task release processed",
|
|
1191
1198
|
attributes.merge("workhorse.release.status" => status))
|
|
@@ -1217,6 +1224,12 @@ module Stablemates
|
|
|
1217
1224
|
rescue => e
|
|
1218
1225
|
return if finish_suspended(task, ownership, arbiter)
|
|
1219
1226
|
|
|
1227
|
+
# A handler that stopped for shutdown returns the task to the queue without an attempt.
|
|
1228
|
+
if cancellation.reason == :shutdown && shutdown_cancellation?(e)
|
|
1229
|
+
state[:span_outcome] = release_owned(task, arbiter, Telemetry.task_span_attributes(task))
|
|
1230
|
+
return
|
|
1231
|
+
end
|
|
1232
|
+
|
|
1220
1233
|
state[:span_outcome] = settle_failure(task, e, arbiter)
|
|
1221
1234
|
state[:errors] << (task.redact_error_details ? REDACTED_NAME : e.class.name)
|
|
1222
1235
|
return
|
|
@@ -1453,7 +1466,7 @@ module Stablemates
|
|
|
1453
1466
|
def settle_failure(task, error, arbiter)
|
|
1454
1467
|
envelope = error_envelope(error, task.redact_error_details)
|
|
1455
1468
|
state = Telemetry.span("workhorse.retry", Telemetry.task_span_attributes(task)) do |span|
|
|
1456
|
-
delay = retry_delay_override(task)
|
|
1469
|
+
delay = error.is_a?(ConflictError) ? -1 : retry_delay_override(task)
|
|
1457
1470
|
state = fenced_row(SqlCatalogue::FAIL_V1, task, JSON.generate(envelope), delay&.to_s)["state"]
|
|
1458
1471
|
Telemetry.set_attribute(span, "workhorse.retry.outcome", state.to_s)
|
|
1459
1472
|
state
|
|
@@ -1472,6 +1485,19 @@ module Stablemates
|
|
|
1472
1485
|
state
|
|
1473
1486
|
end
|
|
1474
1487
|
|
|
1488
|
+
# Whether +error+, or an error it wraps, is a +:shutdown+ CancelledError. With the token's own
|
|
1489
|
+
# reason, it proves the handler saw the stop, so a failure of its own that races the grace
|
|
1490
|
+
# still charges its attempt. The walk stays bounded against a cyclic cause chain.
|
|
1491
|
+
def shutdown_cancellation?(error)
|
|
1492
|
+
16.times do
|
|
1493
|
+
return false if error.nil?
|
|
1494
|
+
return true if error.is_a?(CancelledError) && error.reason == :shutdown
|
|
1495
|
+
|
|
1496
|
+
error = error.cause
|
|
1497
|
+
end
|
|
1498
|
+
false
|
|
1499
|
+
end
|
|
1500
|
+
|
|
1475
1501
|
def retry_delay_override(task)
|
|
1476
1502
|
delay = @retry_delay.respond_to?(:call) ? @retry_delay.call(task.attempt, task) : @retry_delay
|
|
1477
1503
|
return nil if delay.nil?
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: stablemates-workhorse
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.7.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Stablemates
|
|
@@ -49,6 +49,26 @@ dependencies:
|
|
|
49
49
|
- - "<"
|
|
50
50
|
- !ruby/object:Gem::Version
|
|
51
51
|
version: '4'
|
|
52
|
+
- !ruby/object:Gem::Dependency
|
|
53
|
+
name: logger
|
|
54
|
+
requirement: !ruby/object:Gem::Requirement
|
|
55
|
+
requirements:
|
|
56
|
+
- - ">="
|
|
57
|
+
- !ruby/object:Gem::Version
|
|
58
|
+
version: '1.6'
|
|
59
|
+
- - "<"
|
|
60
|
+
- !ruby/object:Gem::Version
|
|
61
|
+
version: '2'
|
|
62
|
+
type: :runtime
|
|
63
|
+
prerelease: false
|
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
65
|
+
requirements:
|
|
66
|
+
- - ">="
|
|
67
|
+
- !ruby/object:Gem::Version
|
|
68
|
+
version: '1.6'
|
|
69
|
+
- - "<"
|
|
70
|
+
- !ruby/object:Gem::Version
|
|
71
|
+
version: '2'
|
|
52
72
|
- !ruby/object:Gem::Dependency
|
|
53
73
|
name: pg
|
|
54
74
|
requirement: !ruby/object:Gem::Requirement
|