Keep the library working when its custom-column definitions can't be read#1154
Conversation
_detail_custom_columns says it degrades safely if DB metadata is unavailable, but it caught only AttributeError, KeyError and TypeError. An actually unreadable calibre schema raises SQLAlchemy's OperationalError -- no such table: custom_columns -- which is none of those, so it escaped and took the whole book detail payload down over a supplementary field. That happens whenever the metadata DB opens but its schema is not there: a library mid-write, a moved or renamed library path, custom_columns mid-migration, a partly restored library. The guard read as working because calibre_db.session could be None and None.query() raises AttributeError, so the no-session case was caught and the unreadable-schema case was never reached. Production never had that cushion -- there the session is real and the query runs. #1149 made session a property that always materialises, which removed the accident and left the gap visible as two red tests on main. Catch SQLAlchemyError as well. The contract pin is red without this and green with it; the two supporting pins keep it from passing vacuously, and the fixture restores the class-level session_factory because the failure mode here is cross-test pollution. For #1153.
Driving the real routes showed the previous commit was incomplete. It guarded the detail API, but /book/<id> -- the classic book page, the same user-visible surface -- still returned 500 on an unreadable schema, because four of the five get_cc_columns callers have no guard at all: the book page, the books table and both search screens. All five want "the custom columns, if there are any", and none can do anything useful with a DB error. So the degradation belongs in get_cc_columns rather than copied five times. An empty list is the shape they already render for a library with no custom columns, so the degraded page is one they all handle. Verified on cwn-local against a live server whose custom_columns table was renamed out from under it: /book/2 and /api/v1/books/2 went 500 -> 200, /table and /advsearch stayed 200, four degrade warnings logged, and every route stayed 200 after the table was put back. For #1153.
Cross-family review raised that catching SQLAlchemyError around an autoflushing query is broader than the failure being handled. If a caller's session is carrying a pending mutation, our SELECT flushes it first, so an unrelated IntegrityError would surface inside this helper, get swallowed as "no custom columns", and leave the session needing a rollback for the rest of the request -- silent, and a long way from its cause. None of the five callers stage a write before calling this, so it is not reachable today. Reading the definitions under no_autoflush closes it anyway, since flushing someone else's work was never part of what this method needs. Also closes two gaps the review found in the tests: nothing proved the request could keep querying after a swallowed failure (the property that makes degrading worth doing at all -- a poisoned session would still satisfy an assertion of []), and every test asserted [], so an unconditional return [] would have left them all green while hiding custom columns from every healthy library. Both are now pinned; the second is mutation-verified to fail against that exact regression. For #1153.
Cross-family review (Terra) — dispositionReviewed the diff against the anti-slop bar plus a targeted brief on the sharpest risk: whether swallowing a DB error leaves the greenlet's Session unusable for the rest of the request. Terra found no HIGH and no data-loss path. Four findings, all dispositioned:
Terra's independent check that the session-poisoning scenario is real is worth recording, since it's the reason the I'd also measured the non-autoflush case directly before the review landed — against a real 42-book library with Reviewer thread Final suite
|
Fixes the red
main(see "Why now") and a real production 500. For #1153.Why
get_cc_columnsreads a library's custom-column definitions — the extra fields you can add to a book in Calibre. Five surfaces call it: the book detail page (cps/web.pyshow_book), the detail API, the books table, and both search screens.Only one of them (the API) had any error handling, and it caught the wrong thing —
AttributeError/KeyError/TypeError, while an actually-unreadable schema raises SQLAlchemy'sOperationalError(no such table: custom_columns). So an unreadable definitions table took whole pages down with a 500 over a supplementary field, while the rest of the library read perfectly fine.Reachable whenever the metadata DB opens but its schema isn't readable: a library mid-write, a moved/renamed library path,
custom_columnsmid-migration, a partially-restored library.Root cause
The API's guard looked like it worked because
calibre_db.sessioncould beNone, andNone.query(...)raisesAttributeError. So the no-session case was caught and the unreadable-schema case was never exercised. Production never had that cushion — there the session is real, the query runs, and it 500s.Pre-existing, not introduced by #1149. What #1149 changed is that
sessionbecame a property that always materialises, removing the accidentalAttributeErrorand turning the latent gap into red tests.Fix
Degrade inside
CalibreDB.get_cc_columnsitself — catchSQLAlchemyError/AttributeError, log, return[]— rather than copying a try/except to five callsites. All five callers want "the custom columns, if any" and none can act on a DB error;[]is the shape they already render for a library with no custom columns._detail_custom_columnskeeps its own guard as defense-in-depth.Correction to this PR's first revision: it fixed only the API endpoint and claimed the
web.py/search.pycallers were deliberately out of scope because a page that can't read the library "has nothing to degrade to". That was wrong — driving the real routes showed/book/<id>still 500'd while the rest of the book rendered fine. The e2e caught what the unit tests didn't.Why now
mainis red and blocks every PR (the docs-only #1152 is red too).Test Suitewas green on0690b42d, failed oncc00a47b:auto-revertthen fired againstmainand pushed anauto-revert/cc00a47branch, failing to open the PR — tracked separately in #1155.These pass in isolation and fail after any test that leaves a
session_factorybehind, so--dist=loadfileworker grouping decides whether they go red.Validation
Live, on
cwn-local, against a running server whosecustom_columnstable was renamed out from under it:/book/2(classic book page)/api/v1/books/2(detail API)/table/advsearch/Pre-fix 500 traceback pointed at
cps/api/books.py:44 in _detail_custom_columns. Post-fix the book renders complete (title, authors, 27 fields) with custom columns omitted andCustom-column definitions unavailablelogged (4 warnings across the run). Every route returns 200 again once the table is restored.Red/green — each fix verified red while uncommitted:
test_missing_custom_columns_table_does_not_raise→ red without thebooks.pyguard.test_get_cc_columns_degrades_for_every_caller→ red without thedb.pyguard (this is the one that pins the four unguarded callers).Full suite (
-m "smoke or unit", single process — stricter than CI's--dist=loadfile): 9F/4189P before → 4F/4197P after. The 4 remaining were all failing pre-fix and are unrelated (test_smoke::test_required_directories_exist,test_802_metadata_change_dispatch,test_ingest_batch_dirty,test_metadata_db_write_coordination).4 regression tests in
tests/unit/test_book_detail_custom_columns_degrade.py. Two are contract pins (red/green above); one asserts the fixture reproduces the real raise so the contract tests can't pass vacuously; one pins thatsessionmaterialises rather than returningNone, which is the precondition that removed the old accidental cushion. The fixture saves/restores the class-levelsession_factory— the bug class here is cross-test pollution, so the test must not add to it.Not fixed here
auto-revertcan't open PRs (GitHub Actions is not permitted to create or approve pull requests), so the red-main safety net currently produces only an orphan branch. The unblocking toggle also lets Actions approve PRs, which would weaken themainruleset, so it needs an explicit decision rather than an autopilot flip.auto-revert/cc00a47branch on origin, left in place rather than deleting a branch this autopilot didn't create.Tier
needs-review— fork-original. Two files, both a single try/except; no dependency, license, or external-URL change.