Backfilling Compliance History After a Schema Change
This walkthrough sits under Time-Series Compliance Drift Analysis and solves one precise task: after the compliance payload schema or the scoring logic changes, recompute the historical aggregates so the drift charts stay honest — without corrupting the live series or double-counting a single capture. A schema bump is unavoidable in a maturing pipeline: you add price_tag_mismatch_count, you re-weight misplaced_sku_list against out_of_stock_flags, or a registry_revision rolls the planogram under a store reset. The moment that lands, every historical compliance_percentage was computed under the old rules, so a 2026-03 figure and a 2026-07 figure are no longer measured with the same ruler, and the seasonal drift the parent cluster hunts becomes an artifact of your own release. The fix is a backfill that reads immutable raw payloads, recomputes aggregates in registry_revision-aware batches, and lands them through an idempotent upsert keyed on the natural key — so a re-run converges instead of accumulating. This page builds that backfill step by step, and each step is independently verifiable.
Prerequisites & Context Jump to heading
Before running a backfill, confirm these foundations are in place. A backfill is only as safe as the immutability guarantees underneath it — if raw payloads can be edited in place, no re-run is reproducible.
- Runtime: Python
3.11+with a database driver (psycopgfor Postgres in the examples) andpydanticon the batch host. No GPU; this is pure aggregation over stored records. - Immutable raw payloads: every compliance capture is stored once, write-once, keyed on the natural key
fixture_id+capture_timestamp+registry_revision. The backfill reads these and never mutates them — they are the source of truth the recompute replays against. If your raw store allows in-place edits, freeze it first. - A versioned aggregate store: the derived table the dashboards read must carry a
schema_version(orscoring_version) column, so old and new aggregates can coexist during the run. This is the same versioning discipline that Versioning Compliance Payloads Across Store Resets applies upstream — a schema bump there is exactly what triggers a backfill here. - A defined backfill window: the
[start, end)capture-timestamp range and the set ofregistry_revisionvalues in scope. Recomputing all of history when only one revision changed wastes hours and risks the live series for no gain. - A pinned scoring function: the new scoring logic, version-tagged, so a recomputed row records which algorithm produced it. Without this you cannot tell a backfilled row from a live one, and parity checks become guesswork.
A note on terms: idempotent here means running the backfill once, twice, or ten times lands the same rows — because every write targets a row identified by the natural key, a repeat overwrites rather than appends.
Step 1 — Snapshot the Live Series and Define the Window Jump to heading
Never recompute in place. Before touching anything, snapshot the current aggregates for the window and materialize the recomputed rows into a shadow table that carries the new schema_version. The live series keeps serving dashboards untouched until the parity gate passes. The snapshot is also your rollback: if the cutover misbehaves, you restore it verbatim.
Start by pinning the window and the revisions in scope, then copy the live rows aside.
from __future__ import annotations
import datetime as dt
from dataclasses import dataclass
@dataclass(frozen=True)
class BackfillWindow:
"""The bounded scope of one backfill run."""
start: dt.datetime # inclusive, UTC-aware
end: dt.datetime # exclusive, UTC-aware
registry_revisions: tuple[str, ...]
new_schema_version: int
def __post_init__(self) -> None:
if self.start.tzinfo is None or self.end.tzinfo is None:
raise ValueError("window bounds must be timezone-aware (UTC)")
if self.start >= self.end:
raise ValueError("start must be strictly before end")
if not self.registry_revisions:
raise ValueError("at least one registry_revision must be in scope")
def snapshot_live(cur, window: BackfillWindow) -> int:
"""Copy live aggregates in scope into a rollback table; return row count."""
cur.execute(
"""
CREATE TABLE IF NOT EXISTS compliance_agg_snapshot
(LIKE compliance_agg INCLUDING ALL);
INSERT INTO compliance_agg_snapshot
SELECT * FROM compliance_agg
WHERE capture_timestamp >= %(start)s
AND capture_timestamp < %(end)s
AND registry_revision = ANY(%(revs)s)
ON CONFLICT DO NOTHING;
""",
{"start": window.start, "end": window.end,
"revs": list(window.registry_revisions)},
)
return cur.rowcountKeeping the window timezone-aware and exclusive on the upper bound ([start, end)) is what stops the boundary double-count described in Troubleshooting — an inclusive end re-scores the midnight capture in two adjacent runs.
Step 2 — Land Rows Through an Idempotent Upsert on the Natural Key Jump to heading
The heart of a safe backfill is that every write is an upsert keyed on the natural key, so a re-run converges rather than accumulates. Model the aggregate row explicitly, then write it with ON CONFLICT ... DO UPDATE on the natural-key unique constraint. If the batch host dies mid-run and you restart, the already-written rows are simply overwritten with identical values — no duplicates, no drift.
from pydantic import BaseModel, Field
class ComplianceAggRow(BaseModel):
"""One recomputed aggregate, uniquely identified by the natural key."""
fixture_id: str
capture_timestamp: dt.datetime
registry_revision: str
schema_version: int
compliance_percentage: float = Field(ge=0.0, le=100.0)
out_of_stock_flags: int = Field(ge=0)
misplaced_sku_count: int = Field(ge=0)
price_tag_mismatch_count: int = Field(ge=0)
UPSERT_SQL = """
INSERT INTO compliance_agg_shadow
(fixture_id, capture_timestamp, registry_revision, schema_version,
compliance_percentage, out_of_stock_flags,
misplaced_sku_count, price_tag_mismatch_count)
VALUES (%(fixture_id)s, %(capture_timestamp)s, %(registry_revision)s,
%(schema_version)s, %(compliance_percentage)s, %(out_of_stock_flags)s,
%(misplaced_sku_count)s, %(price_tag_mismatch_count)s)
ON CONFLICT (fixture_id, capture_timestamp, registry_revision, schema_version)
DO UPDATE SET
compliance_percentage = EXCLUDED.compliance_percentage,
out_of_stock_flags = EXCLUDED.out_of_stock_flags,
misplaced_sku_count = EXCLUDED.misplaced_sku_count,
price_tag_mismatch_count = EXCLUDED.price_tag_mismatch_count;
"""
def upsert_batch(cur, rows: list[ComplianceAggRow]) -> int:
"""Idempotently upsert a batch; safe to replay verbatim."""
if not rows:
return 0
cur.executemany(UPSERT_SQL, [r.model_dump() for r in rows])
return len(rows)The schema_version is part of the conflict target on purpose: it lets the new aggregates land beside the old ones in the shadow table without collision, which is what makes the parity comparison in Step 4 a straight join rather than a diff of two tables.
Step 3 — Recompute in registry_revision-Aware Batches Jump to heading
Recompute in bounded batches grouped by registry_revision, never in one pass over all of history. Grouping by revision matters because the scoring rules and the planogram geometry are constant within a revision but change across one — batching along that seam keeps each batch internally consistent and lets you parallelize or resume per revision. Bounding the batch size is what keeps memory flat on a multi-year window; you stream raw payloads in chunks and commit per batch so a failure loses one batch, not the whole run.
from collections.abc import Iterator
def iter_raw_batches(
cur, window: BackfillWindow, batch_size: int = 5_000,
) -> Iterator[list[dict]]:
"""Yield raw payloads in bounded, revision-ordered batches (keyset paging)."""
for revision in window.registry_revisions:
last_key: tuple[str, dt.datetime] | None = None
while True:
cur.execute(
"""
SELECT fixture_id, capture_timestamp, registry_revision, payload
FROM compliance_raw
WHERE registry_revision = %(rev)s
AND capture_timestamp >= %(start)s
AND capture_timestamp < %(end)s
AND (%(last_ts)s IS NULL OR
(fixture_id, capture_timestamp) > (%(last_fx)s, %(last_ts)s))
ORDER BY fixture_id, capture_timestamp
LIMIT %(limit)s
""",
{"rev": revision, "start": window.start, "end": window.end,
"last_fx": last_key[0] if last_key else None,
"last_ts": last_key[1] if last_key else None,
"limit": batch_size},
)
batch = cur.fetchall()
if not batch:
break
yield batch
last = batch[-1]
last_key = (last["fixture_id"], last["capture_timestamp"])
def run_backfill(conn, window: BackfillWindow, score_fn) -> int:
"""Recompute and upsert the whole window in bounded batches."""
total = 0
for batch in iter_raw_batches(conn.cursor(), window):
rows: list[ComplianceAggRow] = []
for raw in batch:
try:
metrics = score_fn(raw["payload"]) # new scoring logic
except (KeyError, ValueError) as exc:
# a malformed historical payload must not abort the run
_log_skip(raw["fixture_id"], raw["capture_timestamp"], exc)
continue
rows.append(ComplianceAggRow(
fixture_id=raw["fixture_id"],
capture_timestamp=raw["capture_timestamp"],
registry_revision=raw["registry_revision"],
schema_version=window.new_schema_version,
**metrics,
))
with conn.cursor() as write_cur:
total += upsert_batch(write_cur, rows)
conn.commit() # commit per batch so a crash resumes, not restarts
return totalKeyset paging (rather than OFFSET) keeps each page cheap on a large table, and committing per batch means a crashed run resumes from the last committed batch — combined with the idempotent upsert, even re-reading a partially processed batch is harmless. The sibling walkthrough Detecting Seasonal Compliance Drift with Python consumes exactly this recomputed series, so a clean backfill is what makes its seasonal decomposition trustworthy.
Step 4 — Validate Parity Old-vs-New, Then Cut Over Jump to heading
Before the shadow table becomes the live series, prove it. Parity has three cheap, decisive checks: row counts must match the snapshot (every live row got a recomputed counterpart), coverage must be complete (no fixture_id × timestamp in scope is missing), and a spot delta on aggregate movement must stay within an expected band — a schema change should move scores, but a 40-point swing on every fixture signals a scoring bug, not a legitimate re-weight. Only when parity passes do you swap the shadow table into place atomically.
@dataclass(frozen=True)
class ParityReport:
live_rows: int
shadow_rows: int
missing_keys: int
max_abs_delta: float
mean_abs_delta: float
@property
def ok(self) -> bool:
return (self.live_rows == self.shadow_rows
and self.missing_keys == 0
and self.max_abs_delta <= 35.0) # tune per known re-weight
def check_parity(cur, window: BackfillWindow, old_version: int) -> ParityReport:
cur.execute(
"""
WITH live AS (
SELECT fixture_id, capture_timestamp, registry_revision,
compliance_percentage AS old_pct
FROM compliance_agg
WHERE capture_timestamp >= %(start)s AND capture_timestamp < %(end)s
AND registry_revision = ANY(%(revs)s) AND schema_version = %(old)s
),
new AS (
SELECT fixture_id, capture_timestamp, registry_revision,
compliance_percentage AS new_pct
FROM compliance_agg_shadow
WHERE schema_version = %(new)s
)
SELECT
(SELECT count(*) FROM live) AS live_rows,
(SELECT count(*) FROM new) AS shadow_rows,
count(*) FILTER (WHERE n.new_pct IS NULL) AS missing_keys,
coalesce(max(abs(l.old_pct - n.new_pct)), 0) AS max_delta,
coalesce(avg(abs(l.old_pct - n.new_pct)), 0) AS mean_delta
FROM live l
LEFT JOIN new n USING (fixture_id, capture_timestamp, registry_revision);
""",
{"start": window.start, "end": window.end,
"revs": list(window.registry_revisions),
"old": old_version, "new": window.new_schema_version},
)
r = cur.fetchone()
return ParityReport(r["live_rows"], r["shadow_rows"], r["missing_keys"],
float(r["max_delta"]), float(r["mean_delta"]))
def cutover(conn, window: BackfillWindow, old_version: int) -> None:
"""Atomically promote the shadow rows into the live series."""
report = check_parity(conn.cursor(), window, old_version)
if not report.ok:
raise RuntimeError(f"parity failed, refusing cutover: {report}")
with conn.cursor() as cur:
cur.execute(
"""
BEGIN;
DELETE FROM compliance_agg
WHERE capture_timestamp >= %(start)s AND capture_timestamp < %(end)s
AND registry_revision = ANY(%(revs)s);
INSERT INTO compliance_agg
SELECT * FROM compliance_agg_shadow WHERE schema_version = %(new)s;
COMMIT;
""",
{"start": window.start, "end": window.end,
"revs": list(window.registry_revisions),
"new": window.new_schema_version},
)The cutover runs inside one transaction so dashboards never observe a half-swapped window — readers see the old series or the new one, never a mix.
Verification & Testing Jump to heading
Confirm each guarantee deterministically rather than trusting a green run:
- Re-running is a no-op. Run
run_backfilltwice on the same window and assert the second call’s committed row count equals the first and thatSELECT count(*)on the shadow table is unchanged — the upsert overwrote, it did not append. - Row counts stay stable. After backfill, assert
ParityReport.live_rows == shadow_rowsandmissing_keys == 0; a mismatch means the window or the batch iterator dropped keys. - Spot parity holds. Pull
20known fixtures across the window and assert each recomputedcompliance_percentagematches a hand-run of the newscore_fnon its raw payload, and thatmax_abs_deltasits within the band you expect from the re-weight. - The boundary is not double-counted. Insert a capture at exactly
end; assert it is excluded (the window is[start, end)) and that a second adjacent window starting atendscores it exactly once. - A malformed payload is skipped, not fatal. Feed one raw record that raises in
score_fn; assert the run completes, the bad key is logged, and every other fixture still lands. - The snapshot restores. Simulate a bad cutover, restore
compliance_agg_snapshot, and assert the live series byte-matches its pre-run state.
A healthy run reports missing_keys == 0, identical row counts, and a mean_abs_delta consistent with the magnitude of the scoring change — small for a field addition, larger but bounded for a genuine re-weight.
Troubleshooting Jump to heading
| Symptom | Likely root cause | Remediation |
|---|---|---|
| Aggregate totals inflate on every re-run | Writes are plain INSERT, or the conflict target omits part of the natural key |
Make the write ON CONFLICT (fixture_id, capture_timestamp, registry_revision, schema_version) DO UPDATE; assert the unique constraint exists |
| A midnight capture is scored twice | Window upper bound is inclusive, so adjacent runs overlap at the boundary | Use [start, end) — exclusive end — and keep all bounds UTC-aware; verify with test 4 |
| Backfill stops halfway and leaves a partial series live | Recompute wrote directly into the live table with no shadow/cutover gate | Always land in compliance_agg_shadow and promote atomically only after ParityReport.ok; a crash then leaves the live series untouched |
| Charts shift by a whole hour after backfill | Timezone skew — raw capture_timestamp stored naive or in local time, recompute assumes UTC |
Normalize all timestamps to UTC-aware at ingestion and in BackfillWindow; reject naive bounds in __post_init__ |
| Batch host OOMs on a multi-year window | Recompute pulls the whole range into memory, or paging uses OFFSET |
Stream with keyset-paged iter_raw_batches, bound batch_size (e.g. 5000), and commit per batch so memory stays flat |
| Parity flags a huge delta on every fixture | New score_fn version mismatched, or comparing against the wrong old_version |
Confirm the pinned scoring version and the schema_version filters in check_parity; a uniform large delta is a bug, a legitimate re-weight moves scores unevenly |
Related Jump to heading
- Time-Series Compliance Drift Analysis — the parent cluster whose drift charts depend on every historical point being scored under one consistent ruler
- Detecting Seasonal Compliance Drift with Python — the sibling analysis that consumes the recomputed series a clean backfill produces
- Versioning Compliance Payloads Across Store Resets — the upstream schema-versioning discipline whose bumps are exactly what trigger a backfill here