Skip to content

Commit af9fbe4

Browse files
committed
fix(webapp,run-engine,core): raise the debounce ceiling to 24h and reject windows that cannot debounce
A debounced run is only pushed later while the new execution time stays inside maxDelay (or the server maximum) measured from the first trigger, so the room to push is the gap between the two. A delay at or above that ceiling meant the first extension was already out of bounds: every trigger created its own run, with no error and nothing on the run to show the debounce had been ignored. The default ceiling moves from 1 hour to 24 hours, and a delay that leaves no room is now rejected at trigger time with a message naming both values and how to fix them. debounce.delay must also be a duration rather than a date, since it is re-applied on every extension.
1 parent 337dda1 commit af9fbe4

9 files changed

Lines changed: 329 additions & 8 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
"@trigger.dev/core": patch
3+
---
4+
5+
Debounce windows can now run up to 24 hours by default, and a `debounce.delay` that leaves no room to extend the run is rejected instead of silently doing nothing.
6+
7+
A debounced run is only pushed later while its new execution time stays inside `maxDelay` (or the server maximum) measured from the first trigger, so the room you have to push is `maxDelay` minus `delay`. Setting a `delay` at or above that ceiling previously meant every trigger created its own run, with no error and nothing on the run to show the debounce had been ignored. Those triggers now fail with a message naming both values and how to fix them.
8+
9+
```ts
10+
await myTask.trigger(payload, {
11+
debounce: {
12+
key: "conversation-123",
13+
delay: "12h",
14+
maxDelay: "36h",
15+
},
16+
});
17+
```

apps/webapp/app/env.server.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1034,11 +1034,18 @@ const EnvironmentSchema = z
10341034
.default(60_000),
10351035
RUN_ENGINE_SUSPENDED_HEARTBEAT_RETRIES_FACTOR: z.coerce.number().default(2),
10361036

1037-
/** Maximum duration in milliseconds that a run can be debounced. Default: 1 hour (3,600,000ms) */
1037+
/**
1038+
* Ceiling on how long a debounced run can be pushed back, measured from the first trigger.
1039+
* Acts as the default when a trigger does not set `debounce.maxDelay`; a trigger that does
1040+
* set it overrides this entirely. A `debounce.delay` at or above the effective ceiling is
1041+
* rejected at trigger time, since no trigger could ever extend the run.
1042+
*
1043+
* Default: 24 hours (86,400,000ms)
1044+
*/
10381045
RUN_ENGINE_MAXIMUM_DEBOUNCE_DURATION_MS: z.coerce
10391046
.number()
10401047
.int()
1041-
.default(60_000 * 60), // 1 hour
1048+
.default(24 * 60 * 60 * 1000),
10421049

10431050
/**
10441051
* Bucket size in milliseconds used to quantize the newly computed `delayUntil`

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

Lines changed: 68 additions & 0 deletions
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;
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,75 @@ 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+
* Rejects debounce settings that can never debounce anything.
140+
*
141+
* A trigger extends an existing debounced run by moving its `delayUntil` to `now + delay`,
142+
* and that is only allowed while the new time stays inside `createdAt + ceiling`, where the
143+
* ceiling is the trigger's own `maxDelay` or the server default. So a `delay` at or above the
144+
* ceiling means the very first extension is already out of bounds: every trigger starts its
145+
* own run and the debounce key does nothing. The usable window for extensions is
146+
* `ceiling - delay`.
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}. ` +
156+
`debounce.delay must be a duration, not a date. ` +
157+
`Supported formats: {number}s, {number}m, {number}h, {number}d, {number}w`
158+
);
159+
}
160+
161+
const maxDelayMs = debounce.maxDelay
162+
? parseNaturalLanguageDurationInMs(debounce.maxDelay)
163+
: undefined;
164+
165+
if (debounce.maxDelay && maxDelayMs === undefined) {
166+
throw new ServiceValidationError(
167+
`Invalid debounce maxDelay: ${debounce.maxDelay}. ` +
168+
`Supported formats: {number}s, {number}m, {number}h, {number}d, {number}w`
169+
);
170+
}
171+
172+
const ceilingMs = maxDelayMs ?? this.maximumDebounceDurationMs;
173+
174+
if (delayMs < ceilingMs) {
175+
return;
176+
}
177+
178+
const delayText = formatDurationMilliseconds(delayMs, { style: "short" });
179+
const ceilingText = formatDurationMilliseconds(ceilingMs, { style: "short" });
180+
181+
if (maxDelayMs !== undefined) {
182+
throw new ServiceValidationError(
183+
`debounce.delay (${delayText}) must be shorter than debounce.maxDelay (${ceilingText}). ` +
184+
`A debounced run can only be extended while it is inside the maxDelay window, so with ` +
185+
`these values every trigger would create its own run. Raise maxDelay above the delay ` +
186+
`to give yourself an extension window of maxDelay minus delay.`
187+
);
188+
}
189+
190+
throw new ServiceValidationError(
191+
`debounce.delay (${delayText}) is at or above the maximum debounce duration of ${ceilingText}. ` +
192+
`A debounced run can only be extended while it is inside that window, so with this delay ` +
193+
`every trigger would create its own run. Either shorten the delay, or set ` +
194+
`debounce.maxDelay above ${delayText} to raise the ceiling for this trigger.`
195+
);
196+
}
197+
132198
// Mint a new run's friendlyId. The id-kind decides which store the run is born
133199
// in (cuid → legacy store, run-ops id → new store), so the whole subgraph of a run
134200
// must agree. Two cases:
@@ -274,6 +340,8 @@ export class RunEngineTriggerTaskService {
274340
`Supported formats: {number}s, {number}m, {number}h, {number}d, {number}w`
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: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -459,4 +459,97 @@ describe("RunEngineTriggerTaskService", () => {
459459
expect(result).toBeDefined();
460460
expect(result?.run.friendlyId).toBeDefined();
461461
});
462+
463+
containerTest(
464+
"should reject a debounce window that leaves no room to extend the run",
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+
maximumDebounceDurationMs: 24 * 60 * 60 * 1000,
519+
});
520+
521+
const triggerWithDebounce = (debounce: { key: string; delay: string; maxDelay?: string }) =>
522+
triggerTaskService.call({
523+
taskId: taskIdentifier,
524+
environment: authenticatedEnvironment,
525+
body: { payload: { test: "test" }, options: { debounce } },
526+
});
527+
528+
await expect(triggerWithDebounce({ key: "at-the-ceiling", delay: "24h" })).rejects.toThrow(
529+
/at or above the maximum debounce duration/
530+
);
531+
532+
await expect(triggerWithDebounce({ key: "above-the-ceiling", delay: "48h" })).rejects.toThrow(
533+
/at or above the maximum debounce duration/
534+
);
535+
536+
await expect(
537+
triggerWithDebounce({ key: "delay-equals-max", delay: "12h", maxDelay: "12h" })
538+
).rejects.toThrow(/must be shorter than debounce.maxDelay/);
539+
540+
await expect(
541+
triggerWithDebounce({ key: "date-not-duration", delay: "2027-01-01T00:00:00.000Z" })
542+
).rejects.toThrow(/must be a duration, not a date/);
543+
544+
const belowCeiling = await triggerWithDebounce({ key: "below-the-ceiling", delay: "12h" });
545+
expect(belowCeiling?.run.friendlyId).toBeDefined();
546+
547+
const raisedCeiling = await triggerWithDebounce({
548+
key: "raised-ceiling",
549+
delay: "36h",
550+
maxDelay: "72h",
551+
});
552+
expect(raisedCeiling?.run.friendlyId).toBeDefined();
553+
}
554+
);
462555
});

docs/triggering.mdx

Lines changed: 36 additions & 2 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`. Defaults to the maximum debounce duration, which is 24 hours on Trigger.dev Cloud
877877

878878
**How it works:**
879879

@@ -882,9 +882,43 @@ 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+
**Your delay must fit inside the maximum:**
886+
887+
A run can only be pushed later while its new execution time stays inside `maxDelay`, measured from the first trigger. The room you have to push is therefore `maxDelay` minus `delay`. With `delay: "5s"` and the 24 hour default, a key can be pushed for almost a full day. With `delay: "24h"` and no `maxDelay`, there is no room at all: the first push is already out of bounds, so every trigger would create its own run.
888+
889+
Triggers like that are rejected rather than silently behaving as if you had not set a debounce:
890+
891+
```
892+
debounce.delay (24h) is at or above the maximum debounce duration of 1d. A debounced run
893+
can only be extended while it is inside that window, so with this delay every trigger would
894+
create its own run. Either shorten the delay, or set debounce.maxDelay above 24h to raise
895+
the ceiling for this trigger.
896+
```
897+
898+
To debounce for longer than 24 hours, set `maxDelay` above your `delay`:
899+
900+
```ts
901+
await myTask.trigger(
902+
{ conversationId: "123" },
903+
{
904+
debounce: {
905+
key: "conversation-123",
906+
delay: "12h", // Wait 12h after each trigger
907+
maxDelay: "36h", // Keep extending for up to 36h from the first trigger
908+
},
909+
}
910+
);
911+
```
912+
913+
<Warning>
914+
`delay` must be a duration string, not a date. A date is accepted by the `delay` option on a
915+
normal trigger, but `debounce.delay` is re-applied every time the run is pushed later, so it has
916+
to be relative.
917+
</Warning>
918+
885919
**Limiting total delay with `maxDelay`:**
886920

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.
921+
The `maxDelay` option sets an upper bound on the total delay from the first trigger, ensuring the run eventually executes even with constant activity.
888922

889923
```ts
890924
await summarizeChat.trigger(

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 ?? 24 * 60 * 60 * 1000,
388388
quantizeNewDelayUntilMs: options.debounce?.quantizeNewDelayUntilMs ?? 1000,
389389
fastPathSkipEnabled: options.debounce?.fastPathSkipEnabled ?? true,
390390
useReplicaForFastPathRead: options.debounce?.useReplicaForFastPathRead ?? false,

internal-packages/run-engine/src/engine/tests/debounce.test.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3177,4 +3177,81 @@ describe("RunEngine debounce", () => {
31773177
}
31783178
);
31793179
}
3180+
3181+
containerTest(
3182+
"Debounce: a long delay under the default maximum keeps extending one run",
3183+
async ({ prisma, redisOptions }) => {
3184+
const authenticatedEnvironment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION");
3185+
3186+
const engine = new RunEngine({
3187+
prisma,
3188+
worker: {
3189+
redis: redisOptions,
3190+
workers: 1,
3191+
tasksPerWorker: 10,
3192+
pollIntervalMs: 100,
3193+
},
3194+
queue: {
3195+
redis: redisOptions,
3196+
},
3197+
runLock: {
3198+
redis: redisOptions,
3199+
},
3200+
machines: {
3201+
defaultMachine: "small-1x",
3202+
machines: {
3203+
"small-1x": {
3204+
name: "small-1x" as const,
3205+
cpu: 0.5,
3206+
memory: 0.5,
3207+
centsPerMs: 0.0001,
3208+
},
3209+
},
3210+
baseCostInCents: 0.0001,
3211+
},
3212+
tracer: trace.getTracer("test", "0.0.0"),
3213+
});
3214+
3215+
try {
3216+
const taskIdentifier = "test-task";
3217+
3218+
await setupBackgroundWorker(engine, authenticatedEnvironment, taskIdentifier);
3219+
3220+
const runIds: string[] = [];
3221+
3222+
for (let i = 0; i < 4; i++) {
3223+
const run = await engine.trigger(
3224+
{
3225+
number: i + 1,
3226+
friendlyId: `run_long${i}`,
3227+
environment: authenticatedEnvironment,
3228+
taskIdentifier,
3229+
payload: `{"data": ${i}}`,
3230+
payloadType: "application/json",
3231+
context: {},
3232+
traceContext: {},
3233+
traceId: `t1234${i}`,
3234+
spanId: `s1234${i}`,
3235+
workerQueue: "main",
3236+
queue: "task/test-task",
3237+
isTest: false,
3238+
tags: [],
3239+
delayUntil: new Date(Date.now() + 12 * 60 * 60 * 1000),
3240+
debounce: {
3241+
key: "user-123",
3242+
delay: "12h",
3243+
},
3244+
},
3245+
prisma
3246+
);
3247+
3248+
runIds.push(run.id);
3249+
}
3250+
3251+
expect(new Set(runIds).size).toBe(1);
3252+
} finally {
3253+
await engine.quit();
3254+
}
3255+
}
3256+
);
31803257
});

0 commit comments

Comments
 (0)