Log pano imagery transitions instead of reading a snapshot (#4947) - #4994
Conversation
The "Panoramas whose imagery went away" chart on /admin/street-status read pano_data.expired_at, which is current state: it is cleared when the imagery comes back, so a pano that expired in March and returned in May did not move buckets, it vanished from March. Past weeks shrank between two loads of the same page, and the recovery — the thing that separates permanent imagery loss from a provider blip, and so decides whether a city needs a re-drive — was applied silently and retroactively. Evolution 359 adds pano_imagery_change, the same shape street_edge_status_change took in 358: keep the current-state column because it is cheap to read, and add an append-only log as the record of what happened. Rows carry the state moved into and whether a provider check or a labeler's view established it, and the evolution seeds the log from the dates expired_at still holds so the chart keeps the history it has today. All three writers are edge-triggered, which is what keeps the table free: a nightly re-check of an already-expired pano and a labeler loading a pano that was never expired both record nothing. Logging checks rather than transitions would cost a row per pano per night — 266k on the largest pano_data — where real crossings run to a few thousand a year. Each writer is one statement with a CTE that reads the pre-update row, since UPDATE ... RETURNING hands back the new one. The chart now draws both directions, and the paragraph apologizing for the data is gone. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ery-transitions # Conflicts: # .github/workflows/ci.yml # app/models/pano/PanoDataTable.scala # conf/evolutions/default/359.sql # public/css/admin-dashboard/admin-dashboard.css
… the payload Develop's 359 shipped while this branch sat on that number, so the log's evolution moves to 363 and every reference follows it. StreetLifecycleServiceSpec only checked that the imagery series was present under its key. The chart indexes expired_count and returned_count by name and draws zeros on a miss, so a rename would read as a quiet week rather than a failure; the payload now has to carry both counts and an ISO week_start. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Deep reviewVerdict: sound design, correctly implemented, safe to merge. The core mechanism does what the PR claims — verified empirically against Postgres rather than by reading. Three findings, none blocking; only the first would change code, and its obvious fix is wrong in a way worth recording. 1. The
|
Review follow-ups on the imagery-transition log. `edge` reads the statement snapshot, so a flip committed by another connection mid-statement is invisible to it: a sweep expiring a pano while a labeler's upsert is in flight can leave a loss with no matching recovery. Rare enough to accept — the window is one autocommit statement — but the obvious fix is a trap worth recording. `FOR UPDATE` on `edge` collides with the same statement's own write of the row, so `edge` comes back empty and the uncontended case silently stops logging. Verified against PG 16; the pano_view case is the test that would catch it, and now says so. Undated panos were documented as rows the log "has no event for and never will". They have no *loss* event, but they still log a recovery, so "came back" can outrun "went away" until the count drains. On a dev schema the backfill seeded 0 rows against 3,635 undated panos, so that is the shape admins see first — on a page that just dropped its caveat paragraph. The footnote now owns it. Also pins the DBIO[Int] row-count contract the CTE rewrite has to preserve, which nothing checked: the count has to come from the pano flip, not the log INSERT in front of it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The v11.9.0 release landed its version-bump row on develop as 363.sql, the number this branch was already using, so the two files collide on the same id. Ours moves; theirs has shipped. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ery-transitions # Conflicts: # .github/workflows/ci.yml
Closes #4947. Supersedes and closes #4935.
The problem
/admin/street-statuscharted "Panoramas whose imagery went away" offpano_data.expired_at, which is current state, not history: it is cleared the moment the imagery comes back — by a provider re-check that finds it (the non-expired branch ofPanoDataTable.updateExpiredStatus) and by a labeler viewing the pano again (PanoDataTable.upsert).So a pano that expired in March and returned in May didn't move buckets, it vanished from March. Past weeks shrank between two loads of the same page, and the recovery — the thing that separates permanent imagery loss from a provider blip, and so decides whether a city needs a re-drive — was applied silently and retroactively. #4932 shipped the chart with a paragraph apologizing for exactly this.
The change
Evolution 364 adds
pano_imagery_change, the same shapestreet_edge_status_changetook in 358: keep the current-state column because it is cheap to read and join, and add an append-only log as the record of what happened. Rows carry the state moved into and whether a provider check or a labeler's view established it.pano_data— where real crossings run to a few thousand a year.expiredand can't read it back afterwards (UPDATE ... RETURNINGhands back the new row), so each gets a CTE that sees the statement's snapshot — the patterndb/scripts/helpers.shalready uses forstreet_edge_status_change. Every method'sDBIO[Int]row-count contract is unchanged.expired_atstill holds, so the chart keeps the history it has today rather than starting empty. Panos that expired before 358 added the column have no date to seed from; those stay in thepanos_expired_undatedfootnote the page already renders.A write-surface audit over
app/,db/,scripts/, and the evolutions confirmed those three sites are the only writers ofpano_data.expired/expired_at, so the log cannot miss a transition.Testing
PanoImageryLogSpec(new, DB-backed): both halves of the contract — every real crossing recorded exactly once, and nothing recorded for the confirmations that dominate the traffic. Getting the second half wrong is a row per pano per night rather than a wrong number, so the edge semantics are pinned against a real Postgres. Added to the CI gating-test list.EnumTypeParitySpec:pano_imagery_change_sourceagainstPanoImageryChangeSource. The writers cast a Scala-supplied string to this type inside raw SQL, so drift fails the pano upsert every labeler's viewer performs, not just a read.StreetLifecycleServiceSpec: the payload has to carry both counts and an ISOweek_start. The client indexes these by name and draws zeros on a miss, so a rename would read as a quiet week rather than a failure.streetStatusTrend.test.js: the two series render separately, and a loss stays in its week when the imagery later comes back.All green locally:
Test/compile(warning-clean under-Xfatal-warnings),scalafmtAll, all four frontend linters,lint-evolutions, 35/35 across the four affected specs, 23/23 JS.Evolution 364 was also dry-run inside a rolled-back transaction against a real schema (backfill count, table/type/index/FK shape, edge-triggering, and the rollup query), then applied and QA'd on a booted app:
/adminapi/streetStatusTrendservespano_imagery_changeswith both counts, and a seeded loss stays in its own week while its recovery lands in a later one.🤖 Generated with Claude Code — Opus 5 (1M context), claude-opus-5[1m]
Renumbered 363 → 364 after the v11.9.0 release landed its version-bump row on develop as
363.sql. The rename carried its four comment references, and develop was merged in (one conflict, the CI gating-test list, resolved as the union of both additions).