Skip to content

Commit 32d68f4

Browse files
committed
fix(run-store): fall back to legacy on an idempotency-key reset when the new DB matches nothing
Routing a predicate reset to the new database ONLY broke resets during the rollout window: a key minted before an org flipped still lives on a legacy-resident run (idempotency TTL up to 30 days), so a new-only reset matched zero rows, returned 404, and left the stale key deduping child triggers. The reset now checks the new database first and falls back to legacy when nothing matched, so it clears the key wherever the run lives; a fully-drained env still never touches legacy.
1 parent 2b67a2f commit 32d68f4

2 files changed

Lines changed: 38 additions & 13 deletions

File tree

internal-packages/run-store/src/runOpsStore.envScopedResidency.test.ts

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@ import type { RunStore } from "./types.js";
99
type Call = { method: string; args: unknown[] };
1010
type FakeStore = RunStore & { slot: "new" | "legacy"; calls: Call[] };
1111

12-
function fakeStore(slot: "new" | "legacy"): FakeStore {
12+
// `clearCount` lets a test say "this store matched N rows for the reset", so the NEW-first-then-fallback
13+
// path can be exercised (NEW matches 0 → fall back to LEGACY).
14+
function fakeStore(slot: "new" | "legacy", clearCount = slot === "new" ? 1 : 0): FakeStore {
1315
const calls: Call[] = [];
1416
const rec =
1517
(method: string, result: unknown) =>
@@ -21,13 +23,13 @@ function fakeStore(slot: "new" | "legacy"): FakeStore {
2123
slot,
2224
calls,
2325
upsertWaitpointTag: rec("upsertWaitpointTag", { id: slot, slot }),
24-
clearIdempotencyKey: rec("clearIdempotencyKey", { count: slot === "new" ? 1 : 0 }),
26+
clearIdempotencyKey: rec("clearIdempotencyKey", { count: clearCount }),
2527
} as unknown as FakeStore;
2628
}
2729

28-
function buildRouter() {
29-
const newStore = fakeStore("new");
30-
const legacyStore = fakeStore("legacy");
30+
function buildRouter(newClearCount?: number, legacyClearCount?: number) {
31+
const newStore = fakeStore("new", newClearCount);
32+
const legacyStore = fakeStore("legacy", legacyClearCount);
3133
const router = new RoutingRunStore({
3234
new: newStore,
3335
legacy: legacyStore,
@@ -52,9 +54,9 @@ describe("RoutingRunStore.upsertWaitpointTag — residency hint for a tag with n
5254
});
5355
});
5456

55-
describe("RoutingRunStore.clearIdempotencyKey — predicate routes NEW when the env mints new", () => {
56-
it("routes a byPredicate reset to NEW only when residency is NEW (no legacy fan-out)", async () => {
57-
const { router, newStore, legacyStore } = buildRouter();
57+
describe("RoutingRunStore.clearIdempotencyKey — predicate routes NEW-first when the env mints new", () => {
58+
it("clears on NEW and does NOT touch legacy when NEW matches (post-flip key)", async () => {
59+
const { router, newStore, legacyStore } = buildRouter(1, 0);
5860
const result = await router.clearIdempotencyKey({
5961
byPredicate: {
6062
idempotencyKey: "k",
@@ -68,6 +70,23 @@ describe("RoutingRunStore.clearIdempotencyKey — predicate routes NEW when the
6870
expect(result.count).toBe(1);
6971
});
7072

73+
it("falls back to LEGACY when NEW matches 0 (a key held on a pre-flip legacy run)", async () => {
74+
// The env mints new now, but this key was created before the flip → its run lives on LEGACY.
75+
const { router, newStore, legacyStore } = buildRouter(0, 1);
76+
const result = await router.clearIdempotencyKey({
77+
byPredicate: {
78+
idempotencyKey: "k",
79+
taskIdentifier: "task",
80+
runtimeEnvironmentId: "env",
81+
residency: "NEW",
82+
},
83+
});
84+
// NEW checked first (0 rows), then LEGACY cleared the stale key — so the reset actually works.
85+
expect(newStore.calls).toHaveLength(1);
86+
expect(legacyStore.calls).toHaveLength(1);
87+
expect(result.count).toBe(1);
88+
});
89+
7190
it("still fans out a byPredicate reset with no residency (mixed residency)", async () => {
7291
const { router, newStore, legacyStore } = buildRouter();
7392
await router.clearIdempotencyKey({

internal-packages/run-store/src/runOpsStore.ts

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -503,7 +503,7 @@ export class RoutingRunStore implements RunStore {
503503
return (await this.#routeOrNewForWrite(runId)).updateMetadata(runId, data, options);
504504
}
505505

506-
clearIdempotencyKey(
506+
async clearIdempotencyKey(
507507
params: ClearIdempotencyKeyInput,
508508
tx?: PrismaClientOrTransaction
509509
): Promise<{ count: number }> {
@@ -513,11 +513,17 @@ export class RoutingRunStore implements RunStore {
513513
const store = this.#route(params.byId.runId);
514514
return store.clearIdempotencyKey(params, undefined);
515515
}
516-
// A `byPredicate` whose env mints run-ops ids has its matching runs on NEW, so route to NEW only
517-
// and skip the wrong-DB (0-row) write to the draining legacy DB. Without that hint (or byFriendlyIds)
518-
// the predicate can span mixed residency — fan out and sum.
516+
// A `byPredicate` whose env mints run-ops ids has NEW-born runs, so check NEW first. But a key
517+
// minted BEFORE the org flipped still lives on a LEGACY-resident run (idempotency TTL up to 30d),
518+
// so fall back to LEGACY when NEW matched nothing — otherwise the reset 404s and the stale legacy
519+
// key keeps deduping. In the steady (fully-drained) state NEW matches and legacy is never touched.
519520
if ("byPredicate" in params && params.byPredicate?.residency === "NEW") {
520-
return this.#new.clearIdempotencyKey(params, undefined);
521+
const fromNew = await this.#new.clearIdempotencyKey(params, undefined);
522+
if (fromNew.count > 0) {
523+
return fromNew;
524+
}
525+
const fromLegacy = await this.#legacy.clearIdempotencyKey(params);
526+
return { count: fromNew.count + fromLegacy.count };
521527
}
522528
return Promise.all([
523529
this.#new.clearIdempotencyKey(params),

0 commit comments

Comments
 (0)