Commit Graph
4 Commits
Author SHA1 Message Date
bvandeusenandClaude Opus 5 abe449b4f2 feat: placement reconciler tasks + API (4246, slice 3b)
CI / lint (push) Successful in 3s
CI / extension-version (push) Successful in 3s
Build images / sign-extension (push) Successful in 4s
Build images / build-agent (push) Successful in 7s
CI / frontend-build (push) Successful in 29s
CI / backend-lint-and-test (push) Successful in 1m1s
Build images / build-web (push) Successful in 1m34s
Build images / smoke-web (push) Skipped
Build images / build-ml (push) Successful in 2m26s
Build images / promote (push) Skipped
CI / integration (push) Failing after 3m5s
Three Celery tasks wrapping the 3a service, and the endpoints that drive
them. Routed to `maintenance_long` alongside backups: 33k renames on NFS have
no business in the quick lane where the self-healing sweeps live (the
2026-06-07 starvation).

A durability bug in 3a, found by thinking about what a crash costs rather
than by a failing test: `apply_run` wrote its ledger only at the end, so a
worker dying at row 30,000 of 33,789 would have taken the undo information
for the first 29,999 with it — and that ledger is the ONLY record of where
those files came from. It now persists every 200 moves. Two things fell out
of writing that:

- `_persist` reassigns `run.moves`, so the loop had to snapshot the plan
  first rather than iterate the attribute it rewrites.
- the reassignment is itself load-bearing: SQLAlchemy does not track in-place
  mutation of a JSONB list, so an `.append()` alone would never reach the
  database and the ledger would have stayed silently empty.

Re-running a partially-applied plan is safe — the moved rows no longer match
their `from` and refuse as "row moved since planning" — but `apply_placement`
deliberately has NO autoretry: re-entering a half-applied plan should be the
operator's call after reading what happened, not the queue's.

Endpoints gate on run state as well as the service does, so a stray POST
cannot re-apply an applied run. The list response omits `moves` (an applied
whole-library run carries tens of thousands of entries); the detail endpoint
includes them, because that detail IS the preview read before agreeing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR
2026-09-21 14:13:25 -04:00
bvandeusenandClaude Opus 5 9ccc460c69 feat: placement reconciler — plan, apply, revert (4246, slice 3a)
CI / lint (push) Successful in 2s
CI / extension-version (push) Successful in 2s
Build images / sign-extension (push) Successful in 4s
Build images / build-agent (push) Successful in 11s
CI / frontend-build (push) Successful in 29s
CI / backend-lint-and-test (push) Successful in 1m1s
Build images / build-web (push) Successful in 1m11s
Build images / smoke-web (push) Skipped
Build images / build-ml (push) Successful in 1m56s
Build images / promote (push) Skipped
CI / integration (push) Successful in 2m43s
Milestone #421 step 3, reframed on the operator's steer: not a one-off
migration but the system that keeps the tree true. The 33,789 misplaced rows
the survey found are just its first run.

The placement half was already done, verified by reading each writer rather
than assuming: downloads have always written `<root>/<slug>/<platform>/`
(gallery_dl.py:523), attach_in_place leaves files where the downloader put
them, and `_copy_to_library` / `_supersede` became canonical in #4244. So
nothing is written off-canon today; what remains is the backlog and a standing
check for future drift.

`LibraryPlacementRun` (migration 0099) holds the plan as JSONB, and that one
structure does three jobs: it is the PREVIEW the operator reads, the list the
APPLY executes (rather than re-deriving the set, so the two cannot disagree),
and — because `from` is retained — the UNDO.

The undo is the point. It makes a 33,789-file operation something to do one
artist at a time, look at in the gallery, and reverse if it reads wrong. That
settles whether artist_id or the folder held the truth (spike #4257) by doing
rather than by arguing it from a 50-row sample.

An applied run is therefore HISTORY, not state — lesson #4226's trap, since
it is the only record of where those files used to be. The model and the
migration both say so: any future retention here may prune ready/cancelled/
error runs, never an applied one.

Everything fails closed. The apply re-checks each row against what the plan
recorded — source still there, destination still free, row still pointing
where the plan said — because a download or a supersede can land in between.
A refusal is recorded with its reason and the run continues; one stale row is
not a reason to abandon the other 33,788. The row is updated only after its
rename lands, so a failed move can never leave `path` naming a file that is
not there.

Writing the collision test caught the code disagreeing with its own comment:
it claimed the first of two rows wanting one destination and skipped the
second, silently picking a winner by iteration order. Now it counts first and
filters after, so genuinely neither is planned.

Thumbnails are sha-addressed, not path-keyed, so they do not move — pinned by
a test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR
2026-09-21 12:46:06 -04:00
bvandeusenandClaude Opus 5 9728407539 docs: record why the misplaced-rows LIKE needs no escaping (4245)
CI / lint (push) Successful in 3s
CI / extension-version (push) Successful in 3s
Build images / sign-extension (push) Successful in 4s
Build images / build-agent (push) Successful in 9s
CI / frontend-build (push) Successful in 23s
CI / backend-lint-and-test (push) Successful in 35s
Build images / build-web (push) Successful in 1m5s
Build images / smoke-web (push) Skipped
Build images / build-ml (push) Successful in 1m55s
Build images / promote (push) Skipped
CI / integration (push) Successful in 2m26s
`startswith` compiles to LIKE, where `_` and `%` are wildcards, and the call
does not escape them. That is safe only because `slugify` reduces a slug to
[a-z0-9-] — an invariant living in a different module, which is exactly the
kind of thing that gets widened later without anyone connecting the two.

Worth naming because `poch4n_art` is a real directory here: if slugs ever
carried underscores, that prefix would start matching `poch4nXart` and the
sweep would quietly mis-file one artist's rows.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR
2026-09-21 11:05:37 -04:00
bvandeusenandClaude Opus 5 fc982f74b9 feat: survey which image rows sit outside their artist's canonical directory (4245)
CI / lint (push) Successful in 3s
CI / extension-version (push) Successful in 3s
Build images / sign-extension (push) Successful in 4s
Build images / build-agent (push) Successful in 7s
CI / frontend-build (push) Successful in 25s
CI / backend-lint-and-test (push) Successful in 48s
Build images / build-web (push) Successful in 1m14s
Build images / smoke-web (push) Skipped
Build images / build-ml (push) Successful in 2m6s
Build images / promote (push) Skipped
CI / integration (push) Successful in 2m34s
Step 2 of milestone #421. The disk survey counted FOLDERS; this counts ROWS,
which is the number that matters — every move in step 3 is a row update, and
`ImageRecord.path` is the only pointer at the bytes.

`library_layout.py` holds the decision in two shared pieces, and both halves
of the consolidation spread them rather than restating them (rule 93, the
_x_conditions shape from snippet #3087):

- `_misplaced_conditions(root, artist_id, slug)` — rows of one artist whose
  file is not under that artist's directory. The prefix carries a trailing
  separator deliberately: without it `ara` matches everything under
  `arbuzbudesh/`, and one artist reads as fully placed while another's rows
  are silently skipped. Both are real artists here, hence the test.
- `destination_for(path, root, slug)` — where a row's file belongs, or None
  when it must not be moved: outside the images root, or under one of the
  reserved stores (`thumbs`, `attachments`, `cookies`, `secrets`, `_backups`,
  `_quarantine`). Relocating those would move the thumbnail cache or the
  credential key into an artist folder.

`destination_for` diverges from `canonical_subdir` in exactly one case, and
the docstring says why: a file at the images ROOT with a known artist moves
under that artist here, where the import-time helper leaves it alone. The two
answer different questions — an empty subdir at import means no artist was
resolved, while a row that already carries an artist_id is an anomaly with a
known correct home. The 660 unattributed files have no artist_id at all, so
no predicate reaches them; they are counted and left for task #4247.

`GET /api/cleanup/layout` exposes it. `?check_disk=1` additionally stats every
destination for collisions and missing sources — the conditions the apply
refuses on — but it is off by default so the count-only pass answers "how big
is this" in seconds instead of timing the request out on NFS.

Nothing here writes; a test asserts that against both the row and the file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR
2026-09-21 11:03:03 -04:00