diff --git a/startup_tasks/2026_10_02_reapply_non_bisac_nonfiction_reset.py b/startup_tasks/2026_10_02_reapply_non_bisac_nonfiction_reset.py new file mode 100644 index 0000000000..7ab2b66d05 --- /dev/null +++ b/startup_tasks/2026_10_02_reapply_non_bisac_nonfiction_reset.py @@ -0,0 +1,38 @@ +"""Re-apply the non-BISAC nonfiction reset now that no old code is running. + +Startup task ``2026_09_14_reclassify_non_bisac_nonfiction_subjects`` repaired +these subjects a release ago, but its reset was exposed to any old code still +live: hosting-playbook's ``helpers/migrate.yml`` recycles the web containers +after the migrate step, and does not govern Fargate deployments at all. Old +code reaching ``Subject.assign_to_genre`` consumes the reset and re-stamps +``checked=True`` with the same wrong value; see that task's docstring for the +detail. A release later nothing old is running anywhere, so the same reset is +safe to apply again. + +It recomputes which subjects the classifier disagrees with rather than +replaying a stored list, so if the first repair took this is a no-op. +Re-scoring is left to the nightly ``classify_unchecked_subjects``; the release +N task chained it only because it was racing old code. + +This mirrors the null-audience repair, where ``2026_06_17`` re-ran what +``2026_05_12`` had dispatched a release earlier. + +TODO: Remove the whole repair once this has run on all deployments (PP-5129): +this task, the release N startup task, ``reset_non_bisac_nonfiction_subjects`` +with its script and bin wrapper, and +``BISACClassifier.contradicts_stored_fiction``, whose only non-test caller is +that task.""" + +from __future__ import annotations + +import logging + +from celery.canvas import Signature +from sqlalchemy.orm import Session + +from palace.manager.celery.tasks.work import reset_non_bisac_nonfiction_subjects +from palace.manager.service.container import Services + + +def run(services: Services, session: Session, log: logging.Logger) -> Signature | None: + return reset_non_bisac_nonfiction_subjects.s() diff --git a/tests/manager/scripts/test_startup.py b/tests/manager/scripts/test_startup.py index d9d357062f..190521f56a 100644 --- a/tests/manager/scripts/test_startup.py +++ b/tests/manager/scripts/test_startup.py @@ -15,7 +15,9 @@ from palace.util.datetime_helpers import utc_now +from palace.manager.celery.tasks.work import reset_non_bisac_nonfiction_subjects from palace.manager.scripts.startup import ( + STARTUP_TASKS_DIR, _slugify, create_startup_task, discover_startup_tasks, @@ -94,6 +96,42 @@ def test_discover_nonexistent_directory( assert result == {} assert "does not exist" in caplog.text + def test_discover_shipped_startup_tasks(self) -> None: + """Every task we actually ship imports and exposes a usable run(). + + The other tests here point discovery at `tmp_path`, so nothing else + imports `startup_tasks/` -- a task that fails to load would ship green. + Discovery logs and skips a module it cannot import, so compare against + the files on disk rather than just checking what came back. + """ + expected = { + path.stem + for path in STARTUP_TASKS_DIR.glob("*.py") + if not path.stem.startswith("_") + } + + result = discover_startup_tasks(STARTUP_TASKS_DIR) + + assert expected, f"No startup tasks found in {STARTUP_TASKS_DIR}." + assert set(result) == expected + assert all(callable(run) for run in result.values()) + + def test_reapply_non_bisac_nonfiction_reset(self) -> None: + """The re-apply task queues the reset, and only the reset. + + Unlike the release N task it deliberately does not chain the re-score. + + TODO: Remove with the rest of the repair (PP-5129). + """ + run = discover_startup_tasks(STARTUP_TASKS_DIR)[ + "2026_10_02_reapply_non_bisac_nonfiction_reset" + ] + + signature = run(MagicMock(), MagicMock(), logging.getLogger()) + + assert isinstance(signature, Signature) + assert signature.task == reset_non_bisac_nonfiction_subjects.name + class RunStartupTasksFixture: def __init__(self, mock_discover_startup_tasks) -> None: