Skip to content

Commit 04f9c4e

Browse files
authored
fix(webapp,run-engine,core): drop the hidden debounce ceiling, fail fast on an unusable maxDelay (#4521)
Debouncing with a `delay` longer than an hour did nothing at all. The engine applied a server-side ceiling on how long a debounced run could be pushed back, measured from the run's `createdAt` and defaulting to one hour. A run is only pushed back while its new execution time stays inside that ceiling, so a `delay` at or above it could never push anything: the waiting run was released, the trigger started its own run, and the next trigger repeated it. A `delay: "12h"` produced one run per trigger, each correctly delayed by 12h, with no error raised and nothing on the run to show the debounce key had been ignored. The ceiling is now unset by default. A debounce key with no `maxDelay` keeps collapsing triggers for as long as they keep arriving, which is what the docs have always described. Self-hosters who want a bound can still set `RUN_ENGINE_MAXIMUM_DEBOUNCE_DURATION_MS`. That has a consequence worth stating plainly, so the docs now carry a warning for it: with no `maxDelay`, a continuously triggered key never executes. Set `maxDelay` when the work has to happen eventually. **Failing fast on an unusable `maxDelay`.** A caller who sets `maxDelay` no longer than their `delay` hits exactly the dead end described above, so that pair is now rejected at trigger time instead of silently behaving as if no debounce were set: ``` debounce.maxDelay (1h) must be longer than debounce.delay (12h). A debounced run is only pushed back while it stays inside maxDelay, so with these values every trigger would create its own run. ``` An unparseable `maxDelay` is rejected too, rather than quietly falling back to no bound at all, and so is a `delay` given as a date rather than a duration, which could never work because the value is re-applied on every push. The same check runs against a configured server ceiling, so a self-hosted deployment that sets `RUN_ENGINE_MAXIMUM_DEBOUNCE_DURATION_MS` gets the error rather than the silent failure this PR is about. With no `maxDelay` and no configured ceiling, which is the default, there is nothing to conflict with and nothing is rejected. The docs, the `TriggerOptions` JSDoc and the engine option all now state that the room available to push is the gap between `delay` and `maxDelay`. The run engine suite gains the case that motivated this: four triggers on one key with a 12h delay now collapse to a single run.
1 parent c084fa6 commit 04f9c4e

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)