Reduce server load when polling the transaction history - #927
Merged
DavidMStraub merged 3 commits intoAug 11, 2026
Conversation
Contributor
There was a problem hiding this comment.
Pull request overview
This PR reduces server load for clients that poll the transaction history endpoint by introducing conditional requests (ETag/304) and by restructuring how undo DB transactions and their associated changes are loaded.
Changes:
- Refactors undo DB history queries to fetch only the relevant change rows per transaction page (with chunking and deferred columns) and adds an aggregate “state” query for fast revalidation.
- Adds ETag-based revalidation to
GET /api/transactions/history/(304 responses with headers,Cache-Control: no-cache,X-Total-Count) and fixes missing-ID behavior to return 404 instead of 500. - Moves user-id→user-name mapping into a short-lived request cache and adds tests covering ETag behavior, chunking, and change scoping.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/test_undodb.py | Adds unit tests for DbUndoSQLWeb history queries (change scoping, chunking, state aggregation, old/new gating, missing transaction). |
| tests/test_endpoints/test_history.py | Adds endpoint tests verifying ETag round-trip (304) and that ETag varies with query args. |
| gramps_webapi/undodb.py | Implements chunked change fetching and adds get_transactions_state(); updates transaction fetch paths to eager-load connections and avoid walking connection.changes. |
| gramps_webapi/api/resources/util.py | Factors out etag_unchanged() and reuses it in return_304_if_unchanged(). |
| gramps_webapi/api/resources/history.py | Adds ETag/304 flow to the history list endpoint, uses cached user mapping post-304 check, and fixes missing transaction handling (404). |
| gramps_webapi/api/cache.py | Introduces cached get_user_dict() keyed by tree/include-treeless, with short timeout and “refetch if missing requested IDs” behavior. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
4 tasks
Contributor
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 6 changed files in this pull request and generated no new comments.
Suppressed comments (3)
gramps_webapi/undodb.py:73
CHANGES_QUERY_CHUNK_SIZE = 500is likely to exceed SQLite’s default bound-parameter limit (commonly 999) because_get_changes_chunk()builds anORof per-transaction ranges with ~3 bind params each. With 500 transactions that’s ~1500 params and can raiseOperationalError: too many SQL variableswhen fetching unpaginated history on SQLite.
# transactions per change query, to keep the statement size bounded
CHANGES_QUERY_CHUNK_SIZE = 500
gramps_webapi/undodb.py:633
before/afterare checked via truthiness, so a valid value like0.0(Unix epoch) won’t apply the filter. These should beis not Nonechecks to match the type (float | None) and avoid surprising query behavior.
if before:
query = query.filter(Transaction.timestamp < before * 1e9)
if after:
query = query.filter(Transaction.timestamp > after * 1e9)
gramps_webapi/api/resources/util.py:1857
If-None-Matchcan legally contain a comma-separated list of validators. The current implementation passes the whole header value intonormalize_etag(), which will never match in the multi-ETag case and prevents 304 responses (increasing load) when clients send lists.
def etag_unchanged(etag: str) -> bool:
"""Check whether the if none match header agrees with the current etag."""
old_etag = request.headers.get("If-None-Match")
return bool(old_etag) and normalize_etag(old_etag) == etag
Member
Author
Polling the transaction history: instruction for frontends 🤖
|
DavidMStraub
force-pushed
the
history-polling-load
branch
from
August 11, 2026 10:13
2ce98d6 to
bf7c2e4
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
@dsblank since you mentioned you rely on polling, I invested some work to make polling of the history endpoint less expensive for the server.
Claude:
GET /api/transactions/history/had no ETag, noCache-Controland no caching,so every poll did full work. It also loaded every change of a connection to
serve the few transactions on one page.
Changes
Change loading (
undodb.py).Transaction._to_dict()no longer walksconnection.changes._get_changes()fetches the changes of a page in onequery, built from each transaction's
(connection_id, first..last)range, andpartitions the rows in Python. The legacy binary columns are always deferred,
old_json/new_jsononly when not requested. The connection is loaded withcontains_eagerinstead of lazily per row. Sincepagedefaults to0(unpaginated), transactions are chunked at 500 per query.
ETag and 304 (
history.py).get_transactions_state()returns(max_id, count)from one aggregate query. The validator issha256(tree_id, max_id, count, args)and is checked againstIf-None-Matchbefore any change-log work, so a steady-state poll costs one aggregate query.
Responses carry
ETag,Cache-Control: no-cacheandX-Total-Count;304s carry the headers without a body.
User names (
cache.py).get_user_dict()moved out ofhistory.pyintorequest_cachewith a 60 s timeout, and is now called only after the 304check. Users live in the auth database, so there is no change timestamp to key
on; a cached mapping missing one of the IDs being rendered is refetched.
return_304_if_unchanged()inresources/util.pywas refactored to share thenew
etag_unchanged()predicate.Notes
the next transaction is written.
get_transaction()now returnsNoneinstead of raisingAttributeErrorfor a missing ID. Two
except AttributeErrorblocks became dead and wereremoved;
TransactionHistoryResourcegained the 404 it was missing (itpreviously returned 500).
first is None(anempty
DbTxn) reports every change of its connection.Tests
43 passing in
test_history.py,test_undodb.pyandtest_transactions.py,7 of them new: ETag round trip with invalidation on a new transaction, the
validator depending on query args, change scoping across transactions sharing a
connection, the chunk boundary,
get_transactions_state,get_transactionincluding the missing-ID case, and
old/newgating.