Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/batch-trigger-debounce.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
"@trigger.dev/sdk": patch
"@trigger.dev/react-hooks": patch
---

`debounce` now works when you pass an array of items to `batchTrigger` or `batchTriggerAndWait`, and when you trigger from `useTaskTrigger`. Previously the option was accepted by the types and dropped before the request was sent, so every trigger created its own run instead of collapsing onto the debounce key.

```ts
await myTask.batchTrigger([
{ payload: { id: "a" }, options: { debounce: { key: "same-key", delay: "30s" } } },
{ payload: { id: "b" }, options: { debounce: { key: "same-key", delay: "30s" } } },
]);
```

The streaming (async iterable) forms of the batch calls were already forwarding `debounce` correctly.
1 change: 1 addition & 0 deletions packages/react-hooks/src/hooks/useTaskTrigger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ export function useTaskTrigger<TTask extends AnyTask>(
metadata: options?.metadata,
maxDuration: options?.maxDuration,
lockToVersion: options?.version,
debounce: options?.debounce,
},
});

Expand Down
242 changes: 242 additions & 0 deletions packages/trigger-sdk/src/v3/batchDebounce.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,242 @@
import { apiClientManager } from "@trigger.dev/core/v3";
import { runInMockTaskContext } from "@trigger.dev/core/v3/test";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { batch } from "./batch.js";
import { createTask } from "./shared.js";
import { tasks } from "./tasks.js";

const debounceFor = (i: number) => ({
key: `warm-conn-notify:${i}`,
delay: "12h",
maxDelay: "24h",
mode: "trailing" as const,
});

const EXPECTED = [debounceFor(0), debounceFor(1)];

type Payload = { i: number };

const taskA = createTask({
id: "task-a",
run: async (_payload: Payload) => ({ ok: true }),
});

const taskB = createTask({
id: "task-b",
run: async (_payload: Payload) => ({ ok: true }),
});

type SentItem = {
index: number;
task: string;
options?: { debounce?: { key: string; delay: string; mode?: string; maxDelay?: string } };
};

/**
* Captures the NDJSON item stream the SDK sends in phase 2 of a batch trigger,
* answering the only question these tests care about: what actually reached the
* wire. Phase 1 (create) and the item stream get canned success responses.
*/
function installBatchCapture() {
const sent: SentItem[] = [];
const originalFetch = globalThis.fetch;

globalThis.fetch = (async (input: any, init?: RequestInit) => {
const url = typeof input === "string" ? input : (input?.url ?? String(input));

if (url.endsWith("/api/v3/batches")) {
const body = JSON.parse(String(init?.body));
return Response.json({ id: "batch_test", runCount: body.runCount, isCached: false });
}

if (url.includes("/api/v3/batches/") && url.endsWith("/items")) {
const ndjson = await new Response(init?.body as any).text();
const lines = ndjson.split("\n").filter((line) => line.trim().length > 0);
sent.push(...lines.map((line) => JSON.parse(line) as SentItem));

return Response.json({
id: "batch_test",
itemsAccepted: lines.length,
itemsDeduplicated: 0,
sealed: true,
});
}

throw new Error(`Unexpected request during batch trigger: ${url}`);
}) as typeof fetch;
Comment thread
matt-aitken marked this conversation as resolved.

return {
debounceOptions: () =>
[...sent].sort((a, b) => a.index - b.index).map((item) => item.options?.debounce),
restore: () => {
globalThis.fetch = originalFetch;
},
};
}

async function* asAsyncIterable<T>(items: T[]): AsyncIterable<T> {
for (const item of items) {
yield item;
}
}

describe("batch trigger debounce forwarding", () => {
let capture: ReturnType<typeof installBatchCapture>;

beforeEach(() => {
apiClientManager.setGlobalAPIClientConfiguration({
baseURL: "http://localhost:3030",
accessToken: "tr_dev_test",
});
capture = installBatchCapture();
});

afterEach(() => {
capture.restore();
apiClientManager.disable();
});

const surfaces: Array<{ name: string; call: () => Promise<unknown> }> = [
{
name: "task.batchTrigger(array)",
call: () =>
taskA.batchTrigger([
{ payload: { i: 0 }, options: { debounce: debounceFor(0) } },
{ payload: { i: 1 }, options: { debounce: debounceFor(1) } },
]),
},
{
name: "task.batchTrigger(asyncIterable)",
call: () =>
taskA.batchTrigger(
asAsyncIterable([
{ payload: { i: 0 }, options: { debounce: debounceFor(0) } },
{ payload: { i: 1 }, options: { debounce: debounceFor(1) } },
])
),
},
{
name: "tasks.batchTrigger(array)",
call: () =>
tasks.batchTrigger<typeof taskA>("task-a", [
{ payload: { i: 0 }, options: { debounce: debounceFor(0) } },
{ payload: { i: 1 }, options: { debounce: debounceFor(1) } },
]),
},
{
name: "batch.trigger(array)",
call: () =>
batch.trigger<typeof taskA | typeof taskB>([
{ id: "task-a", payload: { i: 0 }, options: { debounce: debounceFor(0) } },
{ id: "task-b", payload: { i: 1 }, options: { debounce: debounceFor(1) } },
]),
},
{
name: "batch.trigger(asyncIterable)",
call: () =>
batch.trigger<typeof taskA | typeof taskB>(
asAsyncIterable([
{ id: "task-a" as const, payload: { i: 0 }, options: { debounce: debounceFor(0) } },
{ id: "task-b" as const, payload: { i: 1 }, options: { debounce: debounceFor(1) } },
])
),
},
{
name: "batch.triggerByTask(array)",
call: () =>
batch.triggerByTask([
{ task: taskA, payload: { i: 0 }, options: { debounce: debounceFor(0) } },
{ task: taskB, payload: { i: 1 }, options: { debounce: debounceFor(1) } },
]),
},
{
name: "batch.triggerByTask(asyncIterable)",
call: () =>
batch.triggerByTask(
asAsyncIterable([
{ task: taskA, payload: { i: 0 }, options: { debounce: debounceFor(0) } },
{ task: taskB, payload: { i: 1 }, options: { debounce: debounceFor(1) } },
])
),
},
];

it.each(surfaces)("$name forwards debounce for every item", async ({ call }) => {
await call();

expect(capture.debounceOptions()).toEqual(EXPECTED);
});

const waitSurfaces: Array<{ name: string; call: () => Promise<unknown> }> = [
{
name: "task.batchTriggerAndWait(array)",
call: () =>
taskA.batchTriggerAndWait([
{ payload: { i: 0 }, options: { debounce: debounceFor(0) } },
{ payload: { i: 1 }, options: { debounce: debounceFor(1) } },
]),
},
{
name: "task.batchTriggerAndWait(asyncIterable)",
call: () =>
taskA.batchTriggerAndWait(
asAsyncIterable([
{ payload: { i: 0 }, options: { debounce: debounceFor(0) } },
{ payload: { i: 1 }, options: { debounce: debounceFor(1) } },
])
),
},
{
name: "tasks.batchTriggerAndWait(array)",
call: () =>
tasks.batchTriggerAndWait<typeof taskA>("task-a", [
{ payload: { i: 0 }, options: { debounce: debounceFor(0) } },
{ payload: { i: 1 }, options: { debounce: debounceFor(1) } },
]),
},
{
name: "batch.triggerAndWait(array)",
call: () =>
batch.triggerAndWait<typeof taskA | typeof taskB>([
{ id: "task-a", payload: { i: 0 }, options: { debounce: debounceFor(0) } },
{ id: "task-b", payload: { i: 1 }, options: { debounce: debounceFor(1) } },
]),
},
{
name: "batch.triggerAndWait(asyncIterable)",
call: () =>
batch.triggerAndWait<typeof taskA | typeof taskB>(
asAsyncIterable([
{ id: "task-a" as const, payload: { i: 0 }, options: { debounce: debounceFor(0) } },
{ id: "task-b" as const, payload: { i: 1 }, options: { debounce: debounceFor(1) } },
])
),
},
{
name: "batch.triggerByTaskAndWait(array)",
call: () =>
batch.triggerByTaskAndWait([
{ task: taskA, payload: { i: 0 }, options: { debounce: debounceFor(0) } },
{ task: taskB, payload: { i: 1 }, options: { debounce: debounceFor(1) } },
]),
},
{
name: "batch.triggerByTaskAndWait(asyncIterable)",
call: () =>
batch.triggerByTaskAndWait(
asAsyncIterable([
{ task: taskA, payload: { i: 0 }, options: { debounce: debounceFor(0) } },
{ task: taskB, payload: { i: 1 }, options: { debounce: debounceFor(1) } },
])
),
},
];

it.each(waitSurfaces)("$name forwards debounce for every item", async ({ call }) => {
await runInMockTaskContext(async () => {
await call();
});

expect(capture.debounceOptions()).toEqual(EXPECTED);
});
});
26 changes: 14 additions & 12 deletions packages/trigger-sdk/src/v3/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -749,7 +749,7 @@ export async function batchTriggerById<TTask extends AnyTask>(
lockToVersion: item.options?.version ?? scopedEnvVar("TRIGGER_VERSION"),
debounce: item.options?.debounce,
},
};
} satisfies BatchItemNDJSON;
})
);

Expand Down Expand Up @@ -1005,7 +1005,7 @@ export async function batchTriggerByIdAndWait<TTask extends AnyTask>(
region: item.options?.region,
debounce: item.options?.debounce,
},
};
} satisfies BatchItemNDJSON;
})
);

Expand Down Expand Up @@ -1271,7 +1271,7 @@ export async function batchTriggerTasks<TTasks extends readonly AnyTask[]>(
lockToVersion: item.options?.version ?? scopedEnvVar("TRIGGER_VERSION"),
debounce: item.options?.debounce,
},
};
} satisfies BatchItemNDJSON;
})
);

Expand Down Expand Up @@ -1532,7 +1532,7 @@ export async function batchTriggerAndWaitTasks<TTasks extends readonly AnyTask[]
region: item.options?.region,
debounce: item.options?.debounce,
},
};
} satisfies BatchItemNDJSON;
})
);

Expand Down Expand Up @@ -2019,7 +2019,7 @@ async function* transformBatchItemsStream<TTask extends AnyTask>(
lockToVersion: item.options?.version ?? scopedEnvVar("TRIGGER_VERSION"),
debounce: item.options?.debounce,
},
};
} satisfies BatchItemNDJSON;
}
}

Expand Down Expand Up @@ -2071,7 +2071,7 @@ async function* transformBatchItemsStreamForWait<TTask extends AnyTask>(
region: item.options?.region,
debounce: item.options?.debounce,
},
};
} satisfies BatchItemNDJSON;
}
}

Expand Down Expand Up @@ -2122,7 +2122,7 @@ async function* transformBatchByTaskItemsStream<TTasks extends readonly AnyTask[
lockToVersion: item.options?.version ?? scopedEnvVar("TRIGGER_VERSION"),
debounce: item.options?.debounce,
},
};
} satisfies BatchItemNDJSON;
}
}

Expand Down Expand Up @@ -2173,7 +2173,7 @@ async function* transformBatchByTaskItemsStreamForWait<TTasks extends readonly A
region: item.options?.region,
debounce: item.options?.debounce,
},
};
} satisfies BatchItemNDJSON;
}
}

Expand Down Expand Up @@ -2226,7 +2226,7 @@ async function* transformSingleTaskBatchItemsStream<TPayload>(
lockToVersion: item.options?.version ?? scopedEnvVar("TRIGGER_VERSION"),
debounce: item.options?.debounce,
},
};
} satisfies BatchItemNDJSON;
}
}

Expand Down Expand Up @@ -2286,7 +2286,7 @@ async function* transformSingleTaskBatchItemsStreamForWait<TPayload>(
region: item.options?.region,
debounce: item.options?.debounce,
},
};
} satisfies BatchItemNDJSON;
}
}

Expand Down Expand Up @@ -2423,8 +2423,9 @@ async function batchTrigger_internal<TRunTypes extends AnyRunTypes>(
priority: item.options?.priority,
region: item.options?.region,
lockToVersion: item.options?.version ?? scopedEnvVar("TRIGGER_VERSION"),
debounce: item.options?.debounce,
},
};
} satisfies BatchItemNDJSON;
})
);

Expand Down Expand Up @@ -2854,8 +2855,9 @@ async function batchTriggerAndWait_internal<TIdentifier extends string, TPayload
machine: item.options?.machine,
priority: item.options?.priority,
region: item.options?.region,
debounce: item.options?.debounce,
},
};
} satisfies BatchItemNDJSON;
})
);

Expand Down
Loading