284 lines
8.8 KiB
TypeScript
284 lines
8.8 KiB
TypeScript
import {
|
|
type ElectricColumnType,
|
|
RUN_ELECTRIC_COLUMNS,
|
|
serializeRunRow,
|
|
} from "./electricStreamProtocol.server";
|
|
import { type RunHydrator, type RunListFilter, type RunListResolver } from "./runReader.server";
|
|
|
|
/**
|
|
* Dual-run shadow-compare: the client is always served the Electric response while this re-derives what
|
|
* the native backend would emit and diffs the two, to prove parity on real traffic before cutover. Checks
|
|
* serialization (semantic per-column compare, gated on same updatedAt so a changed row is "skew", not a
|
|
* divergence) and membership (emitted id-set, only on tag/batch initial snapshots). Pure but for the injected deps.
|
|
*/
|
|
|
|
export type ShadowFeed = "run" | "runs" | "batch";
|
|
|
|
type WireValue = Record<string, string | null>;
|
|
|
|
type ShapeMessage = {
|
|
key?: string;
|
|
value?: WireValue;
|
|
headers: { operation?: string; control?: string };
|
|
};
|
|
|
|
const COLUMN_BY_NAME = new Map(RUN_ELECTRIC_COLUMNS.map((column) => [column.name, column]));
|
|
|
|
export type ColumnDiff = {
|
|
runId: string;
|
|
column: string;
|
|
electric: string | null;
|
|
native: string | null;
|
|
};
|
|
|
|
export type ShadowCompareOutcome = {
|
|
feed: ShadowFeed;
|
|
/** Runs whose every emitted column matched (same-version). */
|
|
serializationMatched: number;
|
|
/** Runs with at least one semantic column divergence (same-version). */
|
|
serializationDiverged: number;
|
|
/** Runs that changed between Electric's emit and our refetch (not a divergence). */
|
|
serializationSkew: number;
|
|
/** Per-column divergences (capped) for logging. */
|
|
diffs: ColumnDiff[];
|
|
/** Set membership (tag/batch initial snapshot only). undefined when not checked. */
|
|
membershipMatch?: boolean;
|
|
missingInNative?: string[];
|
|
extraInNative?: string[];
|
|
};
|
|
|
|
export type ShadowCompareInput = {
|
|
feed: ShadowFeed;
|
|
/** The served Electric response body (a JSON array of messages, or "" / "[]"). */
|
|
electricBody: string;
|
|
environment: { id: string };
|
|
skipColumns: string[];
|
|
/** True when this was an initial snapshot request (offset=-1); enables membership compare. */
|
|
isInitialSnapshot: boolean;
|
|
/** When set (tag/batch initial snapshot), compare the resolved id-set. */
|
|
membershipFilter?: RunListFilter;
|
|
};
|
|
|
|
const MAX_DIFFS = 20;
|
|
|
|
export class RealtimeShadowComparator {
|
|
constructor(
|
|
private readonly options: { runReader: RunHydrator; runListResolver: RunListResolver }
|
|
) {}
|
|
|
|
async compare(input: ShadowCompareInput): Promise<ShadowCompareOutcome> {
|
|
const messages = parseBody(input.electricBody);
|
|
const changes = messages.filter(
|
|
(m): m is ShapeMessage & { value: WireValue } =>
|
|
typeof m.headers?.operation === "string" && !!m.value && m.headers.operation !== "delete"
|
|
);
|
|
|
|
const outcome: ShadowCompareOutcome = {
|
|
feed: input.feed,
|
|
serializationMatched: 0,
|
|
serializationDiverged: 0,
|
|
serializationSkew: 0,
|
|
diffs: [],
|
|
};
|
|
|
|
// Bulk-hydrate every emitted run in one query rather than a per-message round
|
|
// trip, so shadow mode doesn't inflate the very replica load it's measuring.
|
|
const emittedIds = changes
|
|
.map((m) => m.value.id)
|
|
.filter((id): id is string => typeof id === "string");
|
|
const hydrated = await this.options.runReader.hydrateByIds(input.environment.id, emittedIds);
|
|
const rowsById = new Map(hydrated.map((row) => [row.id, row]));
|
|
|
|
for (const message of changes) {
|
|
const runId = message.value.id ?? undefined;
|
|
if (!runId) {
|
|
continue;
|
|
}
|
|
|
|
const row = rowsById.get(runId);
|
|
if (!row) {
|
|
// Run no longer readable (deleted / replica miss). Not a serialization divergence.
|
|
outcome.serializationSkew++;
|
|
continue;
|
|
}
|
|
|
|
const nativeValue = serializeRunRow(row, input.skipColumns);
|
|
|
|
// Only compare rows at the same version; otherwise the row advanced between
|
|
// Electric's emit and our refetch (timing skew, not a divergence).
|
|
if (!sameInstant(message.value.updatedAt, nativeValue.updatedAt)) {
|
|
outcome.serializationSkew++;
|
|
continue;
|
|
}
|
|
|
|
let rowDiverged = false;
|
|
for (const [column, electricRaw] of Object.entries(message.value)) {
|
|
const meta = COLUMN_BY_NAME.get(column);
|
|
if (!meta) {
|
|
continue;
|
|
}
|
|
const nativeRaw = nativeValue[column] ?? null;
|
|
if (!valuesEqual(electricRaw, nativeRaw, meta.type, meta.dims, column)) {
|
|
rowDiverged = true;
|
|
if (outcome.diffs.length < MAX_DIFFS) {
|
|
outcome.diffs.push({ runId, column, electric: electricRaw, native: nativeRaw });
|
|
}
|
|
}
|
|
}
|
|
|
|
if (rowDiverged) {
|
|
outcome.serializationDiverged++;
|
|
} else {
|
|
outcome.serializationMatched++;
|
|
}
|
|
}
|
|
|
|
if (input.isInitialSnapshot && input.membershipFilter) {
|
|
const electricIds = new Set(
|
|
changes.map((m) => m.value.id).filter((id): id is string => typeof id === "string")
|
|
);
|
|
const nativeIds = new Set(
|
|
await this.options.runListResolver.resolveMatchingRunIds(input.membershipFilter)
|
|
);
|
|
|
|
outcome.missingInNative = [...electricIds].filter((id) => !nativeIds.has(id));
|
|
outcome.extraInNative = [...nativeIds].filter((id) => !electricIds.has(id));
|
|
outcome.membershipMatch =
|
|
outcome.missingInNative.length === 0 && outcome.extraInNative.length === 0;
|
|
}
|
|
|
|
return outcome;
|
|
}
|
|
}
|
|
|
|
function parseBody(body: string): ShapeMessage[] {
|
|
const text = body.trim();
|
|
if (!text) {
|
|
return [];
|
|
}
|
|
try {
|
|
const parsed = JSON.parse(text);
|
|
return Array.isArray(parsed) ? (parsed as ShapeMessage[]) : [];
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/** Status carries a known legacy rewrite (DEQUEUED -> EXECUTING) applied equally to
|
|
* both paths for non-current API versions; treat them as equivalent. */
|
|
function normalizeStatus(value: string): string {
|
|
return value === "DEQUEUED" ? "EXECUTING" : value;
|
|
}
|
|
|
|
function sameInstant(a: string | null | undefined, b: string | null | undefined): boolean {
|
|
if (a == null || b == null) {
|
|
return a == null && b == null;
|
|
}
|
|
// Mirror the SDK's RawShapeDate (`new Date(val + "Z")`).
|
|
return new Date(`${a}Z`).getTime() === new Date(`${b}Z`).getTime();
|
|
}
|
|
|
|
function valuesEqual(
|
|
electricRaw: string | null,
|
|
nativeRaw: string | null,
|
|
type: ElectricColumnType,
|
|
dims: number | undefined,
|
|
column: string
|
|
): boolean {
|
|
if (electricRaw == null || nativeRaw == null) {
|
|
return electricRaw == null && nativeRaw == null;
|
|
}
|
|
|
|
if (dims && dims > 0) {
|
|
return arraysEqual(parsePgTextArray(electricRaw), parsePgTextArray(nativeRaw));
|
|
}
|
|
|
|
switch (type) {
|
|
case "timestamp":
|
|
return new Date(`${electricRaw}Z`).getTime() === new Date(`${nativeRaw}Z`).getTime();
|
|
case "bool":
|
|
return parseBool(electricRaw) === parseBool(nativeRaw);
|
|
case "int4":
|
|
case "int8":
|
|
case "float8":
|
|
return Number(electricRaw) === Number(nativeRaw);
|
|
case "jsonb":
|
|
return jsonEqual(electricRaw, nativeRaw);
|
|
case "text":
|
|
default:
|
|
if (column === "status") {
|
|
return normalizeStatus(electricRaw) === normalizeStatus(nativeRaw);
|
|
}
|
|
return electricRaw === nativeRaw;
|
|
}
|
|
}
|
|
|
|
function parseBool(value: string): boolean {
|
|
return value === "t" || value === "true";
|
|
}
|
|
|
|
function jsonEqual(a: string, b: string): boolean {
|
|
try {
|
|
return deepEqual(JSON.parse(a), JSON.parse(b));
|
|
} catch {
|
|
return a === b;
|
|
}
|
|
}
|
|
|
|
function deepEqual(a: unknown, b: unknown): boolean {
|
|
if (a === b) return true;
|
|
if (typeof a !== typeof b || a === null || b === null) return false;
|
|
if (Array.isArray(a) && Array.isArray(b)) {
|
|
return a.length === b.length && a.every((v, i) => deepEqual(v, b[i]));
|
|
}
|
|
if (typeof a === "object" && typeof b === "object") {
|
|
const ak = Object.keys(a as object).sort();
|
|
const bk = Object.keys(b as object).sort();
|
|
return (
|
|
ak.length === bk.length &&
|
|
ak.every((k, i) => k === bk[i]) &&
|
|
ak.every((k) => deepEqual((a as any)[k], (b as any)[k]))
|
|
);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function arraysEqual(a: string[], b: string[]): boolean {
|
|
return a.length === b.length && a.every((v, i) => v === b[i]);
|
|
}
|
|
|
|
/** Parse a Postgres text-array literal (`{"a","b"}` / `{}`). Mirrors the client's pgArrayParser. */
|
|
function parsePgTextArray(literal: string): string[] {
|
|
if (literal === "{}" || literal === "") {
|
|
return [];
|
|
}
|
|
const inner = literal.startsWith("{") && literal.endsWith("}") ? literal.slice(1, -1) : literal;
|
|
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++;
|
|
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;
|
|
}
|