Skip to content

Commit 5df9fb6

Browse files
authored
fix(core,cli): retain idempotency key metadata beyond 1000 keys per run (#4094)
Fixes #4046 ## Problem When a single run creates more than 1000 idempotency keys (e.g. a large batch trigger where each item calls `idempotencyKeys.create()`), the original key and scope metadata is silently dropped for all but the most recent 1000 keys. `idempotencyKeys.create()` returns a plain 64-char hash and stores the `{ key, scope }` mapping in an in-process catalog keyed by that hash. That catalog was a fixed-size **LRU capped at 1000 entries**. Once a run creates more than 1000 keys, the earliest mappings are evicted, so when the SDK later looks them up to attach `idempotencyKeyOptions` to the trigger call, it finds nothing and sends `undefined`. The affected runs then: - report `ctx.run.idempotencyKey` as the raw hash instead of the user-provided key - have no `idempotencyKeyScope` - show empty `idempotency_key` / `idempotency_key_scope` in the dashboard and analytics Deduplication still works (the hash is intact); only the human-readable metadata is lost, which makes the failure silent and hard to notice. ## Fix - Replace the LRU catalog with an unbounded in-memory catalog, so every key created within a run keeps its metadata regardless of how many are created. - Clear the catalog at each run boundary via `resetExecutionEnvironment()` (both dev and managed workers), matching how every other per-run manager is reset. Deployed workers reuse one process across many runs (warm starts), so this bounds memory to a single run's keys instead of accumulating across runs — which is the reason the size cap existed in the first place. ## Tests - New public-API test creates 3000 keys and asserts all of them (including the first) retain their key/scope — this fails on `main` and passes with the fix. - New test for the in-memory catalog covers store/retrieve/overwrite, large-N retention (no eviction), and `clear()`. - New test asserts the catalog is emptied after a run-boundary reset. - Replaces the previous LRU catalog + its eviction tests. Verified: `@trigger.dev/core` and `trigger.dev` both build; all idempotency tests pass. Changeset added (patch).
1 parent bfa902b commit 5df9fb6

11 files changed

Lines changed: 152 additions & 248 deletions
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@trigger.dev/core": patch
3+
"trigger.dev": patch
4+
---
5+
6+
Fix idempotency key metadata (original key + scope) being silently dropped when a single run creates more than 1000 idempotency keys. The in-process catalog that maps a key's hash back to its original key/scope is no longer bounded to 1000 entries, so `idempotencyKeys.create()` results retain their metadata regardless of how many are created in a run. The catalog is now cleared at each run boundary so it does not accumulate across warm-start runs.

packages/cli-v3/src/entryPoints/dev-run-worker.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import {
3535
realtimeStreams,
3636
inputStreams,
3737
sessionStreams,
38+
resetIdempotencyKeyCatalog,
3839
} from "@trigger.dev/core/v3";
3940
import { TriggerTracer } from "@trigger.dev/core/v3/tracer";
4041
import {
@@ -378,6 +379,7 @@ function resetExecutionEnvironment() {
378379
taskContext.disable();
379380
standardTraceContextManager.reset();
380381
standardHeartbeatsManager.reset();
382+
resetIdempotencyKeyCatalog();
381383

382384
// Wait for all streams to finish before completing the run
383385
waitUntil.register({

packages/cli-v3/src/entryPoints/managed-run-worker.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import {
3434
realtimeStreams,
3535
inputStreams,
3636
sessionStreams,
37+
resetIdempotencyKeyCatalog,
3738
} from "@trigger.dev/core/v3";
3839
import { TriggerTracer } from "@trigger.dev/core/v3/tracer";
3940
import {
@@ -350,6 +351,7 @@ function resetExecutionEnvironment() {
350351
taskContext.disable();
351352
standardTraceContextManager.reset();
352353
standardHeartbeatsManager.reset();
354+
resetIdempotencyKeyCatalog();
353355

354356
// Wait for all streams to finish before completing the run
355357
waitUntil.register({

packages/core/src/v3/idempotency-key-catalog/catalog.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,5 @@ export type IdempotencyKeyOptions = {
88
export interface IdempotencyKeyCatalog {
99
registerKeyOptions(hash: string, options: IdempotencyKeyOptions): void;
1010
getKeyOptions(hash: string): IdempotencyKeyOptions | undefined;
11+
clear(): void;
1112
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { describe, it, expect } from "vitest";
2+
import { InMemoryIdempotencyKeyCatalog } from "./inMemoryIdempotencyKeyCatalog.js";
3+
4+
describe("InMemoryIdempotencyKeyCatalog", () => {
5+
it("stores and retrieves options", () => {
6+
const catalog = new InMemoryIdempotencyKeyCatalog();
7+
const options = { key: "my-key", scope: "global" as const };
8+
9+
catalog.registerKeyOptions("hash1", options);
10+
11+
expect(catalog.getKeyOptions("hash1")).toEqual(options);
12+
});
13+
14+
it("returns undefined for non-existent keys", () => {
15+
const catalog = new InMemoryIdempotencyKeyCatalog();
16+
17+
expect(catalog.getKeyOptions("non-existent")).toBeUndefined();
18+
});
19+
20+
it("updates options when registering the same hash twice", () => {
21+
const catalog = new InMemoryIdempotencyKeyCatalog();
22+
23+
catalog.registerKeyOptions("hash1", { key: "key1", scope: "global" });
24+
catalog.registerKeyOptions("hash1", { key: "key1-updated", scope: "run" });
25+
26+
expect(catalog.getKeyOptions("hash1")).toEqual({ key: "key1-updated", scope: "run" });
27+
});
28+
29+
it("retains every entry regardless of count (no eviction)", () => {
30+
const catalog = new InMemoryIdempotencyKeyCatalog();
31+
const count = 5000;
32+
33+
for (let i = 0; i < count; i++) {
34+
catalog.registerKeyOptions(`hash${i}`, { key: `key${i}`, scope: "global" });
35+
}
36+
37+
// The very first entry must still be present — nothing is silently evicted.
38+
expect(catalog.getKeyOptions("hash0")).toEqual({ key: "key0", scope: "global" });
39+
expect(catalog.getKeyOptions(`hash${count - 1}`)).toEqual({
40+
key: `key${count - 1}`,
41+
scope: "global",
42+
});
43+
});
44+
45+
it("clear() removes all entries", () => {
46+
const catalog = new InMemoryIdempotencyKeyCatalog();
47+
48+
catalog.registerKeyOptions("hash1", { key: "key1", scope: "global" });
49+
catalog.registerKeyOptions("hash2", { key: "key2", scope: "run" });
50+
51+
catalog.clear();
52+
53+
expect(catalog.getKeyOptions("hash1")).toBeUndefined();
54+
expect(catalog.getKeyOptions("hash2")).toBeUndefined();
55+
});
56+
});
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import type { IdempotencyKeyCatalog, IdempotencyKeyOptions } from "./catalog.js";
2+
3+
/**
4+
* Maps an idempotency-key hash back to the original user-provided key and scope.
5+
*
6+
* The mapping is held for the lifetime of a single run: the worker clears it at
7+
* each run boundary (warm starts reuse the process), so it never accumulates
8+
* across runs. Within a run every registered key is retained regardless of how
9+
* many are created, so the key/scope metadata is never silently dropped.
10+
*/
11+
export class InMemoryIdempotencyKeyCatalog implements IdempotencyKeyCatalog {
12+
private cache = new Map<string, IdempotencyKeyOptions>();
13+
14+
registerKeyOptions(hash: string, options: IdempotencyKeyOptions): void {
15+
this.cache.set(hash, options);
16+
}
17+
18+
getKeyOptions(hash: string): IdempotencyKeyOptions | undefined {
19+
return this.cache.get(hash);
20+
}
21+
22+
clear(): void {
23+
this.cache.clear();
24+
}
25+
}

packages/core/src/v3/idempotency-key-catalog/index.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ const API_NAME = "idempotency-key-catalog";
22

33
import { getGlobal, registerGlobal } from "../utils/globals.js";
44
import type { IdempotencyKeyCatalog, IdempotencyKeyOptions } from "./catalog.js";
5-
import { LRUIdempotencyKeyCatalog } from "./lruIdempotencyKeyCatalog.js";
5+
import { InMemoryIdempotencyKeyCatalog } from "./inMemoryIdempotencyKeyCatalog.js";
66

77
export class IdempotencyKeyCatalogAPI {
88
private static _instance?: IdempotencyKeyCatalogAPI;
@@ -24,11 +24,15 @@ export class IdempotencyKeyCatalogAPI {
2424
return this.#getCatalog().getKeyOptions(hash);
2525
}
2626

27+
public clear(): void {
28+
this.#getCatalog().clear();
29+
}
30+
2731
#getCatalog(): IdempotencyKeyCatalog {
2832
let catalog = getGlobal(API_NAME);
2933
if (!catalog) {
30-
// Auto-initialize with LRU catalog on first access
31-
catalog = new LRUIdempotencyKeyCatalog();
34+
// Auto-initialize on first access
35+
catalog = new InMemoryIdempotencyKeyCatalog();
3236
registerGlobal(API_NAME, catalog, true);
3337
}
3438
return catalog;

packages/core/src/v3/idempotency-key-catalog/lruIdempotencyKeyCatalog.test.ts

Lines changed: 0 additions & 209 deletions
This file was deleted.

0 commit comments

Comments
 (0)