Refreshing Materialized Cost Views on a Schedule
This page shows a food-tech developer how to keep the cost snapshot that reports read from going stale — building a materialized view of per-item cost and variance, refreshing it on a schedule without locking readers, and stamping each refresh with an immutable run_version so downstream reports can pin exactly what they consumed. It is the upstream companion to batch cost reporting automation: that guide assumes a frozen snapshot exists; this one produces it.
The operational failure this prevents is a report built on stale numbers. If the materialized view silently fails to refresh, a Monday report happily renders last week’s data with this week’s date. The fix is not just scheduling a refresh — it is making the refresh atomic, versioned, and observable, so a failed refresh blocks the report rather than feeding it old figures.
Prerequisites and Data Contract
- PostgreSQL 12+ (for
REFRESH MATERIALIZED VIEW CONCURRENTLY), a scheduler (cron, Airflow, or the async batch processing workflows queue), andpsycopg3.x. - Base tables
theoretical_cost_ledgerandactual_consumptionkeyed bylocation_id,menu_item_id, and aperiod, with cost columns asNUMERIC. - A
refresh_audittable to record each refresh’srun_version, start/end time, row count, and status.
The view’s output contract is one row per (location_id, menu_item_id, period) carrying theoretical_cost, actual_cost, net_revenue, and units_sold — exactly the snapshot contract the reporting layer consumes.
Step-by-Step Implementation
Step 1 — Define the materialized view with a unique index
REFRESH ... CONCURRENTLY requires a unique index on the view, which is also the join key reports pin against.
CREATE MATERIALIZED VIEW cost_variance_mv AS
SELECT t.location_id,
t.menu_item_id,
t.period,
t.theoretical_cost,
a.actual_cost,
a.net_revenue,
a.units_sold
FROM theoretical_cost_ledger t
JOIN actual_consumption a USING (location_id, menu_item_id, period);
CREATE UNIQUE INDEX cost_variance_mv_pk
ON cost_variance_mv (location_id, menu_item_id, period);
Step 2 — Refresh concurrently and stamp a run_version
Wrap the refresh so readers are never blocked and every refresh is auditable. The run_version is a monotonic id (a UUID or a timestamp-derived token) recorded before and after.
from __future__ import annotations
import uuid
import psycopg
def refresh_cost_view(dsn: str) -> str:
run_version = uuid.uuid4().hex
with psycopg.connect(dsn, autocommit=True) as conn:
with conn.cursor() as cur:
cur.execute(
"INSERT INTO refresh_audit (run_version, status, started_at)"
" VALUES (%s, 'running', now())", (run_version,),
)
try:
cur.execute("REFRESH MATERIALIZED VIEW CONCURRENTLY cost_variance_mv")
cur.execute(
"UPDATE refresh_audit SET status='succeeded', ended_at=now(),"
" row_count=(SELECT count(*) FROM cost_variance_mv)"
" WHERE run_version=%s", (run_version,),
)
except Exception as exc:
cur.execute(
"UPDATE refresh_audit SET status='failed', ended_at=now(),"
" error=%s WHERE run_version=%s", (str(exc), run_version),
)
raise
return run_version
Step 3 — Gate reads on freshness
Before a report consumes the view, confirm the latest refresh succeeded inside the freshness window. A stale or failed refresh must block, not degrade.
from datetime import timedelta
import psycopg
def assert_fresh(dsn: str, max_age: timedelta) -> str:
with psycopg.connect(dsn) as conn, conn.cursor() as cur:
cur.execute(
"SELECT run_version, ended_at FROM refresh_audit"
" WHERE status='succeeded' ORDER BY ended_at DESC LIMIT 1"
)
row = cur.fetchone()
cur.execute("SELECT now()")
now = cur.fetchone()[0]
if row is None or (now - row[1]) > max_age:
raise StaleViewError("cost view has no fresh successful refresh")
return row[0]
class StaleViewError(RuntimeError):
"""Raised when the latest successful refresh is outside the freshness window."""
Verification and Validation
- Concurrency proof. Run a long
SELECTagainst the view in one session whilerefresh_cost_viewruns in another; the read must not block. If it does, the unique index is missing and Postgres fell back to a non-concurrent refresh. - Audit completeness. After a refresh,
SELECT * FROM refresh_audit ORDER BY started_at DESC LIMIT 1should showstatus='succeeded', a non-nullended_at, and a plausiblerow_count. - Staleness gate fires. Temporarily set
max_ageto a tiny interval and confirmassert_freshraisesStaleViewError, proving reports would be blocked rather than served stale data. - Run-version handoff. Confirm the
run_versionreturned by the refresh is the one a report pins in generating weekly margin reports with pandas.
Gotchas and Edge Cases
- Missing unique index. Without it,
REFRESH ... CONCURRENTLYerrors and a naive fallback to a plainREFRESHtakes anACCESS EXCLUSIVElock that blocks every reader for the duration — exactly during the report window. - Silent refresh failure. If the scheduler swallows the exception, the view keeps serving old rows. Recording
status='failed'and gating reads on a freshsucceededrow turns a silent failure into a blocked, alertable report. - Long refresh overlapping the next trigger. A refresh that runs longer than the schedule interval can stack. Guard with an advisory lock so a second trigger skips rather than piling on:
pg_try_advisory_lock. - Timezone drift on the freshness window. Compare
ended_atto the database’snow()in the same timezone, not the application host’s clock, or a report can consider a fresh view stale (or vice versa) around a DST change.
Related
- Batch Cost Reporting Automation — the reporting layer that pins the
run_versionthis refresh produces. - Generating Weekly Margin Reports with pandas — the report that consumes a fresh snapshot.
- Async Batch Processing Workflows — scheduling and queuing the refresh job reliably.
- Theoretical vs Actual Food Cost Calculation — the base ledgers the view joins.
- Reporting, CI/CD & Delivery Pipelines — the wider delivery domain.
For engine specifics, see the official PostgreSQL materialized view documentation.