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
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
This commit is contained in:
@@ -317,7 +317,7 @@ def _move_one(src: Path, dest: Path) -> str | None:
|
||||
|
||||
|
||||
def apply_run(
|
||||
session: Session, run: LibraryPlacementRun,
|
||||
session: Session, run: LibraryPlacementRun, *, chunk: int = 0,
|
||||
) -> LibraryPlacementRun:
|
||||
"""Execute a `ready` run's stored plan: file and row together, per row.
|
||||
|
||||
@@ -325,13 +325,38 @@ def apply_run(
|
||||
move can never leave `ImageRecord.path` pointing at a file that is not
|
||||
there. Refusals are recorded and the run continues — one row that moved
|
||||
on since planning is not a reason to abandon the other 33,788.
|
||||
|
||||
`chunk` commits progress every N moves. Set it for any real run: the
|
||||
ledger is the ONLY record of where a file came from, so a worker that
|
||||
dies at row 30,000 of 33,789 must not take the undo information for the
|
||||
first 29,999 with it. Left at 0 (tests, small runs) everything persists
|
||||
in one go at the end.
|
||||
|
||||
Re-running a partially-applied plan is safe rather than clever: the rows
|
||||
already moved no longer match their `from`, so they refuse as "row moved
|
||||
since planning" instead of being moved twice.
|
||||
"""
|
||||
if run.status != "ready":
|
||||
raise ValueError(f"run {run.id} is {run.status}, not ready")
|
||||
|
||||
refusals: list[dict] = []
|
||||
moved: list[dict] = []
|
||||
for move in run.moves:
|
||||
|
||||
def _persist() -> None:
|
||||
# Reassign rather than mutate: SQLAlchemy does not track in-place
|
||||
# changes to a JSONB list, so an .append() alone would never reach
|
||||
# the database and the ledger would silently stay empty.
|
||||
run.moves = list(moved)
|
||||
run.refusals = list(refusals)
|
||||
run.moved_count = len(moved)
|
||||
run.refused_count = len(refusals)
|
||||
session.commit()
|
||||
|
||||
# Snapshot the plan before iterating: `_persist` reassigns `run.moves`,
|
||||
# and iterating the attribute while rewriting it would walk a list that
|
||||
# changes underneath the loop.
|
||||
plan = list(run.moves)
|
||||
for done, move in enumerate(plan, start=1):
|
||||
record = session.get(ImageRecord, move["image_id"])
|
||||
if record is None:
|
||||
refusals.append({"image_id": move["image_id"], "reason": "row gone"})
|
||||
@@ -349,6 +374,8 @@ def apply_run(
|
||||
continue
|
||||
record.path = move["to"]
|
||||
moved.append(move)
|
||||
if chunk and done % chunk == 0:
|
||||
_persist()
|
||||
|
||||
run.moves = moved
|
||||
run.refusals = refusals
|
||||
@@ -372,6 +399,12 @@ def revert_run(
|
||||
|
||||
Refuses the same way the apply does: a file someone has since moved or
|
||||
replaced stays where it is, and its row is left alone.
|
||||
|
||||
A revert interrupted half way is resumable by re-running it: the rows
|
||||
already put back no longer sit at `to`, so they refuse rather than move
|
||||
twice. Unlike the apply this needs no chunked persistence — it consumes
|
||||
the ledger rather than producing it, so a crash costs progress, not
|
||||
information.
|
||||
"""
|
||||
if run.status != "applied":
|
||||
raise ValueError(f"run {run.id} is {run.status}, not applied")
|
||||
|
||||
Reference in New Issue
Block a user