Nothing surfaces a back scan whose front never arrived #143
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Severity: MEDIUM — a small, permanently invisible pile in a large archive
Found while building #105.
#105 introduces the orphan back: a
_bscan whose front is not in the archive isingested and kept rather than rejected, because the bytes of an irreplaceable original
should not be discarded over a filename. It is a
photorow withstorage_key_frontNULL and
storage_key_backset, andPhotoOut.has_frontreports the state per row.But there is no way to ask for the list.
list_photoshas no filter for it, so"which backs never found a front" is answerable only by opening SQL against the
database. On a 5,000-scan archive assembled over months that is exactly the list
somebody needs — each entry is either a front that was never scanned, or a filename
that does not follow the convention closely enough to pair. Both are worth acting on,
and neither is visible.
The fold in #105 means an orphan back resolves itself the moment its front turns up in
any later batch, so this list is self-clearing for the ordinary case. What is left in it
after an import is the genuine exceptions — which is precisely what makes it worth
showing.
Scope
GET /api/photosfor it. Note the shape it should take: the state isderived (
storage_key_front IS NULL), not a column, so this needs the same care asevery other listing predicate here — see below.
flags it sits naturally with.
The index trap, stated up front so it is not rediscovered
This repo has hit "an index whose leading terms match a filter can be catastrophically
mis-chosen" four times (#88, #94, #106, #141). Migration 014 measured a non-partial
(collection_id, deleted_at, id)sending every listing from 0.06 ms to ~250 ms —3,800x — with the answers still correct, so nothing above the query plan would report
it. If this filter gets an index it must be partial on the orphan-back predicate,
the way
ix_photo_collection_deletedis, so a live listing cannot choose it. Measurebefore and after and put the numbers in the migration, as 012, 014 and 015 do.
Also worth checking rather than assuming: orphan backs are rare, so the filter may not
need an index at all — the excluded view's numbers in 014 suggest a predicate matching
a few percent of rows is cheap to evaluate over an index that already carries the order.
References
backend/app/repositories/photos.py—list_photosbackend/alembic/versions/014_photo_exclusion.py— the measured index trapDone in
50c3eb6.back_onlyonGET /api/photos, and a "Back only" checkbox in the browser's flag filters — the same phrase the tile badge and the workspace have used since #105, because a filter for those rows under another name would read as a second concept.The predicate asks both storage keys, not just
storage_key_front IS NULL. A row with neither key is not a print waiting for its front, and this list exists to be worked through — every entry should be a front to re-scan or a filename to correct.The index: I was wrong in this issue, and the measurement says so
This issue told whoever picked it up that an index "may not be needed at all — the excluded view's numbers in 014 suggest a predicate matching a few percent of rows is cheap to evaluate over an index that already carries the order."
That reasoning does not hold, and it fails in the opposite direction to the one I assumed. 014's analogy works for
deleted_atbecause that column sits inside every partial index'sWHEREclause.storage_key_frontis in no index at all, so each candidate row costs a table fetch, and a page of 51 is only reached after walking far enough to find 51 matches. Rarity therefore makes the filter more expensive, not less — the cost peaks when the answer is empty, which is the steady state of a healthy archive.Measured at 50,000 rows in one collection, 014's seeding proportions, best of 18:
The 014 catastrophe does not reproduce here, including with a plain index — worth recording, because this issue's warning implied it would. 014 needed the live listing to carry a term on the indexed column, so SQLite read
deleted_at IS NULLas an equality constraint and preferred that index over the one carrying the sort. No listing carries a term onstorage_key_front, so a plain index on it is simply never chosen.Still no index, for a better reason. The other flag filters cost the same and have shipped since #94:
rescan_requested, page 1missing_page_gap_after, page 1has_duplicate, page 1Same plan, same cost, same absence of an index. Indexing only the newest member buys an eighth index on
photo, paid on every ingest, against a cost the whole family already pays — and 11 ms for a view opened after an import sits inside what this repo already accepts (014 left the excluded queue in date order at 5.5 ms; 015 left an album read in album order at 15.4 ms). If the flag-filter family is ever worth indexing, that is one measured piece of work covering all four, not this issue.The counterfactual is recorded rather than discarded: a partial index on the orphan-back predicate takes page one to 0.024 ms for 4–68 KB, and is pinned with its numbers in
test_query_plans.py::TestTheBackOnlyFilterIsDeliberatelyUnindexed, following 015's precedent so a later reader does not mistake the absence for an oversight.The count: left out, on the stop-condition
130.9 ms. There is no
LIMITto stop at, so it fetches every live row in the collection to read a column no index carries — on a dashboard that loads on every visit. Folding it intocount_by_statusis worse: that query is index-only at 6.3 ms today, and aSUM(CASE …)forces a row fetch and takes the whole thing to ~131 ms.Two secondary reasons it is also the wrong shape:
excludedis inPhotoStatsOutbecause excluded photographs leave the status counts, so the number explains a discrepancy — an orphan back is still counted under its status, so aback_onlycount explains nothing. And the only consumer would be the dashboard. The one-line path if it is ever wanted is the partial index above (0.027 ms) plus acount_back_only.A hole in the safety net, found on the way
This issue's spec said the contract tests would notice a new query parameter. They did not — both passed unchanged, because nothing in either asserted that a browser filter reaches the published document at all. A parameter added to the repository and forgotten on the route would have passed both suites silently.
That is now
TestTheListFiltersAreInTheContract, parametrized over the whole flag family, asserting each is declared, optional, andboolean | null— the tri-state being what a generated client gets wrong — with a companion pinningexcludedas the deliberate two-state exception.Thirteen mutations, all caught. One composition test survived the first run — its album held only the orphan back, so the album filter alone gave the right answer — and was rebuilt so neither half of the request can answer alone.