[FSTORE-2116] Batch inference and training data anchored on a caller-supplied spine_df - #653
Open
jimdowling wants to merge 18 commits into
Open
jimdowling wants to merge 18 commits into
jimdowling wants to merge 18 commits into
Conversation
…atch_data(entries, prediction_times) https://hopsworks.atlassian.net/browse/FSTORE-2116 A feature view is anchored on its root feature group, so get_batch_data(start_time, end_time) can only return rows for times that feature group has already observed. Inference over the future has no such rows: a view joining air quality observations to weather observations and forecasts returns nothing for tomorrow, even though the weather feature group holds the forecast. Users work around it by reading the forecast feature group directly, which bypasses the view's joins, its feature selection and its transformations. Batch inference now accepts the rows to predict for instead of deriving them from the root feature group. get_batch_data(entries, prediction_times) takes a dataframe of entities and a set of future timestamps, forms their cross product as an inference spine, and anchors the query on that spine. Every other feature group is looked up ASOF each prediction time, so a forecast row dated in the future is returned when it is the most recent row at or before that time. The client contributes the rows and the backend authors the SQL, which DuckDB renders as an ASOF LEFT JOIN and Spark as a ranked window. Offline feature groups only; online serving is unchanged. The guide explains why a time range returns nothing for the future, then covers the three ways to build a set of prediction times, what `entries` may carry, how an as-of lookup resolves and what it returns when it matches nothing, and why `max_feature_age` is what makes a stale carried-forward value visible. It states the parts that surprise people: the returned event time is the prediction time asked for rather than the event time of the matched row, keys and event time default to included for this call where they default to excluded for the others, and ambiguous key columns come back fully qualified. Limits, engine coverage and the refused query shapes are listed rather than left to be discovered. Signed-off-by: Jim Dowling <jim@logicalclocks.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F22onm6M6KvU686d5imQw5
jimdowling
marked this pull request as ready for review
September 14, 2026 06:51
…atch_data(entries, prediction_times) https://hopsworks.atlassian.net/browse/FSTORE-2116 Rename the new batch-inference parameter from entries to serving_keys. The feature store already calls these values the view's serving keys, and the intent is for serving_keys to be the name across the API rather than entry or entries. The parameter is new and unreleased, so this is a plain rename with no alias and nothing to deprecate. The frame may still carry columns that are not serving keys: any column of the root feature group is taken as supplied instead of being looked up, which is the batch analogue of a passed feature. The documentation and the parameter's own docstring say so, so the narrower name does not hide it. The user guide names the parameter in prose, in every example and in the limits table, so all of them follow the client. The section on what the frame may carry already said that a root feature group column is taken as supplied, which is what keeps the narrower name honest. Signed-off-by: Jim Dowling <jim@logicalclocks.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F22onm6M6KvU686d5imQw5
…atch_data(serving_keys, prediction_times) https://hopsworks.atlassian.net/browse/FSTORE-2116 Accept serving_keys on the online retrieval methods too, and keep entry working as a deprecated alias. The values that identify which rows to serve are called serving keys everywhere else in the product, including the feature view's own serving_keys property and the batch inference parameter this branch adds, so the online methods were the last place naming them something different. Filed as FSTORE-2118 and folded in here rather than shipped separately, so the API lands consistent in one release instead of disagreeing with itself for one. Every feature vector example moves to the new name so readers learn the preferred spelling, and a note records that entry still works, warns, and will be removed. The agent deployment guide keeps its own unrelated entry argument. Signed-off-by: Jim Dowling <jim@logicalclocks.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F22onm6M6KvU686d5imQw5
…atch_data(serving_keys, prediction_times) https://hopsworks.atlassian.net/browse/FSTORE-2116 Let the spine carry columns the feature view does not define, so the same mechanism that anchors batch inference on caller-supplied rows can anchor training data on a labels dataframe. A spine group already allows this, but only for a view created with one as its left side; the inference spine is the late-bound form, available to any view at read time. The guide gains a section on building training data from the same rows, covering the two differences from a batch read, the relationship to spine groups, and the note that a materialized dataset built this way cannot be regenerated from its metadata. Signed-off-by: Jim Dowling <jim@logicalclocks.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F22onm6M6KvU686d5imQw5
…atch_data(spine_df, prediction_times) https://hopsworks.atlassian.net/browse/FSTORE-2116 Rename the dataframe parameter from serving_keys to spine_df, on get_batch_data and on all six training-data methods. The frame is a spine: it replaces the left side of the query and may carry passed features and labels as well as keys, so naming it for the keys described only part of what it holds. The methods whose argument is a dict of keys keep serving_keys, because there the name is exact. get_feature_vector and get_feature_vectors take a dict and a list of dicts, and the two inference helper methods take the same, so none of them change. Follows the client. Signed-off-by: Jim Dowling <jim@logicalclocks.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F22onm6M6KvU686d5imQw5
…atch_data(spine_df, prediction_times) https://hopsworks.atlassian.net/browse/FSTORE-2116 Leave spine_df as the only spine-related argument of get_batch_data. Two parameters came off it, both because the API was saying the same thing in more than one way. prediction_times is gone, losslessly: the times were always expressible as a column of the frame, and the training-data methods already required them there, so the batch path was the odd one out. PredictionTimes remains, as the thing that builds a schedule from a cron expression, a named interval or a list, and it gains cross(). That matters, because the row order is a documented contract, entities as given and ascending in time within each; leaving callers to hand-roll the cross product would have made that contract theirs to get right. max_feature_age moved onto the feature view. It could not follow the times into the frame: it bounds how stale a lookup may be per feature group, which is a property of the lookup and not of a row. Deleting it would have removed the only defence against an as-of join carrying a stale value forward silently. As a property of the view it now applies to every read anchored on a spine, batch inference and training data alike, which is what it should always have done. The guide loses the cross-product parameter, gains cross() and the row order it guarantees, and says that the bound is a property of the view that applies to training reads too, with the reason: a training example built from a stale feature is worse than an inference row built from one, because the model learns from it. Signed-off-by: Jim Dowling <jim@logicalclocks.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F22onm6M6KvU686d5imQw5
…supplied spine_df https://hopsworks.atlassian.net/browse/FSTORE-2116 Fix max_feature_age at feature view creation and make it read-only after. Allowing it per call let a training set and an inference read be built with different bounds, which is training/serving skew of exactly the kind a feature view exists to prevent. A read-only setting is only honest if it survives a round trip, so it is persisted with the view rather than living on the object. The guide and the batch inference skill set the bound at creation and say why it cannot be changed later. The skill also gains the spine_df sections: a schedule crossed with entities for future prediction times, one row per entity at a single instant for latest values, the staleness bound, training data from the same rows, and what is refused. Signed-off-by: Jim Dowling <jim@logicalclocks.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F22onm6M6KvU686d5imQw5
…supplied spine_df https://hopsworks.atlassian.net/browse/FSTORE-2116 Document the feature-age bound as one value per feature view. It is a timedelta at creation and reads back as one from feature_view.max_feature_age, so the guide no longer describes a map keyed by feature group, a catch-all key, or the error for a name that matches no feature group. Signed-off-by: Jim Dowling <jim@logicalclocks.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F22onm6M6KvU686d5imQw5
…supplied spine_df https://hopsworks.atlassian.net/browse/FSTORE-2116 Document reading the entity set off the feature view's root feature group with get_root_fg().read_primary_keys(), as the alternative to maintaining a list of entity ids by hand. Say plainly that the result is not a spine_df on its own, since it carries no time and is refused, and what it costs: the whole feature group's key columns are read to take the distinct rows, and only the root's keys are covered. Signed-off-by: Jim Dowling <jim@logicalclocks.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F22onm6M6KvU686d5imQw5
…supplied spine_df https://hopsworks.atlassian.net/browse/FSTORE-2116 Say which frames spine_df accepts, now that a Spark DataFrame is one of them under the Spark engine, and what that costs: the rows are collected to the driver, and a Spark DataFrame has no row order, so the positional zip-back does not apply to one. Signed-off-by: Jim Dowling <jim@logicalclocks.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F22onm6M6KvU686d5imQw5
…supplied spine_df https://hopsworks.atlassian.net/browse/FSTORE-2116 Apply a review of this change to itself. Thirteen findings, the two that matter being an unvalidated column name reaching SQL and a staleness bound the backend took from the request rather than from the feature view it is stored on. The spine's column names are now held to an identifier grammar in InferenceSpineResolver, alongside the table name, the Parquet basename and the types that were already checked there. A passthrough column has no feature in the store behind it, so its name arrived exactly as the caller wrote it and was rendered into a backtick identifier by both SQL renderings. Only flyingduck's own check stood between that and a scalar subquery on the signed path, which is sanitisation in the service that trusts the backend rather than in the one that builds the statement. max_feature_age is no longer sent with the read. The spine names its feature view instead, by featurestore id, name and version, and the resolver reads the bound off that view's row; the featurestore is resolved through the caller's project, shared ones included, so naming a view the caller cannot reach is refused with the new code 332. The column exists to stop a training set and the inference reads scored against it being held to different limits, and a bound the request carries is a bound the caller picked, so the guarantee only held for callers who used the SDK. It now holds however the request was built. The sweeper globs for spine files once across the cluster rather than issuing an exists and a listStatus per project every hour, which on a cluster of thousands of projects was thousands of NameNode round trips to find nothing. DistributedFileSystemOps gains globStatus for it. Two annotations in the client were wrong about their own behaviour. get_root_fg returned FeatureGroup while its docstring promised an ExternalFeatureGroup or a SpineGroup for views built on those, which are siblings under FeatureGroupBase, so the documented cases raised under HOPSWORKS_RUN_WITH_TYPECHECK. max_feature_age still advertised the withdrawn per-feature-group map, which always raised; it now advertises what it accepts and a dict is refused with a message naming the replacement. recreate_training_dataset takes spine_df, which its own parameter docs already told callers to use. Without it a training dataset built from a spine was silently rebuilt anchored on the root feature group, since the frame is not recorded with the dataset; the docstring now says so. The row and column ceilings are checked client-side before the frame is typed, written and uploaded, and on the product of entities and prediction times before cross builds it at all. They mirror the shipped defaults and the backend stays authoritative. Smaller: the passthrough dtype map covers the pandas nullable extension types, which is what a label column with missing values is; the bindable column check no longer counts passthrough columns, which let a frame of an event time and a label past a check meant to catch exactly that; the misspelled SplineDataFrameTypes alias becomes DeprecatedSpineTypes, one character from SpineDataFrameTypes being too close to read; a duplicated polars class leaves an isinstance tuple; PASSTHROUGH_TYPES becomes the set it always was. Both CI jobs on the client PR were already failing and are fixed here: ruff format on two files, and the PEP-8 check on InferenceSpine.arrow_table and write_parquet, public-named methods that are internal plumbing and are now _arrow_table and _write_parquet. The user guide and the batch inference skill record what changed for a reader: the bound is applied from the stored value, the client refuses an oversized frame before uploading it, and the limits are per request with no per-user or per-project concurrency cap. Signed-off-by: Jim Dowling <jim@logicalclocks.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YMTv9mFtW88Cy2rcf6Gvzm
…supplied spine_df https://hopsworks.atlassian.net/browse/FSTORE-2116 Record what changed after an external review: a training dataset built from spine_df needs the frame again and refuses to run without it; the prediction time is written in the root's event time type and joined groups may differ; a lookback composes with a spine; a filter on a joined feature restricts its candidates and one spanning two groups is refused; max_feature_age applies to spine-anchored reads only; and what bounds Spark's candidate cardinality. Signed-off-by: Jim Dowling <jim@logicalclocks.com> Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YMTv9mFtW88Cy2rcf6Gvzm
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
https://hopsworks.atlassian.net/browse/FSTORE-2116
get_batch_data(spine_df)returns batch inference data for timestamps that have nothappened yet, by anchoring the feature view's query on the entities and prediction times
you supply instead of on rows the root feature group has already observed. This documents it.
What this repo contributes
A new user guide,
Batch data for future timestamps, and a pointer to it from theexisting batch data page.
The guide explains why a time range returns nothing for the future, then covers the
three ways to build a set of prediction times, what
serving_keysmay carry, how an as-oflookup resolves and what it returns when it matches nothing, and why
max_feature_ageis what makes a stale carried-forward value visible.
It states the parts that surprise people rather than leaving them to be discovered: the
returned event time is the prediction time asked for and not the event time of the
matched row, keys and event time default to included for this call where they default
to excluded for every other
get_batch_data, and ambiguous key columns come back fullyqualified. Limits, engine coverage and the refused query shapes are listed.
Verification
hopsworks-docs check(strict build),markdownlintandsnakeoilall pass on a cleantree. The page is registered in
nav:and the cross-link resolves by heading id.Loadtest regression, current build
Run
bfe7550eagainstjim-asof-inferencewith the out-of-cluster runner, the modifiedclient driving the backend and query service built from this branch and its siblings. 17
files across both drivers:
test_batch,test_batch_extra_filter,test_batch_event_time_excluded,test_pit_join_filter_consistency,test_pit_lookback_window,test_pit_join_pushdown,test_training_data,test_feature_group_read, and the new conformance suite.50 passed, 2 failed, 1 skipped, 1 h 23 m.
Both failures are understood and neither is a regression.
test_batch_event_time_excludedfails withValues list "fg0" does not have a column named "trans_freq", the same error and the same pre-existing failure proven earlier byrebuilding the EAR with this change removed and redeploying.
test_asof_batch_inferenceon the pyspark driver passes. The job runs the clientbaked into the Spark image, which is still the released one, so the run points it at a
wheel built from this branch (
LOADTEST_CLIENT_WHEEL): the driver puts the wheel firston
sys.path, where zipimport shadows the installed client without rebuilding the image.Unset, the case stays xfail. The test asserts the line the suite prints on reaching its
end and that the driver picked the wheel up, so a job that exits 0 having skipped the
suite cannot read as a pass.
Not covered by this run: the
e2e_scalemarker, the data source tests (CONNECTORSwasnot set), and every workflow outside the feature store.
Also here:
entrydeprecated in favour ofserving_keysFiled as FSTORE-2118 and folded into this branch rather than shipped separately, so
the API lands consistent in one release instead of disagreeing with itself for one.
That ticket is closed as duplicated.
get_feature_vector,get_feature_vectors,get_inference_helperandget_inference_helpersnow acceptserving_keys. All four take the same dictionary,so leaving the inference helpers behind would have replaced one inconsistency with
another.
serving_keystakes the positionentryheld andentrymoves to the end of thesignature, so positional callers are unaffected. Passing
entryemits aDeprecationWarningnaming the replacement. Passing both is refused: they are oneargument, so two values are a caller bug rather than a preference to resolve quietly.
The resolver is a private static method rather than a module-level function, which is
not a style preference. A module-level definition in
feature_view.pydisplaces theFeatureViewclass on its generated API reference page, which breaks everycross-reference to a
FeatureViewmember and fails the documentation build in strictmode. That is how it was found.
Out of scope and left alone:
TrainingDataset.get_serving_vectorandget_serving_vectors, the legacy serving path superseded by feature views, and theinternal vector server plumbing, which is not public.
Also here: training data from the same rows
training_data,train_test_split,train_validation_test_splitand the threecreate_*methods now takespine_dfas well. The observation that prompted it:anchoring a query on a caller-supplied dataframe is what a spine group does, and the
inference spine is the late-bound form of that, so a feature view no longer has to have
been created with a spine group to build training data from a labels frame.
Two differences from a batch read. A training row is one entity at one moment rather
than an entity scored repeatedly, so a schedule is rarely what builds the frame. And
columns the view does not define are carried through to
the output untouched, which is how the label rides along; a batch read stays strict,
because inference has no labels and an unrecognised column there is worth catching.
spine_dfandspineboth replace the left side of the query, so passing both isrefused. An event-time window or a lookback is suppressed under a spine, because the
spine is the population and a window on top of it would drop rows the caller asked for.
A materialisation keeps its staged spine file, because its Spark job reads it after the
call returns. A sweeper reclaims those, and the ones a read failed to delete. Before
this there was no sweeper, so the warning the client logged on a failed delete described
one that did not exist.
Two defects the cluster found
Adding the training case to the conformance suite surfaced two bugs, neither reachable
from the cases that existed before.
spine's bindings rather than from a
Join, so the walk that collects needed featuresnever saw them, and DuckDB reported
Values list does not have a column named <key>.It stayed hidden while the root feature group was always pruned. A training read keeps
the root, and the same shape was reachable from
get_batch_datawith a kept root whosekeys are not selected, so this was a latent bug in the batch path too.
were computed separately and a passthrough column fell back to the view's schema, which
does not define it, so the file was written as a string. One function decides both now.
Naming
The dataframe parameter is
spine_df, onget_batch_dataand on all six training-datamethods. The frame is a spine: it replaces the left side of the query and may carry passed
features and labels as well as keys, so naming it for the keys described only part of it.
The methods whose argument is a dict of keys keep
serving_keys, where that name is exact:get_feature_vector,get_feature_vectorsand the two inference helper methods. The featureview's own
serving_keysproperty and the feature logging argument of the same name areunrelated and unchanged.
API consolidation
spine_dfis the only spine-related argument ofget_batch_data. Two parameters cameoff it, both because the API was saying the same thing in more than one way.
prediction_timesis gone, losslessly. The times were always expressible as a columnof the frame, and the training-data methods already required them there, so the batch path
was the odd one out.
PredictionTimesremains as the thing that builds a schedule from acron expression, a named interval or a list, and it gains
cross(). That matters: the roworder is a documented contract, entities as given and ascending in time within each, and
leaving callers to hand-roll the cross product would have made that contract theirs.
max_feature_agemoved onto the feature view. It could not follow the times into theframe, because it bounds how stale a lookup may be, which is a property of the lookup and
not of a row. Deleting it would have removed the only defence against an as-of join
carrying a stale value forward silently. As a property of the view it applies to every read
anchored on a spine, batch inference and training data alike.
It is written once at creation and is read-only afterwards, because an object-only bound is
lost the moment the view is fetched again: a training run and the inference pipeline that
scores against it are separate processes, and each would have had to remember to set the
same value. That is the training/serving skew the bound exists to prevent.
fv.max_feature_agereads it back as atimedelta, orNonewhen the view is unbounded.It is one value for the whole view rather than a bound per feature group. A per-group map
needs a
VARCHARcolumn holding JSON, a name to validate against the view's feature groups,and an error code for the name that matches nothing; a single bound needs an
INTofseconds and no validation beyond positive, and no pipeline asked for the extra resolution.
The column is
feature_view.max_feature_age_secs INT NULL, added by migration V100 andnullable, so an existing view keeps the unbounded behaviour it has today. The spine carries
maxFeatureAgeSecs, and neither the query service nor the chart needed a change.The conformance suite proves the bound reaches training data, not just batch: unbounded,
the gap day is filled by carry-forward; read through a view created with the bound, exactly
that day comes back NULL and the caller's label is untouched.
It also proves the bound survives a round trip, which the rest of the suite cannot. Every
other bounded case reads through the object that created the view, and that object carries
the bound in memory, so all of them would pass unchanged if the column were never written.
The round-trip case fetches each bounded view again, checks it is a different object,
checks the bound came back, and reads through the fetched view to confirm the value that
came back is the one the read is held to.
Verified on
jim-asof-inferenceafter applying V100 and redeploying: the suite passes, andthe rows behind it read 43200 and 86400 seconds against the two bounded views with NULL
against the unbounded one.
Third round: what the guide gained
consequences a reader needs. It is refused under the Python engine, which has no session to
evaluate it, and it has no row order, so the positional zip-back the page shows does not
apply to one. Join predictions back on the serving keys instead.
built from
feature_view.get_root_fg()andfeature_group.read_primary_keys()rather thanfrom a list the reader maintains. The page states what it costs, the key columns of the
whole feature group, and what it does not cover, a joined feature group keyed on something
the root does not carry.
max_feature_ageis now a singletimedeltafor the whole view, read-only after creationand read back with
feature_view.max_feature_age. The per-feature-group map, its"*"catch-all and the unknown-name error are gone from the page, including from the
training-data example.
Review round: thirteen findings, applied
A review of this change against itself, after it was otherwise finished. The two that mattered
are both in other repos and are described in full on logicalclocks/hopsworks-ee#3339 and
logicalclocks/hopsworks-api#1167: a spine column name reached SQL unvalidated, and the staleness
bound was taken from the request rather than from the feature view it is stored on. Nothing in
this repo changed for either.
One consequence is worth knowing here. The spine's wire form no longer carries
maxFeatureAgeSecs; it names the feature view it anchors, by featurestore id, name and version,and the backend reads the bound from that view's row. A client that sends the old shape is
refused with the new code 332.
The limits also remain per request: there is no cap on how many spine reads one user or project
may have in flight, so on a shared cluster a single caller can occupy the query service with
repeated large reads. Admission control is not in this change.
Verification after the round
Backend rebuilt and redeployed on
jim-asof-inference, and the conformance suite run on bothdrivers against it: 2 passed, 5m35s. That run is itself the check that the bound is now read
server-side, because a backend still expecting the bound on the wire would find none and answer
the bounded cases unbounded, which those cases assert against.
External review round: eleven findings, applied
An external review (Codex) of the finished change found eleven defects, six of them release
blockers, and four wider gaps. All eleven are fixed on the branches; none was answered in a
comment. The two that would have shipped wrong data:
spine_dfat two boundaries._create_training_datasetaccepted the frame and did not pass it on, and the Spark job that materialises a split or an
external-sink dataset rebuilds its query from the feature view, so the spine could not reach
it at all. Every
create_*call withspine_dfbuilt the ordinary historical population whilebatch inference used the caller's rows: a training/inference population mismatch, silent. The
spine now travels in the job configuration, validated against the view's query by the same
resolver the read path uses (file existence and READ permission included), and the job reads
the staged Parquet back and anchors on it. A file that is gone fails the job rather than
building another population. Training datasets record
spine_anchored(V102), so a versionbuilt from a spine cannot later be read or recreated from the view's own rows.
existsandgetFileStatussucceed with traversepermission on the directories alone, and the signed query-service read then ran as the
superuser. The resolver now calls Hadoop's
access(path, READ)as the requester beforesigning.
The other nine: a root filter naming a joined feature rendered inside the root's subquery and
failed to bind on both engines (predicates are now routed to the feature group they reference;
one spanning two groups is refused); event times of different types compared raw, and an integer
prediction time was parsed as nanoseconds (the spine is written in the root's event time type,
integers are epoch milliseconds, and a lookup of a different type is converted with the engine's
own function); tie-break primary keys and the event time were missing from the physical
projection; an explicit lookback was silently dropped with a spine (it now composes as a candidate
filter); Spark frames were collected before the row ceiling applied; the query service
materialised the whole spine file before checking its row count (footer first, load bounded);
Spark inferred the spine schema and refused an all-NULL key (it now registers the typed Arrow
table the Python path writes); a passthrough column could shadow a joined feature's output name;
and a dropped lookup's array or struct feature came back as an untyped NULL (each engine's own
type spelling is rendered).
The full list with dispositions is in the workspace's
FSTORE-2116_review.md.Verification after the round
Cluster
jim-asof-inference. Backend rebuilt and redeployed from the branch, with V102 appliedand recorded in
flyway_schema_history; the query service rolled to a rebuilt image carrying theloader change. Conformance suite, both drivers, with a wheel built from the branch:
pyspark driver 1 passed (the branch wheel on the job's
sys.path), python driver 1 passed(4m43s). The python driver's job case needs the branch entrypoint and client inside the Spark
image, which still carries the released ones, so for the run
fs_py_job_utilwas pointed at aHopsFS copy of
hsfs_utils.pythat puts the branch wheel first onsys.path. A scriptedreproduction of that case showed the job configuration carrying
spine, the job running the branchclient, a six-row frame twelve hours off every root row producing 3 + 3 rows, and the dataset
recorded as spine-anchored.
Suites: hopsworks-ee query package and Arrow Flight controller tests pass, checkstyle clean;
hopsworks-api 4985 passed, 13 skipped, plus the fifteen WINDOWED golden shapes bound against a local Spark
4.1.1; flyingduck 32 spine tests and the fifteen ASOF shapes bound against DuckDB 1.5.2; docs
strict build, markdownlint and snakeoil clean.
Not covered: the job path on a cluster whose Spark image predates this change now fails at
argument parsing instead of building the wrong population, but that refusal itself was not run on
a cluster (it needs an image with the old entrypoint and a backend with the new op).
In this repo
The user guide records that a training dataset built from
spine_dfneeds the frame again andrefuses to run without it; that the prediction time is written in the root's event time type and
joined groups may differ; that a lookback composes with a spine; that a filter on a joined feature
restricts its candidates and one spanning two groups is refused; that
max_feature_ageapplies tospine-anchored reads only; and what bounds Spark's candidate cardinality.
Related pull requests
Merge in this order. The chart is the only writer of
hopsworks.variables, sothe backend reads its enum defaults until the chart lands.
🤖 Generated with Claude Code
https://claude.ai/code/session_01F22onm6M6KvU686d5imQw5