fix(thumbs): the cache aged the booth it cached, and two more surfaces

Two corrections to the thumbnail work, the first of them a live bug shipped an
hour ago and caught by design-dev before its worst form landed.

⚠ GENERATING A THUMBNAIL RESET THE BOOTH'S EXPIRY CLOCK. `_newest_mtime`
excludes `.lock` sidecars because machinery is not the operator doing something;
the thumbnail cache is machinery too, and it is written by the SERVER on a mere
view. Excluding the cache's CONTENTS turned out not to be enough — creating
`.thumbs/` touches the BOOTH DIRECTORY's own mtime, which is exactly what
_newest_mtime seeds from. The booth's stamp is now restored across the mkdir,
which cannot hide real activity because any file an agent adds is counted by its
own mtime in the same walk.

The failure this prevents is not small. Once the Desk's preview strip pulls a
thumbnail per booth, ONE INDEX LOAD would have pushed every booth's expiry out
and the TTL would never have fired again — nothing would ever sweep. It was
already live for the gallery, one booth at a time.

TWO MORE SURFACES, because the fix only helped where it was wired:

  Desk preview strip  four small images per booth on the page he opens FIRST.
                      design-dev measured 28 originals / 24.1 MB on a 12-booth
                      copy; live has 28. The heaviest surface in the service,
                      heavier than the gallery it previews.
  flag tray           _marks.html rendered originals as tray thumbnails.

The review stage stays on the original, because that is the full-size review.

754 green plus the new guards.
This commit is contained in:
vh
2026-09-23 10:51:40 -07:00
parent d5e23c7d5f
commit 18d599dd2a
5 changed files with 89 additions and 3 deletions
+16 -2
View File
@@ -144,7 +144,7 @@ def set_blurred(booth: Path, rel: str, on: bool) -> set[str]:
# The link-board logic lives in booth/links.py (stdlib only) so the `booth` CLI
# can use it without pulling FastAPI in. Re-exported here because call sites and
# tests already reference these names through app.
from booth.thumbs import ensure_thumb
from booth.thumbs import THUMB_DIR, ensure_thumb
from booth.asks import ( # noqa: E402
ANSWER_SUFFIX,
ASK_SUFFIX,
@@ -248,6 +248,15 @@ def _newest_mtime(path: Path) -> float:
for p in path.rglob("*"):
if p.name.startswith(".") and p.name.endswith(".lock"):
continue
# ⚠ AND THE THUMBNAIL CACHE, for the same reason as the locks: it is
# MACHINERY, not the operator doing something, and it is written by the
# SERVER on a mere view. Counting it made looking at a booth age it —
# and once the Desk's preview strip pulls a thumbnail per booth, one
# index load would push EVERY booth's expiry out and the TTL would
# never fire again. `.viewed` counting is different and deliberate:
# that is a record of a person looking, which U4 says is activity.
if THUMB_DIR in p.relative_to(path).parts:
continue
try:
m = p.stat().st_mtime
except FileNotFoundError:
@@ -674,7 +683,12 @@ def list_booths(data_dir: Path, ttl_seconds: float, now: float | None = None) ->
"viewed_at": _viewed_at(child),
# The first four images in item order, as the originals shown
# small. Blurred ones stay blurred, the cover's rule.
"preview": [(it.url, it.blurred) for it in items if it.kind == "image"][:4],
# THE THUMBNAIL, not the original: this strip is four small
# images per booth on the page he opens FIRST, so on 28 booths
# it was the heaviest surface in the service — heavier than the
# gallery it previews. `thumb` already carries `?thumb=1`.
"preview": [(it.thumb or it.url, it.blurred)
for it in items if it.kind == "image"][:4],
}
)
# Newest first, NAME as the tie-break. Sorting on mtime alone left equal-mtime