chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { BoundedTtlCache } from "~/services/realtime/boundedTtlCache";
|
||||
|
||||
describe("BoundedTtlCache", () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("returns a live entry within its TTL", () => {
|
||||
vi.useFakeTimers();
|
||||
const cache = new BoundedTtlCache<string>(1_000, 100);
|
||||
cache.set("k", "v");
|
||||
vi.advanceTimersByTime(500);
|
||||
expect(cache.get("k")).toBe("v");
|
||||
expect(cache.size).toBe(1);
|
||||
});
|
||||
|
||||
it("evicts an expired entry on read instead of letting it linger", () => {
|
||||
vi.useFakeTimers();
|
||||
const cache = new BoundedTtlCache<number>(1_000, 100);
|
||||
cache.set("a", 1);
|
||||
expect(cache.size).toBe(1);
|
||||
|
||||
vi.advanceTimersByTime(1_001);
|
||||
expect(cache.get("a")).toBeUndefined();
|
||||
// The previous bug left expired entries in the map until an at-capacity sweep;
|
||||
// they must now be removed on read.
|
||||
expect(cache.size).toBe(0);
|
||||
});
|
||||
|
||||
it("does not evict another entry when updating an existing key at capacity", () => {
|
||||
const cache = new BoundedTtlCache<number>(60_000, 2);
|
||||
cache.set("a", 1);
|
||||
cache.set("b", 2);
|
||||
// Updating an existing key doesn't grow the map, so it must not drop "b".
|
||||
cache.set("a", 11);
|
||||
expect(cache.get("a")).toBe(11);
|
||||
expect(cache.get("b")).toBe(2);
|
||||
expect(cache.size).toBe(2);
|
||||
});
|
||||
|
||||
it("drops the oldest entry when full of still-live entries", () => {
|
||||
const cache = new BoundedTtlCache<number>(60_000, 2);
|
||||
cache.set("a", 1);
|
||||
cache.set("b", 2);
|
||||
cache.set("c", 3); // over capacity, none expired -> evict oldest insertion (a)
|
||||
expect(cache.get("a")).toBeUndefined();
|
||||
expect(cache.get("b")).toBe(2);
|
||||
expect(cache.get("c")).toBe(3);
|
||||
expect(cache.size).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,388 @@
|
||||
import { describe, expect, vi } from "vitest";
|
||||
|
||||
// The runsRepository module graph imports `~/v3/runStore.server`, which imports `~/db.server`
|
||||
// at load. Stub it (the existing runsRepository.part*.test.ts / readthrough test do the same) — the
|
||||
// resolver under test is driven entirely through injected real containers, never the stubbed
|
||||
// module singletons.
|
||||
vi.mock("~/db.server", () => ({
|
||||
prisma: {},
|
||||
$replica: {},
|
||||
}));
|
||||
|
||||
import { createPostgresContainer, replicationContainerTest } from "@internal/testcontainers";
|
||||
import { PrismaClient } from "@trigger.dev/database";
|
||||
import { setTimeout } from "node:timers/promises";
|
||||
import { ClickHouseRunListResolver } from "~/services/realtime/clickHouseRunListResolver.server";
|
||||
import { setupClickhouseReplication } from "../utils/replicationUtils";
|
||||
|
||||
vi.setConfig({ testTimeout: 90_000 });
|
||||
|
||||
type SeedContext = {
|
||||
organizationId: string;
|
||||
projectId: string;
|
||||
environmentId: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates the org/project/env parents on a single prisma client. TaskRun FKs require these to
|
||||
* exist, and this container doubles as the logical-replication source that feeds the
|
||||
* ClickHouse id-set, so all runs whose ids we expect from ClickHouse are seeded here.
|
||||
*/
|
||||
async function seedParents(prisma: PrismaClient, slug: string): Promise<SeedContext> {
|
||||
const organization = await prisma.organization.create({
|
||||
data: { title: `org-${slug}`, slug: `org-${slug}` },
|
||||
});
|
||||
const project = await prisma.project.create({
|
||||
data: {
|
||||
name: `proj-${slug}`,
|
||||
slug: `proj-${slug}`,
|
||||
organizationId: organization.id,
|
||||
externalRef: `proj-${slug}`,
|
||||
},
|
||||
});
|
||||
const runtimeEnvironment = await prisma.runtimeEnvironment.create({
|
||||
data: {
|
||||
slug: `env-${slug}`,
|
||||
type: "DEVELOPMENT",
|
||||
projectId: project.id,
|
||||
organizationId: organization.id,
|
||||
apiKey: `tr_dev_${slug}`,
|
||||
pkApiKey: `pk_dev_${slug}`,
|
||||
shortcode: `sc-${slug}`,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
organizationId: organization.id,
|
||||
projectId: project.id,
|
||||
environmentId: runtimeEnvironment.id,
|
||||
};
|
||||
}
|
||||
|
||||
/** A second environment in the same project — used to prove the CH filter excludes other envs. */
|
||||
async function seedSecondEnvironment(prisma: PrismaClient, ctx: SeedContext, slug: string) {
|
||||
const runtimeEnvironment = await prisma.runtimeEnvironment.create({
|
||||
data: {
|
||||
slug: `env-${slug}-2`,
|
||||
type: "PRODUCTION",
|
||||
projectId: ctx.projectId,
|
||||
organizationId: ctx.organizationId,
|
||||
apiKey: `tr_prod_${slug}`,
|
||||
pkApiKey: `pk_prod_${slug}`,
|
||||
shortcode: `sc-${slug}-2`,
|
||||
},
|
||||
});
|
||||
return runtimeEnvironment.id;
|
||||
}
|
||||
|
||||
async function createRun(
|
||||
prisma: PrismaClient,
|
||||
ctx: SeedContext & { environmentId?: string },
|
||||
run: { friendlyId: string; runTags?: string[]; createdAt?: Date }
|
||||
) {
|
||||
return prisma.taskRun.create({
|
||||
data: {
|
||||
friendlyId: run.friendlyId,
|
||||
taskIdentifier: "my-task",
|
||||
status: "PENDING",
|
||||
payload: JSON.stringify({ foo: run.friendlyId }),
|
||||
traceId: run.friendlyId,
|
||||
spanId: run.friendlyId,
|
||||
queue: "test",
|
||||
runTags: run.runTags ?? [],
|
||||
...(run.createdAt ? { createdAt: run.createdAt } : {}),
|
||||
runtimeEnvironmentId: ctx.environmentId,
|
||||
projectId: ctx.projectId,
|
||||
organizationId: ctx.organizationId,
|
||||
environmentType: "DEVELOPMENT",
|
||||
engine: "V2",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps a real prisma client so ONLY `taskRun.findMany` throws — every other member stays a real
|
||||
* handle. The resolver's id-set path (`listRunIds` -> `listRunRows`) performs no `taskRun.findMany`
|
||||
* and never calls the run-ops store, so this proxy must never trip. The CPRES-owned filter
|
||||
* resolution that DOES run for a `batchId` filter uses `batchTaskRun.findFirst`, which this proxy
|
||||
* leaves intact.
|
||||
*/
|
||||
function throwingTaskRunFindMany(prisma: PrismaClient): PrismaClient {
|
||||
return new Proxy(prisma, {
|
||||
get(target, prop) {
|
||||
if (prop === "taskRun") {
|
||||
return new Proxy((target as any).taskRun, {
|
||||
get(trTarget, trProp) {
|
||||
if (trProp === "findMany") {
|
||||
return async () => {
|
||||
throw new Error(
|
||||
"taskRun.findMany must not be invoked on the realtime id-set path (a hydrate leaked in)"
|
||||
);
|
||||
};
|
||||
}
|
||||
return (trTarget as any)[trProp];
|
||||
},
|
||||
});
|
||||
}
|
||||
return (target as any)[prop];
|
||||
},
|
||||
}) as unknown as PrismaClient;
|
||||
}
|
||||
|
||||
describe("ClickHouseRunListResolver (realtime run-list id-set, split-neutral)", () => {
|
||||
// resolves the CH id-set with NO TaskRun PG hydrate.
|
||||
replicationContainerTest(
|
||||
"resolves the ClickHouse id-set for run-ops rows without ever reading TaskRun in Postgres",
|
||||
async ({ clickhouseContainer, redisOptions, postgresContainer, prisma }) => {
|
||||
const { clickhouse } = await setupClickhouseReplication({
|
||||
prisma,
|
||||
databaseUrl: postgresContainer.getConnectionUri(),
|
||||
clickhouseUrl: clickhouseContainer.getConnectionUrl(),
|
||||
redisOptions,
|
||||
});
|
||||
|
||||
const ctx = await seedParents(prisma, "idset");
|
||||
|
||||
const runA = await createRun(prisma, ctx, { friendlyId: "run_idsetA" });
|
||||
const runB = await createRun(prisma, ctx, { friendlyId: "run_idsetB" });
|
||||
const runC = await createRun(prisma, ctx, { friendlyId: "run_idsetC" });
|
||||
|
||||
await setTimeout(1500);
|
||||
|
||||
// ONLY taskRun.findMany throws; the rest of the client is real so the resolver can run.
|
||||
const resolver = new ClickHouseRunListResolver({
|
||||
getClickhouse: async () => clickhouse,
|
||||
prisma: throwingTaskRunFindMany(prisma),
|
||||
});
|
||||
|
||||
const runIds = await resolver.resolveMatchingRunIds({
|
||||
organizationId: ctx.organizationId,
|
||||
projectId: ctx.projectId,
|
||||
environmentId: ctx.environmentId,
|
||||
limit: 10,
|
||||
});
|
||||
|
||||
// Asserting as a set: equal createdAt makes the CH (created_at, run_id) DESC tie-break the
|
||||
// only ordering signal. The throwing proxy never tripped -> no TaskRun hydrate on this path.
|
||||
expect([...runIds].sort()).toEqual([runA.id, runB.id, runC.id].sort());
|
||||
}
|
||||
);
|
||||
|
||||
// CH filter is split-neutral (ids independent of PG residency).
|
||||
replicationContainerTest(
|
||||
"returns the same id-set regardless of which Postgres the rows are hydrated from (CH-only path)",
|
||||
async ({ clickhouseContainer, redisOptions, postgresContainer, prisma, network }) => {
|
||||
const { clickhouse } = await setupClickhouseReplication({
|
||||
prisma,
|
||||
databaseUrl: postgresContainer.getConnectionUri(),
|
||||
clickhouseUrl: clickhouseContainer.getConnectionUrl(),
|
||||
redisOptions,
|
||||
});
|
||||
|
||||
// A second, unrelated NEW client carrying NO rows. The id-set path never touches it;
|
||||
// pointing the resolver at it must not change the result, proving the ids come from CH only.
|
||||
const { url: newUrl } = await createPostgresContainer(network, {
|
||||
imageTag: "docker.io/postgres:17",
|
||||
});
|
||||
const prismaNew = new PrismaClient({ datasources: { db: { url: newUrl } } });
|
||||
|
||||
try {
|
||||
const ctx = await seedParents(prisma, "neutral");
|
||||
const runA = await createRun(prisma, ctx, { friendlyId: "run_neutralA" });
|
||||
const runB = await createRun(prisma, ctx, { friendlyId: "run_neutralB" });
|
||||
|
||||
await setTimeout(1500);
|
||||
|
||||
const filter = {
|
||||
organizationId: ctx.organizationId,
|
||||
projectId: ctx.projectId,
|
||||
environmentId: ctx.environmentId,
|
||||
limit: 10,
|
||||
};
|
||||
|
||||
// "single-DB" wiring: resolver's prisma is the replication-source DB (where rows live).
|
||||
const singleDb = new ClickHouseRunListResolver({
|
||||
getClickhouse: async () => clickhouse,
|
||||
prisma,
|
||||
});
|
||||
// "split" wiring: resolver's prisma is the empty NEW DB. If the id-set path read TaskRun
|
||||
// from this handle the result would differ; it must not.
|
||||
const split = new ClickHouseRunListResolver({
|
||||
getClickhouse: async () => clickhouse,
|
||||
prisma: prismaNew,
|
||||
});
|
||||
|
||||
const idsSingleDb = await singleDb.resolveMatchingRunIds(filter);
|
||||
const idsSplit = await split.resolveMatchingRunIds(filter);
|
||||
|
||||
expect(idsSplit).toEqual(idsSingleDb);
|
||||
expect([...idsSingleDb].sort()).toEqual([runA.id, runB.id].sort());
|
||||
} finally {
|
||||
await prismaNew.$disconnect();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// single-DB passthrough; no legacy/known-migrated probe on this path.
|
||||
replicationContainerTest(
|
||||
"single-DB passthrough returns the CH id-set and never hydrates TaskRun",
|
||||
async ({ clickhouseContainer, redisOptions, postgresContainer, prisma }) => {
|
||||
const { clickhouse } = await setupClickhouseReplication({
|
||||
prisma,
|
||||
databaseUrl: postgresContainer.getConnectionUri(),
|
||||
clickhouseUrl: clickhouseContainer.getConnectionUrl(),
|
||||
redisOptions,
|
||||
});
|
||||
|
||||
const ctx = await seedParents(prisma, "passthrough");
|
||||
const run = await createRun(prisma, ctx, { friendlyId: "run_passthrough" });
|
||||
|
||||
await setTimeout(1500);
|
||||
|
||||
const resolver = new ClickHouseRunListResolver({
|
||||
getClickhouse: async () => clickhouse,
|
||||
prisma: throwingTaskRunFindMany(prisma),
|
||||
});
|
||||
|
||||
const runIds = await resolver.resolveMatchingRunIds({
|
||||
organizationId: ctx.organizationId,
|
||||
projectId: ctx.projectId,
|
||||
environmentId: ctx.environmentId,
|
||||
limit: 10,
|
||||
});
|
||||
|
||||
expect(runIds).toEqual([run.id]);
|
||||
}
|
||||
);
|
||||
|
||||
// a far-future straggler's id surfaces from the CH id-set.
|
||||
replicationContainerTest(
|
||||
"surfaces a far-future delayed run's id from the CH id-set",
|
||||
async ({ clickhouseContainer, redisOptions, postgresContainer, prisma }) => {
|
||||
const { clickhouse } = await setupClickhouseReplication({
|
||||
prisma,
|
||||
databaseUrl: postgresContainer.getConnectionUri(),
|
||||
clickhouseUrl: clickhouseContainer.getConnectionUrl(),
|
||||
redisOptions,
|
||||
});
|
||||
|
||||
const ctx = await seedParents(prisma, "straggler");
|
||||
|
||||
const now = new Date();
|
||||
const near = await createRun(prisma, ctx, { friendlyId: "run_near", createdAt: now });
|
||||
// The migrated-by-sweep case: CH is residency-agnostic, so the id surfaces once indexed
|
||||
// regardless of which DB holds the row.
|
||||
const farFuture = new Date(now.getTime() + 365 * 24 * 60 * 60 * 1000);
|
||||
const straggler = await createRun(prisma, ctx, {
|
||||
friendlyId: "run_straggler",
|
||||
createdAt: farFuture,
|
||||
});
|
||||
|
||||
await setTimeout(1500);
|
||||
|
||||
const resolver = new ClickHouseRunListResolver({
|
||||
getClickhouse: async () => clickhouse,
|
||||
prisma,
|
||||
});
|
||||
|
||||
const runIds = await resolver.resolveMatchingRunIds({
|
||||
organizationId: ctx.organizationId,
|
||||
projectId: ctx.projectId,
|
||||
environmentId: ctx.environmentId,
|
||||
limit: 10,
|
||||
});
|
||||
|
||||
expect(runIds).toContain(straggler.id);
|
||||
expect(runIds).toContain(near.id);
|
||||
// (created_at, run_id) DESC ordering -> the far-future straggler sorts ahead of the near run.
|
||||
expect(runIds.indexOf(straggler.id)).toBeLessThan(runIds.indexOf(near.id));
|
||||
}
|
||||
);
|
||||
|
||||
// tag match is contains-ALL (tagsMatch: "all" -> hasAll), authoritative.
|
||||
// The sibling runReader.server.ts JSDoc still calls RunListFilter.tags "Contains-ANY"; that is
|
||||
// stale. The resolver passes tagsMatch: "all" and the live CH repo maps
|
||||
// it to hasAll, so contains-ALL is the real behavior — assert that, not the JSDoc.
|
||||
replicationContainerTest(
|
||||
"tag filter is contains-ALL: only runs carrying every requested tag are returned",
|
||||
async ({ clickhouseContainer, redisOptions, postgresContainer, prisma }) => {
|
||||
const { clickhouse } = await setupClickhouseReplication({
|
||||
prisma,
|
||||
databaseUrl: postgresContainer.getConnectionUri(),
|
||||
clickhouseUrl: clickhouseContainer.getConnectionUrl(),
|
||||
redisOptions,
|
||||
});
|
||||
|
||||
const ctx = await seedParents(prisma, "tags");
|
||||
|
||||
// Has BOTH requested tags -> matches contains-ALL.
|
||||
const both = await createRun(prisma, ctx, {
|
||||
friendlyId: "run_bothTags",
|
||||
runTags: ["alpha", "beta"],
|
||||
});
|
||||
// Has only one of the requested tags -> excluded under contains-ALL (would match contains-ANY).
|
||||
await createRun(prisma, ctx, { friendlyId: "run_oneTag", runTags: ["alpha"] });
|
||||
// Has neither -> excluded.
|
||||
await createRun(prisma, ctx, { friendlyId: "run_otherTag", runTags: ["gamma"] });
|
||||
|
||||
await setTimeout(1500);
|
||||
|
||||
const resolver = new ClickHouseRunListResolver({
|
||||
getClickhouse: async () => clickhouse,
|
||||
prisma,
|
||||
});
|
||||
|
||||
const runIds = await resolver.resolveMatchingRunIds({
|
||||
organizationId: ctx.organizationId,
|
||||
projectId: ctx.projectId,
|
||||
environmentId: ctx.environmentId,
|
||||
tags: ["alpha", "beta"],
|
||||
limit: 10,
|
||||
});
|
||||
|
||||
// contains-ALL: only the run with BOTH tags. (contains-ANY would also return run_oneTag.)
|
||||
expect(runIds).toEqual([both.id]);
|
||||
}
|
||||
);
|
||||
|
||||
// environment scoping: the CH filter excludes other environments.
|
||||
// Doubles as a structural proof that an accidental hydrate would NOT change the id-set: rows on a
|
||||
// different env are not returned because CH filters by environment_id, not because PG was read.
|
||||
replicationContainerTest(
|
||||
"scopes the id-set to the filtered environment (other-env rows are excluded by the CH filter)",
|
||||
async ({ clickhouseContainer, redisOptions, postgresContainer, prisma }) => {
|
||||
const { clickhouse } = await setupClickhouseReplication({
|
||||
prisma,
|
||||
databaseUrl: postgresContainer.getConnectionUri(),
|
||||
clickhouseUrl: clickhouseContainer.getConnectionUrl(),
|
||||
redisOptions,
|
||||
});
|
||||
|
||||
const ctx = await seedParents(prisma, "envscope");
|
||||
const otherEnvId = await seedSecondEnvironment(prisma, ctx, "envscope");
|
||||
|
||||
const inEnv = await createRun(prisma, ctx, { friendlyId: "run_inEnv" });
|
||||
await createRun(
|
||||
prisma,
|
||||
{ ...ctx, environmentId: otherEnvId },
|
||||
{ friendlyId: "run_otherEnv" }
|
||||
);
|
||||
|
||||
await setTimeout(1500);
|
||||
|
||||
const resolver = new ClickHouseRunListResolver({
|
||||
getClickhouse: async () => clickhouse,
|
||||
prisma: throwingTaskRunFindMany(prisma),
|
||||
});
|
||||
|
||||
const runIds = await resolver.resolveMatchingRunIds({
|
||||
organizationId: ctx.organizationId,
|
||||
projectId: ctx.projectId,
|
||||
environmentId: ctx.environmentId,
|
||||
limit: 10,
|
||||
});
|
||||
|
||||
expect(runIds).toEqual([inEnv.id]);
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,304 @@
|
||||
import { SubscribeRunRawShape } from "@trigger.dev/core/v3/schemas";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildElectricSchemaHeader,
|
||||
buildRowsBody,
|
||||
buildSnapshotBody,
|
||||
buildUpdateBody,
|
||||
buildUpToDateBody,
|
||||
encodeOffset,
|
||||
parseOffsetUpdatedAtMs,
|
||||
type RealtimeRunRow,
|
||||
rewriteBodyForLegacyApiVersion,
|
||||
serializeRunRow,
|
||||
} from "~/services/realtime/electricStreamProtocol.server";
|
||||
|
||||
function sampleRow(overrides: Partial<RealtimeRunRow> = {}): RealtimeRunRow {
|
||||
return {
|
||||
id: "run_abc123",
|
||||
taskIdentifier: "my-task",
|
||||
createdAt: new Date("2026-06-06T10:00:00.000Z"),
|
||||
updatedAt: new Date("2026-06-06T10:05:30.123Z"),
|
||||
startedAt: new Date("2026-06-06T10:01:00.000Z"),
|
||||
delayUntil: null,
|
||||
queuedAt: new Date("2026-06-06T10:00:30.000Z"),
|
||||
expiredAt: null,
|
||||
completedAt: null,
|
||||
friendlyId: "run_friendly_abc",
|
||||
number: 42,
|
||||
isTest: true,
|
||||
status: "EXECUTING",
|
||||
usageDurationMs: 1234,
|
||||
costInCents: 0.55,
|
||||
baseCostInCents: 0.25,
|
||||
ttl: "1h",
|
||||
payload: '{"hello":"world"}',
|
||||
payloadType: "application/json",
|
||||
metadata: '{"step":1}',
|
||||
metadataType: "application/json",
|
||||
output: null,
|
||||
outputType: "application/json",
|
||||
runTags: ["user:123", "env:prod"],
|
||||
error: null,
|
||||
realtimeStreams: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Faithful re-implementation of the @electric-sql/client value parser rules
|
||||
* (defaultParser + pgArrayParser), so we can decode our wire `value` object the
|
||||
* same way the deployed client would, then validate against the real SDK schema.
|
||||
* Source: @electric-sql/client@1.0.14 src/parser.ts.
|
||||
*/
|
||||
function electricParse(
|
||||
value: Record<string, string | null>,
|
||||
schema: Record<string, { type: string; dims?: number }>
|
||||
): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [key, raw] of Object.entries(value)) {
|
||||
if (raw === null) {
|
||||
out[key] = null;
|
||||
continue;
|
||||
}
|
||||
const info = schema[key];
|
||||
if (!info) {
|
||||
out[key] = raw;
|
||||
continue;
|
||||
}
|
||||
if (info.dims && info.dims > 0) {
|
||||
out[key] = parsePgTextArray(raw);
|
||||
continue;
|
||||
}
|
||||
switch (info.type) {
|
||||
case "bool":
|
||||
out[key] = raw === "t" || raw === "true";
|
||||
break;
|
||||
case "int8":
|
||||
out[key] = BigInt(raw);
|
||||
break;
|
||||
case "int2":
|
||||
case "int4":
|
||||
case "float4":
|
||||
case "float8":
|
||||
out[key] = Number(raw);
|
||||
break;
|
||||
case "json":
|
||||
case "jsonb":
|
||||
out[key] = JSON.parse(raw);
|
||||
break;
|
||||
default:
|
||||
out[key] = raw; // text/timestamp pass through as strings
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function parsePgTextArray(literal: string): string[] {
|
||||
if (literal === "{}") {
|
||||
return [];
|
||||
}
|
||||
const inner = literal.slice(1, -1);
|
||||
const result: string[] = [];
|
||||
let i = 0;
|
||||
while (i < inner.length) {
|
||||
if (inner[i] === '"') {
|
||||
i++;
|
||||
let s = "";
|
||||
while (i < inner.length && inner[i] !== '"') {
|
||||
if (inner[i] === "\\") {
|
||||
i++;
|
||||
}
|
||||
s += inner[i];
|
||||
i++;
|
||||
}
|
||||
result.push(s);
|
||||
i++; // closing quote
|
||||
if (inner[i] === ",") i++;
|
||||
} else {
|
||||
let s = "";
|
||||
while (i < inner.length && inner[i] !== ",") {
|
||||
s += inner[i];
|
||||
i++;
|
||||
}
|
||||
result.push(s);
|
||||
if (inner[i] === ",") i++;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
describe("electricStreamProtocol serializer", () => {
|
||||
it("encodes each Postgres type the way the Electric client expects", () => {
|
||||
const value = serializeRunRow(sampleRow());
|
||||
|
||||
// text: passed through as-is
|
||||
expect(value.id).toBe("run_abc123");
|
||||
expect(value.status).toBe("EXECUTING");
|
||||
expect(value.payload).toBe('{"hello":"world"}');
|
||||
|
||||
// int/float: stringified
|
||||
expect(value.number).toBe("42");
|
||||
expect(value.usageDurationMs).toBe("1234");
|
||||
expect(value.costInCents).toBe("0.55");
|
||||
|
||||
// bool: postgres "t"/"f"
|
||||
expect(value.isTest).toBe("t");
|
||||
|
||||
// timestamp: ISO without trailing Z (the SDK appends Z before parsing)
|
||||
expect(value.updatedAt).toBe("2026-06-06T10:05:30.123");
|
||||
expect(value.createdAt).toBe("2026-06-06T10:00:00.000");
|
||||
|
||||
// nullable timestamp: null stays null
|
||||
expect(value.delayUntil).toBeNull();
|
||||
expect(value.completedAt).toBeNull();
|
||||
|
||||
// text[]: quoted pg array literal; empty realtimeStreams (@default([])) => {}
|
||||
expect(value.runTags).toBe('{"user:123","env:prod"}');
|
||||
expect(value.realtimeStreams).toBe("{}");
|
||||
|
||||
// jsonb: null stays null
|
||||
expect(value.error).toBeNull();
|
||||
});
|
||||
|
||||
it("encodes an empty no-default array column (runTags) as null, matching Electric", () => {
|
||||
// runTags has no Postgres default, so an empty value is stored as SQL NULL and
|
||||
// Electric emits `null` (not `{}`). realtimeStreams has @default([]), so its
|
||||
// empty value is `{}`. Prisma hands us `[]` for both; we re-derive the wire form.
|
||||
const value = serializeRunRow(sampleRow({ runTags: [], realtimeStreams: [] }));
|
||||
expect(value.runTags).toBeNull();
|
||||
expect(value.realtimeStreams).toBe("{}");
|
||||
});
|
||||
|
||||
it("encodes jsonb error as a JSON string", () => {
|
||||
const value = serializeRunRow(sampleRow({ error: { type: "STRING_ERROR", raw: "boom" } }));
|
||||
expect(value.error).toBe('{"type":"STRING_ERROR","raw":"boom"}');
|
||||
});
|
||||
|
||||
it("round-trips through the client parser into a valid SubscribeRunRawShape", () => {
|
||||
const row = sampleRow({ error: { type: "STRING_ERROR", raw: "boom" } });
|
||||
const value = serializeRunRow(row);
|
||||
const schema = JSON.parse(buildElectricSchemaHeader());
|
||||
|
||||
const decoded = electricParse(value, schema);
|
||||
const parsed = SubscribeRunRawShape.parse(decoded);
|
||||
|
||||
expect(parsed.id).toBe("run_abc123");
|
||||
expect(parsed.friendlyId).toBe("run_friendly_abc");
|
||||
expect(parsed.status).toBe("EXECUTING");
|
||||
expect(parsed.number).toBe(42);
|
||||
expect(parsed.isTest).toBe(true);
|
||||
expect(parsed.usageDurationMs).toBe(1234);
|
||||
expect(parsed.costInCents).toBeCloseTo(0.55);
|
||||
expect(parsed.runTags).toEqual(["user:123", "env:prod"]);
|
||||
expect(parsed.realtimeStreams).toEqual([]);
|
||||
// RawShapeDate appends "Z" and coerces to a Date equal to the source instant.
|
||||
expect(parsed.createdAt.toISOString()).toBe("2026-06-06T10:00:00.000Z");
|
||||
expect(parsed.updatedAt.toISOString()).toBe("2026-06-06T10:05:30.123Z");
|
||||
expect(parsed.startedAt?.toISOString()).toBe("2026-06-06T10:01:00.000Z");
|
||||
expect(parsed.delayUntil ?? null).toBeNull();
|
||||
expect(parsed.error).toEqual({ type: "STRING_ERROR", raw: "boom" });
|
||||
});
|
||||
|
||||
it("honors skipColumns (but never the reserved columns)", () => {
|
||||
const value = serializeRunRow(sampleRow(), ["payload", "output", "id", "status"]);
|
||||
expect(value.payload).toBeUndefined();
|
||||
expect(value.output).toBeUndefined();
|
||||
// reserved columns can't be skipped
|
||||
expect(value.id).toBe("run_abc123");
|
||||
expect(value.status).toBe("EXECUTING");
|
||||
|
||||
const schema = JSON.parse(buildElectricSchemaHeader(["payload"]));
|
||||
expect(schema.payload).toBeUndefined();
|
||||
expect(schema.status).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("electricStreamProtocol message bodies", () => {
|
||||
it("emits insert + up-to-date for an initial snapshot", () => {
|
||||
const messages = JSON.parse(buildSnapshotBody(sampleRow()));
|
||||
expect(messages).toHaveLength(2);
|
||||
expect(messages[0].headers.operation).toBe("insert");
|
||||
expect(messages[0].key).toBe('"public"."TaskRun"/"run_abc123"');
|
||||
expect(messages[0].value.status).toBe("EXECUTING");
|
||||
expect(messages[1].headers.control).toBe("up-to-date");
|
||||
});
|
||||
|
||||
it("emits a bare up-to-date for an empty (missing) run snapshot", () => {
|
||||
const messages = JSON.parse(buildSnapshotBody(null));
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(messages[0].headers.control).toBe("up-to-date");
|
||||
});
|
||||
|
||||
it("emits update + up-to-date for a live change", () => {
|
||||
const messages = JSON.parse(buildUpdateBody(sampleRow()));
|
||||
expect(messages[0].headers.operation).toBe("update");
|
||||
expect(messages[1].headers.control).toBe("up-to-date");
|
||||
});
|
||||
|
||||
it("emits a bare up-to-date when nothing advanced", () => {
|
||||
const messages = JSON.parse(buildUpToDateBody());
|
||||
expect(messages).toEqual([{ headers: { control: "up-to-date" } }]);
|
||||
});
|
||||
|
||||
it("uses the same merge key across insert and update so the client merges by row", () => {
|
||||
const insert = JSON.parse(buildSnapshotBody(sampleRow()))[0];
|
||||
const update = JSON.parse(buildUpdateBody(sampleRow()))[0];
|
||||
expect(insert.key).toBe(update.key);
|
||||
});
|
||||
});
|
||||
|
||||
describe("electricStreamProtocol multi-row (tag-list) bodies", () => {
|
||||
it("emits one change message per row with per-row operation, then up-to-date", () => {
|
||||
const a = sampleRow({ id: "run_a" });
|
||||
const b = sampleRow({ id: "run_b", status: "QUEUED" });
|
||||
const messages = JSON.parse(
|
||||
buildRowsBody([
|
||||
{ row: a, operation: "insert" },
|
||||
{ row: b, operation: "update" },
|
||||
])
|
||||
);
|
||||
expect(messages).toHaveLength(3);
|
||||
expect(messages[0].headers.operation).toBe("insert");
|
||||
expect(messages[0].key).toBe('"public"."TaskRun"/"run_a"');
|
||||
expect(messages[1].headers.operation).toBe("update");
|
||||
expect(messages[1].key).toBe('"public"."TaskRun"/"run_b"');
|
||||
expect(messages[1].value.status).toBe("QUEUED");
|
||||
expect(messages[2].headers.control).toBe("up-to-date");
|
||||
});
|
||||
|
||||
it("emits a bare up-to-date for an empty change set", () => {
|
||||
const messages = JSON.parse(buildRowsBody([]));
|
||||
expect(messages).toEqual([{ headers: { control: "up-to-date" } }]);
|
||||
});
|
||||
|
||||
it("honors skipColumns across all rows", () => {
|
||||
const messages = JSON.parse(
|
||||
buildRowsBody([{ row: sampleRow(), operation: "insert" }], ["payload"])
|
||||
);
|
||||
expect(messages[0].value.payload).toBeUndefined();
|
||||
expect(messages[0].value.status).toBe("EXECUTING");
|
||||
});
|
||||
});
|
||||
|
||||
describe("electricStreamProtocol tokens + legacy rewrite", () => {
|
||||
it("encodes and parses the offset updatedAt segment", () => {
|
||||
const offset = encodeOffset(1717667130123, 7);
|
||||
expect(offset).toBe("1717667130123_7");
|
||||
expect(parseOffsetUpdatedAtMs(offset)).toBe(1717667130123);
|
||||
});
|
||||
|
||||
it("treats the initial offset (-1) and garbage as zero", () => {
|
||||
expect(parseOffsetUpdatedAtMs("-1")).toBe(0);
|
||||
expect(parseOffsetUpdatedAtMs(null)).toBe(0);
|
||||
expect(parseOffsetUpdatedAtMs("nonsense")).toBe(0);
|
||||
});
|
||||
|
||||
it("rewrites DEQUEUED to EXECUTING for legacy API versions", () => {
|
||||
const body = buildUpdateBody(sampleRow({ status: "DEQUEUED" }));
|
||||
expect(body).toContain('"status":"DEQUEUED"');
|
||||
const rewritten = rewriteBodyForLegacyApiVersion(body);
|
||||
expect(rewritten).not.toContain('"status":"DEQUEUED"');
|
||||
expect(rewritten).toContain('"status":"EXECUTING"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,553 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
EnvChangeRouter,
|
||||
type EnvChangeSource,
|
||||
type RowHydrator,
|
||||
} from "~/services/realtime/envChangeRouter.server";
|
||||
import { type ChangeRecord } from "~/services/realtime/runChangeNotifier.server";
|
||||
import { type RealtimeRunRow } from "~/services/realtime/electricStreamProtocol.server";
|
||||
|
||||
const FLOOR_MS = Date.UTC(2026, 5, 7, 12, 0, 0);
|
||||
|
||||
function row(
|
||||
id: string,
|
||||
opts: { tags?: string[]; createdAtMs?: number; updatedAtMs?: number } = {}
|
||||
): RealtimeRunRow {
|
||||
return {
|
||||
id,
|
||||
runTags: opts.tags ?? [],
|
||||
createdAt: new Date(opts.createdAtMs ?? FLOOR_MS + 1_000),
|
||||
updatedAt: new Date(opts.updatedAtMs ?? FLOOR_MS + 5_000),
|
||||
} as unknown as RealtimeRunRow;
|
||||
}
|
||||
|
||||
function record(runId: string, extra: Partial<ChangeRecord> = {}): ChangeRecord {
|
||||
return { v: 1, runId, envId: "env_1", ...extra };
|
||||
}
|
||||
|
||||
/** A controllable EnvChangeSource: tests push batches to the env's listener. */
|
||||
function fakeSource() {
|
||||
const listeners = new Map<string, Set<(records: ChangeRecord[]) => void>>();
|
||||
const source: EnvChangeSource = {
|
||||
subscribeToEnv(envId, onBatch) {
|
||||
let set = listeners.get(envId);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
listeners.set(envId, set);
|
||||
}
|
||||
set.add(onBatch);
|
||||
return () => {
|
||||
listeners.get(envId)?.delete(onBatch);
|
||||
};
|
||||
},
|
||||
};
|
||||
return {
|
||||
source,
|
||||
push(envId: string, records: ChangeRecord[]) {
|
||||
for (const l of listeners.get(envId) ?? []) l(records);
|
||||
},
|
||||
isSubscribed(envId: string) {
|
||||
return (listeners.get(envId)?.size ?? 0) > 0;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeRouter(
|
||||
rowsById: Map<string, RealtimeRunRow> = new Map(),
|
||||
options: Record<string, unknown> = {}
|
||||
) {
|
||||
const src = fakeSource();
|
||||
const hydrateSpy = vi.fn<RowHydrator["hydrateByIds"]>(async (_env, ids) =>
|
||||
ids.map((id) => rowsById.get(id)).filter((r): r is RealtimeRunRow => Boolean(r))
|
||||
);
|
||||
const router = new EnvChangeRouter({
|
||||
source: src.source,
|
||||
hydrator: { hydrateByIds: hydrateSpy },
|
||||
...options,
|
||||
});
|
||||
return { router, src, hydrateSpy };
|
||||
}
|
||||
|
||||
describe("EnvChangeRouter", () => {
|
||||
it("routes a tag match to the feed (hydrated + serialized) and ignores non-matches", async () => {
|
||||
const rows = new Map([["r1", row("r1", { tags: ["a"] })]]);
|
||||
const { router, src, hydrateSpy } = makeRouter(rows);
|
||||
const reg = router.register("env_1", { kind: "tag", tags: ["a"] }, []);
|
||||
const wait = reg.waitForMatch(undefined, 1_000);
|
||||
|
||||
// A non-matching tag is dropped (no wake); a matching tag wakes with the hydrated row.
|
||||
src.push("env_1", [record("rX", { tags: ["b"] }), record("r1", { tags: ["a"] })]);
|
||||
|
||||
const result = await wait;
|
||||
expect(result.reason).toBe("notify");
|
||||
expect(result.rows.map((m) => m.row.id)).toEqual(["r1"]);
|
||||
expect(result.rows[0].value.id).toBe("r1"); // serialized wire value
|
||||
expect(hydrateSpy).toHaveBeenCalledWith("env_1", ["r1"], []);
|
||||
reg.close();
|
||||
});
|
||||
|
||||
it("wakes an unfiltered tag feed (no tags) for every full record, live and via replay", async () => {
|
||||
const rows = new Map([["r1", row("r1", { tags: ["a"] })]]);
|
||||
const { router, src } = makeRouter(rows);
|
||||
|
||||
// Live path: a full record (tags defined) must reach the zero-filter feed even
|
||||
// though it can never appear in the byTag index.
|
||||
const reg = router.register("env_1", { kind: "tag", tags: [] }, []);
|
||||
const wait = reg.waitForMatch(undefined, 1_000);
|
||||
src.push("env_1", [record("r1", { tags: ["a"] })]);
|
||||
const live = await wait;
|
||||
expect(live.reason).toBe("notify");
|
||||
expect(live.rows.map((m) => m.row.id)).toEqual(["r1"]);
|
||||
reg.close();
|
||||
|
||||
// Replay path: the buffered record matches an unfiltered feed registered after the push.
|
||||
const late = router.register("env_1", { kind: "tag", tags: [] }, [], {
|
||||
replaySinceMs: Date.now() - 1_000,
|
||||
});
|
||||
const replayed = await late.waitForMatch(undefined, 1_000);
|
||||
expect(replayed.reason).toBe("notify");
|
||||
expect(replayed.rows.map((m) => m.row.id)).toEqual(["r1"]);
|
||||
late.close();
|
||||
});
|
||||
|
||||
it("batch-hydrates ONCE and shares the serialized value across feeds matching the same run", async () => {
|
||||
const rows = new Map([["r1", row("r1", { tags: ["a"] })]]);
|
||||
const { router, src, hydrateSpy } = makeRouter(rows);
|
||||
const regs = [
|
||||
router.register("env_1", { kind: "tag", tags: ["a"] }, []),
|
||||
router.register("env_1", { kind: "tag", tags: ["a"] }, []),
|
||||
];
|
||||
const waits = regs.map((r) => r.waitForMatch(undefined, 1_000));
|
||||
|
||||
src.push("env_1", [record("r1", { tags: ["a"] })]);
|
||||
const results = await Promise.all(waits);
|
||||
|
||||
// One hydrate for the whole tick (same column set), shared by both feeds...
|
||||
expect(hydrateSpy).toHaveBeenCalledTimes(1);
|
||||
// ...and the same serialized value object is reused (serialize-once).
|
||||
expect(results[0].rows[0].value).toBe(results[1].rows[0].value);
|
||||
regs.forEach((r) => r.close());
|
||||
});
|
||||
|
||||
it("a hydrate failure doesn't reject out of the source callback; the feed times out", async () => {
|
||||
const src = fakeSource();
|
||||
const hydrateSpy = vi.fn<RowHydrator["hydrateByIds"]>(async () => {
|
||||
throw new Error("replica down");
|
||||
});
|
||||
const router = new EnvChangeRouter({
|
||||
source: src.source,
|
||||
hydrator: { hydrateByIds: hydrateSpy },
|
||||
});
|
||||
const reg = router.register("env_1", { kind: "run", runId: "r1" }, []);
|
||||
const wait = reg.waitForMatch(undefined, 50);
|
||||
|
||||
// Would be an unhandled rejection (process exit) if #onBatch's promise were unguarded.
|
||||
src.push("env_1", [record("r1")]);
|
||||
|
||||
const result = await wait;
|
||||
expect(result.reason).toBe("timeout");
|
||||
expect(hydrateSpy).toHaveBeenCalledTimes(1);
|
||||
reg.close();
|
||||
});
|
||||
|
||||
it("routes a run feed by exact runId", async () => {
|
||||
const rows = new Map([["r1", row("r1")]]);
|
||||
const { router, src } = makeRouter(rows);
|
||||
const reg = router.register("env_1", { kind: "run", runId: "r1" }, []);
|
||||
const wait = reg.waitForMatch(undefined, 1_000);
|
||||
src.push("env_1", [record("r2"), record("r1")]);
|
||||
const result = await wait;
|
||||
expect(result.rows.map((m) => m.row.id)).toEqual(["r1"]);
|
||||
reg.close();
|
||||
});
|
||||
|
||||
it("routes a batch feed by batchId", async () => {
|
||||
const rows = new Map([["r1", row("r1")]]);
|
||||
const { router, src } = makeRouter(rows);
|
||||
const reg = router.register("env_1", { kind: "batch", batchId: "batch_1" }, []);
|
||||
const wait = reg.waitForMatch(undefined, 1_000);
|
||||
src.push("env_1", [record("rX", { batchId: "other" }), record("r1", { batchId: "batch_1" })]);
|
||||
const result = await wait;
|
||||
expect(result.rows.map((m) => m.row.id)).toEqual(["r1"]);
|
||||
reg.close();
|
||||
});
|
||||
|
||||
it("multi-tag feeds require ALL tags on the row (Electric contains-all semantics)", async () => {
|
||||
const rows = new Map([
|
||||
["r_both", row("r_both", { tags: ["a", "b", "c"] })],
|
||||
["r_one", row("r_one", { tags: ["a"] })],
|
||||
]);
|
||||
const { router, src } = makeRouter(rows);
|
||||
const reg = router.register("env_1", { kind: "tag", tags: ["a", "b"] }, []);
|
||||
const wait = reg.waitForMatch(undefined, 1_000);
|
||||
|
||||
// r_one shares a tag (routes as a candidate via the index) but lacks "b" — must be
|
||||
// culled by the authoritative row check. r_both carries both and wakes the feed.
|
||||
src.push("env_1", [
|
||||
record("r_one", { tags: ["a"] }),
|
||||
record("r_both", { tags: ["a", "b", "c"] }),
|
||||
]);
|
||||
|
||||
const result = await wait;
|
||||
expect(result.reason).toBe("notify");
|
||||
expect(result.rows.map((m) => m.row.id)).toEqual(["r_both"]);
|
||||
reg.close();
|
||||
});
|
||||
|
||||
it("drops a tag match created before the feed's createdAt floor", async () => {
|
||||
const rows = new Map([["r1", row("r1", { tags: ["a"], createdAtMs: FLOOR_MS - 10_000 })]]);
|
||||
const { router, src } = makeRouter(rows);
|
||||
const reg = router.register(
|
||||
"env_1",
|
||||
{ kind: "tag", tags: ["a"], createdAtFloorMs: FLOOR_MS },
|
||||
[]
|
||||
);
|
||||
let settled = false;
|
||||
const wait = reg.waitForMatch(undefined, 60).then((r) => {
|
||||
settled = true;
|
||||
return r;
|
||||
});
|
||||
src.push("env_1", [record("r1", { tags: ["a"], createdAtMs: FLOOR_MS - 10_000 })]);
|
||||
// Hydrated but out-of-window -> not woken; falls through to the timeout.
|
||||
const result = await wait;
|
||||
expect(settled).toBe(true);
|
||||
expect(result.reason).toBe("timeout");
|
||||
reg.close();
|
||||
});
|
||||
|
||||
it("classifies a partial record (no tags) by hydrating and re-checking the row's tags", async () => {
|
||||
// Partial record routes to all tag feeds as candidates; the authoritative row decides.
|
||||
const rows = new Map([["r1", row("r1", { tags: ["a"] })]]);
|
||||
const { router, src } = makeRouter(rows);
|
||||
const match = router.register("env_1", { kind: "tag", tags: ["a"] }, []);
|
||||
const noMatch = router.register("env_1", { kind: "tag", tags: ["z"] }, []);
|
||||
const matchWait = match.waitForMatch(undefined, 1_000);
|
||||
let noMatchSettled = false;
|
||||
const noMatchWait = noMatch.waitForMatch(undefined, 80).then((r) => {
|
||||
noMatchSettled = true;
|
||||
return r;
|
||||
});
|
||||
|
||||
src.push("env_1", [record("r1", { tags: undefined })]); // partial: tags absent
|
||||
|
||||
expect((await matchWait).rows.map((m) => m.row.id)).toEqual(["r1"]);
|
||||
expect((await noMatchWait).reason).toBe("timeout"); // row tags ["a"] don't intersect ["z"]
|
||||
expect(noMatchSettled).toBe(true);
|
||||
match.close();
|
||||
noMatch.close();
|
||||
});
|
||||
|
||||
it("times out and aborts cleanly", async () => {
|
||||
const { router, src } = makeRouter(new Map(), { unsubscribeLingerMs: 0 });
|
||||
const reg = router.register("env_1", { kind: "tag", tags: ["a"] }, []);
|
||||
expect((await reg.waitForMatch(undefined, 30)).reason).toBe("timeout");
|
||||
|
||||
const controller = new AbortController();
|
||||
const wait = reg.waitForMatch(controller.signal, 5_000);
|
||||
controller.abort();
|
||||
expect((await wait).reason).toBe("abort");
|
||||
reg.close();
|
||||
expect(src.isSubscribed("env_1")).toBe(false); // linger disabled: last feed left -> unsubscribed
|
||||
});
|
||||
|
||||
it("buffers a record that arrives between polls and replays it on the next arm", async () => {
|
||||
const rows = new Map([["r1", row("r1", { tags: ["a"] })]]);
|
||||
const { router, src, hydrateSpy } = makeRouter(rows);
|
||||
const reg = router.register("env_1", { kind: "tag", tags: ["a"] }, []);
|
||||
// Not waiting yet: the push can't wake anything, but it lands in the env buffer.
|
||||
src.push("env_1", [record("r1", { tags: ["a"] })]);
|
||||
expect(hydrateSpy).not.toHaveBeenCalled();
|
||||
|
||||
const result = await reg.waitForMatch(undefined, 1_000);
|
||||
expect(result.reason).toBe("notify");
|
||||
expect(result.rows.map((m) => m.row.id)).toEqual(["r1"]);
|
||||
expect(hydrateSpy).toHaveBeenCalledTimes(1);
|
||||
reg.close();
|
||||
});
|
||||
|
||||
it("does not redeliver a replayed record on a later arm", async () => {
|
||||
const rows = new Map([["r1", row("r1", { tags: ["a"] })]]);
|
||||
const { router, src, hydrateSpy } = makeRouter(rows);
|
||||
const reg = router.register("env_1", { kind: "tag", tags: ["a"] }, []);
|
||||
src.push("env_1", [record("r1", { tags: ["a"] })]);
|
||||
expect((await reg.waitForMatch(undefined, 1_000)).reason).toBe("notify");
|
||||
|
||||
// Same buffered record must not fire again; the wait falls through to its timeout.
|
||||
expect((await reg.waitForMatch(undefined, 50)).reason).toBe("timeout");
|
||||
expect(hydrateSpy).toHaveBeenCalledTimes(1);
|
||||
reg.close();
|
||||
});
|
||||
|
||||
it("lingers the env subscription after the last feed closes and replays the gap", async () => {
|
||||
const rows = new Map([["r1", row("r1", { tags: ["a"] })]]);
|
||||
const { router, src, hydrateSpy } = makeRouter(rows, { unsubscribeLingerMs: 60 });
|
||||
const reg1 = router.register("env_1", { kind: "tag", tags: ["a"] }, []);
|
||||
reg1.close();
|
||||
expect(src.isSubscribed("env_1")).toBe(true); // lingering
|
||||
|
||||
// The inter-poll gap: a change arrives while no feed is registered.
|
||||
src.push("env_1", [record("r1", { tags: ["a"] })]);
|
||||
|
||||
const reg2 = router.register("env_1", { kind: "tag", tags: ["a"] }, []);
|
||||
const result = await reg2.waitForMatch(undefined, 1_000);
|
||||
expect(result.reason).toBe("notify");
|
||||
expect(result.rows.map((m) => m.row.id)).toEqual(["r1"]);
|
||||
expect(hydrateSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
reg2.close();
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
expect(src.isSubscribed("env_1")).toBe(false); // linger expired -> unsubscribed
|
||||
});
|
||||
|
||||
it("reports gapCovered=false on a fresh env subscription and true once it ages past the window", async () => {
|
||||
const { router } = makeRouter(new Map(), { replayWindowMs: 50 });
|
||||
const reg1 = router.register("env_1", { kind: "run", runId: "r1" }, []);
|
||||
expect(reg1.gapCovered).toBe(false);
|
||||
|
||||
await new Promise((r) => setTimeout(r, 70));
|
||||
const reg2 = router.register("env_1", { kind: "run", runId: "r2" }, []);
|
||||
expect(reg2.gapCovered).toBe(true);
|
||||
reg1.close();
|
||||
reg2.close();
|
||||
});
|
||||
|
||||
it("honors the caller's replaySinceMs so a new poll doesn't rewind into delivered records", async () => {
|
||||
const rows = new Map([["r1", row("r1", { tags: ["a"] })]]);
|
||||
const { router, src, hydrateSpy } = makeRouter(rows);
|
||||
const anchor = router.register("env_1", { kind: "tag", tags: ["a"] }, []); // keeps the env subscribed
|
||||
src.push("env_1", [record("r1", { tags: ["a"] })]);
|
||||
const afterPush = Date.now();
|
||||
|
||||
// A connection whose last response left after the push: nothing to replay.
|
||||
const caughtUp = router.register("env_1", { kind: "tag", tags: ["a"] }, [], {
|
||||
replaySinceMs: afterPush,
|
||||
});
|
||||
expect(caughtUp.gapCovered).toBe(true); // env subscribed since before its gap began
|
||||
expect((await caughtUp.waitForMatch(undefined, 50)).reason).toBe("timeout");
|
||||
expect(hydrateSpy).not.toHaveBeenCalled();
|
||||
|
||||
// A connection whose gap started before the push: the record replays.
|
||||
const behind = router.register("env_1", { kind: "tag", tags: ["a"] }, [], {
|
||||
replaySinceMs: afterPush - 1_000,
|
||||
});
|
||||
const result = await behind.waitForMatch(undefined, 1_000);
|
||||
expect(result.reason).toBe("notify");
|
||||
expect(result.rows.map((m) => m.row.id)).toEqual(["r1"]);
|
||||
|
||||
anchor.close();
|
||||
caughtUp.close();
|
||||
behind.close();
|
||||
});
|
||||
|
||||
it("caps the replay buffer to the newest records per env", async () => {
|
||||
const rows = new Map([
|
||||
["r1", row("r1")],
|
||||
["r2", row("r2")],
|
||||
["r3", row("r3")],
|
||||
]);
|
||||
const evictions: string[] = [];
|
||||
const { router, src, hydrateSpy } = makeRouter(rows, {
|
||||
replayMaxRunsPerEnv: 2,
|
||||
onReplayEviction: (reason: string) => evictions.push(reason),
|
||||
});
|
||||
const reg = router.register("env_1", { kind: "batch", batchId: "batch_1" }, []);
|
||||
src.push("env_1", [
|
||||
record("r1", { batchId: "batch_1" }),
|
||||
record("r2", { batchId: "batch_1" }),
|
||||
record("r3", { batchId: "batch_1" }),
|
||||
]);
|
||||
|
||||
const result = await reg.waitForMatch(undefined, 1_000);
|
||||
expect(result.reason).toBe("notify");
|
||||
// r1 was evicted by the cap; only the newest two replay.
|
||||
expect(hydrateSpy).toHaveBeenCalledWith("env_1", ["r2", "r3"], []);
|
||||
expect(evictions).toEqual(["cap"]);
|
||||
reg.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("EnvChangeRouter read-your-writes gate", () => {
|
||||
function gate(overrides: Record<string, unknown> = {}) {
|
||||
const observed: number[] = [];
|
||||
const outcomes: { outcome: string; runCount: number }[] = [];
|
||||
return {
|
||||
observed,
|
||||
outcomes,
|
||||
replicaLag: {
|
||||
getLagMs: () => 0,
|
||||
noteObservedLagMs: (ms: number) => observed.push(ms),
|
||||
marginMs: 0,
|
||||
maxDelayMs: 1_000,
|
||||
staleRetries: 3,
|
||||
onStaleHydrate: (outcome: string, runCount: number) => outcomes.push({ outcome, runCount }),
|
||||
...overrides,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
it("delays the wake hydrate by lag+margin anchored to the record's updatedAtMs", async () => {
|
||||
const rows = new Map([["r1", row("r1", { tags: ["a"] })]]);
|
||||
const g = gate({ getLagMs: () => 80, marginMs: 10 });
|
||||
const { router, src, hydrateSpy } = makeRouter(rows, { replicaLag: g.replicaLag });
|
||||
const reg = router.register("env_1", { kind: "tag", tags: ["a"] }, []);
|
||||
const wait = reg.waitForMatch(undefined, 2_000);
|
||||
|
||||
const pushedAt = Date.now();
|
||||
src.push("env_1", [record("r1", { tags: ["a"], updatedAtMs: pushedAt })]);
|
||||
|
||||
// The hydrate must wait out the lag window (~90ms), not run immediately.
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
expect(hydrateSpy).not.toHaveBeenCalled();
|
||||
|
||||
const result = await wait;
|
||||
expect(result.reason).toBe("notify");
|
||||
expect(Date.now() - pushedAt).toBeGreaterThanOrEqual(80);
|
||||
reg.close();
|
||||
});
|
||||
|
||||
it("does not delay when the record's anchor is already past the lag window", async () => {
|
||||
// The row's updatedAt matches the watermark so the tripwire stays quiet.
|
||||
const anchorMs = Date.now() - 5_000;
|
||||
const rows = new Map([["r1", row("r1", { tags: ["a"], updatedAtMs: anchorMs })]]);
|
||||
const g = gate({ getLagMs: () => 80, marginMs: 10 });
|
||||
const { router, src } = makeRouter(rows, { replicaLag: g.replicaLag });
|
||||
const reg = router.register("env_1", { kind: "tag", tags: ["a"] }, []);
|
||||
const wait = reg.waitForMatch(undefined, 2_000);
|
||||
|
||||
const pushedAt = Date.now();
|
||||
src.push("env_1", [record("r1", { tags: ["a"], updatedAtMs: anchorMs })]);
|
||||
|
||||
const result = await wait;
|
||||
expect(result.reason).toBe("notify");
|
||||
expect(Date.now() - pushedAt).toBeLessThan(60);
|
||||
reg.close();
|
||||
});
|
||||
|
||||
it("withholds a stale row, re-hydrates, and delivers only the fresh version", async () => {
|
||||
const watermark = FLOOR_MS + 10_000;
|
||||
const staleRow = row("r1", { tags: ["a"], updatedAtMs: FLOOR_MS + 5_000 });
|
||||
const freshRow = row("r1", { tags: ["a"], updatedAtMs: watermark });
|
||||
const hydrateSpy = vi
|
||||
.fn<RowHydrator["hydrateByIds"]>()
|
||||
.mockResolvedValueOnce([staleRow])
|
||||
.mockResolvedValue([freshRow]);
|
||||
const src = fakeSource();
|
||||
const g = gate();
|
||||
const router = new EnvChangeRouter({
|
||||
source: src.source,
|
||||
hydrator: { hydrateByIds: hydrateSpy },
|
||||
replicaLag: g.replicaLag,
|
||||
});
|
||||
const reg = router.register("env_1", { kind: "tag", tags: ["a"] }, []);
|
||||
const wait = reg.waitForMatch(undefined, 2_000);
|
||||
|
||||
src.push("env_1", [record("r1", { tags: ["a"], updatedAtMs: watermark })]);
|
||||
|
||||
const result = await wait;
|
||||
expect(result.reason).toBe("notify");
|
||||
expect(result.rows[0].row.updatedAt.getTime()).toBe(watermark);
|
||||
expect(hydrateSpy).toHaveBeenCalledTimes(2);
|
||||
expect(g.observed.length).toBe(1); // the stale read fed the estimator
|
||||
expect(g.outcomes).toEqual([{ outcome: "recovered", runCount: 1 }]);
|
||||
reg.close();
|
||||
});
|
||||
|
||||
it("a missing row with a watermark (insert race) retries and delivers when it appears", async () => {
|
||||
const watermark = FLOOR_MS + 10_000;
|
||||
const freshRow = row("r1", { tags: ["a"], updatedAtMs: watermark });
|
||||
const hydrateSpy = vi
|
||||
.fn<RowHydrator["hydrateByIds"]>()
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValue([freshRow]);
|
||||
const src = fakeSource();
|
||||
const g = gate();
|
||||
const router = new EnvChangeRouter({
|
||||
source: src.source,
|
||||
hydrator: { hydrateByIds: hydrateSpy },
|
||||
replicaLag: g.replicaLag,
|
||||
});
|
||||
const reg = router.register("env_1", { kind: "tag", tags: ["a"] }, []);
|
||||
const wait = reg.waitForMatch(undefined, 2_000);
|
||||
|
||||
src.push("env_1", [record("r1", { tags: ["a"], updatedAtMs: watermark })]);
|
||||
|
||||
const result = await wait;
|
||||
expect(result.rows[0].row.id).toBe("r1");
|
||||
expect(hydrateSpy).toHaveBeenCalledTimes(2);
|
||||
reg.close();
|
||||
});
|
||||
|
||||
it("delivers the stale row after exhausting retries (liveness over freshness)", async () => {
|
||||
const watermark = FLOOR_MS + 10_000;
|
||||
const staleRow = row("r1", { tags: ["a"], updatedAtMs: FLOOR_MS + 5_000 });
|
||||
const hydrateSpy = vi.fn<RowHydrator["hydrateByIds"]>().mockResolvedValue([staleRow]);
|
||||
const src = fakeSource();
|
||||
const g = gate({ staleRetries: 1 });
|
||||
const router = new EnvChangeRouter({
|
||||
source: src.source,
|
||||
hydrator: { hydrateByIds: hydrateSpy },
|
||||
replicaLag: g.replicaLag,
|
||||
});
|
||||
const reg = router.register("env_1", { kind: "tag", tags: ["a"] }, []);
|
||||
const wait = reg.waitForMatch(undefined, 2_000);
|
||||
|
||||
src.push("env_1", [record("r1", { tags: ["a"], updatedAtMs: watermark })]);
|
||||
|
||||
const result = await wait;
|
||||
expect(result.reason).toBe("notify");
|
||||
expect(result.rows[0].row.updatedAt.getTime()).toBe(FLOOR_MS + 5_000);
|
||||
expect(hydrateSpy).toHaveBeenCalledTimes(2); // first pass + 1 retry
|
||||
expect(g.outcomes).toEqual([{ outcome: "gave_up", runCount: 1 }]);
|
||||
reg.close();
|
||||
});
|
||||
|
||||
it("after giving up, echo passes deliver the fresh row once the replica catches up", async () => {
|
||||
// Recent watermark (inside the echo horizon); retries exhausted immediately.
|
||||
const watermark = Date.now() - 50;
|
||||
const staleRow = row("r1", { tags: ["a"], updatedAtMs: watermark - 5_000 });
|
||||
const freshRow = row("r1", { tags: ["a"], updatedAtMs: watermark });
|
||||
const hydrateSpy = vi
|
||||
.fn<RowHydrator["hydrateByIds"]>()
|
||||
.mockResolvedValueOnce([staleRow]) // first pass: stale -> gave_up, delivered anyway
|
||||
.mockResolvedValue([freshRow]); // echo pass: fresh
|
||||
const src = fakeSource();
|
||||
const g = gate({ staleRetries: 0 });
|
||||
const router = new EnvChangeRouter({
|
||||
source: src.source,
|
||||
hydrator: { hydrateByIds: hydrateSpy },
|
||||
replicaLag: g.replicaLag,
|
||||
});
|
||||
const reg = router.register("env_1", { kind: "tag", tags: ["a"] }, []);
|
||||
|
||||
const first = reg.waitForMatch(undefined, 2_000);
|
||||
src.push("env_1", [record("r1", { tags: ["a"], updatedAtMs: watermark })]);
|
||||
const staleDelivery = await first;
|
||||
expect(staleDelivery.rows[0].row.updatedAt.getTime()).toBe(watermark - 5_000);
|
||||
expect(g.outcomes).toEqual([{ outcome: "gave_up", runCount: 1 }]);
|
||||
|
||||
// The client re-arms (as it would after consuming the stale emission); the echo
|
||||
// re-hydrate delivers the fresh version through the normal pipeline.
|
||||
const echoed = await reg.waitForMatch(undefined, 2_000);
|
||||
expect(echoed.reason).toBe("notify");
|
||||
expect(echoed.rows[0].row.updatedAt.getTime()).toBe(watermark);
|
||||
reg.close();
|
||||
});
|
||||
|
||||
it("records without a watermark bypass the tripwire entirely", async () => {
|
||||
const staleLooking = row("r1", { tags: ["a"], updatedAtMs: FLOOR_MS + 5_000 });
|
||||
const rows = new Map([["r1", staleLooking]]);
|
||||
const g = gate();
|
||||
const { router, src, hydrateSpy } = makeRouter(rows, { replicaLag: g.replicaLag });
|
||||
const reg = router.register("env_1", { kind: "tag", tags: ["a"] }, []);
|
||||
const wait = reg.waitForMatch(undefined, 2_000);
|
||||
|
||||
src.push("env_1", [record("r1", { tags: ["a"] })]); // no updatedAtMs
|
||||
|
||||
const result = await wait;
|
||||
expect(result.reason).toBe("notify");
|
||||
expect(hydrateSpy).toHaveBeenCalledTimes(1);
|
||||
expect(g.outcomes).toEqual([]);
|
||||
expect(g.observed).toEqual([]);
|
||||
reg.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,278 @@
|
||||
import { setTimeout as sleep } from "node:timers/promises";
|
||||
import { CURRENT_API_VERSION } from "~/api/versions";
|
||||
import {
|
||||
NativeRealtimeClient,
|
||||
type RealtimeListEnvironment,
|
||||
} from "~/services/realtime/nativeRealtimeClient.server";
|
||||
import { type RealtimeRunRow } from "~/services/realtime/electricStreamProtocol.server";
|
||||
import { EnvChangeRouter, type EnvChangeSource } from "~/services/realtime/envChangeRouter.server";
|
||||
import { type ChangeRecord } from "~/services/realtime/runChangeNotifier.server";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const ENV: RealtimeListEnvironment = { id: "env_1", organizationId: "org_1", projectId: "proj_1" };
|
||||
|
||||
// Fixed offset floor: a row's updatedAt above/below it produces a delta / empty diff. The
|
||||
// createdAt window resolves to this same floor (large maximumCreatedAtFilterAgeMs below).
|
||||
const FLOOR_MS = Date.UTC(2026, 5, 7, 12, 0, 0);
|
||||
|
||||
function row(
|
||||
id: string,
|
||||
updatedAtMs: number,
|
||||
opts: { createdAtMs?: number; tags?: string[] } = {}
|
||||
): RealtimeRunRow {
|
||||
return {
|
||||
id,
|
||||
runTags: opts.tags ?? ["t"],
|
||||
createdAt: new Date(opts.createdAtMs ?? FLOOR_MS + 1_000),
|
||||
updatedAt: new Date(updatedAtMs),
|
||||
} as unknown as RealtimeRunRow;
|
||||
}
|
||||
|
||||
function rec(runId: string, extra: Partial<ChangeRecord> = {}): ChangeRecord {
|
||||
return { v: 1, runId, envId: "env_1", ...extra };
|
||||
}
|
||||
|
||||
/** A controllable EnvChangeSource the test pushes batches into. */
|
||||
function fakeSource() {
|
||||
const listeners = new Map<string, Set<(records: ChangeRecord[]) => void>>();
|
||||
const source: EnvChangeSource = {
|
||||
subscribeToEnv(envId, onBatch) {
|
||||
let set = listeners.get(envId);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
listeners.set(envId, set);
|
||||
}
|
||||
set.add(onBatch);
|
||||
return () => listeners.get(envId)?.delete(onBatch);
|
||||
},
|
||||
};
|
||||
return {
|
||||
source,
|
||||
push: (envId: string, records: ChangeRecord[]) => {
|
||||
for (const l of listeners.get(envId) ?? []) l(records);
|
||||
},
|
||||
isSubscribed: (envId: string) => (listeners.get(envId)?.size ?? 0) > 0,
|
||||
};
|
||||
}
|
||||
|
||||
function makeClient(overrides: Record<string, unknown> = {}) {
|
||||
let rowsToReturn: RealtimeRunRow[] = [];
|
||||
const hydrateSpy = vi.fn(async (_env: string, ids: string[]) =>
|
||||
rowsToReturn.filter((r) => ids.includes(r.id))
|
||||
);
|
||||
const resolveSpy = vi.fn(async () => rowsToReturn.map((r) => r.id));
|
||||
const src = fakeSource();
|
||||
const router = new EnvChangeRouter({
|
||||
source: src.source,
|
||||
hydrator: { hydrateByIds: hydrateSpy },
|
||||
replayWindowMs: 0,
|
||||
unsubscribeLingerMs: 0,
|
||||
...((overrides.routerOptions as Record<string, unknown>) ?? {}),
|
||||
});
|
||||
delete overrides.routerOptions;
|
||||
|
||||
const client = new NativeRealtimeClient({
|
||||
runReader: { getRunById: async () => null, hydrateByIds: hydrateSpy } as any,
|
||||
runListResolver: { resolveMatchingRunIds: resolveSpy } as any,
|
||||
router,
|
||||
limiter: { incrementAndCheck: async () => true, decrement: async () => {} } as any,
|
||||
cachedLimitProvider: { getCachedLimit: async () => 100 },
|
||||
// Large so the recovered createdAt floor isn't clamped past FLOOR_MS.
|
||||
maximumCreatedAtFilterAgeMs: 100 * 365 * 24 * 60 * 60 * 1000,
|
||||
runSetResolveCacheTtlMs: 0,
|
||||
livePollTimeoutMs: 10_000,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
return {
|
||||
client,
|
||||
src,
|
||||
hydrateSpy,
|
||||
resolveSpy,
|
||||
setRows: (rows: RealtimeRunRow[]) => (rowsToReturn = rows),
|
||||
};
|
||||
}
|
||||
|
||||
function liveRuns(client: NativeRealtimeClient) {
|
||||
return client.streamRuns(
|
||||
`http://localhost:3030/realtime/v1/runs?offset=${FLOOR_MS}_1&live=true&handle=runs_${FLOOR_MS}_7`,
|
||||
ENV,
|
||||
{ tags: ["t"] },
|
||||
CURRENT_API_VERSION,
|
||||
undefined,
|
||||
"1.0.0"
|
||||
);
|
||||
}
|
||||
|
||||
async function whenWaiting(src: ReturnType<typeof fakeSource>) {
|
||||
// Subscribed (feed registered) + a tick so waitForMatch has armed feed.resolve.
|
||||
await vi.waitFor(() => expect(src.isSubscribed("env_1")).toBe(true));
|
||||
await sleep(15);
|
||||
}
|
||||
|
||||
async function bodyOf(res: Response) {
|
||||
return JSON.parse(await res.text()) as Array<{
|
||||
headers?: { control?: string; operation?: string };
|
||||
value?: unknown;
|
||||
}>;
|
||||
}
|
||||
const hasRowOp = (body: Awaited<ReturnType<typeof bodyOf>>) =>
|
||||
body.some((m) => m?.headers?.operation || (m && typeof m === "object" && "value" in m));
|
||||
const isUpToDate = (body: Awaited<ReturnType<typeof bodyOf>>) =>
|
||||
body.some((m) => m?.headers?.control === "up-to-date");
|
||||
|
||||
describe("NativeRealtimeClient multi-run live path over the router", () => {
|
||||
it("a matching change hydrates by id (no ClickHouse) and returns a delta", async () => {
|
||||
const emits: Array<[string, number, number]> = [];
|
||||
const { client, src, hydrateSpy, resolveSpy, setRows } = makeClient({
|
||||
onEmit: (path: string, lagMs: number, rows: number) => emits.push([path, lagMs, rows]),
|
||||
});
|
||||
setRows([row("run_1", FLOOR_MS + 5_000, { tags: ["t"] })]);
|
||||
|
||||
const responsePromise = liveRuns(client);
|
||||
await whenWaiting(src);
|
||||
src.push("env_1", [rec("run_1", { tags: ["t", "x"] })]);
|
||||
|
||||
const res = await responsePromise;
|
||||
expect(res.status).toBe(200);
|
||||
expect(hasRowOp(await bodyOf(res))).toBe(true);
|
||||
expect(resolveSpy).not.toHaveBeenCalled(); // ClickHouse skipped
|
||||
expect(hydrateSpy).toHaveBeenCalledWith("env_1", ["run_1"], expect.anything());
|
||||
expect(emits).toHaveLength(1);
|
||||
expect(emits[0][0]).toBe("fast-hydrate");
|
||||
expect(emits[0][2]).toBe(1); // one delta row
|
||||
});
|
||||
|
||||
it("a change that doesn't match the filter never wakes the feed (no CH, no PG); a later match does", async () => {
|
||||
const { client, src, hydrateSpy, resolveSpy, setRows } = makeClient();
|
||||
setRows([row("run_1", FLOOR_MS + 5_000, { tags: ["t"] })]);
|
||||
|
||||
const responsePromise = liveRuns(client);
|
||||
let settled = false;
|
||||
void responsePromise.then(() => (settled = true));
|
||||
await whenWaiting(src);
|
||||
|
||||
src.push("env_1", [rec("run_x", { tags: ["other"] })]); // doesn't intersect ["t"]
|
||||
await sleep(50);
|
||||
expect(settled).toBe(false);
|
||||
expect(hydrateSpy).not.toHaveBeenCalled(); // router never routed it
|
||||
expect(resolveSpy).not.toHaveBeenCalled();
|
||||
|
||||
src.push("env_1", [rec("run_1", { tags: ["t"] })]);
|
||||
const res = await responsePromise;
|
||||
expect(settled).toBe(true);
|
||||
expect(hasRowOp(await bodyOf(res))).toBe(true);
|
||||
});
|
||||
|
||||
it("a matching run created before the window floor is hydrated but dropped (keeps holding)", async () => {
|
||||
// Generous backstop so the "still holding" assertion can't race a timeout in slow CI.
|
||||
const { client, src, hydrateSpy, resolveSpy, setRows } = makeClient({
|
||||
livePollTimeoutMs: 1500,
|
||||
});
|
||||
setRows([row("run_1", FLOOR_MS + 5_000, { createdAtMs: FLOOR_MS - 10_000, tags: ["t"] })]);
|
||||
|
||||
const responsePromise = liveRuns(client);
|
||||
let settled = false;
|
||||
void responsePromise.then(() => (settled = true));
|
||||
await whenWaiting(src);
|
||||
src.push("env_1", [rec("run_1", { tags: ["t"] })]);
|
||||
|
||||
await sleep(40);
|
||||
expect(settled).toBe(false); // dropped by the createdAt floor -> held
|
||||
expect(hydrateSpy).toHaveBeenCalledWith("env_1", ["run_1"], expect.anything());
|
||||
expect(resolveSpy).not.toHaveBeenCalled();
|
||||
|
||||
await responsePromise; // drain via the backstop
|
||||
});
|
||||
|
||||
it("the backstop timeout does a full ClickHouse resolve and returns up-to-date", async () => {
|
||||
const backstopResults: string[] = [];
|
||||
const { client, resolveSpy } = makeClient({
|
||||
livePollTimeoutMs: 50,
|
||||
onBackstopResult: (r: string) => backstopResults.push(r),
|
||||
});
|
||||
const res = await liveRuns(client); // never pushed -> backstop fires
|
||||
expect(res.status).toBe(200);
|
||||
expect(isUpToDate(await bodyOf(res))).toBe(true);
|
||||
expect(resolveSpy).toHaveBeenCalled();
|
||||
expect(backstopResults).toEqual(["empty"]);
|
||||
});
|
||||
|
||||
it("a cold env registration resolves immediately instead of holding blind", async () => {
|
||||
// Fresh env subscription (gapCovered=false): a change in the inter-poll gap may have
|
||||
// been missed, so the live poll probes once. The row advanced past the offset floor.
|
||||
const { client, resolveSpy, setRows } = makeClient({
|
||||
routerOptions: { replayWindowMs: 2_000 },
|
||||
});
|
||||
setRows([row("run_1", FLOOR_MS + 5_000, { tags: ["t"] })]);
|
||||
|
||||
const res = await liveRuns(client); // no push needed — the cold probe finds the delta
|
||||
expect(res.status).toBe(200);
|
||||
expect(resolveSpy).toHaveBeenCalledTimes(1);
|
||||
expect(hasRowOp(await bodyOf(res))).toBe(true);
|
||||
});
|
||||
|
||||
it("a cold probe with nothing missed keeps holding", async () => {
|
||||
const { client, src, resolveSpy, setRows } = makeClient({
|
||||
routerOptions: { replayWindowMs: 2_000 },
|
||||
livePollTimeoutMs: 1_500,
|
||||
});
|
||||
setRows([row("run_1", FLOOR_MS - 1_000, { tags: ["t"] })]); // at/below the offset floor
|
||||
|
||||
const responsePromise = liveRuns(client);
|
||||
let settled = false;
|
||||
void responsePromise.then(() => (settled = true));
|
||||
await whenWaiting(src);
|
||||
await sleep(50);
|
||||
expect(settled).toBe(false); // probed, found nothing missed, held
|
||||
expect(resolveSpy).toHaveBeenCalledTimes(1);
|
||||
await responsePromise; // drain via the backstop
|
||||
});
|
||||
|
||||
it("a single-run poll holds on a replayed already-seen record instead of busy re-polling", async () => {
|
||||
const { client, src, setRows } = makeClient({
|
||||
routerOptions: { replayWindowMs: 2_000 },
|
||||
livePollTimeoutMs: 300,
|
||||
});
|
||||
setRows([row("run_1", FLOOR_MS + 1_000)]);
|
||||
const url = `http://localhost:3030/realtime/v1/runs/run_1?offset=${FLOOR_MS + 1_000}_1&handle=run-run_1&live=true`;
|
||||
|
||||
// First poll subscribes the env, then drains via its backstop.
|
||||
const first = await client.streamRun(
|
||||
url,
|
||||
ENV,
|
||||
"run_1",
|
||||
CURRENT_API_VERSION,
|
||||
undefined,
|
||||
"1.0.0"
|
||||
);
|
||||
expect(first.status).toBe(200);
|
||||
|
||||
// The record lands between polls; the lingering env subscription buffers it.
|
||||
src.push("env_1", [rec("run_1")]);
|
||||
|
||||
// The next poll replays it, but the row hasn't advanced past the client's offset:
|
||||
// the poll must HOLD (the old behavior returned up-to-date instantly = a busy loop).
|
||||
let settled = false;
|
||||
const second = client.streamRun(url, ENV, "run_1", CURRENT_API_VERSION, undefined, "1.0.0");
|
||||
void second.then(() => (settled = true));
|
||||
await sleep(120);
|
||||
expect(settled).toBe(false);
|
||||
expect((await second).status).toBe(200); // drains via the backstop
|
||||
});
|
||||
|
||||
it("with holdOnEmpty=false, a matched-but-not-advanced change returns up-to-date without ClickHouse", async () => {
|
||||
const { client, src, resolveSpy, setRows } = makeClient({ holdOnEmpty: false });
|
||||
// Matches the tag and is in-window, but updatedAt is at/below the offset floor -> no delta.
|
||||
setRows([row("run_1", FLOOR_MS - 1_000, { tags: ["t"] })]);
|
||||
|
||||
const responsePromise = liveRuns(client);
|
||||
await whenWaiting(src);
|
||||
src.push("env_1", [rec("run_1", { tags: ["t"] })]);
|
||||
|
||||
const res = await responsePromise;
|
||||
expect(res.status).toBe(200);
|
||||
expect(isUpToDate(await bodyOf(res))).toBe(true);
|
||||
expect(resolveSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import { CURRENT_API_VERSION } from "~/api/versions";
|
||||
import {
|
||||
NativeRealtimeClient,
|
||||
type RealtimeListEnvironment,
|
||||
} from "~/services/realtime/nativeRealtimeClient.server";
|
||||
import { type RealtimeRunRow } from "~/services/realtime/electricStreamProtocol.server";
|
||||
import { EnvChangeRouter } from "~/services/realtime/envChangeRouter.server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
function sampleRow(): RealtimeRunRow {
|
||||
return {
|
||||
id: "run_1",
|
||||
taskIdentifier: "t",
|
||||
createdAt: new Date("2026-06-07T10:00:00.000Z"),
|
||||
updatedAt: new Date("2026-06-07T10:00:01.000Z"),
|
||||
startedAt: null,
|
||||
delayUntil: null,
|
||||
queuedAt: null,
|
||||
expiredAt: null,
|
||||
completedAt: null,
|
||||
friendlyId: "run_friendly_1",
|
||||
number: 1,
|
||||
isTest: false,
|
||||
status: "EXECUTING",
|
||||
usageDurationMs: 0,
|
||||
costInCents: 0,
|
||||
baseCostInCents: 0,
|
||||
ttl: null,
|
||||
payload: "{}",
|
||||
payloadType: "application/json",
|
||||
metadata: null,
|
||||
metadataType: "application/json",
|
||||
output: null,
|
||||
outputType: "application/json",
|
||||
runTags: [],
|
||||
error: null,
|
||||
realtimeStreams: [],
|
||||
};
|
||||
}
|
||||
|
||||
// Only the initial-snapshot path is exercised here, which touches the shared
|
||||
// #buildResponse — enough to lock the response-header contract.
|
||||
function makeClient(row: RealtimeRunRow | null) {
|
||||
return new NativeRealtimeClient({
|
||||
runReader: {
|
||||
getRunById: async () => row,
|
||||
hydrateByIds: async () => (row ? [row] : []),
|
||||
} as any,
|
||||
runListResolver: { resolveMatchingRunIds: async () => [] } as any,
|
||||
// Snapshot path only; the router (over a no-op source) is never invoked here.
|
||||
router: new EnvChangeRouter({
|
||||
source: { subscribeToEnv: () => () => {} },
|
||||
hydrator: { hydrateByIds: async () => (row ? [row] : []) },
|
||||
replayWindowMs: 0,
|
||||
unsubscribeLingerMs: 0,
|
||||
}),
|
||||
limiter: { incrementAndCheck: async () => true, decrement: async () => {} } as any,
|
||||
cachedLimitProvider: { getCachedLimit: async () => 100 },
|
||||
maximumCreatedAtFilterAgeMs: 24 * 60 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
const ENV: RealtimeListEnvironment = {
|
||||
id: "env_1",
|
||||
organizationId: "org_1",
|
||||
projectId: "proj_1",
|
||||
};
|
||||
|
||||
describe("NativeRealtimeClient response headers", () => {
|
||||
it("exposes electric headers cross-origin so browser hooks can read them", async () => {
|
||||
const client = makeClient(sampleRow());
|
||||
const res = await client.streamRun(
|
||||
"http://localhost:3030/realtime/v1/runs/run_1?offset=-1",
|
||||
ENV,
|
||||
"run_1",
|
||||
CURRENT_API_VERSION,
|
||||
undefined,
|
||||
"1.0.0-beta.1" // modern client => lowercase electric-* headers
|
||||
);
|
||||
|
||||
// Without these the deployed @electric-sql/client throws MissingHeadersError
|
||||
// (it can't read the electric-* headers across origins). This regressed once.
|
||||
expect(res.headers.get("access-control-allow-origin")).toBe("*");
|
||||
expect(res.headers.get("access-control-expose-headers")).toBe("*");
|
||||
|
||||
// Initial (non-live) snapshot requires offset + handle + schema.
|
||||
expect(res.headers.get("electric-offset")).toBeTruthy();
|
||||
expect(res.headers.get("electric-handle")).toBeTruthy();
|
||||
expect(res.headers.get("electric-schema")).toBeTruthy();
|
||||
expect(res.headers.get("content-type")).toBe("application/json");
|
||||
});
|
||||
|
||||
it("renames headers for legacy (0.4.0) clients", async () => {
|
||||
const client = makeClient(sampleRow());
|
||||
const res = await client.streamRun(
|
||||
"http://localhost:3030/realtime/v1/runs/run_1?offset=-1",
|
||||
ENV,
|
||||
"run_1",
|
||||
CURRENT_API_VERSION,
|
||||
undefined,
|
||||
undefined // no client version => legacy header names
|
||||
);
|
||||
|
||||
expect(res.headers.get("electric-chunk-last-offset")).toBeTruthy();
|
||||
expect(res.headers.get("electric-shape-id")).toBeTruthy();
|
||||
expect(res.headers.get("electric-offset")).toBeNull();
|
||||
expect(res.headers.get("electric-handle")).toBeNull();
|
||||
expect(res.headers.get("access-control-expose-headers")).toBe("*");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,347 @@
|
||||
import { CURRENT_API_VERSION } from "~/api/versions";
|
||||
import {
|
||||
NativeRealtimeClient,
|
||||
type RealtimeListEnvironment,
|
||||
} from "~/services/realtime/nativeRealtimeClient.server";
|
||||
import { type RealtimeRunRow } from "~/services/realtime/electricStreamProtocol.server";
|
||||
import { EnvChangeRouter } from "~/services/realtime/envChangeRouter.server";
|
||||
import { setTimeout as sleep } from "node:timers/promises";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const ENV: RealtimeListEnvironment = { id: "env_1", organizationId: "org_1", projectId: "proj_1" };
|
||||
|
||||
function row(id: string): RealtimeRunRow {
|
||||
// Only id/createdAt/updatedAt are read directly; the rest serialize to null.
|
||||
return {
|
||||
id,
|
||||
createdAt: new Date("2026-06-07T09:00:00.000Z"),
|
||||
updatedAt: new Date("2026-06-07T10:00:00.000Z"),
|
||||
} as unknown as RealtimeRunRow;
|
||||
}
|
||||
|
||||
function makeClient(overrides: Record<string, unknown> = {}) {
|
||||
const resolveSpy = vi.fn(async () => ["run_1", "run_2"]);
|
||||
const hydrateSpy = vi.fn(async (_env: string, ids: string[]) => ids.map(row));
|
||||
|
||||
const client = new NativeRealtimeClient({
|
||||
runReader: { getRunById: async () => null, hydrateByIds: hydrateSpy } as any,
|
||||
runListResolver: { resolveMatchingRunIds: resolveSpy } as any,
|
||||
// No-op source: live polls never get a router wake, so they fall through to the
|
||||
// backstop full-resolve — which is what the live tests below assert on.
|
||||
router: new EnvChangeRouter({
|
||||
source: { subscribeToEnv: () => () => {} },
|
||||
hydrator: { hydrateByIds: hydrateSpy },
|
||||
replayWindowMs: 0,
|
||||
unsubscribeLingerMs: 0,
|
||||
}),
|
||||
limiter: { incrementAndCheck: async () => true, decrement: async () => {} } as any,
|
||||
cachedLimitProvider: { getCachedLimit: async () => 100 },
|
||||
maximumCreatedAtFilterAgeMs: 24 * 60 * 60 * 1000,
|
||||
runSetResolveCacheTtlMs: 5_000,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
return { client, resolveSpy, hydrateSpy };
|
||||
}
|
||||
|
||||
// streamBatch with offset=-1 takes the snapshot path, which calls the coalescing
|
||||
// resolve+hydrate directly (no concurrency slot / subscription needed).
|
||||
function snapshot(client: NativeRealtimeClient, batchId: string, skipColumns?: string) {
|
||||
const skip = skipColumns ? `&skipColumns=${skipColumns}` : "";
|
||||
return client.streamBatch(
|
||||
`http://localhost:3030/realtime/v1/batches/${batchId}?offset=-1${skip}`,
|
||||
ENV,
|
||||
batchId,
|
||||
CURRENT_API_VERSION,
|
||||
undefined,
|
||||
"1.0.0"
|
||||
);
|
||||
}
|
||||
|
||||
// Tag-list snapshot (offset=-1) — exercises the createdAt bucketing + cache key.
|
||||
function snapshotTag(client: NativeRealtimeClient, tags: string[]) {
|
||||
return client.streamRuns(
|
||||
"http://localhost:3030/realtime/v1/runs?offset=-1",
|
||||
ENV,
|
||||
{ tags },
|
||||
CURRENT_API_VERSION,
|
||||
undefined,
|
||||
"1.0.0"
|
||||
);
|
||||
}
|
||||
|
||||
describe("NativeRealtimeClient run-set resolve coalescing + cache", () => {
|
||||
it("coalesces concurrent same-filter resolves into one ClickHouse + Postgres query", async () => {
|
||||
const { client, resolveSpy, hydrateSpy } = makeClient();
|
||||
let release!: (ids: string[]) => void;
|
||||
const gate = new Promise<string[]>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
resolveSpy.mockReturnValueOnce(gate);
|
||||
|
||||
const p1 = snapshot(client, "batch_1");
|
||||
const p2 = snapshot(client, "batch_1");
|
||||
release(["run_1"]);
|
||||
await Promise.all([p1, p2]);
|
||||
|
||||
expect(resolveSpy).toHaveBeenCalledTimes(1);
|
||||
expect(hydrateSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("serves a second same-filter request from the cache within the TTL", async () => {
|
||||
const { client, resolveSpy, hydrateSpy } = makeClient();
|
||||
await snapshot(client, "batch_1");
|
||||
await snapshot(client, "batch_1");
|
||||
expect(resolveSpy).toHaveBeenCalledTimes(1);
|
||||
expect(hydrateSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not share the cache across different filters", async () => {
|
||||
const { client, resolveSpy } = makeClient();
|
||||
await snapshot(client, "batch_1");
|
||||
await snapshot(client, "batch_2");
|
||||
expect(resolveSpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("re-queries after the cache TTL expires", async () => {
|
||||
vi.useFakeTimers({ toFake: ["Date"] });
|
||||
try {
|
||||
const { client, resolveSpy } = makeClient({ runSetResolveCacheTtlMs: 1_000 });
|
||||
await snapshot(client, "batch_1");
|
||||
vi.advanceTimersByTime(1_001);
|
||||
await snapshot(client, "batch_1");
|
||||
expect(resolveSpy).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("passes the client's skipColumns through to the hydrator (column projection)", async () => {
|
||||
const { client, hydrateSpy } = makeClient();
|
||||
await snapshot(client, "batch_1", "payload,output");
|
||||
expect(hydrateSpy).toHaveBeenCalledWith("env_1", expect.any(Array), ["payload", "output"]);
|
||||
});
|
||||
|
||||
it("reports resolve outcomes (miss then hit) to the metrics hook", async () => {
|
||||
const results: string[] = [];
|
||||
const { client } = makeClient({ onRunSetResolve: (r: string) => results.push(r) });
|
||||
await snapshot(client, "batch_1");
|
||||
await snapshot(client, "batch_1");
|
||||
expect(results).toEqual(["miss", "hit"]);
|
||||
});
|
||||
|
||||
it("mints a distinct batch handle per connection and echoes a client-provided one", async () => {
|
||||
const { client } = makeClient();
|
||||
// Two subscribers to the SAME batch must never share a handle (the working-set
|
||||
// cache is keyed by it; sharing lets one suppress the other's deltas forever).
|
||||
const res1 = await snapshot(client, "batch_1");
|
||||
const res2 = await snapshot(client, "batch_1");
|
||||
const h1 = res1.headers.get("electric-handle");
|
||||
const h2 = res2.headers.get("electric-handle");
|
||||
expect(h1).toBeTruthy();
|
||||
expect(h1).not.toBe(h2);
|
||||
|
||||
// Catch-up under an existing handle keeps it.
|
||||
const res3 = await client.streamBatch(
|
||||
`http://localhost:3030/realtime/v1/batches/batch_1?offset=123_1&handle=${h1}`,
|
||||
ENV,
|
||||
"batch_1",
|
||||
CURRENT_API_VERSION,
|
||||
undefined,
|
||||
"1.0.0"
|
||||
);
|
||||
expect(res3.headers.get("electric-handle")).toBe(h1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("NativeRealtimeClient resolve admission gate (mass-reconnect stampede)", () => {
|
||||
// A resolver that blocks each invocation until released, so we can watch how many run
|
||||
// concurrently. Tracks peak concurrency and exposes a release-one-at-a-time drain.
|
||||
function gatedResolver() {
|
||||
let active = 0;
|
||||
let peak = 0;
|
||||
const releases: Array<() => void> = [];
|
||||
const resolve = vi.fn(async () => {
|
||||
active++;
|
||||
peak = Math.max(peak, active);
|
||||
await new Promise<void>((r) => releases.push(r));
|
||||
active--;
|
||||
return ["run_1"];
|
||||
});
|
||||
return {
|
||||
resolve,
|
||||
peak: () => peak,
|
||||
releaseOne: () => releases.shift()?.(),
|
||||
waiting: () => releases.length,
|
||||
};
|
||||
}
|
||||
|
||||
function makeGatedClient(
|
||||
resolveAdmissionLimit: number,
|
||||
resolver: ReturnType<typeof gatedResolver>
|
||||
) {
|
||||
const hydrateSpy = vi.fn(async (_env: string, ids: string[]) => ids.map(row));
|
||||
return new NativeRealtimeClient({
|
||||
runReader: { getRunById: async () => null, hydrateByIds: hydrateSpy } as any,
|
||||
runListResolver: { resolveMatchingRunIds: resolver.resolve } as any,
|
||||
router: new EnvChangeRouter({
|
||||
source: { subscribeToEnv: () => () => {} },
|
||||
hydrator: { hydrateByIds: hydrateSpy },
|
||||
replayWindowMs: 0,
|
||||
unsubscribeLingerMs: 0,
|
||||
}),
|
||||
limiter: { incrementAndCheck: async () => true, decrement: async () => {} } as any,
|
||||
cachedLimitProvider: { getCachedLimit: async () => 100 },
|
||||
maximumCreatedAtFilterAgeMs: 24 * 60 * 60 * 1000,
|
||||
runSetResolveCacheTtlMs: 0, // no cache -> every distinct filter is a fresh resolve
|
||||
resolveAdmissionLimit,
|
||||
});
|
||||
}
|
||||
|
||||
it("throttles a distinct-filter stampede to the admission limit of concurrent CH resolves", async () => {
|
||||
const resolver = gatedResolver();
|
||||
const client = makeGatedClient(2, resolver);
|
||||
|
||||
// 5 distinct batchIds => 5 distinct filters => 5 fresh resolves, fired at once.
|
||||
const polls = [0, 1, 2, 3, 4].map((i) => snapshot(client, `batch_${i}`));
|
||||
|
||||
// Only the limit (2) may run concurrently; the rest queue for a permit.
|
||||
await vi.waitFor(() => expect(resolver.resolve).toHaveBeenCalledTimes(2));
|
||||
await sleep(20);
|
||||
expect(resolver.resolve).toHaveBeenCalledTimes(2); // 3 still queued behind the gate
|
||||
expect(resolver.peak()).toBe(2);
|
||||
|
||||
// Drain: each release frees a permit, admitting exactly one queued resolve.
|
||||
while (resolver.waiting() > 0) {
|
||||
resolver.releaseOne();
|
||||
await sleep(5);
|
||||
}
|
||||
await Promise.all(polls);
|
||||
|
||||
expect(resolver.resolve).toHaveBeenCalledTimes(5); // all ran...
|
||||
expect(resolver.peak()).toBe(2); // ...but never more than the limit at once
|
||||
});
|
||||
|
||||
it("lets a same-filter burst through on a single permit (coalesces before the gate)", async () => {
|
||||
const resolver = gatedResolver();
|
||||
const client = makeGatedClient(1, resolver); // limit 1 would deadlock if each took a permit
|
||||
|
||||
// 5 identical filters fired at once -> single-flight collapses to one in-flight resolve.
|
||||
const polls = [0, 1, 2, 3, 4].map(() => snapshot(client, "batch_same"));
|
||||
await vi.waitFor(() => expect(resolver.resolve).toHaveBeenCalledTimes(1));
|
||||
await sleep(20);
|
||||
|
||||
resolver.releaseOne();
|
||||
await Promise.all(polls);
|
||||
expect(resolver.resolve).toHaveBeenCalledTimes(1); // one resolve, one permit, no queue
|
||||
});
|
||||
});
|
||||
|
||||
describe("NativeRealtimeClient tag-list createdAt bucketing", () => {
|
||||
it("floors the resolved createdAt lower bound to the bucket boundary", async () => {
|
||||
// Fix the clock to a non-bucket-aligned instant so the assertion is deterministic.
|
||||
vi.useFakeTimers({ toFake: ["Date"] });
|
||||
vi.setSystemTime(new Date("2026-06-07T10:00:30.500Z"));
|
||||
try {
|
||||
const { client, resolveSpy } = makeClient({ runSetCreatedAtBucketMs: 60_000 });
|
||||
await snapshotTag(client, ["critical"]);
|
||||
const passed = resolveSpy.mock.calls[0][0].createdAtAfter as Date;
|
||||
expect(passed.getTime() % 60_000).toBe(0);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("lets two same-tag feeds in the same bucket share one resolve", async () => {
|
||||
// A large bucket guarantees both windows floor to the same boundary regardless of
|
||||
// the sub-millisecond gap between the two calls.
|
||||
const { client, resolveSpy, hydrateSpy } = makeClient({
|
||||
runSetCreatedAtBucketMs: 60 * 60_000,
|
||||
});
|
||||
await snapshotTag(client, ["critical"]);
|
||||
await snapshotTag(client, ["critical"]);
|
||||
expect(resolveSpy).toHaveBeenCalledTimes(1);
|
||||
expect(hydrateSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not share across different tags", async () => {
|
||||
const { client, resolveSpy } = makeClient({ runSetCreatedAtBucketMs: 60 * 60_000 });
|
||||
await snapshotTag(client, ["critical"]);
|
||||
await snapshotTag(client, ["debug"]);
|
||||
expect(resolveSpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("does not collide a comma-containing tag with two separate tags", async () => {
|
||||
const { client, resolveSpy } = makeClient({ runSetCreatedAtBucketMs: 60 * 60_000 });
|
||||
await snapshotTag(client, ["a,b"]); // one tag "a,b"
|
||||
await snapshotTag(client, ["a", "b"]); // two tags a OR b — a different filter
|
||||
expect(resolveSpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("keeps each feed's exact lower bound when bucketing is disabled (0)", async () => {
|
||||
vi.useFakeTimers({ toFake: ["Date"] });
|
||||
vi.setSystemTime(new Date("2026-06-07T10:00:30.500Z"));
|
||||
try {
|
||||
const { client, resolveSpy } = makeClient({ runSetCreatedAtBucketMs: 0 });
|
||||
await snapshotTag(client, ["critical"]);
|
||||
const passed = resolveSpy.mock.calls[0][0].createdAtAfter as Date;
|
||||
// Exact (now - 24h) lower bound, not floored to a 60s boundary.
|
||||
expect(passed.getTime() % 60_000).not.toBe(0);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("NativeRealtimeClient review fixes", () => {
|
||||
// makeClient's router has a no-op source, so the live poll never gets a wake and falls
|
||||
// through to its backstop timeout — the full ClickHouse resolve these tests assert on
|
||||
// (createdAt clamp / concurrency limit).
|
||||
|
||||
it("clamps a stale/crafted handle's createdAt up to the max-age floor", async () => {
|
||||
const maxAge = 24 * 60 * 60 * 1000;
|
||||
const { client, resolveSpy } = makeClient({
|
||||
maximumCreatedAtFilterAgeMs: maxAge,
|
||||
runSetCreatedAtBucketMs: 0,
|
||||
livePollTimeoutMs: 50,
|
||||
});
|
||||
const before = Date.now();
|
||||
// Handle encodes createdAt = 1ms epoch, far older than the 24h ceiling.
|
||||
await client.streamRuns(
|
||||
"http://localhost:3030/realtime/v1/runs?offset=123_1&live=true&handle=runs_1_7",
|
||||
ENV,
|
||||
{ tags: ["t"] },
|
||||
CURRENT_API_VERSION,
|
||||
undefined,
|
||||
"1.0.0"
|
||||
);
|
||||
const passed = resolveSpy.mock.calls[0][0].createdAtAfter as Date;
|
||||
// Clamped to ~now - maxAge, not the epoch value encoded in the handle.
|
||||
expect(passed.getTime()).toBeGreaterThan(before - maxAge - 1_000);
|
||||
});
|
||||
|
||||
it("enforces a concurrency limit of 0 instead of failing with a 500", async () => {
|
||||
let limitCheckedWith: number | undefined;
|
||||
const { client } = makeClient({
|
||||
cachedLimitProvider: { getCachedLimit: async () => 0 },
|
||||
limiter: {
|
||||
incrementAndCheck: async (_env: string, _id: string, limit: number) => {
|
||||
limitCheckedWith = limit;
|
||||
return true;
|
||||
},
|
||||
decrement: async () => {},
|
||||
},
|
||||
livePollTimeoutMs: 50,
|
||||
});
|
||||
const res = await client.streamBatch(
|
||||
"http://localhost:3030/realtime/v1/batches/batch_1?offset=123_1&live=true&handle=batch_batch_1_7_abc",
|
||||
ENV,
|
||||
"batch_1",
|
||||
CURRENT_API_VERSION,
|
||||
undefined,
|
||||
"1.0.0"
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(limitCheckedWith).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
import { redisTest } from "@internal/testcontainers";
|
||||
import { setTimeout as sleep } from "node:timers/promises";
|
||||
import { CURRENT_API_VERSION } from "~/api/versions";
|
||||
import { EnvChangeRouter } from "~/services/realtime/envChangeRouter.server";
|
||||
import {
|
||||
NativeRealtimeClient,
|
||||
type RealtimeListEnvironment,
|
||||
} from "~/services/realtime/nativeRealtimeClient.server";
|
||||
import {
|
||||
InMemoryReplayCursorStore,
|
||||
RedisReplayCursorStore,
|
||||
type ReplayCursorStore,
|
||||
} from "~/services/realtime/replayCursorStore.server";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
describe("InMemoryReplayCursorStore", () => {
|
||||
it("round-trips and expires", async () => {
|
||||
const store = new InMemoryReplayCursorStore(50, 10);
|
||||
store.set("env_1:h1", 123_456);
|
||||
expect(await store.get("env_1:h1")).toBe(123_456);
|
||||
expect(await store.get("env_1:other")).toBeUndefined();
|
||||
await sleep(60);
|
||||
expect(await store.get("env_1:h1")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("RedisReplayCursorStore", () => {
|
||||
redisTest("round-trips, misses, and expires via PX", async ({ redisOptions }) => {
|
||||
const store = new RedisReplayCursorStore({
|
||||
redis: { ...redisOptions, tlsDisabled: true },
|
||||
ttlMs: 150,
|
||||
});
|
||||
try {
|
||||
const now = Date.now();
|
||||
store.set("env_1:h1", now);
|
||||
await vi.waitFor(async () => expect(await store.get("env_1:h1")).toBe(now));
|
||||
expect(await store.get("env_1:missing")).toBeUndefined();
|
||||
await sleep(200);
|
||||
expect(await store.get("env_1:h1")).toBeUndefined();
|
||||
} finally {
|
||||
await store.quit();
|
||||
}
|
||||
});
|
||||
|
||||
redisTest(
|
||||
"a second store instance reads the first's cursor (fleet sharing)",
|
||||
async ({ redisOptions }) => {
|
||||
const a = new RedisReplayCursorStore({
|
||||
redis: { ...redisOptions, tlsDisabled: true },
|
||||
ttlMs: 60_000,
|
||||
});
|
||||
const b = new RedisReplayCursorStore({
|
||||
redis: { ...redisOptions, tlsDisabled: true },
|
||||
ttlMs: 60_000,
|
||||
});
|
||||
try {
|
||||
a.set("env_1:h2", 42_000);
|
||||
await vi.waitFor(async () => expect(await b.get("env_1:h2")).toBe(42_000));
|
||||
} finally {
|
||||
await Promise.all([a.quit(), b.quit()]);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
it("degrades to undefined within the read deadline when Redis is unreachable", async () => {
|
||||
const results: Array<[string, boolean]> = [];
|
||||
const store = new RedisReplayCursorStore({
|
||||
redis: { host: "127.0.0.1", port: 1, tlsDisabled: true } as any,
|
||||
ttlMs: 1_000,
|
||||
getTimeoutMs: 100,
|
||||
onResult: (op, ok) => results.push([op, ok]),
|
||||
});
|
||||
try {
|
||||
expect(await store.get("env_1:h3")).toBeUndefined();
|
||||
expect(results).toContainEqual(["get", false]);
|
||||
} finally {
|
||||
await store.quit().catch(() => {});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("NativeRealtimeClient replay-cursor threading", () => {
|
||||
const ENV: RealtimeListEnvironment = {
|
||||
id: "env_1",
|
||||
organizationId: "org_1",
|
||||
projectId: "proj_1",
|
||||
};
|
||||
const FLOOR_MS = Date.UTC(2026, 5, 7, 12, 0, 0);
|
||||
|
||||
it("passes the stored cursor to register and stamps the store after responding", async () => {
|
||||
const cursorMs = Date.now() - 500;
|
||||
const gets: string[] = [];
|
||||
const sets: Array<[string, number]> = [];
|
||||
const store: ReplayCursorStore = {
|
||||
get: async (key) => {
|
||||
gets.push(key);
|
||||
return cursorMs;
|
||||
},
|
||||
set: (key, ms) => {
|
||||
sets.push([key, ms]);
|
||||
},
|
||||
};
|
||||
|
||||
const router = new EnvChangeRouter({
|
||||
source: { subscribeToEnv: () => () => {} },
|
||||
hydrator: { hydrateByIds: async () => [] },
|
||||
replayWindowMs: 0,
|
||||
unsubscribeLingerMs: 0,
|
||||
});
|
||||
const registerSpy = vi.spyOn(router, "register");
|
||||
|
||||
const client = new NativeRealtimeClient({
|
||||
runReader: { getRunById: async () => null, hydrateByIds: async () => [] } as any,
|
||||
runListResolver: { resolveMatchingRunIds: async () => [] } as any,
|
||||
router,
|
||||
limiter: { incrementAndCheck: async () => true, decrement: async () => {} } as any,
|
||||
cachedLimitProvider: { getCachedLimit: async () => 100 },
|
||||
maximumCreatedAtFilterAgeMs: 100 * 365 * 24 * 60 * 60 * 1000,
|
||||
runSetResolveCacheTtlMs: 0,
|
||||
livePollTimeoutMs: 30,
|
||||
replayCursorStore: store,
|
||||
});
|
||||
|
||||
const res = await client.streamRuns(
|
||||
`http://localhost:3030/realtime/v1/runs?offset=${FLOOR_MS}_1&live=true&handle=runs_${FLOOR_MS}_7`,
|
||||
ENV,
|
||||
{ tags: ["t"] },
|
||||
CURRENT_API_VERSION,
|
||||
undefined,
|
||||
"1.0.0"
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(gets).toEqual([`env_1:runs_${FLOOR_MS}_7`]);
|
||||
expect(registerSpy).toHaveBeenCalledWith(
|
||||
"env_1",
|
||||
expect.objectContaining({ kind: "tag" }),
|
||||
expect.anything(),
|
||||
{ replaySinceMs: cursorMs }
|
||||
);
|
||||
// The backstop's up-to-date response stamps the cursor for the next poll.
|
||||
expect(sets.length).toBe(1);
|
||||
expect(sets[0][0]).toBe(`env_1:runs_${FLOOR_MS}_7`);
|
||||
expect(sets[0][1]).toBeGreaterThanOrEqual(cursorMs);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
FirstSupportedReplicaLagSource,
|
||||
ReplicaLagEstimator,
|
||||
type ReplicaLagSource,
|
||||
} from "~/services/realtime/replicaLagEstimator.server";
|
||||
|
||||
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
function source(sampleLagMs: () => Promise<number | undefined>, name = "fake"): ReplicaLagSource {
|
||||
return { name, sampleLagMs };
|
||||
}
|
||||
|
||||
describe("ReplicaLagEstimator", () => {
|
||||
let estimator: ReplicaLagEstimator | undefined;
|
||||
|
||||
afterEach(() => {
|
||||
estimator?.stop();
|
||||
estimator = undefined;
|
||||
});
|
||||
|
||||
it("returns the default before any sample lands", () => {
|
||||
estimator = new ReplicaLagEstimator({
|
||||
source: source(async () => undefined),
|
||||
defaultLagMs: 42,
|
||||
});
|
||||
expect(estimator.getLagMs()).toBe(42);
|
||||
});
|
||||
|
||||
it("samples the source while touched and reports the window max", async () => {
|
||||
const samples = [10, 60, 20];
|
||||
let i = 0;
|
||||
estimator = new ReplicaLagEstimator({
|
||||
source: source(async () => samples[Math.min(i++, samples.length - 1)]),
|
||||
sampleIntervalMs: 10,
|
||||
windowMs: 5_000,
|
||||
defaultLagMs: 0,
|
||||
});
|
||||
estimator.touch();
|
||||
await sleep(60);
|
||||
// The max sample (60) dominates even after smaller ones land.
|
||||
expect(estimator.getLagMs()).toBe(60);
|
||||
});
|
||||
|
||||
it("widens immediately on an observed (tripwire) lag and clamps wild values", () => {
|
||||
estimator = new ReplicaLagEstimator({
|
||||
source: source(async () => 5),
|
||||
defaultLagMs: 5,
|
||||
maxLagMs: 1_000,
|
||||
});
|
||||
estimator.noteObservedLagMs(250);
|
||||
expect(estimator.getLagMs()).toBe(250);
|
||||
estimator.noteObservedLagMs(99_999);
|
||||
expect(estimator.getLagMs()).toBe(1_000);
|
||||
});
|
||||
|
||||
it("an observed lag floors the estimate past the sample window (until its TTL)", async () => {
|
||||
estimator = new ReplicaLagEstimator({
|
||||
source: source(async () => 0), // caught-up zeros, like vanilla PG between writes
|
||||
sampleIntervalMs: 10,
|
||||
windowMs: 30,
|
||||
defaultLagMs: 0,
|
||||
observedFloorTtlMs: 10_000,
|
||||
});
|
||||
estimator.touch();
|
||||
estimator.noteObservedLagMs(150);
|
||||
// Long past windowMs the zeros have flushed the observation out of the window,
|
||||
// but the floor still carries it.
|
||||
await sleep(80);
|
||||
expect(estimator.getLagMs()).toBe(150);
|
||||
});
|
||||
|
||||
it("stops sampling once idle and resumes on touch", async () => {
|
||||
let probes = 0;
|
||||
estimator = new ReplicaLagEstimator({
|
||||
source: source(async () => {
|
||||
probes++;
|
||||
return 1;
|
||||
}),
|
||||
sampleIntervalMs: 10,
|
||||
idleAfterMs: 20,
|
||||
});
|
||||
estimator.touch();
|
||||
await sleep(80);
|
||||
const afterIdle = probes;
|
||||
expect(afterIdle).toBeGreaterThan(0);
|
||||
await sleep(40);
|
||||
// No new samples while idle...
|
||||
expect(probes).toBe(afterIdle);
|
||||
// ...and touching resumes immediately.
|
||||
estimator.touch();
|
||||
await sleep(15);
|
||||
expect(probes).toBeGreaterThan(afterIdle);
|
||||
});
|
||||
|
||||
it("survives a throwing source and keeps the last known value", async () => {
|
||||
let fail = false;
|
||||
estimator = new ReplicaLagEstimator({
|
||||
source: source(async () => {
|
||||
if (fail) throw new Error("source down");
|
||||
return 33;
|
||||
}),
|
||||
sampleIntervalMs: 10,
|
||||
windowMs: 30,
|
||||
defaultLagMs: 0,
|
||||
});
|
||||
estimator.touch();
|
||||
await sleep(25);
|
||||
expect(estimator.getLagMs()).toBe(33);
|
||||
fail = true;
|
||||
await sleep(60);
|
||||
// Window emptied, source failing — falls back to the last known sample.
|
||||
expect(estimator.getLagMs()).toBe(33);
|
||||
});
|
||||
});
|
||||
|
||||
describe("FirstSupportedReplicaLagSource", () => {
|
||||
it("selects the first candidate whose sample succeeds and sticks with it", async () => {
|
||||
let auroraCalls = 0;
|
||||
let vanillaCalls = 0;
|
||||
const composed = new FirstSupportedReplicaLagSource([
|
||||
source(async () => {
|
||||
auroraCalls++;
|
||||
throw new Error("function aurora_replica_status() does not exist");
|
||||
}, "aurora"),
|
||||
source(async () => {
|
||||
vanillaCalls++;
|
||||
return 7;
|
||||
}, "vanilla-pg"),
|
||||
]);
|
||||
expect(composed.name).toBe("undetected");
|
||||
expect(await composed.sampleLagMs()).toBe(7);
|
||||
expect(composed.name).toBe("vanilla-pg");
|
||||
expect(await composed.sampleLagMs()).toBe(7);
|
||||
// The unsupported dialect was only probed during selection.
|
||||
expect(auroraCalls).toBe(1);
|
||||
expect(vanillaCalls).toBe(2);
|
||||
});
|
||||
|
||||
it("degrades to never-measuring when no candidate works", async () => {
|
||||
const composed = new FirstSupportedReplicaLagSource([
|
||||
source(async () => {
|
||||
throw new Error("nope");
|
||||
}),
|
||||
]);
|
||||
expect(await composed.sampleLagMs()).toBeUndefined();
|
||||
expect(await composed.sampleLagMs()).toBeUndefined();
|
||||
});
|
||||
|
||||
it("a transient error after selection skips the sample without unselecting", async () => {
|
||||
let calls = 0;
|
||||
const composed = new FirstSupportedReplicaLagSource([
|
||||
source(async () => {
|
||||
calls++;
|
||||
if (calls === 2) throw new Error("transient");
|
||||
return 11;
|
||||
}, "flaky"),
|
||||
]);
|
||||
expect(await composed.sampleLagMs()).toBe(11);
|
||||
expect(await composed.sampleLagMs()).toBeUndefined(); // transient error -> skipped sample
|
||||
expect(await composed.sampleLagMs()).toBe(11); // still selected
|
||||
expect(composed.name).toBe("flaky");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
import { redisTest } from "@internal/testcontainers";
|
||||
import { setTimeout as sleep } from "node:timers/promises";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
type ChangeRecord,
|
||||
decodeChangeRecord,
|
||||
encodeChangeRecord,
|
||||
RunChangeNotifier,
|
||||
} from "~/services/realtime/runChangeNotifier.server";
|
||||
|
||||
function toRedisOptions(redisOptions: { host?: string; port?: number; password?: string }) {
|
||||
return {
|
||||
host: redisOptions.host,
|
||||
port: redisOptions.port,
|
||||
password: redisOptions.password,
|
||||
tlsDisabled: true,
|
||||
clusterMode: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Time for a SUBSCRIBE to register server-side before we publish.
|
||||
const SUBSCRIBE_SETTLE_MS = 250;
|
||||
|
||||
describe("RunChangeNotifier", () => {
|
||||
redisTest(
|
||||
"delivers a published change to an env subscriber",
|
||||
{ timeout: 30_000 },
|
||||
async ({ redisOptions }) => {
|
||||
const notifier = new RunChangeNotifier({ redis: toRedisOptions(redisOptions) });
|
||||
try {
|
||||
const received: ChangeRecord[] = [];
|
||||
const unsubscribe = notifier.subscribeToEnv("env_1", (records) =>
|
||||
received.push(...records)
|
||||
);
|
||||
expect(notifier.activeSubscriptionCount).toBe(1);
|
||||
|
||||
await sleep(SUBSCRIBE_SETTLE_MS);
|
||||
notifier.publish({ runId: "run_1", envId: "env_1", tags: ["a"], batchId: "batch_1" });
|
||||
|
||||
await vi.waitFor(() => expect(received.some((r) => r.runId === "run_1")).toBe(true), {
|
||||
timeout: 5_000,
|
||||
interval: 50,
|
||||
});
|
||||
const got = received.find((r) => r.runId === "run_1")!;
|
||||
expect(got.tags).toEqual(["a"]);
|
||||
expect(got.batchId).toBe("batch_1");
|
||||
|
||||
unsubscribe();
|
||||
// Cleanup is deferred until Redis confirms UNSUBSCRIBE, so the count converges to 0.
|
||||
await vi.waitFor(() => expect(notifier.activeSubscriptionCount).toBe(0), {
|
||||
timeout: 5_000,
|
||||
interval: 50,
|
||||
});
|
||||
} finally {
|
||||
await notifier.quit();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
redisTest(
|
||||
"does not deliver a change for a different env",
|
||||
{ timeout: 30_000 },
|
||||
async ({ redisOptions }) => {
|
||||
const notifier = new RunChangeNotifier({ redis: toRedisOptions(redisOptions) });
|
||||
try {
|
||||
const received: ChangeRecord[] = [];
|
||||
notifier.subscribeToEnv("env_a", (records) => received.push(...records));
|
||||
|
||||
await sleep(SUBSCRIBE_SETTLE_MS);
|
||||
notifier.publish({ runId: "run_1", envId: "env_b", tags: [] }); // different env
|
||||
await sleep(500);
|
||||
|
||||
expect(received).toHaveLength(0);
|
||||
} finally {
|
||||
await notifier.quit();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
redisTest(
|
||||
"coalesces a burst of env publishes into far fewer batches than publishes (lossless)",
|
||||
{ timeout: 30_000 },
|
||||
async ({ redisOptions }) => {
|
||||
const notifier = new RunChangeNotifier({
|
||||
redis: toRedisOptions(redisOptions),
|
||||
envWakeCoalesceWindowMs: 100,
|
||||
});
|
||||
try {
|
||||
let batches = 0;
|
||||
const runIds = new Set<string>();
|
||||
notifier.subscribeToEnv("env_burst", (records) => {
|
||||
batches++;
|
||||
for (const r of records) runIds.add(r.runId);
|
||||
});
|
||||
|
||||
await sleep(SUBSCRIBE_SETTLE_MS);
|
||||
let pubs = 0;
|
||||
const end = Date.now() + 1_000;
|
||||
while (Date.now() < end) {
|
||||
notifier.publish({ runId: `r${pubs++}`, envId: "env_burst", tags: [] });
|
||||
await sleep(5);
|
||||
}
|
||||
await sleep(300);
|
||||
|
||||
expect(pubs).toBeGreaterThan(100);
|
||||
expect(batches).toBeGreaterThanOrEqual(1);
|
||||
// Leading-edge throttle: far fewer deliveries than publishes...
|
||||
expect(batches).toBeLessThan(pubs / 4);
|
||||
// ...but lossless — the batch accumulates every run that changed in the window.
|
||||
expect(runIds.size).toBeGreaterThan(pubs / 2);
|
||||
} finally {
|
||||
await notifier.quit();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Sharded pub/sub (SSUBSCRIBE/SPUBLISH/smessage) wiring — validated end to end on a
|
||||
// single node (Redis 7.2 accepts these and delivers same-node). Multi-shard ROUTING
|
||||
// needs a real cluster (the cluster fixture covers that); this proves the command path.
|
||||
redisTest(
|
||||
"delivers via sharded pub/sub on the env channel",
|
||||
{ timeout: 30_000 },
|
||||
async ({ redisOptions }) => {
|
||||
const notifier = new RunChangeNotifier({
|
||||
redis: toRedisOptions(redisOptions),
|
||||
shardedPubSub: true,
|
||||
});
|
||||
try {
|
||||
const received: ChangeRecord[] = [];
|
||||
notifier.subscribeToEnv("env_sharded", (records) => received.push(...records));
|
||||
|
||||
await sleep(SUBSCRIBE_SETTLE_MS);
|
||||
notifier.publish({ runId: "run_1", envId: "env_sharded", tags: ["a"] });
|
||||
|
||||
await vi.waitFor(() => expect(received.some((r) => r.runId === "run_1")).toBe(true), {
|
||||
timeout: 5_000,
|
||||
interval: 50,
|
||||
});
|
||||
} finally {
|
||||
await notifier.quit();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
describe("ChangeRecord codec", () => {
|
||||
it("round-trips a full record (tags with a separator survive)", () => {
|
||||
const encoded = encodeChangeRecord({
|
||||
v: 1,
|
||||
runId: "run_1",
|
||||
envId: "env_1",
|
||||
tags: ["a", "b,c"],
|
||||
batchId: "batch_1",
|
||||
});
|
||||
expect(decodeChangeRecord(encoded)).toMatchObject({
|
||||
v: 1,
|
||||
runId: "run_1",
|
||||
envId: "env_1",
|
||||
tags: ["a", "b,c"],
|
||||
batchId: "batch_1",
|
||||
});
|
||||
});
|
||||
|
||||
it("decodes a bare runId to a partial record (tags undefined)", () => {
|
||||
// A bare/legacy frame: the consumer falls back to hydrate-to-classify.
|
||||
const decoded = decodeChangeRecord("run_3");
|
||||
expect(decoded.runId).toBe("run_3");
|
||||
expect(decoded.tags).toBeUndefined();
|
||||
});
|
||||
|
||||
it("falls back to a bare runId on an unparseable message", () => {
|
||||
expect(decodeChangeRecord("{not json").runId).toBe("{not json");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { PostgresRunStore } from "@internal/run-store";
|
||||
import { buildHydratorSelect, RunHydrator } from "~/services/realtime/runReader.server";
|
||||
|
||||
describe("buildHydratorSelect", () => {
|
||||
it("returns the full select when nothing is skipped", () => {
|
||||
const select = buildHydratorSelect([]);
|
||||
expect(select.id).toBe(true);
|
||||
expect(select.payload).toBe(true);
|
||||
expect(select.output).toBe(true);
|
||||
expect(select.metadata).toBe(true);
|
||||
expect(select.error).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps protocol-reserved columns even when asked to skip them", () => {
|
||||
// Reserved columns are always emitted by the serializer, so hydration must keep
|
||||
// them regardless of skipColumns or the output is null/incorrect.
|
||||
const select = buildHydratorSelect([
|
||||
"status",
|
||||
"taskIdentifier",
|
||||
"createdAt",
|
||||
"friendlyId",
|
||||
"payload",
|
||||
]);
|
||||
expect(select.status).toBe(true);
|
||||
expect(select.taskIdentifier).toBe(true);
|
||||
expect(select.createdAt).toBe(true);
|
||||
expect(select.friendlyId).toBe(true);
|
||||
// A non-reserved skipped column is still dropped.
|
||||
expect(select.payload).toBeUndefined();
|
||||
});
|
||||
|
||||
it("drops skipped columns but always keeps id + updatedAt", () => {
|
||||
const select = buildHydratorSelect(["payload", "output", "metadata", "error"]);
|
||||
expect(select.payload).toBeUndefined();
|
||||
expect(select.output).toBeUndefined();
|
||||
expect(select.metadata).toBeUndefined();
|
||||
expect(select.error).toBeUndefined();
|
||||
// Needed internally regardless of skipColumns (keys the row, drives the diff/offset).
|
||||
expect(select.id).toBe(true);
|
||||
expect(select.updatedAt).toBe(true);
|
||||
// A non-skipped column survives.
|
||||
expect(select.status).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("RunHydrator.hydrateByIds column projection", () => {
|
||||
function makeHydrator() {
|
||||
let capturedSelect: Record<string, boolean> | undefined;
|
||||
const replica = {
|
||||
taskRun: {
|
||||
findMany: vi.fn(async ({ select }: { select: Record<string, boolean> }) => {
|
||||
capturedSelect = select;
|
||||
return [];
|
||||
}),
|
||||
},
|
||||
} as any;
|
||||
const runStore = new PostgresRunStore({ prisma: replica, readOnlyPrisma: replica });
|
||||
return { hydrator: new RunHydrator({ replica, runStore }), getSelect: () => capturedSelect };
|
||||
}
|
||||
|
||||
it("projects the SELECT by skipColumns", async () => {
|
||||
const { hydrator, getSelect } = makeHydrator();
|
||||
await hydrator.hydrateByIds("env_1", ["run_1"], ["payload", "output"]);
|
||||
const select = getSelect()!;
|
||||
expect(select.payload).toBeUndefined();
|
||||
expect(select.output).toBeUndefined();
|
||||
expect(select.id).toBe(true);
|
||||
expect(select.updatedAt).toBe(true);
|
||||
});
|
||||
|
||||
it("selects the full column set when no skipColumns are given", async () => {
|
||||
const { hydrator, getSelect } = makeHydrator();
|
||||
await hydrator.hydrateByIds("env_1", ["run_1"]);
|
||||
expect(getSelect()!.payload).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,532 @@
|
||||
import { heteroPostgresTest, postgresTest } from "@internal/testcontainers";
|
||||
import { PostgresRunStore } from "@internal/run-store";
|
||||
import type { ReadClient, RunStore } from "@internal/run-store";
|
||||
import { ownerEngine, type Residency } from "@trigger.dev/core/v3/isomorphic";
|
||||
import type { Prisma, PrismaClient } from "@trigger.dev/database";
|
||||
import { describe, expect, vi } from "vitest";
|
||||
import { RunHydrator } from "~/services/realtime/runReader.server";
|
||||
|
||||
// Realtime read-route proof for the RunHydrator.
|
||||
//
|
||||
// On origin/main the realtime RunHydrator's two run-ops reads already flow through the runStore
|
||||
// seam: `hydrateByIds` -> `runStore.findRuns(..., replica)` and `#fetch` -> `runStore.findRun(...,
|
||||
// replica)`. The split-aware routing (new-DB-first, legacy READ REPLICA only for ids not
|
||||
// known-migrated) is the store's job below the seam, so this file proves the hydrator *inherits*
|
||||
// that routing — plus that the single-flight + short-TTL cache and the skipColumns projection
|
||||
// (which live in the hydrator, not the store) are unaffected by the seam.
|
||||
//
|
||||
// The heterogeneous fixture gives real legacy + new Postgres containers; NO DB is mocked. The ONLY
|
||||
// non-DB fake is the residency selector that the routing-shaped store uses (`ownerEngine`: run-ops id ->
|
||||
// NEW, cuid -> LEGACY), exactly the substrate the RoutingRunStore ships. Run ids are 25 chars (cuid
|
||||
// -> LEGACY) or v1-shaped (26 chars, version "1" at index 25 -> NEW) so the classifier routes them deterministically.
|
||||
|
||||
// 25-char internal id -> cuid -> LEGACY; v1 internal id (26 chars, version "1" at index 25) -> NEW. The
|
||||
// classifier strips a leading `<prefix>_`, so these ids must carry NO underscore (a bare
|
||||
// alphanumeric body of the exact length).
|
||||
function newId(label: string): string {
|
||||
return ("k" + label.replace(/[^0-9a-v]/g, "")).padEnd(24, "0").slice(0, 24) + "01";
|
||||
}
|
||||
function legacyId(label: string): string {
|
||||
return ("c" + label.replace(/[^a-z0-9]/gi, "")).padEnd(25, "0").slice(0, 25);
|
||||
}
|
||||
|
||||
async function seedEnvironment(prisma: PrismaClient, slugSuffix: string) {
|
||||
const organization = await prisma.organization.create({
|
||||
data: { title: `Org ${slugSuffix}`, slug: `org-${slugSuffix}` },
|
||||
});
|
||||
const project = await prisma.project.create({
|
||||
data: {
|
||||
name: `Project ${slugSuffix}`,
|
||||
slug: `project-${slugSuffix}`,
|
||||
externalRef: `proj_${slugSuffix}`,
|
||||
organizationId: organization.id,
|
||||
},
|
||||
});
|
||||
const environment = await prisma.runtimeEnvironment.create({
|
||||
data: {
|
||||
type: "DEVELOPMENT",
|
||||
slug: "dev",
|
||||
projectId: project.id,
|
||||
organizationId: organization.id,
|
||||
apiKey: `tr_dev_${slugSuffix}`,
|
||||
pkApiKey: `pk_dev_${slugSuffix}`,
|
||||
shortcode: `short_${slugSuffix}`,
|
||||
},
|
||||
});
|
||||
return { organization, project, environment };
|
||||
}
|
||||
|
||||
async function seedRun(
|
||||
prisma: PrismaClient,
|
||||
params: {
|
||||
runId: string;
|
||||
organizationId: string;
|
||||
projectId: string;
|
||||
runtimeEnvironmentId: string;
|
||||
payload?: string;
|
||||
output?: string | null;
|
||||
metadata?: string | null;
|
||||
runTags?: string[];
|
||||
error?: Prisma.InputJsonValue;
|
||||
}
|
||||
) {
|
||||
await prisma.taskRun.create({
|
||||
data: {
|
||||
id: params.runId,
|
||||
engine: "V2",
|
||||
status: "PENDING",
|
||||
friendlyId: `run_friendly_${params.runId.slice(0, 8)}`,
|
||||
runtimeEnvironmentId: params.runtimeEnvironmentId,
|
||||
environmentType: "DEVELOPMENT",
|
||||
organizationId: params.organizationId,
|
||||
projectId: params.projectId,
|
||||
taskIdentifier: "my-task",
|
||||
payload: params.payload ?? '{"hello":"world"}',
|
||||
payloadType: "application/json",
|
||||
...(params.output !== undefined && { output: params.output }),
|
||||
outputType: "application/json",
|
||||
...(params.metadata !== undefined && { metadata: params.metadata }),
|
||||
...(params.error !== undefined && { error: params.error }),
|
||||
traceContext: {},
|
||||
traceId: `trace_${params.runId}`,
|
||||
spanId: `span_${params.runId}`,
|
||||
runTags: params.runTags ?? ["alpha", "beta"],
|
||||
queue: "task/my-task",
|
||||
isTest: false,
|
||||
taskEventStore: "taskEvent",
|
||||
depth: 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A routing-shaped RunStore: routes the single-run `findRun` by residency (the exact substrate
|
||||
* the RoutingRunStore ships) and fans `findRuns` out across NEW + LEGACY, merging by id
|
||||
* (the union/dedup the routing store owns; this hydrator inherits it). For not-known-migrated ids
|
||||
* the read falls back to the LEGACY slot — which is wired over a READ REPLICA handle, never a
|
||||
* writer. Only `findRun`/`findRuns` (the two reads this unit exercises) are implemented; the rest
|
||||
* throw so any accidental call surfaces. The only non-DB fake here is the residency selector.
|
||||
*
|
||||
* By design the router ignores the explicit read `client` and reads off the selected slot's OWN
|
||||
* configured replica, so the hydrator's `replica` arg is dropped here.
|
||||
*/
|
||||
function makeRoutingShapedStore(options: {
|
||||
newStore: PostgresRunStore;
|
||||
legacyStore: PostgresRunStore;
|
||||
classify?: (id: string) => Residency;
|
||||
}): RunStore {
|
||||
const classify = options.classify ?? ownerEngine;
|
||||
const route = (id: string | undefined): PostgresRunStore => {
|
||||
if (typeof id !== "string") return options.legacyStore;
|
||||
try {
|
||||
return classify(id) === "NEW" ? options.newStore : options.legacyStore;
|
||||
} catch {
|
||||
// Not known-migrated / unclassifiable -> fall back to the LEGACY read replica only.
|
||||
return options.legacyStore;
|
||||
}
|
||||
};
|
||||
|
||||
const idFromWhere = (where: Prisma.TaskRunWhereInput): string | undefined => {
|
||||
const id = where.id;
|
||||
if (typeof id === "string") return id;
|
||||
if (id && typeof id === "object" && "equals" in id && typeof id.equals === "string") {
|
||||
return id.equals;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const handler: ProxyHandler<RunStore> = {
|
||||
get(_target, prop) {
|
||||
if (prop === "findRun") {
|
||||
// Drop the explicit `client`: the selected slot reads off its OWN replica.
|
||||
return (where: Prisma.TaskRunWhereInput, args: unknown, _client?: ReadClient) =>
|
||||
(route(idFromWhere(where)).findRun as (...rest: unknown[]) => Promise<unknown>)(
|
||||
where,
|
||||
args
|
||||
);
|
||||
}
|
||||
if (prop === "findRuns") {
|
||||
return async (
|
||||
args: { where: Prisma.TaskRunWhereInput; select: Prisma.TaskRunSelect },
|
||||
_client?: ReadClient
|
||||
) => {
|
||||
// Fan out across both slots (each on its OWN replica) and merge by id (the routing
|
||||
// store's union/dedup contract).
|
||||
const [fromNew, fromLegacy] = await Promise.all([
|
||||
options.newStore.findRuns(args as never),
|
||||
options.legacyStore.findRuns(args as never),
|
||||
]);
|
||||
const byId = new Map<string, Record<string, unknown>>();
|
||||
for (const row of [...fromLegacy, ...fromNew] as Record<string, unknown>[]) {
|
||||
byId.set(row.id as string, row);
|
||||
}
|
||||
return [...byId.values()];
|
||||
};
|
||||
}
|
||||
throw new Error(`routing-shaped store: ${String(prop)} not implemented in test`);
|
||||
},
|
||||
};
|
||||
|
||||
return new Proxy({} as RunStore, handler);
|
||||
}
|
||||
|
||||
describe("RunHydrator read-route through the runStore seam (legacy + new)", () => {
|
||||
// Realtime hydrate pulls run-ops rows from the run-ops replica. A split hydrate returns the
|
||||
// union of NEW + LEGACY-replica rows, byte-identical to source, via both
|
||||
// getRunById and hydrateByIds.
|
||||
heteroPostgresTest(
|
||||
"split hydrate returns the NEW + legacy-replica union, byte-identical",
|
||||
{ timeout: 60_000 },
|
||||
async ({ prisma14, prisma17 }) => {
|
||||
const newStore = new PostgresRunStore({ prisma: prisma17, readOnlyPrisma: prisma17 });
|
||||
const legacyStore = new PostgresRunStore({ prisma: prisma14, readOnlyPrisma: prisma14 });
|
||||
|
||||
const seed14 = await seedEnvironment(prisma14, "u14");
|
||||
const seed17 = await seedEnvironment(prisma17, "u17");
|
||||
// Both seed envs use the SAME runtimeEnvironmentId so the env-scoped `where` matches across
|
||||
// the two physical DBs (each env row is local to its DB but carries the same id).
|
||||
const envId = seed17.environment.id;
|
||||
await prisma14.runtimeEnvironment.update({
|
||||
where: { id: seed14.environment.id },
|
||||
data: { id: envId },
|
||||
});
|
||||
|
||||
const newRunId = newId("union_new");
|
||||
const legacyRunId = legacyId("union_old");
|
||||
|
||||
await seedRun(prisma17, {
|
||||
runId: newRunId,
|
||||
organizationId: seed17.organization.id,
|
||||
projectId: seed17.project.id,
|
||||
runtimeEnvironmentId: envId,
|
||||
payload: '{"side":"new"}',
|
||||
output: '{"result":42}',
|
||||
metadata: '{"m":1}',
|
||||
runTags: ["new", "z"],
|
||||
error: { type: "BUILT_IN_ERROR", name: "Boom", message: "new-side" },
|
||||
});
|
||||
await seedRun(prisma14, {
|
||||
runId: legacyRunId,
|
||||
organizationId: seed14.organization.id,
|
||||
projectId: seed14.project.id,
|
||||
runtimeEnvironmentId: envId,
|
||||
payload: '{"side":"legacy"}',
|
||||
output: null,
|
||||
metadata: null,
|
||||
runTags: ["legacy", "a"],
|
||||
error: { type: "STRING_ERROR", raw: "legacy-side" },
|
||||
});
|
||||
|
||||
const runStore = makeRoutingShapedStore({ newStore, legacyStore });
|
||||
const hydrator = new RunHydrator({ replica: prisma14, runStore });
|
||||
|
||||
const rows = await hydrator.hydrateByIds(envId, [newRunId, legacyRunId]);
|
||||
expect(rows.map((r) => r.id).sort()).toEqual([legacyRunId, newRunId].sort());
|
||||
|
||||
const newRow = rows.find((r) => r.id === newRunId)!;
|
||||
const legacyRow = rows.find((r) => r.id === legacyRunId)!;
|
||||
|
||||
// Byte-identical to source incl. JSON columns, runTags, error JSON.
|
||||
expect(newRow.payload).toBe('{"side":"new"}');
|
||||
expect(newRow.output).toBe('{"result":42}');
|
||||
expect(newRow.metadata).toBe('{"m":1}');
|
||||
expect(newRow.runTags).toEqual(["new", "z"]);
|
||||
expect(newRow.error).toEqual({ type: "BUILT_IN_ERROR", name: "Boom", message: "new-side" });
|
||||
|
||||
expect(legacyRow.payload).toBe('{"side":"legacy"}');
|
||||
expect(legacyRow.output).toBeNull();
|
||||
expect(legacyRow.metadata).toBeNull();
|
||||
expect(legacyRow.runTags).toEqual(["legacy", "a"]);
|
||||
expect(legacyRow.error).toEqual({ type: "STRING_ERROR", raw: "legacy-side" });
|
||||
|
||||
// getRunById resolves each individual run from its correct source through the seam.
|
||||
const newById = await hydrator.getRunById(envId, newRunId);
|
||||
const legacyById = await hydrator.getRunById(envId, legacyRunId);
|
||||
expect(newById?.payload).toBe('{"side":"new"}');
|
||||
expect(legacyById?.payload).toBe('{"side":"legacy"}');
|
||||
}
|
||||
);
|
||||
|
||||
// A known-migrated (NEW-residency) run is NOT re-probed on the legacy replica.
|
||||
heteroPostgresTest(
|
||||
"known-migrated run is never probed on the legacy slot",
|
||||
{ timeout: 60_000 },
|
||||
async ({ prisma14, prisma17 }) => {
|
||||
const newStore = new PostgresRunStore({ prisma: prisma17, readOnlyPrisma: prisma17 });
|
||||
const legacyStore = new PostgresRunStore({ prisma: prisma14, readOnlyPrisma: prisma14 });
|
||||
const legacyFindRunSpy = vi.spyOn(legacyStore, "findRun");
|
||||
|
||||
const seed17 = await seedEnvironment(prisma17, "k17");
|
||||
const envId = seed17.environment.id;
|
||||
const migratedRunId = newId("known_mig");
|
||||
await seedRun(prisma17, {
|
||||
runId: migratedRunId,
|
||||
organizationId: seed17.organization.id,
|
||||
projectId: seed17.project.id,
|
||||
runtimeEnvironmentId: envId,
|
||||
});
|
||||
|
||||
const runStore = makeRoutingShapedStore({ newStore, legacyStore });
|
||||
const hydrator = new RunHydrator({ replica: prisma14, runStore });
|
||||
|
||||
const row = await hydrator.getRunById(envId, migratedRunId);
|
||||
expect(row?.id).toBe(migratedRunId);
|
||||
// The NEW-residency id resolved against the NEW slot only — the legacy probe never ran.
|
||||
expect(legacyFindRunSpy).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
// An old in-retention run is served from the LEGACY read replica (never a writer/primary path).
|
||||
heteroPostgresTest(
|
||||
"old in-retention run served from the legacy replica slot",
|
||||
{ timeout: 60_000 },
|
||||
async ({ prisma14, prisma17 }) => {
|
||||
const newStore = new PostgresRunStore({ prisma: prisma17, readOnlyPrisma: prisma17 });
|
||||
// The LEGACY slot exposes only a read/replica handle: `prisma14` is wired as BOTH prisma and
|
||||
// readOnlyPrisma, and the hydrator passes it as the explicit read client — there is no
|
||||
// legacy-writer read path on the read route (the replica-only invariant is structural in the
|
||||
// store; asserted here as inheritance).
|
||||
const legacyStore = new PostgresRunStore({ prisma: prisma14, readOnlyPrisma: prisma14 });
|
||||
|
||||
const seed14 = await seedEnvironment(prisma14, "o14");
|
||||
const envId = seed14.environment.id;
|
||||
const oldRunId = legacyId("old_run");
|
||||
await seedRun(prisma14, {
|
||||
runId: oldRunId,
|
||||
organizationId: seed14.organization.id,
|
||||
projectId: seed14.project.id,
|
||||
runtimeEnvironmentId: envId,
|
||||
payload: '{"era":"old"}',
|
||||
});
|
||||
|
||||
const runStore = makeRoutingShapedStore({ newStore, legacyStore });
|
||||
const hydrator = new RunHydrator({ replica: prisma14, runStore });
|
||||
|
||||
const byId = await hydrator.getRunById(envId, oldRunId);
|
||||
expect(byId?.payload).toBe('{"era":"old"}');
|
||||
|
||||
const [hydrated] = await hydrator.hydrateByIds(envId, [oldRunId]);
|
||||
expect(hydrated.payload).toBe('{"era":"old"}');
|
||||
}
|
||||
);
|
||||
|
||||
// Terminal-metadata read-seam: a NEW-resident (run-ops id) run's final metadata is hydrated through
|
||||
// the owning (NEW) store, not off a generic legacy replica. Asserts read-seam ROUTING for the
|
||||
// terminal read; it is not a hard ordering/consistency guarantee about when the terminal marker
|
||||
// and the row's terminal columns converge.
|
||||
heteroPostgresTest(
|
||||
"terminal hydrate reads a NEW-resident run's final metadata through the owning store",
|
||||
{ timeout: 60_000 },
|
||||
async ({ prisma14, prisma17 }) => {
|
||||
const newStore = new PostgresRunStore({ prisma: prisma17, readOnlyPrisma: prisma17 });
|
||||
const legacyStore = new PostgresRunStore({ prisma: prisma14, readOnlyPrisma: prisma14 });
|
||||
const legacyFindRunSpy = vi.spyOn(legacyStore, "findRun");
|
||||
|
||||
const seed17 = await seedEnvironment(prisma17, "term17");
|
||||
const envId = seed17.environment.id;
|
||||
const terminalRunId = newId("terminal_run");
|
||||
|
||||
// A terminal run with its final metadata persisted on the NEW store only.
|
||||
await seedRun(prisma17, {
|
||||
runId: terminalRunId,
|
||||
organizationId: seed17.organization.id,
|
||||
projectId: seed17.project.id,
|
||||
runtimeEnvironmentId: envId,
|
||||
output: '{"result":"final"}',
|
||||
metadata: '{"done":true}',
|
||||
});
|
||||
|
||||
// A generic legacy replica would miss the NEW row entirely — the metadata must come off NEW.
|
||||
const runStore = makeRoutingShapedStore({ newStore, legacyStore });
|
||||
const hydrator = new RunHydrator({ replica: prisma14, runStore, cacheTtlMs: 0 });
|
||||
|
||||
const snapshot = await hydrator.getRunById(envId, terminalRunId);
|
||||
expect(snapshot?.id).toBe(terminalRunId);
|
||||
expect(snapshot?.metadata).toBe('{"done":true}');
|
||||
expect(snapshot?.output).toBe('{"result":"final"}');
|
||||
// The NEW-residency terminal read never touched the legacy slot.
|
||||
expect(legacyFindRunSpy).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
// A live-migrated run continues streaming across the seam crossing with no gap.
|
||||
heteroPostgresTest(
|
||||
"live-migrated run continues streaming across the seam crossing",
|
||||
{ timeout: 60_000 },
|
||||
async ({ prisma14, prisma17 }) => {
|
||||
const newStore = new PostgresRunStore({ prisma: prisma17, readOnlyPrisma: prisma17 });
|
||||
const legacyStore = new PostgresRunStore({ prisma: prisma14, readOnlyPrisma: prisma14 });
|
||||
|
||||
const seed14 = await seedEnvironment(prisma14, "m14");
|
||||
const seed17 = await seedEnvironment(prisma17, "m17");
|
||||
const envId = seed17.environment.id;
|
||||
await prisma14.runtimeEnvironment.update({
|
||||
where: { id: seed14.environment.id },
|
||||
data: { id: envId },
|
||||
});
|
||||
|
||||
// The run starts life on LEGACY; the residency selector classifies it NEW once it migrates.
|
||||
// We model the migration by seeding the same run id on LEGACY first, then on NEW, while
|
||||
// flipping the classifier from LEGACY to NEW for that id at the seam crossing.
|
||||
const runId = legacyId("migrating");
|
||||
await seedRun(prisma14, {
|
||||
runId,
|
||||
organizationId: seed14.organization.id,
|
||||
projectId: seed14.project.id,
|
||||
runtimeEnvironmentId: envId,
|
||||
payload: '{"home":"legacy"}',
|
||||
});
|
||||
|
||||
let migrated = false;
|
||||
const classify = (id: string): Residency =>
|
||||
id === runId && migrated ? "NEW" : ownerEngine(id);
|
||||
const legacyFindRunSpy = vi.spyOn(legacyStore, "findRun");
|
||||
|
||||
// Use a 0ms TTL so each getRunById re-reads through the seam (no cached stale row across the
|
||||
// crossing). Single-flight/TTL are proven separately below.
|
||||
const runStore = makeRoutingShapedStore({ newStore, legacyStore, classify });
|
||||
const hydrator = new RunHydrator({ replica: prisma14, runStore, cacheTtlMs: 0 });
|
||||
|
||||
// Before migration: served from LEGACY.
|
||||
const before = await hydrator.getRunById(envId, runId);
|
||||
expect(before?.payload).toBe('{"home":"legacy"}');
|
||||
expect(legacyFindRunSpy).toHaveBeenCalled();
|
||||
|
||||
// Migrate: the run now lives on NEW and the classifier routes it NEW.
|
||||
await seedRun(prisma17, {
|
||||
runId,
|
||||
organizationId: seed17.organization.id,
|
||||
projectId: seed17.project.id,
|
||||
runtimeEnvironmentId: envId,
|
||||
payload: '{"home":"new"}',
|
||||
});
|
||||
migrated = true;
|
||||
legacyFindRunSpy.mockClear();
|
||||
|
||||
// After migration: served from NEW, with no gap and no legacy re-probe.
|
||||
const after = await hydrator.getRunById(envId, runId);
|
||||
expect(after?.payload).toBe('{"home":"new"}');
|
||||
expect(after?.id).toBe(runId);
|
||||
expect(legacyFindRunSpy).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
describe("RunHydrator single-flight + TTL cache intact across the seam", () => {
|
||||
// The cache/single-flight live in the hydrator, independent of the storage seam. Proven in
|
||||
// SPLIT mode here (a counting wrapper over the selected underlying store's read).
|
||||
heteroPostgresTest(
|
||||
"split mode: two concurrent getRunById -> one underlying read; repeat within TTL is cached",
|
||||
{ timeout: 60_000 },
|
||||
async ({ prisma14, prisma17 }) => {
|
||||
const newStore = new PostgresRunStore({ prisma: prisma17, readOnlyPrisma: prisma17 });
|
||||
const legacyStore = new PostgresRunStore({ prisma: prisma14, readOnlyPrisma: prisma14 });
|
||||
const newFindRunSpy = vi.spyOn(newStore, "findRun");
|
||||
|
||||
const seed17 = await seedEnvironment(prisma17, "s17");
|
||||
const envId = seed17.environment.id;
|
||||
const runId = newId("cached_run");
|
||||
await seedRun(prisma17, {
|
||||
runId,
|
||||
organizationId: seed17.organization.id,
|
||||
projectId: seed17.project.id,
|
||||
runtimeEnvironmentId: envId,
|
||||
});
|
||||
|
||||
const runStore = makeRoutingShapedStore({ newStore, legacyStore });
|
||||
const hydrator = new RunHydrator({ replica: prisma14, runStore, cacheTtlMs: 60_000 });
|
||||
|
||||
// Two concurrent calls -> single-flight collapses to ONE underlying read.
|
||||
const [a, b] = await Promise.all([
|
||||
hydrator.getRunById(envId, runId),
|
||||
hydrator.getRunById(envId, runId),
|
||||
]);
|
||||
expect(a?.id).toBe(runId);
|
||||
expect(b?.id).toBe(runId);
|
||||
expect(newFindRunSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
// A third call within the TTL returns the cached value with no new read.
|
||||
const c = await hydrator.getRunById(envId, runId);
|
||||
expect(c?.id).toBe(runId);
|
||||
expect(newFindRunSpy).toHaveBeenCalledTimes(1);
|
||||
}
|
||||
);
|
||||
|
||||
// A cached `null` (missing run) is a valid not-found hit and is not re-read within the TTL.
|
||||
heteroPostgresTest(
|
||||
"split mode: a cached null (missing run) is not re-read within the TTL",
|
||||
{ timeout: 60_000 },
|
||||
async ({ prisma14, prisma17 }) => {
|
||||
const newStore = new PostgresRunStore({ prisma: prisma17, readOnlyPrisma: prisma17 });
|
||||
const legacyStore = new PostgresRunStore({ prisma: prisma14, readOnlyPrisma: prisma14 });
|
||||
const newFindRunSpy = vi.spyOn(newStore, "findRun");
|
||||
|
||||
const seed17 = await seedEnvironment(prisma17, "n17");
|
||||
const envId = seed17.environment.id;
|
||||
const missingRunId = newId("missing_run");
|
||||
|
||||
const runStore = makeRoutingShapedStore({ newStore, legacyStore });
|
||||
const hydrator = new RunHydrator({ replica: prisma14, runStore, cacheTtlMs: 60_000 });
|
||||
|
||||
const first = await hydrator.getRunById(envId, missingRunId);
|
||||
expect(first).toBeNull();
|
||||
expect(newFindRunSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
const second = await hydrator.getRunById(envId, missingRunId);
|
||||
expect(second).toBeNull();
|
||||
// Still one read — the null was cached as a valid "not found" hit.
|
||||
expect(newFindRunSpy).toHaveBeenCalledTimes(1);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
describe("RunHydrator single-DB passthrough (one PostgresRunStore over one client)", () => {
|
||||
// Passthrough: in single-DB the store is one PostgresRunStore over one client; the hydrator
|
||||
// behaves byte-for-byte as today. No split branch, no legacy slot, no second connection.
|
||||
postgresTest(
|
||||
"single store: getRunById + hydrateByIds read from the one client, cache intact",
|
||||
{ timeout: 60_000 },
|
||||
async ({ prisma }) => {
|
||||
const store = new PostgresRunStore({ prisma, readOnlyPrisma: prisma });
|
||||
const findRunSpy = vi.spyOn(store, "findRun");
|
||||
|
||||
const seed = await seedEnvironment(prisma, "sd1");
|
||||
const envId = seed.environment.id;
|
||||
const runIdA = newId("single_a");
|
||||
const runIdB = legacyId("single_b");
|
||||
for (const runId of [runIdA, runIdB]) {
|
||||
await seedRun(prisma, {
|
||||
runId,
|
||||
organizationId: seed.organization.id,
|
||||
projectId: seed.project.id,
|
||||
runtimeEnvironmentId: envId,
|
||||
payload: `{"id":"${runId}"}`,
|
||||
});
|
||||
}
|
||||
|
||||
const hydrator = new RunHydrator({ replica: prisma, runStore: store, cacheTtlMs: 60_000 });
|
||||
|
||||
// hydrateByIds returns both rows from the single client.
|
||||
const rows = await hydrator.hydrateByIds(envId, [runIdA, runIdB]);
|
||||
expect(rows.map((r) => r.id).sort()).toEqual([runIdA, runIdB].sort());
|
||||
|
||||
// getRunById hydrates from the single store; the cache short-circuits a repeat read.
|
||||
const a1 = await hydrator.getRunById(envId, runIdA);
|
||||
const a2 = await hydrator.getRunById(envId, runIdA);
|
||||
expect(a1?.payload).toBe(`{"id":"${runIdA}"}`);
|
||||
expect(a2?.payload).toBe(`{"id":"${runIdA}"}`);
|
||||
expect(findRunSpy).toHaveBeenCalledTimes(1);
|
||||
}
|
||||
);
|
||||
|
||||
// Empty id-set short-circuits with no store call.
|
||||
postgresTest("empty id-set returns [] without touching the store", async ({ prisma }) => {
|
||||
const store = new PostgresRunStore({ prisma, readOnlyPrisma: prisma });
|
||||
const findRunsSpy = vi.spyOn(store, "findRuns");
|
||||
const hydrator = new RunHydrator({ replica: prisma, runStore: store });
|
||||
|
||||
const rows = await hydrator.hydrateByIds("env_none", []);
|
||||
expect(rows).toEqual([]);
|
||||
expect(findRunsSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,223 @@
|
||||
import {
|
||||
type RealtimeRunRow,
|
||||
serializeRunRow,
|
||||
} from "~/services/realtime/electricStreamProtocol.server";
|
||||
import { type RunListFilter } from "~/services/realtime/runReader.server";
|
||||
import { RealtimeShadowComparator } from "~/services/realtime/shadowCompare.server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
function sampleRow(overrides: Partial<RealtimeRunRow> = {}): RealtimeRunRow {
|
||||
return {
|
||||
id: "run_a",
|
||||
taskIdentifier: "my-task",
|
||||
createdAt: new Date("2026-06-07T09:00:00.000Z"),
|
||||
updatedAt: new Date("2026-06-07T10:05:30.123Z"),
|
||||
startedAt: null,
|
||||
delayUntil: null,
|
||||
queuedAt: null,
|
||||
expiredAt: null,
|
||||
completedAt: null,
|
||||
friendlyId: "run_friendly_a",
|
||||
number: 7,
|
||||
isTest: true,
|
||||
status: "EXECUTING",
|
||||
usageDurationMs: 1234,
|
||||
costInCents: 0.55,
|
||||
baseCostInCents: 0.25,
|
||||
ttl: "1h",
|
||||
payload: '{"hello":"world"}',
|
||||
payloadType: "application/json",
|
||||
metadata: null,
|
||||
metadataType: "application/json",
|
||||
output: null,
|
||||
outputType: "application/json",
|
||||
runTags: ["a", "b"],
|
||||
error: null,
|
||||
realtimeStreams: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const UP_TO_DATE = { headers: { control: "up-to-date" } };
|
||||
|
||||
function insert(value: Record<string, string | null>) {
|
||||
return { key: `"public"."TaskRun"/"${value.id}"`, value, headers: { operation: "insert" } };
|
||||
}
|
||||
|
||||
function makeComparator(
|
||||
rowsById: Record<string, RealtimeRunRow | null>,
|
||||
resolvedIds: string[] = []
|
||||
) {
|
||||
return new RealtimeShadowComparator({
|
||||
runReader: {
|
||||
getRunById: async (_env: string, id: string) => rowsById[id] ?? null,
|
||||
hydrateByIds: async (_env: string, ids: string[]) =>
|
||||
ids.map((id) => rowsById[id]).filter((row): row is RealtimeRunRow => Boolean(row)),
|
||||
} as any,
|
||||
runListResolver: { resolveMatchingRunIds: async (_f: RunListFilter) => resolvedIds } as any,
|
||||
});
|
||||
}
|
||||
|
||||
describe("RealtimeShadowComparator serialization", () => {
|
||||
it("counts a faithful re-serialization as a match", async () => {
|
||||
const row = sampleRow();
|
||||
const body = JSON.stringify([insert(serializeRunRow(row)), UP_TO_DATE]);
|
||||
const cmp = makeComparator({ run_a: row });
|
||||
|
||||
const out = await cmp.compare({
|
||||
feed: "run",
|
||||
electricBody: body,
|
||||
environment: { id: "env_1" },
|
||||
skipColumns: [],
|
||||
isInitialSnapshot: true,
|
||||
});
|
||||
|
||||
expect(out.serializationMatched).toBe(1);
|
||||
expect(out.serializationDiverged).toBe(0);
|
||||
expect(out.serializationSkew).toBe(0);
|
||||
expect(out.diffs).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not flag semantically-equivalent but differently-encoded values", async () => {
|
||||
const row = sampleRow();
|
||||
// Electric encodes bool as "true" (native uses "t"), a number with a trailing
|
||||
// zero, and a timestamp without millis — all equal after decoding.
|
||||
const value = {
|
||||
...serializeRunRow(row),
|
||||
isTest: "true",
|
||||
costInCents: "0.5500",
|
||||
createdAt: "2026-06-07T09:00:00",
|
||||
};
|
||||
const body = JSON.stringify([insert(value), UP_TO_DATE]);
|
||||
const cmp = makeComparator({ run_a: row });
|
||||
|
||||
const out = await cmp.compare({
|
||||
feed: "run",
|
||||
electricBody: body,
|
||||
environment: { id: "env_1" },
|
||||
skipColumns: [],
|
||||
isInitialSnapshot: true,
|
||||
});
|
||||
|
||||
expect(out.serializationMatched).toBe(1);
|
||||
expect(out.serializationDiverged).toBe(0);
|
||||
});
|
||||
|
||||
it("flags a genuine column divergence (same version)", async () => {
|
||||
const row = sampleRow();
|
||||
const value = { ...serializeRunRow(row), payload: '{"hello":"TAMPERED"}' };
|
||||
const body = JSON.stringify([insert(value), UP_TO_DATE]);
|
||||
const cmp = makeComparator({ run_a: row });
|
||||
|
||||
const out = await cmp.compare({
|
||||
feed: "run",
|
||||
electricBody: body,
|
||||
environment: { id: "env_1" },
|
||||
skipColumns: [],
|
||||
isInitialSnapshot: true,
|
||||
});
|
||||
|
||||
expect(out.serializationDiverged).toBe(1);
|
||||
expect(out.serializationMatched).toBe(0);
|
||||
expect(out.diffs).toEqual([
|
||||
{
|
||||
runId: "run_a",
|
||||
column: "payload",
|
||||
electric: '{"hello":"TAMPERED"}',
|
||||
native: '{"hello":"world"}',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("treats DEQUEUED/EXECUTING as equivalent (legacy status rewrite)", async () => {
|
||||
const row = sampleRow({ status: "EXECUTING" });
|
||||
const value = { ...serializeRunRow(row), status: "DEQUEUED" };
|
||||
const body = JSON.stringify([insert(value), UP_TO_DATE]);
|
||||
const cmp = makeComparator({ run_a: row });
|
||||
|
||||
const out = await cmp.compare({
|
||||
feed: "run",
|
||||
electricBody: body,
|
||||
environment: { id: "env_1" },
|
||||
skipColumns: [],
|
||||
isInitialSnapshot: true,
|
||||
});
|
||||
|
||||
expect(out.serializationDiverged).toBe(0);
|
||||
expect(out.serializationMatched).toBe(1);
|
||||
});
|
||||
|
||||
it("records skew when the row advanced between emit and refetch", async () => {
|
||||
const row = sampleRow();
|
||||
// Electric emitted an older version; the refetched row is newer.
|
||||
const value = {
|
||||
...serializeRunRow(sampleRow({ updatedAt: new Date("2026-06-07T10:00:00.000Z") })),
|
||||
};
|
||||
const body = JSON.stringify([insert(value), UP_TO_DATE]);
|
||||
const cmp = makeComparator({ run_a: row });
|
||||
|
||||
const out = await cmp.compare({
|
||||
feed: "run",
|
||||
electricBody: body,
|
||||
environment: { id: "env_1" },
|
||||
skipColumns: [],
|
||||
isInitialSnapshot: true,
|
||||
});
|
||||
|
||||
expect(out.serializationSkew).toBe(1);
|
||||
expect(out.serializationMatched).toBe(0);
|
||||
expect(out.serializationDiverged).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("RealtimeShadowComparator membership", () => {
|
||||
const filter: RunListFilter = {
|
||||
organizationId: "org_1",
|
||||
projectId: "proj_1",
|
||||
environmentId: "env_1",
|
||||
tags: ["t"],
|
||||
createdAtAfter: new Date("2026-06-06T00:00:00.000Z"),
|
||||
limit: 1000,
|
||||
};
|
||||
|
||||
function bodyFor(ids: string[]) {
|
||||
const msgs = ids.map((id) => insert(serializeRunRow(sampleRow({ id }))));
|
||||
return JSON.stringify([...msgs, UP_TO_DATE]);
|
||||
}
|
||||
|
||||
it("matches when Electric's set equals the native resolver's set", async () => {
|
||||
const cmp = makeComparator({ a: sampleRow({ id: "a" }), b: sampleRow({ id: "b" }) }, [
|
||||
"a",
|
||||
"b",
|
||||
]);
|
||||
const out = await cmp.compare({
|
||||
feed: "runs",
|
||||
electricBody: bodyFor(["a", "b"]),
|
||||
environment: { id: "env_1" },
|
||||
skipColumns: [],
|
||||
isInitialSnapshot: true,
|
||||
membershipFilter: filter,
|
||||
});
|
||||
expect(out.membershipMatch).toBe(true);
|
||||
expect(out.missingInNative).toEqual([]);
|
||||
expect(out.extraInNative).toEqual([]);
|
||||
});
|
||||
|
||||
it("reports rows missing from / extra in the native resolution", async () => {
|
||||
const cmp = makeComparator(
|
||||
{ a: sampleRow({ id: "a" }), b: sampleRow({ id: "b" }) },
|
||||
["a", "c"] // native missing b, has extra c
|
||||
);
|
||||
const out = await cmp.compare({
|
||||
feed: "runs",
|
||||
electricBody: bodyFor(["a", "b"]),
|
||||
environment: { id: "env_1" },
|
||||
skipColumns: [],
|
||||
isInitialSnapshot: true,
|
||||
membershipFilter: filter,
|
||||
});
|
||||
expect(out.membershipMatch).toBe(false);
|
||||
expect(out.missingInNative).toEqual(["b"]);
|
||||
expect(out.extraInNative).toEqual(["c"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,240 @@
|
||||
import { heteroPostgresTest, redisTest } from "@internal/testcontainers";
|
||||
import { PostgresRunStore } from "@internal/run-store";
|
||||
import type { PrismaClient } from "@trigger.dev/database";
|
||||
import Redis from "ioredis";
|
||||
import { describe, expect } from "vitest";
|
||||
import { RedisRealtimeStreams } from "~/services/realtime/redisRealtimeStreams.server.js";
|
||||
|
||||
// Seeds organization -> project -> runtimeEnvironment -> taskRun on the given prisma client.
|
||||
// Mirrors the route's target run: a V2 run with an (optionally completed) lifecycle and an
|
||||
// initially-empty realtimeStreams array.
|
||||
async function seedRun(
|
||||
prisma: PrismaClient,
|
||||
params: {
|
||||
runId: string;
|
||||
slugSuffix: string;
|
||||
completedAt?: Date;
|
||||
}
|
||||
) {
|
||||
const organization = await prisma.organization.create({
|
||||
data: {
|
||||
title: "Test Organization",
|
||||
slug: `test-organization-${params.slugSuffix}`,
|
||||
},
|
||||
});
|
||||
|
||||
const project = await prisma.project.create({
|
||||
data: {
|
||||
name: "Test Project",
|
||||
slug: `test-project-${params.slugSuffix}`,
|
||||
externalRef: `proj_${params.slugSuffix}`,
|
||||
organizationId: organization.id,
|
||||
},
|
||||
});
|
||||
|
||||
const environment = await prisma.runtimeEnvironment.create({
|
||||
data: {
|
||||
type: "DEVELOPMENT",
|
||||
slug: "dev",
|
||||
projectId: project.id,
|
||||
organizationId: organization.id,
|
||||
apiKey: `tr_dev_apikey_${params.slugSuffix}`,
|
||||
pkApiKey: `pk_dev_apikey_${params.slugSuffix}`,
|
||||
shortcode: `short_code_${params.slugSuffix}`,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.taskRun.create({
|
||||
data: {
|
||||
id: params.runId,
|
||||
engine: "V2",
|
||||
status: "PENDING",
|
||||
friendlyId: `run_friendly_${params.slugSuffix}`,
|
||||
runtimeEnvironmentId: environment.id,
|
||||
environmentType: "DEVELOPMENT",
|
||||
organizationId: organization.id,
|
||||
projectId: project.id,
|
||||
taskIdentifier: "my-task",
|
||||
payload: "{}",
|
||||
payloadType: "application/json",
|
||||
traceContext: {},
|
||||
traceId: `trace_${params.runId}`,
|
||||
spanId: `span_${params.runId}`,
|
||||
queue: "task/my-task",
|
||||
isTest: false,
|
||||
taskEventStore: "taskEvent",
|
||||
depth: 0,
|
||||
...(params.completedAt !== undefined && { completedAt: params.completedAt }),
|
||||
},
|
||||
});
|
||||
|
||||
return { organization, project, environment };
|
||||
}
|
||||
|
||||
// The exact routed sequence performed by realtime.v1.streams.$runId.$target.$streamId(.append) PUT:
|
||||
// read the target via the store, then push the streamId iff it is not already present and the run
|
||||
// is not completed. Driving this against the store is the routed seam (no engine instance required).
|
||||
async function routedRegisterStream(
|
||||
store: PostgresRunStore,
|
||||
client: PrismaClient,
|
||||
runId: string,
|
||||
streamId: string
|
||||
): Promise<{ pushed: boolean }> {
|
||||
const target = await store.findRun(
|
||||
{ id: runId },
|
||||
{
|
||||
select: {
|
||||
id: true,
|
||||
realtimeStreams: true,
|
||||
realtimeStreamsVersion: true,
|
||||
completedAt: true,
|
||||
},
|
||||
},
|
||||
client
|
||||
);
|
||||
|
||||
if (!target) {
|
||||
throw new Error("Run not found");
|
||||
}
|
||||
|
||||
// Completed-run guard (route returns 400 here).
|
||||
if (target.completedAt) {
|
||||
return { pushed: false };
|
||||
}
|
||||
|
||||
if (!target.realtimeStreams.includes(streamId)) {
|
||||
await store.pushRealtimeStream(target.id, streamId, client);
|
||||
return { pushed: true };
|
||||
}
|
||||
|
||||
return { pushed: false };
|
||||
}
|
||||
|
||||
describe("realtime stream registration — run-ops store routed writes", () => {
|
||||
heteroPostgresTest(
|
||||
"push routes to run-ops store for a run on the new DB",
|
||||
{ timeout: 60_000 },
|
||||
async ({ prisma17, prisma14 }) => {
|
||||
// The run-ops store owns the PG17 (new) DB.
|
||||
const store = new PostgresRunStore({ prisma: prisma17, readOnlyPrisma: prisma17 });
|
||||
|
||||
const runId = "run_routed_push_new_db";
|
||||
await seedRun(prisma17, { runId, slugSuffix: "push17" });
|
||||
|
||||
const streamId = "stream-abc";
|
||||
const result = await routedRegisterStream(store, prisma17, runId, streamId);
|
||||
|
||||
expect(result.pushed).toBe(true);
|
||||
|
||||
// Write landed on the new (PG17) DB.
|
||||
const onNewDb = await prisma17.taskRun.findFirst({
|
||||
where: { id: runId },
|
||||
select: { realtimeStreams: true },
|
||||
});
|
||||
expect(onNewDb?.realtimeStreams).toContain(streamId);
|
||||
|
||||
// Write is isolated to the new DB — the legacy (PG14) DB carries no run with that streamId.
|
||||
const onLegacyDb = await prisma14.taskRun.findFirst({
|
||||
where: { realtimeStreams: { has: streamId } },
|
||||
select: { id: true },
|
||||
});
|
||||
expect(onLegacyDb).toBeNull();
|
||||
}
|
||||
);
|
||||
|
||||
heteroPostgresTest(
|
||||
"idempotent — already-registered streamId issues no second write",
|
||||
{ timeout: 60_000 },
|
||||
async ({ prisma17 }) => {
|
||||
const store = new PostgresRunStore({ prisma: prisma17, readOnlyPrisma: prisma17 });
|
||||
|
||||
const runId = "run_routed_push_idempotent";
|
||||
await seedRun(prisma17, { runId, slugSuffix: "idem17" });
|
||||
|
||||
const streamId = "stream-once";
|
||||
|
||||
const first = await routedRegisterStream(store, prisma17, runId, streamId);
|
||||
expect(first.pushed).toBe(true);
|
||||
|
||||
const second = await routedRegisterStream(store, prisma17, runId, streamId);
|
||||
// The includes() guard skipped the second push.
|
||||
expect(second.pushed).toBe(false);
|
||||
|
||||
const row = await prisma17.taskRun.findFirst({
|
||||
where: { id: runId },
|
||||
select: { realtimeStreams: true },
|
||||
});
|
||||
// Exactly one entry — no duplicate appended.
|
||||
expect(row?.realtimeStreams).toEqual([streamId]);
|
||||
expect(row?.realtimeStreams).toHaveLength(1);
|
||||
}
|
||||
);
|
||||
|
||||
heteroPostgresTest(
|
||||
"completed run guard issues no push",
|
||||
{ timeout: 60_000 },
|
||||
async ({ prisma17 }) => {
|
||||
const store = new PostgresRunStore({ prisma: prisma17, readOnlyPrisma: prisma17 });
|
||||
|
||||
const runId = "run_routed_push_completed";
|
||||
await seedRun(prisma17, {
|
||||
runId,
|
||||
slugSuffix: "completed17",
|
||||
completedAt: new Date("2026-06-01T00:00:00.000Z"),
|
||||
});
|
||||
|
||||
const streamId = "stream-late";
|
||||
const result = await routedRegisterStream(store, prisma17, runId, streamId);
|
||||
|
||||
// The completedAt guard blocks the push (route returns 400).
|
||||
expect(result.pushed).toBe(false);
|
||||
|
||||
const row = await prisma17.taskRun.findFirst({
|
||||
where: { id: runId },
|
||||
select: { realtimeStreams: true },
|
||||
});
|
||||
expect(row?.realtimeStreams).toEqual([]);
|
||||
}
|
||||
);
|
||||
|
||||
redisTest(
|
||||
"chunks flow — stream attaches and chunks are ingested",
|
||||
{ timeout: 30_000 },
|
||||
async ({ redisOptions }) => {
|
||||
const redis = new Redis(redisOptions);
|
||||
const streams = new RedisRealtimeStreams({ redis: redisOptions });
|
||||
|
||||
const runId = "run_chunks_flow";
|
||||
const streamId = "registered-stream";
|
||||
|
||||
const chunks = [
|
||||
JSON.stringify({ chunk: 0, data: "chunk 0" }),
|
||||
JSON.stringify({ chunk: 1, data: "chunk 1" }),
|
||||
JSON.stringify({ chunk: 2, data: "chunk 2" }),
|
||||
];
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
for (const chunk of chunks) {
|
||||
controller.enqueue(encoder.encode(chunk + "\n"));
|
||||
}
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
|
||||
const response = await streams.ingestData(stream, runId, streamId, "default");
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const streamKey = `stream:${runId}:${streamId}`;
|
||||
const entries = await redis.xrange(streamKey, "-", "+");
|
||||
expect(entries.length).toBe(3);
|
||||
|
||||
const lastChunkIndex = await streams.getLastChunkIndex(runId, streamId, "default");
|
||||
expect(lastChunkIndex).toBe(2);
|
||||
|
||||
await redis.del(streamKey);
|
||||
await redis.quit();
|
||||
}
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user