Skip to content

Engine registry unification, Arrow string layouts, DuckDB streaming and out-of-core paths - #89

Open
Haigutus wants to merge 25 commits into
mainfrom
feat/arrow-string-type
Open

Engine registry unification, Arrow string layouts, DuckDB streaming and out-of-core paths#89
Haigutus wants to merge 25 commits into
mainfrom
feat/arrow-string-type

Conversation

@Haigutus

@Haigutus Haigutus commented Aug 6, 2026

Copy link
Copy Markdown
Owner

Summary

24 commits unifying engine selection and hardening the DuckDB flavor, following the engine model rule (auto-pick the fastest engine only for flavor-independent results; frame-in/frame-out ops always run in the input's engine).

Engine architecture

  • One EngineRegistry mechanism everywhere (parser, sparql, validation, nquads, cimxml, csv, tools dispatch): eager find_spec availability probing at import, lazy module import on first use.
  • triplets.engines() report and set_engine() override; role-prefixed registry kinds (parser_cimxml, exporter_nquads, ...).
  • flavor() as the single input-flavor detector; shared conversion choke point in _engine_detect.

Parser

  • parse(string_type=...): selectable Arrow layout for ID/VALUE (utf8 / large_utf8 / string_view), "auto" picks what the return type adopts zero-copy; cython engine builds the layout natively.
  • parse_batches() with bounded in-order prefetch (max_workers); parse() rejects unknown kwargs.

DuckDB

  • Streaming out-of-core ingest: con.read_rdf(..., append=) fed by parse_batches (never materialized in Python); per-connection table/schema defaults persisted in the database (closes Make duckdb table/schema name configurable #85).
  • Mutators run in-place DML (no full-table rewrites); tools work on views and registered frames; multivalue tableviews at parity with pandas/polars; exports fetch native arrow.
  • Chunked out-of-core N-Quads export via to_arrow_reader streaming (flat memory profile 1.1M -> 4.6M rows).

Export

Quality

  • Zero-warning test suite; validation IR review fixes + full IR documentation; every doc surface synced (audit).

Closes #85. Addresses #34.

Kristjan Vilgo added 25 commits July 26, 2026 09:09
Wrap duckdb.connect(table=, schema=), store defaults per connection, and
resolve call kwargs → connection → package defaults into quoted SQL refs.
Tools, read_rdf, exports, SHACL, and loaders all use the same path.
Keep all DuckDB engine concerns in one module; delete duckdb_table.py so
there is no extra file to hunt for connection defaults and quoting.
Add to_pandas/to_arrow/to_polars/as_frame/match_flavor as the single
materialization path for pandas, polars, pyarrow, and DuckDB inputs, and
route loaders, SHACL, SPARQL, cgmes, export, and DuckDB export wrappers
through them.
… schema parity

- filter_triplets / filter_triplets_by_value with regex=True used SQL
  SIMILAR TO (full match); now regexp_matches() — search semantics anywhere
  in the value, matching the pandas/polars str.contains engines
- filter_triplets_by_type interpolated the type name unescaped into SQL;
  now quoted via _lit() like every other literal in the engine
- parse([], return_type="arrow"/"polars") now carries the same string /
  dictionary-encoded schema as a non-empty parse, so empties concatenate
- new regex parity specs (real IdentifiedObject.name substring), duckdb
  quote-escaping test, empty-vs-full schema tests
…) override

One EngineRegistry mechanism for every subsystem: availability is probed
eagerly at import with find_spec (microseconds, imports nothing), the chosen
module still imports lazily on first use. New ctor args: requires (extra
probe targets per engine) and policy ("auto" = fastest wins, "input" =
engine bound to the input flavor, not overridable).

- triplets.engines(): what "auto" resolves to per subsystem, plus available
  alternatives, aliases and auto order
- triplets.set_engine(parser=..., sparql=..., ...): global override; None or
  "auto" restores; loads eagerly to fail fast. Precedence: per-call engine=
  > set_engine > auto probe order
- export folds its ad-hoc dispatch into three registries: cimxml (replacing
  the hand-rolled module map), nquads (auto: polars first) and csv
  (policy="input": each engine is fastest for its own input flavor)
- broken-build fall-through: a probe-available engine whose import fails is
  skipped with a warning and dropped from the availability report
tools._get_engine is now flavor-driven and registry-backed (kind "tools",
policy="input"): triplets.tools.<fn>(con) reaches the duckdb engine
(previously it silently fell through to pandas and raised AttributeError
deep inside). Explicit engine= must match the input flavor (TypeError at
the boundary), unknown names raise ValueError, and functions an engine
does not implement raise NotImplementedError instead of AttributeError.

cgmes_tools keeps its deliberate pandas-boundary policy for duckdb/arrow
input; its private engine resolution now uses flavor() and rejects
unsupported explicit engines early.
Lift the offset/dictionary/large_utf8-aware Arrow column accessor out of
the qlever ingest into a shared header, triplets/_arrow/string_column.h
(header-only, arrow+std includes only), and make both compiled consumers
use it:

- sparql/_qlever_arrow_parser: private Column struct replaced by the
  shared triplets_arrow::StringColumn
- export/cimxml_cython_pugixml.pyx: drops the hard arrow::StringArray
  cast (32-bit pointer arithmetic) — now accepts utf8, large_utf8 and
  dictionary-encoded string columns via zero-copy string_views

On top of that, CIM XML export consumes polars input natively with the
cython engine: per-instance splitting runs in the input's own flavor
(polars partition_by / pandas groupby), polars columns reach the extension
as Arrow large_utf8/dictionary with no pandas hop, and output is
byte-identical across input flavors. Already-string pandas columns
(arrow-backed string, dictionary, categorical-of-strings) pass through
undecoded; non-string columns keep the legacy astype("string[pyarrow]")
text formatting. python_lxml stays pandas-only (auto-picked for
datatypes=True).

max_workers with polars frames uses a spawn multiprocessing context —
polars is incompatible with fork (rayon pool locks held in the child).

Build: triplets/_arrow added to include dirs (setup.py,
setup_cython_parser.py, setup_qlever.py, header in depends= and sdist).
A NOTE(string_view) slot marks where the string_view branch lands next.
…gelog

- docs/development.md: the engine model rule (auto-pick fastest engine only
  for flavor-independent results; frame ops bind to the input flavor), the
  registry table per subsystem, and the 'dispatch goes through
  EngineRegistry' contributor rule
- docs/exports.md: polars-native cimxml path, accepted arrow layouts
- README: engines()/set_engine() usage
- API reference: tools.duckdb_engine added
- CHANGELOG: Added/Changed/Fixed entries; TODO: accessor item done
The ID and VALUE columns' Arrow layout is now selectable: "utf8" (32-bit
offsets, the stable default), "large_utf8" (64-bit), "string_view"
(polars'/duckdb's native 16-byte view layout, adopted zero-copy;
needs pyarrow >= 16). "auto" picks the layout the return_type adopts
zero-copy: string_view for polars, utf8 otherwise. KEY and INSTANCE_ID
stay dictionary-encoded regardless — consumers use the indices.

- cython engine: StringColBuilder, a layout-selecting wrapper over
  arrow::StringBuilder / LargeStringBuilder / StringViewBuilder — the
  layout branch is constant per parse, so the hot loop cost is unchanged;
  guarded by ARROW_VERSION_MAJOR >= 16 with a clear runtime error below it
- python_lxml_arrow: layouts applied by a single cast on the combined
  table at finalize (noise on the 1.4s lxml path)
- shared accessor (triplets/_arrow/string_column.h): string_view branch
  added, so cimxml export and qlever ingest accept every layout
- empty parses carry the requested layout; unknown names raise ValueError
- parity tests: every layout x arrow engine against the lxml reference
…nknown kwargs

The unused **kwargs on parse() swallowed typos silently — a misspelled
option looked like it worked while doing nothing (it also invalidated a
benchmark run by ignoring string_type= on an older build). Unknown keyword
arguments now raise TypeError.

Measured on RealGrid (1.14M rows, interleaved min-of-7): parse→arrow
identical across layouts (the StringColBuilder facade costs nothing);
parse→polars ~2-4% faster with string_view — the polars import is bounded
by the dictionary→Categorical conversion of KEY/INSTANCE_ID (~11 ms/col),
noted in docs/parsers.md as the next lever.
…or; honest input-policy report

- Registry kinds carry a role prefix when format-specific, so "cimxml" is
  never ambiguous between the parser and the exporter: parser_cimxml,
  exporter_cimxml, exporter_nquads, exporter_csv (sparql/validation/tools
  unchanged). set_engine kwargs and engines() keys follow. The names had
  not shipped in any release.
- is_polars is removed entirely: flavor(data) is the single detection
  idiom at every boundary (export, sparql engines, cimxml_utils,
  cgmes_tools) — no per-flavor boolean helpers. The nquads input
  conversion also generalizes: any flavor mismatching the resolved engine
  converts (arrow input with the pandas engine previously passed through
  raw).
- policy="input" kinds (tools, exporter_csv) report engine=None,
  source="input" in engines() instead of pretending an auto pick exists —
  the input object's flavor decides per call.
- docs: engine-model table updated; cgmes_tools documented as deliberately
  registry-free (pandas boundary for in-place VALUE mutation semantics).
…df(append=)

read_rdf previously materialized the ENTIRE dataset as one in-memory Arrow
table before CREATE OR REPLACE, and a second load silently replaced the
first. Now:

- parser.iter_all_xml(): lazy generator behind find_all_xml — zip members
  are read into memory only when yielded (one at a time), and zip handles
  this module opens are actually closed (fd-leak fix)
- parser.parse_batches(paths, engine=, ...) -> pyarrow.RecordBatchReader:
  one RecordBatch per XML file, produced as consumed; fixed all-utf8
  ID/KEY/VALUE/INSTANCE_ID schema (no dictionary encoding — per-file
  dictionaries differ, database consumers re-encode internally); requires
  an arrow engine, no silent pandas fallback
- con.read_rdf(paths, append=False): batches stream straight into DuckDB
  via the registered reader. append=True INSERTs into the existing table
  (created if missing); default replaces. Empty input creates the standard
  4-column table. One transactional statement per load — a mid-stream
  parse failure leaves the previous table intact. string_type /
  categorical_columns no longer apply on this path (they were arrow-return
  knobs; passing them now raises TypeError).
The per-connection table/schema defaults lived only in a process-local
WeakKeyDictionary — reopening a persisted file forgot them. Now explicit
configuration (connect(table=/schema=), set_triplets_table, read_rdf with
table=/schema=) is also written to a tiny main."_triplets_config" key/value
table inside the database, and _get_table resolves lazily: in-process
config → DB-stored config → package defaults. Cursors and duplicates share
the database, so they resolve the same config with no special-casing.

- _install_connect uses _UNSET sentinels: a bare duckdb.connect() no longer
  touches the config at all (and never writes — :memory: stays clean)
- read-only connections resolve stored config; set_triplets_table on them
  updates the in-process config only
- ATTACHed extra catalogs are documented as out of scope (config lives in
  the default catalog)
set_value_at_key(_and_id) → UPDATE; remove_triplets_from_triplets → DELETE
WHERE EXISTS; _apply_update → UPDATE ... FROM then INSERT ... WHERE NOT
EXISTS (UPDATE first so updated rows are never re-inserted; MERGE rejected
as more machinery than two plain statements).

Fixes a real bug: the old CREATE OR REPLACE projections hard-coded
ID/KEY/VALUE/INSTANCE_ID, silently dropping any extra user columns on the
first mutation. Extra columns now survive (INSERTed rows get NULL extras).
Also: mutations no longer copy the whole table (works at any size) and
rowids/load order stay stable, so the tableview/reference "first value by
load order" picks are preserved across mutations instead of re-normalized.
… the schema

rowid only exists on base tables — tableviews, references_* and
content_hash(order_sensitive=True) raised BinderException whenever table=
pointed at a VIEW or a registered frame (the shacl_duckdb pattern). Now:

- _ord_expr picks the load-order expression per target: rowid on base
  tables (probed via duckdb_tables()), row_number() OVER () otherwise —
  order then follows scan order, affecting only tie-breaks among duplicate
  (ID, KEY) rows. _table_parts extracted; _resolve_table is a one-liner.
- content_hash(order_sensitive=True) on a non-rowid target raises a clear
  ValueError instead of hashing a meaningless arbitrary order; the default
  order-invariant hash works on any target.
- _create_view qualifies the view with the resolved schema, so tableview
  views land next to the data (previously always the default schema, even
  with connect(schema="cim")). Data-derived default view names are kept
  (view_name= is the escape hatch; collision surface documented).
…olars

type/key/id_tableview and triplets_to_tableviews gain multivalue= (a key
holding several values renders the literal ['a', 'b'] text, single values
stay bare — byte-identical to the polars encoding, element order = load
order) and string_to_number= (accepted for signature parity; True raises
ValueError — tableview columns are VARCHAR, TRY_CAST left as a follow-up).
tableview_to_triplets(multivalue=True) decodes bracketed cells (split +
trim per element + UNNEST), mirroring the polars decoder incl. its
embedded-comma/quote limitation.

Parity harness: duckdb no longer skips string_to_number kwargs; new
multivalue encode + round-trip specs run across all three engines on a
synthetic multi-valued frame (Svedala has no genuine multivalues).
TODO multivalue item resolved; deferred chunked-export design recorded.
…fd leaks

1,593 warnings → 0. The bulk was one rdflib 7.6.0 upstream bug: its own
inherited ConjunctiveGraph/Graph code (and pyshacl's graph cloning) calls
its own deprecated Dataset.default_context/.identifier on every parse,
match and hash — filtered per-module in pyproject with a pointer to drop
the ignores when rdflib migrates its internals. always::ResourceWarning is
now on so real leaks stay visible:

- test_path_handling built ALL parametrized sources eagerly, leaking an
  open file handle in every run; sources are now lazy and closed
- the tools benchmark exercised a legacy alias 119× (pytest-benchmark
  amplification) — switched to triplets.tools.filter_triplets_by_type;
  the deliberate shim-net tests keep their aliases under one module-scoped
  filterwarnings mark stating the intent
- CHANGELOG entries for the duckdb phases; README duckdb section shows
  append= and config persistence
Review of the SHACL constraint IR found four concrete defects, all fixed:

- shacl_report._COMPONENT_MAP was missing MinExclusive/MaxExclusive/
  LessThanOrEquals — pyshacl reports carried VIOLATION_TYPE
  "sh:MinExclusiveConstraintComponent" where the vectorized engines emit
  "sh:minExclusive" (vocabulary drift for 3 of 24 components)
- sh:or/sh:and/sh:not recursion threaded the visited set without ever
  adding to it — mutually referencing shapes recursed to RecursionError
  (sh:node already had the guard); _shape_rows now guards all four
- FALLBACK_COMPONENTS moves to shacl_ir (the shared contract's home) —
  shacl_duckdb no longer imports shacl_polars for a 5-string constant
  (engine="duckdb" without polars installed used to fail at import);
  shacl_polars re-exports the name
- new test_component_registries_agree pins the 8 stringly-typed component
  registries together (pandas complete; polars/duckdb + fallback cover
  everything; pyshacl vocabulary maps onto the same short names), and a
  cyclic-sh:or fixture pins the new guard

docs/validation.md gains the full IR structural reference: field table,
exact params shapes per component (incl. the sh:node/sh:sparql dict keys),
compile-time behaviors, and the per-engine component coverage matrix.
Also fixes two doc drifts (lexical dedup columns, stale engine signature).
A docs-vs-code audit found 20 findings; all actionable ones fixed:

Code-level:
- pyproject: the duckdb extra now pulls pyarrow — con.read_rdf streams
  through parse_batches, which requires an arrow parser engine, so
  pip install triplets[duckdb] alone could no longer load data
- cimxml_pugixml._string_like accepts string_view — the shared accessor
  and extension already handled it, but the Python gate re-encoded
  string_view columns through a cast/astype copy
- engines() docstring matched the pre-rename behavior (input-policy rows
  now correctly described as engine=None/source="input");
  read_rdf docstring states the sequential/arrow-only contract and the
  append-path transactionality nuance; two module docstrings still claimed
  a literal "triplets" table (config resolution shipped weeks ago)

Docs:
- development.md: set_engine example used the removed 'parser' kind;
  to_return_type added to the conversion table
- parsers.md: parse_batches/iter_all_xml section (the streaming ingest
  path was absent from the guide)
- exports.md: duckdb streaming/append + export memory-spike notes,
  datatypes in the shared engine signature, DuckDB-native arrow row,
  string_view in the accessor line, _split_instances in the call sequence
- testing.md: the zero-warning policy (always::ResourceWarning, rdflib
  ignores) and both markers documented
- README: parser speed claims aligned with the committed benchmarks
  (~10x / measured 9.4x, tableview 15ms), unsupported duckdb parse cell
  replaced with a pointer to the streaming section
- API reference: the top-level package page (engines/set_engine/caches)
  joined the toctree; parser.nquads, export.cimxml_pugixml/cimxml_utils/
  nquads_utils autodoc'd
- CHANGELOG: read_rdf capability changes (max_workers/non-arrow engine/
  string_type no longer apply), cgmes explicit-engine ValueError,
  string_view in the accessor entry; TODO header/stale items refreshed
…kers restored

The streaming ingest was scoped sequential in v1, which dropped the
max_workers parallelism con.read_rdf used to have (default None was always
sequential; only explicit max_workers callers lost capability). The proper
fix keeps both properties: a bounded, in-order prefetch window submits up
to max_workers files ahead on a thread pool and yields batches in file
order, so multi-file ingest parallelizes while memory stays bounded by
max_workers+1 batches. Order preserved by construction; verified by test.
con.read_rdf(paths, max_workers=N) works again.
con.export_to_nquads/csv/cimxml/excel used to pull the whole table into a
pandas DataFrame before exporting. duckdb's native arrow result is ~4x
cheaper (measured on RealGrid 1.14M rows: .df() 383 ms vs to_arrow_table()
101 ms), and the consumers adopt arrow near zero-copy — the nquads polars
engine converts arrow in ~9 ms where the old path paid pandas→polars again
(17 ms on top of the 383). cimxml gets arrow-backed strings that flow into
the compiled extension with no re-encode.

Enabler: the exporters now accept pyarrow input directly — _check_columns
is flavor-aware (pa.Table.column_names), export_to_csv rides the pandas
engine via near-zero-copy ArrowDtype conversion, export_to_cimxml converts
arrow input the same way, and export_to_arrow reuses the unified check.
Parity tests gained arrow-input variants for nquads/csv/cimxml, plus a
connection-vs-frame export equivalence test.

Exports remain whole-table in-memory; chunked (larger-than-RAM) export is
still the recorded TODO design.
…treaming)

con.export_to_nquads now streams: duckdb's record-batch reader produces
~1M-row batches that the polars nquads formatter writes into one open
handle, one batch at a time — the whole-table materialization is gone from
the export path. N-Quads is the natural first format: every output line is
row-local (no ORDER BY needed — a sort would buffer inside duckdb; it is
only required for grouped formats like a future streaming cimxml).

- nquads_polars: the expression plan extracted into _quads(); new
  write_nquads_batches(reader, handle, rdf_map) runs the same plan per batch
- export_to_nquads accepts a pyarrow.RecordBatchReader directly (polars
  engine required — clear error otherwise); _check_columns reads
  .schema.names for any pyarrow input
- _accessor: the connection's nquads export streams via a cursor (a
  streaming result pins its connection) when polars is available; other
  formats keep the whole-table native-arrow fetch

Measured (file-backed DB, duckdb memory_limit=300MB, 100k-row batches):
peak-RSS delta stays FLAT at ~350-400MB while the dataset grows
1.1M → 2.3M → 4.6M rows (whole-table path: +1.2GB → +2.1GB → +3.3GB,
linear), at identical speed (stream 870ms vs whole 852ms at 2.3M rows,
352MB output). Line order differs from table order (documented; N-Quads
consumers are order-independent, parity canonicalizes).

Left for this branch's follow-up: streaming cimxml (ORDER BY INSTANCE_ID +
per-instance generator into the existing packaging loops).
…n in TODO

The explore/duckdb-chunked-export branch is merged: CHANGELOG gains the
out-of-core export entry (flat ~350-400MB peak from 1.1M to 4.6M rows vs
linear before, same speed), exports.md reflects that nquads streams while
the other formats remain whole-table, and TODO.md splits the chunked-export
item: nquads done; cimxml streaming (ORDER BY INSTANCE_ID + instance
generator into the existing packaging), and an optional per-batch pandas
fallback writer, remain open.
- con.export_to_nquads: stream only when the nquads registry resolves to
  polars, so an explicit engine="pandas" (or set_engine override) falls
  back to the whole-table arrow path instead of raising; regression test.
- con.read_rdf: unregister the _arrow_import reader in a finally block so
  a mid-stream parse failure cannot leave it registered on the connection.
- tools dispatch: report the real input flavor (pyarrow Table) in the
  engine/input mismatch TypeError instead of calling it a pandas DataFrame.
- to_arrow: document that pyarrow input passes through as-is (a
  RecordBatch is not upgraded to a Table).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Make duckdb table/schema name configurable Investigate possibility to align pandas and polars string arrow data type

1 participant