348 lines
13 KiB
TypeScript
348 lines
13 KiB
TypeScript
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);
|
|
});
|
|
});
|