Skip to content

Commit d7dd33b

Browse files
committed
fix(webapp,run-engine,core): drop the hidden debounce ceiling, fail fast on an unusable maxDelay
The engine applied a server-side ceiling on how long a debounced run could be pushed back, defaulting to an hour and documented nowhere. Any delay at or above it could never push its run, so every trigger created its own run with no error and nothing on the run to show the debounce key had been ignored. The ceiling is now unset by default, so a key keeps collapsing triggers for as long as they arrive and maxDelay is the only bound. Self-hosters can still set one. Callers who pass a maxDelay that is not longer than their delay hit the same dead end, so that pair is rejected at trigger time rather than silently doing nothing.
1 parent af9fbe4 commit d7dd33b

10 files changed

Lines changed: 106 additions & 165 deletions

File tree

.changeset/debounce-max-duration.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,16 @@
22
"@trigger.dev/core": patch
33
---
44

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.
5+
Debouncing with a `delay` longer than an hour now works. A hidden server-side limit was releasing debounced runs after an hour, so any `delay` at or above that never got to push its run back at all: every trigger created its own run, with no error and nothing on the run to show the debounce key had been ignored.
66

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.
7+
That limit is gone. A debounce key with no `maxDelay` now keeps pushing its run back for as long as triggers keep arriving, which means it never executes while they do. Set `maxDelay` when the work has to happen eventually, and keep `delay` well below it, since the room available to push is the gap between the two.
88

99
```ts
1010
await myTask.trigger(payload, {
1111
debounce: {
1212
key: "conversation-123",
13-
delay: "12h",
14-
maxDelay: "36h",
13+
delay: "10s",
14+
maxDelay: "5m",
1515
},
1616
});
1717
```

apps/webapp/app/env.server.ts

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1035,17 +1035,13 @@ const EnvironmentSchema = z
10351035
RUN_ENGINE_SUSPENDED_HEARTBEAT_RETRIES_FACTOR: z.coerce.number().default(2),
10361036

10371037
/**
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)
1038+
* Optional ceiling on how long a debounced run can be pushed back, measured from the first
1039+
* trigger. Unset by default: a continuously triggered debounce key is pushed back for as
1040+
* long as the triggers keep coming, and `debounce.maxDelay` on the trigger is the only
1041+
* bound. Setting this applies a ceiling to every debounced run that does not carry its own
1042+
* `maxDelay`, and any `delay` at or above it stops runs from being pushed at all.
10441043
*/
1045-
RUN_ENGINE_MAXIMUM_DEBOUNCE_DURATION_MS: z.coerce
1046-
.number()
1047-
.int()
1048-
.default(24 * 60 * 60 * 1000),
1044+
RUN_ENGINE_MAXIMUM_DEBOUNCE_DURATION_MS: z.coerce.number().int().optional(),
10491045

10501046
/**
10511047
* Bucket size in milliseconds used to quantize the newly computed `delayUntil`

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

Lines changed: 16 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import type { Tracer } from "@opentelemetry/api";
77
import { tryCatch } from "@trigger.dev/core/utils";
88
import {
99
type TriggerTaskRequestBody,
10-
formatDurationMilliseconds,
1110
RunAnnotations,
1211
TaskRunError,
1312
taskRunErrorEnhancer,
@@ -91,7 +90,6 @@ export class RunEngineTriggerTaskService {
9190
private readonly traceEventConcern: TraceEventConcern;
9291
private readonly triggerRacepointSystem: TriggerRacepointSystem;
9392
private readonly metadataMaximumSize: number;
94-
private readonly maximumDebounceDurationMs: number;
9593
// Mollifier hooks are DI'd so tests can drive the call-site's mollify branch
9694
// deterministically (stub the gate to return mollify, inject a real or fake
9795
// buffer, force the global-enabled predicate to true so the call site
@@ -111,7 +109,6 @@ export class RunEngineTriggerTaskService {
111109
traceEventConcern: TraceEventConcern;
112110
tracer: Tracer;
113111
metadataMaximumSize: number;
114-
maximumDebounceDurationMs?: number;
115112
triggerRacepointSystem?: TriggerRacepointSystem;
116113
evaluateGate?: MollifierEvaluateGate;
117114
getMollifierBuffer?: MollifierGetBuffer;
@@ -126,8 +123,6 @@ export class RunEngineTriggerTaskService {
126123
this.tracer = opts.tracer;
127124
this.traceEventConcern = opts.traceEventConcern;
128125
this.metadataMaximumSize = opts.metadataMaximumSize;
129-
this.maximumDebounceDurationMs =
130-
opts.maximumDebounceDurationMs ?? env.RUN_ENGINE_MAXIMUM_DEBOUNCE_DURATION_MS;
131126
this.triggerRacepointSystem = opts.triggerRacepointSystem ?? new NoopTriggerRacepointSystem();
132127
this.evaluateGate = opts.evaluateGate ?? defaultEvaluateGate;
133128
this.getMollifierBuffer = opts.getMollifierBuffer ?? defaultGetMollifierBuffer;
@@ -136,63 +131,37 @@ export class RunEngineTriggerTaskService {
136131
}
137132

138133
/**
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`.
134+
* A debounced run is only pushed back while its new execution time stays inside `maxDelay`,
135+
* so the room available to push is `maxDelay` minus `delay`. When a caller sets both and
136+
* leaves no room, the debounce key silently does nothing and every trigger creates its own
137+
* run, which is worse than being told. Only an explicit `maxDelay` is checked; with no
138+
* `maxDelay` there is no ceiling to conflict with.
147139
*/
148-
#validateDebounceWindow(
140+
#validateDebounceMaxDelay(
149141
debounce: NonNullable<NonNullable<TriggerTaskRequestBody["options"]>["debounce"]>
150142
) {
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-
);
143+
if (!debounce.maxDelay) {
144+
return;
159145
}
160146

161-
const maxDelayMs = debounce.maxDelay
162-
? parseNaturalLanguageDurationInMs(debounce.maxDelay)
163-
: undefined;
147+
const maxDelayMs = parseNaturalLanguageDurationInMs(debounce.maxDelay);
164148

165-
if (debounce.maxDelay && maxDelayMs === undefined) {
149+
if (maxDelayMs === undefined) {
166150
throw new ServiceValidationError(
167151
`Invalid debounce maxDelay: ${debounce.maxDelay}. ` +
168152
`Supported formats: {number}s, {number}m, {number}h, {number}d, {number}w`
169153
);
170154
}
171155

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" });
156+
const delayMs = parseNaturalLanguageDurationInMs(debounce.delay);
180157

181-
if (maxDelayMs !== undefined) {
158+
if (delayMs !== undefined && maxDelayMs <= delayMs) {
182159
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.`
160+
`debounce.maxDelay (${debounce.maxDelay}) must be longer than debounce.delay (${debounce.delay}). ` +
161+
`A debounced run is only pushed back while it stays inside maxDelay, so with these values ` +
162+
`every trigger would create its own run.`
187163
);
188164
}
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-
);
196165
}
197166

198167
// Mint a new run's friendlyId. The id-kind decides which store the run is born
@@ -341,7 +310,7 @@ export class RunEngineTriggerTaskService {
341310
);
342311
}
343312

344-
this.#validateDebounceWindow(body.options.debounce);
313+
this.#validateDebounceMaxDelay(body.options.debounce);
345314
}
346315

347316
const parentRun = body.options?.parentRunId

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

Lines changed: 16 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -461,7 +461,7 @@ describe("RunEngineTriggerTaskService", () => {
461461
});
462462

463463
containerTest(
464-
"should reject a debounce window that leaves no room to extend the run",
464+
"should reject a debounce maxDelay that leaves no room to push the run back",
465465
async ({ prisma, redisOptions }) => {
466466
const engine = new RunEngine({
467467
prisma,
@@ -515,7 +515,6 @@ describe("RunEngineTriggerTaskService", () => {
515515
traceEventConcern: new MockTraceEventConcern(),
516516
tracer: trace.getTracer("test", "0.0.0"),
517517
metadataMaximumSize: 1024 * 1024 * 1,
518-
maximumDebounceDurationMs: 24 * 60 * 60 * 1000,
519518
});
520519

521520
const triggerWithDebounce = (debounce: { key: string; delay: string; maxDelay?: string }) =>
@@ -525,31 +524,27 @@ describe("RunEngineTriggerTaskService", () => {
525524
body: { payload: { test: "test" }, options: { debounce } },
526525
});
527526

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-
536527
await expect(
537-
triggerWithDebounce({ key: "delay-equals-max", delay: "12h", maxDelay: "12h" })
538-
).rejects.toThrow(/must be shorter than debounce.maxDelay/);
528+
triggerWithDebounce({ key: "equal", delay: "12h", maxDelay: "12h" })
529+
).rejects.toThrow(/must be longer than debounce.delay/);
539530

540531
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/);
532+
triggerWithDebounce({ key: "shorter", delay: "12h", maxDelay: "1h" })
533+
).rejects.toThrow(/must be longer than debounce.delay/);
543534

544-
const belowCeiling = await triggerWithDebounce({ key: "below-the-ceiling", delay: "12h" });
545-
expect(belowCeiling?.run.friendlyId).toBeDefined();
535+
await expect(
536+
triggerWithDebounce({ key: "unparseable", delay: "10s", maxDelay: "soon" })
537+
).rejects.toThrow(/Invalid debounce maxDelay/);
546538

547-
const raisedCeiling = await triggerWithDebounce({
548-
key: "raised-ceiling",
549-
delay: "36h",
550-
maxDelay: "72h",
539+
const withRoom = await triggerWithDebounce({
540+
key: "with-room",
541+
delay: "10s",
542+
maxDelay: "5m",
551543
});
552-
expect(raisedCeiling?.run.friendlyId).toBeDefined();
544+
expect(withRoom?.run.friendlyId).toBeDefined();
545+
546+
const noMaxDelay = await triggerWithDebounce({ key: "no-max-delay", delay: "12h" });
547+
expect(noMaxDelay?.run.friendlyId).toBeDefined();
553548
}
554549
);
555550
});

docs/triggering.mdx

Lines changed: 7 additions & 32 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`. Defaults to the maximum debounce duration, which is 24 hours on Trigger.dev Cloud
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,38 +882,11 @@ 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-
913885
<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.
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.
917890
</Warning>
918891

919892
**Limiting total delay with `maxDelay`:**
@@ -955,6 +928,8 @@ Consider `delay: "5s"` and `maxDelay: "30s"` with triggers arriving every 2 seco
955928

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

931+
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: every trigger creates its own run and the debounce key has no effect.
932+
958933
<Note>
959934
The `maxDelay` value is evaluated from each trigger call, not stored with the original run. This
960935
means if you pass different `maxDelay` values for the same debounce key, each trigger uses its own

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 ?? 24 * 60 * 60 * 1000,
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)