fix(anpr): sliding poll window so a car arriving mid-loop isn't lost

A loop started by a far/early car would (a) give up before the REAL car settled at
the barrier, and (b) swallow the real car's pushes (the #polling guard dropped them).
So a confident-but-wrong far-car plate could win, or the intended car get debounced
out after the loop ended — wrong car acted on, right car blocked.

Fix: a push that JOINS a running loop now EXTENDS the deadline (lastPush +
ANPR_POLL_WINDOW_MS) instead of being dropped, capped at start + ANPR_POLL_MAX_MS
(30s) so a continuously-busy lane can't slide forever. Each tick still pulls a FRESH
frame, so the loop tracks whoever is at the barrier NOW, not the car that started it.
Per-camera sliding deadline in #pollDeadline (cleared with #polling in finally).

+1 test (push mid-poll keeps the loop alive past the initial deadline); 171 server
tests green. New knob ANPR_POLL_MAX_MS documented in the komodo env reference + the
two concurrency guards written up in lane-presence-and-anpr-entry.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
2026-06-27 23:30:41 +02:00
parent a888125eca
commit c2a861208f
4 changed files with 92 additions and 11 deletions
+37 -8
View File
@@ -59,13 +59,22 @@ function pollMs(): number {
return Number.isFinite(raw) && raw > 0 ? raw : 1000;
}
/** Total time to keep polling for a confident read before giving up (the car drove off, or
* it's a non-subscriber). Bounded so a stray car can't loop forever. */
/** How long to keep polling AFTER THE LAST vehicle push before giving up. SLIDING: each new
* push for the camera extends the deadline by this much from now — so a loop started by a
* far/early car keeps pulling fresh frames as the REAL car arrives and settles at the
* barrier (the loop tracks "whoever is here now", not the car that started it). */
function pollWindowMs(): number {
const raw = Number(process.env.ANPR_POLL_WINDOW_MS ?? 8000);
return Number.isFinite(raw) && raw > 0 ? raw : 8000;
}
/** Hard ceiling on a single loop from its START, so a continuously-busy lane (pushes never
* stop) can't slide the window forever. The loop ends at min(lastPush + window, start + max). */
function pollMaxMs(): number {
const raw = Number(process.env.ANPR_POLL_MAX_MS ?? 30_000);
return Number.isFinite(raw) && raw > 0 ? raw : 30_000;
}
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
/** A plate DeviceReadEvent skeleton (value filled by the caller) — for matching the
@@ -83,12 +92,17 @@ export class AnprBridge {
readonly #debounceMs: number;
readonly #pollMs: number;
readonly #pollWindowMs: number;
readonly #pollMaxMs: number;
/** Last-fire timestamps, keyed by deviceId (camera-level, pre-snapshot) AND by
* `deviceId:plate` (post-match) — both gated against #debounceMs. */
readonly #lastFire = new Map<string, number>();
/** Cameras with a poll loop already in flight — a re-fired alarm (the camera pushes ~1Hz
* while the car sits) must NOT start a second concurrent loop on the same camera. */
readonly #polling = new Set<string>();
/** Per-camera SLIDING deadline for the running poll loop. A push that joins a running loop
* bumps this forward (lastPush + window, capped at start + max), so the loop keeps pulling
* fresh frames while cars keep arriving — tracking whoever settles at the barrier. */
readonly #pollDeadline = new Map<string, number>();
constructor(db: Db, vision: VisionClient | null, subscription: SubscriptionFlow, logger: FastifyBaseLogger) {
this.#db = db;
@@ -99,6 +113,7 @@ export class AnprBridge {
this.#debounceMs = debounceMs();
this.#pollMs = pollMs();
this.#pollWindowMs = pollWindowMs();
this.#pollMaxMs = pollMaxMs();
}
/**
@@ -122,10 +137,19 @@ export class AnprBridge {
// ~1Hz re-fires for #debounceMs (set on success below). A fresh alarm AFTER the
// window is a new presentation and may start a new poll loop.
if (this.#debounced(deviceId)) return;
// One poll loop per camera: the camera pushes the SAME alarm ~1Hz while the car
// sits at the barrier — those re-fires must JOIN the running loop, not spawn N of them.
if (this.#polling.has(deviceId)) return;
// One poll loop per camera. A push that arrives while a loop runs JOINs it — and
// SLIDES the deadline forward (a different car arriving mid-loop keeps the loop alive
// so it tracks whoever's at the barrier now, instead of giving up on the early car).
const now = Date.now();
if (this.#polling.has(deviceId)) {
const cur = this.#pollDeadline.get(deviceId) ?? now;
// Slide to lastPush + window, but never past the per-loop hard ceiling (set at start).
this.#pollDeadline.set(deviceId, Math.max(cur, now + this.#pollWindowMs));
return;
}
this.#polling.add(deviceId);
// Initial deadline; the hard ceiling (start + max) is enforced in the loop below.
this.#pollDeadline.set(deviceId, now + this.#pollWindowMs);
const camera = buildCamera(row);
if (!camera) {
@@ -153,10 +177,12 @@ export class AnprBridge {
let result: Awaited<ReturnType<VisionClient["analyze"]>> = null;
let watchedSubId: string | null = null;
let baselineOpen = 0;
const deadline = Date.now() + this.#pollWindowMs;
// Hard ceiling for THIS loop (start + max); the sliding deadline (bumped by joining
// pushes) is read from #pollDeadline each tick but never allowed past this cap.
const hardCap = Date.now() + this.#pollMaxMs;
let attempts = 0;
try {
while (Date.now() < deadline) {
while (Date.now() < Math.min(this.#pollDeadline.get(deviceId) ?? 0, hardCap)) {
attempts++;
const shot = await camera.captureSnapshot({ direction });
const r = await this.#vision.analyze(shot.bytes, shot.contentType);
@@ -189,11 +215,14 @@ export class AnprBridge {
`${this.#entryMinConfidence} — re-pulling (attempt ${attempts})`,
);
}
if (Date.now() + this.#pollMs >= deadline) break;
// Stop if the next tick would land past the (possibly slid) deadline or the cap.
const effDeadline = Math.min(this.#pollDeadline.get(deviceId) ?? 0, hardCap);
if (Date.now() + this.#pollMs >= effDeadline) break;
await sleep(this.#pollMs);
}
} finally {
this.#polling.delete(deviceId);
this.#pollDeadline.delete(deviceId);
}
if (!result || !result.plate) {