This is an automated email from the ASF dual-hosted git repository.

roryqi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/gravitino.git


The following commit(s) were added to refs/heads/main by this push:
     new 0b091e12db [#11284] docs: Add design doc for Iceberg REST server-side 
scan planning results (#12241)
0b091e12db is described below

commit 0b091e12db52b209038e04ac54d6d0bb66793a93
Author: Akshay Thorat <[email protected]>
AuthorDate: Tue Sep 29 18:55:22 2026 -0700

    [#11284] docs: Add design doc for Iceberg REST server-side scan planning 
results (#12241)
    
    ### What changes were proposed in this pull request?
    
    Adds `design-docs/iceberg-rest-server-side-scan-planning.md`, the design
    for completing the Iceberg REST two-step scan planning protocol in the
    Gravitino Iceberg REST server, as requested in
    
[#12194](https://github.com/apache/gravitino/pull/12194#issuecomment-5102634079).
    
    The document covers:
    
    - **Background**: `POST .../plan` is implemented, `POST .../tasks` is
    not, so clients that gate on the advertised endpoint set (pyiceberg with
    `scan-planning-mode=server`) cannot use server-side planning at all, and
    a plan of a large table has to be returned in one unbounded response.
    - **Solution Investigations**: four token representations compared -
    always-inline (status quo), an in-memory plan-task store with random
    ids, a self-describing token (chosen), and asynchronous planning with
    server-side plan state - each with the concrete reason it was accepted
    or rejected, plus the cost of the chosen option stated in a table.
    - **Proposal**:
    - the new `POST .../tasks` interface (request fields, response schema,
    behavior, and the full error table),
    - the changed `POST .../plan` interface (old vs. new response,
    per-client migration impact),
    - the `plan-task` token: payload fields, encoding, validation, forward
    compatibility, and why the token is not a capability,
    - determinism: why the planned snapshot is pinned and why planned tasks
    are ordered totally,
      - interaction with the existing scan plan cache,
    - the internal dispatcher interfaces added, events and audit operation
    type,
    - federated catalogs, delete files and merge-on-read tables,
    configuration, user process, and implementation flow.
    - **Backward compatibility** and **Testing** summaries.
    - **Open questions** the implementation deliberately leaves to review:
    whether batching should require the scan plan cache to be enabled,
    whether 100 is the right default batch size, and whether tokens should
    carry an expiry.
    - **Task Breakdown**: what #12194 delivers, and the follow-ups that
    should be their own issues.
    
    ### Why are the changes needed?
    
    The implementation in #12194 introduces a new token type and changes
    what `POST .../plan` returns, and reviewers asked for the design to be
    written down and reviewed separately from the code.
    
    Fix: #11284
    
    ### Does this PR introduce _any_ user-facing change?
    
    No. Documentation only; the design document describes changes proposed
    in #12194.
    
    ### How was this patch tested?
    
    No code change. The document was checked against the implementation in
    #12194: every interface, config default, error status and behavior it
    describes was verified against the code on that branch.
---
 .../iceberg-rest-server-side-scan-planning.md      | 894 +++++++++++++++++++++
 1 file changed, 894 insertions(+)

diff --git a/design-docs/iceberg-rest-server-side-scan-planning.md 
b/design-docs/iceberg-rest-server-side-scan-planning.md
new file mode 100644
index 0000000000..cbdac2d01e
--- /dev/null
+++ b/design-docs/iceberg-rest-server-side-scan-planning.md
@@ -0,0 +1,894 @@
+<!--
+  Licensed to the Apache Software Foundation (ASF) under one
+  or more contributor license agreements.  See the NOTICE file
+  distributed with this work for additional information
+  regarding copyright ownership.  The ASF licenses this file
+  to you 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
+
+   http://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.
+-->
+
+# Design: Server-Side Scan Planning Results (`plan-tasks`) for the Gravitino 
Iceberg REST Server
+
+Implemented by [#12194](https://github.com/apache/gravitino/pull/12194) for
+[#11284](https://github.com/apache/gravitino/issues/11284), in
+`iceberg/iceberg-rest-server`, `iceberg/iceberg-common`,
+`catalogs/catalog-common` and `core`.
+
+---
+
+## 1. Background
+
+Server-side scan planning in the Iceberg REST specification is a **two-step**
+protocol. A client submits a scan, and the server may answer with results
+inline, with opaque `plan-tasks`, or with both:
+
+```
+1. POST /v1/{prefix}/namespaces/{ns}/tables/{t}/plan    → status, 
file-scan-tasks, plan-tasks
+2. POST /v1/{prefix}/namespaces/{ns}/tables/{t}/tasks    → file-scan-tasks for 
one plan-task
+```
+
+Gravitino implements step 1 only. `CatalogWrapperForREST.planTableScan`
+plans the scan, returns `COMPLETED` with every `file-scan-task` inline, and
+step 2 has no route at all, so `POST .../tasks` returns `404` because the
+path does not exist and `/v1/config` does not advertise it.
+
+Two concrete problems follow.
+
+**Clients gate on the advertised endpoint set, not on probing.** pyiceberg
+with `scan-planning-mode=server` reads `/v1/config`, sees that
+`POST /v1/{prefix}/namespaces/{namespace}/tables/{table}/tasks` is missing,
+and refuses to use server-side planning at all:
+
+```text
+NotImplementedError: Server does not support endpoint:
+  POST /v1/{prefix}/namespaces/{namespace}/tables/{table}/tasks
+```
+
+So the half of the protocol Gravitino *does* implement is unreachable for
+those clients, which is the report in
+[#11284](https://github.com/apache/gravitino/issues/11284).
+
+**One response has to carry the whole plan.** Returning every task inline is
+fine for a table with tens of data files and increasingly bad as tables
+grow: a scan of a 100,000-file table serializes 100,000 structured tasks
+into a single HTTP response, on one request thread, with the client unable to
+start work until the last byte arrives. Batched results are exactly what
+step 2 of the protocol exists for.
+
+A first iteration of PR #12194 added the route and the dispatcher plumbing
+but left the handler throwing `NoSuchPlanTaskException` unconditionally,
+since no plan task was ever issued. Review feedback
+([#12194](https://github.com/apache/gravitino/pull/12194#discussion_r3655295585))
+was that an endpoint which always fails is not support for the interface.
+This document covers the design that makes both steps real.
+
+### 1.1 Current state, in one table
+
+| Aspect                                       | Today                         
               |
+| -------------------------------------------- | 
-------------------------------------------- |
+| `POST .../plan`                              | Implemented, always 
`COMPLETED`, all inline  |
+| `POST .../tasks`                             | No route; `404` from Jersey   
               |
+| `plan-tasks` in a plan response              | Never emitted                 
               |
+| `/v1/config` endpoints                       | `.../plan` advertised, 
`.../tasks` absent    |
+| pyiceberg `scan-planning-mode=server`        | Fails with 
`NotImplementedError`             |
+| Plan size                                    | Unbounded in one response     
               |
+| Scan plan cache (`scan-plan-cache-impl`)     | Caches whole plan responses, 
disabled by default |
+
+---
+
+## 2. Goals
+
+1. **Complete the two-step protocol**: `POST .../tasks` is implemented,
+   advertised in `/v1/config`, and returns the file scan tasks a `plan-task`
+   covers. Verifiable: an end-to-end HTTP test redeems a `plan-task` issued by
+   `POST .../plan` and gets `200` with the expected tasks.
+2. **Bounded plan responses**: no scan planning response carries more than a
+   configured number of `file-scan-tasks`; the remainder is offered as
+   `plan-tasks`. Verifiable: a scan with N tasks and batch size B returns B
+   tasks inline and `ceil(N/B) - 1` plan tasks.
+3. **Correct task coverage**: the tasks reachable through a plan response
+   plus all of its `plan-tasks` are exactly the tasks of the planned
+   snapshot, each appearing once. Verifiable by a unit test over a table
+   whose plan spans several batches.
+4. **Plan tasks survive restart and replica failover**: a plan task issued by 
one
+   Gravitino instance is redeemable after that instance restarts and on any
+   other instance serving the same catalog. Verifiable: plan task resolution
+   depends on no in-process state.
+5. **Snapshot stability**: a plan task resolves against the snapshot that was
+   planned, even if the table is committed to in between. Verifiable: append
+   to the table after planning, then redeem the plan tasks and see the original
+   snapshot's tasks.
+6. **Spec-conformant errors**: an unknown, foreign or no-longer-resolvable
+   plan task returns `404` with `NoSuchPlanTaskException`; a missing table
+   returns `404` with `NoSuchTableException`.
+7. **No regression for small scans**: a plan that fits in one batch is
+   byte-identical to today's response and needs no second call.
+8. **Observability parity**: the new operation emits pre/post/failure events
+   and an audit operation type, like every other Iceberg REST operation.
+
+---
+
+## 3. Non-Goals
+
+1. **Asynchronous planning (`SUBMITTED` / `planId`)**: planning stays
+   synchronous. The plan lifecycle endpoints
+   `GET`/`DELETE .../plan/{planId}` are the subject of a separate change
+   ([#11635](https://github.com/apache/gravitino/pull/11635)); this design
+   deliberately does not touch `PlanStatus` other than `COMPLETED`.
+2. **Honoring `min-rows-requested`**: the spec's row-count hint for sizing
+   plan tasks is not implemented. Batches are sized in tasks, not rows;
+   supporting the hint requires row-count accounting we can add later
+   without changing the plan task format.
+3. **Scan plan cache defaults and implementations**: `ScanPlanCache` stays a
+   pluggable interface with an in-memory implementation, disabled by default.
+   Whether Gravitino should enable a cache by default, and whether it should
+   ship a shared one, is tracked separately (§8.1).
+4. **Signed or encrypted plan tasks**: a plan task carries no secret and 
grants no
+   access (§5.5.4), so cryptographic protection would add key management for
+   no privilege boundary.
+5. **Legacy Iceberg clients (< 1.11)**: responses use structured
+   `file-scan-tasks` per the 1.11 REST API. Clients expecting the older
+   JSON-string task encoding remain unsupported, as before this change.
+6. **Changing scan planning semantics**: filters, projections, snapshot
+   selection, incremental scans and credential vending behave exactly as
+   they do today; only how results are delivered changes.
+
+---
+
+## 4. Solution Investigations
+
+The endpoint itself is not the interesting part; how a `plan-task` is
+represented and resolved is. Four options were considered.
+
+| Approach                                                    | Pros           
                                                                             | 
Cons                                                                            
                                                     | Decision                 
                                                   |
+| ----------------------------------------------------------- | 
-------------------------------------------------------------------------------------------
 | 
------------------------------------------------------------------------------------------------------------------------------------
 | --------------------------------------------------------------------------- |
+| **A.** Keep returning everything inline; `.../tasks` always `404` | No new 
concepts; smallest diff                                                         
     | The endpoint is decoration, not support (review feedback); plan 
responses stay unbounded                                              | 
**Rejected** — does not meet Goals 1–2                                      |
+| **B.** In-memory plan-task store, random ids                | Fastest 
redemption (no re-planning); trivial to produce                                 
    | The state dies with the process and means nothing on another replica, so 
a scan fails mid-way after a restart or behind a load balancer | **Rejected** — 
violates Goal 4                                              |
+| **C.** Self-describing plan task: scan request + pinned snapshot + task 
range | No cross-request state, so restart-safe and replica-safe; reuses the 
existing plan cache for speed | Redeeming one re-plans the snapshot on a cache 
miss; the string is longer than a random id                                     
     | **Chosen**                                                               
   |
+| **D.** Asynchronous plan (`SUBMITTED` + `planId`) with server-side plan 
state | Matches the part of the spec built for very large plans                 
                    | Needs a plan lifecycle (states, expiry, cancellation, 
storage) — that is #11635's scope, and it does not remove the need for 
`.../tasks` | **Deferred** — orthogonal; this design stays synchronous          
           |
+
+**Why not B, in more detail.** Gravitino's Iceberg REST server is routinely
+run as more than one replica behind a load balancer, and the Iceberg Java
+client fetches plan tasks *concurrently* (`ScanTaskIterable` uses a worker
+pool), so consecutive requests of one scan land on different replicas by
+design. With node-local plan task state, a client's scan fails with `404` for
+reasons that have nothing to do with its own behavior, and the failure is
+timing-dependent — the worst kind to debug. Sticky sessions or a shared
+store would fix it, but that is either an operator burden or option D's
+plan-state problem in disguise.
+
+**Cost of C, stated plainly.** A plan task redemption needs the full ordered
+task list for its snapshot. With the scan plan cache enabled that is one
+cache lookup; with the cache disabled (today's default) it re-plans the
+pinned snapshot. For a plan of N tasks and batch size B the client makes
+`ceil(N/B)` requests, and the server computes 1 plan with the cache on and
+`ceil(N/B)` plans with it off:
+
+| Plan size | Batch size | Client requests | Plans computed (cache on) | Plans 
computed (cache off) |
+| --------- | ---------- | --------------- | ------------------------- | 
-------------------------- |
+| 80        | 100        | 1               | 1                         | 1     
                     |
+| 1,000     | 100        | 10              | 1                         | 10    
                     |
+| 100,000   | 100        | 1,000           | 1                         | 1,000 
                     |
+
+Re-planning is not free but it is also not new work: it reads the same
+manifests the first plan read, for a snapshot that is pinned and therefore
+immutable. The mitigation is documented (enable `scan-plan-cache-impl`);
+coupling batching to the cache being enabled was considered and rejected in
+review (§8.1). What this costs in a replicated deployment, and what would 
remove
+the cost rather than shrink it, is §5.15 and §8.5.
+
+**Alternative considered for ordering.** A plan task could carry the identity 
of
+the files it covers instead of an index range, which would remove the
+dependency on a stable order. Rejected: it makes plan tasks grow with batch 
size
+(kilobytes per plan task) and duplicates in the plan task what the plan already
+knows, when a total order over `(file location, start, length)` gives
+stability for free (§5.6).
+
+---
+
+## 5. Proposal
+
+### 5.1 Overview
+
+```
+                POST .../plan  {filter, select, snapshot-id, …}
+                          │
+                          ▼
+        ┌───────────────────────────────────────────────┐
+        │ CatalogWrapperForREST.planTableScan           │
+        │  1. loadTable                                 │
+        │  2. pin snapshot (if the request left it open) │
+        │  3. plan full scan  ── cache hit ─▶ cached plan│
+        │     miss ─▶ plan, sort tasks, put in cache    │
+        │  4. split: first B tasks inline,              │
+        │            one plan task per later batch│
+        │  5. inject vended credentials (if requested)  │
+        └───────────────────────────────────────────────┘
+                          │
+     status: COMPLETED, file-scan-tasks[0..B), plan-tasks[…]
+                          │
+                          ▼
+                POST .../tasks  {plan-task}
+                          │
+                          ▼
+        ┌───────────────────────────────────────────────┐
+        │ CatalogWrapperForREST.fetchScanTasks          │
+        │  1. decode plan task (not ours ⇒ 404)             │
+        │  2. loadTable (missing ⇒ 404)                 │
+        │  3. plan full scan for the plan task's scan       │
+        │     request ── cache hit ─▶ cached plan       │
+        │     miss ─▶ re-plan pinned snapshot, sort     │
+        │  4. slice [offset, offset+limit)              │
+        └───────────────────────────────────────────────┘
+                          │
+              file-scan-tasks for that batch
+```
+
+Nothing is stored between the two calls. The plan task *is* the state.
+
+### 5.2 New REST API: `POST 
/v1/{prefix}/namespaces/{namespace}/tables/{table}/tasks`
+
+**Request** (`FetchScanTasksRequest`, Iceberg 1.11):
+
+| Field       | Type   | Required | Description                                
            |
+| ----------- | ------ | -------- | 
------------------------------------------------------ |
+| `plan-task` | string | yes      | An opaque string from a prior plan 
response (§5.5)     |
+
+```json
+{ "plan-task": 
"eyJ0YWJsZSI6ImRiLnRibCIsIm9mZnNldCI6MTAwLCJsaW1pdCI6MTAwLCJzY2FuIjp7…" }
+```
+
+**Response** `200 OK` (`FetchScanTasksResponse`):
+
+| Field             | Type   | Present                     | Description       
                                             |
+| ----------------- | ------ | --------------------------- | 
-------------------------------------------------------------- |
+| `file-scan-tasks` | array  | always                      | The tasks this 
plan task covers: data file, residual, delete refs   |
+| `delete-files`    | array  | when the batch's tasks have deletes | Delete 
files the batch's tasks reference by index (§5.11)   |
+| `specs-by-id`     | object | always                      | Partition specs, 
required to deserialize the tasks              |
+| `plan-tasks`      | array  | never in this implementation | Reserved by the 
spec for a server that sub-divides further      |
+
+```json
+{
+  "file-scan-tasks": [
+    { "data-file": { "file-path": "s3://bucket/db/tbl/data/00001.parquet", 
"…": "…" },
+      "residual-filter": { "type": "eq", "term": "day", "value": "2026-07-28" 
} }
+  ],
+  "specs-by-id": { "0": { "spec-id": 0, "fields": [] } }
+}
+```
+
+**Behavior.** Authorization runs first, from the table in the request path,
+with the same expression as `POST .../plan` (owner, or `USE_CATALOG` +
+`USE_SCHEMA` + `SELECT_TABLE`/`MODIFY_TABLE`). Then the table is loaded, so
+a request against a missing table reports the missing table rather than
+masking it as a bad plan task. Then the plan task is decoded and resolved.
+
+| Condition                                                        | Status | 
Error type                 |
+| ---------------------------------------------------------------- | ------ | 
-------------------------- |
+| Plan task resolves to a batch of the plan                        | `200`  | 
—                          |
+| Missing or empty request body                                    | `400`  | 
`IllegalArgumentException` |
+| Table does not exist                                             | `404`  | 
`NoSuchTableException`     |
+| Plan task not issued by this server, or issued for another table | `404`  | 
`NoSuchPlanTaskException`  |
+| Plan task's snapshot no longer exists (expired, rolled back)     | `404`  | 
`NoSuchPlanTaskException`  |
+| Plan task's range starts past the end of the plan                | `404`  | 
`NoSuchPlanTaskException`  |
+| Caller lacks table privileges                                    | `403`  | 
`ForbiddenException`       |
+
+Plan tasks are **not** single-use: redeeming one twice returns the same tasks,
+which keeps client retries safe.
+
+### 5.3 Changed REST API: `POST 
/v1/{prefix}/namespaces/{namespace}/tables/{table}/plan`
+
+**Old behavior.** `COMPLETED` with every `file-scan-task` inline and no
+`plan-tasks`, whatever the plan's size.
+
+**New behavior.** `COMPLETED` with at most `scan-plan-task-batch-size`
+`file-scan-tasks` inline; every later batch is offered as a `plan-task`
+plan task. Below the threshold the response is unchanged.
+
+```json
+{
+  "status": "completed",
+  "file-scan-tasks": [ "… 100 tasks …" ],
+  "plan-tasks": [ "eyJ0YWJsZSI6…MjAwLCJsaW1pdCI6MTAw…", 
"eyJ0YWJsZSI6…MzAwLCJsaW1pdCI6MTAw…" ],
+  "specs-by-id": { "0": { "…": "…" } }
+}
+```
+
+**Migration impact.**
+
+| Client                                            | Impact                   
                                                                      |
+| ------------------------------------------------- | 
----------------------------------------------------------------------------------------------
 |
+| Iceberg Java 1.11+ (`ScanTaskIterable`)           | None; it already 
consumes `plan-tasks`, now concurrently against `.../tasks`                    |
+| pyiceberg with `scan-planning-mode=server`        | Fixed: server-side 
planning becomes usable at all                                              |
+| A client that ignores `plan-tasks`                | Would silently see only 
the first batch. Such a client is not spec-conformant, but operators can 
restore the old shape with `scan-plan-task-batch-size=0` |
+| Clients not using server-side planning            | None                     
                                                                      |
+
+Credential vending is unchanged: credentials belong to the plan response,
+and a client that received them at plan time keeps using them for the tasks
+it fetches later. `FetchScanTasksResponse` has no credential field in the
+spec.
+
+### 5.4 `/v1/config`
+
+`Endpoint.V1_FETCH_TABLE_SCAN_PLAN_TASKS` is added to the advertised
+endpoint list next to the existing plan endpoint, which is what unblocks
+clients that gate on the endpoint set.
+
+### 5.5 The `plan-task` string
+
+To a client a plan task is an opaque string, exactly as the Iceberg REST
+specification defines it (§8.3); to the server it describes the unit of work it
+stands for. `PlanTaskCodec` is the only place that reads or writes it.
+
+#### 5.5.1 Payload
+
+The plan task is `base64url(JSON)`, unpadded. Four fields, nothing else:
+
+| Field    | Type   | Description                                              
                                          |
+| -------- | ------ | 
--------------------------------------------------------------------------------------------------
 |
+| `table`  | string | `TableIdentifier.toString()` of the planned table        
                                           |
+| `offset` | int    | Index of the first task it covers, in the plan's total 
order (§5.6)                                 |
+| `limit`  | int    | Maximum number of tasks it covers                        
                                          |
+| `scan`   | object | The planned request, serialized by Iceberg's 
`PlanTableScanRequestParser`, with the snapshot pinned |
+
+```json
+{
+  "table": "db.tbl",
+  "offset": 100,
+  "limit": 100,
+  "scan": {
+    "snapshot-id": 3821550127947089009,
+    "case-sensitive": true,
+    "use-snapshot-schema": false,
+    "filter": { "type": "eq", "term": "day", "value": "2026-07-28" }
+  }
+}
+```
+
+Reusing `PlanTableScanRequestParser` for the `scan` member means filters,
+projections and stats fields round-trip through Iceberg's own serializer
+rather than a hand-rolled one, and a new scan request field is carried
+without touching this design.
+
+#### 5.5.2 Encoding and decoding
+
+`PlanTaskCodec.encode(table, scanRequest, offset, limit)` produces the string;
+`PlanTaskCodec.decode(planTask)` returns
+`Optional<PlanTaskCodec.PlanTask>` — the unit of work the string stands for —
+and is empty for anything this server would not have issued: not base64, not a
+JSON object, a missing or wrongly typed field, `offset < 0`, or `limit <= 0`. 
`fetchScanTasks` additionally requires
+`planTask.matchesTable(tableIdentifier)`. Every rejection path becomes one
+`404 NoSuchPlanTaskException`, so a malformed plan task and an expired one are
+indistinguishable to a client, as the spec intends.
+
+`decode` returning `Optional` rather than throwing keeps "this is not our
+plan task" as an ordinary outcome, and leaves the mapping to a REST error in 
the
+one place that knows the request context.
+
+#### 5.5.3 Forward compatibility
+
+An earlier revision carried a `version` field; review feedback
+([#12194](https://github.com/apache/gravitino/pull/12194#discussion_r3664512613))
+asked for it to be dropped, and it was. It is redundant: a plan task from a
+future format that this server cannot decode into these four fields is
+already reported as an unknown plan task, which is the same behavior the
+version check produced. A future format change should therefore keep these
+field names meaning what they mean here, and may add fields freely.
+
+#### 5.5.4 Security properties
+
+The plan task is opaque to clients but is **not a capability**:
+
+- Authorization is evaluated from the **table in the request path**, never
+  from the plan task, so a plan task cannot widen access.
+- A plan task encoded for another table is rejected, so it cannot be used to 
read
+  a table the caller happens to be authorized for by swapping paths.
+- A forged plan task can at most express a scan the caller could already submit
+  directly through `POST .../plan` (filters and projections are not
+  privileges).
+- The plan task contains no credential and no user identity; it names a table, 
a
+  snapshot id, and a scan.
+
+The plan task does reveal the snapshot id and the client's own filter to anyone
+who sees the plan task, which is the same information that client already sent
+and received in the plan response.
+
+### 5.6 Determinism: pinned snapshots and a total order over tasks
+
+An index range only identifies tasks if the plan it indexes is reproducible.
+Two properties make it so.
+
+**Snapshot pinning.** If a request does not name a snapshot (and is not an
+incremental scan, which pins a range), `planTableScan` resolves the table's
+current snapshot and plans *that*, embedding it in the plan tasks. Without 
this,
+a commit between plan and fetch would move the plan task to a different
+snapshot: tasks would be dropped or duplicated, and a client could produce a
+result set that never existed at any point in time. Requests that already
+pin a snapshot, incremental requests, and tables with no current snapshot
+are planned exactly as they arrive.
+
+**Total order.** Iceberg plans manifests in parallel, so `planFiles()` does not
+return tasks in a reproducible order. Tasks are therefore sorted before 
batching
+or caching, by
+
+```
+(data file location, start, length,
+ data sequence number, file sequence number,
+ manifest location, manifest entry position)
+```
+
+An earlier revision of this design sorted on the first three fields only,
+reasoning that two tasks over one file cover disjoint byte ranges. That is not
+enough, and review on
+[#12241](https://github.com/apache/gravitino/pull/12241#issuecomment-5129278423)
+asked for the claim to be proved or fixed. It cannot be proved: **one path can 
be
+referenced by several manifest entries.** Appending the same data file twice
+produces two entries with the same location, offset and length, differing in
+sequence number and potentially in the delete files attached to them:
+
+```
+location=…/dup.parquet start=0 length=10  dataSeq=2  manifest=…-m0.avro pos=0
+location=…/dup.parquet start=0 length=10  dataSeq=1  manifest=…-m0.avro pos=0
+```
+
+Sorting is stable, so tasks that tie keep the order Iceberg planned them in,
+which is exactly the order that is not reproducible. Two tied tasks either side
+of a batch boundary could therefore swap places between the plan and a later
+re-plan, and the client would receive one of them twice and never see the 
other -
+a wrong result, not merely a slow one.
+
+A manifest entry is unique within a snapshot, identified by its manifest and 
its
+position in that manifest, so comparing those makes the order total for any two
+tasks that are not interchangeable. Sequence numbers are compared first because
+they survive a rewrite into new manifests, and they remain the discriminator 
if a
+reader leaves the manifest fields unset. Tasks that tie on every key carry the
+same file, range, sequence numbers and entry, so exchanging them is invisible 
to
+a client.
+
+Both properties are needed together: pinning without ordering still lets a
+re-plan shuffle the batches; ordering without pinning still lets the
+underlying data change.
+
+### 5.7 Interaction with the scan plan cache
+
+`ScanPlanCache` already caches whole plan responses under a
+`ScanPlanCacheKey` that includes the resolved snapshot id and every scan
+parameter. Two consequences fall out for free:
+
+- The cache key computed for a *pinned* request equals the key for the
+  original *unpinned* request, because the key resolves a null snapshot id
+  from the table. A plan cached by `POST .../plan` is therefore hit by the
+  matching `POST .../tasks`.
+- The cache stores the **full** plan, and batching is applied per response,
+  so a cached entry serves both the inline first batch and every later
+  batch.
+
+### 5.8 Internal interfaces
+
+`fetchScanTasks` is added along the existing dispatcher chain, so it gets
+the same authorization, event, hook and audit treatment as every other
+table operation. No existing signature changes.
+
+```java
+// iceberg/iceberg-rest-server 
.../service/dispatcher/IcebergTableOperationDispatcher.java
+FetchScanTasksResponse fetchScanTasks(
+    IcebergRequestContext context,
+    TableIdentifier tableIdentifier,
+    FetchScanTasksRequest request);
+```
+
+Implemented by `IcebergTableOperationExecutor` (delegates to the catalog
+wrapper), `IcebergTableEventDispatcher` (events around the call) and
+`IcebergTableHookDispatcher` (pass-through today, for symmetry).
+
+```java
+// .../service/CatalogWrapperForREST.java
+public FetchScanTasksResponse fetchScanTasks(
+    TableIdentifier tableIdentifier, FetchScanTasksRequest request);
+
+// package-private helpers introduced in the same class
+private PlanTableScanResponse planFullScan(
+    TableIdentifier tableIdentifier, Table table, PlanTableScanRequest 
scanRequest);
+private PlanTableScanResponse splitIntoPlanTasks(
+    TableIdentifier tableIdentifier, PlanTableScanRequest scanRequest,
+    PlanTableScanResponse fullPlan);
+private static List<DeleteFile> referencedDeleteFiles(List<FileScanTask> 
fileScanTasks);
+
+// .../service/PlanTaskCodec.java (new, package-private)
+static String encode(TableIdentifier table, PlanTableScanRequest scan, int 
offset, int limit);
+static Optional<PlanTask> decode(String planTask);
+
+// PlanTaskCodec.PlanTask, the unit of work a decoded plan-task stands for
+boolean matchesTable(TableIdentifier tableIdentifier);
+PlanTableScanRequest scanRequest();
+int offset();
+int limit();
+```
+
+`planTableScan` keeps its signature; internally it now pins the snapshot,
+delegates to `planFullScan`, and batches through `splitIntoPlanTasks`.
+
+### 5.9 Events and audit
+
+| Addition                                                        | Purpose    
                          |
+| --------------------------------------------------------------- | 
------------------------------------ |
+| `IcebergFetchScanTasksPreEvent`                                 | Before the 
operation                 |
+| `IcebergFetchScanTasksEvent`                                    | After 
success                        |
+| `IcebergFetchScanTasksFailureEvent`                             | After 
failure, carrying the exception |
+| `OperationType.FETCH_SCAN_TASKS`                                | Audit log 
operation type             |
+| `IcebergExceptionMapper`: `NoSuchPlanTaskException` → `404`      | 
Spec-conformant error status         |
+
+### 5.10 Federated catalogs
+
+When the backend is another REST catalog, `planTableScan` already delegates
+upstream, so the `plan-tasks` a client receives are the **remote** catalog's
+plan tasks and are opaque to Gravitino. 
`FederatedCatalogWrapper.fetchScanTasks`
+therefore forwards the plan task unchanged to the remote `.../tasks` and 
returns
+what the remote answers; local plan task decoding is never involved. Response
+deserialization needs the table's partition specs, which are supplied
+through a `ParserContext` built from the loaded table, mirroring the existing
+federated plan path.
+
+### 5.11 Delete files and merge-on-read tables
+
+Iceberg serializes a task's deletes as `delete-file-references`, indexes into
+the response's top-level `delete-files` array. Batching slices the task list,
+so a naive implementation could leave a task in one batch referencing a
+delete file listed in another.
+
+No extra code is needed to avoid that:
+`BaseScanTaskResponse.Builder.withFileScanTasks` derives the response's
+`delete-files` from the tasks it is given (`DeleteFileSet.of(...)`), so every
+response — the inline first batch and each fetched batch — lists exactly the
+delete files its own tasks reference. `withDeleteFiles` is deprecated in
+1.11 for that reason and is not called. The constraint is covered by a
+merge-on-read test asserting that the batch holding a deleted-from data file
+carries the delete file and serializes with `"delete-file-references":[0]`.
+
+### 5.12 Configuration
+
+| Property                                             | Default | Since | 
Description                                                                     
                                                |
+| ---------------------------------------------------- | ------- | ----- | 
-------------------------------------------------------------------------------------------------------------------------------
 |
+| `gravitino.iceberg-rest.scan-plan-task-batch-size`   | `100`   | 1.3.0 | 
Maximum `file-scan-tasks` returned inline by one scan planning response; later 
batches become `plan-tasks`. `0` disables batching. |
+
+The default matches the Iceberg side, as agreed in review
+([#12194](https://github.com/apache/gravitino/pull/12194#discussion_r3662630664)),
+and keeps a response comfortably inside the ~1 MB body limit common to
+gateways and load balancers: 100 file scan tasks serialize to roughly 0.3 MB
+(§8.2). Setting `0` restores the pre-change response shape exactly, which is
+the escape hatch for an operator with a non-conformant client.
+
+### 5.13 User process
+
+1. The client reads `/v1/config` and sees both `.../plan` and `.../tasks`
+   advertised, so it enables server-side scan planning.
+2. The client submits a scan:
+   `POST /v1/{prefix}/namespaces/db/tables/tbl/plan` with its filter,
+   projection and optionally `snapshot-id`.
+3. The server answers `COMPLETED` with up to `scan-plan-task-batch-size`
+   tasks inline plus one `plan-task` per remaining batch. If the plan fits in
+   one batch there are no plan tasks and the client is done.
+4. The client starts work on the inline tasks and, for each plan task, calls
+   `POST /v1/{prefix}/namespaces/db/tables/tbl/tasks` with
+   `{"plan-task": "<plan task>"}` — the Iceberg Java client does this
+   concurrently from a worker pool.
+5. Each response carries that batch's `file-scan-tasks` (plus `delete-files`
+   for a merge-on-read table). The union across the plan response and all
+   plan tasks is the complete task set for the planned snapshot.
+6. If the client presents a plan task after the planned snapshot has expired, 
it
+   gets `404 NoSuchPlanTaskException` and re-plans from step 2.
+
+### 5.14 Implementation process
+
+```
+POST .../tasks
+      │
+      ▼
+IcebergTableOperations.fetchScanTasks
+      │   authorization on the request thread; empty body ⇒ 400
+      ▼
+IcebergTableEventDispatcher            PreEvent … Event | FailureEvent
+      ▼
+IcebergTableHookDispatcher
+      ▼
+IcebergTableOperationExecutor
+      ▼
+CatalogWrapperForREST.fetchScanTasks
+      │
+      ├── PlanTaskCodec.decode ──── empty / other table ──▶ 404 
NoSuchPlanTaskException
+      ├── catalog.loadTable ─────── missing ──────────────▶ 404 
NoSuchTableException
+      ├── planFullScan(planTask.scanRequest())
+      │        ├── scan plan cache hit ─▶ cached full ordered plan
+      │        └── miss ─▶ plan pinned snapshot, sort, cache
+      │        └── snapshot gone (IllegalArgumentException) ─▶ 404 
NoSuchPlanTaskException
+      ├── offset >= task count ───────────────────────────▶ 404 
NoSuchPlanTaskException
+      └── slice [offset, min(offset+limit, count)) ─▶ FetchScanTasksResponse
+                                                      + specs-by-id, + 
delete-files
+```
+
+`FederatedCatalogWrapper` overrides the last step and posts to the remote
+catalog instead.
+
+### 5.15 Multi-replica deployments
+
+Gravitino's Iceberg REST server is routinely run as several replicas behind a
+load balancer, and the Iceberg Java client fetches plan tasks **concurrently**
+(`ScanTaskIterable` uses a worker pool), so the requests of one scan land on
+different replicas by design. This section states what that costs and what it
+does not.
+
+**Correctness does not depend on which replica serves a request.** A plan task
+carries everything needed to resolve it, so any replica that can load the table
+can decode it, reproduce the plan of the pinned snapshot, and slice the same
+range - provided the order in §5.6 is total, which is why that section matters
+more than it first appears. No replica holds state the others lack, nothing
+expires, and a restart mid-scan is invisible to the client.
+
+**Cost does depend on it.** Redeeming a plan task needs the full ordered task
+list for its snapshot. The scan plan cache is node-local, so `/plan` served by
+replica A warms only A: concurrent `/tasks` on B, C and D each miss and re-plan
+the whole pinned snapshot - reading the manifests again and sorting the full 
task
+list - to return one batch. For a plan of N tasks and batch size B the worst 
case
+is `ceil(N/B)` full plans spread across replicas, which for a large table
+multiplies manifest reads against object storage.
+
+Nothing about that is incorrect: the snapshot is pinned, so every re-plan of a
+given plan task produces the same tasks. It is a load-amplification problem, 
and
+the expected production posture is:
+
+| Measure | Effect |
+| ------- | ------ |
+| Enable a scan plan cache 
([#12254](https://github.com/apache/gravitino/issues/12254) proposes making it 
the default) | Removes re-planning for requests that land on a replica that 
already has the plan |
+| Route a client's scan to one replica (load balancer affinity) | Makes the 
node-local cache effective for the whole scan |
+| Raise `scan-plan-task-batch-size` for large tables | Fewer plan tasks, so 
proportionally fewer redemptions |
+| Coalesce concurrent redemptions of one plan on a replica | Collapses the 
concurrent fetches of one scan into a single plan per replica |
+
+The measures above reduce the multiplier; they do not remove it. Removing it
+needs a plan task whose redemption cost is independent of the size of the plan 
-
+see the manifest-scoped alternative in §8.4 - or a cache shared by all 
replicas.
+
+**On signing plan tasks.** §5.5.4 explains why plan tasks are not signed: they
+grant nothing, and authorization comes from the request path. Review raised the
+consequence for this section, which is worth recording: if a future change 
signs
+or HMACs a plan task to distinguish "issued by this server" from "well-formed 
but
+forged", the key must be shared by every replica. A per-replica key would make 
a
+plan task verifiable only on the replica that issued it, reintroducing exactly
+the failure mode this design avoids, and it would do so silently - the client
+would see intermittent `404`s that depend on load balancer routing.
+
+---
+
+## 6. Backward compatibility
+
+| Surface                                  | Compatible? | Note                
                                                          |
+| ---------------------------------------- | ----------- | 
----------------------------------------------------------------------------- |
+| Iceberg REST wire protocol               | Yes         | Both steps are 
spec-defined; only which of them Gravitino uses changes         |
+| `POST .../plan` for plans ≤ batch size   | Yes         | Identical response  
                                                          |
+| `POST .../plan` for larger plans         | Shape change | Spec-conformant; 
`scan-plan-task-batch-size=0` restores the old shape          |
+| Gravitino Java/Python clients            | Unaffected  | Scan planning is an 
Iceberg REST surface, not a Gravitino client API           |
+| `ScanPlanCache` interface                | Yes         | No signature 
change; entries still hold whole plans                           |
+| Existing configuration                   | Yes         | One new property, 
defaulted                                                   |
+| Event listeners                          | Additive    | Three new event 
classes and one new `OperationType`                            |
+
+---
+
+## 7. Testing
+
+| Level                    | Coverage                                          
                                                                                
                    |
+| ------------------------ | 
-----------------------------------------------------------------------------------------------------------------------------------------------------
 |
+| Plan task unit tests         | Round-trip of snapshot, filter, projection, 
stats fields and range; rejection of foreign, malformed, wrongly typed and 
non-object payloads; URL safety |
+| Wrapper unit tests       | Batching arithmetic; every task reachable exactly 
once; resolution against a pinned snapshot after a later append; cache-served 
path; batching disabled; merge-on-read batches carrying their own delete files; 
unknown/foreign/stale plan tasks; missing table |
+| REST end-to-end          | With batch size 1: `/plan` advertises the 
expected plan tasks and each `POST .../tasks` returns `200` with its task; 
unknown plan task returns `404` with the right error type; empty body returns 
`400` |
+| Federation               | The federated path posts to the remote 
`.../tasks` with the plan task forwarded untouched                              
                                    |
+| Events and config        | Pre/failure event dispatch for the new operation; 
`/v1/config` advertises the endpoint                                            
                     |
+
+---
+
+## 8. Decisions taken in review
+
+Three questions were open when this design was first written and were settled 
in
+review on [#12241](https://github.com/apache/gravitino/pull/12241) (§8.1 to
+§8.3). The later sections record what a second round of review asked for: how
+this compares with other implementations, and what a full fix for re-planning
+cost would look like.
+
+### 8.1 Batching does not depend on the scan plan cache
+
+With the cache disabled — today's default — an N-batch plan costs N full plans
+(§4), which argued for keeping everything inline unless a cache is configured.
+
+**Decision:** leave caching out of this design. Batching always applies, and
+whether Gravitino should enable a scan plan cache by default is a separate
+question, raised as its own issue so it can be discussed on its own terms
+(with a stated preference for enabling it). This keeps Goal 2 intact in every
+configuration, at the cost of re-planning on a cache miss, which the user
+documentation calls out.
+
+### 8.2 The default batch size stays 100
+
+100 matches the Iceberg side, and the number also lines up with deployment
+reality: many gateways and load balancers cap a response body at about 1 MB,
+and 100 file scan tasks serialize to roughly 0.3 MB. It does mean a
+100,000-file scan takes 1,000 requests.
+
+**Decision:** keep 100. If real workloads show the default causing problems,
+that is a separate issue — possibly sizing batches by serialized bytes rather
+than by task count.
+
+### 8.3 A plan task does not expire
+
+Neither the Iceberg specification nor its implementation gives a plan task any
+lifetime. The OpenAPI definition calls it "an opaque string provided by the
+REST server that represents a unit of work for generating file scan tasks for
+scan planning" — an ordinary request parameter, like a table or catalog name,
+not a credential.
+
+**Decision:** no expiry. A plan task stays redeemable as long as the snapshot
+it pins exists, and the class that produces and reads it is named
+`PlanTaskCodec` rather than a token type, to keep the code aligned with that
+framing.
+
+### 8.4 How other implementations compare
+
+Review asked how this compares with what other catalogs are doing
+([#12241](https://github.com/apache/gravitino/pull/12241#issuecomment-5129106422)).
+
+**Apache Polaris (incubating): not implemented.** Polaris deliberately excludes
+all three scan planning paths from the catalog service spec it generates its 
API
+from - `.../plan`, `.../plan/{plan-id}` and `.../tasks` appear only as 
commented
+placeholders marked "Not implemented in Polaris" in
+[`spec/polaris-catalog-service.yaml`](https://github.com/apache/polaris/blob/main/spec/polaris-catalog-service.yaml),
+and its advertised `/v1/config` endpoint list omits them, so a conformant 
client
+falls back to client-side planning. The tracking issue,
+[apache/polaris#966](https://github.com/apache/polaris/issues/966), has been 
open
+since February 2025 with no comments, across spec refreshes up to Iceberg 1.11.
+There is therefore no Polaris design to borrow from or contrast against: it has
+neither the load-amplification problem nor the ordering problem, because it 
does
+not plan scans server side.
+
+**Iceberg's own reference handler: in-memory, single node.** The closest 
existing
+Java implementation is `CatalogHandlers` with `RESTCatalogAdapter`, the fixture
+behind the `iceberg-rest-fixture` image. It keeps plans in a static
+`InMemoryPlanningState`, hands out plan tasks derived from a plan id and an 
index,
+and resolves them by map lookup. That inverts this design's trade-off exactly:
+redemption is free, but a plan task means nothing after a restart or on another
+process, and the fixture is not intended for a replicated deployment. It also
+consults `min-rows-requested`, which this design does not (Non-Goal 2).
+
+So the comparison is: redemption cost is the price this design pays for working
+across replicas and restarts, and the reference implementation pays the 
opposite
+price. Neither problem is solved for free by an existing implementation.
+
+### 8.5 Manifest entry-range plan tasks, the follow-up that removes re-planning
+
+The measures in §5.15 shrink the re-planning multiplier without removing it. 
The
+way to remove it is to change what a plan task *is*: instead of a range of
+positions in a global task list, have it name the work directly, as manifest
+entry ranges. Redeeming it then reads those manifest slices rather than
+re-planning the snapshot, so the cost is proportional to the batch and not to 
the
+plan, on any replica, with no shared cache. It also retires §5.6: manifests are
+immutable and a position inside one is fixed, so there is no global order left 
to
+stabilise, and the duplicate-entry hazard §5.6 describes cannot arise.
+
+@lasdf1234 proposed a payload for this on
+[#12241](https://github.com/apache/gravitino/pull/12241#issuecomment-5178530018),
+and the batching it describes is the model below. What follows records the 
payload
+this design would use, the reasons it keeps three fields that proposal dropped,
+and the one piece of work that makes it more than a refactor.
+
+#### 8.5.1 Batching model
+
+A batch is a run of manifest entries, which may span manifests. With a batch 
size
+of 100 over manifests holding 60, 60, 60 and 70 entries:
+
+```text
+Batch 0 (100):  m0[0,60) + m1[0,40)             → inline in the plan response
+Batch 1 (100):  m1[40,60) + m2[0,60) + m3[0,20) → plan task
+Batch 2 (50):   m3[20,70)                       → plan task
+```
+
+Ranges are half-open. `ManifestFile.existingFilesCount()` and
+`addedFilesCount()` give the per-manifest counts without reading a manifest,
+though they count entries before the scan filter is applied, so a batch of 100
+entries can yield fewer than 100 tasks. Batch size becomes a bound rather than 
an
+exact count.
+
+#### 8.5.2 Payload
+
+```json
+{
+  "table": "db.t",
+  "snapshot-id": 42,
+  "scan": { "filter": "…", "case-sensitive": true, "select": [], 
"stats-fields": [] },
+  "ranges": [
+    { "manifest": "s3://wh/db/t/metadata/snap-42-m1.avro", "entry-start": 40, 
"entry-end": 60 },
+    { "manifest": "s3://wh/db/t/metadata/snap-42-m2.avro", "entry-start": 0, 
"entry-end": 60 },
+    { "manifest": "s3://wh/db/t/metadata/snap-42-m3.avro", "entry-start": 0, 
"entry-end": 20 }
+  ]
+}
+```
+
+`ranges` is the new part. `table`, `snapshot-id` and `scan` are carried over 
from
+the current payload, and each earns its place:
+
+| Field | Why the ranges alone are not enough |
+| ----- | ----------------------------------- |
+| `snapshot-id` | Delete manifests are reachable only through a snapshot 
(`Snapshot.deleteManifests`); a manifest path does not say which snapshot 
referenced it. Entries also inherit their sequence numbers from the 
`ManifestFile` in the snapshot's manifest list, and `ManifestFiles.read` takes 
that `ManifestFile`, not a path. Without it, a merge-on-read table returns 
tasks with no deletes attached and the client reads deleted rows. |
+| `scan` | `ManifestReader.iterator()` yields entries *after* `filterRows`, 
`filterPartitions` and `caseSensitive` are applied, so an entry position means 
one thing with the client's filter and another without it. `residual-filter`, 
which the engine applies per data file, comes from the same filter through 
`ResidualEvaluator`; with no filter the server would have to emit always-true 
residuals. |
+| `table` | Authorization is evaluated from the table in the request path, but 
manifest paths arrive in the request body. Without binding the plan task to a 
table and checking each manifest against that snapshot's manifest list, a 
caller authorized on one table could name another table's manifest and receive 
its data file paths and column statistics. |
+
+#### 8.5.3 What it costs
+
+Plan tasks stop being a fixed size. One batch can name several manifests, at
+roughly 100 bytes of object-store path each, and tables written by frequent 
small
+commits have many small manifests, so a plan task can reach kilobytes and the 
plan
+response carries all of them. Today a plan task is a few hundred bytes whatever
+the plan size (§5.5.1). Capping the manifests named by one plan task bounds 
it, at
+the price of uneven batches.
+
+#### 8.5.4 The work that is not a refactor
+
+The data side is buildable on public API: `ManifestFiles.read`,
+`ManifestReader.filterRows`/`select`/`caseSensitive`, and `ResidualEvaluator`.
+
+Delete attachment is not. `DeleteFileIndex`, which matches delete files to data
+files by partition and sequence number, is package-private in `iceberg-core`, 
so
+this design would have to reimplement those rules - equality deletes, 
positional
+deletes, deletion vectors - where a subtle mistake means returning deleted 
rows.
+
+A staged first cut avoids that risk while taking most of the win: use entry 
ranges
+when the pinned snapshot has no delete manifests, and fall back to the 
re-planning
+path in §5.5 when it does. Copy-on-write tables, which are the common case and 
the
+ones with the largest plans, get redemption proportional to the batch 
immediately;
+merge-on-read keeps today's behaviour until Iceberg exposes delete indexing or 
the
+rules are ported deliberately, with tests.
+
+#### 8.5.5 Why this need not block the current change
+
+A plan task is opaque and nothing persists it: no client stores one, and none
+outlives the scan that produced it. Its encoding can therefore change in any 
later
+release without a migration or a compatibility flag, which is what makes the
+offset form a reasonable thing to ship first and this a follow-up rather than a
+prerequisite.
+
+---
+
+## 9. Task Breakdown
+
+Delivered in [#12194](https://github.com/apache/gravitino/pull/12194):
+
+- [x] Add `fetchScanTasks` to `IcebergTableOperationDispatcher` and implement 
it in `IcebergTableOperationExecutor`
+- [x] Add the `POST {table}/tasks` endpoint in `IcebergTableOperations` with 
metrics and authorization
+- [x] Map `NoSuchPlanTaskException` to `404` in `IcebergExceptionMapper`
+- [x] Advertise `Endpoint.V1_FETCH_TABLE_SCAN_PLAN_TASKS` in 
`IcebergConfigOperations`
+- [x] Add `IcebergFetchScanTasks{Pre,,Failure}Event` and 
`OperationType.FETCH_SCAN_TASKS`, and wire them in 
`IcebergTableEventDispatcher` and `IcebergTableHookDispatcher`
+- [x] Add `PlanTaskCodec` (encode, decode, table match)
+- [x] Add `scan-plan-task-batch-size` to `IcebergConstants` and `IcebergConfig`
+- [x] Split plan responses into inline tasks plus `plan-tasks` in 
`CatalogWrapperForREST.planTableScan`
+- [x] Pin the planned snapshot and order planned tasks totally
+- [x] Resolve a plan task to its batch in 
`CatalogWrapperForREST.fetchScanTasks`
+- [x] Forward `POST {table}/tasks` upstream in `FederatedCatalogWrapper`
+- [x] Unit tests: `TestPlanTaskCodec`, `TestScanPlanTaskBatching`
+- [x] REST tests: `TestIcebergFetchScanTasksEndpoint`, 
`TestIcebergTableOperations`, `TestIcebergConfig`
+- [x] Federation test in `TestCatalogWrapperForREST`
+- [x] Document the endpoint, plan task semantics and the new property in 
`docs/iceberg-rest-service.md`
+
+Follow-ups, each its own issue:
+
+- [ ] Decide whether the scan plan cache should be enabled by default (§8.1)
+- [ ] Coalesce concurrent redemptions of one plan on a replica, so the 
concurrent fetches of one scan cost one plan (§5.15)
+- [ ] Implement manifest entry-range plan tasks, copy-on-write first (§8.5), 
and raise the delete-indexing gap with Iceberg upstream
+- [ ] Integration test with pyiceberg `scan-planning-mode=server` against a 
multi-batch table
+- [ ] Honor `min-rows-requested` when sizing batches (Non-Goal 2)
+- [ ] Ship a shared `ScanPlanCache` implementation so plan task redemption 
avoids re-planning across replicas (Non-Goal 3)
+- [ ] Revisit the default batch size if real workloads show 100 causing 
problems (§8.2)

Reply via email to