chore: import upstream snapshot with attribution
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user