fix(anpr): share one camera snapshot across bridge + advisory paths
On a vehicle entry, two paths captured the SAME Hikvision camera within ~1s — the ANPR bridge (barrier-driving) and the advisory snapshotAsync (evidence/ telemetry) — each from a separate adapter instance. Hikvision serves snapshots single-threaded, so the second concurrent GET returned HTTP 503; the bridge then fail-softed and burned its 12s debounce, producing a ~74s "slow" subscriber entry (observed 2026-06-25, Qazim Mulleti / AB816NN — plate read was instant at conf 1.000; the delay was the 503/debounce churn, not recognition). Add captureSnapshotShared() in snapshot.ts: a module-level, deviceId-keyed cache that both paths call. It coalesces in-flight captures (the 2nd caller awaits the 1st's pull → no concurrent 503), serves a brief freshness window (1500ms) so the bridge→advisory sequence for one vehicle reuses one frame, never caches a failure (next caller retries), and keys by deviceId (no cross-camera/stale-vehicle reuse). Wired into anpr-entry.ts (bridge) and snapshot.ts (advisory). Tests: snapshot.test.ts (concurrent coalescing, TTL reuse, TTL-lapse re-pull, failure-not-cached, per-camera keying); anpr-entry.test.ts mock updated. 168 server tests green. NOTE: this removes the latency (the 503 collision). The separate double-entry (two signed vehicle_entry for one car) — debounce-too-short / stamp-before- success — is still open; less likely now but not eliminated. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { CameraDevice, Snapshot } from "@parking/devices";
|
||||
import { captureSnapshotShared } from "./snapshot.js";
|
||||
|
||||
// captureSnapshotShared: one HTTP pull per camera per vehicle. A Hikvision unit serves
|
||||
// snapshots SINGLE-THREADED (a 2nd concurrent GET → HTTP 503). On an entry the ANPR
|
||||
// bridge AND the advisory snapshotAsync both capture the same camera within ~1s, each
|
||||
// from a SEPARATE adapter instance — so this deviceId-keyed cache coalesces in-flight
|
||||
// captures and serves a brief freshness window, collapsing the two into one real pull.
|
||||
// (Root cause of the slow 2026-06-25 subscriber entry.)
|
||||
|
||||
/** A fake camera whose captureSnapshot is controllable (count calls, delay, fail). */
|
||||
function fakeCamera(opts: { delayMs?: number; fail?: boolean; tag?: string } = {}): {
|
||||
camera: CameraDevice;
|
||||
calls: () => number;
|
||||
} {
|
||||
let calls = 0;
|
||||
const tag = opts.tag ?? "x";
|
||||
const camera = {
|
||||
async captureSnapshot(): Promise<Snapshot> {
|
||||
calls++;
|
||||
if (opts.delayMs) await new Promise((r) => setTimeout(r, opts.delayMs));
|
||||
if (opts.fail) throw new Error("HTTP 503");
|
||||
// Tag distinguishes frames from different cameras (the per-camera keying test).
|
||||
return { bytes: Buffer.from(`shot-${tag}-${calls}`), contentType: "image/jpeg", capturedAt: new Date().toISOString() };
|
||||
},
|
||||
} as unknown as CameraDevice;
|
||||
return { camera, calls: () => calls };
|
||||
}
|
||||
|
||||
/** A unique deviceId per test so the module-level cache never bleeds across cases. */
|
||||
function id(): string {
|
||||
return `cam-${Math.random().toString(36).slice(2)}`;
|
||||
}
|
||||
|
||||
describe("captureSnapshotShared", () => {
|
||||
it("coalesces CONCURRENT captures into a single hardware pull (the 503 fix)", async () => {
|
||||
const { camera, calls } = fakeCamera({ delayMs: 20 });
|
||||
const dev = id();
|
||||
// The bridge and the advisory path fire at nearly the same instant.
|
||||
const [a, b] = await Promise.all([
|
||||
captureSnapshotShared(dev, camera, { direction: "entry" }),
|
||||
captureSnapshotShared(dev, camera, { direction: "entry" }),
|
||||
]);
|
||||
expect(calls()).toBe(1); // ONE GET, not two — no concurrent 503
|
||||
expect(a.bytes.equals(b.bytes)).toBe(true); // both got the same frame
|
||||
});
|
||||
|
||||
it("reuses a fresh capture within the TTL (sequential, same vehicle)", async () => {
|
||||
const { camera, calls } = fakeCamera();
|
||||
const dev = id();
|
||||
const a = await captureSnapshotShared(dev, camera, { direction: "entry" });
|
||||
const b = await captureSnapshotShared(dev, camera, { direction: "entry" }); // ~0ms later
|
||||
expect(calls()).toBe(1); // 2nd call served from the freshness cache
|
||||
expect(a.bytes.equals(b.bytes)).toBe(true);
|
||||
});
|
||||
|
||||
it("pulls AGAIN after the TTL lapses (a later, different vehicle)", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const { camera, calls } = fakeCamera();
|
||||
const dev = id();
|
||||
await captureSnapshotShared(dev, camera, { direction: "entry" });
|
||||
expect(calls()).toBe(1);
|
||||
await vi.advanceTimersByTimeAsync(2000); // past SNAPSHOT_TTL_MS (1500)
|
||||
await captureSnapshotShared(dev, camera, { direction: "entry" });
|
||||
expect(calls()).toBe(2); // stale → a real new pull (never a stale frame for a new car)
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("does NOT cache a failure — the next caller retries", async () => {
|
||||
const dev = id();
|
||||
const failing = fakeCamera({ fail: true });
|
||||
await expect(captureSnapshotShared(dev, failing.camera, { direction: "entry" })).rejects.toThrow("503");
|
||||
// A subsequent capture (camera recovered) must actually pull, not inherit the error.
|
||||
const ok = fakeCamera();
|
||||
const shot = await captureSnapshotShared(dev, ok.camera, { direction: "entry" });
|
||||
expect(shot.bytes.toString()).toBe("shot-x-1");
|
||||
expect(ok.calls()).toBe(1);
|
||||
});
|
||||
|
||||
it("keys by deviceId — different cameras never share a frame", async () => {
|
||||
const c1 = fakeCamera({ tag: "A" });
|
||||
const c2 = fakeCamera({ tag: "B" });
|
||||
const s1 = await captureSnapshotShared("cam-A", c1.camera, { direction: "entry" });
|
||||
const s2 = await captureSnapshotShared("cam-B", c2.camera, { direction: "entry" });
|
||||
expect(c1.calls()).toBe(1);
|
||||
expect(c2.calls()).toBe(1);
|
||||
expect(s1.bytes.equals(s2.bytes)).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user