Enforcing Scd Type 2 Integrity For Real Time Feature Store Feeds
Written by
Sage Stream
Why I got stuck: “the feature went backwards”
I was building a real-time feature feed for an AI model—think “latest customer attributes right now”—and I assumed my joins were safe. Then a weird thing happened during evaluation: a downstream prediction stream started behaving as if the customer’s data time-traveled.
What I found was surprisingly specific: my “dimension” table (slow-changing attributes like customer tier, region, eligibility) was implemented as SCD Type 2 (slowly changing dimension with history). But my real-time pipeline wasn’t enforcing the rules that guarantee Type 2 integrity. Result: some feature rows were pulled from an older “version” of the same entity even when newer versions existed—so features went backwards in time.
This post is the exact guardrail I built: a set of SQL checks that detect SCD Type 2 integrity violations and block feature materialization when they occur.
The exact failure mode (SCD Type 2, but with broken validity windows)
In SCD Type 2, each entity (say customer_id) has multiple versions. Each version is valid for a time range:
valid_from: when this version startsvalid_to: when this version ends (oftenNULLfor the current version)
Correct invariant (Type 2):
For a given customer_id:
- Valid ranges must not overlap.
- There must be exactly one “current” row (
valid_to IS NULL). - When you order by
valid_from, each next version should start after the previous one ends.
In my case, the pipeline had no enforcement, so invalid history quietly produced wrong “latest” rows.
Minimal schema I used
I used two tables:
customer_dim_scd2: history tableevents_stream: real-time events that we enrich with customer features
-- customer_dim_scd2 (history table) -- customer_id: entity key -- tier: some attribute used as a feature -- valid_from, valid_to: SCD2 validity window (valid_to NULL => current) -- events_stream (real-time events) -- event_id: unique event key -- customer_id: join key -- event_ts: timestamp when the event happened
The enrichment query looked like a typical “as-of join”:
- for each event at
event_ts, pick the dimension row wherevalid_from <= event_ts < valid_to - if
valid_tois NULL, treat it as “infinity”
That’s correct only if the dimension history satisfies the invariants.
Step 1: I wrote an “SCD Type 2 integrity” SQL test suite
I focused on checks that are:
- fast enough to run per batch
- strict enough to catch the time-travel bug
- deterministic (so it doesn’t hide behind probabilistic sampling)
1) Detect overlapping validity windows
Overlaps mean two versions are simultaneously valid—this breaks “as-of” logic.
-- Overlap detection for a single customer_id -- Two rows overlap if their ranges intersect. WITH ordered AS ( SELECT customer_id, valid_from, COALESCE(valid_to, TIMESTAMP '9999-12-31') AS valid_to, tier, ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY valid_from) AS rn FROM customer_dim_scd2 ), pairs AS ( SELECT a.customer_id, a.rn AS rn_a, a.valid_from AS a_valid_from, a.valid_to AS a_valid_to, b.rn AS rn_b, b.valid_from AS b_valid_from, b.valid_to AS b_valid_to FROM ordered a JOIN ordered b ON a.customer_id = b.customer_id AND a.rn < b.rn ) SELECT * FROM pairs WHERE -- intersection: a_start < b_end AND b_start < a_end a_valid_from < b_valid_to AND b_valid_from < a_valid_to ORDER BY customer_id, rn_a, rn_b;
What happens when I run this?
- If it returns rows, at least one pair of versions overlaps for the same
customer_id. - That’s enough to block materialization because an as-of join can pick the wrong row (or produce duplicates).
2) Detect multiple “current” rows
If more than one row has valid_to IS NULL, then “latest” is ambiguous.
SELECT customer_id, COUNT(*) AS current_row_count FROM customer_dim_scd2 WHERE valid_to IS NULL GROUP BY customer_id HAVING COUNT(*) <> 1 ORDER BY customer_id;
3) Detect invalid ordering / back-to-back gaps and reversals
Even without overlaps, you can have reversed windows or nonsensical values.
SELECT customer_id, valid_from, valid_to FROM ( SELECT customer_id, valid_from, valid_to, LAG(valid_from) OVER (PARTITION BY customer_id ORDER BY valid_from) AS prev_valid_from, LAG(COALESCE(valid_to, TIMESTAMP '9999-12-31')) OVER (PARTITION BY customer_id ORDER BY valid_from) AS prev_valid_to FROM customer_dim_scd2 ) t WHERE -- next start should not be before previous end valid_from < prev_valid_to ORDER BY customer_id, valid_from;
Step 2: I wired these tests into a single “gate” query
Instead of running three separate ad-hoc queries in a notebook, I consolidated them into a single gate that returns is_valid = true/false plus human-readable reasons.
Here’s a working pattern for Postgres. (The SQL is standard-ish; adapt timestamps if needed.)
WITH overlaps AS ( WITH ordered AS ( SELECT customer_id, valid_from, COALESCE(valid_to, TIMESTAMP '9999-12-31') AS valid_to, ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY valid_from) AS rn FROM customer_dim_scd2 ), pairs AS ( SELECT a.customer_id, a.rn AS rn_a, b.rn AS rn_b FROM ordered a JOIN ordered b ON a.customer_id = b.customer_id AND a.rn < b.rn ) SELECT o.customer_id FROM ( SELECT a.customer_id, a.rn_a, a.rn_b, a_valid_from, a_valid_to, b_valid_from, b_valid_to FROM ( SELECT a.customer_id, a.rn AS rn_a, a.valid_from AS a_valid_from, a.valid_to AS a_valid_to, b.rn AS rn_b, b.valid_from AS b_valid_from, b.valid_to AS b_valid_to FROM ordered a JOIN ordered b ON a.customer_id = b.customer_id AND a.rn < b.rn ) x ) z WHERE z.a_valid_from < z.b_valid_to AND z.b_valid_from < z.a_valid_to ), bad_currents AS ( SELECT customer_id FROM customer_dim_scd2 WHERE valid_to IS NULL GROUP BY customer_id HAVING COUNT(*) <> 1 ), bad_ordering AS ( SELECT customer_id FROM ( SELECT customer_id, valid_from, LAG(COALESCE(valid_to, TIMESTAMP '9999-12-31')) OVER (PARTITION BY customer_id ORDER BY valid_from) AS prev_valid_to FROM customer_dim_scd2 ) t WHERE prev_valid_to IS NOT NULL AND valid_from < prev_valid_to ) SELECT CASE WHEN EXISTS (SELECT 1 FROM overlaps) OR EXISTS (SELECT 1 FROM bad_currents) OR EXISTS (SELECT 1 FROM bad_ordering) THEN false ELSE true END AS is_valid, ARRAY_REMOVE(ARRAY[ CASE WHEN EXISTS (SELECT 1 FROM overlaps) THEN 'overlapping_validity_windows' END, CASE WHEN EXISTS (SELECT 1 FROM bad_currents) THEN 'multiple_current_rows' END, CASE WHEN EXISTS (SELECT 1 FROM bad_ordering) THEN 'bad_temporal_ordering' END ], NULL) AS failure_reasons;
Why this worked for my pipeline: it made “Type 2 integrity” a single decision point. I could stop the real-time enrichment/materalization immediately instead of letting the wrong feature values propagate.
Step 3: enforce the gate in real-time feature materialization
I treated the integrity check as a blocking precondition for any batch (or micro-batch) job that populates the feature store.
Example: materialize features only if dimension history is valid
Assuming:
- events arrive with
event_ts - features depend on
tierat that timestamp
-- Pseudocode-ish SQL pattern: -- 1) run the gate -- 2) if valid, do the as-of join enrichment WITH gate AS ( SELECT CASE WHEN EXISTS (SELECT 1 FROM customer_dim_scd2 WHERE valid_to IS NULL GROUP BY customer_id HAVING COUNT(*) <> 1) THEN false ELSE true END AS is_valid ), enriched AS ( SELECT e.event_id, e.event_ts, d.tier FROM events_stream e JOIN customer_dim_scd2 d ON d.customer_id = e.customer_id AND d.valid_from <= e.event_ts AND e.event_ts < COALESCE(d.valid_to, TIMESTAMP '9999-12-31') CROSS JOIN gate g WHERE g.is_valid ) SELECT * FROM enriched;
What happens when dimension history is broken?
- The
WHERE g.is_validclause prevents enrichment from producing any output. - In my setup, that also prevented publishing partial/wrong features to downstream model consumers.
Step 4: catch it earlier with a “duplicate as-of match” check
Even with good windows, the as-of join can still produce multiple matches if overlaps exist.
So I added a “join cardinality guard”:
SELECT e.event_id, COUNT(*) AS match_count FROM events_stream e JOIN customer_dim_scd2 d ON d.customer_id = e.customer_id AND d.valid_from <= e.event_ts AND e.event_ts < COALESCE(d.valid_to, TIMESTAMP '9999-12-31') GROUP BY e.event_id HAVING COUNT(*) <> 1 ORDER BY match_count DESC, e.event_id;
Interpretation:
match_count = 1is correct.match_count = 0means there’s no valid dimension row for that event timestamp.match_count > 1means overlaps exist (or history is inconsistent).
This check gave me a direct signal tied to the real-world join behavior, not just the raw SCD fields.
Step 5: (Practical part) I turned the SQL into an automated blocker
I ran the gate using a tiny Python script. The script:
- executes the gate query
- if
is_validis false, raises an exception (stopping the pipeline run) - otherwise proceeds to materialize features
import psycopg2 from psycopg2.extras import RealDictCursor GATE_SQL = """ WITH overlaps AS ( WITH ordered AS ( SELECT customer_id, valid_from, COALESCE(valid_to, TIMESTAMP '9999-12-31') AS valid_to, ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY valid_from) AS rn FROM customer_dim_scd2 ) SELECT a.customer_id FROM ( SELECT a.customer_id, a.valid_from AS a_valid_from, a.valid_to AS a_valid_to, b.valid_from AS b_valid_from, b.valid_to AS b_valid_to FROM ordered a JOIN ordered b ON a.customer_id = b.customer_id AND a.rn < b.rn ) x WHERE x.a_valid_from < x.b_valid_to AND x.b_valid_from < x.a_valid_to GROUP BY customer_id ), bad_currents AS ( SELECT customer_id FROM customer_dim_scd2 WHERE valid_to IS NULL GROUP BY customer_id HAVING COUNT(*) <> 1 ), bad_ordering AS ( SELECT customer_id FROM ( SELECT customer_id, valid_from, LAG(COALESCE(valid_to, TIMESTAMP '9999-12-31')) OVER (PARTITION BY customer_id ORDER BY valid_from) AS prev_valid_to FROM customer_dim_scd2 ) t WHERE prev_valid_to IS NOT NULL AND valid_from < prev_valid_to ) SELECT CASE WHEN EXISTS (SELECT 1 FROM overlaps) OR EXISTS (SELECT 1 FROM bad_currents) OR EXISTS (SELECT 1 FROM bad_ordering) THEN false ELSE true END AS is_valid, ARRAY_REMOVE(ARRAY[ CASE WHEN EXISTS (SELECT 1 FROM overlaps) THEN 'overlapping_validity_windows' END, CASE WHEN EXISTS (SELECT 1 FROM bad_currents) THEN 'multiple_current_rows' END, CASE WHEN EXISTS (SELECT 1 FROM bad_ordering) THEN 'bad_temporal_ordering' END ], NULL) AS failure_reasons; """ def gate_check_and_materialize(): conn = psycopg2.connect( host="localhost", port=5432, dbname="mydb", user="myuser", password="mypassword", ) try: with conn.cursor(cursor_factory=RealDictCursor) as cur: cur.execute(GATE_SQL) row = cur.fetchone() if not row["is_valid"]: reasons = row["failure_reasons"] raise RuntimeError(f"SCD2 gate failed: {reasons}") # If valid, proceed with materialization. # Replace this with your actual feature-store write logic. cur.execute(""" CREATE TEMP TABLE enriched_features AS SELECT e.event_id, e.event_ts, d.tier FROM events_stream e JOIN customer_dim_scd2 d ON d.customer_id = e.customer_id AND d.valid_from <= e.event_ts AND e.event_ts < COALESCE(d.valid_to, TIMESTAMP '9999-12-31'); """) conn.commit() print("Gate passed; enriched features materialized.") finally: conn.close() if __name__ == "__main__": gate_check_and_materialize()
Why I like this approach: the “go/no-go” decision is based on data rules, not on heuristics like “it looks fine” or “the last run didn’t fail.”
What I learned about AI Data Governance from this
This wasn’t just a pipeline bug; it was an AI Data Governance control. The governance issue was: my model’s training/serving feature semantics depended on the correctness of SCD Type 2 history, but nothing guaranteed that correctness.
By treating SCD Type 2 integrity as a first-class requirement—and blocking feature materialization when it fails—I prevented silent data corruption that would otherwise show up much later as unexplained model degradation.
In short: I learned that for real-time AI systems, governance must include temporal correctness checks (validity windows, current-row uniqueness, and join cardinality), not just freshness and schema validation.