refactor(telemetry): remove push-then-pull round-trip and batch the sync broadcast (#14) #52

Merged
gabogg merged 4 commits from refactor/telemetry-streaming-deepening into master 2026-09-23 22:19:43 +00:00
Owner

Closes #14.

🔒 Blocked: waits for #53 to merge. Can proceed in parallel with #54 once #53 lands.

This description replaces the original draft scope following the grilling session of 2026-09-22. The original body treated #14 as a live three-candidate menu. It isn't: Candidates 3 and 4 shipped on 2026-09-10, and Candidate 2's stated friction is factually wrong about the code. See the correction on #14.


What is actually left

Issue #14's Candidate 2 claims "the current backend WebSocket broadcast in monitor_service.py drops individual directional passage vector events and emits coarse aggregates." That is false at master:

  • occupancy_service.py:528-552 builds a complete directional event — camera_index_code, camera_name, direction, count, timestamp_epoch, timestamp_formatted — and broadcasts it as recent_event.
  • PassengerFlowEvent (app/schemas/occupancy_models.py:234) already is the proposed PassageFluxVector shape, and ships in bulk via OccupancyLiveResponse.recent_passages (:285).
  • The client already models it: TelemetryEngine.ingestMessage absorbs recent_event into _recentFluxVectors (telemetry_engine.js:564-572, :605-615) with no follow-up fetch.

The candidate's second friction point is real, and it has a server-side twin. That pair is this PR.

1. Kill the client's push-then-pull round-trip

app/static/js/app.js:522-524:

} else if (data.type === 'occupancy_update') {
    if (data.live) updateOccupancyLiveCard(data.live);
    refreshOccupancyData();

refreshOccupancyData() issues GET /api/occupancy/overview?preset=... (app.js:1590) on every occupancy_update. The live card and flux stream do not need it — the broadcast carries live and the new events.

But the fetch cannot simply be deleted. It also redraws the selected-timespan dashboard (renderOccupancyDashboard(data): camera table and window totals for currentOccupancyTimespan), which the broadcast does not carry and cannot carry cheaply (it depends on each client's selected preset).

Settled (2026-09-23): throttle it. The live card and flux stream are driven purely by the push. The overview fetch becomes a trailing throttle — at most one per 15 s window, only while the occupancy view is visible. Window aggregates do not need 3-second freshness; a burst still shows within seconds.

2. One recomputation and one broadcast per batch

record_counting_event_async recomputes get_live_occupancy_async() and broadcasts per event (occupancy_service.py:543-552). It has several callers — the sync tick, the simulator (occupancy_controller.py:374/386), the manual endpoint (:341). (The webhook caller is deleted by #53: it was misreading door alarms as passages.)

Settled (2026-09-23): batch ingestion at the interface. Add record_counting_events_async(events: list): persist all, recompute live once, broadcast once with recent_events: [...]. The single-event method becomes a batch of one. Every caller hands over what it has (the sync tick's events, the simulator's loop). Rejected: a broadcast=False flag (shallow — callers must remember an ordering rule) and timer-based coalescing (hidden state, timing-sensitive tests).

The payload key changes from recent_event to recent_events; update TelemetryEngine.ingestMessage (telemetry_engine.js:564-572, :605-615) accordingly.


Sequencing

Lands after #53. #53 edits the same sync loop (occupancy_service.py:1814-1888) and deletes the webhook counting block. Under the revised plan the full group-join cutover is deferred to #62, so the even split stays — with every camera group holding exactly one camera (ADR 0005) it produces one event per group per tick. #54 no longer touches the sync loop beyond #53, so #52 and #54 can proceed in parallel after #53.

No longer blocked by #26 or #31. This scope publishes nothing new — it throttles a redundant fetch and batches an existing broadcast. It reads no count, timestamp or camera code.


Out of scope — spun out

  • Broadcasting door_hardware_state_transitions. The table exists (database.py:276) and is written at door_repository.py:486/527/577/596, but never broadcast. That is a new capability, not a deepening.
  • requestAnimationFrame batching in CommandDeckAdapter.render(). render() writes innerHTML synchronously. Deferred on cost: all 22 tests in tests/frontend/test_command_deck_adapter.test.js call private renderers directly and assert on raw innerHTML substrings, so nearly every assertion would need rewriting — against a file that took +198 lines in 7ba208d and further changes in e7bf76b, bbc435a and 3368f4f, all on 2026-09-21.
  • Vendoring scripts/tailwindcss. Candidate 4 shipped, but the binary is gitignored (.gitignore:9), so a fresh clone cannot rebuild tactical-bundle.min.css.

Acceptance criteria

  • occupancy_update no longer triggers an overview fetch per message; at most one GET /api/occupancy/overview per 15 s window, trailing, only while the occupancy view is visible.
  • Live card and flux stream update from the broadcast payload alone.
  • record_counting_events_async(list) exists; one sync tick → one live recomputation and one occupancy_update broadcast carrying recent_events.
  • Simulator and manual endpoint go through the batch interface.
  • TelemetryEngine consumes recent_events.
  • No regression to the flux stream, live card, or analytics panels; frontend tests stay green.
  • Full suite green (pytest + node --test).

🤖 Generated with Claude Code


Deliberate additions beyond the settled spec

Recorded so they are not mistaken for scope creep (review round 1):

  • Browser-tab visibility. The throttle also skips while the browser tab is hidden, and catches up on visibilitychange. Fetching an overview nobody can see was the same waste the spec targets.
  • Occupancy admin deck. The throttle runs while either the command deck (content-doors) or the occupancy admin deck (content-occupancy-admin) is on screen, because renderOccupancyDashboard fills both.
  • CountingEventInput schema. The typed input to the batch interface.
  • PASS direction removed from POST /api/occupancy/event and its i18n messages. CountingEventInput.direction is now Literal["IN", "OUT"], and PASS rows were never counted by any aggregate. This takes one item off #64.
  • Refresh feedback loop fixed. TelemetryEngine.ingestMessage() no longer echoes locally sourced data to onRawMessage. This was already an unbounded fetch loop on master.
  • Flux de-duplication now keys on the event id, falling back to camera, time, direction and count, so an IN and an OUT from one camera in the same tick are both kept.
  • Whitespace. Pre-commit trimmed trailing spaces in three existing app.js button-label template literals (no rendered change).
Closes #14. **🔒 Blocked: waits for #53 to merge.** Can proceed in parallel with #54 once #53 lands. This description replaces the original draft scope following the grilling session of 2026-09-22. The original body treated #14 as a live three-candidate menu. It isn't: **Candidates 3 and 4 shipped on 2026-09-10**, and Candidate 2's stated friction is factually wrong about the code. See the correction on #14. --- ## What is actually left Issue #14's Candidate 2 claims *"the current backend WebSocket broadcast in `monitor_service.py` drops individual directional passage vector events and emits coarse aggregates."* That is **false** at `master`: - `occupancy_service.py:528-552` builds a complete directional event — `camera_index_code`, `camera_name`, `direction`, `count`, `timestamp_epoch`, `timestamp_formatted` — and broadcasts it as `recent_event`. - `PassengerFlowEvent` (`app/schemas/occupancy_models.py:234`) already **is** the proposed `PassageFluxVector` shape, and ships in bulk via `OccupancyLiveResponse.recent_passages` (`:285`). - The client already models it: `TelemetryEngine.ingestMessage` absorbs `recent_event` into `_recentFluxVectors` (`telemetry_engine.js:564-572`, `:605-615`) with no follow-up fetch. The candidate's **second** friction point is real, and it has a server-side twin. That pair is this PR. ### 1. Kill the client's push-then-pull round-trip `app/static/js/app.js:522-524`: ```js } else if (data.type === 'occupancy_update') { if (data.live) updateOccupancyLiveCard(data.live); refreshOccupancyData(); ``` `refreshOccupancyData()` issues `GET /api/occupancy/overview?preset=...` (`app.js:1590`) on **every** `occupancy_update`. The live card and flux stream do not need it — the broadcast carries `live` and the new events. **But the fetch cannot simply be deleted.** It also redraws the *selected-timespan* dashboard (`renderOccupancyDashboard(data)`: camera table and window totals for `currentOccupancyTimespan`), which the broadcast does not carry and cannot carry cheaply (it depends on each client's selected preset). **Settled (2026-09-23): throttle it.** The live card and flux stream are driven purely by the push. The overview fetch becomes a **trailing throttle — at most one per 15 s window, only while the occupancy view is visible**. Window aggregates do not need 3-second freshness; a burst still shows within seconds. ### 2. One recomputation and one broadcast per batch `record_counting_event_async` recomputes `get_live_occupancy_async()` and broadcasts **per event** (`occupancy_service.py:543-552`). It has several callers — the sync tick, the simulator (`occupancy_controller.py:374/386`), the manual endpoint (`:341`). (The webhook caller is deleted by #53: it was misreading door alarms as passages.) **Settled (2026-09-23): batch ingestion at the interface.** Add `record_counting_events_async(events: list)`: persist all, recompute live once, broadcast once with `recent_events: [...]`. The single-event method becomes a batch of one. Every caller hands over what it has (the sync tick's events, the simulator's loop). Rejected: a `broadcast=False` flag (shallow — callers must remember an ordering rule) and timer-based coalescing (hidden state, timing-sensitive tests). The payload key changes from `recent_event` to `recent_events`; update `TelemetryEngine.ingestMessage` (`telemetry_engine.js:564-572`, `:605-615`) accordingly. --- ## Sequencing **Lands after #53.** #53 edits the same sync loop (`occupancy_service.py:1814-1888`) and deletes the webhook counting block. Under the revised plan the full group-join cutover is deferred to #62, so the even split stays — with every camera group holding exactly one camera (ADR 0005) it produces one event per group per tick. #54 no longer touches the sync loop beyond #53, so #52 and #54 can proceed in parallel after #53. **No longer blocked by #26 or #31.** This scope publishes nothing new — it throttles a redundant fetch and batches an existing broadcast. It reads no count, timestamp or camera code. --- ## Out of scope — spun out - **Broadcasting `door_hardware_state_transitions`.** The table exists (`database.py:276`) and is written at `door_repository.py:486/527/577/596`, but never broadcast. That is a new capability, not a deepening. - **`requestAnimationFrame` batching in `CommandDeckAdapter.render()`.** `render()` writes `innerHTML` synchronously. Deferred on cost: all 22 tests in `tests/frontend/test_command_deck_adapter.test.js` call private renderers directly and assert on raw `innerHTML` substrings, so nearly every assertion would need rewriting — against a file that took +198 lines in `7ba208d` and further changes in `e7bf76b`, `bbc435a` and `3368f4f`, all on 2026-09-21. - **Vendoring `scripts/tailwindcss`.** Candidate 4 shipped, but the binary is gitignored (`.gitignore:9`), so a fresh clone cannot rebuild `tactical-bundle.min.css`. --- ## Acceptance criteria - [x] `occupancy_update` no longer triggers an overview fetch per message; at most one `GET /api/occupancy/overview` per 15 s window, trailing, only while the occupancy view is visible. - [x] Live card and flux stream update from the broadcast payload alone. - [x] `record_counting_events_async(list)` exists; one sync tick → one live recomputation and one `occupancy_update` broadcast carrying `recent_events`. - [x] Simulator and manual endpoint go through the batch interface. - [x] `TelemetryEngine` consumes `recent_events`. - [x] No regression to the flux stream, live card, or analytics panels; frontend tests stay green. - [x] Full suite green (pytest + `node --test`). 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- ## Deliberate additions beyond the settled spec Recorded so they are not mistaken for scope creep (review round 1): - **Browser-tab visibility.** The throttle also skips while the browser tab is hidden, and catches up on `visibilitychange`. Fetching an overview nobody can see was the same waste the spec targets. - **Occupancy admin deck.** The throttle runs while either the command deck (`content-doors`) or the occupancy admin deck (`content-occupancy-admin`) is on screen, because `renderOccupancyDashboard` fills both. - **`CountingEventInput` schema.** The typed input to the batch interface. - **`PASS` direction removed** from `POST /api/occupancy/event` and its i18n messages. `CountingEventInput.direction` is now `Literal["IN", "OUT"]`, and `PASS` rows were never counted by any aggregate. This takes one item off #64. - **Refresh feedback loop fixed.** `TelemetryEngine.ingestMessage()` no longer echoes locally sourced data to `onRawMessage`. This was already an unbounded fetch loop on `master`. - **Flux de-duplication** now keys on the event id, falling back to camera, time, direction and count, so an IN and an OUT from one camera in the same tick are both kept. - **Whitespace.** Pre-commit trimmed trailing spaces in three existing `app.js` button-label template literals (no rendered change).
chore(wip): open draft for telemetry-streaming architecture deepening (#14)
Some checks failed
CI / lint-and-test (pull_request) Has been cancelled
ebf32a8ede
WIP scaffold. See PR description for scope and the open design questions;
implementation follows a design pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
gabogg changed title from WIP: refactor(architecture): deepen telemetry streaming, command deck, and CSS tooling (#14) to WIP: refactor(telemetry): remove push-then-pull round-trip and batch the sync broadcast (#14) 2026-09-22 16:31:04 +00:00
Author
Owner

🔒 Blocked — waiting for #53 to merge.

Settled in the grilling session of 2026-09-22/23: merge order is #53 → then #52 and #54 in parallel. #53 rewrites the sync loop (occupancy_service.py:1814-1888) and deletes the webhook counting block, both of which this PR depends on. Do not start implementation until #53 is merged; then merge master into this branch first.

Description updated to the settled scope. The deferred full group-join cutover is #62.

🔒 **Blocked — waiting for #53 to merge.** Settled in the grilling session of 2026-09-22/23: merge order is **#53 → then #52 and #54 in parallel**. #53 rewrites the sync loop (`occupancy_service.py:1814-1888`) and deletes the webhook counting block, both of which this PR depends on. Do not start implementation until #53 is merged; then merge `master` into this branch first. Description updated to the settled scope. The deferred full group-join cutover is #62.
Author
Owner

🔓 Unblocked — #53 merged (1ed0f45, 2026-09-23).

The sync loop rewrite and the webhook counting-block deletion this PR depended on are now on master. Merging master into this branch and starting implementation. Draft status removed at the maintainer's request.

Note for the implementation: after #53, record_counting_event_async raises UnregisteredCameraError for unknown cameras, and the webhook is no longer a caller, so the batch interface only has three callers to migrate: the sync tick, the simulator and the manual event endpoint (#64 may delete the last one).

🔓 **Unblocked — #53 merged** (`1ed0f45`, 2026-09-23). The sync loop rewrite and the webhook counting-block deletion this PR depended on are now on `master`. Merging `master` into this branch and starting implementation. Draft status removed at the maintainer's request. Note for the implementation: after #53, `record_counting_event_async` raises `UnregisteredCameraError` for unknown cameras, and the webhook is no longer a caller, so the batch interface only has three callers to migrate: the sync tick, the simulator and the manual event endpoint (#64 may delete the last one).
gabogg changed title from WIP: refactor(telemetry): remove push-then-pull round-trip and batch the sync broadcast (#14) to refactor(telemetry): remove push-then-pull round-trip and batch the sync broadcast (#14) 2026-09-23 18:05:21 +00:00
Merge remote-tracking branch 'origin/master' into wt52
All checks were successful
CI / lint-and-test (pull_request) Successful in 1m18s
44fe214641
refactor(telemetry): one broadcast per sync tick, no push-then-pull storm (#14)
All checks were successful
CI / lint-and-test (pull_request) Successful in 1m20s
35783c3c50
Backend:
- record_counting_events_async(list[CountingEventInput]) persists a batch,
  recomputes live occupancy once and broadcasts one occupancy_update with
  `recent_events` (oldest first, capped at the client's 50-entry flux
  buffer). Cameras are validated before anything is written, so a batch
  with an unregistered camera records nothing.
- record_counting_event_async is now a batch of one.
- The passenger flow sync collects a tick's deltas and ingests them in one
  batch. Before, a tick sent one broadcast and one live recomputation per
  event, plus one more at the end.
- The admin simulator ingests its whole run as one batch.

Frontend:
- app.js no longer calls refreshOccupancyData() on every occupancy_update.
  The live card and flux stream come from the push; the selected-timespan
  panels refresh through a trailing throttle (at most one overview fetch
  per 15 s, only while the doors deck and the browser tab are visible,
  caught up on return).
- createTrailingThrottle added to src/utils.js with injectable clock.
- TelemetryEngine ingests `recent_events` oldest-first so the newest ends
  up on top; duplicates are still dropped.
- Pre-commit trimmed pre-existing trailing spaces in three app.js template
  literals (button labels; no rendered change).

Closes #14

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Author
Owner

Implemented — 35783c3

Backend

  • New record_counting_events_async(list[CountingEventInput]): persists the batch, recomputes live occupancy once, broadcasts one occupancy_update with recent_events (oldest first, capped at 50 = the client's flux buffer). Cameras are validated before any write, so a batch naming an unregistered camera records nothing.
  • record_counting_event_async is now a batch of one (the manual endpoint keeps its signature).
  • Sync tick: collects its deltas and ingests one batch. It was worse than the description said: a tick sent one broadcast and one live recomputation per event, plus one more at the end (N+1).
  • Simulator: one batch for the whole run.

Frontend

  • app.js no longer fetches /api/occupancy/overview on every push. The live card and flux stream come from the broadcast; the selected-timespan panels refresh through a trailing throttle: at most one fetch per 15 s, only while the doors deck and the browser tab are visible, caught up on visibilitychange.
  • createTrailingThrottle added to src/utils.js (injectable clock and timer, so it's unit-tested); app.js creates it lazily because app.js loads before the module bootstrap.
  • TelemetryEngine reads recent_events, ingesting oldest-first so the newest ends up on top; duplicates still dropped.

Tests

  • tests/test_batched_occupancy_broadcast.py (7): one recompute and one broadcast per batch; empty batch is silent; unregistered camera → nothing written; payload cap keeps the newest; single event = batch of one; a sync tick with 8 deltas → 1 broadcast, 1 recompute; simulator → 1 broadcast. Uses the real ws_manager.broadcast with a recording client.
  • tests/frontend/test_trailing_throttle.test.js (4) and one new TelemetryEngine test for batched ingestion order and dedup.

Verification: pytest 236 passed / 1 skipped, node --test 64/64, ruff clean, pre-commit hooks passed. The whitespace hook also trimmed trailing spaces in three existing app.js button-label template literals (no rendered change).

Not covered by automated tests: the app.js wiring itself (it is DOM glue with no test harness). Worth a quick manual check on the dashboard: the live card updates every tick, the camera/timespan panels update at most every ~15 s, and returning to a background tab refreshes them.

## Implemented — `35783c3` **Backend** - New `record_counting_events_async(list[CountingEventInput])`: persists the batch, recomputes live occupancy **once**, broadcasts **one** `occupancy_update` with `recent_events` (oldest first, capped at 50 = the client's flux buffer). Cameras are validated **before** any write, so a batch naming an unregistered camera records nothing. - `record_counting_event_async` is now a batch of one (the manual endpoint keeps its signature). - **Sync tick:** collects its deltas and ingests one batch. It was worse than the description said: a tick sent one broadcast and one live recomputation **per event, plus one more at the end** (N+1). - **Simulator:** one batch for the whole run. **Frontend** - `app.js` no longer fetches `/api/occupancy/overview` on every push. The live card and flux stream come from the broadcast; the selected-timespan panels refresh through a **trailing throttle**: at most one fetch per 15 s, only while the doors deck **and** the browser tab are visible, caught up on `visibilitychange`. - `createTrailingThrottle` added to `src/utils.js` (injectable clock and timer, so it's unit-tested); `app.js` creates it lazily because `app.js` loads before the module bootstrap. - `TelemetryEngine` reads `recent_events`, ingesting oldest-first so the newest ends up on top; duplicates still dropped. **Tests** - `tests/test_batched_occupancy_broadcast.py` (7): one recompute and one broadcast per batch; empty batch is silent; unregistered camera → nothing written; payload cap keeps the newest; single event = batch of one; **a sync tick with 8 deltas → 1 broadcast, 1 recompute**; simulator → 1 broadcast. Uses the real `ws_manager.broadcast` with a recording client. - `tests/frontend/test_trailing_throttle.test.js` (4) and one new `TelemetryEngine` test for batched ingestion order and dedup. **Verification:** pytest **236 passed / 1 skipped**, `node --test` **64/64**, ruff clean, pre-commit hooks passed. The whitespace hook also trimmed trailing spaces in three existing `app.js` button-label template literals (no rendered change). Not covered by automated tests: the `app.js` wiring itself (it is DOM glue with no test harness). Worth a quick manual check on the dashboard: the live card updates every tick, the camera/timespan panels update at most every ~15 s, and returning to a background tab refreshes them.
Author
Owner

Code review — round 1

Reviewed 35783c3 against master (4115881) with two independent passes: Standards (docs/standards/code-standards.md, ui-design-guidelines.md, ADR 0002, AGENTS.md, plus a code-smell baseline) and Spec (this PR's description with its two settled decisions, and #14). The Spec pass re-ran the suites in a throwaway worktree: pytest 236 passed / 1 skipped, node --test 64/64.

Verdict: not ready to merge. Spec has a P2 that defeats the PR's goal.

Standards

  • P2, §2.3 ("Avoid passing untyped, raw dictionaries between service layers when structured schemas are available"), occupancy_service.py:643 and the return type at :615: record_counting_events_async builds each event as a raw dict and returns list[dict[str, Any]], although PassengerFlowEvent (occupancy_models.py:308) has exactly those fields. The shape predates the PR, but this diff rewrites it and spreads it to the sync tick and the controller.
  • P3, AGENTS.md §2 ("Validate input strictly"), occupancy_models.py:301: CountingEventInput.direction: str is uppercased later in the service; a Literal["IN","OUT"] would validate on input.
  • P3, possible Duplicated Code, occupancy_controller.py:390-415, occupancy_service.py:2060-2087: the IN and OUT branches build the same CountingEventInput(...) block, differing only in the direction.
  • P3, possible Shotgun Surgery, occupancy_service.py:40: RECENT_EVENTS_BROADCAST_LIMIT = 50 stays in step with the client's flux buffer only by a comment.
  • P3, app.js:545: a missing window.createTrailingThrottle drops the refresh silently.
  • P3, possible Mysterious Name, app.js:535: isOccupancyViewVisible actually checks content-doors.
  • No finding: the single-event wrapper is a legitimate compatibility seam; the test's _count_live_recomputations wraps the real method, so it complies with AGENTS.md §3; the lazy window.* lookup matches the existing module-to-app.js bridge (ADR 0002).

Spec

Correct: one recompute and one broadcast per batch on every counting path; the other occupancy_update broadcasts (calibration, multiplier, camera exclusion) are one per admin action and now throttled; no reader of recent_event remains; oldest-first + cap 50 matches the engine; the occupancy deck is content-doors; #53's guard means an unregistered camera cannot abort a sync tick.

  • P2, reproduced and confirmed in code, app.js:547 with :1620-1629. Spec: "occupancy_update no longer triggers an overview fetch per message; at most one … per 15 s window, trailing". refreshOccupancyData() feeds its result back through telemetryEngine.ingestMessage({type: 'occupancy_update'}) → _handleMessage → onRawMessage → handleIncomingWsMessage → requestOccupancyRefresh(): each fetch schedules the next. With zero pushes, a fake-clock simulation fetched at t=0, 0, 15 s, 30 s … 120 s: a permanent 15 s poll, plus a duplicate at login and on switchTab('doors'). The fake messages also bump the analytics "new records pending" counter. On master this path already loops with no limit; the throttle caps it but doesn't break it.
  • P3, app.js:536-539: renderOccupancyDashboard also fills the admin camera IN/OUT table (#occ-admin-cameras-body, index.html:932), which used to refresh on every push and now never refreshes from pushes while content-occupancy-admin is open. Matches the spec's wording, but should be deliberate.
  • P3, pre-existing, telemetry_engine.js:609: flux dedup also treats events with the same (camera_index_code, timestamp_epoch) as duplicates. Every event in a tick shares now, so on a bidirectional one-camera group (ADR 0005) the OUT event is dropped when an IN arrives in the same tick. Conflicts with "No regression to the flux stream".
  • P3, minor scope creep: pausing on browser-tab visibility plus the visibilitychange catch-up (spec asks only for view visibility); three trimmed app.js template literals; the CountingEventInput schema (justified by the batch interface).

Standards: 7 findings, worst P2 (raw event dicts). Spec: 4 findings, worst P2 (the refresh feedback loop becomes a permanent 15 s poll). All being addressed in a follow-up commit on this branch.

## Code review — round 1 Reviewed `35783c3` against `master` (`4115881`) with two independent passes: **Standards** (`docs/standards/code-standards.md`, `ui-design-guidelines.md`, ADR 0002, `AGENTS.md`, plus a code-smell baseline) and **Spec** (this PR's description with its two settled decisions, and #14). The Spec pass re-ran the suites in a throwaway worktree: pytest **236 passed / 1 skipped**, `node --test` **64/64**. **Verdict: not ready to merge.** Spec has a P2 that defeats the PR's goal. ## Standards - **P2, §2.3 ("Avoid passing untyped, raw dictionaries between service layers when structured schemas are available"),** `occupancy_service.py:643` and the return type at `:615`: `record_counting_events_async` builds each event as a raw dict and returns `list[dict[str, Any]]`, although `PassengerFlowEvent` (`occupancy_models.py:308`) has exactly those fields. The shape predates the PR, but this diff rewrites it and spreads it to the sync tick and the controller. - **P3, AGENTS.md §2 ("Validate input strictly"),** `occupancy_models.py:301`: `CountingEventInput.direction: str` is uppercased later in the service; a `Literal["IN","OUT"]` would validate on input. - **P3, possible Duplicated Code,** `occupancy_controller.py:390-415`, `occupancy_service.py:2060-2087`: the IN and OUT branches build the same `CountingEventInput(...)` block, differing only in the direction. - **P3, possible Shotgun Surgery,** `occupancy_service.py:40`: `RECENT_EVENTS_BROADCAST_LIMIT = 50` stays in step with the client's flux buffer only by a comment. - **P3,** `app.js:545`: a missing `window.createTrailingThrottle` drops the refresh silently. - **P3, possible Mysterious Name,** `app.js:535`: `isOccupancyViewVisible` actually checks `content-doors`. - No finding: the single-event wrapper is a legitimate compatibility seam; the test's `_count_live_recomputations` wraps the real method, so it complies with AGENTS.md §3; the lazy `window.*` lookup matches the existing module-to-`app.js` bridge (ADR 0002). ## Spec Correct: one recompute and one broadcast per batch on every counting path; the other `occupancy_update` broadcasts (calibration, multiplier, camera exclusion) are one per admin action and now throttled; no reader of `recent_event` remains; oldest-first + cap 50 matches the engine; the occupancy deck is `content-doors`; #53's guard means an unregistered camera cannot abort a sync tick. - **P2, reproduced and confirmed in code,** `app.js:547` with `:1620-1629`. Spec: *"`occupancy_update` no longer triggers an overview fetch per message; at most one … per 15 s window, trailing"*. `refreshOccupancyData()` feeds its result back through `telemetryEngine.ingestMessage({type: 'occupancy_update'})` → `_handleMessage` → `onRawMessage` → `handleIncomingWsMessage` → `requestOccupancyRefresh()`: **each fetch schedules the next.** With **zero pushes**, a fake-clock simulation fetched at t=0, 0, 15 s, 30 s … 120 s: a permanent 15 s poll, plus a duplicate at login and on `switchTab('doors')`. The fake messages also bump the analytics "new records pending" counter. **On `master` this path already loops with no limit;** the throttle caps it but doesn't break it. - **P3,** `app.js:536-539`: `renderOccupancyDashboard` also fills the admin camera IN/OUT table (`#occ-admin-cameras-body`, `index.html:932`), which used to refresh on every push and now never refreshes from pushes while `content-occupancy-admin` is open. Matches the spec's wording, but should be deliberate. - **P3, pre-existing,** `telemetry_engine.js:609`: flux dedup also treats events with the same `(camera_index_code, timestamp_epoch)` as duplicates. Every event in a tick shares `now`, so on a bidirectional one-camera group (ADR 0005) the OUT event is dropped when an IN arrives in the same tick. Conflicts with *"No regression to the flux stream"*. - **P3, minor scope creep:** pausing on browser-tab visibility plus the `visibilitychange` catch-up (spec asks only for view visibility); three trimmed `app.js` template literals; the `CountingEventInput` schema (justified by the batch interface). --- **Standards: 7 findings, worst P2** (raw event dicts). **Spec: 4 findings, worst P2** (the refresh feedback loop becomes a permanent 15 s poll). All being addressed in a follow-up commit on this branch.
fix(telemetry): address PR #52 review round 1
All checks were successful
CI / lint-and-test (pull_request) Successful in 1m18s
2b78fdb3e4
Spec:
- Break the overview refresh feedback loop. refreshOccupancyData() handed
  its fetch result to TelemetryEngine.ingestMessage(), which echoed it to
  onRawMessage -> handleIncomingWsMessage -> requestOccupancyRefresh(), so
  each fetch scheduled the next: a permanent 15 s poll with no pushes (an
  unbounded loop on master). ingestMessage() is for locally sourced data
  and no longer calls onRawMessage; transport messages still do. This
  also stops the duplicate doors render and the fake "new records"
  analytics bumps.
- Login no longer fetches the overview and door status twice.
- The throttle also runs while the occupancy admin deck is open, since
  renderOccupancyDashboard fills its camera IN/OUT table.
- Flux de-duplication keys on the event id, falling back to camera,
  timestamp, direction and count. Before, an IN and an OUT from the same
  one-camera BIDIRECTIONAL group in one tick collided on (camera, time)
  and the OUT was dropped.

Standards:
- record_counting_events_async builds and returns PassengerFlowEvent
  models instead of raw dicts; model_dump() at the repository and
  broadcast boundaries.
- CountingEventInput.direction is Literal["IN", "OUT"], normalised on
  input. POST /api/occupancy/event and its i18n messages drop the unused
  PASS direction (it could only ever store dead rows; see #64).
- The IN/OUT branches in the sync tick and the simulator are one loop.
- FLUX_VECTOR_BUFFER_SIZE is exported by the engine; a test asserts it
  equals the server's RECENT_EVENTS_BROADCAST_LIMIT.
- app.js warns once if createTrailingThrottle is missing, and the
  visibility check is renamed isOccupancyOverviewOnScreen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Author
Owner

Review round 1 addressed — 2b78fdb

Spec

  • P2, refresh feedback loop: fixed. TelemetryEngine.ingestMessage() is for locally sourced data and no longer calls onRawMessage; only transport (network) messages do. A fetch result can no longer schedule the next fetch. This also removes the duplicate doors render and the fake analytics "new records" bumps. Test: locally ingested data is not echoed to onRawMessage.
    • Login no longer fetches the overview and door status twice (switchTab('doors') already does).
  • P3, admin camera table: fixed. The throttle runs while the command deck or the occupancy admin deck is on screen, since renderOccupancyDashboard fills both.
  • P3, flux de-duplication: fixed. It keys on the event id, falling back to camera + timestamp + direction + count. Test: an IN and an OUT from one camera in the same tick are both kept.
  • P3, scope creep: documented. A "Deliberate additions" section in the PR description covers tab visibility, the admin deck, the schema, the PASS removal, the loop fix, the dedup and the whitespace trim.

Standards

  • P2, raw event dicts: fixed. record_counting_events_async builds and returns PassengerFlowEvent models; model_dump() only at the repository and broadcast boundaries.
  • P3, input validation: fixed. CountingEventInput.direction is Literal["IN", "OUT"], normalised on input. POST /api/occupancy/event and its i18n messages drop the unused PASS direction (noted on #64).
  • P3, duplicated IN/OUT blocks: fixed. They are one loop in both the sync tick and the simulator.
  • P3, constant kept in step by comment: fixed. The engine exports FLUX_VECTOR_BUFFER_SIZE, and a Python test asserts it equals RECENT_EVENTS_BROADCAST_LIMIT.
  • P3, silent drop: app.js warns once if createTrailingThrottle is missing.
  • P3, name: isOccupancyViewVisible → isOccupancyOverviewOnScreen, checking an explicit list of decks.

Verification: pytest 239 passed / 1 skipped, node --test 67/67, ruff clean, pre-commit hooks passed.

## Review round 1 addressed — `2b78fdb` **Spec** - **P2, refresh feedback loop: fixed.** `TelemetryEngine.ingestMessage()` is for locally sourced data and no longer calls `onRawMessage`; only transport (network) messages do. A fetch result can no longer schedule the next fetch. This also removes the duplicate doors render and the fake analytics "new records" bumps. Test: `locally ingested data is not echoed to onRawMessage`. - Login no longer fetches the overview and door status twice (`switchTab('doors')` already does). - **P3, admin camera table: fixed.** The throttle runs while the command deck **or** the occupancy admin deck is on screen, since `renderOccupancyDashboard` fills both. - **P3, flux de-duplication: fixed.** It keys on the event id, falling back to camera + timestamp + direction + count. Test: an IN and an OUT from one camera in the same tick are both kept. - **P3, scope creep: documented.** A "Deliberate additions" section in the PR description covers tab visibility, the admin deck, the schema, the `PASS` removal, the loop fix, the dedup and the whitespace trim. **Standards** - **P2, raw event dicts: fixed.** `record_counting_events_async` builds and returns `PassengerFlowEvent` models; `model_dump()` only at the repository and broadcast boundaries. - **P3, input validation: fixed.** `CountingEventInput.direction` is `Literal["IN", "OUT"]`, normalised on input. `POST /api/occupancy/event` and its i18n messages drop the unused `PASS` direction (noted on #64). - **P3, duplicated IN/OUT blocks: fixed.** They are one loop in both the sync tick and the simulator. - **P3, constant kept in step by comment: fixed.** The engine exports `FLUX_VECTOR_BUFFER_SIZE`, and a Python test asserts it equals `RECENT_EVENTS_BROADCAST_LIMIT`. - **P3, silent drop:** `app.js` warns once if `createTrailingThrottle` is missing. - **P3, name:** `isOccupancyViewVisible` → `isOccupancyOverviewOnScreen`, checking an explicit list of decks. **Verification:** pytest **239 passed / 1 skipped**, `node --test` **67/67**, ruff clean, pre-commit hooks passed.
Author
Owner

Code review — round 2 (follow-up)

Reviewed 2b78fdb against master with the same two independent passes. CI green; merges cleanly.

Verdict: ready to merge. Every round-1 finding holds; no P1 or P2 on either axis.

Standards

Round 1: all hold. The PassengerFlowEvent return serialises to the same JSON; divmod equals the old ///%; both locales updated consistently; the regex test reading the JS file fails loudly if the line changes, which is acceptable.

  • P3, shadowing: in /simulate the loop variable cams (occupancy_controller.py:~392) reuses the name of the all-cameras list.
  • P3: the ingestMessage / onRawMessage contract is an inline comment, not JSDoc.
  • P3, possible duplication: allowed directions are defined twice (controller check and Literal).
  • P3, possible Primitive Obsession: PassengerFlowEvent.direction and the single-event method's direction stay str.
  • P3: tuple unpacking beside member.code in the same function.
  • Reviewer's note on a possible ZeroDivisionError in the split: not reachable. Since #53 an empty direction list falls back to the member cameras, and memberless groups are skipped first.

Spec

Round 1: all 4 hold. Fake-clock simulation with the real engine and throttle: zero pushes over 120 s → 1 fetch (login), 0 echoes; a push every 3 s → fetches at 0, 0, 15, 30 … 120 s. Nothing depended on the removed echo: both local fetches render directly; the overview has no server_time; real admin actions still reach analytics over the network. Login fetches hold for every role (doors is in OPERATOR_ALLOWED_DECKS). No producer of PASS remains. pytest 239 passed, node --test 67/67.

  • P3, reproduced: the first push after an idle window fires at once (a leading edge), and direct refreshOccupancyData() calls don't reset the throttle window, so a push right after one causes a second fetch.
  • P3: the PASS removal touches #64's endpoint. #64 already records it (comment 1373).

Standards: 5 findings, worst P3. Spec: 1 finding, worst P3. All filed in #67. Merging.

## Code review — round 2 (follow-up) Reviewed `2b78fdb` against `master` with the same two independent passes. CI green; merges cleanly. **Verdict: ready to merge.** Every round-1 finding holds; no P1 or P2 on either axis. ## Standards Round 1: all hold. The `PassengerFlowEvent` return serialises to the same JSON; `divmod` equals the old `//`/`%`; both locales updated consistently; the regex test reading the JS file fails loudly if the line changes, which is acceptable. - **P3, shadowing:** in `/simulate` the loop variable `cams` (`occupancy_controller.py:~392`) reuses the name of the all-cameras list. - **P3:** the `ingestMessage` / `onRawMessage` contract is an inline comment, not JSDoc. - **P3, possible duplication:** allowed directions are defined twice (controller check and `Literal`). - **P3, possible Primitive Obsession:** `PassengerFlowEvent.direction` and the single-event method's `direction` stay `str`. - **P3:** tuple unpacking beside `member.code` in the same function. - Reviewer's note on a possible `ZeroDivisionError` in the split: not reachable. Since #53 an empty direction list falls back to the member cameras, and memberless groups are skipped first. ## Spec Round 1: all 4 hold. Fake-clock simulation with the real engine and throttle: **zero pushes over 120 s → 1 fetch (login), 0 echoes**; a push every 3 s → fetches at 0, 0, 15, 30 … 120 s. Nothing depended on the removed echo: both local fetches render directly; the overview has no `server_time`; real admin actions still reach analytics over the network. Login fetches hold for every role (`doors` is in `OPERATOR_ALLOWED_DECKS`). No producer of `PASS` remains. pytest 239 passed, `node --test` 67/67. - **P3, reproduced:** the first push after an idle window fires at once (a leading edge), and direct `refreshOccupancyData()` calls don't reset the throttle window, so a push right after one causes a second fetch. - **P3:** the `PASS` removal touches #64's endpoint. #64 already records it (comment 1373). --- **Standards: 5 findings, worst P3. Spec: 1 finding, worst P3.** All filed in **#67**. Merging.
gabogg merged commit 42891fa93e into master 2026-09-23 22:19:43 +00:00
gabogg deleted branch refactor/telemetry-streaming-deepening 2026-09-23 22:19:43 +00:00
Sign in to join this conversation.
No description provided.