stablemates-workhorse 0.6.0 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 98c5582b19c14aaf69dd9b317684c9a5e49a5508caf5759b79894fbe78c54146
4
- data.tar.gz: 6f9a14b8dff61da2cadacc25f792c44a8b25281877b12bb47530a296b5a1e9e0
3
+ metadata.gz: e72b7d14542014971d81d818e073acaadbaef569c2516ba66427583e607fd0f3
4
+ data.tar.gz: 0e1d31974f65086473c750137249edd45722d75aa3b630e6c0a4c6c66bf7eb2e
5
5
  SHA512:
6
- metadata.gz: 98805ebb4ddc387026073a530960c1494ea7fbfac0a294829b1152b0f300dbb551d1ae4c944a87fdf698e6a0debd96ddf825e55444c8dffb9b584beb74053e05
7
- data.tar.gz: 81fef1e8bf1c9e5850b031aa5ac697a93b85ecbda2ae63687b60dfd6c2d821f08304b45e816dafadeea6105ef12820734e81086f3543bbfb5bb9bd7f857c9061
6
+ metadata.gz: 3de1cc0e31810df55903ec87b96e553edc96bfa2e7a699e6b926b669a995d6231ae03d5bd1ccfa62c8908d1ed6095e3a775998d092ac27b28fd92c8c9288cd20
7
+ data.tar.gz: 4683b2cebb636655c2c5c608d56059cedb5e6d837c0eb96735443aa78d2abad27371c3231737ae5be3d8cdfa504bf179a468f70cd15a038cf4516f0388e02459
data/CHANGELOG.md CHANGED
@@ -3,6 +3,187 @@
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
+
172
+ ## 0.6.1 — 2026-10-02
173
+
174
+ The npm packages, Python distribution, Go module, Rust crate, and Ruby gem release from one source commit.
175
+
176
+ Requires **schema v43**, Ruby **3.3** or newer, and PostgreSQL **15** or newer.
177
+
178
+ The gem has no changes in 0.6.1. It releases at 0.6.1 to keep one version across the
179
+ registries ([ADR 0050](../docs/decisions/0050-release-0-1-0-without-a-prerelease-suffix.md)).
180
+ The release exists for the Python distribution, whose 0.6.0 files on PyPI have no PEP 740
181
+ attestations.
182
+
183
+ **A 0.6.0 database needs no migration.** 0.6.1 adds no migration, so the final schema version is
184
+ **52** and the SDK compatibility floor stays at schema version **43**. An installation on 0.5
185
+ follows the [0.6.0 upgrade steps](../CHANGELOG.md#060--2026-10-02).
186
+
6
187
  ## 0.6.0 — 2026-10-02
7
188
 
8
189
  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. The one exception is
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.6.0 workhorse schema install
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
- this repository, so the numbers match. A schema tool older than the application leaves a schema the
37
- application refuses to start against.
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::CREATE_CHILD_V1, name, encoded)
219
- row = replayed(row, encoded, build) { |accepted| write(SqlCatalogue::CREATE_CHILD_V1, name, accepted) }
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
@@ -2,5 +2,5 @@
2
2
  "formatVersion": 1,
3
3
  "readSurfaceVersion": 1,
4
4
  "archive": "read-surface-1.tar.gz",
5
- "sha256": "67459299efcee8926d357badc947113529399453d08d59d4f240bf06f022f539"
5
+ "sha256": "38ca62438266d41ab61f9ea0c84b1264b8a2397896a1db75b22ca90c0b199fc0"
6
6
  }
@@ -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, :DEFAULT_PORTS,
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 = (authorization == true) ? Principal.new(@audit_actor || "dashboard") : authorization
126
- actor = @audit_actor || principal.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 Error => e
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) = super(operation, name, "conflicts with a different retained request")
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 = 43
14
- MAXIMUM_SCHEMA_VERSION = 52
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 workhorse.task_runtime runtime
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 workhorse.task_runtime runtime
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)
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Stablemates
4
4
  module Workhorse
5
- VERSION = "0.6.0"
5
+ VERSION = "0.7.1"
6
6
  end
7
7
  end
@@ -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, which makes it claimable again without
1186
- # charging the attempt. Returns the release status for the handler span.
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.6.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