554 lines
22 KiB
TypeScript
554 lines
22 KiB
TypeScript
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();
|
|
});
|
|
});
|