fix(webapp): use composite keyset cursor for run pagination (#3852)
## Problem
`ClickHouseRunsRepository.listRunIds` / `listRuns` order results by the
composite key `(created_at, run_id)`, but the cursor predicate cut on
`run_id` **alone**:
```ts
.where("run_id < {runId: String}", { runId: cursor })
.orderBy("created_at DESC, run_id DESC")
```
This is only sound when `run_id` lexicographic order matches
`created_at` order. `run_id`s are cuids — only coarsely time-sortable —
so when a burst of runs is created within a sub-second window, the two
orders can diverge. When they do, the next-page predicate (`run_id <
cursor`, where `cursor` is the *last* page element = the smallest
`created_at`, not necessarily the smallest `run_id`):
- **re-includes** rows already returned on a previous page (duplicates),
and
- **skips** rows it should have returned (silent data loss).
For bulk **replay** this caused runs to be replayed more than once
(replay has no idempotency guard). For the dashboard and the `runs.list`
API it could silently repeat or skip runs at page boundaries.
## Fix
Make the cursor predicate match the composite ordering:
- Cursors now encode the full `(created_at, run_id)` key as an **opaque
URL-safe base64 token**
(`base64url({"c":<createdAtMs>,"r":"<runId>"})`), and the query cuts on
the matching tuple — `(created_at, run_id) < (…)` forward / `> (…)`
backward.
- The `ORDER BY` is unchanged, so the query stays aligned with the
table's primary key — no performance regression (the tuple range
predicate is actually more index-friendly than `run_id <` alone).
- Cursors are **server-issued opaque tokens** (the SDK only echoes
`pagination.next` / `pagination.previous` back), so this needs **no
client/SDK update**. Legacy cursors were the bare internal `run_id`;
they're detected by decode failure (a cuid isn't a valid base64-wrapped
JSON payload) and fall back to the old `run_id`-only predicate, so
in-flight cursors keep working and drain naturally. New cursors also no
longer expose a bare internal run id.
- `listRunIds` is now the single cursor-aware list primitive: it returns
`{ runIds, pagination: { nextCursor, previousCursor } }`, and `listRuns`
builds on it (one place constructs cursors). Bulk actions consume the
same method and advance by `pagination.nextCursor`, finishing when it's
`null`.
- `getTaskRunsQueryBuilder` now also selects
`toUnixTimestamp64Milli(created_at) AS created_at_ms`, using a dedicated
`TaskRunListQueryResult` schema. The shared `TaskRunV2QueryResult` stays
`run_id`-only so the run-engine pending-version lookup
(`getPendingVersionIdsQueryBuilder`, which selects only `run_id`)
doesn't fail validation on a column it doesn't query.
## Tests
New `runsRepositoryCursor.test.ts` (testcontainer-backed, real
Postgres→ClickHouse replication):
- **forward** pagination returns every run exactly once when `run_id`
order is the reverse of `created_at` order (reproduces the
duplicate/skip bug — fails on `main`; this
walk-until-`nextCursor`-null-and-assert-complete is exactly the bulk
action's iteration),
- **backward** pagination round-trips to the previous page across a
boundary,
- **legacy** bare-`run_id` cursor still uses the old predicate
(backwards compatibility).
The existing `runsRepository` suites (part1–4) still pass; `part4`'s
`count new runs with listRunIds` test was updated for the new `{ runIds,
pagination }` return shape, and the `clickhouse` `taskRuns`
query-builder snapshots were regenerated for the added `created_at_ms`
column.
## Notes
- Separate, pre-existing issue (out of scope, not introduced here):
`listRuns`' backward display-slicing (`rows.slice(1, size+1)` when
`hasMore`) has an off-by-one that can return a straddled page. Tracked
separately.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
---
|
||||
area: webapp
|
||||
type: fix
|
||||
---
|
||||
|
||||
Fix run pagination that could duplicate or skip runs: the query orders by `(created_at, run_id)` but the cursor cut on `run_id` alone, which diverges when run_id order doesn't match created_at order (e.g. bulk replay re-processing runs). Cursors now encode the composite key as an opaque token and cut on the matching tuple; legacy bare-run_id cursors stay supported for in-flight pagination.
|
||||
@@ -203,7 +203,7 @@ export class TestTaskPresenter {
|
||||
prisma: this.replica as PrismaClient,
|
||||
});
|
||||
|
||||
const runIds = await runsRepository.listRunIds({
|
||||
const { runIds } = await runsRepository.listRunIds({
|
||||
organizationId: environment.organizationId,
|
||||
environmentId: environment.id,
|
||||
projectId: environment.projectId,
|
||||
|
||||
+1
-1
@@ -58,7 +58,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
|
||||
return { count: 0, since: newRunsSince };
|
||||
}
|
||||
|
||||
const newRunIds = await runsRepository.listRunIds({
|
||||
const { runIds: newRunIds } = await runsRepository.listRunIds({
|
||||
organizationId: project.organizationId,
|
||||
projectId: project.id,
|
||||
environmentId: environment.id,
|
||||
|
||||
@@ -4,12 +4,16 @@ import {
|
||||
type FilterRunsOptions,
|
||||
type IRunsRepository,
|
||||
type ListRunsOptions,
|
||||
type RunIdsPage,
|
||||
type RunListInputOptions,
|
||||
type RunsRepositoryOptions,
|
||||
type TagListOptions,
|
||||
convertRunListInputOptionsToFilterRunsOptions,
|
||||
} from "./runsRepository.server";
|
||||
import parseDuration from "parse-duration";
|
||||
import { decodeRunsCursor, encodeRunsCursor } from "./runsCursor.server";
|
||||
|
||||
type RunCursorRow = { runId: string; createdAt: number };
|
||||
|
||||
export class ClickHouseRunsRepository implements IRunsRepository {
|
||||
constructor(private readonly options: RunsRepositoryOptions) {}
|
||||
@@ -18,25 +22,52 @@ export class ClickHouseRunsRepository implements IRunsRepository {
|
||||
return "clickhouse";
|
||||
}
|
||||
|
||||
async listRunIds(options: ListRunsOptions) {
|
||||
/**
|
||||
* Runs the keyset-paginated query and returns `{ runId, createdAt }` rows
|
||||
* (one extra beyond `page.size` to signal "has more"). The ordering is always
|
||||
* the composite `(created_at, run_id)`; the cursor predicate must match it.
|
||||
*
|
||||
* Composite cursors carry both components, so we cut on the
|
||||
* `(created_at, run_id)` tuple — sound regardless of how run_id order relates
|
||||
* to created_at order. Legacy bare-run_id cursors fall back to the old
|
||||
* `run_id`-only predicate (knowingly unsound) for backwards compatibility
|
||||
* with in-flight cursors.
|
||||
*/
|
||||
private async listRunRows(options: ListRunsOptions): Promise<RunCursorRow[]> {
|
||||
const queryBuilder = this.options.clickhouse.taskRuns.queryBuilder();
|
||||
applyRunFiltersToQueryBuilder(
|
||||
queryBuilder,
|
||||
await convertRunListInputOptionsToFilterRunsOptions(options, this.options.prisma)
|
||||
);
|
||||
|
||||
const forward = options.page.direction === "forward" || !options.page.direction;
|
||||
|
||||
if (options.page.cursor) {
|
||||
if (options.page.direction === "forward" || !options.page.direction) {
|
||||
queryBuilder
|
||||
.where("run_id < {runId: String}", { runId: options.page.cursor })
|
||||
.orderBy("created_at DESC, run_id DESC")
|
||||
.limit(options.page.size + 1);
|
||||
const decoded = decodeRunsCursor(options.page.cursor);
|
||||
|
||||
if (forward) {
|
||||
if (decoded.kind === "composite") {
|
||||
queryBuilder.where(
|
||||
"(created_at, run_id) < (fromUnixTimestamp64Milli({cursorCreatedAt: Int64}), {runId: String})",
|
||||
{ cursorCreatedAt: decoded.createdAt, runId: decoded.runId }
|
||||
);
|
||||
} else {
|
||||
queryBuilder.where("run_id < {runId: String}", { runId: decoded.runId });
|
||||
}
|
||||
queryBuilder.orderBy("created_at DESC, run_id DESC");
|
||||
} else {
|
||||
queryBuilder
|
||||
.where("run_id > {runId: String}", { runId: options.page.cursor })
|
||||
.orderBy("created_at ASC, run_id ASC")
|
||||
.limit(options.page.size + 1);
|
||||
if (decoded.kind === "composite") {
|
||||
queryBuilder.where(
|
||||
"(created_at, run_id) > (fromUnixTimestamp64Milli({cursorCreatedAt: Int64}), {runId: String})",
|
||||
{ cursorCreatedAt: decoded.createdAt, runId: decoded.runId }
|
||||
);
|
||||
} else {
|
||||
queryBuilder.where("run_id > {runId: String}", { runId: decoded.runId });
|
||||
}
|
||||
queryBuilder.orderBy("created_at ASC, run_id ASC");
|
||||
}
|
||||
|
||||
queryBuilder.limit(options.page.size + 1);
|
||||
} else {
|
||||
// Initial page - no cursor provided
|
||||
queryBuilder.orderBy("created_at DESC, run_id DESC").limit(options.page.size + 1);
|
||||
@@ -48,15 +79,64 @@ export class ClickHouseRunsRepository implements IRunsRepository {
|
||||
throw queryError;
|
||||
}
|
||||
|
||||
const runIds = result.map((row) => row.run_id);
|
||||
return runIds;
|
||||
return result.map((row) => ({ runId: row.run_id, createdAt: row.created_at_ms }));
|
||||
}
|
||||
|
||||
/**
|
||||
* A keyset-paginated page of run ids ordered by `(created_at, run_id)`, plus
|
||||
* the cursors to page forward/backward. Cursors are composite tokens that
|
||||
* match the ordering, so pagination can't duplicate or skip runs even when
|
||||
* run_id order diverges from created_at order. This is the single source of
|
||||
* cursor construction — `listRuns` and bulk actions both build on it.
|
||||
*/
|
||||
async listRunIds(options: ListRunsOptions): Promise<RunIdsPage> {
|
||||
const rows = await this.listRunRows(options);
|
||||
|
||||
// listRunRows fetches one extra row beyond page.size to detect "has more".
|
||||
const hasMore = rows.length > options.page.size;
|
||||
|
||||
const cursorFor = (row: RunCursorRow | undefined): string | null =>
|
||||
row ? encodeRunsCursor(row.createdAt, row.runId) : null;
|
||||
|
||||
let nextCursor: string | null = null;
|
||||
let previousCursor: string | null = null;
|
||||
|
||||
const direction = options.page.direction ?? "forward";
|
||||
switch (direction) {
|
||||
case "forward": {
|
||||
previousCursor = options.page.cursor ? cursorFor(rows.at(0)) : null;
|
||||
if (hasMore) {
|
||||
// The next cursor is the last run on this page.
|
||||
nextCursor = cursorFor(rows[options.page.size - 1]);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "backward": {
|
||||
const reversedRows = [...rows].reverse();
|
||||
if (hasMore) {
|
||||
previousCursor = cursorFor(reversedRows.at(1));
|
||||
nextCursor = cursorFor(reversedRows.at(options.page.size));
|
||||
} else {
|
||||
nextCursor = cursorFor(reversedRows.at(options.page.size - 1));
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const runIds = (
|
||||
direction === "backward" && hasMore
|
||||
? rows.slice(1, options.page.size + 1)
|
||||
: rows.slice(0, options.page.size)
|
||||
).map((row) => row.runId);
|
||||
|
||||
return { runIds, pagination: { nextCursor, previousCursor } };
|
||||
}
|
||||
|
||||
async listFriendlyRunIds(options: ListRunsOptions) {
|
||||
// First get internal IDs from ClickHouse
|
||||
const internalIds = await this.listRunIds(options);
|
||||
const { runIds } = await this.listRunIds(options);
|
||||
|
||||
if (internalIds.length === 0) {
|
||||
if (runIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -64,7 +144,7 @@ export class ClickHouseRunsRepository implements IRunsRepository {
|
||||
const runs = await this.options.prisma.taskRun.findMany({
|
||||
where: {
|
||||
id: {
|
||||
in: internalIds,
|
||||
in: runIds,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
@@ -76,47 +156,12 @@ export class ClickHouseRunsRepository implements IRunsRepository {
|
||||
}
|
||||
|
||||
async listRuns(options: ListRunsOptions) {
|
||||
const runIds = await this.listRunIds(options);
|
||||
|
||||
// If there are more runs than the page size, we need to fetch the next page
|
||||
const hasMore = runIds.length > options.page.size;
|
||||
|
||||
let nextCursor: string | null = null;
|
||||
let previousCursor: string | null = null;
|
||||
|
||||
//get cursors for next and previous pages
|
||||
const direction = options.page.direction ?? "forward";
|
||||
switch (direction) {
|
||||
case "forward": {
|
||||
previousCursor = options.page.cursor ? runIds.at(0) ?? null : null;
|
||||
if (hasMore) {
|
||||
// The next cursor should be the last run ID from this page
|
||||
nextCursor = runIds[options.page.size - 1];
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "backward": {
|
||||
const reversedRunIds = [...runIds].reverse();
|
||||
if (hasMore) {
|
||||
previousCursor = reversedRunIds.at(1) ?? null;
|
||||
nextCursor = reversedRunIds.at(options.page.size) ?? null;
|
||||
} else {
|
||||
nextCursor = reversedRunIds.at(options.page.size - 1) ?? null;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const runIdsToReturn =
|
||||
options.page.direction === "backward" && hasMore
|
||||
? runIds.slice(1, options.page.size + 1)
|
||||
: runIds.slice(0, options.page.size);
|
||||
const { runIds, pagination } = await this.listRunIds(options);
|
||||
|
||||
let runs = await this.options.prisma.taskRun.findMany({
|
||||
where: {
|
||||
id: {
|
||||
in: runIdsToReturn,
|
||||
in: runIds,
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
@@ -163,10 +208,7 @@ export class ClickHouseRunsRepository implements IRunsRepository {
|
||||
|
||||
return {
|
||||
runs,
|
||||
pagination: {
|
||||
nextCursor,
|
||||
previousCursor,
|
||||
},
|
||||
pagination,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Cursor encoding for keyset pagination over `(created_at, run_id)`.
|
||||
*
|
||||
* The list query orders by the composite key `(created_at, run_id)`, so a sound
|
||||
* cursor must carry BOTH components — cutting on `run_id` alone re-includes and
|
||||
* skips rows whenever `run_id` order diverges from `created_at` order.
|
||||
*
|
||||
* A cursor is an opaque URL-safe base64 token wrapping `{ c: createdAtMs, r:
|
||||
* runId }`. Cursors are server-issued (the SDK just echoes
|
||||
* `pagination.next`/`previous` back), so this format needs no client update.
|
||||
*
|
||||
* Legacy cursors were the bare internal run_id (a cuid). They are detected by
|
||||
* decode failure: a cuid base64-decodes to non-JSON bytes, so it falls through
|
||||
* to `{ kind: "legacy" }` and the old (knowingly unsound) `run_id`-only
|
||||
* predicate. In-flight legacy cursors keep working and drain naturally.
|
||||
*/
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
export type DecodedRunsCursor =
|
||||
| { kind: "composite"; createdAt: number; runId: string }
|
||||
| { kind: "legacy"; runId: string };
|
||||
|
||||
// `c` = created_at (ms since epoch), `r` = run_id. Short keys keep the token small.
|
||||
const CompositeCursor = z.object({
|
||||
c: z.number().int(),
|
||||
r: z.string().min(1),
|
||||
});
|
||||
|
||||
export function encodeRunsCursor(createdAtMs: number, runId: string): string {
|
||||
return Buffer.from(JSON.stringify({ c: createdAtMs, r: runId })).toString("base64url");
|
||||
}
|
||||
|
||||
export function decodeRunsCursor(cursor: string): DecodedRunsCursor {
|
||||
try {
|
||||
const parsed = CompositeCursor.safeParse(
|
||||
JSON.parse(Buffer.from(cursor, "base64url").toString("utf8"))
|
||||
);
|
||||
if (parsed.success) {
|
||||
return { kind: "composite", createdAt: parsed.data.c, runId: parsed.data.r };
|
||||
}
|
||||
} catch {
|
||||
// JSON.parse threw — not a composite cursor.
|
||||
}
|
||||
|
||||
return { kind: "legacy", runId: cursor };
|
||||
}
|
||||
@@ -127,9 +127,25 @@ export type TagList = {
|
||||
tags: string[];
|
||||
};
|
||||
|
||||
export type CursorPagination = {
|
||||
nextCursor: string | null;
|
||||
previousCursor: string | null;
|
||||
};
|
||||
|
||||
export type RunIdsPage = {
|
||||
runIds: string[];
|
||||
pagination: CursorPagination;
|
||||
};
|
||||
|
||||
export interface IRunsRepository {
|
||||
name: string;
|
||||
listRunIds(options: ListRunsOptions): Promise<string[]>;
|
||||
/**
|
||||
* A keyset-paginated page of run ids plus the cursors to navigate
|
||||
* forward/backward. The cursors are opaque composite `(created_at, run_id)`
|
||||
* tokens, so pagination can't duplicate or skip runs. This is the single
|
||||
* cursor-aware list primitive — `listRuns` and bulk actions build on it.
|
||||
*/
|
||||
listRunIds(options: ListRunsOptions): Promise<RunIdsPage>;
|
||||
/** Returns friendly IDs (e.g., run_xxx) instead of internal UUIDs. Used for ClickHouse task_events queries. */
|
||||
listFriendlyRunIds(options: ListRunsOptions): Promise<string[]>;
|
||||
listRuns(options: ListRunsOptions): Promise<{
|
||||
@@ -154,7 +170,7 @@ export class RunsRepository implements IRunsRepository {
|
||||
return "runsRepository";
|
||||
}
|
||||
|
||||
async listRunIds(options: ListRunsOptions): Promise<string[]> {
|
||||
async listRunIds(options: ListRunsOptions): Promise<RunIdsPage> {
|
||||
return startActiveSpan(
|
||||
"runsRepository.listRunIds",
|
||||
async () => this.clickHouseRunsRepository.listRunIds(options),
|
||||
|
||||
@@ -159,8 +159,13 @@ export class BulkActionService extends BaseService {
|
||||
throw new Error(`Bulk action group has invalid query name: ${group.queryName}`);
|
||||
}
|
||||
|
||||
// 2. Get the runs to process in this batch
|
||||
const runIds = await runsRepository.listRunIds({
|
||||
// 2. Get the runs to process in this batch, plus the cursor for the next
|
||||
// batch. The cursor is a composite (created_at, run_id) keyset cursor so the
|
||||
// next batch can't re-include or skip runs.
|
||||
const {
|
||||
runIds: runIdsToProcess,
|
||||
pagination: { nextCursor },
|
||||
} = await runsRepository.listRunIds({
|
||||
...filters,
|
||||
page: {
|
||||
size: env.BULK_ACTION_BATCH_SIZE,
|
||||
@@ -172,8 +177,6 @@ export class BulkActionService extends BaseService {
|
||||
// 3. Process the runs
|
||||
let successCount = 0;
|
||||
let failureCount = 0;
|
||||
// Slice because we fetch an extra for the cursor
|
||||
const runIdsToProcess = runIds.slice(0, env.BULK_ACTION_BATCH_SIZE);
|
||||
|
||||
switch (group.type) {
|
||||
case BulkActionType.CANCEL: {
|
||||
@@ -274,7 +277,10 @@ export class BulkActionService extends BaseService {
|
||||
}
|
||||
}
|
||||
|
||||
const isFinished = runIdsToProcess.length === 0;
|
||||
// A null nextCursor means there is no further page — this batch was the
|
||||
// last (or there were no runs at all), so the action is complete. (An empty
|
||||
// batch also yields a null cursor.)
|
||||
const isFinished = nextCursor === null;
|
||||
|
||||
logger.debug("Bulk action group processed batch", {
|
||||
bulkActionId,
|
||||
@@ -292,7 +298,8 @@ export class BulkActionService extends BaseService {
|
||||
const updatedGroup = await this._prisma.bulkActionGroup.update({
|
||||
where: { id: bulkActionId },
|
||||
data: {
|
||||
cursor: runIdsToProcess.at(runIdsToProcess.length - 1),
|
||||
// Json column: leave unchanged when there's no next cursor (finished).
|
||||
cursor: nextCursor ?? undefined,
|
||||
successCount: {
|
||||
increment: successCount,
|
||||
},
|
||||
|
||||
@@ -424,14 +424,14 @@ describe("RunsRepository (part 4/4)", () => {
|
||||
from: createdAtMs - 1,
|
||||
page: { size: 100 },
|
||||
});
|
||||
expect(newRunIdsBefore.length).toBeGreaterThanOrEqual(1);
|
||||
expect(newRunIdsBefore.runIds.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
const newRunIdsAfter = await runsRepository.listRunIds({
|
||||
...baseOptions,
|
||||
from: createdAtMs + 60_000,
|
||||
page: { size: 100 },
|
||||
});
|
||||
expect(newRunIdsAfter).toHaveLength(0);
|
||||
expect(newRunIdsAfter.runIds).toHaveLength(0);
|
||||
|
||||
const fromBeforeRun = createdAtMs - 1;
|
||||
|
||||
@@ -441,7 +441,7 @@ describe("RunsRepository (part 4/4)", () => {
|
||||
tasks: ["my-task"],
|
||||
page: { size: 100 },
|
||||
});
|
||||
expect(matchingTaskIds.length).toBeGreaterThanOrEqual(1);
|
||||
expect(matchingTaskIds.runIds.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
const otherTaskIds = await runsRepository.listRunIds({
|
||||
...baseOptions,
|
||||
@@ -449,7 +449,7 @@ describe("RunsRepository (part 4/4)", () => {
|
||||
tasks: ["other-task"],
|
||||
page: { size: 100 },
|
||||
});
|
||||
expect(otherTaskIds).toHaveLength(0);
|
||||
expect(otherTaskIds.runIds).toHaveLength(0);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
import { describe, expect, vi } from "vitest";
|
||||
|
||||
// Mock the db prisma client
|
||||
vi.mock("~/db.server", () => ({
|
||||
prisma: {},
|
||||
$replica: {},
|
||||
}));
|
||||
|
||||
import { replicationContainerTest } from "@internal/testcontainers";
|
||||
import { setTimeout } from "node:timers/promises";
|
||||
import { RunsRepository } from "~/services/runsRepository/runsRepository.server";
|
||||
import { setupClickhouseReplication } from "./utils/replicationUtils";
|
||||
|
||||
vi.setConfig({ testTimeout: 60_000 });
|
||||
|
||||
/**
|
||||
* Regression tests for keyset pagination over `(created_at, run_id)`.
|
||||
*
|
||||
* `listRunIds`/`listRuns` order by the composite key `(created_at, run_id)` but
|
||||
* the old cursor predicate cut on `run_id` alone. That is only sound when
|
||||
* `run_id` lexicographic order matches `created_at` order. When a burst of runs
|
||||
* is created such that the two orders diverge (here: deliberately reversed),
|
||||
* keyset pagination both re-includes already-seen runs (duplicates) and drops
|
||||
* runs it should have returned (skips).
|
||||
*
|
||||
* Each test inserts runs with explicit ids so that `run_id` ascending order is
|
||||
* the exact reverse of `created_at` ascending order, then walks every page and
|
||||
* asserts the union is exactly the inserted set with no duplicates.
|
||||
*/
|
||||
describe("RunsRepository cursor pagination", () => {
|
||||
replicationContainerTest(
|
||||
"forward pagination returns every run exactly once when run_id order is the reverse of created_at order",
|
||||
async ({ clickhouseContainer, redisOptions, postgresContainer, prisma }) => {
|
||||
const { clickhouse } = await setupClickhouseReplication({
|
||||
prisma,
|
||||
databaseUrl: postgresContainer.getConnectionUri(),
|
||||
clickhouseUrl: clickhouseContainer.getConnectionUrl(),
|
||||
redisOptions,
|
||||
});
|
||||
|
||||
const organization = await prisma.organization.create({
|
||||
data: { title: "test", slug: "test" },
|
||||
});
|
||||
const project = await prisma.project.create({
|
||||
data: {
|
||||
name: "test",
|
||||
slug: "test",
|
||||
organizationId: organization.id,
|
||||
externalRef: "test",
|
||||
},
|
||||
});
|
||||
const runtimeEnvironment = await prisma.runtimeEnvironment.create({
|
||||
data: {
|
||||
slug: "test",
|
||||
type: "DEVELOPMENT",
|
||||
projectId: project.id,
|
||||
organizationId: organization.id,
|
||||
apiKey: "test",
|
||||
pkApiKey: "test",
|
||||
shortcode: "test",
|
||||
},
|
||||
});
|
||||
|
||||
// run_id ascending: a < b < c < d < e
|
||||
// created_at ascending: e < d < c < b < a (the exact reverse)
|
||||
const ids = [
|
||||
"aaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
"bbbbbbbbbbbbbbbbbbbbbbbb",
|
||||
"cccccccccccccccccccccccc",
|
||||
"dddddddddddddddddddddddd",
|
||||
"eeeeeeeeeeeeeeeeeeeeeeee",
|
||||
];
|
||||
const base = new Date("2026-06-04T16:55:07.000Z").getTime();
|
||||
for (let i = 0; i < ids.length; i++) {
|
||||
await prisma.taskRun.create({
|
||||
data: {
|
||||
id: ids[i],
|
||||
// earliest-created run has the largest run_id (reverse correlation)
|
||||
createdAt: new Date(base + (ids.length - 1 - i) * 1000),
|
||||
friendlyId: `run_${ids[i]}`,
|
||||
taskIdentifier: "my-task",
|
||||
payload: JSON.stringify({ foo: "bar" }),
|
||||
traceId: `trace_${i}`,
|
||||
spanId: `span_${i}`,
|
||||
queue: "test",
|
||||
runtimeEnvironmentId: runtimeEnvironment.id,
|
||||
projectId: project.id,
|
||||
organizationId: organization.id,
|
||||
environmentType: "DEVELOPMENT",
|
||||
engine: "V2",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await setTimeout(1000);
|
||||
|
||||
const runsRepository = new RunsRepository({ prisma, clickhouse });
|
||||
|
||||
const baseOptions = {
|
||||
projectId: project.id,
|
||||
environmentId: runtimeEnvironment.id,
|
||||
organizationId: organization.id,
|
||||
};
|
||||
|
||||
// Walk every forward page, size 2, accumulating ids.
|
||||
const seen: string[] = [];
|
||||
let cursor: string | undefined = undefined;
|
||||
for (let guard = 0; guard < 20; guard++) {
|
||||
const page = await runsRepository.listRuns({
|
||||
...baseOptions,
|
||||
page: { size: 2, cursor, direction: cursor ? "forward" : undefined },
|
||||
});
|
||||
seen.push(...page.runs.map((r) => r.id));
|
||||
if (!page.pagination.nextCursor) break;
|
||||
cursor = page.pagination.nextCursor;
|
||||
}
|
||||
|
||||
// No duplicates, no skips: every inserted run appears exactly once.
|
||||
expect(seen.slice().sort()).toEqual(ids.slice().sort());
|
||||
expect(new Set(seen).size).toBe(ids.length);
|
||||
}
|
||||
);
|
||||
|
||||
replicationContainerTest(
|
||||
"backward pagination round-trips to the previous page when run_id order is the reverse of created_at order",
|
||||
async ({ clickhouseContainer, redisOptions, postgresContainer, prisma }) => {
|
||||
const { clickhouse } = await setupClickhouseReplication({
|
||||
prisma,
|
||||
databaseUrl: postgresContainer.getConnectionUri(),
|
||||
clickhouseUrl: clickhouseContainer.getConnectionUrl(),
|
||||
redisOptions,
|
||||
});
|
||||
|
||||
const organization = await prisma.organization.create({
|
||||
data: { title: "test", slug: "test" },
|
||||
});
|
||||
const project = await prisma.project.create({
|
||||
data: {
|
||||
name: "test",
|
||||
slug: "test",
|
||||
organizationId: organization.id,
|
||||
externalRef: "test",
|
||||
},
|
||||
});
|
||||
const runtimeEnvironment = await prisma.runtimeEnvironment.create({
|
||||
data: {
|
||||
slug: "test",
|
||||
type: "DEVELOPMENT",
|
||||
projectId: project.id,
|
||||
organizationId: organization.id,
|
||||
apiKey: "test",
|
||||
pkApiKey: "test",
|
||||
shortcode: "test",
|
||||
},
|
||||
});
|
||||
|
||||
// run_id ascending: a < b < c ; created_at ascending: c < b < a (reversed).
|
||||
const ids = [
|
||||
"aaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
"bbbbbbbbbbbbbbbbbbbbbbbb",
|
||||
"cccccccccccccccccccccccc",
|
||||
];
|
||||
const base = new Date("2026-06-04T16:55:07.000Z").getTime();
|
||||
for (let i = 0; i < ids.length; i++) {
|
||||
await prisma.taskRun.create({
|
||||
data: {
|
||||
id: ids[i],
|
||||
createdAt: new Date(base + (ids.length - 1 - i) * 1000),
|
||||
friendlyId: `run_${ids[i]}`,
|
||||
taskIdentifier: "my-task",
|
||||
payload: JSON.stringify({ foo: "bar" }),
|
||||
traceId: `trace_${i}`,
|
||||
spanId: `span_${i}`,
|
||||
queue: "test",
|
||||
runtimeEnvironmentId: runtimeEnvironment.id,
|
||||
projectId: project.id,
|
||||
organizationId: organization.id,
|
||||
environmentType: "DEVELOPMENT",
|
||||
engine: "V2",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await setTimeout(1000);
|
||||
|
||||
const runsRepository = new RunsRepository({ prisma, clickhouse });
|
||||
const baseOptions = {
|
||||
projectId: project.id,
|
||||
environmentId: runtimeEnvironment.id,
|
||||
organizationId: organization.id,
|
||||
};
|
||||
|
||||
// Forward order (created_at DESC) is [a, b, c]. First page (size 2) = {a, b}.
|
||||
const firstPage = await runsRepository.listRuns({
|
||||
...baseOptions,
|
||||
page: { size: 2 },
|
||||
});
|
||||
const firstIds = firstPage.runs.map((r) => r.id).sort();
|
||||
expect(firstIds).toEqual(["aaaaaaaaaaaaaaaaaaaaaaaa", "bbbbbbbbbbbbbbbbbbbbbbbb"]);
|
||||
expect(firstPage.pagination.nextCursor).toBeTruthy();
|
||||
|
||||
// Forward to the second page = {c}; it exposes a previousCursor.
|
||||
const secondPage = await runsRepository.listRuns({
|
||||
...baseOptions,
|
||||
page: { size: 2, cursor: firstPage.pagination.nextCursor!, direction: "forward" },
|
||||
});
|
||||
expect(secondPage.runs.map((r) => r.id)).toEqual(["cccccccccccccccccccccccc"]);
|
||||
expect(secondPage.pagination.previousCursor).toBeTruthy();
|
||||
|
||||
// Stepping backward from the second page must land back on the first page
|
||||
// exactly — no duplicated or skipped runs across the boundary.
|
||||
const backPage = await runsRepository.listRuns({
|
||||
...baseOptions,
|
||||
page: { size: 2, cursor: secondPage.pagination.previousCursor!, direction: "backward" },
|
||||
});
|
||||
expect(backPage.runs.map((r) => r.id).sort()).toEqual(firstIds);
|
||||
}
|
||||
);
|
||||
|
||||
replicationContainerTest(
|
||||
"legacy bare run_id cursor still uses the old (run_id-only) predicate for backwards compatibility",
|
||||
async ({ clickhouseContainer, redisOptions, postgresContainer, prisma }) => {
|
||||
const { clickhouse } = await setupClickhouseReplication({
|
||||
prisma,
|
||||
databaseUrl: postgresContainer.getConnectionUri(),
|
||||
clickhouseUrl: clickhouseContainer.getConnectionUrl(),
|
||||
redisOptions,
|
||||
});
|
||||
|
||||
const organization = await prisma.organization.create({
|
||||
data: { title: "test", slug: "test" },
|
||||
});
|
||||
const project = await prisma.project.create({
|
||||
data: {
|
||||
name: "test",
|
||||
slug: "test",
|
||||
organizationId: organization.id,
|
||||
externalRef: "test",
|
||||
},
|
||||
});
|
||||
const runtimeEnvironment = await prisma.runtimeEnvironment.create({
|
||||
data: {
|
||||
slug: "test",
|
||||
type: "DEVELOPMENT",
|
||||
projectId: project.id,
|
||||
organizationId: organization.id,
|
||||
apiKey: "test",
|
||||
pkApiKey: "test",
|
||||
shortcode: "test",
|
||||
},
|
||||
});
|
||||
|
||||
const ids = [
|
||||
"aaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
"bbbbbbbbbbbbbbbbbbbbbbbb",
|
||||
"cccccccccccccccccccccccc",
|
||||
];
|
||||
const base = new Date("2026-06-04T16:55:07.000Z").getTime();
|
||||
for (let i = 0; i < ids.length; i++) {
|
||||
await prisma.taskRun.create({
|
||||
data: {
|
||||
id: ids[i],
|
||||
createdAt: new Date(base + i * 1000),
|
||||
friendlyId: `run_${ids[i]}`,
|
||||
taskIdentifier: "my-task",
|
||||
payload: JSON.stringify({ foo: "bar" }),
|
||||
traceId: `trace_${i}`,
|
||||
spanId: `span_${i}`,
|
||||
queue: "test",
|
||||
runtimeEnvironmentId: runtimeEnvironment.id,
|
||||
projectId: project.id,
|
||||
organizationId: organization.id,
|
||||
environmentType: "DEVELOPMENT",
|
||||
engine: "V2",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await setTimeout(1000);
|
||||
|
||||
const runsRepository = new RunsRepository({ prisma, clickhouse });
|
||||
|
||||
// A legacy cursor is a bare run_id. The old predicate is `run_id < cursor`,
|
||||
// so passing the largest run_id must return the two smaller ones.
|
||||
const page = await runsRepository.listRuns({
|
||||
projectId: project.id,
|
||||
environmentId: runtimeEnvironment.id,
|
||||
organizationId: organization.id,
|
||||
page: { size: 10, cursor: "cccccccccccccccccccccccc", direction: "forward" },
|
||||
});
|
||||
|
||||
const returned = page.runs.map((r) => r.id).sort();
|
||||
expect(returned).toEqual([
|
||||
"aaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
"bbbbbbbbbbbbbbbbbbbbbbbb",
|
||||
]);
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -5,7 +5,7 @@ exports[`Task Runs V2 > should be able to query task runs using the query builde
|
||||
"params": {
|
||||
"environmentId": "cm9kddfcs01zqdy88ld9mmrli",
|
||||
},
|
||||
"query": "SELECT run_id FROM trigger_dev.task_runs_v2 FINAL WHERE environment_id = {environmentId: String}",
|
||||
"query": "SELECT run_id, toUnixTimestamp64Milli(created_at) AS created_at_ms FROM trigger_dev.task_runs_v2 FINAL WHERE environment_id = {environmentId: String}",
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -15,6 +15,6 @@ exports[`Task Runs V2 > should be able to query task runs using the query builde
|
||||
"environmentId": "cm9kddfcs01zqdy88ld9mmrli",
|
||||
"status": "COMPLETED_SUCCESSFULLY",
|
||||
},
|
||||
"query": "SELECT run_id FROM trigger_dev.task_runs_v2 FINAL WHERE environment_id = {environmentId: String} AND status = {status: String}",
|
||||
"query": "SELECT run_id, toUnixTimestamp64Milli(created_at) AS created_at_ms FROM trigger_dev.task_runs_v2 FINAL WHERE environment_id = {environmentId: String} AND status = {status: String}",
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -370,11 +370,25 @@ export const TaskRunV2QueryResult = z.object({
|
||||
|
||||
export type TaskRunV2QueryResult = z.infer<typeof TaskRunV2QueryResult>;
|
||||
|
||||
// Adds the created_at timestamp (ms since epoch) needed to build composite
|
||||
// keyset cursors over (created_at, run_id) — see runsRepository.server.ts.
|
||||
// Returned as a JSON number because the client sets
|
||||
// output_format_json_quote_64bit_integers: 0. Kept separate from
|
||||
// TaskRunV2QueryResult so run_id-only consumers (e.g. the pending-version
|
||||
// lookup) aren't forced to select a column they don't need.
|
||||
export const TaskRunListQueryResult = z.object({
|
||||
run_id: z.string(),
|
||||
created_at_ms: z.number().int(),
|
||||
});
|
||||
|
||||
export type TaskRunListQueryResult = z.infer<typeof TaskRunListQueryResult>;
|
||||
|
||||
export function getTaskRunsQueryBuilder(ch: ClickhouseReader, settings?: ClickHouseSettings) {
|
||||
return ch.queryBuilder({
|
||||
name: "getTaskRuns",
|
||||
baseQuery: "SELECT run_id FROM trigger_dev.task_runs_v2 FINAL",
|
||||
schema: TaskRunV2QueryResult,
|
||||
baseQuery:
|
||||
"SELECT run_id, toUnixTimestamp64Milli(created_at) AS created_at_ms FROM trigger_dev.task_runs_v2 FINAL",
|
||||
schema: TaskRunListQueryResult,
|
||||
settings,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user