Keep one bad custom list from blowing up the entries sweep (PP-4787)#3550
Conversation
|
Claude finished @dbernstein's task in 5m 23s —— View job Code ReviewI reviewed both commits ( SummaryThis is a clean, well-targeted fix and I found no blocking issues. Both changes are correct and correctly scoped: |
Greptile SummaryThis PR makes the custom-list entries sweep keep running when one list has a bad query. The main changes are:
Confidence Score: 5/5This looks safe to merge.
Important Files Changed
Reviews (3): Last reviewed commit: "Pin REPOPULATE rollback on a swallowed p..." | Re-trigger Greptile |
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #3550 +/- ##
=======================================
Coverage 93.46% 93.46%
=======================================
Files 512 512
Lines 46611 46614 +3
Branches 6352 6352
=======================================
+ Hits 43566 43570 +4
+ Misses 1969 1968 -1
Partials 1076 1076 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
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>
ad810db to
c514d4e
Compare
Adds a regression test for the invariant that the QueryParseException / RequestError handler sits *outside* the `with task.transaction()` block: in REPOPULATE mode the entries are bulk-deleted before the query is parsed, so if the swallow happened inside the transaction the delete would commit and silently empty the list. The test asserts the last good entries survive and the list stays in REPOPULATE, so a future refactor that moves the catch inside the transaction fails loudly. Prompted by a review comment on this PR flagging the theoretical empty-commit; the behavior is already correct, this locks it in. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
## Description > [!NOTE] > Companion to #3550, but independent — the two touch disjoint files (this one is the admin controller; #3550 is the Celery task), so this targets `main` and can merge in any order. #3550 makes the entry sweep resilient to a bad query at runtime; this stops a bad query being saved in the first place. A custom list's `auto_update_query` was only checked for JSON *serializability* before being stored — never for whether the search layer could actually parse it. `CustomListsController._create_or_update_list` now builds a `JSONQuery` from the submitted query and returns `INVALID_INPUT` (400) if it can't be parsed, passing along the parser's own explanation of what's wrong. Also corrects the `auto_update_query` parameter annotation, which claimed `dict[str, str]` even though a query's `value` is a nested dict. That's why the existing tests were able to pass placeholders like `{"query": "foo"}` without mypy complaining. ## Motivation and Context Follow-up to #3550, which fixed the *symptom*: a list whose stored query contained a `published` value of `2025>01>01` failed its Celery task every sweep and (before that PR) wedged the sweep lock for every library. This PR fixes how the bad value got in. Two gaps combined: - **Creating** a list incidentally exercised its query, via the `populate_query_pages` call on the `is_new` path — but a `QueryParseException` there surfaced as an unhandled 500, not a validation error. - **Editing** a list never parsed the query at all. So an invalid query could only ever be introduced — and never surfaced — through an edit. Either way the result was a list that silently stopped updating, since the entry-update task can do nothing with an unparseable query but log it and skip. Rejecting the query at save time turns a silent, indefinitely-stalled list into an immediate 400 for the librarian who fat-fingered the date. **Behavior change worth flagging in review:** a librarian editing an existing list whose stored query is *already* invalid will now get a 400 rather than a successful save, even if they only meant to change the list's name — the admin UI resubmits `auto_update_query` unchanged. That seems right to me (it forces the broken query to be fixed), but it is a real change, and it means any already-corrupted rows in production should be corrected before this ships. (At this time as far as I know there are no corrupted rows in production - I manually fixed the one issue that alerted us to the problem.) ## How Has This Been Tested? `tox -e py312-docker -- tests/manager/api/admin/controller/test_custom_lists.py tests/manager/core/test_customlist_queries.py tests/manager/celery/tasks/test_custom_lists.py` (64 passed). - `test_auto_update_query_must_be_a_valid_search_query` is parametrized over four ways to be invalid — the `2025>01>01` value from production, an unknown key, an unknown operator, and a missing `query` root — and asserts the parser's reason is surfaced in the problem detail and that nothing is persisted. - `test_auto_update_query_validated_on_edit` covers the gap specifically: an existing list rejects a bad query on edit and keeps its previous good one. - Three existing tests had to be updated. They passed placeholder queries (`{"query": "foo"}`, `{"query": "...changed"}`) that the new validation correctly rejects. They test facets serialization, entry handling, and the repopulate-on-query-change path, so they now pass a valid query. That they needed changing at all is a decent illustration of how little the old code cared what was in this column. ## Checklist - [x] I have updated the documentation accordingly. - [x] All new and existing tests passed. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: Jonathan Green <jonathan@razzard.com>
Description
Two fixes to
update_custom_list_entries, both aimed at the same failure: a single custom list with a malformedauto_update_querycurrently takes down the entire custom-list entries sweep.1. Catch
QueryParseExceptionalongsideRequestError.The per-list task already intends to log-and-skip a list whose query is malformed, so that one bad list doesn't abort the sweep chord. But it only caught
RequestError(an OpenSearch 400). A query can also be rejected by our ownJSONQueryparser, before any request reaches OpenSearch — that raisesQueryParseException, which sailed straight past the handler and failed the task.This restores the behavior of the
CustomListUpdateEntriesScriptthis pipeline replaced, which wrapped each list in a bareexcept Exception: self.log.exception(...).One other note here: even though we are validating custom list queries when they are entered by users, it is still possible that query validation logic could tighten down the road causing existing queries to start failing. That is why I believe it is worth in submitting this PR along with #3551.
2. Release the sweep lock when a per-list task fails.
update_custom_list_entriesis a chord header, and Celery runs a chord's body only if every header task succeeded. The body isfinalize_custom_list_entries_sweep, which is what releases the sweep-level Redis lock — so any header failure left that lock held for its full 2-hour TTL. The sweep is beat-scheduled hourly, so the next one or two ticks would skip with "another sweep is already in progress," and then the same thing would happen again.finalizeis now registered as the chord body's error callback as well as its body. Exactly one of the two runs, so the lock is released exactly once. Infrastructure errors (database down, OpenSearch transport failure) still propagate and alarm as before — they just no longer strand the lock on their way out.Motivation and Context
Seen in production (PP-4787). A list's stored query contained a
publishedvalue of2025>01>01— someone typed2025.01.01with the shift key held down. Every sweep, that list raised:which failed the task, skipped the chord body, and left the sweep lock held. The net effect was that no custom list updated for any library, on a permanent, self-renewing cycle — from one bad row.
A follow-up PR (stacked on this one) adds validation of
auto_update_queryat save time in the admin controller, so a query that can't be parsed can't be stored in the first place.The error was occurring on a single custom list in the California collection. I manually fixed the production issue via one off database write so we are not longer seeing the aforementioned error in production.
How Has This Been Tested?
tox -e py312-docker -- tests/manager/celery/tasks/test_custom_lists.py(24 passed).test_malformed_query_logged_and_swallowedis now parametrized over bothRequestErrorandQueryParseException.test_finalize_registered_as_chord_error_callbackasserts the error callback is wired up and carries the samelock_valueas the body.test_failing_per_list_task_still_releases_sweep_lockis end-to-end: it runs a real sweep whose per-list task raisesSQLAlchemyError, then polls until the sweep lock comes back. I confirmed this test is not vacuous by reverting theon_errorline and watching it fail with "Sweep lock was not released within 10.0 seconds."Checklist
🤖 Generated with Claude Code