147 lines
4.8 KiB
TypeScript
147 lines
4.8 KiB
TypeScript
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);
|
|
});
|
|
});
|