Repository navigation
SavedDatasetStorage registry: custom_storage is claimed by three classes, so from_proto dispatch depends on import order #6946
Description
Problem
_StorageRegistry keys storage classes by _proto_attr_name in a plain dict:
# sdk/python/feast/saved_dataset.py
class _StorageRegistry(type):
classes_by_proto_attr_name: Dict[str, Type["SavedDatasetStorage"]] = {}
def __new__(cls, name, bases, dct):
kls = type.__new__(cls, name, bases, dct)
if dct.get("_proto_attr_name"):
cls.classes_by_proto_attr_name[dct["_proto_attr_name"]] = kls
return klsThree classes claim the same key:
| file | line | class |
|---|---|---|
contrib/couchbase_offline_store/couchbase_source.py |
318 | SavedDatasetCouchbaseStorage |
contrib/clickhouse_offline_store/clickhouse_source.py |
161 | SavedDatasetClickhouseStorage |
contrib/postgres_offline_store/postgres_source.py |
221 | SavedDatasetPostgreSQLStorage |
Since the assignment is unconditional, the last class whose module is imported wins, and SavedDatasetStorage.from_proto dispatches every custom_storage proto to that one:
@staticmethod
def from_proto(storage_proto: SavedDatasetStorageProto) -> "SavedDatasetStorage":
proto_attr_name = cast(str, storage_proto.WhichOneof("kind"))
return _StorageRegistry.classes_by_proto_attr_name[proto_attr_name].from_proto(
storage_proto
)So a saved dataset written against Postgres can be read back as a Clickhouse or Couchbase storage, depending only on which offline store modules happened to be imported first.
The other eight _proto_attr_name values are each claimed exactly once, so custom_storage is the only colliding key.
Scope of what I verified
Read from source: the three claims, the dict assignment, and the from_proto dispatch.
I could not demonstrate the overwrite end to end, because only postgres of the three has its optional dependency installable in my environment — importing the other two fails on clickhouse_connect / couchbase. With postgres alone:
imported SavedDatasetPostgreSQLStorage -> custom_storage now = SavedDatasetPostgreSQLStorage
In practice the collision is latent rather than active, since a deployment rarely installs more than one of these three. That is presumably why it has not surfaced.
Why I am raising it
DataSource.CustomSourceOptions is the only extensible slot in the SavedDatasetStorage oneof:
message SavedDatasetStorage {
oneof kind {
DataSource.FileOptions file_storage = 4;
DataSource.BigQueryOptions bigquery_storage = 5;
DataSource.RedshiftOptions redshift_storage = 6;
DataSource.SnowflakeOptions snowflake_storage = 7;
DataSource.TrinoOptions trino_storage = 8;
DataSource.SparkOptions spark_storage = 9;
DataSource.CustomSourceOptions custom_storage = 10;
DataSource.AthenaOptions athena_storage = 11;
}
}So any new CUSTOM_SOURCE-based data source that wants saved-dataset support has to either add a fourth claim to a key that already does not round-trip reliably, or add a new proto field. I ran into this adding a SavedDatasetStorage for a Lance source (#6945) and chose to defer rather than make the collision worse.
Note that DataSource itself does not have this problem: it carries data_source_class_type alongside custom_options and resolves the concrete class from that string, which is exactly the discriminator SavedDatasetStorage lacks.
Possible directions
- Give
SavedDatasetStoragethe discriminatorDataSourcealready has, resolving the class from a stored class path instead of from the oneof field name, and keep the registry for backwards compatibility. - Give each custom storage its own proto field, which does not scale but is explicit.
- Make the registry reject a duplicate claim, which would turn this into an import-time error and surface it immediately. That alone is breaking for the three existing classes, so it would need (1) or (2) first.
Happy to send a PR for whichever direction maintainers prefer. I would lean to (1) for symmetry with DataSource.
Activity
haoxu0 commentedon Oct 9, 2026
A concrete case for why this matters, measured this week rather than argued.
I have been trying to decide where "which version of a dataset is in production" gets recorded for a Lance-backed feature view. The obvious candidate was the catalog — Apache Polaris, which Lance registers through its generic-tables API. It cannot be:
That is the complete endpoint list the catalog publishes. There is no update verb —
PUT,PATCHandPOSTagainst the table resource all return 404Unable to find matching target resource method, and re-POSTing the same name returns 409. A registered generic table storesname,format,base-location,docand a free-formpropertiesmap, and once created none of it can change. So a promotion — moving a pointer from version 3 to version 4 — would mean deleting the registration and recreating it, with a window where the table does not exist.Which puts the record where it probably belonged anyway:
SavedDatasetmetadata in Feast. A saved dataset already names a source, a point in time, and the feature views it came from, which is most of what a promotion record needs.That is what runs into this issue. Writing a
SavedDatasetLanceStoragemeans claimingcustom_storage, and three classes already claim it, sofrom_protowould dispatch by import order rather than by what the proto actually describes. I am not going to add a fourth claimant to make a provenance record depend on which offline store happened to be imported first.I would still lean to option 1 from the original report — resolve the class from a stored class path the way
DataSourcealready does withdata_source_class_type, keeping the registry for backwards compatibility. Happy to send that patch if a maintainer confirms the direction; it is small, and it unblocks saved datasets for everyCUSTOM_SOURCE-based source rather than just Lance.