Personalize this lesson
Adapt explanations and teaching visuals to your background and preferred voice.
A valid training-job SLA row is useful only if the pipeline can build it honestly at scale. The system must produce millions of historical rows for training and keep fresh values ready for live scoring jobs.
Those jobs run at different speeds, but they share one question: what did the scoring system know at the prediction timestamp? If an offline replay sees facts that live serving couldn't have known yet, training gets an unfairly clean view of history.

10:00, only E1 lies inside both time boundaries. E2 is the subtle failure: it occurred at 09:30 but arrived at 11:00, so an event-time-only replay leaks information unavailable to live serving.One event has two relevant times
Consider a training-job telemetry event:
| Field | Example | Meaning |
|---|---|---|
event_time | May 1, 09:30 | when the scheduler recorded the heartbeat |
ingested_at | May 1, 11:00 | when this pipeline learned about the heartbeat |
prediction_time | May 1, 10:00 | when the scoring system asked for a training SLA feature row |
The heartbeat happened before the scoring request, but it arrived one hour afterward. A live prediction at 10:00 couldn't use it. A faithful replay excludes facts that occurred too late or arrived too late:
1event_time <= prediction_time
2and ingested_at <= prediction_timeThe first boundary prevents future-event leakage. The second reproduces what the live system knew. Keep both timestamps even if your first source rarely arrives late.
Table time travel isn't a point-in-time join
A common ML systems mistake is conflating table-level time travel with per-entity point-in-time (PIT) joins.
- Table Time Travel: Analytical storage formats like Delta Lake or Apache Iceberg let you query a whole table "as of" one specific snapshot or database transaction timestamp (e.g.
2026-05-01 10:30:00). This restores the entire dataset to a single global timestamp. - Point-in-Time Join: In ML training, each row in your label dataset represents a separate prediction event with its own distinct
prediction_time(e.g.,R-201predicted at10:00:00,R-202predicted at10:30:00). A point-in-time join must search backward from each row's unique timestamp to fetch the latest feature state visible at that exact millisecond.
This simplified comparison treats each feature update's time as the moment it became available. When event time and ingestion time differ, the production join must enforce both boundaries introduced above.
If you query a single global table snapshot (e.g., at 10:30:00) to construct features for all rows:
- For
R-202(predicted at10:30:00), you get the correct state. - For
R-201(predicted at10:00:00), you accidentally pull in feature updates that occurred between10:00:00and10:30:00(e.g. a new queue backlog value at10:15:00). This is data leakage.
Compare a pandas merge_asof point-in-time join against a table-snapshot query:
1import pandas as pd
2
3# Prediction labels: each entity has a different prediction cutoff
4labels = pd.DataFrame([
5 {"run": "R-201", "prediction_time": pd.Timestamp("2026-05-01 10:00:00")},
6 {"run": "R-202", "prediction_time": pd.Timestamp("2026-05-01 10:30:00")},
7])
8
9# Feature updates log over time
10feature_updates = pd.DataFrame([
11 {"run": "R-201", "time": pd.Timestamp("2026-05-01 09:50:00"), "queue_backlog": 5},
12 {"run": "R-201", "time": pd.Timestamp("2026-05-01 10:15:00"), "queue_backlog": 12}, # Future for R-201 at 10:00!
13 {"run": "R-202", "time": pd.Timestamp("2026-05-01 10:20:00"), "queue_backlog": 8},
14])
15
16# 1. Correct Point-in-Time Join (ASOF join)
17labels_sorted = labels.sort_values("prediction_time")
18features_sorted = feature_updates.sort_values("time")
19
20pit_join = pd.merge_asof(
21 labels_sorted,
22 features_sorted,
23 left_on="prediction_time",
24 right_on="time",
25 by="run",
26 direction="backward"
27)
28
29# 2. Leaky Table Snapshot Join (taking a snapshot at 10:30 for the whole table)
30snapshot_time = pd.Timestamp("2026-05-01 10:30:00")
31visible_features = feature_updates[feature_updates["time"] <= snapshot_time]
32latest_snapshot = visible_features.sort_values("time").groupby("run").last().reset_index()
33
34leakage_join = pd.merge(labels, latest_snapshot, on="run")
35
36print("ASOF (PIT) JOIN:")
37print(pit_join[["run", "prediction_time", "queue_backlog"]])
38print("\nSNAPSHOT JOIN (LEAKAGE):")
39print(leakage_join[["run", "prediction_time", "queue_backlog"]])
40
41assert pit_join.loc[pit_join["run"] == "R-201", "queue_backlog"].values[0] == 5
42assert leakage_join.loc[leakage_join["run"] == "R-201", "queue_backlog"].values[0] == 121ASOF (PIT) JOIN:
2 run prediction_time queue_backlog
30 R-201 2026-05-01 10:00:00 5
41 R-202 2026-05-01 10:30:00 8
5
6SNAPSHOT JOIN (LEAKAGE):
7 run prediction_time queue_backlog
80 R-201 2026-05-01 10:00:00 12
91 R-202 2026-05-01 10:30:00 8Feature stores commonly provide point-in-time historical retrieval. Feast documents historical joins that scan backward from each entity timestamp within a configured time-to-live (TTL) window.[1] Your pipeline still owns the exact replay contract, including whether ingestion time belongs in it.

A heartbeat occurred at 09:30 but wasn't ingested until 11:00. Can a prediction made at 10:00 use it?
Answer
No. A faithful replay requires both event_time <= prediction_time and ingested_at <= prediction_time. The heartbeat had happened, but the live system didn't know about it yet.
Rebuild what serving knew
Build the replay in small steps. The first cell defines one normal event, one late arrival, and one future event for run R-204. Run the cells in order.
1from datetime import datetime
2from datetime import timedelta
3
4def dt(value):
5 return datetime.fromisoformat(value)
6
7events = [
8 {
9 "id": "E1",
10 "run": "R-204",
11 "event_time": dt("2026-05-01T08:00:00"),
12 "ingested_at": dt("2026-05-01T08:02:00"),
13 "source_version": 1,
14 "status": "queued",
15 },
16 {
17 "id": "E2",
18 "run": "R-204",
19 "event_time": dt("2026-05-01T09:30:00"),
20 "ingested_at": dt("2026-05-01T11:00:00"),
21 "source_version": 1,
22 "status": "warmup_spike",
23 },
24 {
25 "id": "E3",
26 "run": "R-204",
27 "event_time": dt("2026-05-01T13:00:00"),
28 "ingested_at": dt("2026-05-01T13:01:00"),
29 "source_version": 1,
30 "status": "gpu_pressure",
31 },
32]
33prediction_time = dt("2026-05-01T10:00:00")
34
35for event in events:
36 lag_minutes = int((event["ingested_at"] - event["event_time"]).total_seconds() / 60)
37 print(
38 f'{event["id"]} status={event["status"]} '
39 f'occurred={event["event_time"]:%H:%M} arrived={event["ingested_at"]:%H:%M} '
40 f'lag={lag_minutes}m'
41 )1E1 status=queued occurred=08:00 arrived=08:02 lag=2m
2E2 status=warmup_spike occurred=09:30 arrived=11:00 lag=90m
3E3 status=gpu_pressure occurred=13:00 arrived=13:01 lag=1mA filter on event_time alone looks reasonable, but it leaks E2 into a replay of the 10:00 decision.
1def event_time_only(events, run_id, at):
2 return [
3 event
4 for event in events
5 if event["run"] == run_id and event["event_time"] <= at
6 ]
7
8naive_replay = event_time_only(events, "R-204", prediction_time)
9print("event-time-only replay:", [event["status"] for event in naive_replay])
10print("leaked late arrival:", "warmup_spike" in {event["status"] for event in naive_replay})1event-time-only replay: ['queued', 'warmup_spike']
2leaked late arrival: TrueAdd the availability boundary. Now replay sees the same facts live serving saw at 10:00.
1def known_by(events, run_id, at):
2 return [
3 event
4 for event in events
5 if (
6 event["run"] == run_id
7 and event["event_time"] <= at
8 and event["ingested_at"] <= at
9 )
10 ]
11
12faithful_replay = known_by(events, "R-204", prediction_time)
13print("decision-time replay:", [event["status"] for event in faithful_replay])
14print("late heartbeat excluded:", "warmup_spike" not in {event["status"] for event in faithful_replay})1decision-time replay: ['queued']
2late heartbeat excluded: TrueA historical training snapshot needs the latest visible fact for each prediction request. The warmup_spike becomes usable after it arrives at 11:00; gpu_pressure becomes usable after 13:01.
1def state_order(event):
2 return (event["event_time"], event.get("source_version", 0))
3
4def revision_value(event):
5 return event["status"]
6
7def as_of_status(events, run_id, at):
8 visible = known_by(events, run_id, at)
9 if not visible:
10 return "missing"
11 latest_order = max(state_order(event) for event in visible)
12 latest = [event for event in visible if state_order(event) == latest_order]
13 if len({revision_value(event) for event in latest}) > 1:
14 return "quarantined"
15 return revision_value(latest[0])
16
17for requested_at in [
18 dt("2026-05-01T10:00:00"),
19 dt("2026-05-01T12:00:00"),
20 dt("2026-05-01T16:00:00"),
21]:
22 print(f"{requested_at:%H:%M} -> {as_of_status(events, 'R-204', requested_at)}")110:00 -> queued
212:00 -> warmup_spike
316:00 -> gpu_pressureAnalysts may also need corrected history of what happened by 10:00, regardless of arrival time. Keep that artifact separate: corrected history may include warmup_spike, while the decision-time replay contains only queued.
Keep online state current
Batch replay, stream updates, and online reads are related but distinct jobs:
| Job | Reads | Produces | Typical latency |
|---|---|---|---|
| batch replay | bounded event history | versioned training snapshot | minutes or hours |
| stream updater | newly arrived events | current feature state | seconds |
| online read | current feature state | one scoring row | milliseconds |
Different stores are acceptable. Different meanings aren't. If batch computes a seven-day backlog mean while online returns a one-hour queue count under the same name, offline metrics can't predict live behavior.
Feast documents an online store built for low-latency feature serving. For each entity key, it stores only the latest feature values rather than full history.[1] Google Cloud's MLOps guidance describes a feature store as an optional shared repository for definitions, storage, and access across high-throughput batch and low-latency serving workloads.[2]
A late, older event mustn't overwrite newer online state. Same-time corrections need a deterministic rule too. Use the same source-owned (event_time, source_version) order in batch replay and online updates: version 2 replaces version 1, including when a stale version-1 retry arrives later. In a real source contract, require a stable sequence or revision field rather than trusting arrival order.
1online_state = {}
2
3def apply_latest_state(state, event):
4 previous = state.get(event["run"])
5 if previous is not None:
6 if event["id"] == previous.get("id"):
7 return "ignored duplicate event"
8 if state_order(event) < state_order(previous):
9 return "ignored stale event"
10 if state_order(event) == state_order(previous):
11 if previous.get("quarantined"):
12 return "quarantine persists"
13 if revision_value(event) == revision_value(previous):
14 return "ignored duplicate revision"
15 state[event["run"]] = {
16 "run": event["run"],
17 "event_time": event["event_time"],
18 "source_version": event.get("source_version", 0),
19 "status": "quarantined",
20 "quarantined": True,
21 }
22 return "quarantined conflicting revision"
23 state[event["run"]] = event
24 return "stored"
25
26def online_status(state, run_id):
27 current = state.get(run_id)
28 if current is None:
29 return "missing"
30 return "quarantined" if current.get("quarantined") else revision_value(current)
31
32arrivals = [
33 {
34 "id": "E4",
35 "run": "R-204",
36 "event_time": dt("2026-05-01T10:30:00"),
37 "ingested_at": dt("2026-05-01T10:31:00"),
38 "source_version": 1,
39 "status": "worker_scaled",
40 },
41 {
42 "id": "E4-correction",
43 "run": "R-204",
44 "event_time": dt("2026-05-01T10:30:00"),
45 "ingested_at": dt("2026-05-01T10:34:00"),
46 "source_version": 2,
47 "status": "worker_scaled_corrected",
48 },
49 {
50 "id": "E4-v1-retry",
51 "run": "R-204",
52 "event_time": dt("2026-05-01T10:30:00"),
53 "ingested_at": dt("2026-05-01T10:36:00"),
54 "source_version": 1,
55 "status": "worker_scaled",
56 },
57 {
58 "id": "E1-retry",
59 "run": "R-204",
60 "event_time": dt("2026-05-01T08:00:00"),
61 "ingested_at": dt("2026-05-01T11:05:00"),
62 "status": "queued",
63 },
64]
65
66for event in arrivals:
67 print(f'{event["id"]}: {apply_latest_state(online_state, event)}')
68print("current status:", online_status(online_state, "R-204"))1E4: stored
2E4-correction: stored
3E4-v1-retry: ignored stale event
4E1-retry: ignored stale event
5current status: worker_scaled_correctedLatest-value state isn't enough for aggregates. A duplicated queue update can silently inflate queue backlog unless the updater is idempotent. Idempotent means retrying the same event doesn't change the result after its first successful application. The updater should also reject impossible aggregate states instead of publishing them.
1seen_event_ids = set()
2origin_backlog = 0
3
4def apply_queue_update(event):
5 global origin_backlog
6 if event["id"] in seen_event_ids:
7 return "duplicate skipped"
8 next_backlog = origin_backlog + event["delta"]
9 if next_backlog < 0:
10 return "blocked negative backlog"
11 seen_event_ids.add(event["id"])
12 origin_backlog = next_backlog
13 return "applied"
14
15queue_updates = [
16 {"id": "Q1", "delta": 3},
17 {"id": "Q1", "delta": 3},
18 {"id": "Q2", "delta": -1},
19 {"id": "Q3", "delta": -5},
20]
21
22for update in queue_updates:
23 result = apply_queue_update(update)
24 print(f'{update["id"]}: {result}; backlog={origin_backlog}')1Q1: applied; backlog=3
2Q1: duplicate skipped; backlog=3
3Q2: applied; backlog=2
4Q3: blocked negative backlog; backlog=2The set makes sequential retries visible, but production needs a durable atomic write: record the event ID and apply its aggregate change together. Otherwise a worker can crash between those steps and a retry can lose or repeat an update. Use a transaction, conditional write, or stream processor state primitive with the same contract.
A version-2 correction and a later retry of version 1 share the same event time. Which value should remain online?
Answer
Version 2. Ordering by (event_time, source_version) prevents a late retry from moving current state backward. If both ordering fields match but values conflict, quarantine the state instead of choosing by arrival order.
Bound lateness and freshness
A stream processor can't wait forever for missing events. It commonly tracks a watermark, an estimate of how far event-time processing has progressed. A watermark isn't a guarantee: an event with an older timestamp can still arrive later. A product policy decides whether that event can update a recent window or must wait for a controlled replay.
This miniature policy accepts updates no more than 45 minutes behind the watermark. A real stream processor also defines windows, triggers, state cleanup, and monitoring.
1def late_event_action(event_time, watermark, allowed_lateness):
2 lateness = watermark - event_time
3 if lateness <= timedelta(0):
4 return "on-time path"
5 if lateness <= allowed_lateness:
6 return "accept late update"
7 return "quarantine for replay"
8
9watermark = dt("2026-05-01T12:00:00")
10allowed_lateness = timedelta(minutes=45)
11
12for event_time in [
13 dt("2026-05-01T12:05:00"),
14 dt("2026-05-01T11:30:00"),
15 dt("2026-05-01T10:00:00"),
16]:
17 print(f"{event_time:%H:%M} -> {late_event_action(event_time, watermark, allowed_lateness)}")112:05 -> on-time path
211:30 -> accept late update
310:00 -> quarantine for replayFreshness requires a serving policy:
1def serving_mode(updated_at, requested_at):
2 lag_minutes = int((requested_at - updated_at).total_seconds() / 60)
3 if lag_minutes < 0:
4 raise ValueError("feature update is from the future")
5 if lag_minutes <= 15:
6 return "normal scoring"
7 if lag_minutes <= 45:
8 return "fallback and log degraded mode"
9 return "abstain from narrow SLA estimate"
10
11requested_at = dt("2026-05-01T12:00:00")
12for updated_at in [
13 dt("2026-05-01T11:52:00"),
14 dt("2026-05-01T11:30:00"),
15 dt("2026-05-01T10:30:00"),
16]:
17 print(f"{updated_at:%H:%M} -> {serving_mode(updated_at, requested_at)}")111:52 -> normal scoring
211:30 -> fallback and log degraded mode
310:30 -> abstain from narrow SLA estimateNo model fixes a missing or temporally invalid input. Record freshness lag with each prediction trace so operators can distinguish model error from stale state.
Compare offline and online rows
Before promotion, replay a sample of requests offline and compare those rows with rows captured from online serving. Start with exact equality for categorical and integer fields; define tolerances explicitly if floating-point aggregations need them.
1def mismatches(left, right):
2 keys = left.keys() | right.keys()
3 return {key: (left.get(key), right.get(key)) for key in keys if left.get(key) != right.get(key)}
4
5correction_offline_row = {
6 "status": as_of_status(arrivals, "R-204", dt("2026-05-01T10:40:00")),
7}
8correction_online_row = {"status": online_status(online_state, "R-204")}
9correction_differences = mismatches(correction_offline_row, correction_online_row)
10print("same-time retry mismatches:", correction_differences)
11
12conflicting_revisions = [
13 {
14 "id": "E5-a",
15 "run": "R-205",
16 "event_time": dt("2026-05-01T10:30:00"),
17 "ingested_at": dt("2026-05-01T10:31:00"),
18 "source_version": 7,
19 "status": "worker_scaled",
20 },
21 {
22 "id": "E5-b",
23 "run": "R-205",
24 "event_time": dt("2026-05-01T10:30:00"),
25 "ingested_at": dt("2026-05-01T10:32:00"),
26 "source_version": 7,
27 "status": "worker_scale_failed",
28 },
29]
30conflict_online_state = {}
31for event in conflicting_revisions:
32 apply_latest_state(conflict_online_state, event)
33conflict_offline = as_of_status(conflicting_revisions, "R-205", dt("2026-05-01T10:40:00"))
34conflict_online = online_status(conflict_online_state, "R-205")
35assert conflict_offline == conflict_online == "quarantined"
36print("equal-order conflict parity:", conflict_offline, "==", conflict_online)
37
38for event in events:
39 if event["ingested_at"] <= dt("2026-05-01T16:00:00"):
40 apply_latest_state(online_state, event)
41
42offline_row = {
43 "status": as_of_status(events, "R-204", dt("2026-05-01T16:00:00")),
44 "origin_backlog": origin_backlog,
45}
46online_row = {
47 "status": online_status(online_state, "R-204"),
48 "origin_backlog": origin_backlog,
49}
50
51print("offline row:", offline_row)
52print("online row:", online_row)
53print("mismatches:", mismatches(offline_row, online_row))1same-time retry mismatches: {}
2equal-order conflict parity: quarantined == quarantined
3offline row: {'status': 'gpu_pressure', 'origin_backlog': 2}
4online row: {'status': 'gpu_pressure', 'origin_backlog': 2}
5mismatches: {}A stale online row produces a visible failure instead of a mysterious model regression.
1stale_online_row = {
2 "status": "worker_scaled",
3 "origin_backlog": origin_backlog,
4}
5differences = mismatches(offline_row, stale_online_row)
6print("mismatches:", differences)
7print("release allowed:", not differences)1mismatches: {'status': ('gpu_pressure', 'worker_scaled')}
2release allowed: FalsePublish a compact receipt beside every snapshot and candidate model. It makes replay semantics inspectable instead of leaving them hidden in job code.
1receipt = {
2 "feature_definition": "training_sla_features_v1",
3 "replay_boundary": "event_time <= prediction_time and ingested_at <= prediction_time",
4 "offline_online_ordering": "(event_time, source_version); equal-key conflicts quarantine",
5 "aggregate_updates": "atomic event-id dedup; reject negative backlog",
6 "snapshot": "training-sla-train-2026-05-01",
7 "parity_samples_checked": 2,
8 "freshness_policy": "sla-freshness-v1",
9}
10
11assert as_of_status(events, "R-204", dt("2026-05-01T10:00:00")) == "queued"
12assert as_of_status(events, "R-204", dt("2026-05-01T12:00:00")) == "warmup_spike"
13assert not correction_differences
14assert not mismatches(offline_row, online_row)
15
16for key, value in receipt.items():
17 print(f"{key}={value}")1feature_definition=training_sla_features_v1
2replay_boundary=event_time <= prediction_time and ingested_at <= prediction_time
3offline_online_ordering=(event_time, source_version); equal-key conflicts quarantine
4aggregate_updates=atomic event-id dedup; reject negative backlog
5snapshot=training-sla-train-2026-05-01
6parity_samples_checked=2
7freshness_policy=sla-freshness-v1Offline replay says gpu_pressure, while the captured online row says worker_scaled for the same entity and cutoff. Should the model release proceed?
Answer
No. The mismatch shows that replay and serving disagree on definition, ordering, or freshness. Fix that parity failure before using offline model quality as evidence for live behavior.
When the contract breaks
| Symptom | Cause | Fix |
|---|---|---|
| Training gets better after a backfill but serving doesn't | replay used facts that arrived later | filter by event time and ingestion time |
| Online status moves backward after retry or same-time corrections disagree | stale or ambiguous event overwrote latest state | compare event timestamp plus source revision; quarantine unresolved ties |
| Backlog inflates during redelivery | aggregate update ran twice | deduplicate by stable event ID in same atomic write as aggregate change |
| Reliable model emits bad SLA estimates during upstream lag | freshness wasn't part of serving policy | trace age and fail to a safer response |