Replies: 3 comments 3 replies
|
I think it would be nice to have, but having to maintain this for each DB backend we support increases the drift from Gramps core and the maintenance burden after Gramps core version bumps. Of course, a |
There are some things here that are gramps-web-api specific, but yes, moving |
|
I have a better understanding of all of the slow parts of an import. I made two PRs:
They are both drafts currently, and 2 depends (slightly) on 1. After all of this, sifts (and its two 800k objects imported in a few minutes. Not bad. |
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
The problem
Importing a file into a fresh, empty gramps-web-api tree goes through the
same object-by-object commit path as importing into a populated one:
legalize_id()'s per-objectgramps_id/handle collision queries, whateverper-object schema-validation cost the importer pays, and one Postgres round
trip per object per write. All of that exists to protect a populated
tree from collisions during a merge -- but on an empty tree there is
nothing to collide with, so it's pure overhead.
And, the preview (dry_run) already imports it once into a memory-based SQLite database.
What if we got rid of the overhead, and re-used the SQLite database?
Why would we consider this? Importing into an empty database is a common operation for gramps-web-api. How much time would this save? Some real numbers with a prototype (
new pathis described in this post,old pathis the current method):example.gramps(XML).gramps(XML)In other words, for a 100k person tree, it reduces import from over 2 hours, down to 13 minutes.
The idea
When the target tree is empty (
db_handle.get_total() == 0), forkrun_import():SQLite,
:memory:) instead of the real tree -- the same scratch DBdry_run_import()already builds for preview counts, just kept insteadof discarded.
(handle, json_data)per object table plus thereferencetable, nogramps_id/handle reconciliation (nothing to reconcile against).rebuild_secondary()once (existing, already-tested Grampsmachinery) to backfill
given_name/surname/title/etc. -- reusedrather than reimplemented, and it already knows how to handle
backend-specific column naming (e.g. SharedPostgreSQL's
desc->desc_reserved-word rename) without this code needing to know aboutit.
SharedPostgreSQL.Connection.bulk_insert,built on psycopg2's
execute_values), the copy itself is real batchedINSERTs, not one round trip per row. This turned out to matter more
than the collision-check savings alone -- see numbers below.
ANALYZEruns on the just-written tables immediately after the bulkinsert, before
rebuild_secondary()'s point lookups run against them --otherwise a bulk INSERT finishes faster than autovacuum's normal cycle
and the planner picks bad query plans (sequential scans) against tables
it still thinks are empty.
Backends without a
bulk_insert()hook (plain sqlite/postgresql) fall backto the existing row-by-row
execute()loop -- correct, just without theextra speedup on top.
Correctness safeguards
(
set_default_person_handle) persists immediately via_set_metadata();some (
set_researcher, bookmarks, custom type/attribute registries) onlylands in an in-memory attribute normally flushed by
_set_all_metadata()atclose()time -- which is a no-op for a:memory:DB. Fixed for default-person/researcher by explicitlyflushing scratch's metadata before and after parsing, diffing against a
pre-parse baseline, and propagating the diff -- plus, for researcher
specifically, updating
real_db.ownerdirectly so the real tree's ownlater
close()-time flush doesn't clobber it. Bookmarks and the ~17custom type/attribute registries go through the identical deferred
mechanism and have the identical gap, not yet fixed (lower severity --
mostly UI-autocomplete conveniences, not broken references).
default -- two concurrent imports into the same tree could both pass it.
Closed with a Postgres advisory lock keyed on the tree's
treeid, heldfrom a re-check of emptiness through the whole copy. Verified with two
genuinely concurrent requests (threaded dev server): one completes, the
other correctly detects the tree is no longer empty and fails loudly
instead of silently corrupting anything.
copy starts, any exception during the copy triggers
delete_all_objects()(the same function backing the existing "Delete all items" feature)
before re-raising -- so a failure leaves the tree exactly as it started,
not a half-populated, half-reindexed mess.
Numbers
All comparisons below are old-path-forced vs. new-path, both run against a
freshly truncated database.
example.gramps(XML).gramps(XML)New-path breakdown for the 847,862-object file:
Memory
Both paths show substantial memory growth for this file: old path
plateaued around ~2.1GB RSS, new path peaked around ~2.7GB over its
13-minute run. Not yet established as a general bound for either path;
only characterized on this one 847k-object file.
Not yet addressed
real files, in a live dev environment
SharedPostgreSQL+ two formats (.gramps,.jsonl) -- GEDCOM 5.5.1, CSV, GeneWeb, ProGen untested via this path;GEDCOM 7 doesn't take this path at all (only wired into the generic
plugin-loop branch of
run_import(), not the GEDCOM7-specific branch)sqlite/built-inpostgresqlbackends untested (should be safeby construction -- fails over to the existing row-by-row path -- but
"should be" isn't "verified")
path if a file is too large for available memory
Open questions
bulk_insert()stay a duck-typed, backend-specific hook(
hasattr(dbapi, "bulk_insert"), currently only implemented onSharedPostgreSQL) the way it is now, or does this belong as a formalmethod on
DbWriteBaseupstream in gramps core, with a defaultimplementation other backends inherit?
All reactions