"""Versioned physical layout for :class:`httk.store.db.store.SqlStore`."""
import dataclasses
from collections.abc import Mapping
from types import MappingProxyType
from typing import Final, Literal
import sqlalchemy
from httk.store.db.mapping import dispatch_table_for, entry_dispatch_table_name, table_for
from httk.store.db.schema import resolve_schema
from httk.store.storage_layout import (
EntryFamilyLayout,
StorageLayout,
StorageLayoutUpgradeRequiredError,
declaration_json,
)
from httk.store.storage_layout import (
normalize_entry_records as _normalize_entry_records,
)
__all__ = [
"METADATA_TABLE_NAME",
"STORAGE_PROTOCOL_VERSION",
"WRITE_PROFILE_VOCABULARY",
"BackendFacts",
"EntryFamilyLayout",
"StorageLayout",
"StorageLayoutUpgradeRequiredError",
"StoreUnderConstructionError",
"actual_schema_objects",
"actual_table_names",
"backend_facts_for_dialect",
"declaration_json",
"expected_metadata",
"metadata_table_for",
"normalize_entry_records",
"read_store_metadata",
]
[docs]
STORAGE_PROTOCOL_VERSION: Final = "v2.4.0"
# This bump adds the ClickHouse bulk-fenced profile and KeeperMap metadata
# semantics to the permanentization layout.
"""The persisted SqlStore layout protocol implemented by this package."""
"""Reserved key/value table holding the store protocol and entry declaration."""
_METADATA_PROTOCOL_KEY: Final = "protocol"
_METADATA_DECLARATION_KEY: Final = "entry_declaration"
_RESERVED_PREFIX: Final = "_httk_"
[docs]
WRITE_PROFILE_VOCABULARY: Final = frozenset({"transactional", "degraded", "bulk-fenced"})
[docs]
class StoreUnderConstructionError(RuntimeError):
"""A new open found an interrupted empty-store bulk ingest.
Crash window for new SQLite/DuckDB opens: before the marker commits the
old clean state remains accepted; after the marker and through ingest,
finalize, or before marker clear the store is rejected; after clear it is
accepted again. The marker is intentionally not a resume protocol.
ClickHouse marker residue is fail-closed: the default recovery is to drop
the database and re-ingest. Clearing the marker is valid only after an
operator has restored and verified the declared empty-store invariant.
"""
@dataclasses.dataclass(frozen=True)
[docs]
class BackendFacts:
"""Dialect capabilities used by the SQL storage protocol."""
[docs]
transactional_ddl: bool
[docs]
transactional_dml: bool
[docs]
supports_sequences: bool
[docs]
supports_deferred_finalize: bool
[docs]
supports_degraded: bool
[docs]
write_profiles: tuple[str, ...]
[docs]
supports_incremental_save: bool
[docs]
system_catalog: Literal["sqlite", "duckdb", "clickhouse"]
[docs]
stage_load: Literal["attach", "duckdb-views", "client-stream"]
[docs]
finalize_map_maintenance: Literal["update", "swap"]
[docs]
supports_adhoc_indexes: bool
_BACKEND_FACTS: Final[dict[str, BackendFacts]] = {
"sqlite": BackendFacts(
transactional_ddl=False,
transactional_dml=True,
supports_sequences=False,
atomic_upsert=True,
serial_stage_format="sqlite",
parallel_shard_format="sqlite",
supports_deferred_finalize=True,
supports_degraded=True,
write_profiles=("transactional", "degraded"),
metadata_backend="table",
supports_incremental_save=True,
system_catalog="sqlite",
stage_load="attach",
finalize_map_maintenance="update",
supports_adhoc_indexes=True,
),
"duckdb": BackendFacts(
transactional_ddl=True,
transactional_dml=True,
supports_sequences=True,
atomic_upsert=True,
serial_stage_format="duckdb-attach",
parallel_shard_format="parquet",
supports_deferred_finalize=True,
supports_degraded=False,
write_profiles=("transactional",),
metadata_backend="table",
supports_incremental_save=True,
system_catalog="duckdb",
stage_load="duckdb-views",
finalize_map_maintenance="update",
supports_adhoc_indexes=True,
),
"clickhousedb": BackendFacts(
transactional_ddl=False,
transactional_dml=False,
supports_sequences=False,
atomic_upsert=False,
serial_stage_format="parquet",
parallel_shard_format="parquet",
supports_deferred_finalize=True,
supports_degraded=False,
write_profiles=("bulk-fenced",),
metadata_backend="keepermap",
supports_incremental_save=False,
system_catalog="clickhouse",
stage_load="client-stream",
finalize_map_maintenance="swap",
supports_adhoc_indexes=False,
),
}
[docs]
def backend_facts_for_dialect(dialect_name: str) -> BackendFacts:
"""Resolve the hardcoded protocol facts for one supported dialect."""
try:
return _BACKEND_FACTS[dialect_name]
except KeyError as error:
raise ValueError(f"SqlStore layout validation does not support dialect {dialect_name!r}") from error
[docs]
def normalize_entry_records(entry_records: Mapping[type, type | tuple[type, ...]]) -> StorageLayout:
"""Normalize a declaration and apply SQL physical-name validation."""
layout = _normalize_entry_records(entry_records)
_validate_physical_names(layout)
return layout
def _layout_from_declaration(value: str) -> StorageLayout:
"""Parse a declaration and apply SQL physical-name validation."""
from httk.store.storage_layout import _layout_from_declaration as parse_declaration
layout = parse_declaration(value)
_validate_physical_names(layout)
return layout
[docs]
def actual_schema_objects(connection: sqlalchemy.Connection) -> Mapping[str, frozenset[str]]:
"""Return application schema-object names mapped to their stable object kinds.
The DuckDB SQLAlchemy inspector presently routes column inspection through
a PostgreSQL catalogue relation DuckDB does not expose, so the whole
layout path intentionally uses the dialect catalogues directly.
"""
facts = backend_facts_for_dialect(connection.dialect.name)
if facts.system_catalog == "sqlite":
rows = connection.execute(
sqlalchemy.text(
"SELECT name, type FROM sqlite_master WHERE type IN ('table', 'view') AND name NOT LIKE 'sqlite_%'"
)
)
elif facts.system_catalog == "duckdb":
rows = connection.execute(
sqlalchemy.text(
"SELECT table_name, lower(table_type) FROM information_schema.tables "
"WHERE table_catalog = current_database() AND table_schema = current_schema() "
"UNION ALL "
"SELECT sequence_name, 'sequence' FROM duckdb_sequences() "
"WHERE database_name = current_database() AND schema_name = current_schema()"
)
)
else:
from httk.store.db.clickhouse import actual_schema_objects as clickhouse_schema_objects
return clickhouse_schema_objects(connection)
result: dict[str, set[str]] = {}
for name, kind in rows:
result.setdefault(str(name), set()).add(str(kind).lower().replace("base ", ""))
return MappingProxyType({name: frozenset(kinds) for name, kinds in result.items()})
[docs]
def actual_table_names(connection: sqlalchemy.Connection) -> frozenset[str]:
"""Return application base-table names without SQLAlchemy reflection."""
return frozenset(name for name, kinds in actual_schema_objects(connection).items() if "table" in kinds)
def _validate_physical_names(layout: StorageLayout) -> None:
owners: dict[str, type] = {}
visited: set[type] = set()
def visit(record: type) -> None:
if record in visited:
return
visited.add(record)
schema = resolve_schema(record)
names = [schema.table_name]
names.extend(spec.child.table_name for spec in schema.fields if spec.child is not None)
for name in names:
if name.startswith(_RESERVED_PREFIX):
raise ValueError(f"record {record.__name__} claims reserved SqlStore table name {name!r}")
previous = owners.get(name)
if previous is not None and previous is not record:
raise ValueError(
f"records {previous.__name__} and {record.__name__} collide on physical table name {name!r}"
)
owners[name] = record
for target in schema.referenced_classes():
visit(target)
for family in layout.families:
for record in family.records:
visit(record)
dispatch_name = entry_dispatch_table_name(family.name) if len(family.records) > 1 else None
if dispatch_name is not None:
if dispatch_name in owners:
raise ValueError(
f"entry family {family.name!r} dispatch table collides with record table {dispatch_name!r}"
)
owners[dispatch_name] = family.family