Backend: Data 🗄️

Data can land in Postgres or MSSQL

A model’s target — where its actual data lands — can be either Postgres or MSSQL. That’s a separate choice from state: a model can write its tables to MSSQL while keeping its state in a separate Postgres (set with STATE_DSN). So the data warehouse is yours to pick, with Postgres still doing the bookkeeping behind the scenes.

Backend: State 🐘

State is only supported on Postgres

State always lives in Postgres — it’s the only backend that can hold it. The z_bollhav library, the per-model status tables, and the advisory locks that coordinate runners all need a Postgres store, pointed at with STATE_DSN. With no Postgres store at all there’s nowhere to track progress, so models fall back to stateless units of work and an external orchestrator (Airflow or cron) has to own ordering and retries.

Batching 📦

Divide the model's work into smaller intervals

An interval expression defines the chunk (@hourly, @daily) whose ticks become the (since, until) windows a model iterates.

Choreography 💃

Decentralized model ordering

In a traditional Airflow setup, an external scheduler holds the whole dependency graph and decides what runs after what. Choreography flips that: with state and upstream contracts, the ordering lives inside the models themselves. A downstream interval simply stays blocked until its upstream’s covering window is applied — so all you need on the outside is a dumb trigger, like a cron running python main.py every few minutes. Each run works out what’s actionable, does it, and leaves the rest blocked for next time. There’s no central DAG to maintain that just duplicates the dependencies your models already declare.

Contract 📜

What a model is expected to produce

A Contract declares what a model is expected to produce — the span of data it should cover, from begin to end. With a loose upper bound (no fixed end), it keeps filling as more days elapse, always extending to the latest complete window — rather than stopping at a set date.

Curfew ⏰

Forbid the model to begin work during these hours (or the inverse!)

Curfew(...) names the wall-clock hours or weekdays when a model isn’t allowed to begin a run — say, leave the source alone during business hours, on weekends, or through a maintenance window. You can flip it around too and give the hours when the model may run, treating everything else as off-limits. Either way, work that gets skipped stays pending and is picked up on a later run. By default a model has no curfew.

Decorators 🎀

The framework entry points

@load_models does discovery — read env, apply overrides, match models by TAGS, resolve each run’s window. @model_lifecycle wraps execution with state: prefill, lock, run pending intervals, mark applied.

Dry runs 🧪

Inspect what a model would run without committing any data

Two safety valves. A dry run works out what would execute — the matched models and their windows — without reading or writing any data. DRY_STATE goes further and runs for real, but records nothing in state (no applied rows). Both are one click in the TUI, handy for sanity-checking a tag expression or a backfill window before you fire the real run.

Errors table 🐛

Centralized error table

When a model raises, that interval’s state row flips to error and a full record lands in the shared z_bollhav.errors table — name, run id, error type, message, traceback, and timestamp. The table keeps history across runs and joins back to a model’s state on (since, until) or run_id. In discover mode the failed interval is retried automatically on the next run.

Freshness ❄️

Set limits on how stale your upstream data can be

A freshness bound sits on an upstream contract and caps how old the input may be. Even when the data is all present, the downstream counts as stale if the upstream is older than the bound. It shows up as a ❄ on the dependency arrow.

GUI 🕸️

See the whole pipeline as a live graph

A small web app (under gui/) that draws the cross-pipeline model graph from the central z_bollhav library, with live state and errors. A FastAPI backend reads Postgres; a Svelte Flow frontend renders managed models versus sources, status lights, and each arrow’s upstream contract and freshness. A 🎄 toggle switches between lappland (bare) and stockholm (full detail).

Lineage 🧬

Understand all relationships between models in your pipelines

Because every model declares its own inputs, you can trace one model’s lineage straight from the code — no database needed — or read the whole cross-pipeline graph out of the library, with managed upstreams as internal edges and sources as the boundary nodes where data enters.

Locking 🔒

Running one model from many workers safely

Each interval is claimed with its own advisory lock, so you can run the same model from several workers at once — each one grabs different pending intervals, and any interval already running elsewhere is skipped. That’s how a single heavy model spreads across machines, and how intervals left running by a crash get picked up and recovered on a later run.

Materialization 🧱

Whether a model becomes a table or a view

materialization chooses how a model’s output shows up in the database: a TABLE (the default — rows written by the execute function) or a VIEW (built from the model’s defining query, its SELECT body). It’s a deliberate choice, separate from what the model computes — having a query alone doesn’t make something a view.

Model 🧩

The Model object everything hangs off of

A Model ties together where it writes (target), what it reads (upstream managed inputs plus Source external ones), whether it tracks state, how it chunks time (batching + temporality), its own time bounds (contract), and how it’s selected (tagging). Discovery finds every Model in a folder; TAGS picks which ones a run actually touches.

Model library 📚

The shared registry of models, state, and errors

The z_bollhav library lives in Postgres and holds the cross-pipeline graph, the state rows, and the error history all in one place. Tooling reads it to colour lineage by status — applied, blocked, stale — which makes it feel closer to a live observability graph than a static diagram.

Orchestration 🐙

Centralized model ordering

The stateless alternative: Airflow, Dagster, or cron owns the graph — it encodes raw → clean → marts, plus retries and schedules — while bollhav just runs whichever model you point it at (chosen by TAGS). It’s the only setup where state isn’t supported (for example on MSSQL). The two can also compose: a thin trigger on the outside with contracts doing the real ordering inside. Put Airflow in front of stateful models and it becomes a timer, not a dependency engine.

Rigidity 🗿

How locked a model's chunk grid is — rigid or fluid

rigidity is how locked a temporal model’s chunk grid is. RIGID (the default) makes the grid the model’s state identity — one state row per (since, until) chunk, so re-slicing it needs a torch reset and downstreams can gate EXACT; it’s the safe choice for any model, including aggregations and order-dependent writes. FLUID attests that the output doesn’t depend on how time is partitioned (the query is window-decomposable, the write idempotent): state tracks coverage instead, so one model can be sliced at more than one grain — backfill history @daily, run recent data @hourly — without a reset. Not for aggregations.

Run modes ▶️

Which slice of time a run goes after

A run mode picks which window the run targets. LATEST_ENABLED catches up the most recent complete unit. BACKFILL_ENABLED, with BACKFILL_SINCE and BACKFILL_UNTIL, runs a bounded historical window. Reload (r:[tag]) re-runs a tag’s entire contract range from the start.

Runtime overrides 🎛️

Change a model's settings at runtime

Environment variables can rewrite a model as it loads, without touching code: INTERVAL_OVERRIDE changes the chunk size, WINDOW_OVERRIDE the catch-up scope in latest mode, LOOKBACK_OVERRIDE how far back late data is re-walked, and TIMEZONE_OVERRIDE the interval’s timezone. USE_SCHEMA_SUFFIX writes under a suffixed schema (z_bollhav_<suffix>) so a test run stays isolated from production. Overriding the chunk on a fixed-interval model forks its state identity — that’s by design.

Sources 📥

Where data enters from outside the framework

Sources are the edges of the system, where data comes in from somewhere bollhav doesn’t manage: SourceModel (a raw table), SourceFile, SourceApi, and SourceHardcoded (inline rows or SQL). Unlike managed upstreams, they aren’t state-tracked — so nothing gates on them.

Staging 🎭

Using temporary tables to load data in order to guarantee atomicity and avoid reading a whole table into memory

A staging table (like MssqlStaging) is a temporary side table the model writes a window into first, then merges into the target in a single step — so the target never shows a half-written interval, and the database does the merge in place without pulling the whole target table into memory. Staging only works window by window, so staged models always run windowed (latest or backfill), the same way the fact tables do.

State 🚦

Keeps track of the state of the model

Turn on state=State(...) and each model gets its own table in the central z_bollhav schema — one row per (since, until) interval, each carrying a status: pending (not done yet), running (in flight, with crash recovery), applied (done), blocked (an upstream’s covering window isn’t applied yet), or error (the model raised — retried automatically).

That bookkeeping buys you two things. Nothing slips through: any interval that’s still pending, blocked, or error is owed, so repeated runs keep chipping away until every window is applied — the data is guaranteed complete. Nothing is done twice: an applied interval is skipped on the next run, so data that’s already loaded is never loaded again. Gating, resumable backfills, and the GUI status lights all read off these same rows.

State marking ✅

Explicitly let state know you have made your own backfilling

STATE_MARK_APPLIED marks the run’s intervals as applied without actually executing the model. Use it when you loaded the tables some other way — outside a normal state run — and just want state to catch up and reflect that the work is already done.

State modes 🔁

What a re-run does with state that's already there

When you run a model again, the state mode decides what happens to intervals that already have rows. discover (the default) leaves applied intervals alone and runs only the ones still pending. bulldozer resets existing intervals back to pending, forcing the whole window to run again. torch clears every row and refills the state from scratch — what you reach for when changing the chunk size or wiping out a backlog.

Tag generation 🏭

How a model's set of tags is derived from explicit and implicit rules

Beyond the explicit tags={...} you set, Tags(...) automatically derives a whole set from the model’s identity, and each toggle adds more:

  • the raw name, schema, and catalog
  • all (the wildcard), plus the dotted schema.name and catalog.schema.name
  • snake splits (cha_clean_rstcha, clean, rst) when unsnake_*_for_tags is on
  • PascalCase splits (FactCasefact, case) when unpascal_*_for_tags is on

So a FactCase in cha_clean_rst becomes matchable by [FactCase], [cha_clean_rst], [all], [fact], [case], and more. That’s the trick behind selection: the literal name picks exactly one model, while a shared token like [fact] picks the whole family.

Tags 🏷️

Selecting which models run using tag expressions

TAGS is a small expression language for picking models. [a & b] means both, separate groups [a][b] mean either, not: excludes, and r: marks a model for reload. Every model’s own name counts as a tag, so [FactCase] selects exactly that one model.

Target 🎯

Where data lands

The destination: table name, schema, database connection, column definitions, indexes, and the write mode — optionally a staging table for an atomic incremental load.

Temporality ⏳

Whether a model has a time axis

A TEMPORAL model can divide its work into batches (time windows). A TIMELESS model is a one-shot table — rebuilt in a single run.

TUI 🖥️

Drive runs from a terminal screen

A terminal screen for running models: install with pip install "bollhav[tui]", then run bollhav inside a folder of models. You set up the run on a form — backfill window, schema suffixes, state mode, overrides — and launch a dry run, a dry-state run, or the real thing, without having to remember any environment-variable names.

Upstream contracts 📜

What a model requires of its inputs before running

Before a model runs a window, it checks that its inputs are ready — and each policy sets the bar differently. EXISTS only asks that the upstream is registered at all. EXACT demands the very same window. ENCAPSULATE matches the upstream’s shape — full window coverage for a temporal input, a single existence row for a timeless one. THROUGH chains that coverage across a span, and WHOLE waits for the entire table to be loaded.

Virtual environments 🏖️

Use a schema suffix so dev runs don't overwrite production

Setting SCHEMA_SUFFIX gives a run its own isolated environment: every schema it touches — the target tables, state, library, errors, and staging — gets the suffix appended, turning z_bollhav into something like z_bollhav_pr123. A development or CI run then writes into that sandbox instead of overwriting the production models. Without a suffix, a dev run would point straight at the prod schemas — exactly what you don’t want. The suffix can also carry a timestamp appendix, so the whole environment is ephemeral and easy to tear down once you’re done.

Write modes ✍️

How should data be written to the target?

A write mode decides how a run’s rows meet what’s already in the target. UPSERT_NO_DELETE merges on the key — updating matched rows and adding new ones, but never deleting the rest — so re-running a window only refreshes its own rows. Because no write depends on another landing first, windows can arrive in any order; that’s what makes flexible intervals safe.