Skip to content

Layer 1: Model

Pure data structures with no dependencies. Pydantic models for serialization.

Dataset Models

ftm_lakehouse.model.DatasetModel

Bases: Dataset

Source code in ftm_lakehouse/model/dataset.py
class DatasetModel(Dataset):
    storage: StoreModel | None = None
    """Set storage for external lakehouse"""
    public_url_prefix: HttpUrlStr | None = None
    """Public url prefix for resources"""
    shards: int = DEFAULT_SHARDS
    """Number of entity-id hash shards for the parquet store. ``0`` (default)
    means a single shard; huge datasets should configure ``8`` or more at
    creation for bounded per-partition working sets (e.g.
    ``ensure_dataset("big_leak", shards=8)``). Fixed once the store is
    written: setting it here only changes where readers *look*, so changing
    it after the fact means a full rewrite –
    [`ShardOperation`][ftm_lakehouse.operation.maintenance.ShardOperation]."""
    compression: CompressKind | None = None
    """Compress exported artifacts (statements.csv, entities.ftm.json, diffs...)"""

    def get_public_prefix(self) -> str | None:
        if self.public_url_prefix:
            return self.public_url_prefix
        if settings.public_url_prefix:
            # ``${dataset}`` placeholder; safe_substitute so literal ``$``
            # (and ``%``) in a prefix never breaks
            return Template(settings.public_url_prefix).safe_substitute(
                dataset=self.name
            )

compression = None class-attribute instance-attribute

Compress exported artifacts (statements.csv, entities.ftm.json, diffs...)

public_url_prefix = None class-attribute instance-attribute

Public url prefix for resources

shards = DEFAULT_SHARDS class-attribute instance-attribute

Number of entity-id hash shards for the parquet store. 0 (default) means a single shard; huge datasets should configure 8 or more at creation for bounded per-partition working sets (e.g. ensure_dataset("big_leak", shards=8)). Fixed once the store is written: setting it here only changes where readers look, so changing it after the fact means a full rewrite – ShardOperation.

storage = None class-attribute instance-attribute

Set storage for external lakehouse

Statement Schema

Two schemas, one column apart. JOURNAL_SCHEMA is what every write path packs, the journal table (journal_table) stores and the api wire carries. SHARDED_SCHEMA adds the shard partition key and is what parquet holds – ParquetStore.append derives it from entity_id.

LakehouseStatement is ftmq's LakeStatement plus the two columns the lakehouse adds: deleted_at (the tombstone marker) and role (who asserted the statement). statements_to_arrow packs statements into a JOURNAL_SCHEMA table for both write paths and applies their shared fill rules.

ftm_lakehouse.model.statement.LakehouseStatement

Bases: LakeStatement

A statement carrying the two columns the lakehouse adds to the schema.

deleted_at is the tombstone marker – a storage fact about a statement rather than statement content, and lakehouse-only (ftmq's lake store deletes physically). role records who asserted the statement: an identifier a submitting application supplies, alongside origin's where. Both live here for the same reason fragment lives on ftmq.store.lake.LakeStatement: so the write path can pass statements around instead of (stmt, deleted_at, role) tuples.

role joins origin and fragment in dedupe_key, so two roles asserting identical content stay two rows through merge – full provenance, rather than one row whose role is whoever wrote last. The empty string collapses to None so "no role" has one representation.

There is deliberately no shard attribute – a statement is content plus provenance, and which partition it lands in is append's call.

Source code in ftm_lakehouse/model/statement.py
class LakehouseStatement(LakeStatement):
    """A statement carrying the two columns the lakehouse adds to the schema.

    ``deleted_at`` is the tombstone marker – a storage fact about a statement
    rather than statement content, and lakehouse-only (ftmq's lake store
    deletes physically). ``role`` records *who* asserted the statement: an
    identifier a submitting application supplies, alongside ``origin``'s
    *where*. Both live here for the same reason ``fragment`` lives on
    `ftmq.store.lake.LakeStatement`: so the write path can pass statements
    around instead of ``(stmt, deleted_at, role)`` tuples.

    ``role`` joins ``origin`` and ``fragment`` in `dedupe_key`, so two roles
    asserting identical content stay two rows through
    [`merge`][ftm_lakehouse.storage.parquet.ParquetStore.merge] – full
    provenance, rather than one row whose role is whoever wrote last. The
    empty string collapses to ``None`` so "no role" has one representation.

    There is deliberately no ``shard`` attribute – a statement is content plus
    provenance, and which partition it lands in is
    [`append`][ftm_lakehouse.storage.parquet.ParquetStore.append]'s call.
    """

    __slots__ = ["deleted_at", "role"]

    def __init__(
        self,
        *args: Any,
        deleted_at: datetime | None = None,
        role: str | None = None,
        **kwargs: Any,
    ) -> None:
        super().__init__(*args, **kwargs)
        self.deleted_at = deleted_at
        self.role = role or None

    @property
    def dedupe_key(self) -> str:
        """Stable row identity: ``id``, ``origin``, ``fragment``, ``role``.

        Extends `ftmq.store.lake.LakeStatement.dedupe_key` with the
        lakehouse's fourth identity dimension, so the write buffers collapse
        re-emissions exactly where `merge` does.
        `ftm_lakehouse.helpers.statements.dedupe_key` keeps the same key shape
        for the packed-row paths that never build a statement object.
        """
        return f"{super().dedupe_key}\t{self.role or ''}"

    @classmethod
    def from_dict(cls, data: StatementDict) -> "LakehouseStatement":
        """Read a statement back from a row dict, keeping ``role``.

        ``deleted_at`` is deliberately not read back: every consumer of this
        (statement queries, the api NDJSON wire) reads the *live* view, where
        a surfaced row is by definition not a tombstone.
        """
        stmt = cast("LakehouseStatement", super().from_dict(data))
        stmt.role = cast(dict[str, Any], data).get("role") or None
        return stmt

    @classmethod
    def from_db_row(cls, row: Any) -> "LakehouseStatement":
        """Read a statement back from a SQL row, keeping ``role``."""
        stmt = cast("LakehouseStatement", super().from_db_row(row))
        stmt.role = getattr(row, "role", None) or None
        return stmt

dedupe_key property

Stable row identity: id, origin, fragment, role.

Extends ftmq.store.lake.LakeStatement.dedupe_key with the lakehouse's fourth identity dimension, so the write buffers collapse re-emissions exactly where merge does. ftm_lakehouse.helpers.statements.dedupe_key keeps the same key shape for the packed-row paths that never build a statement object.

from_db_row(row) classmethod

Read a statement back from a SQL row, keeping role.

Source code in ftm_lakehouse/model/statement.py
@classmethod
def from_db_row(cls, row: Any) -> "LakehouseStatement":
    """Read a statement back from a SQL row, keeping ``role``."""
    stmt = cast("LakehouseStatement", super().from_db_row(row))
    stmt.role = getattr(row, "role", None) or None
    return stmt

from_dict(data) classmethod

Read a statement back from a row dict, keeping role.

deleted_at is deliberately not read back: every consumer of this (statement queries, the api NDJSON wire) reads the live view, where a surfaced row is by definition not a tombstone.

Source code in ftm_lakehouse/model/statement.py
@classmethod
def from_dict(cls, data: StatementDict) -> "LakehouseStatement":
    """Read a statement back from a row dict, keeping ``role``.

    ``deleted_at`` is deliberately not read back: every consumer of this
    (statement queries, the api NDJSON wire) reads the *live* view, where
    a surfaced row is by definition not a tombstone.
    """
    stmt = cast("LakehouseStatement", super().from_dict(data))
    stmt.role = cast(dict[str, Any], data).get("role") or None
    return stmt

ftm_lakehouse.model.statement.statements_to_arrow(statements, now)

Pack a stream of statements into a JOURNAL_SCHEMA table.

ftmq's statements_to_table packs the statement columns columnwise; this adds the two columns the lakehouse stores on top (role and deleted_at), drops canonical_id (this store never resolves entities), and applies the two rules both write paths share:

  • first_seen / last_seen fall back to now when the statement carries none,
  • tombstones (deleted_at set) bump last_seen to the later of the delete timestamp and the row they shadow, so they win the ROW_NUMBER() OVER (... ORDER BY last_seen DESC, deleted_at DESC NULLS LAST) tiebreak in ParquetStore.merge. Taking the delete timestamp alone would lose to a row dated in the future – input carries last_seen, so nothing bounds it by the wall clock – and merge would drop the tombstone rather than the row, leaving the entity undeletable on every retry. On the tie this leaves, the deleted_at tiebreak decides, which is what it is there for.
  • first_seen is clamped to clamp_first_seen – after the tombstone rule, so it settles against the final last_seen.

All three rules are vectorized fills over the packed columns rather than per-row branches, and every column swap below is zero-copy. The closing cast is what makes the result align with JOURNAL_SCHEMA – including its NOT NULL columns, so a statement missing one is rejected here rather than by a reader later.

Parameters:

Name Type Description Default
statements Iterable[LakehouseStatement]

Statements, typically a whole drained EntityBuffer.flush_buffer.

required
now datetime

Default timestamp for missing first_seen / last_seen.

required

Returns:

Type Description
Table

A table with exactly JOURNAL_SCHEMA.

Source code in ftm_lakehouse/model/statement.py
def statements_to_arrow(
    statements: Iterable[LakehouseStatement], now: datetime
) -> pa.Table:
    """Pack a stream of statements into a `JOURNAL_SCHEMA` table.

    ftmq's `statements_to_table` packs the statement
    columns columnwise; this adds the two columns the lakehouse stores on top
    (``role`` and ``deleted_at``), drops ``canonical_id`` (this store never
    resolves entities), and applies the two rules both write paths share:

    - ``first_seen`` / ``last_seen`` fall back to ``now`` when the statement
      carries none,
    - tombstones (``deleted_at`` set) bump ``last_seen`` to the *later* of the
      delete timestamp and the row they shadow, so they win the ``ROW_NUMBER()
      OVER (... ORDER BY last_seen DESC, deleted_at DESC NULLS LAST)`` tiebreak
      in [`ParquetStore.merge`][ftm_lakehouse.storage.parquet.ParquetStore.merge].
      Taking the delete timestamp alone would lose to a row dated in the future
      – input carries ``last_seen``, so nothing bounds it by the wall clock –
      and ``merge`` would drop the tombstone rather than the row, leaving the
      entity undeletable on every retry. On the tie this leaves, the
      ``deleted_at`` tiebreak decides, which is what it is there for.
    - ``first_seen`` is clamped to `clamp_first_seen` – *after* the tombstone
      rule, so it settles against the final ``last_seen``.

    All three rules are vectorized fills over the packed columns rather than
    per-row branches, and every column swap below is zero-copy. The closing cast is what
    makes the result align with `JOURNAL_SCHEMA` – including its ``NOT
    NULL`` columns, so a statement missing one is rejected here rather than by a
    reader later.

    Args:
        statements: Statements, typically a whole drained
            `EntityBuffer.flush_buffer`.
        now: Default timestamp for missing ``first_seen`` / ``last_seen``.

    Returns:
        A table with exactly `JOURNAL_SCHEMA`.
    """
    statements = list(statements)  # needs materialization before
    table = statements_to_table(statements)
    stamp = pa.scalar(now, PA_TS)
    deleted_at = pa.array([s.deleted_at for s in statements], PA_TS)
    role = pa.array([s.role for s in statements], pa.string())
    first_seen = pc.fill_null(table.column("first_seen"), stamp)
    seen = pc.fill_null(table.column("last_seen"), stamp)
    # element-wise MAX(deleted_at, last_seen): `max_element_wise` has no
    # timestamp kernel, and a bare `greater` would go null on the non-tombstone
    # rows, so the delete stamp is coalesced onto `seen` before the comparison
    deleted_or_seen = pc.coalesce(deleted_at, seen)
    last_seen = pc.if_else(pc.greater(deleted_or_seen, seen), deleted_or_seen, seen)
    return clamp_first_seen(
        table.set_column(
            table.schema.get_field_index("first_seen"), "first_seen", first_seen
        )
        .set_column(table.schema.get_field_index("last_seen"), "last_seen", last_seen)
        .append_column("role", role)
        .append_column("deleted_at", deleted_at)
        .select(JOURNAL_SCHEMA.names)
        .cast(JOURNAL_SCHEMA)
    )

ftm_lakehouse.model.statement.journal_table(metadata, name)

Physical journal table named name, mirroring JOURNAL_SCHEMA.

The journal buffers exactly the rows producers pack, so its DDL is derived from the same pyarrow schema – a journal row needs no packing to become a statement row, and a segment can be streamed straight into ParquetStore.append as Arrow, which appends the derived shard partition key.

No primary key, no unique constraint, no index: the journal is an append-only heap – but the schema's own NOT NULL columns (REQUIRED_COLUMNS) still hold, so a row that could not be read back never lands. Re-emissions accumulate as extra rows and ParquetStore.merge collapses them, which is where dedup lives anyway – and without a key, row identity (origin, id, fragment, role) survives the journal instead of collapsing to (id, fragment).

Parameters:

Name Type Description Default
metadata MetaData

The MetaData to attach the table to.

required
name str

Table name – the live journal or one of its segments.

required

Returns:

Type Description
Table

The SQLAlchemy Table.

Source code in ftm_lakehouse/model/statement.py
def journal_table(metadata: MetaData, name: str) -> Table:
    """Physical journal table named ``name``, mirroring `JOURNAL_SCHEMA`.

    The journal buffers exactly the rows producers pack, so its DDL is
    derived from the same pyarrow schema – a journal row needs no packing to
    become a statement row, and a segment can be streamed straight into
    [`ParquetStore.append`][ftm_lakehouse.storage.parquet.ParquetStore.append]
    as Arrow, which appends the derived ``shard`` partition key.

    No primary key, no unique constraint, no index: the journal is an
    append-only heap – but the schema's own ``NOT NULL`` columns
    (`REQUIRED_COLUMNS`) still hold, so a row that could not be read
    back never lands. Re-emissions accumulate as extra rows and
    [`ParquetStore.merge`][ftm_lakehouse.storage.parquet.ParquetStore.merge]
    collapses them, which is where dedup lives anyway – and without a key, row
    identity ``(origin, id, fragment, role)`` survives the journal instead of
    collapsing to ``(id, fragment)``.

    Args:
        metadata: The ``MetaData`` to attach the table to.
        name: Table name – the live journal or one of its segments.

    Returns:
        The SQLAlchemy ``Table``.
    """
    cols = (Column(f.name, _sa_type(f), nullable=f.nullable) for f in JOURNAL_SCHEMA)
    return Table(name, metadata, *cols)

Job Models

ftm_lakehouse.model.JobModel

Bases: BaseModel

Status model for a (probably long running) job

Source code in ftm_lakehouse/model/job.py
class JobModel(BaseModel):
    """Status model for a (probably long running) job"""

    run_id: str
    started: datetime | None = None
    stopped: datetime | None = None
    last_updated: datetime | None = None
    pending: int = 0
    done: int = 0
    errors: int = 0
    running: bool = False
    exc: str | None = None
    took: timedelta = timedelta()

    @computed_field
    @property
    def name(self) -> str:
        return self.__class__.__name__

    @field_validator("run_id", mode="before")
    @classmethod
    def ensure_run_id(cls, value: str | None = None) -> str:
        """Give a manual run id or create one"""
        return value or ensure_uuid()

    def touch(self) -> None:
        self.last_updated = utc_now()

    def stop(self, exc: Exception | None = None) -> None:
        self.running = False
        self.stopped = utc_now()
        self.exc = str(exc)
        if self.started and self.stopped:
            self.took = self.stopped - self.started

    @classmethod
    def make(cls, **kwargs) -> Self:
        kwargs["run_id"] = cls.ensure_run_id(kwargs.get("run_id"))
        return cls(**kwargs)

    @cached_property
    def log(self) -> BoundLogger:
        return get_logger(__name__, run_id=self.run_id)

ensure_run_id(value=None) classmethod

Give a manual run id or create one

Source code in ftm_lakehouse/model/job.py
@field_validator("run_id", mode="before")
@classmethod
def ensure_run_id(cls, value: str | None = None) -> str:
    """Give a manual run id or create one"""
    return value or ensure_uuid()

ftm_lakehouse.model.DatasetJobModel

Bases: JobModel

Status model for a (probably long running) job bound to a dataset

Source code in ftm_lakehouse/model/job.py
class DatasetJobModel(JobModel):
    """Status model for a (probably long running) job bound to a dataset"""

    dataset: str

    @cached_property
    def log(self) -> BoundLogger:
        return get_logger(
            f"{self.dataset}.{self.name}",
            run_id=self.run_id,
            dataset=self.dataset,
        )