Repository navigation
Entity-less get_historical_features defaults end_date to the host's local time, read as UTC #7036
Description
Expected Behavior
With entity_df=None and no end_date, the window ends at the current time (docs, feature-retrieval.md: "If end_date is omitted, it defaults to the current time"), so the result matches passing end_date=datetime.now(timezone.utc).
Current Behavior
The default end bound is the host's naive local wall-clock time. Offline stores read naive datetimes as UTC, so the window shifts by the host's UTC offset: west of UTC the newest rows are missing, east of UTC rows stamped in the future are returned.
Dask offline store, one feature view with TTL 1 day. Source rows: driver 1 at now−5h (d1_old) and now−1h (d1_recent); driver 2 at now−5h (d2_old) and now+1h (d2_future). Call: store.get_historical_features(entity_df=None, features=["driver_stats:label"]), run under three host time zones.
| TZ | end_date=datetime.now(timezone.utc) |
end_date omitted |
|---|---|---|
| UTC | (1, d1_recent), (2, d2_old) | (1, d1_recent), (2, d2_old) |
| America/Sao_Paulo (UTC−3) | (1, d1_recent), (2, d2_old) | (1, d1_old), (2, d2_old) |
| Asia/Tokyo (UTC+9) | (1, d1_recent), (2, d2_old) | (1, d1_recent), (2, d2_future) |
Passing end_date=datetime.now() explicitly returns the same wrong rows as the default. Same result on 0.67.0 and on current master.
Steps to reproduce
feature_repo/feature_store.yaml:
project: end_date_default
registry: data/registry.db
provider: local
online_store:
type: sqlite
path: data/online.db
offline_store:
type: dask
entity_key_serialization_version: 3feature_repo/features.py:
from datetime import timedelta
from feast import Entity, FeatureView, Field, FileSource
from feast.types import Int64, String
driver = Entity(name="driver", join_keys=["driver_id"])
source = FileSource(path="data/driver_stats.parquet", timestamp_field="event_timestamp")
driver_stats = FeatureView(
name="driver_stats",
entities=[driver],
ttl=timedelta(days=1),
schema=[Field(name="driver_id", dtype=Int64), Field(name="label", dtype=String)],
source=source,
)probe.py (next to feature_repo/):
import sys
from datetime import datetime, timedelta, timezone
import pandas as pd
from feast import FeatureStore
sys.path.insert(0, "feature_repo")
from features import driver, driver_stats
now = datetime.now(timezone.utc).replace(microsecond=0)
pd.DataFrame({
"driver_id": [1, 1, 2, 2],
"label": ["d1_old", "d1_recent", "d2_old", "d2_future"],
"event_timestamp": [now - timedelta(hours=5), now - timedelta(hours=1),
now - timedelta(hours=5), now + timedelta(hours=1)],
}).to_parquet("feature_repo/data/driver_stats.parquet")
store = FeatureStore(repo_path="feature_repo")
store.apply([driver, driver_stats])
for label, kwargs in [("explicit utc now", {"end_date": now}), ("end_date omitted", {})]:
df = store.get_historical_features(entity_df=None, features=["driver_stats:label"], **kwargs).to_df()
print(label, sorted(zip(df["driver_id"], df["label"])))mkdir -p feature_repo/data
TZ=UTC python probe.py
TZ=America/Sao_Paulo python probe.py
TZ=Asia/Tokyo python probe.pySpecifications
- Version: 0.67.0; same on current master
- Platform: macOS arm64, Python 3.11, pandas 2.3.3, pyarrow 26.0.0, dask 2026.8.0
- Subsystem: Python SDK,
FeatureStore.get_historical_featureswithoutentity_df, Dask offline store
Possible Solution
get_historical_features in sdk/python/feast/feature_store.py sets end_date = datetime.now() when entity_df is None and no end_date is given, and compute_non_entity_date_range then treats that naive value as UTC. Using utils._utc_now() there, as #6956 did for materialize and the feature server's /materialize, gives the expected rows in all three zones (checked locally with a unit test). I searched issues and PRs for the symptom and the mechanism and found only #6936/#6956, which did not touch this call site. Happy to open a PR.
Activity