Skip to content

Commit ac5caab

Browse files
feat: Honor loadTable scan-planning-mode overrides per table
Prefer scan-planning-mode from LoadTableResponse.config over the catalog-level property (matching Java), so REST catalogs can enable server-side planning only for selected tables. Invalid catalog values are ignored with a warning and no longer block a valid table override. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 257a42a commit ac5caab

8 files changed

Lines changed: 236 additions & 11 deletions

File tree

mkdocs/docs/api.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1695,7 +1695,7 @@ 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. Catalogs that return async plans (`status=submitted`) are polled automatically until they reach a terminal state; see [REST Catalog configuration](configuration.md#rest-catalog).
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).
16991699

17001700
The low level API `plan_files` methods returns a set of tasks that provide the files that might contain matching rows:
17011701

mkdocs/docs/configuration.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -386,7 +386,7 @@ catalog:
386386
| 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. |
387387
| `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`) |
388388
| 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. |
389-
| 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 or returned by the catalog via `GET /v1/config` defaults/overrides when the server wants clients to use server-side planning. Async plans (`status=submitted`) are polled via `GET .../plan/{plan-id}` until completion. |
389+
| 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 completion. |
390390
| rest-scan-planning.poll-timeout-ms | 300000 | Maximum time to wait for an async scan plan to complete before failing (default: 5 minutes). |
391391

392392
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.

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: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
# under the License.
1717
from __future__ import annotations
1818

19+
import logging
1920
import time
2021
from collections import deque
2122
from enum import Enum
@@ -100,6 +101,8 @@
100101
if TYPE_CHECKING:
101102
import pyarrow as pa
102103

104+
logger = logging.getLogger(__name__)
105+
103106

104107
class HttpMethod(str, Enum):
105108
GET = "GET"
@@ -295,6 +298,23 @@ class ScanPlanningMode(Enum):
295298
DEFAULT_NAMESPACE_SEPARATOR = b"\x1f".decode(UTF8)
296299

297300

301+
def _parse_scan_planning_mode(properties: Properties, *, strict: bool = True) -> ScanPlanningMode | None:
302+
"""Read the scan planning mode from a set of properties, returning None when it is not set.
303+
304+
When ``strict`` is False, an unrecognized value is logged and treated as unset so a higher-priority
305+
source (for example a loadTable override) or the default mode can still decide.
306+
"""
307+
if (mode := properties.get(SCAN_PLANNING_MODE)) is None:
308+
return None
309+
try:
310+
return ScanPlanningMode(str(mode).strip().lower())
311+
except ValueError as exc:
312+
if strict:
313+
raise ValueError(f"Invalid {SCAN_PLANNING_MODE}: {mode}") from exc
314+
logger.warning("Ignoring invalid %s=%r", SCAN_PLANNING_MODE, mode)
315+
return None
316+
317+
298318
def _retry_hook(retry_state: RetryCallState) -> None:
299319
rest_catalog: RestCatalog = retry_state.args[0]
300320
rest_catalog._refresh_token() # pylint: disable=protected-access
@@ -512,11 +532,31 @@ def _load_file_io(self, properties: Properties = EMPTY_DICT, location: str | Non
512532
merged_properties[AUTH_MANAGER] = self._auth_manager
513533
return load_file_io(merged_properties, location)
514534

535+
def _effective_scan_planning_mode(self, table_config: Properties) -> ScanPlanningMode:
536+
"""Resolve the scan planning mode, where a loadTable override wins over the catalog property.
537+
538+
An invalid catalog-level value is ignored (with a warning) so it cannot block a valid
539+
loadTable override or the default client-side mode. An invalid loadTable value still fails.
540+
"""
541+
# Parse the table override first so a valid loadTable value is not blocked by a bad catalog property.
542+
table_mode = _parse_scan_planning_mode(table_config)
543+
catalog_mode = _parse_scan_planning_mode(self.properties, strict=False)
544+
545+
if catalog_mode is not None and table_mode is not None and catalog_mode != table_mode:
546+
logger.warning(
547+
"Scan planning mode mismatch: client config=%s, server config=%s. Server config takes precedence.",
548+
catalog_mode.value,
549+
table_mode.value,
550+
)
551+
552+
return table_mode or catalog_mode or ScanPlanningMode(SCAN_PLANNING_MODE_DEFAULT)
553+
515554
@override
516-
def supports_server_side_planning(self) -> bool:
517-
"""Check if the catalog supports server-side scan planning."""
518-
scan_planning_mode = ScanPlanningMode(self.properties.get(SCAN_PLANNING_MODE, SCAN_PLANNING_MODE_DEFAULT))
519-
return Capability.V1_SUBMIT_TABLE_SCAN_PLAN in self._supported_endpoints and scan_planning_mode == ScanPlanningMode.SERVER
555+
def supports_server_side_planning(self, table_config: Properties = EMPTY_DICT) -> bool:
556+
"""Check if server-side scan planning should be used, honoring a per-table loadTable override."""
557+
if Capability.V1_SUBMIT_TABLE_SCAN_PLAN not in self._supported_endpoints:
558+
return False
559+
return self._effective_scan_planning_mode(table_config) == ScanPlanningMode.SERVER
520560

521561
@retry(**_RETRY_ARGS)
522562
def _plan_table_scan(self, identifier: str | Identifier, request: PlanTableScanRequest) -> PlanningResponse:

pyiceberg/table/__init__.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1267,6 +1267,7 @@ def scan(
12671267
limit=limit,
12681268
catalog=self.catalog,
12691269
table_identifier=self._identifier,
1270+
table_config=self.config,
12701271
)
12711272

12721273
def incremental_append_scan(
@@ -1985,6 +1986,7 @@ class TableScan(BaseScan):
19851986
snapshot_id: int | None
19861987
catalog: Catalog | None
19871988
table_identifier: Identifier | None
1989+
table_config: Properties
19881990

19891991
def __init__(
19901992
self,
@@ -1998,6 +2000,7 @@ def __init__(
19982000
limit: int | None = None,
19992001
catalog: Catalog | None = None,
20002002
table_identifier: Identifier | None = None,
2003+
table_config: Properties = EMPTY_DICT,
20012004
):
20022005
super().__init__(
20032006
table_metadata=table_metadata,
@@ -2011,6 +2014,7 @@ def __init__(
20112014
self.snapshot_id = snapshot_id
20122015
self.catalog = catalog
20132016
self.table_identifier = table_identifier
2017+
self.table_config = table_config
20142018

20152019
def snapshot(self) -> Snapshot | None:
20162020
if self.snapshot_id:
@@ -2255,7 +2259,7 @@ def _should_use_server_side_planning(self) -> bool:
22552259
"""Check if server-side scan planning should be used for this scan."""
22562260
if not self.catalog:
22572261
return False
2258-
return self.catalog.supports_server_side_planning()
2262+
return self.catalog.supports_server_side_planning(self.table_config)
22592263

22602264
def _plan_files_server_side(self) -> Iterable[FileScanTask]:
22612265
"""Plan files using REST server-side scan planning."""

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",

tests/catalog/test_scan_planning_models.py

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ def rest_scan_catalog(requests_mock: Mocker) -> RestCatalog:
5252
"defaults": {"scan-planning-mode": "server"},
5353
"overrides": {},
5454
"endpoints": [
55+
"GET /v1/{prefix}/namespaces/{namespace}/tables/{table}",
5556
"POST /v1/{prefix}/namespaces/{namespace}/tables/{table}/plan",
5657
"GET /v1/{prefix}/namespaces/{namespace}/tables/{table}/plan/{plan-id}",
5758
"DELETE /v1/{prefix}/namespaces/{namespace}/tables/{table}/plan/{plan-id}",
@@ -68,6 +69,28 @@ def rest_scan_catalog(requests_mock: Mocker) -> RestCatalog:
6869
)
6970

7071

72+
@pytest.fixture
73+
def rest_client_planning_catalog(requests_mock: Mocker) -> RestCatalog:
74+
"""A catalog that advertises the plan endpoints but leaves the mode at the client-side default."""
75+
requests_mock.get(
76+
f"{TEST_URI}v1/config",
77+
json={
78+
"defaults": {},
79+
"overrides": {},
80+
"endpoints": [
81+
"GET /v1/{prefix}/namespaces/{namespace}/tables/{table}",
82+
"POST /v1/{prefix}/namespaces/{namespace}/tables/{table}/plan",
83+
"GET /v1/{prefix}/namespaces/{namespace}/tables/{table}/plan/{plan-id}",
84+
"DELETE /v1/{prefix}/namespaces/{namespace}/tables/{table}/plan/{plan-id}",
85+
"POST /v1/{prefix}/namespaces/{namespace}/tables/{table}/tasks",
86+
],
87+
},
88+
status_code=200,
89+
)
90+
91+
return RestCatalog("test", uri=TEST_URI)
92+
93+
7194
def _rest_data_file(
7295
*,
7396
file_path: str = "s3://bucket/table/data/file.parquet",
@@ -694,3 +717,102 @@ def test_plan_scan_equality_deletes_not_supported(rest_scan_catalog: RestCatalog
694717
request = PlanTableScanRequest()
695718
with pytest.raises(NotImplementedError, match="PyIceberg does not yet support equality deletes"):
696719
rest_scan_catalog.plan_scan(("db", "tbl"), request)
720+
721+
722+
def _mock_load_table(requests_mock: Mocker, metadata: dict[str, Any], config: dict[str, str]) -> None:
723+
requests_mock.get(
724+
f"{TEST_URI}v1/namespaces/db/tables/tbl",
725+
json={
726+
"metadata-location": "s3://bucket/tbl/metadata/00000.metadata.json",
727+
"metadata": metadata,
728+
"config": config,
729+
},
730+
status_code=200,
731+
)
732+
733+
734+
def test_scan_uses_server_side_planning_from_load_table_config(
735+
rest_client_planning_catalog: RestCatalog, requests_mock: Mocker, example_table_metadata_v2: dict[str, Any]
736+
) -> None:
737+
_mock_load_table(requests_mock, example_table_metadata_v2, {"scan-planning-mode": "server"})
738+
plan_mock = requests_mock.post(
739+
f"{TEST_URI}v1/namespaces/db/tables/tbl/plan",
740+
json={
741+
"status": "completed",
742+
"plan-id": "plan-123",
743+
"delete-files": [],
744+
"file-scan-tasks": [{"data-file": _rest_data_file(file_path="s3://bucket/tbl/data/file1.parquet")}],
745+
"plan-tasks": [],
746+
},
747+
status_code=200,
748+
)
749+
750+
table = rest_client_planning_catalog.load_table(("db", "tbl"))
751+
scan = table.scan()
752+
753+
assert scan._should_use_server_side_planning() is True
754+
755+
tasks = list(scan.plan_files())
756+
assert [task.file.file_path for task in tasks] == ["s3://bucket/tbl/data/file1.parquet"]
757+
assert plan_mock.call_count == 1
758+
759+
760+
def test_scan_keeps_client_side_planning_without_load_table_config(
761+
rest_client_planning_catalog: RestCatalog, requests_mock: Mocker, example_table_metadata_v2: dict[str, Any]
762+
) -> None:
763+
_mock_load_table(requests_mock, example_table_metadata_v2, {})
764+
765+
table = rest_client_planning_catalog.load_table(("db", "tbl"))
766+
767+
assert table.scan()._should_use_server_side_planning() is False
768+
769+
770+
def test_scan_load_table_config_overrides_catalog_property(
771+
rest_scan_catalog: RestCatalog, requests_mock: Mocker, example_table_metadata_v2: dict[str, Any]
772+
) -> None:
773+
_mock_load_table(requests_mock, example_table_metadata_v2, {"scan-planning-mode": "client"})
774+
775+
table = rest_scan_catalog.load_table(("db", "tbl"))
776+
777+
assert table.scan()._should_use_server_side_planning() is False
778+
779+
780+
def test_scan_load_table_override_survives_invalid_catalog_mode(
781+
requests_mock: Mocker, example_table_metadata_v2: dict[str, Any]
782+
) -> None:
783+
requests_mock.get(
784+
f"{TEST_URI}v1/config",
785+
json={
786+
"defaults": {},
787+
"overrides": {},
788+
"endpoints": [
789+
"GET /v1/{prefix}/namespaces/{namespace}/tables/{table}",
790+
"POST /v1/{prefix}/namespaces/{namespace}/tables/{table}/plan",
791+
"GET /v1/{prefix}/namespaces/{namespace}/tables/{table}/plan/{plan-id}",
792+
"DELETE /v1/{prefix}/namespaces/{namespace}/tables/{table}/plan/{plan-id}",
793+
"POST /v1/{prefix}/namespaces/{namespace}/tables/{table}/tasks",
794+
],
795+
},
796+
status_code=200,
797+
)
798+
catalog = RestCatalog("test", uri=TEST_URI, **{"scan-planning-mode": "servr"})
799+
_mock_load_table(requests_mock, example_table_metadata_v2, {"scan-planning-mode": "server"})
800+
plan_mock = requests_mock.post(
801+
f"{TEST_URI}v1/namespaces/db/tables/tbl/plan",
802+
json={
803+
"status": "completed",
804+
"plan-id": "plan-123",
805+
"delete-files": [],
806+
"file-scan-tasks": [{"data-file": _rest_data_file(file_path="s3://bucket/tbl/data/file1.parquet")}],
807+
"plan-tasks": [],
808+
},
809+
status_code=200,
810+
)
811+
812+
assert catalog.supports_server_side_planning() is False
813+
814+
table = catalog.load_table(("db", "tbl"))
815+
scan = table.scan()
816+
assert scan._should_use_server_side_planning() is True
817+
assert [task.file.file_path for task in scan.plan_files()] == ["s3://bucket/tbl/data/file1.parquet"]
818+
assert plan_mock.call_count == 1

0 commit comments

Comments
 (0)