Skip to content

Commit 7ff1cdf

Browse files
jonfroehlichclaude
andcommitted
Treat label crops as content, not cache (#4926)
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>
1 parent 530e62d commit 7ff1cdf

9 files changed

Lines changed: 53 additions & 40 deletions

app/controllers/ImageController.scala

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,7 @@ class ImageController @Inject() (
140140
case None =>
141141
// Reaching here means the file was on disk when this URL was signed (backupImageUrl and
142142
// getBackupImageMetadata both check first) and is gone within the signature's ~75-minute life. For an
143-
// expired pano this store holds the only copy left anywhere, so say so (#4926) — the 404 stays bare.
143+
// pano the provider no longer serves nothing can re-fetch it, so say so (#4926) — the 404 stays bare.
144144
lostMediaLog.reportMissing(
145145
"pano",
146146
panoId,
@@ -199,8 +199,9 @@ class ImageController @Inject() (
199199
Future.successful(Ok.sendFile(file, inline = true).as("image/png"))
200200
} else {
201201
// Same signed-URL reasoning as serveBackupImage above: cropUrl only signs a crop it just saw on disk, so a
202-
// miss here is a file that vanished. A crop can be re-cut from pano imagery, so this is the warning tier.
203-
lostMediaLog.reportMissing("crop", s"$labelType/$labelId", file.getAbsolutePath, irreplaceable = false)
202+
// miss here is a file that vanished — and nothing recreates it: the crop was captured in the labeler's
203+
// browser at labeling time, from a pano the provider may no longer serve.
204+
lostMediaLog.reportMissing("crop", s"$labelType/$labelId", file.getAbsolutePath, irreplaceable = true)
204205
Future.successful(NotFound("Crop image not found"))
205206
}
206207
}

app/modules/PersistentMediaDirCheck.scala

Lines changed: 16 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,10 @@ import scala.util.{Failure, Success, Try}
2323
* incomplete file (the #4925 failure) would disarm the guard exactly when it is needed. Dev and test runs skip it;
2424
* there the application root is the hand-managed repo checkout, where the relative defaults are the point.
2525
*
26-
* A directory holding irreplaceable bytes — user uploads, or our only copies of provider-expired imagery — is
27-
* **fatal**: refusing to boot is far cheaper than accepting content we already know will be destroyed, and the test
28-
* stage redeploys on every push to `develop` while prod waits for a release tag, so a missing variable surfaces on
29-
* test long before it can reach prod. The rest is derived data whose loss costs rebuild time, so it logs and lets
26+
* A directory holding irreplaceable bytes — user uploads, label crops, or imagery that cannot be re-fetched — is
27+
* **fatal**: refusing to boot is far cheaper than accepting content we already know will be destroyed,
28+
* and the test stage redeploys on every push to `develop` while prod waits for a release tag, so a missing variable
29+
* surfaces on test long before it can reach prod. Cached share previews rebuild on demand, so that one logs and lets
3030
* the app run.
3131
*/
3232
@Singleton
@@ -59,8 +59,8 @@ object PersistentMediaDirCheck {
5959
* @param key Config key naming the directory.
6060
* @param envVar Environment variable a deployment sets it with, named in the failure message so the fix
6161
* doesn't require reading the config.
62-
* @param irreplaceable Whether it holds bytes no rebuild can recreate — user uploads, or the only surviving copy
63-
* of provider-expired imagery. These form the fatal tier; the rest only log.
62+
* @param irreplaceable Whether it holds bytes no rebuild can recreate. These form the fatal tier; the rest only
63+
* log.
6464
*/
6565
case class PersistentDir(key: String, envVar: String, irreplaceable: Boolean)
6666

@@ -79,14 +79,17 @@ object PersistentMediaDirCheck {
7979
def arms(environment: Environment): Boolean = environment.mode == Mode.Prod
8080

8181
val persistentDirs: Seq[PersistentDir] = Seq(
82-
// Crops and share previews are derived: a crop can be re-cut from pano imagery and a share preview rebuilds on
83-
// demand, so losing them costs rebuild time, not content.
84-
PersistentDir("cropped.image.directory", "SIDEWALK_IMAGES_DIR", irreplaceable = false),
85-
PersistentDir("share.image.directory", "SIDEWALK_SHARE_IMAGES_DIR", irreplaceable = false),
86-
// The self-hosted pano store backs up GSV imagery Google has already expired (pano_data.has_backup) — for those
87-
// panos it is the only copy left anywhere, as unrecoverable as a user upload.
82+
// A crop is a screenshot of the pano canvas taken in the labeler's browser as the label was placed
83+
// (Canvas.saveCanvasScreenshot); nothing here rebuilds one, and the Static API still we fall back to is a
84+
// different image that only exists while the provider still serves that pano — about half the labels on prod sit
85+
// on panos already marked expired.
86+
PersistentDir("cropped.image.directory", "SIDEWALK_IMAGES_DIR", irreplaceable = true),
87+
// Locally stored pano imagery (pano_data.has_backup) the providers no longer serve, so it cannot be re-fetched.
8888
PersistentDir("pano.images.directory", "SIDEWALK_PANO_DIR", irreplaceable = true),
89-
PersistentDir("story.media.directory", "SIDEWALK_STORY_MEDIA_DIR", irreplaceable = true)
89+
PersistentDir("story.media.directory", "SIDEWALK_STORY_MEDIA_DIR", irreplaceable = true),
90+
// Share previews are the one cache here: each rebuilds on demand from the label's crop, or from a Street View
91+
// still when the crop is gone.
92+
PersistentDir("share.image.directory", "SIDEWALK_SHARE_IMAGES_DIR", irreplaceable = false)
9093
)
9194

9295
/**

conf/application.conf

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -265,13 +265,15 @@ custom.news.ribbon.link = null
265265
# at boot in prod mode, and every consumer resolves its path through service.MediaDirs so the check can't drift from
266266
# the write paths.
267267

268-
# Directory to store cropped images.
268+
# Directory for label crops, organized as <dir>/<city-id>/<labelType>/crop_<labelId>.png. Each crop is a screenshot
269+
# of the pano canvas taken in the labeler's browser as the label was placed, and nothing here rebuilds one.
270+
# Irreplaceable, like the pano and story dirs.
269271
cropped.image.directory = ".crops"
270272
cropped.image.directory = ${?SIDEWALK_IMAGES_DIR}
271273

272-
# Directory containing self-hosted pano images, organized as <dir>/<city-id>/<panoId[0:2]>/<panoId>.<ext>. These back
273-
# up GSV imagery Google has already expired (pano_data.has_backup), so for those panos this holds the only copy
274-
# anywhere — irreplaceable, like the story media below.
274+
# Directory containing self-hosted pano images, organized as <dir>/<city-id>/<panoId[0:2]>/<panoId>.<ext>. Holds
275+
# imagery the providers no longer serve (pano_data.has_backup), which cannot be re-fetched — irreplaceable, like the
276+
# story media below.
275277
pano.images.directory = ".panos"
276278
pano.images.directory = ${?SIDEWALK_PANO_DIR}
277279

docs/deployment-and-stages.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -289,10 +289,14 @@ outside the build tree** via its environment variable (a variable that is set bu
289289
| Config key | Env var | Holds | Missing on a deployed stage |
290290
|---|---|---|---|
291291
| `story.media.directory` | `SIDEWALK_STORY_MEDIA_DIR` | User-uploaded story photos (**irreplaceable**) | **App refuses to start** |
292-
| `pano.images.directory` | `SIDEWALK_PANO_DIR` | Self-hosted pano store — the only copies of GSV imagery Google has expired (**irreplaceable**) | **App refuses to start** |
293-
| `cropped.image.directory` | `SIDEWALK_IMAGES_DIR` | Label crops (re-derivable from pano imagery) | Error logged at boot |
292+
| `pano.images.directory` | `SIDEWALK_PANO_DIR` | Locally stored pano imagery the app serves itself (**irreplaceable**) | **App refuses to start** |
293+
| `cropped.image.directory` | `SIDEWALK_IMAGES_DIR` | Label crops — browser captures of the pano as it was labeled, with no rebuild path (**irreplaceable**) | **App refuses to start** |
294294
| `share.image.directory` | `SIDEWALK_SHARE_IMAGES_DIR` | Cached social-share previews (regenerable) | Error logged at boot |
295295

296+
Crops sit in the fatal tier because nothing in this app regenerates one: `/saveImage` stores a canvas screenshot the
297+
labeler's browser took as the label was placed, and the Street View Static still used as a fallback elsewhere is a
298+
different, smaller image of a pano the provider must still serve — which it often no longer does.
299+
296300
`PersistentMediaDirCheck` enforces this at boot in **prod mode** — what every staged binary runs in — so it covers
297301
every deployed stage *and* a staged binary run by hand (export the four variables to `/tmp` paths for that; CI's
298302
`e2e-smoke` job does exactly this). It deliberately does not key on `ENV_TYPE`: that variable arrives through the

test/controllers/ImageControllerSpec.scala

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -196,8 +196,9 @@ class ImageControllerSpec extends PlaySpec with AnonSession with GuiceOneAppPerS
196196
}
197197

198198
events must have size 1
199-
// A crop can be re-cut from pano imagery, so it is the warning tier rather than the paging one.
200-
events.head.getLevel mustBe Level.WARN
199+
// A crop exists in one place only — it was captured in the labeler's browser as the label was placed — so
200+
// its disappearance is the error tier, not a rebuild cost.
201+
events.head.getLevel mustBe Level.ERROR
201202
val message = events.head.getFormattedMessage
202203
message must include("crop")
203204
message must include(syntheticLabelId.toString)
@@ -207,8 +208,8 @@ class ImageControllerSpec extends PlaySpec with AnonSession with GuiceOneAppPerS
207208
}
208209

209210
"answer a signed pano URL with no backup image with a plain 404, every time it is asked" in {
210-
// For a pano whose source imagery has expired this store holds the only copy left anywhere, so the loss is the
211-
// error tier. The log deduplicates the repeat (LostMediaLogSpec pins that); the responses must not.
211+
// Nothing can re-fetch a pano the provider no longer serves, so the loss is the error tier. The log
212+
// deduplicates the repeat (LostMediaLogSpec pins that); the responses must not.
212213
val panoId = "sidewalkSpecNoSuchPano4926"
213214
val url = signingService.signedUrl(s"/backupImage/$panoId")
214215

test/js/healthMediaPanel.test.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -115,8 +115,8 @@ describe('HealthPage media storage panel', () => {
115115
});
116116

117117
it('says of a rebuildable directory that losing it costs a rebuild, not content', async () => {
118-
const crops = { ...OK_DIR, key: 'cropped.image.directory', irreplaceable: false };
119-
const { dirs } = await render({ directories: [crops], enforced: true, story_media: null });
118+
const shareImages = { ...OK_DIR, key: 'share.image.directory', irreplaceable: false };
119+
const { dirs } = await render({ directories: [shareImages], enforced: true, story_media: null });
120120

121121
expect(dirs).toContain('Yes — rebuilt on demand');
122122
});

test/modules/PersistentMediaDirCheckSpec.scala

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@ import scala.io.Source
99
import scala.util.Using
1010

1111
/**
12-
* The deployment contract behind the media directories: anything irreplaceable — user uploads, our only copies of
13-
* provider-expired panos — has to land outside the build output tree, because a deploy deletes that whole tree
12+
* The deployment contract behind the media directories: anything irreplaceable — user uploads, imagery that cannot
13+
* be re-fetched — has to land outside the build output tree, because a deploy deletes that whole tree
1414
* (`sbt clean`) and rebuilds it (#4925).
1515
*
1616
* Nothing else can catch a violation. The configuration that lost a story photo was correct in dev, correct in CI,
@@ -74,11 +74,12 @@ class PersistentMediaDirCheckSpec extends PlaySpec {
7474
}
7575

7676
"the fatal set" should {
77-
// Refusing to boot is only justified for bytes no rebuild can recreate: the story photos users gave us, and the
78-
// self-hosted pano store — it backs up GSV imagery Google has already expired, so for those panos it is the only
79-
// copy anywhere. Crops and share previews re-derive from them, so they must stay warn-only.
77+
// Refusing to boot is only justified for bytes no rebuild can recreate: the story photos users gave us, imagery
78+
// the providers no longer serve, and the label crops, each captured once in a labeler's browser. Cached share
79+
// previews rebuild on demand, so that one must stay warn-only.
8080
"be exactly the irreplaceable directories" in {
81-
persistentDirs.filter(_.irreplaceable).map(_.key) mustBe Seq("pano.images.directory", "story.media.directory")
81+
persistentDirs.filter(_.irreplaceable).map(_.key) mustBe
82+
Seq("cropped.image.directory", "pano.images.directory", "story.media.directory")
8283
}
8384

8485
// The failure message tells the operator which variable to set. If this mapping drifts from application.conf,

test/service/LostMediaLogSpec.scala

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ class LostMediaLogSpec extends PlaySpec {
8282
// The tiering is the same call PersistentMediaDirCheck makes, and it is what decides whether anyone is paged.
8383
val error = captured(_.reportMissing("pano", "abc", "/srv/panos/abc.jpg", irreplaceable = true))
8484
error.head.getLevel mustBe Level.ERROR
85-
val warn = captured(_.reportMissing("crop", "CurbRamp/7", "/srv/crops/crop_7.png", irreplaceable = false))
85+
val warn = captured(_.reportMissing("share_image", "7", "/srv/share/share_7.jpg", irreplaceable = false))
8686
warn.head.getLevel mustBe Level.WARN
8787
}
8888

test/service/MediaIntegritySpec.scala

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -87,30 +87,31 @@ class MediaIntegritySpec extends PlaySpec {
8787
}
8888
}
8989

90-
private val storyDir = persistentDirs.find(_.irreplaceable).value
90+
private val irreplaceableDir = persistentDirs.find(_.irreplaceable).value
9191

9292
// The permission branches can't be provoked through the filesystem from a suite that runs as root — which CI and
9393
// the dev container both do, and where chmod 000 still reads and writes fine — so they are pinned on the rules
9494
// themselves.
9595
"dirStatus" should {
9696
"call a directory this process cannot read bad, since nothing in it can be verified" in {
9797
val probe = MediaIntegrity.DirProbe(exists = true, readable = false, writable = true)
98-
val status = MediaIntegrity.dirStatus(storyDir, "/srv/media", probe, None, enforced = true)
98+
val status = MediaIntegrity.dirStatus(irreplaceableDir, "/srv/media", probe, None, enforced = true)
9999
status.status mustBe "not_readable"
100100
status.severity mustBe "bad"
101-
status.detail.value must include(storyDir.envVar)
101+
status.detail.value must include(irreplaceableDir.envVar)
102102
}
103103

104104
"call a directory this process cannot write to bad, since uploads will fail against it" in {
105105
val probe = MediaIntegrity.DirProbe(exists = true, readable = true, writable = false)
106-
val status = MediaIntegrity.dirStatus(storyDir, "/srv/media", probe, None, enforced = true)
106+
val status = MediaIntegrity.dirStatus(irreplaceableDir, "/srv/media", probe, None, enforced = true)
107107
status.status mustBe "not_writable"
108108
status.severity mustBe "bad"
109109
}
110110

111111
"report an unsafe directory before either permission, since a deploy deleting it outranks both" in {
112112
val probe = MediaIntegrity.DirProbe(exists = true, readable = false, writable = false)
113-
val status = MediaIntegrity.dirStatus(storyDir, "/srv/media", probe, Some("in the wipe zone"), enforced = true)
113+
val status =
114+
MediaIntegrity.dirStatus(irreplaceableDir, "/srv/media", probe, Some("in the wipe zone"), enforced = true)
114115
status.status mustBe "unsafe"
115116
}
116117
}

0 commit comments

Comments
 (0)