Source code for httk.store.db.bulk_parallel

"""Parallel encode + shard-merge backend for :class:`~httk.store.db.bulk.BulkIngest`.

This module implements the ``workers > 1`` mode of
:meth:`~httk.store.db.store.SqlStore.bulk_ingest`. The serial ``workers = 1``
path in :mod:`httk.store.db.bulk` is untouched; :class:`~httk.store.db.bulk.BulkIngest`
delegates to the helpers here only when more than one worker is requested.

The design has three moving parts:

- **Workers** (``_worker_main``): forked processes that run the *pure* encoders
  (``_encode_parent_row`` / ``_encode_child_rows`` from
  :mod:`httk.store.db.store`) with a per-worker
  :class:`~httk.store.store_common.SaveProjection`. Each worker owns a disjoint
  sid block (``(worker_index + 1) << 26``) so its rows never collide with
  another worker's before the merge. A worker deduplicates *content-addressed
  records that carry no identity-excluded metadata* and *all by_value records*
  within its own stream (bounding shard size); records that carry a metadata
  plan are emitted per occurrence so the merge can verify every collision.
  Workers never touch the database — they only write shard files.

- **Shards** (``_ParquetShardWriter``, ``_SqliteShardWriter``): per-worker,
  per-table row files. DuckDB stores each flush as a pyarrow Parquet file
  (``pyarrow`` imported lazily; its absence raises the documented ``parallel``
  extra hint); SQLite stores one shard database per worker written with native
  ``executemany``. Shards live in a ``tempfile.TemporaryDirectory`` next to the
  target database file when it is file-backed, else the tempfile default, and
  are always removed.

- **Merge** (:func:`merge`): the main process, inside the ingest's spanning
  transaction, loads every shard into the freshly created (index-less) record
  tables under the workers' block sids, then collapses cross-worker duplicates
  set-wise (content-id and by_value) in foreign-key dependency order. Because
  referenced tables collapse before their referrers, two rows sharing a content
  id then differ only in their identity-excluded (``IdentitySkip``) columns, so
  each collision's metadata is verified with a single grouped scan per table
  rather than by reconstructing every duplicate record (the dominant cost at
  real-build scale); nested and ``descend`` conflicts surface at the target
  table where the skip metadata lives. The merge then sweeps rows orphaned by a
  collapsed duplicate's subtree and remaps the surviving block sids to a compact
  ``1..N`` range, rewriting every foreign-key column through the same map.

``workers > 1`` targets the offline *build* of a store: it requires a
physically empty target (no application table already holds rows). Incremental
appends into a populated store remain the serial path's domain, where the
per-record staging protocol and its metadata verification already live.
"""

import csv
import functools
import importlib
import math
import os
import pickle
import queue as queue_mod
import sqlite3
import tempfile
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any

import sqlalchemy
from httk.core.storage import StorageProjectionCycleError, resolve_storage_record

from httk.store.db.mapping import (
    CONTENT_ID_COLUMN,
    DISPATCH_CONTENT_ID_COLUMN,
    ROLE_COLUMN,
    SID_COLUMN,
    backing_dispatch_column_name,
    entry_dispatch_table_name,
)
from httk.store.db.schema import TableSchema, resolve_schema
from httk.store.db.store import (
    SqlStore,
    _encode_child_rows,
    _encode_parent_row,
    _field_path,
)
from httk.store.store_common import (
    EntryDispatchIntegrityError,
    EntryMetadataConflictError,
    SaveProjection,
    _metadata_plan,
)

if TYPE_CHECKING:
    import httk.store.db.bulk
    from httk.store.db.bulk import BulkIngest

__all__ = ["ParallelController", "merge"]

# Each worker allocates sids from a disjoint high block so rows never collide
# before the merge; the merge remaps every block sid to a compact 1..N value.
# The store's sid column is a 32-bit integer (SQLAlchemy ``Integer`` renders as
# DuckDB ``INTEGER``), so worker ``w`` bases its block at ``(w + 1) << 26``:
# that fits a signed 32-bit integer for up to ~30 workers and leaves 2**26
# (~67M) rows per worker per table before the next worker's block.
_SID_BLOCK_BITS = 26
_SID_BLOCK = 1 << _SID_BLOCK_BITS
_MAX_WORKERS = (1 << 31) // _SID_BLOCK - 1

# Per-worker task queue depth (bounds in-flight buffering). A module constant so
# tests can shrink it to force a saturated queue.
_QUEUE_MAXSIZE = 64
# Upper bound on how long the main process waits for a worker to make progress
# (report a result, or accept a stop sentinel) before declaring the pool stalled.
_WORKER_STALL_TIMEOUT = 300.0


def _worker_base(worker_index: int) -> int:
    """The first sid a worker may allocate (its block is ``[base, base + 2**26)``)."""
    return (worker_index + 1) << _SID_BLOCK_BITS


def _references_reach(start: type, goal: type, seen: set[type] | None = None) -> bool:
    """Whether following reference edges from ``start`` reaches ``goal`` (a reference cycle test)."""
    if start is goal:
        return True
    seen = set() if seen is None else seen
    if start in seen:
        return False
    seen.add(start)
    return any(_references_reach(referenced, goal, seen) for referenced in resolve_schema(start).referenced_classes())


@functools.cache
def unsupported_metadata_reason(record_type: type) -> str | None:
    """Why the parallel merge cannot verify ``record_type``'s identity-excluded metadata, or ``None``.

    The set-wise merge verifies identity-excluded metadata with a grouped column
    scan (see :meth:`_Merger._verify_collision_metadata`). That covers scalar
    ``IdentitySkip`` columns and skipped references to content-addressed or
    by_value targets, and it delegates ``descend`` conflicts to the target
    table's own collapse. Three shapes fall outside it and are rejected up front
    (fail fast, naming ``workers=1``) rather than verified incorrectly:

    - an identity-excluded **child sequence** (no parent column to group on);
    - an identity-excluded **reference to a non-deduplicated** (``none``) record,
      or a **descend into** one (the target is never collapsed, so its metadata
      is never compared);
    - a **self-referential** identity-excluded reference (the target table is the
      one being collapsed, so its sids are not yet final when compared).

    :param record_type: The record class to classify.
    :return: A human-readable reason string, or ``None`` when the shape is supported.
    """
    plan = _metadata_plan(record_type)
    if plan is None:
        return None
    name = record_type.__name__
    for spec in plan.skipped_nested:
        if spec.role != "reference":
            return f"{name}.{spec.field} is an identity-excluded child sequence"
        if spec.target is None:
            continue
        if resolve_schema(spec.target).dedup not in ("content_id", "by_value"):
            return f"{name}.{spec.field} is an identity-excluded reference to a non-deduplicated record"
        if _references_reach(spec.target, record_type):
            return f"{name}.{spec.field} is a self-referential identity-excluded reference"
    for spec in plan.descend_specs:
        if spec.target is not None and resolve_schema(spec.target).dedup == "none":
            return f"{name}.{spec.field} descends into a non-deduplicated ('none') record's metadata"
    return None


@functools.cache
def _plain_float_skip_fields(record_type: type) -> tuple[tuple[str, str], ...]:
    """The ``(field, column)`` pairs of ``record_type``'s plain-``float`` ``IdentitySkip`` fields.

    Only the plain Python-``float`` codec is meant here — not the exact numeric
    codecs (``fraction``, ``fracscalar``, tensor codecs) that also carry a float
    column beside their exact text channel. A NaN in a plain-float column reads
    back as ``NaN`` on DuckDB and as ``NULL`` on SQLite (which has no NaN), so it
    cannot be told apart from a real ``None`` once stored; the worker therefore
    flags NaN-bearing content ids while it still holds the source value, and the
    merge treats a duplicated flagged content id as a conflict (serial's
    ``NaN != NaN``).
    """
    plan = _metadata_plan(record_type)
    if plan is None:
        return ()
    fields: list[tuple[str, str]] = []
    for spec in plan.skipped_specs:
        if spec.codec_name != "float":
            continue
        fields.extend((spec.field, column.name) for column in spec.columns if column.kind == "float")
    return tuple(fields)


@dataclass(frozen=True)
class _WorkerConfig:
    """Immutable per-run settings handed to every worker (fork-inherited)."""

    chunk_size: int
    shard_dir: str
    backend: str  # "duckdb" or "sqlite"
    track_sids: bool = True
    # Deferred Parquet builds persist auxiliary root/dispatch/diagnostic data
    # alongside record rows.  Parity merge deliberately keeps its established
    # in-memory manifest protocol.
    spill_deferred_auxiliary: bool = False


@dataclass
class _DispatchRow:
    """A buffered entry-dispatch row a worker produced (backing sid still a block sid)."""

    dispatch_name: str
    key: str
    column: str
    all_columns: tuple[str, ...]
    ref_table: str
    block_sid: int
    family_name: str


@dataclass
class _WorkerManifest:
    """What a finished worker reports to the main process."""

    worker_index: int
    token_sid: dict[int, tuple[str, int]]
    dispatch: list[_DispatchRow]
    tables: list[str]
    # DuckDB: table -> list of parquet file paths. SQLite: {"db": path}.
    shards: dict[str, Any]
    # (table, content_id, field) triples whose identity-excluded float held a NaN.
    nan_content: list[tuple[str, str, str]] = field(default_factory=list)


# Fork-inherited handles the worker reads from module scope (never pickled).
_PARENT_STORE: SqlStore | None = None
_PARENT_CONFIG: _WorkerConfig | None = None


# --------------------------------------------------------------------- shard writers


def _pa_type(column: sqlalchemy.Column[Any], pa: Any) -> Any:
    """Map a record column's SQLAlchemy type to the pyarrow type of its shard column."""
    type_ = column.type
    if isinstance(type_, sqlalchemy.Boolean):
        return pa.bool_()
    if isinstance(type_, sqlalchemy.Integer):
        return pa.int64()
    if isinstance(type_, sqlalchemy.Float):
        return pa.float64()
    if isinstance(type_, sqlalchemy.LargeBinary):
        return pa.binary()
    # Text / String and everything else stringly-typed.
    return pa.string()


class _ParquetShardWriter:
    """Write per-worker, per-table Parquet shards (the DuckDB backend hand-off)."""

    def __init__(self, store: SqlStore, worker_index: int, shard_dir: str) -> None:
        try:
            self._pa = importlib.import_module("pyarrow")
            self._pq = importlib.import_module("pyarrow.parquet")
        except ImportError as error:  # pragma: no cover - guarded before fork
            raise ImportError(
                "bulk_ingest(workers>1) on a DuckDB store needs pyarrow; "
                "install the 'httk-store[parallel]' extra to use it"
            ) from error
        self._store = store
        self._worker_index = worker_index
        self._dir = shard_dir
        self._schemas: dict[str, Any] = {}
        self._files: dict[str, list[str]] = {}
        self._sequence = 0

    def _schema_for(self, table_name: str) -> Any:
        schema = self._schemas.get(table_name)
        if schema is None:
            table = self._store._table(table_name)
            fields = [self._pa.field(column.name, _pa_type(column, self._pa)) for column in table.columns]
            schema = self._pa.schema(fields)
            self._schemas[table_name] = schema
        return schema

    def write(self, table_name: str, rows: list[dict[str, Any]]) -> None:
        if not rows:
            return
        schema = self._schema_for(table_name)
        columns = [field_.name for field_ in schema]
        data = {name: self._pa.array([row.get(name) for row in rows], type=schema.field(name).type) for name in columns}
        table = self._pa.table(data, schema=schema)
        path = os.path.join(self._dir, f"w{self._worker_index}_{table_name}_{self._sequence}.parquet")
        self._sequence += 1
        self._pq.write_table(table, path)
        self._files.setdefault(table_name, []).append(path)

    def finalize(self) -> dict[str, Any]:
        return dict(self._files)

    def write_roots(self, rows: list[dict[str, Any]]) -> None:
        """Persist top-level roots instead of retaining them in a manifest."""
        if not rows:
            return
        schema = self._pa.schema(
            [
                self._pa.field("token", self._pa.int64()),
                self._pa.field("tbl", self._pa.string()),
                self._pa.field("stage_sid", self._pa.int64()),
            ]
        )
        data = {field.name: self._pa.array([row[field.name] for row in rows], type=field.type) for field in schema}
        path = os.path.join(self._dir, f"w{self._worker_index}_roots_{self._sequence}.parquet")
        self._sequence += 1
        self._pq.write_table(self._pa.table(data, schema=schema), path)
        self._files.setdefault("_httk_roots", []).append(path)

    def _write_auxiliary(self, name: str, schema: Any, rows: list[dict[str, Any]]) -> None:
        """Write one bounded auxiliary batch into the Parquet stage."""
        if not rows:
            return
        data = {field.name: self._pa.array([row[field.name] for row in rows], type=field.type) for field in schema}
        path = os.path.join(self._dir, f"w{self._worker_index}_{name}_{self._sequence}.parquet")
        self._sequence += 1
        self._pq.write_table(self._pa.table(data, schema=schema), path)
        self._files.setdefault(name, []).append(path)

    def write_dispatch(self, rows: list[dict[str, Any]]) -> None:
        """Persist deferred dispatch payloads rather than returning a manifest list."""
        self._write_auxiliary(
            "_httk_dispatch_payload",
            self._pa.schema(
                [
                    self._pa.field("dispatch_name", self._pa.string()),
                    self._pa.field("content_id", self._pa.string()),
                    self._pa.field("column", self._pa.string()),
                    self._pa.field("block_sid", self._pa.int64()),
                ]
            ),
            rows,
        )

    def write_nan_content(self, rows: list[dict[str, Any]]) -> None:
        """Persist NaN conflict diagnostics rather than returning a manifest set."""
        self._write_auxiliary(
            "_httk_nan_content",
            self._pa.schema(
                [
                    self._pa.field("table_name", self._pa.string()),
                    self._pa.field("content_id", self._pa.string()),
                    self._pa.field("field_name", self._pa.string()),
                ]
            ),
            rows,
        )


class _SqliteShardWriter:
    """Write one native-SQLite shard database per worker (the SQLite backend hand-off)."""

    def __init__(self, store: SqlStore, worker_index: int, shard_dir: str) -> None:
        self._store = store
        self._path = os.path.join(shard_dir, f"w{worker_index}.sqlite")
        self._connection = sqlite3.connect(self._path)
        self._connection.execute("PRAGMA journal_mode=OFF")
        self._connection.execute("PRAGMA synchronous=OFF")
        self._created: set[str] = set()

    def _columns(self, table_name: str) -> list[str]:
        return [column.name for column in self._store._table(table_name).columns]

    def write(self, table_name: str, rows: list[dict[str, Any]]) -> None:
        if not rows:
            return
        columns = self._columns(table_name)
        if table_name not in self._created:
            definitions = ", ".join(f'"{name}"' for name in columns)
            self._connection.execute(f'CREATE TABLE "{table_name}" ({definitions})')
            self._created.add(table_name)
        placeholders = ", ".join("?" for _ in columns)
        self._connection.executemany(
            f'INSERT INTO "{table_name}" VALUES ({placeholders})',
            [tuple(row.get(name) for name in columns) for row in rows],
        )

    def finalize(self) -> dict[str, Any]:
        self._connection.commit()
        self._connection.close()
        return {"db": self._path, "tables": sorted(self._created)}


class _DuckdbStageWriter:
    """Stream a serial stage through quote-all CSV and DuckDB ``COPY``.

    A CSV field is paired with an explicit boolean null bitmap.  This avoids a
    magic ``NULLSTR`` altogether: empty strings, arbitrary Unicode, and every
    possible text value remain distinct from SQL ``NULL``.  CSV files are
    written as the encoder flushes; only native bulk ``COPY`` and a set-wise
    typed projection run at stage finish.
    """

    def __init__(self, store: SqlStore, worker_index: int, shard_dir: str) -> None:
        self._store = store
        self._path = os.path.join(shard_dir, f"w{worker_index}.duckdb")
        self._dir = shard_dir
        self._csv: dict[str, tuple[Any, Any, list[str]]] = {}

    def write(self, table_name: str, rows: list[dict[str, Any]]) -> None:
        if not rows:
            return
        writer, _file, columns = self._csv_for(table_name)
        for row in rows:
            encoded: list[object] = []
            for name in columns:
                value = row.get(name)
                encoded.extend(("" if value is None else self._csv_value(value), value is None))
            writer.writerow(encoded)

    def finalize(self) -> dict[str, Any]:
        for _writer, file, _columns in self._csv.values():
            file.close()
        engine = sqlalchemy.create_engine(f"duckdb:///{self._path}")
        try:
            with engine.begin() as connection:
                for table_name, (_writer, _file, columns) in self._csv.items():
                    source = self._store._table(table_name)
                    raw = f"_httk_stage_raw_{table_name}"
                    raw_columns = ", ".join(
                        f'"v{index}" VARCHAR, "n{index}" BOOLEAN' for index, _name in enumerate(columns)
                    )
                    connection.execute(sqlalchemy.text(f'CREATE TABLE "{raw}" ({raw_columns})'))
                    path = os.path.join(self._dir, f"{table_name}.csv").replace("'", "''")
                    connection.execute(
                        sqlalchemy.text(
                            f"COPY \"{raw}\" FROM '{path}' (FORMAT CSV, HEADER TRUE, QUOTE '\"', ESCAPE '\"', "
                            f"FORCE_NOT_NULL ({', '.join(repr(f'v{index}') for index in range(len(columns)))}))"
                        )
                    )
                    stage = sqlalchemy.Table(
                        table_name,
                        sqlalchemy.MetaData(),
                        *(sqlalchemy.Column(column.name, column.type) for column in source.columns),
                    )
                    connection.execute(sqlalchemy.schema.CreateTable(stage))
                    select = ", ".join(
                        f'CASE WHEN "n{index}" THEN NULL ELSE CAST("v{index}" AS {column.type.compile(dialect=engine.dialect)}) END'
                        for index, column in enumerate(source.columns)
                    )
                    names = ", ".join(f'"{column.name}"' for column in source.columns)
                    connection.execute(
                        sqlalchemy.text(f'INSERT INTO "{table_name}" ({names}) SELECT {select} FROM "{raw}"')
                    )
                    connection.execute(sqlalchemy.text(f'DROP TABLE "{raw}"'))
        finally:
            engine.dispose()
        return {"format": "duckdb", "db": self._path, "tables": sorted(self._csv)}

    def _csv_for(self, table_name: str) -> tuple[Any, Any, list[str]]:
        existing = self._csv.get(table_name)
        if existing is not None:
            return existing
        path = os.path.join(self._dir, f"{table_name}.csv")
        file = open(path, "w", newline="", encoding="utf-8")  # noqa: SIM115 — handle is cached in self._csv and closed by the shard lifecycle, not per call
        columns = [column.name for column in self._store._table(table_name).columns]
        writer = csv.writer(file, quoting=csv.QUOTE_ALL, lineterminator="\n")
        writer.writerow([item for index in range(len(columns)) for item in (f"v{index}", f"n{index}")])
        built = (writer, file, columns)
        self._csv[table_name] = built
        return built

    @staticmethod
    def _csv_value(value: Any) -> str:
        if isinstance(value, bytes):
            return "".join(f"\\x{byte:02x}" for byte in value)
        return str(value)


def _make_writer(store: SqlStore, worker_index: int, config: _WorkerConfig) -> Any:
    if config.backend in {"duckdb", "parquet", "clickhousedb"}:
        return _ParquetShardWriter(store, worker_index, config.shard_dir)
    if config.backend == "duckdb-stage":
        return _DuckdbStageWriter(store, worker_index, config.shard_dir)
    return _SqliteShardWriter(store, worker_index, config.shard_dir)


# --------------------------------------------------------------------- worker encoder


class _WorkerEncoder:
    """Encode a worker's slice of the stream into shard rows with block sids.

    A stripped connection-free counterpart of
    :meth:`~httk.store.db.bulk.BulkIngest._encode_active`: no table DDL, sids from
    the worker's own block, and no metadata verification (the merge verifies
    every surviving collision). Content records that carry a metadata plan are
    emitted per occurrence — never deduplicated in the worker — so the merge
    sees, and can compare, all of them.
    """

    def __init__(self, store: SqlStore, worker_index: int, config: _WorkerConfig) -> None:
        self._store = store
        self._config = config
        self._chunk_size = config.chunk_size
        self._writer = _make_writer(store, worker_index, config)
        self._base = _worker_base(worker_index)
        self._registered: set[type] = set()
        self._next_sid: dict[str, int] = {}
        self._rows: dict[str, list[dict[str, Any]]] = {}
        self._content_index: dict[str, dict[str, int]] = {}
        self._value_index: dict[str, dict[tuple[Any, ...], int]] = {}
        self._token_sid: dict[int, tuple[str, int]] = {}
        self._root_rows: list[dict[str, Any]] = []
        self._dispatch: list[_DispatchRow] = []
        self._dispatch_payload: list[dict[str, Any]] = []
        self._tables: set[str] = set()
        self._nan_content: set[tuple[str, str, str]] = set()
        self._nan_rows: list[dict[str, Any]] = []
        self._since_flush = 0

    @property
    def _deduplicate_in_worker(self) -> bool:
        """Whether this encoder needs in-memory keys to preserve public provisional sids.

        Parquet deferred finalization has an occurrence sidecar and does the
        content/by-value collapse set-wise.  With ``track_sids=False`` callers
        have explicitly opted out of stable in-ingest provisional identities,
        so retaining one client key per record would only defeat bounded mode.
        """
        return self._config.track_sids or self._config.backend != "parquet"

    # -- encoding

    def save(self, token: int, obj: Any, as_record: type | None) -> int:
        record_type = resolve_storage_record(obj, as_record=as_record)
        projection = SaveProjection()
        sid = self._encode(record_type, obj, projection, "")
        self._promote_buffered_role(resolve_schema(record_type).table_name, sid)
        table_name = resolve_schema(record_type).table_name
        family = self._store._family_for_backing(record_type)
        if family is not None and len(family.records) > 1:
            dispatch = _DispatchRow(
                dispatch_name=entry_dispatch_table_name(family.name),
                key=projection.content_id(record_type, obj),
                column=backing_dispatch_column_name(family.record_names[family.records.index(record_type)]),
                all_columns=tuple(backing_dispatch_column_name(name) for name in family.record_names),
                ref_table=table_name,
                block_sid=sid,
                family_name=family.name,
            )
            if self._config.spill_deferred_auxiliary:
                self._dispatch_payload.append(
                    {
                        "dispatch_name": dispatch.dispatch_name,
                        "content_id": dispatch.key,
                        "column": dispatch.column,
                        "block_sid": dispatch.block_sid,
                    }
                )
            else:
                self._dispatch.append(dispatch)
        if self._config.track_sids or not self._config.spill_deferred_auxiliary:
            self._token_sid[token] = (table_name, sid)
        if self._config.spill_deferred_auxiliary:
            self._root_rows.append({"token": token, "tbl": table_name, "stage_sid": sid})
        self._since_flush += 1
        if self._since_flush >= self._chunk_size:
            self._flush()
        return sid

    def _encode(self, record_type: type, source: Any, projection: SaveProjection, path: str) -> int:
        active_key = (record_type, id(source))
        if active_key in projection.active:
            raise StorageProjectionCycleError(path, record_type)
        projection.active.add(active_key)
        try:
            return self._encode_active(record_type, source, projection, path)
        finally:
            projection.active.remove(active_key)

    def _encode_active(self, record_type: type, source: Any, projection: SaveProjection, path: str) -> int:
        schema = resolve_schema(record_type)
        self._register(record_type)
        table_name = schema.table_name
        self._next_sid.setdefault(table_name, self._base + 1)
        projected = projection.projector(record_type, source)

        validation_key = (record_type, id(source))
        if type(source) is record_type and validation_key not in projection.validated:
            validator = vars(record_type).get("__httk_validate__")
            if validator is not None:
                validator.__get__(None, record_type)(source)
            projection.validated.add(validation_key)

        dedup_content = schema.dedup == "content_id" and _metadata_plan(record_type) is None
        key: str | None = None
        if schema.dedup == "content_id":
            key = projection.content_id(record_type, source)
            if dedup_content and self._deduplicate_in_worker:
                existing = self._content_index.setdefault(table_name, {}).get(key)
                if existing is not None:
                    return existing

        def resolve_sid(referenced_type: type, value: Any, field_path: str) -> int:
            return self._encode(referenced_type, value, projection, field_path)

        values = _encode_parent_row(schema, source, projected, path, resolve_sid)

        if schema.dedup == "by_value":
            value_tuple = tuple(sorted(values.items()))
            if self._deduplicate_in_worker:
                existing = self._value_index.setdefault(table_name, {}).get(value_tuple)
                if existing is not None:
                    return existing

        sid = self._next_sid[table_name]
        if sid - self._base >= _SID_BLOCK:
            raise RuntimeError(
                f"bulk_ingest worker exceeded its {_SID_BLOCK} sid block for table {table_name!r}; "
                "reduce the worker count or split the ingest"
            )
        self._next_sid[table_name] = sid + 1
        row = {SID_COLUMN: sid, ROLE_COLUMN: 0, **values}
        if key is not None:
            row[CONTENT_ID_COLUMN] = key
            for field_name, column_name in _plain_float_skip_fields(record_type):
                candidate = values.get(column_name)
                if isinstance(candidate, float) and math.isnan(candidate):
                    # Report every NaN field (not just the first): the merge picks
                    # the schema-order-first among them, deterministically.
                    if self._config.spill_deferred_auxiliary:
                        self._nan_rows.append({"table_name": table_name, "content_id": key, "field_name": field_name})
                    else:
                        self._nan_content.add((table_name, key, field_name))
            if dedup_content and self._deduplicate_in_worker:
                self._content_index[table_name][key] = sid
        elif schema.dedup == "by_value" and self._deduplicate_in_worker:
            self._value_index[table_name][tuple(sorted(values.items()))] = sid
        self._buffer(table_name, row)

        for spec in schema.fields:
            if spec.role != "child":
                continue
            assert spec.child is not None
            child_rows = _encode_child_rows(
                schema,
                spec,
                sid,
                SqlStore._projected_value(record_type, source, projected, spec),
                _field_path(path, spec.field),
                resolve_sid,
            )
            for child_row in child_rows:
                self._buffer(spec.child.table_name, child_row)
        return sid

    def _promote_buffered_role(self, table_name: str, sid: int) -> None:
        for row in self._rows.get(table_name, ()):
            if row[SID_COLUMN] == sid:
                row[ROLE_COLUMN] = 1
                return

    def _register(self, record_type: type) -> None:
        if record_type in self._registered:
            return
        self._store._register_tables((record_type,))
        self._registered.add(record_type)

    def _buffer(self, table_name: str, row: dict[str, Any]) -> None:
        self._rows.setdefault(table_name, []).append(row)
        self._tables.add(table_name)

    def _flush(self) -> None:
        for table_name, rows in self._rows.items():
            if rows:
                self._writer.write(table_name, rows)
                rows.clear()
        if self._root_rows:
            self._writer.write_roots(self._root_rows)
            self._root_rows.clear()
        if self._config.spill_deferred_auxiliary:
            self._writer.write_dispatch(self._dispatch_payload)
            self._dispatch_payload.clear()
            self._writer.write_nan_content(self._nan_rows)
            self._nan_rows.clear()
        self._since_flush = 0

    def finish(self) -> _WorkerManifest:
        self._flush()
        return _WorkerManifest(
            worker_index=-1,  # filled by the caller
            token_sid=self._token_sid,
            dispatch=self._dispatch,
            tables=sorted(self._tables),
            shards=self._writer.finalize(),
            nan_content=sorted(self._nan_content),
        )


# --------------------------------------------------------------------- worker process


def _worker_main(worker_index: int, task_queue: Any, result_queue: Any) -> None:
    """Worker process entry point: encode tasks into shards, then report a manifest.

    Tasks arrive as pickled ``(token, obj, as_record)`` byte strings (the main
    process pickles synchronously, so an unpicklable object fails the caller's
    ``save`` promptly instead of vanishing in a queue feeder thread). The worker
    never touches the store's database. On completion (or failure) it flushes its
    result onto ``result_queue`` and exits with :func:`os._exit` to skip
    interpreter finalizers that might disturb the fork-inherited engine.

    :param worker_index: The worker's index (its sid block and shard names).
    :param task_queue: This worker's task queue of pickled tasks (``None`` stops).
    :param result_queue: The queue the manifest or an exception is reported on.
    :return: None.
    """
    assert _PARENT_STORE is not None and _PARENT_CONFIG is not None
    encoder = _WorkerEncoder(_PARENT_STORE, worker_index, _PARENT_CONFIG)
    try:
        while True:
            item = task_queue.get()
            if item is None:
                break
            token, obj, as_record = pickle.loads(item)
            encoder.save(token, obj, as_record)
        manifest = encoder.finish()
        manifest.worker_index = worker_index
        _report(result_queue, (worker_index, "ok", manifest))
    except BaseException as error:  # faithfully relayed to the caller
        _report(result_queue, (worker_index, "error", _as_reportable(error)))
    os._exit(0)


def _report(result_queue: Any, payload: Any) -> None:
    result_queue.put(payload)
    result_queue.close()
    result_queue.join_thread()


def _as_reportable(error: BaseException) -> BaseException:
    """Return an exception that survives pickling back to the main process."""
    try:
        import pickle

        pickle.loads(pickle.dumps(error))
    except Exception:  # fall back to a faithful-typed surrogate
        return RuntimeError(f"{type(error).__name__}: {error}")
    return error


# --------------------------------------------------------------------- pool controller


[docs] class ParallelController: """Own the worker pool, task dispatch, and shard directory for one parallel ingest. Each worker has its own task queue; ``dispatch`` routes token ``k`` to worker ``k % workers`` (deterministic round-robin), so the record order the caller saves fully determines which worker encodes each record. A shared result queue carries each worker's manifest (or exception) back. """ def __init__( self, store: SqlStore, *, workers: int, chunk_size: int, backend: str, track_sids: bool = True, spill_deferred_auxiliary: bool = False, ) -> None: import multiprocessing if workers > _MAX_WORKERS: raise ValueError(f"bulk_ingest supports at most {_MAX_WORKERS} workers (sid-block limit)") self._store = store self._workers = workers self._context = multiprocessing.get_context("fork") self._temp = tempfile.TemporaryDirectory(prefix="httk_bulk_", dir=_shard_parent_dir(store)) self._config = _WorkerConfig( chunk_size=chunk_size, shard_dir=self._temp.name, backend=backend, track_sids=track_sids, spill_deferred_auxiliary=spill_deferred_auxiliary, ) self._queues: list[Any] = [self._context.Queue(maxsize=_QUEUE_MAXSIZE) for _ in range(workers)] self._result_queue: Any = self._context.Queue() self._processes: list[Any] = [] # Every result consumed by health polling is cached here (not just errors), # so a worker that reports and exits cleanly while a sibling's queue is full # keeps its manifest and is not mistaken for a crash. self._results_cache: dict[int, tuple[str, Any]] = {} self._closed = False
[docs] def start(self) -> None: import warnings global _PARENT_STORE, _PARENT_CONFIG _PARENT_STORE = self._store _PARENT_CONFIG = self._config try: for index in range(self._workers): process = self._context.Process( target=_worker_main, args=(index, self._queues[index], self._result_queue), daemon=True, ) with warnings.catch_warnings(): # Forking is required: workers inherit the (unpicklable) store # and never touch its database, so Python 3.12's multi-threaded # fork() advisory does not apply here. warnings.simplefilter("ignore", DeprecationWarning) process.start() self._processes.append(process) finally: # The children have forked; the parent no longer needs the globals. _PARENT_STORE = None _PARENT_CONFIG = None
[docs] def dispatch(self, token: int, obj: Any, as_record: type | None) -> None: """Pickle the task synchronously and enqueue it on its worker (routed by token).""" # Pickle here, in the caller's thread: an unpicklable object raises out of # ``save`` promptly rather than being silently dropped by a queue feeder. payload = pickle.dumps((token, obj, as_record)) queue = self._queues[token % self._workers] while True: self._raise_if_worker_broken() try: queue.put(payload, timeout=0.5) return except queue_mod.Full: continue
def _raise_if_worker_broken(self) -> None: """Raise if any worker reported an error or exited *without* reporting (a crash or kill). Results are cached (both ``ok`` and ``error``), so a worker that reported and then exited cleanly — e.g. it took its stop sentinel while a sibling's queue was still full — is recognized as done, not misreported as a crash, and its manifest survives for :meth:`finish`. """ self._drain_results(self._results_cache) for status, payload in self._results_cache.values(): if status == "error": raise _forward(payload) for index, process in enumerate(self._processes): if process.exitcode is not None and index not in self._results_cache: raise RuntimeError( "a bulk_ingest worker exited unexpectedly (crashed or was killed); the ingest is aborted" )
[docs] def finish(self) -> list[_WorkerManifest]: """Signal completion, collect every worker's manifest, and re-raise the first error. A worker that exits without reporting (a crash or an external kill) is detected by its exit code and aborts the ingest, so a lost task can never reach the merge. Both the stop-sentinel sends and the result waits are bounded and interleaved with health checks, so a worker that dies with a full queue cannot deadlock the main process. """ import time self._send_sentinels() # Start from whatever health polling already consumed (sending the # sentinels may have drained some workers' results into the cache). results: dict[int, tuple[str, Any]] = dict(self._results_cache) error: BaseException | None = None last_progress = time.monotonic() while len(results) < self._workers: try: worker_index, status, payload = self._result_queue.get(timeout=1.0) results[worker_index] = (status, payload) last_progress = time.monotonic() except queue_mod.Empty: if all(process.exitcode is not None for process in self._processes): self._drain_results(results) break if time.monotonic() - last_progress > _WORKER_STALL_TIMEOUT: error = RuntimeError("bulk_ingest workers stopped making progress; aborting") break if len(results) < self._workers and error is None: error = RuntimeError("a bulk_ingest worker exited without reporting a result (crashed or was killed)") for status, payload in results.values(): if status == "error" and error is None: error = _forward(payload) for process in self._processes: process.join(timeout=30) if error is not None: raise error return [payload for status, payload in results.values() if status == "ok"]
def _send_sentinels(self) -> None: """Put a stop sentinel on each worker queue, aborting if a worker died with a full queue.""" import time pending = list(range(self._workers)) deadline = time.monotonic() + _WORKER_STALL_TIMEOUT while pending: still_pending: list[int] = [] for index in pending: try: self._queues[index].put(None, timeout=0.1) except queue_mod.Full: still_pending.append(index) pending = still_pending if not pending: return # A queue that will not accept the sentinel belongs to a worker that # is no longer draining it — detect the crash/kill and abort. self._raise_if_worker_broken() if time.monotonic() > deadline: raise RuntimeError("bulk_ingest could not signal completion to its workers; aborting") def _drain_results(self, results: dict[int, tuple[str, Any]]) -> None: """Absorb any results still queued after every worker has exited (avoids a report/exit race).""" try: while True: worker_index, status, payload = self._result_queue.get_nowait() results[worker_index] = (status, payload) except queue_mod.Empty: return
[docs] def close(self) -> None: """Terminate any live workers and remove the shard directory (idempotent).""" if self._closed: return self._closed = True for process in self._processes: if process.is_alive(): process.terminate() for process in self._processes: process.join(timeout=10) # Cancel each queue's feeder thread before closing: a queue left non-empty # by an aborted ingest would otherwise block ``close`` on the feeder join. for queue in (*self._queues, self._result_queue): queue.cancel_join_thread() queue.close() self._temp.cleanup()
def _forward(payload: Any) -> BaseException: return payload if isinstance(payload, BaseException) else RuntimeError(str(payload)) def _shard_parent_dir(store: SqlStore) -> str | None: """The directory shards are created in: next to a file-backed database, else the tempfile default.""" try: database = store._database.engine.url.database except Exception: # any odd URL falls back to the default temp root return None if not database or database == ":memory:": return None parent = os.path.dirname(os.path.abspath(database)) return parent if os.path.isdir(parent) else None # --------------------------------------------------------------------- merge (main process)
[docs] def merge(ingest: "httk.store.db.bulk.BulkIngest", manifests: list[_WorkerManifest]) -> None: """Load every worker shard, collapse cross-worker duplicates, and compact the sids. Runs in the main process inside the ingest's spanning transaction. :param ingest: The owning bulk-ingest context (its connection and store). :param manifests: One manifest per finished worker. :return: None. """ _Merger(ingest, manifests).run()
class _Merger: """The set-wise shard merge for a parallel ingest (see :func:`merge`).""" def __init__(self, ingest: "BulkIngest", manifests: list[_WorkerManifest]) -> None: self._ingest = ingest self._store = ingest._store assert ingest._connection is not None self._connection = ingest._connection self._manifests = manifests self._graph = ingest._logical_graph() self._fk_columns = self._graph.sid_columns() self._referrers = {name: list(self._graph.referrers(name)) for name in self._graph.tables} # (table, block_sid) -> keep_sid after cross-worker collapse. self._collapse: dict[tuple[str, int], int] = {} # (table, sid) -> compact_sid after final renumbering. self._compaction: dict[tuple[str, int], int] = {} # table -> {content id -> set of fields} whose identity-excluded float held # a NaN. A set (not last-manifest-wins) keeps attribution deterministic: # the merge names the schema-order-first field among the reported set. self._nan_content: dict[str, dict[str, set[str]]] = {} for manifest in manifests: for table_name, content_id, field_name in manifest.nan_content: self._nan_content.setdefault(table_name, {}).setdefault(content_id, set()).add(field_name) def run(self) -> None: self._load_shards() for name in self._graph.dependency_order(self._store._metadata.tables): table = self._store._metadata.tables[name] schema = self._ingest._parent_schema.get(table.name) if schema is None: continue if schema.dedup == "content_id": self._collapse_content(table, schema) # A collapse in one by-value table can make a normalized key in a # mutually-referential table equal only on the next pass. Iterate the # full deterministic order to the graph-wide fixpoint before orphan # sweeping (the former one-pass order under-collapsed A <-> B graphs). by_value = [ (self._store._metadata.tables[name], self._ingest._parent_schema[name]) for name in self._graph.dependency_order(self._store._metadata.tables) if name in self._ingest._parent_schema and self._ingest._parent_schema[name].dedup == "by_value" ] while True: changed = False for table, schema in by_value: changed = self._collapse_by_value(table, schema) or changed if not changed: break self._sweep_orphans() for name in self._graph.dependency_order(self._store._metadata.tables): table = self._store._metadata.tables[name] if SID_COLUMN in table.c: self._compact(table) self._merge_dispatch() self._populate_resolved_map() def _populate_resolved_map(self) -> None: """Map every sid ``save`` returned (a synthetic token) to its durable stored sid.""" ingest = self._ingest for manifest in self._manifests: for token, (table_name, block_sid) in manifest.token_sid.items(): ingest._resolved_map[(table_name, token)] = self._final_sid(table_name, block_sid) # -- shard loading def _load_shards(self) -> None: backend = self._connection.dialect.name if backend == "duckdb": self._load_parquet_shards() else: self._load_sqlite_shards() def _table_columns(self, table_name: str) -> list[str]: return [column.name for column in self._store._table(table_name).columns] def _load_parquet_shards(self) -> None: files_by_table: dict[str, list[str]] = {} for manifest in self._manifests: for table_name, files in manifest.shards.items(): if table_name == "_httk_roots": continue files_by_table.setdefault(table_name, []).extend(files) for table_name, files in files_by_table.items(): if not files: continue columns = ", ".join(f'"{name}"' for name in self._table_columns(table_name)) # Bind each shard path as a parameter — a path containing a quote must # not break or inject into the SQL. placeholders = ", ".join(f":f{index}" for index in range(len(files))) statement = sqlalchemy.text( f'INSERT INTO "{table_name}" ({columns}) SELECT {columns} FROM read_parquet([{placeholders}])' ).bindparams(**{f"f{index}": path for index, path in enumerate(files)}) self._connection.execute(statement) def _load_sqlite_shards(self) -> None: # SQLite forbids DETACH inside a transaction, and the merge owns one # spanning transaction, so every shard is attached at once (its file is # unlinked when the shard directory is cleaned up). Raise the default # 10-database attach ceiling to accommodate many workers. # The ingest owns this connection until after the transaction closes, so # the post-transaction DETACH in _release_connection runs on the exact # connection that attached these shards. driver: Any = self._connection.connection.driver_connection try: driver.setlimit(sqlite3.SQLITE_LIMIT_ATTACHED, 125) except (AttributeError, sqlite3.NotSupportedError): # pragma: no cover - platform dependent pass for index, manifest in enumerate(self._manifests): database = manifest.shards.get("db") tables = manifest.shards.get("tables", []) if not database or not tables: continue alias = f"httk_shard_{index}" # The path is bound (a quote in it must not break or inject); the alias # is a controlled identifier and cannot be a bound parameter. self._connection.execute(sqlalchemy.text(f"ATTACH DATABASE :db AS {alias}").bindparams(db=database)) self._ingest._parallel_attached.append(alias) for table_name in tables: columns = ", ".join(f'"{name}"' for name in self._table_columns(table_name)) self._connection.execute( sqlalchemy.text( f'INSERT INTO main."{table_name}" ({columns}) SELECT {columns} FROM {alias}."{table_name}"' ) ) # -- cross-worker collapse def _collapse_content(self, table: sqlalchemy.Table, schema: TableSchema) -> None: keep = ( sqlalchemy.select(table.c[CONTENT_ID_COLUMN], sqlalchemy.func.min(table.c[SID_COLUMN]).label("keep")) .group_by(table.c[CONTENT_ID_COLUMN]) .subquery() ) statement = ( sqlalchemy.select(table.c[SID_COLUMN], keep.c.keep) .join_from(table, keep, table.c[CONTENT_ID_COLUMN] == keep.c[CONTENT_ID_COLUMN]) .where(table.c[SID_COLUMN] != keep.c.keep) ) pairs = [(int(row[0]), int(row[1])) for row in self._connection.execute(statement).all()] if not pairs: return if self._ingest._verify_metadata and _metadata_plan(schema.cls) is not None: self._verify_collision_metadata(table, schema) self._apply_collapse(table, schema, pairs) def _collapse_by_value(self, table: sqlalchemy.Table, schema: TableSchema) -> bool: value_columns = [column.name for column in table.columns if column.name not in (SID_COLUMN, ROLE_COLUMN)] while True: keep = ( sqlalchemy.select( *(table.c[name] for name in value_columns), sqlalchemy.func.min(table.c[SID_COLUMN]).label("keep"), ) .group_by(*(table.c[name] for name in value_columns)) .subquery() ) condition = sqlalchemy.and_(*(table.c[name].is_not_distinct_from(keep.c[name]) for name in value_columns)) statement = ( sqlalchemy.select(table.c[SID_COLUMN], keep.c.keep) .join_from(table, keep, condition) .where(table.c[SID_COLUMN] != keep.c.keep) ) pairs = [(int(row[0]), int(row[1])) for row in self._connection.execute(statement).all()] if not pairs: return False self._apply_collapse(table, schema, pairs) # _apply_collapse rewrites references and deletes all pairs in one # operation, so a second local pass is only needed for rows made # equal by the remap. return True def _apply_collapse(self, table: sqlalchemy.Table, schema: TableSchema, pairs: list[tuple[int, int]]) -> None: name = table.name # Role is a monotone bookkeeping bit, not part of either identity. A # canonical bulk row is main whenever any collapsed occurrence was a # top-level record. for old, keep in pairs: old_role = self._connection.execute( sqlalchemy.select(table.c[ROLE_COLUMN]).where(table.c[SID_COLUMN] == old) ).scalar_one() if int(old_role) == 1: self._connection.execute( sqlalchemy.update(table).where(table.c[SID_COLUMN] == keep).values({ROLE_COLUMN: 1}) ) for old, keep in pairs: self._collapse[(name, old)] = keep child_links = { (edge.target_table, edge.target_column) for edge in self._graph.ownership() if edge.source_table == name and edge.target_column is not None } map_table = self._make_map_table(pairs) try: for referrer_table, column in self._referrers.get(name, ()): if (referrer_table, column) in child_links: # A collapsed parent's own child rows are dropped, not repointed; # the surviving parent already carries its own children. self._delete_where_in_map(referrer_table, column, map_table) else: self._remap_column(referrer_table, column, map_table) self._delete_where_in_map(name, SID_COLUMN, map_table) finally: self._drop_map_table(map_table) def _verify_collision_metadata(self, table: sqlalchemy.Table, schema: TableSchema) -> None: """Set-wise verify that rows sharing a content id agree on their identity-excluded metadata. Only the columns that actually carry identity-excluded metadata are compared: the ``IdentitySkip`` scalar columns, and a skipped reference's sid column when its target is content-addressed (equal content collapses to one sid before this runs, so a differing sid is a differing skipped reference). ``descend`` conflicts — a non-skipped reference whose target carries the skip metadata — surface at that target table's own collapse, where the metadata lives, because equal-content parents reference equal-content (hence collapsed-together) targets. One grouped scan per content table therefore replaces reconstructing each duplicate record, the dominant cost at real-build scale. :param table: The content-addressed record table being collapsed. :param schema: The table's resolved schema (for the diagnostic record name). :raises httk.store.store_common.EntryMetadataConflictError: If a content id occurs with differing metadata. """ compare_columns = self._metadata_compare_columns(schema) if not compare_columns: return # NaN scan first: serial treats ``NaN != NaN`` as a conflict, but SQL # equality groups a NaN with itself (DuckDB's total order) and SQLite # stores no NaN at all, so the exact scan below cannot see it. Running it # first also gives a NaN in an earlier schema field priority over a plain # value difference in a later one, matching serial's field-order checks. # A duplicated content id the workers flagged as NaN-bearing is a conflict; # the field named is the schema-order-first of the reported set. nan_by_content = self._nan_content.get(table.name) if nan_by_content: duplicated_nan = self._connection.execute( sqlalchemy.select(table.c[CONTENT_ID_COLUMN]) .where(table.c[CONTENT_ID_COLUMN].in_(sorted(nan_by_content))) .group_by(table.c[CONTENT_ID_COLUMN]) .having(sqlalchemy.func.count() > 1) .limit(1) ).first() if duplicated_nan is not None: key = duplicated_nan[0] reported = nan_by_content.get(key, set()) field_name = next( (field for _column, field in compare_columns if field in reported), schema.cls.__name__, ) raise EntryMetadataConflictError( f"metadata conflict for {schema.cls.__name__}.{field_name}: content id {key!r} occurs with " "a NaN identity-excluded value that never equals itself" ) # Exact-difference scan: SQL ``=`` matches serial's scalar equality for # every finite value — ``-0.0 == 0.0`` and ``NULL``/``None`` both group as # equal — so a content id whose group has more than one distinct tuple # carries differing identity-excluded metadata. column_names = [column for column, _field in compare_columns] selected = [table.c[CONTENT_ID_COLUMN], *(table.c[name] for name in column_names)] distinct_rows = sqlalchemy.select(*selected).distinct().subquery() conflicting = self._connection.execute( sqlalchemy.select(distinct_rows.c[CONTENT_ID_COLUMN]) .group_by(distinct_rows.c[CONTENT_ID_COLUMN]) .having(sqlalchemy.func.count() > 1) .limit(1) ).first() if conflicting is not None: self._raise_metadata_conflict(table, schema, compare_columns, conflicting[0]) def _raise_metadata_conflict( self, table: sqlalchemy.Table, schema: TableSchema, compare_columns: list[tuple[str, str]], key: str ) -> None: differing_field = self._first_differing_field(table, compare_columns, key) field_name = f"{schema.cls.__name__}.{differing_field}" if differing_field else schema.cls.__name__ raise EntryMetadataConflictError( f"metadata conflict for {field_name}: content id {key!r} occurs with differing identity-excluded metadata" ) @staticmethod def _metadata_compare_columns(schema: TableSchema) -> list[tuple[str, str]]: """The ``(column, field)`` pairs whose within-group difference is an identity-excluded conflict.""" plan = _metadata_plan(schema.cls) if plan is None: return [] columns: list[tuple[str, str]] = [] for spec in plan.skipped_specs: if spec.codec_name == "float": # The plain-float codec stores an exact text companion for lossless # reconstruction, but ``-0.0`` and ``0.0`` differ there while serial's # ``_metadata_scalar_equal`` compares them with IEEE ``==``. Compare # only the float column (whose SQL equality is IEEE) and drop the # string companion; NaN is handled separately below. Exact numeric # codecs (fraction, fracscalar, tensors) keep their exact text # channel — dropping it would collapse them to a float approximation # and silently accept e.g. Fraction(2**53) vs Fraction(2**53 + 1). columns.extend((column.name, spec.field) for column in spec.columns if column.kind == "float") else: columns.extend((column.name, spec.field) for column in spec.columns) for spec in plan.skipped_nested: if ( spec.role == "reference" and spec.target is not None and resolve_schema(spec.target).dedup in ("content_id", "by_value") ): columns.append((spec.columns[0].name, spec.field)) return columns def _first_differing_field( self, table: sqlalchemy.Table, compare_columns: list[tuple[str, str]], key: str ) -> str | None: """The schema field of the first compared column that differs within the conflicting group.""" for column, field_name in compare_columns: distinct = self._connection.execute( sqlalchemy.select(sqlalchemy.func.count(sqlalchemy.distinct(table.c[column]))).where( table.c[CONTENT_ID_COLUMN] == key ) ).scalar_one() if distinct is not None and int(distinct) > 1: return field_name return None # -- orphan sweep def _sweep_orphans(self) -> None: """Delete rows no longer reachable from a surviving top-level record. A collapsed duplicate parent drops its subtree; descendants that other surviving records also reach stay, but a duplicate's private ``dedup="none"`` (or otherwise non-deduplicated) descendants become unreachable and must go, matching the per-record ``save()`` loop's result. """ reach = self._reachable_table() seeds = self._survivor_seeds() self._insert_reach(reach, seeds) self._close_reachability(reach) self._delete_unreached(reach) self._connection.execute(sqlalchemy.schema.DropTable(reach, if_exists=True)) def _survivor_seeds(self) -> list[tuple[str, int]]: seeds: set[tuple[str, int]] = set() for manifest in self._manifests: for table_name, block_sid in manifest.token_sid.values(): seeds.add((table_name, self._collapse.get((table_name, block_sid), block_sid))) return sorted(seeds) def _reachable_table(self) -> sqlalchemy.Table: reach = sqlalchemy.Table( "_httk_bulk_reach", sqlalchemy.MetaData(), sqlalchemy.Column("tbl", sqlalchemy.Text, nullable=False), sqlalchemy.Column(SID_COLUMN, sqlalchemy.Integer, nullable=False), ) self._connection.execute(sqlalchemy.schema.DropTable(reach, if_exists=True)) self._connection.execute(sqlalchemy.schema.CreateTable(reach)) self._ingest._staging_tables.add(reach.name) return reach def _insert_reach(self, reach: sqlalchemy.Table, seeds: list[tuple[str, int]]) -> None: if not seeds: return self._connection.execute( sqlalchemy.insert(reach), [{"tbl": table_name, SID_COLUMN: sid} for table_name, sid in seeds] ) def _close_reachability(self, reach: sqlalchemy.Table) -> None: store = self._store # Forward edges: a reached row keeps every record it references, directly # (reference columns) or through its child rows (child-element columns). reference_edges: list[tuple[sqlalchemy.Table, str, str]] = [] child_edges: list[tuple[sqlalchemy.Table, str, str, str]] = [] ownership_columns = { edge.target_table: edge.target_column for edge in self._graph.ownership() if edge.target_column } for edge in self._graph.edges: if edge.kind == "reference" and edge.source_column is not None: reference_edges.append((store._table(edge.source_table), edge.source_column, edge.target_table)) elif edge.kind == "child_element" and edge.source_column is not None: parent_column = ownership_columns.get(edge.source_table) if parent_column is not None: child_edges.append( (store._table(edge.source_table), parent_column, edge.source_column, edge.target_table) ) while True: before = self._connection.execute( sqlalchemy.select(sqlalchemy.func.count()).select_from(reach) ).scalar_one() for table, column, ref_table in reference_edges: self._grow_reach(reach, table, table.name, column, ref_table) for child, parent_column, element_column, ref_table in child_edges: self._grow_reach_via_child(reach, child, parent_column, element_column, ref_table) after = self._connection.execute(sqlalchemy.select(sqlalchemy.func.count()).select_from(reach)).scalar_one() if after == before: return def _grow_reach( self, reach: sqlalchemy.Table, table: sqlalchemy.Table, table_name: str, column: str, ref_table: str ) -> None: already = sqlalchemy.select(reach.c[SID_COLUMN]).where(reach.c.tbl == ref_table) source = ( sqlalchemy.select(sqlalchemy.literal(ref_table).label("tbl"), table.c[column].label(SID_COLUMN)) .join_from( table, reach, sqlalchemy.and_(reach.c.tbl == table_name, reach.c[SID_COLUMN] == table.c[SID_COLUMN]) ) .where(table.c[column].is_not(None)) .where(table.c[column].not_in(already)) .distinct() ) self._connection.execute(sqlalchemy.insert(reach).from_select(["tbl", SID_COLUMN], source)) def _grow_reach_via_child( self, reach: sqlalchemy.Table, child: sqlalchemy.Table, parent_column: str, element_column: str, ref_table: str, ) -> None: parent_table = parent_column[: -(len(SID_COLUMN) + 1)] already = sqlalchemy.select(reach.c[SID_COLUMN]).where(reach.c.tbl == ref_table) source = ( sqlalchemy.select(sqlalchemy.literal(ref_table).label("tbl"), child.c[element_column].label(SID_COLUMN)) .join_from( child, reach, sqlalchemy.and_(reach.c.tbl == parent_table, reach.c[SID_COLUMN] == child.c[parent_column]), ) .where(child.c[element_column].is_not(None)) .where(child.c[element_column].not_in(already)) .distinct() ) self._connection.execute(sqlalchemy.insert(reach).from_select(["tbl", SID_COLUMN], source)) def _delete_unreached(self, reach: sqlalchemy.Table) -> None: store = self._store ownership_by_parent: dict[str, list[tuple[str, str]]] = {} for edge in self._graph.ownership(): if edge.target_column is not None: ownership_by_parent.setdefault(edge.source_table, []).append((edge.target_table, edge.target_column)) for table_name in self._ingest._parent_schema: table = store._table(table_name) reached = sqlalchemy.select(reach.c[SID_COLUMN]).where(reach.c.tbl == table_name) self._connection.execute(sqlalchemy.delete(table).where(table.c[SID_COLUMN].not_in(reached))) for child_name, parent_column in ownership_by_parent.get(table_name, ()): child = store._table(child_name) surviving_parents = sqlalchemy.select(table.c[SID_COLUMN]) self._connection.execute( sqlalchemy.delete(child).where(child.c[parent_column].not_in(surviving_parents)) ) # -- compaction def _compact(self, table: sqlalchemy.Table) -> None: name = table.name start = self._ingest._initial_next_sid.get(name, 1) row_number = sqlalchemy.func.row_number().over(order_by=table.c[SID_COLUMN]) statement = sqlalchemy.select( table.c[SID_COLUMN].label("old"), (row_number + (start - 1)).label("new"), ).where(table.c[SID_COLUMN] >= _SID_BLOCK) pairs = [(int(row[0]), int(row[1])) for row in self._connection.execute(statement).all()] surviving = self._connection.execute(sqlalchemy.select(sqlalchemy.func.count()).select_from(table)).scalar_one() self._ingest._inserted_count[name] = int(surviving) self._ingest._next_sid[name] = start + len(pairs) if not pairs: return for old, new in pairs: self._compaction[(name, old)] = new map_table = self._make_map_table(pairs) try: for referrer_table, column in self._referrers.get(name, ()): self._remap_column(referrer_table, column, map_table) self._remap_column(name, SID_COLUMN, map_table) finally: self._drop_map_table(map_table) # -- dispatch def _merge_dispatch(self) -> None: ingest = self._ingest for manifest in self._manifests: for row in manifest.dispatch: final_sid = self._final_sid(row.ref_table, row.block_sid) built: dict[str, Any] = {DISPATCH_CONTENT_ID_COLUMN: row.key} for column in row.all_columns: built[column] = None built[row.column] = final_sid bucket = ingest._dispatch_rows.setdefault(row.dispatch_name, {}) ingest._dispatch_family.setdefault(row.dispatch_name, self._family_named(row.family_name)) existing = bucket.get(row.key) if existing is not None: if existing != built: raise EntryDispatchIntegrityError( f"entry dispatch {row.family_name!r} maps content_id {row.key!r} " f"to a conflicting backing row" ) continue bucket[row.key] = built ingest._flush_dispatch() def _family_named(self, family_name: str) -> Any: for family in self._store.layout.families: if family.name == family_name: return family raise KeyError(family_name) # pragma: no cover - families are declared up front def _final_sid(self, table_name: str, block_sid: int) -> int: keep = self._collapse.get((table_name, block_sid), block_sid) return self._compaction.get((table_name, keep), keep) # -- sid map helpers def _make_map_table(self, pairs: list[tuple[int, int]]) -> sqlalchemy.Table: map_table = sqlalchemy.Table( "_httk_bulk_sidmap", sqlalchemy.MetaData(), sqlalchemy.Column("old", sqlalchemy.Integer, nullable=False), sqlalchemy.Column("new", sqlalchemy.Integer, nullable=False), ) self._connection.execute(sqlalchemy.schema.DropTable(map_table, if_exists=True)) self._connection.execute(sqlalchemy.schema.CreateTable(map_table)) self._ingest._staging_tables.add(map_table.name) self._connection.execute(sqlalchemy.insert(map_table), [{"old": old, "new": new} for old, new in pairs]) return map_table def _drop_map_table(self, map_table: sqlalchemy.Table) -> None: self._connection.execute(sqlalchemy.schema.DropTable(map_table, if_exists=True)) def _remap_column(self, table_name: str, column: str, map_table: sqlalchemy.Table) -> None: # A join-based ``UPDATE ... FROM`` (a single hash join), not a per-row # correlated subquery: the latter is quadratic on DuckDB and dominates # the whole merge on a real-scale build. table = self._store._table(table_name) self._connection.execute( sqlalchemy.update(table).where(table.c[column] == map_table.c.old).values({column: map_table.c.new}) ) def _delete_where_in_map(self, table_name: str, column: str, map_table: sqlalchemy.Table) -> None: table = self._store._table(table_name) member = sqlalchemy.select(map_table.c.old) self._connection.execute(sqlalchemy.delete(table).where(table.c[column].in_(member)))