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:
@@ -1,6 +1,6 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { deviceEvents as deviceEventsTable, snapshots, type Db } from "@parking/db";
|
||||
import { registry, type CameraDevice } from "@parking/devices";
|
||||
import { registry, type CameraDevice, type Snapshot } from "@parking/devices";
|
||||
import type { FastifyBaseLogger } from "fastify";
|
||||
import { devicesByDirection, type FlowDirection } from "./device-resolve.js";
|
||||
import type { VisionClient } from "./vision-client.js";
|
||||
@@ -62,7 +62,9 @@ export function snapshotAsync(job: SnapshotJob): Promise<string[]> {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const shot = await camera.captureSnapshot({ direction });
|
||||
// Shared capture: if the ANPR bridge just pulled this camera's frame for the
|
||||
// same vehicle, reuse it instead of a 2nd concurrent GET (which 503s).
|
||||
const shot = await captureSnapshotShared(row.id, camera, { direction });
|
||||
const id: string = randomUUID();
|
||||
db.insert(snapshots)
|
||||
.values({
|
||||
@@ -153,6 +155,70 @@ export function buildCamera(row: { driverId: string; config: unknown }): CameraD
|
||||
}
|
||||
}
|
||||
|
||||
// --- shared snapshot capture (one HTTP pull per camera per vehicle) -----------
|
||||
// A Hikvision camera serves /ISAPI/.../picture SINGLE-THREADED: two concurrent
|
||||
// snapshot GETs to the same unit return HTTP 503 "service busy". On a vehicle entry
|
||||
// TWO paths capture the SAME camera within ~1s — the ANPR bridge (barrier-driving,
|
||||
// anpr-entry.ts) and the advisory snapshotAsync (evidence + telemetry, below). They
|
||||
// each `buildCamera()` a SEPARATE adapter instance, so a per-instance cache can't
|
||||
// dedupe them. This module-level, deviceId-keyed cache does: it coalesces in-flight
|
||||
// captures (the 2nd caller awaits the 1st's pull) AND serves a result captured within
|
||||
// SNAPSHOT_TTL_MS, so the bridge + advisory share ONE frame instead of colliding into
|
||||
// a 503 (which then burned the bridge's 12s debounce → the slow entry observed
|
||||
// 2026-06-25; see wiki/concepts/lane-presence-and-anpr-entry.md).
|
||||
|
||||
/** How long a fresh capture is reused for the same camera. A car is one event for a
|
||||
* couple of seconds; 1.5s comfortably spans the bridge→advisory gap without ever
|
||||
* serving a stale frame for a *different* vehicle (entries are seconds apart). */
|
||||
const SNAPSHOT_TTL_MS = 1500;
|
||||
|
||||
interface CacheEntry {
|
||||
/** A capture in flight — concurrent callers await this instead of issuing a 2nd GET. */
|
||||
inflight?: Promise<Snapshot>;
|
||||
/** The last SUCCESSFUL capture + when it resolved, for the freshness window. */
|
||||
last?: { shot: Snapshot; at: number };
|
||||
}
|
||||
|
||||
const snapshotCache = new Map<string, CacheEntry>();
|
||||
|
||||
/**
|
||||
* Capture a snapshot for a camera, sharing ONE HTTP pull across concurrent/near-
|
||||
* simultaneous callers (the ANPR bridge and the advisory snapshot). Same contract as
|
||||
* `camera.captureSnapshot` (throws on failure) — a failed pull is NOT cached, so the
|
||||
* next caller retries rather than inheriting the error. Key by the stable `deviceId`.
|
||||
*/
|
||||
export function captureSnapshotShared(
|
||||
deviceId: string,
|
||||
camera: CameraDevice,
|
||||
ctx: { direction: FlowDirection },
|
||||
): Promise<Snapshot> {
|
||||
const now = Date.now();
|
||||
let entry = snapshotCache.get(deviceId);
|
||||
if (!entry) {
|
||||
entry = {};
|
||||
snapshotCache.set(deviceId, entry);
|
||||
}
|
||||
// Fresh enough → reuse the last frame (same vehicle, no second hardware hit).
|
||||
if (entry.last && now - entry.last.at < SNAPSHOT_TTL_MS) {
|
||||
return Promise.resolve(entry.last.shot);
|
||||
}
|
||||
// A capture is already running → join it (this is what prevents the 503 collision).
|
||||
if (entry.inflight) return entry.inflight;
|
||||
// Otherwise issue the single real pull; record it as the in-flight promise.
|
||||
const pull = camera
|
||||
.captureSnapshot(ctx)
|
||||
.then((shot) => {
|
||||
entry.last = { shot, at: Date.now() };
|
||||
return shot;
|
||||
})
|
||||
.finally(() => {
|
||||
// Clear the in-flight slot whether it resolved or threw; a failure is never cached.
|
||||
if (entry.inflight === pull) entry.inflight = undefined;
|
||||
});
|
||||
entry.inflight = pull;
|
||||
return pull;
|
||||
}
|
||||
|
||||
function recordFailure(
|
||||
db: Db,
|
||||
direction: FlowDirection,
|
||||
|
||||
Reference in New Issue
Block a user