Skip to content

Commit af7c1b7

Browse files
dbernsteinclaude
andcommitted
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 <noreply@anthropic.com>
1 parent 46d57e9 commit af7c1b7

8 files changed

Lines changed: 226 additions & 8 deletions

File tree

alembic/versions/20260902_52d1bbdd4671_reset_checked_for_non_bisac_nonfiction_.py

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -63,14 +63,7 @@ def upgrade() -> None:
6363
# value would survive this repair.
6464
stale_ids = []
6565
for row in candidates:
66-
if not row.identifier and not row.name:
67-
# Nothing to classify. Subject.lookup will not create such a row,
68-
# but the columns are nullable, so don't assume.
69-
continue
70-
identifier, name = BISACClassifier.scrub_identifier_and_name(
71-
row.identifier, row.name
72-
)
73-
if BISACClassifier.is_fiction(identifier, name) is not False:
66+
if BISACClassifier.contradicts_stored_fiction(row.identifier, row.name, False):
7467
stale_ids.append(row.id)
7568
log.info(
7669
f"Reset checked=False for subject id={row.id} "
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
#!/usr/bin/env python
2+
"""Queue the reset_non_bisac_nonfiction_subjects Celery task.
3+
4+
Convenience wrapper that manually dispatches the repair for BISAC subjects
5+
stored as nonfiction in error (the `reset_non_bisac_nonfiction_subjects` Celery
6+
task) for a worker to process.
7+
"""
8+
9+
from palace.manager.scripts.work import ResetNonBisacNonfictionSubjectsScript
10+
11+
ResetNonBisacNonfictionSubjectsScript().run()

src/palace/manager/celery/tasks/work.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from sqlalchemy.orm import Session
55

66
from palace.manager.celery.task import Task
7+
from palace.manager.core.classifier.bisac import BISACClassifier
78
from palace.manager.data_layer.policy.presentation import PresentationCalculationPolicy
89
from palace.manager.service.celery.celery import QueueNames
910
from palace.manager.sqlalchemy.model.classification import Classification, Subject
@@ -39,6 +40,55 @@ def reclassify_null_audience_works(task: Task) -> None:
3940
session.commit()
4041

4142

43+
@shared_task(queue=QueueNames.default, bind=True)
44+
def reset_non_bisac_nonfiction_subjects(task: Task) -> None:
45+
"""Re-apply the reset that repairs subjects stored as nonfiction in error.
46+
47+
Migration 52d1bbdd4671 marks these subjects unchecked so that
48+
classify_unchecked_subjects re-scores them. That reset can be consumed
49+
before it takes effect: if old code reaches the subjects first -- a
50+
still-running scripts server, or a host that redeploys itself -- it
51+
re-scores them under the superseded rules and re-stamps checked=True.
52+
Nothing errors, and nothing revisits them afterwards, so the repair
53+
quietly did nothing. This task exists to run the reset again.
54+
55+
It resets only. The re-scoring stays with classify_unchecked_subjects,
56+
which picks these subjects up on its next nightly run; trigger
57+
bin/work_classify_unchecked_subjects to have it happen sooner.
58+
59+
Idempotent: a second run finds nothing to do.
60+
"""
61+
with task.session() as session:
62+
candidates = (
63+
session.query(Subject.id, Subject.identifier, Subject.name)
64+
.filter(
65+
Subject.type == Subject.BISAC,
66+
Subject.checked == True, # noqa: E712
67+
Subject.fiction == False, # noqa: E712
68+
)
69+
.all()
70+
)
71+
72+
stale_ids = [
73+
row.id
74+
for row in candidates
75+
if BISACClassifier.contradicts_stored_fiction(
76+
row.identifier, row.name, False
77+
)
78+
]
79+
80+
if stale_ids:
81+
session.query(Subject).filter(Subject.id.in_(stale_ids)).update(
82+
{Subject.checked: False}, synchronize_session=False
83+
)
84+
session.commit()
85+
86+
task.log.info(
87+
f"Reset checked=False for {len(stale_ids)} of {len(candidates)} "
88+
f"BISAC subjects stored as nonfiction."
89+
)
90+
91+
4292
@shared_task(queue=QueueNames.default, bind=True)
4393
def classify_unchecked_subjects(task: Task) -> None:
4494
"""Reclassify all Works whose current classifications appear to

src/palace/manager/core/classifier/bisac.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -689,6 +689,36 @@ def _has_canonical_heading(cls, name: list[str]) -> bool:
689689
"""
690690
return bool(name) and name[0] in cls.TOP_LEVEL_HEADINGS
691691

692+
@classmethod
693+
def contradicts_stored_fiction(
694+
cls,
695+
identifier: str | None,
696+
name: str | None,
697+
stored_fiction: bool | None,
698+
) -> bool:
699+
"""Does this classifier disagree with a subject's stored fiction status?
700+
701+
Subjects are only re-examined when `checked` is false, so a value
702+
scored under superseded rules persists indefinitely. Repairs that
703+
reset `checked` need to identify those rows, and they need to agree
704+
with each other about which rows they are. Expressing the question
705+
here keeps that definition in one place: a subject is stale when the
706+
classifier, run now, does not return what is stored.
707+
708+
:param identifier: The subject's identifier, as stored.
709+
:param name: The subject's name, as stored.
710+
:param stored_fiction: The subject's current `fiction` value.
711+
:return: True when the classifier no longer agrees with `stored_fiction`.
712+
"""
713+
if not identifier and not name:
714+
# Nothing to classify. Subject.lookup will not create such a row,
715+
# but both columns are nullable, so do not assume.
716+
return False
717+
scrubbed_identifier, scrubbed_name = cls.scrub_identifier_and_name(
718+
identifier, name
719+
)
720+
return cls.is_fiction(scrubbed_identifier, scrubbed_name) is not stored_fiction
721+
692722
@classmethod
693723
def _apply_rulesets[RulesetResult](
694724
cls,

src/palace/manager/scripts/work.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from palace.manager.celery.tasks.work import (
1212
classify_unchecked_subjects,
1313
reclassify_null_audience_works,
14+
reset_non_bisac_nonfiction_subjects,
1415
)
1516
from palace.manager.data_layer.policy.presentation import (
1617
PresentationCalculationPolicy,
@@ -252,6 +253,25 @@ class WorkOPDSScript(WorkPresentationScript):
252253
)
253254

254255

256+
class ResetNonBisacNonfictionSubjectsScript(Script):
257+
"""Manually dispatch the ``reset_non_bisac_nonfiction_subjects`` Celery task.
258+
259+
The work itself happens in the Celery task; this script just queues it. It
260+
exists so the repair in migration 52d1bbdd4671 can be applied again, in
261+
case its reset was consumed by old code before the new classifier was live.
262+
263+
TODO: Remove this script when the ``reset_non_bisac_nonfiction_subjects``
264+
Celery task is removed.
265+
"""
266+
267+
def do_run(self, *args: Any, **kwargs: Any) -> None:
268+
reset_non_bisac_nonfiction_subjects.delay()
269+
self.log.info(
270+
'The "reset_non_bisac_nonfiction_subjects" task has been queued for '
271+
"execution. See the celery logs for details about task execution."
272+
)
273+
274+
255275
class ReclassifyNullAudienceWorksScript(Script):
256276
"""Manually dispatch the ``reclassify_null_audience_works`` Celery task.
257277

tests/manager/celery/tasks/test_work.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,3 +119,64 @@ def test_reclassify_null_audience_works(
119119
policy = call_obj[1]["policy"]
120120
assert policy.classify is True
121121
assert policy.choose_edition is False
122+
123+
124+
def test_reset_non_bisac_nonfiction_subjects(
125+
db: DatabaseTransactionFixture,
126+
celery_fixture: CeleryFixture,
127+
):
128+
"""The task re-applies the reset for subjects stored as nonfiction in error.
129+
130+
Re-runnable stand-in for migration 52d1bbdd4671, for when that migration's
131+
reset was consumed by old code before the new classifier was live.
132+
"""
133+
stale = db.subject(Subject.BISAC, "INFEN000")
134+
stale.name = "English literature"
135+
stale.fiction = False
136+
stale.checked = True
137+
138+
# A real nonfiction code the classifier still agrees with.
139+
agrees = db.subject(Subject.BISAC, "HIS027000")
140+
agrees.fiction = False
141+
agrees.checked = True
142+
143+
# Already scored as fiction, so outside the set the task examines.
144+
scored_fiction = db.subject(Subject.BISAC, "INFENUSA")
145+
scored_fiction.name = "American and Canadian literature"
146+
scored_fiction.fiction = True
147+
scored_fiction.checked = True
148+
149+
# Same identifier, but not a BISAC subject.
150+
other_type = db.subject(Subject.TAG, "INFEN000")
151+
other_type.fiction = False
152+
other_type.checked = True
153+
154+
db.session.commit()
155+
156+
work_tasks.reset_non_bisac_nonfiction_subjects.delay().wait()
157+
db.session.expire_all()
158+
159+
assert stale.checked is False
160+
assert agrees.checked is True
161+
assert scored_fiction.checked is True
162+
assert other_type.checked is True
163+
164+
165+
def test_reset_non_bisac_nonfiction_subjects_is_idempotent(
166+
db: DatabaseTransactionFixture,
167+
celery_fixture: CeleryFixture,
168+
):
169+
"""A second run finds nothing left to do and leaves the reset in place."""
170+
subject = db.subject(Subject.BISAC, "INFEN000")
171+
subject.name = "English literature"
172+
subject.fiction = False
173+
subject.checked = True
174+
db.session.commit()
175+
176+
work_tasks.reset_non_bisac_nonfiction_subjects.delay().wait()
177+
db.session.expire_all()
178+
assert subject.checked is False
179+
180+
work_tasks.reset_non_bisac_nonfiction_subjects.delay().wait()
181+
db.session.expire_all()
182+
assert subject.checked is False

tests/manager/core/classifiers/test_bisac.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -549,6 +549,48 @@ def test_fragments_are_not_top_level_headings(self, fragment: str) -> None:
549549
"""
550550
assert Lowercased(fragment) not in BISACClassifier.TOP_LEVEL_HEADINGS
551551

552+
@pytest.mark.parametrize(
553+
"identifier,name,stored_fiction,expected",
554+
[
555+
pytest.param(
556+
"INFEN000", "English literature", False, True, id="vendor_code_is_stale"
557+
),
558+
pytest.param(
559+
"FBZZZ000000", "Historical", False, True, id="unreal_code_is_stale"
560+
),
561+
pytest.param(
562+
"FBFIC014000",
563+
"Historical",
564+
False,
565+
True,
566+
id="real_fiction_code_is_stale",
567+
),
568+
pytest.param("HIS027000", None, False, False, id="real_nonfiction_agrees"),
569+
pytest.param("FBFIC014000", "Historical", True, False, id="fiction_agrees"),
570+
pytest.param(
571+
"INFEN000", "English literature", True, False, id="keyword_agrees"
572+
),
573+
pytest.param(None, None, False, False, id="nothing_to_classify"),
574+
],
575+
)
576+
def test_contradicts_stored_fiction(
577+
self,
578+
identifier: str | None,
579+
name: str | None,
580+
stored_fiction: bool | None,
581+
expected: bool,
582+
) -> None:
583+
"""The shared definition of a subject whose stored value went stale.
584+
585+
Subjects are only re-examined when `checked` is false, so the repairs
586+
that reset it need one definition of which rows are affected. Both the
587+
migration and the re-run task ask this.
588+
"""
589+
assert (
590+
BISACClassifier.contradicts_stored_fiction(identifier, name, stored_fiction)
591+
is expected
592+
)
593+
552594
@pytest.mark.parametrize(
553595
"identifier,stored_name",
554596
[

tests/manager/scripts/test_work.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from palace.manager.scripts.work import (
1111
ReclassifyNullAudienceWorksScript,
1212
ReclassifyWorksForUncheckedSubjectsScript,
13+
ResetNonBisacNonfictionSubjectsScript,
1314
WorkProcessingScript,
1415
)
1516
from palace.manager.sqlalchemy.model.datasource import DataSource
@@ -198,3 +199,13 @@ def test_run(self, db: DatabaseTransactionFixture):
198199
) as task:
199200
ReclassifyNullAudienceWorksScript(db.session).run()
200201
assert task.delay.call_count == 1
202+
203+
204+
class TestResetNonBisacNonfictionSubjectsScript:
205+
def test_run(self, db: DatabaseTransactionFixture):
206+
"""The script queues the reset_non_bisac_nonfiction_subjects Celery task."""
207+
with patch(
208+
"palace.manager.scripts.work.reset_non_bisac_nonfiction_subjects"
209+
) as task:
210+
ResetNonBisacNonfictionSubjectsScript(db.session).run()
211+
assert task.delay.call_count == 1

0 commit comments

Comments
 (0)