Feature Store: Point-in-Time Joins, Backfills and Skew
MediumAI-assisted coding · AI-Assisted Debugging
Feature Store: Point-in-Time Joins, Backfills and Skew
You've joined the ML platform team that owns Halcyon, the company's feature store. Data scientists write feature values into it, build training sets by joining those values onto labelled examples, and the product reads the same features online. Last week a churn model looked fantastic offline and fell apart in production; the post-mortem (ML-1840) found the training join was using data that did not exist yet at label time. Two more bugs turned up while digging, and three features are queued behind the fixes. Timestamps and feature values are integers.
Codebase
| File | What it does |
|---|---|
fstore/api.py | run(operations) entry point and the Dispatcher that routes each operation |
fstore/service.py | FeatureStore: the facade every operation goes through |
fstore/pit.py | Point-in-time resolution: which stored value a (entity, feature, time) lookup sees |
fstore/training.py | Builds training rows by resolving every label against every requested feature |
fstore/eventlog.py | EventLog: every record ever written, per (entity, feature), plus arrival order |
fstore/registry.py | FeatureRegistry: feature definitions (TTL, default, kind) |
fstore/models.py | Record and FeatureDef |
fstore/validation.py | Argument checks shared across layers (including the label grid) |
fstore/snapshots.py, online.py, skew.py | Stubs for Parts 2 and 3 (they answer error: not supported) |
fstore/errors.py, config.py | Error codes and constants |
tests/test_pit.py | Example tests (one of them currently fails) |
How your code is run
run(operations: list[list]) -> list executes the operations in order against a fresh store and returns one result per operation. Every operation is [name, ts, ...args]; ts is the integer operation clock. It never decreases, and it is also the ingestion time of anything the operation writes. Errors are returned as strings, never raised.
- Each error is
"error: <code>", e.g."error: unknown feature". An unknown operation name is"error: unknown operation". A wrong number of arguments or a non-integertsis"error: invalid arguments". Integers never includetrue/false; names (features, entities, snapshots) are non-empty, non-blank strings, case-sensitive. - When a rule below lists several errors, they are checked in the order listed.
- Operations that fail change nothing.
Records and the point-in-time rule
A stored record has event_ts (when the value was true in the world), ingested_ts (when the store learned about it), value and an arrival number seq (1, 2, 3, ... across the whole store). A feature value is an integer.
For a lookup of (entity, feature) as of time T (a training label time, or "now" when serving), a record is visible if event_ts <= T and ingested_ts <= T (both inclusive). Using anything else is *leakage*: the model would train on facts that nobody could have known at T.
A raw feature's value as of T is determined like this:
- Among the visible records pick the one with the greatest
event_ts. If several share it, the one that arrived last (greatestseq) wins. Corrections are just new records for the sameevent_ts. - If there is none, or
T - event_ts > ttl, the value is the feature'sdefault. A value is valid for exactlyttlseconds:T - event_ts == ttlis still valid.
Operations (Part 1)
| Operation | Arguments after ts | Returns | Errors (in order) |
|---|---|---|---|
register | feature, ttl, default | true | invalid arguments (ttl must be an integer > 0), feature exists |
ingest | entity, feature, event_ts, value | true | invalid arguments, unknown feature, event in future |
training_set | labels, features | list of rows | invalid arguments, unknown feature, label in future |
ingestrecordsingested_ts = ts.event_ts > tsisevent in future(event_ts == tsis fine). Oldevent_tsvalues are fine: late data is normal.labelsis a list of[entity_id, label_ts]pairs, or a label grid that expands to many labels:{"entities": [...], "first": t0, "step": dt, "count": n}means timestampst0, t0 + dt, ...(n of them,dtandnpositive), and at each timestamp every entity in the order listed. Anylabel_ts > tsislabel in future.featuresis a non-empty list of feature names (duplicates allowed); every name is checked before any label.- Each row is
[entity_id, label_ts, value_1, ..., value_k], one per label in label order, with the features' values in the order requested.
Example
[["register", 1, "avg_basket", 100, 0],
["ingest", 20, "u1", "avg_basket", 10, 40],
["ingest", 60, "u1", "avg_basket", 50, 55],
["ingest", 90, "u1", "avg_basket", 52, 99],
["training_set", 200, [["u1", 5], ["u1", 25], ["u1", 85], ["u1", 95], ["u1", 152], ["u1", 153]], ["avg_basket"]]]returns
[true, true, true, true,
[["u1", 5, 0], ["u1", 25, 40], ["u1", 85, 55], ["u1", 95, 99], ["u1", 152, 99], ["u1", 153, 0]]]The record with event time 52 arrived at 90: the label at 85 must not see it; the label at 95 must.
Part 1: Fix the training join (ML-1840, ML-1852, ML-1861)
Goal: make training_set follow the point-in-time rule above. Reports from the team:
- "Training rows contain values that were ingested after the label time." (ML-1840, the churn model)
- "A value is dropped one second too early; the docs say it lives exactly
ttlseconds." (ML-1852) - "We re-ingested a corrected value for the same
event_tsand the old one still wins." (ML-1861)
tests/test_pit.py::test_value_ingested_after_the_label_is_not_visible currently fails. The rules for ties, visibility and TTL are in the section above; fix them for every lookup, not just the example.
Part 2: Late data, backfills, snapshots and online serving (ML-1893)
Goal: data scientists need to repair history and freeze training sets. Late ingests keep their real ingestion time, so they never change the past. A backfill is a re-run of the pipeline that produces corrected history, so its records count as available from their own event time. Frozen snapshots must not move when history is later repaired, and the online path reads a separate, explicitly published store.
| Operation | Arguments after ts | Returns | Errors (in order) |
|---|---|---|---|
backfill | entity, feature, points | number of points written | invalid arguments, unknown feature, event in future |
backfill_series | entity, feature, first_ts, step, count, base | number of points written | invalid arguments, unknown feature, event in future |
materialize | name, labels, features | {"snapshot", "version", "rows", "checksum"} | same as training_set, after invalid arguments for name |
read_snapshot | name, version | the snapshot's rows | invalid arguments, unknown snapshot, unknown version |
publish | (none) | number of records published | |
serve | entity, features | list of values | invalid arguments, unknown feature |
Rules
backfill'spointsis a non-empty list of[event_ts, value]. Each point becomes a record withingested_ts = event_ts(so it is visible to any label at or after its event time, even one that was built before the backfill ran). Points are written in list order, so for equalevent_tsthe later point wins. Arrival order, notingested_ts, breaks ties (see the point-in-time rule): a backfilled correction beats the original it corrects even though itsingested_tsis earlier. The operation is atomic: any point withevent_ts > tsisevent in futureand nothing is written.backfill_seriesgeneratescountpointsfirst_ts + i * stepwith valuebase + i % 10(i = 0..count-1;stepandcountpositive integers) and then behaves exactly likebackfill.materializerunstraining_set(same labels forms, same errors) and stores the resulting rows undername. Versions are 1, 2, ... per name; materializing an existing name creates the next version.checksumis the sum of every feature value in the rows (not entity ids or label times). A stored snapshot never changes, whatever is ingested or backfilled later.read_snapshotreturns a copy of the stored rows of that version;version: nullmeans the latest. A version must benullor an integer>= 1, elseinvalid arguments; an absent name isunknown snapshot; a version that does not exist isunknown version.- The online store starts empty.
publishcopies every record written offline (ingests and backfills) since the previouspublish, keeping theirseq/ingested_ts, and returns how many.serveanswers as oftsfrom the online store only: the same point-in-time rule and TTL as offline, applied to the published records (so unpublished writes are invisible, and a feature with nothing published is the default). It returns one value per requested feature, in order. - Part 1 behaviour is unchanged. A late record with an *older* event time never displaces a newer value.
Example
[["register", 1, "avg_basket", 100, 0],
["ingest", 20, "u1", "avg_basket", 10, 40],
["materialize", 30, "train", [["u1", 15], ["u1", 25]], ["avg_basket"]],
["backfill", 40, "u1", "avg_basket", [[12, 7]]],
["materialize", 41, "train", [["u1", 15], ["u1", 25]], ["avg_basket"]],
["read_snapshot", 42, "train", 1],
["serve", 43, "u1", ["avg_basket"]],
["publish", 44],
["serve", 45, "u1", ["avg_basket"]]]returns
[true, true,
{"snapshot": "train", "version": 1, "rows": 2, "checksum": 40}, 1,
{"snapshot": "train", "version": 2, "rows": 2, "checksum": 14},
[["u1", 15, 0], ["u1", 25, 40]], [0], 2, [7]]Part 3: Rolling features and a skew check (ML-1927)
Goal: the fraud team wants derived features, rolling sums over a raw feature, available in training sets, snapshots and online serving with exactly the same point-in-time semantics as raw ones. The platform team wants a check that finds training/serving skew. This changes the Part 2 online store: serving a rolling sum needs the underlying records, not just a latest value.
| Operation | Arguments after ts | Returns | Errors (in order) |
|---|---|---|---|
register_rolling | feature, source, window, default | true | invalid arguments (window integer > 0), unknown feature (source), not a raw feature, feature exists |
skew_check | entities, features | {"checked", "mismatches"} | invalid arguments, unknown feature |
Rules
- A rolling feature's value for an entity as of
Tis the sum ofvalueover the source feature's records withT - window < event_ts <= Tthat are visible atT(rule above), where for eachevent_tsonly one record counts: the latest-arriving visible one. If no record counts, the value is the feature'sdefault(a counted record whose value is0makes the sum0, not the default). TTL does not apply to rolling features; the window does that job. - Rolling features work anywhere a feature name is accepted (
training_set,materialize,serve,skew_check) with the same visibility and tie rules as raw features, including backfills and late ingests. Online, they are computed from the published records only. A rolling feature cannot be ingested or backfilled (not a raw feature, in the position listed for those operations afterunknown feature), andsourcemust be a raw feature. skew_checkcompares, for every distinct(entity, feature)pair inentitiesxfeatures, whatservereturns attswith what an offlinetraining_setrow for the label(entity, ts)would contain. It returns{"checked": <distinct pairs>, "mismatches": [[entity, feature, online, offline], ...]}listing only differing pairs, sorted by entity then feature name (plain string order). Both lists must be non-empty lists of names.- Earlier Parts' behaviour must not change.
- Hidden tests build tens of thousands of events per entity with
backfill_seriesand materialize thousands of labels at once (materializeover a label grid with raw and rolling features). The whole suite has a few seconds.
Example
[["register", 1, "clicks", 1000, 0],
["register_rolling", 2, "clicks_10", "clicks", 10, -1],
["ingest", 11, "u1", "clicks", 10, 5],
["ingest", 16, "u1", "clicks", 12, 3],
["ingest", 21, "u1", "clicks", 12, 4],
["ingest", 22, "u1", "clicks", 20, 1],
["publish", 23],
["ingest", 24, "u1", "clicks", 23, 9],
["skew_check", 25, ["u1", "u2"], ["clicks_10", "clicks"]],
["serve", 26, "u1", ["clicks_10"]],
["training_set", 40, [["u1", 15], ["u1", 19], ["u1", 21], ["u1", 22], ["u1", 32]], ["clicks_10", "clicks"]]]returns
[true, true, true, true, true, true, 4, true,
{"checked": 4, "mismatches": [["u1", "clicks", 1, 9], ["u1", "clicks_10", 1, 10]]},
[1],
[["u1", 15, 5, 5], ["u1", 19, 8, 3], ["u1", 21, 4, 4], ["u1", 22, 1, 1], ["u1", 32, 9, 9]]]At label 22 the window is (12, 22], so event 12 is out; at label 21 the correction (arrived at 21) replaces the original 3 rather than adding to it.
Notes
pit.pyis the single source of truth for what a lookup sees; callers should not re-implement it.- You may use the AI assistant freely; you are expected to verify what it produces.
- Later parts must not break earlier ones; hidden tests re-check earlier behaviour.
Example 1
- Input:
- [["register",1,"avg_basket",100,0],["ingest",20,"u1","avg_basket",10,40],["ingest",60,"u1","avg_basket",50,55],["ingest",90,"u1","avg_basket",52,99],["training_set",200,[["u1",5],["u1",25],["u1",85],["u1",95],["u1",152],["u1",153]],["avg_basket"]]]
- Output:
- [true,true,true,true,[["u1",5,0],["u1",25,40],["u1",85,55],["u1",95,99],["u1",152,99],["u1",153,0]]]
- Explanation
- The value with event time 52 only arrived at 90, so a label at 85 must not see it but a label at 95 must. Values live for 100 seconds: the value from event time 52 is still valid at 152 and expired at 153.
Example 2
- Input:
- [["register",1,"avg_basket",100,0],["ingest",20,"u1","avg_basket",10,40],["materialize",30,"train",[["u1",15],["u1",25]],["avg_basket"]],["backfill",40,"u1","avg_basket",[[12,7]]],["materialize",41,"train",[["u1",15],["u1",25]],["avg_basket"]],["read_snapshot",42,"train",1]]
- Output:
- [true,true,{"snapshot":"train","version":1,"rows":2,"checksum":40},1,{"snapshot":"train","version":2,"rows":2,"checksum":14},[["u1",15,0],["u1",25,40]]]
- Explanation
- The backfilled value (event time 12) changes what a new snapshot sees for the label at 15, but version 1 of the snapshot is frozen.
Guidelines
- Python 3 standard library only
- Timestamps and feature values are integers; there is no floating point anywhere
- Operation timestamps never decrease from one operation to the next
- Every operation returns exactly one value; errors are returned as "error: <code>" strings, never raised
- Part 3 inputs hold tens of thousands of events per entity and thousands of labels