Skip to content

Commit 7de3484

Browse files
authored
feat: Add async REST scan planning poll and plan storage credentials (#3724)
Implement async REST fetchPlanningResult / cancelPlanning polling when planTableScan returns status=submitted Apply completed-plan storage-credentials to the scan-scoped FileIO (layered on existing IO properties) Keep public RestCatalog.plan_scan(...) -> list[FileScanTask] unchanged; credentials flow through internal _plan_scan_result / _file_io_from_plan Honor per-table scan-planning-mode from LoadTableResponse.config, which takes precedence over the catalog-level / GET /v1/config setting (same precedence as Java) Related: #2775, #3495 Java reference: apache/iceberg#13400 (async planning), apache/iceberg#15572 (table-level scan planning override)
1 parent 7539661 commit 7de3484

10 files changed

Lines changed: 589 additions & 39 deletions

File tree

‎mkdocs/docs/api.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1695,6 +1695,8 @@ scan = table.scan(
16951695
[task.file.file_path for task in scan.plan_files()]
16961696
```
16971697

1698+
When the REST catalog returns `scan-planning-mode=server` and advertises the plan endpoint, `plan_files()` / `to_arrow()` use server-side scan planning. The mode can also be returned per table in the `loadTable` response `config`, which takes precedence over the catalog-level setting, so a server can require server-side planning for some tables while others keep client-side planning. Catalogs that return async plans (`status=submitted`) are polled automatically until they reach a terminal state; see [REST Catalog configuration](configuration.md#rest-catalog).
1699+
16981700
The low level API `plan_files` methods returns a set of tasks that provide the files that might contain matching rows:
16991701

17001702
```json

‎mkdocs/docs/configuration.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -408,6 +408,10 @@ catalog:
408408
| snapshot-loading-mode | refs | The snapshots to return in the body of the metadata. Setting the value to `all` would return the full set of snapshots currently valid for the table. Setting the value to `refs` would load all snapshots referenced by branches or tags. |
409409
| `header.X-Iceberg-Access-Delegation` | `vended-credentials` | Signal to the server that the client supports delegated access via a comma-separated list of access mechanisms. The server may choose to supply access via any or none of the requested mechanisms. When using `vended-credentials`, the server provides temporary credentials to the client. When using `remote-signing`, the server signs requests on behalf of the client. (default: `vended-credentials`) |
410410
| view-endpoints-supported | false | For backwards compatibility with older REST servers. Set to `true` if the server supports view endpoints but doesn't send the `endpoints` field in the ConfigResponse. |
411+
| scan-planning-mode | client | When set to `server`, and the catalog advertises the plan-table-scan endpoint, `table.scan()` uses REST server-side scan planning. May be set by the client, returned by the catalog via `GET /v1/config` defaults/overrides, or returned per table in the `config` of the `loadTable` response. The `loadTable` value takes precedence over the catalog-level value, which lets a server request server-side planning only for specific tables. Async plans (`status=submitted`) are polled via `GET .../plan/{plan-id}` until terminal state (completed / failed / cancelled). |
412+
| rest-scan-planning.poll-timeout-ms | 300000 | Maximum time to wait for an async scan plan to complete before failing (default: 5 minutes). |
413+
414+
When server-side planning returns `storage-credentials` on a completed plan, PyIceberg applies them to the scan-scoped FileIO (layered on top of the existing table/load-time IO properties) so planned data and delete files can be read using the creds vended by the server.
411415

412416
#### Headers in REST Catalog
413417

‎pyiceberg/catalog/__init__.py‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -804,8 +804,13 @@ def namespace_to_string(identifier: str | Identifier, err: type[ValueError] | ty
804804
return ".".join(segment.strip() for segment in tuple_identifier)
805805

806806
@abstractmethod
807-
def supports_server_side_planning(self) -> bool:
808-
"""Check if the catalog supports server-side scan planning."""
807+
def supports_server_side_planning(self, table_config: Properties = EMPTY_DICT) -> bool:
808+
"""Check if server-side scan planning should be used.
809+
810+
Args:
811+
table_config: Table configuration returned by the catalog when loading the table,
812+
which may override the catalog-level scan planning mode for a single table.
813+
"""
809814

810815
@staticmethod
811816
def identifier_to_database(
@@ -907,7 +912,7 @@ def __init__(self, name: str, **properties: str):
907912
super().__init__(name, **properties)
908913

909914
@override
910-
def supports_server_side_planning(self) -> bool:
915+
def supports_server_side_planning(self, table_config: Properties = EMPTY_DICT) -> bool:
911916
return False
912917

913918
@override

‎pyiceberg/catalog/noop.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ def drop_table(self, identifier: str | Identifier) -> None:
9898
raise NotImplementedError
9999

100100
@override
101-
def supports_server_side_planning(self) -> bool:
101+
def supports_server_side_planning(self, table_config: Properties = EMPTY_DICT) -> bool:
102102
return False
103103

104104
@override

‎pyiceberg/catalog/rest/__init__.py‎

Lines changed: 230 additions & 23 deletions
Large diffs are not rendered by default.

‎pyiceberg/catalog/rest/scan_planning.py‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,10 @@
1616
# under the License.
1717
from __future__ import annotations
1818

19+
from dataclasses import dataclass, field
1920
from datetime import date, datetime, time
2021
from decimal import Decimal
21-
from typing import Annotated, Generic, Literal, TypeAlias, TypeVar
22+
from typing import TYPE_CHECKING, Annotated, Generic, Literal, TypeAlias, TypeVar
2223
from uuid import UUID
2324

2425
from pydantic import Field, model_validator
@@ -28,6 +29,9 @@
2829
from pyiceberg.manifest import FileFormat
2930
from pyiceberg.typedef import IcebergBaseModel
3031

32+
if TYPE_CHECKING:
33+
from pyiceberg.table import FileScanTask
34+
3135
# Primitive types that can appear in partition values and bounds
3236
PrimitiveTypeValue: TypeAlias = bool | int | float | str | Decimal | UUID | date | time | datetime | bytes
3337

@@ -207,3 +211,12 @@ class FetchScanTasksRequest(IcebergBaseModel):
207211
"""Request body for fetching scan tasks endpoint."""
208212

209213
plan_task: str = Field(alias="plan-task")
214+
215+
216+
@dataclass(frozen=True)
217+
class PlannedScanResult:
218+
"""Result of REST server-side scan planning, including optional storage credentials."""
219+
220+
tasks: list[FileScanTask]
221+
storage_credentials: list[StorageCredential] = field(default_factory=list)
222+
plan_id: str | None = None

‎pyiceberg/exceptions.py‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,14 @@ class NoSuchPlanTaskError(Exception):
6060
"""Raised when a scan plan task is not found."""
6161

6262

63+
class NoSuchPlanIdError(Exception):
64+
"""Raised when a scan plan-id is not found."""
65+
66+
67+
class RemotePlanTimeoutError(Exception):
68+
"""Raised when async remote scan planning does not complete within configured limits."""
69+
70+
6371
class RESTError(Exception):
6472
"""Raises when there is an unknown response from the REST Catalog."""
6573

‎pyiceberg/table/__init__.py‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1437,6 +1437,7 @@ def scan(
14371437
limit=limit,
14381438
catalog=self.catalog,
14391439
table_identifier=self._identifier,
1440+
table_config=self.config,
14401441
)
14411442

14421443
def incremental_append_scan(
@@ -2155,6 +2156,7 @@ class TableScan(BaseScan):
21552156
snapshot_id: int | None
21562157
catalog: Catalog | None
21572158
table_identifier: Identifier | None
2159+
table_config: Properties
21582160

21592161
def __init__(
21602162
self,
@@ -2168,6 +2170,7 @@ def __init__(
21682170
limit: int | None = None,
21692171
catalog: Catalog | None = None,
21702172
table_identifier: Identifier | None = None,
2173+
table_config: Properties = EMPTY_DICT,
21712174
):
21722175
super().__init__(
21732176
table_metadata=table_metadata,
@@ -2181,6 +2184,7 @@ def __init__(
21812184
self.snapshot_id = snapshot_id
21822185
self.catalog = catalog
21832186
self.table_identifier = table_identifier
2187+
self.table_config = table_config
21842188

21852189
def snapshot(self) -> Snapshot | None:
21862190
if self.snapshot_id:
@@ -2425,7 +2429,7 @@ def _should_use_server_side_planning(self) -> bool:
24252429
"""Check if server-side scan planning should be used for this scan."""
24262430
if not self.catalog:
24272431
return False
2428-
return self.catalog.supports_server_side_planning()
2432+
return self.catalog.supports_server_side_planning(self.table_config)
24292433

24302434
def _plan_files_server_side(self) -> Iterable[FileScanTask]:
24312435
"""Plan files using REST server-side scan planning."""
@@ -2444,7 +2448,12 @@ def _plan_files_server_side(self) -> Iterable[FileScanTask]:
24442448
case_sensitive=self.case_sensitive,
24452449
)
24462450

2447-
return self.catalog.plan_scan(self.table_identifier, request)
2451+
result = self.catalog._plan_scan_result(self.table_identifier, request)
2452+
location = result.tasks[0].file.file_path if result.tasks else None
2453+
plan_io = self.catalog._file_io_from_plan(self.io.properties, result.storage_credentials, location)
2454+
if plan_io is not None:
2455+
self.io = plan_io
2456+
return result.tasks
24482457

24492458
def _plan_files_local(self) -> Iterable[FileScanTask]:
24502459
"""Plan files locally by reading manifests."""

‎tests/catalog/test_rest.py‎

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2857,6 +2857,60 @@ def test_server_side_planning_enabled_from_server_config(self, rest_mock: Mocker
28572857

28582858
assert catalog.supports_server_side_planning() is True
28592859

2860+
def test_server_side_planning_enabled_by_table_config(self, rest_mock: Mocker) -> None:
2861+
catalog = RestCatalog("rest", uri=TEST_URI, token=TEST_TOKEN)
2862+
2863+
assert catalog.supports_server_side_planning() is False
2864+
assert catalog.supports_server_side_planning({"scan-planning-mode": ScanPlanningMode.SERVER.value}) is True
2865+
2866+
def test_server_side_planning_table_config_overrides_catalog_property(self, rest_mock: Mocker) -> None:
2867+
catalog = RestCatalog(
2868+
"rest",
2869+
uri=TEST_URI,
2870+
token=TEST_TOKEN,
2871+
**{"scan-planning-mode": ScanPlanningMode.SERVER.value},
2872+
)
2873+
2874+
assert catalog.supports_server_side_planning({"scan-planning-mode": ScanPlanningMode.CLIENT.value}) is False
2875+
2876+
def test_server_side_planning_table_config_ignored_when_endpoint_unsupported(self, requests_mock: Mocker) -> None:
2877+
requests_mock.get(
2878+
f"{TEST_URI}v1/config",
2879+
json={"defaults": {}, "overrides": {}},
2880+
status_code=200,
2881+
)
2882+
catalog = RestCatalog("rest", uri=TEST_URI, token=TEST_TOKEN)
2883+
2884+
assert catalog.supports_server_side_planning({"scan-planning-mode": ScanPlanningMode.SERVER.value}) is False
2885+
2886+
def test_server_side_planning_invalid_mode(self, rest_mock: Mocker) -> None:
2887+
catalog = RestCatalog("rest", uri=TEST_URI, token=TEST_TOKEN)
2888+
2889+
with pytest.raises(ValueError, match="Invalid scan-planning-mode: remote"):
2890+
catalog.supports_server_side_planning({"scan-planning-mode": "remote"})
2891+
2892+
def test_server_side_planning_invalid_catalog_mode_falls_back_to_default(self, rest_mock: Mocker) -> None:
2893+
catalog = RestCatalog(
2894+
"rest",
2895+
uri=TEST_URI,
2896+
token=TEST_TOKEN,
2897+
**{"scan-planning-mode": "servr"},
2898+
)
2899+
2900+
# Bad catalog config is ignored; default remains client-side planning.
2901+
assert catalog.supports_server_side_planning() is False
2902+
2903+
def test_server_side_planning_table_override_survives_invalid_catalog_mode(self, rest_mock: Mocker) -> None:
2904+
catalog = RestCatalog(
2905+
"rest",
2906+
uri=TEST_URI,
2907+
token=TEST_TOKEN,
2908+
**{"scan-planning-mode": "servr"},
2909+
)
2910+
2911+
assert catalog.supports_server_side_planning({"scan-planning-mode": ScanPlanningMode.SERVER.value}) is True
2912+
assert catalog.supports_server_side_planning({"scan-planning-mode": ScanPlanningMode.CLIENT.value}) is False
2913+
28602914
def test_supported_endpoint(self, requests_mock: Mocker) -> None:
28612915
requests_mock.get(
28622916
f"{TEST_URI}v1/config",

0 commit comments

Comments
 (0)