feat(janitor): reap expired pending deletes nightly - #882
Merged
Merged
Conversation
ajslater
force-pushed
the
feat/pending-deletes-reaper
branch
from
September 21, 2026 08:52
778478d to
16c7f78
Compare
This was referenced Sep 21, 2026
ajslater
added this pull request to stack #891
September 21, 2026 16:20
ajslater
force-pushed
the
feat/pending-deletes-reaper
branch
from
September 21, 2026 16:20
16c7f78 to
cd1544c
Compare
ajslater
force-pushed
the
feat/pending-deletes-reaper
branch
from
September 21, 2026 16:21
cd1544c to
accfa4b
Compare
ajslater
force-pushed
the
feat/pending-deletes-reaper
branch
from
September 21, 2026 16:22
accfa4b to
3ec022c
Compare
Last of the pending-delete mechanism. Until now nothing ever really deleted a stamped row, which was safe but leaked rows indefinitely. The window is a bare module constant, not a setting. It is a safety delay before an irreversible action rather than a tuning knob, and the nearest precedent is exact: the telemeter's 24-hour opt-out grace is a bare constant that is documented to admins in prose while still not being configurable. An AdminFlag would cost roughly ten files and would be the only configurable knob in the whole delete-safety story. The effective window is 24h plus the time to the next nightly run, which is what "24 hours or more" meant. Priority index 17, the first slot in the contiguous cleanup band. The FK cleanup must follow it -- while a stamped comic still exists its Publisher/Imprint/Series/Volume are not orphaned, so a reaper later in the tuple leaves emptied groups visible until the next night -- and so must the search sync, which drops FTS rows whose comic no longer exists. Not earlier, because 11-16 are tag-writing and import tasks and a bulk DELETE under the write lock should not stall a user-initiated import behind maintenance. The folder guard uses Exists subqueries rather than a join predicate on purpose: on a LEFT OUTER JOIN a folder with no comics at all is NULL-extended, so `exclude(comic__missing_since__isnull=True)` reads its absent comic as a live one and the folder can never be reaped. Caught by the test for exactly that case. Covers are removed here, because the stamp deliberately left them in place so a revived row would find its own. And the job publishes its own cache clear, collection re-stamp and library.changed broadcast -- the janitor has no importer finish() to do it, and cleanup_fks notifying nothing is a gap rather than a precedent. Twelve registrations, not the three CLAUDE.md names. Omitting the priority tuple is the silent-failure mode: get_task_priority raises ValueError and the job never runs. A test asserts all of them. One corrected premise: LibrarianStatus.status_type's max_length is derived from the choice *keys*, which are all three characters, so the column width does not move. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ajslater
force-pushed
the
feat/pending-deletes-reaper
branch
from
September 21, 2026 16:22
3ec022c to
312d103
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Implements P1e of
tasks/followups-implementation-plan.md§4.8 — the last of the pending-delete mechanism.Until this lands nothing ever really deletes a stamped row. That is a safe state, but it leaks rows indefinitely.
The window is a constant, not a setting
A safety delay before an irreversible action, not a tuning knob. The precedent is nearly exact: the telemeter already has a 24-hour grace window whose purpose is "give the admin time to opt out before anything irreversible happens", it is a bare constant (
_ONE_DAY), and it is documented to admins in prose while still not being configurable. The nearest neighbour in the delete path is also a commented constant pair (_MASS_DELETE_FLOOR/_MASS_DELETE_FRACTION).An
AdminFlagwould cost roughly ten files (enum, title, seed default, data migration,settings/db.pyhelper, serializer validation, two JSON description files, av-else-ifarm, a Stats field, plusmake build-choices) and would be the only configurable knob in the whole delete-safety story.The effective window is 24h plus the time to the next nightly run, which is exactly what "24 hours or more" meant. Said so in the docstring.
Priority: index 17
Verified in place:
UpdateCollectionsTask→JanitorReapPendingDeletesTask→JanitorCleanFKsTask.cleanup_fksruns a convergence loop, so it cleans the whole chain in the same pass.Two tests pin the ordering relative to
JanitorCleanFKsTask,SearchIndexSyncTaskandImportTask.The folder guard, and the trap in it
A folder delete cascades to its comics through
Comic.parent_folderand to sub-folders throughFolder.parent_folder, so reaping a stamped ancestor would take live descendants — and their bookmarks — with it.The obvious implementation is wrong, and the test caught it:
Replaced with explicit
~Exists(live_comics)/~Exists(live_subfolders)subqueries, which have no NULL-extension semantics to get wrong. Comics are reaped first, then folders, because reaping the comics is what can make a folder eligible.Twelve registrations, not three
CLAUDE.md names three. The actual list is twelve, and omitting #8 is the silent-failure mode —
get_task_priorityraisesValueError: tuple.index(x): x not in tupleand the job simply never runs.Task class · status class ·
JANITOR_STATII· migration ·_JANITOR_STATII·_NIGHTLY_TASKS·_JANITOR_METHOD_MAP·_SCRIBE_TASK_PRIORITY·_JANITOR_NIGHTLY_STATUSES· the Jobs-tab entry ·_TASK_MAP·make build-choices.test_the_job_is_fully_registeredasserts all of them.One corrected premise: the plan warned that
max_length=max_choices_len(StatusChoices)is computed from the labels, so a long title would change the column width. It is computed from the choice keys (choice[0]), which are all three characters.max_length=3is unchanged; migration0056only alterschoices."Reap now" needed no new endpoint
Registrations #10 and #11 make it POST-able through the existing
POST /admin/tasks/run+librarianTask(...)store action, exactly as Poll and Force Update work from the library rows — which gets the Jobs-tab entry, the sidebar progress row and the abort plumbing for free. The job carries aconfirmstring, since it permanently deletes read progress.Publishing
The job clears its own caches, re-stamps the reaped comics' collections through
TimestampUpdater, and enqueuesLIBRARY_CHANGED_TASKitself — the janitor has no importerfinish()to do it, andcleanup_fksnotifying nothing is a gap, not a precedent. Collections are gathered before the delete, since afterwards there is nothing to read them off.It also enqueues
CoverRemoveTask: #880 deliberately left covers in place so a revived row would find its own, so the reaper owns removing them.Scheduling
Untouched. The nightly trigger stays the cron thread; commit
11b3210b4fixed "nightly janitor running dozens of times each midnight" and nothing here adds a second wake-up.Tests
Eight cases in
TestReaper: a fresh stamp is not reaped, an expired one is, a live comic is never touched, a folder with a live comic is spared (with its comic), a folder whose comics all expired is reaped, the announcement, the twelve registrations, and the ordering.tests/test_scribe_priority.pystill passes.make build-choicesrun.make fix && make lint && make tyclean;makemigrations --checkanddjango-checkclean. Fullmake testgreen: 1294 pytest (+8), 579 vitest.No new NEWS line — #881's covers the user-visible behaviour.
Known limitation, worth stating
poll_forcecannot recover a stamped library — it setsstat[8] = 0.0on every DB row and skips the stale-stat refresh, so every path reads as modified, never as revived. That matters because Force Poll is the button an admin will press. The admin surface (P1f) should say so, or the force path should run the unstamp pass.🤖 Generated with Claude Code