Files
shelfmark/tests/core/test_library_index.py
T
splitsec2 a430341a93 perf: cut repeated work on search and cover paths (#1544)
I went through what Shelfmark does per search and per cover and measured
the pieces that showed up. Four of them are cheap to fix, so this is one
PR. Shelfmark runs on a single gevent worker, so any of these blocks
every other request while it runs, and I'm assuming a lot of people run
it on small hardware (a Pi, say).

Cover cache. Every cache hit rewrote the whole `cache_index.json` just
to bump the entry's last-access time. On my box that is 3.7 ms of CPU
per hit at 500 entries and about 100 ms at 15,000 (2.2 MB written each
time), and a results page asks for around 40 covers. The access time now
stays in memory and the index is written at most every 30 seconds, or
whenever something else saves it. A crash inside that window loses some
access times, which only changes eviction order.

Settings reload. `config.refresh()` rebuilt the cache by reading and
parsing a tab's JSON file once per field, so about 250 parses per
refresh, 100 ms of CPU, and the metadata search, providers and config
routes each trigger it. It now reads each file once per rebuild (several
tabs share `settings.json`, so the memo is keyed on the file path).

Library match. When the library check is on, each search result is
compared against every library entry, and the title was split into words
again for every entry. The words are now computed once per result. I
measured 33 ms per result at 5,000 library entries and 106 ms at 20,000,
which is a second or more per page of 40 results. The matching itself is
unchanged.

Cover URL probe. `is_covers_cache_enabled()` creates and deletes a file
in the config directory to check it is writable, and it runs for every
cover, queue task and history row. The answer is remembered for 60
seconds. The probe is 0.045 ms on its own, but it is called a few
hundred times per request in the worst case.

Each change has a test that fails without it (call counts on the index
saves, file opens during a refresh, tokenisation calls, and probe
calls). Ruff, basedpyright on the changed files and the non-e2e suite
pass. The timings are from a dev machine with an empty config, so the
real numbers depend on cache and library size.
2026-10-22 20:24:57 -04:00

487 lines
17 KiB
Python

"""Tests for the library ownership facade: matcher, routing, caching and fail-open."""
from __future__ import annotations
from typing import Any
import pytest
from shelfmark.core import library_index
from shelfmark.core.library_providers import LibraryEntry
from shelfmark.core.text_match import isbn_variants
from shelfmark.metadata_providers import BookMetadata
def _book(**overrides: Any) -> BookMetadata:
fields: dict[str, Any] = {
"provider": "hardcover",
"provider_id": "446681",
"title": "Dungeon Crawler Carl",
"authors": ["Matt Dinniman"],
}
fields.update(overrides)
return BookMetadata(**fields)
def _entry(
*words: str,
isbns: frozenset[str] = frozenset(),
external_ids: frozenset[tuple[str, str]] = frozenset(),
) -> LibraryEntry:
return LibraryEntry(frozenset(words), isbns, frozenset(), external_ids)
_DCC_ENTRY = _entry("dungeon", "crawler", "carl", "matt", "dinniman")
class _Provider:
def __init__(
self,
name: str,
content_types: set[str],
entries: list[LibraryEntry] | None = None,
*,
enabled: bool = True,
error: Exception | None = None,
token: object | None = None,
) -> None:
self.name = name
self.display_name = name.title()
self.content_types = frozenset(content_types)
self.entries = entries or []
self.enabled = enabled
self.error = error
self.token = token
self.fetches = 0
def is_enabled(self) -> bool:
return self.enabled
def describe(self) -> str:
return f"{self.display_name} (test)"
def fingerprint(self) -> object | None:
return self.token
def fetch_entries(self) -> list[LibraryEntry]:
self.fetches += 1
if self.error is not None:
raise self.error
return list(self.entries)
@pytest.fixture
def providers(monkeypatch: pytest.MonkeyPatch) -> list[_Provider]:
registered: list[_Provider] = []
monkeypatch.setattr(library_index, "all_providers", lambda overrides=None: list(registered))
monkeypatch.setattr(library_index, "_cache", {})
return registered
@pytest.fixture
def clock(monkeypatch: pytest.MonkeyPatch) -> list[float]:
now = [1000.0]
monkeypatch.setattr(library_index.time, "monotonic", lambda: now[0])
return now
def _warnings(monkeypatch: pytest.MonkeyPatch) -> list[str]:
messages: list[str] = []
monkeypatch.setattr(
library_index.logger, "warning", lambda msg, *args: messages.append(msg % args)
)
return messages
# --------------------------------------------------------------------------- #
# ISBN forms
# --------------------------------------------------------------------------- #
def test_isbn_variants_add_the_isbn13_form_of_an_isbn10() -> None:
assert isbn_variants("0-593-82024-X") == {"059382024X", "9780593820247"}
assert isbn_variants("978-0-593-82024-7") == {"9780593820247"}
assert isbn_variants(None) == frozenset()
assert isbn_variants("n/a") == frozenset()
# --------------------------------------------------------------------------- #
# Pure matcher
# --------------------------------------------------------------------------- #
def test_no_entries_never_match() -> None:
assert library_index.book_matches_entries(_book(), []) is False
def test_isbn13_exact_match() -> None:
entries = [_entry("unrelated", isbns=frozenset({"9780593820247"}))]
book = _book(title="Different Title", authors=["Someone Else"], isbn_13="978-0-593-82024-7")
assert library_index.book_matches_entries(book, entries) is True
def test_isbn10_book_matches_isbn13_entry() -> None:
entries = [_entry("unrelated", isbns=frozenset({"9780593820247"}))]
book = _book(title="Different Title", authors=["Someone Else"], isbn_10="059382024X")
assert library_index.book_matches_entries(book, entries) is True
def test_isbn13_book_matches_entry_indexed_from_an_isbn10() -> None:
entries = [_entry("unrelated", isbns=isbn_variants("059382024X"))]
book = _book(title="Different Title", authors=["Someone Else"], isbn_13="9780593820247")
assert library_index.book_matches_entries(book, entries) is True
def test_external_id_match_requires_the_same_provider() -> None:
entries = [_entry("unrelated", external_ids=frozenset({("hardcover", "446681")}))]
book = _book(title="Different Title", authors=["Someone Else"])
assert library_index.book_matches_entries(book, entries) is True
assert library_index.book_matches_entries(book, [_entry("x")]) is False
other_provider = _book(provider="googlebooks", title="Different", authors=["Else"])
assert library_index.book_matches_entries(other_provider, entries) is False
def test_fuzzy_title_and_author_surname_match() -> None:
assert library_index.book_matches_entries(_book(), [_DCC_ENTRY]) is True
def test_near_miss_title_fails() -> None:
sequel = _book(title="Dungeon Crawler Carl 2: Carl's Doomsday Scenario")
assert library_index.book_matches_entries(sequel, [_DCC_ENTRY]) is False
def test_right_title_wrong_author_fails() -> None:
assert library_index.book_matches_entries(_book(authors=["Andy Weir"]), [_DCC_ENTRY]) is False
def test_search_fields_take_precedence_over_display_fields() -> None:
book = _book(
title="Dungeon Crawler Carl: A LitRPG Adventure", search_title="Dungeon Crawler Carl"
)
assert library_index.book_matches_entries(book, [_DCC_ENTRY]) is True
# --------------------------------------------------------------------------- #
# Routing
# --------------------------------------------------------------------------- #
def test_ebook_request_consults_only_the_ebook_provider(providers: list[_Provider]) -> None:
audio = _Provider("audio", {"audiobook"}, [_DCC_ENTRY])
ebook = _Provider("ebook", {"ebook"}, [])
providers.extend([audio, ebook])
assert library_index.is_in_library(_book(), "ebook") is False
assert (audio.fetches, ebook.fetches) == (0, 1)
assert library_index.is_in_library(_book(), "audiobook") is True
assert (audio.fetches, ebook.fetches) == (1, 1)
def test_no_content_type_consults_every_enabled_provider(providers: list[_Provider]) -> None:
audio = _Provider("audio", {"audiobook"}, [])
ebook = _Provider("ebook", {"ebook"}, [_DCC_ENTRY])
providers.extend([audio, ebook])
assert library_index.is_in_library(_book()) is True
assert (audio.fetches, ebook.fetches) == (1, 1)
def test_disabled_provider_is_not_consulted(providers: list[_Provider]) -> None:
disabled = _Provider("ebook", {"ebook"}, [_DCC_ENTRY], enabled=False)
providers.append(disabled)
assert library_index.is_in_library(_book(), "ebook") is False
assert disabled.fetches == 0
def test_any_provider_enabled(providers: list[_Provider]) -> None:
assert library_index.any_provider_enabled() is False
providers.append(_Provider("ebook", {"ebook"}, enabled=False))
assert library_index.any_provider_enabled() is False
providers.append(_Provider("audio", {"audiobook"}))
assert library_index.any_provider_enabled() is True
# --------------------------------------------------------------------------- #
# Fail-open + cache
# --------------------------------------------------------------------------- #
def test_provider_error_fails_open_without_a_cache(
providers: list[_Provider], monkeypatch: pytest.MonkeyPatch
) -> None:
providers.append(_Provider("ebook", {"ebook"}, error=OSError("no such file")))
warnings = _warnings(monkeypatch)
assert library_index.is_in_library(_book(), "ebook") is False
assert warnings == ["library check: Ebook (test) unavailable (no such file); failing open"]
def test_provider_error_keeps_answering_from_the_stale_cache(
providers: list[_Provider], monkeypatch: pytest.MonkeyPatch, clock: list[float]
) -> None:
provider = _Provider("ebook", {"ebook"}, [_DCC_ENTRY])
providers.append(provider)
assert library_index.is_in_library(_book(), "ebook") is True
provider.error = RuntimeError("boom")
clock[0] += library_index._CACHE_TTL_SECONDS + 1
warnings = _warnings(monkeypatch)
assert library_index.is_in_library(_book(), "ebook") is True
assert provider.fetches == 2
assert len(warnings) == 1
def test_a_failing_library_is_tried_once_per_search_not_once_per_result(
providers: list[_Provider], monkeypatch: pytest.MonkeyPatch, clock: list[float]
) -> None:
provider = _Provider("ebook", {"ebook"}, [_DCC_ENTRY])
providers.append(provider)
assert library_index.is_in_library(_book(), "ebook") is True
provider.error = RuntimeError("boom")
clock[0] += library_index._CACHE_TTL_SECONDS + 1
warnings = _warnings(monkeypatch)
for _ in range(40): # one search page, every result asks
assert library_index.is_in_library(_book(), "ebook") is True
assert provider.fetches == 2 # the first index, then one failed attempt
assert len(warnings) == 1
def test_a_failing_library_with_no_cache_is_not_retried_for_every_result(
providers: list[_Provider], clock: list[float]
) -> None:
provider = _Provider("ebook", {"ebook"}, error=OSError("no such file"))
providers.append(provider)
for _ in range(10):
assert library_index.is_in_library(_book(), "ebook") is False
assert provider.fetches == 1
def test_a_failing_library_is_retried_once_the_back_off_has_passed(
providers: list[_Provider], clock: list[float]
) -> None:
provider = _Provider("ebook", {"ebook"}, [_DCC_ENTRY], error=OSError("down"))
providers.append(provider)
assert library_index.is_in_library(_book(), "ebook") is False
provider.error = None
clock[0] += library_index._FAILURE_BACKOFF_SECONDS - 1
assert library_index.is_in_library(_book(), "ebook") is False
assert provider.fetches == 1
clock[0] += 2
assert library_index.is_in_library(_book(), "ebook") is True
assert provider.fetches == 2
def test_entries_are_cached_until_the_ttl_expires(
providers: list[_Provider], clock: list[float]
) -> None:
provider = _Provider("ebook", {"ebook"}, [_DCC_ENTRY])
providers.append(provider)
library_index.is_in_library(_book(), "ebook")
library_index.is_in_library(_book(), "ebook")
assert provider.fetches == 1
clock[0] += library_index._CACHE_TTL_SECONDS - 1
library_index.is_in_library(_book(), "ebook")
assert provider.fetches == 1
clock[0] += 2
library_index.is_in_library(_book(), "ebook")
assert provider.fetches == 2
def test_fingerprint_change_refreshes_before_the_ttl(
providers: list[_Provider], clock: list[float]
) -> None:
provider = _Provider("ebook", {"ebook"}, [], token=1.0)
providers.append(provider)
assert library_index.is_in_library(_book(), "ebook") is False
clock[0] += 5
library_index.is_in_library(_book(), "ebook")
assert provider.fetches == 1
provider.entries = [_DCC_ENTRY]
provider.token = 2.0
assert library_index.is_in_library(_book(), "ebook") is True
assert provider.fetches == 2
def test_cache_is_kept_per_provider(providers: list[_Provider]) -> None:
audio = _Provider("audio", {"audiobook"}, [])
ebook = _Provider("ebook", {"ebook"}, [_DCC_ENTRY])
providers.extend([audio, ebook])
assert library_index.is_in_library(_book(), "audiobook") is False
assert library_index.is_in_library(_book(), "ebook") is True
assert library_index.is_in_library(_book(), "audiobook") is False
assert (audio.fetches, ebook.fetches) == (1, 1)
# --------------------------------------------------------------------------- #
# Settings test button
# --------------------------------------------------------------------------- #
def test_connection_reports_the_count_and_primes_the_cache(providers: list[_Provider]) -> None:
provider = _Provider("ebook", {"ebook"}, [_DCC_ENTRY, _entry("other")])
providers.append(provider)
result = library_index.test_connection("ebook", None)
assert result == {"success": True, "message": "Ebook (test): indexed 2 item(s)."}
assert library_index.is_in_library(_book(), "ebook") is True
assert provider.fetches == 1
def test_connection_reports_provider_errors(providers: list[_Provider]) -> None:
providers.append(_Provider("ebook", {"ebook"}, error=OSError("no such file")))
result = library_index.test_connection("ebook", None)
assert result == {"success": False, "message": "Ebook (test): no such file"}
def test_connection_rejects_unknown_providers(providers: list[_Provider]) -> None:
result = library_index.test_connection("nope", None)
assert result == {"success": False, "message": "Unknown library provider: nope"}
def test_unsaved_form_values_override_saved_settings_for_test_connection(tmp_path):
from shelfmark.core.library_providers import setting
from shelfmark.core.library_providers.calibre import CalibreLibrary
class _Saved:
def get(self, key, default=None, user_id=None):
return {"CALIBRE_LIBRARY_DB_PATH": "/saved/metadata.db"}.get(key, default)
assert (
setting(_Saved(), "CALIBRE_LIBRARY_DB_PATH", "", {"CALIBRE_LIBRARY_DB_PATH": "/form/x.db"})
== "/form/x.db"
)
assert (
setting(_Saved(), "CALIBRE_LIBRARY_DB_PATH", "", {"CALIBRE_LIBRARY_DB_PATH": ""})
== "/saved/metadata.db"
)
assert setting(_Saved(), "CALIBRE_LIBRARY_DB_PATH", "", None) == "/saved/metadata.db"
provider = CalibreLibrary({"CALIBRE_LIBRARY_DB_PATH": str(tmp_path / "form.db")})
assert provider.describe().endswith("form.db")
def test_ownership_reports_only_formats_with_an_enabled_library(providers):
owned = LibraryEntry(
frozenset({"dungeon", "crawler", "carl"}),
frozenset({"9780593820247"}),
frozenset(),
frozenset(),
)
providers.append(_Provider("calibre", {"ebook"}, [owned]))
providers.append(_Provider("audiobooks", {"audiobook"}, [], enabled=False))
assert library_index.ownership(_book(isbn_13="9780593820247")) == {"ebook": "owned"}
assert library_index.ownership(_book(isbn_13="9999999999999", title="Something Else")) == {
"ebook": None
}
def test_ownership_is_none_when_no_library_check_is_enabled(providers):
providers.append(_Provider("calibre", {"ebook"}, [], enabled=False))
assert library_index.ownership(_book()) is None
def test_ownership_covers_both_formats_when_both_libraries_are_enabled(providers):
entry = LibraryEntry(
frozenset({"dungeon", "crawler", "carl"}),
frozenset({"9780593820247"}),
frozenset(),
frozenset(),
)
providers.append(_Provider("calibre", {"ebook"}, []))
providers.append(_Provider("audiobooks", {"audiobook"}, [entry]))
assert library_index.ownership(_book(isbn_13="9780593820247")) == {
"ebook": None,
"audiobook": "owned",
}
def _shelf_entry(title_tokens: set[str], context: set[str] | None = None) -> LibraryEntry:
context = context or set()
return LibraryEntry(
frozenset(title_tokens | context),
frozenset(),
frozenset(),
frozenset(),
frozenset(title_tokens),
frozenset(context),
)
def test_a_longer_shelf_title_naming_another_work_is_not_a_match() -> None:
# "Dune" is a subset of "Dune Messiah", which is a different book.
shelf = [_shelf_entry({"dune", "messiah"}, {"frank", "herbert"})]
book = _book(title="Dune", authors=["Frank Herbert"])
assert library_index.match_entries(book, shelf) is None
def test_the_series_and_volume_a_shelf_title_repeats_do_not_block_a_match() -> None:
# Calibre titles often carry the series and its number: "Alex Cross 25: Cross Kill".
shelf = [_shelf_entry({"alex", "cross", "25", "kill"}, {"alex", "cross", "james", "patterson"})]
book = _book(title="Cross Kill", authors=["James Patterson"])
assert library_index.match_entries(book, shelf) == "owned"
def test_an_edition_word_does_not_block_a_match() -> None:
shelf = [_shelf_entry({"dune", "illustrated", "edition"}, {"frank", "herbert"})]
assert (
library_index.match_entries(_book(title="Dune", authors=["Frank Herbert"]), shelf)
== "owned"
)
def test_a_collection_reports_itself_as_a_collection() -> None:
shelf = [_shelf_entry({"dungeon", "crawler", "carl", "books", "1", "6"}, {"matt", "dinniman"})]
book = _book(title="Dungeon Crawler Carl", authors=["Matt Dinniman"])
assert library_index.match_entries(book, shelf) == "collection"
def test_an_exact_shelf_title_outranks_a_collection_holding_the_same_book() -> None:
collection = _shelf_entry({"dungeon", "crawler", "carl", "omnibus"}, {"matt", "dinniman"})
single = _shelf_entry({"dungeon", "crawler", "carl"}, {"matt", "dinniman"})
book = _book(title="Dungeon Crawler Carl", authors=["Matt Dinniman"])
assert library_index.match_entries(book, [collection, single]) == "owned"
def test_matching_tokenises_the_title_once_for_all_entries(monkeypatch: pytest.MonkeyPatch) -> None:
"""The title does not change between entries, so it is split into words once."""
from shelfmark.core import text_match
calls: list[str | None] = []
real = text_match.significant_tokens
def counting(text: str | None) -> list[str]:
calls.append(text)
return real(text)
monkeypatch.setattr(text_match, "significant_tokens", counting)
entries = [_entry("unrelated", f"word{i}", "someone") for i in range(50)]
assert library_index.match_entries(_book(), entries) is None
assert len(calls) <= 2