I have a video rendering task that calls an external server via fetch, passing a webhook token URL and waiting for the server to callback before proceeding. This external server can only render one video at a time. I have two instances of this video rendering service at different URLs.
I want to:
• Execute only one video rendering subtask concurrently. Since I use wait.forToken, concurrency doesn't work as intended because the task enters a WAITING state, allowing other runs to start.
• Implement a timeout so that if the root task has been waiting for more than X minutes for the video rendering subtask to run and it hasn't due to the concurrency limit, I can run another subtask instead.
Essentially, my goal is to have a root render-video task that attempts to render the video through either of the two server instances. If it cannot do so after 10 minutes because both are busy, I will trigger another subtask to render the video using an on-demand third-party provider that is always available.
#How to Do Concurrency Control with Timeout handling
1 messages · Page 1 of 1 (latest)
Hi @crimson dirge. I'm kapa.ai, your friendly support bot. I will try to answer your question by searching through the knowledge base. :wave:
You can achieve this with queue-level concurrency, wait tokens, and a bounded wait with idempotency, plus a fallback path.
Key points from the platform behavior:
- Only actively executing runs consume concurrency; runs that reach a waitpoint (e.g., wait.forToken) checkpoint and release their slot. This prevents deadlocks but means “one-at-a-time” isn’t enforced while a run is waiting (others can start). Plan around this by giving the subtask its own queue with concurrency 1, and let the parent checkpoint while waiting (so the queue slot is available only to the subtask that’s actually executing) (Concurrency & queues).
- Subtasks don’t inherit the parent queue; assign a dedicated queue to the rendering subtasks and set concurrencyLimit: 1 (per server instance). Trigger whichever server’s subtask you want to try first (Concurrency & subtasks).
- Use wait.for with an idempotency key to bound how long the parent will wait before failing over, or use wait.forToken with a timeout and inspect the result.ok to choose a fallback path (Wait overview, wait.for idempotency, wait.forToken).
- You can also set a per-run TTL when triggering a subtask, so queued runs that can’t start within X minutes expire (status Expired), letting you decide to fall back sooner (Advanced run features: TTL).
- Max duration doesn’t count time spent waiting on waitpoints, so use waits/TTL for wall-clock failover, not maxDuration (Max duration).
Sketch of an approach:
- Define two subtasks, one per server instance, each with its own queue concurrencyLimit: 1.
- Parent render-video task:
- Create a wait token and send its URL to the chosen server instance subtask.
- Wait for the token with a timeout (e.g., 10m). If it times out, cancel/ignore that path and try the second instance similarly.
- If both time out (or their runs expire via TTL), trigger the on-demand provider.
Example outline:
import { task, queue, wait } from "@trigger.dev/sdk";
// Two queues, one per server instance, each single-concurrency
const serverAQueue = queue({ name: "render-server-a", concurrencyLimit: 1 });
const serverBQueue = queue({ name: "render-server-b", concurrencyLimit: 1 });
export const renderOnServerA = task({
id: "render-on-server-a",
queue: serverAQueue,
run: async ({ videoId, callbackUrl }: { videoId: string; callbackUrl: string }) => {
// call external server A with callbackUrl, then return
// external server will POST callbackUrl when finished
},
});
export const renderOnServerB = task({
id: "render-on-server-b",
queue: serverBQueue,
run: async ({ videoId, callbackUrl }: { videoId: string; callbackUrl: string }) => {
// call external server B with callbackUrl
},
});
type RenderResult = { s3Url: string };
export const renderVideo = task({
id: "render-video",
run: async ({ videoId }: { videoId: string }) => {
// Try server A first
const tokenA = await wait.createToken({ timeout: "10m" }); // bounds wall-clock wait
await renderOnServerA.trigger(
{ videoId, callbackUrl: tokenA.url },
{ ttl: "10m" } // expire if it can't start in time
); // does not block
const resA = await wait.forToken<RenderResult>(tokenA.id);
if (resA.ok) return { s3Url: resA.output.s3Url };
// If A timed out, try server B
const tokenB = await wait.createToken({ timeout: "10m" });
await renderOnServerB.trigger(
{ videoId, callbackUrl: tokenB.url },
{ ttl: "10m" }
);
const resB = await wait.forToken<RenderResult>(tokenB.id);
if (resB.ok) return { s3Url: resB.output.s3Url };
// Fallback to on-demand provider
// ... trigger provider and wait similarly (token or direct integration)
},
});
Why this works:
- Each server subtask runs on its own queue with concurrencyLimit: 1, so only one render per server executes at a time ([Setting task concurrency](<https://trigger.dev/docs/queue-concurrency#page-title>)).
- The parent renderVideo uses wait.forToken with a timeout; when waiting, it checkpoints and releases concurrency, but that’s fine because the single-concurrency is enforced on the subtask queues (not on the parent). If the wait times out (server busy or subtask didn’t start due to concurrency), you fall back to the next option ([Waits and concurrency](<https://trigger.dev/docs/queue-concurrency#waits-and-concurrency>), [wait.forToken](<https://trigger.dev/docs/wait-for-token#wait-fortoken>)).
- Adding ttl: "10m" to subtask triggers ensures that if the subtask can’t acquire its queue slot within 10 minutes, it expires, aligning with your failover window ([Advanced run features: TTL](<https://trigger.dev/docs/runs#advanced-run-features>)).
- If you need to skip re-waits on retries, add idempotencyKey/idempotencyKeyTTL to your waits ([Wait idempotency](<https://trigger.dev/docs/wait-for#page-title>), [Wait idempotency tokens](<https://trigger.dev/docs/wait-for-token#wait-idempotency>)).
Caveats:
- Subtasks don’t inherit parent queues; ensure you assign the queues directly on the subtasks (Concurrency and subtasks).
- Avoid Promise.all for multiple waits; use sequential waits or batchTriggerAndWait if you fan out (triggerAndWait guidance, batchTriggerAndWait).
Note: you can ask me a follow up question by @ mentioning me again :speech_balloon:
@crimson dirge Kapa.ai is still learning and improving, please let me know how I did by reacting below.
By the way, the doc pages says there's a releaseConcurrency option in the wait.forToken method but such thing does not exist...