fix(run-store,webapp): correct split-database read routing, write residency, and batches list ordering (#4272)
## Summary Correctness and performance fixes for deployments that split run data across more than one database. Single-database / self-hosted deployments are unaffected (they collapse to a single read/write path). - **Batches list (dashboard):** for some organizations the Batches list could hide older batches or show them out of order. It now orders and paginates by creation time (with the id as a stable tiebreak), so every batch appears exactly once, newest first. The pagination cursor format changes; older in-flight cursors simply restart from the first page. - **Reads:** waitpoint and snapshot lookups that are keyed by a single run now read only the database that holds that run instead of querying both, removing redundant queries on hot paths (unblock, snapshot reads). - **Writes:** environment-scoped writes with no owning run (standalone wait tokens, waitpoint tags, idempotency-key resets) now land in the same database as that environment's runs, rather than defaulting to the other one. An idempotency-key reset also falls back to the other database when it matches nothing, so a reset still clears the key wherever the run actually lives. ## Notes Verified end-to-end against multi-database setups: run-keyed reads and env-scoped writes land on the correct database with no cross-database writes, and the batches list surfaces every batch in creation order. New tests cover the batches ordering/reachability and the write-residency routing.
This commit is contained in:
@@ -8,10 +8,14 @@ export async function createWaitpointTag({
|
||||
tag,
|
||||
environmentId,
|
||||
projectId,
|
||||
residency,
|
||||
}: {
|
||||
tag: string;
|
||||
environmentId: string;
|
||||
projectId: string;
|
||||
// Residency from the env mint kind: a tag has no owning run, so a minted-new env pins it to NEW
|
||||
// instead of defaulting to the draining legacy DB.
|
||||
residency?: "NEW" | "LEGACY";
|
||||
}) {
|
||||
if (tag.trim().length === 0) return;
|
||||
|
||||
@@ -19,11 +23,15 @@ export async function createWaitpointTag({
|
||||
|
||||
while (attempts < MAX_RETRIES) {
|
||||
try {
|
||||
return await runStore.upsertWaitpointTag({
|
||||
environmentId,
|
||||
name: tag,
|
||||
projectId,
|
||||
});
|
||||
return await runStore.upsertWaitpointTag(
|
||||
{
|
||||
environmentId,
|
||||
name: tag,
|
||||
projectId,
|
||||
},
|
||||
undefined,
|
||||
residency
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") {
|
||||
// Handle unique constraint violation (conflict)
|
||||
|
||||
@@ -43,6 +43,28 @@ type BatchRow = {
|
||||
batchVersion: string;
|
||||
};
|
||||
|
||||
// Composite keyset cursor "<createdAt-epoch-ms>_<id>". Ordering is by createdAt then id: a batch id is
|
||||
// a cuid (legacy) OR a run-ops id (new), and the two schemes occupy different lexical ranges, so `id`
|
||||
// alone is not a valid chronological order across the residency split. `id` is the stable tiebreak.
|
||||
// Old plain-id cursors (no "_") decode to undefined and restart from page 1 (self-healing).
|
||||
type BatchCursor = { createdAt: Date; id: string };
|
||||
function encodeBatchCursor(row: BatchCursor): string {
|
||||
return `${row.createdAt.getTime()}_${row.id}`;
|
||||
}
|
||||
function decodeBatchCursor(cursor: string | undefined): BatchCursor | undefined {
|
||||
if (!cursor) return undefined;
|
||||
const sep = cursor.indexOf("_");
|
||||
if (sep === -1) return undefined;
|
||||
const ms = Number(cursor.slice(0, sep));
|
||||
const id = cursor.slice(sep + 1);
|
||||
// Number.isFinite accepts e.g. 1e20, but new Date(1e20) is Invalid Date — reject it so a malformed
|
||||
// URL cursor self-heals to page 1 instead of reaching Prisma with an invalid date.
|
||||
const createdAt = new Date(ms);
|
||||
if (!Number.isFinite(ms) || Number.isNaN(createdAt.getTime()) || id.length === 0)
|
||||
return undefined;
|
||||
return { createdAt, id };
|
||||
}
|
||||
|
||||
export class BatchListPresenter extends BasePresenter {
|
||||
// Optional run-ops read-routing. Omitted (single-DB / self-host) => everything
|
||||
// reads from `_replica` exactly as today (passthrough). Field names are local to
|
||||
@@ -86,17 +108,16 @@ export class BatchListPresenter extends BasePresenter {
|
||||
return scan(passthrough);
|
||||
}
|
||||
|
||||
const newRows = await scan(this.readRoute.runOpsNew ?? passthrough);
|
||||
// Always read BOTH stores and merge. The old "skip legacy when new fills the page" shortcut is
|
||||
// unsound across the residency split: legacy cuid ids ("c…") sort ABOVE new run-ops ids ("0…")
|
||||
// under id order, so a new-only page can hide pre-flip legacy batches that belong ahead of it.
|
||||
// Ordering is by createdAt (id tiebreak), which is chronologically correct across both schemes.
|
||||
const [newRows, legacyRows] = await Promise.all([
|
||||
scan(this.readRoute.runOpsNew ?? passthrough),
|
||||
scan(this.readRoute.runOpsLegacyReplica ?? passthrough),
|
||||
]);
|
||||
|
||||
// New DB filled the page — skip the legacy read entirely; older rows fall on a later page.
|
||||
if (newRows.length >= pageSize + 1) {
|
||||
return newRows;
|
||||
}
|
||||
|
||||
const legacyRows = await scan(this.readRoute.runOpsLegacyReplica ?? passthrough);
|
||||
|
||||
// De-dupe by id (new wins), re-sort under the page's keyset order, re-apply the over-fetch
|
||||
// LIMIT — reproduces the pageSize+1 window a single union scan would return.
|
||||
// De-dupe by id (new wins), re-sort under the page's keyset order, re-apply the over-fetch LIMIT.
|
||||
const byId = new Map<string, BatchRow>();
|
||||
for (const row of newRows) {
|
||||
byId.set(row.id, row);
|
||||
@@ -107,10 +128,16 @@ export class BatchListPresenter extends BasePresenter {
|
||||
}
|
||||
}
|
||||
|
||||
// codepoint comparator (NEVER localeCompare): BatchTaskRun.id is ASCII (cuid or run-ops id).
|
||||
const sign = direction === "forward" ? 1 : -1; // forward => DESC; backward => ASC
|
||||
// forward => newest-first (createdAt DESC), backward => oldest-first (ASC); id is the stable
|
||||
// tiebreak (ASCII codepoint, NEVER localeCompare).
|
||||
const sign = direction === "forward" ? 1 : -1;
|
||||
return Array.from(byId.values())
|
||||
.sort((a, b) => (a.id < b.id ? sign : a.id > b.id ? -sign : 0))
|
||||
.sort((a, b) => {
|
||||
const at = a.createdAt.getTime();
|
||||
const bt = b.createdAt.getTime();
|
||||
if (at !== bt) return at < bt ? sign : -sign;
|
||||
return a.id < b.id ? sign : a.id > b.id ? -sign : 0;
|
||||
})
|
||||
.slice(0, pageSize + 1);
|
||||
}
|
||||
|
||||
@@ -212,11 +239,28 @@ export class BatchListPresenter extends BasePresenter {
|
||||
}
|
||||
const createdAtLte: Date | undefined = time.to;
|
||||
|
||||
// Composite (createdAt, id) keyset — see encodeBatchCursor. An old plain-id cursor decodes to
|
||||
// undefined and restarts from page 1.
|
||||
const keyCursor = decodeBatchCursor(cursor);
|
||||
|
||||
const batches = await this.#scanBatchTaskRun(pageSize, direction, (client) =>
|
||||
client.batchTaskRun.findMany({
|
||||
where: {
|
||||
runtimeEnvironmentId: environmentId,
|
||||
...(cursor ? { id: direction === "forward" ? { lt: cursor } : { gt: cursor } } : {}),
|
||||
...(keyCursor
|
||||
? {
|
||||
OR:
|
||||
direction === "forward"
|
||||
? [
|
||||
{ createdAt: { lt: keyCursor.createdAt } },
|
||||
{ createdAt: keyCursor.createdAt, id: { lt: keyCursor.id } },
|
||||
]
|
||||
: [
|
||||
{ createdAt: { gt: keyCursor.createdAt } },
|
||||
{ createdAt: keyCursor.createdAt, id: { gt: keyCursor.id } },
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
...(friendlyId ? { friendlyId } : {}),
|
||||
...(statuses && statuses.length > 0
|
||||
? { status: { in: statuses }, batchVersion: { not: "v1" } }
|
||||
@@ -230,7 +274,10 @@ export class BatchListPresenter extends BasePresenter {
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
orderBy: { id: direction === "forward" ? "desc" : "asc" },
|
||||
orderBy: [
|
||||
{ createdAt: direction === "forward" ? "desc" : "asc" },
|
||||
{ id: direction === "forward" ? "desc" : "asc" },
|
||||
],
|
||||
take: pageSize + 1,
|
||||
select: {
|
||||
id: true,
|
||||
@@ -248,23 +295,24 @@ export class BatchListPresenter extends BasePresenter {
|
||||
|
||||
const hasMore = batches.length > pageSize;
|
||||
|
||||
//get cursors for next and previous pages
|
||||
//get cursors for next and previous pages (composite (createdAt, id) keyset)
|
||||
const cur = (row?: BatchRow) => (row ? encodeBatchCursor(row) : undefined);
|
||||
let next: string | undefined;
|
||||
let previous: string | undefined;
|
||||
switch (direction) {
|
||||
case "forward":
|
||||
previous = cursor ? batches.at(0)?.id : undefined;
|
||||
previous = cursor ? cur(batches.at(0)) : undefined;
|
||||
if (hasMore) {
|
||||
next = batches[pageSize - 1]?.id;
|
||||
next = cur(batches[pageSize - 1]);
|
||||
}
|
||||
break;
|
||||
case "backward":
|
||||
batches.reverse();
|
||||
if (hasMore) {
|
||||
previous = batches[1]?.id;
|
||||
next = batches[pageSize]?.id;
|
||||
previous = cur(batches[1]);
|
||||
next = cur(batches[pageSize]);
|
||||
} else {
|
||||
next = batches[pageSize - 1]?.id;
|
||||
next = cur(batches[pageSize - 1]);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
type PrismaClientOrTransaction,
|
||||
} from "~/db.server";
|
||||
import { type AuthenticatedEnvironment } from "~/services/apiAuth.server";
|
||||
import { resolveRunIdMintKind } from "~/v3/engineVersion.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { generateHttpCallbackUrl } from "~/services/httpCallback.server";
|
||||
import {
|
||||
@@ -58,6 +59,16 @@ const { action } = createActionApiRoute(
|
||||
|
||||
const timeout = await parseDelay(body.timeout);
|
||||
|
||||
// A token (and its tags) has no owning run, so it can't co-locate. Resolve the env mint kind so a
|
||||
// minted-new env creates them on the run-ops DB (NEW) instead of defaulting to the draining LEGACY
|
||||
// DB by their cuid id-shape.
|
||||
const mintKind = await resolveRunIdMintKind({
|
||||
organizationId: authentication.environment.organizationId,
|
||||
id: authentication.environment.id,
|
||||
orgFeatureFlags: authentication.environment.organization.featureFlags,
|
||||
});
|
||||
const residency = mintKind === "runOpsId" ? "NEW" : "LEGACY";
|
||||
|
||||
//upsert tags
|
||||
let tags: { id: string; name: string }[] = [];
|
||||
const bodyTags = typeof body.tags === "string" ? [body.tags] : body.tags;
|
||||
@@ -74,6 +85,7 @@ const { action } = createActionApiRoute(
|
||||
tag,
|
||||
environmentId: authentication.environment.id,
|
||||
projectId: authentication.environment.projectId,
|
||||
residency,
|
||||
});
|
||||
if (tagRecord) {
|
||||
tags.push(tagRecord);
|
||||
@@ -88,6 +100,7 @@ const { action } = createActionApiRoute(
|
||||
idempotencyKeyExpiresAt,
|
||||
timeout,
|
||||
tags: bodyTags,
|
||||
standaloneResidency: residency,
|
||||
});
|
||||
|
||||
const $responseHeaders = await responseHeaders(authentication.environment);
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { AuthenticatedEnvironment } from "~/services/apiAuth.server";
|
||||
import { BaseService, ServiceValidationError } from "./baseService.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { getMollifierBuffer } from "~/v3/mollifier/mollifierBuffer.server";
|
||||
import { resolveRunIdMintKind } from "~/v3/engineVersion.server";
|
||||
|
||||
export class ResetIdempotencyKeyService extends BaseService {
|
||||
public async call(
|
||||
@@ -9,12 +10,27 @@ export class ResetIdempotencyKeyService extends BaseService {
|
||||
taskIdentifier: string,
|
||||
authenticatedEnv: AuthenticatedEnvironment
|
||||
): Promise<{ id: string }> {
|
||||
// The predicate has no run id to route by. When the env mints run-ops ids its runs live on NEW,
|
||||
// so pin the reset to NEW and skip the wrong-DB (0-row) write to the draining legacy DB. Resolve
|
||||
// this only when the org (and its flags) is loaded on the env — which the authenticated API path
|
||||
// always provides; otherwise fall back to the two-store reset (correct, just not optimized).
|
||||
let residency: "NEW" | "LEGACY" = "LEGACY";
|
||||
if (authenticatedEnv.organization) {
|
||||
const mintKind = await resolveRunIdMintKind({
|
||||
organizationId: authenticatedEnv.organizationId,
|
||||
id: authenticatedEnv.id,
|
||||
orgFeatureFlags: authenticatedEnv.organization.featureFlags,
|
||||
});
|
||||
residency = mintKind === "runOpsId" ? "NEW" : "LEGACY";
|
||||
}
|
||||
|
||||
const { count: pgCount } = await this.runStore.clearIdempotencyKey(
|
||||
{
|
||||
byPredicate: {
|
||||
idempotencyKey,
|
||||
taskIdentifier,
|
||||
runtimeEnvironmentId: authenticatedEnv.id,
|
||||
residency,
|
||||
},
|
||||
},
|
||||
this._prisma
|
||||
@@ -80,6 +96,7 @@ export class ResetIdempotencyKeyService extends BaseService {
|
||||
idempotencyKey,
|
||||
taskIdentifier,
|
||||
runtimeEnvironmentId: authenticatedEnv.id,
|
||||
residency,
|
||||
},
|
||||
},
|
||||
this._prisma
|
||||
|
||||
@@ -309,9 +309,9 @@ describe("BatchListPresenter run-ops read routing (PG14 control-plane/legacy + P
|
||||
}
|
||||
);
|
||||
|
||||
// Split scan merge serves new + legacy in one keyset-ordered page.
|
||||
// Split scan merge serves new + legacy in one createdAt-ordered page; legacy is always read.
|
||||
heteroPostgresTest(
|
||||
"split scan merges new (PG17) + legacy (PG14) rows under the keyset order; legacy read only when new does not fill the page",
|
||||
"split scan merges new (PG17) + legacy (PG14) rows under the createdAt keyset order; legacy always read",
|
||||
async ({ prisma14, prisma17 }) => {
|
||||
const ctx14 = await seedParents(prisma14, "merge");
|
||||
await mirrorEnvParents(prisma17, ctx14, "merge");
|
||||
@@ -323,7 +323,8 @@ describe("BatchListPresenter run-ops read routing (PG14 control-plane/legacy + P
|
||||
await createBatch(prisma14, ctx14, { id: "batch_d", friendlyId: "fr_d", runCount: 4 });
|
||||
await createBatch(prisma17, ctx14, { id: "batch_e", friendlyId: "fr_e", runCount: 5 });
|
||||
|
||||
// Case A: small page fully served by new alone => legacy NOT read.
|
||||
// Case A: always-merge — legacy is read even when new could fill the page (the old skip was
|
||||
// unsound across the residency split). Page is the createdAt-ordered union of both DBs.
|
||||
const legacySpyA = spyClient(prisma14);
|
||||
const presenterA = new BatchListPresenter(prisma17, prisma17, {
|
||||
runOpsNew: prisma17,
|
||||
@@ -332,9 +333,9 @@ describe("BatchListPresenter run-ops read routing (PG14 control-plane/legacy + P
|
||||
splitEnabled: true,
|
||||
});
|
||||
const pageA = await presenterA.call(baseCall(ctx14, { pageSize: 2 }));
|
||||
// new ids are e, c, a -> DESC: e, c (pageSize 2). pageSize+1 = 3 rows from new fills the page.
|
||||
expect(pageA.batches.map((b) => b.id)).toEqual(["batch_e", "batch_c"]);
|
||||
expect(legacySpyA.counts.findMany).toBe(0);
|
||||
// union newest-first (createdAt, insertion order a<b<c<d<e): e, d, c, b, a -> page of 2 = e, d.
|
||||
expect(pageA.batches.map((b) => b.id)).toEqual(["batch_e", "batch_d"]);
|
||||
expect(legacySpyA.counts.findMany).toBeGreaterThan(0);
|
||||
|
||||
// Case B: page needs legacy rows => legacy IS read and the merge is keyset-ordered union.
|
||||
const legacySpyB = spyClient(prisma14);
|
||||
@@ -348,8 +349,10 @@ describe("BatchListPresenter run-ops read routing (PG14 control-plane/legacy + P
|
||||
// union DESC of all 5: e, d, c, b, a -> first 4.
|
||||
expect(pageB.batches.map((b) => b.id)).toEqual(["batch_e", "batch_d", "batch_c", "batch_b"]);
|
||||
expect(legacySpyB.counts.findMany).toBeGreaterThan(0);
|
||||
// cursor parity: next is the 4th id (pageSize-th), previous undefined (no input cursor).
|
||||
expect(pageB.pagination.next).toBe("batch_b");
|
||||
// cursor parity: next is the FULL composite (createdAt, id) cursor of the 4th row (batch_b),
|
||||
// previous undefined. Assert the complete value so a bad timestamp prefix can't pass.
|
||||
const bRow = pageB.batches.find((b) => b.id === "batch_b")!;
|
||||
expect(pageB.pagination.next).toBe(`${new Date(bRow.createdAt).getTime()}_batch_b`);
|
||||
expect(pageB.pagination.previous).toBeUndefined();
|
||||
expect(pageB.hasAnyBatches).toBe(true);
|
||||
}
|
||||
@@ -436,7 +439,11 @@ describe("BatchListPresenter run-ops read routing (PG14 control-plane/legacy + P
|
||||
const hasMore = direct.length > 2;
|
||||
const expectedPage = direct.slice(0, 2);
|
||||
expect(page.batches.map((b) => b.id)).toEqual(expectedPage.map((r) => r.id));
|
||||
expect(page.pagination.next).toBe(hasMore ? expectedPage[1].id : undefined);
|
||||
expect(
|
||||
hasMore
|
||||
? page.pagination.next === `${expectedPage[1].createdAt.getTime()}_${expectedPage[1].id}`
|
||||
: !page.pagination.next
|
||||
).toBe(true);
|
||||
expect(page.pagination.previous).toBeUndefined();
|
||||
expect(page.hasAnyBatches).toBe(true);
|
||||
|
||||
@@ -453,6 +460,138 @@ describe("BatchListPresenter run-ops read routing (PG14 control-plane/legacy + P
|
||||
}
|
||||
);
|
||||
|
||||
// REGRESSION: a flipped org's real id mix. A cuid ("c"=0x63) sorts ABOVE a run-ops id ("0"=0x30)
|
||||
// under `id DESC`, so pre-flip legacy batches belong at the top — but #scanBatchTaskRun reads new
|
||||
// first, skips legacy once the page is full, and `id < cursor` can never reach a "c…" from a "0…"
|
||||
// cursor. Net: pre-flip legacy batches become unreachable.
|
||||
heteroPostgresTest(
|
||||
"flipped org: pre-flip legacy (cuid) batches remain reachable alongside post-flip run-ops batches",
|
||||
async ({ prisma14, prisma17 }) => {
|
||||
const ctx = await seedParents(prisma14, "flip");
|
||||
await mirrorEnvParents(prisma17, ctx, "flip");
|
||||
|
||||
// Pre-flip cuid batch on legacy (sorts highest); post-flip run-ops batches on new (sort below).
|
||||
const LEGACY_CUID = "cm0preflipbatch0000000001";
|
||||
await createBatch(prisma14, ctx, { id: LEGACY_CUID, friendlyId: "fr_preflip", runCount: 9 });
|
||||
|
||||
const NEW_RUNOPS = [
|
||||
"06fnewbatch00000000000000a",
|
||||
"06fnewbatch00000000000000b",
|
||||
"06fnewbatch00000000000000c",
|
||||
];
|
||||
for (const id of NEW_RUNOPS) {
|
||||
await createBatch(prisma17, ctx, { id, friendlyId: `fr_${id.slice(-1)}`, runCount: 1 });
|
||||
}
|
||||
|
||||
const presenter = new BatchListPresenter(prisma17, prisma17, {
|
||||
runOpsNew: prisma17,
|
||||
runOpsLegacyReplica: prisma14,
|
||||
controlPlaneReplica: prisma14,
|
||||
splitEnabled: true,
|
||||
});
|
||||
|
||||
// The pre-flip cuid batch is the oldest, so under newest-first it lands on a later page — but it
|
||||
// must be REACHABLE by paging forward, not stranded behind the run-ops ids (the skip + id-order
|
||||
// bug dropped it entirely: `id < <run-ops cursor>` never matches a "c…" id).
|
||||
const seen = new Set<string>();
|
||||
let cursor: string | undefined = undefined;
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const page = await presenter.call(
|
||||
baseCall(ctx, { pageSize: 2, cursor, direction: "forward" })
|
||||
);
|
||||
page.batches.forEach((b) => seen.add(b.id));
|
||||
if (!page.pagination.next) break;
|
||||
cursor = page.pagination.next;
|
||||
}
|
||||
expect([...seen]).toContain(LEGACY_CUID);
|
||||
}
|
||||
);
|
||||
|
||||
// REGRESSION (ordering): even with an always-merge fix, keyset-by-id is chronologically wrong across
|
||||
// the flip — a cuid ("c") sorts above a run-ops id ("0"), so an OLDER pre-flip batch outranks a NEWER
|
||||
// post-flip batch. The list is "newest first", so the later-created run-ops batch must come first.
|
||||
heteroPostgresTest(
|
||||
"flipped org: batches list is newest-first across the flip boundary (by createdAt, not id)",
|
||||
async ({ prisma14, prisma17 }) => {
|
||||
const ctx = await seedParents(prisma14, "order");
|
||||
await mirrorEnvParents(prisma17, ctx, "order");
|
||||
|
||||
const OLD_LEGACY = "cm0oldbatch00000000000001"; // cuid, created EARLIER
|
||||
const NEW_RUNOPS = "06fnewbatch000000000000001"; // run-ops, created LATER
|
||||
await createBatch(prisma14, ctx, {
|
||||
id: OLD_LEGACY,
|
||||
friendlyId: "fr_old",
|
||||
createdAt: new Date(Date.now() - 3_600_000),
|
||||
});
|
||||
await createBatch(prisma17, ctx, {
|
||||
id: NEW_RUNOPS,
|
||||
friendlyId: "fr_new",
|
||||
createdAt: new Date(),
|
||||
});
|
||||
|
||||
const presenter = new BatchListPresenter(prisma17, prisma17, {
|
||||
runOpsNew: prisma17,
|
||||
runOpsLegacyReplica: prisma14,
|
||||
controlPlaneReplica: prisma14,
|
||||
splitEnabled: true,
|
||||
});
|
||||
const page = await presenter.call(baseCall(ctx, { pageSize: 10 }));
|
||||
// Newest-first: the later-created run-ops batch outranks the older legacy one.
|
||||
expect(page.batches.map((b) => b.id)).toEqual([NEW_RUNOPS, OLD_LEGACY]);
|
||||
}
|
||||
);
|
||||
|
||||
// Overlap regression: batches duplicated on BOTH stores (mid-migration copies) must de-dupe to one
|
||||
// each without dropping rows or underfilling pages. Proves the merge needs no post-dedup refill:
|
||||
// the union of each store's top-(pageSize+1) always contains the global top, and the next page
|
||||
// re-queries both stores from the cursor.
|
||||
heteroPostgresTest(
|
||||
"flipped org: batches duplicated across both stores de-dupe without dropping rows across pagination",
|
||||
async ({ prisma14, prisma17 }) => {
|
||||
const ctx = await seedParents(prisma14, "ovl");
|
||||
await mirrorEnvParents(prisma17, ctx, "ovl");
|
||||
|
||||
const at = (secondsAgo: number) => new Date(Date.now() - secondsAgo * 1000);
|
||||
// a..e newest->oldest. a,b,c live on BOTH DBs (dup); d,e only on legacy.
|
||||
const rows = [
|
||||
{ id: "batch_ov_a", both: true, s: 1 },
|
||||
{ id: "batch_ov_b", both: true, s: 2 },
|
||||
{ id: "batch_ov_c", both: true, s: 3 },
|
||||
{ id: "batch_ov_d", both: false, s: 4 },
|
||||
{ id: "batch_ov_e", both: false, s: 5 },
|
||||
];
|
||||
for (const r of rows) {
|
||||
// A dup is a row COPY: identical (createdAt, id) on both DBs. Compute createdAt ONCE so both
|
||||
// copies match exactly (two at(r.s) calls would drift by ms and the older copy would re-surface
|
||||
// on the next page under the createdAt keyset).
|
||||
const createdAt = at(r.s);
|
||||
await createBatch(prisma14, ctx, { id: r.id, friendlyId: `fr_${r.id}`, createdAt });
|
||||
if (r.both) {
|
||||
await createBatch(prisma17, ctx, { id: r.id, friendlyId: `fr_${r.id}`, createdAt });
|
||||
}
|
||||
}
|
||||
|
||||
const seen: string[] = [];
|
||||
let cursor: string | undefined = undefined;
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const presenter = new BatchListPresenter(prisma17, prisma17, {
|
||||
runOpsNew: prisma17,
|
||||
runOpsLegacyReplica: prisma14,
|
||||
controlPlaneReplica: prisma14,
|
||||
splitEnabled: true,
|
||||
});
|
||||
const page = await presenter.call(
|
||||
baseCall(ctx, { pageSize: 2, cursor, direction: "forward" })
|
||||
);
|
||||
seen.push(...page.batches.map((b) => b.id));
|
||||
if (!page.pagination.next) break;
|
||||
cursor = page.pagination.next;
|
||||
}
|
||||
// Every batch exactly once, newest-first; the three dups collapsed to one each.
|
||||
expect(seen).toEqual(["batch_ov_a", "batch_ov_b", "batch_ov_c", "batch_ov_d", "batch_ov_e"]);
|
||||
}
|
||||
);
|
||||
|
||||
heteroRunOpsPostgresTest(
|
||||
"scan against dedicated RunOpsPrismaClient (splitEnabled): returns batches from new DB",
|
||||
async ({ prisma14, prisma17 }) => {
|
||||
|
||||
Reference in New Issue
Block a user