Skip to content

HomeModelTemporality

Temporality

A model's temporality is whether its unit of work has a time axis: TEMPORAL or TIMELESS. The temporality decides its unit of work, how many state rows it has when state tracking is on, and how a downstream contract checks it.

temporality defaults to Temporality.TEMPORAL (the common case). Set it explicitly from the Temporality enum when a model is timeless.

from bollhav.model import Temporality
temporality unit of work state rows
Temporality.TEMPORAL a time window — or, unbatched, the whole [begin, end] range one per window (batched) · one (unbatched)
Temporality.TIMELESS the whole thing one

A TEMPORAL model has a Contract time window. With batching it splits that window into chunks (one state row per window); without batching it loads its whole [begin, end] range in one run (one state row spanning the range). A TIMELESS model has no time axis — no batching, and no begin/end.

State is optional

Temporality does not require state — you never need state=State(...) just to set a temporality. They're independent:

  • Without state the model simply runs its unit(s) every time — the window(s) of a temporal model, or the whole thing for a timeless one — recording nothing and resuming nothing. The "state rows" column above is what would be recorded if state were on.
  • With state=State(...) bollhav writes those rows (per window / one-shot), so reruns skip already-applied units and you get status, errors, and resumability. See State.

The one case where state becomes required is gating: a Source that carries an UpstreamContract is enforced by the state machine, so a gated upstream without state is a construction error. Setting a temporality on its own never forces it.

View vs table

Materialization is a separate choice from the time axis — the materialization enum (Materialization.TABLE by default, or Materialization.VIEW). A VIEW is created with CREATE OR REPLACE VIEW from the model's defining query (its SELECT body, which lives on the model). Note: the query is the definition; materialization is how it's realized — a query alone does not make a model a view.

Model(temporality=Temporality.TEMPORAL, batching=Batch(...), ...)  # a windowed table
Model(temporality=Temporality.TIMELESS, ...)                       # a whole-table load
Model(temporality=Temporality.TIMELESS,                            # a timeless view
      materialization=Materialization.VIEW, query="SELECT ...")
Model(temporality=Temporality.TEMPORAL,                            # a temporal view —
      materialization=Materialization.VIEW, query="SELECT ...",
      contract=Contract(begin=..., end=...), ...)    #   its Contract is the range it covers

A view can't be batched — it's one CREATE VIEW, not materialized per-window. Its temporality is otherwise free: TEMPORAL, where its Contract begin/end declares the range it covers (recorded as a single state row a downstream can gate ENCAPSULATE against), or TIMELESS (existence only).

Validation

The temporality and its companions must agree, caught at construction:

  • Temporality.TIMELESS with batching raises — a timeless model isn't windowed.
  • Temporality.TIMELESS with a Contract begin/end raises — no time to bound.
  • materialization=Materialization.VIEW with batching raises — a view isn't materialized per-window.
  • materialization=Materialization.VIEW without a query raises — a view is created from its SELECT body.

Units per run

run.intervals (on the ModelRun from @load_models) yields one window per unit for a batched temporal model; a single None for a timeless model (or an unbatched temporal model with no declared range); and one interval spanning [begin, end] for an unbatched temporal model with a contract range — so the same loop runs the unit of work the right number of times.

Combinations

Two independent questions decide a model's shape — has a time axis? (TEMPORAL / TIMELESS) and materialized as a table or a view? (view=) — plus, for temporal tables only, chunk it? (batching).

flowchart TD
    Start([New model]) --> Q1{Does its work<br/>reference time?}

    Q1 -->|no| TL[temporality = TIMELESS]
    Q1 -->|yes| TP[temporality = TEMPORAL]

    TL --> Q2{Materialized?}
    Q2 -->|table| T1["Timeless table<br/>1 whole-table row"]
    Q2 -->|view| V1["Timeless view<br/>1 existence row"]

    TP --> Q3{Process in<br/>time chunks?}
    Q3 -->|yes · batching| B1["Temporal batched table<br/>1 row per window"]
    Q3 -->|no · one-shot| Q4{Materialized?}
    Q4 -->|table| O1["Temporal one-shot table<br/>1 row spanning begin..end"]
    Q4 -->|view| O2["Temporal view<br/>1 row spanning begin..end"]

    X1["TIMELESS + batching — rejected<br/>no time to window"]:::bad
    X2["view + batching — rejected<br/>a view isn't materialized per-window"]:::bad
    TL -.->X1
    Q4 -.->X2

    classDef bad fill:#7a1f1f,stroke:#c0392b,color:#fff
Combo Declare State Gate it with Use when
Temporal batched table temporality=TEMPORAL, batching=Batch(...) one row per window ENCAPSULATE / THROUGH / WHOLE / EXISTS The default for incremental fact/event tables — daily/hourly loads, backfills, anything processed and resumed per time window.
Temporal one-shot table temporality=TEMPORAL, contract=Contract(begin,end) (no batching) one row spanning [begin,end] ENCAPSULATE (window ⊆ range) / WHOLE / EXISTS A time-bounded table loaded in a single pass — small enough not to chunk, or the source only does a whole-range read — but downstreams still ask "is my window covered."
Timeless table temporality=TIMELESS one whole-table row WHOLE / EXISTS (not ENCAPSULATE) Dimensions, reference / lookup tables, config — no time axis, reloaded wholesale. The only question is "is it loaded."
Temporal view temporality=TEMPORAL, materialization=VIEW, query=..., contract=Contract(begin,end) one row spanning [begin,end] (no data) ENCAPSULATE (window ⊆ range) / WHOLE / EXISTS A SQL view over time-ranged data where consumers care "is this view current through my window." Declare the range; enforce it by gating the view's own source ENCAPSULATE.
Timeless view temporality=TIMELESS, materialization=VIEW, query=... one existence row (no data) WHOLE / EXISTS A plain view — renames, joins, projections, lookups — where the only question is "does it exist."

The two rejected corners each follow from one rule: timeless can't batch (batching chops a time window, and there's no time axis), and a view can't batch (batching is per-window materialization, and a view materializes nothing — its only way to carry time is the one-shot [begin, end] row).

See also

  • Contract — a temporal model's begin/end window.
  • Upstream — depending on another model, and the contract levels.
  • State — the rows each temporality records.