Feast Integrator
Channel | Revision | Published | Runs on |
---|---|---|---|
latest/edge | 108 | 13 Aug 2025 | |
0.49/stable | 93 | 18 Jun 2025 | |
0.49/edge | 95 | 18 Jun 2025 |
juju deploy feast-integrator --channel edge
Deploy universal operators easily with Juju, the Universal Operator Lifecycle Manager.
Platform:
24.04
-
- Last updated
- Revision Library version 0.1
"""Library for sharing Feast store configuration information.
This library offers a Python API for providing and requesting information about
Feast feature store configuration.
The default relation name is `feast-configuration` and it's recommended to use that name,
though if changed, you must ensure to pass the correct name when instantiating the
provider and requirer classes, as well as in `metadata.yaml`.
## Getting Started
### Fetching the library with charmcraft
Using charmcraft you can:
```shell
charmcraft fetch-lib charms.feast_integrator.v0.feast_store_configuration
```
## Using the library as requirer
### Add relation to metadata.yaml
```yaml
requires:
feast-configuration:
interface: feast-configuration
limit: 1
```
### Instantiate the class in charm.py
```python
from ops.charm import CharmBase
from charms.feast_integrator.v0.feast_store_configuration import (
FeastStoreConfigurationRequirer,
FeastStoreConfigurationRelationError
)
class RequirerCharm(CharmBase):
def __init__(self, *args):
self.feast_configuration_requirer = FeastStoreConfigurationRequirer(self)
self.framework.observe(self.on.some_event_emitted, self.some_event_function)
def some_event_function():
# use the getter function wherever the info is needed
try:
feast_configuration_yaml = self.feast_configuration_requirer.get_feature_store_yaml()
except FeastStoreConfigurationRelationError as error:
"your error handler goes here"
except FeastStoreConfigurationDataInvalidError as error:
"your error handler goes here"
```
## Using the library as provider
### Add relation to metadata.yaml
```yaml
provides:
feast-configuration:
interface: feast-configuration
limit: 1
```
### Instantiate the class in charm.py
```python
from ops.charm import CharmBase
from charms.feast_integrator.v0.feast_store_configuration import (
FeastStoreConfigurationProvider,
FeastStoreConfigurationRelationMissingError
)
class ProviderCharm(CharmBase):
def __init__(self, *args, **kwargs):
...
self.feast_configuration_provider = FeastStoreConfigurationProvider(self)
self.observe(self.on.some_event, self._some_event_handler)
def _some_event_handler(self, ...):
# Create the FeastStoreConfiguration object
try:
store_config = FeastStoreConfiguration(
registry_user="my_user",
registry_password="pass",
registry_host="host",
registry_port=5432,
registry_database="reg_db",
offline_store_host="offline_host",
offline_store_port=3306,
offline_store_database="offline_db",
offline_store_user="off_user",
offline_store_password="off_pass",
online_store_host="online_host",
online_store_port=6379,
online_store_database="online_db",
online_store_user="on_user",
online_store_password="on_pass"
)
except FeastStoreConfigurationDataInvalidError as e:
"your error handler goes here"
try:
self.feast_configuration_provider.send_data(store_config)
except FeastStoreConfigurationRelationMissingError as error:
"your error handler goes here"
```
## Relation data
The data shared by this library is defined by the FeastStoreConfiguration dataclass.
The attributes of this dataclass are shared in the relation data bag as a dictionary.
"""
# The unique Charmhub library identifier, never change it
import logging
from dataclasses import asdict, dataclass
from typing import Dict, Optional
import yaml
from ops import BoundEvent, CharmBase, EventSource, Object, ObjectEvents, RelationEvent
logger = logging.getLogger(__name__)
LIBID = "811da8ece74c4c09b53e114cb74d591e"
# Increment this major API version when introducing breaking changes
LIBAPI = 0
# Increment this PATCH version before using `charmcraft publish-lib` or reset
# to 0 if you are raising the major API version
LIBPATCH = 1
DEFAULT_RELATION_NAME = "feast-configuration"
class FeastStoreConfigurationUpdatedEvent(RelationEvent):
"""Indicates the Feast Store Configuration data was updated."""
class FeastStoreConfigurationEvents(ObjectEvents):
"""Events for the Feast Store Configuration library."""
updated = EventSource(FeastStoreConfigurationUpdatedEvent)
class FeastStoreConfigurationRelationError(Exception):
"""Base exception class for any relation error handled by this library."""
pass
class FeastStoreConfigurationRelationMissingError(FeastStoreConfigurationRelationError):
"""Exception to raise when the relation is missing on either end."""
def __init__(self, relation_name):
self.message = (
f"Missing relation with name {relation_name} with a store configuration provider."
)
super().__init__(self.message)
class FeastStoreConfigurationRelationDataMissingError(FeastStoreConfigurationRelationError):
"""Exception to raise when there is missing data in the relation data bag."""
def __init__(self, relation_name):
self.message = f"No data found in relation {relation_name} data bag."
super().__init__(self.message)
class FeastStoreConfigurationDataInvalidError(Exception):
"""Exception to raise when the data in the relation data bag has incorrect format."""
def __init__(self, error):
self.message = f"Data format for FeastStoreConfiguration has incorrect format: {error}."
super().__init__(self.message)
@dataclass
class FeastStoreConfiguration:
"""Configuration parameters for generating a Feast feature store.
This dataclass captures all dynamic, parameterizable values used in the
Feast store configuration template. These values are typically substituted into
the YAML template at runtime or during deployment to configure connections
to the registry, online store, and offline store.
Attributes:
registry_user (str): Username for connecting to the registry database.
registry_password (str): Password for the registry user.
registry_host (str): Hostname or IP address of the registry database.
registry_port (int): Port number for the registry database.
registry_database (str): Name of the registry database.
offline_store_host (str): Hostname or IP for the offline store database.
offline_store_port (int): Port number for the offline store.
offline_store_database (str): Database name for the offline store.
offline_store_user (str): Username for the offline store.
offline_store_password (str): Password for the offline store user.
online_store_host (str): Hostname or IP for the online store database.
online_store_port (int): Port number for the online store.
online_store_database (str): Database name for the online store.
online_store_user (str): Username for the online store.
online_store_password (str): Password for the online store user.
"""
# Registry configuration
registry_user: str
registry_password: str
registry_host: str
registry_port: int
registry_database: str
# Offline store configuration
offline_store_host: str
offline_store_port: int
offline_store_database: str
offline_store_user: str
offline_store_password: str
# Online store configuration
online_store_host: str
online_store_port: int
online_store_database: str
online_store_user: str
online_store_password: str
def __post_init__(self):
for field_name, expected_type in self.__annotations__.items():
value = getattr(self, field_name)
# Convert str to int where expected
if expected_type is int:
if isinstance(value, str):
try:
value = int(value)
setattr(self, field_name, value)
except ValueError:
raise FeastStoreConfigurationDataInvalidError(
f"{field_name} must be int or string representing an int, got :{value}"
)
# Final strict type check after any conversion
if not isinstance(value, expected_type):
raise FeastStoreConfigurationDataInvalidError(
f"{field_name} must be of type {expected_type.__name__}, "
f"got {type(value).__name__}"
)
class FeastStoreConfigurationProvider(Object):
"""Implement the Provider end of the Feast Configuration relation.
Attributes:
charm (CharmBase): the requirer application
relation_name (str, optional): the name of the relation
"""
on = FeastStoreConfigurationEvents()
def __init__(
self,
charm: CharmBase,
relation_name: Optional[str] = DEFAULT_RELATION_NAME,
):
super().__init__(charm, relation_name)
self.charm = charm
self.relation_name = relation_name
def send_data(self, store_configuration: FeastStoreConfiguration):
"""Update the relation data bag with data from a Store Configuration.
Args:
store_configuration (StoreConfiguration): the Feast store configuration object
"""
# Validate unit is leader to send data; otherwise return
if not self.charm.model.unit.is_leader():
logger.info(
"StoreConfigurationProivder handled send_data event when it is not the leader."
"Skipping event - no data sent."
)
return
relation = self.model.get_relation(self.relation_name)
if not relation:
raise FeastStoreConfigurationRelationMissingError(self.relation_name)
relation_data = {k: str(v) for k, v in asdict(store_configuration).items()}
# Update relation data
logger.debug(f"Sending data {relation_data}")
relation.data[self.charm.app].update(relation_data)
class FeastStoreConfigurationRequirer(Object):
"""Implement the Requirer end of the Feast Configuration relation.
Attributes:
charm (CharmBase): the requirer application
relation_name (str, optional): the name of the relation
"""
on = FeastStoreConfigurationEvents()
def __init__(self, charm: CharmBase, relation_name: Optional[str] = DEFAULT_RELATION_NAME):
super().__init__(charm, relation_name)
self.charm = charm
self.relation_name = relation_name
self.framework.observe(
self.charm.on[self.relation_name].relation_changed, self._on_relation_changed
)
self.framework.observe(
self.charm.on[self.relation_name].relation_broken, self._on_relation_broken
)
def _on_relation_changed(self, event: BoundEvent) -> None:
"""Handle relation-changed event for this relation."""
self.on.updated.emit(event.relation)
def _on_relation_broken(self, event: BoundEvent) -> None:
"""Handle relation-broken event for this relation."""
self.on.updated.emit(event.relation)
def get_feature_store_yaml(self):
"""Generate the Feast feature_store.yaml content from a FeastConfiguration instance.
Args:
config (FeastConfiguration): The configuration values to populate the YAML.
Returns:
str: A string representation of the feature_store.yaml file.
Raises:
FeatureStoreConfigurationRelationDataMissingError if data is missing
FeatureStoreConfigurationRelationMissingError: if there is no related application
"""
relation = self.model.get_relation(self.relation_name)
if not relation:
raise FeastStoreConfigurationRelationMissingError(self.relation_name)
relation_data = relation.data[relation.app]
if not relation_data:
raise FeastStoreConfigurationRelationDataMissingError(self.relation_name)
try:
config = FeastStoreConfiguration(**relation_data)
except FeastStoreConfigurationDataInvalidError as e:
raise FeastStoreConfigurationDataInvalidError(
f"Failed to create FeastStoreConfiguration: {e.message}"
)
yaml_dict: Dict = {
"project": "feast_project",
"registry": {
"registry_type": "sql",
"path": (
f"postgresql://{config.registry_user}:{config.registry_password}"
f"@{config.registry_host}:{config.registry_port}/{config.registry_database}"
),
"cache_ttl_seconds": 60,
"sqlalchemy_config_kwargs": {
"echo": False,
"pool_pre_ping": True,
},
},
"provider": "local",
"offline_store": {
"type": "postgres",
"host": config.offline_store_host,
"port": config.offline_store_port,
"database": config.offline_store_database,
"db_schema": "public",
"user": config.offline_store_user,
"password": config.offline_store_password,
},
"online_store": {
"type": "postgres",
"host": config.online_store_host,
"port": config.online_store_port,
"database": config.online_store_database,
"db_schema": "public",
"user": config.online_store_user,
"password": config.online_store_password,
},
"entity_key_serialization_version": 2,
}
return yaml.dump(yaml_dict, sort_keys=False)