From af9fbe485a9d8352d1ea912618696808068da78d Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Thu, 6 Aug 2026 13:10:27 +0100 Subject: [PATCH 01/11] 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. --- .changeset/debounce-max-duration.md | 17 ++++ apps/webapp/app/env.server.ts | 11 ++- .../runEngine/services/triggerTask.server.ts | 68 ++++++++++++++ .../test/engine/triggerTask.debounce.test.ts | 93 +++++++++++++++++++ docs/triggering.mdx | 38 +++++++- .../run-engine/src/engine/index.ts | 2 +- .../src/engine/tests/debounce.test.ts | 77 +++++++++++++++ .../run-engine/src/engine/types.ts | 9 +- packages/core/src/v3/types/tasks.ts | 22 ++++- 9 files changed, 329 insertions(+), 8 deletions(-) create mode 100644 .changeset/debounce-max-duration.md diff --git a/.changeset/debounce-max-duration.md b/.changeset/debounce-max-duration.md new file mode 100644 index 00000000000..8dea20953a6 --- /dev/null +++ b/.changeset/debounce-max-duration.md @@ -0,0 +1,17 @@ +--- +"@trigger.dev/core": patch +--- + +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. + +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. + +```ts +await myTask.trigger(payload, { + debounce: { + key: "conversation-123", + delay: "12h", + maxDelay: "36h", + }, +}); +``` diff --git a/apps/webapp/app/env.server.ts b/apps/webapp/app/env.server.ts index 295b3a75daf..c385d13e46e 100644 --- a/apps/webapp/app/env.server.ts +++ b/apps/webapp/app/env.server.ts @@ -1034,11 +1034,18 @@ const EnvironmentSchema = z .default(60_000), RUN_ENGINE_SUSPENDED_HEARTBEAT_RETRIES_FACTOR: z.coerce.number().default(2), - /** Maximum duration in milliseconds that a run can be debounced. Default: 1 hour (3,600,000ms) */ + /** + * Ceiling on how long a debounced run can be pushed back, measured from the first trigger. + * Acts as the default when a trigger does not set `debounce.maxDelay`; a trigger that does + * set it overrides this entirely. A `debounce.delay` at or above the effective ceiling is + * rejected at trigger time, since no trigger could ever extend the run. + * + * Default: 24 hours (86,400,000ms) + */ RUN_ENGINE_MAXIMUM_DEBOUNCE_DURATION_MS: z.coerce .number() .int() - .default(60_000 * 60), // 1 hour + .default(24 * 60 * 60 * 1000), /** * Bucket size in milliseconds used to quantize the newly computed `delayUntil` diff --git a/apps/webapp/app/runEngine/services/triggerTask.server.ts b/apps/webapp/app/runEngine/services/triggerTask.server.ts index cb847f9fcb5..f66a9b2a788 100644 --- a/apps/webapp/app/runEngine/services/triggerTask.server.ts +++ b/apps/webapp/app/runEngine/services/triggerTask.server.ts @@ -7,6 +7,7 @@ import type { Tracer } from "@opentelemetry/api"; import { tryCatch } from "@trigger.dev/core/utils"; import { type TriggerTaskRequestBody, + formatDurationMilliseconds, RunAnnotations, TaskRunError, taskRunErrorEnhancer, @@ -14,6 +15,7 @@ import { TriggerTraceContext, } from "@trigger.dev/core/v3"; import { + parseNaturalLanguageDurationInMs, parseTraceparent, RunId, serializeTraceparent, @@ -89,6 +91,7 @@ export class RunEngineTriggerTaskService { private readonly traceEventConcern: TraceEventConcern; private readonly triggerRacepointSystem: TriggerRacepointSystem; private readonly metadataMaximumSize: number; + private readonly maximumDebounceDurationMs: number; // Mollifier hooks are DI'd so tests can drive the call-site's mollify branch // deterministically (stub the gate to return mollify, inject a real or fake // buffer, force the global-enabled predicate to true so the call site @@ -108,6 +111,7 @@ export class RunEngineTriggerTaskService { traceEventConcern: TraceEventConcern; tracer: Tracer; metadataMaximumSize: number; + maximumDebounceDurationMs?: number; triggerRacepointSystem?: TriggerRacepointSystem; evaluateGate?: MollifierEvaluateGate; getMollifierBuffer?: MollifierGetBuffer; @@ -122,6 +126,8 @@ export class RunEngineTriggerTaskService { this.tracer = opts.tracer; this.traceEventConcern = opts.traceEventConcern; this.metadataMaximumSize = opts.metadataMaximumSize; + this.maximumDebounceDurationMs = + opts.maximumDebounceDurationMs ?? env.RUN_ENGINE_MAXIMUM_DEBOUNCE_DURATION_MS; this.triggerRacepointSystem = opts.triggerRacepointSystem ?? new NoopTriggerRacepointSystem(); this.evaluateGate = opts.evaluateGate ?? defaultEvaluateGate; this.getMollifierBuffer = opts.getMollifierBuffer ?? defaultGetMollifierBuffer; @@ -129,6 +135,66 @@ export class RunEngineTriggerTaskService { opts.isMollifierGloballyEnabled ?? (() => env.TRIGGER_MOLLIFIER_ENABLED === "1"); } + /** + * Rejects debounce settings that can never debounce anything. + * + * A trigger extends an existing debounced run by moving its `delayUntil` to `now + delay`, + * and that is only allowed while the new time stays inside `createdAt + ceiling`, where the + * ceiling is the trigger's own `maxDelay` or the server default. So a `delay` at or above the + * ceiling means the very first extension is already out of bounds: every trigger starts its + * own run and the debounce key does nothing. The usable window for extensions is + * `ceiling - delay`. + */ + #validateDebounceWindow( + debounce: NonNullable["debounce"]> + ) { + const delayMs = parseNaturalLanguageDurationInMs(debounce.delay); + + if (delayMs === undefined) { + throw new ServiceValidationError( + `Invalid debounce delay: ${debounce.delay}. ` + + `debounce.delay must be a duration, not a date. ` + + `Supported formats: {number}s, {number}m, {number}h, {number}d, {number}w` + ); + } + + const maxDelayMs = debounce.maxDelay + ? parseNaturalLanguageDurationInMs(debounce.maxDelay) + : undefined; + + if (debounce.maxDelay && maxDelayMs === undefined) { + throw new ServiceValidationError( + `Invalid debounce maxDelay: ${debounce.maxDelay}. ` + + `Supported formats: {number}s, {number}m, {number}h, {number}d, {number}w` + ); + } + + const ceilingMs = maxDelayMs ?? this.maximumDebounceDurationMs; + + if (delayMs < ceilingMs) { + return; + } + + const delayText = formatDurationMilliseconds(delayMs, { style: "short" }); + const ceilingText = formatDurationMilliseconds(ceilingMs, { style: "short" }); + + if (maxDelayMs !== undefined) { + throw new ServiceValidationError( + `debounce.delay (${delayText}) must be shorter than debounce.maxDelay (${ceilingText}). ` + + `A debounced run can only be extended while it is inside the maxDelay window, so with ` + + `these values every trigger would create its own run. Raise maxDelay above the delay ` + + `to give yourself an extension window of maxDelay minus delay.` + ); + } + + throw new ServiceValidationError( + `debounce.delay (${delayText}) is at or above the maximum debounce duration of ${ceilingText}. ` + + `A debounced run can only be extended while it is inside that window, so with this delay ` + + `every trigger would create its own run. Either shorten the delay, or set ` + + `debounce.maxDelay above ${delayText} to raise the ceiling for this trigger.` + ); + } + // Mint a new run's friendlyId. The id-kind decides which store the run is born // in (cuid → legacy store, run-ops id → new store), so the whole subgraph of a run // must agree. Two cases: @@ -274,6 +340,8 @@ export class RunEngineTriggerTaskService { `Supported formats: {number}s, {number}m, {number}h, {number}d, {number}w` ); } + + this.#validateDebounceWindow(body.options.debounce); } const parentRun = body.options?.parentRunId diff --git a/apps/webapp/test/engine/triggerTask.debounce.test.ts b/apps/webapp/test/engine/triggerTask.debounce.test.ts index cc502c6b619..93a99d8af4b 100644 --- a/apps/webapp/test/engine/triggerTask.debounce.test.ts +++ b/apps/webapp/test/engine/triggerTask.debounce.test.ts @@ -459,4 +459,97 @@ describe("RunEngineTriggerTaskService", () => { expect(result).toBeDefined(); expect(result?.run.friendlyId).toBeDefined(); }); + + containerTest( + "should reject a debounce window that leaves no room to extend the run", + async ({ prisma, redisOptions }) => { + const engine = new RunEngine({ + prisma, + worker: { + redis: redisOptions, + workers: 1, + tasksPerWorker: 10, + pollIntervalMs: 100, + }, + queue: { + redis: redisOptions, + }, + runLock: { + redis: redisOptions, + }, + machines: { + defaultMachine: "small-1x", + machines: { + "small-1x": { + name: "small-1x" as const, + cpu: 0.5, + memory: 0.5, + centsPerMs: 0.0001, + }, + }, + baseCostInCents: 0.0005, + }, + tracer: trace.getTracer("test", "0.0.0"), + }); + onTestFinished(() => engine.quit()); + + const authenticatedEnvironment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION"); + const taskIdentifier = "test-task"; + + await setupBackgroundWorker(engine, authenticatedEnvironment, taskIdentifier); + + const queuesManager = new DefaultQueueManager(prisma, engine); + const idempotencyKeyConcern = new IdempotencyKeyConcern( + prisma, + engine, + new MockTraceEventConcern() + ); + + const triggerTaskService = new RunEngineTriggerTaskService({ + engine, + prisma, + payloadProcessor: new MockPayloadProcessor(), + queueConcern: queuesManager, + idempotencyKeyConcern, + validator: new MockTriggerTaskValidator(), + traceEventConcern: new MockTraceEventConcern(), + tracer: trace.getTracer("test", "0.0.0"), + metadataMaximumSize: 1024 * 1024 * 1, + maximumDebounceDurationMs: 24 * 60 * 60 * 1000, + }); + + const triggerWithDebounce = (debounce: { key: string; delay: string; maxDelay?: string }) => + triggerTaskService.call({ + taskId: taskIdentifier, + environment: authenticatedEnvironment, + body: { payload: { test: "test" }, options: { debounce } }, + }); + + await expect(triggerWithDebounce({ key: "at-the-ceiling", delay: "24h" })).rejects.toThrow( + /at or above the maximum debounce duration/ + ); + + await expect(triggerWithDebounce({ key: "above-the-ceiling", delay: "48h" })).rejects.toThrow( + /at or above the maximum debounce duration/ + ); + + await expect( + triggerWithDebounce({ key: "delay-equals-max", delay: "12h", maxDelay: "12h" }) + ).rejects.toThrow(/must be shorter than debounce.maxDelay/); + + await expect( + triggerWithDebounce({ key: "date-not-duration", delay: "2027-01-01T00:00:00.000Z" }) + ).rejects.toThrow(/must be a duration, not a date/); + + const belowCeiling = await triggerWithDebounce({ key: "below-the-ceiling", delay: "12h" }); + expect(belowCeiling?.run.friendlyId).toBeDefined(); + + const raisedCeiling = await triggerWithDebounce({ + key: "raised-ceiling", + delay: "36h", + maxDelay: "72h", + }); + expect(raisedCeiling?.run.friendlyId).toBeDefined(); + } + ); }); diff --git a/docs/triggering.mdx b/docs/triggering.mdx index cb2608269a9..b35aa99f78c 100644 --- a/docs/triggering.mdx +++ b/docs/triggering.mdx @@ -873,7 +873,7 @@ The `debounce` option accepts: - `key` - A unique string to identify the debounce group (scoped to the task) - `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"` - `mode` - Optional. Controls which trigger's data is used: `"leading"` (default) or `"trailing"` -- `maxDelay` - Optional. Maximum total time from the first trigger before the run must execute. Uses the same duration format as `delay` +- `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 **How it works:** @@ -882,9 +882,43 @@ The `debounce` option accepts: 3. Once no new triggers occur within the delay duration, the run executes 4. After the run starts executing, a new trigger with the same key will create a new run +**Your delay must fit inside the maximum:** + +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. + +Triggers like that are rejected rather than silently behaving as if you had not set a debounce: + +``` +debounce.delay (24h) is at or above the maximum debounce duration of 1d. A debounced run +can only be extended while it is inside that window, so with this delay every trigger would +create its own run. Either shorten the delay, or set debounce.maxDelay above 24h to raise +the ceiling for this trigger. +``` + +To debounce for longer than 24 hours, set `maxDelay` above your `delay`: + +```ts +await myTask.trigger( + { conversationId: "123" }, + { + debounce: { + key: "conversation-123", + delay: "12h", // Wait 12h after each trigger + maxDelay: "36h", // Keep extending for up to 36h from the first trigger + }, + } +); +``` + + + `delay` must be a duration string, not a date. A date is accepted by the `delay` option on a + normal trigger, but `debounce.delay` is re-applied every time the run is pushed later, so it has + to be relative. + + **Limiting total delay with `maxDelay`:** -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. +The `maxDelay` option sets an upper bound on the total delay from the first trigger, ensuring the run eventually executes even with constant activity. ```ts await summarizeChat.trigger( diff --git a/internal-packages/run-engine/src/engine/index.ts b/internal-packages/run-engine/src/engine/index.ts index b88f4f276e4..a740d4c71a5 100644 --- a/internal-packages/run-engine/src/engine/index.ts +++ b/internal-packages/run-engine/src/engine/index.ts @@ -384,7 +384,7 @@ export class RunEngine { redis: options.debounce?.redis ?? options.runLock.redis, executionSnapshotSystem: this.executionSnapshotSystem, delayedRunSystem: this.delayedRunSystem, - maxDebounceDurationMs: options.debounce?.maxDebounceDurationMs ?? 60 * 60 * 1000, // Default 1 hour + maxDebounceDurationMs: options.debounce?.maxDebounceDurationMs ?? 24 * 60 * 60 * 1000, quantizeNewDelayUntilMs: options.debounce?.quantizeNewDelayUntilMs ?? 1000, fastPathSkipEnabled: options.debounce?.fastPathSkipEnabled ?? true, useReplicaForFastPathRead: options.debounce?.useReplicaForFastPathRead ?? false, diff --git a/internal-packages/run-engine/src/engine/tests/debounce.test.ts b/internal-packages/run-engine/src/engine/tests/debounce.test.ts index d926ddecb34..98fcd8d5f54 100644 --- a/internal-packages/run-engine/src/engine/tests/debounce.test.ts +++ b/internal-packages/run-engine/src/engine/tests/debounce.test.ts @@ -3177,4 +3177,81 @@ describe("RunEngine debounce", () => { } ); } + + containerTest( + "Debounce: a long delay under the default maximum keeps extending one run", + async ({ prisma, redisOptions }) => { + const authenticatedEnvironment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION"); + + const engine = new RunEngine({ + prisma, + worker: { + redis: redisOptions, + workers: 1, + tasksPerWorker: 10, + pollIntervalMs: 100, + }, + queue: { + redis: redisOptions, + }, + runLock: { + redis: redisOptions, + }, + machines: { + defaultMachine: "small-1x", + machines: { + "small-1x": { + name: "small-1x" as const, + cpu: 0.5, + memory: 0.5, + centsPerMs: 0.0001, + }, + }, + baseCostInCents: 0.0001, + }, + tracer: trace.getTracer("test", "0.0.0"), + }); + + try { + const taskIdentifier = "test-task"; + + await setupBackgroundWorker(engine, authenticatedEnvironment, taskIdentifier); + + const runIds: string[] = []; + + for (let i = 0; i < 4; i++) { + const run = await engine.trigger( + { + number: i + 1, + friendlyId: `run_long${i}`, + environment: authenticatedEnvironment, + taskIdentifier, + payload: `{"data": ${i}}`, + payloadType: "application/json", + context: {}, + traceContext: {}, + traceId: `t1234${i}`, + spanId: `s1234${i}`, + workerQueue: "main", + queue: "task/test-task", + isTest: false, + tags: [], + delayUntil: new Date(Date.now() + 12 * 60 * 60 * 1000), + debounce: { + key: "user-123", + delay: "12h", + }, + }, + prisma + ); + + runIds.push(run.id); + } + + expect(new Set(runIds).size).toBe(1); + } finally { + await engine.quit(); + } + } + ); }); diff --git a/internal-packages/run-engine/src/engine/types.ts b/internal-packages/run-engine/src/engine/types.ts index f37ec7df50a..9170633cace 100644 --- a/internal-packages/run-engine/src/engine/types.ts +++ b/internal-packages/run-engine/src/engine/types.ts @@ -168,7 +168,14 @@ export type RunEngineOptions = { }; debounce?: { redis?: RedisOptions; - /** Maximum duration in milliseconds that a run can be debounced. Default: 1 hour */ + /** + * Ceiling on how long a debounced run can be pushed back, measured from the run's + * `createdAt`. A trigger's own `debounce.maxDelay` overrides this. Once a trigger would + * push `delayUntil` past the ceiling, the existing run is released to execute and the + * trigger starts a new one. + * + * Default: 24 hours + */ maxDebounceDurationMs?: number; /** * Bucket size in milliseconds used to quantize the newly computed `delayUntil`. diff --git a/packages/core/src/v3/types/tasks.ts b/packages/core/src/v3/types/tasks.ts index 0e1ab20daf6..f11fa063516 100644 --- a/packages/core/src/v3/types/tasks.ts +++ b/packages/core/src/v3/types/tasks.ts @@ -940,6 +940,14 @@ export type TriggerOptions = { * * The debounce key is scoped to the task identifier, so different tasks can use the same key without conflicts. * + * Extensions are bounded. A run can only be pushed back while its new execution time stays + * inside `maxDelay` (or the server's maximum debounce duration, 24 hours on Trigger.dev Cloud) + * measured from the first trigger. Once a trigger would push past that, the waiting run is + * released to execute and the trigger starts a fresh one. This means `delay` must be shorter + * than that ceiling: a `delay` of `"24h"` with no `maxDelay` leaves no room to extend at all, + * so every trigger would create its own run. Triggers like that are rejected rather than + * silently behaving as if no debounce were set. + * * @example * * ```ts @@ -964,8 +972,13 @@ export type TriggerOptions = { * Duration string specifying how long to delay the run. If another trigger with the same key * occurs within this duration, the delay is extended. * + * Must be shorter than `maxDelay` (or the server's maximum debounce duration when `maxDelay` + * is not set), otherwise the trigger is rejected: there would be no room to extend the run + * and every trigger would create its own. + * * Supported formats: `{number}s` (seconds), `{number}m` (minutes), `{number}h` (hours), - * `{number}d` (days), `{number}w` (weeks). Minimum delay is 1 second. + * `{number}d` (days), `{number}w` (weeks). Must be a duration, not a date. Minimum delay is + * 1 second. * * @example "1s", "5s", "1m", "30m", "1h" */ @@ -988,7 +1001,12 @@ export type TriggerOptions = { * (measured from the first trigger), the current debounced run will be allowed to execute * and a new run will be created for subsequent triggers. * - * If not specified, falls back to the server's default maximum (typically 1 hour). + * Set this above `delay` to debounce for longer than the server's maximum debounce duration + * (24 hours on Trigger.dev Cloud). The gap between the two is what you actually get: a + * `delay` of `"12h"` with a `maxDelay` of `"36h"` keeps extending the run for up to 24 hours + * after the first trigger, then runs it. + * + * If not specified, falls back to the server's maximum debounce duration. * * Supported formats: `{number}s` (seconds), `{number}m` (minutes), `{number}h` (hours), * `{number}d` (days), `{number}w` (weeks). From d7dd33b0b64de40493eac60165d95f3fc2826229 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Thu, 6 Aug 2026 14:12:14 +0100 Subject: [PATCH 02/11] 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. --- .changeset/debounce-max-duration.md | 8 +-- apps/webapp/app/env.server.ts | 16 ++--- .../runEngine/services/triggerTask.server.ts | 63 +++++------------ .../test/engine/triggerTask.debounce.test.ts | 37 +++++----- docs/triggering.mdx | 39 ++--------- .../run-engine/src/engine/index.ts | 2 +- .../src/engine/systems/debounceSystem.ts | 67 +++++++++++-------- .../src/engine/tests/debounce.test.ts | 2 +- .../run-engine/src/engine/types.ts | 12 ++-- packages/core/src/v3/types/tasks.ts | 25 +++---- 10 files changed, 106 insertions(+), 165 deletions(-) diff --git a/.changeset/debounce-max-duration.md b/.changeset/debounce-max-duration.md index 8dea20953a6..98687ecdb8a 100644 --- a/.changeset/debounce-max-duration.md +++ b/.changeset/debounce-max-duration.md @@ -2,16 +2,16 @@ "@trigger.dev/core": patch --- -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. +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. -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. +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. ```ts await myTask.trigger(payload, { debounce: { key: "conversation-123", - delay: "12h", - maxDelay: "36h", + delay: "10s", + maxDelay: "5m", }, }); ``` diff --git a/apps/webapp/app/env.server.ts b/apps/webapp/app/env.server.ts index c385d13e46e..e4fda321e5e 100644 --- a/apps/webapp/app/env.server.ts +++ b/apps/webapp/app/env.server.ts @@ -1035,17 +1035,13 @@ const EnvironmentSchema = z RUN_ENGINE_SUSPENDED_HEARTBEAT_RETRIES_FACTOR: z.coerce.number().default(2), /** - * Ceiling on how long a debounced run can be pushed back, measured from the first trigger. - * Acts as the default when a trigger does not set `debounce.maxDelay`; a trigger that does - * set it overrides this entirely. A `debounce.delay` at or above the effective ceiling is - * rejected at trigger time, since no trigger could ever extend the run. - * - * Default: 24 hours (86,400,000ms) + * Optional ceiling on how long a debounced run can be pushed back, measured from the first + * trigger. Unset by default: a continuously triggered debounce key is pushed back for as + * long as the triggers keep coming, and `debounce.maxDelay` on the trigger is the only + * bound. Setting this applies a ceiling to every debounced run that does not carry its own + * `maxDelay`, and any `delay` at or above it stops runs from being pushed at all. */ - RUN_ENGINE_MAXIMUM_DEBOUNCE_DURATION_MS: z.coerce - .number() - .int() - .default(24 * 60 * 60 * 1000), + RUN_ENGINE_MAXIMUM_DEBOUNCE_DURATION_MS: z.coerce.number().int().optional(), /** * Bucket size in milliseconds used to quantize the newly computed `delayUntil` diff --git a/apps/webapp/app/runEngine/services/triggerTask.server.ts b/apps/webapp/app/runEngine/services/triggerTask.server.ts index f66a9b2a788..53c832e7501 100644 --- a/apps/webapp/app/runEngine/services/triggerTask.server.ts +++ b/apps/webapp/app/runEngine/services/triggerTask.server.ts @@ -7,7 +7,6 @@ import type { Tracer } from "@opentelemetry/api"; import { tryCatch } from "@trigger.dev/core/utils"; import { type TriggerTaskRequestBody, - formatDurationMilliseconds, RunAnnotations, TaskRunError, taskRunErrorEnhancer, @@ -91,7 +90,6 @@ export class RunEngineTriggerTaskService { private readonly traceEventConcern: TraceEventConcern; private readonly triggerRacepointSystem: TriggerRacepointSystem; private readonly metadataMaximumSize: number; - private readonly maximumDebounceDurationMs: number; // Mollifier hooks are DI'd so tests can drive the call-site's mollify branch // deterministically (stub the gate to return mollify, inject a real or fake // buffer, force the global-enabled predicate to true so the call site @@ -111,7 +109,6 @@ export class RunEngineTriggerTaskService { traceEventConcern: TraceEventConcern; tracer: Tracer; metadataMaximumSize: number; - maximumDebounceDurationMs?: number; triggerRacepointSystem?: TriggerRacepointSystem; evaluateGate?: MollifierEvaluateGate; getMollifierBuffer?: MollifierGetBuffer; @@ -126,8 +123,6 @@ export class RunEngineTriggerTaskService { this.tracer = opts.tracer; this.traceEventConcern = opts.traceEventConcern; this.metadataMaximumSize = opts.metadataMaximumSize; - this.maximumDebounceDurationMs = - opts.maximumDebounceDurationMs ?? env.RUN_ENGINE_MAXIMUM_DEBOUNCE_DURATION_MS; this.triggerRacepointSystem = opts.triggerRacepointSystem ?? new NoopTriggerRacepointSystem(); this.evaluateGate = opts.evaluateGate ?? defaultEvaluateGate; this.getMollifierBuffer = opts.getMollifierBuffer ?? defaultGetMollifierBuffer; @@ -136,63 +131,37 @@ export class RunEngineTriggerTaskService { } /** - * Rejects debounce settings that can never debounce anything. - * - * A trigger extends an existing debounced run by moving its `delayUntil` to `now + delay`, - * and that is only allowed while the new time stays inside `createdAt + ceiling`, where the - * ceiling is the trigger's own `maxDelay` or the server default. So a `delay` at or above the - * ceiling means the very first extension is already out of bounds: every trigger starts its - * own run and the debounce key does nothing. The usable window for extensions is - * `ceiling - delay`. + * A debounced run is only pushed back while its new execution time stays inside `maxDelay`, + * so the room available to push is `maxDelay` minus `delay`. When a caller sets both and + * leaves no room, the debounce key silently does nothing and every trigger creates its own + * run, which is worse than being told. Only an explicit `maxDelay` is checked; with no + * `maxDelay` there is no ceiling to conflict with. */ - #validateDebounceWindow( + #validateDebounceMaxDelay( debounce: NonNullable["debounce"]> ) { - const delayMs = parseNaturalLanguageDurationInMs(debounce.delay); - - if (delayMs === undefined) { - throw new ServiceValidationError( - `Invalid debounce delay: ${debounce.delay}. ` + - `debounce.delay must be a duration, not a date. ` + - `Supported formats: {number}s, {number}m, {number}h, {number}d, {number}w` - ); + if (!debounce.maxDelay) { + return; } - const maxDelayMs = debounce.maxDelay - ? parseNaturalLanguageDurationInMs(debounce.maxDelay) - : undefined; + const maxDelayMs = parseNaturalLanguageDurationInMs(debounce.maxDelay); - if (debounce.maxDelay && maxDelayMs === undefined) { + if (maxDelayMs === undefined) { throw new ServiceValidationError( `Invalid debounce maxDelay: ${debounce.maxDelay}. ` + `Supported formats: {number}s, {number}m, {number}h, {number}d, {number}w` ); } - const ceilingMs = maxDelayMs ?? this.maximumDebounceDurationMs; - - if (delayMs < ceilingMs) { - return; - } - - const delayText = formatDurationMilliseconds(delayMs, { style: "short" }); - const ceilingText = formatDurationMilliseconds(ceilingMs, { style: "short" }); + const delayMs = parseNaturalLanguageDurationInMs(debounce.delay); - if (maxDelayMs !== undefined) { + if (delayMs !== undefined && maxDelayMs <= delayMs) { throw new ServiceValidationError( - `debounce.delay (${delayText}) must be shorter than debounce.maxDelay (${ceilingText}). ` + - `A debounced run can only be extended while it is inside the maxDelay window, so with ` + - `these values every trigger would create its own run. Raise maxDelay above the delay ` + - `to give yourself an extension window of maxDelay minus delay.` + `debounce.maxDelay (${debounce.maxDelay}) must be longer than debounce.delay (${debounce.delay}). ` + + `A debounced run is only pushed back while it stays inside maxDelay, so with these values ` + + `every trigger would create its own run.` ); } - - throw new ServiceValidationError( - `debounce.delay (${delayText}) is at or above the maximum debounce duration of ${ceilingText}. ` + - `A debounced run can only be extended while it is inside that window, so with this delay ` + - `every trigger would create its own run. Either shorten the delay, or set ` + - `debounce.maxDelay above ${delayText} to raise the ceiling for this trigger.` - ); } // Mint a new run's friendlyId. The id-kind decides which store the run is born @@ -341,7 +310,7 @@ export class RunEngineTriggerTaskService { ); } - this.#validateDebounceWindow(body.options.debounce); + this.#validateDebounceMaxDelay(body.options.debounce); } const parentRun = body.options?.parentRunId diff --git a/apps/webapp/test/engine/triggerTask.debounce.test.ts b/apps/webapp/test/engine/triggerTask.debounce.test.ts index 93a99d8af4b..07e6e057b94 100644 --- a/apps/webapp/test/engine/triggerTask.debounce.test.ts +++ b/apps/webapp/test/engine/triggerTask.debounce.test.ts @@ -461,7 +461,7 @@ describe("RunEngineTriggerTaskService", () => { }); containerTest( - "should reject a debounce window that leaves no room to extend the run", + "should reject a debounce maxDelay that leaves no room to push the run back", async ({ prisma, redisOptions }) => { const engine = new RunEngine({ prisma, @@ -515,7 +515,6 @@ describe("RunEngineTriggerTaskService", () => { traceEventConcern: new MockTraceEventConcern(), tracer: trace.getTracer("test", "0.0.0"), metadataMaximumSize: 1024 * 1024 * 1, - maximumDebounceDurationMs: 24 * 60 * 60 * 1000, }); const triggerWithDebounce = (debounce: { key: string; delay: string; maxDelay?: string }) => @@ -525,31 +524,27 @@ describe("RunEngineTriggerTaskService", () => { body: { payload: { test: "test" }, options: { debounce } }, }); - await expect(triggerWithDebounce({ key: "at-the-ceiling", delay: "24h" })).rejects.toThrow( - /at or above the maximum debounce duration/ - ); - - await expect(triggerWithDebounce({ key: "above-the-ceiling", delay: "48h" })).rejects.toThrow( - /at or above the maximum debounce duration/ - ); - await expect( - triggerWithDebounce({ key: "delay-equals-max", delay: "12h", maxDelay: "12h" }) - ).rejects.toThrow(/must be shorter than debounce.maxDelay/); + triggerWithDebounce({ key: "equal", delay: "12h", maxDelay: "12h" }) + ).rejects.toThrow(/must be longer than debounce.delay/); await expect( - triggerWithDebounce({ key: "date-not-duration", delay: "2027-01-01T00:00:00.000Z" }) - ).rejects.toThrow(/must be a duration, not a date/); + triggerWithDebounce({ key: "shorter", delay: "12h", maxDelay: "1h" }) + ).rejects.toThrow(/must be longer than debounce.delay/); - const belowCeiling = await triggerWithDebounce({ key: "below-the-ceiling", delay: "12h" }); - expect(belowCeiling?.run.friendlyId).toBeDefined(); + await expect( + triggerWithDebounce({ key: "unparseable", delay: "10s", maxDelay: "soon" }) + ).rejects.toThrow(/Invalid debounce maxDelay/); - const raisedCeiling = await triggerWithDebounce({ - key: "raised-ceiling", - delay: "36h", - maxDelay: "72h", + const withRoom = await triggerWithDebounce({ + key: "with-room", + delay: "10s", + maxDelay: "5m", }); - expect(raisedCeiling?.run.friendlyId).toBeDefined(); + expect(withRoom?.run.friendlyId).toBeDefined(); + + const noMaxDelay = await triggerWithDebounce({ key: "no-max-delay", delay: "12h" }); + expect(noMaxDelay?.run.friendlyId).toBeDefined(); } ); }); diff --git a/docs/triggering.mdx b/docs/triggering.mdx index b35aa99f78c..433467f9816 100644 --- a/docs/triggering.mdx +++ b/docs/triggering.mdx @@ -873,7 +873,7 @@ The `debounce` option accepts: - `key` - A unique string to identify the debounce group (scoped to the task) - `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"` - `mode` - Optional. Controls which trigger's data is used: `"leading"` (default) or `"trailing"` -- `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 +- `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 **How it works:** @@ -882,38 +882,11 @@ The `debounce` option accepts: 3. Once no new triggers occur within the delay duration, the run executes 4. After the run starts executing, a new trigger with the same key will create a new run -**Your delay must fit inside the maximum:** - -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. - -Triggers like that are rejected rather than silently behaving as if you had not set a debounce: - -``` -debounce.delay (24h) is at or above the maximum debounce duration of 1d. A debounced run -can only be extended while it is inside that window, so with this delay every trigger would -create its own run. Either shorten the delay, or set debounce.maxDelay above 24h to raise -the ceiling for this trigger. -``` - -To debounce for longer than 24 hours, set `maxDelay` above your `delay`: - -```ts -await myTask.trigger( - { conversationId: "123" }, - { - debounce: { - key: "conversation-123", - delay: "12h", // Wait 12h after each trigger - maxDelay: "36h", // Keep extending for up to 36h from the first trigger - }, - } -); -``` - - `delay` must be a duration string, not a date. A date is accepted by the `delay` option on a - normal trigger, but `debounce.delay` is re-applied every time the run is pushed later, so it has - to be relative. + There is no time limit on step 2. While triggers keep arriving on the same key, the run keeps + being pushed back and never executes. A key triggered every 10 seconds with a `delay` of `"30s"` + runs 30 seconds after the triggers stop, however long that takes. Set `maxDelay` whenever the + work needs to happen eventually. **Limiting total delay with `maxDelay`:** @@ -955,6 +928,8 @@ Consider `delay: "5s"` and `maxDelay: "30s"` with triggers arriving every 2 seco Without `maxDelay`, continuous triggers would prevent the run from ever executing. With `maxDelay: "30s"`, execution is guaranteed within 30 seconds of the first trigger. +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. + The `maxDelay` value is evaluated from each trigger call, not stored with the original run. This means if you pass different `maxDelay` values for the same debounce key, each trigger uses its own diff --git a/internal-packages/run-engine/src/engine/index.ts b/internal-packages/run-engine/src/engine/index.ts index a740d4c71a5..b6e46ce05f5 100644 --- a/internal-packages/run-engine/src/engine/index.ts +++ b/internal-packages/run-engine/src/engine/index.ts @@ -384,7 +384,7 @@ export class RunEngine { redis: options.debounce?.redis ?? options.runLock.redis, executionSnapshotSystem: this.executionSnapshotSystem, delayedRunSystem: this.delayedRunSystem, - maxDebounceDurationMs: options.debounce?.maxDebounceDurationMs ?? 24 * 60 * 60 * 1000, + maxDebounceDurationMs: options.debounce?.maxDebounceDurationMs, quantizeNewDelayUntilMs: options.debounce?.quantizeNewDelayUntilMs ?? 1000, fastPathSkipEnabled: options.debounce?.fastPathSkipEnabled ?? true, useReplicaForFastPathRead: options.debounce?.useReplicaForFastPathRead ?? false, diff --git a/internal-packages/run-engine/src/engine/systems/debounceSystem.ts b/internal-packages/run-engine/src/engine/systems/debounceSystem.ts index ae37df9a9e0..8d8860a1fc6 100644 --- a/internal-packages/run-engine/src/engine/systems/debounceSystem.ts +++ b/internal-packages/run-engine/src/engine/systems/debounceSystem.ts @@ -53,7 +53,12 @@ export type DebounceSystemOptions = { redis: RedisOptions; executionSnapshotSystem: ExecutionSnapshotSystem; delayedRunSystem: DelayedRunSystem; - maxDebounceDurationMs: number; + /** + * Optional server-side ceiling on how long a debounced run can be pushed back, measured + * from the run's `createdAt`. Unset means there is no ceiling and a continuously triggered + * key can be pushed back indefinitely; a trigger's own `maxDelay` is then the only bound. + */ + maxDebounceDurationMs?: number; /** * Bucket size in milliseconds used to quantize the newly computed `delayUntil`. * Set to 0 to disable quantization. @@ -113,7 +118,7 @@ export class DebounceSystem { private readonly redis: Redis; private readonly executionSnapshotSystem: ExecutionSnapshotSystem; private readonly delayedRunSystem: DelayedRunSystem; - private readonly maxDebounceDurationMs: number; + private readonly maxDebounceDurationMs: number | undefined; private readonly quantizeNewDelayUntilMs: number; private readonly fastPathSkipEnabled: boolean; private readonly useReplicaForFastPathRead: boolean; @@ -568,6 +573,29 @@ return 0 return new Date(quantized); } + /** + * How long after a run's `createdAt` triggers may keep pushing it back, or `undefined` for + * no bound at all. A trigger's own `maxDelay` wins; otherwise the server ceiling applies, + * which is itself unset by default. An unparseable `maxDelay` falls back to the ceiling. + */ + #resolveMaxDurationMs(debounce: DebounceOptions): number | undefined { + if (!debounce.maxDelay) { + return this.maxDebounceDurationMs; + } + + const parsedMaxDelay = parseNaturalLanguageDurationInMs(debounce.maxDelay); + + if (parsedMaxDelay === undefined) { + this.$.logger.warn("handleExistingRun: invalid maxDelay duration, using server ceiling", { + maxDelay: debounce.maxDelay, + fallbackMs: this.maxDebounceDurationMs, + }); + return this.maxDebounceDurationMs; + } + + return parsedMaxDelay; + } + #isLockContentionError(error: unknown): boolean { if (!(error instanceof Error)) return false; return ( @@ -624,17 +652,13 @@ return 0 // Fall through to the lock path when newDelayUntil would exceed the run's // max debounce window so the caller can return max_duration_exceeded and // create a fresh run. - let maxDurationMs = this.maxDebounceDurationMs; - if (debounce.maxDelay) { - const parsedMaxDelay = parseNaturalLanguageDurationInMs(debounce.maxDelay); - if (parsedMaxDelay !== undefined) { - maxDurationMs = parsedMaxDelay; + const maxDurationMs = this.#resolveMaxDurationMs(debounce); + if (maxDurationMs !== undefined) { + const maxDelayUntilMs = probe.createdAt.getTime() + maxDurationMs; + if (newDelayUntil.getTime() > maxDelayUntilMs) { + return null; } } - const maxDelayUntilMs = probe.createdAt.getTime() + maxDurationMs; - if (newDelayUntil.getTime() > maxDelayUntilMs) { - return null; - } const fullRun = await this.$.runStore.findRun( { id: existingRunId }, @@ -819,25 +843,12 @@ return 0 }); } - // Check if max debounce duration would be exceeded - // Use per-trigger maxDelay if provided, otherwise use global config - let maxDurationMs = this.maxDebounceDurationMs; - if (debounce.maxDelay) { - const parsedMaxDelay = parseNaturalLanguageDurationInMs(debounce.maxDelay); - if (parsedMaxDelay !== undefined) { - maxDurationMs = parsedMaxDelay; - } else { - this.$.logger.warn("handleExistingRun: invalid maxDelay duration, using global config", { - maxDelay: debounce.maxDelay, - fallbackMs: this.maxDebounceDurationMs, - }); - } - } - + const maxDurationMs = this.#resolveMaxDurationMs(debounce); const runCreatedAt = existingRun.createdAt; - const maxDelayUntil = new Date(runCreatedAt.getTime() + maxDurationMs); + const maxDelayUntil = + maxDurationMs === undefined ? undefined : new Date(runCreatedAt.getTime() + maxDurationMs); - if (newDelayUntil > maxDelayUntil) { + if (maxDelayUntil && newDelayUntil > maxDelayUntil) { this.$.logger.debug("handleExistingRun: max debounce duration would be exceeded", { existingRunId, debounceKey: debounce.key, diff --git a/internal-packages/run-engine/src/engine/tests/debounce.test.ts b/internal-packages/run-engine/src/engine/tests/debounce.test.ts index 98fcd8d5f54..6094ac7daa7 100644 --- a/internal-packages/run-engine/src/engine/tests/debounce.test.ts +++ b/internal-packages/run-engine/src/engine/tests/debounce.test.ts @@ -3179,7 +3179,7 @@ describe("RunEngine debounce", () => { } containerTest( - "Debounce: a long delay under the default maximum keeps extending one run", + "Debounce: with no server ceiling configured, a long delay keeps extending one run", async ({ prisma, redisOptions }) => { const authenticatedEnvironment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION"); diff --git a/internal-packages/run-engine/src/engine/types.ts b/internal-packages/run-engine/src/engine/types.ts index 9170633cace..547852803b2 100644 --- a/internal-packages/run-engine/src/engine/types.ts +++ b/internal-packages/run-engine/src/engine/types.ts @@ -169,12 +169,14 @@ export type RunEngineOptions = { debounce?: { redis?: RedisOptions; /** - * Ceiling on how long a debounced run can be pushed back, measured from the run's - * `createdAt`. A trigger's own `debounce.maxDelay` overrides this. Once a trigger would - * push `delayUntil` past the ceiling, the existing run is released to execute and the - * trigger starts a new one. + * Optional ceiling on how long a debounced run can be pushed back, measured from the run's + * `createdAt`. A trigger's own `debounce.maxDelay` overrides this. Once a trigger would push + * `delayUntil` past the ceiling, the existing run is released to execute and the trigger + * starts a new one. * - * Default: 24 hours + * Unset by default, which means a continuously triggered key is pushed back for as long as + * the triggers keep coming. Set it to bound that; note that any `delay` at or above the + * ceiling stops the run from ever being pushed, so every trigger creates its own run. */ maxDebounceDurationMs?: number; /** diff --git a/packages/core/src/v3/types/tasks.ts b/packages/core/src/v3/types/tasks.ts index f11fa063516..0ce35b5c071 100644 --- a/packages/core/src/v3/types/tasks.ts +++ b/packages/core/src/v3/types/tasks.ts @@ -940,13 +940,8 @@ export type TriggerOptions = { * * The debounce key is scoped to the task identifier, so different tasks can use the same key without conflicts. * - * Extensions are bounded. A run can only be pushed back while its new execution time stays - * inside `maxDelay` (or the server's maximum debounce duration, 24 hours on Trigger.dev Cloud) - * measured from the first trigger. Once a trigger would push past that, the waiting run is - * released to execute and the trigger starts a fresh one. This means `delay` must be shorter - * than that ceiling: a `delay` of `"24h"` with no `maxDelay` leaves no room to extend at all, - * so every trigger would create its own run. Triggers like that are rejected rather than - * silently behaving as if no debounce were set. + * There is no time limit by default: while triggers keep arriving on the same key, the run + * keeps being pushed back and never executes. Set `maxDelay` to bound that. * * @example * @@ -972,9 +967,9 @@ export type TriggerOptions = { * Duration string specifying how long to delay the run. If another trigger with the same key * occurs within this duration, the delay is extended. * - * Must be shorter than `maxDelay` (or the server's maximum debounce duration when `maxDelay` - * is not set), otherwise the trigger is rejected: there would be no room to extend the run - * and every trigger would create its own. + * When you also set `maxDelay`, keep `delay` well below it. A run is only pushed back while + * the new execution time stays inside `maxDelay`, so a `delay` at or above `maxDelay` leaves + * no room to push and every trigger creates its own run. * * Supported formats: `{number}s` (seconds), `{number}m` (minutes), `{number}h` (hours), * `{number}d` (days), `{number}w` (weeks). Must be a duration, not a date. Minimum delay is @@ -1001,12 +996,10 @@ export type TriggerOptions = { * (measured from the first trigger), the current debounced run will be allowed to execute * and a new run will be created for subsequent triggers. * - * Set this above `delay` to debounce for longer than the server's maximum debounce duration - * (24 hours on Trigger.dev Cloud). The gap between the two is what you actually get: a - * `delay` of `"12h"` with a `maxDelay` of `"36h"` keeps extending the run for up to 24 hours - * after the first trigger, then runs it. - * - * If not specified, falls back to the server's maximum debounce duration. + * Without it a continuously triggered key is pushed back indefinitely and never runs. The + * gap between the two values is the room you have to push: a `delay` of `"10s"` with a + * `maxDelay` of `"5m"` keeps extending for just under 5 minutes from the first trigger, + * then runs. * * Supported formats: `{number}s` (seconds), `{number}m` (minutes), `{number}h` (hours), * `{number}d` (days), `{number}w` (weeks). From 13da5979fb462cc6b653e25e9081915df3474f6e Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Thu, 6 Aug 2026 16:33:43 +0100 Subject: [PATCH 03/11] fix(webapp,run-engine,core): treat an empty maxDelay as invalid and document the duration grammar An empty string passed as maxDelay was falsy everywhere it was checked, so it read as no ceiling at all rather than as the invalid value it is. The server ceiling now also rejects zero and negative values at startup, and the duration grammar in the errors and JSDoc lists the hr suffix and compound forms that the parser has always accepted. --- .changeset/debounce-max-duration.md | 2 ++ apps/webapp/app/env.server.ts | 2 +- .../runEngine/services/triggerTask.server.ts | 8 +++++--- .../test/engine/triggerTask.debounce.test.ts | 11 +++++++++++ .../src/engine/systems/debounceSystem.ts | 2 +- packages/core/src/v3/types/tasks.ts | 17 ++++++++++------- 6 files changed, 30 insertions(+), 12 deletions(-) diff --git a/.changeset/debounce-max-duration.md b/.changeset/debounce-max-duration.md index 98687ecdb8a..38ab84482fd 100644 --- a/.changeset/debounce-max-duration.md +++ b/.changeset/debounce-max-duration.md @@ -6,6 +6,8 @@ Debouncing with a `delay` longer than an hour now works. A hidden server-side li 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. +Triggers that set a `maxDelay` no longer than their `delay` are now rejected, as are unparseable `maxDelay` values. Both previously went through and left debouncing doing nothing. + ```ts await myTask.trigger(payload, { debounce: { diff --git a/apps/webapp/app/env.server.ts b/apps/webapp/app/env.server.ts index e4fda321e5e..a22c4e449d6 100644 --- a/apps/webapp/app/env.server.ts +++ b/apps/webapp/app/env.server.ts @@ -1041,7 +1041,7 @@ const EnvironmentSchema = z * bound. Setting this applies a ceiling to every debounced run that does not carry its own * `maxDelay`, and any `delay` at or above it stops runs from being pushed at all. */ - RUN_ENGINE_MAXIMUM_DEBOUNCE_DURATION_MS: z.coerce.number().int().optional(), + RUN_ENGINE_MAXIMUM_DEBOUNCE_DURATION_MS: z.coerce.number().int().positive().optional(), /** * Bucket size in milliseconds used to quantize the newly computed `delayUntil` diff --git a/apps/webapp/app/runEngine/services/triggerTask.server.ts b/apps/webapp/app/runEngine/services/triggerTask.server.ts index 53c832e7501..6f1831ba7c8 100644 --- a/apps/webapp/app/runEngine/services/triggerTask.server.ts +++ b/apps/webapp/app/runEngine/services/triggerTask.server.ts @@ -140,7 +140,7 @@ export class RunEngineTriggerTaskService { #validateDebounceMaxDelay( debounce: NonNullable["debounce"]> ) { - if (!debounce.maxDelay) { + if (debounce.maxDelay === undefined) { return; } @@ -149,7 +149,8 @@ export class RunEngineTriggerTaskService { if (maxDelayMs === undefined) { throw new ServiceValidationError( `Invalid debounce maxDelay: ${debounce.maxDelay}. ` + - `Supported formats: {number}s, {number}m, {number}h, {number}d, {number}w` + `Supported formats: {number}s, {number}m, {number}h or {number}hr, {number}d, {number}w, ` + + `optionally combined (for example "2h30m").` ); } @@ -306,7 +307,8 @@ export class RunEngineTriggerTaskService { if (debounceDelayError || !debounceDelayUntil) { throw new ServiceValidationError( `Invalid debounce delay: ${body.options.debounce.delay}. ` + - `Supported formats: {number}s, {number}m, {number}h, {number}d, {number}w` + `Supported formats: {number}s, {number}m, {number}h or {number}hr, {number}d, ` + + `{number}w, optionally combined (for example "2h30m").` ); } diff --git a/apps/webapp/test/engine/triggerTask.debounce.test.ts b/apps/webapp/test/engine/triggerTask.debounce.test.ts index 07e6e057b94..470439cf38a 100644 --- a/apps/webapp/test/engine/triggerTask.debounce.test.ts +++ b/apps/webapp/test/engine/triggerTask.debounce.test.ts @@ -536,6 +536,17 @@ describe("RunEngineTriggerTaskService", () => { triggerWithDebounce({ key: "unparseable", delay: "10s", maxDelay: "soon" }) ).rejects.toThrow(/Invalid debounce maxDelay/); + await expect( + triggerWithDebounce({ key: "empty", delay: "10s", maxDelay: "" }) + ).rejects.toThrow(/Invalid debounce maxDelay/); + + const compound = await triggerWithDebounce({ + key: "compound", + delay: "2h30m", + maxDelay: "1d", + }); + expect(compound?.run.friendlyId).toBeDefined(); + const withRoom = await triggerWithDebounce({ key: "with-room", delay: "10s", diff --git a/internal-packages/run-engine/src/engine/systems/debounceSystem.ts b/internal-packages/run-engine/src/engine/systems/debounceSystem.ts index 8d8860a1fc6..d50e5e94990 100644 --- a/internal-packages/run-engine/src/engine/systems/debounceSystem.ts +++ b/internal-packages/run-engine/src/engine/systems/debounceSystem.ts @@ -579,7 +579,7 @@ return 0 * which is itself unset by default. An unparseable `maxDelay` falls back to the ceiling. */ #resolveMaxDurationMs(debounce: DebounceOptions): number | undefined { - if (!debounce.maxDelay) { + if (debounce.maxDelay === undefined) { return this.maxDebounceDurationMs; } diff --git a/packages/core/src/v3/types/tasks.ts b/packages/core/src/v3/types/tasks.ts index 0ce35b5c071..253e09619e9 100644 --- a/packages/core/src/v3/types/tasks.ts +++ b/packages/core/src/v3/types/tasks.ts @@ -971,11 +971,14 @@ export type TriggerOptions = { * the new execution time stays inside `maxDelay`, so a `delay` at or above `maxDelay` leaves * no room to push and every trigger creates its own run. * - * Supported formats: `{number}s` (seconds), `{number}m` (minutes), `{number}h` (hours), - * `{number}d` (days), `{number}w` (weeks). Must be a duration, not a date. Minimum delay is - * 1 second. + * Must be a duration, not a date: the value is re-applied every time the run is pushed + * back, so an absolute date cannot work and debouncing silently stops collapsing. * - * @example "1s", "5s", "1m", "30m", "1h" + * Supported formats: `{number}s` (seconds), `{number}m` (minutes), `{number}h` or + * `{number}hr` (hours), `{number}d` (days), `{number}w` (weeks), optionally combined. + * Minimum delay is 1 second. + * + * @example "1s", "5s", "1m", "30m", "1h", "2h30m" */ delay: string; /** @@ -1001,10 +1004,10 @@ export type TriggerOptions = { * `maxDelay` of `"5m"` keeps extending for just under 5 minutes from the first trigger, * then runs. * - * Supported formats: `{number}s` (seconds), `{number}m` (minutes), `{number}h` (hours), - * `{number}d` (days), `{number}w` (weeks). + * Supported formats: `{number}s` (seconds), `{number}m` (minutes), `{number}h` or + * `{number}hr` (hours), `{number}d` (days), `{number}w` (weeks), optionally combined. * - * @example "30m", "2h", "1d" + * @example "30m", "2h", "1d", "2h30m" */ maxDelay?: string; }; From 61d77ea66702638f084230747673fbb8aa5e536f Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Thu, 6 Aug 2026 16:53:04 +0100 Subject: [PATCH 04/11] fix(webapp): reject debounce windows that cannot collapse anything Validation only looked at a caller-supplied maxDelay, so a deployment that configures a server ceiling kept the original silent failure: a delay at or above it accepted the trigger and then created a run per trigger. The check now runs against the effective ceiling, whichever of the two applies, and a delay given as a date is rejected as well since the value is re-applied on every push and an absolute date can never work. --- .changeset/debounce-max-duration.md | 2 +- .../runEngine/services/triggerTask.server.ts | 71 +++++++++++++------ .../test/engine/triggerTask.debounce.test.ts | 48 +++++++++++++ docs/triggering.mdx | 2 +- 4 files changed, 100 insertions(+), 23 deletions(-) diff --git a/.changeset/debounce-max-duration.md b/.changeset/debounce-max-duration.md index 38ab84482fd..7294e3f09c4 100644 --- a/.changeset/debounce-max-duration.md +++ b/.changeset/debounce-max-duration.md @@ -6,7 +6,7 @@ Debouncing with a `delay` longer than an hour now works. A hidden server-side li 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. -Triggers that set a `maxDelay` no longer than their `delay` are now rejected, as are unparseable `maxDelay` values. Both previously went through and left debouncing doing nothing. +Triggers we know cannot debounce are now rejected instead of quietly doing nothing: a `maxDelay` no longer than the `delay`, an unparseable `maxDelay`, and a `delay` given as a date rather than a duration. Self-hosters who configure a maximum debounce duration get the same treatment for a `delay` at or above it. ```ts await myTask.trigger(payload, { diff --git a/apps/webapp/app/runEngine/services/triggerTask.server.ts b/apps/webapp/app/runEngine/services/triggerTask.server.ts index 6f1831ba7c8..34805d4c30c 100644 --- a/apps/webapp/app/runEngine/services/triggerTask.server.ts +++ b/apps/webapp/app/runEngine/services/triggerTask.server.ts @@ -7,6 +7,7 @@ import type { Tracer } from "@opentelemetry/api"; import { tryCatch } from "@trigger.dev/core/utils"; import { type TriggerTaskRequestBody, + formatDurationMilliseconds, RunAnnotations, TaskRunError, taskRunErrorEnhancer, @@ -90,6 +91,7 @@ export class RunEngineTriggerTaskService { private readonly traceEventConcern: TraceEventConcern; private readonly triggerRacepointSystem: TriggerRacepointSystem; private readonly metadataMaximumSize: number; + private readonly maximumDebounceDurationMs: number | undefined; // Mollifier hooks are DI'd so tests can drive the call-site's mollify branch // deterministically (stub the gate to return mollify, inject a real or fake // buffer, force the global-enabled predicate to true so the call site @@ -109,6 +111,7 @@ export class RunEngineTriggerTaskService { traceEventConcern: TraceEventConcern; tracer: Tracer; metadataMaximumSize: number; + maximumDebounceDurationMs?: number; triggerRacepointSystem?: TriggerRacepointSystem; evaluateGate?: MollifierEvaluateGate; getMollifierBuffer?: MollifierGetBuffer; @@ -123,6 +126,8 @@ export class RunEngineTriggerTaskService { this.tracer = opts.tracer; this.traceEventConcern = opts.traceEventConcern; this.metadataMaximumSize = opts.metadataMaximumSize; + this.maximumDebounceDurationMs = + opts.maximumDebounceDurationMs ?? env.RUN_ENGINE_MAXIMUM_DEBOUNCE_DURATION_MS; this.triggerRacepointSystem = opts.triggerRacepointSystem ?? new NoopTriggerRacepointSystem(); this.evaluateGate = opts.evaluateGate ?? defaultEvaluateGate; this.getMollifierBuffer = opts.getMollifierBuffer ?? defaultGetMollifierBuffer; @@ -131,36 +136,60 @@ export class RunEngineTriggerTaskService { } /** - * A debounced run is only pushed back while its new execution time stays inside `maxDelay`, - * so the room available to push is `maxDelay` minus `delay`. When a caller sets both and - * leaves no room, the debounce key silently does nothing and every trigger creates its own - * run, which is worse than being told. Only an explicit `maxDelay` is checked; with no - * `maxDelay` there is no ceiling to conflict with. + * A debounced run is only pushed back while its new execution time stays inside the effective + * ceiling, which is the trigger's own `maxDelay` or, failing that, whatever ceiling the server + * is configured with. The room available to push is that ceiling minus `delay`, so a `delay` + * at or above it leaves none: the debounce key does nothing and every trigger creates its own + * run. Rejecting is better than accepting a trigger we know cannot debounce. + * + * With no `maxDelay` and no server ceiling there is nothing to conflict with, which is the + * default. */ - #validateDebounceMaxDelay( + #validateDebounceWindow( debounce: NonNullable["debounce"]> ) { - if (debounce.maxDelay === undefined) { - return; - } - - const maxDelayMs = parseNaturalLanguageDurationInMs(debounce.maxDelay); + const delayMs = parseNaturalLanguageDurationInMs(debounce.delay); - if (maxDelayMs === undefined) { + if (delayMs === undefined) { throw new ServiceValidationError( - `Invalid debounce maxDelay: ${debounce.maxDelay}. ` + - `Supported formats: {number}s, {number}m, {number}h or {number}hr, {number}d, {number}w, ` + - `optionally combined (for example "2h30m").` + `Invalid debounce delay: ${debounce.delay}. debounce.delay must be a duration, not a ` + + `date, because it is re-applied every time the run is pushed back. Supported formats: ` + + `{number}s, {number}m, {number}h or {number}hr, {number}d, {number}w, optionally ` + + `combined (for example "2h30m").` ); } - const delayMs = parseNaturalLanguageDurationInMs(debounce.delay); + if (debounce.maxDelay !== undefined) { + const maxDelayMs = parseNaturalLanguageDurationInMs(debounce.maxDelay); + + if (maxDelayMs === undefined) { + throw new ServiceValidationError( + `Invalid debounce maxDelay: ${debounce.maxDelay}. ` + + `Supported formats: {number}s, {number}m, {number}h or {number}hr, {number}d, ` + + `{number}w, optionally combined (for example "2h30m").` + ); + } + + if (maxDelayMs <= delayMs) { + throw new ServiceValidationError( + `debounce.maxDelay (${debounce.maxDelay}) must be longer than debounce.delay ` + + `(${debounce.delay}). A debounced run is only pushed back while it stays inside ` + + `maxDelay, so with these values every trigger would create its own run.` + ); + } + + return; + } + + const serverCeilingMs = this.maximumDebounceDurationMs; - if (delayMs !== undefined && maxDelayMs <= delayMs) { + if (serverCeilingMs !== undefined && delayMs >= serverCeilingMs) { throw new ServiceValidationError( - `debounce.maxDelay (${debounce.maxDelay}) must be longer than debounce.delay (${debounce.delay}). ` + - `A debounced run is only pushed back while it stays inside maxDelay, so with these values ` + - `every trigger would create its own run.` + `debounce.delay (${debounce.delay}) is at or above this server's maximum debounce ` + + `duration of ${formatDurationMilliseconds(serverCeilingMs, { style: "short" })}. A ` + + `debounced run is only pushed back while it stays inside that window, so with this ` + + `delay every trigger would create its own run. Either shorten the delay, or set ` + + `debounce.maxDelay above ${debounce.delay}.` ); } } @@ -312,7 +341,7 @@ export class RunEngineTriggerTaskService { ); } - this.#validateDebounceMaxDelay(body.options.debounce); + this.#validateDebounceWindow(body.options.debounce); } const parentRun = body.options?.parentRunId diff --git a/apps/webapp/test/engine/triggerTask.debounce.test.ts b/apps/webapp/test/engine/triggerTask.debounce.test.ts index 470439cf38a..a0e885e4f76 100644 --- a/apps/webapp/test/engine/triggerTask.debounce.test.ts +++ b/apps/webapp/test/engine/triggerTask.debounce.test.ts @@ -556,6 +556,54 @@ describe("RunEngineTriggerTaskService", () => { const noMaxDelay = await triggerWithDebounce({ key: "no-max-delay", delay: "12h" }); expect(noMaxDelay?.run.friendlyId).toBeDefined(); + + await expect( + triggerWithDebounce({ key: "date-delay", delay: "2027-01-01T00:00:00.000Z" }) + ).rejects.toThrow(/must be a duration, not a date/); + + const withServerCeiling = new RunEngineTriggerTaskService({ + engine, + prisma, + payloadProcessor: new MockPayloadProcessor(), + queueConcern: queuesManager, + idempotencyKeyConcern, + validator: new MockTriggerTaskValidator(), + traceEventConcern: new MockTraceEventConcern(), + tracer: trace.getTracer("test", "0.0.0"), + metadataMaximumSize: 1024 * 1024 * 1, + maximumDebounceDurationMs: 60 * 60 * 1000, + }); + + await expect( + withServerCeiling.call({ + taskId: taskIdentifier, + environment: authenticatedEnvironment, + body: { + payload: { test: "test" }, + options: { debounce: { key: "over-server-ceiling", delay: "12h" } }, + }, + }) + ).rejects.toThrow(/at or above this server's maximum debounce duration of 1h/); + + const underServerCeiling = await withServerCeiling.call({ + taskId: taskIdentifier, + environment: authenticatedEnvironment, + body: { + payload: { test: "test" }, + options: { debounce: { key: "under-server-ceiling", delay: "10s" } }, + }, + }); + expect(underServerCeiling?.run.friendlyId).toBeDefined(); + + const overCeilingWithMaxDelay = await withServerCeiling.call({ + taskId: taskIdentifier, + environment: authenticatedEnvironment, + body: { + payload: { test: "test" }, + options: { debounce: { key: "override", delay: "12h", maxDelay: "24h" } }, + }, + }); + expect(overCeilingWithMaxDelay?.run.friendlyId).toBeDefined(); } ); }); diff --git a/docs/triggering.mdx b/docs/triggering.mdx index 433467f9816..760b1bd9080 100644 --- a/docs/triggering.mdx +++ b/docs/triggering.mdx @@ -928,7 +928,7 @@ Consider `delay: "5s"` and `maxDelay: "30s"` with triggers arriving every 2 seco Without `maxDelay`, continuous triggers would prevent the run from ever executing. With `maxDelay: "30s"`, execution is guaranteed within 30 seconds of the first trigger. -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. +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. The `maxDelay` value is evaluated from each trigger call, not stored with the original run. This From 39c80556788effd6b72c0d6a05ee6aa730e9384d Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Thu, 6 Aug 2026 16:57:18 +0100 Subject: [PATCH 05/11] fix(webapp): keep a blank debounce ceiling from failing startup validation z.coerce turns an empty string into 0, so a deployment that templates the variable without a value would have failed the new positive check and refused to boot. Reuse the file's existing blank-normalising pattern so absent, empty and whitespace all mean no ceiling, while a value that is actually set still has to be greater than zero. --- apps/webapp/app/env.server.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/webapp/app/env.server.ts b/apps/webapp/app/env.server.ts index a22c4e449d6..3df027008cf 100644 --- a/apps/webapp/app/env.server.ts +++ b/apps/webapp/app/env.server.ts @@ -114,6 +114,12 @@ const OptionalIntEnv = z.preprocess( z.coerce.number().int().optional() ); +/** As {@link OptionalIntEnv}, but a value that is set must be greater than zero. */ +const OptionalPositiveIntEnv = z.preprocess( + (v) => (typeof v === "string" && v.trim() === "" ? undefined : v), + z.coerce.number().int().positive().optional() +); + const EnvironmentSchema = z .object({ NODE_ENV: z.union([z.literal("development"), z.literal("production"), z.literal("test")]), @@ -1041,7 +1047,7 @@ const EnvironmentSchema = z * bound. Setting this applies a ceiling to every debounced run that does not carry its own * `maxDelay`, and any `delay` at or above it stops runs from being pushed at all. */ - RUN_ENGINE_MAXIMUM_DEBOUNCE_DURATION_MS: z.coerce.number().int().positive().optional(), + RUN_ENGINE_MAXIMUM_DEBOUNCE_DURATION_MS: OptionalPositiveIntEnv, /** * Bucket size in milliseconds used to quantize the newly computed `delayUntil` From 373898cd1cc657d246454af6c66f77b1ff8f5e89 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Thu, 6 Aug 2026 17:03:03 +0100 Subject: [PATCH 06/11] docs(webapp): state that the debounce ceiling is a default, not an enforced limit A trigger that sets maxDelay uses that value even when it is longer than the configured ceiling, so the setting bounds only the triggers that omit it. Say so where an operator reads it. --- apps/webapp/app/env.server.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/webapp/app/env.server.ts b/apps/webapp/app/env.server.ts index 3df027008cf..54f33c46797 100644 --- a/apps/webapp/app/env.server.ts +++ b/apps/webapp/app/env.server.ts @@ -1045,7 +1045,9 @@ const EnvironmentSchema = z * trigger. Unset by default: a continuously triggered debounce key is pushed back for as * long as the triggers keep coming, and `debounce.maxDelay` on the trigger is the only * bound. Setting this applies a ceiling to every debounced run that does not carry its own - * `maxDelay`, and any `delay` at or above it stops runs from being pushed at all. + * `maxDelay`, and any `delay` at or above it is rejected at trigger time. It is a default + * rather than an enforced limit: a trigger that sets `maxDelay` uses that value even when it + * is longer than this. */ RUN_ENGINE_MAXIMUM_DEBOUNCE_DURATION_MS: OptionalPositiveIntEnv, From 435f4104232cede4a3a015221874437ea471baed Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Thu, 6 Aug 2026 17:09:39 +0100 Subject: [PATCH 07/11] fix(webapp): treat a zero debounce ceiling as no ceiling rather than a boot failure Zero was previously a legal value meaning the window closes on the first push, so rejecting it would stop an existing deployment from starting after an upgrade. Accepting it as a real ceiling is worse still, since the trigger-time check would then reject every debounced trigger. Zero and blank now both mean no ceiling, which keeps the setting switchable off and leaves negatives and garbage rejected. Also note in the docs that a trigger omitting maxDelay now has no bound at all, where it previously fell back to the built-in ceiling. --- apps/webapp/app/env.server.ts | 20 +++++++++++++------- docs/triggering.mdx | 5 +++-- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/apps/webapp/app/env.server.ts b/apps/webapp/app/env.server.ts index 54f33c46797..3ae566b1b00 100644 --- a/apps/webapp/app/env.server.ts +++ b/apps/webapp/app/env.server.ts @@ -114,11 +114,17 @@ const OptionalIntEnv = z.preprocess( z.coerce.number().int().optional() ); -/** As {@link OptionalIntEnv}, but a value that is set must be greater than zero. */ -const OptionalPositiveIntEnv = z.preprocess( - (v) => (typeof v === "string" && v.trim() === "" ? undefined : v), - z.coerce.number().int().positive().optional() -); +/** + * Optional int env var for a limit that can be switched off. Blank, whitespace and `0` all mean + * "no limit" and normalise to undefined; anything else that is set must be greater than zero. + */ +const OptionalLimitEnv = z.preprocess((v) => { + if (typeof v === "string" && (v.trim() === "" || Number(v.trim()) === 0)) { + return undefined; + } + + return v === 0 ? undefined : v; +}, z.coerce.number().int().positive().optional()); const EnvironmentSchema = z .object({ @@ -1047,9 +1053,9 @@ const EnvironmentSchema = z * bound. Setting this applies a ceiling to every debounced run that does not carry its own * `maxDelay`, and any `delay` at or above it is rejected at trigger time. It is a default * rather than an enforced limit: a trigger that sets `maxDelay` uses that value even when it - * is longer than this. + * is longer than this. `0` and blank both mean no ceiling. */ - RUN_ENGINE_MAXIMUM_DEBOUNCE_DURATION_MS: OptionalPositiveIntEnv, + RUN_ENGINE_MAXIMUM_DEBOUNCE_DURATION_MS: OptionalLimitEnv, /** * Bucket size in milliseconds used to quantize the newly computed `delayUntil` diff --git a/docs/triggering.mdx b/docs/triggering.mdx index 760b1bd9080..1de7eb41778 100644 --- a/docs/triggering.mdx +++ b/docs/triggering.mdx @@ -933,8 +933,9 @@ Keep `delay` well below `maxDelay`. A run is only pushed back while its new exec The `maxDelay` value is evaluated from each trigger call, not stored with the original run. This means if you pass different `maxDelay` values for the same debounce key, each trigger uses its own - `maxDelay` to check against the original run's creation time. For consistent behavior, use the - same `maxDelay` value for all triggers with the same debounce key. + `maxDelay` to check against the original run's creation time. A trigger that omits `maxDelay` + has no bound at all, so a single call without it can push the run past the limit the other calls + set. Use the same `maxDelay` value for every trigger with the same debounce key. **Leading vs Trailing mode:** From d18ddf9b04bc7db7e5a66a1f1faae8e520794218 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Thu, 6 Aug 2026 17:30:20 +0100 Subject: [PATCH 08/11] chore: trim the debounce changeset to the fix it is --- .changeset/debounce-max-duration.md | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/.changeset/debounce-max-duration.md b/.changeset/debounce-max-duration.md index 7294e3f09c4..24a66a8cb58 100644 --- a/.changeset/debounce-max-duration.md +++ b/.changeset/debounce-max-duration.md @@ -2,18 +2,6 @@ "@trigger.dev/core": patch --- -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. +Fix `debounce` doing nothing when `delay` was longer than an hour. An undocumented server limit released debounced runs after an hour, so a longer delay could never push its run back and every trigger created its own. -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. - -Triggers we know cannot debounce are now rejected instead of quietly doing nothing: a `maxDelay` no longer than the `delay`, an unparseable `maxDelay`, and a `delay` given as a date rather than a duration. Self-hosters who configure a maximum debounce duration get the same treatment for a `delay` at or above it. - -```ts -await myTask.trigger(payload, { - debounce: { - key: "conversation-123", - delay: "10s", - maxDelay: "5m", - }, -}); -``` +Debounce keys now keep collapsing triggers for as long as they keep arriving, so set `maxDelay` when the work has to happen eventually. Settings that could never debounce, such as a `maxDelay` no longer than the `delay`, are now rejected rather than quietly ignored. From fba36a19df70d16925e32bbecd51fd9ea1ad68e1 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Thu, 6 Aug 2026 17:32:26 +0100 Subject: [PATCH 09/11] chore: report the debounce fix as a server change, not a package change The only packages change here is JSDoc, so a changeset would file a server-side behaviour fix under the core package changelog, where upgrading the package does nothing for it. --- .changeset/debounce-max-duration.md | 7 ------- .server-changes/debounce-hidden-ceiling.md | 6 ++++++ 2 files changed, 6 insertions(+), 7 deletions(-) delete mode 100644 .changeset/debounce-max-duration.md create mode 100644 .server-changes/debounce-hidden-ceiling.md diff --git a/.changeset/debounce-max-duration.md b/.changeset/debounce-max-duration.md deleted file mode 100644 index 24a66a8cb58..00000000000 --- a/.changeset/debounce-max-duration.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -"@trigger.dev/core": patch ---- - -Fix `debounce` doing nothing when `delay` was longer than an hour. An undocumented server limit released debounced runs after an hour, so a longer delay could never push its run back and every trigger created its own. - -Debounce keys now keep collapsing triggers for as long as they keep arriving, so set `maxDelay` when the work has to happen eventually. Settings that could never debounce, such as a `maxDelay` no longer than the `delay`, are now rejected rather than quietly ignored. diff --git a/.server-changes/debounce-hidden-ceiling.md b/.server-changes/debounce-hidden-ceiling.md new file mode 100644 index 00000000000..86ff9ff29dd --- /dev/null +++ b/.server-changes/debounce-hidden-ceiling.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: fix +--- + +Fix debounce doing nothing when the delay was longer than an hour, which made every trigger create its own run instead of collapsing onto the debounce key. Debounced runs now keep being pushed back for as long as triggers keep arriving, so set `maxDelay` when the work has to happen eventually, and settings that could never debounce are rejected rather than silently ignored. From 11b3be4c948de9b8fb33fc4969d384a0fd160b70 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Thu, 6 Aug 2026 17:38:32 +0100 Subject: [PATCH 10/11] docs: warn that waiting parents are blocked for as long as a debounced run is pushed back With no ceiling, a continuously triggered key holds its run indefinitely, and triggerAndWait parents block on that run's waitpoint for the whole time, holding their concurrency with it. --- docs/triggering.mdx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/triggering.mdx b/docs/triggering.mdx index 1de7eb41778..78e830a7e02 100644 --- a/docs/triggering.mdx +++ b/docs/triggering.mdx @@ -886,7 +886,9 @@ The `debounce` option accepts: There is no time limit on step 2. While triggers keep arriving on the same key, the run keeps being pushed back and never executes. A key triggered every 10 seconds with a `delay` of `"30s"` runs 30 seconds after the triggers stop, however long that takes. Set `maxDelay` whenever the - work needs to happen eventually. + work needs to happen eventually. This matters most with `triggerAndWait`: every parent waiting + on a debounced run stays blocked, and holds its concurrency, for as long as the run keeps being + pushed back. **Limiting total delay with `maxDelay`:** From 41fb9d460eecc8d35d69aa526179a92ea6bbd917 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Thu, 6 Aug 2026 17:44:01 +0100 Subject: [PATCH 11/11] chore(run-engine): say so when an unparseable maxDelay leaves a run unbounded The fallback for an unparseable maxDelay is the server ceiling, which is now normally unset, so the old log line claimed a bound that no longer exists. Name the real outcome instead. --- .../src/engine/systems/debounceSystem.ts | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/internal-packages/run-engine/src/engine/systems/debounceSystem.ts b/internal-packages/run-engine/src/engine/systems/debounceSystem.ts index d50e5e94990..bbe5bbbd65a 100644 --- a/internal-packages/run-engine/src/engine/systems/debounceSystem.ts +++ b/internal-packages/run-engine/src/engine/systems/debounceSystem.ts @@ -576,7 +576,9 @@ return 0 /** * How long after a run's `createdAt` triggers may keep pushing it back, or `undefined` for * no bound at all. A trigger's own `maxDelay` wins; otherwise the server ceiling applies, - * which is itself unset by default. An unparseable `maxDelay` falls back to the ceiling. + * which is itself unset by default. An unparseable `maxDelay` falls back to the ceiling, and + * so to no bound when no ceiling is configured. Callers that reach the engine through the + * trigger API never get that far, since an unparseable `maxDelay` is rejected there. */ #resolveMaxDurationMs(debounce: DebounceOptions): number | undefined { if (debounce.maxDelay === undefined) { @@ -586,10 +588,15 @@ return 0 const parsedMaxDelay = parseNaturalLanguageDurationInMs(debounce.maxDelay); if (parsedMaxDelay === undefined) { - this.$.logger.warn("handleExistingRun: invalid maxDelay duration, using server ceiling", { - maxDelay: debounce.maxDelay, - fallbackMs: this.maxDebounceDurationMs, - }); + this.$.logger.warn( + this.maxDebounceDurationMs === undefined + ? "handleExistingRun: invalid maxDelay duration and no server ceiling, the run can be pushed back indefinitely" + : "handleExistingRun: invalid maxDelay duration, using server ceiling", + { + maxDelay: debounce.maxDelay, + fallbackMs: this.maxDebounceDurationMs, + } + ); return this.maxDebounceDurationMs; }