Skip to content

Keep one bad custom list from blowing up the entries sweep (PP-4787)#3550

Merged
jonathangreen merged 2 commits into
mainfrom
bugfix/custom-list-sweep-query-parse-error
Jul 15, 2026
Merged

Keep one bad custom list from blowing up the entries sweep (PP-4787)#3550
jonathangreen merged 2 commits into
mainfrom
bugfix/custom-list-sweep-query-parse-error

Conversation

@dbernstein

@dbernstein dbernstein commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Description

Two fixes to update_custom_list_entries, both aimed at the same failure: a single custom list with a malformed auto_update_query currently takes down the entire custom-list entries sweep.

1. Catch QueryParseException alongside RequestError.

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 own JSONQuery parser, before any request reaches OpenSearch — that raises QueryParseException, which sailed straight past the handler and failed the task.

This restores the behavior of the CustomListUpdateEntriesScript this pipeline replaced, which wrapped each list in a bare except 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_entries is a chord header, and Celery runs a chord's body only if every header task succeeded. The body is finalize_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.

finalize is 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 published value of 2025>01>01 — someone typed 2025.01.01 with the shift key held down. Every sweep, that list raised:

palace.manager.search.query.QueryParseException: Could not parse 'published' value '2025>01>01'. Only use 'YYYY-MM-DD'

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_query at 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_swallowed is now parametrized over both RequestError and QueryParseException.
  • test_finalize_registered_as_chord_error_callback asserts the error callback is wired up and carries the same lock_value as the body.
  • test_failing_per_list_task_still_releases_sweep_lock is end-to-end: it runs a real sweep whose per-list task raises SQLAlchemyError, then polls until the sweep lock comes back. I confirmed this test is not vacuous by reverting the on_error line and watching it fail with "Sweep lock was not released within 10.0 seconds."

Checklist

  • I have updated the documentation accordingly.
  • All new and existing tests passed.

🤖 Generated with Claude Code

@dbernstein dbernstein added the bug Something isn't working label Jul 10, 2026
@claude

claude Bot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Claude finished @dbernstein's task in 5m 23s —— View job


Code Review

I reviewed both commits (c514d4e, 507a2a9) against origin/main, the surrounding task/transaction/lock machinery, and the tests.

Summary

This is a clean, well-targeted fix and I found no blocking issues. Both changes are correct and correctly scoped: QueryParseException is a BasePalaceException raised only by the JSONQuery parser, so it's genuinely a per-list config problem and belongs in the same skip-and-log path as RequestError — infrastructure errors (SQLAlchemyError, transport errors) still propagate and alarm as before. The chord error-callback is wired with .si() (immutable), which matters: it stops Celery from prepending the failed task-id as a positional arg that would otherwise clobber lock_value, and RedisLock.release() is value-checked via its Lua unlock script, so any redundant or stale release is a safe no-op that can never free a different sweep's lock. The end-to-end lock-release test runs against a real celery_session_worker (not eager mode), so it truly exercises the chord failure path rather than a simulation. I also specifically verified the Greptile P1 ("REPOPULATE can commit an emptied list"): the except (RequestError, QueryParseException) handler sits outside the with task.transaction() block, and task.transaction() is session_maker.begin(), which rolls back on exception — so the REPOPULATE bulk-delete is undone before the swallow. That invariant is now pinned by test_repopulate_parse_error_rolls_back_the_delete, so the concern is a non-issue. Nice work on the test coverage — the parametrization, the error-callback wiring assertion, and the poll-until-released end-to-end test all target the right behaviors.
· bugfix/custom-list-sweep-query-parse-error

@greptile-apps

greptile-apps Bot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR makes the custom-list entries sweep keep running when one list has a bad query. The main changes are:

  • Catches local query parse failures with malformed OpenSearch requests.
  • Releases the sweep lock from the chord error path.
  • Adds tests for rollback, lock release, and malformed-query handling.

Confidence Score: 5/5

This looks safe to merge.

  • No blocking issues found in the changed code.

Important Files Changed

Filename Overview
src/palace/manager/celery/tasks/custom_lists.py Adds malformed-query handling for local parse errors and releases the sweep lock on chord failure.
tests/manager/celery/tasks/test_custom_lists.py Adds tests for malformed-query swallowing, REPOPULATE rollback, and sweep-lock release after task failure.

Reviews (3): Last reviewed commit: "Pin REPOPULATE rollback on a swallowed p..." | Re-trigger Greptile

Comment thread src/palace/manager/celery/tasks/custom_lists.py
@codecov

codecov Bot commented Jul 10, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 93.46%. Comparing base (65b15bf) to head (507a2a9).
⚠️ Report is 4 commits behind head on main.

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.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@dbernstein dbernstein changed the title Keep one bad custom list from wedging the entries sweep (PP-4506) Keep one bad custom list from blowing up the entries sweep (PP-4787) Jul 10, 2026
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>
@dbernstein
dbernstein force-pushed the bugfix/custom-list-sweep-query-parse-error branch from ad810db to c514d4e Compare July 13, 2026 17:08
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>
@dbernstein
dbernstein requested a review from a team July 13, 2026 17:42
jonathangreen added a commit that referenced this pull request Jul 15, 2026
## 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>

@jonathangreen jonathangreen left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good

@jonathangreen
jonathangreen merged commit 5bce034 into main Jul 15, 2026
25 checks passed
@jonathangreen
jonathangreen deleted the bugfix/custom-list-sweep-query-parse-error branch July 15, 2026 16:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants