Skip to content

Commit ad810db

Browse files
dbernsteinclaude
andcommitted
Keep one bad custom list from wedging the entries sweep (PP-4506)
A custom list whose auto_update_query contained an invalid `published` value (e.g. `2025>01>01`) failed its per-list task and, because that task is a chord header, stopped the chord body from ever running. The body is what releases the sweep-level Redis lock, so the lock stayed held for its full 2-hour TTL while the sweep is scheduled hourly -- one malformed list silently blocked custom list updates for every library, every sweep. Two fixes: * update_custom_list_entries now catches QueryParseException alongside RequestError. Both mean "this list's query is malformed", but they are raised from different layers: RequestError is an OpenSearch 400, while QueryParseException comes from our own JSONQuery parser before any request is sent. Catching only the latter let the former abort the sweep. This restores the behavior of the CustomListUpdateEntriesScript this pipeline replaced, which logged and skipped a bad list. * finalize_custom_list_entries_sweep is now registered as the chord body's error callback as well as its body, so the sweep lock is released even when a per-list task fails. Infrastructure errors (database down, OpenSearch transport failure) are still deliberately allowed to propagate and alarm -- they just no longer strand the lock too. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent ea07c6e commit ad810db

2 files changed

Lines changed: 133 additions & 17 deletions

File tree

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

Lines changed: 36 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@
1111
and reconciles the list's cached ``size``. Uses ``task.replace()`` to spread
1212
pagination over multiple short task invocations.
1313
3. ``finalize_custom_list_entries_sweep`` — chord callback: releases the sweep-level
14-
Redis lock after every per-list task has finished.
14+
Redis lock after every per-list task has finished. Registered as both the chord's
15+
body and its error callback, so the lock is released even when a per-list task fails.
1516
"""
1617

1718
from __future__ import annotations
@@ -31,6 +32,7 @@
3132
from palace.manager.celery.utils import ModelNotFoundError, load_from_id, signature_with
3233
from palace.manager.core.query.customlist import CustomListQueries
3334
from palace.manager.search.external_search import ExternalSearchIndex
35+
from palace.manager.search.query import QueryParseException
3436
from palace.manager.service.celery.celery import QueueNames
3537
from palace.manager.service.redis.models.lock import RedisLock
3638
from palace.manager.service.redis.redis import Redis
@@ -109,7 +111,9 @@ def update_custom_list_entries_sweep(task: Task) -> None:
109111
A sweep-level Redis lock prevents a second beat-triggered run from
110112
overlapping with a sweep already in progress. The lock is acquired here
111113
and released by ``finalize_custom_list_entries_sweep`` at the end of the
112-
chord — so it genuinely covers the full fan-out.
114+
chord — so it genuinely covers the full fan-out. ``finalize`` is wired up
115+
as both the chord's body and its error callback, so the lock is released
116+
whether or not the per-list tasks all succeed.
113117
"""
114118
redis = task.services.redis.client()
115119
lock_value = str(uuid4())
@@ -139,9 +143,19 @@ def update_custom_list_entries_sweep(task: Task) -> None:
139143
finalize_custom_list_entries_sweep.delay(lock_value=lock_value)
140144
return
141145

146+
# Celery runs a chord's body only if *every* header task succeeded. Attaching
147+
# finalize as the body's error callback as well means the sweep lock is released
148+
# on both paths: without it, one per-list task failing (a database outage, an
149+
# OpenSearch transport error -- anything the per-list handler deliberately lets
150+
# propagate) would leave the lock held for its full 2-hour TTL, silently skipping
151+
# the next hourly sweeps for every list. Exactly one of the two runs, so the lock
152+
# is released exactly once.
153+
finalize = finalize_custom_list_entries_sweep.si(lock_value=lock_value)
154+
finalize.on_error(finalize_custom_list_entries_sweep.si(lock_value=lock_value))
155+
142156
chord(
143157
group([update_custom_list_entries.si(list_id) for list_id in list_ids]),
144-
finalize_custom_list_entries_sweep.si(lock_value=lock_value),
158+
finalize,
145159
).delay()
146160

147161

@@ -350,18 +364,23 @@ def update_custom_list_entries(
350364
f"Custom list {custom_list_id} not found; it may have been deleted. "
351365
"Skipping."
352366
)
353-
except RequestError:
367+
except (RequestError, QueryParseException):
354368
# This task is a chord header, so an unhandled error aborts the whole
355-
# sweep chord. A RequestError (OpenSearch 400) means *this* list's
356-
# auto_update_query is malformed -- a list-specific problem, not an
357-
# infrastructure failure -- so we skip it rather than let one bad query
358-
# block the rest of the sweep.
369+
# sweep chord. Both of these mean *this* list's auto_update_query is
370+
# malformed -- a list-specific problem, not an infrastructure failure --
371+
# so we skip it rather than let one bad query block the rest of the sweep.
372+
#
373+
# The two arrive from different layers, and catching only one of them
374+
# leaves the other to abort the sweep:
375+
# * QueryParseException is raised by our own JSONQuery parser, before
376+
# any request reaches OpenSearch (e.g. a `published` value that isn't
377+
# YYYY-MM-DD).
378+
# * RequestError is an OpenSearch 400, for a query that parses here but
379+
# that OpenSearch rejects.
359380
#
360-
# A malformed query should not be reachable through normal use: the
361-
# admin UI and circulation API validate queries before saving. Seeing
362-
# one here therefore points to an upstream validation bug, so we log it
363-
# as an exception (with traceback) to make it visible -- but we do not
364-
# escalate to a task failure, since a single bad list shouldn't take
381+
# Either way the list is unusable until an admin fixes its query, so we
382+
# log it as an exception (with traceback) to make it visible -- but we do
383+
# not escalate to a task failure, since a single bad list shouldn't take
365384
# down the sweep.
366385
#
367386
# Infrastructure failures are deliberately NOT caught here: a
@@ -390,6 +409,10 @@ def finalize_custom_list_entries_sweep(
390409
to release the sweep lock after every per-list ``update_custom_list_entries``
391410
task has finished, so the lock truly covers the full fan-out.
392411
412+
``update_custom_list_entries_sweep`` registers this task as both the chord's
413+
body and the body's error callback, since Celery skips the body entirely when
414+
a header task fails. Exactly one of the two invocations happens per sweep.
415+
393416
:param lock_value: Sweep-lock random value from
394417
``update_custom_list_entries_sweep``. When provided, releases the lock.
395418
"""

tests/manager/celery/tasks/test_custom_lists.py

Lines changed: 97 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from __future__ import annotations
44

55
import datetime
6+
import time
67
from unittest.mock import patch
78
from uuid import uuid4
89

@@ -16,6 +17,7 @@
1617
_entry_update_lock,
1718
_sweep_lock,
1819
)
20+
from palace.manager.search.query import QueryParseException
1921
from palace.manager.sqlalchemy.model.customlist import CustomList, CustomListEntry
2022
from tests.fixtures.celery import CeleryFixture
2123
from tests.fixtures.database import DatabaseTransactionFixture
@@ -43,6 +45,24 @@ def _make_auto_updating_list(
4345
return custom_list
4446

4547

48+
def _wait_for_sweep_lock_release(
49+
redis_fixture: RedisFixture, timeout: float = 10.0
50+
) -> None:
51+
"""Block until the sweep lock is free, or fail the test after ``timeout``.
52+
53+
The chord body (or its error callback) releases the lock on a worker thread,
54+
after the sweep task that queued it has already returned.
55+
"""
56+
deadline = time.monotonic() + timeout
57+
while time.monotonic() < deadline:
58+
lock = _sweep_lock(redis_fixture.client, str(uuid4()))
59+
if lock.acquire() is not False:
60+
lock.release()
61+
return
62+
time.sleep(0.05)
63+
pytest.fail(f"Sweep lock was not released within {timeout} seconds.")
64+
65+
4666
# ---------------------------------------------------------------------------
4767
# Stage 0 — Sweep orchestrator
4868
# ---------------------------------------------------------------------------
@@ -124,6 +144,61 @@ def test_sweep_lock_value_forwarded_to_finalize(
124144
chord_callback = mock_chord.call_args[0][1]
125145
assert chord_callback.kwargs.get("lock_value") is not None
126146

147+
def test_finalize_registered_as_chord_error_callback(
148+
self,
149+
db: DatabaseTransactionFixture,
150+
redis_fixture: RedisFixture,
151+
celery_fixture: CeleryFixture,
152+
):
153+
"""finalize is wired up as the chord body's error callback too.
154+
155+
Celery skips a chord's body when any header task fails, so without an
156+
error callback a single failing per-list task would strand the sweep lock
157+
until its 2-hour TTL expired.
158+
"""
159+
_make_auto_updating_list(db)
160+
161+
with (
162+
patch.object(custom_lists, "update_custom_list_entries"),
163+
patch("palace.manager.celery.tasks.custom_lists.chord") as mock_chord,
164+
patch("palace.manager.celery.tasks.custom_lists.group"),
165+
):
166+
custom_lists.update_custom_list_entries_sweep.delay().wait()
167+
168+
chord_callback = mock_chord.call_args[0][1]
169+
(errback,) = chord_callback.options["link_error"]
170+
assert errback.task == custom_lists.finalize_custom_list_entries_sweep.name
171+
# The errback must release the same lock the body would have released.
172+
assert errback.kwargs["lock_value"] == chord_callback.kwargs["lock_value"]
173+
174+
def test_failing_per_list_task_still_releases_sweep_lock(
175+
self,
176+
db: DatabaseTransactionFixture,
177+
redis_fixture: RedisFixture,
178+
celery_fixture: CeleryFixture,
179+
services_fixture: ServicesFixture,
180+
):
181+
"""End-to-end: a per-list task that fails must not strand the sweep lock.
182+
183+
Infrastructure errors are deliberately allowed to propagate out of
184+
update_custom_list_entries so they trigger the unhandled-error alarm. That
185+
failure aborts the chord body, so the lock has to come back via the error
186+
callback instead.
187+
"""
188+
_make_auto_updating_list(db, status=CustomList.INIT)
189+
190+
with patch(
191+
"palace.manager.celery.tasks.custom_lists.CustomListQueries"
192+
) as mock_queries:
193+
mock_queries.populate_query_pages.side_effect = SQLAlchemyError(
194+
"database is down"
195+
)
196+
custom_lists.update_custom_list_entries_sweep.delay().wait()
197+
198+
# The chord header and its error callback run asynchronously, after the
199+
# sweep task itself has returned. Poll until the lock comes back.
200+
_wait_for_sweep_lock_release(redis_fixture)
201+
127202
def test_sweep_lock_prevents_concurrent_sweeps(
128203
self,
129204
db: DatabaseTransactionFixture,
@@ -399,28 +474,46 @@ def test_missing_list_logs_and_continues(
399474
# Should not raise.
400475
custom_lists.update_custom_list_entries.delay(nonexistent_id).wait()
401476

477+
@pytest.mark.parametrize(
478+
"error",
479+
[
480+
pytest.param(
481+
RequestError(400, "parsing_exception", {"error": "malformed query"}),
482+
id="opensearch-400",
483+
),
484+
pytest.param(
485+
QueryParseException(
486+
detail="Could not parse 'published' value '2025>01>01'."
487+
),
488+
id="json-query-parse",
489+
),
490+
],
491+
)
402492
def test_malformed_query_logged_and_swallowed(
403493
self,
494+
error: Exception,
404495
db: DatabaseTransactionFixture,
405496
redis_fixture: RedisFixture,
406497
celery_fixture: CeleryFixture,
407498
services_fixture: ServicesFixture,
408499
):
409-
"""A RequestError (this list's query is malformed) is logged and skipped.
500+
"""A malformed auto_update_query for this list is logged and skipped.
410501
411502
Because this task is a chord header, a propagated error would abort the
412503
whole sweep chord and hold the sweep lock until its TTL. A malformed
413504
query is a list-specific config problem, so the task must instead succeed
414505
(the chord proceeds and the per-list lock is released).
506+
507+
Both flavors must be caught: OpenSearch rejects some queries with a 400
508+
(RequestError), while others are rejected by our own JSONQuery parser
509+
before a request is ever sent (QueryParseException).
415510
"""
416511
custom_list = _make_auto_updating_list(db, status=CustomList.INIT)
417512

418513
with patch(
419514
"palace.manager.celery.tasks.custom_lists.CustomListQueries"
420515
) as mock_queries:
421-
mock_queries.populate_query_pages.side_effect = RequestError(
422-
400, "parsing_exception", {"error": "malformed query"}
423-
)
516+
mock_queries.populate_query_pages.side_effect = error
424517
# Should not raise -- the error is caught, logged, and swallowed.
425518
custom_lists.update_custom_list_entries.delay(custom_list.id).wait()
426519

0 commit comments

Comments
 (0)