fix(telemetry): direct fetches reset the refresh throttle; one Direction type (#67) #91
No reviewers
Labels
No labels
blocked
bug
enhancement
high-priority
low-priority
needs-info
needs-triage
ready-for-agent
ready-for-human
referenced
research
wontfix
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
gabogg/hikcentral!91
Loading…
Reference in a new issue
No description provided.
Delete branch "fix/telemetry-throttle-direction-typing"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #67
Summary
Direct overview fetches now share the WebSocket refresh throttle, so a push right after a direct fetch no longer fetches the overview twice.
IN/OUThas oneDirectiontype on the write path. The stored-event read model deliberately stays a normalisedstrso legacy rows stay readable.Problem
Follow-ups from the round-2 review of PR #52:
refreshOccupancyData()calls (login,switchTab('doors'), timespan change, about 14 admin actions) didn't start a window, so a WebSocket push right after one of them fetched the overview a second time.camsshadowed the list of all cameras.ingestMessage()never reachesonRawMessagelived only in an inline comment.IN/OUTwas defined in several places anddirectionstayed a plainstrin the stored-event model and the service.GroupMember.Approach
Throttle (item 1).
createTrailingThrottlenow returns aschedulefunction with amarkRan()method.refreshOccupancyData()calls it on every fetch, whether direct or push-triggered. If a push is already waiting when a direct fetch happens, its timer is re-armed for the end of the new window instead of firing early.getOccupancyRefreshThrottle()builds the throttle lazily, so both paths share it.Decision on "first push after idle": it still runs on the next tick. A trailing-only design would delay the first change after a quiet period by up to 15 s on the operations deck. The throttle exists to coalesce bursts, not to delay isolated updates. This is now written in the JSDoc and covered by a test.
Direction (items 4–5).
Direction = Annotated[Literal["IN", "OUT"], BeforeValidator(upper)]andDIRECTIONS: tuple[Direction, ...] = ("IN", "OUT"), both inoccupancy_models.py. They are used byCountingEventInputandrecord_counting_event_async; the controller passes a validated, upper-cased value.PassengerFlowEvent.directionstaysstr, upper-cased on read.recent_passagesbuilds it from raw stored rows. Production held onlyIN/OUTwhen checked (2026-09-24), but the repository write path doesn't enforce that, so narrowing the read model could turn one bad row into a 500 on the overview.tests/test_direction_type.pyasserts thatPASSis accepted on the read model and rejected onCountingEventInput.CountingEventWebhookPayload.directionstaysstr, and/eventchecks againstDIRECTIONS. Typing it with the alias would turn the documented400 INVALID_DIRECTION(translated ini18n.js, asserted in two tests) into a generic422 VALIDATION_ERROR. #64 is expected to delete/event, so changing its error contract now isn't worth it. A comment on the field explains this.Items 2, 3, 6. Renamed the loop variable to
targets. Moved the contract into JSDoc oningestMessage,onRawMessageand_handleMessage({ fromNetwork }).GroupMembernow usesm.codethroughout.Verification
node --test tests/frontend/*.test.js: 70 passed. There are 3 new fake-clock tests: a push right after a direct fetch waits for the window; a waiting push moves behind a direct fetch; and the first push after idle runs at once.pytest: all passed (282 before the new file). Newtests/test_direction_type.pychecks thatoutnormalises toOUTon both models, that the write model rejectsPASS, and that the read model keeps it.ruff check/ruff format --check: clean. Pre-commit passed.Architectural Impact
createTrailingThrottlenow returns aschedulefunction with amarkRan()method (a public return-type change), and the occupancy controller's direct and push-triggered refreshes share one window. The write-side Direction contract validates IN/OUT, while stored passage readback accepts legacy directions. No database schema change.Checklist
Review follow-up
The read model deliberately accepts legacy stored directions; write input still validates IN/OUT. The direct-fetch throttle starts before the request, so a failed fetch waits for the next window. Analytics direction filters outside this PR retain their existing string contracts.
🤖 Generated with Claude Code
Code review — pass 1 (two-axis)
Reviewed the PR's own diff (
git diff <base>...<head>); Standards and Spec ran as independent passes and are reported separately, not reranked. Severity: P1 must fix · P2 fix before merge · P3 minor. Per review policy, pass 1 findings are all to be fixed on the branch.Standards
The diff breaks no layering, error-schema, or testing standard. The controller stays thin,
/eventkeepsHTTPExceptionwithX-Error-Code: INVALID_DIRECTION(code-standards §3), both changes have new tests (§4.3), andDirection/DirectionTypefollow the CONTEXT.md glossary. No P1 bugs.Documented-standard findings
createTrailingThrottlereturn type and narrowsPassengerFlowEvent.direction, so both deserve a line there.Judgement calls (baseline smells / typing)
app/controllers/occupancy_controller.py:359passespayload.direction(plainstr, possibly lower-case) intorecord_counting_event_async(direction: Direction)atapp/services/occupancy_service.py:617. Works at runtime becauseCountingEventInputupper-cases, but a type checker would reject it. Passpayload.direction.upper()or annotate the parameter asstr.CountingEventWebhookPayload.direction: str(occupancy_models.py:515) is declined with a reason — fine.direction: str | Nonestill appears inanalytics_controller.py:110,207,analytics_service.py:522,623andoccupancy_repository.py:1670. Outside the diff; a follow-up issue would do if "one Direction type" is meant literally.DIRECTIONS: tuple[str, ...](occupancy_models.py:213) could betuple[Direction, ...].fire()setslastRunAt(app/static/js/src/utils.js:110), then callsrefreshOccupancyData, which callsmarkRan()again (app/static/js/app.js:1642). Harmless; drop the assignment infire()or note in the JSDoc that re-marking is expected.markRan()runs before the fetch (app.js:1642). A failed or 401 fetch still starts a 15 s window, so the next push waits instead of retrying. Probably acceptable, but document it.requestOccupancyRefresh(app.js:568) is now a 2-line wrapper; it keeps call sites readable.Notes
fire()re-arm loop is correct:pendingstays true across the re-arm, so a waiting push is never duplicated; covered by the new fake-clock tests.Spec
Verdict: All six items are addressed. No P1 or P2 issues. Three P3 notes.
(a) Missing or partial
tests/frontend/test_trailing_throttle.test.js:80-121. The spec asks for "a fake-clock test covering 'direct fetch then push' and 'first push after idle'". Both tests exist, but they callschedule.markRan()by hand. Nothing checks thatrefreshOccupancyData()actually calls it (app/static/js/app.js:1639-1642). If that one line were removed, the original bug would come back and no test would fail.(b) Scope creep
None. The
getOccupancyRefreshThrottle()extraction (app.js:547-571) is the smallest change that lets both paths share one throttle. Thefire()re-arm inutils.js:101-111is the fix the spec asked for.(c) Implemented but questionable
app/schemas/occupancy_models.py:438. The spec says "Keep in mind that stored rows may predate validation (oldPASSrows) when narrowing the read model."PassengerFlowEventalso backsOccupancyOverviewResponse.recent_passages(occupancy_service.py:1838-1890). A single non-IN/OUT row in the DB would now make the overview return a 500. The PR explains the decision: production holds only IN and OUT rows. But nothing stops such a row being written later; the repository still accepts anyevt.get("direction","IN").upper()(occupancy_repository.py:1092,1153). Acceptable; a DBCHECKconstraint could be a follow-up.Open design points
utils.js:76-77), covered by a test.markRan()"markRan(). A pending push is re-armed rather than firing twice; covered by a test.CountingEventWebhookPayload.directionstaysstrto keep the documented400 INVALID_DIRECTIONresponse (asserted intest_i18n.py:209,test_batched_occupancy_broadcast.py:250); the check now usesDIRECTIONS. Reason documented atoccupancy_models.py:513-514.Items 2, 3, 6
camsrenamed totargets(occupancy_controller.py:393-400). Done.ingestMessage,onRawMessageand_handleMessage({ fromNetwork })(telemetry_engine.js:352-356, 507-513, 556-563). Done.occupancy_service.py:2165-2168usesm.codethroughout. Done.occupancy_service.py:616.direction: Directiononrecord_counting_event_asyncis only a type hint; normalisation happens inCountingEventInput. Behaviour correct; noted for clarity.Summary: Standards 6 (all P3) — worst:
Directionannotation vs lower-casestrpassed from controller. Spec 3 (all P3) — worst: narrowedPassengerFlowEvent.directioncan 500 the overview on a legacy non-IN/OUT row.🤖 Generated with Claude Code
Code review — pass 2 (two-axis)
Re-reviewed at head
2676094. Each axis verified every pass-1 finding against the code (not the commit messages), then reviewed the fix commits for new problems. Verdicts: ✅ fixed · ◐ partial · ❌ not addressed · ↺ regressed · ⊘ declined with documented reason. Per review policy, from pass 2 on P1/P2 block merge; P3s go to one follow-up issue for this PR.Standards
Head
2676094.Pass-1 findings
🤖 Generated withfooter; Architectural Impact doesn't mention the publiccreateTrailingThrottlereturn-type change (schedule+markRan). Approach is now stale (see below).Directionannotation vs lower-casestrfrom controlleroccupancy_controller.py:360passescast(Direction, payload.direction.upper())after theDIRECTIONScheck at:350.direction: strin analytics/repositoryDIRECTIONS: tuple[str, ...]occupancy_models.py:213istuple[Direction, ...].lastRunAtwrite infire()thenmarkRan()fire()still setslastRunAt = now()thenfn()→refreshOccupancyData→markRan()(app.js:1639-1642). No JSDoc note, no reason given.markRan()before fetch — failed fetch starts the windowutils.js:82and the Review follow-up.requestOccupancyRefreshNew findings
Directionis used by "PassengerFlowEvent(the stored-event read model)" and keeps "Why narrowing the read model is safe".2676094reverted that narrowing (occupancy_models.py:438-439,direction: str), so the description contradicts the code and its own Architectural Impact line. Update/remove those lines; add a Summary.PassengerFlowEventnormalises with@field_validator(..., mode="before")calling_upper_direction(occupancy_models.py:446-449), while the module otherwise uses theAnnotated[..., BeforeValidator(_upper_direction)]alias pattern (:212).direction: Annotated[str, BeforeValidator(_upper_direction)]matches and drops 4 lines.tests/test_direction_type.py:15-25parametrises over two models then branches onmodel is ...twice. The models now have different contracts (write rejectsPASS, read keeps it); two plain tests are clearer. The name "normalised_on_every_model" hides thatPASSis accepted on one.occupancy_controller.py:350and:360callpayload.direction.upper()twice; bind a local once.No P1/P2. Layering, error schema (
INVALID_DIRECTION400 kept), async hygiene and the JSDoc-only frontend change are fine. Re-rantest_direction_type,test_i18n,test_batched_occupancy_broadcast(17 passed) andtest_trailing_throttle.test.js(7 passed) at2676094. (ruff not available in the throwaway worktree.)Merge readiness (this axis): Ready — only P3s remain (→ follow-up issue). Fixing the stale Approach text before merge is recommended.
Spec
Checked at head
2676094against pass-1 head85bbec6. New tests run in a throwaway worktree:node --test tests/frontend/test_trailing_throttle.test.js7/7,pytest tests/test_direction_type.py3/3.Pass-1 findings
refreshOccupancyData()callsmarkRan()2676094does not touchtests/frontend/test_trailing_throttle.test.js.markRan()is still only called by hand in the tests (lines 85, 104, 116). Deleting the call atapp.js:1641-1642would fail nothing. The PR description does not decline this.PassengerFlowEvent.directioncould 500 on a legacy rowoccupancy_models.py:438back todirection: str, with a comment and an upper-casingfield_validator(446-449).test_direction_type.pyassertsPASSis accepted on the read model and rejected onCountingEventInput.direction: Directiononrecord_counting_event_asyncwas only a hint; controller passed rawstroccupancy_controller.py:360passescast(Direction, payload.direction.upper()); theDIRECTIONSguard at line 350 makes the cast sound.Does "preserve legacy passage reads" undermine "one Direction type"? No — it only reverts the narrowing. Item 4 ("Define one
Directiontype alias and use it in all three":/eventcheck,CountingEventInput, webhook payload) is untouched by2676094. The change affects item 5; the service parameter is now typed, and the read model went back tostras the spec anticipated: "Keep in mind that stored rows may predate validation (oldPASSrows) when narrowing the read model." Versus master, the read model now only upper-cases stored directions on read — harmless, since the repository upper-cases on write.New findings
Directionis "used byCountingEventInput,PassengerFlowEvent(the stored-event read model)…" and keeps the bullet "Why narrowing the read model is safe:". Only the appended "Review follow-up" and the code comment atoccupancy_models.py:437say the read model was not narrowed. Rewrite that Approach bullet as a stated decline ("read model staysstrfor legacy rows; normalised only"). Text-only.utils.js:82: "A failed direct fetch still starts the window; the next push retries when it ends." The throttle never retries on its own; a fetch happens only if a push arrives, and then waits for the window end. Suggested: "…a push arriving during the window is held until it ends; nothing retries on its own."record_counting_event_asyncacceptsDirectionbut returnsPassengerFlowEventwithdirection: str, so callers lose the validated type. Acceptable given the decline; follow-up issue.Quick re-check of the whole diff: no other P1/P2.
Merge readiness (this axis): Not ready until the P2 (description-only edit) is fixed. The unaddressed pass-1 P3 (
markRan()wiring untested) should be fixed or explicitly declined; other P3s can go to a follow-up issue.Summary: Blocking (P2): Spec — PR description's Approach still claims the read model was narrowed (contradicts
2676094); description-only fix. Standards ready (P3 only). Worst per axis: Standards → stale description / missing Summary (P3); Spec → the same contradiction (P2). Unaddressed pass-1 P3: nothing tests thatrefreshOccupancyData()callsmarkRan().🤖 Generated with Claude Code