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

File Model

ftm_lakehouse.model.file.File

Bases: Stats

File metadata model. Arbitrary data can be stored in extra, including ftm properties that should be added to the generated Entity

Source code in ftm_lakehouse/model/file.py
class File(Stats):
    """File metadata model. Arbitrary data can be stored in `extra`, including
    ftm properties that should be added to the generated Entity"""

    model_config = ConfigDict(extra="allow")

    id: str

    dataset: str
    """Dataset name"""
    checksum: str
    """SHA256 checksum (often referred to as `content_hash`)"""
    extra: dict[str, Any] = {}
    """Arbitrary extra data"""
    origin: str | None = None
    """Origin stage of this file"""

    @field_validator("checksum")
    @classmethod
    def check_checksum(cls, v: str) -> str:
        return validate_checksum(v)

    @field_validator("store")
    @classmethod
    def hide_store(cls, v: str) -> str:
        # always don't include original store uri
        return "lakehouse://"

    @model_validator(mode="before")
    @classmethod
    def collect_extra_fields(cls, data: Any) -> Any:
        if not isinstance(data, dict):
            return data
        known_fields = set(cls.model_fields.keys())
        known_fields.update(["path", "parent"])  # computed_field
        extra = data.get("extra", {})
        extra_fields = {k: v for k, v in data.items() if k not in known_fields}
        if extra_fields:
            data = {k: v for k, v in data.items() if k in known_fields}
            data["extra"] = {**extra, **extra_fields}
        # an explicitly given id may arrive as a non-string (e.g. a numeric
        # source id from a crawler payload) – pydantic won't coerce that
        data["id"] = str(data["id"]) if data.get("id") else cls.make_id(data)
        return data

    def to_entity(self) -> StatementEntity:
        """Make an entity for this File"""
        schema = mime_to_schema(self.mimetype)
        entity = make_entity(
            {"id": self.id, "schema": schema},
            entity_type=StatementEntity,
            default_dataset=self.dataset,
        )
        entity.add("contentHash", self.checksum)
        entity.add("fileName", self.name)
        entity.add("fileSize", self.size)
        entity.add("mimeType", self.mimetype)
        entity.add("parent", self.parent)
        for prop in schema.properties:
            if prop in self.extra:
                entity.add(prop, self.extra[prop])
        return entity

    def make_parents(self) -> StatementEntities:
        """Make parent `Folder` entities"""
        parent = Path(self.key).parent
        if parent.name:
            yield from make_folders(parent, dataset=self.dataset)

    def make_entities(self) -> StatementEntities:
        yield from self.make_parents()
        yield self.to_entity()

    @staticmethod
    def make_id(data: SDict) -> str:
        """The entity id is generated by a hash of the file path and the
        checksum. Uses just the checksum as id if that's the key"""
        if data["key"] == data["checksum"]:
            return data["key"]
        return make_file_id(data["key"], data["checksum"])

    @computed_field
    @property
    def path(self) -> str:
        # "key" can be misleading in the codebase so this is an alias
        return self.key

    @computed_field
    @property
    def parent(self) -> str | None:
        for parent in reversed(list(self.make_parents())):
            return parent.id

    @property
    def blob_path(self) -> str:
        """Relative path to blob in dataset archive"""
        return path.ArchiveKey(self.checksum).blob

    @property
    def meta_path(self) -> str:
        """Relative path for this file's metadata json in dataset archive"""
        return path.ArchiveKey(self.checksum).meta(self.id)

    @classmethod
    def from_info(cls, info: Stats, checksum: str, **data) -> Self:
        data["dataset"] = data.get("dataset", DEFAULT_DATASET)
        data["checksum"] = checksum
        return cls(**{**info.model_dump(), **data})

    def to_document(self) -> Document:
        return Document(
            id=self.id,
            checksum=self.checksum,
            name=self.name,
            path=self.path,
            size=self.size,
            mimetype=self.mimetype,
            updated_at=self.updated_at,
        )

blob_path property

Relative path to blob in dataset archive

checksum instance-attribute

SHA256 checksum (often referred to as content_hash)

dataset instance-attribute

Dataset name

extra = {} class-attribute instance-attribute

Arbitrary extra data

meta_path property

Relative path for this file's metadata json in dataset archive

origin = None class-attribute instance-attribute

Origin stage of this file

make_id(data) staticmethod

The entity id is generated by a hash of the file path and the checksum. Uses just the checksum as id if that's the key

Source code in ftm_lakehouse/model/file.py
@staticmethod
def make_id(data: SDict) -> str:
    """The entity id is generated by a hash of the file path and the
    checksum. Uses just the checksum as id if that's the key"""
    if data["key"] == data["checksum"]:
        return data["key"]
    return make_file_id(data["key"], data["checksum"])

make_parents()

Make parent Folder entities

Source code in ftm_lakehouse/model/file.py
def make_parents(self) -> StatementEntities:
    """Make parent `Folder` entities"""
    parent = Path(self.key).parent
    if parent.name:
        yield from make_folders(parent, dataset=self.dataset)

to_entity()

Make an entity for this File

Source code in ftm_lakehouse/model/file.py
def to_entity(self) -> StatementEntity:
    """Make an entity for this File"""
    schema = mime_to_schema(self.mimetype)
    entity = make_entity(
        {"id": self.id, "schema": schema},
        entity_type=StatementEntity,
        default_dataset=self.dataset,
    )
    entity.add("contentHash", self.checksum)
    entity.add("fileName", self.name)
    entity.add("fileSize", self.size)
    entity.add("mimeType", self.mimetype)
    entity.add("parent", self.parent)
    for prop in schema.properties:
        if prop in self.extra:
            entity.add(prop, self.extra[prop])
    return entity

Statement Schema

Two schemas, one column apart. JOURNAL_SCHEMA is the producer schema – what every write path packs, what the journal table (journal_table) physically stores, and what the api wire format carries. SHARDED_SCHEMA prepends the shard partition key and is what parquet holds; ParquetStore.append derives that column from entity_id, so no producer carries a shard key of its own and none can route a row against a shard count other than the store's.

LakehouseStatement is the statement the write path passes around – ftmq's LakeStatement plus deleted_at, the tombstone marker. It deliberately carries no shard: a statement is content plus provenance, and where it lands is the store's call. statements_to_arrow is the one packer both statement write paths use: ftmq's statements_to_table packs the statement columns columnwise, this adds deleted_at, drops canonical_id, and applies the shared rules (first_seen / last_seen default, tombstone last_seen bump) as vectorized fills.

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_seenafter 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,
        )