Skip to content

Commit a045ff8

Browse files
MariefayTrigger.dev RepoOps
authored andcommitted
docs: add runs.list() best practices and anti-patterns
Adds a [runs.list() best practices](/runs/list-best-practices) page covering the patterns to avoid and what to use instead: - Checking for active runs before triggering: use `debounce`, a per-key concurrency limit, or an `idempotencyKey`. - A fixed `delay` or a random `idempotencyKey` on every trigger, which don't dedupe anything. - Polling a run's status or a tag: use `triggerAndWait()` or realtime subscriptions. - Counting queued runs: use `queues.retrieve()`. - How the default 7-day time window works, and the `422` returned for queries that are too expensive. Also links the page from the runs, tags, triggering and List runs API reference pages, and bounds the `runs.list()` code samples to a recent time window instead of listing every run. Mono-RevId: c134032a627f73249f8f700c67db182cad38a659
1 parent 92f933f commit a045ff8

6 files changed

Lines changed: 268 additions & 8 deletions

File tree

‎docs/docs.json‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,12 @@
4040
},
4141
"triggering",
4242
"runs",
43+
{
44+
"group": "Runs",
45+
"hidden": true,
46+
"searchable": true,
47+
"pages": ["runs/list-best-practices"]
48+
},
4349
"apikeys"
4450
]
4551
},

‎docs/runs.mdx‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,12 @@ Similar to `triggerAndWait()`, the `batchTriggerAndWait()` function lets you bat
253253

254254
#### runs.list()
255255

256+
<Warning>
257+
`runs.list()` is for browsing, reporting, and bulk operations, not for coordinating runs. Don't
258+
call it before every trigger or poll it in a loop. See [runs.list() best
259+
practices](/runs/list-best-practices) for what to use instead.
260+
</Warning>
261+
256262
List runs in a specific environment. You can filter the runs by status, created at, task identifier, version, and more:
257263

258264
```ts
@@ -290,7 +296,7 @@ import { runs } from "@trigger.dev/sdk";
290296
const response = await runs.list({
291297
status: ["QUEUED", "EXECUTING"], // Filter by status
292298
taskIdentifier: ["my-task", "my-other-task"], // Filter by task identifier
293-
from: new Date("2024-04-01T00:00:00Z"), // Filter by created at
299+
from: new Date(Date.now() - 24 * 60 * 60 * 1000), // Filter by created at (last 24 hours)
294300
to: new Date(),
295301
version: "20241127.2", // Filter by deployment version,
296302
tag: ["tag1", "tag2"], // Filter by tags

‎docs/runs/list-best-practices.mdx‎

Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
---
2+
title: "Using runs.list() efficiently"
3+
sidebarTitle: "runs.list() best practices"
4+
description: "When to use runs.list(), the patterns to avoid, and what to use instead."
5+
---
6+
7+
`runs.list()` (and `GET /api/v1/runs`) is built for browsing, reporting, and bulk operations. It is not a coordination tool. Each call searches your environment's runs in the requested time window, so its cost depends on how many runs you created in that window much more than on the page size you ask for.
8+
9+
A call with `limit: 4` in a busy environment can still search millions of runs to find those 4. That's fine once in a while. On a hot path, like before every trigger or in a loop every second, it slows your own code down and counts against your [API rate limit](/limits).
10+
11+
## Quick reference
12+
13+
| You want to… | Avoid | Use instead |
14+
| --- | --- | --- |
15+
| Avoid piling up runs for the same work | `runs.list()` then `trigger()` | [`debounce`](/triggering#debounce) to merge a burst into one run, a [concurrency limit](/concurrency) so runs queue instead of overlapping, or an [`idempotencyKey`](/idempotency) for one run per item |
16+
| Collapse a burst of triggers into one run | A fixed `delay` on every trigger | [`debounce`](/triggering#debounce) |
17+
| Avoid duplicate runs for the same work | A random `idempotencyKey` per trigger | A key derived from the work itself |
18+
| Wait for a run you triggered | Polling `runs.list()` or `runs.retrieve()` | [`triggerAndWait()`](/triggering#yourtask-triggerandwait) inside a task, or [`runs.subscribeToRun()`](/realtime/backend/subscribe) |
19+
| Follow a group of runs | Polling `runs.list({ tag })` | [`runs.subscribeToRunsWithTag()`](/realtime/backend/subscribe) |
20+
| Know how backed up a queue is | Counting `QUEUED` runs with `runs.list()` | [`queues.retrieve()`](/queues#retrieving-a-queue) |
21+
22+
## Anti-patterns
23+
24+
### Checking for active runs before triggering
25+
26+
This pattern tries to make sure only one run handles a piece of work at a time:
27+
28+
```ts Avoid
29+
import { runs, tasks } from "@trigger.dev/sdk";
30+
31+
// Called every time something changes for this account
32+
export async function onAccountChanged(accountId: string) {
33+
const active = await runs.list({
34+
status: ["QUEUED", "EXECUTING"],
35+
taskIdentifier: ["sync-account"],
36+
tag: [`account:${accountId}`],
37+
limit: 1,
38+
});
39+
40+
if (active.data.length > 0) return;
41+
42+
await tasks.trigger(
43+
"sync-account",
44+
{ accountId },
45+
{ tags: [`account:${accountId}`] }
46+
);
47+
}
48+
```
49+
50+
It has three problems:
51+
52+
- **It races.** Two calls can both see "nothing running" and both trigger.
53+
- **It's expensive.** Every change runs a search, and the search gets slower as you create more runs.
54+
- **It doesn't collapse bursts.** 100 changes in a second still mean 100 searches.
55+
56+
Let Trigger.dev do the coordination instead.
57+
58+
**To collapse bursts into one run, use `debounce`.** While a debounced run is still waiting for its delay, triggers with the same key push it back instead of creating new runs:
59+
60+
```ts Use debounce
61+
import { tasks } from "@trigger.dev/sdk";
62+
63+
export async function onAccountChanged(accountId: string) {
64+
await tasks.trigger(
65+
"sync-account",
66+
{ accountId },
67+
{
68+
debounce: {
69+
key: `account-${accountId}`,
70+
delay: "10s",
71+
// The run uses the payload of the last trigger
72+
mode: "trailing",
73+
// Never push it back past 1m after the first trigger
74+
maxDelay: "1m",
75+
},
76+
}
77+
);
78+
}
79+
```
80+
81+
<Tip>
82+
With debounce, one run handles many changes. Have the task read the pending work from your own
83+
database when it starts, instead of relying on a single trigger's payload.
84+
</Tip>
85+
86+
**To never run two at once for the same key, use a per-key concurrency limit.** Extra runs wait in the queue instead of running in parallel:
87+
88+
```ts Use a concurrency limit
89+
import { task } from "@trigger.dev/sdk";
90+
91+
export const syncAccount = task({
92+
id: "sync-account",
93+
concurrency: { perKey: 1 },
94+
run: async ({ accountId }: { accountId: string }) => {
95+
// ...
96+
},
97+
});
98+
99+
// When triggering
100+
await syncAccount.trigger({ accountId }, { concurrencyKey: accountId });
101+
```
102+
103+
The `concurrency` option requires SDK 4.7.0 or later. See [Concurrency](/concurrency) for details.
104+
105+
Debounce only merges triggers into a run that hasn't started yet. Once that run is queued or executing, the next trigger creates a new run. Combine both options so bursts collapse and a new run never overlaps one that's still going.
106+
107+
**For exactly one run per piece of work, use an `idempotencyKey`.** See [Using a random idempotency key](#using-a-random-idempotency-key) below.
108+
109+
### Using a fixed delay to space out triggers
110+
111+
Adding the same `delay` to every trigger doesn't collapse anything. Each trigger still creates its own run. It just starts later:
112+
113+
```ts Avoid
114+
// 100 changes still create 100 runs, each one 30s after its trigger
115+
await tasks.trigger("sync-account", { accountId }, { delay: "30s" });
116+
```
117+
118+
Use [`debounce`](#checking-for-active-runs-before-triggering) with the same key instead, so triggers within the delay join one run.
119+
120+
### Using a random idempotency key
121+
122+
An idempotency key only dedupes triggers that send the same key. A fresh random value on every trigger never matches, so every call creates a new run:
123+
124+
```ts Avoid
125+
await tasks.trigger("sync-account", payload, {
126+
// Never matches another trigger, so it dedupes nothing
127+
idempotencyKey: `sync-${crypto.randomUUID()}`,
128+
});
129+
```
130+
131+
Build the key from the thing that should only happen once:
132+
133+
```ts Your backend
134+
import { tasks } from "@trigger.dev/sdk";
135+
136+
// One run per change, even if this code runs again within 30 days
137+
// (the default key lifetime, set with idempotencyKeyTTL)
138+
await tasks.trigger("process-change", payload, {
139+
idempotencyKey: `change-${change.id}`,
140+
});
141+
```
142+
143+
<Note>
144+
Inside a task, a raw string key is scoped to the current run. To dedupe across runs, create it
145+
with `idempotencyKeys.create(key, { scope: "global" })`. See [Idempotency](/idempotency) for
146+
scopes and TTLs.
147+
</Note>
148+
149+
### Polling for a run's status
150+
151+
```ts Avoid
152+
const handle = await tasks.trigger("generate-report", payload);
153+
154+
while (true) {
155+
const run = await runs.retrieve(handle.id);
156+
if (run.isCompleted) break;
157+
await new Promise((resolve) => setTimeout(resolve, 1000));
158+
}
159+
```
160+
161+
Inside a task, use [`triggerAndWait()`](/triggering#yourtask-triggerandwait). You don't pay for compute during waits longer than 5 seconds.
162+
163+
From your backend, subscribe to the run. The loop ends when the run finishes:
164+
165+
```ts Use realtime
166+
import { runs, tasks } from "@trigger.dev/sdk";
167+
168+
const handle = await tasks.trigger("generate-report", payload);
169+
170+
for await (const run of runs.subscribeToRun(handle.id)) {
171+
console.log(run.status);
172+
}
173+
```
174+
175+
### Polling runs by tag to follow progress
176+
177+
Calling `runs.list({ tag })` on a timer to see what a user's or a job's runs are doing repeats the same search every time. Subscribe once instead:
178+
179+
```ts Use realtime
180+
import { runs } from "@trigger.dev/sdk";
181+
182+
const tag = `user:${userId}`;
183+
184+
for await (const run of runs.subscribeToRunsWithTag(tag)) {
185+
console.log(run.id, run.status);
186+
}
187+
```
188+
189+
This iterator never completes on its own, so break out of the loop when you're done. In a frontend, use the [React hooks](/realtime/react-hooks/overview).
190+
191+
<Note>
192+
Tag subscriptions only include runs created in the last 24 hours. You can narrow that with the
193+
`createdAt` option, but not extend it. To follow a specific older run, use
194+
`runs.subscribeToRun(runId)`.
195+
</Note>
196+
197+
### Counting runs to measure a backlog
198+
199+
Listing `QUEUED` runs to count them gets slower as the backlog grows, which is exactly when you need the number. It can also miss runs: queued runs created before the [time window](#when-you-do-need-runs-list) aren't returned. The queue already tracks the count:
200+
201+
```ts Use queues.retrieve()
202+
import { queues } from "@trigger.dev/sdk";
203+
204+
// A task's default queue is named after the task ID
205+
const queue = await queues.retrieve({
206+
type: "task",
207+
name: "sync-account",
208+
});
209+
210+
console.log(queue.queued, queue.running);
211+
```
212+
213+
For a queue shared between tasks, use `{ type: "custom", name: "your-queue-name" }`.
214+
215+
## When you do need runs.list()
216+
217+
`runs.list()` is the right tool for dashboards, reports, audits, and finding runs to act on in bulk. To keep it fast:
218+
219+
- **Pass a time window.** Use the narrowest `period` (or `from`/`to`) that covers what you need, like `period: "1h"`.
220+
- **Filter by `taskIdentifier`** as well as status or tag.
221+
- **Stop paginating once you have what you need.** Don't page through every result by default.
222+
- **Keep it off hot paths.** Don't call it per event, per trigger, or in a tight loop inside a task. If several places need the same answer, cache it.
223+
224+
<Warning>
225+
If you don't pass `period`, `from`, or `to`, only runs created in the last 7 days are searched.
226+
The window applies to every status, so runs created before it aren't returned even if they're
227+
still queued or executing. Passing only `to` removes the lower bound, so the search covers every
228+
run up to that date.
229+
</Warning>
230+
231+
<Note>
232+
If a query would be too expensive to complete, the API returns a `422` asking you to narrow the
233+
time window. The SDK doesn't retry it.
234+
</Note>

‎docs/tags.mdx‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,3 +118,9 @@ for await (const run of runs.list({ tag: "user_123456", status: ["COMPLETED"] })
118118
console.log(run.id, run.taskIdentifier, run.finishedAt, run.tags);
119119
}
120120
```
121+
122+
<Tip>
123+
To follow runs with a tag as they change, use
124+
[`runs.subscribeToRunsWithTag()`](/realtime/backend/subscribe) instead of polling `runs.list()`.
125+
See [runs.list() best practices](/runs/list-best-practices).
126+
</Tip>

‎docs/triggering.mdx‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -746,6 +746,12 @@ await myTask.batchTrigger([{ payload: { some: "data" }, options: { delay: "1h" }
746746
manually, convert Dates to ISO strings first (e.g., `date.toISOString()`).
747747
</Note>
748748

749+
<Tip>
750+
A `delay` doesn't merge triggers: each trigger still creates its own run. To collapse a burst of
751+
triggers into one run, use [`debounce`](#debounce). See [runs.list() best
752+
practices](/runs/list-best-practices#using-a-fixed-delay-to-space-out-triggers).
753+
</Tip>
754+
749755
### `ttl`
750756

751757
You can set a TTL (time to live) when triggering a task, which will automatically expire the run if it hasn't started within the specified time. This is useful for ensuring that a run doesn't get stuck in the queue for too long.
@@ -814,6 +820,8 @@ export const myTask = task({
814820
});
815821
```
816822

823+
Build the key from the work being done, like an order or event ID. A random value on every trigger never matches, so it never dedupes. See [runs.list() best practices](/runs/list-best-practices#using-a-random-idempotency-key).
824+
817825
For more information, see our [Idempotency](/idempotency) documentation.
818826

819827
### `idempotencyKeyTTL`

‎docs/v3-openapi.yaml‎

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1776,7 +1776,7 @@ paths:
17761776
get:
17771777
operationId: list_runs_v1
17781778
summary: List runs
1779-
description: List runs in a specific environment. You can filter the runs by status, created at, task identifier, version, and more.
1779+
description: List runs in a specific environment. You can filter the runs by status, created at, task identifier, version, and more. Without a `filter[createdAt]` value, only runs created in the last 7 days are returned. This endpoint is for browsing, reporting, and bulk operations, not for coordinating runs, so don't call it before every trigger or poll it in a loop. See [runs.list() best practices](/runs/list-best-practices).
17801780
parameters:
17811781
- $ref: "#/components/parameters/cursorPagination"
17821782
- $ref: "#/components/parameters/runsFilter"
@@ -1805,8 +1805,8 @@ paths:
18051805
source: |-
18061806
import { runs } from "@trigger.dev/sdk";
18071807
1808-
// Get the first page of runs
1809-
let page = await runs.list({ limit: 20 });
1808+
// Get the first page of runs created in the last day
1809+
let page = await runs.list({ period: "1d", limit: 20 });
18101810
18111811
for (const run of page.data) {
18121812
console.log(`Run ID: ${run.id}, Status: ${run.status}`);
@@ -1818,10 +1818,10 @@ paths:
18181818
// Do something with the next page of runs
18191819
}
18201820
1821-
// Auto-paginate through all runs
1821+
// Auto-paginate through all runs created in the last day
18221822
const allRuns = [];
18231823
1824-
for await (const run of runs.list({ limit: 20 })) {
1824+
for await (const run of runs.list({ period: "1d", limit: 20 })) {
18251825
allRuns.push(run);
18261826
}
18271827
- lang: typescript
@@ -1832,7 +1832,7 @@ paths:
18321832
const response = await runs.list({
18331833
status: ["QUEUED", "EXECUTING"],
18341834
taskIdentifier: ["my-task", "my-other-task"],
1835-
from: new Date("2024-04-01T00:00:00Z"),
1835+
from: new Date(Date.now() - 24 * 60 * 60 * 1000), // last 24 hours
18361836
to: new Date(),
18371837
});
18381838
@@ -2087,7 +2087,7 @@ paths:
20872087
env: ["prod", "staging"],
20882088
status: ["QUEUED", "EXECUTING"],
20892089
taskIdentifier: ["my-task", "my-other-task"],
2090-
from: new Date("2024-04-01T00:00:00Z"),
2090+
from: new Date(Date.now() - 24 * 60 * 60 * 1000), // last 24 hours
20912091
to: new Date(),
20922092
});
20932093

0 commit comments

Comments
 (0)