Archive search counts from the search index (#814)

The total for an archive search joined `archive LEFT JOIN file_group` to the FTS table. A LEFT JOIN
fixes `archive` as the outer table, so SQLite scanned every Archive and ran the MATCH once per row:
about 20 seconds of CPU on a Pi with 128k Archives, every time the 60-second totals cache expired.
An inner join lets the planner start from the FTS matches (the same count in milliseconds). Every
Archive has a FileGroup, so the count is unchanged.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
lrnselfreliance 2026-10-11 15:32:46 -06:00 • committed by Roland
parent 99a0fc275d
commit 8e2ab935af
No known key found for this signature in database
GPG key ID: 1BA620CE6C9A4680
2 changed files with 30 additions and 1 deletions

View file

@ -1491,8 +1491,10 @@ def search_archives(session: Session, search_str: str, domain: str, limit: int,
)
else:
if file_group_search:
# An inner join lets SQLite start from the FTS matches; a LEFT JOIN pins `archive` as the outer
# table, which runs the MATCH once per Archive.
count_from = f'''FROM archive a
LEFT JOIN file_group fg ON fg.id = a.file_group_id
JOIN file_group fg ON fg.id = a.file_group_id
{file_group_search.join}'''
count_wheres = list(wheres)
count_wheres.append(file_group_search.where)

View file

@ -3,11 +3,13 @@ import pathlib
import tempfile
from datetime import datetime, timedelta
from http import HTTPStatus
from unittest import mock
import pytest
import pytz
from PIL import Image
from pytz import utc
from sqlalchemy import text
from modules.archive import lib
from modules.archive.lib import ArchiveDownloaderConfigValidator
@ -20,6 +22,7 @@ from wrolpi.api_utils import CustomJSONEncoder
from wrolpi.collections import Collection
from wrolpi.common import get_wrolpi_config
from wrolpi.db import get_db_session
from wrolpi.files.lib import count_file_groups
from wrolpi.files.models import FileGroup
from wrolpi.test.common import skip_ci
@ -1425,3 +1428,27 @@ async def test_singlefile_to_archive_result_outlives_its_session(async_client, t
assert name == 'upload title'
assert url == 'https://example.com/upload'
assert uploaded.file_group_id == archive.file_group_id
@pytest.mark.parametrize('deep', [False, True])
def test_search_archives_count_is_driven_by_the_search_index(test_directory, test_session, archive_factory, deep):
"""A search's total starts from the FTS matches. Starting from `archive` runs the MATCH once per Archive,
which took ~20 seconds on a Pi with 128k Archives."""
archive_factory(domain='example.com', title='solar panels', contents='solar panels')
archive_factory(domain='example.com', title='wind turbines', contents='wind turbines')
test_session.commit()
counts = []
def capture_count(session, statement, params):
counts.append((statement, params))
return count_file_groups(session, statement, params)
with mock.patch('modules.archive.lib.count_file_groups', capture_count):
file_groups, total = lib.search_archives(test_session, search_str='solar', domain=None, limit=10, offset=0,
order=None, tag_names=None, deep=deep)
assert total == 1 and len(file_groups) == 1
(statement, params), = counts
plan = [row[3] for row in test_session.execute(text(f'EXPLAIN QUERY PLAN {statement}'), params).fetchall()]
assert plan[0].startswith('SCAN fts VIRTUAL TABLE'), plan