#4975·hatchet

[BUG] Manual slot release succeeds but leaves worker capacity occupied

Author: ScalahansoloCreated Sep 17, 2026Updated Sep 20, 2026

Describe the issue

ctx.releaseSlot() succeeds but leaves the task's row in v1_task_runtime_slot. On a worker with one regular slot, another task cannot start until the released task completes or fails.

Environment

  • TypeScript SDK 1.31.1.
  • Local self-hosted hatchet-lite-dev:v0.106.8, PostgreSQL 17.

Expected behavior

As described in Manual Slot Release, another task should be able to use the released slot while the original task remains running.

Reproduction and results

Register two tasks on a worker configured with slots: 1. Task A calls await ctx.releaseSlot(), submits task B, then sleeps for 6 s. Repeat on retries, giving each submitted child a distinct childKey.

The RPC succeeds. Immediately afterward, v1_task_runtime.worker_id is NULL, but v1_task_runtime_slot still has the original worker ID and units = 1.

Release path Initial attempt: B starts after release Retry 1 Retry 2 B starts before A finishes?
SDK RPC 8.267 s 6.389 s 7.486 s No
Corrected SQL, executed directly 55 ms 1.514 s 1.544 s Yes

A no-release control also kept B waiting until A finished. The corrected-SQL comparison uses the same engine and worker, but executes the candidate query directly against the local database; it is not a rebuilt-engine test.

Cause and proposed fix

In ManualSlotRelease, the final UPDATE depends on task, while slot deletion depends on locked_runtime, a SELECT ... FOR UPDATE. The UPDATE can modify the runtime row before the locking CTE executes. That locking read then skips the row modified by the same command, so slot deletion finds no rows.

Make the UPDATE depend on the locking CTE:

diff
 UPDATE
     v1_task_runtime
 SET
     worker_id = NULL
 FROM
-    task
+    locked_runtime
 WHERE
-    (v1_task_runtime.task_id, v1_task_runtime.task_inserted_at, v1_task_runtime.retry_count) IN (SELECT id, inserted_at, retry_count FROM task)
+    (v1_task_runtime.task_id, v1_task_runtime.task_inserted_at, v1_task_runtime.retry_count) IN (SELECT task_id, task_inserted_at, retry_count FROM locked_runtime)
 RETURNING
     v1_task_runtime.*;

The generated query in pkg/repository/sqlcv1/tasks.sql.go would also need regeneration. The same problematic query was present in main when checked.

Here is a standalone SQL reproduction. It uses temporary tables and rolls back:

sql
BEGIN;
CREATE TEMP TABLE repro_runtime (id int PRIMARY KEY, worker_id int);
CREATE TEMP TABLE repro_slot (task_id int);
INSERT INTO repro_runtime VALUES (1, 42);
INSERT INTO repro_slot VALUES (1);

WITH locked_runtime AS (
  SELECT id FROM repro_runtime WHERE id = 1 FOR UPDATE
), deleted_slots AS (
  DELETE FROM repro_slot
  WHERE task_id IN (SELECT id FROM locked_runtime)
  RETURNING task_id
)
UPDATE repro_runtime SET worker_id = NULL WHERE id = 1;

TABLE repro_slot; -- Incorrectly retains task_id = 1.

UPDATE repro_runtime SET worker_id = 42 WHERE id = 1;

WITH locked_runtime AS (
  SELECT id FROM repro_runtime WHERE id = 1 FOR UPDATE
), deleted_slots AS (
  DELETE FROM repro_slot
  WHERE task_id IN (SELECT id FROM locked_runtime)
  RETURNING task_id
)
UPDATE repro_runtime SET worker_id = NULL
WHERE id IN (SELECT id FROM locked_runtime);

TABLE repro_slot; -- Empty, as expected.
ROLLBACK;

AI disclosure

OpenCode with GPT-6 Astra assisted with investigation, running the local SDK and SQL reproductions, and drafting this report and candidate fix.