Skip to content

Commit 31adc8b

Browse files
committed
Do not let the definitions read flush a caller's pending write
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.
1 parent 22aa25c commit 31adc8b

2 files changed

Lines changed: 85 additions & 5 deletions

File tree

cps/db.py

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1904,11 +1904,23 @@ def get_cc_columns(self, config, filter_config_custom_read=False):
19041904
"""
19051905
try:
19061906
self.ensure_session()
1907-
tmp_cc = (self.session.query(CustomColumns)
1908-
.filter(CustomColumns.datatype.notin_(cc_exceptions)).all())
1907+
session = self.session
1908+
# no_autoflush because this is a read of definitions, and the
1909+
# session may be carrying a caller's pending mutation. With
1910+
# autoflush on, our SELECT would flush *their* write first, so an
1911+
# unrelated IntegrityError would surface here, get swallowed as
1912+
# "no custom columns", and leave the session needing a rollback for
1913+
# the rest of the request. Not reachable from today's five callers
1914+
# (all read paths), but the failure would be silent and remote from
1915+
# its cause, so don't leave it available to the sixth.
1916+
with session.no_autoflush:
1917+
tmp_cc = (session.query(CustomColumns)
1918+
.filter(CustomColumns.datatype.notin_(cc_exceptions)).all())
19091919
except (SQLAlchemyError, AttributeError):
1910-
# AttributeError covers the reconnect window, where the session can
1911-
# be absent rather than merely unreadable.
1920+
# AttributeError: the session is absent rather than unreadable --
1921+
# `session` is None whenever session_factory is (before init_db, or
1922+
# after an explicit `session = None`), and None.query() is an
1923+
# AttributeError rather than a SQLAlchemyError.
19121924
log.warning("Custom-column definitions unavailable; continuing without them",
19131925
exc_info=True)
19141926
return []

tests/unit/test_book_detail_custom_columns_degrade.py

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,10 @@
1818
import sqlite3
1919
import tempfile
2020
from pathlib import Path
21+
from types import SimpleNamespace
2122

2223
import pytest
23-
from sqlalchemy import create_engine
24+
from sqlalchemy import create_engine, text as sa_text
2425
from sqlalchemy.exc import SQLAlchemyError
2526
from sqlalchemy.orm import scoped_session, sessionmaker
2627

@@ -95,6 +96,73 @@ def test_fixture_really_reproduces_an_unreadable_schema(unreadable_calibre_schem
9596
session.query(dbmod.CustomColumns).all()
9697

9798

99+
@pytest.mark.unit
100+
def test_request_can_keep_querying_after_the_failure(unreadable_calibre_schema):
101+
"""The safety property that actually matters, not just the return value.
102+
103+
Degrading is only useful if the *rest* of the page still renders. A caught
104+
DB error can leave a Session in partial-rollback, where every later query
105+
raises ``PendingRollbackError`` -- which would turn one dead page into a
106+
subtly broken one and still satisfy a test that only asserts ``[]``.
107+
108+
So: fail the custom-column read, then keep using the same session.
109+
"""
110+
from cps import db as dbmod
111+
from cps.api import books as books_mod
112+
113+
session = dbmod.CalibreDB.session_factory()
114+
session.execute(sa_text("CREATE TABLE books (id INTEGER PRIMARY KEY, title TEXT)"))
115+
session.execute(sa_text("INSERT INTO books (id, title) VALUES (1, 'Still here')"))
116+
session.commit()
117+
118+
assert books_mod.calibre_db.get_cc_columns(books_mod.config,
119+
filter_config_custom_read=True) == []
120+
121+
# Same session, after the swallowed error.
122+
assert session.execute(sa_text("SELECT title FROM books WHERE id = 1")).scalar() == "Still here"
123+
assert session.is_active
124+
125+
126+
@pytest.mark.unit
127+
def test_healthy_library_still_returns_its_custom_columns():
128+
"""Guards the degrade tests against passing vacuously.
129+
130+
Every other test here asserts ``[]``, so an accidental unconditional
131+
``return []`` -- which would silently hide custom columns from every user
132+
with a perfectly healthy library -- would leave them all green.
133+
"""
134+
from cps import db as dbmod
135+
136+
tmp = Path(tempfile.mkdtemp()) / "metadata.db"
137+
con = sqlite3.connect(tmp)
138+
con.execute("""CREATE TABLE custom_columns (
139+
id INTEGER PRIMARY KEY, label TEXT, name TEXT, datatype TEXT,
140+
mark_for_delete BOOL, editable BOOL, display TEXT, is_multiple BOOL,
141+
normalized BOOL)""")
142+
con.execute("INSERT INTO custom_columns VALUES (1,'read','Read','bool',0,1,'{}',0,0)")
143+
con.commit()
144+
con.close()
145+
146+
engine = create_engine(f"sqlite:///{tmp}", future=True)
147+
factory = scoped_session(sessionmaker(autocommit=False, autoflush=True,
148+
bind=engine, future=True))
149+
previous_factory = dbmod.CalibreDB.session_factory
150+
previous_init = dbmod.CalibreDB._init
151+
dbmod.CalibreDB.session_factory = factory
152+
dbmod.CalibreDB._init = True
153+
try:
154+
cdb = dbmod.CalibreDB.__new__(dbmod.CalibreDB)
155+
cc = cdb.get_cc_columns(SimpleNamespace(config_columns_to_ignore=None,
156+
config_read_column=0))
157+
assert [c.name for c in cc] == ["Read"]
158+
finally:
159+
factory.remove()
160+
engine.dispose()
161+
dbmod.CalibreDB.session_factory = previous_factory
162+
dbmod.CalibreDB._init = previous_init
163+
tmp.unlink(missing_ok=True)
164+
165+
98166
@pytest.mark.unit
99167
def test_session_is_materialised_not_none(unreadable_calibre_schema):
100168
"""The precondition that removed the old accidental cushion.

0 commit comments

Comments
 (0)