Skip to content

Commit 6d54da1

Browse files
committed
fix(webapp): stop the Batches list hiding or mis-ordering batches for some orgs
The dashboard Batches list ordered and paginated by batch id, but batch id no longer reflects creation order for every batch, so some organizations could see older batches missing from the list or sorted above newer ones. The list now orders and paginates by creation time (with id as a stable tiebreak) and always reads both backing stores, so every batch appears exactly once, newest first.
1 parent 32d68f4 commit 6d54da1

2 files changed

Lines changed: 158 additions & 30 deletions

File tree

apps/webapp/app/presenters/v3/BatchListPresenter.server.ts

Lines changed: 65 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,24 @@ type BatchRow = {
4343
batchVersion: string;
4444
};
4545

46+
// Composite keyset cursor "<createdAt-epoch-ms>_<id>". Ordering is by createdAt then id: a batch id is
47+
// a cuid (legacy) OR a run-ops id (new), and the two schemes occupy different lexical ranges, so `id`
48+
// alone is not a valid chronological order across the residency split. `id` is the stable tiebreak.
49+
// Old plain-id cursors (no "_") decode to undefined and restart from page 1 (self-healing).
50+
type BatchCursor = { createdAt: Date; id: string };
51+
function encodeBatchCursor(row: BatchCursor): string {
52+
return `${row.createdAt.getTime()}_${row.id}`;
53+
}
54+
function decodeBatchCursor(cursor: string | undefined): BatchCursor | undefined {
55+
if (!cursor) return undefined;
56+
const sep = cursor.indexOf("_");
57+
if (sep === -1) return undefined;
58+
const ms = Number(cursor.slice(0, sep));
59+
const id = cursor.slice(sep + 1);
60+
if (!Number.isFinite(ms) || id.length === 0) return undefined;
61+
return { createdAt: new Date(ms), id };
62+
}
63+
4664
export class BatchListPresenter extends BasePresenter {
4765
// Optional run-ops read-routing. Omitted (single-DB / self-host) => everything
4866
// reads from `_replica` exactly as today (passthrough). Field names are local to
@@ -86,17 +104,16 @@ export class BatchListPresenter extends BasePresenter {
86104
return scan(passthrough);
87105
}
88106

89-
const newRows = await scan(this.readRoute.runOpsNew ?? passthrough);
90-
91-
// New DB filled the page — skip the legacy read entirely; older rows fall on a later page.
92-
if (newRows.length >= pageSize + 1) {
93-
return newRows;
94-
}
95-
96-
const legacyRows = await scan(this.readRoute.runOpsLegacyReplica ?? passthrough);
107+
// Always read BOTH stores and merge. The old "skip legacy when new fills the page" shortcut is
108+
// unsound across the residency split: legacy cuid ids ("c…") sort ABOVE new run-ops ids ("0…")
109+
// under id order, so a new-only page can hide pre-flip legacy batches that belong ahead of it.
110+
// Ordering is by createdAt (id tiebreak), which is chronologically correct across both schemes.
111+
const [newRows, legacyRows] = await Promise.all([
112+
scan(this.readRoute.runOpsNew ?? passthrough),
113+
scan(this.readRoute.runOpsLegacyReplica ?? passthrough),
114+
]);
97115

98-
// De-dupe by id (new wins), re-sort under the page's keyset order, re-apply the over-fetch
99-
// LIMIT — reproduces the pageSize+1 window a single union scan would return.
116+
// De-dupe by id (new wins), re-sort under the page's keyset order, re-apply the over-fetch LIMIT.
100117
const byId = new Map<string, BatchRow>();
101118
for (const row of newRows) {
102119
byId.set(row.id, row);
@@ -107,10 +124,16 @@ export class BatchListPresenter extends BasePresenter {
107124
}
108125
}
109126

110-
// codepoint comparator (NEVER localeCompare): BatchTaskRun.id is ASCII (cuid or run-ops id).
111-
const sign = direction === "forward" ? 1 : -1; // forward => DESC; backward => ASC
127+
// forward => newest-first (createdAt DESC), backward => oldest-first (ASC); id is the stable
128+
// tiebreak (ASCII codepoint, NEVER localeCompare).
129+
const sign = direction === "forward" ? 1 : -1;
112130
return Array.from(byId.values())
113-
.sort((a, b) => (a.id < b.id ? sign : a.id > b.id ? -sign : 0))
131+
.sort((a, b) => {
132+
const at = a.createdAt.getTime();
133+
const bt = b.createdAt.getTime();
134+
if (at !== bt) return at < bt ? sign : -sign;
135+
return a.id < b.id ? sign : a.id > b.id ? -sign : 0;
136+
})
114137
.slice(0, pageSize + 1);
115138
}
116139

@@ -212,11 +235,28 @@ export class BatchListPresenter extends BasePresenter {
212235
}
213236
const createdAtLte: Date | undefined = time.to;
214237

238+
// Composite (createdAt, id) keyset — see encodeBatchCursor. An old plain-id cursor decodes to
239+
// undefined and restarts from page 1.
240+
const keyCursor = decodeBatchCursor(cursor);
241+
215242
const batches = await this.#scanBatchTaskRun(pageSize, direction, (client) =>
216243
client.batchTaskRun.findMany({
217244
where: {
218245
runtimeEnvironmentId: environmentId,
219-
...(cursor ? { id: direction === "forward" ? { lt: cursor } : { gt: cursor } } : {}),
246+
...(keyCursor
247+
? {
248+
OR:
249+
direction === "forward"
250+
? [
251+
{ createdAt: { lt: keyCursor.createdAt } },
252+
{ createdAt: keyCursor.createdAt, id: { lt: keyCursor.id } },
253+
]
254+
: [
255+
{ createdAt: { gt: keyCursor.createdAt } },
256+
{ createdAt: keyCursor.createdAt, id: { gt: keyCursor.id } },
257+
],
258+
}
259+
: {}),
220260
...(friendlyId ? { friendlyId } : {}),
221261
...(statuses && statuses.length > 0
222262
? { status: { in: statuses }, batchVersion: { not: "v1" } }
@@ -230,7 +270,10 @@ export class BatchListPresenter extends BasePresenter {
230270
}
231271
: {}),
232272
},
233-
orderBy: { id: direction === "forward" ? "desc" : "asc" },
273+
orderBy: [
274+
{ createdAt: direction === "forward" ? "desc" : "asc" },
275+
{ id: direction === "forward" ? "desc" : "asc" },
276+
],
234277
take: pageSize + 1,
235278
select: {
236279
id: true,
@@ -248,23 +291,24 @@ export class BatchListPresenter extends BasePresenter {
248291

249292
const hasMore = batches.length > pageSize;
250293

251-
//get cursors for next and previous pages
294+
//get cursors for next and previous pages (composite (createdAt, id) keyset)
295+
const cur = (row?: BatchRow) => (row ? encodeBatchCursor(row) : undefined);
252296
let next: string | undefined;
253297
let previous: string | undefined;
254298
switch (direction) {
255299
case "forward":
256-
previous = cursor ? batches.at(0)?.id : undefined;
300+
previous = cursor ? cur(batches.at(0)) : undefined;
257301
if (hasMore) {
258-
next = batches[pageSize - 1]?.id;
302+
next = cur(batches[pageSize - 1]);
259303
}
260304
break;
261305
case "backward":
262306
batches.reverse();
263307
if (hasMore) {
264-
previous = batches[1]?.id;
265-
next = batches[pageSize]?.id;
308+
previous = cur(batches[1]);
309+
next = cur(batches[pageSize]);
266310
} else {
267-
next = batches[pageSize - 1]?.id;
311+
next = cur(batches[pageSize - 1]);
268312
}
269313
break;
270314
}

apps/webapp/test/batchListPresenter.readroute.test.ts

Lines changed: 93 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -309,9 +309,9 @@ describe("BatchListPresenter run-ops read routing (PG14 control-plane/legacy + P
309309
}
310310
);
311311

312-
// Split scan merge serves new + legacy in one keyset-ordered page.
312+
// Split scan merge serves new + legacy in one createdAt-ordered page; legacy is always read.
313313
heteroPostgresTest(
314-
"split scan merges new (PG17) + legacy (PG14) rows under the keyset order; legacy read only when new does not fill the page",
314+
"split scan merges new (PG17) + legacy (PG14) rows under the createdAt keyset order; legacy always read",
315315
async ({ prisma14, prisma17 }) => {
316316
const ctx14 = await seedParents(prisma14, "merge");
317317
await mirrorEnvParents(prisma17, ctx14, "merge");
@@ -323,7 +323,8 @@ describe("BatchListPresenter run-ops read routing (PG14 control-plane/legacy + P
323323
await createBatch(prisma14, ctx14, { id: "batch_d", friendlyId: "fr_d", runCount: 4 });
324324
await createBatch(prisma17, ctx14, { id: "batch_e", friendlyId: "fr_e", runCount: 5 });
325325

326-
// Case A: small page fully served by new alone => legacy NOT read.
326+
// Case A: always-merge — legacy is read even when new could fill the page (the old skip was
327+
// unsound across the residency split). Page is the createdAt-ordered union of both DBs.
327328
const legacySpyA = spyClient(prisma14);
328329
const presenterA = new BatchListPresenter(prisma17, prisma17, {
329330
runOpsNew: prisma17,
@@ -332,9 +333,9 @@ describe("BatchListPresenter run-ops read routing (PG14 control-plane/legacy + P
332333
splitEnabled: true,
333334
});
334335
const pageA = await presenterA.call(baseCall(ctx14, { pageSize: 2 }));
335-
// new ids are e, c, a -> DESC: e, c (pageSize 2). pageSize+1 = 3 rows from new fills the page.
336-
expect(pageA.batches.map((b) => b.id)).toEqual(["batch_e", "batch_c"]);
337-
expect(legacySpyA.counts.findMany).toBe(0);
336+
// union newest-first (createdAt, insertion order a<b<c<d<e): e, d, c, b, a -> page of 2 = e, d.
337+
expect(pageA.batches.map((b) => b.id)).toEqual(["batch_e", "batch_d"]);
338+
expect(legacySpyA.counts.findMany).toBeGreaterThan(0);
338339

339340
// Case B: page needs legacy rows => legacy IS read and the merge is keyset-ordered union.
340341
const legacySpyB = spyClient(prisma14);
@@ -348,8 +349,8 @@ describe("BatchListPresenter run-ops read routing (PG14 control-plane/legacy + P
348349
// union DESC of all 5: e, d, c, b, a -> first 4.
349350
expect(pageB.batches.map((b) => b.id)).toEqual(["batch_e", "batch_d", "batch_c", "batch_b"]);
350351
expect(legacySpyB.counts.findMany).toBeGreaterThan(0);
351-
// cursor parity: next is the 4th id (pageSize-th), previous undefined (no input cursor).
352-
expect(pageB.pagination.next).toBe("batch_b");
352+
// cursor parity: next is the composite (createdAt, id) cursor of the 4th row, previous undefined.
353+
expect(pageB.pagination.next?.endsWith("_batch_b")).toBe(true);
353354
expect(pageB.pagination.previous).toBeUndefined();
354355
expect(pageB.hasAnyBatches).toBe(true);
355356
}
@@ -436,7 +437,9 @@ describe("BatchListPresenter run-ops read routing (PG14 control-plane/legacy + P
436437
const hasMore = direct.length > 2;
437438
const expectedPage = direct.slice(0, 2);
438439
expect(page.batches.map((b) => b.id)).toEqual(expectedPage.map((r) => r.id));
439-
expect(page.pagination.next).toBe(hasMore ? expectedPage[1].id : undefined);
440+
expect(
441+
hasMore ? page.pagination.next?.endsWith(`_${expectedPage[1].id}`) : !page.pagination.next
442+
).toBe(true);
440443
expect(page.pagination.previous).toBeUndefined();
441444
expect(page.hasAnyBatches).toBe(true);
442445

@@ -453,6 +456,87 @@ describe("BatchListPresenter run-ops read routing (PG14 control-plane/legacy + P
453456
}
454457
);
455458

459+
// REGRESSION: a flipped org's real id mix. A cuid ("c"=0x63) sorts ABOVE a run-ops id ("0"=0x30)
460+
// under `id DESC`, so pre-flip legacy batches belong at the top — but #scanBatchTaskRun reads new
461+
// first, skips legacy once the page is full, and `id < cursor` can never reach a "c…" from a "0…"
462+
// cursor. Net: pre-flip legacy batches become unreachable.
463+
heteroPostgresTest(
464+
"flipped org: pre-flip legacy (cuid) batches remain reachable alongside post-flip run-ops batches",
465+
async ({ prisma14, prisma17 }) => {
466+
const ctx = await seedParents(prisma14, "flip");
467+
await mirrorEnvParents(prisma17, ctx, "flip");
468+
469+
// Pre-flip cuid batch on legacy (sorts highest); post-flip run-ops batches on new (sort below).
470+
const LEGACY_CUID = "cm0preflipbatch0000000001";
471+
await createBatch(prisma14, ctx, { id: LEGACY_CUID, friendlyId: "fr_preflip", runCount: 9 });
472+
473+
const NEW_RUNOPS = [
474+
"06fnewbatch00000000000000a",
475+
"06fnewbatch00000000000000b",
476+
"06fnewbatch00000000000000c",
477+
];
478+
for (const id of NEW_RUNOPS) {
479+
await createBatch(prisma17, ctx, { id, friendlyId: `fr_${id.slice(-1)}`, runCount: 1 });
480+
}
481+
482+
const presenter = new BatchListPresenter(prisma17, prisma17, {
483+
runOpsNew: prisma17,
484+
runOpsLegacyReplica: prisma14,
485+
controlPlaneReplica: prisma14,
486+
splitEnabled: true,
487+
});
488+
489+
// The pre-flip cuid batch is the oldest, so under newest-first it lands on a later page — but it
490+
// must be REACHABLE by paging forward, not stranded behind the run-ops ids (the skip + id-order
491+
// bug dropped it entirely: `id < <run-ops cursor>` never matches a "c…" id).
492+
const seen = new Set<string>();
493+
let cursor: string | undefined = undefined;
494+
for (let i = 0; i < 10; i++) {
495+
const page = await presenter.call(
496+
baseCall(ctx, { pageSize: 2, cursor, direction: "forward" })
497+
);
498+
page.batches.forEach((b) => seen.add(b.id));
499+
if (!page.pagination.next) break;
500+
cursor = page.pagination.next;
501+
}
502+
expect([...seen]).toContain(LEGACY_CUID);
503+
}
504+
);
505+
506+
// REGRESSION (ordering): even with an always-merge fix, keyset-by-id is chronologically wrong across
507+
// the flip — a cuid ("c") sorts above a run-ops id ("0"), so an OLDER pre-flip batch outranks a NEWER
508+
// post-flip batch. The list is "newest first", so the later-created run-ops batch must come first.
509+
heteroPostgresTest(
510+
"flipped org: batches list is newest-first across the flip boundary (by createdAt, not id)",
511+
async ({ prisma14, prisma17 }) => {
512+
const ctx = await seedParents(prisma14, "order");
513+
await mirrorEnvParents(prisma17, ctx, "order");
514+
515+
const OLD_LEGACY = "cm0oldbatch00000000000001"; // cuid, created EARLIER
516+
const NEW_RUNOPS = "06fnewbatch000000000000001"; // run-ops, created LATER
517+
await createBatch(prisma14, ctx, {
518+
id: OLD_LEGACY,
519+
friendlyId: "fr_old",
520+
createdAt: new Date(Date.now() - 3_600_000),
521+
});
522+
await createBatch(prisma17, ctx, {
523+
id: NEW_RUNOPS,
524+
friendlyId: "fr_new",
525+
createdAt: new Date(),
526+
});
527+
528+
const presenter = new BatchListPresenter(prisma17, prisma17, {
529+
runOpsNew: prisma17,
530+
runOpsLegacyReplica: prisma14,
531+
controlPlaneReplica: prisma14,
532+
splitEnabled: true,
533+
});
534+
const page = await presenter.call(baseCall(ctx, { pageSize: 10 }));
535+
// Newest-first: the later-created run-ops batch outranks the older legacy one.
536+
expect(page.batches.map((b) => b.id)).toEqual([NEW_RUNOPS, OLD_LEGACY]);
537+
}
538+
);
539+
456540
heteroRunOpsPostgresTest(
457541
"scan against dedicated RunOpsPrismaClient (splitEnabled): returns batches from new DB",
458542
async ({ prisma14, prisma17 }) => {

0 commit comments

Comments
 (0)