Skip to content

Record imagery expiries, street status changes, and job runs (#4928) - #4932

Merged
jonfroehlich merged 9 commits into
developfrom
4928-record-imagery-and-status-transitions
Aug 20, 2026
Merged

jonfroehlich merged 9 commits into
developfrom
4928-record-imagery-and-status-transitions

Conversation

@jonfroehlich

Copy link
Copy Markdown
Member

Closes #4928.

Every imagery/street-status surface we have is a snapshot: pano_data.expired says what is expired now,
street_edge.status says what is retired now, and a nightly job that silently stopped looks exactly like a
nightly job that found nothing to do. This PR records the transitions, so "what newly expired this week",
"which streets were retired this month, by what", and "did last night's sweep run" become answerable — and gives
them a first read side in /admin.

Schema (evolution 358)

  • pano_data.expired_at — when the pano's imagery went away, as distinct from last_checked ("when did we
    last look", bumped by every sweep). Stamped by the sweep only on the false→true edge; cleared when a re-check or
    a view un-expires the pano. CHECK (expired OR expired_at IS NULL).
  • audit_task.outdated_imagery_at — same pattern for the With new coverage maps, an important question is: what areas need to be re-audited because new streetscape imagery has landed #4384 re-audit flag (decided with Jon on this
    issue's thread): the sync's set-pass stamps it, the clear-pass nulls it, and because the set-pass only touches
    unflagged rows the stamp survives re-runs — flag→re-audit latency and "newly flagged since X" read straight off
    the column.
  • street_edge_status_change — old/new status + source enum per transition. street_edge.status has no app
    write path at all (only the hand-run db/scripts writers), so this table is the only trace those script runs
    leave. A CHECK (old_status <> new_status) keeps re-runs of the same CSV from faking a spike.
  • background_job_run — one row per nightly-actor run (started/finished, status, summary), written by a new
    JobRunService.record wrapper around all nine scheduled actors. ScheduledJobs.scala now declares the nightly
    roster once — each actor reads its schedule from it, and the Health panel uses it so a job that has never run
    still shows.

Read side

Review callouts

Verified

  • Test/compile warning-clean; scalafmt clean; evolutions lint clean.
  • DB-backed specs pass locally against Postgres+PostGIS (teaneck schema), including evolution 358 forward-apply and
    the new outdated_imagery_at edge-only stamping case (sentinel-based, so a re-stamp can't pass).
  • Both admin pages rendered headless with no console errors.

Filed while building this: #4929 (nothing reopens a no_imagery street that regains imagery) and #4930 (admin
type renders at ~62.5% of intended size).

🤖 Generated with Claude Code (claude-fable-5)

jonfroehlich and others added 5 commits August 16, 2026 12:46
Every imagery and street-status surface was a snapshot of the current
state, so "what newly expired this week" and "which streets were closed
this month" had no answer, and a nightly job that silently stopped firing
looked exactly like one that found nothing to do.

Evolution 358 adds the three records that were missing:

- `pano_data.expired_at`, stamped only on the false -> true edge, so it
  dates the disappearance rather than the last look (which `last_checked`
  already bumps on every sweep).
- `street_edge_status_change`, the only trace the `db/scripts` status
  writers leave. `street_edge.status` has no application write path, so
  the scripts log old status, new status and which script ran, guarded so
  a re-run over the same CSV records nothing.
- `background_job_run`, one row per run of every scheduled actor, opened
  before the work and closed with its outcome and counts.

`JobRunService.record` brackets each job and is strictly subordinate to
it: a bookkeeping failure is logged and swallowed, and the job's own
failure propagates unchanged. `ScheduledJobs` now holds the nightly
schedule in one place, which each actor reads instead of a literal, and
which doubles as the roster the Health panel checks so a job that has
never run still gets a row.

The imagery jobs return structured counts rather than a log line, which
is what makes the #4384 freshness pipeline checkable: the poll's rotation
coverage and the sync's flag counts are now persisted (#4908).

Two admin surfaces read it back: a "Recently changed" section on
/admin/street-status charting status transitions, missing-imagery reports
and pano expiries by week, plus the queue of still-open streets several
labelers independently reported (#4922) for the offline checker to
confirm; and a "Nightly jobs" panel on /admin/health flagging anything
overdue, failed, or never run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
audit_task.outdated_imagery said a completed audit predates its street's
newer imagery, but not since when -- so flag-to-re-audit latency and
"newly flagged this week" were unanswerable, and retroactively so. The
sync's set-pass now stamps outdated_imagery_at (it only touches unflagged
rows, so the stamp marks the false-to-true edge and survives re-runs) and
the clear-pass nulls it, mirroring pano_data.expired_at.

Also enrolls the three pre-existing outdated-imagery specs in the ci.yml
testOnly allowlist, which they had been missing from.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
)

Deep review turned up three ways the Health panel could show green while
knowing nothing, all in the same direction: the panel exists to catch a
scheduler that stopped, and each of these let it miss one.

Overdue now keys on the last successful *scheduled* run rather than on the
latest run of any kind. A sweep an admin triggers from /adminapi is recorded
under the actor's own job name, so it was clearing the alarm for the job the
scheduler had stopped firing -- exactly what JobRunTrigger's own contract says
must not happen. The same read also called every job overdue for as long as it
was running, since an in-flight run is not a successful one; keying on the last
success leaves the previous night's run standing through the next one. The
panel now also says when a last run was hand-triggered, which it never showed.

An empty jobs list rendered as "No scheduled jobs are configured" behind a
green check. The roster is a compile-time constant, so empty can only mean the
background_job_run read failed and was recovered -- the blind-panel-reads-clear
failure the roster-driven design was meant to rule out. It renders as a problem
now.

The expiry chart is a snapshot, not a series: expired_at is cleared when
imagery returns or a user views the pano, so past weeks shrink between visits.
Said so on the page and in the DAO rather than implying a transition log.

Also: cache the trend payload for ten minutes (six unindexed scans backed every
load and every window change, uncached); queue a window change made mid-fetch
instead of dropping it; source the default window from the server; stop
double-escaping an error that already goes through textContent; count the
status-change summary as changes rather than streets, which is what the
per-bucket SQL actually returns; and note that corroboration counts accounts,
not people, since every anonymous sign-up mints its own user.

Adds lastScheduledSuccessPerJob coverage for the manual, in-flight, and
never-succeeded cases, and makes the undated-expiry spec assert the exclusion
its name promises.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Enrolling OutdatedImageryFlagSyncSpec in CI turned two of its refreshFromPanoData
cases red -- the first time they had ever run outside a dev DB.

Both cases place a pano on a street's midpoint and expect the refresh to give
that street an imagery row. The refresh assigns each pano to its *nearest*
street via DISTINCT ON, so that only holds if exactly one street is nearest,
and a street taken from whatever the connected database happens to hold cannot
promise it. CI's seed clones the tutorial street's geometry onto its one
non-tutorial street, so the pano sits at distance zero from both; the tie
breaks arbitrarily, and when it falls to the tutorial street the row is dropped
by the tutorial filter and the street under test gets nothing. Hence
row.isDefined failing outright in one case, and only the updated_at assertion
failing in the other -- every earlier assertion there passes precisely because
the row went untouched.

Each case now inserts its own street, off the coast of Africa where no city has
one, and takes the id from MAX rather than the sequence, which a dump-restored
dev DB leaves behind the table's own ids. Verified against the dev schema in a
rolled-back transaction: the insert succeeds and no other street falls within
the refresh's 0.001-degree prefilter of the new midpoint, so there is no tie
left to break.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Prod data says the loophole it warns about has never occurred: across 26 weeks
of Seattle reports, no street reaches the threshold on two accounts sharing an
ip_address, and the single street that qualifies at all does so with four
independent reporters. A caveat for a case with no instances is noise in front
of a reader looking at a one-row queue.

The caveat stays in the ScalaDoc, where it is aimed at whoever considers
automating this predicate -- which is when accounts-not-people would start to
matter.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The alarm exists to catch a scheduler that has stopped, so the load-bearing
rule is what counts as evidence it is still running: only a scheduled run that
succeeded. A run someone triggered by hand from /adminapi shows the code works,
not that anything still fires it, and a run merely in flight has proved nothing
yet -- and both land under the same job_name as the nightly one, so this rule is
all that separates them.

That rule lived only in an expression inside a private method. Anyone
simplifying it back to "the latest run, whatever it was" would reintroduce both
bugs silently, since the panel looks healthier afterwards, not worse.

Five cases, driven through getDbHealth against seeded history, including a
control that pins overdue can be false at all -- without it the suite would pass
against an implementation that never cleared. Confirmed the suite discriminates
by reverting the fix in a throwaway copy: the hand-triggered and in-flight cases
fail (false was not equal to true, and its converse), the other three pass on
both implementations.

Enrolled in the ci.yml allowlist, since a spec absent from it is dead weight
while CI stays green -- the trap this PR set out to close.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jonfroehlich
jonfroehlich force-pushed the 4928-record-imagery-and-status-transitions branch from 19ba268 to 678170f Compare August 17, 2026 23:24
…y-and-status-transitions

# Conflicts:
#	.github/workflows/ci.yml
@jonfroehlich

Copy link
Copy Markdown
Member Author

Deep code review

Reviewed at 7ff25c2 across ten angles (line-by-line, removed-behavior, cross-file contracts, language pitfalls, wrapper correctness, reuse, simplification, efficiency, conventions, altitude), with every finding re-verified by hand against the branch — and, where scale or data mattered, against the production database.

Overall this is careful, well-documented work. The SQL in particular avoids the traps it could have fallen into: helpers.sh snapshots the pre-update status in a separate CTE rather than expecting UPDATE ... RETURNING to yield old values, the two reveal-or-hide-neighborhoods.sh branches correctly use status literals because they filter on an exact prior status, and getStreetStatusTrend clamps weeks before building the cache key so a junk query param can't mint unbounded entries. The wrapper's failure semantics are right: the original exception propagates unchanged, and bookkeeping failures are swallowed so they can't take a job down.

The findings below cluster around one theme, which is worth stating plainly because it's the PR's own thesis: several paths let the Health panel report health it doesn't actually have.


Blocking before merge

1. Evolution 358.sql is claimed by two open PRs. #4924 (4922-no-imagery-not-audited) also adds conf/evolutions/default/358.sql, and origin/develop tops out at 357. The two files are entirely different DDL — theirs is the false-audited-streets repair, this one adds expired_at / outdated_imagery_at / street_edge_status_change / background_job_run.

Whoever merges second gets an add/add conflict on that exact path, and the natural-looking resolution for what appears to be a duplicate file (take one side) silently drops the other PR's entire schema change. The failure mode is nasty because CI stays green either way — it seeds from the evolutions that survive, so the surviving file applies cleanly — while at runtime PanoDataTable.updateExpiredStatus, /adminapi/streetStatusTrend and JobRunService.record all fail with "relation does not exist". Since Play also needs contiguous numbering, the loser must actually be renumbered to 359, not just merged. Worth deciding the merge order deliberately rather than discovering it in the conflict.


Correctness — the panel can report health it doesn't have

2. A poll that structurally cannot run is recorded as a success. ImageryFreshnessService.pollImageryAges returns PollResult.notPolled(...) inside a successful Future when google-maps-api-key / mapillary-access-token is missing or the provider is unsupported (ImageryFreshnessService.scala:216-226). JobRunService.record sees Success and writes status='succeeded', and that row then satisfies lastScheduledSuccessPerJob — so overdue for the imagery-age poll can never fire.

If a city's Maps key expires or is rotated out, every night records succeeded with streets_polled: 0, /admin/health shows a green ok badge and "Every job has succeeded within the last 36 hours", street_imagery.newest_capture stops advancing, and the #4384 re-audit signal quietly dies. The only trace is not_polled_reason in the Result column, which reads as an informational note. Recording this as Failed (or adding a distinct skipped status that the overdue check ignores) would make the panel honest.

Note: AiService.validateLabelsWithAiDaily returning Seq.empty for AI-disabled cities has the same shape, but there it's arguably correct — the city is deliberately configured off, so an alarm would be noise. The imagery case differs because a rotated key is a real failure wearing a success's clothes.

3. A hand-triggered run masks a failed nightly one. lastScheduledSuccessPerJob is carefully scoped to triggered_by = 'scheduled', with a paragraph of rationale and a test pinning it. But lastStatus, lastError, lastDetails, lastDurationSeconds and hoursSinceLastRun all come from latestRunPerJob (BackgroundJobRunTable.scala:152), which is DISTINCT ON (job_name) ORDER BY started_at DESC with no triggered_by filter — and AdminController.checkImagery writes under the same job_name.

So: nightly sweep fails at 00:15, admin clicks "Check imagery" at 09:00 and it succeeds. #jobStatusBadge reads last_status='succeeded'tones.succeeded = 'good'green "ok" badge, the failed run's last_error gone, overdue false because the previous night's scheduled run is still inside 36h. This directly contradicts the endpoint's own docstring: "tagged Manual so a run someone kicked off by hand can't stand in for one the scheduler never fired (#4928)." That guarantee currently holds only for overdue.

4. The "Failures (7d)" column has two independent defects. Both live in HealthService.getNightlyJobs (HealthService.scala:297-298):

  • Abandoned runs inflate the denominator but never the numerator. runsInWindow sums every status; failuresInWindow filters == JobRunStatus.Failed. A stuck running row is neither. A job the JVM is OOM-killed inside every night for a week reads 0/7 — no failures, while describeStatus separately labels only the latest run abandoned. The one assertion covering this column (NightlyJobStatusSpec:141) is failuresInWindow must be >= 1, which doesn't reach the case.
  • outcomeCountsSince isn't trigger-scoped (BackgroundJobRunTable.scala:182), unlike its deliberately-scoped sibling one method up. Five failed manual clicks while debugging a provider outage show as 5/5 for the imagery sweep even if every scheduled run that week succeeded; three manual successes dilute a real 3/7 scheduled rate to 3/10. The column reads as a statement about the nightly schedule but is only that for the 9 jobs nobody can trigger by hand.

Correctness — the trend charts

5. The server and the client disagree about what offset since carries, and the charts silently drop the rows that fall in the gap. Two separate defects with one shared symptom, so they're worth fixing together.

Server sideStreetLifecycleService.scala:175-179 computes:

.toLocalDate.atStartOfDay(OffsetDateTime.now.getOffset)

That stamps today's offset onto a Monday up to 156 weeks in the past. Today is PDT (-07:00); a window starting in PST (-08:00) yields Monday 00:00-07:00, i.e. 23:00 the preceding Sunday local — the tail of the previous ISO week. Those extra rows are pulled in by WHERE changed_at >= $since, but Postgres buckets them under the previous Monday via date_trunc('week', ...), a week_start the client never generates. I confirmed the production DB session runs America/Los_Angeles, so this one is live. Fix: .atStartOfDay(ZoneId.systemDefault).toOffsetDateTime, which uses the offset that date actually had.

Client side#weekStarts (StreetStatusTrend.js:228-236) slices the date out of since and re-parses it as UTC midnight, then steps 7 days at a time and breaks on week.getTime() > Date.now(). So the grid is offset from the server's window by exactly the JVM's offset. On a negative-offset host (which is what Project Sidewalk runs today) the grid lands early and nothing is dropped; on a positive-offset host the newest bucket's UTC midnight is still in the future during the first hours of local Monday, the loop breaks early, and the current week vanishes from all three charts. Latent rather than live given the current deployment, but it's the kind of thing that surfaces the first time an instance runs anywhere east of Greenwich.

The shared symptom is what makes these worth fixing: #renderStatusChanges renders only matched buckets (values: weekStarts.map((week) => byWeek.get(week) || 0)) but computes its headline from the raw rows (rows.reduce(...)), so the page reads e.g. "1,204 status changes in this window" above bars that visibly sum to fewer, with nothing on the page explaining the gap. Same shape for the report and expiry series. Even after the offsets are fixed, having the summary count rows the chart can't show is a trap worth closing — deriving the total from the rendered series would make any future bucket mismatch visible instead of silent.

6. topReportRegions doesn't exclude deleted regions. StreetEdgeIssueTable.scala:111-121 joins through to region with no region.deleted = FALSE, against a codebase convention that's otherwise consistent (RegionTable.regionsWithoutDeleted and its callers). Its sibling corroboratedOpenStreets is protected only incidentally, by street_edge.status = 'open'.

This isn't hypothetical — in production sidewalk_seattle there are 12 deleted regions, and they carry PanoNotAvailable reports today: Broadview (15 streets), Pinehurst (9), Arbor Heights (6), Rainier Beach (4), Cedar Park (3). Those are eligible to rank into a table the view frames as "the regions most likely to lose streets next", pointing the offline imagery checker at neighborhoods that aren't open for auditing and pushing genuinely at-risk live regions out of the top 10.

7. withName in a GetResult throws on an unrecognized enum label. StreetEdgeStatusChangeTable.scala:83 uses StreetEdgeStatus.withName(r.nextString()); BackgroundJobRunTable.scala:104/107 do the same for the two new job enums. Enumeration.withName raises NoSuchElementException, which fails the DBIO, fails assembleTrend, and 500s the whole endpoint — so all five series, the corroborated queue and the region table vanish together over one unknown label.

This repo grows Postgres enums with ALTER TYPE ... ADD VALUE routinely (331/332/339, and 358 mints three more), and during a rolling deploy an already-migrated schema can be read by a not-yet-updated instance. Worth noting the fix is already sitting there unused: this PR adds fromString helpers returning Option on all three new enums (BackgroundJobRunTable.scala:28, :45, StreetEdgeStatusChangeTable.scala:30) and nothing calls any of them. Either wire them into the GetResults with a sensible fallback, or drop them as dead code.


Observability gaps

8. Five of six hand-trigger endpoints bypass the wrapper. Only checkImagery records a Manual run. updateUserStats (AdminController.scala:330), updateFunnelStats (:346), recalculateStreetPriority (:955), refreshOsmWayData (:1010) and ClusterController.runClustering (:49) call the same service work their wrapped actors call and write no row at all — even though every one corresponds to a job on ScheduledJobs.All that the panel renders. An admin hand-runs a 40-minute refreshOsmWayData that half-fails and there's no details/error_message trail for the run that did the damage.

9. A throwing details builder is silently discarded. JobRunService.scala:63Try(details(result)).toOption with no log line, while the two neighbouring error paths both logger.error. A details lambda that NPEs on an edge case records succeeded with empty details forever, and because a legitimately-empty Json.obj() takes the identical path, "builder is broken" is indistinguishable from "job has nothing to report". One logger.warn in a Failure branch closes it.


Schema / model mirror

10. The Slick FK omits the cascade the schema declares. 358.sql:38 says REFERENCES street_edge (street_edge_id) ON DELETE CASCADE, but StreetEdgeStatusChangeTable.scala:62 calls foreignKey(...) without onDelete = ForeignKeyAction.Cascade, and Slick's default is NoAction — so the model states the opposite of the schema. CLAUDE.md asks the model to mirror each constraint, and here the cascade is load-bearing: the evolution justifies it explicitly, and remove_streets.sql was edited in this same PR to say "street_edge_status_change needs no line here: its FK cascades."


Quality, reuse and conventions

Grouped, since none of these change behaviour:

  • Duplicate formatters in HealthPage.js. #jobDuration (:409) is a strict subset of #dur (:522) in the same class — same tiers, except #dur also clamps negatives and has a days tier, so a run over 24h renders "26h 5m" in one column and "1d 2h" in another. Separately, #jobLastRun (:401) invents a fourth relative-time format alongside the #relativeTime already copied verbatim into ActivityPage.js, OverviewPage.js and AcrossCitiesPage.js.
  • StreetStatusTrend.js re-copies HealthPage's helpers#renderTable (:264-283) is line-for-line HealthPage.#table (:440), plus #setText/#setHtml/#num/#esc. AdminShell.js loads on every dashboard page and is the natural shared home.
  • Unknown job statuses render green. #jobStatusBadge uses tones[job.last_status] || 'good' and sorts with rank[...] ?? 9, so a future status value (or a rename of abandoned) shows as a healthy badge sorted least-urgent — the wrong drift direction for a health panel. Defaulting to warn is a one-word fix.
  • Backend values re-declared in JS. data.min_reporters || 2 (StreetStatusTrend.js:178) hardcodes MinCorroboratingReporters even though the server always sends it; the typeof StreetStatusColors !== 'undefined' fallback (:100-103) re-declares the whole street_edge_status roster as dead code, since streetStatus.scala.html loads StreetStatusMap.js first. Same pattern in the Twirl template, where the 13/26/52 options are hardcoded while the backend owns Default/Min/MaxTrendWeeks.
  • 'PanoNotAvailable' hardcoded three times (StreetEdgeIssueTable.scala:99/116/155) rather than sourced from the existing StreetEdgeIssueType enum.
  • Hand-built 45-line Writes[StreetStatusTrend] (StreetLifecycleService.scala:51) where the repo convention — used by HealthService in this very PR — is scoped JsonConfiguration(JsonNaming.SnakeCase) + Json.writes, needing only a one-line Writes.enumNameWrites. A hand-typed key that's forgotten compiles fine and just never reaches the chart.
  • + string concatenation for HTML in three places (HealthPage.js:462, StreetStatusTrend.js:181, :185), which CLAUDE.md rules out and ESLint deliberately can't catch.
  • Stale docs. pollImageryAges' @return still says "A human-readable summary for the actor log" after the change to PollResult (the @param one line below was updated). Three hardcoded schedule times survive the roster refactor in CheckImageryAgeActor.scala:25-26, FunnelStatActor.scala:25 and docs/ai-subsystems.md:32. And this PR updates docs/architecture.md's frontend-app list to describe admin/ + admin-dashboard/ without the matching edit to CLAUDE.md's mirrored list, which CLAUDE.md's own "keep the two in sync" rule asks for.
  • Two copies of the same details builder. CheckImageExpiryActor.scala:61-67 and AdminController.scala:996-1002 are byte-identical Json.obj(...) literals; a def runDetails: JsObject on ImageryCheckResult would keep the recorded shape from forking by trigger type. Relatedly, ScheduledJobs.ImageryFreshnessSync re-types 1, 45 as fresh literals in the file whose stated purpose is to be the schedule rather than a copy of it.

Non-findings worth recording

So these don't get re-litigated later:

  • The unindexed trend scans are fine. Three angles independently flagged pano_data / street_edge_issue needing indexes on the assumption of "multi-million-row" tables. I measured production: pano_data tops out at 266k rows (Chicago), street_edge_issue at 59k (Kaohsiung), audit_task at 129k. Sequential scans at that size are tens of milliseconds behind a 10-minute cache, and a partial expired_at index would be over-engineering. The six sequential db.runs in assembleTrend are likewise a non-issue at that scale, though binding them before the for-comprehension would be free if you want the parallelism.
  • The validating CHECKs in 358 are fine for the same reason — brief ACCESS EXCLUSIVE on 266k rows, not worth NOT VALID + VALIDATE.
  • Every roster time matches its pre-PR literal (0:15 through 4:00), so the schedule didn't drift in the refactor, and all nine actors pass their own Name with no copy-paste error.
  • Never-run jobs reading as overdue is intentional, documented in the DAO and pinned by NightlyJobStatusSpec:86. The only cost is that a fresh deploy lights up all ten rows until the first nightly tick — fine, but a grace period keyed on app start would spare ~50 city stages a day of false red.
  • All seven CI jobs pass at 7ff25c2.

🤖 Generated with Claude Code (claude-opus-5[1m])

Review findings on this PR clustered on one theme: several paths let the
nightly-jobs panel show green over a pipeline that had in fact stopped. Fixing
them:

A poll that structurally cannot run no longer counts as a success. A GSV or
Mapillary city whose API key is missing now fails the run instead of recording
`succeeded` with `streets_polled: 0`, so a rotated-out key surfaces rather than
quietly ending the #4384 re-audit signal behind an ok badge. A provider with no
age query to make (Infra3d) still reports success -- that is a settled
configuration, not a fault, and alarming on it forever would be noise.

A hand-triggered run can no longer stand in for the scheduled one. `overdue` was
already scoped to scheduled runs, but the badge, error, duration and details all
came from the latest run of any trigger -- so an admin clicking "run it now" the
morning after a failed nightly run replaced that failure with a green ok. The
DAO now reads the latest run per (job, trigger); the panel's columns describe the
schedule and report the last manual run beside them rather than in place of them.

The "Failures (7d)" column had two independent defects. Abandoned runs -- still
open long past any plausible duration -- landed in the denominator and never the
numerator, so a job the JVM is killed inside every night read 0/7. And the count
was not trigger-scoped, so five failed debugging clicks read as five failed
nights. Both fixed, and both pinned by tests.

The trend charts silently dropped rows. The window start stamped *today's* UTC
offset onto a Monday up to 156 weeks back, so a window crossing a daylight-saving
change began at 23:00 the preceding Sunday and pulled in rows Postgres then
bucketed under a week_start the client never generates. The client's own week
grid compared UTC midnight against `Date.now()`, which drops the current week for
the first hours of local Monday on any host east of UTC. Both fixed, and the
status-change headline now totals the rendered series rather than the raw rows,
so a future bucket mismatch shows up instead of hiding.

Also: `topReportRegions` and `corroboratedOpenStreets` now exclude deleted
regions (prod `sidewalk_seattle` has 12, several carrying PanoNotAvailable
reports, eligible to rank into a table framed as "most likely to lose streets
next"); the Slick FK mirrors the schema's ON DELETE CASCADE that
remove_streets.sql relies on; a details builder that throws is logged rather than
silently indistinguishable from a job with nothing to report; and the five
remaining hand-trigger endpoints -- user stats, funnel stats, street priority,
OSM way refresh, clustering -- record a Manual run like checkImagery already did.

Quality: shared formatting helpers (escape, number, duration, relative time,
table markup) move to AdminShell.js, which loads on every dashboard page,
replacing HealthPage's near-duplicate pair of duration formatters, the fourth
relative-time format this PR introduced, the three pre-existing copies of
`#relativeTime`, and StreetStatusTrend's line-for-line re-copy of the table
helper. An unknown job status now renders warn and sorts most-urgent rather than
green and least-urgent. The hand-built 45-line Writes becomes a scoped
SnakeCase + `Json.writes`; the trend window options, the corroboration threshold
and the status palette come from the backend instead of being mirrored in JS;
`PanoNotAvailable` comes from its enum; the duplicated imagery-check details
builder moves onto `ImageryCheckResult`; `ImageryFreshnessSync` takes its time
from the job it runs inside; the six trend reads and three job reads are bound
before their for-comprehensions so they run concurrently; and the schedule times
left in three doc comments now point at ScheduledJobs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jonfroehlich

Copy link
Copy Markdown
Member Author

Review findings addressed

All ten numbered findings and the quality list are handled in 7660be5, except #7, where I landed on a third option — reasoning below. Everything is verified locally: 83 backend tests across the eight touched specs pass (one cancels on a data precondition, as it does on develop), all 918 JS tests pass, and ESLint / Stylelint / HTMLHint / evolutions-lint are clean.

Correctness

2 — a poll that structurally cannot run. Split into the two cases, which turn out not to be the same thing. A missing credential on a provider that does support age polling (google-maps-api-key on a GSV city, mapillary-access-token on a Mapillary one) now fails the Future via a named MissingImageryCredentialException, so the run records failed with the message and overdue eventually fires. An unsupported provider (Infra3d) still records success — that's a settled configuration rather than a fault, same shape as AI-disabled cities, and alarming on it every night forever would be pure noise. This keeps the fix pointed at the case you identified (a rotated key wearing a success's clothes) without a new skipped enum value and its permanent-warn tail on Zurich.

3 — a hand-triggered run masking a failed nightly one. latestRunPerJob is now latestRunPerJobAndTrigger (DISTINCT ON (job_name, triggered_by)), and every last* field on NightlyJobStatus describes the last scheduled run. lastTriggeredBy is gone — always scheduled now — replaced by lastManualRunAt / lastManualStatus, which the panel renders as a muted · manual 2h ago: succeeded suffix beside the schedule's own record. So the hand-run stays visible without being able to supply the badge. Pinned by NightlyJobStatusSpec's new "should not let a hand-triggered success paint over the night the job failed" and a DAO-level case in BackgroundJobRunTableSpec.

4 — the Failures (7d) column, both defects. outcomeCountsSince now takes an abandonedSince cutoff and returns (job, status, stale, count), where stale marks an open run older than JobAbandonedAfterHours; HealthService counts those alongside Failed, so the OOM-killed job reads 7/7 rather than 0/7. And the query is trigger-scoped like its sibling, so a debugging session's clicks neither invent failures nor dilute real ones. Two new DAO cases plus a service case cover both.

One thing worth flagging: my first cut expressed the staleness flag as a Slick groupBy on run.startedAt < abandonedSince, and it compiled fine but blew up at runtime — Slick emits the expression in the select list without repeating it in GROUP BY, which Postgres rejects (column "background_job_run.started_at" must appear in the GROUP BY clause). The new spec caught it; it's raw SQL with an ordinal GROUP BY reference now, with a comment saying why.

5 — the since offset, both sides. Server: .atStartOfDay(ZoneId.systemDefault).toOffsetDateTime, pinned by a spec asserting the offset equals what the zone rules give that LocalDateTime rather than today's. Client: #weekStarts now compares ISO date strings (which sort lexicographically) against the viewer's local date instead of comparing UTC midnight to Date.now(), so the current week survives on a positive-offset host. And the shared symptom is closed — #renderStatusChanges totals the rendered series rather than the raw rows, so a bucket mismatch would show up as a chart that visibly disagrees with nothing, instead of a headline silently exceeding its bars.

6 — deleted regions. region.deleted = FALSE added to topReportRegions, and to corroboratedOpenStreets too — you're right that it was only incidentally protected, and "incidentally" is exactly what rots.

7 — withName in the GetResults. No change, and I want to be explicit about why rather than have it re-litigated. Both options you offered look wrong against repo convention:

  • Dead code: fromString isn't this PR's invention. Six pre-existing enums carry the identical helper (MissionType, ValidationOption, WayType, ComputationMethod, StreetEdgeIssueType, StreetEdgeStatus) and two of those six are equally uncalled. It's a convention on every enum in models/, so removing it from the three new ones would make them the odd ones out.
  • Fallback in the GetResult: MyPostgresProfile.createEnumJdbcType is itself built on withName, so every Slick-typed read of every enum in this codebase throws identically on an unknown label. Making these three the sole exceptions would be inconsistent, and CLAUDE.md's A few tables in the db should just be enums/types #4103 guidance explicitly wants enums to "fail loudly on drift."

On the blast radius: the Health panel already degrades — getNightlyJobs is wrapped in .recover(logAndEmpty), and the JS renders #renderProblem ("this panel is blind, not clear") rather than an empty healthy-looking table. The trend endpoint 500s, but StreetStatusTrend fetches separately precisely so that failure leaves the snapshot above it intact, and the page says "Could not load recent changes". So the rolling-deploy scenario degrades visibly on both surfaces without a per-enum exception. Happy to be overruled, but I'd rather change the convention repo-wide than in three places.

Observability

8 — the five bypassing endpoints. All wrapped as Manual runs under their nightly job's name: updateUserStatsUserStatActor.Name, updateFunnelStatsFunnelStatActor.Name, recalculateStreetPriorityRecalculateStreetPriorityActor.Name, refreshOsmWayDataOsmWayRefreshActor.Name, ClusterController.runClusteringClusteringActor.Name. refreshOsmWayData keeps its ServiceUnavailable recovery — the run row records the failure and the message, then the outer .recover still answers with the resumable-progress body. recalculateStreetPriority records Json.obj() deliberately: the admin route runs only the recalculation, not the freshness-sync-and-region_completion sequence around it, and there's a comment saying so.

9 — the silent details builder. logger.warn in a Failure branch, with the reason spelled out (an empty details is also what a job with nothing to report writes, so without the line the two are the same row).

Schema

10 — the missing cascade. onDelete = ForeignKeyAction.Cascade on the Slick FK, with a comment naming what depends on it (remove_streets.sql deletes no rows there because of it).

Quality list

  • Shared helpers → AdminShell.js, which loads on every dashboard page: esc, num, dur, relativeTime, tableHtml, setText, setHtml, nil. That removes #jobDuration (the strict subset of #dur — a 26-hour run rendered two different ways in adjacent columns), the fourth relative-time format, StreetStatusTrend's line-for-line re-copy of the table helper, and — while I was there — the three pre-existing #relativeTime copies in ActivityPage / OverviewPage / AcrossCitiesPage. Those three weren't byte-identical (ActivityPage returns '' on an unparseable timestamp and omits the year), so the shared one takes { invalid, withYear } and each call site keeps its exact prior behavior. The two jsdom suites that eval AcrossCitiesPage.js now concat AdminShell.js the way one of them already concats MiniLineChart.js.
  • Unknown job statuses: warn tone, and rank[...] ?? -1 so they sort most-urgent. A status the page hasn't learned is a reason to look, not to relax.
  • Backend values: data.min_reporters read straight (no || 2); the typeof StreetStatusColors !== 'undefined' fallback and its dead re-declaration of the status roster removed (StreetStatusMap.js always loads first); the window options come from a new StreetLifecycleService.TrendWeekOptions, defined as Seq(13, DefaultTrendWeeks, 52) so the offered set can't drift from the default it has to contain.
  • 'PanoNotAvailable' now derives from StreetEdgeIssueType, spliced (not bound) with a comment noting that Postgres compares an enum column against an enum literal and that the value is a compile-time constant.
  • The 45-line Writes is a scoped JsonConfiguration(JsonNaming.SnakeCase) + Json.writes per nested type. LocalDate / OffsetDateTime writers are pinned locally as ISO strings rather than left to play-json's defaults — the client slices dates out of those strings, so the wire format shouldn't be a library-upgrade away from changing. A new spec case asserts the nested key names and the ISO since.
  • + HTML concatenation converted at all three sites.
  • Stale docs: pollImageryAges' @return; the three hardcoded schedule times in CheckImageryAgeActor / FunnelStatActor / docs/ai-subsystems.md now point at ScheduledJobs; and CLAUDE.md's frontend-app list gets the admin/ + admin-dashboard/ edit that docs/architecture.md had, per its own sync rule.
  • Duplicated details builderImageryCheckResult.runDetails, used by both the actor and checkImagery. ScheduledJobs.ImageryFreshnessSync is now RecalculateStreetPriority.copy(...) rather than re-typing 1, 45.
  • Free parallelism taken up: the six trend reads and the three job reads are bound before their for-comprehensions.

Not changed

1 — the 358.sql collision with #4924. Keeping 358, deliberately. The repo rule is max(applied on develop) + 1 even when an open PR already claims itdevelop tops out at 357, so 358 is the only correct number for both branches. Renumbering pre-emptively to 359 creates a gap, and a gap is the genuinely dangerous case: Play's ClassLoaderEvolutionsReader stops at the first missing number, so a 359 next to a 357 is silently never applied — no error, no log line, play_evolutions stays at 357, and every DB-backed spec then aborts with relation "..." does not exist. (Worse in the other direction: DatabaseEvolutions.scripts zips applied rows against files positionally in descending revision order, so an evolution later filling a skipped slot shifts every pair below it and, with autoApplyDowns=true in the base config, Play re-applies the entire history.)

So this is a merge-order decision rather than a code change, and you're right that it's worth making deliberately instead of discovering it in the conflict. Whoever merges second renumbers to 359 in the same sitting — the loser's file, not a "take one side" conflict resolution. I've checked the other open PRs: #4924 is the only other claimant (#4927 and #4944 top out at 357).

The rest of the "non-findings" section I agree with and haven't touched — including the never-run-reads-overdue grace period, which I left as-is.

🤖 Generated with Claude Code (claude-opus-5[1m])

A coverage audit before merge turned up five units this PR added or changed
with nothing asserting them at all, plus two client-side fixes from the review
round that only existed as code.

The largest gap was `StreetEdgeIssueTable`: the weekly report series, the
by-region ranking and the "Awaiting confirmation" queue had no test of any
kind, which meant the deleted-region fix from the last commit was unpinned and
so was the rule the whole queue rests on -- corroboration is distinct
*accounts*, not repeat visits by one. NoImageryReportsSpec seeds its own
regions, streets, users and reports inside a rolled-back transaction and covers
all three reads. Confirmed it discriminates: dropping `region.deleted = FALSE`
turns exactly the two deleted-region cases red and leaves the other eight green.

Seeding needs explicit ids rather than the sequences. The dev DB is restored
from a dump that inserts ids without advancing its sequences, so `nextval`
returns ids that already exist -- street_edge is at 2172 with its sequence on 4.

ScheduledJobsSpec pins the roster, which nothing checked. A job missing from
`All` is simply absent from the Health panel, and absent is indistinguishable
from healthy, so the invariant is found by reflection rather than by a second
hand-written list: adding a `val Foo: ScheduledJob` and forgetting `All` now
fails here. It also pins that every name a hand-trigger endpoint records under
is on the roster, since a Manual run recorded under an unlisted name is written
and still invisible.

EnumTypeParitySpec checks the three Scala enums against the Postgres types they
back. That pairing was held together by a `NOTE:` comment asking the next person
to change both sides; drift surfaces as a NoSuchElementException mid-read, on
whichever page happens to read that row first.

ImageryPollOutcomeSpec separates a poll with nothing to do from one that could
not run -- the distinction the last commit introduced and nothing asserted.
It rebuilds the service against a config with the provider's credential removed
and requires the Future to fail. Also covers ImageryCheckResult's totals and its
recorded shape, which two callers share.

NightlyJobStatusSpec now asserts the serialized field names. The panel's JS
renders from its own fixtures, so a rename here would surface as blank cells
against a live database and nowhere else.

On the frontend, HealthPage and StreetStatusTrend had no tests at all, which
left the client half of the timezone fix and the badge-tone fix unpinned. Three
jsdom suites cover the shared AdminShell formatters, the nightly-jobs panel
(unknown status tones and sorts as a reason to look; a hand-triggered run sits
beside the schedule's record; an empty panel reads as a failed read) and the
trend section (the week grid, the headline totalling only what it can draw, the
threshold read from the payload, escaping). Reverting the four fixes turns
exactly the four corresponding cases red.

Two of the grid's edges only exist away from UTC, and `process.env.TZ` set
inside a test is ignored -- V8 caches the zone per context and jest's jsdom
context is already built by then, so the case passes for the wrong reason.
test/js/support/timeZoneJsdomEnvironment.js sets it before that context exists,
letting a file declare its timezone; streetStatusTrendWeekGrid.test.js pins
itself to Sydney and asserts the environment really is east of UTC before
relying on it.

`#weekStarts` now reads the clock via `Date.now()` rather than `new Date()`, so
"now" enters the method in one place and the grid's edges are reachable.

All five new specs are enrolled in the ci.yml allowlist, since a spec absent
from it is dead weight while CI stays green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jonfroehlich

Copy link
Copy Markdown
Member Author

Coverage audit before merge

Went through every unit this PR adds or changes and checked what actually asserts it. Five had nothing at all, and two of the fixes from the last round existed only as code. All closed in e748b52; 8/8 CI jobs green.

What had no coverage

Unit Status before Now
StreetEdgeIssueTable.reportsByWeek / topReportRegions / corroboratedOpenStreets nothing NoImageryReportsSpec, 10 cases
ScheduledJobs.All roster nothing (review checked it by hand) ScheduledJobsSpec, 8 cases
The three new Scala enums vs their Postgres types nothing (a NOTE: comment) EnumTypeParitySpec, 3 cases
pollImageryAges missing-credential branch nothing ImageryPollOutcomeSpec, 4 cases
HealthPage, StreetStatusTrend, the shared AdminShell helpers nothing 4 jsdom suites, 51 cases
nightly_jobs serialized field names nothing NightlyJobStatusSpec

The StreetEdgeIssueTable trio was the one that bothered me most: it's the entire "Awaiting confirmation" queue and the region ranking — the surfaces that decide where the offline imagery checker gets pointed next — and the deleted-region fix from last round was sitting there unpinned. It now covers corroboration meaning distinct accounts rather than one person's repeat visits, exclusion of already-retired streets and of deleted regions, the window boundary, ordering and limits.

ScheduledJobsSpec finds the roster by reflection rather than by re-listing it, so adding a val Foo: ScheduledJob and forgetting All fails there instead of silently dropping that job off the panel — which is the failure the whole feature exists to close, since invisible and healthy look identical.

Every fix is mutation-checked

A test that passes with and without the fix is decoration, so I reverted each fix in a throwaway copy and confirmed the intended cases — and only those — go red:

  • dropping region.deleted = FALSE2 of 10 cases fail, the other 8 stay green
  • the four JS fixes (headline-from-series, unknown-status tone, unknown-status sort, east-of-UTC week) → exactly those 4 fail

Two things worth flagging

A latent bug surfaced. The timezone case in the review's finding #5 could not be tested the obvious way: process.env.TZ set inside a jest test is silently ignored (V8 caches the zone per context, and jsdom's is already built), so the test would have passed against the unfixed code. test/js/support/timeZoneJsdomEnvironment.js pins the zone before that context exists; streetStatusTrendWeekGrid.test.js runs in Sydney — east of UTC and on DST, so it reaches both grid edges — and asserts the environment really took before relying on it. #weekStarts now reads the clock through Date.now() rather than new Date() so "now" enters in one place.

Seeding needs explicit ids. The dev DB is restored from a dump that inserts ids without advancing its sequences — street_edge is at 2172 with its sequence on 4 — so a spec that lets nextval assign gets a duplicate-key error. The new spec assigns MAX + 1 inside its rolled-back transaction.

Known gaps I did not close

Stating these rather than implying the surface is complete:

  1. The db/scripts shell writers. helpers.sh, hide-streets-without-imagery.sh, reveal-or-hide-neighborhoods.sh and remove_streets.sql are what actually write street_edge_status_change, and they have no automated test — they're bash + psql, outside every suite we have. Mitigations: the source enum makes a typo'd label a loud INSERT failure (now pinned by EnumTypeParitySpec), the old_status <> new_status CHECK is covered, and the idempotence guard was checked by hand (running hide-streets-without-imagery.sh twice inserts 0 rows the second time). A bash-level DB test harness is a bigger piece of work than this PR should carry.
  2. That a specific hand-trigger endpoint still calls record. The wrapper is covered (JobRunServiceSpec), and every job name those endpoints use is now pinned to the roster (ScheduledJobsSpec) — but nothing asserts that, say, updateFunnelStats still goes through jobRunService. Closing it means admin-authenticated controller tests; I'd rather do that as its own piece than bolt auth plumbing onto this PR.
  3. The actors' own record calls are likewise unasserted, but that failure is loud rather than silent — the job would read never_run and overdue on the panel.

All five new specs are enrolled in the ci.yml allowlist, and I checked the CI log to confirm they ran rather than being quietly absent: 27 suites, 225 passed, 0 failed. The 14 cancels are all pre-existing data-precondition ones on the sparse CI seed — none of the new cases cancel, because they seed their own fixtures instead of hunting for them.

Local: 96 backend tests across the 14 specs touching this PR, 969 jsdom tests (up from 918). CI: 972 jsdom tests, all 8 jobs green.

🤖 Generated with Claude Code (claude-opus-5[1m])

@jonfroehlich
jonfroehlich merged commit 6619680 into develop Aug 20, 2026
10 checks passed
jonfroehlich added a commit that referenced this pull request Aug 20, 2026
The stack was branched before #4932's second review pass, which replaced
`last_triggered_by` with `last_manual_run_at`/`last_manual_status` and moved the
dashboard's formatters onto AdminShell. The pipeline panel read the field that no
longer exists and carried its own copy of the job-row formatters — including the
`unknown status -> good` default that pass fixed on the Health panel.

The three job-row formatters now live on AdminShell, so both panels reach the same
verdict about the same `nightly_jobs` entry, and the Imagery page's escaping, number
formatting, and table markup come from there too rather than from three private
re-implementations.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
jonfroehlich added a commit that referenced this pull request Aug 25, 2026
Develop claimed 358-361 while this PR was in flight (#4932's transition
logs took 358). The repair evolution moves to 362 unchanged; conflicts
were both-sides method additions in StreetEdgeIssueTable and a comment
in ExploreNoImageryRateLimitSpec, resolved by keeping both/develop's.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@misaugstad
misaugstad deleted the 4928-record-imagery-and-status-transitions branch September 10, 2026 20:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Record when imagery expires and streets change status, so /admin can show what's newly identified

1 participant