Skip to content

Say when media bytes go missing, and show it on /admin/health (#4926) - #4944

Open
jonfroehlich wants to merge 10 commits into
developfrom
4926-media-loss-guardrails
Open

jonfroehlich wants to merge 10 commits into
developfrom
4926-media-loss-guardrails

Conversation

@jonfroehlich

@jonfroehlich jonfroehlich commented Aug 20, 2026

Copy link
Copy Markdown
Member

Closes #4926.

Stacked on #4927 (base is 4925-story-media-deploy-wipe, not develop) — it needs service.MediaDirs and
PersistentMediaDirCheck.persistentDirs from that PR. Review only the two commits above the base; retarget this
PR onto develop before #4927 merges, or GitHub will close it when the base branch is deleted.

Items (1), (4) and (5) of the issue already shipped inside #4927 (PersistentMediaDirCheck, its spec, and the
"Directories that must survive a deploy" docs). What's left is item (2), extended past story media, and item (3).

Why

A media row whose file is gone answers every request with a bare 404, indistinguishable from an id that never
existed. That is why #4925 — a story photo deleted by a deploy — went six days without anyone noticing, and why it
was found by a human opening the story rather than by us.

What's here

Request-time (LostMediaLog). /cropImage and /backupImage now say when the bytes are gone. Both are only
ever reached through a signed URL, and PanoDataService only signs one for a file it just saw on disk, so a miss
means the bytes vanished inside the signature's ~75-minute life — no DB read needed to know it is real loss. Panos
and crops are both the ERROR tier — nothing can re-fetch imagery the provider no longer serves, and nothing
regenerates a crop, which is a canvas screenshot the labeler's browser took as the label was placed. Share previews
get nothing — a miss there is the normal cold-cache case and rebuilds itself.

StoryController's dedup set moves into the shared LostMediaLog. The old ConcurrentHashMap.newKeySet was fine
for a handful of story ids and would not have been for pano ids — a dead mount reports every pano in a city at
once — so the tracking set is now a bounded LRU.

Dashboard. A Media storage panel on /admin/health:

It covers every city on the stage, not just the instance being viewed. One dir.list() per city serves any row
count; schema discovery, counts and ids are three UNION ALL queries — a per-city fan-out is exactly the
~50-connection flood this dashboard exists to catch (#4559).

Two rules keep it from crying wolf, since an ignored monitor leaves us where #4925 found us: an unreadable base
directory reports the scan unavailable rather than every row lost, and a dev checkout is not scolded for the
relative defaults landing where they are meant to (enforced: false outside Mode.Prod). Filesystem work runs on
a new blocking-io dispatcher, so a stat against a dead mount can only park a thread nothing else uses.

The current-city fix (second commit). Found QAing against a dev container where SIDEWALK_CITY_ID=seattle-wa
and DATABASE_USER=sidewalk_teaneck disagree — the state CLAUDE.md warns about. StoryService builds its write
path from city-id, so the photo landed under seattle-wa/ while its row sat in sidewalk_teaneck, and the first
cut — deriving the directory from the schema — called a file that was right there lost. So this instance's own
schema, read from current_schema() rather than inferred from config, takes its directory from city-id, and that
claim is exclusive (otherwise the schema config maps to that same city id lists the same directory and reports
every file in it as an orphan). Generalizable: a monitor over a write path has to resolve paths exactly the way
the writer does
— the same principle as MediaDirs itself.

Testing

  • 86/86 across the six touched suites against the dev DB, including 21 new pure-logic cases in
    test/service/MediaIntegritySpec.scala (added to ci.yml's testOnly allowlist, or it runs nowhere).
  • Compile clean under -Xfatal-warnings; scalafmt, ESLint and HTMLHint clean.
  • Live on the running app: all 4 dev schemas discovered and mapped; dev correctly not alarmed about the relative
    defaults; deleting story_331.jpg produced exactly one
    ERROR s.LostMediaLog - story_media 331 has no file on disk at … across three requests (dedup working), with a
    bare 404 each time.

Not yet verified: the rendered panel HTML and the missing count end-to-end — port :9000 was in use by another
worktree during QA. Recipe is in the issue thread.

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

jonfroehlich and others added 2 commits August 19, 2026 16:13
A media row whose file is gone answers every request with a bare 404,
indistinguishable from an id that never existed. That is why #4925 —
a story photo deleted by a deploy — went six days without anyone
noticing, and it was found by a human opening the story, not by us.

Request-time: /cropImage and /backupImage now say so. Both endpoints
are only ever reached through a signed URL, and PanoDataService only
signs one for a file it just saw on disk, so a miss means the bytes
vanished inside the signature's ~75-minute life. Panos are the error
tier (the store holds the only copies of GSV imagery Google expired),
crops the warning tier (re-cuttable). Share previews get nothing: a
missing one is the normal cold-cache case and rebuilds itself.

The dedup behind those lines moves into LostMediaLog, shared with
StoryController. Its unbounded set was fine for a handful of story
rows and would not have been for pano ids — a dead mount reports
every pano at once — so the tracking set is now bounded.

Dashboard: a Media storage panel on /admin/health, covering every
city on the stage rather than only the instance being viewed. It
shows where each persistent directory resolves and what the boot
check makes of it, then counts story_media rows with no file
(destroyed content) and files with no row (a retraction whose file
delete didn't land, against #4054's hard-delete contract). One
directory listing per city serves any row count, and the schema and
id reads are single UNION ALL queries — a per-city fan-out is the
~50-connection flood this dashboard exists to catch.

Two rules keep it from crying wolf, since an ignored monitor leaves
us where #4925 found us: an unreadable base directory reports the
scan unavailable rather than every row lost, and a dev checkout is
not scolded for the relative defaults landing where they are meant
to. Filesystem work runs on a new blocking-io dispatcher, so a stat
against a dead mount can only park a thread nothing else uses.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Found QAing the panel against a dev container where SIDEWALK_CITY_ID
and DATABASE_USER disagree — the state CLAUDE.md warns about, and one
the panel handled badly. StoryService builds its write path from
city-id, so the photo landed under seattle-wa/ while its row sat in
sidewalk_teaneck; the scan, deriving the directory from the schema,
looked under teaneck-nj/ and called a file that was right there lost.
The whole point of the panel is that people believe it when it says
data is gone, so it has to look where the writer actually writes.

So this instance's own schema, read from current_schema() rather than
inferred from config, takes its directory from city-id. That claim is
exclusive: without it, the schema the config maps to that same city id
listed the same directory and reported every one of those files as an
orphan instead.

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

Copy link
Copy Markdown
Member Author

Deep code review

All 17 files read. The design is sound — sourcing the panel from persistentDirs/unsafeDirs so the page and the
boot check can't disagree is the right call, the signed-URL argument for why a 404 on /cropImage and
/backupImage is provably loss holds up (checked every signing site: backupImageUrl and cropUrl both
exists()-check first, getBackupImageMetadata goes through getLocalBackupImage which does too, and signatures
live 60–75 min), and the UNION ALL + one-dir.list()-per-city shape genuinely avoids the fan-out.

Six findings.

1. An unreadable directory is reported as total data loss — the exact cry-wolf failure the design rules out

MediaIntegrity.listFileNames (app/service/MediaIntegrity.scala:144) returns None for both "absent" and
"exists but this process can't read it" — File.list() returns null in either case. compareCity maps None
every row missing. Verified in the container as a non-root uid:

exists=true isDirectory=true canRead=false listIsNull=true

The base-dir guard doesn't save us, because it uses isDirectory (app/service/HealthService.scala:404), which is
true for an unreadable directory. So storyMediaIntegrity's own scaladoc promise —

If the base directory itself is unreadable, the scan reports itself unavailable instead of declaring every row
lost: a monitor that cries data loss over a missing mount would be worse than no monitor at all.

— is not implemented. A base directory the process can't read sails past isDirectory, every city's list() comes
back null, and the panel reports every story photo in the fleet as destroyed, with the KPI red. That is the one
outcome this panel must never produce.

Fix: canRead() in the base guard, and have listFileNames distinguish dir.exists() && !dir.canRead()
(→ unscanned) from absent (→ missing), which is what the spec's own test name already claims it does.

2. A hung mount permanently kills the blocking-io pool

withTimeout (app/service/HealthService.scala:473) is firstCompletedOf — it abandons the blocked thread, it
can't cancel it. Failures are deliberately not cached (.recover sits outside getOrElseUpdate, matching this
file's stated convention), and HealthPage polls every 20 s. On a mount that never returns, four consecutive
5-second timeouts park all four threads of the fixed-pool-size = 4 pool forever, and the queue behind it is
unbounded — so the panel stays dead even after the mount recovers, until the app restarts.

Not hypothetical: pano.images.directory is one of the four directories directoryStatuses stats, and the pano
store is NFS-exported from makelab2. The comment's claim that a hung stat "can only park a thread nothing else
uses" is true for the rest of the app but not for this panel's own recovery.

3. The "Missing media files" KPI shows a directory count

public/js/admin-dashboard/HealthPage.js:155:

if (!media.story_media) return [unsafeDirs > 0 ? unsafeDirs : '—', unsafeDirs > 0 ? 'bad' : 'ok'];

When the scan is unavailable but two directories are unsafe, the tile labelled "Missing media files" reads 2.
The next line has the mirror problem: missing === 0 with an unsafe directory renders 0 in red. The value means
a different thing in each branch.

4. "no city configured for schema X" is false on the one instance that hits it

public/js/admin-dashboard/HealthPage.js:443. The exclusivity rule in 2d086f3 drops the schema that config maps
to currentCity out of cityDirsBySchema, so it arrives with scanned: false — and the panel then tells the
operator no city is configured for it. On the misconfiguration the commit exists for (SIDEWALK_CITY_ID=seattle-wa,
DATABASE_USER=sidewalk_teaneck), sidewalk_seattle is configured; its directory was just claimed by the running
instance. The message points debugging the wrong way on exactly the box where someone is already confused.

5. LostMediaLog has no spec, and the test that names its dedup doesn't test it

It's small and pure. Its LRU (access-ordered eviction, the kind:id key shape, the error/warn split) is the part
most likely to regress into silence, and silence is indistinguishable from health. ImageControllerSpec's
"…and stay quiet on a repeat" asserts only two 404s; nothing observes the log. MediaIntegritySpec is otherwise
strong (21 cases, false alarms pinned as hard as real ones) but has no case for finding 1's unreadable-directory
branch, nor for the not_writable status.

6. enforced is computed twice

app/service/MediaIntegrity.scala:39 derives environment.mode == Mode.Prod internally;
app/service/HealthService.scala:381 recomputes it for the payload field. Two copies of the arming rule that the
rest of this PR works hard to keep in one place.


Findings 1 and 2 both defeat the panel in the exact conditions it was built for. Fixing all six now.

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

Two of the six defeated the panel in the conditions it exists for.

An unreadable directory read as total data loss. File.list answers null
both for a directory that isn't there and for one the process may not
read, and the scan collapsed them into "every row missing" — while the
base-dir guard used isDirectory, which is true for an unreadable
directory. So a permissions change on the media base would have put
every story photo on the stage on the panel as destroyed, which is the
one thing a monitor like this must never do. Listing now reports
Absent and Unreadable apart, an unreadable city directory reports
unscanned instead of lost, and the base-dir guard checks canRead.

A hung mount permanently killed the blocking-io pool. The five-second
timeout abandons the future but cannot cancel the thread under it, and
the dashboard polls every ~20s, so four stuck scans parked all four
threads for good — the panel stayed dead even after the mount came
back. One scan at a time now, gated on the underlying scan rather than
on the timeout, so a stuck mount costs one thread and releases it when
it unsticks.

The rest: the "Missing media files" KPI reported a count of unsafe
directories when the scan was unavailable, so it now only ever shows a
missing-file count and lets a bad directory color the tile without
supplying its number; an unscanned city said "no city configured for
schema X" even when a city was configured and this instance had simply
claimed its directory, so the reason travels from the backend that
knows which case applies; and the arming rule lives once, on the boot
check, rather than being recomputed by the panel.

Tests: LostMediaLogSpec covers the dedup, the kind/id key, the
error-vs-warn tiering and eviction by asserting the log lines
themselves, since both ways that class fails are silent.
MediaIntegritySpec covers the unreadable branches, and the directory
status rules move behind a pure seam so the permission branches are
reachable from a suite that runs as root. Both are in ci.yml's
testOnly list, or they run nowhere.

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

jonfroehlich commented Aug 20, 2026

Copy link
Copy Markdown
Member Author

All six fixed in bbab1c3.

1 — unreadable directory read as loss. MediaIntegrity.listing now returns DirListing.{Listed, Absent, Unreadable} instead of an Option that collapsed the last two, an unreadable city directory reports unscanned rather than every-row-lost, and the base-dir guard checks canRead alongside isDirectory.

2 — hung mount killed the pool. A single in-flight gate, cleared on the underlying scan rather than on the timeout, so a stuck mount parks one thread and releases it when it unsticks instead of stacking a fresh scan every poll until all four are gone. When the gate is shut the panel says so rather than rendering a stale all-clear.

3 — KPI. It now only ever shows a missing-file count or ; an unsafe directory colors the tile without supplying its number, and the table below names which directory.

4 — unscanned reason. CityStoryMedia.unscanned_reason carries the backend's own wording, so "no city configured", "this instance writes that directory under another schema" and "not readable" are told apart on the page.

5 — tests. New LostMediaLogSpec asserts the log lines themselves (dedup, the kind/id key, ERROR-vs-WARN tiering, eviction) since both of that class's failure modes are silent. MediaIntegritySpec gained the unreadable branches, and the directory-status rules moved behind a pure dirStatus seam so the permission branches are reachable from a suite running as root. Both specs are in ci.yml's testOnly list.

6 — arming rule. Now PersistentMediaDirCheck.arms, called by the check's own if, the panel, and the payload field.

Verified locally: Test / compile clean under -Xfatal-warnings, scalafmt clean, ESLint 0 errors (3 pre-existing max-len warnings), HTMLHint clean. 70 tests green against the dev DB — MediaIntegritySpec (26), PersistentMediaDirCheckSpec (16), HealthServiceSpec (13), ImageControllerSpec (7), LostMediaLogSpec (6), HealthDashboardSpec (2).

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

jonfroehlich and others added 5 commits August 19, 2026 22:23
Its directory rows rendered 193-208px tall against ~32px for every
other table on the page. Each one carried the boot check's full
wipe-zone sentence under the status badge, in the narrowest column —
and that sentence names the config key and the resolved path, both of
which are already their own columns, so four rows repeated the same
explanation four times to say nothing the row didn't already say. The
badge states the status; the fix ("point its environment variable at
storage outside the application") is said once in the note below.
Row-specific details that aren't the generic one — unresolved, not
readable, not writable — still show, and they fit on a line.

The "Holds" column read "content" or "rebuildable", which doesn't
answer anything: every directory holds content. It now asks the
question that matters, "If lost", and answers "gone for good" or
"rebuildable". Same fix for "Ids" over the story table, which shows a
sentence rather than ids whenever a city couldn't be scanned; it's
"Notes" now.

The intro said in 425 characters what it says here in 290.

Left the directories table the only one on the page wide enough to
scroll sideways, which clipped Status — the column being read. Long
paths now wrap, scoped to the path cell so the config keys and
variable names beside them stay whole.

Measured in headless Chromium at 1440px against the running app: rows
34-46px (the connections table is 35px), section 1281px -> 641px, and
horizontal overflow 0 across all four health tables.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three things this PR added could be deleted with CI still green, and each
one is silent when it breaks -- which is the failure mode the whole PR
exists to end.

Nothing asserted that ImageController reports a loss at all. The 404 a
signed crop or pano URL answers when its bytes are gone is indistinguishable
from an id that never existed, so the log line is the only signal, and the
old cases checked only the status code. ImageControllerSpec now pins the
line, its tier, and the path it names -- and pins silence on a crop that is
present, using a second label id so dedup can't supply that silence.

HealthPage.js had no tests. Its rules decide whether an operator believes
the panel: an unscanned city must not render as zero losses, and the KPI
must never call an unknown healthy. healthMediaPanel.test.js drives the
real load path through jsdom for 19 cases, including the escaping of
server-supplied reasons.

The field names joining the two are unpinned in both directions: rename a
case class field and the writer emits a different key, every value the page
reads goes undefined, and a monitor reporting nothing looks exactly like a
monitor reporting nothing wrong. HealthMediaPayloadSpec asserts the key sets
the page consumes, including that absent Options stay absent.

Two seams make the rest reachable. The base-directory guard moves to
MediaIntegrity.scanRefusal, so the isDirectory-is-true-for-unreadable trap
is covered by the same spec as its per-directory twin rather than only from
a booted app. The in-flight guard becomes SingleFlightGate, whose contract
-- the gate opens on the work, never on a caller giving up -- is the part a
deadline alone doesn't give and the part a spec can drive with promises.

HealthServiceSpec's media cases would have passed against a checkout with no
media directory, which is CI. It now owns its directory, seeds a file with
no row in it, and requires that file to be attributed to this instance's own
schema: the assertion fails if the scan resolves the directory from the
schema instead of city-id, which is the bug live QA caught.

ImageControllerSpec ran nowhere in CI; it and the two new pure specs join
the gating list.

92 Scala tests across 8 suites and 837 jsdom tests green; scalafmt and
eslint clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
serveStoryMedia skips reporting a media row younger than a minute, because
StoryService commits the row before the file move lands and a healthy upload
looks exactly like a destroyed one for that moment. Nothing covered the rule,
and it is not free to get wrong in either direction: reporting is once per
media id, so a false alarm spends the single line that id will ever get, and
a window too generous is a stretch in which real loss passes unannounced.

The predicate moves to StoryController.withinUploadWindow, next to
ListingMax and following StoryServiceImpl.secondsUntilFree's precedent, so
the two boundaries can be pinned against a fixed clock rather than a live
one. StoryUploadWindowSpec joins the CI gating list.

129 tests across 11 suites green, including StoryControllerSpec and
StoryServiceSpec.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two columns were asking the reader to do the interpreting.

"If lost" gave no clue what the loss was of, and answered in a warn-toned
badge: every irreplaceable directory carried an amber "gone for good" that
sat right beside the live Status badge and read as a second alarm on a row
where nothing was wrong. It is now "Recoverable?", answered Yes or No first
in muted text, so it can't be mistaken for a condition.

"Status" on a dev checkout read "inside the build tree (dev)" -- a location,
leaving the reader to work out whether that was a problem. It now leads with
the verdict: "ok for dev (inside the build tree)".

Resolves-to moves up beside the variable that sets it, so the row reads as
identity (key, var, path) then judgment (recoverable, status).

Three jsdom cases pin the new shape, including that recoverability is never
badged. 51 Scala tests and 22 jsdom tests green; verified live on :9000.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A crop is a screenshot of the pano canvas taken in the labeler's browser
as the label was placed, and nothing in this app regenerates one. The
Street View Static still used as a fallback elsewhere is a different,
smaller image of a pano the provider must still serve — and roughly half
the labels on prod sit on panos already marked expired.

So the guardrails move the crop directory to the fatal tier alongside the
pano and story-media directories: a stage that would place it inside the
tree `sbt clean stage` deletes refuses to boot, and a signed crop URL
whose file has vanished logs at ERROR. Cached share previews are now the
only entry that still rebuilds on demand.

Every deployed stage already points SIDEWALK_IMAGES_DIR outside the build
tree, and CI's e2e-smoke job exports it, so no stage newly fails the
check.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jonfroehlich
jonfroehlich force-pushed the 4926-media-loss-guardrails branch from 5ee439a to 7ff1cdf Compare August 20, 2026 17:58
@jonfroehlich jonfroehlich changed the title Say when media bytes go missing, and show it on /admin/health (#4926) [Blocked on ops] Say when media bytes go missing, and show it on /admin/health (#4926) Aug 20, 2026
@jonfroehlich
jonfroehlich changed the base branch from 4925-story-media-deploy-wipe to develop August 20, 2026 23:15
@jonfroehlich jonfroehlich changed the title [Blocked on ops] Say when media bytes go missing, and show it on /admin/health (#4926) Say when media bytes go missing, and show it on /admin/health (#4926) Aug 20, 2026
@jonfroehlich

Copy link
Copy Markdown
Member Author

Retargeted from 4925-story-media-deploy-wipe onto develop ahead of #4927 merging — retargeting a stacked PR after its base branch is deleted closes it silently, so it has to happen first. Until #4927 lands, the diff here also shows that PR's changes; it will shrink back to this branch's own work once it does.

Dropped the [Blocked on ops] prefix too: the ops exports this stack waited on are live (verified in #4927 (comment)).

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

Three conflicts, all "both sides added a sibling panel" rather than
disagreements — #4928's nightly-jobs panel landed beside this branch's
media-storage panel, so both are kept:

- HealthService: both imports, both DbHealthData fields, both injected
  dependencies, both futures in getDbHealth, both Writes.
- HealthPage.js: both render calls and both panel sections. develop moved
  esc/num/nil/setHtml off HealthPage onto AdminShell, so the media panel's
  calls follow them; healthMediaPanel.test.js now eval's AdminShell ahead
  of HealthPage the way healthNightlyJobs.test.js does.
- ci.yml: the gating testOnly allowlist, union of both sides.

HealthMediaPayloadSpec builds a whole DbHealthData/HealthThresholds, so it
takes develop's two new threshold values and nightlyJobs.

Test / compile clean under -Xfatal-warnings, scalafmt clean, 70 pure specs
and 994 jsdom tests green, eslint/htmlhint 0 errors.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.

No guardrail or signal when media bytes go missing: silent 404s, no boot-time validation of persistent dirs

1 participant