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:
@@ -43,6 +43,7 @@ afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
delete process.env.ANPR_POLL_MS;
|
||||
delete process.env.ANPR_POLL_WINDOW_MS;
|
||||
delete process.env.ANPR_POLL_MAX_MS;
|
||||
});
|
||||
|
||||
/** A camera bound to an entry relay; `anpr` toggles the opt-in flag. */
|
||||
@@ -173,6 +174,42 @@ describe("AnprBridge", () => {
|
||||
expect(captureSnapshot.mock.calls.length).toBeGreaterThanOrEqual(3); // re-pulled fresh frames
|
||||
});
|
||||
|
||||
it("SLIDES the window: a push mid-poll keeps the loop alive past the initial deadline", async () => {
|
||||
// A loop started by an early/far car would expire — but a NEW push (another car arriving)
|
||||
// extends the deadline, so the loop keeps polling and reads the car that settles at the
|
||||
// barrier. Here: a SHORT base window, vision stays low until attempt 5; a second push at
|
||||
// the start bumps the deadline so attempt 5's confident read still lands.
|
||||
const cam = seedCamera({ anpr: true });
|
||||
const confs = [0.2, 0.2, 0.2, 0.2, 0.97];
|
||||
let i = 0;
|
||||
const vision = {
|
||||
enabled: true,
|
||||
analyze: vi.fn(async () => ({
|
||||
plate: { text: "AA111BB", confidence: confs[Math.min(i++, confs.length - 1)] },
|
||||
plates: [],
|
||||
lowConfidence: false,
|
||||
modelVersion: "test",
|
||||
tookMs: 1,
|
||||
})),
|
||||
} as unknown as VisionClient;
|
||||
process.env.ANPR_POLL_MS = "5";
|
||||
process.env.ANPR_POLL_WINDOW_MS = "12"; // tiny — would expire ~attempt 2 WITHOUT a slide
|
||||
process.env.ANPR_POLL_MAX_MS = "5000"; // ceiling far above, so the slide is what matters
|
||||
const bridge = new AnprBridge(db, vision, fakeSubFlow(SUB_MATCH), silentLogger());
|
||||
|
||||
const reads = await captureReads(async () => {
|
||||
const loop = bridge.onVehicleDetected(cam); // starts the loop
|
||||
// Joining pushes keep sliding the deadline forward so the slow-to-confident read lands.
|
||||
for (let k = 0; k < 5; k++) {
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
void bridge.onVehicleDetected(cam); // each bumps the deadline (loop already running)
|
||||
}
|
||||
await loop;
|
||||
});
|
||||
expect(reads).toHaveLength(1);
|
||||
expect(reads[0]).toMatchObject({ value: "AA111BB" });
|
||||
});
|
||||
|
||||
it("ABORTS if the subscriber transacts by another credential mid-poll (no double-act)", async () => {
|
||||
// The car's plate is read (identity known) but stays below the floor; meanwhile the
|
||||
// subscriber scans their card → openOccurrenceCount drops. The bridge must abort and NOT
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user