Skip to content

Signal Store

Tollkeeper's signal store tracks which tables have passed their audit. Pipelines that depend on a table can poll or wait for its signal before proceeding.

Setup

Two implementations ship with tollkeeper:

from tollkeeper import SqliteSignalStore, DbApiSignalStore

# SQLite (zero config, good for single-machine pipelines)
store = SqliteSignalStore("/var/data/signals.db")

# DB-API 2.0 (PostgreSQL, MySQL, any PEP 249 driver)
import psycopg2
conn = psycopg2.connect("dbname=pipeline")
store = DbApiSignalStore(conn, paramstyle="format")  # PostgreSQL uses %s

SqliteSignalStore defaults to :memory: if no path is given.

DbApiSignalStore accepts paramstyle="qmark" (SQLite, default) or paramstyle="format" (PostgreSQL, MySQL).

Both create the required tables on first use.

Wiring into Tollkeeper

Pass the store to the Tollkeeper constructor. When an audit passes, tollkeeper writes a signal automatically.

from tollkeeper import Tollkeeper, CsvBackend, SqliteSignalStore

store = SqliteSignalStore("signals.db")
backend = CsvBackend("/data/staging", "/data/prod")

(
    Tollkeeper(backend, signal_store=store)
    .table("orders")
    .audit([RowCountCheck(min_rows=1)])
    .publish()
)

# Signal written automatically on audit pass
assert store.check("orders") is not None

If the audit fails, no signal is written.

Reading and waiting for signals

# Non-blocking check
signal = store.check("orders")
if signal:
    print(f"orders passed at {signal.execution_ts}")

# Blocking wait (raises TimeoutError after 300s by default)
signal = store.wait("orders", timeout_s=60, poll_s=5)

Execution context

Partition signals by execution context (date, region, etc.) so daily runs don't overwrite each other:

ctx = {"ds": "2026-07-13"}

(
    Tollkeeper(backend, signal_store=store)
    .table("orders")
    .audit([RowCountCheck(min_rows=1)], execution_ctx=ctx)
    .publish()
)

# Only returns the signal for this specific date
store.check("orders", {"ds": "2026-07-13"})

# Different date returns None
store.check("orders", {"ds": "2026-07-12"})  # None

Dependencies and cascading deletes

Register that one table depends on another. When the upstream signal is deleted (e.g., a re-run), downstream signals are automatically invalidated.

store.register_dep("raw_events", "dim_users", cascade_policy="cascade")
store.register_dep("dim_users", "fact_sessions", cascade_policy="cascade")

# Write signals for all three
store.write(Signal(table_name="raw_events"))
store.write(Signal(table_name="dim_users"))
store.write(Signal(table_name="fact_sessions"))

# Deleting raw_events cascades through the chain
store.delete("raw_events")
assert store.check("dim_users") is None
assert store.check("fact_sessions") is None

Use cascade_policy="notify" to get a callback instead of automatic deletion:

def on_upstream_invalidated(upstream, downstream, ctx):
    print(f"{upstream} was invalidated, {downstream} may be stale")

store = SqliteSignalStore("signals.db", on_delete_callback=on_upstream_invalidated)
store.register_dep("raw_events", "dim_users", cascade_policy="notify")

DQ result storage

The signal store also persists individual check results for auditing and debugging:

from tollkeeper.signals.base import DqResult

result = DqResult(table_name="orders", check_name="NullCheck", passed=True, details="0 nulls in 'id'")
store.write_dq_result(result)

results = store.get_dq_results("orders")
for r in results:
    print(f"{r.check_name}: {'PASS' if r.passed else 'FAIL'} - {r.details}")

Resource management

Signal stores hold database connections. Close them when done:

# Explicit close
store = SqliteSignalStore("signals.db")
# ... use store ...
store.close()

# Context manager (preferred)
with SqliteSignalStore("signals.db") as store:
    Tollkeeper(backend, signal_store=store).table("orders").audit(checks).publish()
# connection closed automatically

Custom signal store

Implement the SignalStore ABC to back signals with Redis, DynamoDB, or anything else:

from tollkeeper.signals.base import SignalStore, Signal, DqResult

class RedisSignalStore(SignalStore):
    def write(self, signal: Signal) -> None: ...
    def delete(self, table: str, execution_ctx: dict | None = None) -> None: ...
    def check(self, table: str, execution_ctx: dict | None = None) -> Signal | None: ...
    def write_dq_result(self, result: DqResult) -> None: ...
    def get_dq_results(self, table: str, execution_ctx: dict | None = None) -> list[DqResult]: ...
    def delete_dq_results(self, table: str, execution_ctx: dict | None = None) -> None: ...
    def register_dep(self, upstream_table: str, downstream_table: str, ...) -> None: ...
    def get_downstream(self, table: str, execution_ctx: dict | None = None) -> list[tuple[str, dict, str]]: ...
    def close(self) -> None: ...

The wait() method is inherited and works with any implementation that provides check().