diff --git a/CHANGELOG.md b/CHANGELOG.md index 374e436b882..2725fb643e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,23 @@ # Changelog +# [0.57.0](https://github.com/feast-dev/feast/compare/v0.56.0...v0.57.0) (2025-11-13) + + +### Bug Fixes + +* Improve trino to feast type mapping with (real,varchar,timestamp,decimal) ([#5691](https://github.com/feast-dev/feast/issues/5691)) ([f855ad2](https://github.com/feast-dev/feast/commit/f855ad245f1800cb5e591ad0370903f361641037)) +* Materialize API - ODFV views not looked-up (thinks views non existant) - crashes materialize ([#5716](https://github.com/feast-dev/feast/issues/5716)) ([1b050b3](https://github.com/feast-dev/feast/commit/1b050b32fefd1190044087dec504acdcb43a51d5)) +* Support historical feature retrieval with start_date/end_date in RemoteOfflineStore ([#5703](https://github.com/feast-dev/feast/issues/5703)) ([ad32756](https://github.com/feast-dev/feast/commit/ad3275654226f614fa4a40411440d16634f6971c)) +* Thread safe Clickhouse offline store ([#5710](https://github.com/feast-dev/feast/issues/5710)) ([5f446ed](https://github.com/feast-dev/feast/commit/5f446ede403e778264e6a44266ba72e1174e1db9)) + + +### Features + +* Add annotations to cronjob CRDs ([#5701](https://github.com/feast-dev/feast/issues/5701)) ([be6e6c2](https://github.com/feast-dev/feast/commit/be6e6c2752df669d030ed2c8b66dd2229448490e)) +* Add batch commit mode for MySQL OnlineStore ([#5699](https://github.com/feast-dev/feast/issues/5699)) ([3cfe4eb](https://github.com/feast-dev/feast/commit/3cfe4ebfa9de589fa03c020cadcb4d9de504affa)) +* Add possibility to materialize only latest values, to increase performance ([#5713](https://github.com/feast-dev/feast/issues/5713)) ([8d77b72](https://github.com/feast-dev/feast/commit/8d77b7287b9dea11acad56fed1dada453150785c)) +* Support table format: Iceberg, Delta, and Hudi ([#5650](https://github.com/feast-dev/feast/issues/5650)) ([2915ad1](https://github.com/feast-dev/feast/commit/2915ad18735ee4f749c2e63a6bbf0dd2a922bc96)) + # [0.56.0](https://github.com/feast-dev/feast/compare/v0.55.0...v0.56.0) (2025-10-27) diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index 2e34687d6c7..c06c3398519 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -82,6 +82,7 @@ * [Type System](reference/type-system.md) * [Data sources](reference/data-sources/README.md) * [Overview](reference/data-sources/overview.md) + * [Table formats](reference/data-sources/table-formats.md) * [File](reference/data-sources/file.md) * [Snowflake](reference/data-sources/snowflake.md) * [BigQuery](reference/data-sources/bigquery.md) diff --git a/docs/getting-started/components/compute-engine.md b/docs/getting-started/components/compute-engine.md index baf031b4284..60da1575932 100644 --- a/docs/getting-started/components/compute-engine.md +++ b/docs/getting-started/components/compute-engine.md @@ -25,7 +25,7 @@ engines. | SnowflakeComputeEngine | Runs on Snowflake, designed for scalable feature generation using Snowflake SQL. | ✅ | | | LambdaComputeEngine | Runs on AWS Lambda, designed for serverless feature generation. | ✅ | | | FlinkComputeEngine | Runs on Apache Flink, designed for stream processing and real-time feature generation. | ❌ | | -| RayComputeEngine | Runs on Ray, designed for distributed feature generation and machine learning workloads. | ❌ | | +| RayComputeEngine | Runs on Ray, designed for distributed feature generation and machine learning workloads. | ✅ | | ``` ### Batch Engine diff --git a/docs/getting-started/genai.md b/docs/getting-started/genai.md index 9c8f0c955d2..b4bdf1d1dc8 100644 --- a/docs/getting-started/genai.md +++ b/docs/getting-started/genai.md @@ -104,6 +104,24 @@ This integration enables: - Efficiently materializing features to vector databases - Scaling RAG applications to enterprise-level document repositories +### Scaling with Ray Integration + +Feast integrates with Ray to enable distributed processing for RAG applications: + +* **Ray Compute Engine**: Distributed feature computation using Ray's task and actor model +* **Ray Offline Store**: Process large document collections and generate embeddings at scale +* **Ray Batch Materialization**: Efficiently materialize features from offline to online stores +* **Distributed Embedding Generation**: Scale embedding generation across multiple nodes + +This integration enables: +- Distributed processing of large document collections +- Parallel embedding generation for millions of text chunks +- Kubernetes-native scaling for RAG applications +- Efficient resource utilization across multiple nodes +- Production-ready distributed RAG pipelines + +For detailed information on building distributed RAG applications with Feast and Ray, see [Feast + Ray: Distributed Processing for RAG Applications](https://feast.dev/blog/feast-ray-distributed-processing/). + ## Model Context Protocol (MCP) Support Feast supports the Model Context Protocol (MCP), which enables AI agents and applications to interact with your feature store through standardized MCP interfaces. This allows seamless integration with LLMs and AI agents for GenAI applications. @@ -158,6 +176,7 @@ For more detailed information and examples: * [RAG Tutorial with Docling](../tutorials/rag-with-docling.md) * [RAG Fine Tuning with Feast and Milvus](../../examples/rag-retriever/README.md) * [Milvus Quickstart Example](https://github.com/feast-dev/feast/tree/master/examples/rag/milvus-quickstart.ipynb) +* [Feast + Ray: Distributed Processing for RAG Applications](https://feast.dev/blog/feast-ray-distributed-processing/) * [MCP Feature Store Example](../../examples/mcp_feature_store/) * [MCP Feature Server Reference](../reference/feature-servers/mcp-feature-server.md) * [Spark Data Source](../reference/data-sources/spark.md) diff --git a/docs/reference/compute-engine/ray.md b/docs/reference/compute-engine/ray.md index 5547901b873..22b1e1a4700 100644 --- a/docs/reference/compute-engine/ray.md +++ b/docs/reference/compute-engine/ray.md @@ -2,6 +2,24 @@ The Ray compute engine is a distributed compute implementation that leverages [Ray](https://www.ray.io/) for executing feature pipelines including transformations, aggregations, joins, and materializations. It provides scalable and efficient distributed processing for both `materialize()` and `get_historical_features()` operations. +## Quick Start with Ray Template + +### Ray RAG Template - Batch Embedding at Scale + +For RAG (Retrieval-Augmented Generation) applications with distributed embedding generation: + +```bash +feast init -t ray_rag my_rag_project +cd my_rag_project/feature_repo +``` + +The Ray RAG template demonstrates: +- **Parallel Embedding Generation**: Uses Ray compute engine to generate embeddings across multiple workers +- **Vector Search Integration**: Works with Milvus for semantic similarity search +- **Complete RAG Pipeline**: Data → Embeddings → Search workflow + +The Ray compute engine automatically distributes the embedding generation across available workers, making it ideal for processing large datasets efficiently. + ## Overview The Ray compute engine provides: @@ -365,6 +383,8 @@ batch_engine: ### With Feature Transformations +#### On-Demand Transformations + ```python from feast import FeatureView, Field from feast.types import Float64 @@ -385,4 +405,27 @@ features = store.get_historical_features( ) ``` +#### Ray Native Transformations + +For distributed transformations that leverage Ray's dataset and parallel processing capabilities, use `mode="ray"` in your `BatchFeatureView`: + +```python +# Feature view with Ray transformation mode +document_embeddings_view = BatchFeatureView( + name="document_embeddings", + entities=[document], + mode="ray", # Enable Ray native transformation + ttl=timedelta(days=365), + schema=[ + Field(name="document_id", dtype=String), + Field(name="embedding", dtype=Array(Float32), vector_index=True), + Field(name="movie_name", dtype=String), + Field(name="movie_director", dtype=String), + ], + source=movies_source, + udf=generate_embeddings_ray_native, + online=True, +) +``` + For more information, see the [Ray documentation](https://docs.ray.io/en/latest/) and [Ray Data guide](https://docs.ray.io/en/latest/data/getting-started.html). \ No newline at end of file diff --git a/docs/reference/data-sources/spark.md b/docs/reference/data-sources/spark.md index 99d5902667a..8967e8bd181 100644 --- a/docs/reference/data-sources/spark.md +++ b/docs/reference/data-sources/spark.md @@ -4,6 +4,8 @@ Spark data sources are tables or files that can be loaded from some Spark store (e.g. Hive or in-memory). They can also be specified by a SQL query. +**New in Feast:** SparkSource now supports advanced table formats including **Apache Iceberg**, **Delta Lake**, and **Apache Hudi**, enabling ACID transactions, time travel, and schema evolution capabilities. See the [Table Formats guide](table-formats.md) for detailed documentation. + ## Disclaimer The Spark data source does not achieve full test coverage. @@ -11,6 +13,8 @@ Please do not assume complete stability. ## Examples +### Basic Examples + Using a table reference from SparkSession (for example, either in-memory or a Hive Metastore): ```python @@ -51,8 +55,77 @@ my_spark_source = SparkSource( ) ``` +### Table Format Examples + +SparkSource supports advanced table formats for modern data lakehouse architectures. For detailed documentation, configuration options, and best practices, see the **[Table Formats guide](table-formats.md)**. + +#### Apache Iceberg + +```python +from feast.infra.offline_stores.contrib.spark_offline_store.spark_source import SparkSource +from feast.table_format import IcebergFormat + +iceberg_format = IcebergFormat( + catalog="my_catalog", + namespace="my_database" +) + +my_spark_source = SparkSource( + name="user_features", + path="my_catalog.my_database.user_table", + table_format=iceberg_format, + timestamp_field="event_timestamp" +) +``` + +#### Delta Lake + +```python +from feast.infra.offline_stores.contrib.spark_offline_store.spark_source import SparkSource +from feast.table_format import DeltaFormat + +delta_format = DeltaFormat() + +my_spark_source = SparkSource( + name="transaction_features", + path="s3://my-bucket/delta-tables/transactions", + table_format=delta_format, + timestamp_field="transaction_timestamp" +) +``` + +#### Apache Hudi + +```python +from feast.infra.offline_stores.contrib.spark_offline_store.spark_source import SparkSource +from feast.table_format import HudiFormat + +hudi_format = HudiFormat( + table_type="COPY_ON_WRITE", + record_key="user_id", + precombine_field="updated_at" +) + +my_spark_source = SparkSource( + name="user_profiles", + path="s3://my-bucket/hudi-tables/user_profiles", + table_format=hudi_format, + timestamp_field="event_timestamp" +) +``` + +For advanced configuration including time travel, incremental queries, and performance tuning, see the **[Table Formats guide](table-formats.md)**. + +## Configuration Options + The full set of configuration options is available [here](https://rtd.feast.dev/en/master/#feast.infra.offline_stores.contrib.spark_offline_store.spark_source.SparkSource). +### Table Format Options + +- **IcebergFormat**: See [Table Formats - Iceberg](table-formats.md#apache-iceberg) +- **DeltaFormat**: See [Table Formats - Delta Lake](table-formats.md#delta-lake) +- **HudiFormat**: See [Table Formats - Hudi](table-formats.md#apache-hudi) + ## Supported Types Spark data sources support all eight primitive types and their corresponding array types. diff --git a/docs/reference/data-sources/table-formats.md b/docs/reference/data-sources/table-formats.md new file mode 100644 index 00000000000..f00e6f80c5f --- /dev/null +++ b/docs/reference/data-sources/table-formats.md @@ -0,0 +1,357 @@ +# Table Formats + +## Overview + +Table formats are metadata and transaction layers built on top of data storage formats (like Parquet). They provide advanced capabilities for managing large-scale data lakes, including ACID transactions, time travel, schema evolution, and efficient data management. + +Feast supports modern table formats to enable data lakehouse architectures with your feature store. + +## Supported Table Formats + +### Apache Iceberg + +[Apache Iceberg](https://iceberg.apache.org/) is an open table format designed for huge analytic datasets. It provides: +- **ACID transactions**: Atomic commits with snapshot isolation +- **Time travel**: Query data as of any snapshot +- **Schema evolution**: Add, drop, rename, or reorder columns safely +- **Hidden partitioning**: Partitioning is transparent to users +- **Performance**: Advanced pruning and filtering + +#### Basic Configuration + +```python +from feast.table_format import IcebergFormat + +iceberg_format = IcebergFormat( + catalog="my_catalog", + namespace="my_database" +) +``` + +#### Configuration Options + +| Parameter | Type | Description | +|-----------|------|-------------| +| `catalog` | `str` (optional) | Iceberg catalog name | +| `namespace` | `str` (optional) | Namespace/schema within the catalog | +| `properties` | `dict` (optional) | Additional Iceberg configuration properties | + +#### Common Properties + +```python +iceberg_format = IcebergFormat( + catalog="spark_catalog", + namespace="production", + properties={ + # Snapshot selection + "snapshot-id": "123456789", + "as-of-timestamp": "1609459200000", # Unix timestamp in ms + + # Performance tuning + "read.split.target-size": "134217728", # 128 MB splits + "read.parquet.vectorization.enabled": "true", + + # Advanced configuration + "io-impl": "org.apache.iceberg.hadoop.HadoopFileIO", + "warehouse": "s3://my-bucket/warehouse" + } +) +``` + +#### Time Travel Example + +```python +# Read from a specific snapshot +iceberg_format = IcebergFormat( + catalog="spark_catalog", + namespace="lakehouse" +) +iceberg_format.set_property("snapshot-id", "7896524153287651133") + +# Or read as of a timestamp +iceberg_format.set_property("as-of-timestamp", "1609459200000") +``` + +### Delta Lake + +[Delta Lake](https://delta.io/) is an open-source storage layer that brings ACID transactions to Apache Spark and big data workloads. It provides: +- **ACID transactions**: Serializable isolation for reads and writes +- **Time travel**: Access and revert to earlier versions +- **Schema enforcement**: Prevent bad data from corrupting tables +- **Unified batch and streaming**: Process data incrementally +- **Audit history**: Full history of all changes + +#### Basic Configuration + +```python +from feast.table_format import DeltaFormat + +delta_format = DeltaFormat() +``` + +#### Configuration Options + +| Parameter | Type | Description | +|-----------|------|-------------| +| `checkpoint_location` | `str` (optional) | Location for Delta transaction log checkpoints | +| `properties` | `dict` (optional) | Additional Delta configuration properties | + +#### Common Properties + +```python +delta_format = DeltaFormat( + checkpoint_location="s3://my-bucket/checkpoints", + properties={ + # Time travel + "versionAsOf": "5", + "timestampAsOf": "2024-01-01 00:00:00", + + # Performance optimization + "delta.autoOptimize.optimizeWrite": "true", + "delta.autoOptimize.autoCompact": "true", + + # Data skipping + "delta.dataSkippingNumIndexedCols": "32", + + # Z-ordering + "delta.autoOptimize.zOrderCols": "event_timestamp" + } +) +``` + +#### Time Travel Example + +```python +# Read from a specific version +delta_format = DeltaFormat() +delta_format.set_property("versionAsOf", "10") + +# Or read as of a timestamp +delta_format = DeltaFormat() +delta_format.set_property("timestampAsOf", "2024-01-15 12:00:00") +``` + +### Apache Hudi + +[Apache Hudi](https://hudi.apache.org/) (Hadoop Upserts Deletes and Incrementals) is a data lake storage framework for simplifying incremental data processing. It provides: +- **Upserts and deletes**: Efficient record-level updates +- **Incremental queries**: Process only changed data +- **Time travel**: Query historical versions +- **Multiple table types**: Optimize for read vs. write workloads +- **Change data capture**: Track data changes over time + +#### Basic Configuration + +```python +from feast.table_format import HudiFormat + +hudi_format = HudiFormat( + table_type="COPY_ON_WRITE", + record_key="user_id", + precombine_field="updated_at" +) +``` + +#### Configuration Options + +| Parameter | Type | Description | +|-----------|------|-------------| +| `table_type` | `str` (optional) | `COPY_ON_WRITE` or `MERGE_ON_READ` | +| `record_key` | `str` (optional) | Field(s) that uniquely identify a record | +| `precombine_field` | `str` (optional) | Field used to determine the latest version | +| `properties` | `dict` (optional) | Additional Hudi configuration properties | + +#### Table Types + +**COPY_ON_WRITE (COW)** +- Stores data in columnar format (Parquet) +- Updates create new file versions +- Best for **read-heavy workloads** +- Lower query latency + +```python +hudi_format = HudiFormat( + table_type="COPY_ON_WRITE", + record_key="id", + precombine_field="timestamp" +) +``` + +**MERGE_ON_READ (MOR)** +- Uses columnar + row-based formats +- Updates written to delta logs +- Best for **write-heavy workloads** +- Lower write latency + +```python +hudi_format = HudiFormat( + table_type="MERGE_ON_READ", + record_key="id", + precombine_field="timestamp" +) +``` + +#### Common Properties + +```python +hudi_format = HudiFormat( + table_type="COPY_ON_WRITE", + record_key="user_id", + precombine_field="updated_at", + properties={ + # Query type + "hoodie.datasource.query.type": "snapshot", # or "incremental" + + # Incremental queries + "hoodie.datasource.read.begin.instanttime": "20240101000000", + "hoodie.datasource.read.end.instanttime": "20240102000000", + + # Indexing + "hoodie.index.type": "BLOOM", + + # Compaction (for MOR tables) + "hoodie.compact.inline": "true", + "hoodie.compact.inline.max.delta.commits": "5", + + # Clustering + "hoodie.clustering.inline": "true" + } +) +``` + +#### Incremental Query Example + +```python +# Process only new/changed data +hudi_format = HudiFormat( + table_type="COPY_ON_WRITE", + record_key="id", + precombine_field="timestamp", + properties={ + "hoodie.datasource.query.type": "incremental", + "hoodie.datasource.read.begin.instanttime": "20240101000000", + "hoodie.datasource.read.end.instanttime": "20240102000000" + } +) +``` + +## Table Format vs File Format + +It's important to understand the distinction: + +| Aspect | File Format | Table Format | +|--------|-------------|--------------| +| **What it is** | Physical encoding of data | Metadata and transaction layer | +| **Examples** | Parquet, Avro, ORC, CSV | Iceberg, Delta Lake, Hudi | +| **Handles** | Data serialization | ACID, versioning, schema evolution | +| **Layer** | Storage layer | Metadata layer | + +### Can be used together + +```python +# Table format (metadata layer) built on top of file format (storage layer) +from feast.infra.offline_stores.contrib.spark_offline_store.spark_source import SparkSource +from feast.table_format import IcebergFormat + +iceberg = IcebergFormat(catalog="my_catalog", namespace="db") + +source = SparkSource( + name="features", + path="catalog.db.table", + file_format="parquet", # Underlying storage format + table_format=iceberg, # Table metadata format + timestamp_field="event_timestamp" +) +``` + +## Benefits of Table Formats + +### Reliability +- **ACID transactions**: Ensure data consistency across concurrent operations +- **Automatic retries**: Handle transient failures gracefully +- **Schema validation**: Prevent incompatible schema changes +- **Data quality**: Constraints and validation rules + +### Performance +- **Data skipping**: Read only relevant files based on metadata +- **Partition pruning**: Skip entire partitions based on predicates +- **Compaction**: Merge small files for better performance +- **Columnar pruning**: Read only necessary columns +- **Indexing**: Advanced indexing for fast lookups + +### Flexibility +- **Schema evolution**: Add, remove, or modify columns without rewriting data +- **Time travel**: Access historical data states for auditing or debugging +- **Incremental processing**: Process only changed data efficiently +- **Multiple readers/writers**: Concurrent access without conflicts + +## Choosing the Right Table Format + +| Use Case | Recommended Format | Why | +|----------|-------------------|-----| +| Large-scale analytics with frequent schema changes | **Iceberg** | Best schema evolution, hidden partitioning, mature ecosystem | +| Streaming + batch workloads | **Delta Lake** | Unified architecture, strong integration with Spark, good docs | +| CDC and upsert-heavy workloads | **Hudi** | Efficient record-level updates, incremental queries | +| Read-heavy analytics | **Iceberg or Delta** | Excellent query performance | +| Write-heavy transactional | **Hudi (MOR)** | Optimized for fast writes | +| Multi-engine support | **Iceberg** | Widest engine support (Spark, Flink, Trino, etc.) | + +## Best Practices + +### 1. Choose Appropriate Partitioning +```python +# Iceberg - hidden partitioning +iceberg_format.set_property("partition-spec", "days(event_timestamp)") + +# Delta - explicit partitioning in data source +# Hudi - configure via properties +hudi_format.set_property("hoodie.datasource.write.partitionpath.field", "date") +``` + +### 2. Enable Optimization Features +```python +# Delta auto-optimize +delta_format.set_property("delta.autoOptimize.optimizeWrite", "true") +delta_format.set_property("delta.autoOptimize.autoCompact", "true") + +# Hudi compaction +hudi_format.set_property("hoodie.compact.inline", "true") +``` + +### 3. Manage Table History +```python +# Regularly clean up old snapshots/versions +# For Iceberg: Use expire_snapshots() procedure +# For Delta: Use VACUUM command +# For Hudi: Configure retention policies +``` + +### 4. Monitor Metadata Size +- Table formats maintain metadata for all operations +- Monitor metadata size and clean up old versions +- Configure retention policies based on your needs + +### 5. Test Schema Evolution +```python +# Always test schema changes in non-production first +# Ensure backward compatibility +# Use proper migration procedures +``` + +## Data Source Support + +Currently, table formats are supported with: +- [Spark data source](spark.md) - Full support for Iceberg, Delta, and Hudi + +Future support planned for: +- BigQuery (Iceberg) +- Snowflake (Iceberg) +- Other data sources + +## See Also + +- [Spark Data Source](spark.md) +- [Apache Iceberg Documentation](https://iceberg.apache.org/docs/latest/) +- [Delta Lake Documentation](https://docs.delta.io/latest/index.html) +- [Apache Hudi Documentation](https://hudi.apache.org/docs/overview) +- [Python API Reference - TableFormat](https://rtd.feast.dev/en/master/#feast.table_format) \ No newline at end of file diff --git a/docs/reference/offline-stores/README.md b/docs/reference/offline-stores/README.md index ab25fe9a276..b5e2bccbdd1 100644 --- a/docs/reference/offline-stores/README.md +++ b/docs/reference/offline-stores/README.md @@ -45,3 +45,7 @@ Please see [Offline Store](../../getting-started/components/offline-store.md) fo {% content-ref url="mssql.md" %} [mssql.md](mssql.md) {% endcontent-ref %} + +{% content-ref url="ray.md" %} +[ray.md](ray.md) +{% endcontent-ref %} diff --git a/docs/reference/offline-stores/overview.md b/docs/reference/offline-stores/overview.md index 191ccd21a64..24d37da22f1 100644 --- a/docs/reference/offline-stores/overview.md +++ b/docs/reference/offline-stores/overview.md @@ -26,33 +26,33 @@ The first three of these methods all return a `RetrievalJob` specific to an offl ## Functionality Matrix There are currently four core offline store implementations: `DaskOfflineStore`, `BigQueryOfflineStore`, `SnowflakeOfflineStore`, and `RedshiftOfflineStore`. -There are several additional implementations contributed by the Feast community (`PostgreSQLOfflineStore`, `SparkOfflineStore`, and `TrinoOfflineStore`), which are not guaranteed to be stable or to match the functionality of the core implementations. +There are several additional implementations contributed by the Feast community (`PostgreSQLOfflineStore`, `SparkOfflineStore`, `TrinoOfflineStore`, and `RayOfflineStore`), which are not guaranteed to be stable or to match the functionality of the core implementations. Details for each specific offline store, such as how to configure it in a `feature_store.yaml`, can be found [here](README.md). Below is a matrix indicating which offline stores support which methods. -| | Dask | BigQuery | Snowflake | Redshift | Postgres | Spark | Trino | Couchbase | -| :-------------------------------- | :-- | :-- | :-- | :-- | :-- | :-- | :-- | :-- | -| `get_historical_features` | yes | yes | yes | yes | yes | yes | yes | yes | -| `pull_latest_from_table_or_query` | yes | yes | yes | yes | yes | yes | yes | yes | -| `pull_all_from_table_or_query` | yes | yes | yes | yes | yes | yes | yes | yes | -| `offline_write_batch` | yes | yes | yes | yes | no | no | no | no | -| `write_logged_features` | yes | yes | yes | yes | no | no | no | no | +|| | Dask | BigQuery | Snowflake | Redshift | Postgres | Spark | Trino | Couchbase | Ray | +|| :-------------------------------- | :-- | :-- | :-- | :-- | :-- | :-- | :-- | :-- | :-- | +|| `get_historical_features` | yes | yes | yes | yes | yes | yes | yes | yes | yes | +|| `pull_latest_from_table_or_query` | yes | yes | yes | yes | yes | yes | yes | yes | yes | +|| `pull_all_from_table_or_query` | yes | yes | yes | yes | yes | yes | yes | yes | yes | +|| `offline_write_batch` | yes | yes | yes | yes | no | no | no | no | yes | +|| `write_logged_features` | yes | yes | yes | yes | no | no | no | no | yes | Below is a matrix indicating which `RetrievalJob`s support what functionality. -| | Dask | BigQuery | Snowflake | Redshift | Postgres | Spark | Trino | DuckDB | Couchbase | -| --------------------------------- | --- | --- | --- | --- | --- | --- | --- | --- | --- | -| export to dataframe | yes | yes | yes | yes | yes | yes | yes | yes | yes | -| export to arrow table | yes | yes | yes | yes | yes | yes | yes | yes | yes | -| export to arrow batches | no | no | no | yes | no | no | no | no | no | -| export to SQL | no | yes | yes | yes | yes | no | yes | no | yes | -| export to data lake (S3, GCS, etc.) | no | no | yes | no | yes | no | no | no | yes | -| export to data warehouse | no | yes | yes | yes | yes | no | no | no | yes | -| export as Spark dataframe | no | no | yes | no | no | yes | no | no | no | -| local execution of Python-based on-demand transforms | yes | yes | yes | yes | yes | no | yes | yes | yes | -| remote execution of Python-based on-demand transforms | no | no | no | no | no | no | no | no | no | -| persist results in the offline store | yes | yes | yes | yes | yes | yes | no | yes | yes | -| preview the query plan before execution | yes | yes | yes | yes | yes | yes | yes | no | yes | -| read partitioned data | yes | yes | yes | yes | yes | yes | yes | yes | yes | +|| | Dask | BigQuery | Snowflake | Redshift | Postgres | Spark | Trino | DuckDB | Couchbase | Ray | +|| --------------------------------- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +|| export to dataframe | yes | yes | yes | yes | yes | yes | yes | yes | yes | yes | +|| export to arrow table | yes | yes | yes | yes | yes | yes | yes | yes | yes | yes | +|| export to arrow batches | no | no | no | yes | no | no | no | no | no | no | +|| export to SQL | no | yes | yes | yes | yes | no | yes | no | yes | no | +|| export to data lake (S3, GCS, etc.) | no | no | yes | no | yes | no | no | no | yes | yes | +|| export to data warehouse | no | yes | yes | yes | yes | no | no | no | yes | no | +|| export as Spark dataframe | no | no | yes | no | no | yes | no | no | no | no | +|| local execution of Python-based on-demand transforms | yes | yes | yes | yes | yes | no | yes | yes | yes | yes | +|| remote execution of Python-based on-demand transforms | no | no | no | no | no | no | no | no | no | no | +|| persist results in the offline store | yes | yes | yes | yes | yes | yes | no | yes | yes | yes | +|| preview the query plan before execution | yes | yes | yes | yes | yes | yes | yes | no | yes | yes | +|| read partitioned data | yes | yes | yes | yes | yes | yes | yes | yes | yes | yes | \ No newline at end of file diff --git a/docs/reference/offline-stores/ray.md b/docs/reference/offline-stores/ray.md index a46102ee132..89040f05c94 100644 --- a/docs/reference/offline-stores/ray.md +++ b/docs/reference/offline-stores/ray.md @@ -5,6 +5,23 @@ The Ray offline store is a data I/O implementation that leverages [Ray](https://www.ray.io/) for reading and writing data from various sources. It focuses on efficient data access operations, while complex feature computation is handled by the [Ray Compute Engine](../compute-engine/ray.md). +## Quick Start with Ray Template + +The easiest way to get started with Ray offline store is to use the built-in Ray template: + +```bash +feast init -t ray my_ray_project +cd my_ray_project/feature_repo +``` + +This template includes: +- Pre-configured Ray offline store and compute engine setup +- Sample feature definitions optimized for Ray processing +- Demo workflow showcasing Ray capabilities +- Resource settings for local development + +The template provides a complete working example with sample datasets and demonstrates both Ray offline store data I/O operations and Ray compute engine distributed processing. + ## Overview The Ray offline store provides: diff --git a/docs/reference/online-stores/mysql.md b/docs/reference/online-stores/mysql.md index 8868e64279d..2f9650f8916 100644 --- a/docs/reference/online-stores/mysql.md +++ b/docs/reference/online-stores/mysql.md @@ -28,6 +28,28 @@ online_store: The full set of configuration options is available in [MySQLOnlineStoreConfig](https://rtd.feast.dev/en/master/#feast.infra.online_stores.mysql_online_store.MySQLOnlineStoreConfig). +## Batch write mode +By default, the MySQL online store performs row-by-row insert and commit for each feature record. While this ensures per-record atomicity, it can lead to significant overhead on write operations — especially on distributed SQL databases (for example, TiDB, which is MySQL-compatible and uses a consensus protocol). + +To improve writing performance, you can enable batch write mode by setting `batch_write` to `true` and `batch_size`, which executes multiple insert queries in batches and commits them together per batch instead of committing each record individually. + +{% code title="feature_store.yaml" %} +```yaml +project: my_feature_repo +registry: data/registry.db +provider: local +online_store: + type: mysql + host: DB_HOST + port: DB_PORT + database: DB_NAME + user: DB_USERNAME + password: DB_PASSWORD + batch_write: true + batch_size: 100 +``` +{% endcode %} + ## Functionality Matrix The set of functionality supported by online stores is described in detail [here](overview.md#functionality). diff --git a/infra/charts/feast-feature-server/Chart.yaml b/infra/charts/feast-feature-server/Chart.yaml index eaf14f21fe6..70f98bf8190 100644 --- a/infra/charts/feast-feature-server/Chart.yaml +++ b/infra/charts/feast-feature-server/Chart.yaml @@ -2,7 +2,7 @@ apiVersion: v2 name: feast-feature-server description: Feast Feature Server in Go or Python type: application -version: 0.56.0 +version: 0.57.0 keywords: - machine learning - big data diff --git a/infra/charts/feast-feature-server/README.md b/infra/charts/feast-feature-server/README.md index e73be95a909..48dcac6904f 100644 --- a/infra/charts/feast-feature-server/README.md +++ b/infra/charts/feast-feature-server/README.md @@ -1,6 +1,6 @@ # Feast Python / Go Feature Server Helm Charts -Current chart version is `0.56.0` +Current chart version is `0.57.0` ## Installation @@ -42,7 +42,7 @@ See [here](https://github.com/feast-dev/feast/tree/master/examples/python-helm-d | fullnameOverride | string | `""` | | | image.pullPolicy | string | `"IfNotPresent"` | | | image.repository | string | `"quay.io/feastdev/feature-server"` | Docker image for Feature Server repository | -| image.tag | string | `"0.56.0"` | The Docker image tag (can be overwritten if custom feature server deps are needed for on demand transforms) | +| image.tag | string | `"0.57.0"` | The Docker image tag (can be overwritten if custom feature server deps are needed for on demand transforms) | | imagePullSecrets | list | `[]` | | | livenessProbe.initialDelaySeconds | int | `30` | | | livenessProbe.periodSeconds | int | `30` | | diff --git a/infra/charts/feast-feature-server/values.yaml b/infra/charts/feast-feature-server/values.yaml index 0f94b5bc579..452a4579f5c 100644 --- a/infra/charts/feast-feature-server/values.yaml +++ b/infra/charts/feast-feature-server/values.yaml @@ -9,7 +9,7 @@ image: repository: quay.io/feastdev/feature-server pullPolicy: IfNotPresent # image.tag -- The Docker image tag (can be overwritten if custom feature server deps are needed for on demand transforms) - tag: 0.56.0 + tag: 0.57.0 logLevel: "WARNING" # Set log level DEBUG, INFO, WARNING, ERROR, and CRITICAL (case-insensitive) diff --git a/infra/charts/feast/Chart.yaml b/infra/charts/feast/Chart.yaml index 307e01563cd..d5c4ed68f1b 100644 --- a/infra/charts/feast/Chart.yaml +++ b/infra/charts/feast/Chart.yaml @@ -1,7 +1,7 @@ apiVersion: v1 description: Feature store for machine learning name: feast -version: 0.56.0 +version: 0.57.0 keywords: - machine learning - big data diff --git a/infra/charts/feast/README.md b/infra/charts/feast/README.md index dca35b67065..91493736a07 100644 --- a/infra/charts/feast/README.md +++ b/infra/charts/feast/README.md @@ -8,7 +8,7 @@ This repo contains Helm charts for Feast Java components that are being installe ## Chart: Feast -Feature store for machine learning Current chart version is `0.56.0` +Feature store for machine learning Current chart version is `0.57.0` ## Installation @@ -65,8 +65,8 @@ See [here](https://github.com/feast-dev/feast/tree/master/examples/java-demo) fo | Repository | Name | Version | |------------|------|---------| | https://charts.helm.sh/stable | redis | 10.5.6 | -| https://feast-helm-charts.storage.googleapis.com | feature-server(feature-server) | 0.56.0 | -| https://feast-helm-charts.storage.googleapis.com | transformation-service(transformation-service) | 0.56.0 | +| https://feast-helm-charts.storage.googleapis.com | feature-server(feature-server) | 0.57.0 | +| https://feast-helm-charts.storage.googleapis.com | transformation-service(transformation-service) | 0.57.0 | ## Values diff --git a/infra/charts/feast/charts/feature-server/Chart.yaml b/infra/charts/feast/charts/feature-server/Chart.yaml index 43a7e41c3ab..99f22582d38 100644 --- a/infra/charts/feast/charts/feature-server/Chart.yaml +++ b/infra/charts/feast/charts/feature-server/Chart.yaml @@ -1,8 +1,8 @@ apiVersion: v1 description: "Feast Feature Server: Online feature serving service for Feast" name: feature-server -version: 0.56.0 -appVersion: v0.56.0 +version: 0.57.0 +appVersion: v0.57.0 keywords: - machine learning - big data diff --git a/infra/charts/feast/charts/feature-server/README.md b/infra/charts/feast/charts/feature-server/README.md index 46159a543c5..21b234dd245 100644 --- a/infra/charts/feast/charts/feature-server/README.md +++ b/infra/charts/feast/charts/feature-server/README.md @@ -1,6 +1,6 @@ # feature-server -![Version: 0.56.0](https://img.shields.io/badge/Version-0.56.0-informational?style=flat-square) ![AppVersion: v0.56.0](https://img.shields.io/badge/AppVersion-v0.56.0-informational?style=flat-square) +![Version: 0.57.0](https://img.shields.io/badge/Version-0.57.0-informational?style=flat-square) ![AppVersion: v0.57.0](https://img.shields.io/badge/AppVersion-v0.57.0-informational?style=flat-square) Feast Feature Server: Online feature serving service for Feast @@ -17,7 +17,7 @@ Feast Feature Server: Online feature serving service for Feast | envOverrides | object | `{}` | Extra environment variables to set | | image.pullPolicy | string | `"IfNotPresent"` | Image pull policy | | image.repository | string | `"quay.io/feastdev/feature-server-java"` | Docker image for Feature Server repository | -| image.tag | string | `"0.56.0"` | Image tag | +| image.tag | string | `"0.57.0"` | Image tag | | ingress.grpc.annotations | object | `{}` | Extra annotations for the ingress | | ingress.grpc.auth.enabled | bool | `false` | Flag to enable auth | | ingress.grpc.class | string | `"nginx"` | Which ingress controller to use | diff --git a/infra/charts/feast/charts/feature-server/values.yaml b/infra/charts/feast/charts/feature-server/values.yaml index c054158be62..d0a74ad3ba5 100644 --- a/infra/charts/feast/charts/feature-server/values.yaml +++ b/infra/charts/feast/charts/feature-server/values.yaml @@ -5,7 +5,7 @@ image: # image.repository -- Docker image for Feature Server repository repository: quay.io/feastdev/feature-server-java # image.tag -- Image tag - tag: 0.56.0 + tag: 0.57.0 # image.pullPolicy -- Image pull policy pullPolicy: IfNotPresent diff --git a/infra/charts/feast/charts/transformation-service/Chart.yaml b/infra/charts/feast/charts/transformation-service/Chart.yaml index 10c15403d9d..83690118ff3 100644 --- a/infra/charts/feast/charts/transformation-service/Chart.yaml +++ b/infra/charts/feast/charts/transformation-service/Chart.yaml @@ -1,8 +1,8 @@ apiVersion: v1 description: "Transformation service: to compute on-demand features" name: transformation-service -version: 0.56.0 -appVersion: v0.56.0 +version: 0.57.0 +appVersion: v0.57.0 keywords: - machine learning - big data diff --git a/infra/charts/feast/charts/transformation-service/README.md b/infra/charts/feast/charts/transformation-service/README.md index 716d5f9332b..23a4e2e491c 100644 --- a/infra/charts/feast/charts/transformation-service/README.md +++ b/infra/charts/feast/charts/transformation-service/README.md @@ -1,6 +1,6 @@ # transformation-service -![Version: 0.56.0](https://img.shields.io/badge/Version-0.56.0-informational?style=flat-square) ![AppVersion: v0.56.0](https://img.shields.io/badge/AppVersion-v0.56.0-informational?style=flat-square) +![Version: 0.57.0](https://img.shields.io/badge/Version-0.57.0-informational?style=flat-square) ![AppVersion: v0.57.0](https://img.shields.io/badge/AppVersion-v0.57.0-informational?style=flat-square) Transformation service: to compute on-demand features @@ -13,7 +13,7 @@ Transformation service: to compute on-demand features | envOverrides | object | `{}` | Extra environment variables to set | | image.pullPolicy | string | `"IfNotPresent"` | Image pull policy | | image.repository | string | `"quay.io/feastdev/feature-transformation-server"` | Docker image for Transformation Server repository | -| image.tag | string | `"0.56.0"` | Image tag | +| image.tag | string | `"0.57.0"` | Image tag | | nodeSelector | object | `{}` | Node labels for pod assignment | | podLabels | object | `{}` | Labels to be added to Feast Serving pods | | replicaCount | int | `1` | Number of pods that will be created | diff --git a/infra/charts/feast/charts/transformation-service/values.yaml b/infra/charts/feast/charts/transformation-service/values.yaml index fe805f0bed1..fad48630231 100644 --- a/infra/charts/feast/charts/transformation-service/values.yaml +++ b/infra/charts/feast/charts/transformation-service/values.yaml @@ -5,7 +5,7 @@ image: # image.repository -- Docker image for Transformation Server repository repository: quay.io/feastdev/feature-transformation-server # image.tag -- Image tag - tag: 0.56.0 + tag: 0.57.0 # image.pullPolicy -- Image pull policy pullPolicy: IfNotPresent diff --git a/infra/charts/feast/requirements.yaml b/infra/charts/feast/requirements.yaml index 2ef611c9a0e..e55c068f904 100644 --- a/infra/charts/feast/requirements.yaml +++ b/infra/charts/feast/requirements.yaml @@ -1,12 +1,12 @@ dependencies: - name: feature-server alias: feature-server - version: 0.56.0 + version: 0.57.0 condition: feature-server.enabled repository: https://feast-helm-charts.storage.googleapis.com - name: transformation-service alias: transformation-service - version: 0.56.0 + version: 0.57.0 condition: transformation-service.enabled repository: https://feast-helm-charts.storage.googleapis.com - name: redis diff --git a/infra/feast-operator/Makefile b/infra/feast-operator/Makefile index 9541ed99656..809b8d12c63 100644 --- a/infra/feast-operator/Makefile +++ b/infra/feast-operator/Makefile @@ -3,7 +3,7 @@ # To re-generate a bundle for another specific version without changing the standard setup, you can: # - use the VERSION as arg of the bundle target (e.g make bundle VERSION=0.0.2) # - use environment variables to overwrite this value (e.g export VERSION=0.0.2) -VERSION ?= 0.56.0 +VERSION ?= 0.57.0 # CHANNELS define the bundle channels used in the bundle. # Add a new line here if you would like to change its default config. (E.g CHANNELS = "candidate,fast,stable") diff --git a/infra/feast-operator/api/feastversion/version.go b/infra/feast-operator/api/feastversion/version.go index 9279475e53d..1e3985859ee 100644 --- a/infra/feast-operator/api/feastversion/version.go +++ b/infra/feast-operator/api/feastversion/version.go @@ -17,4 +17,4 @@ limitations under the License. package feastversion // Feast release version. Keep on line #20, this is critical to release CI -const FeastVersion = "0.56.0" +const FeastVersion = "0.57.0" diff --git a/infra/feast-operator/api/v1alpha1/featurestore_types.go b/infra/feast-operator/api/v1alpha1/featurestore_types.go index 9250309b1cc..243827a487c 100644 --- a/infra/feast-operator/api/v1alpha1/featurestore_types.go +++ b/infra/feast-operator/api/v1alpha1/featurestore_types.go @@ -111,6 +111,9 @@ type FeastInitOptions struct { // FeastCronJob defines a CronJob to execute against a Feature Store deployment. type FeastCronJob struct { + // Annotations to be added to the CronJob metadata. + Annotations map[string]string `json:"annotations,omitempty"` + // Specification of the desired behavior of a job. JobSpec *JobSpec `json:"jobSpec,omitempty"` ContainerConfigs *CronJobContainerConfigs `json:"containerConfigs,omitempty"` diff --git a/infra/feast-operator/api/v1alpha1/zz_generated.deepcopy.go b/infra/feast-operator/api/v1alpha1/zz_generated.deepcopy.go index 7ea04929b3d..15c61cc86d6 100644 --- a/infra/feast-operator/api/v1alpha1/zz_generated.deepcopy.go +++ b/infra/feast-operator/api/v1alpha1/zz_generated.deepcopy.go @@ -114,6 +114,13 @@ func (in *DefaultCtrConfigs) DeepCopy() *DefaultCtrConfigs { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *FeastCronJob) DeepCopyInto(out *FeastCronJob) { *out = *in + if in.Annotations != nil { + in, out := &in.Annotations, &out.Annotations + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } if in.JobSpec != nil { in, out := &in.JobSpec, &out.JobSpec *out = new(JobSpec) diff --git a/infra/feast-operator/bundle/manifests/feast-operator.clusterserviceversion.yaml b/infra/feast-operator/bundle/manifests/feast-operator.clusterserviceversion.yaml index 78205683183..244f9565905 100644 --- a/infra/feast-operator/bundle/manifests/feast-operator.clusterserviceversion.yaml +++ b/infra/feast-operator/bundle/manifests/feast-operator.clusterserviceversion.yaml @@ -50,10 +50,10 @@ metadata: } ] capabilities: Basic Install - createdAt: "2025-10-27T20:28:39Z" + createdAt: "2025-11-13T20:26:23Z" operators.operatorframework.io/builder: operator-sdk-v1.38.0 operators.operatorframework.io/project_layout: go.kubebuilder.io/v4 - name: feast-operator.v0.56.0 + name: feast-operator.v0.57.0 namespace: placeholder spec: apiservicedefinitions: {} @@ -225,10 +225,10 @@ spec: - /manager env: - name: RELATED_IMAGE_FEATURE_SERVER - value: quay.io/feastdev/feature-server:0.56.0 + value: quay.io/feastdev/feature-server:0.57.0 - name: RELATED_IMAGE_CRON_JOB value: quay.io/openshift/origin-cli:4.17 - image: quay.io/feastdev/feast-operator:0.56.0 + image: quay.io/feastdev/feast-operator:0.57.0 livenessProbe: httpGet: path: /healthz @@ -318,8 +318,8 @@ spec: name: Feast Community url: https://lf-aidata.atlassian.net/wiki/spaces/FEAST/ relatedImages: - - image: quay.io/feastdev/feature-server:0.56.0 + - image: quay.io/feastdev/feature-server:0.57.0 name: feature-server - image: quay.io/openshift/origin-cli:4.17 name: cron-job - version: 0.56.0 + version: 0.57.0 diff --git a/infra/feast-operator/bundle/manifests/feast.dev_featurestores.yaml b/infra/feast-operator/bundle/manifests/feast.dev_featurestores.yaml index 9c0c09d141a..3a99b2e7a70 100644 --- a/infra/feast-operator/bundle/manifests/feast.dev_featurestores.yaml +++ b/infra/feast-operator/bundle/manifests/feast.dev_featurestores.yaml @@ -87,6 +87,11 @@ spec: description: FeastCronJob defines a CronJob to execute against a Feature Store deployment. properties: + annotations: + additionalProperties: + type: string + description: Annotations to be added to the CronJob metadata. + type: object concurrencyPolicy: description: Specifies how to treat concurrent executions of a Job. @@ -4063,6 +4068,11 @@ spec: description: FeastCronJob defines a CronJob to execute against a Feature Store deployment. properties: + annotations: + additionalProperties: + type: string + description: Annotations to be added to the CronJob metadata. + type: object concurrencyPolicy: description: Specifies how to treat concurrent executions of a Job. diff --git a/infra/feast-operator/config/component_metadata.yaml b/infra/feast-operator/config/component_metadata.yaml index cda672ead9a..fd776ac19bb 100644 --- a/infra/feast-operator/config/component_metadata.yaml +++ b/infra/feast-operator/config/component_metadata.yaml @@ -1,5 +1,5 @@ # This file is required to configure Feast release information for ODH/RHOAI Operator releases: - name: Feast - version: 0.56.0 + version: 0.57.0 repoUrl: https://github.com/feast-dev/feast diff --git a/infra/feast-operator/config/crd/bases/feast.dev_featurestores.yaml b/infra/feast-operator/config/crd/bases/feast.dev_featurestores.yaml index c964d46c27d..9264cfecf49 100644 --- a/infra/feast-operator/config/crd/bases/feast.dev_featurestores.yaml +++ b/infra/feast-operator/config/crd/bases/feast.dev_featurestores.yaml @@ -87,6 +87,11 @@ spec: description: FeastCronJob defines a CronJob to execute against a Feature Store deployment. properties: + annotations: + additionalProperties: + type: string + description: Annotations to be added to the CronJob metadata. + type: object concurrencyPolicy: description: Specifies how to treat concurrent executions of a Job. @@ -4063,6 +4068,11 @@ spec: description: FeastCronJob defines a CronJob to execute against a Feature Store deployment. properties: + annotations: + additionalProperties: + type: string + description: Annotations to be added to the CronJob metadata. + type: object concurrencyPolicy: description: Specifies how to treat concurrent executions of a Job. diff --git a/infra/feast-operator/config/default/related_image_fs_patch.yaml b/infra/feast-operator/config/default/related_image_fs_patch.yaml index 30cd7d3616f..e954af1876e 100644 --- a/infra/feast-operator/config/default/related_image_fs_patch.yaml +++ b/infra/feast-operator/config/default/related_image_fs_patch.yaml @@ -2,7 +2,7 @@ path: "/spec/template/spec/containers/0/env/0" value: name: RELATED_IMAGE_FEATURE_SERVER - value: quay.io/feastdev/feature-server:0.56.0 + value: quay.io/feastdev/feature-server:0.57.0 - op: replace path: "/spec/template/spec/containers/0/env/1" value: diff --git a/infra/feast-operator/config/manager/kustomization.yaml b/infra/feast-operator/config/manager/kustomization.yaml index 80aaa3faf28..8b3405fb024 100644 --- a/infra/feast-operator/config/manager/kustomization.yaml +++ b/infra/feast-operator/config/manager/kustomization.yaml @@ -5,4 +5,4 @@ kind: Kustomization images: - name: controller newName: quay.io/feastdev/feast-operator - newTag: 0.56.0 + newTag: 0.57.0 diff --git a/infra/feast-operator/config/overlays/odh/params.env b/infra/feast-operator/config/overlays/odh/params.env index b112bb2d854..2e8ed43f3a2 100644 --- a/infra/feast-operator/config/overlays/odh/params.env +++ b/infra/feast-operator/config/overlays/odh/params.env @@ -1,3 +1,3 @@ -RELATED_IMAGE_FEAST_OPERATOR=quay.io/feastdev/feast-operator:0.56.0 -RELATED_IMAGE_FEATURE_SERVER=quay.io/feastdev/feature-server:0.56.0 +RELATED_IMAGE_FEAST_OPERATOR=quay.io/feastdev/feast-operator:0.57.0 +RELATED_IMAGE_FEATURE_SERVER=quay.io/feastdev/feature-server:0.57.0 RELATED_IMAGE_CRON_JOB=quay.io/openshift/origin-cli:4.17 diff --git a/infra/feast-operator/config/overlays/rhoai/params.env b/infra/feast-operator/config/overlays/rhoai/params.env index f548227235d..c54ad85aa02 100644 --- a/infra/feast-operator/config/overlays/rhoai/params.env +++ b/infra/feast-operator/config/overlays/rhoai/params.env @@ -1,3 +1,3 @@ -RELATED_IMAGE_FEAST_OPERATOR=quay.io/feastdev/feast-operator:0.56.0 -RELATED_IMAGE_FEATURE_SERVER=quay.io/feastdev/feature-server:0.56.0 +RELATED_IMAGE_FEAST_OPERATOR=quay.io/feastdev/feast-operator:0.57.0 +RELATED_IMAGE_FEATURE_SERVER=quay.io/feastdev/feature-server:0.57.0 RELATED_IMAGE_CRON_JOB=registry.redhat.io/openshift4/ose-cli@sha256:bc35a9fc663baf0d6493cc57e89e77a240a36c43cf38fb78d8e61d3b87cf5cc5 \ No newline at end of file diff --git a/infra/feast-operator/dist/install.yaml b/infra/feast-operator/dist/install.yaml index 58886675ec1..4b725af55eb 100644 --- a/infra/feast-operator/dist/install.yaml +++ b/infra/feast-operator/dist/install.yaml @@ -95,6 +95,11 @@ spec: description: FeastCronJob defines a CronJob to execute against a Feature Store deployment. properties: + annotations: + additionalProperties: + type: string + description: Annotations to be added to the CronJob metadata. + type: object concurrencyPolicy: description: Specifies how to treat concurrent executions of a Job. @@ -4071,6 +4076,11 @@ spec: description: FeastCronJob defines a CronJob to execute against a Feature Store deployment. properties: + annotations: + additionalProperties: + type: string + description: Annotations to be added to the CronJob metadata. + type: object concurrencyPolicy: description: Specifies how to treat concurrent executions of a Job. @@ -8483,10 +8493,10 @@ spec: - /manager env: - name: RELATED_IMAGE_FEATURE_SERVER - value: quay.io/feastdev/feature-server:0.56.0 + value: quay.io/feastdev/feature-server:0.57.0 - name: RELATED_IMAGE_CRON_JOB value: quay.io/openshift/origin-cli:4.17 - image: quay.io/feastdev/feast-operator:0.56.0 + image: quay.io/feastdev/feast-operator:0.57.0 livenessProbe: httpGet: path: /healthz diff --git a/infra/feast-operator/dist/operator-e2e-tests b/infra/feast-operator/dist/operator-e2e-tests index 2a8e2a02352..0734ab81a9e 100755 Binary files a/infra/feast-operator/dist/operator-e2e-tests and b/infra/feast-operator/dist/operator-e2e-tests differ diff --git a/infra/feast-operator/docs/api/markdown/ref.md b/infra/feast-operator/docs/api/markdown/ref.md index fac7ebfa784..6016a70a1b8 100644 --- a/infra/feast-operator/docs/api/markdown/ref.md +++ b/infra/feast-operator/docs/api/markdown/ref.md @@ -98,6 +98,7 @@ _Appears in:_ | Field | Description | | --- | --- | +| `annotations` _object (keys:string, values:string)_ | Annotations to be added to the CronJob metadata. | | `jobSpec` _[JobSpec](#jobspec)_ | Specification of the desired behavior of a job. | | `containerConfigs` _[CronJobContainerConfigs](#cronjobcontainerconfigs)_ | | | `schedule` _string_ | The schedule in Cron format, see https://en.wikipedia.org/wiki/Cron. | diff --git a/infra/feast-operator/internal/controller/services/cronjob.go b/infra/feast-operator/internal/controller/services/cronjob.go index f15200d22ec..b368133b113 100644 --- a/infra/feast-operator/internal/controller/services/cronjob.go +++ b/infra/feast-operator/internal/controller/services/cronjob.go @@ -54,6 +54,12 @@ func (feast *FeastServices) initCronJob() *batchv1.CronJob { func (feast *FeastServices) setCronJob(cronJob *batchv1.CronJob) error { appliedCronJob := feast.Handler.FeatureStore.Status.Applied.CronJob cronJob.Labels = feast.getFeastTypeLabels(CronJobFeastType) + if appliedCronJob.Annotations != nil { + cronJob.Annotations = make(map[string]string, len(appliedCronJob.Annotations)) + for k, v := range appliedCronJob.Annotations { + cronJob.Annotations[k] = v + } + } cronJob.Spec = batchv1.CronJobSpec{ Schedule: appliedCronJob.Schedule, JobTemplate: batchv1.JobTemplateSpec{ diff --git a/infra/feast-operator/test/api/featurestore_types_test.go b/infra/feast-operator/test/api/featurestore_types_test.go index 83ac2906ec0..e8b08b549d0 100644 --- a/infra/feast-operator/test/api/featurestore_types_test.go +++ b/infra/feast-operator/test/api/featurestore_types_test.go @@ -438,6 +438,35 @@ func registryWithGRPCFalse(featureStore *feastdevv1alpha1.FeatureStore) *feastde return fsCopy } +func cronJobWithAnnotations(featureStore *feastdevv1alpha1.FeatureStore) *feastdevv1alpha1.FeatureStore { + fsCopy := featureStore.DeepCopy() + fsCopy.Spec.CronJob = &feastdevv1alpha1.FeastCronJob{ + Annotations: map[string]string{ + "test-annotation": "test-value", + "another-annotation": "another-value", + }, + Schedule: "0 0 * * *", + } + return fsCopy +} + +func cronJobWithEmptyAnnotations(featureStore *feastdevv1alpha1.FeatureStore) *feastdevv1alpha1.FeatureStore { + fsCopy := featureStore.DeepCopy() + fsCopy.Spec.CronJob = &feastdevv1alpha1.FeastCronJob{ + Annotations: map[string]string{}, + Schedule: "0 0 * * *", + } + return fsCopy +} + +func cronJobWithoutAnnotations(featureStore *feastdevv1alpha1.FeatureStore) *feastdevv1alpha1.FeatureStore { + fsCopy := featureStore.DeepCopy() + fsCopy.Spec.CronJob = &feastdevv1alpha1.FeastCronJob{ + Schedule: "0 0 * * *", + } + return fsCopy +} + func quotedSlice(stringSlice []string) string { quotedSlice := make([]string, len(stringSlice)) @@ -645,4 +674,76 @@ var _ = Describe("FeatureStore API", func() { }) }) }) + + Context("When creating a CronJob", func() { + ctx := context.Background() + + BeforeEach(func() { + By("verifying the custom resource FeatureStore is not there") + resource := &feastdevv1alpha1.FeatureStore{} + err := k8sClient.Get(ctx, typeNamespacedName, resource) + Expect(err != nil && errors.IsNotFound(err)).To(BeTrue()) + }) + AfterEach(func() { + By("Cleaning up the test resource") + resource := &feastdevv1alpha1.FeatureStore{} + err := k8sClient.Get(ctx, typeNamespacedName, resource) + if err == nil { + Expect(k8sClient.Delete(ctx, resource)).To(Succeed()) + } + err = k8sClient.Get(ctx, typeNamespacedName, resource) + Expect(err != nil && errors.IsNotFound(err)).To(BeTrue()) + }) + + Context("with annotations", func() { + It("should succeed when annotations are provided", func() { + featurestore := createFeatureStore() + resource := cronJobWithAnnotations(featurestore) + Expect(k8sClient.Create(ctx, resource)).To(Succeed()) + }) + + It("should succeed when annotations are empty", func() { + featurestore := createFeatureStore() + resource := cronJobWithEmptyAnnotations(featurestore) + Expect(k8sClient.Create(ctx, resource)).To(Succeed()) + }) + + It("should succeed when annotations are not specified", func() { + featurestore := createFeatureStore() + resource := cronJobWithoutAnnotations(featurestore) + Expect(k8sClient.Create(ctx, resource)).To(Succeed()) + }) + + It("should apply the annotations correctly in the status", func() { + featurestore := createFeatureStore() + resource := cronJobWithAnnotations(featurestore) + services.ApplyDefaultsToStatus(resource) + + Expect(resource.Status.Applied.CronJob).NotTo(BeNil()) + Expect(resource.Status.Applied.CronJob.Annotations).NotTo(BeNil()) + Expect(resource.Status.Applied.CronJob.Annotations).To(HaveLen(2)) + Expect(resource.Status.Applied.CronJob.Annotations["test-annotation"]).To(Equal("test-value")) + Expect(resource.Status.Applied.CronJob.Annotations["another-annotation"]).To(Equal("another-value")) + }) + + It("should keep empty annotations in the status", func() { + featurestore := createFeatureStore() + resource := cronJobWithEmptyAnnotations(featurestore) + services.ApplyDefaultsToStatus(resource) + + Expect(resource.Status.Applied.CronJob).NotTo(BeNil()) + Expect(resource.Status.Applied.CronJob.Annotations).NotTo(BeNil()) + Expect(resource.Status.Applied.CronJob.Annotations).To(BeEmpty()) + }) + + It("should have nil annotations in status when not specified", func() { + featurestore := createFeatureStore() + resource := cronJobWithoutAnnotations(featurestore) + services.ApplyDefaultsToStatus(resource) + + Expect(resource.Status.Applied.CronJob).NotTo(BeNil()) + Expect(resource.Status.Applied.CronJob.Annotations).To(BeNil()) + }) + }) + }) }) diff --git a/infra/feast-operator/test/e2e_rhoai/resources/custom-nb.yaml b/infra/feast-operator/test/e2e_rhoai/resources/custom-nb.yaml index 8c91cdc5f34..f23fd5f008f 100644 --- a/infra/feast-operator/test/e2e_rhoai/resources/custom-nb.yaml +++ b/infra/feast-operator/test/e2e_rhoai/resources/custom-nb.yaml @@ -9,9 +9,8 @@ apiVersion: kubeflow.org/v1 kind: Notebook metadata: annotations: - notebooks.opendatahub.io/inject-oauth: "true" + notebooks.opendatahub.io/inject-auth: "true" notebooks.opendatahub.io/last-size-selection: Small - notebooks.opendatahub.io/oauth-logout-url: https://odh-dashboard-{{.OpenDataHubNamespace}}.{{.IngressDomain}}/notebookController/kube-3aadmin/home opendatahub.io/link: https://jupyter-nb-kube-3aadmin-{{.Namespace}}.{{.IngressDomain}}/notebook/{{.Namespace}}/jupyter-nb-kube-3aadmin opendatahub.io/username: {{.Username}} generation: 1 @@ -78,80 +77,12 @@ spec: - mountPath: /opt/app-root/notebooks name: {{.NotebookConfigMapName}} workingDir: /opt/app-root/src - - args: - - --provider=openshift - - --https-address=:8443 - - --http-address= - - --openshift-service-account=jupyter-nb-kube-3aadmin - - --cookie-secret-file=/etc/oauth/config/cookie_secret - - --cookie-expire=24h0m0s - - --tls-cert=/etc/tls/private/tls.crt - - --tls-key=/etc/tls/private/tls.key - - --upstream=http://localhost:8888 - - --upstream-ca=/var/run/secrets/kubernetes.io/serviceaccount/ca.crt - - --skip-auth-regex=^(?:/notebook/test-feast-wb/jupyter-nb-kube-3aadmin)?/api$ - - --email-domain=* - - --skip-provider-button - - --openshift-sar={"verb":"get","resource":"notebooks","resourceAPIGroup":"kubeflow.org","resourceName":"jupyter-nb-kube-3aadmin","namespace":$(NAMESPACE)} - - --logout-url=https://odh-dashboard-{{.OpenDataHubNamespace}}.{{.IngressDomain}}/notebookController/kube-3aadmin/home - env: - - name: NAMESPACE - valueFrom: - fieldRef: - fieldPath: metadata.namespace - image: registry.redhat.io/openshift4/ose-oauth-proxy:v4.10 - imagePullPolicy: Always - livenessProbe: - failureThreshold: 3 - httpGet: - path: /oauth/healthz - port: oauth-proxy - scheme: HTTPS - initialDelaySeconds: 30 - periodSeconds: 5 - successThreshold: 1 - timeoutSeconds: 1 - name: oauth-proxy - ports: - - containerPort: 8443 - name: oauth-proxy - protocol: TCP - readinessProbe: - failureThreshold: 3 - httpGet: - path: /oauth/healthz - port: oauth-proxy - scheme: HTTPS - initialDelaySeconds: 5 - periodSeconds: 5 - successThreshold: 1 - timeoutSeconds: 1 - resources: - limits: - cpu: 100m - memory: 64Mi - requests: - cpu: 100m - memory: 64Mi - volumeMounts: - - mountPath: /etc/oauth/config - name: oauth-config - - mountPath: /etc/tls/private - name: tls-certificates enableServiceLinks: false - serviceAccountName: jupyter-nb-kube-3aadmin + serviceAccountName: default volumes: - name: jupyterhub-nb-kube-3aadmin-pvc persistentVolumeClaim: claimName: {{.NotebookPVC}} - - name: oauth-config - secret: - defaultMode: 420 - secretName: jupyter-nb-kube-3aadmin-oauth-config - - name: tls-certificates - secret: - defaultMode: 420 - secretName: jupyter-nb-kube-3aadmin-tls - name: {{.NotebookConfigMapName}} configMap: name: {{.NotebookConfigMapName}} diff --git a/infra/feast-operator/test/testdata/feast_integration_test_crs/feast.yaml b/infra/feast-operator/test/testdata/feast_integration_test_crs/feast.yaml index 8d311ab1de1..4a834c63e34 100644 --- a/infra/feast-operator/test/testdata/feast_integration_test_crs/feast.yaml +++ b/infra/feast-operator/test/testdata/feast_integration_test_crs/feast.yaml @@ -7,7 +7,7 @@ stringData: redis: | connection_string: redis.test-ns-feast.svc.cluster.local:6379 sql: | - path: postgresql+psycopg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres.test-ns-feast.svc.cluster.local:5432/${POSTGRES_DB} + path: postgresql+psycopg://${POSTGRESQL_USER}:${POSTGRESQL_PASSWORD}@postgres.test-ns-feast.svc.cluster.local:5432/${POSTGRESQL_DATABASE} cache_ttl_seconds: 60 sqlalchemy_config_kwargs: echo: false diff --git a/infra/feast-operator/test/testdata/feast_integration_test_crs/postgres.yaml b/infra/feast-operator/test/testdata/feast_integration_test_crs/postgres.yaml index f6799e1c700..8fbb11cf1cd 100644 --- a/infra/feast-operator/test/testdata/feast_integration_test_crs/postgres.yaml +++ b/infra/feast-operator/test/testdata/feast_integration_test_crs/postgres.yaml @@ -4,9 +4,9 @@ metadata: name: postgres-secret namespace: test-ns-feast stringData: - POSTGRES_DB: feast - POSTGRES_USER: feast - POSTGRES_PASSWORD: feast + POSTGRESQL_DATABASE: feast + POSTGRESQL_USER: feast + POSTGRESQL_PASSWORD: feast --- apiVersion: apps/v1 kind: Deployment @@ -25,7 +25,7 @@ spec: spec: containers: - name: postgres - image: 'postgres:16-alpine' + image: 'quay.io/sclorg/postgresql-16-c9s@sha256:5879226a0fd2ea295df6836cc30ab624d2a1c51b81b3406284885604e10ddefe' ports: - containerPort: 5432 envFrom: diff --git a/infra/feast-operator/test/testdata/feast_integration_test_crs/redis.yaml b/infra/feast-operator/test/testdata/feast_integration_test_crs/redis.yaml index 57f2765e97e..cd88fb88fe5 100644 --- a/infra/feast-operator/test/testdata/feast_integration_test_crs/redis.yaml +++ b/infra/feast-operator/test/testdata/feast_integration_test_crs/redis.yaml @@ -15,7 +15,7 @@ spec: spec: containers: - name: redis - image: 'quay.io/sclorg/redis-7-c9s' + image: 'quay.io/sclorg/redis-7-c9s@sha256:ce07d358cea749e67bcc77f73b2c5244d771ac0781ed20d7ebb2ba271c169173' ports: - containerPort: 6379 env: diff --git a/infra/website/docs/blog/feast-ray-distributed-processing.md b/infra/website/docs/blog/feast-ray-distributed-processing.md new file mode 100644 index 00000000000..cc637da4e68 --- /dev/null +++ b/infra/website/docs/blog/feast-ray-distributed-processing.md @@ -0,0 +1,226 @@ +--- +title: "Scaling ML with Feast and Ray: Distributed Processing for Modern AI Applications" +description: "Learn how Feast's integration with Ray enables distributed processing for both traditional feature engineering and modern RAG applications, with support for Kubernetes deployment through KubeRay." +date: 2025-10-29 +authors: ["Nikhil Kathole"] +--- + +
+ Feast + Ray Architecture for Distributed Processing +
+ +In today's data-driven world, organizations are increasingly turning to distributed computing to handle large-scale machine learning workloads. When it comes to feature engineering and retrieval-augmented generation (RAG) systems, the combination of **Feast** and **Ray** provides a powerful solution for building scalable, production-ready pipelines. + +This blog post explores how Feast's integration with Ray enables distributed processing for both traditional feature engineering and modern RAG applications, with support for Kubernetes deployment through KubeRay. + +## The Scaling Challenge + +Modern ML teams face critical scaling challenges: + +- **Massive Datasets**: Processing millions of documents for embedding generation +- **Complex Transformations**: CPU-intensive operations like text processing and feature engineering +- **Real-time Requirements**: Low-latency retrieval for RAG applications +- **Resource Efficiency**: Optimal utilization of compute resources across clusters + +## Building Scalable Feature Pipelines and RAG Systems with Distributed Computing + +Feast's integration with Ray addresses these challenges head-on, providing a unified platform where distributed processing is the default, not an afterthought. The magic happens when you realize that embedding generation, one of the most computationally intensive tasks in modern AI, can be treated as just another transformation in your feature pipeline. + +### The Ray RAG Revolution + +Consider the Ray RAG template, which demonstrates this new approach in action: + +```bash +# Built-in RAG template with distributed embedding generation +feast init -t ray_rag my_rag_project +cd my_rag_project/feature_repo +``` + +This single command gives you a complete system that can process thousands of documents in parallel, generate embeddings using distributed computing, and serve them through a vector database. + +The Ray RAG template demonstrates: + +- **Parallel Embedding Generation**: Distribute embedding computation across workers +- **Vector Search Integration**: Seamless integration with vector databases for similarity search +- **Complete RAG Pipeline**: Data → Embeddings → Search in one workflow + +## Embedding Generation as a Feast Transformation + +Feast's Ray integration makes embedding generation a first-class transformation operation. When you define a transformation in Feast, Ray handles the complexity of distributed processing. It partitions your data, distributes the computation across available workers, and manages the orchestration, all transparently to the developer. Here's how it works in practice: + +### Distributed Embedding Processing + +```python +from feast import BatchFeatureView, Entity, Field, FileSource +from feast.types import Array, Float32, String +from datetime import timedelta + +# Embedding processor for distributed Ray processing +class EmbeddingProcessor: + """Generate embeddings using SentenceTransformer model.""" + + def __init__(self): + import torch + from sentence_transformers import SentenceTransformer + + device = "cuda" if torch.cuda.is_available() else "cpu" + self.model = SentenceTransformer("all-MiniLM-L6-v2", device=device) + + def __call__(self, batch): + """Process batch and generate embeddings.""" + descriptions = batch["Description"].fillna("").tolist() + embeddings = self.model.encode( + descriptions, + show_progress_bar=False, + batch_size=128, + normalize_embeddings=True, + convert_to_numpy=True, + ) + batch["embedding"] = embeddings.tolist() + return batch + +# Ray native UDF for distributed processing +def generate_embeddings_ray_native(ds): + """Distributed embedding generation using Ray Data.""" + max_workers = 8 + batch_size = 2500 + + # Optimize partitioning for available workers + num_blocks = ds.num_blocks() + if num_blocks < max_workers: + ds = ds.repartition(max_workers) + + result = ds.map_batches( + EmbeddingProcessor, + batch_format="pandas", + concurrency=max_workers, + batch_size=batch_size, + ) + return result + +# Feature view with Ray transformation +document_embeddings_view = BatchFeatureView( + name="document_embeddings", + entities=[document], + mode="ray", # Native Ray Dataset mode + ttl=timedelta(days=365 * 100), + schema=[ + Field(name="document_id", dtype=String), + Field(name="embedding", dtype=Array(Float32), vector_index=True), + Field(name="movie_name", dtype=String), + Field(name="movie_director", dtype=String), + ], + source=movies_source, + udf=generate_embeddings_ray_native, + online=True, +) +``` + +### RAG Query Example + +```python +from feast import FeatureStore +from sentence_transformers import SentenceTransformer + +# Initialize feature store +store = FeatureStore(repo_path=".") + +# Generate query embedding +model = SentenceTransformer("all-MiniLM-L6-v2") +query_embedding = model.encode(["sci-fi movie about space"])[0].tolist() + +# Retrieve similar documents +results = store.retrieve_online_documents_v2( + features=[ + "document_embeddings:embedding", + "document_embeddings:movie_name", + "document_embeddings:movie_director", + ], + query=query_embedding, + top_k=5, +).to_dict() + +# Display results +for i in range(len(results["document_id_pk"])): + print(f"{i+1}. {results['movie_name'][i]}") + print(f" Director: {results['movie_director'][i]}") + print(f" Distance: {results['distance'][i]:.3f}") +``` + +## Component Responsibilities + +The Feast + Ray integration follows a clear separation of concerns: + +- **Ray Compute Engine**: Executes distributed feature computations, transformations, and joins +- **Ray Offline Store**: Handles data I/O operations, reading from various sources (Parquet, CSV, etc.) + +This architectural separation ensures that each component has a single responsibility, making the system more maintainable and allowing for independent optimization of data access and computation layers. + +## Ray Integration Modes + +Feast supports three execution modes for Ray integration: + +### 1. Local Development +Perfect for experimentation and testing: + +```yaml +offline_store: + type: ray + storage_path: data/ray_storage + # Conservative settings for local development + broadcast_join_threshold_mb: 25 + max_parallelism_multiplier: 1 + target_partition_size_mb: 16 +``` + +### 2. Remote Ray Cluster +Connect to existing Ray infrastructure: + +```yaml +offline_store: + type: ray + storage_path: s3://my-bucket/feast-data + ray_address: "ray://my-cluster.example.com:10001" +``` + +### 3. Kubernetes with KubeRay +Enterprise-ready deployment: + +```yaml +offline_store: + type: ray + storage_path: s3://my-bucket/feast-data + use_kuberay: true + kuberay_conf: + cluster_name: "feast-ray-cluster" + namespace: "feast-system" +``` + +## Getting Started + +### Install Feast with Ray Support +```bash +pip install feast[ray] +``` + +### Initialize Ray RAG Template +```bash +# RAG applications with distributed embedding generation +feast init -t ray_rag my_rag_project +cd my_rag_project/feature_repo +``` + +### Deploy to Production +```bash +feast apply +feast materialize --disable-event-timestamp +python test_workflow.py +``` + +Whether you're building traditional feature pipelines or modern RAG systems, Feast + Ray offers the scalability and performance needed for production workloads. The integration supports everything from local development to large-scale Kubernetes deployments, making it an ideal choice for organizations looking to scale their ML infrastructure. + +--- + +**Ready to build distributed RAG applications?** Get started with our [Ray RAG template](https://docs.feast.dev/reference/compute-engine/ray) and explore [Feast + Ray documentation](https://docs.feast.dev/reference/offline-stores/ray) for distributed embedding generation. + +*Learn more about Feast's distributed processing capabilities and join the community at [feast.dev](https://feast.dev).* diff --git a/infra/website/public/images/blog/feast_ray_architecture.png b/infra/website/public/images/blog/feast_ray_architecture.png new file mode 100644 index 00000000000..649d912a790 Binary files /dev/null and b/infra/website/public/images/blog/feast_ray_architecture.png differ diff --git a/infra/website/public/images/logos/castai.png b/infra/website/public/images/logos/castai.png new file mode 100644 index 00000000000..66eac3be3f5 Binary files /dev/null and b/infra/website/public/images/logos/castai.png differ diff --git a/infra/website/src/pages/index.astro b/infra/website/src/pages/index.astro index fd651d86a02..9c0b8ba1d9e 100644 --- a/infra/website/src/pages/index.astro +++ b/infra/website/src/pages/index.astro @@ -114,6 +114,9 @@ features = store.retrieve_online_documents(
+
+ +
diff --git a/infra/website/src/styles/global.css b/infra/website/src/styles/global.css index f6bcbf24f18..95163f38941 100644 --- a/infra/website/src/styles/global.css +++ b/infra/website/src/styles/global.css @@ -265,15 +265,17 @@ main::before { /* Logo grid */ .logo-grid { - display: grid; - grid-template-columns: repeat(5, 1fr); + display: flex; + flex-wrap: wrap; gap: 32px; max-width: 1000px; margin: 0 auto; padding: 20px 20px var(--spacing-xl); + justify-content: center; } .logo-item { + flex: 0 0 150px; display: flex; align-items: center; justify-content: center; diff --git a/java/pom.xml b/java/pom.xml index d9625cecf49..4f32be3bdf8 100644 --- a/java/pom.xml +++ b/java/pom.xml @@ -35,7 +35,7 @@ - 0.56.0 + 0.57.0 https://github.com/feast-dev/feast UTF-8 diff --git a/protos/feast/core/DataFormat.proto b/protos/feast/core/DataFormat.proto index 0a32089b0f3..98464fa3c2b 100644 --- a/protos/feast/core/DataFormat.proto +++ b/protos/feast/core/DataFormat.proto @@ -27,12 +27,59 @@ message FileFormat { // Defines options for the Parquet data format message ParquetFormat {} - // Defines options for delta data format - message DeltaFormat {} - oneof format { ParquetFormat parquet_format = 1; + // Deprecated: Delta Lake is a table format, not a file format. + // Use TableFormat.DeltaFormat instead for Delta Lake support. + TableFormat.DeltaFormat delta_format = 2 [deprecated = true]; + } +} + +message TableFormat { + // Defines options for Apache Iceberg table format + message IcebergFormat { + // Optional catalog name for the Iceberg table + string catalog = 1; + + // Optional namespace (schema/database) within the catalog + string namespace = 2; + + // Additional properties for Iceberg configuration + // Examples: warehouse location, snapshot-id, as-of-timestamp, etc. + map properties = 3; + } + + // Defines options for Delta Lake table format + message DeltaFormat { + // Optional checkpoint location for Delta transaction logs + string checkpoint_location = 1; + + // Additional properties for Delta configuration + // Examples: auto-optimize settings, vacuum settings, etc. + map properties = 2; + } + + // Defines options for Apache Hudi table format + message HudiFormat { + // Type of Hudi table (COPY_ON_WRITE or MERGE_ON_READ) + string table_type = 1; + + // Field(s) that uniquely identify a record + string record_key = 2; + + // Field used to determine the latest version of a record + string precombine_field = 3; + + // Additional properties for Hudi configuration + // Examples: compaction strategy, indexing options, etc. + map properties = 4; + } + + // Specifies the table format and format-specific options + oneof format { + IcebergFormat iceberg_format = 1; DeltaFormat delta_format = 2; + HudiFormat hudi_format = 3; } } diff --git a/protos/feast/core/DataSource.proto b/protos/feast/core/DataSource.proto index b27767f527c..b91296dca31 100644 --- a/protos/feast/core/DataSource.proto +++ b/protos/feast/core/DataSource.proto @@ -36,7 +36,7 @@ message DataSource { reserved 6 to 10; // Type of Data Source. - // Next available id: 12 + // Next available id: 13 enum SourceType { INVALID = 0; BATCH_FILE = 1; @@ -231,6 +231,9 @@ message DataSource { // Date Format of date partition column (e.g. %Y-%m-%d) string date_partition_column_format = 5; + + // Table Format (e.g. iceberg, delta, hudi) + TableFormat table_format = 6; } // Defines configuration for custom third-party data sources. diff --git a/sdk/python/feast/data_format.py b/sdk/python/feast/data_format.py index 301dfb81302..409c1500f88 100644 --- a/sdk/python/feast/data_format.py +++ b/sdk/python/feast/data_format.py @@ -17,6 +17,7 @@ from feast.protos.feast.core.DataFormat_pb2 import FileFormat as FileFormatProto from feast.protos.feast.core.DataFormat_pb2 import StreamFormat as StreamFormatProto +from feast.protos.feast.core.DataFormat_pb2 import TableFormat as TableFormatProto class FileFormat(ABC): @@ -70,11 +71,12 @@ def __str__(self): class DeltaFormat(FileFormat): """ - Defines delta data format + Defines delta data format (deprecated - use TableFormat.DeltaFormat instead) """ def to_proto(self): - return FileFormatProto(delta_format=FileFormatProto.DeltaFormat()) + # Reference TableFormat.DeltaFormat since DeltaFormat is now nested there + return FileFormatProto(delta_format=TableFormatProto.DeltaFormat()) def __str__(self): return "delta" diff --git a/sdk/python/feast/feature_server.py b/sdk/python/feast/feature_server.py index fee7e56e9c1..e88b1eb5c28 100644 --- a/sdk/python/feast/feature_server.py +++ b/sdk/python/feast/feature_server.py @@ -344,18 +344,27 @@ async def push(request: PushFeaturesRequest) -> None: async def _get_feast_object( feature_view_name: str, allow_registry_cache: bool ) -> FeastObject: + # FIXME: this logic repeated at least 3 times in the codebase - should be centralized + # in logging, in server and in feature_store (Python SDK) try: - return await run_in_threadpool( - store.get_stream_feature_view, - feature_view_name, - allow_registry_cache=allow_registry_cache, - ) - except FeatureViewNotFoundException: return await run_in_threadpool( store.get_feature_view, feature_view_name, allow_registry_cache=allow_registry_cache, ) + except FeatureViewNotFoundException: + try: + return await run_in_threadpool( + store.get_on_demand_feature_view, + feature_view_name, + allow_registry_cache=allow_registry_cache, + ) + except FeatureViewNotFoundException: + return await run_in_threadpool( + store.get_stream_feature_view, + feature_view_name, + allow_registry_cache=allow_registry_cache, + ) @app.post("/write-to-online-store", dependencies=[Depends(inject_user_details)]) async def write_to_online_store(request: WriteToFeatureStoreRequest) -> None: diff --git a/sdk/python/feast/infra/compute_engines/utils.py b/sdk/python/feast/infra/compute_engines/utils.py index 20a3dae981d..d2c49305376 100644 --- a/sdk/python/feast/infra/compute_engines/utils.py +++ b/sdk/python/feast/infra/compute_engines/utils.py @@ -21,20 +21,40 @@ def create_offline_store_retrieval_job( context: start_time: end_time: - Returns: """ offline_store = context.offline_store - # 📥 Reuse Feast's robust query resolver - retrieval_job = offline_store.pull_all_from_table_or_query( - config=context.repo_config, - data_source=data_source, - join_key_columns=column_info.join_keys, - feature_name_columns=column_info.feature_cols, - timestamp_field=column_info.ts_col, - created_timestamp_column=column_info.created_ts_col, - start_date=start_time, - end_date=end_time, - ) + + pull_latest = context.repo_config.materialization_config.pull_latest_features + + if pull_latest: + if not start_time or not end_time: + raise ValueError( + "start_time and end_time must be provided when pull_latest_features is True" + ) + + retrieval_job = offline_store.pull_latest_from_table_or_query( + config=context.repo_config, + data_source=data_source, + join_key_columns=column_info.join_keys, + feature_name_columns=column_info.feature_cols, + timestamp_field=column_info.ts_col, + created_timestamp_column=column_info.created_ts_col, + start_date=start_time, + end_date=end_time, + ) + else: + # 📥 Reuse Feast's robust query resolver + retrieval_job = offline_store.pull_all_from_table_or_query( + config=context.repo_config, + data_source=data_source, + join_key_columns=column_info.join_keys, + feature_name_columns=column_info.feature_cols, + timestamp_field=column_info.ts_col, + created_timestamp_column=column_info.created_ts_col, + start_date=start_time, + end_date=end_time, + ) + return retrieval_job diff --git a/sdk/python/feast/infra/feature_servers/multicloud/requirements.txt b/sdk/python/feast/infra/feature_servers/multicloud/requirements.txt index 4b3c6f959f0..4f144773215 100644 --- a/sdk/python/feast/infra/feature_servers/multicloud/requirements.txt +++ b/sdk/python/feast/infra/feature_servers/multicloud/requirements.txt @@ -1,2 +1,2 @@ # keep VERSION on line #2, this is critical to release CI -feast[minimal] == 0.56.0 +feast[minimal] == 0.57.0 diff --git a/sdk/python/feast/infra/offline_stores/contrib/spark_offline_store/spark_source.py b/sdk/python/feast/infra/offline_stores/contrib/spark_offline_store/spark_source.py index bd4fb1ac817..6f2af7054b4 100644 --- a/sdk/python/feast/infra/offline_stores/contrib/spark_offline_store/spark_source.py +++ b/sdk/python/feast/infra/offline_stores/contrib/spark_offline_store/spark_source.py @@ -14,17 +14,17 @@ ) from feast.repo_config import RepoConfig from feast.saved_dataset import SavedDatasetStorage +from feast.table_format import TableFormat, table_format_from_proto from feast.type_map import spark_to_feast_value_type from feast.value_type import ValueType logger = logging.getLogger(__name__) -class SparkSourceFormat(Enum): +class SparkFileSourceFormat(Enum): csv = "csv" json = "json" parquet = "parquet" - delta = "delta" avro = "avro" @@ -42,6 +42,7 @@ def __init__( query: Optional[str] = None, path: Optional[str] = None, file_format: Optional[str] = None, + table_format: Optional[TableFormat] = None, created_timestamp_column: Optional[str] = None, field_mapping: Optional[Dict[str, str]] = None, description: Optional[str] = "", @@ -58,7 +59,9 @@ def __init__( table: The name of a Spark table. query: The query to be executed in Spark. path: The path to file data. - file_format: The format of the file data. + file_format: The underlying file format (parquet, avro, csv, json). + table_format: The table metadata format (iceberg, delta, hudi, etc.). + Optional and separate from file_format. created_timestamp_column: Timestamp column indicating when the row was created, used for deduplicating rows. field_mapping: A dictionary mapping of column names in this data @@ -70,7 +73,7 @@ def __init__( timestamp_field: Event timestamp field used for point-in-time joins of feature values. date_partition_column: The column to partition the data on for faster - retrieval. This is useful for large tables and will limit the number ofi + retrieval. This is useful for large tables and will limit the number of """ # If no name, use the table as the default name. if name is None and table is None: @@ -102,6 +105,7 @@ def __init__( path=path, file_format=file_format, date_partition_column_format=date_partition_column_format, + table_format=table_format, ) @property @@ -132,6 +136,13 @@ def file_format(self): """ return self.spark_options.file_format + @property + def table_format(self): + """ + Returns the table format of this feature data source. + """ + return self.spark_options.table_format + @property def date_partition_column_format(self): """ @@ -151,6 +162,7 @@ def from_proto(data_source: DataSourceProto) -> Any: query=spark_options.query, path=spark_options.path, file_format=spark_options.file_format, + table_format=spark_options.table_format, date_partition_column_format=spark_options.date_partition_column_format, date_partition_column=data_source.date_partition_column, timestamp_field=data_source.timestamp_field, @@ -219,7 +231,7 @@ def get_table_query_string(self) -> str: if spark_session is None: raise AssertionError("Could not find an active spark session.") try: - df = spark_session.read.format(self.file_format).load(self.path) + df = self._load_dataframe_from_path(spark_session) except Exception: logger.exception( "Spark read of file source failed.\n" + traceback.format_exc() @@ -230,6 +242,24 @@ def get_table_query_string(self) -> str: return f"`{tmp_table_name}`" + def _load_dataframe_from_path(self, spark_session): + """Load DataFrame from path, considering both file format and table format.""" + + if self.table_format is None: + # No table format specified, use standard file reading with file_format + return spark_session.read.format(self.file_format).load(self.path) + + # Build reader with table format and options + reader = spark_session.read.format(self.table_format.format_type.value) + + # Add table format specific options + for key, value in self.table_format.properties.items(): + reader = reader.option(key, value) + + # For catalog-based table formats like Iceberg, the path is actually a table name + # For file-based formats, it's still a file path + return reader.load(self.path) + def __eq__(self, other): base_eq = super().__eq__(other) if not base_eq: @@ -245,7 +275,7 @@ def __hash__(self): class SparkOptions: - allowed_formats = [format.value for format in SparkSourceFormat] + allowed_formats = [format.value for format in SparkFileSourceFormat] def __init__( self, @@ -254,6 +284,7 @@ def __init__( path: Optional[str], file_format: Optional[str], date_partition_column_format: Optional[str] = "%Y-%m-%d", + table_format: Optional[TableFormat] = None, ): # Check that only one of the ways to load a spark dataframe can be used. We have # to treat empty string and null the same due to proto (de)serialization. @@ -262,11 +293,14 @@ def __init__( "Exactly one of params(table, query, path) must be specified." ) if path: - if not file_format: + # If table_format is specified, file_format is optional (table format determines the reader) + # If no table_format, file_format is required for basic file reading + if not table_format and not file_format: raise ValueError( - "If 'path' is specified, then 'file_format' is required." + "If 'path' is specified without 'table_format', then 'file_format' is required." ) - if file_format not in self.allowed_formats: + # Only validate file_format if it's provided (it's optional with table_format) + if file_format and file_format not in self.allowed_formats: raise ValueError( f"'file_format' should be one of {self.allowed_formats}" ) @@ -276,6 +310,7 @@ def __init__( self._path = path self._file_format = file_format self._date_partition_column_format = date_partition_column_format + self._table_format = table_format @property def table(self): @@ -317,6 +352,14 @@ def date_partition_column_format(self): def date_partition_column_format(self, date_partition_column_format): self._date_partition_column_format = date_partition_column_format + @property + def table_format(self): + return self._table_format + + @table_format.setter + def table_format(self, table_format): + self._table_format = table_format + @classmethod def from_proto(cls, spark_options_proto: DataSourceProto.SparkOptions): """ @@ -326,12 +369,18 @@ def from_proto(cls, spark_options_proto: DataSourceProto.SparkOptions): Returns: Returns a SparkOptions object based on the spark_options protobuf """ + # Parse table_format if present + table_format = None + if spark_options_proto.HasField("table_format"): + table_format = table_format_from_proto(spark_options_proto.table_format) + spark_options = cls( table=spark_options_proto.table, query=spark_options_proto.query, path=spark_options_proto.path, file_format=spark_options_proto.file_format, date_partition_column_format=spark_options_proto.date_partition_column_format, + table_format=table_format, ) return spark_options @@ -342,6 +391,10 @@ def to_proto(self) -> DataSourceProto.SparkOptions: Returns: SparkOptionsProto protobuf """ + table_format_proto = None + if self.table_format: + table_format_proto = self.table_format.to_proto() + spark_options_proto = DataSourceProto.SparkOptions( table=self.table, query=self.query, @@ -350,6 +403,9 @@ def to_proto(self) -> DataSourceProto.SparkOptions: date_partition_column_format=self.date_partition_column_format, ) + if table_format_proto: + spark_options_proto.table_format.CopyFrom(table_format_proto) + return spark_options_proto @@ -364,12 +420,14 @@ def __init__( query: Optional[str] = None, path: Optional[str] = None, file_format: Optional[str] = None, + table_format: Optional[TableFormat] = None, ): self.spark_options = SparkOptions( table=table, query=query, path=path, file_format=file_format, + table_format=table_format, ) @staticmethod @@ -380,6 +438,7 @@ def from_proto(storage_proto: SavedDatasetStorageProto) -> SavedDatasetStorage: query=spark_options.query, path=spark_options.path, file_format=spark_options.file_format, + table_format=spark_options.table_format, ) def to_proto(self) -> SavedDatasetStorageProto: @@ -391,4 +450,5 @@ def to_data_source(self) -> DataSource: query=self.spark_options.query, path=self.spark_options.path, file_format=self.spark_options.file_format, + table_format=self.spark_options.table_format, ) diff --git a/sdk/python/feast/infra/offline_stores/contrib/trino_offline_store/trino_type_map.py b/sdk/python/feast/infra/offline_stores/contrib/trino_offline_store/trino_type_map.py index 72f58aef43f..8d21f9a6ac5 100644 --- a/sdk/python/feast/infra/offline_stores/contrib/trino_offline_store/trino_type_map.py +++ b/sdk/python/feast/infra/offline_stores/contrib/trino_offline_store/trino_type_map.py @@ -14,13 +14,37 @@ def trino_to_feast_value_type(trino_type_as_str: str) -> ValueType: "integer": ValueType.INT32, "bigint": ValueType.INT64, "double": ValueType.DOUBLE, - "decimal": ValueType.FLOAT, + "decimal32": ValueType.FLOAT, + "decimal64": ValueType.DOUBLE, "timestamp": ValueType.UNIX_TIMESTAMP, "char": ValueType.STRING, "varchar": ValueType.STRING, "boolean": ValueType.BOOL, + "real": ValueType.FLOAT, } - return type_map[trino_type_as_str.lower()] + _trino_type_as_str: str = trino_type_as_str + trino_type_as_str = trino_type_as_str.lower() + + if trino_type_as_str.startswith("decimal"): + search_precision = re.search( + r"^decimal\((\d+)(?>,\s?\d+)?\)$", trino_type_as_str + ) + if search_precision: + precision = int(search_precision.group(1)) + if precision > 32: + trino_type_as_str = "decimal64" + else: + trino_type_as_str = "decimal32" + + elif trino_type_as_str.startswith("timestamp"): + trino_type_as_str = "timestamp" + + elif trino_type_as_str.startswith("varchar"): + trino_type_as_str = "varchar" + + if trino_type_as_str not in type_map: + raise ValueError(f"Trino type not supported by feast {_trino_type_as_str}") + return type_map[trino_type_as_str] def pa_to_trino_value_type(pa_type_as_str: str) -> str: diff --git a/sdk/python/feast/infra/offline_stores/remote.py b/sdk/python/feast/infra/offline_stores/remote.py index abe75ca57e5..e0a1df573d4 100644 --- a/sdk/python/feast/infra/offline_stores/remote.py +++ b/sdk/python/feast/infra/offline_stores/remote.py @@ -443,7 +443,16 @@ def get_table_column_names_and_types_from_data_source( return zip(table.column("name").to_pylist(), table.column("type").to_pylist()) -def _create_retrieval_metadata(feature_refs: List[str], entity_df: pd.DataFrame): +def _create_retrieval_metadata( + feature_refs: List[str], entity_df: Optional[pd.DataFrame] = None +): + if entity_df is None: + return RetrievalMetadata( + features=feature_refs, + keys=[], # No entity keys when no entity_df provided + min_event_timestamp=None, + max_event_timestamp=None, + ) entity_schema = _get_entity_schema( entity_df=entity_df, ) diff --git a/sdk/python/feast/infra/online_stores/mysql_online_store/README.md b/sdk/python/feast/infra/online_stores/mysql_online_store/README.md index ac38237cd11..7b6e97091d9 100644 --- a/sdk/python/feast/infra/online_stores/mysql_online_store/README.md +++ b/sdk/python/feast/infra/online_stores/mysql_online_store/README.md @@ -25,6 +25,9 @@ online_store: user: test # mysql user, default to test password: test # mysql password, default to test database: feast # mysql database, default to feast + batch_write: false # supporting batch write and commit per batch + batch_size: 100 # batch size, default to 100 + ``` #### Apply the feature definitions in `example.py` diff --git a/sdk/python/feast/infra/online_stores/mysql_online_store/mysql.py b/sdk/python/feast/infra/online_stores/mysql_online_store/mysql.py index d44eddfbd0b..2172f3aa359 100644 --- a/sdk/python/feast/infra/online_stores/mysql_online_store/mysql.py +++ b/sdk/python/feast/infra/online_stores/mysql_online_store/mysql.py @@ -30,6 +30,8 @@ class MySQLOnlineStoreConfig(FeastConfigBaseModel): password: Optional[StrictStr] = None database: Optional[StrictStr] = None port: Optional[int] = None + batch_write: Optional[bool] = False + batch_size: Optional[int] = None class MySQLOnlineStore(OnlineStore): @@ -51,7 +53,7 @@ def _get_conn(self, config: RepoConfig) -> Connection: password=online_store_config.password or "test", database=online_store_config.database or "feast", port=online_store_config.port or 3306, - autocommit=True, + autocommit=(not online_store_config.batch_write), ) return self._conn @@ -69,29 +71,97 @@ def online_write_batch( project = config.project - for entity_key, values, timestamp, created_ts in data: - entity_key_bin = serialize_entity_key( - entity_key, - entity_key_serialization_version=3, - ).hex() - timestamp = to_naive_utc(timestamp) - if created_ts is not None: - created_ts = to_naive_utc(created_ts) - - for feature_name, val in values.items(): - self.write_to_table( - created_ts, - cur, - entity_key_bin, - feature_name, - project, - table, - timestamp, - val, - ) - conn.commit() - if progress: - progress(1) + batch_write = config.online_store.batch_write + if not batch_write: + for entity_key, values, timestamp, created_ts in data: + entity_key_bin = serialize_entity_key( + entity_key, + entity_key_serialization_version=3, + ).hex() + timestamp = to_naive_utc(timestamp) + if created_ts is not None: + created_ts = to_naive_utc(created_ts) + + for feature_name, val in values.items(): + self.write_to_table( + created_ts, + cur, + entity_key_bin, + feature_name, + project, + table, + timestamp, + val, + ) + conn.commit() + if progress: + progress(1) + else: + batch_size = config.online_store.bacth_size + if not batch_size or batch_size < 2: + raise ValueError("Batch size must be at least 2") + insert_values = [] + for entity_key, values, timestamp, created_ts in data: + entity_key_bin = serialize_entity_key( + entity_key, + entity_key_serialization_version=2, + ).hex() + timestamp = to_naive_utc(timestamp) + if created_ts is not None: + created_ts = to_naive_utc(created_ts) + + for feature_name, val in values.items(): + serialized_val = val.SerializeToString() + insert_values.append( + ( + entity_key_bin, + feature_name, + serialized_val, + timestamp, + created_ts, + ) + ) + + if len(insert_values) >= batch_size: + try: + self._execute_batch(cur, project, table, insert_values) + conn.commit() + if progress: + progress(len(insert_values)) + except Exception as e: + conn.rollback() + raise e + insert_values.clear() + + if insert_values: + try: + self._execute_batch(cur, project, table, insert_values) + conn.commit() + if progress: + progress(len(insert_values)) + except Exception as e: + conn.rollback() + raise e + + def _execute_batch(self, cur, project, table, insert_values): + sql = f""" + INSERT INTO {_table_id(project, table)} + (entity_key, feature_name, value, event_ts, created_ts) + values (%s, %s, %s, %s, %s) + ON DUPLICATE KEY UPDATE + value = VALUES(value), + event_ts = VALUES(event_ts), + created_ts = VALUES(created_ts); + """ + try: + cur.executemany(sql, insert_values) + except Exception as e: + # Log SQL info for debugging without leaking sensitive data + first_sample = insert_values[0] if insert_values else None + raise RuntimeError( + f"Failed to execute batch insert into table '{_table_id(project, table)}' " + f"(rows={len(insert_values)}, sample={first_sample}): {e}" + ) from e @staticmethod def write_to_table( diff --git a/sdk/python/feast/infra/online_stores/mysql_online_store/mysql_repo_configuration.py b/sdk/python/feast/infra/online_stores/mysql_online_store/mysql_repo_configuration.py index 3e92ead2d0b..e5a1c0114c3 100644 --- a/sdk/python/feast/infra/online_stores/mysql_online_store/mysql_repo_configuration.py +++ b/sdk/python/feast/infra/online_stores/mysql_online_store/mysql_repo_configuration.py @@ -2,9 +2,11 @@ IntegrationTestRepoConfig, ) from tests.integration.feature_repos.universal.online_store.mysql import ( + BatchWriteMySQLOnlineStoreCreator, MySQLOnlineStoreCreator, ) FULL_REPO_CONFIGS = [ IntegrationTestRepoConfig(online_store_creator=MySQLOnlineStoreCreator), + IntegrationTestRepoConfig(online_store_creator=BatchWriteMySQLOnlineStoreCreator), ] diff --git a/sdk/python/feast/infra/utils/clickhouse/connection_utils.py b/sdk/python/feast/infra/utils/clickhouse/connection_utils.py index e60922e478d..88f5334db14 100644 --- a/sdk/python/feast/infra/utils/clickhouse/connection_utils.py +++ b/sdk/python/feast/infra/utils/clickhouse/connection_utils.py @@ -1,18 +1,22 @@ -from functools import cache +import threading import clickhouse_connect from clickhouse_connect.driver import Client from feast.infra.utils.clickhouse.clickhouse_config import ClickhouseConfig +thread_local = threading.local() + -@cache def get_client(config: ClickhouseConfig) -> Client: - client = clickhouse_connect.get_client( - host=config.host, - port=config.port, - user=config.user, - password=config.password, - database=config.database, - ) - return client + # Clickhouse client is not thread-safe, so we need to create a separate instance for each thread. + if not hasattr(thread_local, "clickhouse_client"): + thread_local.clickhouse_client = clickhouse_connect.get_client( + host=config.host, + port=config.port, + user=config.user, + password=config.password, + database=config.database, + ) + + return thread_local.clickhouse_client diff --git a/sdk/python/feast/offline_server.py b/sdk/python/feast/offline_server.py index 6bc573888fc..bcdf808868b 100644 --- a/sdk/python/feast/offline_server.py +++ b/sdk/python/feast/offline_server.py @@ -431,6 +431,9 @@ def get_historical_features(self, command: dict, key: Optional[str] = None): # Extract parameters from the internal flights dictionary entity_df_value = self.flights[key] entity_df = pa.Table.to_pandas(entity_df_value) + # Check if this is a mock/empty table (contains only 'key' column) + if len(entity_df.columns) == 1 and "key" in entity_df.columns: + entity_df = None feature_view_names = command["feature_view_names"] name_aliases = command["name_aliases"] diff --git a/sdk/python/feast/protos/feast/core/DataFormat_pb2.py b/sdk/python/feast/protos/feast/core/DataFormat_pb2.py index a3883dcec3b..b90958cb325 100644 --- a/sdk/python/feast/protos/feast/core/DataFormat_pb2.py +++ b/sdk/python/feast/protos/feast/core/DataFormat_pb2.py @@ -14,7 +14,7 @@ -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1b\x66\x65\x61st/core/DataFormat.proto\x12\nfeast.core\"\xb2\x01\n\nFileFormat\x12>\n\x0eparquet_format\x18\x01 \x01(\x0b\x32$.feast.core.FileFormat.ParquetFormatH\x00\x12:\n\x0c\x64\x65lta_format\x18\x02 \x01(\x0b\x32\".feast.core.FileFormat.DeltaFormatH\x00\x1a\x0f\n\rParquetFormat\x1a\r\n\x0b\x44\x65ltaFormatB\x08\n\x06\x66ormat\"\xb7\x02\n\x0cStreamFormat\x12:\n\x0b\x61vro_format\x18\x01 \x01(\x0b\x32#.feast.core.StreamFormat.AvroFormatH\x00\x12<\n\x0cproto_format\x18\x02 \x01(\x0b\x32$.feast.core.StreamFormat.ProtoFormatH\x00\x12:\n\x0bjson_format\x18\x03 \x01(\x0b\x32#.feast.core.StreamFormat.JsonFormatH\x00\x1a!\n\x0bProtoFormat\x12\x12\n\nclass_path\x18\x01 \x01(\t\x1a!\n\nAvroFormat\x12\x13\n\x0bschema_json\x18\x01 \x01(\t\x1a!\n\nJsonFormat\x12\x13\n\x0bschema_json\x18\x01 \x01(\tB\x08\n\x06\x66ormatBT\n\x10\x66\x65\x61st.proto.coreB\x0f\x44\x61taFormatProtoZ/github.com/feast-dev/feast/go/protos/feast/coreb\x06proto3') +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1b\x66\x65\x61st/core/DataFormat.proto\x12\nfeast.core\"\xa8\x01\n\nFileFormat\x12>\n\x0eparquet_format\x18\x01 \x01(\x0b\x32$.feast.core.FileFormat.ParquetFormatH\x00\x12?\n\x0c\x64\x65lta_format\x18\x02 \x01(\x0b\x32#.feast.core.TableFormat.DeltaFormatB\x02\x18\x01H\x00\x1a\x0f\n\rParquetFormatB\x08\n\x06\x66ormat\"\xf9\x05\n\x0bTableFormat\x12?\n\x0eiceberg_format\x18\x01 \x01(\x0b\x32%.feast.core.TableFormat.IcebergFormatH\x00\x12;\n\x0c\x64\x65lta_format\x18\x02 \x01(\x0b\x32#.feast.core.TableFormat.DeltaFormatH\x00\x12\x39\n\x0bhudi_format\x18\x03 \x01(\x0b\x32\".feast.core.TableFormat.HudiFormatH\x00\x1a\xb1\x01\n\rIcebergFormat\x12\x0f\n\x07\x63\x61talog\x18\x01 \x01(\t\x12\x11\n\tnamespace\x18\x02 \x01(\t\x12I\n\nproperties\x18\x03 \x03(\x0b\x32\x35.feast.core.TableFormat.IcebergFormat.PropertiesEntry\x1a\x31\n\x0fPropertiesEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\x1a\xa6\x01\n\x0b\x44\x65ltaFormat\x12\x1b\n\x13\x63heckpoint_location\x18\x01 \x01(\t\x12G\n\nproperties\x18\x02 \x03(\x0b\x32\x33.feast.core.TableFormat.DeltaFormat.PropertiesEntry\x1a\x31\n\x0fPropertiesEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\x1a\xc9\x01\n\nHudiFormat\x12\x12\n\ntable_type\x18\x01 \x01(\t\x12\x12\n\nrecord_key\x18\x02 \x01(\t\x12\x18\n\x10precombine_field\x18\x03 \x01(\t\x12\x46\n\nproperties\x18\x04 \x03(\x0b\x32\x32.feast.core.TableFormat.HudiFormat.PropertiesEntry\x1a\x31\n\x0fPropertiesEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\x42\x08\n\x06\x66ormat\"\xb7\x02\n\x0cStreamFormat\x12:\n\x0b\x61vro_format\x18\x01 \x01(\x0b\x32#.feast.core.StreamFormat.AvroFormatH\x00\x12<\n\x0cproto_format\x18\x02 \x01(\x0b\x32$.feast.core.StreamFormat.ProtoFormatH\x00\x12:\n\x0bjson_format\x18\x03 \x01(\x0b\x32#.feast.core.StreamFormat.JsonFormatH\x00\x1a!\n\x0bProtoFormat\x12\x12\n\nclass_path\x18\x01 \x01(\t\x1a!\n\nAvroFormat\x12\x13\n\x0bschema_json\x18\x01 \x01(\t\x1a!\n\nJsonFormat\x12\x13\n\x0bschema_json\x18\x01 \x01(\tB\x08\n\x06\x66ormatBT\n\x10\x66\x65\x61st.proto.coreB\x0f\x44\x61taFormatProtoZ/github.com/feast-dev/feast/go/protos/feast/coreb\x06proto3') _globals = globals() _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) @@ -22,18 +22,38 @@ if _descriptor._USE_C_DESCRIPTORS == False: _globals['DESCRIPTOR']._options = None _globals['DESCRIPTOR']._serialized_options = b'\n\020feast.proto.coreB\017DataFormatProtoZ/github.com/feast-dev/feast/go/protos/feast/core' + _globals['_FILEFORMAT'].fields_by_name['delta_format']._options = None + _globals['_FILEFORMAT'].fields_by_name['delta_format']._serialized_options = b'\030\001' + _globals['_TABLEFORMAT_ICEBERGFORMAT_PROPERTIESENTRY']._options = None + _globals['_TABLEFORMAT_ICEBERGFORMAT_PROPERTIESENTRY']._serialized_options = b'8\001' + _globals['_TABLEFORMAT_DELTAFORMAT_PROPERTIESENTRY']._options = None + _globals['_TABLEFORMAT_DELTAFORMAT_PROPERTIESENTRY']._serialized_options = b'8\001' + _globals['_TABLEFORMAT_HUDIFORMAT_PROPERTIESENTRY']._options = None + _globals['_TABLEFORMAT_HUDIFORMAT_PROPERTIESENTRY']._serialized_options = b'8\001' _globals['_FILEFORMAT']._serialized_start=44 - _globals['_FILEFORMAT']._serialized_end=222 - _globals['_FILEFORMAT_PARQUETFORMAT']._serialized_start=182 - _globals['_FILEFORMAT_PARQUETFORMAT']._serialized_end=197 - _globals['_FILEFORMAT_DELTAFORMAT']._serialized_start=199 - _globals['_FILEFORMAT_DELTAFORMAT']._serialized_end=212 - _globals['_STREAMFORMAT']._serialized_start=225 - _globals['_STREAMFORMAT']._serialized_end=536 - _globals['_STREAMFORMAT_PROTOFORMAT']._serialized_start=423 - _globals['_STREAMFORMAT_PROTOFORMAT']._serialized_end=456 - _globals['_STREAMFORMAT_AVROFORMAT']._serialized_start=458 - _globals['_STREAMFORMAT_AVROFORMAT']._serialized_end=491 - _globals['_STREAMFORMAT_JSONFORMAT']._serialized_start=493 - _globals['_STREAMFORMAT_JSONFORMAT']._serialized_end=526 + _globals['_FILEFORMAT']._serialized_end=212 + _globals['_FILEFORMAT_PARQUETFORMAT']._serialized_start=187 + _globals['_FILEFORMAT_PARQUETFORMAT']._serialized_end=202 + _globals['_TABLEFORMAT']._serialized_start=215 + _globals['_TABLEFORMAT']._serialized_end=976 + _globals['_TABLEFORMAT_ICEBERGFORMAT']._serialized_start=416 + _globals['_TABLEFORMAT_ICEBERGFORMAT']._serialized_end=593 + _globals['_TABLEFORMAT_ICEBERGFORMAT_PROPERTIESENTRY']._serialized_start=544 + _globals['_TABLEFORMAT_ICEBERGFORMAT_PROPERTIESENTRY']._serialized_end=593 + _globals['_TABLEFORMAT_DELTAFORMAT']._serialized_start=596 + _globals['_TABLEFORMAT_DELTAFORMAT']._serialized_end=762 + _globals['_TABLEFORMAT_DELTAFORMAT_PROPERTIESENTRY']._serialized_start=544 + _globals['_TABLEFORMAT_DELTAFORMAT_PROPERTIESENTRY']._serialized_end=593 + _globals['_TABLEFORMAT_HUDIFORMAT']._serialized_start=765 + _globals['_TABLEFORMAT_HUDIFORMAT']._serialized_end=966 + _globals['_TABLEFORMAT_HUDIFORMAT_PROPERTIESENTRY']._serialized_start=544 + _globals['_TABLEFORMAT_HUDIFORMAT_PROPERTIESENTRY']._serialized_end=593 + _globals['_STREAMFORMAT']._serialized_start=979 + _globals['_STREAMFORMAT']._serialized_end=1290 + _globals['_STREAMFORMAT_PROTOFORMAT']._serialized_start=1177 + _globals['_STREAMFORMAT_PROTOFORMAT']._serialized_end=1210 + _globals['_STREAMFORMAT_AVROFORMAT']._serialized_start=1212 + _globals['_STREAMFORMAT_AVROFORMAT']._serialized_end=1245 + _globals['_STREAMFORMAT_JSONFORMAT']._serialized_start=1247 + _globals['_STREAMFORMAT_JSONFORMAT']._serialized_end=1280 # @@protoc_insertion_point(module_scope) diff --git a/sdk/python/feast/protos/feast/core/DataFormat_pb2.pyi b/sdk/python/feast/protos/feast/core/DataFormat_pb2.pyi index 1f904e9886a..193fb82a776 100644 --- a/sdk/python/feast/protos/feast/core/DataFormat_pb2.pyi +++ b/sdk/python/feast/protos/feast/core/DataFormat_pb2.pyi @@ -17,7 +17,9 @@ See the License for the specific language governing permissions and limitations under the License. """ import builtins +import collections.abc import google.protobuf.descriptor +import google.protobuf.internal.containers import google.protobuf.message import sys @@ -42,32 +44,174 @@ class FileFormat(google.protobuf.message.Message): self, ) -> None: ... + PARQUET_FORMAT_FIELD_NUMBER: builtins.int + DELTA_FORMAT_FIELD_NUMBER: builtins.int + @property + def parquet_format(self) -> global___FileFormat.ParquetFormat: ... + @property + def delta_format(self) -> global___TableFormat.DeltaFormat: + """Deprecated: Delta Lake is a table format, not a file format. + Use TableFormat.DeltaFormat instead for Delta Lake support. + """ + def __init__( + self, + *, + parquet_format: global___FileFormat.ParquetFormat | None = ..., + delta_format: global___TableFormat.DeltaFormat | None = ..., + ) -> None: ... + def HasField(self, field_name: typing_extensions.Literal["delta_format", b"delta_format", "format", b"format", "parquet_format", b"parquet_format"]) -> builtins.bool: ... + def ClearField(self, field_name: typing_extensions.Literal["delta_format", b"delta_format", "format", b"format", "parquet_format", b"parquet_format"]) -> None: ... + def WhichOneof(self, oneof_group: typing_extensions.Literal["format", b"format"]) -> typing_extensions.Literal["parquet_format", "delta_format"] | None: ... + +global___FileFormat = FileFormat + +class TableFormat(google.protobuf.message.Message): + DESCRIPTOR: google.protobuf.descriptor.Descriptor + + class IcebergFormat(google.protobuf.message.Message): + """Defines options for Apache Iceberg table format""" + + DESCRIPTOR: google.protobuf.descriptor.Descriptor + + class PropertiesEntry(google.protobuf.message.Message): + DESCRIPTOR: google.protobuf.descriptor.Descriptor + + KEY_FIELD_NUMBER: builtins.int + VALUE_FIELD_NUMBER: builtins.int + key: builtins.str + value: builtins.str + def __init__( + self, + *, + key: builtins.str = ..., + value: builtins.str = ..., + ) -> None: ... + def ClearField(self, field_name: typing_extensions.Literal["key", b"key", "value", b"value"]) -> None: ... + + CATALOG_FIELD_NUMBER: builtins.int + NAMESPACE_FIELD_NUMBER: builtins.int + PROPERTIES_FIELD_NUMBER: builtins.int + catalog: builtins.str + """Optional catalog name for the Iceberg table""" + namespace: builtins.str + """Optional namespace (schema/database) within the catalog""" + @property + def properties(self) -> google.protobuf.internal.containers.ScalarMap[builtins.str, builtins.str]: + """Additional properties for Iceberg configuration + Examples: warehouse location, snapshot-id, as-of-timestamp, etc. + """ + def __init__( + self, + *, + catalog: builtins.str = ..., + namespace: builtins.str = ..., + properties: collections.abc.Mapping[builtins.str, builtins.str] | None = ..., + ) -> None: ... + def ClearField(self, field_name: typing_extensions.Literal["catalog", b"catalog", "namespace", b"namespace", "properties", b"properties"]) -> None: ... + class DeltaFormat(google.protobuf.message.Message): - """Defines options for delta data format""" + """Defines options for Delta Lake table format""" DESCRIPTOR: google.protobuf.descriptor.Descriptor + class PropertiesEntry(google.protobuf.message.Message): + DESCRIPTOR: google.protobuf.descriptor.Descriptor + + KEY_FIELD_NUMBER: builtins.int + VALUE_FIELD_NUMBER: builtins.int + key: builtins.str + value: builtins.str + def __init__( + self, + *, + key: builtins.str = ..., + value: builtins.str = ..., + ) -> None: ... + def ClearField(self, field_name: typing_extensions.Literal["key", b"key", "value", b"value"]) -> None: ... + + CHECKPOINT_LOCATION_FIELD_NUMBER: builtins.int + PROPERTIES_FIELD_NUMBER: builtins.int + checkpoint_location: builtins.str + """Optional checkpoint location for Delta transaction logs""" + @property + def properties(self) -> google.protobuf.internal.containers.ScalarMap[builtins.str, builtins.str]: + """Additional properties for Delta configuration + Examples: auto-optimize settings, vacuum settings, etc. + """ def __init__( self, + *, + checkpoint_location: builtins.str = ..., + properties: collections.abc.Mapping[builtins.str, builtins.str] | None = ..., ) -> None: ... + def ClearField(self, field_name: typing_extensions.Literal["checkpoint_location", b"checkpoint_location", "properties", b"properties"]) -> None: ... - PARQUET_FORMAT_FIELD_NUMBER: builtins.int + class HudiFormat(google.protobuf.message.Message): + """Defines options for Apache Hudi table format""" + + DESCRIPTOR: google.protobuf.descriptor.Descriptor + + class PropertiesEntry(google.protobuf.message.Message): + DESCRIPTOR: google.protobuf.descriptor.Descriptor + + KEY_FIELD_NUMBER: builtins.int + VALUE_FIELD_NUMBER: builtins.int + key: builtins.str + value: builtins.str + def __init__( + self, + *, + key: builtins.str = ..., + value: builtins.str = ..., + ) -> None: ... + def ClearField(self, field_name: typing_extensions.Literal["key", b"key", "value", b"value"]) -> None: ... + + TABLE_TYPE_FIELD_NUMBER: builtins.int + RECORD_KEY_FIELD_NUMBER: builtins.int + PRECOMBINE_FIELD_FIELD_NUMBER: builtins.int + PROPERTIES_FIELD_NUMBER: builtins.int + table_type: builtins.str + """Type of Hudi table (COPY_ON_WRITE or MERGE_ON_READ)""" + record_key: builtins.str + """Field(s) that uniquely identify a record""" + precombine_field: builtins.str + """Field used to determine the latest version of a record""" + @property + def properties(self) -> google.protobuf.internal.containers.ScalarMap[builtins.str, builtins.str]: + """Additional properties for Hudi configuration + Examples: compaction strategy, indexing options, etc. + """ + def __init__( + self, + *, + table_type: builtins.str = ..., + record_key: builtins.str = ..., + precombine_field: builtins.str = ..., + properties: collections.abc.Mapping[builtins.str, builtins.str] | None = ..., + ) -> None: ... + def ClearField(self, field_name: typing_extensions.Literal["precombine_field", b"precombine_field", "properties", b"properties", "record_key", b"record_key", "table_type", b"table_type"]) -> None: ... + + ICEBERG_FORMAT_FIELD_NUMBER: builtins.int DELTA_FORMAT_FIELD_NUMBER: builtins.int + HUDI_FORMAT_FIELD_NUMBER: builtins.int @property - def parquet_format(self) -> global___FileFormat.ParquetFormat: ... + def iceberg_format(self) -> global___TableFormat.IcebergFormat: ... @property - def delta_format(self) -> global___FileFormat.DeltaFormat: ... + def delta_format(self) -> global___TableFormat.DeltaFormat: ... + @property + def hudi_format(self) -> global___TableFormat.HudiFormat: ... def __init__( self, *, - parquet_format: global___FileFormat.ParquetFormat | None = ..., - delta_format: global___FileFormat.DeltaFormat | None = ..., + iceberg_format: global___TableFormat.IcebergFormat | None = ..., + delta_format: global___TableFormat.DeltaFormat | None = ..., + hudi_format: global___TableFormat.HudiFormat | None = ..., ) -> None: ... - def HasField(self, field_name: typing_extensions.Literal["delta_format", b"delta_format", "format", b"format", "parquet_format", b"parquet_format"]) -> builtins.bool: ... - def ClearField(self, field_name: typing_extensions.Literal["delta_format", b"delta_format", "format", b"format", "parquet_format", b"parquet_format"]) -> None: ... - def WhichOneof(self, oneof_group: typing_extensions.Literal["format", b"format"]) -> typing_extensions.Literal["parquet_format", "delta_format"] | None: ... + def HasField(self, field_name: typing_extensions.Literal["delta_format", b"delta_format", "format", b"format", "hudi_format", b"hudi_format", "iceberg_format", b"iceberg_format"]) -> builtins.bool: ... + def ClearField(self, field_name: typing_extensions.Literal["delta_format", b"delta_format", "format", b"format", "hudi_format", b"hudi_format", "iceberg_format", b"iceberg_format"]) -> None: ... + def WhichOneof(self, oneof_group: typing_extensions.Literal["format", b"format"]) -> typing_extensions.Literal["iceberg_format", "delta_format", "hudi_format"] | None: ... -global___FileFormat = FileFormat +global___TableFormat = TableFormat class StreamFormat(google.protobuf.message.Message): """Defines the data format encoding features/entity data in data streams""" diff --git a/sdk/python/feast/protos/feast/core/DataSource_pb2.py b/sdk/python/feast/protos/feast/core/DataSource_pb2.py index cb06cca5c10..f3086233584 100644 --- a/sdk/python/feast/protos/feast/core/DataSource_pb2.py +++ b/sdk/python/feast/protos/feast/core/DataSource_pb2.py @@ -19,7 +19,7 @@ from feast.protos.feast.core import Feature_pb2 as feast_dot_core_dot_Feature__pb2 -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1b\x66\x65\x61st/core/DataSource.proto\x12\nfeast.core\x1a\x1egoogle/protobuf/duration.proto\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\x1b\x66\x65\x61st/core/DataFormat.proto\x1a\x17\x66\x65\x61st/types/Value.proto\x1a\x18\x66\x65\x61st/core/Feature.proto\"\xd9\x17\n\nDataSource\x12\x0c\n\x04name\x18\x14 \x01(\t\x12\x0f\n\x07project\x18\x15 \x01(\t\x12\x13\n\x0b\x64\x65scription\x18\x17 \x01(\t\x12.\n\x04tags\x18\x18 \x03(\x0b\x32 .feast.core.DataSource.TagsEntry\x12\r\n\x05owner\x18\x19 \x01(\t\x12/\n\x04type\x18\x01 \x01(\x0e\x32!.feast.core.DataSource.SourceType\x12?\n\rfield_mapping\x18\x02 \x03(\x0b\x32(.feast.core.DataSource.FieldMappingEntry\x12\x17\n\x0ftimestamp_field\x18\x03 \x01(\t\x12\x1d\n\x15\x64\x61te_partition_column\x18\x04 \x01(\t\x12 \n\x18\x63reated_timestamp_column\x18\x05 \x01(\t\x12\x1e\n\x16\x64\x61ta_source_class_type\x18\x11 \x01(\t\x12,\n\x0c\x62\x61tch_source\x18\x1a \x01(\x0b\x32\x16.feast.core.DataSource\x12/\n\x04meta\x18\x32 \x01(\x0b\x32!.feast.core.DataSource.SourceMeta\x12:\n\x0c\x66ile_options\x18\x0b \x01(\x0b\x32\".feast.core.DataSource.FileOptionsH\x00\x12\x42\n\x10\x62igquery_options\x18\x0c \x01(\x0b\x32&.feast.core.DataSource.BigQueryOptionsH\x00\x12<\n\rkafka_options\x18\r \x01(\x0b\x32#.feast.core.DataSource.KafkaOptionsH\x00\x12@\n\x0fkinesis_options\x18\x0e \x01(\x0b\x32%.feast.core.DataSource.KinesisOptionsH\x00\x12\x42\n\x10redshift_options\x18\x0f \x01(\x0b\x32&.feast.core.DataSource.RedshiftOptionsH\x00\x12I\n\x14request_data_options\x18\x12 \x01(\x0b\x32).feast.core.DataSource.RequestDataOptionsH\x00\x12\x44\n\x0e\x63ustom_options\x18\x10 \x01(\x0b\x32*.feast.core.DataSource.CustomSourceOptionsH\x00\x12\x44\n\x11snowflake_options\x18\x13 \x01(\x0b\x32\'.feast.core.DataSource.SnowflakeOptionsH\x00\x12:\n\x0cpush_options\x18\x16 \x01(\x0b\x32\".feast.core.DataSource.PushOptionsH\x00\x12<\n\rspark_options\x18\x1b \x01(\x0b\x32#.feast.core.DataSource.SparkOptionsH\x00\x12<\n\rtrino_options\x18\x1e \x01(\x0b\x32#.feast.core.DataSource.TrinoOptionsH\x00\x12>\n\x0e\x61thena_options\x18# \x01(\x0b\x32$.feast.core.DataSource.AthenaOptionsH\x00\x1a+\n\tTagsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\x1a\x33\n\x11\x46ieldMappingEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\x1a\xf5\x01\n\nSourceMeta\x12:\n\x16\x65\x61rliestEventTimestamp\x18\x01 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x38\n\x14latestEventTimestamp\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x35\n\x11\x63reated_timestamp\x18\x03 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12:\n\x16last_updated_timestamp\x18\x04 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x1a\x65\n\x0b\x46ileOptions\x12+\n\x0b\x66ile_format\x18\x01 \x01(\x0b\x32\x16.feast.core.FileFormat\x12\x0b\n\x03uri\x18\x02 \x01(\t\x12\x1c\n\x14s3_endpoint_override\x18\x03 \x01(\t\x1a/\n\x0f\x42igQueryOptions\x12\r\n\x05table\x18\x01 \x01(\t\x12\r\n\x05query\x18\x02 \x01(\t\x1a,\n\x0cTrinoOptions\x12\r\n\x05table\x18\x01 \x01(\t\x12\r\n\x05query\x18\x02 \x01(\t\x1a\xae\x01\n\x0cKafkaOptions\x12\x1f\n\x17kafka_bootstrap_servers\x18\x01 \x01(\t\x12\r\n\x05topic\x18\x02 \x01(\t\x12\x30\n\x0emessage_format\x18\x03 \x01(\x0b\x32\x18.feast.core.StreamFormat\x12<\n\x19watermark_delay_threshold\x18\x04 \x01(\x0b\x32\x19.google.protobuf.Duration\x1a\x66\n\x0eKinesisOptions\x12\x0e\n\x06region\x18\x01 \x01(\t\x12\x13\n\x0bstream_name\x18\x02 \x01(\t\x12/\n\rrecord_format\x18\x03 \x01(\x0b\x32\x18.feast.core.StreamFormat\x1aQ\n\x0fRedshiftOptions\x12\r\n\x05table\x18\x01 \x01(\t\x12\r\n\x05query\x18\x02 \x01(\t\x12\x0e\n\x06schema\x18\x03 \x01(\t\x12\x10\n\x08\x64\x61tabase\x18\x04 \x01(\t\x1aT\n\rAthenaOptions\x12\r\n\x05table\x18\x01 \x01(\t\x12\r\n\x05query\x18\x02 \x01(\t\x12\x10\n\x08\x64\x61tabase\x18\x03 \x01(\t\x12\x13\n\x0b\x64\x61ta_source\x18\x04 \x01(\t\x1aX\n\x10SnowflakeOptions\x12\r\n\x05table\x18\x01 \x01(\t\x12\r\n\x05query\x18\x02 \x01(\t\x12\x0e\n\x06schema\x18\x03 \x01(\t\x12\x10\n\x08\x64\x61tabase\x18\x04 \x01(\tJ\x04\x08\x05\x10\x06\x1au\n\x0cSparkOptions\x12\r\n\x05table\x18\x01 \x01(\t\x12\r\n\x05query\x18\x02 \x01(\t\x12\x0c\n\x04path\x18\x03 \x01(\t\x12\x13\n\x0b\x66ile_format\x18\x04 \x01(\t\x12$\n\x1c\x64\x61te_partition_column_format\x18\x05 \x01(\t\x1a,\n\x13\x43ustomSourceOptions\x12\x15\n\rconfiguration\x18\x01 \x01(\x0c\x1a\xf7\x01\n\x12RequestDataOptions\x12Z\n\x11\x64\x65precated_schema\x18\x02 \x03(\x0b\x32?.feast.core.DataSource.RequestDataOptions.DeprecatedSchemaEntry\x12)\n\x06schema\x18\x03 \x03(\x0b\x32\x19.feast.core.FeatureSpecV2\x1aT\n\x15\x44\x65precatedSchemaEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12*\n\x05value\x18\x02 \x01(\x0e\x32\x1b.feast.types.ValueType.Enum:\x02\x38\x01J\x04\x08\x01\x10\x02\x1a\x13\n\x0bPushOptionsJ\x04\x08\x01\x10\x02\"\xf8\x01\n\nSourceType\x12\x0b\n\x07INVALID\x10\x00\x12\x0e\n\nBATCH_FILE\x10\x01\x12\x13\n\x0f\x42\x41TCH_SNOWFLAKE\x10\x08\x12\x12\n\x0e\x42\x41TCH_BIGQUERY\x10\x02\x12\x12\n\x0e\x42\x41TCH_REDSHIFT\x10\x05\x12\x10\n\x0cSTREAM_KAFKA\x10\x03\x12\x12\n\x0eSTREAM_KINESIS\x10\x04\x12\x11\n\rCUSTOM_SOURCE\x10\x06\x12\x12\n\x0eREQUEST_SOURCE\x10\x07\x12\x0f\n\x0bPUSH_SOURCE\x10\t\x12\x0f\n\x0b\x42\x41TCH_TRINO\x10\n\x12\x0f\n\x0b\x42\x41TCH_SPARK\x10\x0b\x12\x10\n\x0c\x42\x41TCH_ATHENA\x10\x0c\x42\t\n\x07optionsJ\x04\x08\x06\x10\x0b\"=\n\x0e\x44\x61taSourceList\x12+\n\x0b\x64\x61tasources\x18\x01 \x03(\x0b\x32\x16.feast.core.DataSourceBT\n\x10\x66\x65\x61st.proto.coreB\x0f\x44\x61taSourceProtoZ/github.com/feast-dev/feast/go/protos/feast/coreb\x06proto3') +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1b\x66\x65\x61st/core/DataSource.proto\x12\nfeast.core\x1a\x1egoogle/protobuf/duration.proto\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\x1b\x66\x65\x61st/core/DataFormat.proto\x1a\x17\x66\x65\x61st/types/Value.proto\x1a\x18\x66\x65\x61st/core/Feature.proto\"\x89\x18\n\nDataSource\x12\x0c\n\x04name\x18\x14 \x01(\t\x12\x0f\n\x07project\x18\x15 \x01(\t\x12\x13\n\x0b\x64\x65scription\x18\x17 \x01(\t\x12.\n\x04tags\x18\x18 \x03(\x0b\x32 .feast.core.DataSource.TagsEntry\x12\r\n\x05owner\x18\x19 \x01(\t\x12/\n\x04type\x18\x01 \x01(\x0e\x32!.feast.core.DataSource.SourceType\x12?\n\rfield_mapping\x18\x02 \x03(\x0b\x32(.feast.core.DataSource.FieldMappingEntry\x12\x17\n\x0ftimestamp_field\x18\x03 \x01(\t\x12\x1d\n\x15\x64\x61te_partition_column\x18\x04 \x01(\t\x12 \n\x18\x63reated_timestamp_column\x18\x05 \x01(\t\x12\x1e\n\x16\x64\x61ta_source_class_type\x18\x11 \x01(\t\x12,\n\x0c\x62\x61tch_source\x18\x1a \x01(\x0b\x32\x16.feast.core.DataSource\x12/\n\x04meta\x18\x32 \x01(\x0b\x32!.feast.core.DataSource.SourceMeta\x12:\n\x0c\x66ile_options\x18\x0b \x01(\x0b\x32\".feast.core.DataSource.FileOptionsH\x00\x12\x42\n\x10\x62igquery_options\x18\x0c \x01(\x0b\x32&.feast.core.DataSource.BigQueryOptionsH\x00\x12<\n\rkafka_options\x18\r \x01(\x0b\x32#.feast.core.DataSource.KafkaOptionsH\x00\x12@\n\x0fkinesis_options\x18\x0e \x01(\x0b\x32%.feast.core.DataSource.KinesisOptionsH\x00\x12\x42\n\x10redshift_options\x18\x0f \x01(\x0b\x32&.feast.core.DataSource.RedshiftOptionsH\x00\x12I\n\x14request_data_options\x18\x12 \x01(\x0b\x32).feast.core.DataSource.RequestDataOptionsH\x00\x12\x44\n\x0e\x63ustom_options\x18\x10 \x01(\x0b\x32*.feast.core.DataSource.CustomSourceOptionsH\x00\x12\x44\n\x11snowflake_options\x18\x13 \x01(\x0b\x32\'.feast.core.DataSource.SnowflakeOptionsH\x00\x12:\n\x0cpush_options\x18\x16 \x01(\x0b\x32\".feast.core.DataSource.PushOptionsH\x00\x12<\n\rspark_options\x18\x1b \x01(\x0b\x32#.feast.core.DataSource.SparkOptionsH\x00\x12<\n\rtrino_options\x18\x1e \x01(\x0b\x32#.feast.core.DataSource.TrinoOptionsH\x00\x12>\n\x0e\x61thena_options\x18# \x01(\x0b\x32$.feast.core.DataSource.AthenaOptionsH\x00\x1a+\n\tTagsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\x1a\x33\n\x11\x46ieldMappingEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\x1a\xf5\x01\n\nSourceMeta\x12:\n\x16\x65\x61rliestEventTimestamp\x18\x01 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x38\n\x14latestEventTimestamp\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x35\n\x11\x63reated_timestamp\x18\x03 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12:\n\x16last_updated_timestamp\x18\x04 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x1a\x65\n\x0b\x46ileOptions\x12+\n\x0b\x66ile_format\x18\x01 \x01(\x0b\x32\x16.feast.core.FileFormat\x12\x0b\n\x03uri\x18\x02 \x01(\t\x12\x1c\n\x14s3_endpoint_override\x18\x03 \x01(\t\x1a/\n\x0f\x42igQueryOptions\x12\r\n\x05table\x18\x01 \x01(\t\x12\r\n\x05query\x18\x02 \x01(\t\x1a,\n\x0cTrinoOptions\x12\r\n\x05table\x18\x01 \x01(\t\x12\r\n\x05query\x18\x02 \x01(\t\x1a\xae\x01\n\x0cKafkaOptions\x12\x1f\n\x17kafka_bootstrap_servers\x18\x01 \x01(\t\x12\r\n\x05topic\x18\x02 \x01(\t\x12\x30\n\x0emessage_format\x18\x03 \x01(\x0b\x32\x18.feast.core.StreamFormat\x12<\n\x19watermark_delay_threshold\x18\x04 \x01(\x0b\x32\x19.google.protobuf.Duration\x1a\x66\n\x0eKinesisOptions\x12\x0e\n\x06region\x18\x01 \x01(\t\x12\x13\n\x0bstream_name\x18\x02 \x01(\t\x12/\n\rrecord_format\x18\x03 \x01(\x0b\x32\x18.feast.core.StreamFormat\x1aQ\n\x0fRedshiftOptions\x12\r\n\x05table\x18\x01 \x01(\t\x12\r\n\x05query\x18\x02 \x01(\t\x12\x0e\n\x06schema\x18\x03 \x01(\t\x12\x10\n\x08\x64\x61tabase\x18\x04 \x01(\t\x1aT\n\rAthenaOptions\x12\r\n\x05table\x18\x01 \x01(\t\x12\r\n\x05query\x18\x02 \x01(\t\x12\x10\n\x08\x64\x61tabase\x18\x03 \x01(\t\x12\x13\n\x0b\x64\x61ta_source\x18\x04 \x01(\t\x1aX\n\x10SnowflakeOptions\x12\r\n\x05table\x18\x01 \x01(\t\x12\r\n\x05query\x18\x02 \x01(\t\x12\x0e\n\x06schema\x18\x03 \x01(\t\x12\x10\n\x08\x64\x61tabase\x18\x04 \x01(\tJ\x04\x08\x05\x10\x06\x1a\xa4\x01\n\x0cSparkOptions\x12\r\n\x05table\x18\x01 \x01(\t\x12\r\n\x05query\x18\x02 \x01(\t\x12\x0c\n\x04path\x18\x03 \x01(\t\x12\x13\n\x0b\x66ile_format\x18\x04 \x01(\t\x12$\n\x1c\x64\x61te_partition_column_format\x18\x05 \x01(\t\x12-\n\x0ctable_format\x18\x06 \x01(\x0b\x32\x17.feast.core.TableFormat\x1a,\n\x13\x43ustomSourceOptions\x12\x15\n\rconfiguration\x18\x01 \x01(\x0c\x1a\xf7\x01\n\x12RequestDataOptions\x12Z\n\x11\x64\x65precated_schema\x18\x02 \x03(\x0b\x32?.feast.core.DataSource.RequestDataOptions.DeprecatedSchemaEntry\x12)\n\x06schema\x18\x03 \x03(\x0b\x32\x19.feast.core.FeatureSpecV2\x1aT\n\x15\x44\x65precatedSchemaEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12*\n\x05value\x18\x02 \x01(\x0e\x32\x1b.feast.types.ValueType.Enum:\x02\x38\x01J\x04\x08\x01\x10\x02\x1a\x13\n\x0bPushOptionsJ\x04\x08\x01\x10\x02\"\xf8\x01\n\nSourceType\x12\x0b\n\x07INVALID\x10\x00\x12\x0e\n\nBATCH_FILE\x10\x01\x12\x13\n\x0f\x42\x41TCH_SNOWFLAKE\x10\x08\x12\x12\n\x0e\x42\x41TCH_BIGQUERY\x10\x02\x12\x12\n\x0e\x42\x41TCH_REDSHIFT\x10\x05\x12\x10\n\x0cSTREAM_KAFKA\x10\x03\x12\x12\n\x0eSTREAM_KINESIS\x10\x04\x12\x11\n\rCUSTOM_SOURCE\x10\x06\x12\x12\n\x0eREQUEST_SOURCE\x10\x07\x12\x0f\n\x0bPUSH_SOURCE\x10\t\x12\x0f\n\x0b\x42\x41TCH_TRINO\x10\n\x12\x0f\n\x0b\x42\x41TCH_SPARK\x10\x0b\x12\x10\n\x0c\x42\x41TCH_ATHENA\x10\x0c\x42\t\n\x07optionsJ\x04\x08\x06\x10\x0b\"=\n\x0e\x44\x61taSourceList\x12+\n\x0b\x64\x61tasources\x18\x01 \x03(\x0b\x32\x16.feast.core.DataSourceBT\n\x10\x66\x65\x61st.proto.coreB\x0f\x44\x61taSourceProtoZ/github.com/feast-dev/feast/go/protos/feast/coreb\x06proto3') _globals = globals() _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) @@ -34,7 +34,7 @@ _globals['_DATASOURCE_REQUESTDATAOPTIONS_DEPRECATEDSCHEMAENTRY']._options = None _globals['_DATASOURCE_REQUESTDATAOPTIONS_DEPRECATEDSCHEMAENTRY']._serialized_options = b'8\001' _globals['_DATASOURCE']._serialized_start=189 - _globals['_DATASOURCE']._serialized_end=3222 + _globals['_DATASOURCE']._serialized_end=3270 _globals['_DATASOURCE_TAGSENTRY']._serialized_start=1436 _globals['_DATASOURCE_TAGSENTRY']._serialized_end=1479 _globals['_DATASOURCE_FIELDMAPPINGENTRY']._serialized_start=1481 @@ -57,18 +57,18 @@ _globals['_DATASOURCE_ATHENAOPTIONS']._serialized_end=2428 _globals['_DATASOURCE_SNOWFLAKEOPTIONS']._serialized_start=2430 _globals['_DATASOURCE_SNOWFLAKEOPTIONS']._serialized_end=2518 - _globals['_DATASOURCE_SPARKOPTIONS']._serialized_start=2520 - _globals['_DATASOURCE_SPARKOPTIONS']._serialized_end=2637 - _globals['_DATASOURCE_CUSTOMSOURCEOPTIONS']._serialized_start=2639 - _globals['_DATASOURCE_CUSTOMSOURCEOPTIONS']._serialized_end=2683 - _globals['_DATASOURCE_REQUESTDATAOPTIONS']._serialized_start=2686 - _globals['_DATASOURCE_REQUESTDATAOPTIONS']._serialized_end=2933 - _globals['_DATASOURCE_REQUESTDATAOPTIONS_DEPRECATEDSCHEMAENTRY']._serialized_start=2843 - _globals['_DATASOURCE_REQUESTDATAOPTIONS_DEPRECATEDSCHEMAENTRY']._serialized_end=2927 - _globals['_DATASOURCE_PUSHOPTIONS']._serialized_start=2935 - _globals['_DATASOURCE_PUSHOPTIONS']._serialized_end=2954 - _globals['_DATASOURCE_SOURCETYPE']._serialized_start=2957 - _globals['_DATASOURCE_SOURCETYPE']._serialized_end=3205 - _globals['_DATASOURCELIST']._serialized_start=3224 - _globals['_DATASOURCELIST']._serialized_end=3285 + _globals['_DATASOURCE_SPARKOPTIONS']._serialized_start=2521 + _globals['_DATASOURCE_SPARKOPTIONS']._serialized_end=2685 + _globals['_DATASOURCE_CUSTOMSOURCEOPTIONS']._serialized_start=2687 + _globals['_DATASOURCE_CUSTOMSOURCEOPTIONS']._serialized_end=2731 + _globals['_DATASOURCE_REQUESTDATAOPTIONS']._serialized_start=2734 + _globals['_DATASOURCE_REQUESTDATAOPTIONS']._serialized_end=2981 + _globals['_DATASOURCE_REQUESTDATAOPTIONS_DEPRECATEDSCHEMAENTRY']._serialized_start=2891 + _globals['_DATASOURCE_REQUESTDATAOPTIONS_DEPRECATEDSCHEMAENTRY']._serialized_end=2975 + _globals['_DATASOURCE_PUSHOPTIONS']._serialized_start=2983 + _globals['_DATASOURCE_PUSHOPTIONS']._serialized_end=3002 + _globals['_DATASOURCE_SOURCETYPE']._serialized_start=3005 + _globals['_DATASOURCE_SOURCETYPE']._serialized_end=3253 + _globals['_DATASOURCELIST']._serialized_start=3272 + _globals['_DATASOURCELIST']._serialized_end=3333 # @@protoc_insertion_point(module_scope) diff --git a/sdk/python/feast/protos/feast/core/DataSource_pb2.pyi b/sdk/python/feast/protos/feast/core/DataSource_pb2.pyi index 668d83525cf..7876e1adc98 100644 --- a/sdk/python/feast/protos/feast/core/DataSource_pb2.pyi +++ b/sdk/python/feast/protos/feast/core/DataSource_pb2.pyi @@ -66,7 +66,7 @@ class DataSource(google.protobuf.message.Message): class SourceType(_SourceType, metaclass=_SourceTypeEnumTypeWrapper): """Type of Data Source. - Next available id: 12 + Next available id: 13 """ INVALID: DataSource.SourceType.ValueType # 0 @@ -369,6 +369,7 @@ class DataSource(google.protobuf.message.Message): PATH_FIELD_NUMBER: builtins.int FILE_FORMAT_FIELD_NUMBER: builtins.int DATE_PARTITION_COLUMN_FORMAT_FIELD_NUMBER: builtins.int + TABLE_FORMAT_FIELD_NUMBER: builtins.int table: builtins.str """Table name""" query: builtins.str @@ -379,6 +380,9 @@ class DataSource(google.protobuf.message.Message): """Format of files at `path` (e.g. parquet, avro, etc)""" date_partition_column_format: builtins.str """Date Format of date partition column (e.g. %Y-%m-%d)""" + @property + def table_format(self) -> feast.core.DataFormat_pb2.TableFormat: + """Table Format (e.g. iceberg, delta, hudi)""" def __init__( self, *, @@ -387,8 +391,10 @@ class DataSource(google.protobuf.message.Message): path: builtins.str = ..., file_format: builtins.str = ..., date_partition_column_format: builtins.str = ..., + table_format: feast.core.DataFormat_pb2.TableFormat | None = ..., ) -> None: ... - def ClearField(self, field_name: typing_extensions.Literal["date_partition_column_format", b"date_partition_column_format", "file_format", b"file_format", "path", b"path", "query", b"query", "table", b"table"]) -> None: ... + def HasField(self, field_name: typing_extensions.Literal["table_format", b"table_format"]) -> builtins.bool: ... + def ClearField(self, field_name: typing_extensions.Literal["date_partition_column_format", b"date_partition_column_format", "file_format", b"file_format", "path", b"path", "query", b"query", "table", b"table", "table_format", b"table_format"]) -> None: ... class CustomSourceOptions(google.protobuf.message.Message): """Defines configuration for custom third-party data sources.""" diff --git a/sdk/python/feast/repo_config.py b/sdk/python/feast/repo_config.py index 895002948f1..ac4383e1142 100644 --- a/sdk/python/feast/repo_config.py +++ b/sdk/python/feast/repo_config.py @@ -182,6 +182,14 @@ def validate_path(cls, path: str, values: ValidationInfo) -> str: return path +class MaterializationConfig(BaseModel): + """Configuration options for feature materialization behavior.""" + + pull_latest_features: StrictBool = False + """ bool: If true, feature retrieval jobs will only pull the latest feature values for each entity. + If false, feature retrieval jobs will pull all feature values within the specified time range. """ + + class RepoConfig(FeastBaseModel): """Repo config. Typically loaded from `feature_store.yaml`""" @@ -239,6 +247,11 @@ class RepoConfig(FeastBaseModel): coerce_tz_aware: Optional[bool] = True """ If True, coerces entity_df timestamp columns to be timezone aware (to UTC by default). """ + materialization_config: MaterializationConfig = Field( + MaterializationConfig(), alias="materialization" + ) + """ MaterializationConfig: Configuration options for feature materialization behavior. """ + def __init__(self, **data: Any): super().__init__(**data) diff --git a/sdk/python/feast/table_format.py b/sdk/python/feast/table_format.py new file mode 100644 index 00000000000..829d8a6e19e --- /dev/null +++ b/sdk/python/feast/table_format.py @@ -0,0 +1,564 @@ +# Copyright 2020 The Feast Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import json +from abc import ABC, abstractmethod +from enum import Enum +from typing import TYPE_CHECKING, Dict, Optional + +if TYPE_CHECKING: + from feast.protos.feast.core.DataFormat_pb2 import TableFormat as TableFormatProto + + +class TableFormatType(Enum): + """Enum for supported table formats""" + + DELTA = "delta" + ICEBERG = "iceberg" + HUDI = "hudi" + + +class TableFormat(ABC): + """ + Abstract base class for table formats. + + Table formats encapsulate metadata and configuration specific to different + table storage formats like Iceberg, Delta Lake, Hudi, etc. They provide + a unified interface for configuring table-specific properties that are + used when reading from or writing to these advanced table formats. + + This base class defines the contract that all table format implementations + must follow, including serialization/deserialization capabilities and + property management. + + Attributes: + format_type (TableFormatType): The type of table format (iceberg, delta, hudi). + properties (Dict[str, str]): Dictionary of format-specific properties. + + Examples: + Table formats are typically used with data sources to specify + advanced table metadata and reading options: + + >>> from feast.table_format import IcebergFormat + >>> iceberg_format = IcebergFormat( + ... catalog="my_catalog", + ... namespace="my_namespace" + ... ) + >>> iceberg_format.set_property("snapshot-id", "123456789") + """ + + def __init__( + self, format_type: TableFormatType, properties: Optional[Dict[str, str]] = None + ): + self.format_type = format_type + self.properties = properties or {} + + @abstractmethod + def to_dict(self) -> Dict: + """Convert table format to dictionary representation""" + pass + + @classmethod + @abstractmethod + def from_dict(cls, data: Dict) -> "TableFormat": + """Create table format from dictionary representation""" + pass + + def get_property(self, key: str, default: Optional[str] = None) -> Optional[str]: + """Get a table format property""" + return self.properties.get(key, default) + + def set_property(self, key: str, value: str) -> None: + """Set a table format property""" + self.properties[key] = value + + +class IcebergFormat(TableFormat): + """ + Apache Iceberg table format configuration. + + Iceberg is an open table format for huge analytic datasets. This class provides + configuration for Iceberg-specific properties including catalog configuration, + namespace settings, and table-level properties for reading and writing Iceberg tables. + + Args: + catalog (Optional[str]): Name of the Iceberg catalog to use. The catalog manages + table metadata and provides access to tables. + namespace (Optional[str]): Namespace (schema/database) within the catalog where + the table is located. + properties (Optional[Dict[str, str]]): Properties for configuring Iceberg + catalog and table operations (e.g., warehouse location, snapshot-id, + as-of-timestamp, file format, compression, partitioning). + + Attributes: + catalog (str): The Iceberg catalog name. + namespace (str): The namespace within the catalog. + properties (Dict[str, str]): Iceberg configuration properties. + + Examples: + Basic Iceberg configuration: + + >>> iceberg_format = IcebergFormat( + ... catalog="my_catalog", + ... namespace="my_database" + ... ) + + Advanced configuration with properties: + + >>> iceberg_format = IcebergFormat( + ... catalog="spark_catalog", + ... namespace="lakehouse", + ... properties={ + ... "warehouse": "s3://my-bucket/warehouse", + ... "catalog-impl": "org.apache.iceberg.spark.SparkCatalog", + ... "format-version": "2", + ... "write.parquet.compression-codec": "snappy" + ... } + ... ) + + Reading from a specific snapshot: + + >>> iceberg_format = IcebergFormat(catalog="my_catalog", namespace="db") + >>> iceberg_format.set_property("snapshot-id", "123456789") + + Time travel queries: + + >>> iceberg_format.set_property("as-of-timestamp", "1648684800000") + """ + + def __init__( + self, + catalog: Optional[str] = None, + namespace: Optional[str] = None, + properties: Optional[Dict[str, str]] = None, + ): + super().__init__(TableFormatType.ICEBERG, properties) + self.catalog = catalog + self.namespace = namespace + + # Add catalog and namespace to properties if provided + if catalog: + self.properties["iceberg.catalog"] = catalog + if namespace: + self.properties["iceberg.namespace"] = namespace + + def to_dict(self) -> Dict: + return { + "format_type": self.format_type.value, + "catalog": self.catalog, + "namespace": self.namespace, + "properties": self.properties, + } + + @classmethod + def from_dict(cls, data: Dict) -> "IcebergFormat": + return cls( + catalog=data.get("catalog"), + namespace=data.get("namespace"), + properties=data.get("properties", {}), + ) + + def to_proto(self) -> "TableFormatProto": + """Convert to protobuf TableFormat message""" + from feast.protos.feast.core.DataFormat_pb2 import ( + TableFormat as TableFormatProto, + ) + + iceberg_proto = TableFormatProto.IcebergFormat( + catalog=self.catalog or "", + namespace=self.namespace or "", + properties=self.properties, + ) + return TableFormatProto(iceberg_format=iceberg_proto) + + @classmethod + def from_proto(cls, proto: "TableFormatProto") -> "IcebergFormat": + """Create from protobuf TableFormat message""" + iceberg_proto = proto.iceberg_format + return cls( + catalog=iceberg_proto.catalog if iceberg_proto.catalog else None, + namespace=iceberg_proto.namespace if iceberg_proto.namespace else None, + properties=dict(iceberg_proto.properties), + ) + + +class DeltaFormat(TableFormat): + """ + Delta Lake table format configuration. + + Delta Lake is an open-source storage layer that brings ACID transactions to Apache Spark + and big data workloads. This class provides configuration for Delta-specific properties + including table properties, checkpoint locations, and versioning options. + + Args: + checkpoint_location (Optional[str]): Location for storing Delta transaction logs + and checkpoints. Required for streaming operations. + properties (Optional[Dict[str, str]]): Properties for configuring Delta table + behavior (e.g., auto-optimize, vacuum settings, data skipping). + + Attributes: + checkpoint_location (str): Path to checkpoint storage location. + properties (Dict[str, str]): Delta table configuration properties. + + Examples: + Basic Delta configuration: + + >>> delta_format = DeltaFormat() + + Configuration with table properties: + + >>> delta_format = DeltaFormat( + ... properties={ + ... "delta.autoOptimize.optimizeWrite": "true", + ... "delta.autoOptimize.autoCompact": "true", + ... "delta.tuneFileSizesForRewrites": "true" + ... } + ... ) + + Streaming configuration with checkpoint: + + >>> delta_format = DeltaFormat( + ... checkpoint_location="s3://my-bucket/checkpoints/my_table" + ... ) + + Time travel - reading specific version: + + >>> delta_format = DeltaFormat() + >>> delta_format.set_property("versionAsOf", "5") + + Time travel - reading at specific timestamp: + + >>> delta_format.set_property("timestampAsOf", "2023-01-01 00:00:00") + """ + + def __init__( + self, + checkpoint_location: Optional[str] = None, + properties: Optional[Dict[str, str]] = None, + ): + super().__init__(TableFormatType.DELTA, properties) + self.checkpoint_location = checkpoint_location + + # Add checkpoint location to properties if provided + if checkpoint_location: + self.properties["delta.checkpointLocation"] = checkpoint_location + + def to_dict(self) -> Dict: + return { + "format_type": self.format_type.value, + "checkpoint_location": self.checkpoint_location, + "properties": self.properties, + } + + @classmethod + def from_dict(cls, data: Dict) -> "DeltaFormat": + return cls( + checkpoint_location=data.get("checkpoint_location"), + properties=data.get("properties", {}), + ) + + def to_proto(self) -> "TableFormatProto": + """Convert to protobuf TableFormat message""" + from feast.protos.feast.core.DataFormat_pb2 import ( + TableFormat as TableFormatProto, + ) + + delta_proto = TableFormatProto.DeltaFormat( + checkpoint_location=self.checkpoint_location or "", + properties=self.properties, + ) + return TableFormatProto(delta_format=delta_proto) + + @classmethod + def from_proto(cls, proto: "TableFormatProto") -> "DeltaFormat": + """Create from protobuf TableFormat message""" + delta_proto = proto.delta_format + return cls( + checkpoint_location=delta_proto.checkpoint_location + if delta_proto.checkpoint_location + else None, + properties=dict(delta_proto.properties), + ) + + +class HudiFormat(TableFormat): + """ + Apache Hudi table format configuration. + + Apache Hudi is a data management framework used to simplify incremental data processing + and data pipeline development. This class provides configuration for Hudi-specific + properties including table type, record keys, and write operations. + + Args: + table_type (Optional[str]): Type of Hudi table. Options are: + - "COPY_ON_WRITE": Stores data in columnar format (Parquet) and rewrites entire files + - "MERGE_ON_READ": Stores data using combination of columnar and row-based formats + record_key (Optional[str]): Field(s) that uniquely identify a record. Can be a single + field or comma-separated list for composite keys. + precombine_field (Optional[str]): Field used to determine the latest version of a record + when multiple updates exist (usually a timestamp or version field). + properties (Optional[Dict[str, str]]): Additional Hudi table properties for + configuring compaction, indexing, and other Hudi features. + + Attributes: + table_type (str): The Hudi table type (COPY_ON_WRITE or MERGE_ON_READ). + record_key (str): The record key field(s). + precombine_field (str): The field used for record deduplication. + properties (Dict[str, str]): Additional Hudi configuration properties. + + Examples: + Basic Hudi configuration: + + >>> hudi_format = HudiFormat( + ... table_type="COPY_ON_WRITE", + ... record_key="user_id", + ... precombine_field="timestamp" + ... ) + + Configuration with composite record key: + + >>> hudi_format = HudiFormat( + ... table_type="MERGE_ON_READ", + ... record_key="user_id,event_type", + ... precombine_field="event_timestamp" + ... ) + + Advanced configuration with table properties: + + >>> hudi_format = HudiFormat( + ... table_type="COPY_ON_WRITE", + ... record_key="id", + ... precombine_field="updated_at", + ... properties={ + ... "hoodie.compaction.strategy": "org.apache.hudi.table.action.compact.strategy.LogFileSizeBasedCompactionStrategy", + ... "hoodie.index.type": "BLOOM", + ... "hoodie.bloom.index.parallelism": "100" + ... } + ... ) + + Reading incremental data: + + >>> hudi_format = HudiFormat(table_type="COPY_ON_WRITE") + >>> hudi_format.set_property("hoodie.datasource.query.type", "incremental") + >>> hudi_format.set_property("hoodie.datasource.read.begin.instanttime", "20230101000000") + """ + + def __init__( + self, + table_type: Optional[str] = None, # COPY_ON_WRITE or MERGE_ON_READ + record_key: Optional[str] = None, + precombine_field: Optional[str] = None, + properties: Optional[Dict[str, str]] = None, + ): + super().__init__(TableFormatType.HUDI, properties) + self.table_type = table_type + self.record_key = record_key + self.precombine_field = precombine_field + + # Add Hudi-specific properties if provided + if table_type: + self.properties["hoodie.datasource.write.table.type"] = table_type + if record_key: + self.properties["hoodie.datasource.write.recordkey.field"] = record_key + if precombine_field: + self.properties["hoodie.datasource.write.precombine.field"] = ( + precombine_field + ) + + def to_dict(self) -> Dict: + return { + "format_type": self.format_type.value, + "table_type": self.table_type, + "record_key": self.record_key, + "precombine_field": self.precombine_field, + "properties": self.properties, + } + + @classmethod + def from_dict(cls, data: Dict) -> "HudiFormat": + return cls( + table_type=data.get("table_type"), + record_key=data.get("record_key"), + precombine_field=data.get("precombine_field"), + properties=data.get("properties", {}), + ) + + def to_proto(self) -> "TableFormatProto": + """Convert to protobuf TableFormat message""" + from feast.protos.feast.core.DataFormat_pb2 import ( + TableFormat as TableFormatProto, + ) + + hudi_proto = TableFormatProto.HudiFormat( + table_type=self.table_type or "", + record_key=self.record_key or "", + precombine_field=self.precombine_field or "", + properties=self.properties, + ) + return TableFormatProto(hudi_format=hudi_proto) + + @classmethod + def from_proto(cls, proto: "TableFormatProto") -> "HudiFormat": + """Create from protobuf TableFormat message""" + hudi_proto = proto.hudi_format + return cls( + table_type=hudi_proto.table_type if hudi_proto.table_type else None, + record_key=hudi_proto.record_key if hudi_proto.record_key else None, + precombine_field=hudi_proto.precombine_field + if hudi_proto.precombine_field + else None, + properties=dict(hudi_proto.properties), + ) + + +def create_table_format(format_type: TableFormatType, **kwargs) -> TableFormat: + """ + Factory function to create appropriate TableFormat instance based on type. + + This is a convenience function that creates the correct TableFormat subclass + based on the provided format type, passing through any additional keyword arguments + to the constructor. + + Args: + format_type (TableFormatType): The type of table format to create. + **kwargs: Additional keyword arguments passed to the format constructor. + + Returns: + TableFormat: An instance of the appropriate TableFormat subclass. + + Raises: + ValueError: If an unsupported format_type is provided. + + Examples: + Create an Iceberg format: + + >>> iceberg_format = create_table_format( + ... TableFormatType.ICEBERG, + ... catalog="my_catalog", + ... namespace="my_db" + ... ) + + Create a Delta format: + + >>> delta_format = create_table_format( + ... TableFormatType.DELTA, + ... checkpoint_location="s3://bucket/checkpoints" + ... ) + """ + if format_type == TableFormatType.ICEBERG: + return IcebergFormat(**kwargs) + elif format_type == TableFormatType.DELTA: + return DeltaFormat(**kwargs) + elif format_type == TableFormatType.HUDI: + return HudiFormat(**kwargs) + else: + raise ValueError(f"Unknown table format type: {format_type}") + + +def table_format_from_dict(data: Dict) -> TableFormat: + """ + Create TableFormat instance from dictionary representation. + + This function deserializes a dictionary (typically from JSON or protobuf) + back into the appropriate TableFormat instance. The dictionary must contain + a 'format_type' field that indicates which format class to instantiate. + + Args: + data (Dict): Dictionary containing table format configuration. Must include + 'format_type' field with value 'iceberg', 'delta', or 'hudi'. + + Returns: + TableFormat: An instance of the appropriate TableFormat subclass. + + Raises: + ValueError: If format_type is not recognized. + KeyError: If format_type field is missing from data. + + Examples: + Deserialize an Iceberg format: + + >>> data = { + ... "format_type": "iceberg", + ... "catalog": "my_catalog", + ... "namespace": "my_db" + ... } + >>> iceberg_format = table_format_from_dict(data) + """ + if "format_type" not in data: + raise KeyError("Missing 'format_type' field in data") + format_type = data["format_type"] + + if format_type == TableFormatType.ICEBERG.value: + return IcebergFormat.from_dict(data) + elif format_type == TableFormatType.DELTA.value: + return DeltaFormat.from_dict(data) + elif format_type == TableFormatType.HUDI.value: + return HudiFormat.from_dict(data) + else: + raise ValueError(f"Unknown table format type: {format_type}") + + +def table_format_from_json(json_str: str) -> TableFormat: + """ + Create TableFormat instance from JSON string. + + This is a convenience function that parses a JSON string and creates + the appropriate TableFormat instance. Useful for loading table format + configurations from files or network requests. + + Args: + json_str (str): JSON string containing table format configuration. + + Returns: + TableFormat: An instance of the appropriate TableFormat subclass. + + Raises: + json.JSONDecodeError: If the JSON string is invalid. + ValueError: If format_type is not recognized. + KeyError: If format_type field is missing. + + Examples: + Load from JSON string: + + >>> json_config = '{"format_type": "delta", "checkpoint_location": "s3://bucket/checkpoints"}' + >>> delta_format = table_format_from_json(json_config) + """ + data = json.loads(json_str) + return table_format_from_dict(data) + + +def table_format_from_proto(proto: "TableFormatProto") -> TableFormat: + """ + Create TableFormat instance from protobuf TableFormat message. + + Args: + proto: TableFormat protobuf message + + Returns: + TableFormat: An instance of the appropriate TableFormat subclass. + + Raises: + ValueError: If the proto doesn't contain a recognized format. + """ + + which_format = proto.WhichOneof("format") + + if which_format == "iceberg_format": + return IcebergFormat.from_proto(proto) + elif which_format == "delta_format": + return DeltaFormat.from_proto(proto) + elif which_format == "hudi_format": + return HudiFormat.from_proto(proto) + else: + raise ValueError(f"Unknown table format in proto: {which_format}") diff --git a/sdk/python/feast/ui/package.json b/sdk/python/feast/ui/package.json index 6b65265755c..45cb24e2e54 100644 --- a/sdk/python/feast/ui/package.json +++ b/sdk/python/feast/ui/package.json @@ -6,7 +6,7 @@ "@elastic/datemath": "^5.0.3", "@elastic/eui": "^72.0.0", "@emotion/react": "^11.9.0", - "@feast-dev/feast-ui": "0.55.0", + "@feast-dev/feast-ui": "0.56.0", "@testing-library/jest-dom": "^5.16.4", "@testing-library/react": "^13.2.0", "@testing-library/user-event": "^13.5.0", diff --git a/sdk/python/feast/ui/yarn.lock b/sdk/python/feast/ui/yarn.lock index 152bf0fb3fd..bb4dde4c85f 100644 --- a/sdk/python/feast/ui/yarn.lock +++ b/sdk/python/feast/ui/yarn.lock @@ -1575,10 +1575,10 @@ minimatch "^3.1.2" strip-json-comments "^3.1.1" -"@feast-dev/feast-ui@0.55.0": - version "0.55.0" - resolved "https://registry.yarnpkg.com/@feast-dev/feast-ui/-/feast-ui-0.55.0.tgz#dd90ae8a96fdf3a5da5dd12e1d740c4b78faf0a5" - integrity sha512-T+j5ZwPafIsnsUFcSUENh+fR9aKmlfMkJhtKUzKreqg/5bxXRAo0fKIBWkKFSV+KsIAkDCwa8R7V9N5C/eeiKQ== +"@feast-dev/feast-ui@0.56.0": + version "0.56.0" + resolved "https://registry.yarnpkg.com/@feast-dev/feast-ui/-/feast-ui-0.56.0.tgz#69a08ce552a163ae37e5af5b363459a7615e7907" + integrity sha512-FkmT6oUzuEj/NKZ5+3aBmMVkNZ7CHB+58KW9YS7Gb8BoxjP97ALO8ERV6VONK+xLou2njubinKzzsBZwy5joIA== dependencies: "@elastic/datemath" "^5.0.3" "@elastic/eui" "^95.12.0" diff --git a/sdk/python/requirements/py3.10-ci-requirements.txt b/sdk/python/requirements/py3.10-ci-requirements.txt index 36097195ebe..7e97506aaa1 100644 --- a/sdk/python/requirements/py3.10-ci-requirements.txt +++ b/sdk/python/requirements/py3.10-ci-requirements.txt @@ -5335,9 +5335,9 @@ stack-data==0.6.3 \ --hash=sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9 \ --hash=sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695 # via ipython -starlette==0.48.0 \ - --hash=sha256:0764ca97b097582558ecb498132ed0c7d942f233f365b86ba37770e026510659 \ - --hash=sha256:7e8cee469a8ab2352911528110ce9088fdc6a37d9876926e73da7ce4aa4c7a46 +starlette==0.49.1 \ + --hash=sha256:481a43b71e24ed8c43b11ea02f5353d77840e01480881b8cb5a26b8cae64a8cb \ + --hash=sha256:d92ce9f07e4a3caa3ac13a79523bd18e3bc0042bb8ff2d759a8e7dd0e1859875 # via # fastapi # mcp diff --git a/sdk/python/requirements/py3.10-minimal-requirements.txt b/sdk/python/requirements/py3.10-minimal-requirements.txt index 32ea73edcc9..dad417dce86 100644 --- a/sdk/python/requirements/py3.10-minimal-requirements.txt +++ b/sdk/python/requirements/py3.10-minimal-requirements.txt @@ -2579,9 +2579,9 @@ sse-starlette==3.0.2 \ --hash=sha256:16b7cbfddbcd4eaca11f7b586f3b8a080f1afe952c15813455b162edea619e5a \ --hash=sha256:ccd60b5765ebb3584d0de2d7a6e4f745672581de4f5005ab31c3a25d10b52b3a # via mcp -starlette==0.48.0 \ - --hash=sha256:0764ca97b097582558ecb498132ed0c7d942f233f365b86ba37770e026510659 \ - --hash=sha256:7e8cee469a8ab2352911528110ce9088fdc6a37d9876926e73da7ce4aa4c7a46 +starlette==0.49.1 \ + --hash=sha256:481a43b71e24ed8c43b11ea02f5353d77840e01480881b8cb5a26b8cae64a8cb \ + --hash=sha256:d92ce9f07e4a3caa3ac13a79523bd18e3bc0042bb8ff2d759a8e7dd0e1859875 # via # fastapi # mcp diff --git a/sdk/python/requirements/py3.10-minimal-sdist-requirements.txt b/sdk/python/requirements/py3.10-minimal-sdist-requirements.txt index a08c1764e12..6f02ffa81e8 100644 --- a/sdk/python/requirements/py3.10-minimal-sdist-requirements.txt +++ b/sdk/python/requirements/py3.10-minimal-sdist-requirements.txt @@ -2843,9 +2843,9 @@ sse-starlette==3.0.2 \ --hash=sha256:16b7cbfddbcd4eaca11f7b586f3b8a080f1afe952c15813455b162edea619e5a \ --hash=sha256:ccd60b5765ebb3584d0de2d7a6e4f745672581de4f5005ab31c3a25d10b52b3a # via mcp -starlette==0.48.0 \ - --hash=sha256:0764ca97b097582558ecb498132ed0c7d942f233f365b86ba37770e026510659 \ - --hash=sha256:7e8cee469a8ab2352911528110ce9088fdc6a37d9876926e73da7ce4aa4c7a46 +starlette==0.49.1 \ + --hash=sha256:481a43b71e24ed8c43b11ea02f5353d77840e01480881b8cb5a26b8cae64a8cb \ + --hash=sha256:d92ce9f07e4a3caa3ac13a79523bd18e3bc0042bb8ff2d759a8e7dd0e1859875 # via # fastapi # mcp diff --git a/sdk/python/requirements/py3.10-requirements.txt b/sdk/python/requirements/py3.10-requirements.txt index 34753401037..e6f78af2933 100644 --- a/sdk/python/requirements/py3.10-requirements.txt +++ b/sdk/python/requirements/py3.10-requirements.txt @@ -1161,9 +1161,9 @@ sqlalchemy[mypy]==2.0.44 \ --hash=sha256:f9480c0740aabd8cb29c329b422fb65358049840b34aba0adf63162371d2a96e \ --hash=sha256:ff486e183d151e51b1d694c7aa1695747599bb00b9f5f604092b54b74c64a8e1 # via feast (setup.py) -starlette==0.48.0 \ - --hash=sha256:0764ca97b097582558ecb498132ed0c7d942f233f365b86ba37770e026510659 \ - --hash=sha256:7e8cee469a8ab2352911528110ce9088fdc6a37d9876926e73da7ce4aa4c7a46 +starlette==0.49.1 \ + --hash=sha256:481a43b71e24ed8c43b11ea02f5353d77840e01480881b8cb5a26b8cae64a8cb \ + --hash=sha256:d92ce9f07e4a3caa3ac13a79523bd18e3bc0042bb8ff2d759a8e7dd0e1859875 # via fastapi tabulate==0.9.0 \ --hash=sha256:0095b12bf5966de529c0feb1fa08671671b3368eec77d7ef7ab114be2c068b3c \ diff --git a/sdk/python/requirements/py3.11-ci-requirements.txt b/sdk/python/requirements/py3.11-ci-requirements.txt index 6284d422fb4..55e1eaaa493 100644 --- a/sdk/python/requirements/py3.11-ci-requirements.txt +++ b/sdk/python/requirements/py3.11-ci-requirements.txt @@ -5561,9 +5561,9 @@ stack-data==0.6.3 \ --hash=sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9 \ --hash=sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695 # via ipython -starlette==0.48.0 \ - --hash=sha256:0764ca97b097582558ecb498132ed0c7d942f233f365b86ba37770e026510659 \ - --hash=sha256:7e8cee469a8ab2352911528110ce9088fdc6a37d9876926e73da7ce4aa4c7a46 +starlette==0.49.1 \ + --hash=sha256:481a43b71e24ed8c43b11ea02f5353d77840e01480881b8cb5a26b8cae64a8cb \ + --hash=sha256:d92ce9f07e4a3caa3ac13a79523bd18e3bc0042bb8ff2d759a8e7dd0e1859875 # via # fastapi # mcp diff --git a/sdk/python/requirements/py3.11-minimal-requirements.txt b/sdk/python/requirements/py3.11-minimal-requirements.txt index 08412591cea..bb34efb473a 100644 --- a/sdk/python/requirements/py3.11-minimal-requirements.txt +++ b/sdk/python/requirements/py3.11-minimal-requirements.txt @@ -2543,9 +2543,9 @@ sse-starlette==3.0.2 \ --hash=sha256:16b7cbfddbcd4eaca11f7b586f3b8a080f1afe952c15813455b162edea619e5a \ --hash=sha256:ccd60b5765ebb3584d0de2d7a6e4f745672581de4f5005ab31c3a25d10b52b3a # via mcp -starlette==0.48.0 \ - --hash=sha256:0764ca97b097582558ecb498132ed0c7d942f233f365b86ba37770e026510659 \ - --hash=sha256:7e8cee469a8ab2352911528110ce9088fdc6a37d9876926e73da7ce4aa4c7a46 +starlette==0.49.1 \ + --hash=sha256:481a43b71e24ed8c43b11ea02f5353d77840e01480881b8cb5a26b8cae64a8cb \ + --hash=sha256:d92ce9f07e4a3caa3ac13a79523bd18e3bc0042bb8ff2d759a8e7dd0e1859875 # via # fastapi # mcp diff --git a/sdk/python/requirements/py3.11-minimal-sdist-requirements.txt b/sdk/python/requirements/py3.11-minimal-sdist-requirements.txt index 6f5e8dc6bb2..b39e8936ff0 100644 --- a/sdk/python/requirements/py3.11-minimal-sdist-requirements.txt +++ b/sdk/python/requirements/py3.11-minimal-sdist-requirements.txt @@ -2809,9 +2809,9 @@ sse-starlette==3.0.2 \ --hash=sha256:16b7cbfddbcd4eaca11f7b586f3b8a080f1afe952c15813455b162edea619e5a \ --hash=sha256:ccd60b5765ebb3584d0de2d7a6e4f745672581de4f5005ab31c3a25d10b52b3a # via mcp -starlette==0.48.0 \ - --hash=sha256:0764ca97b097582558ecb498132ed0c7d942f233f365b86ba37770e026510659 \ - --hash=sha256:7e8cee469a8ab2352911528110ce9088fdc6a37d9876926e73da7ce4aa4c7a46 +starlette==0.49.1 \ + --hash=sha256:481a43b71e24ed8c43b11ea02f5353d77840e01480881b8cb5a26b8cae64a8cb \ + --hash=sha256:d92ce9f07e4a3caa3ac13a79523bd18e3bc0042bb8ff2d759a8e7dd0e1859875 # via # fastapi # mcp diff --git a/sdk/python/requirements/py3.11-requirements.txt b/sdk/python/requirements/py3.11-requirements.txt index 497ac1bc0d9..a2d8e1b2efa 100644 --- a/sdk/python/requirements/py3.11-requirements.txt +++ b/sdk/python/requirements/py3.11-requirements.txt @@ -1176,9 +1176,9 @@ sqlalchemy[mypy]==2.0.44 \ --hash=sha256:f9480c0740aabd8cb29c329b422fb65358049840b34aba0adf63162371d2a96e \ --hash=sha256:ff486e183d151e51b1d694c7aa1695747599bb00b9f5f604092b54b74c64a8e1 # via feast (setup.py) -starlette==0.48.0 \ - --hash=sha256:0764ca97b097582558ecb498132ed0c7d942f233f365b86ba37770e026510659 \ - --hash=sha256:7e8cee469a8ab2352911528110ce9088fdc6a37d9876926e73da7ce4aa4c7a46 +starlette==0.49.1 \ + --hash=sha256:481a43b71e24ed8c43b11ea02f5353d77840e01480881b8cb5a26b8cae64a8cb \ + --hash=sha256:d92ce9f07e4a3caa3ac13a79523bd18e3bc0042bb8ff2d759a8e7dd0e1859875 # via fastapi tabulate==0.9.0 \ --hash=sha256:0095b12bf5966de529c0feb1fa08671671b3368eec77d7ef7ab114be2c068b3c \ diff --git a/sdk/python/requirements/py3.12-ci-requirements.txt b/sdk/python/requirements/py3.12-ci-requirements.txt index 438408049d2..c2bae2150a2 100644 --- a/sdk/python/requirements/py3.12-ci-requirements.txt +++ b/sdk/python/requirements/py3.12-ci-requirements.txt @@ -5552,9 +5552,9 @@ stack-data==0.6.3 \ --hash=sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9 \ --hash=sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695 # via ipython -starlette==0.48.0 \ - --hash=sha256:0764ca97b097582558ecb498132ed0c7d942f233f365b86ba37770e026510659 \ - --hash=sha256:7e8cee469a8ab2352911528110ce9088fdc6a37d9876926e73da7ce4aa4c7a46 +starlette==0.49.1 \ + --hash=sha256:481a43b71e24ed8c43b11ea02f5353d77840e01480881b8cb5a26b8cae64a8cb \ + --hash=sha256:d92ce9f07e4a3caa3ac13a79523bd18e3bc0042bb8ff2d759a8e7dd0e1859875 # via # fastapi # mcp diff --git a/sdk/python/requirements/py3.12-minimal-requirements.txt b/sdk/python/requirements/py3.12-minimal-requirements.txt index cba3a0d63a5..13c3766b194 100644 --- a/sdk/python/requirements/py3.12-minimal-requirements.txt +++ b/sdk/python/requirements/py3.12-minimal-requirements.txt @@ -2535,9 +2535,9 @@ sse-starlette==3.0.2 \ --hash=sha256:16b7cbfddbcd4eaca11f7b586f3b8a080f1afe952c15813455b162edea619e5a \ --hash=sha256:ccd60b5765ebb3584d0de2d7a6e4f745672581de4f5005ab31c3a25d10b52b3a # via mcp -starlette==0.48.0 \ - --hash=sha256:0764ca97b097582558ecb498132ed0c7d942f233f365b86ba37770e026510659 \ - --hash=sha256:7e8cee469a8ab2352911528110ce9088fdc6a37d9876926e73da7ce4aa4c7a46 +starlette==0.49.1 \ + --hash=sha256:481a43b71e24ed8c43b11ea02f5353d77840e01480881b8cb5a26b8cae64a8cb \ + --hash=sha256:d92ce9f07e4a3caa3ac13a79523bd18e3bc0042bb8ff2d759a8e7dd0e1859875 # via # fastapi # mcp diff --git a/sdk/python/requirements/py3.12-minimal-sdist-requirements.txt b/sdk/python/requirements/py3.12-minimal-sdist-requirements.txt index 1f0390892be..0fd654f364e 100644 --- a/sdk/python/requirements/py3.12-minimal-sdist-requirements.txt +++ b/sdk/python/requirements/py3.12-minimal-sdist-requirements.txt @@ -2801,9 +2801,9 @@ sse-starlette==3.0.2 \ --hash=sha256:16b7cbfddbcd4eaca11f7b586f3b8a080f1afe952c15813455b162edea619e5a \ --hash=sha256:ccd60b5765ebb3584d0de2d7a6e4f745672581de4f5005ab31c3a25d10b52b3a # via mcp -starlette==0.48.0 \ - --hash=sha256:0764ca97b097582558ecb498132ed0c7d942f233f365b86ba37770e026510659 \ - --hash=sha256:7e8cee469a8ab2352911528110ce9088fdc6a37d9876926e73da7ce4aa4c7a46 +starlette==0.49.1 \ + --hash=sha256:481a43b71e24ed8c43b11ea02f5353d77840e01480881b8cb5a26b8cae64a8cb \ + --hash=sha256:d92ce9f07e4a3caa3ac13a79523bd18e3bc0042bb8ff2d759a8e7dd0e1859875 # via # fastapi # mcp diff --git a/sdk/python/requirements/py3.12-requirements.txt b/sdk/python/requirements/py3.12-requirements.txt index 37bb6d3ff9d..d3e3743cf54 100644 --- a/sdk/python/requirements/py3.12-requirements.txt +++ b/sdk/python/requirements/py3.12-requirements.txt @@ -1172,9 +1172,9 @@ sqlalchemy[mypy]==2.0.44 \ --hash=sha256:f9480c0740aabd8cb29c329b422fb65358049840b34aba0adf63162371d2a96e \ --hash=sha256:ff486e183d151e51b1d694c7aa1695747599bb00b9f5f604092b54b74c64a8e1 # via feast (setup.py) -starlette==0.48.0 \ - --hash=sha256:0764ca97b097582558ecb498132ed0c7d942f233f365b86ba37770e026510659 \ - --hash=sha256:7e8cee469a8ab2352911528110ce9088fdc6a37d9876926e73da7ce4aa4c7a46 +starlette==0.49.1 \ + --hash=sha256:481a43b71e24ed8c43b11ea02f5353d77840e01480881b8cb5a26b8cae64a8cb \ + --hash=sha256:d92ce9f07e4a3caa3ac13a79523bd18e3bc0042bb8ff2d759a8e7dd0e1859875 # via fastapi tabulate==0.9.0 \ --hash=sha256:0095b12bf5966de529c0feb1fa08671671b3368eec77d7ef7ab114be2c068b3c \ diff --git a/sdk/python/tests/integration/feature_repos/repo_configuration.py b/sdk/python/tests/integration/feature_repos/repo_configuration.py index 89a13df69ed..14e60cb7cf9 100644 --- a/sdk/python/tests/integration/feature_repos/repo_configuration.py +++ b/sdk/python/tests/integration/feature_repos/repo_configuration.py @@ -34,7 +34,7 @@ from feast.permissions.auth_model import OidcClientAuthConfig from feast.permissions.permission import Permission from feast.permissions.policy import RoleBasedPolicy -from feast.repo_config import RegistryConfig, RepoConfig +from feast.repo_config import MaterializationConfig, RegistryConfig, RepoConfig from feast.utils import _utc_now from tests.integration.feature_repos.integration_test_repo_config import ( IntegrationTestRepoConfig, @@ -423,6 +423,9 @@ class Environment: entity_key_serialization_version: int repo_dir_name: str fixture_request: Optional[pytest.FixtureRequest] = None + materialization: MaterializationConfig = dataclasses.field( + default_factory=lambda: MaterializationConfig() + ) def __post_init__(self): self.end_date = _utc_now().replace(microsecond=0, second=0, minute=0) @@ -443,6 +446,7 @@ def setup(self): repo_path=self.repo_dir_name, feature_server=self.feature_server, entity_key_serialization_version=self.entity_key_serialization_version, + materialization_config=self.materialization, ) self.feature_store = FeatureStore(config=self.config) diff --git a/sdk/python/tests/integration/feature_repos/universal/online_store/mysql.py b/sdk/python/tests/integration/feature_repos/universal/online_store/mysql.py index 093295c86ba..c0ba91d15a4 100644 --- a/sdk/python/tests/integration/feature_repos/universal/online_store/mysql.py +++ b/sdk/python/tests/integration/feature_repos/universal/online_store/mysql.py @@ -31,3 +31,31 @@ def create_online_store(self) -> Dict[str, str]: def teardown(self): self.container.stop() + + +class BatchWriteMySQLOnlineStoreCreator(OnlineStoreCreator): + def __init__(self, project_name: str, **kwargs): + super().__init__(project_name) + self.container = ( + MySqlContainer("mysql:latest", platform="linux/amd64") + .with_exposed_ports(3306) + .with_env("MYSQL_USER", "root") + .with_env("MYSQL_PASSWORD", "test") + .with_env("MYSQL_DATABASE", "test") + ) + + def create_online_store(self) -> Dict[str, str]: + self.container.start() + exposed_port = self.container.get_exposed_port(3306) + return { + "type": "mysql", + "user": "root", + "password": "test", + "database": "test", + "port": exposed_port, + "batch_write": "True", + "bacth_size": "1000", + } + + def teardown(self): + self.container.stop() diff --git a/sdk/python/tests/integration/materialization/test_universal_materialization.py b/sdk/python/tests/integration/materialization/test_universal_materialization.py index 860e9a5fc6c..cf15746bf9e 100644 --- a/sdk/python/tests/integration/materialization/test_universal_materialization.py +++ b/sdk/python/tests/integration/materialization/test_universal_materialization.py @@ -219,7 +219,12 @@ def odfv_multi(df: pd.DataFrame) -> pd.DataFrame: @pytest.mark.integration @pytest.mark.universal_offline_stores -def test_universal_materialization_consistency(environment): +@pytest.mark.parametrize("materialization_pull_latest", [True, False]) +def test_universal_materialization_consistency( + environment, materialization_pull_latest +): + environment.materialization.pull_latest_features = materialization_pull_latest + fs = environment.feature_store df = create_basic_driver_dataset() ds = environment.data_source_creator.create_data_source( diff --git a/sdk/python/tests/unit/infra/offline_stores/contrib/spark_offline_store/test_spark_table_format_integration.py b/sdk/python/tests/unit/infra/offline_stores/contrib/spark_offline_store/test_spark_table_format_integration.py new file mode 100644 index 00000000000..639f478fb28 --- /dev/null +++ b/sdk/python/tests/unit/infra/offline_stores/contrib/spark_offline_store/test_spark_table_format_integration.py @@ -0,0 +1,303 @@ +from unittest.mock import MagicMock, patch + +import pytest + +from feast.infra.offline_stores.contrib.spark_offline_store.spark_source import ( + SparkOptions, + SparkSource, +) +from feast.table_format import ( + DeltaFormat, + HudiFormat, + IcebergFormat, +) + + +class TestSparkSourceWithTableFormat: + """Test SparkSource integration with TableFormat.""" + + def test_spark_source_with_iceberg_table_format(self): + """Test SparkSource with IcebergFormat.""" + iceberg_format = IcebergFormat( + catalog="my_catalog", + namespace="my_db", + ) + iceberg_format.set_property("snapshot-id", "123456789") + + spark_source = SparkSource( + name="iceberg_features", + path="my_catalog.my_db.my_table", + table_format=iceberg_format, + ) + + assert spark_source.table_format == iceberg_format + assert spark_source.table_format.catalog == "my_catalog" + assert spark_source.table_format.get_property("snapshot-id") == "123456789" + assert spark_source.path == "my_catalog.my_db.my_table" + + def test_spark_source_with_delta_table_format(self): + """Test SparkSource with DeltaFormat.""" + delta_format = DeltaFormat() + delta_format.set_property("versionAsOf", "1") + + spark_source = SparkSource( + name="delta_features", + path="s3://bucket/delta-table", + table_format=delta_format, + ) + + assert spark_source.table_format == delta_format + assert spark_source.table_format.get_property("versionAsOf") == "1" + assert spark_source.path == "s3://bucket/delta-table" + + def test_spark_source_with_hudi_table_format(self): + """Test SparkSource with HudiFormat.""" + hudi_format = HudiFormat( + table_type="COPY_ON_WRITE", + record_key="id", + ) + hudi_format.set_property("hoodie.datasource.query.type", "snapshot") + + spark_source = SparkSource( + name="hudi_features", + path="s3://bucket/hudi-table", + table_format=hudi_format, + ) + + assert spark_source.table_format == hudi_format + assert spark_source.table_format.table_type == "COPY_ON_WRITE" + assert ( + spark_source.table_format.get_property("hoodie.datasource.query.type") + == "snapshot" + ) + + def test_spark_source_without_table_format(self): + """Test SparkSource without table format (traditional file reading).""" + spark_source = SparkSource( + name="parquet_features", + path="s3://bucket/data.parquet", + file_format="parquet", + ) + + assert spark_source.table_format is None + assert spark_source.file_format == "parquet" + assert spark_source.path == "s3://bucket/data.parquet" + + def test_spark_source_both_file_and_table_format(self): + """Test SparkSource with both file_format and table_format.""" + iceberg_format = IcebergFormat() + + spark_source = SparkSource( + name="mixed_features", + path="s3://bucket/iceberg-table", + file_format="parquet", # Underlying file format + table_format=iceberg_format, # Table metadata format + ) + + assert spark_source.table_format == iceberg_format + assert spark_source.file_format == "parquet" + + def test_spark_source_validation_with_table_format(self): + """Test SparkSource validation with table_format.""" + iceberg_format = IcebergFormat() + + # Should work: path with table_format, no file_format + spark_source = SparkSource( + name="iceberg_table", + path="catalog.db.table", + table_format=iceberg_format, + ) + assert spark_source.table_format == iceberg_format + + # Should work: path with both table_format and file_format + spark_source = SparkSource( + name="iceberg_table_with_file_format", + path="s3://bucket/data", + file_format="parquet", + table_format=iceberg_format, + ) + assert spark_source.table_format == iceberg_format + assert spark_source.file_format == "parquet" + + def test_spark_source_validation_without_table_format(self): + """Test SparkSource validation without table_format.""" + # Should work: path with file_format, no table_format + spark_source = SparkSource( + name="parquet_file", + path="s3://bucket/data.parquet", + file_format="parquet", + ) + assert spark_source.file_format == "parquet" + assert spark_source.table_format is None + + # Should fail: path without file_format or table_format + with pytest.raises( + ValueError, + match="If 'path' is specified without 'table_format', then 'file_format' is required", + ): + SparkSource( + name="invalid_source", + path="s3://bucket/data", + ) + + @patch( + "feast.infra.offline_stores.contrib.spark_offline_store.spark.get_spark_session_or_start_new_with_repoconfig" + ) + def test_load_dataframe_from_path_with_table_format(self, mock_get_spark_session): + """Test _load_dataframe_from_path with table formats.""" + mock_spark_session = MagicMock() + mock_get_spark_session.getActiveSession.return_value = mock_spark_session + + mock_reader = MagicMock() + mock_spark_session.read.format.return_value = mock_reader + mock_reader.option.return_value = mock_reader + mock_reader.load.return_value = MagicMock() + + # Test Iceberg with options + iceberg_format = IcebergFormat() + iceberg_format.set_property("snapshot-id", "123456789") + iceberg_format.set_property("read.split.target-size", "134217728") + + spark_source = SparkSource( + name="iceberg_test", + path="catalog.db.table", + table_format=iceberg_format, + ) + + spark_source._load_dataframe_from_path(mock_spark_session) + + # Verify format was set to iceberg + mock_spark_session.read.format.assert_called_with("iceberg") + + # Verify options were set + mock_reader.option.assert_any_call("snapshot-id", "123456789") + mock_reader.option.assert_any_call("read.split.target-size", "134217728") + + # Verify load was called with the path + mock_reader.load.assert_called_with("catalog.db.table") + + @patch( + "feast.infra.offline_stores.contrib.spark_offline_store.spark.get_spark_session_or_start_new_with_repoconfig" + ) + def test_load_dataframe_from_path_without_table_format( + self, mock_get_spark_session + ): + """Test _load_dataframe_from_path without table formats.""" + mock_spark_session = MagicMock() + mock_get_spark_session.getActiveSession.return_value = mock_spark_session + + mock_reader = MagicMock() + mock_spark_session.read.format.return_value = mock_reader + mock_reader.load.return_value = MagicMock() + + spark_source = SparkSource( + name="parquet_test", + path="s3://bucket/data.parquet", + file_format="parquet", + ) + + spark_source._load_dataframe_from_path(mock_spark_session) + + # Verify format was set to parquet (file format) + mock_spark_session.read.format.assert_called_with("parquet") + + # Verify load was called with the path + mock_reader.load.assert_called_with("s3://bucket/data.parquet") + + +class TestSparkOptionsWithTableFormat: + """Test SparkOptions serialization with TableFormat.""" + + def test_spark_options_protobuf_serialization_with_table_format(self): + """Test SparkOptions protobuf serialization/deserialization with table format.""" + iceberg_format = IcebergFormat( + catalog="test_catalog", + namespace="test_namespace", + ) + iceberg_format.set_property("snapshot-id", "123456789") + + spark_options = SparkOptions( + table=None, + query=None, + path="catalog.db.table", + file_format=None, + table_format=iceberg_format, + ) + + # Test serialization to proto + proto = spark_options.to_proto() + assert proto.path == "catalog.db.table" + assert proto.file_format == "" # Should be empty when not provided + + # Verify table_format is serialized as proto TableFormat + assert proto.HasField("table_format") + assert proto.table_format.HasField("iceberg_format") + assert proto.table_format.iceberg_format.catalog == "test_catalog" + assert proto.table_format.iceberg_format.namespace == "test_namespace" + assert ( + proto.table_format.iceberg_format.properties["snapshot-id"] == "123456789" + ) + + # Test deserialization from proto + restored_options = SparkOptions.from_proto(proto) + assert restored_options.path == "catalog.db.table" + assert restored_options.file_format == "" + assert isinstance(restored_options.table_format, IcebergFormat) + assert restored_options.table_format.catalog == "test_catalog" + assert restored_options.table_format.namespace == "test_namespace" + assert restored_options.table_format.get_property("snapshot-id") == "123456789" + + def test_spark_options_protobuf_serialization_without_table_format(self): + """Test SparkOptions protobuf serialization/deserialization without table format.""" + spark_options = SparkOptions( + table=None, + query=None, + path="s3://bucket/data.parquet", + file_format="parquet", + table_format=None, + ) + + # Test serialization to proto + proto = spark_options.to_proto() + assert proto.path == "s3://bucket/data.parquet" + assert proto.file_format == "parquet" + assert not proto.HasField("table_format") # Should not have table_format field + + # Test deserialization from proto + restored_options = SparkOptions.from_proto(proto) + assert restored_options.path == "s3://bucket/data.parquet" + assert restored_options.file_format == "parquet" + assert restored_options.table_format is None + + def test_spark_source_protobuf_roundtrip_with_table_format(self): + """Test complete SparkSource protobuf roundtrip with table format.""" + delta_format = DeltaFormat() + delta_format.set_property("versionAsOf", "1") + + original_source = SparkSource( + name="delta_test", + path="s3://bucket/delta-table", + table_format=delta_format, + timestamp_field="event_timestamp", + created_timestamp_column="created_at", + description="Test delta source", + ) + + # Serialize to proto + proto = original_source._to_proto_impl() + + # Deserialize from proto + restored_source = SparkSource.from_proto(proto) + + assert restored_source.name == original_source.name + assert restored_source.path == original_source.path + assert restored_source.timestamp_field == original_source.timestamp_field + assert ( + restored_source.created_timestamp_column + == original_source.created_timestamp_column + ) + assert restored_source.description == original_source.description + + # Verify table_format is properly restored + assert isinstance(restored_source.table_format, DeltaFormat) + assert restored_source.table_format.get_property("versionAsOf") == "1" diff --git a/sdk/python/tests/unit/infra/offline_stores/test_clickhouse.py b/sdk/python/tests/unit/infra/offline_stores/test_clickhouse.py new file mode 100644 index 00000000000..38c632a59a7 --- /dev/null +++ b/sdk/python/tests/unit/infra/offline_stores/test_clickhouse.py @@ -0,0 +1,78 @@ +import threading +from unittest.mock import MagicMock, patch + +import pytest + +from feast.infra.utils.clickhouse.clickhouse_config import ClickhouseConfig +from feast.infra.utils.clickhouse.connection_utils import get_client, thread_local + + +@pytest.fixture +def clickhouse_config(): + """Create a test ClickHouse configuration.""" + return ClickhouseConfig( + host="localhost", + port=9000, + user="default", + password="password", + database="test_db", + ) + + +@pytest.fixture(autouse=True) +def cleanup_thread_local(): + """Clean up thread_local storage after each test.""" + yield + if hasattr(thread_local, "clickhouse_client"): + delattr(thread_local, "clickhouse_client") + + +@patch("feast.infra.utils.clickhouse.connection_utils.clickhouse_connect.get_client") +def test_get_client_returns_different_objects_for_separate_threads( + mock_get_client, clickhouse_config +): + """ + Clickhouse client is thread-unsafe and crashes if shared between threads. + This test ensures that get_client returns different client instances for different threads, while + reusing the same instance within the same thread. + """ + + def create_mock_client(*args, **kwargs): + """Create a unique mock client for each call.""" + return MagicMock() + + mock_get_client.side_effect = create_mock_client + + results = {} + + def thread_1_work(): + """Thread 1 makes 2 calls to get_client.""" + client_1a = get_client(clickhouse_config) + client_1b = get_client(clickhouse_config) + results["thread_1"] = (client_1a, client_1b) + + def thread_2_work(): + """Thread 2 makes 1 call to get_client.""" + client_2 = get_client(clickhouse_config) + results["thread_2"] = client_2 + + thread_1 = threading.Thread(target=thread_1_work) + thread_2 = threading.Thread(target=thread_2_work) + + thread_1.start() + thread_2.start() + + thread_1.join() + thread_2.join() + + # Thread 1's two calls should return the same client (thread-local reuse) + client_1a, client_1b = results["thread_1"] + assert client_1a is client_1b, ( + "Same thread should get same client instance (cached)" + ) + + # Thread 2's client should be different from thread 1's client + client_2 = results["thread_2"] + assert client_1a is not client_2, ( + "Different threads should get different client instances (not cached)" + ) diff --git a/sdk/python/tests/unit/test_table_format.py b/sdk/python/tests/unit/test_table_format.py new file mode 100644 index 00000000000..908d491e940 --- /dev/null +++ b/sdk/python/tests/unit/test_table_format.py @@ -0,0 +1,323 @@ +import json + +import pytest + +from feast.table_format import ( + DeltaFormat, + HudiFormat, + IcebergFormat, + TableFormatType, + create_table_format, + table_format_from_dict, + table_format_from_json, +) + + +class TestTableFormat: + """Test core TableFormat classes and functionality.""" + + def test_iceberg_table_format_creation(self): + """Test IcebergFormat creation and properties.""" + iceberg_format = IcebergFormat( + catalog="my_catalog", + namespace="my_namespace", + properties={"catalog.uri": "s3://bucket/warehouse", "format-version": "2"}, + ) + + assert iceberg_format.format_type == TableFormatType.ICEBERG + assert iceberg_format.catalog == "my_catalog" + assert iceberg_format.namespace == "my_namespace" + assert iceberg_format.get_property("iceberg.catalog") == "my_catalog" + assert iceberg_format.get_property("iceberg.namespace") == "my_namespace" + assert iceberg_format.get_property("catalog.uri") == "s3://bucket/warehouse" + assert iceberg_format.get_property("format-version") == "2" + + def test_iceberg_table_format_minimal(self): + """Test IcebergFormat with minimal config.""" + iceberg_format = IcebergFormat() + + assert iceberg_format.format_type == TableFormatType.ICEBERG + assert iceberg_format.catalog is None + assert iceberg_format.namespace is None + assert len(iceberg_format.properties) == 0 + + def test_delta_table_format_creation(self): + """Test DeltaFormat creation and properties.""" + delta_format = DeltaFormat( + checkpoint_location="s3://bucket/checkpoints", + properties={"delta.autoOptimize.optimizeWrite": "true"}, + ) + + assert delta_format.format_type == TableFormatType.DELTA + assert delta_format.checkpoint_location == "s3://bucket/checkpoints" + assert ( + delta_format.get_property("delta.checkpointLocation") + == "s3://bucket/checkpoints" + ) + assert delta_format.get_property("delta.autoOptimize.optimizeWrite") == "true" + + def test_hudi_table_format_creation(self): + """Test HudiFormat creation and properties.""" + hudi_format = HudiFormat( + table_type="COPY_ON_WRITE", + record_key="id", + precombine_field="timestamp", + properties={ + "hoodie.compaction.strategy": "org.apache.hudi.table.action.compact.strategy.LogFileSizeBasedCompactionStrategy" + }, + ) + + assert hudi_format.format_type == TableFormatType.HUDI + assert hudi_format.table_type == "COPY_ON_WRITE" + assert hudi_format.record_key == "id" + assert hudi_format.precombine_field == "timestamp" + assert ( + hudi_format.get_property("hoodie.datasource.write.table.type") + == "COPY_ON_WRITE" + ) + assert ( + hudi_format.get_property("hoodie.datasource.write.recordkey.field") == "id" + ) + assert ( + hudi_format.get_property("hoodie.datasource.write.precombine.field") + == "timestamp" + ) + + def test_table_format_property_methods(self): + """Test property getter/setter methods.""" + iceberg_format = IcebergFormat() + + # Test setting and getting properties + iceberg_format.set_property("snapshot-id", "123456789") + assert iceberg_format.get_property("snapshot-id") == "123456789" + + # Test default value + assert iceberg_format.get_property("non-existent-key", "default") == "default" + assert iceberg_format.get_property("non-existent-key") is None + + def test_table_format_serialization(self): + """Test table format serialization to/from dict.""" + # Test Iceberg + iceberg_format = IcebergFormat( + catalog="test_catalog", + namespace="test_namespace", + properties={"key1": "value1", "key2": "value2"}, + ) + + iceberg_dict = iceberg_format.to_dict() + iceberg_restored = IcebergFormat.from_dict(iceberg_dict) + + assert iceberg_restored.format_type == iceberg_format.format_type + assert iceberg_restored.catalog == iceberg_format.catalog + assert iceberg_restored.namespace == iceberg_format.namespace + assert iceberg_restored.properties == iceberg_format.properties + + # Test Delta + delta_format = DeltaFormat( + checkpoint_location="s3://bucket/checkpoints", + properties={"key": "value"}, + ) + + delta_dict = delta_format.to_dict() + delta_restored = DeltaFormat.from_dict(delta_dict) + + assert delta_restored.format_type == delta_format.format_type + assert delta_restored.properties == delta_format.properties + assert delta_restored.checkpoint_location == delta_format.checkpoint_location + + # Test Hudi + hudi_format = HudiFormat( + table_type="MERGE_ON_READ", + record_key="uuid", + precombine_field="ts", + ) + + hudi_dict = hudi_format.to_dict() + hudi_restored = HudiFormat.from_dict(hudi_dict) + + assert hudi_restored.format_type == hudi_format.format_type + assert hudi_restored.table_type == hudi_format.table_type + assert hudi_restored.record_key == hudi_format.record_key + assert hudi_restored.precombine_field == hudi_format.precombine_field + + def test_factory_function(self): + """Test create_table_format factory function.""" + # Test Iceberg + iceberg_format = create_table_format( + TableFormatType.ICEBERG, + catalog="test_catalog", + namespace="test_ns", + ) + assert isinstance(iceberg_format, IcebergFormat) + assert iceberg_format.catalog == "test_catalog" + assert iceberg_format.namespace == "test_ns" + + # Test Delta + delta_format = create_table_format( + TableFormatType.DELTA, + checkpoint_location="s3://test", + ) + assert isinstance(delta_format, DeltaFormat) + assert delta_format.checkpoint_location == "s3://test" + + # Test Hudi + hudi_format = create_table_format( + TableFormatType.HUDI, + table_type="COPY_ON_WRITE", + ) + assert isinstance(hudi_format, HudiFormat) + assert hudi_format.table_type == "COPY_ON_WRITE" + + # Test invalid format type + with pytest.raises(ValueError, match="Unknown table format type"): + create_table_format("invalid_format") + + def test_table_format_from_dict(self): + """Test table_format_from_dict function.""" + # Test Iceberg + iceberg_dict = { + "format_type": "iceberg", + "catalog": "test_catalog", + "namespace": "test_namespace", + "properties": {"key1": "value1", "key2": "value2"}, + } + iceberg_format = table_format_from_dict(iceberg_dict) + assert isinstance(iceberg_format, IcebergFormat) + assert iceberg_format.catalog == "test_catalog" + + # Test Delta + delta_dict = { + "format_type": "delta", + "properties": {"key": "value"}, + "checkpoint_location": "s3://bucket/checkpoints", + } + delta_format = table_format_from_dict(delta_dict) + assert isinstance(delta_format, DeltaFormat) + assert delta_format.checkpoint_location == "s3://bucket/checkpoints" + + # Test Hudi + hudi_dict = { + "format_type": "hudi", + "table_type": "MERGE_ON_READ", + "record_key": "id", + "precombine_field": "ts", + "properties": {}, + } + hudi_format = table_format_from_dict(hudi_dict) + assert isinstance(hudi_format, HudiFormat) + assert hudi_format.table_type == "MERGE_ON_READ" + + # Test invalid format type + with pytest.raises(ValueError, match="Unknown table format type"): + table_format_from_dict({"format_type": "invalid"}) + + def test_table_format_from_json(self): + """Test table_format_from_json function.""" + iceberg_dict = { + "format_type": "iceberg", + "catalog": "test_catalog", + "namespace": "test_namespace", + "properties": {}, + } + json_str = json.dumps(iceberg_dict) + iceberg_format = table_format_from_json(json_str) + + assert isinstance(iceberg_format, IcebergFormat) + assert iceberg_format.catalog == "test_catalog" + assert iceberg_format.namespace == "test_namespace" + + def test_table_format_error_handling(self): + """Test error handling in table format operations.""" + + # Test invalid format type - create mock enum value + class MockFormat: + value = "invalid_format" + + with pytest.raises(ValueError, match="Unknown table format type"): + create_table_format(MockFormat()) + + # Test invalid format type in from_dict + with pytest.raises(ValueError, match="Unknown table format type"): + table_format_from_dict({"format_type": "invalid"}) + + # Test missing format_type + with pytest.raises(KeyError): + table_format_from_dict({}) + + # Test invalid JSON + with pytest.raises(json.JSONDecodeError): + table_format_from_json("invalid json") + + def test_table_format_property_edge_cases(self): + """Test edge cases for table format properties.""" + iceberg_format = IcebergFormat() + + # Test property overwriting + iceberg_format.set_property("snapshot-id", "123") + assert iceberg_format.get_property("snapshot-id") == "123" + iceberg_format.set_property("snapshot-id", "456") + assert iceberg_format.get_property("snapshot-id") == "456" + + # Test empty properties + delta_format = DeltaFormat(properties=None) + assert len(delta_format.properties) == 0 + + # Test None values in constructors + hudi_format = HudiFormat( + table_type=None, + record_key=None, + precombine_field=None, + properties=None, + ) + assert hudi_format.table_type is None + assert hudi_format.record_key is None + assert hudi_format.precombine_field is None + + def test_hudi_format_comprehensive(self): + """Test comprehensive Hudi format functionality.""" + # Test with all properties + hudi_format = HudiFormat( + table_type="COPY_ON_WRITE", + record_key="id,uuid", + precombine_field="ts", + properties={"custom.prop": "value"}, + ) + + assert ( + hudi_format.get_property("hoodie.datasource.write.table.type") + == "COPY_ON_WRITE" + ) + assert ( + hudi_format.get_property("hoodie.datasource.write.recordkey.field") + == "id,uuid" + ) + assert ( + hudi_format.get_property("hoodie.datasource.write.precombine.field") == "ts" + ) + assert hudi_format.get_property("custom.prop") == "value" + + # Test serialization roundtrip with complex data + serialized = hudi_format.to_dict() + restored = HudiFormat.from_dict(serialized) + assert restored.table_type == hudi_format.table_type + assert restored.record_key == hudi_format.record_key + assert restored.precombine_field == hudi_format.precombine_field + + def test_table_format_with_special_characters(self): + """Test table formats with special characters and edge values.""" + # Test with unicode and special characters + iceberg_format = IcebergFormat( + catalog="测试目录", # Chinese + namespace="тест_ns", # Cyrillic + properties={"special.key": "value with spaces & symbols!@#$%^&*()"}, + ) + + # Serialization roundtrip should preserve special characters + serialized = iceberg_format.to_dict() + restored = IcebergFormat.from_dict(serialized) + assert restored.catalog == "测试目录" + assert restored.namespace == "тест_ns" + assert ( + restored.properties["special.key"] + == "value with spaces & symbols!@#$%^&*()" + ) diff --git a/ui/package.json b/ui/package.json index f087d6cad14..dd9f240810b 100644 --- a/ui/package.json +++ b/ui/package.json @@ -1,6 +1,6 @@ { "name": "@feast-dev/feast-ui", - "version": "0.56.0", + "version": "0.57.0", "private": false, "files": [ "dist"