From d02999e2837a478c3290e04c6661a13ea0b2f7ca Mon Sep 17 00:00:00 2001 From: Daniel Bernstein Date: Mon, 14 Sep 2026 11:17:56 -0700 Subject: [PATCH 1/4] Make the non-BISAC nonfiction reset re-runnable (PP-5129) Migration 52d1bbdd4671 repairs subjects that were stored as nonfiction because an unresolvable BISAC code fell through the ruleset catch-all. It only sets checked=false; the re-scoring happens later, in classify_unchecked_subjects. That leaves a window. If old code reaches those subjects first -- a still-running scripts server, or a host that redeploys itself via watchtower or ECS/Fargate auto_restart -- it re-scores them under the superseded rules and re-stamps checked=true. The reset is consumed rather than lost, so nothing errors, nothing retries, and no later run revisits them. The repair quietly did nothing, having paid for a full reindex. Raised in review on #3726; it is not preventable from inside the migration, so make it recoverable instead. Adds reset_non_bisac_nonfiction_subjects, a Celery task that re-applies the reset, with ResetNonBisacNonfictionSubjectsScript and bin/work_reset_non_bisac_nonfiction_subjects to queue it -- the same three layers as reclassify_null_audience_works, which repairs the sibling audience defect. The task resets only. Re-scoring stays with classify_unchecked_subjects, which picks these subjects up on its next nightly run and can be triggered immediately through bin/work_classify_unchecked_subjects. One task, one job, and no large reindex fired the moment someone runs the repair. The selection lives on BISACClassifier as contradicts_stored_fiction, so the migration and the task cannot disagree about which rows are affected. Both already import the classifier, so this adds no coupling that was not there, and it puts the definition with the code that owns the answer. The migration is updated to use it. Co-Authored-By: Claude Opus 5 --- bin/work_reset_non_bisac_nonfiction_subjects | 11 ++++ src/palace/manager/celery/tasks/work.py | 50 ++++++++++++++++ src/palace/manager/core/classifier/bisac.py | 30 ++++++++++ src/palace/manager/scripts/work.py | 20 +++++++ tests/manager/celery/tasks/test_work.py | 61 ++++++++++++++++++++ tests/manager/core/classifiers/test_bisac.py | 42 ++++++++++++++ tests/manager/scripts/test_work.py | 11 ++++ 7 files changed, 225 insertions(+) create mode 100755 bin/work_reset_non_bisac_nonfiction_subjects diff --git a/bin/work_reset_non_bisac_nonfiction_subjects b/bin/work_reset_non_bisac_nonfiction_subjects new file mode 100755 index 0000000000..00e84b51f1 --- /dev/null +++ b/bin/work_reset_non_bisac_nonfiction_subjects @@ -0,0 +1,11 @@ +#!/usr/bin/env python +"""Queue the reset_non_bisac_nonfiction_subjects Celery task. + +Convenience wrapper that manually dispatches the repair for BISAC subjects +stored as nonfiction in error (the `reset_non_bisac_nonfiction_subjects` Celery +task) for a worker to process. +""" + +from palace.manager.scripts.work import ResetNonBisacNonfictionSubjectsScript + +ResetNonBisacNonfictionSubjectsScript().run() diff --git a/src/palace/manager/celery/tasks/work.py b/src/palace/manager/celery/tasks/work.py index 32c10c52bf..1241419290 100644 --- a/src/palace/manager/celery/tasks/work.py +++ b/src/palace/manager/celery/tasks/work.py @@ -4,6 +4,7 @@ from sqlalchemy.orm import Session from palace.manager.celery.task import Task +from palace.manager.core.classifier.bisac import BISACClassifier from palace.manager.data_layer.policy.presentation import PresentationCalculationPolicy from palace.manager.service.celery.celery import QueueNames from palace.manager.sqlalchemy.model.classification import Classification, Subject @@ -39,6 +40,55 @@ def reclassify_null_audience_works(task: Task) -> None: session.commit() +@shared_task(queue=QueueNames.default, bind=True) +def reset_non_bisac_nonfiction_subjects(task: Task) -> None: + """Re-apply the reset that repairs subjects stored as nonfiction in error. + + Migration 52d1bbdd4671 marks these subjects unchecked so that + classify_unchecked_subjects re-scores them. That reset can be consumed + before it takes effect: if old code reaches the subjects first -- a + still-running scripts server, or a host that redeploys itself -- it + re-scores them under the superseded rules and re-stamps checked=True. + Nothing errors, and nothing revisits them afterwards, so the repair + quietly did nothing. This task exists to run the reset again. + + It resets only. The re-scoring stays with classify_unchecked_subjects, + which picks these subjects up on its next nightly run; trigger + bin/work_classify_unchecked_subjects to have it happen sooner. + + Idempotent: a second run finds nothing to do. + """ + with task.session() as session: + candidates = ( + session.query(Subject.id, Subject.identifier, Subject.name) + .filter( + Subject.type == Subject.BISAC, + Subject.checked == True, # noqa: E712 + Subject.fiction == False, # noqa: E712 + ) + .all() + ) + + stale_ids = [ + row.id + for row in candidates + if BISACClassifier.contradicts_stored_fiction( + row.identifier, row.name, False + ) + ] + + if stale_ids: + session.query(Subject).filter(Subject.id.in_(stale_ids)).update( + {Subject.checked: False}, synchronize_session=False + ) + session.commit() + + task.log.info( + f"Reset checked=False for {len(stale_ids)} of {len(candidates)} " + f"BISAC subjects stored as nonfiction." + ) + + @shared_task(queue=QueueNames.default, bind=True) def classify_unchecked_subjects(task: Task) -> None: """Reclassify all Works whose current classifications appear to diff --git a/src/palace/manager/core/classifier/bisac.py b/src/palace/manager/core/classifier/bisac.py index f81254109e..34d6f04739 100644 --- a/src/palace/manager/core/classifier/bisac.py +++ b/src/palace/manager/core/classifier/bisac.py @@ -696,6 +696,36 @@ def _has_canonical_heading(cls, name: list[str]) -> bool: """ return bool(name) and name[0] in cls.TOP_LEVEL_HEADINGS + @classmethod + def contradicts_stored_fiction( + cls, + identifier: str | None, + name: str | None, + stored_fiction: bool | None, + ) -> bool: + """Does this classifier disagree with a subject's stored fiction status? + + Subjects are only re-examined when `checked` is false, so a value + scored under superseded rules persists indefinitely. Repairs that + reset `checked` need to identify those rows, and they need to agree + with each other about which rows they are. Expressing the question + here keeps that definition in one place: a subject is stale when the + classifier, run now, does not return what is stored. + + :param identifier: The subject's identifier, as stored. + :param name: The subject's name, as stored. + :param stored_fiction: The subject's current `fiction` value. + :return: True when the classifier no longer agrees with `stored_fiction`. + """ + if not identifier and not name: + # Nothing to classify. Subject.lookup will not create such a row, + # but both columns are nullable, so do not assume. + return False + scrubbed_identifier, scrubbed_name = cls.scrub_identifier_and_name( + identifier, name + ) + return cls.is_fiction(scrubbed_identifier, scrubbed_name) is not stored_fiction + @classmethod def _apply_rulesets[RulesetResult]( cls, diff --git a/src/palace/manager/scripts/work.py b/src/palace/manager/scripts/work.py index 0c848b3d2b..a87412ff40 100644 --- a/src/palace/manager/scripts/work.py +++ b/src/palace/manager/scripts/work.py @@ -11,6 +11,7 @@ from palace.manager.celery.tasks.work import ( classify_unchecked_subjects, reclassify_null_audience_works, + reset_non_bisac_nonfiction_subjects, ) from palace.manager.data_layer.policy.presentation import ( PresentationCalculationPolicy, @@ -252,6 +253,25 @@ class WorkOPDSScript(WorkPresentationScript): ) +class ResetNonBisacNonfictionSubjectsScript(Script): + """Manually dispatch the ``reset_non_bisac_nonfiction_subjects`` Celery task. + + The work itself happens in the Celery task; this script just queues it. It + exists so the repair in migration 52d1bbdd4671 can be applied again, in + case its reset was consumed by old code before the new classifier was live. + + TODO: Remove this script when the ``reset_non_bisac_nonfiction_subjects`` + Celery task is removed. + """ + + def do_run(self, *args: Any, **kwargs: Any) -> None: + reset_non_bisac_nonfiction_subjects.delay() + self.log.info( + 'The "reset_non_bisac_nonfiction_subjects" task has been queued for ' + "execution. See the celery logs for details about task execution." + ) + + class ReclassifyNullAudienceWorksScript(Script): """Manually dispatch the ``reclassify_null_audience_works`` Celery task. diff --git a/tests/manager/celery/tasks/test_work.py b/tests/manager/celery/tasks/test_work.py index d7923c5fc5..ce8b680773 100644 --- a/tests/manager/celery/tasks/test_work.py +++ b/tests/manager/celery/tasks/test_work.py @@ -119,3 +119,64 @@ def test_reclassify_null_audience_works( policy = call_obj[1]["policy"] assert policy.classify is True assert policy.choose_edition is False + + +def test_reset_non_bisac_nonfiction_subjects( + db: DatabaseTransactionFixture, + celery_fixture: CeleryFixture, +): + """The task re-applies the reset for subjects stored as nonfiction in error. + + Re-runnable stand-in for migration 52d1bbdd4671, for when that migration's + reset was consumed by old code before the new classifier was live. + """ + stale = db.subject(Subject.BISAC, "INFEN000") + stale.name = "English literature" + stale.fiction = False + stale.checked = True + + # A real nonfiction code the classifier still agrees with. + agrees = db.subject(Subject.BISAC, "HIS027000") + agrees.fiction = False + agrees.checked = True + + # Already scored as fiction, so outside the set the task examines. + scored_fiction = db.subject(Subject.BISAC, "INFENUSA") + scored_fiction.name = "American and Canadian literature" + scored_fiction.fiction = True + scored_fiction.checked = True + + # Same identifier, but not a BISAC subject. + other_type = db.subject(Subject.TAG, "INFEN000") + other_type.fiction = False + other_type.checked = True + + db.session.commit() + + work_tasks.reset_non_bisac_nonfiction_subjects.delay().wait() + db.session.expire_all() + + assert stale.checked is False + assert agrees.checked is True + assert scored_fiction.checked is True + assert other_type.checked is True + + +def test_reset_non_bisac_nonfiction_subjects_is_idempotent( + db: DatabaseTransactionFixture, + celery_fixture: CeleryFixture, +): + """A second run finds nothing left to do and leaves the reset in place.""" + subject = db.subject(Subject.BISAC, "INFEN000") + subject.name = "English literature" + subject.fiction = False + subject.checked = True + db.session.commit() + + work_tasks.reset_non_bisac_nonfiction_subjects.delay().wait() + db.session.expire_all() + assert subject.checked is False + + work_tasks.reset_non_bisac_nonfiction_subjects.delay().wait() + db.session.expire_all() + assert subject.checked is False diff --git a/tests/manager/core/classifiers/test_bisac.py b/tests/manager/core/classifiers/test_bisac.py index 6ddae2c067..252ac57a63 100644 --- a/tests/manager/core/classifiers/test_bisac.py +++ b/tests/manager/core/classifiers/test_bisac.py @@ -557,6 +557,48 @@ def test_fragments_are_not_top_level_headings(self, fragment: str) -> None: """ assert Lowercased(fragment) not in BISACClassifier.TOP_LEVEL_HEADINGS + @pytest.mark.parametrize( + "identifier,name,stored_fiction,expected", + [ + pytest.param( + "INFEN000", "English literature", False, True, id="vendor_code_is_stale" + ), + pytest.param( + "FBZZZ000000", "Historical", False, True, id="unreal_code_is_stale" + ), + pytest.param( + "FBFIC014000", + "Historical", + False, + True, + id="real_fiction_code_is_stale", + ), + pytest.param("HIS027000", None, False, False, id="real_nonfiction_agrees"), + pytest.param("FBFIC014000", "Historical", True, False, id="fiction_agrees"), + pytest.param( + "INFEN000", "English literature", True, False, id="keyword_agrees" + ), + pytest.param(None, None, False, False, id="nothing_to_classify"), + ], + ) + def test_contradicts_stored_fiction( + self, + identifier: str | None, + name: str | None, + stored_fiction: bool | None, + expected: bool, + ) -> None: + """The shared definition of a subject whose stored value went stale. + + Subjects are only re-examined when `checked` is false, so the repairs + that reset it need one definition of which rows are affected. Both the + migration and the re-run task ask this. + """ + assert ( + BISACClassifier.contradicts_stored_fiction(identifier, name, stored_fiction) + is expected + ) + @pytest.mark.parametrize( "identifier,stored_name", [ diff --git a/tests/manager/scripts/test_work.py b/tests/manager/scripts/test_work.py index 14dda5de47..58fb519207 100644 --- a/tests/manager/scripts/test_work.py +++ b/tests/manager/scripts/test_work.py @@ -10,6 +10,7 @@ from palace.manager.scripts.work import ( ReclassifyNullAudienceWorksScript, ReclassifyWorksForUncheckedSubjectsScript, + ResetNonBisacNonfictionSubjectsScript, WorkProcessingScript, ) from palace.manager.sqlalchemy.model.datasource import DataSource @@ -198,3 +199,13 @@ def test_run(self, db: DatabaseTransactionFixture): ) as task: ReclassifyNullAudienceWorksScript(db.session).run() assert task.delay.call_count == 1 + + +class TestResetNonBisacNonfictionSubjectsScript: + def test_run(self, db: DatabaseTransactionFixture): + """The script queues the reset_non_bisac_nonfiction_subjects Celery task.""" + with patch( + "palace.manager.scripts.work.reset_non_bisac_nonfiction_subjects" + ) as task: + ResetNonBisacNonfictionSubjectsScript(db.session).run() + assert task.delay.call_count == 1 From 61ae9b6fd4249cf628907d4b2c67b43d607cb6ab Mon Sep 17 00:00:00 2001 From: Daniel Bernstein Date: Mon, 14 Sep 2026 16:34:29 -0700 Subject: [PATCH 2/4] Re-score the reset subjects at deploy instead of overnight (PP-5129) Migration 52d1bbdd4671 only resets checked=False. classify_unchecked_subjects is what re-scores those subjects and recalculates their works, and left to itself that does not happen until the nightly run. The gap between the two is where the repair is exposed: anything reaching Subject.assign_to_genre in the meantime consumes the reset, and if it is running the superseded rules it re-stamps checked=True with the same wrong value. Nothing errors and nothing revisits the subject afterwards. Adds a startup task that dispatches the re-score immediately after the migration, shrinking that gap from about a day to seconds. This is the pattern the null-audience repair already used -- migration d856ff4dbefb makes the data change, startup task 2026_05_12 dispatches the follow-up. The timing works out: helpers/migrate.yml stops the scripts container -- which is where every Celery worker and beat run -- before running the migration, and starts it again afterwards from the new image. So no worker is alive when this dispatches, and the one that picks the task up is necessarily new code. Web containers are the remaining exposure. The deploy recycles them after the migration, and they can reach assign_to_genre through a presentation recalculation. A second startup task in the next release re-applies the reset once no old code is running anywhere. This moves roughly 4,877 works' worth of recalculation and reindexing from overnight to deploy time. That is about 2% of the nightly volume behind PP-4472, so it should be unremarkable, but it is a deliberate choice. Co-Authored-By: Claude Opus 5 --- ...eclassify_non_bisac_nonfiction_subjects.py | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 startup_tasks/2026_09_14_reclassify_non_bisac_nonfiction_subjects.py diff --git a/startup_tasks/2026_09_14_reclassify_non_bisac_nonfiction_subjects.py b/startup_tasks/2026_09_14_reclassify_non_bisac_nonfiction_subjects.py new file mode 100644 index 0000000000..91e8578a63 --- /dev/null +++ b/startup_tasks/2026_09_14_reclassify_non_bisac_nonfiction_subjects.py @@ -0,0 +1,38 @@ +"""Re-score the subjects that migration 52d1bbdd4671 marked unchecked. + +That migration resets ``checked=False`` on BISAC subjects stored as nonfiction +because an unresolvable code fell through the ruleset catch-all. It only resets; +``classify_unchecked_subjects`` is what re-scores them and recalculates their +works, and left to itself that does not happen until the nightly run. + +The gap between the two is where the repair is exposed. Anything that reaches +``Subject.assign_to_genre`` in the meantime consumes the reset -- and if it is +running the superseded rules, it re-stamps ``checked=True`` with the same wrong +value. Nothing errors and nothing revisits the subject afterwards, so the repair +silently did nothing, having paid for a reindex to do it. + +Dispatching here closes that gap to seconds. This runs from the migrate +container immediately after the migration, at a point in the deploy where the +Celery workers have been stopped and will come back on the new image, so the +task is picked up by new code. + +The remaining exposure is the web containers, which the deploy recycles after +the migration and which can reach ``assign_to_genre`` through a presentation +recalculation. A later startup task re-applies the reset once no old code is +running anywhere. + +TODO: Remove this task once it has run on all deployments (PP-5129).""" + +from __future__ import annotations + +import logging + +from celery.canvas import Signature +from sqlalchemy.orm import Session + +from palace.manager.celery.tasks.work import classify_unchecked_subjects +from palace.manager.service.container import Services + + +def run(services: Services, session: Session, log: logging.Logger) -> Signature | None: + return classify_unchecked_subjects.s() From 1f3cab49feb7fa142c903fd3963ad5785d5963b4 Mon Sep 17 00:00:00 2001 From: Daniel Bernstein Date: Mon, 14 Sep 2026 17:14:34 -0700 Subject: [PATCH 3/4] Do the reset from the startup task, not a migration Follow-on from dropping the migration. The reset and the re-score are now chained from one place: reset_non_bisac_nonfiction_subjects marks the stale subjects unchecked, classify_unchecked_subjects re-scores them. The second signature is immutable so the chain does not pass the first task's return value into a task that takes no arguments. There is now one implementation of the selection instead of two. The task is the only thing that knows how to find these subjects, and all three callers -- this startup task, the next-release re-apply, and the bin wrapper -- go through it. Chaining rather than dispatching the reset alone is what keeps the repair from being exposed. The reset on its own can be consumed by anything reaching Subject.assign_to_genre first, and code on the superseded rules re-stamps checked=True with the same wrong value, silently. Running the re-score straight after closes that to seconds instead of waiting for the nightly. Docstrings that referred to migration 52d1bbdd4671 now describe the condition they repair rather than pointing at a file that no longer exists. Co-Authored-By: Claude Opus 5 --- src/palace/manager/celery/tasks/work.py | 23 ++++--- src/palace/manager/scripts/work.py | 4 +- ...eclassify_non_bisac_nonfiction_subjects.py | 64 ++++++++++++------- 3 files changed, 53 insertions(+), 38 deletions(-) diff --git a/src/palace/manager/celery/tasks/work.py b/src/palace/manager/celery/tasks/work.py index 1241419290..de76d8cfc2 100644 --- a/src/palace/manager/celery/tasks/work.py +++ b/src/palace/manager/celery/tasks/work.py @@ -42,21 +42,20 @@ def reclassify_null_audience_works(task: Task) -> None: @shared_task(queue=QueueNames.default, bind=True) def reset_non_bisac_nonfiction_subjects(task: Task) -> None: - """Re-apply the reset that repairs subjects stored as nonfiction in error. + """Mark BISAC subjects unchecked when their stored fiction status went stale. - Migration 52d1bbdd4671 marks these subjects unchecked so that - classify_unchecked_subjects re-scores them. That reset can be consumed - before it takes effect: if old code reaches the subjects first -- a - still-running scripts server, or a host that redeploys itself -- it - re-scores them under the superseded rules and re-stamps checked=True. - Nothing errors, and nothing revisits them afterwards, so the repair - quietly did nothing. This task exists to run the reset again. + A code that cannot be resolved to a canonical BISAC heading used to be + read as nonfiction by the ruleset catch-all, so those subjects carry a + fabricated fiction=False. Subjects are only re-examined when checked is + false, so repairing them means resetting that flag. - It resets only. The re-scoring stays with classify_unchecked_subjects, - which picks these subjects up on its next nightly run; trigger - bin/work_classify_unchecked_subjects to have it happen sooner. + This resets only. Re-scoring is classify_unchecked_subjects' job: the + startup task that runs this at deploy chains the two together, and the + nightly run picks up anything left over. - Idempotent: a second run finds nothing to do. + Idempotent and self-selecting -- it recomputes which subjects the + classifier no longer agrees with, so a second run finds nothing to do. + That is what lets a later release re-apply it safely. """ with task.session() as session: candidates = ( diff --git a/src/palace/manager/scripts/work.py b/src/palace/manager/scripts/work.py index a87412ff40..2d33297957 100644 --- a/src/palace/manager/scripts/work.py +++ b/src/palace/manager/scripts/work.py @@ -257,8 +257,8 @@ class ResetNonBisacNonfictionSubjectsScript(Script): """Manually dispatch the ``reset_non_bisac_nonfiction_subjects`` Celery task. The work itself happens in the Celery task; this script just queues it. It - exists so the repair in migration 52d1bbdd4671 can be applied again, in - case its reset was consumed by old code before the new classifier was live. + exists so the repair can be applied again on demand, in case its reset was + consumed by old code before the new classifier was live everywhere. TODO: Remove this script when the ``reset_non_bisac_nonfiction_subjects`` Celery task is removed. diff --git a/startup_tasks/2026_09_14_reclassify_non_bisac_nonfiction_subjects.py b/startup_tasks/2026_09_14_reclassify_non_bisac_nonfiction_subjects.py index 91e8578a63..d8b29c1e21 100644 --- a/startup_tasks/2026_09_14_reclassify_non_bisac_nonfiction_subjects.py +++ b/startup_tasks/2026_09_14_reclassify_non_bisac_nonfiction_subjects.py @@ -1,24 +1,34 @@ -"""Re-score the subjects that migration 52d1bbdd4671 marked unchecked. - -That migration resets ``checked=False`` on BISAC subjects stored as nonfiction -because an unresolvable code fell through the ruleset catch-all. It only resets; -``classify_unchecked_subjects`` is what re-scores them and recalculates their -works, and left to itself that does not happen until the nightly run. - -The gap between the two is where the repair is exposed. Anything that reaches -``Subject.assign_to_genre`` in the meantime consumes the reset -- and if it is -running the superseded rules, it re-stamps ``checked=True`` with the same wrong -value. Nothing errors and nothing revisits the subject afterwards, so the repair -silently did nothing, having paid for a reindex to do it. - -Dispatching here closes that gap to seconds. This runs from the migrate -container immediately after the migration, at a point in the deploy where the -Celery workers have been stopped and will come back on the new image, so the -task is picked up by new code. - -The remaining exposure is the web containers, which the deploy recycles after -the migration and which can reach ``assign_to_genre`` through a presentation -recalculation. A later startup task re-applies the reset once no old code is +"""Repair BISAC subjects stored as nonfiction because their code did not resolve. + +Everything on the Palace Marketplace / Feedbooks category scheme is stored with +``type='BISAC'``, including codes that are not BISAC at all -- language and +territory categories such as ``INFEN000`` ("English literature"). Those cannot +be resolved to a canonical heading, so classification used to infer nonfiction +from the distributor's name and store ``fiction=False``. The classifier no +longer does that, which leaves the stored values stale. + +Subjects are only re-examined when ``checked`` is false, so this dispatches two +steps: ``reset_non_bisac_nonfiction_subjects`` marks the stale ones unchecked, +then ``classify_unchecked_subjects`` re-scores them and recalculates their +works. The second signature is immutable so the chain does not pass the first +task's return value into it. + +Doing both here matters. The reset on its own is exposed: anything reaching +``Subject.assign_to_genre`` before the re-score consumes it, and code running +the superseded rules re-stamps ``checked=True`` with the same wrong value. +Nothing errors and nothing revisits the subject afterwards, so the repair +silently did nothing, having paid for a reindex to do it. Chaining the re-score +closes that gap to seconds rather than waiting for the nightly run. + +The timing works out. ``helpers/migrate.yml`` stops the scripts container -- +where every Celery worker and beat run -- before migrating, and starts it again +from the new image afterwards, so the worker that picks this up is necessarily +new code. + +Web containers are the remaining exposure: the deploy recycles them after the +migration step, and they can reach ``assign_to_genre`` through a presentation +recalculation. Fargate deployments are not governed by that playbook at all. A +second startup task re-applies the reset a release later, once no old code is running anywhere. TODO: Remove this task once it has run on all deployments (PP-5129).""" @@ -27,12 +37,18 @@ import logging -from celery.canvas import Signature +from celery.canvas import Signature, chain from sqlalchemy.orm import Session -from palace.manager.celery.tasks.work import classify_unchecked_subjects +from palace.manager.celery.tasks.work import ( + classify_unchecked_subjects, + reset_non_bisac_nonfiction_subjects, +) from palace.manager.service.container import Services def run(services: Services, session: Session, log: logging.Logger) -> Signature | None: - return classify_unchecked_subjects.s() + return chain( + reset_non_bisac_nonfiction_subjects.s(), + classify_unchecked_subjects.si(), + ) From 5769c9aa6c3d0556970bb8ce708a4ace963e75b3 Mon Sep 17 00:00:00 2001 From: Daniel Bernstein Date: Tue, 15 Sep 2026 07:39:51 -0700 Subject: [PATCH 4/4] Drop the stale migration reference from the task test The migration this named was removed when the repair moved to a startup task. Co-Authored-By: Claude Opus 5 --- tests/manager/celery/tasks/test_work.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/manager/celery/tasks/test_work.py b/tests/manager/celery/tasks/test_work.py index ce8b680773..5a3236e27b 100644 --- a/tests/manager/celery/tasks/test_work.py +++ b/tests/manager/celery/tasks/test_work.py @@ -125,10 +125,10 @@ def test_reset_non_bisac_nonfiction_subjects( db: DatabaseTransactionFixture, celery_fixture: CeleryFixture, ): - """The task re-applies the reset for subjects stored as nonfiction in error. + """The task resets subjects whose stored nonfiction status went stale. - Re-runnable stand-in for migration 52d1bbdd4671, for when that migration's - reset was consumed by old code before the new classifier was live. + Re-runnable, so the repair can be applied again when its reset was consumed + by old code before the new classifier was live everywhere. """ stale = db.subject(Subject.BISAC, "INFEN000") stale.name = "English literature"