Batch the per-feature database round trips in feature import - #70
Draft
njakobsen wants to merge 2 commits into
Draft
Batch the per-feature database round trips in feature import#70njakobsen wants to merge 2 commits into
njakobsen wants to merge 2 commits into
Conversation
Saving a feature asks the database whether its geometry is valid, and asks separately for every feature. On an import of any size that is the largest single cost after parsing: profiling one record's import showed the validity query at 18.9% of all database time, behind only the inserts themselves, and the save loop as a whole at 69.7% of wall clock. Instead of asking once per feature, `::precompute_geometry_validation` asks for a batch in one query and hands each record its own answer, which `valid?` then reads in place of querying. Saving is otherwise untouched and still runs every callback, so nothing about what is written changes. A record whose geometry is repaired discards the batch's answer, because `make_valid` replaces the geometry the answer was about. Those records fall back to asking for themselves, which is what they did before. The batch is sent as a literal list of geometries, so what bounds it is the size of that statement rather than the record count: a file of few but very large geometries reaches the ceiling first. At 500 the largest statement measured over a 136,769-feature import was 0.39 MB, against 0.05 MB before, and peak memory rose 2.3%. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`geom_from_kml` sent one `ST_GeomFromKML` per geometry element, each wrapped in its own savepoint so that an element PostGIS could not read cost only itself. A KML holding six figures of geometries therefore spent six figures of round trips there, which profiling put at 11.8% of database time with the savepoints adding most of another 8.6%. Instead of one query per element, elements are held until there are enough to parse together and then read in a single query. The savepoint moves with them: a batch holding an element PostGIS rejects fails as a whole, so it is caught and re-read one element at a time, and only that element is lost. That is the behaviour the per-element savepoints provided, at one round trip per batch rather than one per element. Order is unchanged, since elements are held and yielded in the order they were read, and an element with no coordinates is still dropped before it is held rather than after. Building the features for a 136,769-feature import goes from 39.4s to 15.2s. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
njakobsen
force-pushed
the
batch-feature-saves
branch
from
August 18, 2026 18:50
dc609ca to
6a80bf1
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on #67, which it takes as its base. The traversal is fast after that PR; this is where the time goes next.
Pure performance: identical output, and that is measured rather than asserted. Nothing here changes what is imported, so neither #68 nor #69 is touched.
Where the time goes
Profiling one import, with SQL bucketed by statement shape, 9,164 features:
ST_Force2DST_GeomFromKML6.16 statements per feature. Database time was 68% of wall clock, Ruby the other 32%.
What this changes
Geometry validity, read a batch at a time.
::precompute_geometry_validationasks for a batch in one query and hands each record its own answer.saveis untouched and still runs every callback. A record whose geometry is repaired discards the answer, becausemake_validreplaces the geometry it was about, and falls back to asking for itself.KML geometry, parsed a batch at a time. Elements are held until there are enough to parse together. The savepoint moves with them: a batch holding an element PostGIS rejects fails as a whole, is caught, and is re-read one element at a time so only that element is lost — the same containment the per-element savepoints gave.
The inserts are left alone, at 32.4% the largest single item. Batching them means
insert_all, which bypasses the callback chain, and the import path would then silently skip any callback a future change adds toFeature. That is a maintenance hazard rather than a correctness one, but it is not "no judgment" in the way the rest of this is, so it wants its own discussion.ST_Force2Dis in the same position, since it runs as abefore_save.Proving the output is unchanged
Both branches were run over the same four real source files, 136,769 features, and digested:
de693be5…de693be5…16a43844…16a43844…f3d76280…f3d76280…The stream digest covers geometry, name, metadata and image paths for every feature in yield order. The rows digest covers the geometry as stored, read back as WKB hex, with name, metadata and source identifier, ordered by id. All three match exactly.
Numbers
Measured on a laptop against local Postgres, where a round trip is cheap. Statements per feature fall from 6.16 to roughly 2.4, so the gain should be larger where round trips cost more than they do here.
Batch sizes and memory
Both sizes are bounded by statement size rather than record count, since each batch is sent as a literal list of geometries — a file of few but very large geometries reaches the ceiling first. At the shipped sizes (500 features, 200 geometry elements), over the 136,769-feature import:
The 1.1 GB is pre-existing and is the importer holding every feature in memory before saving; this branch adds a bounded buffer and one statement string on top of it.
Verification
Full suite: 315 examples, 0 failures, 19 pending.
New specs cover the batch falling back to one query per element when it holds an unreadable one, that batched validity matches the per-record answer, that nothing is queried again while the answer holds, and that a repair discards it.
🤖 Generated with Claude Code
https://claude.ai/code/session_012b7n8NbLAd6s7c3atii4Mv