Database storage in detail¶
httk.store.db is the database storage layer of httk₂: it stores plain
frozen dataclasses in a relational database, makes them queryable through a
backend-agnostic search DSL, and serves them through the neutral
httk.core.EntryProvider contract (e.g. as an OPTIMADE API via
httk-serve). SQL generation and dialect handling run on SQLAlchemy Core
internally; the public API exposes no SQLAlchemy types.
Installing¶
The SQL layer is an optional extra (plain import httk.store works without it):
python -m pip install "httk-store[db]" # SQLite (built into Python) via sqlalchemy
python -m pip install "httk-store[duckdb]" # additionally the DuckDB backend
python -m pip install "httk-store[clickhouse]" # ClickHouse backend
Touching a SQL-backed name (such as httk.store.db.Database) without the extra
installed raises an ImportError naming it.
Declaring a storable class¶
Storability is non-intrusive: any frozen dataclass whose fields resolve is
storable — there is no base class. The stdlib-only marker vocabulary lives in
httk-core (Indexed, Unique, Skip, Shape, StorageInfo,
stored_property), so domain modules can declare storable classes without
depending on httk-store:
from dataclasses import dataclass
from fractions import Fraction
from typing import Annotated, ClassVar
from httk.core import FracVector
from httk.core.storage import Indexed, Shape, StorageInfo, stored_property
@dataclass(frozen=True)
class Author:
name: str
year: int
@dataclass(frozen=True)
class StructureRecord:
__httk_storage__: ClassVar[StorageInfo] = StorageInfo(indexes=(("spacegroup", "formula"),))
formula: Annotated[str, Indexed()]
spacegroup: int
energy: Fraction # stored exactly (see below)
cell_basis: Annotated[FracVector, Shape(3, 3)] # fixed-shape tensor, stored inline
reduced_coords: Annotated[FracVector, Shape(0, 3)] # variable rows, child table
symbols: list[str] # child table
reference: Author | None = None # foreign key, saved recursively
@stored_property
def natoms(self) -> int: # stored & queryable; recomputed on load
return len(self.symbols)
Scalars (int/str/bool/bytes) become columns, while float gets a query
DOUBLE plus an exact hexadecimal text companion so signed zero and every
other finite binary64 value round-trip unchanged. X | None makes fields
nullable, rationals and datetimes are encoded by value codecs, lists and tuples
become child tables, and nested storable dataclasses become foreign keys (saved
recursively first). Classes you cannot modify can be described externally with
register_schema_override.
Storing and fetching¶
Database names where data lives; SqlStore saves and reconstructs
instances. Saving deduplicates per the class’s StorageInfo.dedup policy
(by content identity by default), and transaction() scopes several
operations into one database transaction:
from httk.store.db import Database, SqlStore
db = Database.sqlite("example.sqlite") # or Database.sqlite() in memory,
store = SqlStore(db, entry_records={}) # first-time custom-record store
# Reopen an initialized database with: SqlStore(db)
with store.transaction():
sid = store.save(record) # returns the integer sid; dedups; recurses
same_record = store.fetch(StructureRecord, sid) # reconstructed exactly
Vocabulary¶
An entry family is a logical key such as StructureEntry.
A record is a durable frozen-dataclass representation; a family may have several.
Backend/View is the representation pattern: a backend owns data, and a view presents it.
A content id identifies the record’s content across stores; a SID is only a local row id.
Every database starts with a persisted, versioned layout declaration. Passing
entry_records={} says that this is a private/custom-record store with no
queryable entry families. An entry store instead maps each registered logical
family to the exact durable Record representation or representations it may
contain:
store = SqlStore(
db,
entry_records={StructureEntry: UnitcellStructureRecord},
)
A single record is queried directly. A tuple of two or more records creates
a small family dispatch table, while the representation-specific data remains
in its normalized Record tables. Saving an exact configured record (including
saving a naturally bound domain object) makes it discoverable through
fetch_entry(StructureEntry, content_id); that method returns the actual
concrete Record.
Later SqlStore(db) calls trust the persisted declaration; there is no layout
mode or schema diffing. Missing or edited record tables fail with the database’s
own errors when used. Tables are created lazily on the first write; reads never
issue DDL. Old, unversioned, or incompatible layouts raise
StorageLayoutUpgradeRequiredError; this redesign does not migrate old stores,
so rebuild them explicitly.
A source object with an exact __httk_storage_record__ can be saved directly;
save(source, as_record=OtherRecord) selects another declared projection.
Nested record fields are projected recursively. A projected source must expose
any derived stored_property declared by its target record because storage
does not construct an intermediate record merely to evaluate that property.
Record validation runs at this storage boundary through __httk_validate__.
Optional child fields use presence columns, so None remains distinct from an
empty child value.
While a saved or fetched instance is alive, fetching its sid again returns the
very same object. Join-objects pointing at a stored instance are found with
store.referring(TagClass, field="structure", to=record).
Permanentization, degraded writes, and fsck¶
SQL stores use a storage-only _httk_role parent column: 1 marks a record
saved at the public top level and 0 marks a recursively saved dependency.
The column is not part of content identity, by-value matching, canonical
encoding, hydrated records, or query results. Saving a dependency again at the
top level promotes its existing row to main; bulk canonicalization likewise
keeps the maximum role of all collapsed occurrences.
The usual Database.sqlite(...) and Database.duckdb(...) stores have the
persisted transactional write profile (the absent metadata value means the
same thing). SQLite additionally exposes an explicitly opt-in, artificial
transactionless conformance vehicle:
db = Database.sqlite("recovery-test.sqlite", degraded=True)
store = SqlStore(db, entry_records={})
That construction stamps the degraded profile and can only reopen through a
similarly configured database. Opening validates the live SQLite DB-API
autocommit state, not just the construction flag; a transactional profile also
rejects an autocommit engine. It is SQLite-only in this release: it uses DB-API
autocommit to model SQL-like backends that cannot provide transaction rollback.
The profile is deliberately single-writer. A database-visible writer lease is
acquired on mutation and held until Database.dispose(); another instance can
inspect the holder/age and explicitly call store.steal_lease() when recovery
authority is clear.
Degraded saves permanently write dependencies first, then child-element rows
under a preallocated monotonic sid, then the parent sid row last. Thus a visible
parent means its subtree is complete; a failed write may leave only dependency
or child residue. No compensation deletion is attempted. Per-operation dirty
markers cost one lookup, one upsert, and one conditional delete per touched
table; a leftover marker arranges a targeted ownerless-child sweep before the
next write to that table. Sid counters are created and initialized lazily at
the first allocation for each parent table. bulk_ingest() is intentionally unavailable for degraded stores in
v2.3.0; use ordered save() calls.
Run store.fsck(known_types=(...)) after a failed degraded writer (or for an
integrity audit). It repairs missing dispatch rows for main entries, sweeps
ownerless child rows, marks from main and dispatch roots, removes unreachable
dependency rows, and reports dangling logical references. It refuses garbage
collection if it finds an ordinary application table it cannot attribute to
the declared layout or known_types; no unrelated table is guessed or swept.
SQLite transactional fsck uses BEGIN IMMEDIATE. DuckDB callers must pass
exclusive=True, which is an explicit acknowledgement that the database is
offline from all writers for the entire fsck; DuckDB cannot otherwise enforce
the necessary read/delete exclusion. Invalid role values are violations; with
repair=True fsck normalizes them to dependency role 0 rather than inventing
a new root.
ClickHouse bulk-fenced writes¶
For local/CI server setup and the required _httk_bootstrap KeeperMap DDL,
see the ClickHouse testing guide.
ClickHouse uses KeeperMap metadata and the persisted bulk-fenced profile.
Reads do not acquire a lease. A bulk writer acquires a fresh, never-reused
token with a strict insert, verifies that exact value during the P2 bulk-entry
and marker operations, and releases it with an exact-value delete when
Database.dispose() runs. P3 adds verification around its durable phases. The
ingest_state marker is also a strict insert and carries the lease token plus
a fresh per-ingest nonce; it is cleared only by an exact-value delete after a
successful ingest.
steal_lease() is intentionally unavailable.
If a writer dies with only a lease residue, inspect _httk_store_metadata,
verify that the writer is no longer alive, and delete only the observed lease
value with a ClickHouse client:
SELECT key, value FROM _httk_store_metadata WHERE key = 'lease';
SET keeper_map_strict_mode = 1;
DELETE FROM _httk_store_metadata
WHERE key = 'lease' AND value = '<observed lease JSON>';
Never clear ingest_state merely because its lease was removed. Its presence
means the store may contain partial or inconsistent physical state, so the
default remedy is DROP DATABASE, recreate the bootstrap table, and re-ingest.
Only after a verified cleanup/rebuild has restored the declared empty-store
invariant may an operator clear the exact observed marker value. Use the same
strict setting:
SET keeper_map_strict_mode = 1;
DELETE FROM _httk_store_metadata
WHERE key = 'ingest_state' AND value = '<observed marker JSON>';
Do not delete values belonging to a live writer or use broad key-only deletes.
Bulk ingestion¶
For SQLite and DuckDB, 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.db.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.
# 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.db.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.db.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).
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.db.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.
Searching¶
store.searcher() opens a query through the backend-agnostic protocols in
httk.store.query: bind classes to variables and add conditions. Freeze the
query into the user-facing lazy result set with results(). Variables of the
same class self-join; reference fields chain (v.reference.name),
variable-length fields support the set operations (has_any, has_only), and
~ negates them as sets. String matching (contains, startswith,
endswith) always takes literal text — % and _ match themselves:
search = store.searcher()
s = search.variable(StructureRecord)
search.add(s.spacegroup == 225)
search.add(s.reference.name == "Ada") # auto-joins the author table
search.add(s.symbols.has_only("O", "Ca", "Ti")) # for-all over the child rows
search.add(~s.symbols.has_any("Fe")) # no child row is iron
results = search.results(structure=s, energy=s.energy)
for row in results: # lazy ResultRow values
print(row.structure.formula, row.energy) # exact rational energy
ResultRow supports names, attributes, and positions. scalars() is the
short form for a one-column result (or takes a column name), and first() and
one() return one row; one() raises NoResultError or
MultipleResultsError unless there is exactly one. Results are reusable:
len(results), results[1:3], and re-iteration are all supported. A slice is
a view over its own positions: iteration, len(), indexing, first(),
one(), and column() are all scoped to that slice without re-querying.
Scalar columns stay exact by default. column() returns a ResultColumn with
an explicit approximate view through .floats() and an exact rational tensor
through .to_fracvector() for integer, fraction, and fracscalar columns:
energies = results.column("energy")
exact = list(energies)
approximate = list(energies.floats())
as_vector = energies.to_fracvector()
.to_fracvector() rejects floats, surds, strings, datetimes, and other
non-rational projections. Variable-length CHILD-role projections are rejected
when results() is declared; reference-path projections are supported.
Continuation pages¶
SqlResultSet.page() is an optional capability (described neutrally by
httk.store.PageableResultSetLike), separate from the required ResultSetLike
contract. It uses a stable keyset/seek order over named root scalar result
projections and returns an immutable ResultPage:
from httk.store import PageOrder
page = results.page(
size=100,
order_by=(PageOrder("spacegroup"), PageOrder("energy", descending=True)),
)
for row in page.rows:
print(row.structure.formula, row.energy)
if page.next is not None:
later = results.page(
size=100,
order_by=(PageOrder("spacegroup"), PageOrder("energy", descending=True)),
cursor=page.next,
)
PageOrder.name refers to the output name ("energy" above), not a
SQLAlchemy column. Ordering accepts root scalar and encoded-scalar projections;
object outputs, child/reference-derived keys, duplicate names, an existing
add_sort(), a nonzero query offset, and a query limit are rejected. The SQL
implementation always appends the root sid as an internal ascending
tie-breaker, so duplicate user-order values do not duplicate or skip rows.
nulls="first"/"last" is explicit and portable across SQLite and DuckDB.
An empty order tuple is valid when storage order by root sid is sufficient.
The opaque URL-safe ContinuationToken contains only a version, tagged anchor
values, root sid, direction, and a digest binding the frozen query/output/order
schema and dialect. It contains no SQL and decoded values are always bound
parameters. It is deliberately not authenticated: a web boundary can wrap its
string value in an HMAC or another authenticated envelope. Corrupt, oversized,
non-canonical, or mismatched tokens raise PaginationCursorError; do not
construct application tokens yourself.
Pages are live: every call uses a fresh read connection and does not keep a
driver cursor or transaction open. On an unchanged store, following next and
then previous recovers the original page. Concurrent direct database changes
can move, insert, or remove matches between calls, so continuation paging does
not promise snapshot consistency.
A page fetches at most size + 1 root match rows and uses a lexicographic seek
predicate rather than a query offset; the library hard-caps size at 10,000.
include_total=False (the default) does not count. include_total=True runs
the normal exact SQL count separately. This bounds application memory and match
row transfer, not database CPU for arbitrary filters or sorts; indexed root
order fields benefit from the indexes declared in the schema.
cursor() bounds the number of hydrated record/proxy objects held by a
row-by-row consumer, but not the raw values pinned by the result set. The
object value in each cursor ResultRow is an instance of the record class, so
views can be built on it. Each object output uses an explicitly unhashable,
reused proxy that expires when the cursor advances. Equality on an expired
cursor row raises; copying and pickling cursor rows are rejected even before
expiry. Components already filled into a view before advancing remain
readable on that view, but later component fills raise
ExpiredCursorRowError.
Low-level portable protocol¶
The backend-neutral protocol form remains useful for code that must run on
any Searcher implementation. Declare outputs and iterate its plain
SearchResult values directly:
search.output(s, "structure")
for (structure,), names in search:
print(names, structure.formula)
This is the low-level/portable layer; SQL consumers should generally use
results().
Neutral portable Store profile¶
httk.store.Store is intentionally a small, backend-neutral contract:
store.searcher() returns a one-query Searcher, which binds one or more
backend-defined targets with variable(), receives expressions through
add(), and exposes count(), limit/offset, sorting, iteration, and
results(). A portable result supports iteration, len(), first(), one(),
and scalars(); one() uses the shared NoResultError and
MultipleResultsError exceptions. UnsupportedQueryError means that a
requested expression is outside a particular backend’s portable subset.
This profile is deliberately what a remote, read-only OPTIMADE store can
implement too: it supports a single root endpoint, portable scalar/flat-list
filters, named outputs, and result cardinality without making the caller
depend on SQLAlchemy or a database dialect. Query code that only needs this
profile should depend on httk.store.Store, not SqlStore.
The following are SQL-specific extensions, not portable Store requirements:
persisting/fetching frozen dataclasses with save() and fetch(), schema and
transaction management, recursive reference storage, lazy SQL rows,
ResultColumn.floats()/to_fracvector(), cursor rows, child/reference joins,
continuation pages, and SQL’s approximate comparisons for exact rationals. Do not assume those
operations exist on a remote or in-memory Store.
A plain comparison on a child field is existential un-negated and set-negating
under ~: s.symbols == "O" means “some symbol is O”, ~(s.symbols == "O")
means “no symbol is O” (not “some symbol is not O”), agreeing with
~s.symbols.has_any("O"). is_in reads by field kind: on a root field
s.formula.is_in("CaTiO3", "NaCl") is plain membership, while on a child field
s.symbols.is_in("O", "Ca", "Ti") is the for-all reading — every element must
be in the set — exactly the same as has_only.
s.always_true() and s.always_false() are constant conditions on a search
variable. They are reserved method names that never resolve to a stored field,
and they matter mainly to code that builds filters programmatically: the
obvious alternative, a field == field probe, is not NULL-safe — it yields
NULL rather than true for a row whose field is NULL, and so silently drops
rows.
Result and identity semantics¶
Search rows are lazy subclasses of the storable class, so isinstance(row, StructureRecord) is true. The parent row is loaded when the row is first
used, and fields decode independently as they are accessed. They provide the
dataclass-generated compare/hash/repr behavior, honoring each field’s
compare, hash, and repr flags, plus the same content-id and save()
behavior as the eager record. Classes with custom __eq__ or __hash__ are
rejected for lazy rows. Each row also exposes its database sid.
dataclasses.replace(row, ...) creates a new ordinary dataclass instance of
the lazy row class and runs validation. Lazy rows intentionally reject
copy.copy, copy.deepcopy, and pickling. Search rows bypass the store’s
identity cache: two result rows for one sid are not an identity guarantee.
fetch() retains identity-while-alive, so repeated fetches of a sid return
the same live object.
An object output from an outer join can be None; the result row is retained,
not dropped. If a matched sid is deleted before an object output’s lazy row is
hydrated, hydration raises StaleResultError; exact scalar projections cannot
become stale because their values and _exact companion texts came from the
outer SELECT.
Memory and statement cost¶
The result set materializes a match index that pins the matched sids, raw
scalar-output values, and, for exact projections, their _exact companion
texts. All of those arrive in the one outer SELECT; there is no second
per-chunk exact-value fetch. Hydrated records live in a weak chunk cache of
500 parent sids; live rows pin their own chunk, while re-iteration may
rehydrate chunks that are no longer pinned. cursor() limits hydrated
record/proxy objects, not this pinned raw result data. A typical full-object
pass costs one outer match query plus about one parent query per 500 rows and
one batch per touched child-field group per 500 rows — not one SELECT per row.
The public store API is append-only, so rows do not disappear during normal
use; direct database edits can produce StaleResultError for object outputs.
Exact rationals, approximate comparisons¶
Rational values (fractions.Fraction, FracScalar, SurdScalar,
FracVector tensors) are stored losslessly: a canonical exact text column
is the round-trip source of truth, alongside float companion columns used for
querying and indexing. Stored values therefore reconstruct exactly at
arbitrary precision — but SQL comparisons (and sorting) on rational fields run
on the float companions and are documented approximate. Content identity
and deduplication always use the exact form.
Serving through OPTIMADE¶
StoreEntryProvider bridges a store to the httk.core.EntryProvider
contract: it auto-generates an OPTIMADE entry-type definition per served class
from its schema (every schema-derived property named with a registered
database-specific prefix, _httk_ by default), yields JSON-able records, and
declares relationships for reference fields whose target class is also served:
from httk.store.db import StoreEntryProvider
provider = StoreEntryProvider(store, {"structures": StructureRecord, "authors": Author})
Handing the provider to httk-serve’s adapter_from_providers serves the
database as an OPTIMADE API. httk-store does not depend on httk-serve: the
provider handoff uses the httk-core contract, while httk-serve also consumes
httk-store’s neutral query and store APIs. Fields with no OPTIMADE value
representation (bytes, custom codecs) are not served, and rationals are
served as their nearest floats. The provider is also registered (as
store-db-store) for discovery through the httk.core registry.