Skip to content

bug: a resumed run whose restore fails is requeued behind newer runs #5104

Open

Description

@PlunderStruck

Provide environment information

  System:
    OS: Linux 6.8 Ubuntu 24.04.5 LTS 24.04.5 LTS (Noble Numbat)
    CPU: (16) x64 AMD Ryzen 7 7700 8-Core Processor
    Memory: 48.87 GB / 61.89 GB
    Container: Yes
    Shell: 5.2.21 - /bin/bash
  Binaries:
    Node: 24.22.1 - /usr/local/bin/node
    Yarn: 1.22.22 - /usr/local/bin/yarn
    npm: 11.19.1 - /usr/local/bin/npm
    pnpm: 12.15.0 - /usr/local/bin/pnpm

The runs were on Trigger.dev Cloud: prod environment, us-east-1. Tasks were deployed with the trigger.dev CLI and @trigger.dev/sdk 4.5.14, runtime: "node-24". The output above is from the machine we reproduced on.

Describe the bug

A run that resumes after a wait is enqueued at its original queue position. continueRunIfUnblocked says so: "Re-enqueue with the original timestamp, so waiting runs dequeue before new runs." If the restore then fails before the run continues, the run is requeued through tryNackAndRequeue with no retry time. RunQueue then scores the message at now + backoff (internal-packages/run-engine/src/run-queue/index.ts, #callNackMessage). The run loses its position and waits behind every run that was triggered after it.

What we saw on 2026-11-06:

  • Two parent runs called batchTriggerAndWait with about 50 children each, and were checkpointed while they waited. Their queue has concurrencyLimit: 4 and held a backlog of about 100 queued runs.
  • All children ended by 21:03 and 21:59 UTC. A few children were OOM-killed, and each of those was marked CRASHED (TASK_PROCESS_OOM_KILLED) 300 s after it started.
  • Each parent was then requeued once, at 22:21 and 22:04 UTC. After that the API showed status: "QUEUED", attempts: [], and a trace that ends at batchTriggerAndWait(). In the dashboard the run looks as if it never started.
  • The parents stayed queued for over 70 minutes while runs created after them kept starting in the same queue. We cancelled them at 23:32 UTC. That was about 4.5 minutes before the last run that was queued ahead of them started, so we think they were waiting their turn, not lost.
  • Each requeue freed a queue slot that another run took within 0.2 s. As far as we can tell from the code, only tryNackAndRequeue writes PENDING for a run that is resuming from a checkpoint.

Expected: a resume whose restore has to be retried keeps its place ahead of runs triggered after it, as the first resume does.

We can't see why the restores failed. The parents ran on a small machine and were close to its memory limit when they started waiting, so we think the restored process was OOM-killed. That part is our sizing. See "Additional information" for how such a restore is reported.

Reproduction repo

https://github.com/PlunderStruck/trigger.dev/tree/repro/resumed-run-queue-position

To reproduce

The branch adds one run-engine test file, explained in repro/resumed-run-queue-position/README.md. It uses containerTest and a real RunEngine. The tests assert today's behaviour, so they pass on main:

cd internal-packages/run-engine
pnpm run test ./src/engine/tests/resumedRunQueuePositionRepro.test.ts --run

Each test:

  1. Triggers a parent in a queue with concurrency limit 1, starts it, and blocks it on a waitpoint.
  2. Checkpoints it, then completes the waitpoint, so the parent is enqueued for a restore and dequeued ahead of newer runs, as designed.
  3. Triggers a newer run while the restore holds the slot.
  4. Fails the restore before continueRunExecution, in one of two ways:
    • Stall. The restored runner never continues, so the PENDING_EXECUTING heartbeat requeues it.
    • Supervisor report. The supervisor reports the restore as requeue, as classifyRestoreFailure does for a Runner that failed with reason OOMKilled.
  5. Asserts that the parent is PENDING with its checkpoint, that the next dequeue returns the newer run, and that the parent is dequeued only after the newer run finishes.

Suggested fix

These are ideas from reading the code; you know the trade-offs far better than we do.

  • Keep the position on the first requeue of a resume. tryNackAndRequeue already knows whether the run has a checkpoint. It could ask nackMessage to redeliver at the message's original score, as the resume enqueue does. Later requeues would keep the backoff, so a restore that keeps failing does not spend the 12 queue attempts in a tight loop. Cold starts would be unchanged. We put this on a branch with a regression test in restoreOutcome.test.ts, which fails without the change: main...PlunderStruck:trigger.dev:fix/resumed-run-queue-position. It adds 18 lines to runAttemptSystem.ts and run-queue/index.ts, most of them comments. We'd be glad to open it as a PR if that's welcome.
  • Not covered by that branch: the direct runQueue.nackMessage calls in dequeueSystem.ts and engine/index.ts (error and admin paths) still requeue a resume at now + backoff.

Additional information

Activity

  1. PlunderStruck commented on Nov 9, 2026

    @PlunderStruck
    Author

    A correction and more data, after a second pass through our run history.

    Correction. The description says each parent "was then requeued once". We only know the time of the last requeue. The restores most likely failed several times.

    Two more runs, with an explicit error. Two other parents from the same day followed the same path, and we did not cancel them:

    • Each called batchTriggerAndWait with 50 children. One child was OOM-killed, so the batch stayed open for about 5 minutes. All children ended by 17:12 and 17:17 UTC.
    • Each parent then ended as SYSTEM_FAILURE with TASK_RUN_DEQUEUED_MAX_RETRIES, at 19:53 and 19:46 UTC. That is 161 and 150 minutes after its last child ended. attempts is [], and the trace ends at batchTriggerAndWait().

    As we read the code, that error means tryNackAndRequeue requeued the run until the queue's 12 attempts were spent. So the restore failed every time, and the two parents we cancelled were in the same loop.

    How rare it is. Over 14 days, 208 parents of this task had an OOM-killed child, and so waited about 5 minutes. 203 resumed, almost all within 10 seconds. 4 never resumed: the two above and the two in the description. One crashed while it waited.

    Our side. Our workers were at their memory limit that day (512 MB machines). We have since moved them to larger machines, and we have seen no case since. We think each failed restore was an OOM kill, but we can't see that from outside.

    What this changes.

    • The queue-position change in "Suggested fix" still holds, but it is not what failed these runs. It would have made the first retry immediate. With a restore that fails every time, the run would still have used all 12 attempts.
    • The first point under "Additional information" is the one that matches what we saw. A restore that keeps failing is retried for 2.5 hours, the run shows QUEUED the whole time, and the final error does not name the cause. If the restores were OOM kills, retry.outOfMemory never had a chance to apply.
    • In "To reproduce", step 3 is out of order. The test triggers the newer run while the parent's first attempt holds the slot, before the checkpoint. The README on the branch has the right order.

    We can share the four run IDs privately.

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