Skip to content

GrampsWebApiDb: repair a mirror the history feed can't explain - #1015

Open
dsblank wants to merge 2 commits into
gramps-project:maintenance/gramps60from
dsblank:grampswebapidb-poll-backoff-and-mirror-verify
Open

GrampsWebApiDb: repair a mirror the history feed can't explain#1015
dsblank wants to merge 2 commits into
gramps-project:maintenance/gramps60from
dsblank:grampswebapidb-poll-backoff-and-mirror-verify

Conversation

@dsblank

@dsblank dsblank commented Aug 13, 2026

Copy link
Copy Markdown
Member

Follow-up to #1014, against the same addon.

1. An unreachable server no longer floods the log or stutters the UI

A refused connection logged a full traceback at ERROR on every 10-second poll tick for as long as the server stayed down, and each tick spent a blocking round trip on the GTK main thread (plus webapi_client's own one-shot retry sleep):

355703: ERROR: grampswebapidb.py: line 776: Periodic sync from server failed; will retry.
  ... ~50 lines of traceback ...
ConnectionRefusedError: [Errno 111] Connection refused

An outage is an expected, self-healing condition here — the mirror stays usable, local edits queue for the next successful push, and the sync cursor is persisted — so it is now reported once per outage (one WARNING line with the error text, traceback demoted to DEBUG), and the poll interval doubles from POLL_INTERVAL_SECONDS up to a new POLL_BACKOFF_MAX_SECONDS (300) until the first success restores it. The media poll gets the same once-per-outage reporting without backoff — 300s is already coarse.

2. A mirror the transaction history can't account for is now repaired

The history table only records what gramps-web-api itself wrote. A tree populated by any other route — a server-side import straight into the database, a restored dump, a truncated history table — has nothing for the incremental feed to replay, so syncing it produced a mirror holding only the handful of edits the history does know about, with nothing in the log to say why.

https://demo.grampsweb.org is exactly this case:

people 4668 · families 2855 · events 16243 · media 175
GET /transactions/history/  ->  0 rows, X-Total-Count: 0

load() now compares the mirror's own get_total() against the server's object_counts after each sync (_mirror_is_short_of_the_server(), via _sync_from_server()'s new verify_totals) and routes a shortfall to the existing _full_resync().

Comparing totals rather than watching for an empty feed is what makes the case detectable at all: one API edit against such a server is enough to hand back a transaction, advance sync_last_time, and make the sync look like it worked. Guards:

  • runs at load() only, never on the 10-second poll — repairs happen when a tree is opened, not mid-session, and it costs one GET /metadata/ per open;
  • skipped while pending_pushes is non-empty — those are local edits the server hasn't taken yet, so the counts are legitimately out of step;
  • a larger local total is left alone: that's either something about to be pushed or something the export would destroy, and a rebuild shouldn't decide that on its own;
  • webapi_client.OBJECT_COUNT_KEYS pins the sum to the same ten primary types DbGeneric.get_total() counts, so a future server bucket can't make a good mirror look permanently short.

Verified live against the demo, on a mirror left in the broken state (one person, cursor already advanced):

WARNING .grampswebapidb: Local mirror holds 1 objects but the server reports 26541;
        its transaction history cannot account for the difference, so the mirror
        is being rebuilt from a full export.
load() took 17.1s -> people 4669, total 26541 (server 26541)
reopen took 3.7s  -> no second rebuild

3. A DEBUG trace, since none of the above is visible from the UI

Both modules now log to .grampswebapidb, so gramps -d .grampswebapidb turns on a per-operation trace: one line per HTTP request (method, path, status, round-trip time), one per sync page and one per sync (applied/skipped/cursor/elapsed), plus load, push, pending-queue depth, media transfer counts, and the local-vs-server totals compared at load. No object data, payloads, or headers — replaying a busy feed costs a fixed handful of lines rather than one per change. A full repair traces in ~20 lines.

Testing

python3 -m unittest GrampsWebApiDb.tests.test_grampswebapidb GrampsWebApiDb.tests.test_webapi_client — 255 tests pass (94 of them new here: backoff, once-per-outage reporting, the totals check and each of its guards, and get_object_count()). Also exercised end to end against the live demo server, as above.

🤖 Generated with Claude Code

dsblank and others added 2 commits August 13, 2026 11:39
Three related fixes to how a session copes with a server that isn't
answering, or that answers with a history it cannot back up.

An unreachable server logged a full traceback at ERROR on every 10
second poll tick for as long as the outage lasted, and spent a blocking
round trip (plus webapi_client's own retry sleep) on the GTK main thread
each time. It is now reported once per outage -- one line at WARNING,
traceback kept at DEBUG -- with the poll interval doubling up to the new
POLL_BACKOFF_MAX_SECONDS while it persists and resetting on the first
success. The media poll gets the same once-per-outage treatment (no
backoff: 300s is already coarse).

A server whose tree was populated without gramps-web-api recording any
history has nothing for the incremental feed to replay, so syncing it
produced a mirror holding only the handful of edits the history does
know about, with nothing logged to say so. https://demo.grampsweb.org is
exactly this: 4668 people against a history that was empty until someone
edited it through the API. load() now compares the mirror's own
get_total() against the server's object_counts after each sync
(_mirror_is_short_of_the_server(), via _sync_from_server()'s new
verify_totals) and routes a shortfall to the existing _full_resync().
Comparing totals rather than watching for an empty feed is what makes
the case detectable at all -- one API edit is enough to hand back a
transaction, advance the cursor, and make the sync look like it worked.
The check runs at load() only, never on the poll; it is skipped while
pushes are queued, and a larger local total is left alone rather than
overwritten from the export.

Neither of those was visible from the UI, so both modules now log to
".grampswebapidb" ("gramps -d .grampswebapidb") with a per-operation
DEBUG trace: one line per HTTP request, one per sync page and sync, plus
load, push, queue depth, media transfer counts and the totals compared.
No object data, payloads or headers -- a busy feed costs a fixed handful
of lines, not one per change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every network round trip this addon makes runs synchronously on the GTK
main thread, so a long one -- the initial catch-up, a full-export
rebuild, a first media sync of hundreds of files, a backgrounded push
being waited on -- is time the main loop spends inside the addon rather
than answering. The window stops redrawing and the window manager offers
to force-quit Gramps.

_pump_main_loop() hands the loop its turn at the boundaries of each of
those: between sync pages and once on the way out (so even a poll that
found nothing pumps after its round trip), between media files, across
the export download's chunks (new on_chunk hook on download_export() /
_get_binary()) and the task-poll loop (new on_wait hook on
wait_for_task() / push_transaction()), and either side of the rebuild --
before the wipe and after request_rebuild(), never in between, where a
dispatched event would be looking at a half-empty tree.

It goes through GLib's default main context rather than
Gtk.main_iteration() so the module stays importable without a display:
this is a DATABASE plugin, loadable from the CLI, and GTK drives that
same context anyway. Pumping re-enters, so both poll timeouts now check
a _syncing flag and skip their turn rather than starting a second sync
underneath the first.

Measured on a full rebuild of the 26540-object demo tree: a 100ms
heartbeat timeout went from 0 dispatches during load() to 15, leaving
two stalls the pump structurally can't cover -- the export request
itself (~6.6s, server-side generation, blocked inside urlopen) and
ImportXml (~5.2s, which never calls User.step_progress so there is no
hook to drive). Moving the sync off-thread remains the real fix for
those; this is the version that doesn't restructure every call path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@dsblank
dsblank marked this pull request as ready for review August 13, 2026 18:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant