diff --git a/GrampsWebApiDb/grampswebapidb.py b/GrampsWebApiDb/grampswebapidb.py index eae077915..0043bc930 100644 --- a/GrampsWebApiDb/grampswebapidb.py +++ b/GrampsWebApiDb/grampswebapidb.py @@ -52,7 +52,7 @@ "_class"/"value"/"string" triplet on GrampsType-derived fields), and data_to_object() raises KeyError on it. Confirmed against a live gramps52 server: read-only endpoints (auth, /trees/, /people/ counts, etc.) work -fine, but _sync_from_server() cannot deserialize its transaction history. +fine, but _sync_from_server_async() cannot deserialize its transaction history. Credentials come from a single environment variable, GRAMPS_WEB_API_KEY (see webapi_client.py for its "*" shape and @@ -63,7 +63,7 @@ without going through Gramps at all -- one credential, two consumers. Because of that, nothing but the Family Tree's own name ties its local -mirror to one particular server account. _check_identity() requires that +mirror to one particular server account. _check_identity_async() requires that name to be "@" (modulo Gramps' own filename-safe-character substitution on tree names, e.g. dots -> underscores -- see _FAMILY_TREE_NAME_UNSAFE_CHARS) for whoever GRAMPS_WEB_API_KEY currently @@ -83,14 +83,14 @@ super().transaction_commit(), since DBAPI.transaction_commit() clears the transaction's records as its last step. -The other place a DbTxn gets used is _sync_from_server() itself, applying +The other place a DbTxn gets used is _sync_from_server_async() itself, applying server-pulled changes -- that uses batch=True, and DBAPI._commit_base() skips trans.add() entirely for batch transactions (see dbapi.py), so transaction_to_json() naturally sees nothing there and no push happens. No separate "am I currently syncing" flag is needed to stop synced changes from being echoed straight back to the server. -_sync_from_server() can only replay what the history feed actually +_sync_from_server_async() can only replay what the history feed actually logged, and a batch=True commit -- any bulk import, merge, or tool run through gramps-web-api, not just a one-off -- logs nothing per-object: DBAPI's own commit_*/remove_* methods guard their trans.add() undo-log @@ -100,49 +100,117 @@ example.gramps produced exactly one such marker, and the 2157 people it added were otherwise invisible to this addon's sync no matter how often it resynced, because the transaction history itself never recorded -them. _sync_from_server() treats an empty-changes transaction as a +them. _sync_from_server_async() treats an empty-changes transaction as a signal that its history-replay approach cannot describe what happened, -and falls back to _full_resync() -- downloading the server's current +and falls back to _full_resync_async() -- downloading the server's current full Gramps XML export and reimporting it into a wiped local mirror, the only way to recover completeness when the incremental feed has a blind spot by construction. +An empty-changes marker is not the only shape that blind spot takes: a +server whose tree was populated without gramps-web-api recording any +history at all (a server-side import straight into the database, a +restored dump, a truncated history table) has no history to describe +what it holds -- https://demo.grampsweb.org is exactly that, 4668 people +against a history that was empty until someone edited it through the +API. Replaying such a feed produces a mirror holding only the few edits +the history does know about, with nothing to distinguish that from a +correct sync. load() therefore checks the mirror's own object total +against the server's after each sync (_mirror_is_short_of_the_server_async(), +via _sync_from_server_async()'s verify_totals) and routes a shortfall to the +same _full_resync_async(). Comparing totals rather than watching for an empty +feed is what makes the case detectable: one API edit against such a +server is enough for the feed to hand back a transaction and for the +sync to look like it worked. + Pushes go out without force=1, so the server compares each item's "old" snapshot against its own current data and rejects the whole batch with WebApiPushConflict (see webapi_client.push_transaction()) if anything changed server-side since the local mirror last synced -- a real, if coarse, optimistic-concurrency check: the whole push either applies or -none of it does, with no indication of which item conflicted. On a -conflict, _push_payload() below resyncs from the server (so the local -mirror picks up whatever changed) and then, for a plain commit (not an -undo/redo -- see _retry_after_conflict()), replays each object's intended -*new* state as a fresh local edit via commit_()/remove_() on -top of that just-resynced data. That fresh edit goes through the normal -transaction_commit() -> _push_payload() path again with is_retry=True, -so it carries an up-to-date "old" snapshot and will only be rejected a -second time if something changes server-side in the brief window between -the resync and the retry -- in which case it is logged and dropped rather -than retried again, to avoid retrying forever against a genuinely hot -object. - -For an add/update whose handle still exists after the resync (i.e. the -conflicting server-side edit changed the same object rather than deleting -it), _merge_or_overwrite() below combines the two edits with the object's -own merge() -- the same list-unioning logic behind Gramps' Merge People/ +none of it does, with no indication of which item conflicted. + +The incremental history feed can't be trusted to explain a conflict, and +this is not a one-server edge case: gramps-web-api's bulk-import path +(POST /importers//file -- GEDCOM, Gramps XML, CSV, ...) runs the +same batch=True import machinery a local Gramps client's own Import menu +action would, which never touches its transaction-history table at all +(DBAPI's own _commit_base() only calls trans.add() when `not +trans.batch`) -- ordinary server administration for any real +installation, not a quirk of any particular one. So the incremental +history feed can be blind to an object's true current state from the +moment it was imported, indefinitely -- and a totals comparison +(_mirror_is_short_of_the_server_async(), the verify_totals check load() and +_sync_from_server_async() use elsewhere) can't catch this either, since the +object count doesn't change when an already-known object's content +changes server-side, only when objects are added or removed. + +A per-object fix was tried and doesn't work: gramps-web-api's REST +single-object endpoints (GET //) serialize with +GrampsJSONEncoder.extract_object() (gramps_webapi/api/resources/ +emit.py) -- a walk of the object's own __dict__/properties for the +frontend's display schema, with no "_class" tag on GrampsType-derived +fields -- not the gramps.gen.lib.json_utils shape data_to_object() +needs to reconstruct a Gramps object; feeding it that shape raises +KeyError. Only two things produce the compatible shape: the +transaction-history feed's new_data, and a raw Gramps XML export (see +_full_resync_async()). So on a conflict, _push_payload_async() below does a full +resync (_resync_after_conflict_async(), reusing _full_resync_async()) -- expensive, +but the only server round-trip that reliably brings the local mirror +back to the server's true current state for whatever this push touched +-- and then, for a plain commit (not an undo/redo -- see +_retry_after_conflict()), replays the intended *new* state as a fresh +local edit via commit_()/remove_(). That fresh edit goes +through the normal transaction_commit() -> _start_push() path again +with is_retry=True, so it now carries an "old" snapshot matching the +just-resynced mirror, and will only be rejected a second time if +something changes server-side in the brief window since the resync ran +-- in which case it is logged and dropped rather than retried again, to +avoid retrying forever against a genuinely hot object. + +A Gramps XML export isn't perfectly round-trip-faithful either, though: +it has no element for Person.birth_ref_index/death_ref_index (see +exportxml.py's write_person()), so ImportXml recomputes both from +document order on the way back in instead of preserving them -- wrong +whenever the true index was -1 despite a BIRTH/DEATH-type event ref +existing, or pointed at something other than the first such ref. Since +diff_items() (the same function old_unchanged() uses server-side) treats +those two fields as ordinary content, a Person whose true index doesn't +match that heuristic would otherwise disagree with the server after +every single resync, forever, for reasons unrelated to anything actually +edited. _snapshot_birth_death_indices()/_restore_birth_death_indices() +carry the pre-resync value back across the reimport for any Person whose +event_ref_list didn't itself change, closing that gap. + +For an add/update whose object still exists server-side (i.e. the +conflicting edit changed the same object rather than deleting it), +_merge_or_overwrite() below combines the two edits with the object's own +merge() -- the same list-unioning logic behind Gramps' Merge People/ Family/... tools (ported from GrampsWebSync's diffhandler.py, credit David Straub, same license) -- rather than letting the retry blindly -clobber whatever the other side changed. merge() only unions *list*-valued -fields (notes, citations, media, urls, event/family refs, ...); it never -touches scalar fields (a name, a date, a gender), so two edits to the -exact same scalar field still resolve as local-overwrites-remote -- real -field-level conflict *resolution* for that narrower case (diff, prompt the -user) is still out of scope. If the push fails for a non-conflict reason +clobber whatever the other side changed. merge(current, acquisition) is +called as current.merge(acquisition) with current the server's post- +resync copy and acquisition the local edit, so a *list*-valued field +(notes, citations, media, urls, event/family refs, tags, ...) is +unioned -- both sides' items survive -- and any other field merge() +doesn't specially handle is simply left as current's own value, with +acquisition's silently discarded (confirmed empirically: +merge(FEMALE-current, MALE-local) keeps FEMALE) -- the *opposite* of +"local overwrites remote". Two fields do get their own special-cased +merge instead of either of those: privacy is OR'd +(PrivacyBase._merge_privacy(): ``self.private = self.private or +other.private``, so the merged object is private if either side marked +it private -- confirmed the same way), and Person.merge() keeps +current's own primary name but demotes acquisition's into current's +alternate_names list rather than discarding it. Real field-level +conflict *resolution* for the plain-scalar case (diff, prompt the user) +is still out of scope. If the push fails for a non-conflict reason (network error, auth failure), the local commit has already happened and is not rolled back -- the local mirror just drifts from the server until the next successful push or read sync. Not every local batch=True commit is a pull-side replay, though: the same -trans.batch guard that makes _sync_from_server()'s own replay silent to +trans.batch guard that makes _sync_from_server_async()'s own replay silent to transaction_to_json() applies equally to *local* bulk operations run against this open tree from outside this file entirely -- ImportXml/ ImportGedcom/ImportCsv/..., and stock Tools like Check and Repair @@ -150,41 +218,56 @@ Types, Reorder Gramps IDs, and Sort Events all open their own DbTxn with batch=True for performance. Left alone, any of those would apply locally and never reach the server: transaction_commit() would see the same empty -transaction_to_json() payload it correctly sees for _sync_from_server()'s +transaction_to_json() payload it correctly sees for _sync_from_server_async()'s own pull-side batch replay, with nothing in the payload itself to tell the two apart. transaction_begin() (called by DbTxn.__enter__, so before the batch operation's body runs) tells them apart with a _pulling flag set -only around _sync_from_server()'s own batch DbTxns (including the ones -_full_resync() opens) -- everywhere else, a batch=True transaction gets a -snapshot of every primary object type's handle set stashed on the -transaction itself (_handles_by_class()). transaction_commit() diffs that -snapshot against the post-commit state (_reconcile_batch_commit()) to -reconstruct what changed: a handle that appeared is an add, one that -disappeared is a delete, and one that persisted but whose .change moved to -at or after the transaction's own start_time was an update -- _commit_ -base() always stamps .change on every commit, batch or not; only the -undo-log recording that batch skips is what normally would have let -transaction_to_json() see this directly. The reconstructed entries are -handed to _retry_after_conflict() -- not because anything conflicted, but -because it already does exactly what's needed here: replay each entry as -a fresh, ordinary (non-batch) local edit, so it picks up a real "old" -snapshot from DBAPI's own commit path and goes out through the normal -push path one object at a time, with the same conflict handling a live -edit gets. This costs roughly double the local writes for whatever the -batch operation actually touched (once for the batch commit, once more -for this replay) -- accepted as the price of correctness, the same trade -_full_resync() already makes for the equivalent pull-side blind spot. +only around _sync_from_server_async()'s own batch DbTxns (including the ones +_full_resync_async() opens) -- everywhere else, a batch=True transaction gets a +full snapshot of every primary object's current data stashed on the +transaction itself (_snapshot_all_objects()), not just which handles +exist. transaction_commit() diffs that snapshot against a fresh one taken +right after the commit (_reconcile_batch_commit()) to reconstruct what +changed: a handle that appeared is an add ("old": None); one that +disappeared is a delete ("old" the pre-transaction data); one that +persisted is an update only if its content actually differs, compared +with gramps.gen.merge.diff.diff_items() -- the same function gramps-web- +api's own old_unchanged() conflict check uses server-side, so this +addon's "did it change" agrees with the server's. (An earlier version of +this used handle presence plus a `.change`-timestamp comparison instead +of a real content diff, and replayed each reconstructed entry through +_retry_after_conflict() to pick up an "old" snapshot -- three separate +bugs followed from that: `.change` (whole seconds) compared against the +transaction's own start_time (a sub-second float) silently missed any +edit landing in the same wall-clock second the batch began in, which is +the common case; replaying against local storage that by then already +held the batch's own result sent the object's *post*-batch content as +"old" instead of what the server last actually saw, which a real +server's own old-data check always reads as a conflict; and replaying a +delete against storage where the object was already legitimately gone +did nothing at all. Building the payload directly from the two +snapshots, with real "old" data captured before anything ran, avoids +all three.) The reconstructed payload goes out through the normal +transaction_commit() -> _start_push() path, with the same conflict +handling (full resync, then _retry_after_conflict()) any other edit +gets, for the rare case something else changed the same object in the +meantime. Reading (and briefly holding in memory) two full copies of +every primary object's data per batch commit is a real cost, but +_snapshot_all_objects() keeps it to O(types) bulk queries rather than +O(handles) individual ones, and correctness here is worth more than the +memory -- the same trade _full_resync_async() already makes for the +equivalent pull-side blind spot. A second, previously-unhandled kind of silent drift: when a push's own HTTP call fails for a plain connectivity reason (network down, server unreachable -- not a conflict), the local commit has already happened and is never rolled back, but until now nothing remembered that the push still needed to go out -- "the next successful push or read sync" above -was aspirational, not implemented. _push_payload() now persists such a +was aspirational, not implemented. _push_payload_async() now persists such a payload (via _set_metadata(), the same mechanism sync_last_time already uses, so it survives close()/reopen) to a "pending_pushes" queue instead -of just logging and forgetting it. _flush_pending_pushes(), called at the -top of every _sync_from_server() (both the load()-time call and every +of just logging and forgetting it. _flush_pending_pushes_async(), called at the +top of every _sync_from_server_async() (both the load()-time call and every poll tick), retries the queue in order and stops at the first entry that still can't be delivered, rather than skipping ahead -- so a later, causally-dependent edit (e.g. a Family added after the Person it @@ -208,7 +291,7 @@ does not refuse the request at all -- it passes view_private=has_permissions({PERM_VIEW_PRIVATE}) into the export task (exporters.py), so an under-privileged caller gets a privacy-filtered -export, which _full_resync() would then import over a wiped local mirror, +export, which _full_resync_async() would then import over a wiped local mirror, quietly dropping every private record from the mirror. Checking the permission set up front costs no extra round trip (gramps-web-api puts it in the access token's own claims, so get_permissions() just decodes the @@ -218,7 +301,7 @@ GET /metadata/ (cached per handler; needs no special permission) supplies the two versions the addon reasons about. The server's *Gramps* version -gates compatibility outright: _check_server_version() refuses at load() +gates compatibility outright: _check_server_version_async() refuses at load() below MIN_SERVER_GRAMPS_VERSION, since anything older serializes its transaction history in the pre-6.0 shape and would otherwise fail much later as a bare KeyError out of data_to_object() mid-sync. It is @@ -228,7 +311,7 @@ The server's *gramps-web-api* version gates one optimization: from 2.7, POST /transactions/ accepts ?background=1, queueing the work and answering 202 immediately instead of holding the connection open while it -processes. _push_payload() uses that only for payloads at or above +processes. _push_payload_async() uses that only for payloads at or above BACKGROUND_PUSH_THRESHOLD, where server-side processing could plausibly outlast webapi_client.TIMEOUT and drop the connection mid-write -- most of all the single large payload _reconcile_batch_commit() builds after a bulk @@ -248,21 +331,119 @@ The mirror stays current while the tree is open, not just at load() time: load() also schedules a GLib.timeout_add_seconds() tick (POLL_INTERVAL_SECONDS) -that re-runs _sync_from_server() for as long as the database stays open -- -the same timestamp-cursor poll gramps-connect's browser client uses against -this same endpoint (see gramps-connect's store/historyPoll.ts), so a change -made from any other client shows up here without closing and reopening the -tree. It runs synchronously on the GTK main thread (like the initial -load()-time sync already did, and like viewmanager.py's own autobackup -timer) rather than on a background thread -- correct but simple, at the -cost of a brief UI pause during each poll's network round trip; moving it -off-thread (GrampsWebSync's GLibTaskRunner is the precedent, not imported -here for the same no-cross-addon-dependency reason as transaction_to_json() -below) is a reasonable future improvement, not attempted here. close() -cancels the pending timeout so a closed database doesn't keep polling. +that re-runs _sync_from_server_async() for as long as the database stays +open -- the same timestamp-cursor poll gramps-connect's browser client uses +against this same endpoint (see gramps-connect's store/historyPoll.ts), so a +change made from any other client shows up here without closing and +reopening the tree. Its network legs run on a worker thread (see "Keeping +the GUI alive" below), so a poll's round trip no longer costs the window a +UI pause the way it once did. close() cancels the pending timeout so a +closed database doesn't keep polling. + +A server that stops answering does not interrupt the session: the poll +reports the outage once, backs off towards POLL_BACKOFF_MAX_SECONDS while +it lasts, and picks the mirror up again from the persisted sync cursor on +the first tick that succeeds -- meanwhile local edits go on working +against the mirror and queue for push (see _queue_pending_push()). See +_poll_tick() and _record_poll_failure(). + +Keeping the GUI alive +--------------------- +Every network round trip this addon makes runs on a worker thread +(taskrunner.py's IoRunner), never on the GTK main thread -- so nothing here +can hold the window unresponsive the way blocking network I/O on the main +thread would, and there is nothing to interleave with the main loop's own +event processing while a sync, push, or resync is in flight. Everything +that touches self.dbapi (a commit, a DbTxn, importData()'s reimport) stays +on the main thread instead, dispatched via GLibTaskRunner -- the sqlite +backend binds a connection to its creating thread, so a DB step can never +run anywhere else. Each such step is written as one method call that +starts, runs to completion, and returns, with the *next* step (on either +runner) scheduled only once it has -- see _push_payload_async(), +_full_resync_async(), _sync_from_server_async()/_sync_page(), and +_sync_media_files_async() for the actual chains. + +This wasn't always true: earlier versions of this addon ran all of that +network work synchronously on the GTK main thread and periodically called +_pump_main_loop() (still present, now with exactly one caller -- +_run_async_to_completion(), see below) to hand the loop back its turn +mid-operation, the same tactic viewmanager.py's own autobackup timer uses. +That reentrancy caused two separate crashes in the field: switching Family +Trees while a pump-driven sync was suspended mid-operation resumed against +an already-closed self.dbapi (sqlite3.ProgrammingError), and a HandleError +raised by an unrelated view's redraw, dispatched from a pending GTK +callback during a pump, propagated straight up through an otherwise- +successful WebApiPushConflict recovery and crashed the whole application. +Both are structurally impossible now: there is no reentrant pump inside +any of the chains above for another GTK event to interleave with, and +close() can only ever run strictly before or strictly after one of these +main-thread steps, never during one. + +A callback scheduled on a worker thread can still find, by the time it's +delivered back to the main thread, that close() ran while it was in +flight -- switching Family Trees or quitting Gramps is a perfectly +ordinary GTK event, unrelated to whatever network call happens to be +outstanding. self._run_id, a generation counter close() bumps before +anything else it does, and self._guarded() (a decorator-like wrapper +applied to a step's on_success/on_error before handing it to a runner) +together replace what _DatabaseClosed/_guarded_pump()/self._closed used to +do for the old pump-based reentrancy: a self._guarded()-wrapped callback +whose captured run_id no longer matches self._run_id is silently dropped +rather than run, instead of raising an exception for some caller further +up a call stack to catch. Some DB-touching steps (_full_resync_async()'s +rebuild(), _after_conflict_resync()'s run_retry()) go further and re-check +self._run_id themselves as their very first action, before touching +self.dbapi at all -- needed wherever a step is scheduled from inside a +callback that already ran (so self._guarded() has already let it through +once) rather than scheduled directly by the top-level caller that claimed +self._syncing; see either method's own comments for the narrow scheduling +gap this closes. + +self._syncing is the single-flight gate stopping two such chains from +running concurrently and landing overlapping DB-apply callbacks against +the same local mirror: the true top-level entry point for a given +operation (_start_push(), _poll_tick(), _media_poll_tick(), load()'s +wait-adapter) claims it before anything touches the network, and only +_finish_async_op() -- wrapping that operation's real completion, including +any conflict-recovery detour a push takes through a full resync and retry +-- releases it, once the whole chain has actually finished, not merely +started. A push arriving while self._syncing is already held is queued +(_queue_pending_push()) rather than raced against whatever is in flight; +_finish_async_op() attempts one flush of that queue before releasing the +flag, so a deferred push goes out as soon as the chain that pre-empted it +finishes rather than waiting for the next poll tick. + +load() is the one entry point that still needs a synchronous answer: +Gramps core's own DbGeneric.load() contract requires the tree to be ready +by the time it returns, unlike every other entry point in this file, which +starts a chain and returns immediately. _run_async_to_completion() bridges +that gap -- the *only* remaining caller of _guarded_pump()/ +_pump_main_loop() -- by driving one of the async chains above to +completion synchronously: pumping the main loop (so the worker-thread +dispatch that chain depends on can actually be delivered), but never +touching self.dbapi itself while doing so, and never reentering any of +this addon's own DB-touching steps. See that method's own docstring. + +Tracing a session +----------------- +Most of what this addon does is invisible from the Gramps UI: a sync that +finds nothing, a push that succeeded, a mirror quietly short of the +server. Both this module and webapi_client.py log to ".grampswebapidb", +so:: + + gramps -d .grampswebapidb + +turns on a DEBUG trace of exactly that (argparser.py's -d hands the name +to logging.getLogger().setLevel()). It is deliberately per-operation +rather than per-object: one line per HTTP request (method, path, status, +round-trip time -- WebApiHandler._open()), one per sync page and one per +sync (changes applied/skipped, cursor, elapsed), plus load, push, queue +depth, media transfer counts, and the local-vs-server totals compared at +load. Nothing logs object data or credentials, and replaying a busy feed +costs a fixed handful of lines rather than one per change. A second, independent timeout (MEDIA_POLL_INTERVAL_SECONDS, coarser than -POLL_INTERVAL_SECONDS) drives _sync_media_files(): downloading media files +POLL_INTERVAL_SECONDS) drives _sync_media_files_async(): downloading media files that exist as Media-object records in the mirror but not on local disk, and uploading local media files the server doesn't have yet. This is ported from GrampsWebSync's own media-file-sync step (grampswebsync.py's @@ -277,7 +458,7 @@ re-asks the server's own GET /media/?filemissing=1 endpoint, and anything found missing is then transferred in full. -_sync_from_server()'s replay runs inside a batch=True DbTxn deliberately +_sync_from_server_async()'s replay runs inside a batch=True DbTxn deliberately (see the write-through section below for why), but that has a side effect beyond suppressing trans.add(): DBAPI.transaction_commit() only emits its person-add/family-update/event-delete/... signals `if not transaction.batch` @@ -293,12 +474,12 @@ immediately followed by a delete of the same object only fires the delete signal, not both. -A _full_resync() (see below) is the one path that doesn't go through +A _full_resync_async() (see below) is the one path that doesn't go through _emit_change_signals(): a full wipe-and-reimport is exactly the "too much changed to describe incrementally" case DbGeneric's own request_rebuild() exists for (it emits a single -rebuild signal per object type, telling every view to reload wholesale rather than replay a specific -add/update/delete) -- so _full_resync() calls that once after a successful +add/update/delete) -- so _full_resync_async() calls that once after a successful reimport instead. Undo/redo integration hooks undo()/redo() the same way transaction_commit() @@ -319,16 +500,20 @@ persisted, so this only ever matters within a single running session. """ +import inspect +import json import logging import os import re from copy import deepcopy from tempfile import NamedTemporaryFile +from time import monotonic, time from urllib.error import HTTPError, URLError from gi.repository import GLib from gramps.gen.const import GRAMPS_LOCALE as glocale +from gramps.gen.constfunc import has_display from gramps.gen.db import DbTxn from gramps.gen.db.dbconst import ( CLASS_TO_KEY_MAP, @@ -342,12 +527,14 @@ from gramps.gen.db.exceptions import DbConnectionError from gramps.gen.errors import HandleError from gramps.gen.lib.baseobj import BaseObject -from gramps.gen.lib.json_utils import data_to_object, object_to_data, remove_object +from gramps.gen.lib.json_utils import data_to_object, remove_object +from gramps.gen.merge.diff import diff_items from gramps.gen.user import User from gramps.gen.utils.file import media_path_full from gramps.plugins.db.dbapi.sqlite import SQLite from gramps.plugins.importer.importxml import importData +from taskrunner import GLibTaskRunner, IoRunner from webapi_client import WebApiHandler, WebApiPushConflict, parse_version try: @@ -355,7 +542,7 @@ except ValueError: _trans = glocale.translation _ = _trans.gettext -LOG = logging.getLogger("grampswebapidb") +LOG = logging.getLogger(".grampswebapidb") #: How many transactions to request per page while syncing. SYNC_PAGE_SIZE = 100 @@ -374,6 +561,19 @@ #: tick. MEDIA_POLL_INTERVAL_SECONDS = 300 +#: Ceiling (seconds) on the record poll's backoff while the server is +#: unreachable -- see _poll_tick(). Every consecutive failure doubles the +#: interval from POLL_INTERVAL_SECONDS up to this cap, and the first +#: success resets it. A server that is down (or a laptop that is off the +#: network) stays down for minutes or hours, not seconds, and each futile +#: tick costs a blocking round trip on the GTK main thread -- including +#: webapi_client's own one-shot retry sleep -- so retrying every 10 +#: seconds for the whole outage buys nothing and stutters the UI. The cap +#: is deliberately no larger than MEDIA_POLL_INTERVAL_SECONDS: once the +#: server comes back, the mirror should catch up within a poll or two, +#: not stay stale for an hour. +POLL_BACKOFF_MAX_SECONDS = 300 + #: Cap on the persisted pending-push queue (see _queue_pending_push()). #: A queue this long means the server has been unreachable across a great #: many local edits; keeping every one of them forever would grow the @@ -381,8 +581,27 @@ #: rather than silently. MAX_PENDING_PUSHES = 1000 +#: Net change count at or below which _full_resync_async()/ +#: _bootstrap_full_resync() describe a reimport with granular per-object +#: signals (_emit_change_signals(), reusing _reconcile_batch_commit()'s +#: own before/after diff via _diff_snapshots()) instead of request_rebuild(). +#: See those methods' own comments: a resync recovering from a push +#: conflict or repairing a mirror the history feed lost track of touches a +#: small number of objects against an otherwise-already-correct mirror, so +#: a precise diff is both cheap and far less disruptive than telling every +#: view to reload wholesale -- most importantly, gui/displaystate.py's +#: History.history_changed() only resets Active Person on an actual +#: -rebuild signal, so a small, targeted diff leaves Active Person +#: alone unless the active object itself was one of the handles that +#: genuinely changed. Above this threshold (a bootstrap resync against an +#: empty mirror, or a mirror repair that's badly fallen behind), the diff +#: itself is legitimately "everything," where one rebuild signal per type +#: is cheaper for every view than replaying that many individual add +#: signals -- so request_rebuild() stays the right tool there. +GRANULAR_REBUILD_MAX_CHANGES = 500 + #: Server-side permission names (gramps-web-api's auth/const.py) this -#: addon depends on, checked at load() by _check_permissions(). +#: addon depends on, checked at load() by _check_permissions_async(). #: #: ViewPrivate is required to read at all: GET /transactions/history/ #: calls require_permissions([PERM_VIEW_PRIVATE]) outright (see @@ -411,7 +630,7 @@ #: Oldest Gramps version a *server* can run and still produce the #: "_class"-tagged transaction-history serialization data_to_object() #: understands -- see the module docstring's note on gramps52 servers. -#: Checked at load() by _check_server_version() so an incompatible server +#: Checked at load() by _check_server_version_async() so an incompatible server #: says so, instead of failing later as a bare KeyError mid-sync. MIN_SERVER_GRAMPS_VERSION = (6, 0) @@ -434,6 +653,188 @@ _CONNECTION_ERRORS = (ValueError, KeyError, HTTPError, URLError, OSError) +class _DatabaseClosed(Exception): + """Raised by WebApiDB._guarded_pump() when close() ran while a + pump-driven sync/push was suspended -- see the module docstring's + "Keeping the GUI alive" section. Deliberately not one of the + _CONNECTION_ERRORS: it isn't a connectivity problem, and turning it + into a DbConnectionError would show the user a scary message about a + tree they've already left.""" + + +def _pump_main_loop(): + """Dispatch the main loop's next source, blocking until one is ready + rather than busy-spinning. + + _run_async_to_completion()'s own wait loop calls this over and over + until the chain it's waiting on finishes. An earlier version of this + function checked context.pending() and looped calling + context.iteration(False) only while something was already queued -- + which means, the instant nothing is pending (the common case while + waiting on a worker thread doing real I/O), that outer wait loop + spun as fast as Python and the GIL would allow, pinning a CPU core + for the whole wait. Confirmed live (2026-08-17) that this made both + the window's own responsiveness and the actual worker-thread transfer + it was waiting on noticeably worse -- a tight Python loop reacquiring + the GIL on every spin leaves less of it for the thread doing the + actual work. context.iteration(True) blocks efficiently (via the + platform's own poll/select under the hood) until a source is ready -- + including the GLib.idle_add() callback a worker thread's result + arrives through -- then dispatches exactly that one, the same + at-most-one-source-per-call contract the old loop had. + + Every network round trip this addon makes runs synchronously on the + GTK main thread (see the module docstring's polling section), so a + long one -- a full-export download, a page-by-page catch-up, a media + transfer, a backgrounded push being waited on -- is time the main + loop spends inside this addon rather than answering. The window + manager reads that as a hung application and offers to force-quit it, + and the window itself stops redrawing (no progress bar movement, no + repaint after an overlapping window moves away). + + Calling this at the boundaries of those operations gives the loop its + turn: pending redraws, the progress bar Gramps is already driving via + load()'s callback, and the window manager's own ping all get handled, + and the application stays live. + + Goes through GLib's default main context rather than Gtk.main_ + iteration() so this module stays importable without a display: it is + a DATABASE plugin, loadable from the CLI, where pulling in gramps.gui + (or Gtk) has no business being a requirement. GTK drives that very + context, so the effect under the GUI is the same. + + The obvious hazard of pumping a main loop mid-operation is + re-entrancy -- our own POLL_INTERVAL_SECONDS timeout coming round + while a sync is in flight. _poll_tick()/_media_poll_tick() check + _syncing for exactly that and skip their turn. + + A second, subtler hazard: whatever pending source this dispatches -- + a redraw, an idle callback a view scheduled off one of our own + request_rebuild()/commit signals, an unrelated timer -- runs + arbitrary code this addon does not own and cannot make correct. + Gramps' own Callback.emit() already treats a connected handler's + exception as that handler's problem (log and move on, never let it + abort the emit()); GLib.MainContext.iteration() has no such + protection built in, so left unguarded, a bug in some completely + unrelated bit of GUI code reached this way can propagate up through + this addon's sync/push machinery and take the whole application down + with it -- confirmed in the field as a HandleError raised from a + PeopleView redraw during _full_resync()'s post-reimport pump, + surfacing (and killing Gramps) from inside a WebApiPushConflict + handler that had otherwise recovered correctly. Catching and logging + here, matching Callback.emit()'s own posture, keeps that class of bug + a cosmetic GUI glitch instead of a lost edit and a crashed app. + """ + context = GLib.MainContext.default() + try: + context.iteration(True) + except Exception: + LOG.exception( + "Unhandled exception from a GTK/GLib callback dispatched " + "while pumping the main loop mid-sync; continuing rather " + "than letting it abort the sync/push in progress." + ) + + +def _http_error_detail(err): + """Pull the server's own explanation out of an HTTPError's JSON body, + if it has one. + + _get_json()/_get_binary() re-raise a non-401/429 HTTPError as-is, + which throws away its response body -- so a bare "HTTP Error 422: + Unprocessable Entity" reaches the user with no hint which request + parameter the server actually objected to. FastAPI's own automatic + validation errors (a raw 422, before the request even reaches + gramps-web-api's route handler) put that under "detail"; the app's + own domain errors (see webapi_client._raise_for_push_conflict(), + _task_error_message()) use {"error": {"message": ...}} instead; + flask-jwt-extended's own error handlers -- what actually answers a + rejected POST /token/refresh/ (expired, revoked, or otherwise invalid + refresh token) -- use a third shape, {"msg": ...}. Try all three; give + up quietly (None) if the body isn't JSON at all, or has already been + read by something else. + """ + try: + body = json.loads(err.read()) + except (ValueError, OSError, AttributeError): + return None + if not isinstance(body, dict): + return None + detail = body.get("detail") + if detail: + return str(detail) + error = body.get("error") + if isinstance(error, dict) and error.get("message"): + return str(error["message"]) + msg = body.get("msg") + if msg: + return str(msg) + return None + + +def _wrap_progress_callback(callback, text): + """Adapt a Gramps load()-progress callback (a plain percentage + function, 0-100) to also carry a descriptive label, for callers that + accept one: gui/dbloader.py's uistate.pulse_progressbar(value, + text=None) shows it as ": NN%" on the progress bar dbloader.py + already displays for the duration of any db.load() call, turning + that otherwise-blank bar into a real "Syncing with Gramps Web API..." + indicator during the initial catch-up sync. cli/grampscli.py's own + callback, _pulse_progress(value), takes only the one positional + argument -- calling it with a second would raise TypeError -- so the + signature is inspected once, here, rather than assumed. + + Returns ``callback`` unchanged if it is None or doesn't accept a + second argument. + """ + if callback is None: + return None + try: + accepts_text = len(inspect.signature(callback).parameters) >= 2 + except (TypeError, ValueError): + # Some callables (a bound method of a C extension type, a + # functools.partial with no introspectable signature, ...) can't + # be inspected at all -- safest default is the plain percent-only + # call every caller is guaranteed to accept. + accepts_text = False + if not accepts_text: + return callback + return lambda value: callback(value, text) + + +def _import_progress_user(callback): + """Build the User importData() should see for _full_resync_async()'s + reimport, using exactly the class and wiring Gramps' own GUI import + uses -- gui/dbloader.py's DbLoader.do_import(): + ``User(callback=self._pulse_progress, ...)`` -- confirmed, by testing + it directly against the same large export this addon's own reimport + was previously freezing on with no progress at all, to report real + progress throughout a large import without hanging or crashing + Gramps. + + uistate/dbstate/parent are intentionally omitted: ImportXml's + GrampsParser never calls begin_progress()/step_progress()/ + end_progress() (only self.update() -> UpdateCallback -> + user.callback(), see gen/updatecallback.py) -- so the ProgressMeter + dialog those three would drive, the only thing that would need a + parent window, is never triggered either way. Only UserBase.callback() + (inherited unchanged) matters here, and that just calls + callback(percentage[, text]). + + Falls back to the inert gramps.gen.user.User if there's no display + (CLI use) or gi/Gtk aren't importable -- gui.user.User pulls in Gtk at + import time, which this DATABASE plugin must stay usable without. + """ + if has_display(): + try: + from gramps.gui.user import User as GuiUser + + return GuiUser(callback=callback) + except ImportError: + pass + return User(callback=callback) + + def _describe_connection_error(err): """ Turn a _CONNECTION_ERRORS exception into DbConnectionError's message @@ -441,6 +842,18 @@ def _describe_connection_error(err): correctly identified but isn't allowed to do this -- worth calling out specifically, since the raw HTTPError text ("HTTP Error 403: Forbidden") reads like an auth failure rather than a permissions one. + + Anything else that came with a JSON body (see _http_error_detail()) + gets that appended, so a validation failure like a bare 422 names the + field it rejected instead of just its status code. + + URLError.__str__() wraps its reason in literal angle brackets -- + "" -- which Gramps' + own dialog code renders as Pango markup: the "" reads + as an unclosed tag, so the markup parser rejects the whole string + and the user sees a GTK warning in the log instead of the actual + error message. Using err.reason directly avoids ever producing that + wrapper. """ if isinstance(err, HTTPError) and err.code == 403: return _( @@ -450,6 +863,16 @@ def _describe_connection_error(err): "permissions, or ask the server administrator to grant this " "one access." ) + if isinstance(err, HTTPError): + detail = _http_error_detail(err) + if detail: + return "%s\n\n%s" % (err, detail) + return str(err) + if isinstance(err, URLError): + # HTTPError is itself a URLError subclass, so this branch is only + # reached for a "real" URLError (DNS failure, connection refused, + # ...) -- an HTTPError always returns above, one way or the other. + return str(err.reason) return str(err) @@ -458,7 +881,7 @@ def _is_retryable_push_error(err): A 4xx is the server's considered answer about *this* request -- 403 (the account lacks AddObject/EditObject/DeleteObject; see - _check_permissions()), 404, or a 400 that push_transaction() already + _check_permissions_async()), 404, or a 400 that push_transaction() already determined isn't a conflict -- and will keep being the answer no matter how often it is replayed. Queueing one would retry it on every poll forever and, worse, eventually evict genuinely retryable work @@ -477,12 +900,19 @@ def _is_retryable_push_error(err): #: whatever a user types renaming a tree (dbman.py's __change_name(): "kill #: special characters so can use as file name in backup"). A hostname- #: bearing name can't survive that GUI round-trip with its dots intact, so -#: _check_identity() normalizes through this same substitution on both +#: _check_identity_async() normalizes through this same substitution on both #: sides before comparing -- see that method. _FAMILY_TREE_NAME_UNSAFE_CHARS = re.compile(r"[':<>|,;=\"\[\]\.\+\*\/\?\\]") _TRANS_TYPE_NAME = {TXNADD: "add", TXNUPD: "update", TXNDEL: "delete"} +#: The reverse of _TRANS_TYPE_NAME -- _diff_snapshots() emits "type" as a +#: string (transaction_to_json()'s shape, and what _reconcile_batch_commit() +#: pushes), but _emit_change_signals() takes TXNADD/TXNUPD/TXNDEL. Built +#: from _TRANS_TYPE_NAME rather than duplicated by hand so the two can +#: never drift apart. +_NAME_TO_TRANS_TYPE = {name: code for code, name in _TRANS_TYPE_NAME.items()} + #: Same signal-name suffixes DBAPI.transaction_commit() uses (dbapi.py's #: own `action` dict) -- see _emit_change_signals(). _TRANS_TYPE_ACTION = {TXNADD: "-add", TXNUPD: "-update", TXNDEL: "-delete"} @@ -514,7 +944,205 @@ def transaction_to_json(transaction): return out -def _merge_or_overwrite(current, local_obj): +#: Handle-list attributes merge() unions without any existence check -- +#: see _prune_dangling_references() below. TagBase/NoteBase/CitationBase +#: are the only primary-object mixins that hold plain handle lists rather +#: than reference objects of their own (EventRef, ChildRef, ... carry a +#: handle *inside* a child object get_handle_referents() already walks +#: to, so pruning each of these three attributes on every node +#: _iter_referents() yields covers those too). +_DANGLING_REFERENCE_CHECKS = ( + ("tag_list", "has_tag_handle"), + ("note_list", "has_note_handle"), + ("citation_list", "has_citation_handle"), +) + + +def _iter_referents(obj): + """obj, then every child object get_handle_referents() reaches, + recursively -- e.g. a Person's EventRef/Attribute/MediaRef/Address + entries, each of which can carry its own tag_list/note_list/ + citation_list. Same traversal BaseObject.get_referenced_handles_ + recursively() uses, just walking nodes instead of collecting handles. + """ + yield obj + for child in obj.get_handle_referents(): + yield from _iter_referents(child) + + +def _prune_dangling_references(obj, db): + """Strip any Tag/Note/Citation handle obj (or any nested child object + -- see _iter_referents()) carries that no longer resolves in db. + + _merge_or_overwrite() below exists specifically to replay a *stale* + pre-conflict local edit on top of a freshly-resynced object + (_retry_after_conflict()) -- and merge()'s own list-unioning + (TagBase._merge_tag_list() and the Note/Citation equivalents, + gen/lib/{tagbase,notebase,citationbase}.py) has no existence check at + all. If the push conflict this retry is recovering from was itself + caused by another client deleting one of those Tag/Note/Citation + objects, resync correctly drops the reference from the freshly- + fetched "current" -- but the union then resurrects that now-dangling + handle straight into the object about to be committed. Confirmed in + the field (2026-08-17): the very next redraw of the affected row then + crashed Gramps with an uncaught HandleError (PeopleModel. + column_tag_color() -> db.get_tag_from_handle()), reproduced exactly + by replaying this same sequence against a real GTK PersonListModel. + + Applied to whatever _merge_or_overwrite() is about to return, not + just the merge() branch's output -- the type(current).merge is + BaseObject.merge fallback (e.g. Tag) returns local_obj outright with + no merge() call at all to have caught this otherwise. + """ + for node in _iter_referents(obj): + for attr, has_handle_name in _DANGLING_REFERENCE_CHECKS: + handles = getattr(node, attr, None) + if not handles: + continue + has_handle = getattr(db, has_handle_name) + handles[:] = [h for h in handles if has_handle(h)] + + +def _event_ref_signature(person): + """A (handle, role) tuple per entry of person's event_ref_list -- + enough to tell whether the list itself was untouched across a resync + (see _snapshot_birth_death_indices()), without pulling in the full + equality check EventRef.is_equivalent() does (citations, notes, ... + are irrelevant here).""" + return tuple((ref.ref, ref.role.serialize()) for ref in person.get_event_ref_list()) + + +def _snapshot_birth_death_indices(db): + """Capture birth_ref_index/death_ref_index (plus an event_ref_list + signature to tell whether it's still safe to trust that capture + afterwards) for every Person, keyed by handle -- taken right before + _full_resync_async()'s rebuild() clears the mirror. + + Gramps XML has no element for either index (confirmed against + exportxml.py's write_person(): only the event_ref_list itself is + written) -- ImportXml instead *recomputes* both, unconditionally, + from document order: the first PRIMARY-role BIRTH/DEATH-type event + ref becomes the new birth_ref_index/death_ref_index, and a Person + with no such ref keeps -1 (importxml.py's own GrampsParser, + ``self.person.get_birth_ref() is None`` guard). That recomputation + is wrong whenever the true index was already -1 despite a BIRTH/ + DEATH-type ref existing (nothing marks one as "the" birth/death + among several, or none is marked as primary at all -- both real, + legal states, common on data imported from outside Gramps) or + pointed at something other than the first such ref (multiple + disputed-date events, a later one picked as authoritative). Since + diff_items() (gen/merge/diff.py) treats birth_ref_index/ + death_ref_index as ordinary content -- unlike "change", there is no + key-name skip for them -- a Person whose true index doesn't match + that heuristic silently and permanently disagrees with the server + after every single resync, so any future edit to that Person's own + "old" snapshot never matches the server's current data again: + gramps-web-api's old_unchanged() (api/tasks.py) rejects the push, + _push_payload_async() resyncs (recomputing the same wrong value + right back), and the retry conflicts identically -- confirmed via a + local export/reimport round-trip against a real DBAPI database + (birth_ref_index went from -1, correct, to 0 purely from the XML + round trip, with no edit involved). + + _restore_birth_death_indices() undoes the damage for whatever this + captured, once the reimport is done -- but only for a Person whose + event_ref_list (see _event_ref_signature()) still matches signature + for signature afterwards: if it doesn't, something legitimately + changed that Person server-side and the freshly-recomputed index is + at least as trustworthy as blindly replaying a now-stale one. + """ + snapshot = {} + for handle in db.get_person_handles(): + person = db.get_person_from_handle(handle) + snapshot[handle] = ( + person.birth_ref_index, + person.death_ref_index, + _event_ref_signature(person), + ) + return snapshot + + +def _restore_birth_death_indices(db, snapshot, trans): + """_snapshot_birth_death_indices()'s other half -- see that + function's docstring. Called under the same self._pulling context + _full_resync_async()'s rebuild() already holds around the reimport + itself, so this local-only correction does not get mistaken for an + edit to push back to the server (see transaction_begin()'s + self._pulling check). Returns the number of Person objects + corrected, purely for the caller's own debug log.""" + restored = 0 + for handle, (birth_idx, death_idx, signature) in snapshot.items(): + if not db.has_person_handle(handle): + continue + person = db.get_person_from_handle(handle) + if _event_ref_signature(person) != signature: + continue + if person.birth_ref_index == birth_idx and person.death_ref_index == death_idx: + continue + person.birth_ref_index = birth_idx + person.death_ref_index = death_idx + db.commit_person(person, trans) + restored += 1 + return restored + + +def _diff_snapshots(before, after): + """Diff two {(obj_class, handle): data} snapshots (_snapshot_all_ + objects()'s own shape) into a transaction_to_json()-shaped change + list: a handle present only in ``after`` is an add ("old": None), one + present only in ``before`` is a delete ("new": None), and one present + in both is an update only if its content actually differs -- compared + with gramps.gen.merge.diff.diff_items(), the exact function gramps- + web-api's own old_unchanged() conflict check uses server-side + (gramps_webapi/api/tasks.py, confirmed by reading that source), so + "did this really change" here agrees with the server's own idea of + it: both ignore the object's own "change" timestamp, so a resave with + no other change is correctly not reported at all. + + Shared by _reconcile_batch_commit() (which pushes the result to the + server -- a local batch=True commit's own changes need to go out) and + _full_resync_async()/_bootstrap_full_resync() (which only need it to + decide what to *tell already-open views*, via _emit_change_signals() + -- what was just pulled *from* the server must never be pushed back). + """ + entries = [] + for obj_class, handle in after.keys() - before.keys(): + entries.append( + { + "type": "add", + "handle": handle, + "_class": obj_class, + "old": None, + "new": after[(obj_class, handle)], + } + ) + for obj_class, handle in before.keys() - after.keys(): + entries.append( + { + "type": "delete", + "handle": handle, + "_class": obj_class, + "old": before[(obj_class, handle)], + "new": None, + } + ) + for obj_class, handle in before.keys() & after.keys(): + old_data = before[(obj_class, handle)] + new_data = after[(obj_class, handle)] + if diff_items(obj_class, old_data, new_data): + entries.append( + { + "type": "update", + "handle": handle, + "_class": obj_class, + "old": old_data, + "new": new_data, + } + ) + return entries + + +def _merge_or_overwrite(current, local_obj, db): """Combine local_obj's content into current via the object's own merge() -- the same list-unioning logic behind Gramps' Merge People/ Family/... tools (ported from GrampsWebSync's diffhandler.py, credit @@ -529,14 +1157,20 @@ def _merge_or_overwrite(current, local_obj): misread it as a real second object being absorbed (which is what merge() is for) and tag on a spurious "Merged Gramps ID" attribute -- this is the same object, edited twice, not two objects becoming one. + + db is required so the result can be checked for dangling references + before it's returned -- see _prune_dangling_references(). """ if type(current).merge is BaseObject.merge: - return local_obj - merged = deepcopy(current) - local_copy = deepcopy(local_obj) - local_copy.gramps_id = None - merged.merge(local_copy) - return merged + result = local_obj + else: + merged = deepcopy(current) + local_copy = deepcopy(local_obj) + local_copy.gramps_id = None + merged.merge(local_copy) + result = merged + _prune_dangling_references(result, db) + return result class WebApiDB(SQLite): @@ -558,6 +1192,42 @@ class WebApiDB(SQLite): #: _reconcile_batch_commit(). _pulling = False + #: Set for the duration of any async operation that owns the mirror + #: -- a record/media sync, or (since the move off reentrant pumping) + #: a push -- so a second one can't start concurrently and land an + #: overlapping DB-apply callback. See _start_push()/_finish_async_op(). + _syncing = False + + #: The chain a conflict retry's nested re-push belongs to, stashed by + #: _after_conflict_resync() so _start_push()'s recursive + #: (is_retry=True) call can complete *that* chain instead of treating + #: the retry's own local commit as the finish line -- see both + #: methods' docstrings and section 2.2.1 of the refactor plan for the + #: premature-completion bug this exists to prevent. + _retry_chain_done = None + _retry_chain_error = None + + #: Bumped by close(), before anything else it does, so a pump-driven + #: sync/push suspended elsewhere on the call stack can tell (via + #: _guarded_pump()) that the tree it was working on is gone as soon as + #: the main loop gives control back. Also what _guarded() (used by the + #: worker-thread-based ..._async() chains) compares against to drop a + #: callback belonging to an abandoned chain -- see that method. An + #: instance can be load()-ed again after close() (Gramps may reuse one + #: WebApiDB object across Family Trees), so this is a generation + #: counter rather than a boolean: each close() starts a new generation + #: instead of leaving a single flag that a fresh load() would have to + #: remember to clear. + _run_id = 0 + + #: Consecutive failed polls, per timer, and the record poll's current + #: interval -- the outage state _poll_tick()/_media_poll_tick() use to + #: log an outage once instead of once per tick, and to back the record + #: poll off while it lasts. Reset by the first successful sync. + _poll_failures = 0 + _media_poll_failures = 0 + _poll_interval = POLL_INTERVAL_SECONDS + def requires_login(self): # Credentials come from GRAMPS_WEB_API_KEY, not a login dialog. return False @@ -567,6 +1237,14 @@ def _initialize(self, directory, username, password): self.web_client = WebApiHandler.from_env() except _CONNECTION_ERRORS as err: raise DbConnectionError(_describe_connection_error(err), directory) from err + LOG.debug("client: mirroring %s", self.web_client.url) + + # runner dispatches DB/GUI-touching steps on the main loop; + # io_runner runs pure network I/O on a worker thread. See + # taskrunner.py's module docstring for why -- this is what replaces + # the old _pump_main_loop()/_guarded_pump() reentrancy. + self.runner = GLibTaskRunner() + self.io_runner = IoRunner() # Local mirror: reuse SQLite's own _initialize for the on-disk # cache file, then sync from the server on load(). @@ -577,8 +1255,8 @@ def load(self, *args, **kwargs): # DbGeneric.load()'s signature, or the "callback" kwarg -- the same # plain percentage function cli/grampscli.py's _pulse_progress and # gui/dbloader.py's real progress-bar wiring already provide. - # Forwarded to _sync_from_server() so a slow initial catch-up (a - # new mirror, or one that's been offline a while) shows real + # Forwarded to _sync_from_server_async() so a slow initial catch-up + # (a new mirror, or one that's been offline a while) shows real # progress instead of Gramps just looking hung; _poll_tick()'s own # background-poll call deliberately leaves this at its None # default, since a 10-second background tick shouldn't pop a @@ -586,6 +1264,12 @@ def load(self, *args, **kwargs): callback = kwargs.get("callback") if callback is None and len(args) >= 2: callback = args[1] + # Labels the already-visible progress bar dbloader.py shows for + # the duration of this call ("Syncing with Gramps Web API: NN%") + # instead of leaving it a bare percentage -- see + # _wrap_progress_callback()'s own docstring for why this is safe + # for callers (the CLI's) that don't accept a label at all. + callback = _wrap_progress_callback(callback, _("Syncing with Gramps Web API")) # mode is position 3 in DbGeneric.load()'s signature, defaulting to # DBMODE_W -- read the same two ways as callback above. A tree # opened read-only never pushes, so it needs no write permissions. @@ -594,24 +1278,143 @@ def load(self, *args, **kwargs): mode = args[2] if mode is None: mode = DBMODE_W + LOG.debug( + "load: %s (mode %s)", args[0] if args else kwargs.get("directory"), mode + ) super().load(*args, **kwargs) - self._check_identity() - self._check_permissions(writable=(mode == DBMODE_W)) - self._check_server_version() + # Each check below makes exactly one network call. Run via + # _run_async_to_completion() (on io_runner, main loop pumped while + # waiting) rather than the old synchronous versions directly: + # confirmed live (2026-08-17) that three back-to-back blocking + # calls here, with nothing pumping the loop between them, is + # enough on its own to make the window unresponsive. + # + # Each also reports a small, fixed percentage once it succeeds -- + # not a real fraction of anything, just visible proof of progress + # during a stretch that, before this, reported nothing at all. + # Reported live (2026-08-17): checks + a full bootstrap resync + # (see _bootstrap_full_resync() below) can together leave the bar + # motionless for over ten seconds before the reimport's own real + # percentages start arriving. + for check_name, start_chain, percent_after in ( + ( + "identity", + lambda on_done, on_error: self._check_identity_async(on_done, on_error), + 5, + ), + ( + "permissions", + lambda on_done, on_error: self._check_permissions_async( + on_done, on_error, writable=(mode == DBMODE_W) + ), + 10, + ), + ( + "server version", + lambda on_done, on_error: self._check_server_version_async( + on_done, on_error + ), + 15, + ), + ): + result = self._run_async_to_completion(start_chain) + if result is None: + LOG.debug("load: tree closed during %s check; aborting", check_name) + return + if callback is not None: + callback(percent_after) + # A quick, synchronous, unwrapped check for the totals-shortfall + # case -- the same condition _mirror_is_short_of_the_server_async() + # checks, done directly here rather than through the async chain. + # If the mirror is clearly behind, run the whole resync outside + # _run_async_to_completion()'s pump loop entirely (see + # _bootstrap_full_resync()'s own docstring), skipping the wrapped + # record-sync call below (a bootstrap resync already brings the + # mirror fully current, same as the async path's effect). + # Deliberately does not cover the other full-resync trigger (an + # empty-"changes" marker on an otherwise-adequate feed) -- that + # still goes through _full_resync_async() via the wrapped path + # below, same as before this method existed. + needs_bootstrap_resync = False + if not self._get_metadata("pending_pushes", default=[]): + try: + local_total = self.get_total() + server_total = self.web_client.get_object_count() + except _CONNECTION_ERRORS as err: + raise DbConnectionError( + _describe_connection_error(err), self._directory + ) from err + needs_bootstrap_resync = server_total > local_total + # _sync_from_server_async() runs its network legs on a worker + # thread; _run_async_to_completion() blocks this call (pumping + # the main loop so that worker thread's result can actually be + # delivered) until it finishes -- see that method's docstring + # for why load() still waits synchronously here rather than + # returning early, unlike everywhere else in this file. + tree_closed_during_sync = False + self._syncing = True try: - self._sync_from_server(progress_callback=callback) + if needs_bootstrap_resync: + if callback is not None: + callback(20) + self._bootstrap_full_resync(callback) + else: + sync_result = self._run_async_to_completion( + lambda on_done, on_error: self._sync_from_server_async( + on_done, + on_error, + progress_callback=callback, + verify_totals=True, + ) + ) + tree_closed_during_sync = sync_result is None except _CONNECTION_ERRORS as err: raise DbConnectionError( _describe_connection_error(err), self._directory ) from err + finally: + self._syncing = False + if tree_closed_during_sync: + # The tree was closed (or switched away from) while this + # initial sync was still in flight. Nothing left to open; + # don't schedule polling for it. + LOG.debug("load: tree closed during initial sync; aborting") + return + # Fresh outage state for a freshly opened tree: these are class + # attributes, so an instance reused across a close()/load() would + # otherwise start out backed off from the previous tree's outage. + self._poll_failures = 0 + self._media_poll_failures = 0 + self._poll_interval = POLL_INTERVAL_SECONDS + # _sync_media_files_async() runs its network legs on a worker + # thread; _run_async_to_completion() blocks this call (pumping the + # main loop so that worker thread's result can actually be + # delivered) until it finishes -- see that method's docstring for + # why load() still waits synchronously here rather than returning + # early, unlike everywhere else in this file. + tree_closed_during_media_sync = False + self._syncing = True try: - self._sync_media_files() + media_result = self._run_async_to_completion( + lambda on_done, on_error: self._sync_media_files_async( + on_done, on_error + ) + ) + tree_closed_during_media_sync = media_result is None except _CONNECTION_ERRORS: # Unlike the record sync above, a media-file-sync failure here # doesn't block opening the tree: the record mirror is already # usable, and missing/un-uploaded media files are recovered on # the next successful media poll (or the next load()). LOG.exception("Initial media file sync failed; will retry.") + # Counts as this outage's one loud report, so _media_poll_tick() + # doesn't immediately say the same thing again 300 seconds later. + self._media_poll_failures = 1 + finally: + self._syncing = False + if tree_closed_during_media_sync: + LOG.debug("load: tree closed during initial media sync; aborting") + return self._poll_source_id = GLib.timeout_add_seconds( POLL_INTERVAL_SECONDS, self._poll_tick ) @@ -619,7 +1422,7 @@ def load(self, *args, **kwargs): MEDIA_POLL_INTERVAL_SECONDS, self._media_poll_tick ) - def _check_identity(self): + def _check_identity_async(self, on_done, on_error): """Require this Family Tree's own name to be "@" for whoever GRAMPS_WEB_API_KEY currently authenticates as. @@ -640,37 +1443,60 @@ def _check_identity(self): a hostname's dots can never actually reach name.txt intact -- an exact-string comparison would reject every tree name Gramps itself would let you type. + + Runs on io_runner like every other network call in this file: + confirmed live (2026-08-17, against a real server) that running + this and the other two load()-time checks synchronously on the + main thread -- each one a real network round trip with nothing + pumping the loop in between -- is enough on its own to make the + window unresponsive, even before reaching any resync work. """ - try: - expected = self.web_client.get_identity() - except _CONNECTION_ERRORS as err: - raise DbConnectionError( - _describe_connection_error(err), self._directory - ) from err - expected_typeable = _FAMILY_TREE_NAME_UNSAFE_CHARS.sub("_", expected) - actual = self.get_dbname() - actual_normalized = _FAMILY_TREE_NAME_UNSAFE_CHARS.sub("_", actual) - if actual_normalized != expected_typeable: - raise DbConnectionError( - _( - 'This Family Tree is named "%(actual)s", but ' - "GRAMPS_WEB_API_KEY currently authenticates as " - '"%(expected)s". Rename this Family Tree to ' - '"%(expected_typeable)s" (Family Trees -> Manage ' - "Family Trees) if it's meant to mirror that account, " - "or open/create the Family Tree already named that -- " - "reusing this one would mix its existing local data " - "with the other account's." + + def fetch(): + return self.web_client.get_identity() + + def on_fetched(expected): + expected_typeable = _FAMILY_TREE_NAME_UNSAFE_CHARS.sub("_", expected) + actual = self.get_dbname() + actual_normalized = _FAMILY_TREE_NAME_UNSAFE_CHARS.sub("_", actual) + if actual_normalized != expected_typeable: + on_error( + DbConnectionError( + _( + 'This Family Tree is named "%(actual)s", but ' + "GRAMPS_WEB_API_KEY currently authenticates as " + '"%(expected)s". Rename this Family Tree to ' + '"%(expected_typeable)s" (Family Trees -> Manage ' + "Family Trees) if it's meant to mirror that " + "account, or open/create the Family Tree " + "already named that -- reusing this one would " + "mix its existing local data with the other " + "account's." + ) + % { + "actual": actual, + "expected": expected, + "expected_typeable": expected_typeable, + }, + self._directory, + ) ) - % { - "actual": actual, - "expected": expected, - "expected_typeable": expected_typeable, - }, - self._directory, - ) + return + on_done(True) + + def on_fetch_error(exc): + if isinstance(exc, _CONNECTION_ERRORS): + on_error( + DbConnectionError(_describe_connection_error(exc), self._directory) + ) + else: + on_error(exc) + + self.io_runner.run( + fetch, self._guarded(on_fetched), self._guarded(on_fetch_error) + ) - def _check_permissions(self, writable=True): + def _check_permissions_async(self, on_done, on_error, writable=True): """Fail at load() if the account GRAMPS_WEB_API_KEY authenticates as lacks a server permission this addon depends on, naming exactly which -- rather than letting each affected operation fail on its @@ -685,34 +1511,51 @@ def _check_permissions(self, writable=True): in the access token's own claims (token.py's ``claims = { "permissions": [...]}``), so get_permissions() just decodes the JWT this handler already holds. + + Runs on io_runner for the same reason _check_identity_async() does. """ required = [_PERM_VIEW_PRIVATE] if writable: required += list(_WRITE_PERMISSIONS) - try: - granted = set(self.web_client.get_permissions()) - except _CONNECTION_ERRORS as err: - raise DbConnectionError( - _describe_connection_error(err), self._directory - ) from err - missing = [perm for perm in required if perm not in granted] - if not missing: - return - raise DbConnectionError( - _( - "The account authenticating via GRAMPS_WEB_API_KEY is " - "missing server permission(s) this addon requires: " - "%(missing)s. Grant it at least the " - '"%(role)s" role on the server (or ask an administrator ' - "to), then reopen this Family Tree. Opening it as-is would " - "leave the local mirror silently incomplete or unable to " - "save changes back." + + def fetch(): + return self.web_client.get_permissions() + + def on_fetched(permissions): + granted = set(permissions) + missing = [perm for perm in required if perm not in granted] + if not missing: + on_done(True) + return + on_error( + DbConnectionError( + _( + "The account authenticating via GRAMPS_WEB_API_KEY is " + "missing server permission(s) this addon requires: " + "%(missing)s. Grant it at least the " + '"%(role)s" role on the server (or ask an ' + "administrator to), then reopen this Family Tree. " + "Opening it as-is would leave the local mirror " + "silently incomplete or unable to save changes back." + ) + % {"missing": ", ".join(missing), "role": _REQUIRED_ROLE_NAME}, + self._directory, + ) ) - % {"missing": ", ".join(missing), "role": _REQUIRED_ROLE_NAME}, - self._directory, + + def on_fetch_error(exc): + if isinstance(exc, _CONNECTION_ERRORS): + on_error( + DbConnectionError(_describe_connection_error(exc), self._directory) + ) + else: + on_error(exc) + + self.io_runner.run( + fetch, self._guarded(on_fetched), self._guarded(on_fetch_error) ) - def _check_server_version(self): + def _check_server_version_async(self, on_done, on_error): """Fail at load() if the server runs a Gramps too old to produce the transaction-history serialization this addon reads. @@ -726,32 +1569,71 @@ def _check_server_version(self): report a Gramps version, or reports one this can't parse, is allowed through rather than blocked on a guess. The KeyError path still catches a genuinely incompatible one, just less kindly. + + Runs on io_runner for the same reason _check_identity_async() does. """ - try: - reported = self.web_client.get_gramps_version() - except _CONNECTION_ERRORS as err: - raise DbConnectionError( - _describe_connection_error(err), self._directory - ) from err - version = parse_version(reported) - if version is None or version >= MIN_SERVER_GRAMPS_VERSION: - return - raise DbConnectionError( - _( - "This server runs Gramps %(actual)s, but this addon needs " - "a server running Gramps %(required)s or newer: older " - "servers serialize their transaction history in a format " - "it cannot read. Upgrade the Gramps installation behind " - "the Gramps Web API server (or ask its administrator to)." + + def fetch(): + return self.web_client.get_gramps_version() + + def on_fetched(reported): + version = parse_version(reported) + if version is None or version >= MIN_SERVER_GRAMPS_VERSION: + on_done(True) + return + on_error( + DbConnectionError( + _( + "This server runs Gramps %(actual)s, but this addon " + "needs a server running Gramps %(required)s or " + "newer: older servers serialize their transaction " + "history in a format it cannot read. Upgrade the " + "Gramps installation behind the Gramps Web API " + "server (or ask its administrator to)." + ) + % { + "actual": reported, + "required": ".".join( + str(part) for part in MIN_SERVER_GRAMPS_VERSION + ), + }, + self._directory, + ) ) - % { - "actual": reported, - "required": ".".join(str(part) for part in MIN_SERVER_GRAMPS_VERSION), - }, - self._directory, + + def on_fetch_error(exc): + if isinstance(exc, _CONNECTION_ERRORS): + on_error( + DbConnectionError(_describe_connection_error(exc), self._directory) + ) + else: + on_error(exc) + + self.io_runner.run( + fetch, self._guarded(on_fetched), self._guarded(on_fetch_error) ) def close(self, *args, **kwargs): + # Bumped first, before anything else: a sync/push elsewhere on the + # call stack may be suspended inside _guarded_pump() (waiting to + # find out whether it's still safe to touch self.dbapi once the + # main loop hands control back) or, once the ..._async() chains + # land, inside a worker-thread step whose eventual callback + # _guarded() must recognize as belonging to an abandoned chain. + # See _guarded_pump(), _guarded(), and the module docstring's + # "Keeping the GUI alive" section. + self._run_id += 1 + # A callback _guarded() drops (or a _guarded_pump() call that + # raises) never reaches the `finally` blocks that would otherwise + # clear these -- that unwind only ever happens because something + # further up the call stack catches it, and after this point + # nothing does. Reset explicitly so an instance reused for a fresh + # load() (Gramps may reuse one WebApiDB object across Family + # Trees) doesn't start out believing an abandoned operation from + # the previous tree is still in flight. + self._syncing = False + self._pulling = False + self._retrying = False # Stop polling a database that's no longer open -- otherwise the # next tick would run _sync_from_server() (and touch self.dbapi) # against a connection that's about to be (or already) closed. @@ -765,55 +1647,319 @@ def close(self, *args, **kwargs): self._media_poll_source_id = None super().close(*args, **kwargs) + def _guarded(self, callback): + """Wrap a worker-thread-step callback (an ``on_success``/``on_error`` + handed to ``self.runner.run()``/``self.io_runner.run()``) so it is + silently dropped if close() ran while that step was in flight, + instead of resuming and touching a ``self.dbapi`` that is already + gone. + + Not used yet -- introduced here alongside _run_id so later phases' + ..._async() methods (see the module docstring) have it ready. The + async equivalent of _guarded_pump(): where _guarded_pump() raises + to unwind a still-synchronous call stack, this instead just never + calls through, since there is no stack left to unwind once the + step it wraps has already been handed to a worker thread or the + idle-add queue -- the same posture GrampsWebSync's + SyncSession._guarded() takes for a run the user has abandoned. + """ + run_id = self._run_id + + def guarded(value): + if run_id == self._run_id: + callback(value) + else: + LOG.debug("Dropping a callback from a chain abandoned by close().") + + return guarded + + def _guarded_pump(self): + """_pump_main_loop(), then raise _DatabaseClosed if that let + close() run out from under us. + + Every _pump_main_loop() call this class makes goes through here + instead of the bare function. Without it, a sync/push resumes + after the pump and immediately crashes trying to touch + self.dbapi -- the user switching or closing this very Family Tree + is a perfectly ordinary GTK event, and reentering the main loop + mid-operation (see the module docstring) is exactly what lets it + get dispatched underneath a suspended call. Callers that can + trigger a pump (directly or via webapi_client's on_wait/on_chunk + hooks) let this propagate up to whichever entry point started + them -- _poll_tick(), _media_poll_tick(), load(), _push_payload(), + _flush_pending_pushes() -- which treat it as nothing left to do, + not a failure. + + Detects this the same way _guarded() does -- comparing _run_id + before and after -- rather than a boolean _closed flag, since an + instance can be load()-ed again after close() (see _run_id's own + docstring); capturing run_id fresh on each call, right before the + one pump it guards, is exactly the right scope: nothing before + this call needed protecting (it already ran), and nothing this + call's caller does after seeing the raised exception touches + self.dbapi either. + """ + run_id = self._run_id + _pump_main_loop() + if self._run_id != run_id: + raise _DatabaseClosed() + + def _run_async_to_completion(self, start_chain): + """Block the calling thread (always the main thread -- this is + only ever called from load()) until an async chain finishes, + while still pumping the main loop so the worker-thread dispatch + that chain depends on can actually be delivered. + + start_chain(on_done, on_error) must kick off exactly one + ..._async() chain (e.g. ``lambda on_done, on_error: + self._sync_media_files_async(on_done, on_error)``) and return + immediately, the same contract every ..._async() method in this + file follows. + + Raises whatever the chain's on_error received. Returns whatever + its on_done received -- or None, with nothing raised, if the tree + was closed while this was waiting (_guarded_pump() propagates + that as _DatabaseClosed, caught here rather than left to whoever + called this). load() is the one caller that still needs a + synchronous answer: Gramps core's DbGeneric.load() contract + requires the tree to be ready by the time it returns, unlike + every other entry point in this file (_poll_tick(), + transaction_commit(), ...), which starts a chain and returns + immediately -- see the module docstring's "Keeping the GUI alive" + section for why load() alone keeps this synchronous wait instead + of also going fully asynchronous. + """ + box = {} + + def on_done(value=None): + box["done"] = True + box["value"] = value + + def on_error(exc): + box["done"] = True + box["error"] = exc + + start_chain(on_done, on_error) + while not box.get("done"): + try: + self._guarded_pump() + except _DatabaseClosed: + LOG.debug("load: tree closed while waiting on an async chain") + return None + if "error" in box: + raise box["error"] + return box.get("value") + def _poll_tick(self): """GLib.timeout_add_seconds callback -- see the module docstring's polling section. Must return True (GLib.SOURCE_CONTINUE) to keep - firing; returning a falsy value cancels the timeout, so a network - error is caught and logged here rather than left to propagate.""" - try: - self._sync_from_server() - except _CONNECTION_ERRORS: - LOG.exception("Periodic sync from server failed; will retry.") + firing. + + Unlike the old synchronous version, always returns + GLib.SOURCE_CONTINUE immediately: starting + _sync_from_server_async() can't report success or failure + synchronously, so the backoff/recovery bookkeeping + (_on_poll_success()/_on_poll_error(), via _reschedule_poll()) + happens later, from its on_done/on_error. A tree closed mid-sync + needs no special handling here to stop this timer -- close() + removes it directly, and _guarded() (wrapping + _sync_from_server_async()'s callbacks) silently drops one + belonging to an abandoned chain rather than this method having to + notice and react. + + An unreachable server is an expected, self-healing condition, not + a bug in this addon: local edits keep working against the mirror + and queue up for the next successful push (_queue_pending_push()), + and the sync cursor is persisted, so the poll only has to survive + the outage. It is therefore reported once per outage rather than + once per tick, and the timer backs off while it lasts (see + _record_poll_failure()) -- a 10-second traceback loop for as long + as a server stays down buries anything else in the log and makes a + routine outage look like a crash.""" + if self._syncing: + # Reached from inside a still-running chain this tick would + # otherwise start again underneath. + LOG.debug("poll: a sync is already running; skipping this tick") + return GLib.SOURCE_CONTINUE + self._syncing = True + self._sync_from_server_async( + on_done=self._finish_async_op(self._on_poll_success), + on_error=self._finish_async_op(self._on_poll_error), + ) return GLib.SOURCE_CONTINUE + def _on_poll_success(self, applied): + """_poll_tick()'s on_done -- see that method.""" + if self._poll_failures: + LOG.info( + "Sync from server succeeded again after %d failed attempt(s).", + self._poll_failures, + ) + self._poll_failures = 0 + self._reschedule_poll(POLL_INTERVAL_SECONDS) + + def _on_poll_error(self, exc): + """_poll_tick()'s on_error -- see that method.""" + if not isinstance(exc, _CONNECTION_ERRORS): + # Not a connectivity classification this poll knows how to + # back off from -- see _on_media_poll_error()'s identical + # reasoning. + LOG.error( + "Unexpected error during periodic sync from server.", exc_info=exc + ) + return + self._record_poll_failure(exc) + + def _record_poll_failure(self, err): + """Handle a failed _poll_tick() sync: report it (once per outage, + with the detail kept at DEBUG for whoever is diagnosing one) and + slow the timer down, doubling up to POLL_BACKOFF_MAX_SECONDS.""" + self._poll_failures += 1 + interval = min(self._poll_interval * 2, POLL_BACKOFF_MAX_SECONDS) + if self._poll_failures == 1: + LOG.warning( + "Periodic sync from server failed (%s); retrying, backing off " + "to at most every %d seconds until the server answers again.", + err, + POLL_BACKOFF_MAX_SECONDS, + ) + LOG.debug("Periodic sync failure detail", exc_info=err) + else: + LOG.debug( + "Periodic sync from server still failing after %d attempts (%s); " + "next retry in %d seconds.", + self._poll_failures, + err, + interval, + exc_info=err, + ) + self._reschedule_poll(interval) + + def _reschedule_poll(self, interval): + """Point the record poll at a new interval. A no-op when the + interval is unchanged, which is the common case on both a + healthy poll and a failing one already sitting at + POLL_BACKOFF_MAX_SECONDS. + + Unlike the old synchronous _poll_tick(), which could report + GLib.SOURCE_REMOVE from inside the very timeout callback being + replaced (letting GLib itself drop that firing instance), this + is now always called from an async on_done/on_error, well after + _poll_tick() already returned GLib.SOURCE_CONTINUE to keep the + current timer alive -- so the old timer has to be removed + explicitly (GLib.timeout_add_seconds() has no way to retime an + existing source in place either way) rather than relying on a + return value GLib is no longer watching for by the time this + runs.""" + if interval == self._poll_interval: + return + self._poll_interval = interval + GLib.source_remove(self._poll_source_id) + self._poll_source_id = GLib.timeout_add_seconds(interval, self._poll_tick) + def _media_poll_tick(self): """GLib.timeout_add_seconds callback for the slower media-file scan -- same contract as _poll_tick() (must return True to keep - firing; a connection error is caught and logged here rather than - left to propagate), just for _sync_media_files() instead of the - record-history poll.""" - try: - self._sync_media_files() - except _CONNECTION_ERRORS: - LOG.exception("Periodic media file sync failed; will retry.") + firing), just for _sync_media_files_async() instead of the + record-history poll. + + Unlike _poll_tick(), this always returns GLib.SOURCE_CONTINUE + immediately: starting _sync_media_files_async() can't report + success or failure synchronously the way the old + _sync_media_files() call this replaced could, so that bookkeeping + (_on_media_poll_success()/_on_media_poll_error()) happens later, + from its on_done/on_error. A tree closed mid-sync needs no + special handling here the way it once did to stop this very + timer -- close() removes the timeout itself, and _guarded() + (wrapping _sync_media_files_async()'s callbacks) silently drops + one belonging to an abandoned chain rather than this method + having to notice and react. + + Reported once per outage for the same reason as _poll_tick(), but + with no backoff to go with it: MEDIA_POLL_INTERVAL_SECONDS is + already as coarse as that poll's backoff cap.""" + if self._syncing: + LOG.debug("media poll: a sync is already running; skipping this tick") + return GLib.SOURCE_CONTINUE + self._syncing = True + self._sync_media_files_async( + on_done=self._finish_async_op(self._on_media_poll_success), + on_error=self._finish_async_op(self._on_media_poll_error), + ) return GLib.SOURCE_CONTINUE + def _on_media_poll_success(self, result): + """_media_poll_tick()'s on_done -- see that method.""" + if self._media_poll_failures: + LOG.info( + "Media file sync succeeded again after %d failed attempt(s).", + self._media_poll_failures, + ) + self._media_poll_failures = 0 + + def _on_media_poll_error(self, exc): + """_media_poll_tick()'s on_error -- see that method.""" + if not isinstance(exc, _CONNECTION_ERRORS): + # Not a connectivity classification this poll knows how to + # back off from. The old synchronous _sync_media_files() let + # anything else propagate out of _media_poll_tick() uncaught + # (there was nowhere for it to go but the caller); there is no + # such caller for an async on_error, so log it loudly here + # instead of silently losing it. + LOG.error("Unexpected error during periodic media file sync.", exc_info=exc) + return + if self._media_poll_failures == 0: + LOG.warning( + "Periodic media file sync failed (%s); will retry every " "%d seconds.", + exc, + MEDIA_POLL_INTERVAL_SECONDS, + ) + LOG.debug("Periodic media file sync failure detail", exc_info=exc) + else: + LOG.debug( + "Periodic media file sync still failing after %d attempts (%s).", + self._media_poll_failures + 1, + exc, + exc_info=exc, + ) + self._media_poll_failures += 1 + def transaction_begin(self, transaction): """Hook DbTxn.__enter__ (which calls this immediately, before the - transaction's body runs) to snapshot the local per-type handle - sets ahead of a batch=True transaction that isn't one of + transaction's body runs) to snapshot every primary object's full + current data ahead of a batch=True transaction that isn't one of _sync_from_server()'s own (self._pulling) -- _reconcile_batch_ - commit() needs that "before" picture to diff against, since DBAPI - skips its usual undo-log recording for a batch commit regardless - of who started it. See the module docstring.""" + commit() needs that "before" picture (not just which handles + exist) to diff against, since DBAPI skips its usual undo-log + recording for a batch commit regardless of who started it. See + the module docstring and _reconcile_batch_commit()'s own + docstring for why full data, not just handles or timestamps.""" result = super().transaction_begin(transaction) if transaction.batch and not self._pulling: - transaction._webapidb_before_handles = self._handles_by_class() + transaction._webapidb_before = self._snapshot_all_objects() return result def transaction_commit(self, transaction): # Must run before super(): it clears the transaction's records. payload = transaction_to_json(transaction) + # The same description Gramps desktop's own editors set on this + # DbTxn (e.g. "Add Person (Jane Doe)", editperson.py) and the + # convention gramps-web-api's own per-object PUT/POST endpoints + # use for their transaction log -- forwarded as-is so a push from + # this addon shows up the same way in the server's revision + # history instead of as a generic "Raw transaction". See + # push_transaction()'s docstring. + message = transaction.get_description() or None super().transaction_commit(transaction) - before_handles = getattr(transaction, "_webapidb_before_handles", None) - if before_handles is not None: - self._reconcile_batch_commit(before_handles, transaction.start_time) + before = getattr(transaction, "_webapidb_before", None) + if before is not None: + self._reconcile_batch_commit(before, message=message) return # self._retrying is set by _retry_after_conflict() while it holds # its own DbTxn open, so the push this commit triggers knows it is # itself a conflict retry and won't retry again on a second - # conflict -- see _push_payload(). - self._push_payload(payload, is_retry=self._retrying) + # conflict -- see _start_push(). + self._start_push(payload, is_retry=self._retrying, message=message) def undo(self, update_history=True): # Peek before super(): DbGenericUndo._undo() pops this DbTxn off @@ -824,75 +1970,351 @@ def undo(self, update_history=True): transaction = self.undodb.undoq[-1] if self.undodb.undo_count else None result = super().undo(update_history) if result and transaction is not None: - self._push_payload(transaction_to_json(transaction), undo=True) + description = transaction.get_description() + message = _("Undo: %s") % description if description else None + self._start_push( + transaction_to_json(transaction), undo=True, message=message + ) return result def redo(self, update_history=True): transaction = self.undodb.redoq[-1] if self.undodb.redo_count else None result = super().redo(update_history) if result and transaction is not None: + description = transaction.get_description() + message = _("Redo: %s") % description if description else None # Redo is just re-applying the original transaction forward -- # not a variant of undo=True. See push_transaction()'s docstring. - self._push_payload(transaction_to_json(transaction)) + self._start_push(transaction_to_json(transaction), message=message) return result - def _push_payload(self, payload, undo=False, is_retry=False): - """Push a change-list payload to the server, handling a rejected - push (conflict or otherwise) the same way regardless of whether it - came from a plain commit, an undo, or a redo. + def _start_push(self, payload, undo=False, is_retry=False, message=None): + """Route a locally-intended push through the single-flight gate + self._syncing (see the module docstring): the true top-level + entry point for a given local edit -- transaction_commit(), + undo(), redo() -- claims self._syncing here before anything + touches the network, and only the terminal handler + _finish_async_op() wraps releases it, once this whole chain + (including any conflict-recovery detour through + _push_payload_async() -> _resync_after_conflict_async() -> + _retry_after_conflict()'s own nested push) has actually + finished -- not merely been started. + + A recursive call (is_retry=True, reached only via + _retry_after_conflict()'s own nested transaction_commit()) never + claims self._syncing itself: it is always already held by + whichever outer call started this chain, and reuses that outer + call's own completion callbacks (self._retry_chain_done/ + self._retry_chain_error, stashed by _after_conflict_resync()) + so the chain's real end -- not just this recursive leg's local + commit -- is what finally clears the flag. An earlier version of + this design let self._syncing clear as soon as the retry's local + DbTxn committed, before its own nested re-push (a real network + round trip) had actually resolved -- exactly the class of race + this refactor exists to eliminate, just reintroduced one level + up if not guarded against here too. + + If self._syncing is already held by something else when a + non-retry payload arrives, the push is queued + (_queue_pending_push()) rather than raced against whatever is in + flight -- deferred, not dropped: _finish_async_op() flushes the + queue once the in-flight chain completes. + """ + if not payload: + if is_retry and self._retry_chain_done is not None: + self._retry_chain_done(None) + return + if not is_retry: + if self._syncing: + self._queue_pending_push(payload, undo=undo, message=message) + return + self._syncing = True + on_done = self._finish_async_op(None) + on_error = self._finish_async_op(None) + else: + on_done = self._retry_chain_done or self._finish_async_op(None) + on_error = self._retry_chain_error or self._finish_async_op(None) + self._push_payload_async( + payload, on_done, on_error, undo=undo, is_retry=is_retry, message=message + ) - is_retry marks a push that is itself the replay _retry_after_conflict() - made from an earlier conflict -- a second conflict on that replay is - logged and dropped rather than retried again, so a genuinely hot - object can't send this into an unbounded retry loop. + def _finish_async_op(self, on_done): + """Wrap a top-level async chain's true completion handler so + self._syncing only clears once the chain is genuinely done -- + including one attempt at flushing anything that arrived and got + queued (_queue_pending_push()) while this chain held the flag. + Shared by every top-level entry point that claims self._syncing + (_start_push(), _poll_tick(), _media_poll_tick()), so a push + that had to wait behind, say, a poll-triggered resync goes out + as soon as that resync's chain finishes, not on the next poll + tick. + + Deliberately *one* flush attempt, not a "keep looping while the + queue is non-empty" recursion: _flush_pending_pushes_async() + already drains the queue as far as it currently can in that one + call (see its own docstring), stopping naturally at the first + still-undeliverable entry -- if it stopped there, the queue is + non-empty for exactly that reason, and calling it again + immediately would just retry the identical failing entry, + synchronously, forever. An earlier version of this method did + exactly that (loop while non-empty) and deadlocked every test + (and would have hung a real session) the moment any push failed + for a connectivity reason, since the same queued entry made the + post-flush check non-empty again on every pass. A push that + arrives genuinely *during* this flush (a concurrent edit while + self._syncing is still held) is not lost, just not flushed + immediately -- it waits for the next chain's own completion, or + the next poll tick, same as any other queued push today. + """ + + def finish(*args): + if self._get_metadata("pending_pushes", default=[]): + # self._syncing stays True until the flush -- a + # continuation of this same chain, not a new operation + # free to race whatever comes next -- itself finishes. + def done_flushing(_result): + self._syncing = False + if on_done is not None: + on_done(*args) + + self._flush_pending_pushes_async(done_flushing, done_flushing) + return + self._syncing = False + if on_done is not None: + on_done(*args) + + return finish + + def _push_payload_async( + self, payload, on_done, on_error, undo=False, is_retry=False, message=None + ): + """Push a change-list payload to the server, handling a rejected + push (conflict or otherwise) the same way regardless of whether + it came from a plain commit, an undo, or a redo. The async + counterpart of the old (pump-based) _push_payload(): the network + call runs entirely on io_runner (see taskrunner.py) -- no + self.dbapi touch anywhere in this method or anything it + schedules. + + Caller (_start_push()) already owns self._syncing; this method + and everything it chains into never touches that flag itself -- + see that method's docstring. + + is_retry marks a push that is itself the replay + _retry_after_conflict() made from an earlier conflict -- a + second conflict on that replay is logged and dropped rather than + retried again, so a genuinely hot object can't send this into an + unbounded retry loop. + + ``message`` is forwarded to push_transaction() as-is -- see its + docstring. """ if not payload: + on_done(None) return - try: - self.web_client.push_transaction( - payload, undo=undo, background=self._use_background_push(payload) - ) - except WebApiPushConflict: - LOG.warning( - "Server rejected %d local change(s): the object(s) changed " - "server-side since the local mirror last synced. Resyncing " - "the mirror from the server now.", + started = monotonic() + + def do_push(): + # io_runner: pure network, no self.dbapi touch. + # _use_background_push()'s own network call + # (supports_background_transactions()) belongs here too, not + # on the main thread -- see that method's docstring. + background = self._use_background_push(payload) + LOG.debug( + "push: %d change(s) (%s)%s%s", len(payload), + ", ".join(sorted({entry["type"] for entry in payload})), + " undo" if undo else "", + " background" if background else "", ) - try: - self._sync_from_server() - except _CONNECTION_ERRORS: - LOG.exception("Resync after a push conflict also failed.") - return - if undo or is_retry: + self.web_client.push_transaction( + payload, undo=undo, background=background, message=message + ) + LOG.debug("push: accepted in %.2fs", monotonic() - started) + + def on_pushed(_result): + on_done(None) + + def on_push_error(exc): + if isinstance(exc, WebApiPushConflict): LOG.warning( - "Giving up on %d local change(s) after a repeated or " - "undo/redo conflict; the local mirror was not resent to " - "the server.", + "Server rejected %d local change(s): the object(s) " + "changed server-side since the local mirror last " + "synced. Resyncing the mirror from the server now.", len(payload), ) + + def on_resync_error(resync_exc): + LOG.error( + "Resync after a push conflict also failed.", + exc_info=resync_exc, + ) + on_error(resync_exc) + + # A full resync, not the incremental history feed: a + # conflict can be caused by a server-side change the + # history feed cannot describe at all -- a bulk import + # runs entirely outside gramps-web-api's own transaction + # log (see _resync_after_conflict_async()'s docstring), + # ordinary server administration rather than an edge + # case -- and a totals check can't catch a content-only + # change to an already-known object either. See + # _resync_after_conflict_async() for why nothing cheaper + # is trustworthy here. + self._resync_after_conflict_async( + on_done=lambda _: self._after_conflict_resync( + payload, undo, is_retry, on_done, on_error, message=message + ), + on_error=on_resync_error, + ) return - self._retry_after_conflict(payload) - except _CONNECTION_ERRORS as err: - if not _is_retryable_push_error(err): + if not _is_retryable_push_error(exc): # A permission/payload rejection is not going to start # working on its own; queueing it would retry it on every - # poll forever and eventually push real, retryable work out - # of the capped queue. + # poll forever and eventually push real, retryable work + # out of the capped queue. LOG.error( "Server permanently rejected %d local change(s) (%s). " "They will not be retried, and the local mirror has " "drifted from the server for those object(s).", len(payload), - err, + exc, ) + on_error(exc) return - LOG.exception( + # LOG.exception() (used by the old synchronous _push_payload()) + # relies on sys.exc_info(), which has nothing to show from + # inside a callback outside any active except block -- exc is + # passed explicitly via exc_info instead, same as elsewhere + # in this file's ..._async() error handlers. + LOG.error( "Failed to push %d local change(s) to the server; queued " "for retry on the next successful contact with the server.", len(payload), + exc_info=exc, ) - self._queue_pending_push(payload, undo=undo) + self._queue_pending_push(payload, undo=undo, message=message) + on_error(exc) + + self.io_runner.run( + do_push, self._guarded(on_pushed), self._guarded(on_push_error) + ) + + def _after_conflict_resync( + self, payload, undo, is_retry, on_done, on_error, message=None + ): + """_push_payload_async()'s continuation once + _resync_after_conflict_async() has brought the local mirror back + to the server's true current state: replay the original edit on + top of it (_retry_after_conflict()), unless this is already a + retry or an undo/redo -- see _push_payload_async()'s docstring + on is_retry. + + _retry_after_conflict()'s own DbTxn body is 100% local DB work + (no network), so it runs as one runner (main-thread) step -- + run_retry() below. What it triggers on exit + (DbTxn.__exit__ -> transaction_commit() -> _start_push(..., + is_retry=True)) is a *nested* push, itself asynchronous; + run_retry()'s own runner.run() completing therefore does NOT + mean this chain is done, only that the local commit landed. The + chain's real on_done/on_error are stashed on self + (self._retry_chain_done/_retry_chain_error) so that nested + _start_push() call can find and use them instead of treating the + retry's local commit as the finish line -- see _start_push()'s + docstring for the bug this specifically fixes. + """ + if undo or is_retry: + LOG.warning( + "Giving up on %d local change(s) after a repeated or " + "undo/redo conflict; the local mirror was not resent to " + "the server.", + len(payload), + ) + on_done(None) + return + + # This call is itself already known-valid (reached only via a + # self._guarded() callback further up the chain), but scheduling + # run_retry() below is a fresh hop through the main loop + # (self.runner.run() -> another GLib.idle_add) -- close() could + # still run in that narrow gap before run_retry() actually + # executes. Re-checked inside run_retry() itself, same reasoning + # as _full_resync_async()'s rebuild() -- see that method's + # comment for the fuller explanation of why a fresh self._guarded() + # wrapping alone can't catch this (it only stops the *outcome* + # from being delivered, not the DbTxn from running in the first + # place). + run_id = self._run_id + + def on_retry_db_error(exc): + # _retry_after_conflict()'s own DbTxn body (data_to_object(), + # commit_(), the merge) is what can raise here -- its + # nested transaction_commit() -> _start_push() call handles a + # rejected push itself and does not re-raise, so reaching + # this means the DbTxn body never finished and aborted + # without committing. Nothing local to lose; queue the + # original payload the same as any other connectivity + # failure. + LOG.warning( + "Could not replay %d local change(s) after a conflict " + "(%s); queued for retry on the next successful contact " + "with the server.", + len(payload), + exc, + ) + self._queue_pending_push(payload, undo=undo, message=message) + on_error(exc) + + def run_retry(): + if self._run_id != run_id: + # The tree closed between this being scheduled and + # actually running. No resource to clean up here (unlike + # _full_resync_async()'s temp file) -- just don't touch + # self.dbapi, which may already be closed. + LOG.debug("retry: tree closed before it ran; discarding it") + return + # Read synchronously by _start_push() inside this same call + # (via DbTxn.__exit__ -> transaction_commit()), before the + # finally below clears it -- same single-callback-body + # ordering guarantee as self._retrying itself. + # + # DbTxn.__exit__() calls transaction_commit() unconditionally + # on a clean exit (txn.py), whether or not the transaction's + # body actually committed anything -- so _start_push(..., + # is_retry=True) is *always* reached exactly once here, never + # skipped. Its own "if not payload:" branch already handles + # the all-entries-were-no-ops case by calling + # self._retry_chain_done(None) itself; there is deliberately + # no fallback completion call here after + # _retry_after_conflict() returns -- an earlier version of + # this method had one, on the mistaken assumption that an + # empty commit skips transaction_commit() entirely, and it + # fired unconditionally, completing the chain the moment the + # *local* commit landed regardless of whether the recursive + # push it had just scheduled was still genuinely in flight -- + # reintroducing the exact premature-completion bug + # _start_push()'s docstring describes. Only a real exception + # from _retry_after_conflict() itself (caught below via + # on_retry_db_error) is a valid reason for this chain to stop + # here instead of via that recursive push's own eventual + # on_done/on_error. + self._retry_chain_done, self._retry_chain_error = on_done, on_error + try: + self._retry_after_conflict(payload) + finally: + self._retry_chain_done = None + self._retry_chain_error = None + + self.runner.run( + run_retry, + # No-op: the chain's real completion fires from inside + # run_retry() itself (via the nested push's on_done/ + # on_error), not from runner.run()'s own on_success -- + # run_retry() returning just means the *local* commit + # landed, not that the chain is done. + self._guarded(lambda _: None), + self._guarded(on_retry_db_error), + ) def _use_background_push(self, payload): """Whether to ask the server to process this payload as a @@ -916,14 +2338,20 @@ def _use_background_push(self, payload): ) return False - def _queue_pending_push(self, payload, undo=False): + def _queue_pending_push(self, payload, undo=False, message=None): """Persist a payload whose push failed for a connectivity reason, so _flush_pending_pushes() can retry it later -- including after a close()/reopen, since this goes through _set_metadata() (the same mechanism sync_last_time already uses) rather than an in-memory - list. See the module docstring.""" + list. See the module docstring. + + ``message`` (see push_transaction()'s docstring) is persisted + alongside the payload so a queued push, once retried, still shows + up in the server's history under its original description rather + than falling back to "Raw transaction". + """ pending = self._get_metadata("pending_pushes", default=[]) - pending.append({"payload": payload, "undo": undo}) + pending.append({"payload": payload, "undo": undo, "message": message}) if len(pending) > MAX_PENDING_PUSHES: dropped = len(pending) - MAX_PENDING_PUSHES LOG.error( @@ -937,34 +2365,74 @@ def _queue_pending_push(self, payload, undo=False): pending = pending[dropped:] self._set_metadata("pending_pushes", pending) - def _flush_pending_pushes(self): + def _flush_pending_pushes_async(self, on_done, on_error): """Retry queued pushes that previously failed for a connectivity reason, oldest first, stopping at the first that still can't be delivered -- see the module docstring on why this doesn't skip ahead past a stuck entry. A queued entry that comes back as a *conflict* rather than a - connectivity failure is dropped rather than retried forever: by the - time it is replayed the server has moved on, and _push_payload()'s - resync-and-merge path needs an "old" snapshot contemporaneous with - the edit, which a queued payload no longer has. An entry the server - permanently rejects (see _is_retryable_push_error()) is likewise - dropped rather than left to block the queue forever -- permissions - may well have changed between queueing and now. + connectivity failure is dropped rather than retried forever: by + the time it is replayed the server has moved on, and + _push_payload_async()'s resync-and-merge path needs an "old" + snapshot contemporaneous with the edit, which a queued payload + no longer has. An entry the server permanently rejects (see + _is_retryable_push_error()) is likewise dropped rather than left + to block the queue forever -- permissions may well have changed + between queueing and now. + + Stopping early (a still-undeliverable entry) or exhausting the + queue both call on_done, not on_error: neither is a failure of + this method itself, and its caller (currently only + _finish_async_op(), via a fresh self._get_metadata() check each + time it re-wraps itself) always treats "flushed as far as + possible" as success. Reads the queue itself (rather than + taking it as a parameter) for the same reason _flush_pending_ + pushes() always did: called from more than one place, each + needing the current persisted state, not a snapshot from + whenever the caller happened to start. """ pending = self._get_metadata("pending_pushes", default=[]) if not pending: + on_done(None) return LOG.info("Retrying %d queued push(es) to the server.", len(pending)) - while pending: - entry = pending[0] - try: - self.web_client.push_transaction( - entry["payload"], - undo=entry.get("undo", False), - background=self._use_background_push(entry["payload"]), - ) - except WebApiPushConflict: + self._flush_one_pending_push(pending, on_done, on_error) + + def _flush_one_pending_push(self, pending, on_done, on_error): + """_flush_pending_pushes_async()'s per-entry step. ``pending`` is + mutated in place (entries popped off the front as they're + delivered or dropped) and persisted once this recursion bottoms + out, exactly the way the old synchronous while-loop this + replaces did with its own local variable.""" + if not pending: + self._set_metadata("pending_pushes", pending) + LOG.debug("queue: %d push(es) still pending after the flush", len(pending)) + on_done(None) + return + entry = pending[0] + + def do_push(): + # io_runner: pure network, no self.dbapi touch. See + # _push_payload_async()'s do_push() for why + # _use_background_push() belongs here too. + background = self._use_background_push(entry["payload"]) + self.web_client.push_transaction( + entry["payload"], + undo=entry.get("undo", False), + background=background, + message=entry.get("message"), + ) + + def pop_and_continue(): + pending.pop(0) + self._flush_one_pending_push(pending, on_done, on_error) + + def on_pushed(_result): + pop_and_continue() + + def on_push_error(exc): + if isinstance(exc, WebApiPushConflict): LOG.warning( "A queued push of %d change(s) conflicts with the " "server's current data and cannot be replayed safely; " @@ -972,36 +2440,62 @@ def _flush_pending_pushes(self): "server for those object(s).", len(entry["payload"]), ) - except _CONNECTION_ERRORS as err: - if _is_retryable_push_error(err): - LOG.warning( - "Still unable to deliver %d queued push(es); will retry.", - len(pending), - ) - break - LOG.error( - "Server permanently rejected a queued push of %d " - "change(s) (%s); dropping it. The local mirror has " - "drifted from the server for those object(s).", - len(entry["payload"]), - err, + pop_and_continue() + return + if _is_retryable_push_error(exc): + LOG.warning( + "Still unable to deliver %d queued push(es); will retry.", + len(pending), ) - pending.pop(0) - self._set_metadata("pending_pushes", pending) + self._set_metadata("pending_pushes", pending) + LOG.debug( + "queue: %d push(es) still pending after the flush", len(pending) + ) + on_done(None) + return + LOG.error( + "Server permanently rejected a queued push of %d change(s) " + "(%s); dropping it. The local mirror has drifted from the " + "server for those object(s).", + len(entry["payload"]), + exc, + ) + pop_and_continue() + + self.io_runner.run( + do_push, self._guarded(on_pushed), self._guarded(on_push_error) + ) def _retry_after_conflict(self, payload): - """Reapply each locally-intended change on top of the mirror - _push_payload() just resynced, as a fresh local edit -- see the - module docstring's write-through section. An add/update whose - object still exists after the resync is combined with the current - (server-fresh) object via _merge_or_overwrite() rather than - blindly replacing it. + """Reapply each locally-intended change as a fresh local edit -- + see the module docstring's write-through section. An add/update + whose object still exists is combined with the current object via + _merge_or_overwrite() rather than blindly replacing it. + + Callers are responsible for making sure the local mirror already + holds the server's true current state for whatever this payload + touches, *before* this runs: DBAPI computes this retry's own "old" + snapshot from whatever is stored locally at commit time + (_commit_base()'s _get_raw_data() call), so a stale mirror means + the retry pushes the same stale "old" as the original failed push + and is rejected again identically, no matter how correct its + "new" is. _push_payload_async()'s conflict handler does this with + a full resync (_resync_after_conflict_async()) before calling + here (via _after_conflict_resync()); see that method's docstring + for why nothing cheaper is trustworthy. This is only ever reached + via that path now -- a local batch operation's own reconciled + changes (_reconcile_batch_commit()) push directly through + _start_push(), landing here only if that push itself conflicts, + the same as any other edit. Runs as one ordinary (non-batch) DbTxn, so it goes through the - normal transaction_commit() -> _push_payload() path again -- this - time with an "old" snapshot that matches what the resync just - pulled down, so it will only be rejected again if something else - changed server-side in the brief window since that resync. + normal transaction_commit() -> _start_push() path again -- this + time with an "old" snapshot that matches the local mirror's + current (freshly-resynced, for the conflict path) state, so it + will only be rejected again if something else changed server-side + in the brief window since then. _after_conflict_resync() runs + this as one runner (main-thread) step, since it's 100% local DB + work with no network of its own. """ self._retrying = True try: @@ -1020,68 +2514,547 @@ def _retry_after_conflict(self, payload): obj = data_to_object(entry["new"]) if has_handle(handle): current = getattr(self, f"get_{name}_from_handle")(handle) - obj = _merge_or_overwrite(current, obj) + obj = _merge_or_overwrite(current, obj, self) getattr(self, f"commit_{name}")(obj, trans) finally: self._retrying = False - def _handles_by_class(self): - """{obj_class: set(handles)} across every primary object type -- - the "before" snapshot transaction_begin() stashes on a local - batch=True transaction for _reconcile_batch_commit() to diff - against. See the module docstring.""" - return { - obj_class: set(getattr(self, f"get_{KEY_TO_NAME_MAP[key]}_handles")()) - for obj_class, key in CLASS_TO_KEY_MAP.items() - } + def _resync_after_conflict_async(self, on_done, on_error): + """Rebuild the local mirror from a fresh server export + (_full_resync_async()) before a conflict retry -- called by + _push_payload_async()'s WebApiPushConflict handler in place of + an incremental sync. Neither the incremental history feed nor a + totals check is trustworthy here: gramps-web-api's bulk-import + path (POST /importers//file -- GEDCOM, Gramps XML, CSV, + ...) runs the same batch=True import machinery a local Gramps + client's own Import menu action would, which never touches the + transaction-history table at all (see the module docstring), so + the incremental feed can be blind to an object's true current + state indefinitely -- ordinary server administration for any + real installation, not a quirk of one server. A totals + comparison doesn't catch this either: the object count doesn't + change when an already-known object's content changes + server-side, only when objects are added or removed, so a + conflict caused by a content edit on a bulk-imported object + leaves totals matching on both sides even though the mirror's + copy of that object is stale. + + A per-object REST fetch (GET //) was tried here + first and doesn't work: gramps-web-api's single-object endpoints + serialize with GrampsJSONEncoder.extract_object() (a walk of the + object's own __dict__/properties for the frontend's display + schema -- no "_class" tag on GrampsType-derived fields), not the + gramps.gen.lib.json_utils shape data_to_object() requires to + reconstruct a Gramps object. Only two things produce that + compatible shape: the transaction-history feed's new_data, and a + raw Gramps XML export -- see _full_resync_async()'s own + docstring. So a full resync, expensive as it is, is the only + server round-trip that can bring the local mirror back into a + state _retry_after_conflict() can safely build an "old" snapshot + from. + + Caller already owns self._syncing (see _start_push()); this is a + thin, purpose-named wrapper around _full_resync_async() rather + than a second flag-managing layer -- unlike the old synchronous + _resync_after_conflict() this replaces, which had to manage + self._syncing itself since nothing else did for a plain push. + """ + self._full_resync_async(on_done, on_error) + + def _full_resync_async(self, on_done, on_error, progress_callback=None): + """Rebuild the local mirror from scratch: download the server's + own current Gramps XML export and reimport it, after clearing + every local primary object first. Called by + _resync_after_conflict_async() (a push conflict) and, from phase + 4 on, also when the transaction-history feed contains an + empty-changes marker -- by definition there is nothing in that + history to replay for whatever produced it, so the only way to + recover is to fetch the server's current state wholesale, the + same way populating a brand new local mirror already works. + + Deliberately reuses the stock ImportXml importer against a raw + XML export rather than reconstructing objects from the REST + /people/, /families/, ... endpoints: those return a marshalled + display schema (plain ints for GrampsType fields, no "_class" + tag), not the json_utils shape data_to_object() needs. Only the + transaction-history feed's new_data and a raw XML export share + that shape, and the whole point of this method is that the + former can't be trusted here. - def _reconcile_batch_commit(self, before_handles, start_time): - """Reconstruct what a local batch=True transaction changed, by - diffing the pre-transaction handle snapshot against the current - state, and push it -- see the module docstring's note on why a - batch commit is otherwise invisible to transaction_to_json(). - - An object present now but not before is an add; one present before - but not now is a delete; one present in both whose .change is at or - after the transaction's start_time was updated during it - (_commit_base() stamps .change on every commit, batch included). - Objects the batch left untouched keep an older .change and are - correctly skipped. - - The reconstructed entries go to _retry_after_conflict(), which - replays each as an ordinary non-batch local edit -- so each one - picks up a real "old" snapshot and goes out through the normal - transaction_commit() -> _push_payload() path. - - Cost: the surviving-handle pass reads every primary object in the - tree to check its .change, so this is O(total objects) per batch - commit, not O(objects the batch touched). That is the same order - as whatever opened a batch transaction in the first place (a bulk - import writes everything; Check and Repair reads everything), and - .change isn't a queryable secondary column in DBAPI's schema -- - only handle plus a couple of per-type extras are -- so there is no - cheaper way to ask "what did this transaction touch" after the - fact. If this ever shows up as a real bottleneck, the fix is to - make the batch operation's own transaction non-batch, not to - make this cleverer. + Two steps: the download runs on io_runner (network + disk, no + self.dbapi touch); the clear-then-reimport runs as a single + runner (main-thread) step -- both the explicit clearing DbTxn + and ImportXml's own internal batch DbTxn, under self._pulling so + transaction_commit() treats them as pull-side replays rather + than local bulk edits to reconstruct and push back. Doing both + halves inside one callback body (rather than pumping between + them, as the old synchronous _full_resync() this will eventually + replace still does) means close() can no longer interrupt a + rebuild mid-way -- it can only run strictly before this step + starts or strictly after it returns. + + rebuild() re-checks self._run_id itself, rather than relying + solely on self._guarded() around its scheduling, for a reason + specific to this method: the downloaded export is a real + resource (a temp file) that needs cleaning up even if the tree + closes in the gap between the download finishing and this step + actually running -- a self._guarded()-dropped callback runs + nothing at all, which would leak the file. Checking inside + rebuild() itself (before it does anything else) additionally + closes the narrower window between that check and the step + being scheduled, so self.dbapi -- possibly already closed by + then -- is never touched once the chain is known to be stale. + + progress_callback, if given, gets a 0 marker before the download, + then real percentages throughout the reimport -- forwarded to + importData() via _import_progress_user(), which builds exactly + the gui.user.User Gramps' own GUI import uses (see that + function's docstring) -- and a final 100 once everything, + including request_rebuild(), has finished. + + Also (re)sets sync_last_time to a timestamp taken right before + the export download starts, so the next incremental sync asks + the history feed for changes after that point instead of + wherever the walk that triggered this rebuild happened to leave + the cursor -- for the totals-shortfall case, that walk can be a + single empty page, which leaves sync_last_time at its untouched + starting value (0 for a brand new mirror) rather than anywhere + near "now". Left uncorrected, every later poll asks for history + "after 0" forever on a server whose history can't describe its + own data anyway, so it's a harmless no-op -- but a *push + conflict*'s own recovery (resync then retry) uses that exact + same stuck cursor, so the resync it does can never actually pick + up what changed and the retry is doomed to repeat the same + conflict and give up. Taken before the download rather than + after: a transaction the server commits while the export is + being generated or transferred is safer to see again on the + next poll (re-applying an already-reflected change is a no-op) + than to have it fall silently before the cursor and only be + discovered next time a shortfall check runs. """ - entries = [] - for obj_class, key in CLASS_TO_KEY_MAP.items(): - name = KEY_TO_NAME_MAP[key] - before = before_handles.get(obj_class, set()) - after = set(getattr(self, f"get_{name}_handles")()) - for handle in after - before: - entries.append({"type": "add", "handle": handle, "_class": obj_class}) - for handle in before - after: - entries.append( - {"type": "delete", "handle": handle, "_class": obj_class} + if progress_callback is not None: + progress_callback(0) + sync_cutoff = time() + started = monotonic() + # Captured once, up front, and reused for every hop below -- + # deliberately not re-wrapped via self._guarded() partway through + # (see on_downloaded()'s own comment for why that would be wrong + # here specifically). + run_id = self._run_id + guarded_done = self._guarded(lambda _: on_done(None)) + guarded_error = self._guarded(on_error) + + def download(): + # io_runner: network + disk only -- the single longest + # transfer this addon makes. No on_chunk to pump for anymore + # (a worker thread has nothing to hand back to); one plain + # read is fine. + data = self.web_client.download_export() + LOG.debug( + "resync: downloaded a %.1f MB export in %.2fs", + len(data) / (1024 * 1024), + monotonic() - started, + ) + with NamedTemporaryFile(suffix=".gramps", delete=False) as tmp_file: + tmp_file.write(data) + return tmp_file.name + + def rebuild(tmp_path): + # runner: clear + reimport + rebuild-signal + sync_last_time, + # all in one main-thread callback body -- see this method's + # own docstring on why that's the point, not incidental. + if self._run_id != run_id: + # The tree closed somewhere between the download + # finishing and this step actually running. Clean up the + # temp file -- nothing else will -- but do not touch + # self.dbapi, which may already be closed. See this + # method's own docstring on why this check lives here + # rather than relying on self._guarded() alone. + LOG.debug("resync: tree closed before rebuild ran; discarding it") + os.remove(tmp_path) + return + self._pulling = True + try: + before = self._snapshot_all_objects() + birth_death_snapshot = _snapshot_birth_death_indices(self) + cleared = 0 + with DbTxn( + _("Clear local mirror before full resync"), self, batch=True + ) as trans: + for key in set(CLASS_TO_KEY_MAP.values()): + name = KEY_TO_NAME_MAP[key] + handles = list(getattr(self, f"get_{name}_handles")()) + remove = getattr(self, f"remove_{name}") + for handle in handles: + remove(handle, trans) + cleared += len(handles) + LOG.debug("resync: cleared %d local object(s); reimporting", cleared) + imported_at = monotonic() + import_user = ( + _import_progress_user(progress_callback) + if progress_callback is not None + else User() + ) + importData(self, tmp_path, import_user) + LOG.debug( + "resync: reimport left %d object(s) (%.2fs)", + self.get_total(), + monotonic() - imported_at, ) - get_obj = getattr(self, f"get_{name}_from_handle") - for handle in after & before: - if get_obj(handle).change >= start_time: - entries.append( - {"type": "update", "handle": handle, "_class": obj_class} + if birth_death_snapshot: + with DbTxn( + _("Restore birth/death event references lost on reimport"), + self, + batch=True, + ) as trans: + restored = _restore_birth_death_indices( + self, birth_death_snapshot, trans + ) + if restored: + LOG.debug( + "resync: restored birth/death event reference " + "index on %d person(s) Gramps XML re-import " + "can't preserve", + restored, + ) + self._describe_resync_to_views(before) + finally: + self._pulling = False + os.remove(tmp_path) + self._set_metadata("sync_last_time", sync_cutoff) + if progress_callback is not None: + progress_callback(100) + + def on_downloaded(tmp_path): + # Deliberately NOT wrapped in self._guarded(): tmp_path is a + # real resource (a downloaded temp file) that needs cleaning + # up even if the tree closed while the download was in + # flight, and a self._guarded()-dropped callback runs + # nothing at all. rebuild() re-checks self._run_id itself + # (see its own comment) before touching self.dbapi, so + # scheduling it unconditionally here is still safe -- and + # guarded_done/guarded_error (captured once, above, against + # this call's original run_id) are reused rather than + # wrapped fresh here, since a fresh self._guarded() call made + # from inside this always-firing callback would capture + # self._run_id as it is *now* (already stale, in the case + # this comment is about), defeating the check entirely. + self.runner.run(lambda: rebuild(tmp_path), guarded_done, guarded_error) + + self.io_runner.run(download, on_downloaded, guarded_error) + + def _bootstrap_full_resync(self, progress_callback=None): + """load()'s own alternative to _full_resync_async(), used + specifically for the totals-shortfall check load() runs itself + before the ordinary record-sync call (see load()'s own comment) + -- most commonly hit opening a brand new mirror against a large + existing tree, exactly the scenario that prompted this method. + + _full_resync_async() schedules its reimport step via + self.runner.run() (GLib.idle_add underneath), dispatched from + inside _run_async_to_completion()'s own pump loop + (_pump_main_loop(), using GLib.MainContext.iteration()) when + called from load(). importData()'s own progress reporting (see + _import_progress_user()) then calls Gtk.main_iteration() -- + a *different* pumping API -- from inside that already-running + step. Gramps' own native GUI Import never has that outer + wrapping at all: it calls importData() directly from an ordinary + GTK signal handler. This method reproduces that same shape for + load()'s bootstrap case instead: plain sequential calls, on the + calling thread, with importData() itself never scheduled via + io_runner/runner/GLib.idle_add at all -- so there is nothing of + this addon's own left for importData()'s own pumping to end up + nested inside. + + Confirmed via live GUI testing (2026-08-17) that this resolves a + freeze reported for exactly this load()-time scenario -- delete + an existing mirror, create a fresh one, open it against a large + (26540-object) tree. That investigation also uncovered an + unrelated, session-long confound (a stale/misresolved installed + plugin copy, see project memory) that had made every earlier + live test that session meaningless, regardless of what the code + actually did -- worth keeping in mind before reading too much + into the reentrant-pumping theory above as *proven*: it is + plausible and this method is a reasonable defensive structural + match to Gramps' own working native-Import shape, but the + specific freeze reports blamed on it before the stale-plugin + discovery are not reliable evidence either way. _full_resync_async() + itself is unchanged and still used for both its other callers + (_resync_after_conflict_async(), and _finish_sync()'s own + empty-"changes"-marker trigger) -- neither has actually been + shown to freeze; this method exists for the highest-traffic case + (a brand new or far-behind mirror at load() time) rather than as + a proven-required fix for the other two. + + Safe to skip _full_resync_async()'s _run_id staleness checks and + self._guarded() wrapping here specifically because load() calls + this before the tree is open at all (dbloader.py's read_file() + doesn't call dbstate.change_database(db) until load() returns), + so there is no UI path by which close() could run against this + tree while this method is still executing -- unlike + _full_resync_async()'s other callers, which run against an + already-open tree where that's a live concern. + + Body is otherwise a direct copy of _full_resync_async()'s + rebuild() (see that method for the fuller explanation of each + step): download, clear every local primary object, reimport, + signal a rebuild, and advance sync_last_time. + + The download itself IS still run on io_runner and awaited via + _run_async_to_completion(), unlike everything after it -- unlike + importData(), nothing reentrant happens while it's in flight, so + there is no second pumping API for _run_async_to_completion()'s + own loop to end up nested under. Confirmed live (2026-08-17) that + running the download as a plain blocking call here instead -- + unlike every other network call in this file -- froze the window + for its whole duration (6+ seconds for this export, longer for a + bigger one). + + progress_callback, if given, is called throughout -- not just the + 0/100 bookend load()'s other callers get. load() itself has + already reported 5/10/15/20 by the time this method is called + (see its own comments); from here, DOWNLOAD_START_PCT.. + DOWNLOAD_END_PCT is a slow, fixed-rate pulse for the download + (there is no real byte-level progress to report for a single + unchunked read -- see the download() closure below -- so this is + proof of life, not a measurement), and + REIMPORT_START_PCT..100 is importData()'s own real percentage + (via _import_progress_user()), rescaled onto that remaining span + so the bar keeps climbing instead of resetting to 0% once the + reimport itself starts reporting. + """ + DOWNLOAD_START_PCT = 20 + DOWNLOAD_END_PCT = 30 + REIMPORT_START_PCT = 30 + + sync_cutoff = time() + started = monotonic() + + def download(): + return self.web_client.download_export() + + # Ticks once a second, capped at DOWNLOAD_END_PCT, for as long as + # the download is in flight -- fires because + # _run_async_to_completion()'s own wait loop below pumps this + # same GLib main context. Cancelled in the finally below the + # instant the download finishes (success, failure, or a closed + # tree alike), so it never fires during the reimport phase, which + # reports its own real percentages instead. + pulse_source_id = None + if progress_callback is not None: + pulse_state = {"value": DOWNLOAD_START_PCT} + + def pulse(): + pulse_state["value"] = min(pulse_state["value"] + 1, DOWNLOAD_END_PCT) + progress_callback(pulse_state["value"]) + return GLib.SOURCE_CONTINUE + + pulse_source_id = GLib.timeout_add_seconds(1, pulse) + + try: + data = self._run_async_to_completion( + lambda on_done, on_error: self.io_runner.run( + download, self._guarded(on_done), self._guarded(on_error) + ) + ) + finally: + if pulse_source_id is not None: + GLib.source_remove(pulse_source_id) + if data is None: + # Tree closed while the download was in flight -- see + # _run_async_to_completion()'s own docstring. Not expected in + # practice for load()'s bootstrap case (see this method's own + # docstring on why), but handled the same way the rest of + # this file does rather than assumed away. + LOG.debug("bootstrap resync: tree closed during download; aborting") + return + LOG.debug( + "bootstrap resync: downloaded a %.1f MB export in %.2fs", + len(data) / (1024 * 1024), + monotonic() - started, + ) + with NamedTemporaryFile(suffix=".gramps", delete=False) as tmp_file: + tmp_file.write(data) + tmp_path = tmp_file.name + self._pulling = True + try: + before = self._snapshot_all_objects() + birth_death_snapshot = _snapshot_birth_death_indices(self) + cleared = 0 + with DbTxn( + _("Clear local mirror before full resync"), self, batch=True + ) as trans: + for key in set(CLASS_TO_KEY_MAP.values()): + name = KEY_TO_NAME_MAP[key] + handles = list(getattr(self, f"get_{name}_handles")()) + remove = getattr(self, f"remove_{name}") + for handle in handles: + remove(handle, trans) + cleared += len(handles) + LOG.debug( + "bootstrap resync: cleared %d local object(s); reimporting", cleared + ) + imported_at = monotonic() + if progress_callback is not None: + + def rescaled_progress(value): + progress_callback( + REIMPORT_START_PCT + + int(value * (100 - REIMPORT_START_PCT) / 100) ) + + import_user = _import_progress_user(rescaled_progress) + else: + import_user = User() + importData(self, tmp_path, import_user) + LOG.debug( + "bootstrap resync: reimport left %d object(s) (%.2fs)", + self.get_total(), + monotonic() - imported_at, + ) + if birth_death_snapshot: + with DbTxn( + _("Restore birth/death event references lost on reimport"), + self, + batch=True, + ) as trans: + restored = _restore_birth_death_indices( + self, birth_death_snapshot, trans + ) + if restored: + LOG.debug( + "bootstrap resync: restored birth/death event " + "reference index on %d person(s) Gramps XML " + "re-import can't preserve", + restored, + ) + self._describe_resync_to_views(before) + finally: + self._pulling = False + os.remove(tmp_path) + self._set_metadata("sync_last_time", sync_cutoff) + if progress_callback is not None: + progress_callback(100) + + def _snapshot_all_objects(self): + """{(obj_class, handle): data} across every primary object the + local mirror holds right now, ``data`` being the same + json_utils-shaped, "_object"-stripped form transaction_to_json() + sends as "old"/"new" (_iter_raw_data() reads it straight back out + of storage via the same serializer _commit_base() wrote it with + -- see dbapi.py). Two callers, both diffing a before/after pair + via _diff_snapshots(): _reconcile_batch_commit() around a local + batch=True transaction (transaction_begin()'s own call is the + "before" half), and _full_resync_async()/_bootstrap_full_resync() + around a full wipe-and-reimport (see _describe_resync_to_views()). + + Uses _iter_raw_data() (one bulk SELECT per object type) rather + than _get_raw_data() per handle, so this is O(types) queries, + not O(handles) -- cheap regardless of how many objects a batch + operation actually touches. + """ + snapshot = {} + for obj_class, key in CLASS_TO_KEY_MAP.items(): + for handle, data in self._iter_raw_data(key): + snapshot[(obj_class, handle)] = remove_object(data) + return snapshot + + def _describe_resync_to_views(self, before): + """Tell every already-open view what a full resync's clear+ + reimport actually changed -- called by _full_resync_async()'s + rebuild() and _bootstrap_full_resync(), once each has finished + reimporting (and, for the former, restoring whatever + _restore_birth_death_indices() could). + + request_rebuild() (DbGeneric's own "too much changed to describe + incrementally" signal, gen/db/generic.py) is correct for the + genuinely-everything-changed case -- a brand new mirror, or one + so far behind a repair effectively rebuilds it -- but it is + needlessly disruptive for the far more common case this resync + recovers from: a push conflict or a mirror-repair shortfall, + where the mirror was already correct for everything except the + handful of objects actually involved and the wipe+reimport was + only ever a (surprisingly expensive) way to get back to that + state. gramps.gui.displaystate.py's own History.history_changed() + listens for exactly this signal and responds by resetting Active + Person to find_initial_person() unconditionally -- confirmed live + (2026-08-17): a user mid-edit on one Person, with a different + Person active, would see Active Person silently reset out from + under them on every conflict-triggered resync, since a plain + commit conflict already means at least one resync ran before the + edit could even be retried. + + _diff_snapshots() (the same before/after diff + _reconcile_batch_commit() uses to reconstruct a local batch + commit, minus the push -- what was just pulled *from* the server + must never be pushed back) turns ``before`` and a fresh + _snapshot_all_objects() into the same shape _emit_change_signals() + already knows how to turn into precise person-add/family-update/ + event-delete/... signals. A view listening for those (rather than + a blanket rebuild) only reloads what actually changed -- and + History only resets Active Person if the active object's own + handle is among them. + + GRANULAR_REBUILD_MAX_CHANGES caps this: above it, the diff itself + is legitimately "everything" (an empty-mirror bootstrap, or a + repair recovering from a mirror badly out of step), where one + rebuild signal per type is cheaper for every view than replaying + that many individual signals -- so request_rebuild() stays the + right tool there. An empty diff (nothing genuinely changed -- + possible for a mirror-repair triggered by a totals check that + turns out to have been spurious) skips telling views anything at + all, rather than either signal shape. + """ + entries = _diff_snapshots(before, self._snapshot_all_objects()) + if not entries: + return + if len(entries) > GRANULAR_REBUILD_MAX_CHANGES: + self.request_rebuild() + return + net_changes = { + (entry["_class"], entry["handle"]): _NAME_TO_TRANS_TYPE[entry["type"]] + for entry in entries + } + self._emit_change_signals(net_changes) + + def _reconcile_batch_commit(self, before, message=None): + """Reconstruct exactly what a local batch=True transaction + changed, by diffing the pre-transaction object snapshot + (transaction_begin()'s _snapshot_all_objects() call) against a + fresh one taken now (via _diff_snapshots()), and push the result + -- see the module docstring's note on why a batch commit is + otherwise invisible to transaction_to_json(). + + Getting "old" right this way -- genuinely the pre-transaction + state, not whatever commit_()/remove_() happens to + find in local storage if replayed afterward -- is the point. + local storage by reconciliation time already holds the batch's + own result, not what the mirror last actually synced with the + server; replaying against it (an earlier version of this method + did, via _retry_after_conflict()) sends a false "old" a real + server always rejects as a conflict for an add, and a no-op + "old"-matches-"new" for an update -- and for a delete, replaying + against already-gone-for-real local storage does nothing at + all, silently dropping it. Building the payload directly here + instead avoids all three: pushed the same way as any other + local edit (transaction_commit() -> _push_payload()), with that + method's existing conflict handling (full resync then + _retry_after_conflict()) intact for the rare case something + else changed the same object in the meantime. + + Cost: a full before/after object snapshot, not just handle sets + or timestamps -- see _snapshot_all_objects()'s own docstring for + why, and why that stays cheap (O(types) queries) regardless. If + this ever shows up as a real bottleneck, the fix is to make the + batch operation's own transaction non-batch, not to make this + cleverer. + + ``message`` (see push_transaction()'s docstring) is the + triggering batch DbTxn's own description, forwarded through to + _start_push() -- the reconstructed entries otherwise carry no + description of their own. + """ + entries = _diff_snapshots(before, self._snapshot_all_objects()) if not entries: return LOG.info( @@ -1089,165 +3062,369 @@ def _reconcile_batch_commit(self, before_handles, start_time): "Gramps did not record per-object; pushing them to the server.", len(entries), ) - self._retry_after_conflict(self._fill_entry_payloads(entries)) - - def _fill_entry_payloads(self, entries): - """Attach the "new" object data _retry_after_conflict() needs to - each reconstructed add/update entry, read from the local mirror as - it stands after the batch commit. A delete carries no "new" data, - and an add/update whose handle has since vanished is dropped.""" - filled = [] - for entry in entries: - if entry["type"] == "delete": - filled.append({**entry, "old": None, "new": None}) - continue - key = CLASS_TO_KEY_MAP[entry["_class"]] - name = KEY_TO_NAME_MAP[key] - try: - obj = getattr(self, f"get_{name}_from_handle")(entry["handle"]) - except HandleError: - continue - filled.append( - {**entry, "old": None, "new": remove_object(object_to_data(obj))} - ) - return filled + self._start_push(entries, message=message) - def _sync_from_server(self, progress_callback=None): + def _sync_from_server_async( + self, on_done, on_error, progress_callback=None, verify_totals=False + ): """ Pull every transaction after the last-seen timestamp and replay - its changes into the local mirror. Returns the number of changes - applied. + its changes into the local mirror. Calls on_done(applied) with + the number of changes applied. An empty "changes" list on a transaction is not a no-op: it is what a batch=True commit leaves behind (see the module docstring's note on trans.batch guards around trans.add()) -- something happened server-side that this feed cannot describe. - Flagged rather than silently skipped; _full_resync() is the - fallback once the whole page range has been walked (so + Flagged rather than silently skipped; _full_resync_async() is + the fallback once the whole page range has been walked (so sync_last_time still advances past it and any *describable* - changes around it are applied normally either way). + changes around it are applied normally either way). A feed that + is empty *altogether*, or too sparse to account for what the + server holds, is the same kind of gap and gets the same + fallback -- see _mirror_is_short_of_the_server_async(), which + ``verify_totals`` asks for (load() does; the poll doesn't). progress_callback, if given, is called with an int 0-100 after each page -- see load()'s callback param. "total" comes from the server's X-Total-Count for this "after" filter (get_transaction_ history()'s docstring), so it stays a stable denominator across pages barring concurrent server-side writes during the sync. + + Caller already owns self._syncing (see _start_push()'s + docstring for why none of this file's ..._async() chains touch + that flag themselves). Alternates io_runner (fetch one page) and + runner (apply that page's batch DbTxn replay) for as many pages + as the feed has -- a recursive continuation (_sync_page()) + rather than a fixed-length chain, since the page count isn't + known up front. """ - self._flush_pending_pushes() - after = self._get_metadata("sync_last_time", default=0) - applied = 0 - needs_full_resync = False - page = 1 - seen = 0 - while True: - transactions, total = self.web_client.get_transaction_history( + started = monotonic() + + def after_flush(_result): + after = self._get_metadata("sync_last_time", default=0) + LOG.debug("sync: asking for transactions after %s", after) + self._sync_page( + after=after, + page=1, + seen=0, + applied=0, + skipped=0, + needs_full_resync=False, + started=started, + progress_callback=progress_callback, + verify_totals=verify_totals, + on_done=on_done, + on_error=on_error, + ) + + self._flush_pending_pushes_async(after_flush, on_error) + + def _sync_page( + self, + after, + page, + seen, + applied, + skipped, + needs_full_resync, + started, + progress_callback, + verify_totals, + on_done, + on_error, + ): + """_sync_from_server_async()'s per-page step: fetches one page + on io_runner, applies it on runner, and recurses for the next + page until the feed runs dry or hands back a short page.""" + run_id = self._run_id + + def fetch(): + # io_runner: network only. + return self.web_client.get_transaction_history( after=after, page=page, pagesize=SYNC_PAGE_SIZE ) + + def on_fetched(result): + transactions, total = result if not transactions: - break - # (obj_class, handle) -> trans_type, collapsed to the net - # effect within this page -- see _emit_change_signals(). - net_changes = {} - # _pulling marks this batch DbTxn as one of our own replays, so - # transaction_begin() doesn't snapshot handles for it and - # transaction_commit() doesn't try to push it back out as if it - # were a local bulk edit -- see the module docstring. - self._pulling = True - try: - with DbTxn("Sync from server", self, batch=True) as trans: - for server_trans in transactions: - if not server_trans["changes"]: - needs_full_resync = True - for change in server_trans["changes"]: - if self._apply_change(change, trans): - applied += 1 - net_changes[ - (change["obj_class"], change["obj_handle"]) - ] = change["trans_type"] - after = max(after, server_trans["timestamp"]) - finally: - self._pulling = False - self._emit_change_signals(net_changes) - seen += len(transactions) - if progress_callback is not None and total: - progress_callback(min(100, int(seen * 100 / total))) - if len(transactions) < SYNC_PAGE_SIZE: - break - page += 1 + self._finish_sync( + after, + seen, + applied, + skipped, + needs_full_resync, + started, + progress_callback, + verify_totals, + on_done, + on_error, + ) + return + + def apply_page(): + # runner: self._pulling around a batch=True DbTxn + # replay, then signal emission -- identical body to the + # old synchronous per-page work, no pump in the middle. + # Re-checks self._run_id itself, like + # _full_resync_async()'s rebuild(): this step was + # scheduled from inside an already-guarded callback + # (on_fetched), and close() could in principle run in + # the gap between that scheduling and this actually + # executing -- see that method's own comment for the + # fuller explanation. + if self._run_id != run_id: + LOG.debug( + "sync: tree closed before this page was applied; " + "discarding it" + ) + return None + new_after = after + # (obj_class, handle) -> trans_type, collapsed to the + # net effect within this page -- see + # _emit_change_signals(). + net_changes = {} + new_applied = applied + new_skipped = skipped + new_needs_full_resync = needs_full_resync + # _pulling marks this batch DbTxn as one of our own + # replays, so transaction_begin() doesn't snapshot + # handles for it and transaction_commit() doesn't try to + # push it back out as if it were a local bulk edit -- see + # the module docstring. + self._pulling = True + try: + with DbTxn("Sync from server", self, batch=True) as trans: + for server_trans in transactions: + if not server_trans["changes"]: + new_needs_full_resync = True + for change in server_trans["changes"]: + if self._apply_change(change, trans): + new_applied += 1 + net_changes[ + (change["obj_class"], change["obj_handle"]) + ] = change["trans_type"] + else: + # Reference-type changes and anything + # else with no primary-object class + # to map -- counted rather than + # logged per change, which would be + # one line per row of the feed. + new_skipped += 1 + new_after = max(new_after, server_trans["timestamp"]) + finally: + self._pulling = False + self._emit_change_signals(net_changes) + return new_after, new_applied, new_skipped, new_needs_full_resync + + def on_applied(result2): + if result2 is None: + # Stale (see apply_page()'s own check) -- nothing to + # continue with. self._guarded() below already drops + # this same case for a tree closed *before* + # apply_page() even started; this covers the + # narrower gap where it closed after. + return + ( + new_after, + new_applied, + new_skipped, + new_needs_full_resync, + ) = result2 + new_seen = seen + len(transactions) + LOG.debug( + "sync: page %d, %d transaction(s) of %s, %d change(s) " + "applied so far, cursor %s", + page, + len(transactions), + total, + new_applied, + new_after, + ) + if progress_callback is not None and total: + progress_callback(min(100, int(new_seen * 100 / total))) + if len(transactions) < SYNC_PAGE_SIZE: + self._finish_sync( + new_after, + new_seen, + new_applied, + new_skipped, + new_needs_full_resync, + started, + progress_callback, + verify_totals, + on_done, + on_error, + ) + else: + self._sync_page( + after=new_after, + page=page + 1, + seen=new_seen, + applied=new_applied, + skipped=new_skipped, + needs_full_resync=new_needs_full_resync, + started=started, + progress_callback=progress_callback, + verify_totals=verify_totals, + on_done=on_done, + on_error=on_error, + ) + + self.runner.run( + apply_page, self._guarded(on_applied), self._guarded(on_error) + ) + + self.io_runner.run(fetch, self._guarded(on_fetched), self._guarded(on_error)) + + def _finish_sync( + self, + after, + seen, + applied, + skipped, + needs_full_resync, + started, + progress_callback, + verify_totals, + on_done, + on_error, + ): + """_sync_page()'s tail once the feed runs dry (immediately, or + after the last, short page): persist the cursor, log a summary, + fall back to a full resync if the feed couldn't describe + everything (or, if asked, the mirror's own object count says + it's short), and call on_done(applied). Always reached on the + main thread (either from on_fetched()'s own immediate branch or + from apply_page()'s on_applied(), a runner step's on_success), + so self._set_metadata() here is safe.""" self._set_metadata("sync_last_time", after) - if needs_full_resync: - self._full_resync(progress_callback=progress_callback) - return applied + LOG.debug( + "sync: %d change(s) applied, %d skipped, from %d transaction(s) " + "in %.2fs; cursor now %s", + applied, + skipped, + seen, + monotonic() - started, + after, + ) + + def maybe_full_resync(needs_resync): + if needs_resync: + # Deliver the record-sync's own applied count to on_done + # regardless of the resync outcome, matching what the + # old synchronous method always returned here -- a full + # resync's own result isn't what a caller of *this* + # method is asking about. + self._full_resync_async( + lambda _: on_done(applied), + on_error, + progress_callback=progress_callback, + ) + else: + on_done(applied) - def _full_resync(self, progress_callback=None): + if not needs_full_resync and verify_totals: + self._mirror_is_short_of_the_server_async(maybe_full_resync, on_error) + else: + maybe_full_resync(needs_full_resync) + + def _mirror_is_short_of_the_server_async(self, on_done, on_error): + """Whether the mirror holds fewer objects than the server says + its tree has, once the incremental sync has had its turn -- the + other way the history feed can fail to describe the server's + state, alongside the empty-"changes" marker + _sync_from_server_async() already watches for. Calls + on_done(True) if the mirror is short and needs a full resync, + on_done(False) otherwise. + + A server can hold a full tree that its history does not account + for: that table only records what gramps-web-api itself wrote, so + anything populated by another route (a server-side import straight + into the database, a restored dump, a truncated history table) has + nothing to replay. https://demo.grampsweb.org is exactly this -- + 4668 people, and GET /transactions/history/ returned X-Total-Count + 0 until someone edited it through the API. Without this check, + syncing such a server is *silently* wrong: load() succeeds, the + feed describes only the handful of edits it does know about, and + the user gets a Family Tree holding those and nothing else, with + nothing in the log to say why. + + Comparing totals rather than asking whether the feed came back + empty is what makes that case detectable at all. An empty feed is + only the extreme of it: one API edit against a history-less server + is enough to hand back a transaction, advance sync_last_time, and + make the sync look like it worked. Both counts cover the same ten + primary types (webapi_client.OBJECT_COUNT_KEYS mirrors Gramps' + own DbGeneric.get_total()), so equality is the invariant this + addon exists to maintain and a mirror that falls short of it is + provably missing data -- _full_resync_async()'s wholesale XML + export being the same recovery used for the empty-"changes" case, + and for the same reason: the history cannot describe what is + already there. + + Only run where _sync_from_server_async()'s caller asks for it -- + load(), not the 10-second poll (see POLL_INTERVAL_SECONDS): an + outdated mirror is repaired when the tree is opened, not on a + timer, so the extra GET /metadata/ costs one request per open and + a rebuild can never land in the middle of a working session. + + Skipped outright while pushes are queued (see + _queue_pending_push()): those are local edits the server has not + accepted yet, so the two counts are legitimately out of step, and + rebuilding from the server's export in that state would fight with + work still waiting to go the other way. + + A *larger* local total isn't treated as damage: an extra local + object is either something this mirror is about to push or + something the export would silently destroy, neither of which a + rebuild should decide on its own. Only a shortfall is repaired. + + The local total is a DB read (must run on runner); the server's + count is a network call (must run on io_runner) -- one extra hop + rather than reading self.dbapi from io_runner, consistent with + every other DB-read-then-network-call split in this file. """ - Rebuild the local mirror from scratch: download the server's own - current Gramps XML export and reimport it, after clearing every - local primary object first. Called by _sync_from_server() when - the transaction-history feed contains an empty-changes marker -- - by definition there is nothing in that history to replay for - whatever produced it, so the only way to recover is to fetch the - server's current state wholesale, the same way populating a - brand new local mirror already works. + if self._get_metadata("pending_pushes", default=[]): + LOG.debug("Pending pushes queued; skipping the mirror total check.") + on_done(False) + return - Deliberately reuses the stock ImportXml importer against a raw - XML export rather than reconstructing objects from the REST - /people/, /families/, ... endpoints: those return a marshalled - display schema (plain ints for GrampsType fields, no "_class" - tag), not the json_utils shape data_to_object() needs. Only the - transaction-history feed's new_data and a raw XML export share - that shape, and the whole point of this method is that the - former can't be trusted here. + def read_local_total(): + return self.get_total() - The clear-then-import pair each run inside their own batch=True - DbTxn (ImportXml's own, internally, for the import half -- see - importxml.py), both under the _pulling flag so transaction_commit() - treats them as pull-side replays rather than local bulk edits to - reconstruct and push back -- this is a purely local rebuild from - what the server already has, same as _sync_from_server()'s own - transactions. - - progress_callback, if given, only gets 0/100 markers bookending - the download+reimport -- unlike _sync_from_server()'s page-by-page - reporting, ImportXml has no internal step reporting to forward - finer-grained progress from. - """ - if progress_callback is not None: - progress_callback(0) - data = self.web_client.download_export() - with NamedTemporaryFile(suffix=".gramps", delete=False) as tmp_file: - tmp_file.write(data) - tmp_path = tmp_file.name - # Both halves below are pull-side rebuilds, not local edits -- see - # _sync_from_server()'s own note on the _pulling flag. ImportXml - # opens its own batch DbTxn internally, so this has to stay set - # across importData() too, not just the explicit DbTxn above it. - self._pulling = True - try: - with DbTxn( - _("Clear local mirror before full resync"), self, batch=True - ) as trans: - for key in set(CLASS_TO_KEY_MAP.values()): - name = KEY_TO_NAME_MAP[key] - handles = list(getattr(self, f"get_{name}_handles")()) - remove = getattr(self, f"remove_{name}") - for handle in handles: - remove(handle, trans) - importData(self, tmp_path, User()) - # importData() runs its own batch=True DbTxn internally, so - # (like _sync_from_server()'s replay) it emits nothing to - # already-open views on its own -- request_rebuild() is the - # "too much changed to describe incrementally" signal DbGeneric - # itself defines for exactly this case (one -rebuild per - # object type, telling every view to reload wholesale). - self.request_rebuild() - finally: - self._pulling = False - os.remove(tmp_path) - if progress_callback is not None: - progress_callback(100) + def on_local_total(local_total): + def fetch_server_total(): + return self.web_client.get_object_count() + + def on_server_total(server_total): + LOG.debug( + "totals: local mirror %d, server %d", local_total, server_total + ) + if local_total >= server_total: + on_done(False) + return + LOG.warning( + "Local mirror holds %d objects but the server reports " + "%d; its transaction history cannot account for the " + "difference, so the mirror is being rebuilt from a " + "full export.", + local_total, + server_total, + ) + on_done(True) + + self.io_runner.run( + fetch_server_total, + self._guarded(on_server_total), + self._guarded(on_error), + ) - def _sync_media_files(self): + self.runner.run( + read_local_total, self._guarded(on_local_total), self._guarded(on_error) + ) + + def _sync_media_files_async(self, on_done, on_error): """ Download media files missing locally, then upload local media files the server doesn't have yet -- the file-transfer half of @@ -1260,79 +3437,148 @@ def _sync_media_files(self): A single file failing to transfer (network error, a stale handle, a 409 because something else uploaded it first) is logged and skipped rather than aborting the rest of the pass -- the same - shape as _push_payload()'s error handling, just applied per file - here since there is no single all-or-nothing request covering - every file the way POST /transactions/ does for object records. + shape as _push_payload_async()'s error handling, just applied per + file here since there is no single all-or-nothing request + covering every file the way POST /transactions/ does for object + records. + + Three steps, alternating io_runner (network) and runner (DB + reads) -- the old per-file loop interleaved a DB read with a + network call on every single file, which a worker thread must + never do (self.dbapi is only safe to touch from the main thread), + so this instead resolves every handle this pass will touch to a + (handle, path) pair up front, on the main thread, before any + network I/O starts: + + 1. io_runner: ask the server which files it's missing. + 2. runner: resolve this pass's missing-local and missing-remote + media to (handle, path) pairs (_scan_and_resolve_media()) -- + the last thing to touch self.dbapi. + 3. io_runner: actually move the files (_transfer_media_files()), + pure network + local disk I/O. + + Calls on_done((downloaded, uploaded)) when finished. Caller + already owns self._syncing (see the module docstring's note on + why _push_payload_async()'s ..._async() methods never touch it + themselves). + """ + started = monotonic() + + def fetch_remote_missing(): + return self.web_client.get_missing_files() + + def on_remote_missing_fetched(remote_missing): + def scan(): + return self._scan_and_resolve_media(remote_missing) + + def on_scanned(scan_result): + missing_local, missing_remote = scan_result + + def transfer(): + return self._transfer_media_files(missing_local, missing_remote) + + def on_transferred(transfer_result): + downloaded, uploaded = transfer_result + LOG.debug( + "media: %d missing locally (%d downloaded), %d missing " + "on the server (%d uploaded), in %.2fs", + len(missing_local), + downloaded, + len(missing_remote), + uploaded, + monotonic() - started, + ) + if downloaded or uploaded: + LOG.info( + "Media file sync: downloaded %d file(s), uploaded " + "%d file(s).", + downloaded, + uploaded, + ) + on_done((downloaded, uploaded)) + + self.io_runner.run( + transfer, self._guarded(on_transferred), self._guarded(on_error) + ) + + self.runner.run(scan, self._guarded(on_scanned), self._guarded(on_error)) - Returns ``(downloaded, uploaded)`` file counts. + self.io_runner.run( + fetch_remote_missing, + self._guarded(on_remote_missing_fetched), + self._guarded(on_error), + ) + + def _scan_and_resolve_media(self, remote_missing): + """Resolve this pass's missing-local and missing-remote media to + ``(handle, path)`` pairs while still on the main thread -- DB + reads (iter_media(), get_media_from_handle()) plus a cheap + os.path.exists() check, no network. Ported from the old + _missing_local_media_handles()/_missing_remote_media_handles(), + fused with the local-object lookup the old per-file + _download_one_media_file()/_upload_one_media_file() each did + separately, so _transfer_media_files() below never needs to + touch self.dbapi again once this returns -- see + _sync_media_files_async()'s docstring for why that split exists. + + ``remote_missing`` is the server's own answer (web_client. + get_missing_files()) to "which Media objects have no uploaded + file yet" -- a list of dicts with a "handle" key. + """ + missing_local = [] + for media in self.iter_media(): + path = media_path_full(self, media.get_path()) + if not os.path.exists(path): + missing_local.append((media.handle, path)) + + missing_remote = [] + for item in remote_missing: + handle = item["handle"] + try: + obj = self.get_media_from_handle(handle) + except HandleError: + # The object was removed locally between the scan and here. + continue + path = media_path_full(self, obj.get_path()) + if os.path.exists(path): + missing_remote.append((handle, path)) + return missing_local, missing_remote + + def _transfer_media_files(self, missing_local, missing_remote): + """Actually move the files: pure network + local disk I/O, no + self.dbapi touch at all -- every handle needed was already + resolved to a path by _scan_and_resolve_media() on the main + thread, so this is safe to run entirely on io_runner. A transfer + failing (network error, a 409 because something else uploaded it + first) is logged and skipped rather than aborting the rest of + the pass. + + Logs by handle rather than gramps_id, unlike the old per-file + helpers this replaces: gramps_id would mean a get_media_from_ + handle() call here, and this method must not touch self.dbapi. """ downloaded = 0 - for handle in self._missing_local_media_handles(): - if self._download_one_media_file(handle): - downloaded += 1 + for handle, path in missing_local: + try: + self.web_client.download_media_file(handle, path) + except _CONNECTION_ERRORS as err: + LOG.warning( + "Failed to download media file for handle %s: %s", handle, err + ) + continue + downloaded += 1 + uploaded = 0 - for handle in self._missing_remote_media_handles(): - if self._upload_one_media_file(handle): - uploaded += 1 - if downloaded or uploaded: - LOG.info( - "Media file sync: downloaded %d file(s), uploaded %d file(s).", - downloaded, - uploaded, - ) + for handle, path in missing_remote: + try: + if self.web_client.upload_media_file(handle, path): + uploaded += 1 + except _CONNECTION_ERRORS as err: + LOG.warning( + "Failed to upload media file for handle %s: %s", handle, err + ) return downloaded, uploaded - def _missing_local_media_handles(self): - """Handles of local Media objects whose file isn't on disk. - Ported from GrampsWebSync's get_missing_files_local().""" - return [ - media.handle - for media in self.iter_media() - if not os.path.exists(media_path_full(self, media.get_path())) - ] - - def _missing_remote_media_handles(self): - """Handles of Media objects the server has no uploaded file for - yet. Ported from GrampsWebSync's get_missing_files_remote().""" - return [item["handle"] for item in self.web_client.get_missing_files()] - - def _download_one_media_file(self, handle): - """Download one locally-missing media file. Returns True on - success; logs and returns False on a HandleError (the object was - removed locally between the scan and here) or a connection error, - rather than aborting the rest of _sync_media_files()'s pass.""" - try: - obj = self.get_media_from_handle(handle) - except HandleError: - return False - path = media_path_full(self, obj.get_path()) - try: - self.web_client.download_media_file(handle, path) - except _CONNECTION_ERRORS as err: - LOG.warning("Failed to download media file for %s: %s", obj.gramps_id, err) - return False - return True - - def _upload_one_media_file(self, handle): - """Upload one media file the server is missing. Returns True on - success; False if the local object no longer exists, its file - isn't actually on disk either (nothing to upload), the server - already got a file for it from elsewhere in the meantime (a 409 - -- see WebApiHandler.upload_media_file()), or the upload - otherwise failed.""" - try: - obj = self.get_media_from_handle(handle) - except HandleError: - return False - path = media_path_full(self, obj.get_path()) - if not os.path.exists(path): - return False - try: - return self.web_client.upload_media_file(handle, path) - except _CONNECTION_ERRORS as err: - LOG.warning("Failed to upload media file for %s: %s", obj.gramps_id, err) - return False - def _apply_change(self, change, trans): """Replay one server change into the local mirror. Returns True if it was a recognized primary-object change (as opposed to a diff --git a/GrampsWebApiDb/mintapikeytool.py b/GrampsWebApiDb/mintapikeytool.py index f26d71cad..ec0f12634 100644 --- a/GrampsWebApiDb/mintapikeytool.py +++ b/GrampsWebApiDb/mintapikeytool.py @@ -45,7 +45,7 @@ creates (but does not open) a new, empty Family Tree using the "grampswebapidb" DATABASE plugin, named "@" for whoever the key authenticates as -- the exact name grampswebapidb.py's -_check_identity() requires, via the same CLIDbManager.create_new_db_cli() +_check_identity_async() requires, via the same CLIDbManager.create_new_db_cli() Gramps' own Family Tree Manager uses for its "New" button, just with an explicit dbid instead of the configured default backend. See README.md's "Family Tree naming" section for why that name is required. @@ -94,10 +94,10 @@ #: in the status label rather than acted on. _MINT_ERRORS = (ValueError, HTTPError, URLError, OSError) -#: Same substitution grampswebapidb.py's _check_identity() applies to a +#: Same substitution grampswebapidb.py's _check_identity_async() applies to a #: Family Tree's own name before comparing it against the server identity #: -- keep in sync if that changes. Applied here too so a tree created by -#: this button already has the name _check_identity() will accept. +#: this button already has the name _check_identity_async() will accept. _FAMILY_TREE_NAME_UNSAFE_CHARS = re.compile(r"[':<>|,;=\"\[\]\.\+\*\/\?\\]") #: The DATABASE plugin id grampswebapidb.gpr.py registers WebApiDB under. @@ -256,21 +256,20 @@ def _describe_mint_error(exc): """ if isinstance(exc, HTTPError): if exc.code in (401, 403): - return _( - "Login failed (HTTP %d): check your username and password." - ) % exc.code - return _( - "Server returned an error (HTTP %d %s): check the Server URL." - ) % (exc.code, exc.reason) + return ( + _("Login failed (HTTP %d): check your username and password.") + % exc.code + ) + return _("Server returned an error (HTTP %d %s): check the Server URL.") % ( + exc.code, + exc.reason, + ) if isinstance(exc, OSError): # Covers URLError (DNS failure, connection refused, ...) and # socket.timeout, both OSError subclasses -- the server at # that URL could not be reached at all. reason = getattr(exc, "reason", exc) - return ( - _("Could not reach the server: check the Server URL. (%s)") - % reason - ) + return _("Could not reach the server: check the Server URL. (%s)") % reason return _("Unexpected response from the server: %s") % exc def _mint_failed(self, message): diff --git a/GrampsWebApiDb/taskrunner.py b/GrampsWebApiDb/taskrunner.py new file mode 100644 index 000000000..2b40b6701 --- /dev/null +++ b/GrampsWebApiDb/taskrunner.py @@ -0,0 +1,170 @@ +# +# Gramps - a GTK+/GNOME based genealogy program +# +# Copyright (C) 2021-2026 David Straub +# Copyright (C) 2026 Douglas S. Blank +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +# + +""" +Two task runners, ported from the GrampsWebSync addon's session.py/ +adapters.py (same repo, same license -- credit to David Straub for the +original TaskRunner protocol and both implementations below). Re-added here +(rather than importing GrampsWebSync directly) so this addon has no runtime +dependency on another addon being installed -- the same reasoning +webapi_client.py's own module docstring gives for vendoring rather than +importing. + +grampswebapidb.py used to keep the GTK main thread responsive during a +blocking network call by re-entering the main loop mid-operation +(_pump_main_loop()/_guarded_pump(), see that module's docstring for the +history). That reentrancy caused two separate production crashes: switching +Family Trees while a pump-driven sync was suspended mid-operation resumed +against an already-closed sqlite connection, and an unrelated GTK +callback's exception, dispatched from a pump, propagated up through this +addon's own call stack and crashed the whole application. Both were +patched defensively, but the root cause is the reentrancy itself, not +either specific symptom. + +GLibTaskRunner and IoRunner replace that: network I/O moves onto a real +worker thread (IoRunner) so the main thread is never blocked waiting on it, +and DB-touching work stays on the main thread (GLibTaskRunner) since the +sqlite backend is usable only from the thread that created the connection +(no check_same_thread=False). Nothing in grampswebapidb.py needs to +re-enter the main loop anymore -- see that module's docstring for the one +remaining exception (load()'s bootstrap sync). +""" + +# ------------------------------------------------------------------------- +# +# Standard python modules +# +# ------------------------------------------------------------------------- +from __future__ import annotations + +import threading +from collections.abc import Callable +from typing import Any, Protocol + +# ------------------------------------------------------------------------- +# +# GTK/Gnome modules +# +# ------------------------------------------------------------------------- +from gi.repository import GLib + + +class TaskRunner(Protocol): + """Runs a potentially slow callable and reports the outcome back.""" + + def run( + self, + func: Callable[[], Any], + on_success: Callable[[Any], None], + on_error: Callable[[BaseException], None], + ) -> None: ... + + def post(self, func: Callable[[], None]) -> None: ... + + +def _post_to_main_loop(func: Callable[[], None]) -> None: + """Schedule ``func`` to run once on the GTK main loop.""" + + def once() -> bool: + func() + return False + + GLib.idle_add(once) + + +class GLibTaskRunner: + """Defers a task to the GTK main loop. + + For steps that touch a Gramps database. Those must not run on a worker + thread: the sqlite backend passes no ``check_same_thread=False`` and + shares one cursor, so a connection is usable only from the thread that + created it. + + :func:`GLib.idle_add` keeps the work on the main loop while still + letting the caller return immediately. + """ + + def run( + self, + func: Callable[[], Any], + on_success: Callable[[Any], None], + on_error: Callable[[BaseException], None], + ) -> None: + """Schedule ``func`` on the main loop and dispatch the outcome there.""" + + def once() -> bool: + try: + result = func() + except BaseException as exc: # noqa: BLE001 -- reported, not swallowed + on_error(exc) + else: + on_success(result) + return False # run once + + GLib.idle_add(once) + + def post(self, func: Callable[[], None]) -> None: + """Run ``func`` on the main loop.""" + _post_to_main_loop(func) + + +class IoRunner: + """Runs a task on a worker thread, dispatching the outcome on the main loop. + + For steps that only do network I/O (webapi_client calls) -- never for + anything that reads or writes ``self.dbapi``. Network waits are where a + sync/push spends most of its wall-clock time and the only place it can + block indefinitely, so moving them off the main loop is what keeps the + window responsive without needing to re-enter the main loop to fake it. + Callbacks are marshalled back through :func:`GLib.idle_add`, so + listeners still run on the thread that owns GTK. + """ + + def run( + self, + func: Callable[[], Any], + on_success: Callable[[Any], None], + on_error: Callable[[BaseException], None], + ) -> None: + """Run ``func`` on a worker thread; call back on the main loop.""" + + def work() -> None: + try: + result = func() + except BaseException as exc: # noqa: BLE001 + # Handed straight on: `except ... as exc` unbinds the name + # when the block exits, and the callback runs later than that. + self._dispatch(on_error, exc) + else: + self._dispatch(on_success, result) + + # daemon=True: a worker abandoned by close() (tree closed while a + # push/sync is in flight) finishes quietly in the background and + # does not block process exit. + threading.Thread(target=work, daemon=True, name="grampswebapidb-io").start() + + @staticmethod + def _dispatch(callback: Callable[[Any], None], value: Any) -> None: + _post_to_main_loop(lambda: callback(value)) + + def post(self, func: Callable[[], None]) -> None: + """Run ``func`` on the main loop.""" + _post_to_main_loop(func) diff --git a/GrampsWebApiDb/tests/fakes.py b/GrampsWebApiDb/tests/fakes.py new file mode 100644 index 000000000..db68df6fb --- /dev/null +++ b/GrampsWebApiDb/tests/fakes.py @@ -0,0 +1,87 @@ +# +# Gramps - a GTK+/GNOME based genealogy program +# +# Copyright (C) 2021-2026 David Straub +# Copyright (C) 2026 Douglas S. Blank +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +# + +""" +Test doubles for taskrunner.TaskRunner. InlineTaskRunner is ported from the +GrampsWebSync addon's tests/fakes.py (same repo, same license -- credit to +David Straub for the original). +""" + +# ------------------------------------------------------------------------- +# +# Standard python modules +# +# ------------------------------------------------------------------------- +from collections.abc import Callable +from typing import Any + + +class InlineTaskRunner: + """Runs each task synchronously on the calling thread. + + By the time ``run`` returns, the step and its completion callback have + both finished. Standing in for both ``self.runner`` and + ``self.io_runner`` keeps a test single-threaded and its assertions + deterministic: an async chain built from ``..._async()`` methods + resolves entirely within the one call that kicks it off, with no real + thread and no real GLib main loop involved. + """ + + def run( + self, + func: Callable[[], Any], + on_success: Callable[[Any], None], + on_error: Callable[[BaseException], None], + ) -> None: + """Execute ``func`` and dispatch to the appropriate callback.""" + try: + result = func() + except BaseException as exc: # noqa: BLE001 -- mirrors the real runner + on_error(exc) + else: + on_success(result) + + def post(self, func: Callable[[], None]) -> None: + """Run ``func`` immediately; there is no other thread to marshal from.""" + func() + + +class FakeHandleDb: + """Minimal stand-in for the ``db`` argument grampswebapidb.py's + _prune_dangling_references() (called from _merge_or_overwrite()) uses + to check whether a Tag/Note/Citation handle still exists: answers + has_tag_handle()/has_note_handle()/has_citation_handle() from a + settable set of "known" handles. + + Defaults to reporting every handle as known (``known_handles=None``), + so a test that isn't exercising the pruning itself can pass one + without also having to enumerate every handle its test objects use. + """ + + def __init__(self, known_handles=None): + self.known_handles = known_handles + + def _has_handle(self, handle): + return True if self.known_handles is None else handle in self.known_handles + + has_tag_handle = _has_handle + has_note_handle = _has_handle + has_citation_handle = _has_handle diff --git a/GrampsWebApiDb/tests/test_grampswebapidb.py b/GrampsWebApiDb/tests/test_grampswebapidb.py index 9f7b40b17..7223ded5d 100644 --- a/GrampsWebApiDb/tests/test_grampswebapidb.py +++ b/GrampsWebApiDb/tests/test_grampswebapidb.py @@ -43,8 +43,15 @@ # Standard python modules # # ------------------------------------------------------------------------- +import copy +import io +import json import os +import shutil import sys +import tempfile +import threading +import time import unittest from urllib.error import HTTPError, URLError from unittest import mock @@ -65,17 +72,32 @@ except ImportError as _err: raise unittest.SkipTest("gramps package not available: %s" % _err) +from gramps.gen.db import DbTxn from gramps.gen.db.dbconst import REFERENCE_KEY, TXNADD, TXNDEL, TXNUPD from gramps.gen.db.exceptions import DbConnectionError -from gramps.gen.lib import Person, Tag -from gramps.gen.lib.json_utils import object_to_data, remove_object +from gramps.gen.db.utils import make_database +from gramps.gen.lib import ( + Attribute, + Event, + EventRef, + EventRoleType, + EventType, + Person, + Tag, +) +from gramps.gen.lib.json_utils import data_to_object, object_to_data, remove_object from GrampsWebApiDb import grampswebapidb from GrampsWebApiDb.grampswebapidb import ( + GRANULAR_REBUILD_MAX_CHANGES, WebApiDB, WebApiPushConflict, + _diff_snapshots, + _restore_birth_death_indices, + _snapshot_birth_death_indices, transaction_to_json, ) +from GrampsWebApiDb.tests.fakes import FakeHandleDb, InlineTaskRunner # grampswebapidb.py imports webapi_client with a bare `from webapi_client # import ...` (see CLAUDE.md Testing conventions -- this addon has no @@ -106,9 +128,10 @@ class FakeTransaction: get_recnos()/get_record(). Avoids needing a real commitdb/pickle round trip just to test the flattening logic.""" - def __init__(self, records): + def __init__(self, records, description=""): # records: list of (key, action, handle, old_data, new_data) self._records = records + self._description = description def get_recnos(self, reverse=False): idx = range(len(self._records)) @@ -117,10 +140,53 @@ def get_recnos(self, reverse=False): def get_record(self, recno): return self._records[recno] + def get_description(self): + return self._description + def new_instance(): """A WebApiDB that never touched a real SQLite file or server.""" - return WebApiDB.__new__(WebApiDB) + db = WebApiDB.__new__(WebApiDB) + db.runner = InlineTaskRunner() + db.io_runner = InlineTaskRunner() + return db + + +def stub_async_done(result=None): + """A mock.patch.object(obj, "some_async_method", side_effect=...) stub + for an ``..._async(on_done, on_error, ...)`` method (called either + positionally or by keyword -- both conventions are used across this + file) that immediately calls ``on_done(result)`` -- for tests that + only need the top-level operation to "succeed" without exercising the + real chain underneath it.""" + + def stub(*args, **kwargs): + on_done = kwargs.get("on_done", args[0] if args else None) + on_done(result) + + return stub + + +def stub_async_error(exc): + """Same as stub_async_done(), but for the on_error path.""" + + def stub(*args, **kwargs): + on_error = kwargs.get("on_error", args[1] if len(args) > 1 else None) + on_error(exc) + + return stub + + +def run_check(db, method_name, **kwargs): + """Drive one of the _check_..._async() methods to completion via the + same _run_async_to_completion() production code load() itself uses -- + with InlineTaskRunner wired into new_instance(), the chain resolves + synchronously, so this needs no real main loop. Returns on_done's + value, or raises whatever on_error received.""" + method = getattr(db, method_name) + return db._run_async_to_completion( + lambda on_done, on_error: method(on_done, on_error, **kwargs) + ) # ------------------------------------------------------------------------- @@ -307,11 +373,12 @@ class TestSyncFromServer(unittest.TestCase): def setUp(self): self.db = new_instance() self.db.web_client = mock.MagicMock() - # _sync_from_server() now emits change signals per page (see - # TestEmitChangeSignals for that logic in isolation) -- emit() - # itself needs Callback.__init__'s instance state, which - # new_instance()'s bare __new__() never runs, so it's stubbed here - # the same way commit_person/remove_person are stubbed elsewhere. + # _sync_from_server_async() now emits change signals per page + # (see TestEmitChangeSignals for that logic in isolation) -- + # emit() itself needs Callback.__init__'s instance state, which + # new_instance()'s bare __new__() never runs, so it's stubbed + # here the same way commit_person/remove_person are stubbed + # elsewhere. self.db.emit = mock.MagicMock() self.metadata = {} self.db._get_metadata = lambda key, default=0: self.metadata.get(key, default) @@ -326,7 +393,40 @@ def setUp(self): # "changes": [] purely as pagination/timestamp filler, not to # exercise that fallback -- stub it out so those tests keep # testing what they always tested. - self.db._full_resync = mock.MagicMock() + self.db._full_resync_async = mock.MagicMock(side_effect=stub_async_done(None)) + + def _sync(self, progress_callback=None, verify_totals=False): + """Drive _sync_from_server_async() to completion synchronously + (via InlineTaskRunner -- see new_instance()), returning what + on_done received or raising what on_error received -- the same + contract the old direct _sync_from_server() call had.""" + result = {} + self.db._sync_from_server_async( + on_done=lambda applied: result.update(done=applied), + on_error=lambda exc: result.update(error=exc), + progress_callback=progress_callback, + verify_totals=verify_totals, + ) + if "error" in result: + raise result["error"] + return result.get("done") + + def test_does_not_touch_syncing_itself(self): + # Caller (_start_push()/_poll_tick()/load()'s wait-adapter) + # already owns it -- see _sync_from_server_async()'s docstring. + self.db.web_client.get_transaction_history.return_value = ([], 0) + self.db._syncing = True + self._sync() + self.assertTrue(self.db._syncing) + + def test_error_is_delivered_via_on_error_not_raised_directly(self): + # apply_page()/fetch() run as runner.run()/io_runner.run() steps, + # so an exception there is caught and dispatched to on_error -- + # _sync() above re-raises it only so existing assertRaises-style + # tests keep working unchanged. + self.db.web_client.get_transaction_history.side_effect = OSError("down") + with self.assertRaises(OSError): + self._sync() def test_stops_after_short_page(self): change = {"obj_class": "Person", "trans_type": TXNADD, "obj_handle": "H1"} @@ -335,7 +435,7 @@ def test_stops_after_short_page(self): 1, ) with mock.patch.object(self.db, "_apply_change", return_value=True) as apply: - applied = self.db._sync_from_server() + applied = self._sync() self.assertEqual(applied, 1) apply.assert_called_once_with(change, mock.ANY) self.db.web_client.get_transaction_history.assert_called_once() @@ -350,7 +450,7 @@ def test_pagination_continues_on_full_page(self): (full_page, len(full_page) + 1), (short_page, 1), ] - applied = self.db._sync_from_server() + applied = self._sync() self.assertEqual(applied, 0) self.assertEqual(self.db.web_client.get_transaction_history.call_count, 2) calls = self.db.web_client.get_transaction_history.call_args_list @@ -360,7 +460,7 @@ def test_pagination_continues_on_full_page(self): def test_no_transactions_leaves_sync_time_unchanged(self): self.metadata["sync_last_time"] = 42.0 self.db.web_client.get_transaction_history.return_value = ([], 0) - applied = self.db._sync_from_server() + applied = self._sync() self.assertEqual(applied, 0) self.assertEqual(self.metadata["sync_last_time"], 42.0) @@ -371,7 +471,7 @@ def test_sync_last_time_advances_to_max_timestamp_seen(self): {"timestamp": 20.0, "changes": []}, ] self.db.web_client.get_transaction_history.return_value = (page, 3) - self.db._sync_from_server() + self._sync() self.assertEqual(self.metadata["sync_last_time"], 30.0) def test_unrecognized_changes_are_not_counted(self): @@ -384,7 +484,7 @@ def test_unrecognized_changes_are_not_counted(self): } ] self.db.web_client.get_transaction_history.return_value = (page, 1) - applied = self.db._sync_from_server() + applied = self._sync() self.assertEqual(applied, 0) def test_emits_a_signal_per_applied_change(self): @@ -394,7 +494,7 @@ def test_emits_a_signal_per_applied_change(self): 1, ) with mock.patch.object(self.db, "_apply_change", return_value=True): - self.db._sync_from_server() + self._sync() self.db.emit.assert_called_once_with("person-add", (["H1"],)) def test_repeated_changes_to_one_handle_collapse_to_the_last(self): @@ -416,7 +516,7 @@ def test_repeated_changes_to_one_handle_collapse_to_the_last(self): ] self.db.web_client.get_transaction_history.return_value = (page, 2) with mock.patch.object(self.db, "_apply_change", return_value=True): - self.db._sync_from_server() + self._sync() self.db.emit.assert_called_once_with("person-delete", (["H1"],)) def test_unrecognized_changes_emit_no_signal(self): @@ -429,7 +529,7 @@ def test_unrecognized_changes_emit_no_signal(self): } ] self.db.web_client.get_transaction_history.return_value = (page, 1) - self.db._sync_from_server() + self._sync() self.db.emit.assert_not_called() def test_no_progress_callback_by_default(self): @@ -438,13 +538,13 @@ def test_no_progress_callback_by_default(self): # trying to call None. page = [{"timestamp": 1.0, "changes": []}] self.db.web_client.get_transaction_history.return_value = (page, 1) - self.db._sync_from_server() # must not raise + self._sync() # must not raise def test_progress_reported_as_percent_of_total(self): page = [{"timestamp": 1.0, "changes": []}] * 25 self.db.web_client.get_transaction_history.return_value = (page, 100) progress = mock.MagicMock() - self.db._sync_from_server(progress_callback=progress) + self._sync(progress_callback=progress) progress.assert_called_once_with(25) def test_progress_accumulates_and_caps_at_100_across_pages(self): @@ -459,7 +559,7 @@ def test_progress_accumulates_and_caps_at_100_across_pages(self): (short_page, total), ] progress = mock.MagicMock() - self.db._sync_from_server(progress_callback=progress) + self._sync(progress_callback=progress) self.assertEqual([call.args[0] for call in progress.call_args_list], [100, 100]) def test_no_progress_call_when_total_is_zero(self): @@ -469,15 +569,17 @@ def test_no_progress_call_when_total_is_zero(self): page = [{"timestamp": 1.0, "changes": []}] self.db.web_client.get_transaction_history.return_value = (page, 0) progress = mock.MagicMock() - self.db._sync_from_server(progress_callback=progress) + self._sync(progress_callback=progress) progress.assert_not_called() def test_progress_callback_passed_through_to_full_resync(self): page = [{"timestamp": 1.0, "changes": []}] self.db.web_client.get_transaction_history.return_value = (page, 1) progress = mock.MagicMock() - self.db._sync_from_server(progress_callback=progress) - self.db._full_resync.assert_called_once_with(progress_callback=progress) + self._sync(progress_callback=progress) + self.db._full_resync_async.assert_called_once_with( + mock.ANY, mock.ANY, progress_callback=progress + ) def test_pulling_flag_is_set_during_replay_and_cleared_after(self): # _pulling tells transaction_begin() this batch DbTxn is a @@ -497,7 +599,7 @@ def check_flag(change, trans): return True with mock.patch.object(self.db, "_apply_change", side_effect=check_flag): - self.db._sync_from_server() + self._sync() self.assertTrue(seen["during"]) self.assertFalse(self.db._pulling) @@ -511,7 +613,7 @@ def test_pulling_flag_is_cleared_even_if_replay_raises(self): self.db, "_apply_change", side_effect=RuntimeError("boom") ): with self.assertRaises(RuntimeError): - self.db._sync_from_server() + self._sync() self.assertFalse(self.db._pulling) @@ -536,24 +638,39 @@ def setUp(self): self.db._set_metadata = ( lambda key, value, use_txn=True: self.metadata.__setitem__(key, value) ) - self.db._full_resync = mock.MagicMock() + self.db._full_resync_async = mock.MagicMock(side_effect=stub_async_done(None)) self.patcher = mock.patch.object(grampswebapidb, "DbTxn", FakeDbTxn) self.patcher.start() self.addCleanup(self.patcher.stop) + def _sync(self, progress_callback=None, verify_totals=False): + """See TestSyncFromServer._sync().""" + result = {} + self.db._sync_from_server_async( + on_done=lambda applied: result.update(done=applied), + on_error=lambda exc: result.update(error=exc), + progress_callback=progress_callback, + verify_totals=verify_totals, + ) + if "error" in result: + raise result["error"] + return result.get("done") + def test_empty_changes_transaction_triggers_full_resync(self): page = [{"timestamp": 1.0, "changes": []}] self.db.web_client.get_transaction_history.return_value = (page, 1) - self.db._sync_from_server() - self.db._full_resync.assert_called_once_with(progress_callback=None) + self._sync() + self.db._full_resync_async.assert_called_once_with( + mock.ANY, mock.ANY, progress_callback=None + ) def test_normal_transactions_do_not_trigger_full_resync(self): change = {"obj_class": "Person", "trans_type": TXNADD, "obj_handle": "H1"} page = [{"timestamp": 1.0, "changes": [change]}] self.db.web_client.get_transaction_history.return_value = (page, 1) with mock.patch.object(self.db, "_apply_change", return_value=True): - self.db._sync_from_server() - self.db._full_resync.assert_not_called() + self._sync() + self.db._full_resync_async.assert_not_called() def test_marker_alongside_real_changes_still_applies_the_real_ones(self): # A marker transaction doesn't block replaying whatever *is* @@ -566,16 +683,105 @@ def test_marker_alongside_real_changes_still_applies_the_real_ones(self): ] self.db.web_client.get_transaction_history.return_value = (page, 2) with mock.patch.object(self.db, "_apply_change", return_value=True): - applied = self.db._sync_from_server() + applied = self._sync() self.assertEqual(applied, 1) - self.db._full_resync.assert_called_once_with(progress_callback=None) + self.db._full_resync_async.assert_called_once_with( + mock.ANY, mock.ANY, progress_callback=None + ) def test_marker_still_advances_sync_last_time(self): page = [{"timestamp": 42.0, "changes": []}] self.db.web_client.get_transaction_history.return_value = (page, 1) - self.db._sync_from_server() + self._sync() self.assertEqual(self.metadata["sync_last_time"], 42.0) + def test_short_mirror_triggers_full_resync(self): + # A server whose tree was populated without gramps-web-api + # recording history (demo.grampsweb.org: 4668 people, a history + # holding only the edits made through the API) would otherwise + # sync to a mirror holding just those, with nothing logged. + self.db.web_client.get_transaction_history.return_value = ([], 0) + self.db.web_client.get_object_count.return_value = 26541 + with mock.patch.object(self.db, "get_total", return_value=1): + with self.assertLogs(grampswebapidb.LOG, level="WARNING"): + self._sync(verify_totals=True) + self.db._full_resync_async.assert_called_once_with( + mock.ANY, mock.ANY, progress_callback=None + ) + + def test_a_replayed_feed_that_still_falls_short_triggers_full_resync(self): + # The case that made the empty-feed-only version of this check + # useless: one API edit against a history-less server hands back a + # transaction and advances the cursor, so the sync looks fine. + change = {"obj_class": "Person", "trans_type": TXNADD, "obj_handle": "H1"} + page = [{"timestamp": 1786645046.3, "changes": [change]}] + self.db.web_client.get_transaction_history.return_value = (page, 1) + self.db.web_client.get_object_count.return_value = 26541 + with mock.patch.object(self.db, "_apply_change", return_value=True): + with mock.patch.object(self.db, "get_total", return_value=1): + with self.assertLogs(grampswebapidb.LOG, level="WARNING"): + applied = self._sync(verify_totals=True) + self.assertEqual(applied, 1) + self.assertEqual(self.metadata["sync_last_time"], 1786645046.3) + self.db._full_resync_async.assert_called_once_with( + mock.ANY, mock.ANY, progress_callback=None + ) + + def test_matching_totals_do_not_trigger_full_resync(self): + self.db.web_client.get_transaction_history.return_value = ([], 0) + self.db.web_client.get_object_count.return_value = 26541 + with mock.patch.object(self.db, "get_total", return_value=26541): + self._sync(verify_totals=True) + self.db._full_resync_async.assert_not_called() + + def test_a_larger_local_total_is_left_alone(self): + # An extra local object is either about to be pushed or something + # the export would destroy -- not a shortfall to repair. + self.db.web_client.get_transaction_history.return_value = ([], 0) + self.db.web_client.get_object_count.return_value = 10 + with mock.patch.object(self.db, "get_total", return_value=11): + self._sync(verify_totals=True) + self.db._full_resync_async.assert_not_called() + + def test_queued_pushes_suppress_the_total_check(self): + # Local edits the server hasn't accepted yet: the counts are + # legitimately out of step, and a rebuild would fight with work + # still waiting to go the other way. _sync_from_server_async() + # drains the queue on the way in, so what's left here is what + # the flush couldn't place (a 429, a 5xx) -- hence the stubbed + # flush. + self.metadata["pending_pushes"] = [{"payload": [], "undo": False}] + self.db.web_client.get_transaction_history.return_value = ([], 0) + with mock.patch.object( + self.db, "_flush_pending_pushes_async", side_effect=stub_async_done(None) + ), mock.patch.object(self.db, "get_total", return_value=0) as get_total: + self._sync(verify_totals=True) + self.db._full_resync_async.assert_not_called() + get_total.assert_not_called() + self.db.web_client.get_object_count.assert_not_called() + + def test_poll_syncs_do_not_check_totals(self): + # Only load() asks for the check -- a rebuild must never land in + # the middle of a working session, and the poll shouldn't spend a + # request per tick to find that out. + self.db.web_client.get_transaction_history.return_value = ([], 0) + with mock.patch.object(self.db, "get_total", return_value=0) as get_total: + self._sync() + self.db._full_resync_async.assert_not_called() + get_total.assert_not_called() + self.db.web_client.get_object_count.assert_not_called() + + def test_total_check_forwards_the_progress_callback(self): + callback = mock.MagicMock() + self.db.web_client.get_transaction_history.return_value = ([], 0) + self.db.web_client.get_object_count.return_value = 1 + with mock.patch.object(self.db, "get_total", return_value=0): + with self.assertLogs(grampswebapidb.LOG, level="WARNING"): + self._sync(progress_callback=callback, verify_totals=True) + self.db._full_resync_async.assert_called_once_with( + mock.ANY, mock.ANY, progress_callback=callback + ) + # ------------------------------------------------------------------------- # @@ -588,10 +794,38 @@ def setUp(self): self.db.web_client = mock.MagicMock() self.db.web_client.download_export.return_value = b"fake gramps xml bytes" self.db.emit = mock.MagicMock() # see TestSyncFromServer.setUp's note + # _full_resync_async() reports the rebuilt total at DEBUG; there's + # no real dbapi connection behind these stubs to count. + self.db.get_total = mock.MagicMock(return_value=0) + self.db._set_metadata = mock.MagicMock() + self.db._run_id = 0 + # _describe_resync_to_views()'s before/after diff calls + # _snapshot_all_objects(), which reads real storage via + # _iter_raw_data() -- there's none behind these stubs either, so + # this defaults every test in this class to an empty before/after + # (no signal at all -- see _describe_resync_to_views()'s own + # comment on why that's correct when nothing genuinely changed). + # Tests that care what got signaled override this themselves. + self.db._iter_raw_data = mock.MagicMock(return_value=iter([])) self.patcher = mock.patch.object(grampswebapidb, "DbTxn", FakeDbTxn) self.patcher.start() self.addCleanup(self.patcher.stop) + def _resync(self, progress_callback=None): + """Drive _full_resync_async() to completion synchronously (via + InlineTaskRunner -- see new_instance()), the way these + unit-level tests want the old direct _full_resync() call to + behave: raises whatever on_error received.""" + result = {} + self.db._full_resync_async( + on_done=lambda value: result.update(done=value), + on_error=lambda exc: result.update(error=exc), + progress_callback=progress_callback, + ) + if "error" in result: + raise result["error"] + return result.get("done") + def test_downloads_export_wipes_and_reimports(self): # get__handles/remove_ for every primary type, plus # importData itself, are all faked out -- this test is only @@ -603,6 +837,40 @@ def test_downloads_export_wipes_and_reimports(self): continue setattr(self.db, f"get_{name}_handles", mock.MagicMock(return_value=["H1"])) setattr(self.db, f"remove_{name}", mock.MagicMock()) + # _snapshot_birth_death_indices()/_restore_birth_death_indices() + # (see grampswebapidb.py) look up "H1" as a Person via these two -- + # a fake with unchanged fields keeps the restore step a no-op, same + # as everything else this wiring-only test fakes out. + fake_person = mock.MagicMock() + fake_person.birth_ref_index = -1 + fake_person.death_ref_index = -1 + fake_person.get_event_ref_list.return_value = [] + self.db.get_person_from_handle = mock.MagicMock(return_value=fake_person) + self.db.has_person_handle = mock.MagicMock(return_value=True) + + # Enough net "adds" (see _diff_snapshots()) to land above + # GRANULAR_REBUILD_MAX_CHANGES, so this reimport is the + # legitimately-everything-changed case _describe_resync_to_views() + # still uses request_rebuild() for -- matching what wiping and + # reimporting the *entire* mirror actually represents. The first + # call per type is the pre-clear "before" snapshot (empty, nothing + # populated it); every call after that is "after" -- see + # _snapshot_all_objects()'s two callers. + calls = {"n": 0} + per_type_after = ( + GRANULAR_REBUILD_MAX_CHANGES // len(grampswebapidb.CLASS_TO_KEY_MAP) + 10 + ) + + def fake_iter_raw_data(key): + calls["n"] += 1 + if calls["n"] <= len(grampswebapidb.CLASS_TO_KEY_MAP): + return iter([]) + return iter( + (f"H{calls['n']}-{i}", {"gramps_id": f"G{i}"}) + for i in range(per_type_after) + ) + + self.db._iter_raw_data = mock.MagicMock(side_effect=fake_iter_raw_data) captured_path = {} @@ -613,8 +881,10 @@ def fake_import_data(database, filename, user): self.assertEqual(f.read(), b"fake gramps xml bytes") with mock.patch.object(grampswebapidb, "importData", fake_import_data): - self.db._full_resync() + self._resync() + # No on_chunk anymore -- nothing to pump for once this runs on a + # worker thread. See _full_resync_async()'s own docstring. self.db.web_client.download_export.assert_called_once_with() for key, name in grampswebapidb.KEY_TO_NAME_MAP.items(): if key not in grampswebapidb.CLASS_TO_KEY_MAP.values(): @@ -622,18 +892,21 @@ def fake_import_data(database, filename, user): getattr(self.db, f"remove_{name}").assert_called_once_with("H1", mock.ANY) # The temp file is cleaned up after import, not left behind. self.assertFalse(os.path.exists(captured_path["path"])) - # A successful reimport can't be described as specific add/update/ - # delete signals, so every view is told to reload wholesale instead - # -- see request_rebuild() in gramps.gen.db.generic. + # This many net changes is "everything changed" territory, where + # request_rebuild() -- one signal per type -- stays cheaper for + # every view than replaying an individual signal per object; see + # _describe_resync_to_views()'s own comment. TestDescribeResync + # ToViews covers the far more common small-diff case, which uses + # granular per-object signals instead. emitted = [call.args[0] for call in self.db.emit.call_args_list] self.assertIn("person-rebuild", emitted) self.assertIn("family-rebuild", emitted) def test_failed_import_does_not_trigger_rebuild(self): - # request_rebuild() sits after importData() in _full_resync(), not - # in a finally -- a reimport that raised partway through left the - # mirror in an unknown state, which is not something to tell every - # view "reload, this is now correct" about. + # request_rebuild() sits after importData() in rebuild(), not in + # a finally -- a reimport that raised partway through left the + # mirror in an unknown state, which is not something to tell + # every view "reload, this is now correct" about. for key, name in grampswebapidb.KEY_TO_NAME_MAP.items(): if key not in grampswebapidb.CLASS_TO_KEY_MAP.values(): continue @@ -645,7 +918,7 @@ def failing_import_data(database, filename, user): with mock.patch.object(grampswebapidb, "importData", failing_import_data): with self.assertRaises(RuntimeError): - self.db._full_resync() + self._resync() self.db.emit.assert_not_called() @@ -668,7 +941,7 @@ def check_flag(database, filename, user): seen["during_import"] = self.db._pulling with mock.patch.object(grampswebapidb, "importData", check_flag): - self.db._full_resync() + self._resync() self.assertTrue(seen["during_import"]) self.assertFalse(self.db._pulling) @@ -684,7 +957,7 @@ def failing_import_data(database, filename, user): with mock.patch.object(grampswebapidb, "importData", failing_import_data): with self.assertRaises(RuntimeError): - self.db._full_resync() + self._resync() self.assertFalse(self.db._pulling) def test_no_progress_callback_by_default(self): @@ -694,7 +967,7 @@ def test_no_progress_callback_by_default(self): setattr(self.db, f"get_{name}_handles", mock.MagicMock(return_value=[])) setattr(self.db, f"remove_{name}", mock.MagicMock()) with mock.patch.object(grampswebapidb, "importData"): - self.db._full_resync() # must not raise + self._resync() # must not raise def test_progress_bookends_the_download_and_reimport(self): for key, name in grampswebapidb.KEY_TO_NAME_MAP.items(): @@ -704,7 +977,7 @@ def test_progress_bookends_the_download_and_reimport(self): setattr(self.db, f"remove_{name}", mock.MagicMock()) progress = mock.MagicMock() with mock.patch.object(grampswebapidb, "importData"): - self.db._full_resync(progress_callback=progress) + self._resync(progress_callback=progress) self.assertEqual([call.args[0] for call in progress.call_args_list], [0, 100]) def test_progress_not_completed_if_import_fails(self): @@ -722,9 +995,326 @@ def failing_import_data(database, filename, user): progress = mock.MagicMock() with mock.patch.object(grampswebapidb, "importData", failing_import_data): with self.assertRaises(RuntimeError): - self.db._full_resync(progress_callback=progress) + self._resync(progress_callback=progress) progress.assert_called_once_with(0) + def test_uses_import_progress_user_when_progress_callback_given(self): + # Real progress during the reimport comes from importData()'s own + # internal reporting, forwarded via _import_progress_user() -- + # see TestImportProgressUser for that function's own behavior. + for key, name in grampswebapidb.KEY_TO_NAME_MAP.items(): + if key not in grampswebapidb.CLASS_TO_KEY_MAP.values(): + continue + setattr(self.db, f"get_{name}_handles", mock.MagicMock(return_value=[])) + setattr(self.db, f"remove_{name}", mock.MagicMock()) + progress = mock.MagicMock() + captured = {} + + def fake_import_data(database, filename, user): + captured["user"] = user + + with mock.patch.object( + grampswebapidb, "importData", fake_import_data + ), mock.patch.object( + grampswebapidb, + "_import_progress_user", + return_value=mock.sentinel.import_user, + ) as import_progress_user: + self._resync(progress_callback=progress) + import_progress_user.assert_called_once_with(progress) + self.assertIs(captured["user"], mock.sentinel.import_user) + + def test_uses_the_plain_inert_user_when_no_progress_callback(self): + # A conflict-triggered background resync passes no + # progress_callback -- must not attempt to build a GUI User at + # all (see _import_progress_user()'s own docstring on why that + # matters: it's the thing that would pull in Gtk). + for key, name in grampswebapidb.KEY_TO_NAME_MAP.items(): + if key not in grampswebapidb.CLASS_TO_KEY_MAP.values(): + continue + setattr(self.db, f"get_{name}_handles", mock.MagicMock(return_value=[])) + setattr(self.db, f"remove_{name}", mock.MagicMock()) + captured = {} + + def fake_import_data(database, filename, user): + captured["user"] = user + + with mock.patch.object( + grampswebapidb, "importData", fake_import_data + ), mock.patch.object( + grampswebapidb, "_import_progress_user" + ) as import_progress_user: + self._resync() # progress_callback defaults to None + import_progress_user.assert_not_called() + self.assertIsInstance(captured["user"], grampswebapidb.User) + + def test_advances_sync_last_time_past_the_stuck_cursor(self): + # A totals-shortfall rebuild (_mirror_is_short_of_the_server_async()) + # can be triggered by a history feed whose very first page came + # back empty, which leaves sync_last_time at whatever it started + # as (0 for a brand new mirror) instead of anywhere near "now". + # Left alone, a push conflict's own "resync from the server, then + # retry" recovery reuses that same stuck cursor and so can never + # actually pick up what changed -- see the module's + # _full_resync_async() docstring. Confirm the rebuild now leaves + # a fresh, roughly-"now" cursor behind instead. + for key, name in grampswebapidb.KEY_TO_NAME_MAP.items(): + if key not in grampswebapidb.CLASS_TO_KEY_MAP.values(): + continue + setattr(self.db, f"get_{name}_handles", mock.MagicMock(return_value=[])) + setattr(self.db, f"remove_{name}", mock.MagicMock()) + before = time.time() + with mock.patch.object(grampswebapidb, "importData"): + self._resync() + after = time.time() + self.db._set_metadata.assert_called_once_with("sync_last_time", mock.ANY) + cutoff = self.db._set_metadata.call_args.args[1] + self.assertGreaterEqual(cutoff, before) + self.assertLessEqual(cutoff, after) + + def test_does_not_advance_sync_last_time_if_the_reimport_raises(self): + # A rebuild that failed partway through left the mirror in an + # unknown state (same reasoning as test_failed_import_does_not_ + # trigger_rebuild() above) -- advancing the cursor anyway would + # tell the next sync "everything up to here is accounted for" for + # a mirror that plainly isn't. + for key, name in grampswebapidb.KEY_TO_NAME_MAP.items(): + if key not in grampswebapidb.CLASS_TO_KEY_MAP.values(): + continue + setattr(self.db, f"get_{name}_handles", mock.MagicMock(return_value=[])) + setattr(self.db, f"remove_{name}", mock.MagicMock()) + + def failing_import_data(database, filename, user): + raise RuntimeError("boom") + + with mock.patch.object(grampswebapidb, "importData", failing_import_data): + with self.assertRaises(RuntimeError): + self._resync() + self.db._set_metadata.assert_not_called() + + def test_tree_closed_between_download_and_rebuild_cleans_up_and_skips_dbapi(self): + # The narrow window this addon must not crash in: the export + # finished downloading, but close() ran before rebuild() actually + # started -- self.dbapi may already be gone. rebuild() must + # clean up the temp file (nothing else will) without touching it. + for key, name in grampswebapidb.KEY_TO_NAME_MAP.items(): + if key not in grampswebapidb.CLASS_TO_KEY_MAP.values(): + continue + setattr( + self.db, + f"get_{name}_handles", + mock.MagicMock( + side_effect=AssertionError( + f"get_{name}_handles() touched self.dbapi after close()" + ) + ), + ) + + def close_mid_download(): + self.db._run_id += 1 + return b"fake gramps xml bytes" + + self.db.web_client.download_export.side_effect = close_mid_download + captured_path = {} + + real_named_temp_file = grampswebapidb.NamedTemporaryFile + + def capture_temp_file(*args, **kwargs): + tmp = real_named_temp_file(*args, **kwargs) + captured_path["path"] = tmp.name + return tmp + + with mock.patch.object( + grampswebapidb, "NamedTemporaryFile", capture_temp_file + ), mock.patch.object(grampswebapidb, "importData") as import_data: + self._resync() + import_data.assert_not_called() + self.assertFalse(os.path.exists(captured_path["path"])) + self.db._set_metadata.assert_not_called() + + +# ------------------------------------------------------------------------- +# +# TestBootstrapFullResync +# +# load()'s alternative to _full_resync_async() for its own totals- +# shortfall check -- see _bootstrap_full_resync()'s own docstring for why +# it exists (a load()-time-only, unwrapped call shape) and what its +# progress_callback staging means (DOWNLOAD_START_PCT/DOWNLOAD_END_PCT/ +# REIMPORT_START_PCT, all local to that method). +# +# ------------------------------------------------------------------------- +class TestBootstrapFullResync(unittest.TestCase): + def setUp(self): + self.db = new_instance() + self.db.web_client = mock.MagicMock() + self.db.web_client.download_export.return_value = b"fake gramps xml bytes" + self.db.emit = mock.MagicMock() + self.db.get_total = mock.MagicMock(return_value=0) + self.db._set_metadata = mock.MagicMock() + self.db._run_id = 0 + for key, name in grampswebapidb.KEY_TO_NAME_MAP.items(): + if key not in grampswebapidb.CLASS_TO_KEY_MAP.values(): + continue + setattr(self.db, f"get_{name}_handles", mock.MagicMock(return_value=[])) + setattr(self.db, f"remove_{name}", mock.MagicMock()) + # _describe_resync_to_views()'s before/after diff calls + # _snapshot_all_objects(), which reads real storage via + # _iter_raw_data() -- there's none behind these stubs either, so + # this defaults every test in this class to an empty before/after + # (no signal at all). Tests that care what got signaled override + # this themselves. + self.db._iter_raw_data = mock.MagicMock(return_value=iter([])) + self.patcher = mock.patch.object(grampswebapidb, "DbTxn", FakeDbTxn) + self.patcher.start() + self.addCleanup(self.patcher.stop) + + def test_downloads_wipes_and_reimports_with_no_progress_callback(self): + # Enough net "adds" (see _diff_snapshots()) to land above + # GRANULAR_REBUILD_MAX_CHANGES -- matching what a bootstrap + # against a real, populated tree actually represents, and the + # case _describe_resync_to_views() still uses request_rebuild() + # for. The first call per type is the pre-clear "before" snapshot + # (empty, nothing populated it); every call after that is "after". + calls = {"n": 0} + per_type_after = ( + GRANULAR_REBUILD_MAX_CHANGES // len(grampswebapidb.CLASS_TO_KEY_MAP) + 10 + ) + + def fake_iter_raw_data(key): + calls["n"] += 1 + if calls["n"] <= len(grampswebapidb.CLASS_TO_KEY_MAP): + return iter([]) + return iter( + (f"H{calls['n']}-{i}", {"gramps_id": f"G{i}"}) + for i in range(per_type_after) + ) + + self.db._iter_raw_data = mock.MagicMock(side_effect=fake_iter_raw_data) + + with mock.patch.object(grampswebapidb, "importData") as import_data: + self.db._bootstrap_full_resync() # must not raise + self.db.web_client.download_export.assert_called_once_with() + import_data.assert_called_once() + self.assertIsInstance(import_data.call_args.args[2], grampswebapidb.User) + self.db._set_metadata.assert_called_once_with("sync_last_time", mock.ANY) + emitted = [call.args[0] for call in self.db.emit.call_args_list] + self.assertIn("person-rebuild", emitted) + + def test_no_progress_callback_means_no_pulse_timer_at_all(self): + # Nothing to pulse for if there's nowhere to report it to -- and + # no display/GTK requirement either, matching this addon's + # headless-CLI-safety rule elsewhere in the file. + with mock.patch.object(grampswebapidb, "importData"), mock.patch.object( + grampswebapidb.GLib, "timeout_add_seconds" + ) as timeout_add, mock.patch.object( + grampswebapidb.GLib, "source_remove" + ) as source_remove: + self.db._bootstrap_full_resync() + timeout_add.assert_not_called() + source_remove.assert_not_called() + + def test_progress_callback_given_registers_and_cleans_up_a_pulse_timer(self): + with mock.patch.object(grampswebapidb, "importData"), mock.patch.object( + grampswebapidb.GLib, "timeout_add_seconds", return_value=99 + ) as timeout_add, mock.patch.object( + grampswebapidb.GLib, "source_remove" + ) as source_remove: + self.db._bootstrap_full_resync(progress_callback=mock.MagicMock()) + timeout_add.assert_called_once_with(1, mock.ANY) + source_remove.assert_called_once_with(99) + + def test_pulse_ticks_up_to_but_never_past_download_end_pct(self): + captured_pulse = {} + + def fake_timeout_add_seconds(interval, pulse): + captured_pulse["pulse"] = pulse + return 1 + + progress = mock.MagicMock() + with mock.patch.object(grampswebapidb, "importData"), mock.patch.object( + grampswebapidb.GLib, + "timeout_add_seconds", + side_effect=fake_timeout_add_seconds, + ), mock.patch.object(grampswebapidb.GLib, "source_remove"): + self.db._bootstrap_full_resync(progress_callback=progress) + pulse = captured_pulse["pulse"] + # Fire it far more times than the 20->30 range has room for -- + # confirm it holds at 30 rather than climbing past it. + for _ in range(20): + self.assertEqual(pulse(), grampswebapidb.GLib.SOURCE_CONTINUE) + reported = [call.args[0] for call in progress.call_args_list] + self.assertEqual(max(v for v in reported if v <= 30), 30) + self.assertNotIn(31, reported) + + def test_reimport_progress_is_rescaled_onto_the_remaining_range(self): + # importData()'s own real percentages (0-100) must land on + # REIMPORT_START_PCT..100 (30..100), not 0..100 directly -- that + # would make the bar visibly reset partway through. Mocks + # _import_progress_user() directly (rather than relying on + # has_display()/a real gui.user.User) so this test's outcome + # doesn't depend on whether this environment happens to have a + # display -- see that function's own docstring for the fallback + # this would otherwise silently hit. + captured = {} + + def fake_import_progress_user(callback): + captured["callback"] = callback + return mock.MagicMock() + + def fake_import_data(database, filename, user): + captured["callback"](0) + captured["callback"](50) + captured["callback"](100) + + progress = mock.MagicMock() + with mock.patch.object( + grampswebapidb, "importData", fake_import_data + ), mock.patch.object( + grampswebapidb, "_import_progress_user", fake_import_progress_user + ), mock.patch.object( + grampswebapidb.GLib, "timeout_add_seconds", return_value=1 + ), mock.patch.object( + grampswebapidb.GLib, "source_remove" + ): + self.db._bootstrap_full_resync(progress_callback=progress) + reported = [call.args[0] for call in progress.call_args_list] + # 0 -> 30, 50 -> 65, 100 -> 100, plus a final explicit 100. + self.assertIn(30, reported) + self.assertIn(65, reported) + self.assertEqual(reported[-1], 100) + + def test_tree_closed_during_download_returns_without_reimporting(self): + # _run_async_to_completion() (used for the download step -- see + # this method's own docstring) already returns None, with + # nothing raised, when the tree closed while it was waiting -- + # mocked directly here rather than simulated by bumping + # self.db._run_id synchronously inside download() itself: unlike + # _full_resync_async() (which schedules its own steps via + # io_runner/runner and has no polling wait loop of its own), + # _bootstrap_full_resync() relies on _run_async_to_completion()'s + # wait loop, which only unwinds via a real pumped GTK/GLib event + # noticing the closure -- something a synchronous + # InlineTaskRunner-driven test cannot reproduce (and will hang + # trying to, since nothing would ever wake its blocking pump). + with mock.patch.object( + self.db, "_run_async_to_completion", return_value=None + ), mock.patch.object(grampswebapidb, "importData") as import_data: + self.db._bootstrap_full_resync() # must not raise + import_data.assert_not_called() + self.db._set_metadata.assert_not_called() + + def test_failed_import_does_not_call_request_rebuild(self): + def failing_import_data(database, filename, user): + raise RuntimeError("boom") + + self.db.request_rebuild = mock.MagicMock() + with mock.patch.object(grampswebapidb, "importData", failing_import_data): + with self.assertRaises(RuntimeError): + self.db._bootstrap_full_resync() + self.db.request_rebuild.assert_not_called() + self.assertFalse(self.db._pulling) + # ------------------------------------------------------------------------- # @@ -759,6 +1349,30 @@ def test_local_changes_are_pushed(self): payload = self.db.web_client.push_transaction.call_args[0][0] self.assertEqual(payload[0]["handle"], "H1") + def test_transaction_description_is_forwarded_as_message(self): + # The DbTxn's own description (e.g. "Add Person (Jane Doe)", set + # by Gramps desktop's editors) becomes push_transaction()'s + # ``message`` -- see the module docstring's note on why, and + # gramps-web-api's TransactionsQueryArgs. + trans = FakeTransaction( + [(0, TXNADD, "H1", None, person_data("H1"))], + description="Add Person (Jane Doe)", + ) + with mock.patch.object(grampswebapidb.SQLite, "transaction_commit"): + self.db.transaction_commit(trans) + self.assertEqual( + self.db.web_client.push_transaction.call_args.kwargs["message"], + "Add Person (Jane Doe)", + ) + + def test_empty_description_forwards_none(self): + trans = FakeTransaction([(0, TXNADD, "H1", None, person_data("H1"))]) + with mock.patch.object(grampswebapidb.SQLite, "transaction_commit"): + self.db.transaction_commit(trans) + self.assertIsNone( + self.db.web_client.push_transaction.call_args.kwargs["message"] + ) + def test_payload_built_before_super_clears_records(self): # The base class's transaction_commit() clears the transaction's # records as its last step -- see the module docstring's "must run @@ -786,11 +1400,31 @@ def test_push_failure_is_logged_not_raised(self): with self.assertLogs(grampswebapidb.LOG, level="ERROR"): self.db.transaction_commit(trans) # must not raise + def test_push_dropped_if_tree_closed_mid_push(self): + # The tree was closed (or switched away from) while the push was + # in flight on io_runner -- self._guarded() (wrapping the push's + # own on_success/on_error) silently drops the result, unlike the + # old pump-based _DatabaseClosed mechanism this replaces (nothing + # in this chain pumps the main loop anymore, so nothing can raise + # it here). Not a failure: nothing left to push to. + trans = FakeTransaction([(0, TXNADD, "H1", None, person_data("H1"))]) + + def close_mid_push(*args, **kwargs): + self.db._run_id += 1 + + self.db.web_client.push_transaction.side_effect = close_mid_push + with mock.patch.object(grampswebapidb.SQLite, "transaction_commit"): + self.db.transaction_commit(trans) # must not raise, must not log + # Dropped, not delivered: self._syncing (only cleared by + # on_done/on_error, via _finish_async_op()) is still True -- + # close() resets it directly instead (see TestClose). + self.assertTrue(self.db._syncing) + def test_conflict_triggers_resync_then_retry(self): # A WebApiPushConflict means the server rejected the whole batch # because something changed server-side since the local mirror's - # snapshot -- the response is to resync from the server and then - # retry the local edit on top of that fresh data (see + # snapshot -- the response is to do a full resync from the server + # and then retry the local edit on top of that fresh data (see # _retry_after_conflict()), not to propagate the exception (the # local commit already happened). trans = FakeTransaction([(0, TXNADD, "H1", None, person_data("H1"))]) @@ -799,13 +1433,21 @@ def test_conflict_triggers_resync_then_retry(self): ) with mock.patch.object( grampswebapidb.SQLite, "transaction_commit" - ), mock.patch.object(self.db, "_sync_from_server") as resync, mock.patch.object( + ), mock.patch.object( + self.db, + "_resync_after_conflict_async", + side_effect=stub_async_done(None), + ) as resync, mock.patch.object( self.db, "_retry_after_conflict" ) as retry: with self.assertLogs(grampswebapidb.LOG, level="WARNING"): self.db.transaction_commit(trans) # must not raise - resync.assert_called_once_with() - retry.assert_called_once() + # A full resync, not the incremental history feed or a totals + # check -- neither can see a content-only change to an + # already-known, bulk-imported object -- see the module + # docstring and _resync_after_conflict_async(). + self.assertEqual(resync.call_count, 1) + retry.assert_called_once_with(mock.ANY) payload = retry.call_args[0][0] self.assertEqual(payload[0]["handle"], "H1") @@ -818,9 +1460,15 @@ def test_conflict_resync_failure_is_also_swallowed(self): grampswebapidb.SQLite, "transaction_commit" ), mock.patch.object( self.db, - "_sync_from_server", - side_effect=HTTPError( - "https://example.com/api/transactions/history/", 500, "boom", None, None + "_resync_after_conflict_async", + side_effect=stub_async_error( + HTTPError( + "https://example.com/api/exporters/gramps/file", + 500, + "boom", + None, + None, + ) ), ), mock.patch.object( self.db, "_retry_after_conflict" @@ -831,6 +1479,68 @@ def test_conflict_resync_failure_is_also_swallowed(self): # server, so retrying the edit on top of it would be pointless. retry.assert_not_called() + def test_retry_failure_is_queued_not_dropped(self): + # A failure inside _retry_after_conflict() itself (its DbTxn body + # never finished, so nothing committed locally -- see + # _push_payload_async()'s handling of this) is a plain + # connectivity-shaped problem, not a conflict, so the payload is + # queued for later like any other push that couldn't be + # delivered (see TestPendingPushQueue), not dropped. + trans = FakeTransaction([(0, TXNADD, "H1", None, person_data("H1"))]) + self.db.web_client.push_transaction.side_effect = WebApiPushConflict( + "Object has changed" + ) + with mock.patch.object( + grampswebapidb.SQLite, "transaction_commit" + ), mock.patch.object( + self.db, "_resync_after_conflict_async", side_effect=stub_async_done(None) + ), mock.patch.object( + self.db, + "_retry_after_conflict", + side_effect=OSError("network down"), + ), mock.patch.object( + # This test is about the queueing itself, not + # _finish_async_op()'s separately-tested "flush once on + # chain completion" behavior -- left un-stubbed, the queued + # entry would immediately be re-flushed against the same + # ever-conflicting mock and dropped rather than surviving to + # the assertion below. + self.db, + "_flush_pending_pushes_async", + side_effect=stub_async_done(None), + ): + with self.assertLogs(grampswebapidb.LOG, level="WARNING"): + self.db.transaction_commit(trans) # must not raise + self.assertEqual(len(self.metadata["pending_pushes"]), 1) + self.assertEqual( + self.metadata["pending_pushes"][0]["payload"][0]["handle"], "H1" + ) + + def test_conflict_resync_dropped_if_tree_closed_mid_resync(self): + # The async equivalent of the old _DatabaseClosed-during-resync + # test: nothing in this chain pumps the main loop anymore, so + # nothing raises _DatabaseClosed here -- self._guarded() (wrapping + # _full_resync_async()'s own internal steps) just drops the + # result instead. Exercises the real (non-mocked) + # _resync_after_conflict_async()/_full_resync_async() chain, not + # a stubbed-away one, since the drop happens inside it. + trans = FakeTransaction([(0, TXNADD, "H1", None, person_data("H1"))]) + self.db.web_client.push_transaction.side_effect = WebApiPushConflict( + "Object has changed" + ) + + def close_mid_download(): + self.db._run_id += 1 + return b"" + + self.db.web_client.download_export.side_effect = close_mid_download + with mock.patch.object( + grampswebapidb.SQLite, "transaction_commit" + ), mock.patch.object(self.db, "_retry_after_conflict") as retry: + self.db.transaction_commit(trans) # must not raise, must not log + retry.assert_not_called() + self.assertTrue(self.db._syncing) + def test_repeated_conflict_on_a_retry_is_not_retried_again(self): # is_retry=True marks a push that is itself _retry_after_conflict()'s # replay -- a second conflict on that replay must not recurse into @@ -841,14 +1551,18 @@ def test_repeated_conflict_on_a_retry_is_not_retried_again(self): ) with mock.patch.object( grampswebapidb.SQLite, "transaction_commit" - ), mock.patch.object(self.db, "_sync_from_server") as resync, mock.patch.object( + ), mock.patch.object( + self.db, + "_resync_after_conflict_async", + side_effect=stub_async_done(None), + ) as resync, mock.patch.object( self.db, "_retry_after_conflict" ) as retry: with self.assertLogs(grampswebapidb.LOG, level="WARNING"): - self.db._push_payload( + self.db._start_push( transaction_to_json(trans), is_retry=True ) # must not raise - resync.assert_called_once_with() + self.assertEqual(resync.call_count, 1) retry.assert_not_called() def test_undo_conflict_is_not_retried(self): @@ -862,15 +1576,51 @@ def test_undo_conflict_is_not_retried(self): ) with mock.patch.object( grampswebapidb.SQLite, "transaction_commit" - ), mock.patch.object(self.db, "_sync_from_server") as resync, mock.patch.object( + ), mock.patch.object( + self.db, + "_resync_after_conflict_async", + side_effect=stub_async_done(None), + ) as resync, mock.patch.object( self.db, "_retry_after_conflict" ) as retry: with self.assertLogs(grampswebapidb.LOG, level="WARNING"): - self.db._push_payload(transaction_to_json(trans), undo=True) - resync.assert_called_once_with() + self.db._start_push(transaction_to_json(trans), undo=True) + self.assertEqual(resync.call_count, 1) retry.assert_not_called() +# ------------------------------------------------------------------------- +# +# TestResyncAfterConflict +# +# _resync_after_conflict_async() wraps _full_resync_async() -- not the +# incremental _sync_from_server_async() -- and, unlike the old +# synchronous _resync_after_conflict() this replaces, does NOT manage +# self._syncing itself: the caller (ultimately _start_push()) already +# claims it for the whole chain, including this detour -- see the +# module docstring and _start_push()'s own docstring for why a full +# resync, not the cheaper incremental feed or a totals check, is what a +# conflict retry needs. +# +# ------------------------------------------------------------------------- +class TestResyncAfterConflict(unittest.TestCase): + def setUp(self): + self.db = new_instance() + self.db._full_resync_async = mock.MagicMock() + + def test_delegates_to_full_resync(self): + on_done = lambda _: None + on_error = lambda _: None + self.db._resync_after_conflict_async(on_done, on_error) + self.db._full_resync_async.assert_called_once_with(on_done, on_error) + + def test_does_not_touch_syncing_itself(self): + # Caller already owns it -- see _start_push()'s docstring. + self.db._syncing = True + self.db._resync_after_conflict_async(lambda _: None, lambda _: None) + self.assertTrue(self.db._syncing) + + # ------------------------------------------------------------------------- # # TestRetryAfterConflict @@ -893,6 +1643,7 @@ def setUp(self): self.db.remove_person = mock.MagicMock() self.db.has_person_handle = mock.MagicMock() self.db.get_person_from_handle = mock.MagicMock() + self.db.web_client = mock.MagicMock() dbtxn_patch = mock.patch.object(grampswebapidb, "DbTxn") mock_dbtxn_class = dbtxn_patch.start() mock_dbtxn_class.return_value.__enter__.return_value = "TRANS" @@ -938,10 +1689,11 @@ def test_update_of_a_still_present_handle_is_merged_with_the_current_object(self with mock.patch.object(grampswebapidb, "_merge_or_overwrite") as merge_fn: merge_fn.return_value = "MERGED" self.db._retry_after_conflict(payload) - merge_current, merge_local = merge_fn.call_args[0] + merge_current, merge_local, merge_db = merge_fn.call_args[0] self.assertIs(merge_current, current) self.assertIsInstance(merge_local, Person) self.assertEqual(merge_local.get_gramps_id(), "I0002") + self.assertIs(merge_db, self.db) self.db.commit_person.assert_called_once_with("MERGED", "TRANS") def test_delete_removes_if_handle_still_present(self): @@ -1030,47 +1782,520 @@ def test_retrying_flag_cleared_even_if_commit_raises(self): # ------------------------------------------------------------------------- # -# TestMergeOrOverwrite +# TestConflictRetryAgainstARealDatabase +# +# Every test above stubs out commit_person/has_person_handle/get_person_ +# from_handle as independent mocks, which cannot catch a bug where +# _resync_after_conflict()'s write and the later merge step's read of +# "the current object" disagree -- exactly the shape of bug this fix was +# written for: an earlier version fetched fresh server data with a direct +# GET // and fed it straight to data_to_object(), which +# raises KeyError on that endpoint's shape (see _resync_after_conflict()'s +# docstring) -- caught by the broad _CONNECTION_ERRORS handler around it +# and misreported as a connectivity problem, so the payload got queued, +# retried, failed identically, and was silently dropped. This class runs +# against a real (temp-directory) SQLite-backed database and real DbTxn/ +# transaction_commit machinery, with only the network layer (web_client) +# mocked, so it actually exercises that interaction end to end -- and +# _full_resync() itself runs for real against a fake XML export, the same +# way _resync_after_conflict() invokes it in production. # # ------------------------------------------------------------------------- -class TestMergeOrOverwrite(unittest.TestCase): - """_merge_or_overwrite() ports GrampsWebSync's diffhandler.py A_MRG_REM - handling: combine two edits of the same object via the object's own - merge() -- the same list-unioning logic behind Gramps' Merge People/ - Family/... tools -- rather than letting one edit silently clobber the - other. Uses real Person/Tag objects rather than mocks, since the whole - point is exercising Gramps' own merge() implementation.""" +class TestConflictRetryAgainstARealDatabase(unittest.TestCase): + def setUp(self): + tmpdir = tempfile.mkdtemp(prefix="grampswebapidb_test_") + self.addCleanup(shutil.rmtree, tmpdir, ignore_errors=True) + db = make_database("sqlite") + db.load(tmpdir) + with DbTxn("seed", db) as trans: + person = Person() + person.set_gramps_id("I0001") + db.add_person(person, trans) + self.handle = person.handle + # Reclassify the real, already-initialized SQLite-backed db as a + # WebApiDB, rather than going through its network-dependent + # load() -- this addon has no per-tree settings.ini (see the + # module docstring), so nothing else ties identity to a server, + # and this is the minimal way to run its push/retry logic against + # a real DBAPI backend. + db.__class__ = WebApiDB + db.web_client = mock.MagicMock() + db.runner = InlineTaskRunner() + db.io_runner = InlineTaskRunner() + db._syncing = False + db._retrying = False + db._pulling = False + db._get_metadata = lambda key, default=0: default + db._set_metadata = lambda key, value, use_txn=True: None + db.web_client.get_transaction_history.return_value = ([], 0) + db.web_client.get_object_count.return_value = 1 + db.web_client.supports_background_transactions.return_value = False + self.db = db + self.addCleanup(self.db.close) + + def _make_server_fresh(self): + """The server's true current state for self.handle, diverged from + the local mirror's stale copy (private=False -> True) via a route + this addon's history feed cannot see -- see + _resync_after_conflict()'s docstring.""" + server_fresh = copy.deepcopy( + remove_object(object_to_data(self.db.get_person_from_handle(self.handle))) + ) + server_fresh["private"] = True + return server_fresh + + def _stub_full_resync_to(self, server_fresh): + """Patch _full_resync_async() to commit server_fresh into local + storage via a real, batch=True/_pulling=True DbTxn -- standing in + for what a real resync does (download and reimport a full XML + export), without actually needing one here. What matters for + these tests is the local write _resync_after_conflict_async() + relies on, not how a real resync produces it. InlineTaskRunner + (see new_instance()) resolves the real chain inline, so the + write-then-read ordering this test protects (see _start_push()'s + docstring and section 3.6 of the refactor plan) is exercised for + real, not assumed.""" + + def fake_full_resync_async(on_done, on_error, progress_callback=None): + self.db._pulling = True + try: + with DbTxn("fake resync", self.db, batch=True) as trans: + self.db.commit_person(data_to_object(server_fresh), trans) + finally: + self.db._pulling = False + on_done(None) + + return mock.patch.object( + self.db, "_full_resync_async", side_effect=fake_full_resync_async + ) - def test_list_valued_fields_from_both_sides_are_unioned(self): - current = Person() - current.set_handle("H1") - current.set_gramps_id("I0001") - current.add_note("N-remote") + def _push_conflicts_once_then_succeeds(self): + calls = [] + + def fake_push(payload, undo=False, background=False, message=None): + calls.append(copy.deepcopy(payload)) + if len(calls) == 1: + raise WebApiPushConflict("Object has changed") + + self.db.web_client.push_transaction.side_effect = fake_push + return calls + + def _add_an_attribute(self): + with DbTxn("edit", self.db) as trans: + person = self.db.get_person_from_handle(self.handle) + attr = Attribute() + attr.set_type("Occupation") + attr.set_value("Tester") + person.add_attribute(attr) + self.db.commit_person(person, trans) + + def test_retry_pushes_the_resynced_object_as_old_not_the_stale_mirror(self): + stale_local = remove_object( + object_to_data(self.db.get_person_from_handle(self.handle)) + ) + server_fresh = self._make_server_fresh() + calls = self._push_conflicts_once_then_succeeds() + + with self._stub_full_resync_to(server_fresh): + self._add_an_attribute() + + self.assertEqual(len(calls), 2) + # The original push sent the stale local snapshot -- that's what + # the server rejected. + self.assertEqual(calls[0][0]["old"], stale_local) + # The retry must send the *resynced* server state as "old", not + # the same stale value again -- otherwise it is guaranteed to + # conflict identically and the edit is dropped for good (see + # _push_payload()'s "give up after a repeated conflict" branch). + self.assertEqual(calls[1][0]["old"], server_fresh) + # "new" is the merge of that fresh state with the local edit's + # actual intent, not a blind overwrite of either side. + self.assertTrue(calls[1][0]["new"]["private"]) + self.assertTrue( + any(a["value"] == "Tester" for a in calls[1][0]["new"]["attribute_list"]) + ) - local = Person() - local.set_handle("H1") - local.set_gramps_id("I0002") - local.add_note("N-local") + def test_conflict_resolves_without_being_dropped(self): + # Same setup, phrased as an outcome: the local mirror ends up + # holding the merged result, and the edit was not dropped after + # only its first, correctly-rejected attempt. + calls = self._push_conflicts_once_then_succeeds() + + with self._stub_full_resync_to(self._make_server_fresh()): + self._add_an_attribute() + + self.assertEqual(len(calls), 2) + final = self.db.get_person_from_handle(self.handle) + self.assertTrue(final.get_privacy()) + self.assertEqual(len(final.get_attribute_list()), 1) + + +class TestBirthDeathIndexPreservedAcrossResync(unittest.TestCase): + """Gramps XML has no element for Person.birth_ref_index/ + death_ref_index (see exportxml.py's write_person()) -- ImportXml + recomputes both from document order instead of preserving them, + wrong whenever the true index was -1 despite a BIRTH/DEATH-type + event ref existing (confirmed via a real export/reimport round trip: + birth_ref_index went from -1 to 0 with no edit involved). Since + diff_items() treats both fields as ordinary content, that + recomputation would otherwise make a Person's local mirror disagree + with the server after every single resync, forever, for reasons + unrelated to anything actually edited -- see the module docstring's + "A Gramps XML export isn't perfectly round-trip-faithful either" + paragraph. These test _snapshot_birth_death_indices()/ + _restore_birth_death_indices() directly, against a real DBAPI + database, standing in for the mis-recompute a real ImportXml run + would produce rather than running one. + """ - merged = grampswebapidb._merge_or_overwrite(current, local) - self.assertEqual(set(merged.get_note_list()), {"N-remote", "N-local"}) + def setUp(self): + tmpdir = tempfile.mkdtemp(prefix="grampswebapidb_test_") + self.addCleanup(shutil.rmtree, tmpdir, ignore_errors=True) + self.db = make_database("sqlite") + self.db.load(tmpdir) + self.addCleanup(self.db.close) + with DbTxn("seed", self.db) as trans: + event = Event() + event.set_type(EventType.BIRTH) + self.db.add_event(event, trans) + self.event_handle = event.handle + + person = Person() + person.set_gramps_id("I0001") + ref = EventRef() + ref.set_reference_handle(self.event_handle) + ref.set_role(EventRoleType.PRIMARY) + person.add_event_ref(ref) + # Deliberately left at -1 (the constructor default) rather + # than calling set_birth_ref(ref) -- a real, legal state + # (nothing marks this BIRTH-type ref as "the" birth event) + # that ImportXml's own heuristic cannot tell apart from an + # oversight. + self.db.add_person(person, trans) + self.handle = person.handle + + def _mimic_import_xml_recompute(self): + """Overwrite birth_ref_index the way ImportXml's GrampsParser + would on a reimport (self.person.get_birth_ref() is None -> set + it to the first PRIMARY BIRTH-type ref it sees) -- standing in + for a real export/reimport round trip without needing one.""" + with DbTxn("mimic reimport", self.db, batch=True) as trans: + person = self.db.get_person_from_handle(self.handle) + person.birth_ref_index = 0 + self.db.commit_person(person, trans) + + def test_restores_the_index_import_xml_cannot_preserve(self): + snapshot = _snapshot_birth_death_indices(self.db) + + self._mimic_import_xml_recompute() + # The mis-recompute really did happen -- otherwise this test + # would pass even without _restore_birth_death_indices() doing + # anything. + self.assertEqual(self.db.get_person_from_handle(self.handle).birth_ref_index, 0) + + with DbTxn("restore", self.db, batch=True) as trans: + restored = _restore_birth_death_indices(self.db, snapshot, trans) + + self.assertEqual(restored, 1) + self.assertEqual( + self.db.get_person_from_handle(self.handle).birth_ref_index, -1 + ) - def test_current_object_is_not_mutated(self): - current = Person() - current.set_handle("H1") - current.add_note("N-remote") - local = Person() - local.set_handle("H1") - local.add_note("N-local") + def test_does_not_restore_when_the_event_ref_list_itself_changed(self): + # A real edit landed on this Person's event refs between the + # snapshot and the resync (e.g. someone added an event through + # the API) -- restoring the stale pre-resync index would be + # wrong here, so this must leave the freshly-recomputed value + # alone rather than clobber it. + snapshot = _snapshot_birth_death_indices(self.db) + + with DbTxn("real edit", self.db, batch=True) as trans: + person = self.db.get_person_from_handle(self.handle) + other_ref = EventRef() + other_ref.set_reference_handle(self.event_handle) + other_ref.set_role(EventRoleType.WITNESS) + person.add_event_ref(other_ref) + person.birth_ref_index = 0 + self.db.commit_person(person, trans) + + with DbTxn("restore", self.db, batch=True) as trans: + restored = _restore_birth_death_indices(self.db, snapshot, trans) + + self.assertEqual(restored, 0) + self.assertEqual(self.db.get_person_from_handle(self.handle).birth_ref_index, 0) + + def test_a_person_deleted_since_the_snapshot_is_skipped_not_erred(self): + snapshot = _snapshot_birth_death_indices(self.db) + + with DbTxn("delete", self.db, batch=True) as trans: + self.db.remove_person(self.handle, trans) + + with DbTxn("restore", self.db, batch=True) as trans: + restored = _restore_birth_death_indices(self.db, snapshot, trans) + + self.assertEqual(restored, 0) + + +class TestDescribeResyncToViews(unittest.TestCase): + """_describe_resync_to_views() -- called by _full_resync_async()'s + rebuild() and _bootstrap_full_resync() once a reimport is done -- + tells already-open views what actually changed, using a real + before/after object diff (_diff_snapshots(), the same one + _reconcile_batch_commit() uses) instead of request_rebuild()'s + blanket "reload everything" signal, whenever that diff is small + enough to be worth being precise about instead. See + GRANULAR_REBUILD_MAX_CHANGES's own comment for why this matters: + gui/displaystate.py's History.history_changed() resets Active Person + on any -rebuild signal, so a resync recovering from an ordinary + push conflict -- which only ever touches a handful of objects -- + doesn't need to reset it, unlike before this existed. + """ - grampswebapidb._merge_or_overwrite(current, local) - self.assertEqual(current.get_note_list(), ["N-remote"]) + def setUp(self): + tmpdir = tempfile.mkdtemp(prefix="grampswebapidb_test_") + self.addCleanup(shutil.rmtree, tmpdir, ignore_errors=True) + db = make_database("sqlite") + db.load(tmpdir) + # Same minimal reclassify-a-real-DBAPI-db-as-WebApiDB shape as + # TestConflictRetryAgainstARealDatabase.setUp() -- see that + # method's own comment. web_client is mocked so the ordinary + # (non-batch) DbTxns below can commit through transaction_commit() + # -> _start_push() without a real network call; nothing here + # asserts on what got pushed. + db.__class__ = WebApiDB + db.web_client = mock.MagicMock() + db.runner = InlineTaskRunner() + db.io_runner = InlineTaskRunner() + db._syncing = False + db._retrying = False + db._pulling = False + db._get_metadata = lambda key, default=0: default + db._set_metadata = lambda key, value, use_txn=True: None + db.emit = mock.MagicMock() + self.db = db + self.addCleanup(self.db.close) - def test_local_obj_gramps_id_is_cleared_before_merging(self): - # merge() tags on a "Merged Gramps ID" attribute if the acquisition - # has a gramps_id -- appropriate for absorbing a second, separate - # object (Gramps' Merge People tool), but this is one object edited - # twice, not two objects becoming one, so that attribute must not + def emitted(self): + return {call.args[0]: call.args[1][0] for call in self.db.emit.call_args_list} + + def test_small_diff_emits_granular_signals_not_rebuild(self): + with DbTxn("seed", self.db) as trans: + untouched = Person() + untouched.set_gramps_id("I0001") + self.db.add_person(untouched, trans) + touched = Person() + touched.set_gramps_id("I0002") + self.db.add_person(touched, trans) + + before = self.db._snapshot_all_objects() + + with DbTxn("edit", self.db) as trans: + person = self.db.get_person_from_handle(touched.handle) + attr = Attribute() + attr.set_type("Occupation") + attr.set_value("Tester") + person.add_attribute(attr) + self.db.commit_person(person, trans) + self.db.emit.reset_mock() # only care what the resync step itself signals + + self.db._describe_resync_to_views(before) + + self.assertEqual(self.emitted(), {"person-update": [touched.handle]}) + + def test_nothing_changed_emits_nothing(self): + with DbTxn("seed", self.db) as trans: + p = Person() + p.set_gramps_id("I0001") + self.db.add_person(p, trans) + before = self.db._snapshot_all_objects() + self.db.emit.reset_mock() + + self.db._describe_resync_to_views(before) + + self.db.emit.assert_not_called() + + def test_large_diff_falls_back_to_request_rebuild(self): + # An empty "before" plus a low threshold stands in for what a + # bootstrap resync against a real, populated tree looks like -- + # net effect "everything was added" -- without needing hundreds + # of real Person objects to cross the real threshold. + with mock.patch.object(grampswebapidb, "GRANULAR_REBUILD_MAX_CHANGES", 1): + with DbTxn("seed", self.db) as trans: + for i in range(3): + p = Person() + p.set_gramps_id(f"I000{i}") + self.db.add_person(p, trans) + self.db.emit.reset_mock() + + self.db._describe_resync_to_views({}) + + emitted_names = [call.args[0] for call in self.db.emit.call_args_list] + self.assertIn("person-rebuild", emitted_names) + # request_rebuild() emits one signal per type, not one per object + # -- the whole point of falling back to it here. + self.assertNotIn("person-add", emitted_names) + + +# ------------------------------------------------------------------------- +# +# TestSyncingStaysHeldAcrossNestedRetryPush +# +# Regression test for the premature-completion bug documented in +# _start_push()'s docstring (and section 2.2.1 of the refactor plan): a +# first draft of this design cleared self._syncing as soon as +# _retry_after_conflict()'s local DbTxn committed, before its own nested +# re-push -- a real network round trip -- had actually resolved, letting +# a second, unrelated edit race it. InlineTaskRunner-based tests +# structurally cannot catch this class of bug: everything resolves +# inline, in one call, before any assertion runs. This uses the real +# GLibTaskRunner/IoRunner pair and a real worker thread, driven by a +# live GLib.MainContext loop, to prove self._syncing is still True while +# the nested re-push is genuinely in flight. +# +# ------------------------------------------------------------------------- +class TestSyncingStaysHeldAcrossNestedRetryPush(unittest.TestCase): + def setUp(self): + self.db = WebApiDB.__new__(WebApiDB) + self.db.runner = grampswebapidb.GLibTaskRunner() + self.db.io_runner = grampswebapidb.IoRunner() + self.db.web_client = mock.MagicMock() + self.db._syncing = False + self.db._retrying = False + self.db._pulling = False + self.db._run_id = 0 + self.metadata = {} + self.db._get_metadata = lambda key, default=0: self.metadata.get(key, default) + self.db._set_metadata = ( + lambda key, value, use_txn=True: self.metadata.__setitem__(key, value) + ) + + def _pump_until(self, predicate, timeout=5.0): + context = grampswebapidb.GLib.MainContext.default() + deadline = time.monotonic() + timeout + while not predicate(): + if time.monotonic() > deadline: + raise AssertionError("timed out waiting for the async chain") + context.iteration(True) + + def test_syncing_stays_true_across_the_retrys_nested_repush(self): + release_second_push = threading.Event() + calls = [] + + def fake_push(payload, undo=False, background=False, message=None): + calls.append(1) + if len(calls) == 1: + raise WebApiPushConflict("Object has changed") + # The retry's own nested re-push: block on a real worker + # thread until the test below has observed self._syncing + # still True, so the assertion genuinely races real network + # I/O rather than something InlineTaskRunner already resolved. + release_second_push.wait(timeout=5.0) + + self.db.web_client.push_transaction.side_effect = fake_push + + # The write-then-read ordering a real resync's DB write must + # provide is covered separately + # (TestConflictRetryAgainstARealDatabase) -- stubbed trivially + # here so this test is only about the _syncing race. + def fake_full_resync_async(on_done, on_error, progress_callback=None): + on_done(None) + + def fake_retry(payload): + # Standing in for _retry_after_conflict()'s real DbTxn body: + # its own DbTxn.__exit__ is what triggers the nested + # transaction_commit() -> _start_push(is_retry=True) call in + # the real code -- reproduced directly here since what this + # test cares about doesn't need a real database. + self.db._start_push(payload, is_retry=True) + + with mock.patch.object( + self.db, "_full_resync_async", side_effect=fake_full_resync_async + ), mock.patch.object(self.db, "_retry_after_conflict", side_effect=fake_retry): + self.db._syncing = True + payload = [ + { + "type": "add", + "handle": "H1", + "_class": "Person", + "old": None, + "new": {"handle": "H1"}, + } + ] + + # Standing in for _start_push()'s own _finish_async_op(None) + # wrapping (not exercised directly here -- this test is about + # _push_payload_async()'s chain, not _start_push()'s queueing + # logic): the real chain's outermost on_done/on_error clear + # self._syncing once the whole thing -- including the nested + # retry push -- is actually done. + def outer_done(_): + self.db._syncing = False + + def outer_error(_): + self.db._syncing = False + + self.db._push_payload_async( + payload, on_done=outer_done, on_error=outer_error + ) + + # Wait until the nested re-push is genuinely in flight on its + # own worker thread (the second push_transaction call). + self._pump_until(lambda: len(calls) >= 2) + # It's blocked on release_second_push, so this chain is not + # done yet -- the bug this test guards against would have + # already cleared this. + self.assertTrue(self.db._syncing) + + release_second_push.set() + self._pump_until(lambda: not self.db._syncing) + + self.assertFalse(self.db._syncing) + + +# ------------------------------------------------------------------------- +# +# TestMergeOrOverwrite +# +# ------------------------------------------------------------------------- +class TestMergeOrOverwrite(unittest.TestCase): + """_merge_or_overwrite() ports GrampsWebSync's diffhandler.py A_MRG_REM + handling: combine two edits of the same object via the object's own + merge() -- the same list-unioning logic behind Gramps' Merge People/ + Family/... tools -- rather than letting one edit silently clobber the + other. Uses real Person/Tag objects rather than mocks, since the whole + point is exercising Gramps' own merge() implementation.""" + + def test_list_valued_fields_from_both_sides_are_unioned(self): + current = Person() + current.set_handle("H1") + current.set_gramps_id("I0001") + current.add_note("N-remote") + + local = Person() + local.set_handle("H1") + local.set_gramps_id("I0002") + local.add_note("N-local") + + db = FakeHandleDb() + merged = grampswebapidb._merge_or_overwrite(current, local, db) + self.assertEqual(set(merged.get_note_list()), {"N-remote", "N-local"}) + + def test_current_object_is_not_mutated(self): + current = Person() + current.set_handle("H1") + current.add_note("N-remote") + local = Person() + local.set_handle("H1") + local.add_note("N-local") + + grampswebapidb._merge_or_overwrite(current, local, FakeHandleDb()) + self.assertEqual(current.get_note_list(), ["N-remote"]) + + def test_local_obj_gramps_id_is_cleared_before_merging(self): + # merge() tags on a "Merged Gramps ID" attribute if the acquisition + # has a gramps_id -- appropriate for absorbing a second, separate + # object (Gramps' Merge People tool), but this is one object edited + # twice, not two objects becoming one, so that attribute must not # appear, and local's own gramps_id object must be untouched. current = Person() current.set_handle("H1") @@ -1078,7 +2303,7 @@ def test_local_obj_gramps_id_is_cleared_before_merging(self): local.set_handle("H1") local.set_gramps_id("I0002") - merged = grampswebapidb._merge_or_overwrite(current, local) + merged = grampswebapidb._merge_or_overwrite(current, local, FakeHandleDb()) self.assertEqual(merged.get_attribute_list(), []) self.assertEqual(local.get_gramps_id(), "I0002") @@ -1092,9 +2317,131 @@ def test_type_without_a_real_merge_falls_back_to_local_obj(self): local.set_handle("H1") local.set_name("Local name") - result = grampswebapidb._merge_or_overwrite(current, local) + result = grampswebapidb._merge_or_overwrite(current, local, FakeHandleDb()) self.assertIs(result, local) + def test_privacy_is_ored_not_left_at_either_sides_value(self): + # PrivacyBase._merge_privacy() (called from Person.merge()) is + # "self.private = self.private or other.private" -- neither a + # plain list union nor "current's value wins" (see the next + # test): the merged object is private if *either* side marked + # it private. Confirmed against a live server during this + # addon's own conflict-recovery testing. + current = Person() + current.set_handle("H1") + current.set_privacy(False) + local = Person() + local.set_handle("H1") + local.set_privacy(True) + + merged = grampswebapidb._merge_or_overwrite(current, local, FakeHandleDb()) + self.assertTrue(merged.get_privacy()) + + def test_a_scalar_field_merge_does_not_special_case_keeps_currents_value(self): + # A field merge() has no per-field handling for at all (gender, + # unlike privacy or a name) is simply never touched: merged + # starts as a deepcopy of *current*, so current's own value + # survives and local's is silently discarded -- the opposite of + # "local overwrites remote". + current = Person() + current.set_handle("H1") + current.set_gender(Person.FEMALE) + local = Person() + local.set_handle("H1") + local.set_gender(Person.MALE) + + merged = grampswebapidb._merge_or_overwrite(current, local, FakeHandleDb()) + self.assertEqual(merged.get_gender(), Person.FEMALE) + + +# ------------------------------------------------------------------------- +# +# TestPruneDanglingReferences +# +# ------------------------------------------------------------------------- +class TestPruneDanglingReferences(unittest.TestCase): + """_merge_or_overwrite()'s dangling-reference guard: merge()'s own + list-unioning (TagBase._merge_tag_list() and the Note/Citation + equivalents) has no existence check, so replaying a stale pre-conflict + local edit can resurrect a handle for a Tag/Note/Citation another + client deleted server-side in the meantime -- exactly what the + freshly-resynced "current" object correctly no longer references. + Reproduced live (2026-08-17) against a real GTK PersonListModel: the + very next redraw of a row with such a dangling tag_list entry crashed + Gramps with an uncaught HandleError from PeopleModel.column_tag_color(). + """ + + def test_tag_only_known_to_local_side_is_dropped(self): + current = Person() + current.set_handle("H1") + local = Person() + local.set_handle("H1") + local.add_tag("deleted-tag-handle") + + db = FakeHandleDb(known_handles=set()) + merged = grampswebapidb._merge_or_overwrite(current, local, db) + self.assertEqual(merged.get_tag_list(), []) + + def test_tag_still_known_survives(self): + current = Person() + current.set_handle("H1") + local = Person() + local.set_handle("H1") + local.add_tag("live-tag-handle") + + db = FakeHandleDb(known_handles={"live-tag-handle"}) + merged = grampswebapidb._merge_or_overwrite(current, local, db) + self.assertEqual(merged.get_tag_list(), ["live-tag-handle"]) + + def test_dangling_note_and_citation_are_dropped_too(self): + current = Person() + current.set_handle("H1") + local = Person() + local.set_handle("H1") + local.add_note("deleted-note-handle") + local.add_citation("deleted-citation-handle") + + db = FakeHandleDb(known_handles=set()) + merged = grampswebapidb._merge_or_overwrite(current, local, db) + self.assertEqual(merged.get_note_list(), []) + self.assertEqual(merged.get_citation_list(), []) + + def test_dangling_reference_on_a_nested_child_object_is_dropped(self): + # Attribute mixes in NoteBase/CitationBase of its own -- merge() + # copies local's attribute_list wholesale, so a dangling note on + # one of those attributes needs the same pruning, not just on the + # Person's own top-level note_list. + current = Person() + current.set_handle("H1") + local = Person() + local.set_handle("H1") + attr = Attribute() + attr.set_type("Occupation") + attr.add_note("deleted-note-handle") + local.add_attribute(attr) + + db = FakeHandleDb(known_handles=set()) + merged = grampswebapidb._merge_or_overwrite(current, local, db) + merged_attrs = merged.get_attribute_list() + self.assertEqual(len(merged_attrs), 1) + self.assertEqual(merged_attrs[0].get_note_list(), []) + + def test_prune_dangling_references_applies_directly_without_a_merge(self): + # _merge_or_overwrite()'s type(current).merge is BaseObject.merge + # fallback (e.g. Tag) returns local_obj outright with no merge() + # call at all -- confirming _prune_dangling_references() itself + # mutates its argument in place covers that path without needing + # a real no-merge type that also happens to carry a tag_list. + person = Person() + person.set_handle("H1") + person.add_tag("deleted-tag-handle") + person.add_note("live-note-handle") + + db = FakeHandleDb(known_handles={"live-note-handle"}) + grampswebapidb._prune_dangling_references(person, db) + self.assertEqual(person.get_tag_list(), []) + self.assertEqual(person.get_note_list(), ["live-note-handle"]) + # ------------------------------------------------------------------------- # @@ -1119,30 +2466,58 @@ def test_undo_pushes_with_undo_flag(self): self.db.undodb.undo_count = 1 self.db.undodb.undoq = [txn] self.db.undodb.undo.return_value = True - with mock.patch.object(self.db, "_push_payload") as push: + with mock.patch.object(self.db, "_start_push") as push: result = self.db.undo() self.assertTrue(result) push.assert_called_once() payload = push.call_args[0][0] self.assertEqual(payload[0]["handle"], "H1") - self.assertEqual(push.call_args.kwargs, {"undo": True}) + self.assertEqual(push.call_args.kwargs, {"undo": True, "message": None}) def test_redo_pushes_without_undo_flag(self): txn = FakeTransaction([(0, TXNDEL, "H1", person_data("H1"), None)]) self.db.undodb.redo_count = 1 self.db.undodb.redoq = [txn] self.db.undodb.redo.return_value = True - with mock.patch.object(self.db, "_push_payload") as push: + with mock.patch.object(self.db, "_start_push") as push: result = self.db.redo() self.assertTrue(result) payload = push.call_args[0][0] self.assertEqual(payload[0]["handle"], "H1") - self.assertEqual(push.call_args.kwargs, {}) + self.assertEqual(push.call_args.kwargs, {"message": None}) + + def test_undo_message_wraps_description(self): + txn = FakeTransaction( + [(0, TXNADD, "H1", None, person_data("H1"))], + description="Add Person (Jane Doe)", + ) + self.db.undodb.undo_count = 1 + self.db.undodb.undoq = [txn] + self.db.undodb.undo.return_value = True + with mock.patch.object(self.db, "_start_push") as push: + self.db.undo() + self.assertEqual( + push.call_args.kwargs["message"], "Undo: Add Person (Jane Doe)" + ) + + def test_redo_message_wraps_description(self): + txn = FakeTransaction( + [(0, TXNDEL, "H1", person_data("H1"), None)], + description="Add Person (Jane Doe)", + ) + self.db.undodb.redo_count = 1 + self.db.undodb.redoq = [txn] + self.db.undodb.redo.return_value = True + with mock.patch.object(self.db, "_start_push") as push: + self.db.redo() + self.assertEqual( + push.call_args.kwargs["message"], "Redo: Add Person (Jane Doe)" + ) def test_no_push_when_nothing_to_undo(self): self.db.undodb.undo_count = 0 self.db.undodb.undo.return_value = False - with mock.patch.object(self.db, "_push_payload") as push: + with mock.patch.object(self.db, "_start_push") as push: result = self.db.undo() self.assertFalse(result) push.assert_not_called() @@ -1150,7 +2525,7 @@ def test_no_push_when_nothing_to_undo(self): def test_no_push_when_nothing_to_redo(self): self.db.undodb.redo_count = 0 self.db.undodb.redo.return_value = False - with mock.patch.object(self.db, "_push_payload") as push: + with mock.patch.object(self.db, "_start_push") as push: result = self.db.redo() self.assertFalse(result) push.assert_not_called() @@ -1162,7 +2537,7 @@ def test_undo_not_pushed_if_super_reports_nothing_undone(self): self.db.undodb.undo_count = 1 self.db.undodb.undoq = [txn] self.db.undodb.undo.return_value = False - with mock.patch.object(self.db, "_push_payload") as push: + with mock.patch.object(self.db, "_start_push") as push: result = self.db.undo() self.assertFalse(result) push.assert_not_called() @@ -1181,7 +2556,7 @@ def pop_on_undo(update_history): return True self.db.undodb.undo.side_effect = pop_on_undo - with mock.patch.object(self.db, "_push_payload") as push: + with mock.patch.object(self.db, "_start_push") as push: self.db.undo() payload = push.call_args[0][0] self.assertEqual(payload[0]["handle"], "H1") @@ -1217,13 +2592,169 @@ def test_initialize_stores_web_client_and_calls_super(self): super_init.assert_called_once_with("/tmp/some-tree", "user", "pw") +# ------------------------------------------------------------------------- +# +# TestWrapProgressCallback +# +# gui/dbloader.py's uistate.pulse_progressbar(value, text=None) accepts a +# label, turning load()'s already-visible progress bar into a real +# "Syncing with Gramps Web API: NN%" indicator instead of a bare +# percentage; cli/grampscli.py's own callback, _pulse_progress(value), +# takes only the percentage and would raise TypeError if called with a +# second argument. _wrap_progress_callback() picks the right shape per +# callback rather than assuming one. +# +# ------------------------------------------------------------------------- +class TestWrapProgressCallback(unittest.TestCase): + def test_none_callback_stays_none(self): + self.assertIsNone(grampswebapidb._wrap_progress_callback(None, "text")) + + def test_one_arg_callback_is_returned_unwrapped(self): + def one_arg_callback(value): + pass + + wrapped = grampswebapidb._wrap_progress_callback(one_arg_callback, "text") + self.assertIs(wrapped, one_arg_callback) + + def test_two_arg_callback_is_wrapped_with_the_label(self): + seen = [] + + def two_arg_callback(value, text=None): + seen.append((value, text)) + + wrapped = grampswebapidb._wrap_progress_callback( + two_arg_callback, "Syncing with Gramps Web API" + ) + wrapped(42) + self.assertEqual(seen, [(42, "Syncing with Gramps Web API")]) + + def test_uninspectable_callback_falls_back_to_percent_only(self): + # Some callables (a bound method of a C extension type, ...) + # raise from inspect.signature() rather than reporting a shape -- + # the safest default is the plain percent-only call every caller + # is guaranteed to accept. + with mock.patch.object( + grampswebapidb.inspect, + "signature", + side_effect=TypeError("not introspectable"), + ): + callback = mock.MagicMock() + wrapped = grampswebapidb._wrap_progress_callback(callback, "text") + self.assertIs(wrapped, callback) + + +# ------------------------------------------------------------------------- +# +# TestImportProgressUser +# +# _full_resync_async()'s reimport needs a User whose callback actually +# reaches importData()'s internal progress reporting -- gen.user.User +# hardcodes its own no-op callback, discarding whatever is passed to it. +# gui.user.User does not, and is exactly what Gramps' own GUI import uses +# (gui/dbloader.py's DbLoader.do_import()) -- confirmed by direct testing +# against a large export to report real progress without hanging or +# crashing Gramps. See _import_progress_user()'s own docstring. +# +# ------------------------------------------------------------------------- +class TestImportProgressUser(unittest.TestCase): + def test_uses_gui_user_with_a_display(self): + callback = mock.MagicMock() + with mock.patch.object( + grampswebapidb, "has_display", return_value=True + ), mock.patch("gramps.gui.user.User") as gui_user_cls: + user = grampswebapidb._import_progress_user(callback) + gui_user_cls.assert_called_once_with(callback=callback) + self.assertIs(user, gui_user_cls.return_value) + + def test_falls_back_to_the_inert_user_without_a_display(self): + # CLI use: no Gtk to build a gui.user.User with in the first + # place, and nothing to paint progress on anyway. + callback = mock.MagicMock() + with mock.patch.object(grampswebapidb, "has_display", return_value=False): + user = grampswebapidb._import_progress_user(callback) + self.assertIsInstance(user, grampswebapidb.User) + + def test_falls_back_to_the_inert_user_if_gui_user_is_not_importable(self): + # has_display() can be True (a display exists) while PyGObject + # itself still isn't installed -- gui.user.User pulls in Gtk at + # import time, which this DATABASE plugin must stay usable + # without. + callback = mock.MagicMock() + with mock.patch.object( + grampswebapidb, "has_display", return_value=True + ), mock.patch.dict(sys.modules, {"gramps.gui.user": None}): + user = grampswebapidb._import_progress_user(callback) + self.assertIsInstance(user, grampswebapidb.User) + + +# ------------------------------------------------------------------------- +# +# TestDescribeConnectionError +# +# _get_json()/_get_binary() re-raise a non-401/429 HTTPError as-is, which +# throws its response body away -- so, absent _http_error_detail(), a +# validation failure the server explained in detail (a raw 422 from +# FastAPI's own request validation, before gramps-web-api's route handler +# even runs) would reach the user as nothing but "HTTP Error 422: +# Unprocessable Entity". See _describe_connection_error()'s docstring. +# +# ------------------------------------------------------------------------- +class TestDescribeConnectionError(unittest.TestCase): + @staticmethod + def _http_error(code, body=None): + fp = io.BytesIO(json.dumps(body).encode()) if body is not None else None + return HTTPError("https://example.com/api/transactions/", code, "x", None, fp) + + def test_403_names_the_permission_problem_instead_of_the_raw_status(self): + message = grampswebapidb._describe_connection_error(self._http_error(403)) + self.assertIn("GRAMPS_WEB_API_KEY", message) + self.assertNotIn("HTTP Error 403", message) + + def test_422_appends_fastapi_detail(self): + err = self._http_error(422, {"detail": [{"msg": "value is not a valid float"}]}) + message = grampswebapidb._describe_connection_error(err) + self.assertIn("HTTP Error 422", message) + self.assertIn("value is not a valid float", message) + + def test_appends_the_apps_own_error_message_shape_too(self): + err = self._http_error(400, {"error": {"message": "Object has changed"}}) + message = grampswebapidb._describe_connection_error(err) + self.assertIn("Object has changed", message) + + def test_appends_flask_jwt_extendeds_msg_shape_too(self): + # What POST /token/refresh/ actually answers with when the stored + # refresh token is expired, revoked, or otherwise rejected. + err = self._http_error(422, {"msg": "Signature verification failed"}) + message = grampswebapidb._describe_connection_error(err) + self.assertIn("Signature verification failed", message) + + def test_no_body_falls_back_to_the_bare_status(self): + message = grampswebapidb._describe_connection_error(self._http_error(500)) + self.assertEqual(message, str(self._http_error(500))) + + def test_unparseable_body_falls_back_to_the_bare_status(self): + err = HTTPError( + "https://example.com/api/transactions/", + 422, + "x", + None, + io.BytesIO(b"not json"), + ) + message = grampswebapidb._describe_connection_error(err) + self.assertEqual(message, str(err)) + + def test_non_http_error_just_stringifies(self): + message = grampswebapidb._describe_connection_error(ValueError("boom")) + self.assertEqual(message, "boom") + + # ------------------------------------------------------------------------- # # TestCheckIdentity # # Nothing but a Family Tree's own name ties its local mirror to one # particular GRAMPS_WEB_API_KEY account (see the module docstring) -- -# _check_identity() requires that name to be "@" for +# _check_identity_async() requires that name to be "@" for # whoever the current key authenticates as, so pointing the key at a # different account while reopening the same tree fails loudly at load() # instead of quietly mixing that account's data into the old mirror. @@ -1238,7 +2769,7 @@ def setUp(self): def test_matching_name_passes(self): self.db.get_dbname = mock.MagicMock(return_value="dblank@hadaly.duckdns.org") - self.db._check_identity() # must not raise + run_check(self.db, "_check_identity_async") # must not raise def test_name_sanitized_the_same_way_dbman_does_still_passes(self): # gramps.gui.dbman's Family Tree Manager replaces "." (among other @@ -1247,17 +2778,17 @@ def test_name_sanitized_the_same_way_dbman_does_still_passes(self): # must accept the sanitized form as a match, not just the literal # "@" string. self.db.get_dbname = mock.MagicMock(return_value="dblank@hadaly_duckdns_org") - self.db._check_identity() # must not raise + run_check(self.db, "_check_identity_async") # must not raise def test_mismatched_name_raises(self): self.db.get_dbname = mock.MagicMock(return_value="Gramps Web API DB") with self.assertRaises(DbConnectionError): - self.db._check_identity() + run_check(self.db, "_check_identity_async") def test_mismatch_error_names_the_typeable_form(self): self.db.get_dbname = mock.MagicMock(return_value="Gramps Web API DB") with self.assertRaises(DbConnectionError) as ctx: - self.db._check_identity() + run_check(self.db, "_check_identity_async") self.assertIn("dblank@hadaly_duckdns_org", str(ctx.exception)) def test_connection_error_resolving_identity_is_wrapped(self): @@ -1266,7 +2797,7 @@ def test_connection_error_resolving_identity_is_wrapped(self): "https://example.com/api/users/-/", 500, "boom", None, None ) with self.assertRaises(DbConnectionError): - self.db._check_identity() + run_check(self.db, "_check_identity_async") # ------------------------------------------------------------------------- @@ -1287,23 +2818,30 @@ def setUp(self): self.db = new_instance() self.db._directory = "/tmp/tree" self.db.web_client = mock.MagicMock() + # load()'s own tests below reach the EXPERIMENTAL bootstrap-resync + # probe (see load()'s own comment) -- these three keep it a no-op + # (mirror not short of the server) so those tests still exercise + # the ordinary wrapped record-sync path they're actually about. + self.db._get_metadata = mock.MagicMock(return_value=[]) + self.db.get_total = mock.MagicMock(return_value=0) + self.db.web_client.get_object_count.return_value = 0 def _grant(self, *perms): self.db.web_client.get_permissions.return_value = list(perms) def test_editor_role_permissions_pass(self): self._grant(*self.ALL_PERMS) - self.db._check_permissions() # must not raise + run_check(self.db, "_check_permissions_async") # must not raise def test_extra_permissions_are_fine(self): # An Owner/Admin has a superset; only the required ones matter. self._grant(*self.ALL_PERMS, "AddUser", "ImportFile") - self.db._check_permissions() # must not raise + run_check(self.db, "_check_permissions_async") # must not raise def test_missing_view_private_raises(self): self._grant("AddObject", "EditObject", "DeleteObject") with self.assertRaises(DbConnectionError) as ctx: - self.db._check_permissions() + run_check(self.db, "_check_permissions_async") self.assertIn("ViewPrivate", str(ctx.exception)) def test_missing_one_write_permission_raises(self): @@ -1311,7 +2849,7 @@ def test_missing_one_write_permission_raises(self): # missing, so a Contributor (AddObject only) cannot push at all. self._grant("ViewPrivate", "AddObject") with self.assertRaises(DbConnectionError) as ctx: - self.db._check_permissions() + run_check(self.db, "_check_permissions_async") message = str(ctx.exception) self.assertIn("EditObject", message) self.assertIn("DeleteObject", message) @@ -1320,73 +2858,79 @@ def test_missing_one_write_permission_raises(self): def test_error_names_the_role_to_ask_for(self): self._grant() with self.assertRaises(DbConnectionError) as ctx: - self.db._check_permissions() + run_check(self.db, "_check_permissions_async") self.assertIn("Editor", str(ctx.exception)) def test_read_only_tree_does_not_need_write_permissions(self): # A tree opened read-only never pushes, so a Member-level account # (ViewPrivate but no write permissions) is enough for it. self._grant("ViewPrivate") - self.db._check_permissions(writable=False) # must not raise + run_check(self.db, "_check_permissions_async", writable=False) # must not raise def test_read_only_tree_still_needs_view_private(self): self._grant("AddObject", "EditObject", "DeleteObject") with self.assertRaises(DbConnectionError): - self.db._check_permissions(writable=False) + run_check(self.db, "_check_permissions_async", writable=False) def test_connection_error_fetching_permissions_is_wrapped(self): self.db.web_client.get_permissions.side_effect = HTTPError( "https://example.com/api/token/refresh/", 500, "boom", None, None ) with self.assertRaises(DbConnectionError): - self.db._check_permissions() + run_check(self.db, "_check_permissions_async") def test_load_checks_permissions_for_a_writable_tree(self): with mock.patch.object(grampswebapidb.SQLite, "load"), mock.patch.object( - self.db, "_check_identity" - ), mock.patch.object(self.db, "_check_permissions") as check, mock.patch.object( - self.db, "_check_server_version" + self.db, "_check_identity_async", side_effect=stub_async_done(True) ), mock.patch.object( - self.db, "_sync_from_server" + self.db, "_check_permissions_async", side_effect=stub_async_done(True) + ) as check, mock.patch.object( + self.db, "_check_server_version_async", side_effect=stub_async_done(True) ), mock.patch.object( - self.db, "_sync_media_files" + self.db, "_sync_from_server_async", side_effect=stub_async_done(0) + ), mock.patch.object( + self.db, "_sync_media_files_async", side_effect=stub_async_done((0, 0)) ), mock.patch.object( grampswebapidb.GLib, "timeout_add_seconds" ): self.db.load("some/path") - check.assert_called_once_with(writable=True) + check.assert_called_once_with(mock.ANY, mock.ANY, writable=True) def test_load_in_read_only_mode_checks_read_permissions_only(self): # DbGeneric.load()'s signature is (directory, callback, mode, ...) -- # cli/grampscli.py passes mode positionally. with mock.patch.object(grampswebapidb.SQLite, "load"), mock.patch.object( - self.db, "_check_identity" - ), mock.patch.object(self.db, "_check_permissions") as check, mock.patch.object( - self.db, "_check_server_version" + self.db, "_check_identity_async", side_effect=stub_async_done(True) + ), mock.patch.object( + self.db, "_check_permissions_async", side_effect=stub_async_done(True) + ) as check, mock.patch.object( + self.db, "_check_server_version_async", side_effect=stub_async_done(True) ), mock.patch.object( - self.db, "_sync_from_server" + self.db, "_sync_from_server_async", side_effect=stub_async_done(0) ), mock.patch.object( - self.db, "_sync_media_files" + self.db, "_sync_media_files_async", side_effect=stub_async_done((0, 0)) ), mock.patch.object( grampswebapidb.GLib, "timeout_add_seconds" ): self.db.load("some/path", None, "r") - check.assert_called_once_with(writable=False) + check.assert_called_once_with(mock.ANY, mock.ANY, writable=False) def test_load_reads_mode_from_a_keyword_too(self): with mock.patch.object(grampswebapidb.SQLite, "load"), mock.patch.object( - self.db, "_check_identity" - ), mock.patch.object(self.db, "_check_permissions") as check, mock.patch.object( - self.db, "_check_server_version" + self.db, "_check_identity_async", side_effect=stub_async_done(True) ), mock.patch.object( - self.db, "_sync_from_server" + self.db, "_check_permissions_async", side_effect=stub_async_done(True) + ) as check, mock.patch.object( + self.db, "_check_server_version_async", side_effect=stub_async_done(True) ), mock.patch.object( - self.db, "_sync_media_files" + self.db, "_sync_from_server_async", side_effect=stub_async_done(0) + ), mock.patch.object( + self.db, "_sync_media_files_async", side_effect=stub_async_done((0, 0)) ), mock.patch.object( grampswebapidb.GLib, "timeout_add_seconds" ): self.db.load("some/path", mode="r") - check.assert_called_once_with(writable=False) + check.assert_called_once_with(mock.ANY, mock.ANY, writable=False) # ------------------------------------------------------------------------- @@ -1406,16 +2950,16 @@ def setUp(self): def test_supported_version_passes(self): self.db.web_client.get_gramps_version.return_value = "6.0.1" - self.db._check_server_version() # must not raise + run_check(self.db, "_check_server_version_async") # must not raise def test_newer_version_passes(self): self.db.web_client.get_gramps_version.return_value = "6.1.0" - self.db._check_server_version() # must not raise + run_check(self.db, "_check_server_version_async") # must not raise def test_too_old_raises_naming_both_versions(self): self.db.web_client.get_gramps_version.return_value = "5.2.3" with self.assertRaises(DbConnectionError) as ctx: - self.db._check_server_version() + run_check(self.db, "_check_server_version_async") message = str(ctx.exception) self.assertIn("5.2.3", message) self.assertIn("6.0", message) @@ -1424,18 +2968,18 @@ def test_unknown_version_is_allowed_through(self): # Better to try and let the KeyError path catch a genuinely # incompatible server than to block on a guess. self.db.web_client.get_gramps_version.return_value = None - self.db._check_server_version() # must not raise + run_check(self.db, "_check_server_version_async") # must not raise def test_unparseable_version_is_allowed_through(self): self.db.web_client.get_gramps_version.return_value = "some-dev-build" - self.db._check_server_version() # must not raise + run_check(self.db, "_check_server_version_async") # must not raise def test_connection_error_is_wrapped(self): self.db.web_client.get_gramps_version.side_effect = HTTPError( "https://example.com/api/metadata/", 500, "boom", None, None ) with self.assertRaises(DbConnectionError): - self.db._check_server_version() + run_check(self.db, "_check_server_version_async") # ------------------------------------------------------------------------- @@ -1483,7 +3027,9 @@ def test_push_payload_passes_the_flag_through(self): self.db._set_metadata = lambda key, value, use_txn=True: None self.db.web_client.supports_background_transactions.return_value = True payload = self._payload(grampswebapidb.BACKGROUND_PUSH_THRESHOLD) - self.db._push_payload(payload) + self.db._push_payload_async( + payload, on_done=lambda _: None, on_error=lambda _: None + ) self.assertTrue( self.db.web_client.push_transaction.call_args.kwargs["background"] ) @@ -1536,28 +3082,52 @@ def test_network_errors_are_retryable(self): class TestPolling(unittest.TestCase): def setUp(self): self.db = new_instance() + # _reschedule_poll() removes the previous timer explicitly (see + # its own docstring) -- a real placeholder id so that call has + # something to pass to GLib.source_remove() in tests that don't + # mock it away themselves. + self.db._poll_source_id = 1 + # _finish_async_op() (wrapping _poll_tick()'s/_media_poll_tick()'s + # on_done/on_error) checks the pending-push queue on the way out + # -- see its own docstring -- which touches _get_metadata(). + self.metadata = {} + self.db._get_metadata = lambda key, default=0: self.metadata.get(key, default) + self.db._set_metadata = ( + lambda key, value, use_txn=True: self.metadata.__setitem__(key, value) + ) + # load()'s own tests below reach the EXPERIMENTAL bootstrap-resync + # probe (see load()'s own comment) -- these two keep it a no-op + # (mirror not short of the server) so those tests still exercise + # the ordinary wrapped record-sync path they're actually about. + self.db.get_total = mock.MagicMock(return_value=0) + self.db.web_client = mock.MagicMock() + self.db.web_client.get_object_count.return_value = 0 def test_load_syncs_and_schedules_polling(self): with mock.patch.object( grampswebapidb.SQLite, "load" ) as super_load, mock.patch.object( - self.db, "_check_identity" + self.db, "_check_identity_async", side_effect=stub_async_done(True) ) as check_identity, mock.patch.object( - self.db, "_check_permissions" + self.db, "_check_permissions_async", side_effect=stub_async_done(True) ), mock.patch.object( - self.db, "_check_server_version" + self.db, "_check_server_version_async", side_effect=stub_async_done(True) ), mock.patch.object( - self.db, "_sync_from_server" + self.db, "_sync_from_server_async", side_effect=stub_async_done(0) ) as sync, mock.patch.object( - self.db, "_sync_media_files" + self.db, "_sync_media_files_async", side_effect=stub_async_done((0, 0)) ) as sync_media, mock.patch.object( grampswebapidb.GLib, "timeout_add_seconds", side_effect=[42, 43] ) as timeout_add: self.db.load("some/path") super_load.assert_called_once_with("some/path") - check_identity.assert_called_once_with() - sync.assert_called_once_with(progress_callback=None) - sync_media.assert_called_once_with() + check_identity.assert_called_once_with(mock.ANY, mock.ANY) + # Called with on_done/on_error closures now, not no-args -- see + # _run_async_to_completion(). + sync.assert_called_once_with( + mock.ANY, mock.ANY, progress_callback=None, verify_totals=True + ) + self.assertEqual(sync_media.call_count, 1) timeout_add.assert_has_calls( [ mock.call(grampswebapidb.POLL_INTERVAL_SECONDS, self.db._poll_tick), @@ -1570,6 +3140,65 @@ def test_load_syncs_and_schedules_polling(self): self.assertEqual(self.db._poll_source_id, 42) self.assertEqual(self.db._media_poll_source_id, 43) + def test_load_reports_staged_progress_through_the_pre_checks(self): + # Each of the three load()-time checks previously reported + # nothing at all -- confirmed live (2026-08-17) that this left + # the progress bar motionless for a real, noticeable stretch + # before the record sync (or a bootstrap resync) got a chance to + # report anything of its own. A fixed, small percentage after + # each check at least proves the app is still working. + my_callback = mock.MagicMock() + with mock.patch.object(grampswebapidb.SQLite, "load"), mock.patch.object( + self.db, "_check_identity_async", side_effect=stub_async_done(True) + ), mock.patch.object( + self.db, "_check_permissions_async", side_effect=stub_async_done(True) + ), mock.patch.object( + self.db, "_check_server_version_async", side_effect=stub_async_done(True) + ), mock.patch.object( + self.db, "_sync_from_server_async", side_effect=stub_async_done(0) + ), mock.patch.object( + self.db, "_sync_media_files_async", side_effect=stub_async_done((0, 0)) + ), mock.patch.object( + grampswebapidb.GLib, "timeout_add_seconds" + ): + self.db.load("some/path", my_callback, "w") + reported = [ + call.args[0] + for call in my_callback.call_args_list + if call.args[0] in (5, 10, 15) + ] + self.assertEqual(reported, [5, 10, 15]) + + def test_load_reports_20_percent_before_a_bootstrap_resync(self): + my_callback = mock.MagicMock() + self.db.get_total = mock.MagicMock(return_value=0) + self.db.web_client.get_object_count.return_value = 26540 + with mock.patch.object(grampswebapidb.SQLite, "load"), mock.patch.object( + self.db, "_check_identity_async", side_effect=stub_async_done(True) + ), mock.patch.object( + self.db, "_check_permissions_async", side_effect=stub_async_done(True) + ), mock.patch.object( + self.db, "_check_server_version_async", side_effect=stub_async_done(True) + ), mock.patch.object( + self.db, "_bootstrap_full_resync" + ) as bootstrap, mock.patch.object( + self.db, "_sync_media_files_async", side_effect=stub_async_done((0, 0)) + ), mock.patch.object( + grampswebapidb.GLib, "timeout_add_seconds" + ): + self.db.load("some/path", my_callback, "w") + reported = [call.args[0] for call in my_callback.call_args_list] + self.assertIn(20, reported) + # _bootstrap_full_resync() is called with load()'s own wrapped + # callback (which labels the progress bar -- see + # _wrap_progress_callback()), not my_callback directly; confirm + # it still ultimately forwards to my_callback. + bootstrap.assert_called_once_with(mock.ANY) + wrapped_callback = bootstrap.call_args.args[0] + my_callback.reset_mock() + wrapped_callback(55) + my_callback.assert_called_once_with(55, "Syncing with Gramps Web API") + def test_load_forwards_positional_callback_to_sync(self): # DbGeneric.load()'s own signature is (directory, callback=None, # mode=..., ...) -- cli/grampscli.py calls it positionally @@ -1577,34 +3206,53 @@ def test_load_forwards_positional_callback_to_sync(self): # must recognize the callback there too, not just as a kwarg. my_callback = mock.MagicMock() with mock.patch.object(grampswebapidb.SQLite, "load"), mock.patch.object( - self.db, "_check_identity" - ), mock.patch.object(self.db, "_check_permissions"), mock.patch.object( - self.db, "_check_server_version" + self.db, "_check_identity_async", side_effect=stub_async_done(True) + ), mock.patch.object( + self.db, "_check_permissions_async", side_effect=stub_async_done(True) + ), mock.patch.object( + self.db, "_check_server_version_async", side_effect=stub_async_done(True) ), mock.patch.object( - self.db, "_sync_from_server" + self.db, "_sync_from_server_async", side_effect=stub_async_done(0) ) as sync, mock.patch.object( - self.db, "_sync_media_files" + self.db, "_sync_media_files_async", side_effect=stub_async_done((0, 0)) ), mock.patch.object( grampswebapidb.GLib, "timeout_add_seconds" ): self.db.load("some/path", my_callback, "w") - sync.assert_called_once_with(progress_callback=my_callback) + sync.assert_called_once_with( + mock.ANY, mock.ANY, progress_callback=mock.ANY, verify_totals=True + ) + # The callback load() actually forwards is wrapped (see + # _wrap_progress_callback()) to label the progress bar -- confirm + # it still ultimately calls through to my_callback. Reset first: + # the 5/10/15 pre-check stage reports (see load()'s own comment) + # already called my_callback a few times before this point. + my_callback.reset_mock() + sync.call_args.kwargs["progress_callback"](42) + my_callback.assert_called_once_with(42, "Syncing with Gramps Web API") def test_load_forwards_keyword_callback_to_sync(self): my_callback = mock.MagicMock() with mock.patch.object(grampswebapidb.SQLite, "load"), mock.patch.object( - self.db, "_check_identity" - ), mock.patch.object(self.db, "_check_permissions"), mock.patch.object( - self.db, "_check_server_version" + self.db, "_check_identity_async", side_effect=stub_async_done(True) + ), mock.patch.object( + self.db, "_check_permissions_async", side_effect=stub_async_done(True) ), mock.patch.object( - self.db, "_sync_from_server" + self.db, "_check_server_version_async", side_effect=stub_async_done(True) + ), mock.patch.object( + self.db, "_sync_from_server_async", side_effect=stub_async_done(0) ) as sync, mock.patch.object( - self.db, "_sync_media_files" + self.db, "_sync_media_files_async", side_effect=stub_async_done((0, 0)) ), mock.patch.object( grampswebapidb.GLib, "timeout_add_seconds" ): self.db.load("some/path", callback=my_callback) - sync.assert_called_once_with(progress_callback=my_callback) + sync.assert_called_once_with( + mock.ANY, mock.ANY, progress_callback=mock.ANY, verify_totals=True + ) + my_callback.reset_mock() + sync.call_args.kwargs["progress_callback"](42) + my_callback.assert_called_once_with(42, "Syncing with Gramps Web API") def test_load_media_sync_failure_does_not_block_load(self): # Unlike a _sync_from_server() failure (which load() re-raises as @@ -1612,22 +3260,55 @@ def test_load_media_sync_failure_does_not_block_load(self): # swallowed -- the record mirror is already usable, so opening the # tree should still succeed. with mock.patch.object(grampswebapidb.SQLite, "load"), mock.patch.object( - self.db, "_check_identity" - ), mock.patch.object(self.db, "_check_permissions"), mock.patch.object( - self.db, "_check_server_version" + self.db, "_check_identity_async", side_effect=stub_async_done(True) ), mock.patch.object( - self.db, "_sync_from_server" + self.db, "_check_permissions_async", side_effect=stub_async_done(True) ), mock.patch.object( - self.db, "_sync_media_files", side_effect=OSError("network down") + self.db, "_check_server_version_async", side_effect=stub_async_done(True) + ), mock.patch.object( + self.db, "_sync_from_server_async", side_effect=stub_async_done(0) + ), mock.patch.object( + self.db, + "_sync_media_files_async", + side_effect=stub_async_error(OSError("network down")), ), mock.patch.object( grampswebapidb.GLib, "timeout_add_seconds" ): with self.assertLogs(grampswebapidb.LOG, level="ERROR"): self.db.load("some/path") # must not raise + # That traceback is this outage's one loud report -- the media + # poll should stay quiet rather than repeat it 300 seconds later. + self.assertEqual(self.db._media_poll_failures, 1) + + def test_load_resets_poll_backoff_state(self): + # Class-attribute defaults, so an instance reused across a + # close()/load() must not start out backed off from the previous + # tree's outage. + self.db._poll_failures = 4 + self.db._media_poll_failures = 4 + self.db._poll_interval = grampswebapidb.POLL_BACKOFF_MAX_SECONDS + with mock.patch.object(grampswebapidb.SQLite, "load"), mock.patch.object( + self.db, "_check_identity_async", side_effect=stub_async_done(True) + ), mock.patch.object( + self.db, "_check_permissions_async", side_effect=stub_async_done(True) + ), mock.patch.object( + self.db, "_check_server_version_async", side_effect=stub_async_done(True) + ), mock.patch.object( + self.db, "_sync_from_server_async", side_effect=stub_async_done(0) + ), mock.patch.object( + self.db, "_sync_media_files_async", side_effect=stub_async_done((0, 0)) + ), mock.patch.object( + grampswebapidb.GLib, "timeout_add_seconds" + ): + self.db.load("some/path") + self.assertEqual(self.db._poll_failures, 0) + self.assertEqual(self.db._media_poll_failures, 0) + self.assertEqual(self.db._poll_interval, grampswebapidb.POLL_INTERVAL_SECONDS) def test_close_cancels_pending_poll(self): self.db._poll_source_id = 42 self.db._media_poll_source_id = 43 + starting_run_id = self.db._run_id with mock.patch.object( grampswebapidb.SQLite, "close" ) as super_close, mock.patch.object( @@ -1638,10 +3319,13 @@ def test_close_cancels_pending_poll(self): self.assertIsNone(self.db._poll_source_id) self.assertIsNone(self.db._media_poll_source_id) super_close.assert_called_once_with() + self.assertEqual(self.db._run_id, starting_run_id + 1) def test_close_without_a_poll_scheduled_is_a_no_op(self): # e.g. close() called after a failed load(), before the timeouts # were ever scheduled. + del self.db._poll_source_id # undo setUp()'s placeholder + starting_run_id = self.db._run_id with mock.patch.object( grampswebapidb.SQLite, "close" ) as super_close, mock.patch.object( @@ -1650,34 +3334,353 @@ def test_close_without_a_poll_scheduled_is_a_no_op(self): self.db.close() source_remove.assert_not_called() super_close.assert_called_once_with() + self.assertEqual(self.db._run_id, starting_run_id + 1) + + def test_close_resets_syncing_pulling_retrying_flags(self): + # A dropped _guarded() callback (or a raised _DatabaseClosed from + # _guarded_pump()) never reaches the `finally` blocks that would + # otherwise clear these -- close() must reset them itself so a + # reused instance's next load() doesn't start out believing an + # abandoned operation from the previous tree is still in flight. + self.db._syncing = True + self.db._pulling = True + self.db._retrying = True + with mock.patch.object(grampswebapidb.SQLite, "close"), mock.patch.object( + grampswebapidb.GLib, "source_remove" + ): + self.db.close() + self.assertFalse(self.db._syncing) + self.assertFalse(self.db._pulling) + self.assertFalse(self.db._retrying) def test_poll_tick_syncs_and_keeps_repeating(self): - with mock.patch.object(self.db, "_sync_from_server") as sync: + with mock.patch.object( + self.db, "_sync_from_server_async", side_effect=stub_async_done(0) + ) as sync: + result = self.db._poll_tick() + self.assertEqual(sync.call_count, 1) + self.assertEqual(result, grampswebapidb.GLib.SOURCE_CONTINUE) + # on_done fired synchronously (InlineTaskRunner via the mock's own + # stub_async_done), so the flag this tick claimed is released. + self.assertFalse(self.db._syncing) + + def test_poll_tick_does_not_start_a_sync_underneath_a_running_one(self): + self.db._syncing = True + with mock.patch.object(self.db, "_sync_from_server_async") as sync: result = self.db._poll_tick() - sync.assert_called_once_with() + sync.assert_not_called() self.assertEqual(result, grampswebapidb.GLib.SOURCE_CONTINUE) - def test_poll_tick_swallows_connection_errors_and_keeps_repeating(self): + def test_media_poll_tick_does_not_start_a_sync_underneath_a_running_one(self): + self.db._syncing = True + with mock.patch.object(self.db, "_sync_media_files_async") as sync_media: + result = self.db._media_poll_tick() + sync_media.assert_not_called() + self.assertEqual(result, grampswebapidb.GLib.SOURCE_CONTINUE) + + def test_poll_tick_swallows_connection_errors_and_keeps_polling(self): + # Unlike the old synchronous version, _poll_tick() itself always + # returns GLib.SOURCE_CONTINUE immediately -- the backoff + # reschedule (a new, longer-interval timer replacing the current + # one) happens later, from _on_poll_error(), via + # _reschedule_poll()'s own explicit GLib.source_remove() + + # GLib.timeout_add_seconds() pair. + self.db._poll_interval = grampswebapidb.POLL_INTERVAL_SECONDS with mock.patch.object( - self.db, "_sync_from_server", side_effect=OSError("network down") + self.db, + "_sync_from_server_async", + side_effect=stub_async_error(OSError("network down")), + ), mock.patch.object( + grampswebapidb.GLib, "timeout_add_seconds", return_value=99 + ) as timeout_add, mock.patch.object( + grampswebapidb.GLib, "source_remove" + ) as source_remove: + with self.assertLogs(grampswebapidb.LOG, level="WARNING"): + result = self.db._poll_tick() + self.assertEqual(result, grampswebapidb.GLib.SOURCE_CONTINUE) + source_remove.assert_called_once_with(1) # setUp()'s placeholder id + timeout_add.assert_called_once_with( + grampswebapidb.POLL_INTERVAL_SECONDS * 2, self.db._poll_tick + ) + self.assertEqual(self.db._poll_source_id, 99) + self.assertEqual( + self.db._poll_interval, grampswebapidb.POLL_INTERVAL_SECONDS * 2 + ) + self.assertEqual(self.db._poll_failures, 1) + + def test_poll_tick_reports_a_lasting_outage_only_once(self): + # A server that stays down must not log a warning (let alone a + # traceback) on every tick for the whole outage. + with mock.patch.object( + self.db, + "_sync_from_server_async", + side_effect=stub_async_error(OSError("network down")), + ), mock.patch.object( + grampswebapidb.GLib, "timeout_add_seconds" + ), mock.patch.object( + grampswebapidb.GLib, "source_remove" ): - with self.assertLogs(grampswebapidb.LOG, level="ERROR"): + with self.assertLogs(grampswebapidb.LOG, level="DEBUG") as logs: + for _unused in range(5): + self.db._poll_tick() + warnings = [rec for rec in logs.records if rec.levelname == "WARNING"] + self.assertEqual(len(warnings), 1) + self.assertEqual(self.db._poll_failures, 5) + + def test_poll_tick_backs_off_to_the_cap_and_stops_rescheduling(self): + with mock.patch.object( + self.db, + "_sync_from_server_async", + side_effect=stub_async_error(OSError("network down")), + ), mock.patch.object( + grampswebapidb.GLib, "timeout_add_seconds" + ) as timeout_add, mock.patch.object( + grampswebapidb.GLib, "source_remove" + ): + with self.assertLogs(grampswebapidb.LOG, level="WARNING"): + for _unused in range(20): + result = self.db._poll_tick() + self.assertEqual( + self.db._poll_interval, grampswebapidb.POLL_BACKOFF_MAX_SECONDS + ) + self.assertEqual(result, grampswebapidb.GLib.SOURCE_CONTINUE) + intervals = [call.args[0] for call in timeout_add.call_args_list] + self.assertEqual(intervals, sorted(intervals)) + self.assertLessEqual(intervals[-1], grampswebapidb.POLL_BACKOFF_MAX_SECONDS) + + def test_poll_tick_restores_the_normal_interval_after_recovery(self): + self.db._poll_failures = 3 + self.db._poll_interval = grampswebapidb.POLL_BACKOFF_MAX_SECONDS + with mock.patch.object( + self.db, "_sync_from_server_async", side_effect=stub_async_done(0) + ), mock.patch.object( + grampswebapidb.GLib, "timeout_add_seconds", return_value=7 + ) as timeout_add, mock.patch.object( + grampswebapidb.GLib, "source_remove" + ) as source_remove: + with self.assertLogs(grampswebapidb.LOG, level="INFO"): result = self.db._poll_tick() self.assertEqual(result, grampswebapidb.GLib.SOURCE_CONTINUE) + source_remove.assert_called_once_with(1) # setUp()'s placeholder id + timeout_add.assert_called_once_with( + grampswebapidb.POLL_INTERVAL_SECONDS, self.db._poll_tick + ) + self.assertEqual(self.db._poll_source_id, 7) + self.assertEqual(self.db._poll_interval, grampswebapidb.POLL_INTERVAL_SECONDS) + self.assertEqual(self.db._poll_failures, 0) def test_media_poll_tick_syncs_and_keeps_repeating(self): - with mock.patch.object(self.db, "_sync_media_files") as sync_media: + with mock.patch.object( + self.db, "_sync_media_files_async", side_effect=stub_async_done((0, 0)) + ) as sync_media: result = self.db._media_poll_tick() - sync_media.assert_called_once_with() + self.assertEqual(sync_media.call_count, 1) self.assertEqual(result, grampswebapidb.GLib.SOURCE_CONTINUE) + # on_done fired synchronously (InlineTaskRunner), so the flag this + # poll claimed is already released. + self.assertFalse(self.db._syncing) def test_media_poll_tick_swallows_connection_errors_and_keeps_repeating(self): with mock.patch.object( - self.db, "_sync_media_files", side_effect=OSError("network down") + self.db, + "_sync_media_files_async", + side_effect=stub_async_error(OSError("network down")), ): - with self.assertLogs(grampswebapidb.LOG, level="ERROR"): + with self.assertLogs(grampswebapidb.LOG, level="WARNING"): result = self.db._media_poll_tick() self.assertEqual(result, grampswebapidb.GLib.SOURCE_CONTINUE) + self.assertEqual(self.db._media_poll_failures, 1) + + def test_media_poll_tick_reports_a_lasting_outage_only_once(self): + with mock.patch.object( + self.db, + "_sync_media_files_async", + side_effect=stub_async_error(OSError("network down")), + ): + with self.assertLogs(grampswebapidb.LOG, level="DEBUG") as logs: + for _unused in range(4): + result = self.db._media_poll_tick() + warnings = [rec for rec in logs.records if rec.levelname == "WARNING"] + self.assertEqual(len(warnings), 1) + self.assertEqual(result, grampswebapidb.GLib.SOURCE_CONTINUE) + self.assertEqual(self.db._media_poll_failures, 4) + + def test_media_poll_tick_notes_recovery(self): + self.db._media_poll_failures = 2 + with mock.patch.object( + self.db, "_sync_media_files_async", side_effect=stub_async_done((0, 0)) + ): + with self.assertLogs(grampswebapidb.LOG, level="INFO"): + result = self.db._media_poll_tick() + self.assertEqual(result, grampswebapidb.GLib.SOURCE_CONTINUE) + self.assertEqual(self.db._media_poll_failures, 0) + + def test_poll_tick_drops_a_result_delivered_after_close(self): + # Unlike the old pump-based mechanism (see TestGuardedPump), + # nothing in this chain pumps the main loop anymore, so nothing + # raises _DatabaseClosed here -- self._guarded() (wrapping + # _sync_from_server_async()'s internal callbacks) drops the + # result instead. close() already removes this timer's GLib + # source directly (test_close_cancels_pending_poll); what this + # covers is that a sync already in flight when close() runs + # must not touch _poll_failures/_syncing for a tree it no + # longer owns once its result comes back. Exercises the real + # chain (not a mocked _sync_from_server_async) since + # self._guarded() lives inside it. + self.db.web_client = mock.MagicMock() + + def close_mid_fetch(**kwargs): + self.db._run_id += 1 + return ([], 0) + + self.db.web_client.get_transaction_history.side_effect = close_mid_fetch + result = self.db._poll_tick() + self.assertEqual(result, grampswebapidb.GLib.SOURCE_CONTINUE) + self.assertEqual(self.db._poll_failures, 0) + # Dropped, not delivered: _on_poll_success() never ran, so + # _syncing (only cleared there) is still True -- close() resets + # it directly instead (see TestClose). + self.assertTrue(self.db._syncing) + + def test_media_poll_tick_drops_a_result_delivered_after_close(self): + # Unlike _poll_tick(), _media_poll_tick() no longer needs to + # notice a mid-sync close() and stop its own timer -- close() + # already does that directly (test_close_cancels_pending_poll). + # What it must still get right: a media sync already in flight + # when close() runs must not touch _media_poll_failures/_syncing + # for a tree it no longer owns once its result comes back -- + # _guarded() (wrapping _sync_media_files_async()'s internal + # callbacks) drops it instead. Exercises the real chain (not a + # mocked _sync_media_files_async) since _guarded() lives inside + # it, not in _media_poll_tick() itself. + self.db.web_client = mock.MagicMock() + + def fake_get_missing_files(): + self.db._run_id += 1 # simulates close() running before this fires + return [] + + self.db.web_client.get_missing_files.side_effect = fake_get_missing_files + with mock.patch.object(self.db, "iter_media", return_value=[]): + result = self.db._media_poll_tick() + self.assertEqual(result, grampswebapidb.GLib.SOURCE_CONTINUE) + self.assertEqual(self.db._media_poll_failures, 0) + # Dropped, not delivered: _on_media_poll_success() never ran, so + # _syncing (only cleared there) is still True -- close() resets + # it directly instead (see TestClose). + self.assertTrue(self.db._syncing) + + +# ------------------------------------------------------------------------- +# +# TestPumpMainLoop +# +# Regression coverage for a second crash a PR tester hit: a plain local +# edit ("Add Attribute") conflicted, _push_payload()'s WebApiPushConflict +# handler correctly ran a full resync to recover, and a HandleError raised +# by a PeopleView redraw dispatched off the resync's trailing +# _guarded_pump() call propagated all the way up and killed Gramps -- +# after the resync itself had already succeeded. See _pump_main_loop()'s +# docstring: GLib.MainContext.iteration() dispatches arbitrary pending +# GTK/GLib callbacks this addon does not own, and unlike Gramps' own +# Callback.emit() (which already logs-and-continues on a handler +# exception), iteration() has no such protection built in. +# +# ------------------------------------------------------------------------- +class TestPumpMainLoop(unittest.TestCase): + # _pump_main_loop() dispatches at most one source per call, blocking + # (via context.iteration(True)) until one is ready rather than + # busy-spinning over context.pending() -- see its own docstring for + # why (confirmed live: the old spin-while-pending loop pinned a CPU + # core for the whole time _run_async_to_completion() waited on a + # worker thread, competing with that thread for the GIL). + def test_an_exception_from_a_dispatched_callback_is_logged_not_raised(self): + context = mock.Mock() + context.iteration.side_effect = RuntimeError("boom, from unrelated GUI code") + with mock.patch.object( + grampswebapidb.GLib.MainContext, "default", return_value=context + ): + with self.assertLogs(grampswebapidb.LOG.name, level="ERROR"): + grampswebapidb._pump_main_loop() # must not raise + context.iteration.assert_called_once_with(True) + + def test_dispatches_exactly_one_source_per_call(self): + context = mock.Mock() + with mock.patch.object( + grampswebapidb.GLib.MainContext, "default", return_value=context + ): + grampswebapidb._pump_main_loop() + context.iteration.assert_called_once_with(True) + + +# ------------------------------------------------------------------------- +# +# TestGuardedPump +# +# _guarded_pump() is what every _pump_main_loop() call inside WebApiDB +# goes through instead of the bare function -- see the module docstring's +# "Keeping the GUI alive" section. Regression coverage for the crash a PR +# tester hit: switching Family Trees while a poll-driven sync was +# suspended mid-_pump_main_loop() resumed against an already-closed +# sqlite connection (sqlite3.ProgrammingError: Cannot operate on a closed +# database). +# +# ------------------------------------------------------------------------- +class TestGuardedPump(unittest.TestCase): + def setUp(self): + self.db = new_instance() + + def test_pumps_and_returns_when_still_open(self): + with mock.patch.object(grampswebapidb, "_pump_main_loop") as pump: + self.db._guarded_pump() # must not raise + pump.assert_called_once_with() + + def test_raises_database_closed_if_close_ran_during_the_pump(self): + def fake_pump(): + # Simulates close() running from a GTK event dispatched while + # this pump had control -- see close()'s own self._run_id += 1. + self.db._run_id += 1 + + with mock.patch.object( + grampswebapidb, "_pump_main_loop", side_effect=fake_pump + ): + with self.assertRaises(grampswebapidb._DatabaseClosed): + self.db._guarded_pump() + + +# ------------------------------------------------------------------------- +# +# TestGuarded +# +# _guarded() is the async equivalent of _guarded_pump(): it wraps an +# on_success/on_error handed to self.runner.run()/self.io_runner.run() so a +# callback belonging to a chain close() abandoned is silently dropped +# instead of resuming and touching a self.dbapi that's already gone. Not +# used by any real call site yet (introduced in phase 1 alongside _run_id, +# wired up starting phase 2) -- these tests cover the method itself. +# +# ------------------------------------------------------------------------- +class TestGuarded(unittest.TestCase): + def setUp(self): + self.db = new_instance() + + def test_callback_runs_when_run_id_is_unchanged(self): + seen = [] + wrapped = self.db._guarded(seen.append) + wrapped("value") + self.assertEqual(seen, ["value"]) + + def test_callback_is_dropped_when_run_id_changed_since_wrapping(self): + seen = [] + wrapped = self.db._guarded(seen.append) + self.db._run_id += 1 # simulates close() running before this fires + wrapped("value") + self.assertEqual(seen, []) + + def test_drop_is_logged_at_debug(self): + wrapped = self.db._guarded(lambda value: None) + self.db._run_id += 1 + with self.assertLogs(grampswebapidb.LOG.name, level="DEBUG"): + wrapped("value") # ------------------------------------------------------------------------- @@ -1701,200 +3704,171 @@ def get_path(self): return self._path -class TestMissingLocalMediaHandles(unittest.TestCase): +class TestScanAndResolveMedia(unittest.TestCase): def setUp(self): self.db = new_instance() - def test_returns_handles_whose_file_is_missing(self): + def test_returns_missing_local_and_missing_remote_as_handle_path_pairs(self): present = FakeMedia("H1", "present.jpg") missing = FakeMedia("H2", "missing.jpg") + remote_media = FakeMedia("H3", "remote.jpg") with mock.patch.object( self.db, "iter_media", return_value=[present, missing] + ), mock.patch.object( + self.db, "get_media_from_handle", return_value=remote_media ), mock.patch.object( grampswebapidb, "media_path_full", side_effect=lambda db, path: "/tree/" + path, ), mock.patch.object( - grampswebapidb.os.path, "exists", side_effect=lambda p: "present" in p + grampswebapidb.os.path, "exists", side_effect=lambda p: "missing" not in p ): - handles = self.db._missing_local_media_handles() - self.assertEqual(handles, ["H2"]) + missing_local, missing_remote = self.db._scan_and_resolve_media( + [{"handle": "H3"}] + ) + self.assertEqual(missing_local, [("H2", "/tree/missing.jpg")]) + self.assertEqual(missing_remote, [("H3", "/tree/remote.jpg")]) - def test_no_media_objects_returns_empty_list(self): + def test_no_media_objects_and_no_remote_missing_returns_empty_lists(self): with mock.patch.object(self.db, "iter_media", return_value=[]): - self.assertEqual(self.db._missing_local_media_handles(), []) + missing_local, missing_remote = self.db._scan_and_resolve_media([]) + self.assertEqual(missing_local, []) + self.assertEqual(missing_remote, []) - -class TestMissingRemoteMediaHandles(unittest.TestCase): - def setUp(self): - self.db = new_instance() - - def test_extracts_handles_from_server_response(self): - self.db.web_client = mock.MagicMock() - self.db.web_client.get_missing_files.return_value = [ - {"handle": "H1", "gramps_id": "O0001"}, - {"handle": "H2", "gramps_id": "O0002"}, - ] - self.assertEqual(self.db._missing_remote_media_handles(), ["H1", "H2"]) - - def test_empty_server_response(self): - self.db.web_client = mock.MagicMock() - self.db.web_client.get_missing_files.return_value = [] - self.assertEqual(self.db._missing_remote_media_handles(), []) - - -class TestDownloadOneMediaFile(unittest.TestCase): - def setUp(self): - self.db = new_instance() - self.db.web_client = mock.MagicMock() - - def test_downloads_and_returns_true(self): - media = FakeMedia("H1", "photo.jpg") + def test_remote_missing_handle_removed_locally_is_skipped(self): with mock.patch.object( - self.db, "get_media_from_handle", return_value=media + self.db, "iter_media", return_value=[] ), mock.patch.object( - grampswebapidb, "media_path_full", return_value="/tree/photo.jpg" - ): - result = self.db._download_one_media_file("H1") - self.assertTrue(result) - self.db.web_client.download_media_file.assert_called_once_with( - "H1", "/tree/photo.jpg" - ) - - def test_missing_local_object_returns_false(self): - with mock.patch.object( self.db, "get_media_from_handle", side_effect=grampswebapidb.HandleError("H1"), ): - result = self.db._download_one_media_file("H1") - self.assertFalse(result) - self.db.web_client.download_media_file.assert_not_called() + _, missing_remote = self.db._scan_and_resolve_media([{"handle": "H1"}]) + self.assertEqual(missing_remote, []) - def test_connection_error_is_logged_and_returns_false(self): + def test_remote_missing_handle_whose_local_file_is_also_missing_is_excluded(self): + # Nothing to upload if the local file the server wants isn't + # actually on disk -- matches the old _upload_one_media_file()'s + # "not os.path.exists(path): return False" skip. media = FakeMedia("H1", "photo.jpg") - self.db.web_client.download_media_file.side_effect = OSError("network down") with mock.patch.object( + self.db, "iter_media", return_value=[] + ), mock.patch.object( self.db, "get_media_from_handle", return_value=media ), mock.patch.object( grampswebapidb, "media_path_full", return_value="/tree/photo.jpg" + ), mock.patch.object( + grampswebapidb.os.path, "exists", return_value=False ): - with self.assertLogs(grampswebapidb.LOG, level="WARNING"): - result = self.db._download_one_media_file("H1") - self.assertFalse(result) + _, missing_remote = self.db._scan_and_resolve_media([{"handle": "H1"}]) + self.assertEqual(missing_remote, []) -class TestUploadOneMediaFile(unittest.TestCase): +class TestTransferMediaFiles(unittest.TestCase): def setUp(self): self.db = new_instance() self.db.web_client = mock.MagicMock() - def test_uploads_and_returns_true(self): - media = FakeMedia("H1", "photo.jpg") + def test_downloads_and_uploads_and_returns_counts(self): self.db.web_client.upload_media_file.return_value = True - with mock.patch.object( - self.db, "get_media_from_handle", return_value=media - ), mock.patch.object( - grampswebapidb, "media_path_full", return_value="/tree/photo.jpg" - ), mock.patch.object( - grampswebapidb.os.path, "exists", return_value=True - ): - result = self.db._upload_one_media_file("H1") - self.assertTrue(result) - self.db.web_client.upload_media_file.assert_called_once_with( - "H1", "/tree/photo.jpg" + result = self.db._transfer_media_files( + [("H1", "/a"), ("H2", "/b")], [("H3", "/c")] ) + self.db.web_client.download_media_file.assert_has_calls( + [mock.call("H1", "/a"), mock.call("H2", "/b")] + ) + self.db.web_client.upload_media_file.assert_called_once_with("H3", "/c") + self.assertEqual(result, (2, 1)) - def test_conflict_response_returns_false(self): + def test_conflict_response_is_not_counted(self): # WebApiHandler.upload_media_file() itself returns False on a 409 # (someone else already uploaded a file for this object) rather - # than raising -- propagated here as-is. - media = FakeMedia("H1", "photo.jpg") + # than raising. self.db.web_client.upload_media_file.return_value = False - with mock.patch.object( - self.db, "get_media_from_handle", return_value=media - ), mock.patch.object( - grampswebapidb, "media_path_full", return_value="/tree/photo.jpg" - ), mock.patch.object( - grampswebapidb.os.path, "exists", return_value=True - ): - result = self.db._upload_one_media_file("H1") - self.assertFalse(result) - - def test_missing_local_object_returns_false(self): - with mock.patch.object( - self.db, - "get_media_from_handle", - side_effect=grampswebapidb.HandleError("H1"), - ): - result = self.db._upload_one_media_file("H1") - self.assertFalse(result) - self.db.web_client.upload_media_file.assert_not_called() + result = self.db._transfer_media_files([], [("H1", "/a")]) + self.assertEqual(result, (0, 0)) - def test_file_not_on_disk_returns_false_without_uploading(self): - media = FakeMedia("H1", "photo.jpg") - with mock.patch.object( - self.db, "get_media_from_handle", return_value=media - ), mock.patch.object( - grampswebapidb, "media_path_full", return_value="/tree/photo.jpg" - ), mock.patch.object( - grampswebapidb.os.path, "exists", return_value=False - ): - result = self.db._upload_one_media_file("H1") - self.assertFalse(result) - self.db.web_client.upload_media_file.assert_not_called() + def test_download_connection_error_is_logged_and_skipped(self): + self.db.web_client.download_media_file.side_effect = OSError("network down") + with self.assertLogs(grampswebapidb.LOG, level="WARNING"): + result = self.db._transfer_media_files([("H1", "/a")], []) + self.assertEqual(result, (0, 0)) - def test_connection_error_is_logged_and_returns_false(self): - media = FakeMedia("H1", "photo.jpg") + def test_upload_connection_error_is_logged_and_skipped(self): self.db.web_client.upload_media_file.side_effect = OSError("network down") - with mock.patch.object( - self.db, "get_media_from_handle", return_value=media - ), mock.patch.object( - grampswebapidb, "media_path_full", return_value="/tree/photo.jpg" - ), mock.patch.object( - grampswebapidb.os.path, "exists", return_value=True - ): - with self.assertLogs(grampswebapidb.LOG, level="WARNING"): - result = self.db._upload_one_media_file("H1") - self.assertFalse(result) + with self.assertLogs(grampswebapidb.LOG, level="WARNING"): + result = self.db._transfer_media_files([], [("H1", "/a")]) + self.assertEqual(result, (0, 0)) + + def test_nothing_to_transfer_is_a_no_op(self): + result = self.db._transfer_media_files([], []) + self.assertEqual(result, (0, 0)) + self.db.web_client.download_media_file.assert_not_called() + self.db.web_client.upload_media_file.assert_not_called() -class TestSyncMediaFiles(unittest.TestCase): +class TestSyncMediaFilesAsync(unittest.TestCase): + # runner/io_runner are InlineTaskRunner (see new_instance()), so the + # whole io_runner -> runner -> io_runner -> on_done chain resolves + # synchronously within one call, deterministically -- see + # GrampsWebApiDb/taskrunner.py and tests/fakes.py. def setUp(self): self.db = new_instance() + self.db.web_client = mock.MagicMock() - def test_downloads_missing_local_then_uploads_missing_remote(self): + def test_full_chain_resolves_via_on_done_not_a_return_value(self): + present = FakeMedia("H1", "present.jpg") + missing = FakeMedia("H2", "missing.jpg") + remote_media = FakeMedia("H3", "remote.jpg") + self.db.web_client.get_missing_files.return_value = [{"handle": "H3"}] + self.db.web_client.upload_media_file.return_value = True with mock.patch.object( - self.db, "_missing_local_media_handles", return_value=["H1", "H2"] - ), mock.patch.object( - self.db, "_missing_remote_media_handles", return_value=["H3"] + self.db, "iter_media", return_value=[present, missing] ), mock.patch.object( - self.db, "_download_one_media_file", return_value=True - ) as download, mock.patch.object( - self.db, "_upload_one_media_file", return_value=True - ) as upload: - result = self.db._sync_media_files() - download.assert_has_calls([mock.call("H1"), mock.call("H2")]) - upload.assert_called_once_with("H3") - self.assertEqual(result, (2, 1)) - - def test_counts_only_successful_transfers(self): - with mock.patch.object( - self.db, "_missing_local_media_handles", return_value=["H1", "H2"] + self.db, "get_media_from_handle", return_value=remote_media ), mock.patch.object( - self.db, "_missing_remote_media_handles", return_value=[] + grampswebapidb, + "media_path_full", + side_effect=lambda db, path: "/tree/" + path, ), mock.patch.object( - self.db, "_download_one_media_file", side_effect=[True, False] + grampswebapidb.os.path, + "exists", + side_effect=lambda p: "missing" not in p, ): - result = self.db._sync_media_files() - self.assertEqual(result, (1, 0)) + result = {} + self.db._sync_media_files_async( + on_done=lambda value: result.update(done=value), + on_error=lambda exc: result.update(error=exc), + ) + self.assertNotIn("error", result) + self.assertEqual(result["done"], (1, 1)) + self.db.web_client.download_media_file.assert_called_once_with( + "H2", "/tree/missing.jpg" + ) + self.db.web_client.upload_media_file.assert_called_once_with( + "H3", "/tree/remote.jpg" + ) + + def test_error_fetching_remote_missing_calls_on_error(self): + boom = OSError("network down") + self.db.web_client.get_missing_files.side_effect = boom + result = {} + self.db._sync_media_files_async( + on_done=lambda value: result.update(done=value), + on_error=lambda exc: result.update(error=exc), + ) + self.assertNotIn("done", result) + self.assertIs(result["error"], boom) def test_nothing_missing_is_a_no_op(self): - with mock.patch.object( - self.db, "_missing_local_media_handles", return_value=[] - ), mock.patch.object(self.db, "_missing_remote_media_handles", return_value=[]): - result = self.db._sync_media_files() - self.assertEqual(result, (0, 0)) + self.db.web_client.get_missing_files.return_value = [] + with mock.patch.object(self.db, "iter_media", return_value=[]): + result = {} + self.db._sync_media_files_async( + on_done=lambda value: result.update(done=value), + on_error=lambda exc: result.update(error=exc), + ) + self.assertEqual(result["done"], (0, 0)) # ------------------------------------------------------------------------- @@ -1905,19 +3879,29 @@ def test_nothing_missing_is_a_no_op(self): # Check and Repair Database) records nothing per-object -- DBAPI skips # trans.add() for a batch commit -- so transaction_to_json() sees an # empty payload and nothing would ever be pushed. transaction_begin() -# snapshots handles up front and transaction_commit() diffs them after, -# reconstructing the change list. See the module docstring. +# snapshots every primary object's full current data up front +# (_snapshot_all_objects()) and transaction_commit() diffs a fresh +# snapshot against it after, reconstructing the change list -- not just +# which handles exist, and not a .change-vs-start_time timestamp guess +# (an earlier version compared timestamps instead of content and, for +# that and two other reasons, could silently miss changes, push a false +# "old" a real server always rejects as a conflict, or drop deletes +# outright -- see the module docstring and _reconcile_batch_commit()'s +# own docstring for the full account; GrampsWebApiDb/tests/ +# test_reconcile_batch_commit_real_db.py exercises all of that against a +# real database). # # ------------------------------------------------------------------------- class FakeBatchTxn: """Duck-types the DbTxn attributes the batch-reconciliation path reads: .batch, .start_time, and whatever attribute transaction_begin() - stashes its handle snapshot in. get_recnos() returns nothing, matching - a real batch transaction's empty undo log.""" + stashes its snapshot in. get_recnos() returns nothing, matching a real + batch transaction's empty undo log.""" - def __init__(self, batch=True, start_time=100.0): + def __init__(self, batch=True, start_time=100.0, description=""): self.batch = batch self.start_time = start_time + self._description = description def get_recnos(self, reverse=False): return [] @@ -1925,49 +3909,46 @@ def get_recnos(self, reverse=False): def get_record(self, recno): # pragma: no cover - never reached raise AssertionError("a batch transaction records nothing") + def get_description(self): + return self._description -def stored_person(handle, change): - """A real Person with a given .change timestamp -- the field - _reconcile_batch_commit() compares against the transaction's - start_time. Real rather than a stub because _fill_entry_payloads() - then runs it through object_to_data().""" - person = Person() - person.set_handle(handle) - person.set_gramps_id("I" + handle) - person.change = change - return person +def raw_person_data(handle, change=100.0, **extra): + """A minimal json_utils-shaped dict standing in for what + _get_raw_data()/_iter_raw_data() returns for a Person. + _reconcile_batch_commit() only ever diffs and forwards these as + plain dicts (via diff_items()) -- it never turns them back into + real objects -- so a real Person is not needed to test it.""" + data = {"handle": handle, "change": change, "gramps_id": "I" + handle} + data.update(extra) + return data -def stub_all_handle_accessors(db, handles_by_class=None, objects=None): - """Give ``db`` a get__handles/get__from_handle for every - primary type, so _handles_by_class()/_reconcile_batch_commit() can walk - all of CLASS_TO_KEY_MAP without a real database.""" - handles_by_class = handles_by_class or {} - objects = objects or {} - for obj_class, key in grampswebapidb.CLASS_TO_KEY_MAP.items(): - name = grampswebapidb.KEY_TO_NAME_MAP[key] - setattr( - db, - f"get_{name}_handles", - mock.MagicMock(return_value=list(handles_by_class.get(obj_class, []))), - ) - setattr( - db, - f"get_{name}_from_handle", - mock.MagicMock(side_effect=lambda h, _o=objects: _o[h]), - ) + +def stub_iter_raw_data(db, data_by_class): + """data_by_class: {obj_class: {handle: raw_data_dict}}. Stubs + _iter_raw_data() -- the bulk per-type read _snapshot_all_objects() + uses -- so _reconcile_batch_commit()/_snapshot_all_objects() can be + exercised without a real database.""" + + def fake_iter_raw_data(key): + obj_class = grampswebapidb.KEY_TO_CLASS_MAP[key] + return list(data_by_class.get(obj_class, {}).items()) + + db._iter_raw_data = mock.MagicMock(side_effect=fake_iter_raw_data) class TestTransactionBeginSnapshot(unittest.TestCase): def setUp(self): self.db = new_instance() - def test_local_batch_transaction_gets_a_handle_snapshot(self): - stub_all_handle_accessors(self.db, {"Person": ["H1"]}) + def test_local_batch_transaction_gets_a_full_object_snapshot(self): + stub_iter_raw_data(self.db, {"Person": {"H1": raw_person_data("H1")}}) trans = FakeBatchTxn(batch=True) with mock.patch.object(grampswebapidb.SQLite, "transaction_begin"): self.db.transaction_begin(trans) - self.assertEqual(trans._webapidb_before_handles["Person"], {"H1"}) + self.assertEqual( + trans._webapidb_before, {("Person", "H1"): raw_person_data("H1")} + ) def test_non_batch_transaction_gets_no_snapshot(self): # An ordinary edit is recorded per-object by DBAPI, so @@ -1975,7 +3956,7 @@ def test_non_batch_transaction_gets_no_snapshot(self): trans = FakeBatchTxn(batch=False) with mock.patch.object(grampswebapidb.SQLite, "transaction_begin"): self.db.transaction_begin(trans) - self.assertFalse(hasattr(trans, "_webapidb_before_handles")) + self.assertFalse(hasattr(trans, "_webapidb_before")) def test_pull_side_batch_transaction_gets_no_snapshot(self): # _sync_from_server()'s own replay is a batch transaction too, but @@ -1985,7 +3966,7 @@ def test_pull_side_batch_transaction_gets_no_snapshot(self): trans = FakeBatchTxn(batch=True) with mock.patch.object(grampswebapidb.SQLite, "transaction_begin"): self.db.transaction_begin(trans) - self.assertFalse(hasattr(trans, "_webapidb_before_handles")) + self.assertFalse(hasattr(trans, "_webapidb_before")) class TestReconcileBatchCommit(unittest.TestCase): @@ -1993,131 +3974,142 @@ def setUp(self): self.db = new_instance() self.db.web_client = mock.MagicMock() - def test_added_handle_becomes_an_add(self): - obj = stored_person("H2", 150.0) - stub_all_handle_accessors( + def test_added_handle_becomes_an_add_with_no_old_data(self): + stub_iter_raw_data( self.db, - {"Person": ["H1", "H2"]}, - {"H1": stored_person("H1", 50.0), "H2": obj}, - ) - before = {c: set() for c in grampswebapidb.CLASS_TO_KEY_MAP} - before["Person"] = {"H1"} - with mock.patch.object(self.db, "_retry_after_conflict") as replay: - self.db._reconcile_batch_commit(before, start_time=100.0) - entries = replay.call_args[0][0] + {"Person": {"H1": raw_person_data("H1"), "H2": raw_person_data("H2")}}, + ) + before = {("Person", "H1"): raw_person_data("H1")} + with mock.patch.object(self.db, "_start_push") as push: + self.db._reconcile_batch_commit(before) + entries = push.call_args[0][0] self.assertEqual(len(entries), 1) self.assertEqual(entries[0]["type"], "add") self.assertEqual(entries[0]["handle"], "H2") self.assertEqual(entries[0]["_class"], "Person") - - def test_removed_handle_becomes_a_delete(self): - stub_all_handle_accessors(self.db, {"Person": []}) - before = {c: set() for c in grampswebapidb.CLASS_TO_KEY_MAP} - before["Person"] = {"H1"} - with mock.patch.object(self.db, "_retry_after_conflict") as replay: - self.db._reconcile_batch_commit(before, start_time=100.0) - entries = replay.call_args[0][0] + self.assertIsNone(entries[0]["old"]) + self.assertEqual(entries[0]["new"], raw_person_data("H2")) + + def test_removed_handle_becomes_a_delete_carrying_its_last_known_data(self): + stub_iter_raw_data(self.db, {"Person": {}}) + before = {("Person", "H1"): raw_person_data("H1")} + with mock.patch.object(self.db, "_start_push") as push: + self.db._reconcile_batch_commit(before) + entries = push.call_args[0][0] self.assertEqual(len(entries), 1) self.assertEqual(entries[0]["type"], "delete") self.assertEqual(entries[0]["handle"], "H1") self.assertIsNone(entries[0]["new"]) - - def test_surviving_handle_touched_during_the_batch_becomes_an_update(self): - stub_all_handle_accessors( - self.db, {"Person": ["H1"]}, {"H1": stored_person("H1", 150.0)} + # Bug fixed: an earlier version replayed deletes through + # _retry_after_conflict()'s has_handle() guard, which (correct + # for an actual conflict retry) silently no-ops here since the + # object is legitimately already gone -- dropping every + # reconciled delete. This builds the entry directly instead. + self.assertEqual(entries[0]["old"], raw_person_data("H1")) + + def test_surviving_handle_with_real_content_change_becomes_an_update(self): + stub_iter_raw_data( + self.db, + {"Person": {"H1": raw_person_data("H1", change=200.0, gramps_id="I0002")}}, ) - before = {c: set() for c in grampswebapidb.CLASS_TO_KEY_MAP} - before["Person"] = {"H1"} - with mock.patch.object(self.db, "_retry_after_conflict") as replay: - self.db._reconcile_batch_commit(before, start_time=100.0) - entries = replay.call_args[0][0] + before = { + ("Person", "H1"): raw_person_data("H1", change=100.0, gramps_id="I0001") + } + with mock.patch.object(self.db, "_start_push") as push: + self.db._reconcile_batch_commit(before) + entries = push.call_args[0][0] + self.assertEqual(len(entries), 1) + self.assertEqual(entries[0]["type"], "update") + # Bug fixed: an earlier version replayed this through + # _retry_after_conflict(), which re-commits whatever local + # storage holds *now* -- by reconciliation time, already the + # batch's own result -- so "old" ended up matching "new" + # instead of genuinely reflecting the pre-batch state a real + # server needs to compare against. + self.assertEqual(entries[0]["old"]["gramps_id"], "I0001") + self.assertEqual(entries[0]["new"]["gramps_id"], "I0002") + + def test_update_within_the_same_wall_clock_second_as_batch_start_is_still_detected( + self, + ): + # Bug fixed: an earlier version compared .change (int) against + # the transaction's own start_time (a float), so any edit + # landing in the same wall-clock second as start_time -- the + # common case for a fast local tool -- was silently missed. + # This version diffs content, not timestamps, so it isn't + # fooled by two events sharing a second. + stub_iter_raw_data( + self.db, + {"Person": {"H1": raw_person_data("H1", change=100.0, private=True)}}, + ) + before = {("Person", "H1"): raw_person_data("H1", change=100.0, private=False)} + with mock.patch.object(self.db, "_start_push") as push: + self.db._reconcile_batch_commit(before) + entries = push.call_args[0][0] self.assertEqual(len(entries), 1) self.assertEqual(entries[0]["type"], "update") - def test_untouched_handle_is_skipped(self): - # The whole point of comparing .change against start_time: a bulk - # tool that rewrote 3 of 10000 objects must push 3, not 10000. - stub_all_handle_accessors( - self.db, {"Person": ["H1"]}, {"H1": stored_person("H1", 50.0)} - ) - before = {c: set() for c in grampswebapidb.CLASS_TO_KEY_MAP} - before["Person"] = {"H1"} - with mock.patch.object(self.db, "_retry_after_conflict") as replay: - self.db._reconcile_batch_commit(before, start_time=100.0) - replay.assert_not_called() + def test_resave_with_only_the_change_timestamp_different_is_not_pushed(self): + # diff_items() -- the same function gramps-web-api's own + # old_unchanged() conflict check uses server-side -- ignores + # "change", so a resave that touched nothing else must not be + # reported as an update. + stub_iter_raw_data( + self.db, {"Person": {"H1": raw_person_data("H1", change=999.0)}} + ) + before = {("Person", "H1"): raw_person_data("H1", change=100.0)} + with mock.patch.object(self.db, "_start_push") as push: + self.db._reconcile_batch_commit(before) + push.assert_not_called() - def test_change_exactly_at_start_time_counts_as_touched(self): - # int(time.time()) truncation in _commit_base() means a fast - # transaction can stamp .change to exactly its own start second -- - # treating that as untouched would silently drop the change. - stub_all_handle_accessors( - self.db, {"Person": ["H1"]}, {"H1": stored_person("H1", 100.0)} - ) - before = {c: set() for c in grampswebapidb.CLASS_TO_KEY_MAP} - before["Person"] = {"H1"} - with mock.patch.object(self.db, "_retry_after_conflict") as replay: - self.db._reconcile_batch_commit(before, start_time=100.0) - entries = replay.call_args[0][0] - self.assertEqual(entries[0]["type"], "update") + def test_untouched_handle_is_skipped(self): + # A bulk tool that rewrote 3 of 10000 objects must push 3, not + # 10000. + data = raw_person_data("H1") + stub_iter_raw_data(self.db, {"Person": {"H1": data}}) + before = {("Person", "H1"): data} + with mock.patch.object(self.db, "_start_push") as push: + self.db._reconcile_batch_commit(before) + push.assert_not_called() def test_nothing_changed_pushes_nothing(self): - stub_all_handle_accessors(self.db) - before = {c: set() for c in grampswebapidb.CLASS_TO_KEY_MAP} - with mock.patch.object(self.db, "_retry_after_conflict") as replay: - self.db._reconcile_batch_commit(before, start_time=100.0) + stub_iter_raw_data(self.db, {}) + with mock.patch.object(self.db, "_start_push") as push: + self.db._reconcile_batch_commit({}) + push.assert_not_called() + + def test_pushes_directly_not_via_retry_after_conflict(self): + # Bug fixed: an earlier version routed every reconstructed entry + # through _retry_after_conflict(), whose replay reads local + # storage at commit time -- already the batch's own result by + # then, not the pre-batch state a real server needs. This + # builds the correct payload directly and pushes it the normal + # way, so _retry_after_conflict() is only ever reached (via + # _push_payload_async()'s own conflict handling) if the push + # actually conflicts. + stub_iter_raw_data(self.db, {"Person": {"H1": raw_person_data("H1")}}) + with mock.patch.object( + self.db, "_retry_after_conflict" + ) as replay, mock.patch.object(self.db, "_start_push") as push: + self.db._reconcile_batch_commit({}) + push.assert_called_once() replay.assert_not_called() def test_transaction_commit_routes_a_snapshotted_batch_to_reconcile(self): trans = FakeBatchTxn(batch=True, start_time=100.0) - trans._webapidb_before_handles = {"Person": {"H1"}} + trans._webapidb_before = {("Person", "H1"): raw_person_data("H1")} with mock.patch.object( grampswebapidb.SQLite, "transaction_commit" ), mock.patch.object(self.db, "_reconcile_batch_commit") as reconcile: self.db.transaction_commit(trans) - reconcile.assert_called_once_with({"Person": {"H1"}}, 100.0) + reconcile.assert_called_once_with( + {("Person", "H1"): raw_person_data("H1")}, message=None + ) # ...and does NOT also take the ordinary push path, which would # push an empty payload. self.db.web_client.push_transaction.assert_not_called() -class TestFillEntryPayloads(unittest.TestCase): - def setUp(self): - self.db = new_instance() - - def test_add_entry_gets_current_object_data(self): - person = Person() - person.set_handle("H1") - person.set_gramps_id("I0001") - self.db.get_person_from_handle = mock.MagicMock(return_value=person) - filled = self.db._fill_entry_payloads( - [{"type": "add", "handle": "H1", "_class": "Person"}] - ) - self.assertEqual(len(filled), 1) - self.assertIsNone(filled[0]["old"]) - self.assertEqual(filled[0]["new"]["gramps_id"], "I0001") - # remove_object() strips the cached _object back-reference, which - # is not JSON-serializable and must never reach the wire. - self.assertNotIn("_object", filled[0]["new"]) - - def test_delete_entry_carries_no_object_data(self): - filled = self.db._fill_entry_payloads( - [{"type": "delete", "handle": "H1", "_class": "Person"}] - ) - self.assertEqual(filled[0]["new"], None) - self.assertEqual(filled[0]["old"], None) - - def test_vanished_handle_is_dropped(self): - # The object was removed between the diff and here -- nothing left - # to describe, and asking the server to add it would fail. - self.db.get_person_from_handle = mock.MagicMock( - side_effect=grampswebapidb.HandleError("H1") - ) - filled = self.db._fill_entry_payloads( - [{"type": "add", "handle": "H1", "_class": "Person"}] - ) - self.assertEqual(filled, []) - - # ------------------------------------------------------------------------- # # TestPendingPushQueue @@ -2140,12 +4132,34 @@ def setUp(self): def _payload(self, handle="H1"): return [{"type": "add", "handle": handle, "_class": "Person"}] + def _push(self, payload, undo=False, is_retry=False): + """Drive _push_payload_async() to completion synchronously (via + InlineTaskRunner -- see new_instance()), the way these + unit-level tests want the old direct _push_payload() call to + behave.""" + self.db._push_payload_async( + payload, + on_done=lambda _: None, + on_error=lambda _: None, + undo=undo, + is_retry=is_retry, + ) + + def _flush(self): + """Drive _flush_pending_pushes_async() to completion + synchronously (via InlineTaskRunner), the way these unit-level + tests want the old direct _flush_pending_pushes() call to + behave.""" + self.db._flush_pending_pushes_async( + on_done=lambda _: None, on_error=lambda _: None + ) + def test_connection_failure_queues_the_payload(self): self.db.web_client.push_transaction.side_effect = HTTPError( "https://example.com/api/transactions/", 500, "boom", None, None ) with self.assertLogs(grampswebapidb.LOG, level="ERROR"): - self.db._push_payload(self._payload()) + self._push(self._payload()) queued = self.metadata["pending_pushes"] self.assertEqual(len(queued), 1) self.assertEqual(queued[0]["payload"], self._payload()) @@ -2154,11 +4168,11 @@ def test_connection_failure_queues_the_payload(self): def test_undo_flag_is_preserved_in_the_queue(self): self.db.web_client.push_transaction.side_effect = OSError("network down") with self.assertLogs(grampswebapidb.LOG, level="ERROR"): - self.db._push_payload(self._payload(), undo=True) + self._push(self._payload(), undo=True) self.assertTrue(self.metadata["pending_pushes"][0]["undo"]) def test_successful_push_queues_nothing(self): - self.db._push_payload(self._payload()) + self._push(self._payload()) self.assertNotIn("pending_pushes", self.metadata) def test_conflict_does_not_queue(self): @@ -2167,11 +4181,11 @@ def test_conflict_does_not_queue(self): self.db.web_client.push_transaction.side_effect = WebApiPushConflict( "Object has changed" ) - with mock.patch.object(self.db, "_sync_from_server"), mock.patch.object( - self.db, "_retry_after_conflict" - ): + with mock.patch.object( + self.db, "_resync_after_conflict_async", side_effect=stub_async_done(None) + ), mock.patch.object(self.db, "_retry_after_conflict"): with self.assertLogs(grampswebapidb.LOG, level="WARNING"): - self.db._push_payload(self._payload()) + self._push(self._payload()) self.assertNotIn("pending_pushes", self.metadata) def test_flush_sends_queued_pushes_in_order_and_clears_them(self): @@ -2180,7 +4194,7 @@ def test_flush_sends_queued_pushes_in_order_and_clears_them(self): {"payload": self._payload("H2"), "undo": False}, ] with self.assertLogs(grampswebapidb.LOG, level="INFO"): - self.db._flush_pending_pushes() + self._flush() sent = [ c[0][0][0]["handle"] for c in self.db.web_client.push_transaction.call_args_list @@ -2198,7 +4212,7 @@ def test_flush_stops_at_the_first_still_undeliverable_entry(self): ] self.db.web_client.push_transaction.side_effect = OSError("still down") with self.assertLogs(grampswebapidb.LOG, level="WARNING"): - self.db._flush_pending_pushes() + self._flush() self.assertEqual(self.db.web_client.push_transaction.call_count, 1) self.assertEqual(len(self.metadata["pending_pushes"]), 2) @@ -2215,13 +4229,13 @@ def test_flush_drops_a_queued_push_that_now_conflicts(self): None, ] with self.assertLogs(grampswebapidb.LOG, level="WARNING"): - self.db._flush_pending_pushes() + self._flush() # The conflicting entry is dropped, but the one behind it still goes. self.assertEqual(self.db.web_client.push_transaction.call_count, 2) self.assertEqual(self.metadata["pending_pushes"], []) def test_empty_queue_makes_no_requests(self): - self.db._flush_pending_pushes() + self._flush() self.db.web_client.push_transaction.assert_not_called() def test_queue_is_capped_dropping_the_oldest(self): @@ -2231,7 +4245,7 @@ def test_queue_is_capped_dropping_the_oldest(self): ] self.db.web_client.push_transaction.side_effect = OSError("network down") with self.assertLogs(grampswebapidb.LOG, level="ERROR"): - self.db._push_payload(self._payload("NEW")) + self._push(self._payload("NEW")) queued = self.metadata["pending_pushes"] self.assertEqual(len(queued), grampswebapidb.MAX_PENDING_PUSHES) # Oldest dropped, newest kept. @@ -2246,7 +4260,7 @@ def test_permanent_rejection_is_not_queued(self): "https://example.com/api/transactions/", 403, "Forbidden", None, None ) with self.assertLogs(grampswebapidb.LOG, level="ERROR"): - self.db._push_payload(self._payload()) + self._push(self._payload()) self.assertNotIn("pending_pushes", self.metadata) def test_rate_limit_is_still_queued(self): @@ -2255,7 +4269,7 @@ def test_rate_limit_is_still_queued(self): "https://example.com/api/transactions/", 429, "Too Many", None, None ) with self.assertLogs(grampswebapidb.LOG, level="ERROR"): - self.db._push_payload(self._payload()) + self._push(self._payload()) self.assertEqual(len(self.metadata["pending_pushes"]), 1) def test_flush_drops_a_permanently_rejected_entry_and_continues(self): @@ -2270,7 +4284,7 @@ def test_flush_drops_a_permanently_rejected_entry_and_continues(self): None, ] with self.assertLogs(grampswebapidb.LOG, level="ERROR"): - self.db._flush_pending_pushes() + self._flush() # The rejected entry is dropped rather than blocking the queue # forever -- permissions may have changed since it was queued. self.assertEqual(self.db.web_client.push_transaction.call_count, 2) @@ -2280,9 +4294,13 @@ def test_sync_from_server_flushes_the_queue_first(self): # The queue is retried on every poll tick, not just at load(). self.db.emit = mock.MagicMock() self.db.web_client.get_transaction_history.return_value = ([], 0) - with mock.patch.object(self.db, "_flush_pending_pushes") as flush: - self.db._sync_from_server() - flush.assert_called_once_with() + with mock.patch.object( + self.db, "_flush_pending_pushes_async", side_effect=stub_async_done(None) + ) as flush: + self.db._sync_from_server_async( + on_done=lambda _: None, on_error=lambda _: None + ) + self.assertEqual(flush.call_count, 1) if __name__ == "__main__": diff --git a/GrampsWebApiDb/tests/test_reconcile_batch_commit_real_db.py b/GrampsWebApiDb/tests/test_reconcile_batch_commit_real_db.py new file mode 100644 index 000000000..6a90230e6 --- /dev/null +++ b/GrampsWebApiDb/tests/test_reconcile_batch_commit_real_db.py @@ -0,0 +1,446 @@ +# +# Gramps - a GTK+/GNOME based genealogy program +# +# Copyright (C) 2026 Douglas S. Blank +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +# + +""" +Real-database integration tests for WebApiDB._reconcile_batch_commit() -- +the mechanism that is supposed to notice when a *local* batch=True +operation (a real Gramps Import, or a stock Tool like Check and Repair +Database, Media Manager, Reorder Gramps IDs, ...) added, changed, or +removed something, and push that to the server -- see the module +docstring's "Not every local batch=True commit is a pull-side replay" +section and _reconcile_batch_commit()'s own docstring. + +Why this file exists, separately +--------------------------------- +Every test in test_grampswebapidb.py's TestReconcileBatchCommit class +stubs out _iter_raw_data() and DbTxn itself, isolating the diff logic +from real commit/push machinery -- appropriate for a fast unit suite, +but exactly the isolation that let a previous fix to a neighboring +mechanism (conflict-retry) ship broken for two review rounds: see +commit 8e21ed72d, and TestConflictRetryAgainstARealDatabase in +test_grampswebapidb.py, which was written for the same reason. This +file runs a real ImportXml import and real batch=True DbTxns against a +real DBAPI-backed SQLite database (WebApiDB.__class__ reclassification, +same trick), with only the network layer (web_client) mocked, and +checks what actually gets pushed. + +What it found (now fixed) +-------------------------- +This file originally documented three independent bugs in +_reconcile_batch_commit(), none caught by the mocked unit tests, each +individually sufficient to make it fail to sync real local batch +changes to a real server -- every test below currently passes because +all three are fixed (_reconcile_batch_commit() and +_snapshot_all_objects() in grampswebapidb.py; see their docstrings for +the current design). Left here, unmodified, as the regression tests +that prove it and guard against it breaking again: + +1. Timestamp precision (test_update_within_the_same_wall_clock_second_ + as_batch_start_is_not_silently_missed): the old implementation only + treated a surviving handle as changed if + ``get_obj(handle).change >= start_time``. ``.change`` is an int + (whole seconds); ``start_time`` is a raw ``time.time()`` float. Any + real edit that landed within the same wall-clock second the batch + transaction began in -- the common case for a fast local tool -- + compared a truncated-down int against a float with a nonzero + fractional part and silently failed the check. The change was never + even attempted, let alone pushed: no entry, no log line, nothing. + Fixed by comparing actual before/after content + (gramps.gen.merge.diff.diff_items()) instead of timestamps at all. + +2. Stale "old" snapshot (test_real_import_add_is_pushed_as_an_add_not_ + a_false_conflict, test_real_batch_update_pushes_the_pre_batch_state_ + as_old): by the time _reconcile_batch_commit() ran, the real batch + operation had already written its result to local storage for real. + The old implementation's replay (via _retry_after_conflict()) then + re-committed that *already-current* local state as a "fresh" edit, + so DBAPI's own "old" snapshot (_commit_base()'s _get_raw_data(), + read from local storage at commit time) captured the *post-batch* + content, not what the server last actually saw. For a brand-new + object this meant "type": "update" with a non-None "old" instead of + "type": "add" with "old": None; for a changed object it meant "old" + that already matched "new". Either way, a real server's own old- + data check (gramps_webapi/api/tasks.py's old_unchanged(), confirmed + by reading that source) compared this against what it actually held + and called it a conflict -- even though nothing server-side changed + at all -- and because that push already had is_retry=True, it gave + up rather than retrying, silently dropping the entire reconstructed + batch. Fixed by capturing the true pre-transaction data up front + (transaction_begin()'s _snapshot_all_objects() call) and building + the payload's "old" from that, instead of from local storage at + replay time. + +3. Deletes swallowed (test_real_batch_delete_is_pushed_not_swallowed): + _retry_after_conflict()'s delete handling is + ``if has_handle(handle): remove(...)`` -- correct for its original + use (a conflict retry, where "already gone" means someone else beat + us to the delete, nothing to do). But by the time the old + _reconcile_batch_commit() replayed a *real* local delete through it, + the object was *legitimately* already gone (the real batch operation + removed it for real), so has_handle() was already False, the guard + skipped the remove() call entirely, and the delete never reached the + server. Fixed by building the delete entry directly from the pre- + transaction snapshot instead of replaying it through + _retry_after_conflict() at all -- that method is now only reached + (via _push_payload()'s own conflict handling) if a reconciliation + push itself genuinely conflicts, the same as any other edit. + +Not wired into the addon's normal fast test run (this repo has no CI -- +see CLAUDE.md); explicit invocation only:: + + python3 -m unittest GrampsWebApiDb.tests.test_reconcile_batch_commit_real_db -v +""" + +# ------------------------------------------------------------------------- +# +# Standard python modules +# +# ------------------------------------------------------------------------- +import copy +import os +import shutil +import sys +import tempfile +import time +import unittest +from unittest import mock + +# ------------------------------------------------------------------------- +# +# Make the addon importable the way Gramps loads it -- see +# test_grampswebapidb.py's comment on the same hack. +# +# ------------------------------------------------------------------------- +ADDON_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +if ADDON_DIR not in sys.path: + sys.path.insert(0, ADDON_DIR) + +try: + import gramps +except ImportError as _err: + raise unittest.SkipTest("gramps package not available: %s" % _err) + +from gramps.gen.db import DbTxn +from gramps.gen.db.utils import make_database +from gramps.gen.lib import Note, Person, Tag +from gramps.gen.lib.json_utils import data_to_object, object_to_data, remove_object +from gramps.gen.user import User +from gramps.plugins.importer.importxml import importData + +from GrampsWebApiDb.grampswebapidb import WebApiDB, WebApiPushConflict +from GrampsWebApiDb.tests.fakes import InlineTaskRunner + +#: A minimal, valid Gramps XML document holding one person -- enough for +#: a real ImportXml run. ImportXml strips a leading "_" off the XML +#: handle attribute (confirmed empirically: "_h...1" in the XML becomes +#: local handle "h...1"), so callers pass the XML-side spelling and read +#: back whatever ImportXml actually assigned via get_person_handles(). +PERSON_XML = """ + +
+ + + U + + +
+""" + + +# ------------------------------------------------------------------------- +# +# TestReconcileBatchCommitAgainstARealDatabase +# +# ------------------------------------------------------------------------- +class TestReconcileBatchCommitAgainstARealDatabase(unittest.TestCase): + def setUp(self): + tmpdir = tempfile.mkdtemp(prefix="grampswebapidb_reconcile_test_") + self.addCleanup(shutil.rmtree, tmpdir, ignore_errors=True) + self.tmpdir = tmpdir + db = make_database("sqlite") + db.load(tmpdir) + # Same reclassification trick as TestConflictRetryAgainstARealDatabase + # in test_grampswebapidb.py -- a real, already-initialized DBAPI + # backend, without going through WebApiDB's network-dependent load(). + db.__class__ = WebApiDB + db.web_client = mock.MagicMock() + db.runner = InlineTaskRunner() + db.io_runner = InlineTaskRunner() + db._syncing = False + db._retrying = False + db._pulling = False + db._get_metadata = lambda key, default=0: default + db._set_metadata = lambda key, value, use_txn=True: None + self.db = db + self.addCleanup(self.db.close) + self.pushes = [] + + def fake_push(payload, undo=False, background=False): + self.pushes.append(copy.deepcopy(payload)) + + self.db.web_client.push_transaction.side_effect = fake_push + + def _seed_person(self, privacy=False): + """Add a person via a plain (non-batch) commit -- standing in for + an object already synced with the server, the same way a fresh + WebApiDB mirror would hold it. Its own push is not what these + tests are about, so the recorder is cleared afterward.""" + with DbTxn("seed", self.db) as trans: + person = Person() + person.set_gramps_id("I0001") + person.set_privacy(privacy) + self.db.add_person(person, trans) + handle = person.handle + self.pushes.clear() + return handle + + def _import_xml(self, xml_text, filename="import.gramps"): + path = os.path.join(self.tmpdir, filename) + with open(path, "w") as f: + f.write(xml_text) + importData(self.db, path, User()) + + # -- Bug 1: timestamp precision ------------------------------------- + + def test_update_within_the_same_wall_clock_second_as_batch_start_is_not_silently_missed( + self, + ): + # A real external batch tool (Check and Repair Database, Media + # Manager, ...) modifying an existing object typically finishes + # within the same wall-clock second it started in -- this must + # still be detected and pushed, not silently skipped because + # get_obj(handle).change (whole seconds) happens to compare less + # than transaction.start_time (a time.time() float). + handle = self._seed_person() + with DbTxn("simulated batch tool", self.db, batch=True) as trans: + person = self.db.get_person_from_handle(handle) + person.set_privacy(True) + self.db.commit_person(person, trans) + self.assertEqual( + len(self.pushes), + 1, + "a same-second local edit made by a real batch tool must " + "still be reconciled and pushed", + ) + + # -- Bug 2: stale "old" snapshot ------------------------------------- + + def test_real_import_add_is_pushed_as_an_add_not_a_false_conflict(self): + # A real ImportXml run (its own batch=True DbTxn, opened + # internally -- not by this addon) adding a brand-new person must + # be reconstructed as an "add" with "old": None, the same as any + # other first-time local add -- not "update" with the object's + # own just-committed data as "old", which a real server (which + # has never seen this handle) always rejects as a conflict. + self._import_xml( + PERSON_XML.format(handle="h" + "0" * 19 + "1", gramps_id="I0001") + ) + self.assertEqual(len(self.pushes), 1) + entry = self.pushes[0][0] + self.assertEqual(entry["type"], "add") + self.assertIsNone(entry["old"]) + + def test_real_batch_update_pushes_the_pre_batch_state_as_old(self): + # A real external batch tool modifying an existing, already- + # synced object must push "old" as what was last known-synced + # (pre-batch) -- what the server actually still has -- not what + # the batch tool just wrote locally, or a real server's + # old-data check rejects it as a conflict even though nothing + # server-side changed. + handle = self._seed_person(privacy=False) + pre_batch = remove_object( + object_to_data(self.db.get_person_from_handle(handle)) + ) + with DbTxn("simulated batch tool", self.db, batch=True) as trans: + # Sidesteps bug 1 (already covered by its own test above) so + # this test isolates bug 2 only. + trans.start_time -= 10 + person = self.db.get_person_from_handle(handle) + person.set_privacy(True) + self.db.commit_person(person, trans) + self.assertEqual(len(self.pushes), 1) + entry = self.pushes[0][0] + self.assertEqual(entry["type"], "update") + self.assertEqual(entry["old"], pre_batch) + self.assertTrue(entry["new"]["private"]) + + # -- Bug 3: deletes swallowed ----------------------------------------- + + def test_real_batch_delete_is_pushed_not_swallowed(self): + # A real external batch tool removing an existing object inside + # its own batch=True DbTxn must still reach the server as a + # "delete" entry -- _retry_after_conflict()'s has_handle() guard + # (correct for an actual conflict retry, where "already gone" + # means someone else's delete beat ours) must not also treat a + # delete this addon's own reconciliation is replaying as + # "nothing to do", just because the real batch delete already + # removed it locally by the time the replay runs. + handle = self._seed_person() + with DbTxn("simulated batch tool", self.db, batch=True) as trans: + trans.start_time -= 10 # isolate from bug 1, as above + self.db.remove_person(handle, trans) + self.assertFalse(self.db.has_person_handle(handle)) + self.assertEqual(len(self.pushes), 1) + entry = self.pushes[0][0] + self.assertEqual(entry["type"], "delete") + self.assertEqual(entry["handle"], handle) + + # -- Sanity: untouched objects are left alone ------------------------- + + def test_objects_the_batch_did_not_touch_are_not_pushed(self): + untouched = self._seed_person() + handle = self._seed_person() + # Backdating start_time by as much as the bug-2/3 tests above do + # (10s) would also pull it before *untouched*'s own .change, + # making it look touched too -- that's bug 1 again, just aimed + # at the wrong object. A 2s real sleep plus a 1s backdate keeps + # comfortable margin on both sides: well after untouched/handle's + # original .change, and well before the edit's own -- isolating + # this test from bug 1 without reintroducing a false positive. + time.sleep(2) + with DbTxn("simulated batch tool", self.db, batch=True) as trans: + trans.start_time -= 1 + person = self.db.get_person_from_handle(handle) + person.set_privacy(True) + self.db.commit_person(person, trans) + self.assertEqual(len(self.pushes), 1) + touched_handles = {e["handle"] for e in self.pushes[0]} + self.assertEqual(touched_handles, {handle}) + self.assertNotIn(untouched, touched_handles) + + # -- Additional edge cases ------------------------------------------- + + def test_a_resave_with_no_real_change_is_not_pushed(self): + # Some tools (Check and Repair among them) re-commit an object + # even when nothing about it actually needed fixing. That must + # not be reported as an update -- diff_items() ignores "change" + # (the timestamp _commit_base() bumps on every commit, + # unconditionally), the same as gramps-web-api's own + # old_unchanged() conflict check does server-side. + handle = self._seed_person() + with DbTxn("simulated batch tool", self.db, batch=True) as trans: + person = self.db.get_person_from_handle(handle) + self.db.commit_person(person, trans) + self.assertEqual(len(self.pushes), 0) + + def test_multiple_object_types_in_one_batch_are_all_reconciled(self): + # _reconcile_batch_commit() walks every entry in CLASS_TO_KEY_MAP, + # not just Person -- a real batch tool touching several object + # types at once (e.g. Check and Repair fixing both people and + # tags) must have all of them reconciled together in one push. + with DbTxn("simulated batch tool", self.db, batch=True) as trans: + person = Person() + person.set_gramps_id("I0002") + self.db.add_person(person, trans) + tag = Tag() + tag.set_name("A Tag") + self.db.add_tag(tag, trans) + self.assertEqual(len(self.pushes), 1) + entries = {(e["_class"], e["type"]) for e in self.pushes[0]} + self.assertEqual(entries, {("Person", "add"), ("Tag", "add")}) + + def test_add_update_and_delete_together_in_one_batch_are_all_reconciled(self): + keep = self._seed_person() + gone = self._seed_person() + pre_batch_keep = remove_object( + object_to_data(self.db.get_person_from_handle(keep)) + ) + with DbTxn("simulated batch tool", self.db, batch=True) as trans: + new_person = Person() + new_person.set_gramps_id("I0099") + self.db.add_person(new_person, trans) + added = new_person.handle + + person = self.db.get_person_from_handle(keep) + person.set_privacy(True) + self.db.commit_person(person, trans) + + self.db.remove_person(gone, trans) + + self.assertEqual(len(self.pushes), 1) + by_handle = {e["handle"]: e for e in self.pushes[0]} + self.assertEqual(set(by_handle), {added, keep, gone}) + self.assertEqual(by_handle[added]["type"], "add") + self.assertIsNone(by_handle[added]["old"]) + self.assertEqual(by_handle[keep]["type"], "update") + self.assertEqual(by_handle[keep]["old"], pre_batch_keep) + self.assertTrue(by_handle[keep]["new"]["private"]) + self.assertEqual(by_handle[gone]["type"], "delete") + self.assertIsNone(by_handle[gone]["new"]) + + def test_a_genuine_conflict_on_the_reconciliation_push_recovers_via_full_resync( + self, + ): + # If the server's own copy of a touched object really did change + # in the narrow window between this addon's before-snapshot and + # the reconciliation push actually going out, the push must get + # the same recovery any other edit's conflict gets (full resync, + # then a retry) -- not a special case, and not silently dropped. + handle = self._seed_person(privacy=False) + server_fresh = copy.deepcopy( + remove_object(object_to_data(self.db.get_person_from_handle(handle))) + ) + server_fresh["private"] = True # changed server-side, unknown to us + + calls = [] + + def fake_push(payload, undo=False, background=False): + calls.append(copy.deepcopy(payload)) + if len(calls) == 1: + raise WebApiPushConflict("Object has changed") + + self.db.web_client.push_transaction.side_effect = fake_push + + def fake_full_resync_async(on_done, on_error, progress_callback=None): + self.db._pulling = True + try: + with DbTxn("fake resync", self.db, batch=True) as trans: + self.db.commit_person(data_to_object(server_fresh), trans) + finally: + self.db._pulling = False + on_done(None) + + with mock.patch.object( + self.db, "_full_resync_async", side_effect=fake_full_resync_async + ): + with DbTxn("simulated batch tool", self.db, batch=True) as trans: + person = self.db.get_person_from_handle(handle) + # A list-valued field, not a scalar one: _merge_or_ + # overwrite()'s merge() only unions list fields, so this + # is what actually verifies the local edit survives + # alongside the server's own (scalar) change, rather than + # one silently overwriting the other -- see that + # function's own docstring on the scalar-field caveat. + note = Note() + note.set_handle("N-local") + self.db.add_note(note, trans) + person.add_note("N-local") + self.db.commit_person(person, trans) + + self.assertEqual(len(calls), 2) + final = self.db.get_person_from_handle(handle) + # Server's concurrent change survived... + self.assertTrue(final.get_privacy()) + # ...and so did the local batch's own edit. + self.assertIn("N-local", final.get_note_list()) + + +if __name__ == "__main__": + unittest.main() diff --git a/GrampsWebApiDb/tests/test_taskrunner.py b/GrampsWebApiDb/tests/test_taskrunner.py new file mode 100644 index 000000000..71cd9b113 --- /dev/null +++ b/GrampsWebApiDb/tests/test_taskrunner.py @@ -0,0 +1,235 @@ +# +# Gramps - a GTK+/GNOME based genealogy program +# +# Copyright (C) 2026 Douglas S. Blank +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +# + +""" +Unit tests for taskrunner.GLibTaskRunner/IoRunner and +tests.fakes.InlineTaskRunner. + +Uses a real (but GUI-less) GLib.MainContext to prove GLibTaskRunner/ +IoRunner actually dispatch the way their docstrings claim -- the only place +in this addon's test suite that drives a real main loop or a real thread, +since every other test swaps in InlineTaskRunner. See grampswebapidb.py's +module docstring for why this addon needs both. + +Run with:: + + python3 -m unittest GrampsWebApiDb.tests.test_taskrunner -v +""" + +# ------------------------------------------------------------------------- +# +# Standard python modules +# +# ------------------------------------------------------------------------- +import os +import sys +import threading +import unittest + +# ------------------------------------------------------------------------- +# +# Make the addon importable the way Gramps loads it: its own directory on +# sys.path (grampswebapidb.py/webapi_client.py use bare, not package- +# relative, imports of each other -- see CLAUDE.md Testing conventions). +# +# ------------------------------------------------------------------------- +ADDON_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +if ADDON_DIR not in sys.path: + sys.path.insert(0, ADDON_DIR) + +try: + import gi + + gi.require_version("GLib", "2.0") + from gi.repository import GLib +except ImportError as _err: + raise unittest.SkipTest("PyGObject not available: %s" % _err) + +from GrampsWebApiDb.taskrunner import GLibTaskRunner, IoRunner +from GrampsWebApiDb.tests.fakes import InlineTaskRunner + + +def _pump_until(predicate, timeout=5.0): + """Iterate the default GLib main context until predicate() is true. + + Test-only helper: a bounded stand-in for a real GTK main loop, the same + shape as grampswebapidb.py's own _run_async_to_completion() wait-adapter + but with a hard timeout so a broken runner fails the test instead of + hanging it forever. + """ + import time as _time + + context = GLib.MainContext.default() + deadline = _time.monotonic() + timeout + while not predicate(): + if _time.monotonic() > deadline: + raise AssertionError( + "timed out waiting for the main loop to deliver a result" + ) + context.iteration(True) + + +class TestGLibTaskRunner(unittest.TestCase): + def test_success_runs_func_on_the_main_loop_and_calls_on_success(self): + main_thread = threading.current_thread() + seen = {} + + def func(): + seen["thread"] = threading.current_thread() + return 42 + + def on_success(result): + seen["success"] = result + + def on_error(exc): + seen["error"] = exc + + GLibTaskRunner().run(func, on_success, on_error) + _pump_until(lambda: "success" in seen or "error" in seen) + + self.assertEqual(seen.get("success"), 42) + self.assertNotIn("error", seen) + self.assertIs(seen["thread"], main_thread) + + def test_exception_is_reported_to_on_error_not_raised(self): + seen = {} + boom = RuntimeError("boom") + + def func(): + raise boom + + GLibTaskRunner().run( + func, + on_success=lambda result: seen.__setitem__("success", result), + on_error=lambda exc: seen.__setitem__("error", exc), + ) + _pump_until(lambda: "success" in seen or "error" in seen) + + self.assertNotIn("success", seen) + self.assertIs(seen.get("error"), boom) + + def test_post_runs_func_on_the_main_loop(self): + main_thread = threading.current_thread() + seen = {} + + GLibTaskRunner().post( + lambda: seen.__setitem__("thread", threading.current_thread()) + ) + _pump_until(lambda: "thread" in seen) + + self.assertIs(seen["thread"], main_thread) + + +class TestIoRunner(unittest.TestCase): + def test_func_runs_on_a_different_thread_than_the_caller(self): + caller_thread = threading.current_thread() + seen = {} + + def func(): + seen["thread"] = threading.current_thread() + return "ok" + + IoRunner().run( + func, + on_success=lambda result: seen.__setitem__("success", result), + on_error=lambda exc: seen.__setitem__("error", exc), + ) + _pump_until(lambda: "success" in seen or "error" in seen) + + self.assertEqual(seen.get("success"), "ok") + self.assertIsNot(seen["thread"], caller_thread) + + def test_on_success_is_dispatched_back_on_the_main_thread(self): + main_thread = threading.current_thread() + seen = {} + + IoRunner().run( + lambda: None, + on_success=lambda _: seen.__setitem__("thread", threading.current_thread()), + on_error=lambda exc: seen.__setitem__("error", exc), + ) + _pump_until(lambda: "thread" in seen or "error" in seen) + + self.assertNotIn("error", seen) + self.assertIs(seen["thread"], main_thread) + + def test_exception_is_marshalled_to_on_error_on_the_main_thread(self): + main_thread = threading.current_thread() + seen = {} + boom = RuntimeError("boom") + + def func(): + raise boom + + IoRunner().run( + func, + on_success=lambda result: seen.__setitem__("success", result), + on_error=lambda exc: seen.update( + error=exc, thread=threading.current_thread() + ), + ) + _pump_until(lambda: "error" in seen) + + self.assertNotIn("success", seen) + self.assertIs(seen["error"], boom) + self.assertIs(seen["thread"], main_thread) + + def test_post_runs_func_on_the_main_thread(self): + main_thread = threading.current_thread() + seen = {} + + IoRunner().post(lambda: seen.__setitem__("thread", threading.current_thread())) + _pump_until(lambda: "thread" in seen) + + self.assertIs(seen["thread"], main_thread) + + +class TestInlineTaskRunner(unittest.TestCase): + def test_run_success_calls_on_success_synchronously(self): + seen = {} + InlineTaskRunner().run( + lambda: 7, + on_success=lambda result: seen.__setitem__("success", result), + on_error=lambda exc: seen.__setitem__("error", exc), + ) + self.assertEqual(seen, {"success": 7}) + + def test_run_error_calls_on_error_synchronously(self): + seen = {} + boom = RuntimeError("boom") + + def func(): + raise boom + + InlineTaskRunner().run( + func, + on_success=lambda result: seen.__setitem__("success", result), + on_error=lambda exc: seen.__setitem__("error", exc), + ) + self.assertEqual(seen, {"error": boom}) + + def test_post_runs_immediately(self): + seen = [] + InlineTaskRunner().post(lambda: seen.append(1)) + self.assertEqual(seen, [1]) + + +if __name__ == "__main__": + unittest.main() diff --git a/GrampsWebApiDb/tests/test_webapi_client.py b/GrampsWebApiDb/tests/test_webapi_client.py index 2f9ce9cc9..3e57e944c 100644 --- a/GrampsWebApiDb/tests/test_webapi_client.py +++ b/GrampsWebApiDb/tests/test_webapi_client.py @@ -379,7 +379,7 @@ def test_get_permissions_reads_token_claim(self): # TestIdentity # # hostname/get_current_username()/get_identity() resolve who and where a -# credential authenticates as -- grampswebapidb.py's _check_identity() uses +# credential authenticates as -- grampswebapidb.py's _check_identity_async() uses # get_identity() to bind a local mirror to one particular server account # (see that module's docstring). # @@ -619,6 +619,27 @@ def test_request_url_and_returns_raw_bytes(self): fake.requests[0].full_url, "https://example.com/api/exporters/gramps/file" ) + def test_on_chunk_streams_the_body_and_fires_between_chunks(self): + # A multi-megabyte export read in one go is one uninterruptible + # call -- long enough for the window manager to decide Gramps has + # stopped responding. See grampswebapidb._pump_main_loop(). + handler = self._authed_handler() + body = b"x" * (webapi_client._DOWNLOAD_CHUNK_SIZE * 2 + 17) + fake = QueuedUrlopen([FakeChunkedResponse(body)]) + calls = [] + with mock.patch.object(webapi_client, "urlopen", fake): + data = handler.download_export(on_chunk=lambda: calls.append(1)) + self.assertEqual(data, body) + self.assertEqual(len(calls), 3) + + def test_without_on_chunk_the_body_is_read_in_one_go(self): + # FakeBinaryResponse.read() takes no size argument, so this also + # pins that the unchunked path stays a plain read(). + handler = self._authed_handler() + fake = QueuedUrlopen([FakeBinaryResponse(b"gzip-bytes-here")]) + with mock.patch.object(webapi_client, "urlopen", fake): + self.assertEqual(handler.download_export(), b"gzip-bytes-here") + def test_extension_is_configurable(self): handler = self._authed_handler() fake = QueuedUrlopen([FakeBinaryResponse(b"gedcom-bytes")]) @@ -954,6 +975,43 @@ def test_unknown_version_does_not_claim_background_support(self): with mock.patch.object(webapi_client, "urlopen", fake): self.assertFalse(handler.supports_background_transactions()) + def test_object_count_sums_every_type(self): + handler = self._authed_handler() + counts = {"object_counts": {"people": 4668, "families": 2855, "tags": 13}} + fake = QueuedUrlopen([FakeResponse(counts)]) + with mock.patch.object(webapi_client, "urlopen", fake): + self.assertEqual(handler.get_object_count(), 4668 + 2855 + 13) + + def test_object_count_without_the_section_is_zero(self): + handler = self._authed_handler() + fake = QueuedUrlopen([FakeResponse({"gramps": {"version": "6.0.1"}})]) + with mock.patch.object(webapi_client, "urlopen", fake): + self.assertEqual(handler.get_object_count(), 0) + + def test_object_count_ignores_non_numeric_entries(self): + handler = self._authed_handler() + fake = QueuedUrlopen( + [FakeResponse({"object_counts": {"people": 3, "note": "n/a"}})] + ) + with mock.patch.object(webapi_client, "urlopen", fake): + self.assertEqual(handler.get_object_count(), 3) + + def test_object_count_is_not_served_from_the_metadata_cache(self): + # Live state, unlike the versions get_metadata() caches: two calls + # must produce two requests, and must not poison that cache. + handler = self._authed_handler() + fake = QueuedUrlopen( + [ + FakeResponse({"object_counts": {"people": 1}}), + FakeResponse({"object_counts": {"people": 2}}), + ] + ) + with mock.patch.object(webapi_client, "urlopen", fake): + self.assertEqual(handler.get_object_count(), 1) + self.assertEqual(handler.get_object_count(), 2) + self.assertEqual(len(fake.requests), 2) + self.assertIsNone(handler._metadata) + # ------------------------------------------------------------------------- # @@ -993,6 +1051,26 @@ def test_polls_until_the_task_leaves_pending(self): handler.wait_for_task("T1") self.assertEqual(len(fake.requests), 3) + def test_on_wait_fires_once_per_poll(self): + # TASK_TIMEOUT allows ten minutes of this loop; a caller on the + # GUI thread has to be able to keep its main loop alive across it. + handler = self._authed_handler() + fake = QueuedUrlopen( + [ + FakeResponse({"state": "PENDING"}), + FakeResponse({"state": "STARTED"}), + FakeResponse({"state": "SUCCESS"}), + ] + ) + calls = [] + with mock.patch.object(webapi_client, "urlopen", fake), mock.patch.object( + webapi_client, "sleep" + ): + handler.wait_for_task("T1", on_wait=lambda: calls.append(1)) + # Once before each of the two sleeps; the SUCCESS poll returns + # without waiting again. + self.assertEqual(len(calls), 2) + def test_conflict_in_a_failed_task_raises_push_conflict(self): # The whole point: a conflict must look the same whether it came # back as a synchronous 400 or as a failed background task. @@ -1100,6 +1178,46 @@ def test_undo_appends_query_param(self): # reverses it, not the caller. See push_transaction()'s docstring. self.assertEqual(json.loads(req.data), payload) + def test_message_appends_query_param(self): + handler = self._authed_handler() + payload = [{"type": "add", "handle": "H1", "_class": "Person"}] + fake = QueuedUrlopen([FakeResponse({})]) + with mock.patch.object(webapi_client, "urlopen", fake): + handler.push_transaction(payload, message="Add Person (Jane Doe)") + req = fake.requests[0] + self.assertEqual( + req.full_url, + "https://example.com/api/transactions/?message=Add+Person+%28Jane+Doe%29", + ) + + def test_no_message_omits_query_param(self): + handler = self._authed_handler() + fake = QueuedUrlopen([FakeResponse({})]) + with mock.patch.object(webapi_client, "urlopen", fake): + handler.push_transaction([{"type": "add"}]) + self.assertEqual( + fake.requests[0].full_url, "https://example.com/api/transactions/" + ) + + def test_message_survives_401_retry(self): + handler = self._authed_handler() + fake = QueuedUrlopen( + [ + http_error(401), + FakeResponse({"access_token": token("AT1")}), + FakeResponse({}), + ] + ) + with mock.patch.object(webapi_client, "urlopen", fake), mock.patch.object( + webapi_client, "sleep" + ): + handler.push_transaction([{"type": "add"}], message="Edit Family") + # requests[0] = failed push, [1] = re-auth, [2] = retried push + self.assertEqual( + fake.requests[2].full_url, + "https://example.com/api/transactions/?message=Edit+Family", + ) + def test_undo_defaults_to_false(self): handler = self._authed_handler() fake = QueuedUrlopen([FakeResponse({})]) diff --git a/GrampsWebApiDb/webapi_client.py b/GrampsWebApiDb/webapi_client.py index f5cbb3223..0d3388d5f 100644 --- a/GrampsWebApiDb/webapi_client.py +++ b/GrampsWebApiDb/webapi_client.py @@ -79,7 +79,7 @@ from urllib.parse import urlencode, urlparse from urllib.request import Request, urlopen -LOG = logging.getLogger("grampswebapidb") +LOG = logging.getLogger(".grampswebapidb") #: Environment variable read by WebApiHandler.from_env(). API_KEY_ENV_VAR = "GRAMPS_WEB_API_KEY" @@ -96,6 +96,24 @@ #: reliably 429s otherwise. RATE_LIMIT_BACKOFF = 1.1 +#: The "object_counts" buckets GET /metadata/ reports that correspond to +#: the ten primary types Gramps' own DbGeneric.get_total() counts -- see +#: get_object_count(). Summed by name rather than over whatever keys the +#: response happens to carry, so a server that grows an extra bucket +#: can't make a perfectly good local mirror look permanently short of it. +OBJECT_COUNT_KEYS = ( + "people", + "families", + "events", + "places", + "repositories", + "sources", + "citations", + "media", + "notes", + "tags", +) + #: Chunk size used when streaming a media file download to disk -- see #: download_media_file(). _DOWNLOAD_CHUNK_SIZE = 1024 * 64 @@ -139,6 +157,14 @@ def _raise_for_push_conflict(exc: HTTPError) -> None: raise exc +def _request_target(req: Request) -> str: + """The path+query of ``req`` for a log line -- scheme and host + dropped as noise (one server per handler), credentials never + involved: they travel in headers, not the URL. See _open().""" + parts = urlparse(req.full_url) + return parts.path + (f"?{parts.query}" if parts.query else "") + + def parse_version(version): """Parse a SemVer-ish string into a ``(major, minor)`` tuple. @@ -307,8 +333,46 @@ def mint_api_key(cls, url: str, username: str, password: str) -> str: return make_api_key(handler._refresh_token, handler.url) def _open(self, req: Request): - """Open ``req`` with this handler's SSL context and timeout.""" - return urlopen(req, context=self._ctx, timeout=TIMEOUT) + """Open ``req`` with this handler's SSL context and timeout. + + Every request this client makes funnels through here, so this is + also where each one is traced at DEBUG: method, path, outcome and + round-trip time, one line apiece. Only the path+query is logged, + never headers -- the bearer token lives in a header, and nothing + this addon sends puts a credential in a URL. The timing stops at + the response headers, before the body is read, which is what makes + it useful for telling a slow server apart from a slow transfer. + """ + started = time.monotonic() + target = _request_target(req) + try: + res = urlopen(req, context=self._ctx, timeout=TIMEOUT) + except HTTPError as exc: + LOG.debug( + "%s %s -> HTTP %s (%.2fs)", + req.get_method(), + target, + exc.code, + time.monotonic() - started, + ) + raise + except (URLError, socket.timeout) as exc: + LOG.debug( + "%s %s -> %s (%.2fs)", + req.get_method(), + target, + exc, + time.monotonic() - started, + ) + raise + LOG.debug( + "%s %s -> %s (%.2fs)", + req.get_method(), + target, + getattr(res, "status", "?"), + time.monotonic() - started, + ) + return res @property def access_token(self) -> str: @@ -441,6 +505,25 @@ def get_metadata(self) -> dict[str, Any]: self._metadata = data return self._metadata + def get_object_count(self) -> int: + """How many primary objects the server's tree currently holds: + GET /metadata/'s "object_counts", summed over OBJECT_COUNT_KEYS. + + Deliberately *not* routed through get_metadata()'s cache. That + cache is for the deployment description -- versions, server + features -- which cannot change while a tree is open; an object + count is live state, and the only reason to ask for it is to + compare it against what a local mirror holds right now (see + grampswebapidb.py's _mirror_is_short_of_the_server()). + """ + data, _headers = self._get_json(f"{self.url}/metadata/") + counts = data.get("object_counts") or {} + return sum( + count + for key, count in counts.items() + if key in OBJECT_COUNT_KEYS and isinstance(count, int) + ) + def get_api_version(self) -> str | None: """gramps-web-api's own version string, e.g. "2.8.1".""" return (self.get_metadata().get("gramps_webapi") or {}).get("version") @@ -463,7 +546,7 @@ def supports_background_transactions(self) -> bool: def get_identity(self) -> str: """ "@" identifying the account+server this handler authenticates as -- see grampswebapidb.py's - _check_identity(), which requires a Family Tree's own name to + _check_identity_async(), which requires a Family Tree's own name to match this before trusting its local mirror.""" return f"{self.get_current_username()}@{self.hostname}" @@ -497,10 +580,25 @@ def _get_json(self, url: str, retry: bool = True) -> tuple[Any, dict]: return self._get_json(url, retry=False) raise - def _get_binary(self, url: str, retry: bool = True) -> bytes: + def _get_binary(self, url: str, retry: bool = True, on_chunk=None) -> bytes: """GET ``url`` with the bearer token and return the raw response body, unlike _get_json() -- for endpoints that return a file - rather than a JSON document (see download_export()).""" + rather than a JSON document (see download_export()). + + ``on_chunk``, if given, is called with no arguments after each + _DOWNLOAD_CHUNK_SIZE bytes arrive, and switches the read from one + blocking res.read() to a chunked loop. It exists for a caller + blocking a GUI thread on this call: a multi-megabyte export is + one uninterruptible read otherwise, long enough for the window + manager to decide the application has stopped responding. + grampswebapidb.py no longer passes this (its WebApiDB._full_ + resync_async() runs this call on a worker thread instead, where + there is no main loop to keep alive) -- kept here, unused by that + caller, since this file is also the vendored source for the + standalone gramps-api-client package (see this module's own + docstring), and a single-threaded caller elsewhere may still want + it. + """ req = Request( url, headers={ @@ -510,36 +608,51 @@ def _get_binary(self, url: str, retry: bool = True) -> bytes: ) try: with self._open(req) as res: - return res.read() + if on_chunk is None: + return res.read() + chunks = [] + while True: + chunk = res.read(_DOWNLOAD_CHUNK_SIZE) + if not chunk: + break + chunks.append(chunk) + on_chunk() + return b"".join(chunks) except HTTPError as exc: if exc.code == 401 and retry: sleep(RATE_LIMIT_BACKOFF) self._authenticate() - return self._get_binary(url, retry=False) + return self._get_binary(url, retry=False, on_chunk=on_chunk) if exc.code == 429 and retry: sleep(RATE_LIMIT_BACKOFF) - return self._get_binary(url, retry=False) + return self._get_binary(url, retry=False, on_chunk=on_chunk) raise except (URLError, socket.timeout): if retry: sleep(1) - return self._get_binary(url, retry=False) + return self._get_binary(url, retry=False, on_chunk=on_chunk) raise - def download_export(self, extension: str = "gramps") -> bytes: + def download_export(self, extension: str = "gramps", on_chunk=None) -> bytes: """ Download a full backup export of the tree from the server -- by default a gzip-compressed Gramps XML file, the exact on-disk shape Gramps' own ImportXml importer already reads (confirmed against a live server: GET /exporters/gramps/file runs synchronously and streams the file back, no task polling - needed). Used by grampswebapidb.py's WebApiDB._full_resync() to - rebuild the local mirror wholesale when the transaction-history + needed). Used by grampswebapidb.py's WebApiDB._full_resync_async() + to rebuild the local mirror wholesale when the transaction-history feed can't describe what changed -- see that method's own doc comment on why. + + ``on_chunk`` is passed through to _get_binary(): a hook called as + the bytes arrive, so a caller on a GUI thread blocking on this + call can keep its main loop alive across what is easily the + longest single transfer this client makes. See _get_binary()'s + own docstring for why grampswebapidb.py no longer passes this. """ url = f"{self.url}/exporters/{extension}/file" - return self._get_binary(url) + return self._get_binary(url, on_chunk=on_chunk) def get_missing_files(self) -> list[dict[str, Any]]: """ @@ -676,9 +789,21 @@ def wait_for_task( task_id: str, timeout: float = TASK_TIMEOUT, poll_interval: float = TASK_POLL_INTERVAL, + on_wait=None, ) -> None: """Poll GET /tasks/ until a backgrounded server task finishes. + ``on_wait``, if given, is called with no arguments once per poll, + before sleeping. A backgrounded push can occupy the server for + minutes (TASK_TIMEOUT allows ten), which is that much time a + caller blocking a GUI thread on this call would otherwise spend + inside this loop without touching its main loop. + grampswebapidb.py no longer passes this (its own push machinery + runs this call on a worker thread, where blocking in + time.sleep() is exactly what the thread is for) -- kept here, + unused by that caller, for the same vendored-package reason + _get_binary()'s ``on_chunk`` is. + Returns normally on SUCCESS. A FAILURE/REVOKED task raises -- WebApiPushConflict if it failed the server's old-data check (the same "Object has changed" sentinel a synchronous push reports as @@ -703,6 +828,8 @@ def wait_for_task( raise TimeoutError( f"Server task {task_id} did not finish within {timeout}s" ) + if on_wait is not None: + on_wait() sleep(poll_interval) def push_transaction( @@ -711,6 +838,8 @@ def push_transaction( retry: bool = True, undo: bool = False, background: bool = False, + on_wait=None, + message: str | None = None, ) -> None: """ POST a batch of local changes to /transactions/ (no force=1): the @@ -757,6 +886,22 @@ def push_transaction( {"error": {"message": ...}} body. Checked for the conflict sentinel here too, so it doesn't get misread as a transient server error and retried forever. + + ``message``, if given, becomes the ``?message=`` query param -- + the description gramps-web-api stores for this transaction in its + own history log (gramps_webapi/api/resources/transactions.py's + TransactionsQueryArgs). Left unset, the server defaults it to the + generic "Raw transaction", which is what every push from this + addon showed in the server's revision history before callers + started passing the local DbTxn's own description through (see + grampswebapidb.py's transaction_commit()) -- the same per-edit + message ("Add Person (Jane Doe)", "Edit Family", ...) Gramps + desktop's own editors already set on the DbTxn, and the same + convention gramps-web-api's own per-object PUT/POST endpoints use + (DbTxn(f"Edit {class_name}", ...) in + gramps_webapi/api/resources/base.py) -- so a push from this addon + shows up in the server's history the same way an edit made + directly in Gramps Web would. """ if not payload: return @@ -766,6 +911,8 @@ def push_transaction( params["undo"] = "1" if background: params["background"] = "1" + if message: + params["message"] = message url = f"{self.url}/transactions/" if params: url += "?" + urlencode(params) @@ -788,12 +935,22 @@ def push_transaction( sleep(RATE_LIMIT_BACKOFF) self._authenticate() return self.push_transaction( - payload, retry=False, undo=undo, background=background + payload, + retry=False, + undo=undo, + background=background, + on_wait=on_wait, + message=message, ) if exc.code == 429 and retry: sleep(RATE_LIMIT_BACKOFF) return self.push_transaction( - payload, retry=False, undo=undo, background=background + payload, + retry=False, + undo=undo, + background=background, + on_wait=on_wait, + message=message, ) # 400 is the synchronous conflict; 500 is the same conflict # re-wrapped by run_task() on the inline background path. @@ -804,9 +961,14 @@ def push_transaction( if retry: sleep(RATE_LIMIT_BACKOFF) return self.push_transaction( - payload, retry=False, undo=undo, background=background + payload, + retry=False, + undo=undo, + background=background, + on_wait=on_wait, + message=message, ) raise if status == 202: task_id = json.loads(body)["task"]["id"] - self.wait_for_task(task_id) + self.wait_for_task(task_id, on_wait=on_wait)