diff --git a/.server-changes/debounce-hidden-ceiling.md b/.server-changes/debounce-hidden-ceiling.md new file mode 100644 index 00000000000..26a4188e855 --- /dev/null +++ b/.server-changes/debounce-hidden-ceiling.md @@ -0,0 +1,9 @@ +--- +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. diff --git a/apps/webapp/app/env.server.ts b/apps/webapp/app/env.server.ts index 295b3a75daf..3ae566b1b00 100644 --- a/apps/webapp/app/env.server.ts +++ b/apps/webapp/app/env.server.ts @@ -114,6 +114,18 @@ const OptionalIntEnv = z.preprocess( z.coerce.number().int().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({ NODE_ENV: z.union([z.literal("development"), z.literal("production"), z.literal("test")]), @@ -1034,11 +1046,16 @@ 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) */ - RUN_ENGINE_MAXIMUM_DEBOUNCE_DURATION_MS: z.coerce - .number() - .int() - .default(60_000 * 60), // 1 hour + /** + * 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 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. `0` and blank both mean no ceiling. + */ + RUN_ENGINE_MAXIMUM_DEBOUNCE_DURATION_MS: OptionalLimitEnv, /** * 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..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, @@ -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 | 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 @@ -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,65 @@ export class RunEngineTriggerTaskService { opts.isMollifierGloballyEnabled ?? (() => env.TRIGGER_MOLLIFIER_ENABLED === "1"); } + /** + * 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. + */ + #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, 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").` + ); + } + + 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 (serverCeilingMs !== undefined && delayMs >= serverCeilingMs) { + throw new ServiceValidationError( + `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}.` + ); + } + } + // 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: @@ -271,9 +336,12 @@ 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").` ); } + + 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..a0e885e4f76 100644 --- a/apps/webapp/test/engine/triggerTask.debounce.test.ts +++ b/apps/webapp/test/engine/triggerTask.debounce.test.ts @@ -459,4 +459,151 @@ describe("RunEngineTriggerTaskService", () => { expect(result).toBeDefined(); expect(result?.run.friendlyId).toBeDefined(); }); + + containerTest( + "should reject a debounce maxDelay that leaves no room to push the run back", + 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, + }); + + 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: "equal", delay: "12h", maxDelay: "12h" }) + ).rejects.toThrow(/must be longer than debounce.delay/); + + await expect( + triggerWithDebounce({ key: "shorter", delay: "12h", maxDelay: "1h" }) + ).rejects.toThrow(/must be longer than debounce.delay/); + + await expect( + 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", + maxDelay: "5m", + }); + expect(withRoom?.run.friendlyId).toBeDefined(); + + 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 cb2608269a9..78e830a7e02 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`. Not set by default **How it works:** @@ -882,9 +882,18 @@ 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 + + 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. 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`:** -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( @@ -921,11 +930,14 @@ 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, 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 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:** diff --git a/internal-packages/run-engine/src/engine/index.ts b/internal-packages/run-engine/src/engine/index.ts index b88f4f276e4..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 ?? 60 * 60 * 1000, // Default 1 hour + 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..bbe5bbbd65a 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,36 @@ 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, 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) { + return this.maxDebounceDurationMs; + } + + const parsedMaxDelay = parseNaturalLanguageDurationInMs(debounce.maxDelay); + + if (parsedMaxDelay === undefined) { + 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; + } + + return parsedMaxDelay; + } + #isLockContentionError(error: unknown): boolean { if (!(error instanceof Error)) return false; return ( @@ -624,17 +659,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 +850,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 d926ddecb34..6094ac7daa7 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: with no server ceiling configured, a long delay 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..547852803b2 100644 --- a/internal-packages/run-engine/src/engine/types.ts +++ b/internal-packages/run-engine/src/engine/types.ts @@ -168,7 +168,16 @@ export type RunEngineOptions = { }; debounce?: { redis?: RedisOptions; - /** Maximum duration in milliseconds that a run can be debounced. Default: 1 hour */ + /** + * 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. + * + * 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; /** * 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..253e09619e9 100644 --- a/packages/core/src/v3/types/tasks.ts +++ b/packages/core/src/v3/types/tasks.ts @@ -940,6 +940,9 @@ export type TriggerOptions = { * * The debounce key is scoped to the task identifier, so different tasks can use the same key without conflicts. * + * 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 * * ```ts @@ -964,10 +967,18 @@ 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. * - * Supported formats: `{number}s` (seconds), `{number}m` (minutes), `{number}h` (hours), - * `{number}d` (days), `{number}w` (weeks). Minimum delay is 1 second. + * 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. + * + * 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. + * + * 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" + * @example "1s", "5s", "1m", "30m", "1h", "2h30m" */ delay: string; /** @@ -988,12 +999,15 @@ 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). + * 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). + * 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; };