Skip to content

Commit 3928779

Browse files
authored
Merge branch 'main' into docs-ai-agents-chat-agent-guide
2 parents d0a3827 + 04f9c4e commit 3928779

10 files changed

Lines changed: 418 additions & 47 deletions

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
area: webapp
3+
type: fix
4+
---
5+
6+
Fix debounce doing nothing when the delay was longer than an hour, which made every trigger
7+
create its own run instead of collapsing onto the debounce key. Debounced runs now keep being
8+
pushed back for as long as triggers keep arriving, so set `maxDelay` when the work has to happen
9+
eventually, and settings that could never debounce are rejected rather than silently ignored.

apps/webapp/app/env.server.ts

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,18 @@ const OptionalIntEnv = z.preprocess(
114114
z.coerce.number().int().optional()
115115
);
116116

117+
/**
118+
* Optional int env var for a limit that can be switched off. Blank, whitespace and `0` all mean
119+
* "no limit" and normalise to undefined; anything else that is set must be greater than zero.
120+
*/
121+
const OptionalLimitEnv = z.preprocess((v) => {
122+
if (typeof v === "string" && (v.trim() === "" || Number(v.trim()) === 0)) {
123+
return undefined;
124+
}
125+
126+
return v === 0 ? undefined : v;
127+
}, z.coerce.number().int().positive().optional());
128+
117129
const EnvironmentSchema = z
118130
.object({
119131
NODE_ENV: z.union([z.literal("development"), z.literal("production"), z.literal("test")]),
@@ -1034,11 +1046,16 @@ const EnvironmentSchema = z
10341046
.default(60_000),
10351047
RUN_ENGINE_SUSPENDED_HEARTBEAT_RETRIES_FACTOR: z.coerce.number().default(2),
10361048

1037-
/** Maximum duration in milliseconds that a run can be debounced. Default: 1 hour (3,600,000ms) */
1038-
RUN_ENGINE_MAXIMUM_DEBOUNCE_DURATION_MS: z.coerce
1039-
.number()
1040-
.int()
1041-
.default(60_000 * 60), // 1 hour
1049+
/**
1050+
* Optional ceiling on how long a debounced run can be pushed back, measured from the first
1051+
* trigger. Unset by default: a continuously triggered debounce key is pushed back for as
1052+
* long as the triggers keep coming, and `debounce.maxDelay` on the trigger is the only
1053+
* bound. Setting this applies a ceiling to every debounced run that does not carry its own
1054+
* `maxDelay`, and any `delay` at or above it is rejected at trigger time. It is a default
1055+
* rather than an enforced limit: a trigger that sets `maxDelay` uses that value even when it
1056+
* is longer than this. `0` and blank both mean no ceiling.
1057+
*/
1058+
RUN_ENGINE_MAXIMUM_DEBOUNCE_DURATION_MS: OptionalLimitEnv,
10421059

10431060
/**
10441061
* Bucket size in milliseconds used to quantize the newly computed `delayUntil`

apps/webapp/app/runEngine/services/triggerTask.server.ts

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,15 @@ import type { Tracer } from "@opentelemetry/api";
77
import { tryCatch } from "@trigger.dev/core/utils";
88
import {
99
type TriggerTaskRequestBody,
10+
formatDurationMilliseconds,
1011
RunAnnotations,
1112
TaskRunError,
1213
taskRunErrorEnhancer,
1314
taskRunErrorToString,
1415
TriggerTraceContext,
1516
} from "@trigger.dev/core/v3";
1617
import {
18+
parseNaturalLanguageDurationInMs,
1719
parseTraceparent,
1820
RunId,
1921
serializeTraceparent,
@@ -89,6 +91,7 @@ export class RunEngineTriggerTaskService {
8991
private readonly traceEventConcern: TraceEventConcern;
9092
private readonly triggerRacepointSystem: TriggerRacepointSystem;
9193
private readonly metadataMaximumSize: number;
94+
private readonly maximumDebounceDurationMs: number | undefined;
9295
// Mollifier hooks are DI'd so tests can drive the call-site's mollify branch
9396
// deterministically (stub the gate to return mollify, inject a real or fake
9497
// buffer, force the global-enabled predicate to true so the call site
@@ -108,6 +111,7 @@ export class RunEngineTriggerTaskService {
108111
traceEventConcern: TraceEventConcern;
109112
tracer: Tracer;
110113
metadataMaximumSize: number;
114+
maximumDebounceDurationMs?: number;
111115
triggerRacepointSystem?: TriggerRacepointSystem;
112116
evaluateGate?: MollifierEvaluateGate;
113117
getMollifierBuffer?: MollifierGetBuffer;
@@ -122,13 +126,74 @@ export class RunEngineTriggerTaskService {
122126
this.tracer = opts.tracer;
123127
this.traceEventConcern = opts.traceEventConcern;
124128
this.metadataMaximumSize = opts.metadataMaximumSize;
129+
this.maximumDebounceDurationMs =
130+
opts.maximumDebounceDurationMs ?? env.RUN_ENGINE_MAXIMUM_DEBOUNCE_DURATION_MS;
125131
this.triggerRacepointSystem = opts.triggerRacepointSystem ?? new NoopTriggerRacepointSystem();
126132
this.evaluateGate = opts.evaluateGate ?? defaultEvaluateGate;
127133
this.getMollifierBuffer = opts.getMollifierBuffer ?? defaultGetMollifierBuffer;
128134
this.isMollifierGloballyEnabled =
129135
opts.isMollifierGloballyEnabled ?? (() => env.TRIGGER_MOLLIFIER_ENABLED === "1");
130136
}
131137

138+
/**
139+
* A debounced run is only pushed back while its new execution time stays inside the effective
140+
* ceiling, which is the trigger's own `maxDelay` or, failing that, whatever ceiling the server
141+
* is configured with. The room available to push is that ceiling minus `delay`, so a `delay`
142+
* at or above it leaves none: the debounce key does nothing and every trigger creates its own
143+
* run. Rejecting is better than accepting a trigger we know cannot debounce.
144+
*
145+
* With no `maxDelay` and no server ceiling there is nothing to conflict with, which is the
146+
* default.
147+
*/
148+
#validateDebounceWindow(
149+
debounce: NonNullable<NonNullable<TriggerTaskRequestBody["options"]>["debounce"]>
150+
) {
151+
const delayMs = parseNaturalLanguageDurationInMs(debounce.delay);
152+
153+
if (delayMs === undefined) {
154+
throw new ServiceValidationError(
155+
`Invalid debounce delay: ${debounce.delay}. debounce.delay must be a duration, not a ` +
156+
`date, because it is re-applied every time the run is pushed back. Supported formats: ` +
157+
`{number}s, {number}m, {number}h or {number}hr, {number}d, {number}w, optionally ` +
158+
`combined (for example "2h30m").`
159+
);
160+
}
161+
162+
if (debounce.maxDelay !== undefined) {
163+
const maxDelayMs = parseNaturalLanguageDurationInMs(debounce.maxDelay);
164+
165+
if (maxDelayMs === undefined) {
166+
throw new ServiceValidationError(
167+
`Invalid debounce maxDelay: ${debounce.maxDelay}. ` +
168+
`Supported formats: {number}s, {number}m, {number}h or {number}hr, {number}d, ` +
169+
`{number}w, optionally combined (for example "2h30m").`
170+
);
171+
}
172+
173+
if (maxDelayMs <= delayMs) {
174+
throw new ServiceValidationError(
175+
`debounce.maxDelay (${debounce.maxDelay}) must be longer than debounce.delay ` +
176+
`(${debounce.delay}). A debounced run is only pushed back while it stays inside ` +
177+
`maxDelay, so with these values every trigger would create its own run.`
178+
);
179+
}
180+
181+
return;
182+
}
183+
184+
const serverCeilingMs = this.maximumDebounceDurationMs;
185+
186+
if (serverCeilingMs !== undefined && delayMs >= serverCeilingMs) {
187+
throw new ServiceValidationError(
188+
`debounce.delay (${debounce.delay}) is at or above this server's maximum debounce ` +
189+
`duration of ${formatDurationMilliseconds(serverCeilingMs, { style: "short" })}. A ` +
190+
`debounced run is only pushed back while it stays inside that window, so with this ` +
191+
`delay every trigger would create its own run. Either shorten the delay, or set ` +
192+
`debounce.maxDelay above ${debounce.delay}.`
193+
);
194+
}
195+
}
196+
132197
// Mint a new run's friendlyId. The id-kind decides which store the run is born
133198
// in (cuid → legacy store, run-ops id → new store), so the whole subgraph of a run
134199
// must agree. Two cases:
@@ -271,9 +336,12 @@ export class RunEngineTriggerTaskService {
271336
if (debounceDelayError || !debounceDelayUntil) {
272337
throw new ServiceValidationError(
273338
`Invalid debounce delay: ${body.options.debounce.delay}. ` +
274-
`Supported formats: {number}s, {number}m, {number}h, {number}d, {number}w`
339+
`Supported formats: {number}s, {number}m, {number}h or {number}hr, {number}d, ` +
340+
`{number}w, optionally combined (for example "2h30m").`
275341
);
276342
}
343+
344+
this.#validateDebounceWindow(body.options.debounce);
277345
}
278346

279347
const parentRun = body.options?.parentRunId

apps/webapp/test/engine/triggerTask.debounce.test.ts

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -459,4 +459,151 @@ describe("RunEngineTriggerTaskService", () => {
459459
expect(result).toBeDefined();
460460
expect(result?.run.friendlyId).toBeDefined();
461461
});
462+
463+
containerTest(
464+
"should reject a debounce maxDelay that leaves no room to push the run back",
465+
async ({ prisma, redisOptions }) => {
466+
const engine = new RunEngine({
467+
prisma,
468+
worker: {
469+
redis: redisOptions,
470+
workers: 1,
471+
tasksPerWorker: 10,
472+
pollIntervalMs: 100,
473+
},
474+
queue: {
475+
redis: redisOptions,
476+
},
477+
runLock: {
478+
redis: redisOptions,
479+
},
480+
machines: {
481+
defaultMachine: "small-1x",
482+
machines: {
483+
"small-1x": {
484+
name: "small-1x" as const,
485+
cpu: 0.5,
486+
memory: 0.5,
487+
centsPerMs: 0.0001,
488+
},
489+
},
490+
baseCostInCents: 0.0005,
491+
},
492+
tracer: trace.getTracer("test", "0.0.0"),
493+
});
494+
onTestFinished(() => engine.quit());
495+
496+
const authenticatedEnvironment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION");
497+
const taskIdentifier = "test-task";
498+
499+
await setupBackgroundWorker(engine, authenticatedEnvironment, taskIdentifier);
500+
501+
const queuesManager = new DefaultQueueManager(prisma, engine);
502+
const idempotencyKeyConcern = new IdempotencyKeyConcern(
503+
prisma,
504+
engine,
505+
new MockTraceEventConcern()
506+
);
507+
508+
const triggerTaskService = new RunEngineTriggerTaskService({
509+
engine,
510+
prisma,
511+
payloadProcessor: new MockPayloadProcessor(),
512+
queueConcern: queuesManager,
513+
idempotencyKeyConcern,
514+
validator: new MockTriggerTaskValidator(),
515+
traceEventConcern: new MockTraceEventConcern(),
516+
tracer: trace.getTracer("test", "0.0.0"),
517+
metadataMaximumSize: 1024 * 1024 * 1,
518+
});
519+
520+
const triggerWithDebounce = (debounce: { key: string; delay: string; maxDelay?: string }) =>
521+
triggerTaskService.call({
522+
taskId: taskIdentifier,
523+
environment: authenticatedEnvironment,
524+
body: { payload: { test: "test" }, options: { debounce } },
525+
});
526+
527+
await expect(
528+
triggerWithDebounce({ key: "equal", delay: "12h", maxDelay: "12h" })
529+
).rejects.toThrow(/must be longer than debounce.delay/);
530+
531+
await expect(
532+
triggerWithDebounce({ key: "shorter", delay: "12h", maxDelay: "1h" })
533+
).rejects.toThrow(/must be longer than debounce.delay/);
534+
535+
await expect(
536+
triggerWithDebounce({ key: "unparseable", delay: "10s", maxDelay: "soon" })
537+
).rejects.toThrow(/Invalid debounce maxDelay/);
538+
539+
await expect(
540+
triggerWithDebounce({ key: "empty", delay: "10s", maxDelay: "" })
541+
).rejects.toThrow(/Invalid debounce maxDelay/);
542+
543+
const compound = await triggerWithDebounce({
544+
key: "compound",
545+
delay: "2h30m",
546+
maxDelay: "1d",
547+
});
548+
expect(compound?.run.friendlyId).toBeDefined();
549+
550+
const withRoom = await triggerWithDebounce({
551+
key: "with-room",
552+
delay: "10s",
553+
maxDelay: "5m",
554+
});
555+
expect(withRoom?.run.friendlyId).toBeDefined();
556+
557+
const noMaxDelay = await triggerWithDebounce({ key: "no-max-delay", delay: "12h" });
558+
expect(noMaxDelay?.run.friendlyId).toBeDefined();
559+
560+
await expect(
561+
triggerWithDebounce({ key: "date-delay", delay: "2027-01-01T00:00:00.000Z" })
562+
).rejects.toThrow(/must be a duration, not a date/);
563+
564+
const withServerCeiling = new RunEngineTriggerTaskService({
565+
engine,
566+
prisma,
567+
payloadProcessor: new MockPayloadProcessor(),
568+
queueConcern: queuesManager,
569+
idempotencyKeyConcern,
570+
validator: new MockTriggerTaskValidator(),
571+
traceEventConcern: new MockTraceEventConcern(),
572+
tracer: trace.getTracer("test", "0.0.0"),
573+
metadataMaximumSize: 1024 * 1024 * 1,
574+
maximumDebounceDurationMs: 60 * 60 * 1000,
575+
});
576+
577+
await expect(
578+
withServerCeiling.call({
579+
taskId: taskIdentifier,
580+
environment: authenticatedEnvironment,
581+
body: {
582+
payload: { test: "test" },
583+
options: { debounce: { key: "over-server-ceiling", delay: "12h" } },
584+
},
585+
})
586+
).rejects.toThrow(/at or above this server's maximum debounce duration of 1h/);
587+
588+
const underServerCeiling = await withServerCeiling.call({
589+
taskId: taskIdentifier,
590+
environment: authenticatedEnvironment,
591+
body: {
592+
payload: { test: "test" },
593+
options: { debounce: { key: "under-server-ceiling", delay: "10s" } },
594+
},
595+
});
596+
expect(underServerCeiling?.run.friendlyId).toBeDefined();
597+
598+
const overCeilingWithMaxDelay = await withServerCeiling.call({
599+
taskId: taskIdentifier,
600+
environment: authenticatedEnvironment,
601+
body: {
602+
payload: { test: "test" },
603+
options: { debounce: { key: "override", delay: "12h", maxDelay: "24h" } },
604+
},
605+
});
606+
expect(overCeilingWithMaxDelay?.run.friendlyId).toBeDefined();
607+
}
608+
);
462609
});

docs/triggering.mdx

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -873,7 +873,7 @@ The `debounce` option accepts:
873873
- `key` - A unique string to identify the debounce group (scoped to the task)
874874
- `delay` - Duration string specifying how long to delay. Supported units: `s` (seconds), `m` (minutes), `h`/`hr` (hours), `d` (days), `w` (weeks). Minimum is 1 second. Examples: `"5s"`, `"1m"`, `"2h30m"`
875875
- `mode` - Optional. Controls which trigger's data is used: `"leading"` (default) or `"trailing"`
876-
- `maxDelay` - Optional. Maximum total time from the first trigger before the run must execute. Uses the same duration format as `delay`
876+
- `maxDelay` - Optional. Maximum total time from the first trigger before the run must execute. Uses the same duration format as `delay`. Not set by default
877877

878878
**How it works:**
879879

@@ -882,9 +882,18 @@ The `debounce` option accepts:
882882
3. Once no new triggers occur within the delay duration, the run executes
883883
4. After the run starts executing, a new trigger with the same key will create a new run
884884

885+
<Warning>
886+
There is no time limit on step 2. While triggers keep arriving on the same key, the run keeps
887+
being pushed back and never executes. A key triggered every 10 seconds with a `delay` of `"30s"`
888+
runs 30 seconds after the triggers stop, however long that takes. Set `maxDelay` whenever the
889+
work needs to happen eventually. This matters most with `triggerAndWait`: every parent waiting
890+
on a debounced run stays blocked, and holds its concurrency, for as long as the run keeps being
891+
pushed back.
892+
</Warning>
893+
885894
**Limiting total delay with `maxDelay`:**
886895

887-
By default, continuous triggers can delay execution indefinitely. The `maxDelay` option sets an upper bound on the total delay from the first trigger, ensuring the run eventually executes even with constant activity.
896+
The `maxDelay` option sets an upper bound on the total delay from the first trigger, ensuring the run eventually executes even with constant activity.
888897

889898
```ts
890899
await summarizeChat.trigger(
@@ -921,11 +930,14 @@ Consider `delay: "5s"` and `maxDelay: "30s"` with triggers arriving every 2 seco
921930

922931
Without `maxDelay`, continuous triggers would prevent the run from ever executing. With `maxDelay: "30s"`, execution is guaranteed within 30 seconds of the first trigger.
923932

933+
Keep `delay` well below `maxDelay`. A run is only pushed back while its new execution time stays inside `maxDelay`, so the room you have to push is `maxDelay` minus `delay`. Setting them equal, or setting `delay` higher, leaves no room at all, and the trigger is rejected rather than accepted as a debounce that could never collapse anything.
934+
924935
<Note>
925936
The `maxDelay` value is evaluated from each trigger call, not stored with the original run. This
926937
means if you pass different `maxDelay` values for the same debounce key, each trigger uses its own
927-
`maxDelay` to check against the original run's creation time. For consistent behavior, use the
928-
same `maxDelay` value for all triggers with the same debounce key.
938+
`maxDelay` to check against the original run's creation time. A trigger that omits `maxDelay`
939+
has no bound at all, so a single call without it can push the run past the limit the other calls
940+
set. Use the same `maxDelay` value for every trigger with the same debounce key.
929941
</Note>
930942

931943
**Leading vs Trailing mode:**

internal-packages/run-engine/src/engine/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -384,7 +384,7 @@ export class RunEngine {
384384
redis: options.debounce?.redis ?? options.runLock.redis,
385385
executionSnapshotSystem: this.executionSnapshotSystem,
386386
delayedRunSystem: this.delayedRunSystem,
387-
maxDebounceDurationMs: options.debounce?.maxDebounceDurationMs ?? 60 * 60 * 1000, // Default 1 hour
387+
maxDebounceDurationMs: options.debounce?.maxDebounceDurationMs,
388388
quantizeNewDelayUntilMs: options.debounce?.quantizeNewDelayUntilMs ?? 1000,
389389
fastPathSkipEnabled: options.debounce?.fastPathSkipEnabled ?? true,
390390
useReplicaForFastPathRead: options.debounce?.useReplicaForFastPathRead ?? false,

0 commit comments

Comments
 (0)