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
6 changes: 6 additions & 0 deletions .changeset/idempotency-key-catalog-eviction.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@trigger.dev/core": patch
"trigger.dev": patch
---

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.
2 changes: 2 additions & 0 deletions packages/cli-v3/src/entryPoints/dev-run-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {
realtimeStreams,
inputStreams,
sessionStreams,
resetIdempotencyKeyCatalog,
} from "@trigger.dev/core/v3";
import { TriggerTracer } from "@trigger.dev/core/v3/tracer";
import {
Expand Down Expand Up @@ -378,6 +379,7 @@ function resetExecutionEnvironment() {
taskContext.disable();
standardTraceContextManager.reset();
standardHeartbeatsManager.reset();
resetIdempotencyKeyCatalog();

// Wait for all streams to finish before completing the run
waitUntil.register({
Expand Down
2 changes: 2 additions & 0 deletions packages/cli-v3/src/entryPoints/managed-run-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
realtimeStreams,
inputStreams,
sessionStreams,
resetIdempotencyKeyCatalog,
} from "@trigger.dev/core/v3";
import { TriggerTracer } from "@trigger.dev/core/v3/tracer";
import {
Expand Down Expand Up @@ -350,6 +351,7 @@ function resetExecutionEnvironment() {
taskContext.disable();
standardTraceContextManager.reset();
standardHeartbeatsManager.reset();
resetIdempotencyKeyCatalog();

// Wait for all streams to finish before completing the run
waitUntil.register({
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/v3/idempotency-key-catalog/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,5 @@ export type IdempotencyKeyOptions = {
export interface IdempotencyKeyCatalog {
registerKeyOptions(hash: string, options: IdempotencyKeyOptions): void;
getKeyOptions(hash: string): IdempotencyKeyOptions | undefined;
clear(): void;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { describe, it, expect } from "vitest";
import { InMemoryIdempotencyKeyCatalog } from "./inMemoryIdempotencyKeyCatalog.js";

describe("InMemoryIdempotencyKeyCatalog", () => {
it("stores and retrieves options", () => {
const catalog = new InMemoryIdempotencyKeyCatalog();
const options = { key: "my-key", scope: "global" as const };

catalog.registerKeyOptions("hash1", options);

expect(catalog.getKeyOptions("hash1")).toEqual(options);
});

it("returns undefined for non-existent keys", () => {
const catalog = new InMemoryIdempotencyKeyCatalog();

expect(catalog.getKeyOptions("non-existent")).toBeUndefined();
});

it("updates options when registering the same hash twice", () => {
const catalog = new InMemoryIdempotencyKeyCatalog();

catalog.registerKeyOptions("hash1", { key: "key1", scope: "global" });
catalog.registerKeyOptions("hash1", { key: "key1-updated", scope: "run" });

expect(catalog.getKeyOptions("hash1")).toEqual({ key: "key1-updated", scope: "run" });
});

it("retains every entry regardless of count (no eviction)", () => {
const catalog = new InMemoryIdempotencyKeyCatalog();
const count = 5000;

for (let i = 0; i < count; i++) {
catalog.registerKeyOptions(`hash${i}`, { key: `key${i}`, scope: "global" });
}

// The very first entry must still be present — nothing is silently evicted.
expect(catalog.getKeyOptions("hash0")).toEqual({ key: "key0", scope: "global" });
expect(catalog.getKeyOptions(`hash${count - 1}`)).toEqual({
key: `key${count - 1}`,
scope: "global",
});
});

it("clear() removes all entries", () => {
const catalog = new InMemoryIdempotencyKeyCatalog();

catalog.registerKeyOptions("hash1", { key: "key1", scope: "global" });
catalog.registerKeyOptions("hash2", { key: "key2", scope: "run" });

catalog.clear();

expect(catalog.getKeyOptions("hash1")).toBeUndefined();
expect(catalog.getKeyOptions("hash2")).toBeUndefined();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import type { IdempotencyKeyCatalog, IdempotencyKeyOptions } from "./catalog.js";

/**
* Maps an idempotency-key hash back to the original user-provided key and scope.
*
* The mapping is held for the lifetime of a single run: the worker clears it at
* each run boundary (warm starts reuse the process), so it never accumulates
* across runs. Within a run every registered key is retained regardless of how
* many are created, so the key/scope metadata is never silently dropped.
*/
export class InMemoryIdempotencyKeyCatalog implements IdempotencyKeyCatalog {
private cache = new Map<string, IdempotencyKeyOptions>();

registerKeyOptions(hash: string, options: IdempotencyKeyOptions): void {
this.cache.set(hash, options);
}

getKeyOptions(hash: string): IdempotencyKeyOptions | undefined {
return this.cache.get(hash);
}

clear(): void {
this.cache.clear();
}
}
10 changes: 7 additions & 3 deletions packages/core/src/v3/idempotency-key-catalog/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ const API_NAME = "idempotency-key-catalog";

import { getGlobal, registerGlobal } from "../utils/globals.js";
import type { IdempotencyKeyCatalog, IdempotencyKeyOptions } from "./catalog.js";
import { LRUIdempotencyKeyCatalog } from "./lruIdempotencyKeyCatalog.js";
import { InMemoryIdempotencyKeyCatalog } from "./inMemoryIdempotencyKeyCatalog.js";

export class IdempotencyKeyCatalogAPI {
private static _instance?: IdempotencyKeyCatalogAPI;
Expand All @@ -24,11 +24,15 @@ export class IdempotencyKeyCatalogAPI {
return this.#getCatalog().getKeyOptions(hash);
}

public clear(): void {
this.#getCatalog().clear();
}

#getCatalog(): IdempotencyKeyCatalog {
let catalog = getGlobal(API_NAME);
if (!catalog) {
// Auto-initialize with LRU catalog on first access
catalog = new LRUIdempotencyKeyCatalog();
// Auto-initialize on first access
catalog = new InMemoryIdempotencyKeyCatalog();
registerGlobal(API_NAME, catalog, true);
}
return catalog;
Expand Down

This file was deleted.

Loading
Loading