Skip to content

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

Description

@Scalahansolo

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:

 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:

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions