Read a KML Placemark's geometry from the Placemark down - #67
Conversation
`each_record` read a geometry's name and metadata from `feature.ancestors('Placemark').first`. `Nokogiri::XML::Node#ancestors` answers a selector by walking to the document root and searching the whole document from there, then scanning the results for each ancestor, and it caches nothing. Called once per geometry, that costs geometries x document size. A KML where each Placemark holds a single geometry never shows it; one exported from design software, where a Placemark is a `<MultiGeometry>` of hundreds of faces, turns a few thousand Placemarks into six figures of geometries and spends minutes there.
Instead of asking Nokogiri for the ancestors matching a selector, `enclosing_placemark` walks the parent chain and stops at the first Placemark. The answer is the same, since `ancestors` returns matches nearest-first and only the first was ever read. On documents built by repeating the `test.kml` fixture's Placemark, 20,000 geometries go from 14.44s to 0.0155s.
The walk stops at whatever no longer responds to `parent`, which is how a geometry with no Placemark ancestor returns nil. `Nokogiri::XML::Document` is that node, and it responds to `name` but not `parent`.
Adds `kml_file_with_multi_geometry_placemarks.kml`, built by `fixtures:build` from the same squares as the other KML fixtures, covering that every part of a MultiGeometry takes the name and metadata of the Placemark holding it. Its fourth example asserts the document is not searched again once parsed, which fails against the previous implementation.
Closes #66
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
| kml_document.css(kml_type).each do |feature| | ||
| if (placemark = feature.ancestors('Placemark').first) | ||
| if (placemark = enclosing_placemark(feature)) | ||
| metadata = extract_metadata(placemark) |
There was a problem hiding this comment.
We should consider memoizing the placemarks. If we're running this for each feature, there's no reason to derive the same metadata each time if we share placemarks across many features. Let's check if this helps processing times.
There was a problem hiding this comment.
Memoizing turned out not to be needed, because your next comment removes the thing that would have been memoized. Reading each Placemark's geometry from the Placemark down means the metadata is derived once at the top of the loop and shared by that Placemark's parts, so there is nothing to cache across iterations and no cache to size or invalidate.
I did measure what the repeated derivation was costing, since it is worth knowing whether that alone would have been enough. On a 33.8 MB file holding 76,190 geometries in 1,233 Placemarks, with the database round trip stubbed out:
| Traversal | |
|---|---|
ancestors + metadata per geometry |
902.02s |
| Parent walk + metadata per geometry | 10.76s |
| Placemark-first | 2.86s |
So the repeated extract_metadata was 7.9s of the 902s. Memoizing it on its own would have saved that and left the 891s. It is most of what remained after the traversal was fixed, which is why it is still worth having, but it was never the problem on its own.
One thing the sharing needed care with: images_from_metadata deletes the key it reads, so calling it per part would give the images to the first part and nothing to the rest. It is now called once per Placemark alongside extract_metadata. kml_file_with_multi_geometry_photos.kml covers that, and fails against the version that calls it per part.
🤖 Generated with Claude Code
| @@ -22,7 +22,7 @@ def initialize(data, base_dir: nil, **options) | |||
| def each_record(&block) | |||
| {'Polygon' => 'POLYGON', 'LineString' => 'LINE', 'Point' => 'POINT'}.each do |kml_type, sql_type| | |||
| kml_document.css(kml_type).each do |feature| | |||
There was a problem hiding this comment.
Perhaps instead of doing this how we are right now, we should start at each placemark and search for the kml types from that node downwards. That would avoid the need to memoize more than one placemark's metadata at a time, and would eliminate the need to search up the node tree.
There was a problem hiding this comment.
Done — this is what the branch now does. each_record iterates Placemarks, reads metadata, name and image paths once each, then reads the geometry below with placemark.css('Polygon, LineString, Point'). enclosing_placemark is gone, and so is the {'Polygon' => 'POLYGON', ...} hash whose sql_type half was unused.
Measured on a 33.8 MB file holding 76,190 geometries in 1,233 Placemarks, database round trip stubbed out, the traversal goes from 902.02s on master to 2.86s.
Two things it needed beyond the straight inversion:
Geometry outside a Placemark. Iterating Placemarks alone drops it silently, and kmz_file_features_without_placemarks.kmz covers that case. There is now a second pass, one XPath over the document (//Polygon[not(ancestor::Placemark)] and the same for the other two types), rather than a per-element ancestor question. Removing that pass fails kml_file_spec.rb:26, so it is a real guard rather than a vacuous one.
Image paths. images_from_metadata removes the key it reads, so it had to move up beside extract_metadata rather than staying per-part. Covered by a new fixture.
Iteration order changes as a result: each Placemark's geometry together in document order, rather than all Polygons then all LineStrings then all Points document-wide. I checked what depends on it — nothing in the suite asserts sequence, mvt_sql orders by id at query time, and features_hash is an MD5 of the source bytes rather than of the emitted features, so it cannot be invalidated by regrouping. Feature ids do land in a different order. Details in the PR body.
🤖 Generated with Claude Code
Reading a geometry's Placemark, however cheaply, is work repeated for every geometry, and it makes the Placemark's metadata repeated work too: `extract_metadata` parses the CDATA table in a `<description>` into a hash once per geometry, so a Placemark holding several hundred parts parsed the same description several hundred times.
Instead of walking up from each geometry to its Placemark, `each_record` now starts at each Placemark and reads the geometry below it. The Placemark's metadata, name and image paths are read once and shared by its parts, and no geometry ever asks what it belongs to.
Measured on a 33.8 MB CAD-derived KML holding 76,190 geometries in 1,233 Placemarks, with the per-geometry database round trip stubbed out so the figures cover the traversal alone:
ancestors + metadata per geometry 902.02s (before this branch)
parent walk + metadata per geometry 10.76s (previous commit)
Placemark-first 2.86s
Eliminating the upward search is worth 891s of that and eliminating the repeated metadata 7.9s, so the second is small in absolute terms while still being most of what was left.
Geometry outside any Placemark still imports with no name and no metadata, which iterating Placemarks alone would silently drop. A second pass matches it with one XPath over the document rather than by asking each element for its ancestors. `kmz_file_features_without_placemarks.kmz` covers it, and fails when that pass is removed.
Each part takes its own copy of the Placemark's metadata, since a hash shared between features would be one object behind several records. Image paths are read once per Placemark rather than once per part, because `images_from_metadata` removes the key it reads, so calling it per part would leave every part after the first without images.
Iteration order changes. It was every Polygon in the document, then every LineString, then every Point; it is now each Placemark's geometry together in document order, then any geometry outside a Placemark. Nothing asserts on feature sequence, `features_hash` is an MD5 of the source bytes rather than of what the importer emits, and `mvt_sql` orders by id at query time. Feature ids do land in a different order.
Closes #66
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reading a Placemark's geometry with `placemark.css(...)` matches everything below it, including the geometry of a Placemark nested inside it. Both Placemarks are iterated, so the inner geometry was read twice and imported as two features. KML 2.2 does not allow the nesting, but a document that does it produced duplicates rather than being read the way the previous implementation read it, which took each geometry's nearest enclosing Placemark and so claimed it once. Instead of every Placemark claiming all the geometry below it, `geometries_in` scopes the match to elements whose nearest enclosing Placemark is that one. Scoping is expressed by depth, since XPath 1.0 has no node-identity operator and cannot ask whether an ancestor is a particular node. The scoped path counts a candidate's ancestors, so it costs an upward walk per element. Measured on a document holding 76,190 geometries in 1,233 Placemarks, it takes 0.330s against 0.045s for the plain selector, and this branch exists to take that class of work out of the loop. It is therefore used only where it is needed: `//Placemark//Placemark` answers once per document whether anything nests, which costs 0.021s on a 32 MB document, and a document that does not nest keeps the plain selector. Adds `kml_file_with_nested_placemarks.kml`, built by `fixtures:build` from the same squares as the other KML fixtures. Reading it yields two features rather than three, and yields three when the scoping is removed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
The nested-Placemark case now has a guard rather than a note saying KML forbids the nesting. A selector can express it, but not directly: XPath 1.0 has no node-identity operator, so "elements whose nearest Placemark ancestor is this node" cannot be asked for. Scoping by depth gets the same answer — count how many Placemarks a candidate sits inside and keep the ones at this Placemark's depth plus one. The cost is the reason it is not used unconditionally. That predicate walks upward once per candidate, which is the work this branch removes. Measured over 76,190 geometries in 1,233 Placemarks on a 32 MB document:
All three return the same elements. The scoped path is 7.4x the plain selector, so
The importer's output over four real source files, 136,769 features, digests identically before and after: same count, same stream of geometry, names, metadata and image paths, same warnings. Full suite 312 examples, 0 failures. #70 is rebased onto this and its suite is green at 317 examples. 🤖 Generated with Claude Code |
Resolves #66.
each_recordread a geometry's name and metadata fromfeature.ancestors('Placemark').first.Nokogiri::XML::Node#ancestorsanswers a selector by walking to the document root and searching the whole document from there, then scanning the results for each ancestor, and it caches nothing. Called once per geometry, that costs geometries x document size.A KML where each Placemark holds one geometry never shows it. One exported from design software does: a Placemark is a
<MultiGeometry>of hundreds of small faces, so a few thousand Placemarks become six figures of geometries in a document tens of megabytes wide.The iteration is inverted rather than optimised in place.
each_recordstarts at each Placemark and reads the geometry below it, so the Placemark's metadata, name and image paths are read once and shared by its parts, and no geometry ever asks what it belongs to.What each change is worth
Measured on a 33.8 MB CAD-derived KML from a production deployment, holding 76,190 geometries in 1,233 Placemarks, with the per-geometry database round trip stubbed out so the figures cover the traversal alone:
ancestors+ metadata per geometry (master)Eliminating the upward search is worth 891.3s. Eliminating the repeated
extract_metadatais worth a further 7.9s, which is most of what remained after the first change but small against the original cost. The inversion removes the question rather than making it cheaper to ask.The remaining cost of this import is the per-geometry
ST_GeomFromKMLround trip, which this PR does not touch.Geometry outside a Placemark
Iterating Placemarks alone silently drops geometry that sits outside one, which the importer supports and
kmz_file_features_without_placemarks.kmzcovers. A second pass matches it with a single XPath over the document,//Polygon[not(ancestor::Placemark)]and the same for the other two types, rather than by asking each element for its ancestors.That fixture is not passing vacuously: removing the second pass fails
kml_file_spec.rb:26, which asserts the count. The two sibling examples in that shared group assert blank names and empty metadata, both of which hold against an empty collection, so the count is the only guard among the three.Two hazards the inversion introduces, both covered
Image paths.
images_from_metadatadeletes the key it reads. Reading it once per part would leave every part after the first without images. It is now read once per Placemark and shared.kml_file_with_multi_geometry_photos.kmlcovers it, and fails against the straightforward port that calls it per part.Shared metadata. Each part takes its own copy, since one hash behind several records is one object several
Features would write through.Iteration order changes
It was every Polygon in the document, then every LineString, then every Point. It is now each Placemark's geometry together in document order, then any geometry outside a Placemark.
Nothing in the suite asserts on feature sequence; the assertions are
count,all, andcontain_exactly.features_hashderives fromImporters::Base#cache_key, which isDigest::MD5.hexdigest(@data)over the source bytes rather than over what the importer emits, so regrouping cannot invalidate it.mvt_sqlappliesorder(:id)at query time. Feature ids do land in a different order.A Placemark inside another Placemark
Reading a Placemark's geometry with
placemark.css(...)matches everything below it, including the geometry of a Placemark nested inside it. Both are iterated, so that geometry would be read twice where the nearest-ancestor lookup read it once. KML 2.2 does not allow the nesting — a Placemark is a Feature, and Features nest only inside Containers — but a document that does it should not import duplicates.geometries_inscopes the match to elements whose nearest enclosing Placemark is that one. XPath 1.0 has no node-identity operator, so the scope is expressed by depth rather than by asking whether an ancestor is a particular node.The scoped path counts each candidate's ancestors, which is an upward walk per element and the class of work this PR removes. Three approaches over 76,190 geometries in 1,233 Placemarks, on a 32 MB document:
css('Polygon, LineString, Point')cssAll three return the same elements.
//Placemark//Placemarkanswers once per document whether anything nests, at 0.021s, so a document that does not nest keeps the plain selector and the scoped path runs only where it is needed.kml_file_with_nested_placemarks.kmlcovers it: reading it yields two features, and yields three when the scoping is removed.Fixtures
Adds
kml_file_with_multi_geometry_placemarks.kml,kml_file_with_multi_geometry_photos.kmlandkml_file_with_nested_placemarks.kml, all built byfixtures:buildfrom the same round-degree squares as the other KML fixtures, with generic layer and placemark names. None of them is derived from any real file.multi_geometryis factored out of the first builder for the second, and regenerating leaves the first byte-identical.Verification
Full suite locally: 312 examples, 0 failures, 19 pending.
Each guard was checked by breaking it: reverting the call site fails the no-further-search example, removing the unplaced-geometry pass fails the without-placemarks count, moving
images_from_metadataback inside the per-part loop fails the photos example, and dropping the nesting scope turns the nested-placemark count from two into three.The importer's output over four real source files, 136,769 features, digests identically before and after the nesting scope was added: same feature count, same stream of geometry, names, metadata and image paths, same warnings.
🤖 Generated with Claude Code
https://claude.ai/code/session_012b7n8NbLAd6s7c3atii4Mv