Build point-in-time training-run features from events and preserve the same meaning in online serving.
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.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.
Feature 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.
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_pressureSometimes analysts also need a corrected history of what truly happened by 10:00, regardless of when the source delivered it. Keep that artifact separate. Corrected history is useful for auditing training operations, but it isn't a faithful replay of the model's information set.
1decision_view = [event["status"] for event in known_by(events, "R-204", prediction_time)]
2corrected_view = [event["status"] for event in event_time_only(events, "R-204", prediction_time)]
3
4print("decision-time view:", decision_view)
5print("corrected event-time view:", corrected_view)1decision-time view: ['queued']
2corrected event-time view: ['queued', 'warmup_spike']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 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.
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-v1Run the lab, then change one condition at a time:
ingested_at <= at condition from known_by. Confirm the 10:00 row leaks warmup_spike.E4-v1-retry undoes the same-time correction online and creates a parity mismatch; then confirm E1-retry can regress state to queued.seen_event_ids. Confirm the duplicated Q1 update inflates backlog. Then remove the negative-state check and confirm Q3 publishes an impossible queue size.online_row["status"] to "worker_scaled". Confirm parity blocks release.| Evidence | What a strong answer shows |
|---|---|
| temporal correctness | constructs training rows with explicit event-time and availability boundaries |
| online state | handles older arrivals, duplicate updates, and freshness failures deliberately |
| operational replay | versions snapshots, parity receipts, and corrected analytical views separately |
| 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 |
Answer every question, then check your score. Score above 75% to mark this lesson complete.
8 questions remaining.
Questions and insights from fellow learners.