Skip to content

Commit 386759c

Browse files
authored
feat: Add PolarisSource and PolarisProvider for Apache Polaris (#7006)
1 parent 2dfb9e6 commit 386759c

10 files changed

Lines changed: 1181 additions & 57 deletions

File tree

‎docs/reference/data-sources/iceberg.md‎

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,53 @@ my_uc_source = UnityCatalogSource(
8484
When `endpoint` is omitted, it defaults to `{DATABRICKS_HOST}/api/2.1/unity-catalog/iceberg`.
8585
When `token_env_var` is omitted, it defaults to `DATABRICKS_TOKEN`.
8686

87+
### PolarisSource (Apache Polaris) {#polaris-source}
88+
89+
Extends `IcebergSource` for [Apache Polaris](https://polaris.apache.org/). `warehouse` is the Polaris
90+
catalog name. When `endpoint` is omitted it defaults to `$POLARIS_HOST/api/catalog`.
91+
92+
```python
93+
from feast.infra.data_sources.contrib.iceberg_catalog import PolarisSource
94+
95+
my_polaris_source = PolarisSource(
96+
warehouse="my_polaris_catalog",
97+
namespace="ml",
98+
table="driver_stats",
99+
timestamp_field="event_timestamp",
100+
)
101+
```
102+
103+
Authentication is read from the environment (only the variable *names* are stored in the registry):
104+
105+
1. OAuth2 client credentials: `POLARIS_CLIENT_ID` and `POLARIS_CLIENT_SECRET` (override the names with
106+
`client_id_env_var` / `client_secret_env_var`). Tokens are refreshed automatically before they expire.
107+
2. Otherwise a bearer token from `POLARIS_TOKEN` (override with `token_env_var`).
108+
109+
To record Feast metadata on the Polaris tables when you run `feast apply`, select the Polaris provider:
110+
111+
```yaml
112+
# feature_store.yaml
113+
provider: polaris
114+
```
115+
116+
For every feature view backed by a `PolarisSource` (with `sync_metadata=True`, the default) the provider sets
117+
`feast.project`, `feast.feature_view`, `feast.owner`, `feast.description`, `feast.tag.<key>` and
118+
`feast.is_feature_table` as table properties through the standard Iceberg REST `updateTable` operation.
119+
When a feature view is deleted the table is kept and marked `feast.is_feature_table=false` with a
120+
`feast.deleted_at` timestamp. A failed metadata sync is logged and never fails `feast apply`.
121+
122+
Polaris has no lineage API, so `sync_lineage` is not available.
123+
124+
To create a source for every table in a catalog:
125+
126+
```python
127+
sources = PolarisSource.discover(
128+
warehouse="my_polaris_catalog",
129+
namespaces=["ml"], # omit to scan all namespaces
130+
timestamp_fields=("event_timestamp",), # tables without such a column are skipped
131+
)
132+
```
133+
87134
### Full Feature View Example
88135

89136
```python
@@ -145,6 +192,21 @@ driver_stats_fv = FeatureView(
145192
| `register_as_feature_table` | `bool` | Register as UC feature table on `feast apply` (default: `True`) |
146193
| `sync_lineage` | `bool` | Sync lineage metadata to Unity Catalog (default: `True`) |
147194

195+
### PolarisSource (additional parameters)
196+
197+
`PolarisSource` always uses `catalog_type="rest"`; `warehouse` is the Polaris catalog name.
198+
199+
| Parameter | Type | Description |
200+
| :--- | :--- | :--- |
201+
| `endpoint` | `str` | Defaults to `$POLARIS_HOST/api/catalog` |
202+
| `token_env_var` | `str` | Bearer token variable (default: `POLARIS_TOKEN`) |
203+
| `client_id_env_var` | `str` | OAuth client id variable (default: `POLARIS_CLIENT_ID`) |
204+
| `client_secret_env_var` | `str` | OAuth client secret variable (default: `POLARIS_CLIENT_SECRET`) |
205+
| `oauth_scope` | `str` | OAuth scope (default: `PRINCIPAL_ROLE:ALL`) |
206+
| `oauth_server_uri` | `str` | Token endpoint if not `<endpoint>/v1/oauth/tokens`, e.g. an external IdP |
207+
| `credential_vending` | `bool` | Request vended storage credentials for reads/writes (default: `True`) |
208+
| `sync_metadata` | `bool` | Sync Feast metadata to table properties on `feast apply` with the `polaris` provider (default: `True`) |
209+
148210
## Supported Types
149211

150212
| Iceberg Type | Feast Type |

‎sdk/python/feast/infra/data_sources/contrib/iceberg_catalog/__init__.py‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,12 @@
99
from feast.infra.data_sources.contrib.iceberg_catalog.iceberg_source import (
1010
IcebergSource,
1111
)
12+
from feast.infra.data_sources.contrib.iceberg_catalog.polaris_client import (
13+
PolarisClient,
14+
)
15+
from feast.infra.data_sources.contrib.iceberg_catalog.polaris_source import (
16+
PolarisSource,
17+
)
1218
from feast.infra.data_sources.contrib.iceberg_catalog.uc_client import UCClient
1319
from feast.infra.data_sources.contrib.iceberg_catalog.unity_catalog_source import (
1420
UnityCatalogSource,
@@ -19,6 +25,8 @@
1925
"IcebergCatalogError",
2026
"IcebergRestClient",
2127
"IcebergSource",
28+
"PolarisClient",
29+
"PolarisSource",
2230
"TableMetadata",
2331
"TableNotFoundError",
2432
"TempCredential",

‎sdk/python/feast/infra/data_sources/contrib/iceberg_catalog/iceberg_source.py‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,38 @@ def get_catalog_client(self):
131131
def source_type(self) -> DataSourceProto.SourceType.ValueType:
132132
return DataSourceProto.BATCH_ICEBERG
133133

134+
def get_pyiceberg_catalog_config(self) -> Dict[str, str]:
135+
"""Return the PyIceberg ``load_catalog`` properties for a REST catalog.
136+
137+
Subclasses (for example ``PolarisSource``) override this to add
138+
catalog-specific authentication or credential-vending properties.
139+
"""
140+
config: Dict[str, str] = {
141+
"type": "rest",
142+
"uri": self.endpoint,
143+
"warehouse": self.warehouse,
144+
}
145+
if self.token_env_var:
146+
token = os.environ.get(self.token_env_var)
147+
if token:
148+
config["token"] = token
149+
return config
150+
151+
def load_pyiceberg_table(self):
152+
"""Load the backing table as a PyIceberg ``Table``.
153+
154+
REST sources are loaded from ``get_pyiceberg_catalog_config()``; other
155+
catalog types delegate to ``get_catalog_client()``.
156+
"""
157+
fqn = f"{self.namespace}.{self.iceberg_table}"
158+
if self.catalog_type != "rest":
159+
return self.get_catalog_client().load_table(fqn)
160+
161+
from pyiceberg.catalog import load_catalog
162+
163+
catalog = load_catalog(self.catalog_name, **self.get_pyiceberg_catalog_config())
164+
return catalog.load_table(fqn)
165+
134166
@staticmethod
135167
def from_proto(data_source: DataSourceProto) -> Any:
136168
cls_type = data_source.data_source_class_type
@@ -140,6 +172,12 @@ def from_proto(data_source: DataSourceProto) -> Any:
140172
)
141173

142174
return UnityCatalogSource.from_proto(data_source)
175+
if cls_type and "PolarisSource" in cls_type:
176+
from feast.infra.data_sources.contrib.iceberg_catalog.polaris_source import (
177+
PolarisSource,
178+
)
179+
180+
return PolarisSource.from_proto(data_source)
143181

144182
custom_options = json.loads(
145183
str(data_source.custom_options.configuration, encoding="utf8")
Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
# Copyright 2026 The Feast Authors
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# https://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
import logging
16+
import threading
17+
import time
18+
from typing import Dict, List, Optional
19+
20+
import requests
21+
from requests.auth import AuthBase
22+
23+
from feast.infra.data_sources.contrib.iceberg_catalog.iceberg_rest_client import (
24+
CatalogAuthError,
25+
IcebergRestClient,
26+
)
27+
28+
logger = logging.getLogger(__name__)
29+
30+
DEFAULT_OAUTH_SCOPE = "PRINCIPAL_ROLE:ALL"
31+
_DEFAULT_TOKEN_TTL_SECONDS = 3600
32+
33+
34+
class OAuthClientCredentialsTokenProvider:
35+
"""Fetches and caches OAuth2 client-credentials access tokens.
36+
37+
The token is re-fetched shortly before it expires so that long-running
38+
jobs (materialization, feature serving) keep working.
39+
"""
40+
41+
def __init__(
42+
self,
43+
token_url: str,
44+
client_id: str,
45+
client_secret: str,
46+
scope: str = DEFAULT_OAUTH_SCOPE,
47+
timeout: int = 30,
48+
refresh_skew_seconds: int = 60,
49+
):
50+
self.token_url = token_url
51+
self._client_id = client_id
52+
self._client_secret = client_secret
53+
self.scope = scope
54+
self.timeout = timeout
55+
self.refresh_skew_seconds = refresh_skew_seconds
56+
self._token: Optional[str] = None
57+
self._expires_at: float = 0.0
58+
self._lock = threading.Lock()
59+
60+
def get_token(self, force_refresh: bool = False) -> str:
61+
with self._lock:
62+
now = time.monotonic()
63+
if (
64+
force_refresh
65+
or self._token is None
66+
or now >= self._expires_at - self.refresh_skew_seconds
67+
):
68+
self._token, ttl = self._fetch_token()
69+
self._expires_at = now + ttl
70+
return self._token
71+
72+
def _fetch_token(self) -> "tuple[str, float]":
73+
try:
74+
response = requests.post(
75+
self.token_url,
76+
data={
77+
"grant_type": "client_credentials",
78+
"client_id": self._client_id,
79+
"client_secret": self._client_secret,
80+
"scope": self.scope,
81+
},
82+
headers={"Accept": "application/json"},
83+
timeout=self.timeout,
84+
)
85+
except requests.exceptions.RequestException as e:
86+
raise CatalogAuthError(
87+
f"Failed to reach OAuth token endpoint {self.token_url}: {e}"
88+
) from e
89+
90+
if not response.ok:
91+
raise CatalogAuthError(
92+
f"OAuth token request to {self.token_url} failed: "
93+
f"HTTP {response.status_code}",
94+
status_code=response.status_code,
95+
)
96+
body = response.json()
97+
token = body.get("access_token")
98+
if not token:
99+
raise CatalogAuthError(
100+
f"OAuth token response from {self.token_url} has no access_token"
101+
)
102+
return token, float(body.get("expires_in", _DEFAULT_TOKEN_TTL_SECONDS))
103+
104+
105+
class _BearerTokenAuth(AuthBase):
106+
"""requests auth hook that attaches a fresh bearer token to every request."""
107+
108+
def __init__(self, provider: OAuthClientCredentialsTokenProvider):
109+
self._provider = provider
110+
111+
def __call__(self, request: requests.PreparedRequest) -> requests.PreparedRequest:
112+
request.headers["Authorization"] = f"Bearer {self._provider.get_token()}"
113+
return request
114+
115+
116+
class PolarisClient(IcebergRestClient):
117+
"""Apache Polaris client.
118+
119+
Extends :class:`IcebergRestClient` with:
120+
121+
- OAuth2 client-credentials authentication with automatic token refresh
122+
(``client_id`` / ``client_secret``), as an alternative to a static token.
123+
- Table property management through the standard Iceberg REST
124+
``updateTable`` operation (``set-properties`` / ``remove-properties``),
125+
used to record Feast metadata on the Polaris table.
126+
127+
Polaris serves the Iceberg REST API under ``<host>/api/catalog`` and uses
128+
the Polaris catalog name as the ``/v1/{prefix}`` path segment.
129+
"""
130+
131+
def __init__(
132+
self,
133+
endpoint: str,
134+
warehouse: str,
135+
token: Optional[str] = None,
136+
client_id: Optional[str] = None,
137+
client_secret: Optional[str] = None,
138+
scope: str = DEFAULT_OAUTH_SCOPE,
139+
oauth_server_uri: Optional[str] = None,
140+
credential_vending: bool = True,
141+
timeout: int = 30,
142+
):
143+
super().__init__(
144+
endpoint=endpoint,
145+
token=None if (client_id and client_secret) else token,
146+
warehouse=warehouse,
147+
credential_vending=credential_vending,
148+
timeout=timeout,
149+
url_style="prefix",
150+
)
151+
self.token_provider: Optional[OAuthClientCredentialsTokenProvider] = None
152+
if client_id and client_secret:
153+
self.token_provider = OAuthClientCredentialsTokenProvider(
154+
token_url=oauth_server_uri or f"{self.endpoint}/v1/oauth/tokens",
155+
client_id=client_id,
156+
client_secret=client_secret,
157+
scope=scope,
158+
timeout=timeout,
159+
)
160+
self._session.auth = _BearerTokenAuth(self.token_provider)
161+
162+
def set_table_properties(
163+
self, namespace: str, table: str, properties: Dict[str, str]
164+
) -> bool:
165+
"""Set properties on a table. Returns True on success.
166+
167+
Failures are logged and reported as ``False`` so that metadata sync
168+
never aborts ``feast apply``.
169+
"""
170+
if not properties:
171+
return True
172+
return self._commit_updates(
173+
namespace,
174+
table,
175+
[
176+
{
177+
"action": "set-properties",
178+
"updates": {k: str(v) for k, v in properties.items()},
179+
}
180+
],
181+
)
182+
183+
def remove_table_properties(
184+
self, namespace: str, table: str, keys: List[str]
185+
) -> bool:
186+
"""Remove properties from a table. Returns True on success."""
187+
if not keys:
188+
return True
189+
return self._commit_updates(
190+
namespace, table, [{"action": "remove-properties", "removals": keys}]
191+
)
192+
193+
def _commit_updates(
194+
self, namespace: str, table: str, updates: List[Dict[str, object]]
195+
) -> bool:
196+
url = f"{self._base_url}/namespaces/{namespace}/tables/{table}"
197+
try:
198+
response = self._session.post(
199+
url,
200+
json={"requirements": [], "updates": updates},
201+
timeout=self.timeout,
202+
)
203+
except (requests.exceptions.RequestException, CatalogAuthError) as e:
204+
logger.warning("Failed to update table %s.%s: %s", namespace, table, e)
205+
return False
206+
207+
if not response.ok:
208+
logger.warning(
209+
"Failed to update table %s.%s: HTTP %s %s",
210+
namespace,
211+
table,
212+
response.status_code,
213+
response.text[:200],
214+
)
215+
return False
216+
return True

0 commit comments

Comments
 (0)