From a8dde44d4f8bca3477087673c09cc14b0d57e6b4 Mon Sep 17 00:00:00 2001 From: lrnselfreliance <33263708+lrnselfreliance@users.noreply.github.com> Date: Fri, 9 Oct 2026 19:45:13 -0600 Subject: [PATCH] Archive page: load history and tags eagerly (#809) GET /api/archive/ loaded each history Archive's FileGroup and Tags with their own queries while serializing: two queries per history Archive. An Archive with 51 history entries ran 107 statements per request. Both the Archive and its history now use Archive.json_options(), so the count no longer grows with the history (5 statements for the same Archive). Co-authored-by: Claude Opus 5.5 --- modules/archive/lib.py | 3 ++- modules/archive/models.py | 1 + modules/archive/test/test_api.py | 37 ++++++++++++++++++++++++++++++++ 3 files changed, 40 insertions(+), 1 deletion(-) diff --git a/modules/archive/lib.py b/modules/archive/lib.py index e5562577..6c1f11b2 100644 --- a/modules/archive/lib.py +++ b/modules/archive/lib.py @@ -1243,7 +1243,8 @@ def get_archive(session: Session, archive_id: int, skip_viewed: bool = False) -> def get_archive_by_file_group_id(session: Session, file_group_id: int, skip_viewed: bool = False) -> Archive: """Get an Archive by its FileGroup ID.""" - archive = session.query(Archive).filter_by(file_group_id=file_group_id).one_or_none() + archive = session.query(Archive).options(*Archive.json_options()) \ + .filter_by(file_group_id=file_group_id).one_or_none() if not archive: raise UnknownArchive(f'Unknown Archive with FileGroup ID: {file_group_id}') if not skip_viewed: diff --git a/modules/archive/models.py b/modules/archive/models.py index baa75164..0931f1a6 100644 --- a/modules/archive/models.py +++ b/modules/archive/models.py @@ -172,6 +172,7 @@ class Archive(Base, ModelHelper): FileGroup.url != None, # noqa ) \ .join(FileGroup, FileGroup.id == Archive.file_group_id) \ + .options(*Archive.json_options()) \ .order_by(FileGroup.published_datetime)) return history diff --git a/modules/archive/test/test_api.py b/modules/archive/test/test_api.py index 8b601c4a..fb728fa8 100644 --- a/modules/archive/test/test_api.py +++ b/modules/archive/test/test_api.py @@ -590,3 +590,40 @@ async def test_delete_archive_atomic_when_some_tagged(async_client, test_session assert all(p.is_file() for p in untagged_paths) assert all(p.is_file() for p in tagged_paths) assert test_session.query(Archive).count() == 2 + + +@pytest.mark.asyncio +async def test_get_archive_loads_history_eagerly(test_session, archive_factory, async_client, tag_factory): + """An Archive's page returns its history, with each Archive's Tags, without a query per history Archive.""" + from sqlalchemy import event + from sqlalchemy.engine import Engine + + tag = await tag_factory() + short = [archive_factory('example.com', 'https://example.com/short', tag_names=[tag.name]) for _ in range(2)] + long = [archive_factory('example.com', 'https://example.com/long', tag_names=[tag.name]) for _ in range(6)] + test_session.commit() + + async def get(archive): + statements = [] + + def count(conn, cursor, statement, *_): + statements.append(statement) + + event.listen(Engine, 'before_cursor_execute', count) + try: + request, response = await async_client.get(f'/api/archive/{archive.file_group_id}?skip_viewed=true') + finally: + event.remove(Engine, 'before_cursor_execute', count) + assert response.status_code == HTTPStatus.OK, response.json + return response.json, len(statements) + + await get(short[-1]) # The first request does one-time work; measure the next ones. + short_json, short_statements = await get(short[-1]) + long_json, long_statements = await get(long[-1]) + + assert len(short_json['history']) == 1 + assert len(long_json['history']) == 5 + assert long_json['file_group']['tags'] == [tag.name] + assert all(i['tags'] == [tag.name] for i in long_json['history']) + # Five history Archives cost no more queries than one. + assert long_statements == short_statements, (short_statements, long_statements)