Bulk ingestion¶
For SQLite, DuckDB, and PostgreSQL, store.bulk_ingest() is a faster path than
a save() loop for building a store from scratch or appending a large
increment to one. It returns a
httk.store.backend.sql.bulk.BulkIngest context manager that mirrors save() but buffers
encoded rows with pre-assigned sids and appends them in executemany batches
inside one transaction, instead of one statement round-trip and an in-database
deduplication protocol per record. It is a near drop-in for the save loop:
ClickHouse bulk ingestion is currently fresh-store-only and stops at the P2 lease-plus-marker boundary until P3 supplies its nontransactional loader and finalizer. It does not provide rollback or exact restoration; marker residue fails closed and the default recovery is drop-and-reingest.
Known limitation — PostgreSQL bulk NaN in a list-of-floats field. Under
PostgreSQL bulk ingest, a NaN value inside a stored list-of-floats (child)
field is not preserved: it reads back as NULL. Bulk ingest stages rows
through SQLite shards, which cannot represent NaN, so the value is lost in the
list column. A scalar float NaN IS preserved under bulk ingest, and the
serial save() path preserves NaN in both scalar and list-of-floats fields on
every backend.
# Per-record save loop
with store.transaction():
for structure in structures:
store.save(structure)
# Bulk-ingest drop-in
with store.bulk_ingest() as bulk:
for structure in structures:
bulk.save(structure)
Reach for it when the increment is large; for a handful of records the ordinary
save() path is simpler and the round-trips it saves are negligible.
Contract¶
Exclusive write ownership. While a bulk_ingest() context is open the
store’s ordinary write path belongs to it: save(), ensure_tables(), and
transaction() on the same httk.store.backend.sql.SqlStore raise RuntimeError, and a
second bulk_ingest() context on the same store is refused. Reads from an
already-open store remain available; a new open is rejected while an
empty-store ingest marker is present.
SQLite/DuckDB transaction and restoration. On SQLite and DuckDB, the whole ingest runs in a single transaction that commits only on clean exit. Any exception — a metadata conflict, a uniqueness violation, or one you raise inside the block — rolls the transaction back, drops every table the context created, restores any index it dropped, removes its staging tables, and clears the store’s identity caches, leaving the store exactly as it was before the context opened. For an empty-store ingest, cleanup verifies that only the metadata table remains and then clears its marker, so retrying is safe. A hard crash can leave the marker behind; subsequent opens reject that store and require dropping and re-ingesting it.
These transaction and restoration guarantees do not apply to ClickHouse. Its nontransactional P3 ingest will use the marker as a fail-closed recovery gate; an interrupted marker defaults to drop-and-re-ingest.
Deduplication and uniqueness are post-conditions, not per-row checks. Within
the stream, records deduplicate set-wise in memory by the class’s
StorageInfo.dedup policy (content identity by default, by_value, or none),
exactly as save() would. Global uniqueness against what is already stored is
enforced at the boundaries rather than per row: on a physically empty store the
record tables are created index-less and their separable indexes (content-id
uniqueness, Indexed/Unique, composite, and child parent-sid) are built once
the stream completes — building the unique index is the verification, and a
duplicate aborts the ingest. On a populated store each flushed chunk is staged
into an ordinary bulkstage_<table> table and resolved set-wise against the
target: a content-id anti-join, a by_value whole-parent-column anti-join with
null-safe equality, and a sid remap that rewrites every still-buffered reference
to the deduplicated existing sid.
Returned sids are provisional. bulk.save() returns an integer sid like
save(), but it is provisional while the context is open: a record that
deduplicates against a row the store already held is remapped to that existing
sid at flush. After the context exits cleanly,
httk.store.backend.sql.bulk.BulkIngest.resolved_sid maps any returned sid — provisional
or final — to its durable stored sid. It keys on the bare sid value, so resolve
a returned sid against the type it was saved as (sids are allocated per table,
and one value can recur across tables).
Nested entry promotion. bulk.save(envelope, promote=StructureRecord)
makes every nested StructureRecord occurrence a top-level entry while keeping
the envelope as the returned root. Pass an iterable of classes to promote more
than one record type. Each class must be reachable from the envelope’s stored
schema; projection, role marking, and entry dispatch all remain inside the same
worker task.
verify_metadata (default True, a plain bool) controls whether a
content-id hit compares its identity-excluded metadata against the first
in-memory occurrence — or against the stored row for a hit against existing
data — reproducing save() and raising
httk.store.store_common.EntryMetadataConflictError on a conflict. Pass
verify_metadata=False to skip the comparison when the stream is
known-consistent.
index_strategy ("auto", "keep", or "rebuild", default "auto")
governs only how an existing table’s separable indexes are handled during an
append: "keep" appends through them, "rebuild" drops and recreates them at
the end (where the unique-index creation re-verifies global uniqueness), and
"auto" chooses per table by the staged-to-existing row ratio. On DuckDB, which
reserves a dropped index’s name until commit, a "rebuild" decision instead
keeps the indexes in place — relying on their incremental maintenance — and
verifies content-id uniqueness with a duplicate scan at finalize; the final
indexes are identical either way.
finalize ("auto", "parity", or "deferred", default "auto")
chooses the finalization profile. "deferred" is an explicit fresh-store
profile at any worker count; "parity" is the historical in-database path.
"auto" selects deferred only for a physically empty, supported serial ingest;
it selects parity for every other case, including workers>1. At current batch
scales the parallel in-database merge is faster, while serial deferred gains
about 36%.
Nested conflict paths differ by prefix. Because the bulk encoder resolves
referenced and child records eagerly and only discovers their existing-row hits
at flush, an httk.store.store_common.EntryMetadataConflictError reached through
a descend field (a non-skipped reference whose target itself carries skipped
metadata) is reported at the descendant record’s own path ("Leaf.note") rather
than the ancestor field path save() would use ("Root.primary.note"). The
exception type, message template, and roll-back are identical; only the path
prefix differs.
chunk_size (default 100_000) is the number of top-level save() calls
buffered before a flush. Buffered rows and the in-memory dedup indexes are held
until the next flush, so peak memory scales with the chunk size and each
record’s fan-out into child and reference rows: lower it for very wide records
or a tight memory budget, raise it to amortize the staging round-trips over more
rows. Identity caches are deliberately not populated by bulk ingestion.
on_progress is an optional (records_buffered_total, rows_flushed_total)
callback invoked after each flush, for progress reporting over a long build.
Performance¶
Bulk ingestion gains most on flat records with little fan-out: measured against
the per-record save() loop it is roughly 30x faster on DuckDB and 13x
on SQLite for flat rows, easing to about 5x (DuckDB) and 4x (SQLite) for
structure-shaped records whose child and reference tables dominate the row
count. These figures come from single-threaded runs against a tmpfs database, so
the per-record baseline they improve on is already I/O-favorable; both the
speed-up and the absolute throughput will differ on slower storage.
Parallel ingestion¶
For the offline build of a store from a large stream, bulk_ingest(workers=N)
with N > 1 encodes the stream in a pool of forked worker processes and merges
their per-table shards set-wise. Encoding — the bottleneck for structure-shaped
records — runs across cores; the merge (loading shards, collapsing cross-worker
duplicates, renumbering to compact sids, and building the indexes) runs once in
the main process inside the ingest’s single transaction.
with store.bulk_ingest(workers=12) as bulk:
bulk.save(layout_record)
for material in materials:
bulk.save(material)
On DuckDB workers hand rows off as Parquet shards, so parallel mode there needs
pyarrow; install it with the combined extra:
$ pip install "httk-store[duckdb,parallel]"
SQLite workers write one native shard database each and need no extra dependency.
Empty target only. Parallel mode is for building a fresh store, not for
appending: opening workers>1 on a store that already holds application rows is
refused (use workers=1 for incremental appends). On DuckDB the restriction is
stronger — any pre-existing application table is refused, because the merge
renumbers and deletes rows in place and DuckDB will not do that through a live
foreign-key constraint.
Physical schema is foreign-key free. SQLite and DuckDB use the same FK-free physical DDL for serial and parallel builds. Logical reference, ownership, child-element, and dispatch edges remain available to the storage algorithms, while column types, keys, checks, and indexes are unchanged.
Provisional tokens. Because a worker encodes each object asynchronously, the
sid is not known when save returns; in parallel mode save returns an opaque
token instead. After the context exits cleanly,
httk.store.backend.sql.bulk.BulkIngest.resolved_sid maps each returned token to its
durable stored sid, exactly as it maps a provisional sid on the serial path. A
lost task (an unpicklable object, or a worker that crashed or was killed) aborts
the ingest rather than committing a partial store, and on_progress is rejected
up front because per-flush counts are not observable across processes.
Identity-excluded metadata restriction. The merge verifies identity-excluded
(IdentitySkip) metadata with a grouped column scan rather than by reconstructing
every duplicate record. That covers scalar skip columns and skipped references to
content-addressed or by_value records, and it reports a conflict against the
schema field. A few shapes fall outside it and are rejected up front (naming
workers=1): an identity-excluded child sequence, an identity-excluded
reference to — or descend into — a non-deduplicated (dedup="none") record,
and a self-referential identity-excluded reference. Opening with
verify_metadata=False lifts the restriction.
Measured speed-up. Building the ~9,000-material altermagnets store into a
file-backed DuckDB database, parallel mode reaches about 6.6x at 24 workers
when replicas share substructure (the realistic case, where the merge collapses
many cross-worker duplicates) and about 11x at 24 workers with distinct roots
and shared atomic descendants (each material and its structure distinct, their
cells/sites/species still shared, so the merge collapses much less). The encode
phase scales with the worker count; the merge is a small fixed fraction of the total.
The benefit is real only for large builds — the pool fork, the shard round-trip,
and the merge are pure overhead on a small stream — so workers defaults to 1.
Reproduce with benchmarks/bench50_parallel.py.