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 d2f49758be [#11284] feat(iceberg-rest): Add the fetch scan tasks 
endpoint and its plumbing (#12412)
d2f49758be is described below

commit d2f49758be39a62bf6b2e18e5639dbc783296a49
Author: Akshay Thorat <[email protected]>
AuthorDate: Tue Sep 22 23:43:27 2026 -0700

    [#11284] feat(iceberg-rest): Add the fetch scan tasks endpoint and its 
plumbing (#12412)
    
    ### What changes were proposed in this pull request?
    
    This is PR 2 of the stack that splits #12194, as agreed with @nevzheng
    
([plan](https://github.com/apache/gravitino/pull/12194#issuecomment-5228220890)).
    PR 1 is #12411.
    
    It wires up `POST
    /v1/{prefix}/namespaces/{namespace}/tables/{table}/tasks`, the second
    step of the Iceberg REST two-step scan planning protocol, without yet
    implementing what it hands back:
    
    - `IcebergTableOperations.fetchScanTasks` - the JAX-RS resource, with
    the same authorization expression as `POST .../plan` since it is the
    same read of the same table.
    - `fetchScanTasks` through the table dispatcher chain:
    `IcebergTableOperationDispatcher` (interface),
    `IcebergTableEventDispatcher` (events), `IcebergTableHookDispatcher`
    (pass-through, the operation is read-only),
    `IcebergTableOperationExecutor` (delegation to the catalog wrapper).
    - `OperationType.FETCH_SCAN_TASKS` and
    `AuditLog.Operation.FETCH_SCAN_TASKS`, with
    `IcebergFetchScanTasksPreEvent`, `IcebergFetchScanTasksEvent` and
    `IcebergFetchScanTasksFailureEvent`.
    - `NoSuchPlanTaskException` mapped to `404` in `IcebergExceptionMapper`.
    
    `CatalogWrapperForREST.planTableScan` still returns every file scan task
    inline and hands out no `plan-tasks`, so no plan task presented to this
    endpoint was issued by this server, and
    `CatalogWrapperForREST.fetchScanTasks` rejects every one of them as
    unknown. Two deliberate consequences:
    
    - **The endpoint is not advertised in `/v1/config`.** Clients gate on
    the advertised endpoint set rather than probing - pyiceberg enables
    server-side scan planning when it sees `POST .../tasks` there, and fails
    the scan if the endpoint then serves nothing. Advertising it before it
    can return tasks would be worse than not having it. From a client's
    point of view this PR leaves behaviour exactly as it is today.
    - **The success path of the endpoint arrives with batching**, in the
    last PR of the stack, together with `TestIcebergFetchScanTasksEndpoint`
    which exercises it end to end. I checked that `FetchScanTasksResponse`
    is already covered by `RESTSerializers.registerAll`, so the response
    serialization this PR leaves untested is not an open question.
    
    One change here is not strictly about `/tasks`: both scan planning
    endpoints now report `400` instead of `500` when called with no request
    body. Jersey passes the resource method a `null` entity, which became an
    NPE downstream. `/tasks` needed the guard, and applying the same three
    lines to `/plan` seemed better than leaving the neighbouring endpoint
    wrong. Happy to drop it into its own PR if you would rather keep this
    one single-purpose.
    
    ### Why are the changes needed?
    
    #11284 asks for the fetch scan tasks endpoint. Reviewers on #12194 asked
    for that work in reviewable pieces; this is the plumbing piece,
    separated from the batching logic that gives it something to return.
    
    Fix: #11284
    
    ### Does this PR introduce _any_ user-facing change?
    
    A new REST route exists but is not advertised, and answers `404
    NoSuchPlanTaskException` for any plan task. No new configuration
    properties. The only behaviour change a user can observe is that `POST
    .../plan` and `POST .../tasks` with an empty body now return `400`
    instead of `500`.
    
    ### How was this patch tested?
    
    `./gradlew :iceberg:iceberg-rest-server:check -PskipITs` and `./gradlew
    :core:test --tests "*TestCompatibilityUtils*"`, both green.
    
    Three tests added to `TestIcebergTableOperations`, each run against both
    a flat and a nested namespace:
    
    - `testFetchScanTasksUnknownPlanTask` - a plan task this server never
    issued is a `404`. Asserts on the error payload (`type` is
    `NoSuchPlanTaskException`, message names the rejected plan task), not
    just the status, so it cannot pass against an unregistered route.
    Asserts the pre event and the failure event are dispatched.
    - `testFetchScanTasksTableNotFound` - a missing table reports
    `NoSuchTableException`, not a masked unknown plan task.
    - `testScanPlanningEndpointsRejectMissingRequestBody` - an empty body on
    `/plan` and on `/tasks` is a `400`.
---
 .../java/org/apache/gravitino/audit/AuditLog.java  |  2 +
 .../gravitino/audit/v2/CompatibilityUtils.java     |  1 +
 .../listener/api/event/OperationType.java          |  1 +
 .../gravitino/audit/v2/TestCompatibilityUtils.java |  1 +
 .../iceberg/service/CatalogWrapperForREST.java     | 31 +++++++++
 .../iceberg/service/IcebergExceptionMapper.java    |  2 +
 .../dispatcher/IcebergTableEventDispatcher.java    | 35 ++++++++++
 .../dispatcher/IcebergTableHookDispatcher.java     | 19 ++++++
 .../IcebergTableOperationDispatcher.java           | 16 +++++
 .../dispatcher/IcebergTableOperationExecutor.java  | 12 ++++
 .../service/rest/IcebergTableOperations.java       | 77 +++++++++++++++++++++
 .../api/event/IcebergFetchScanTasksEvent.java      | 37 ++++++++++
 .../event/IcebergFetchScanTasksFailureEvent.java   | 37 ++++++++++
 .../api/event/IcebergFetchScanTasksPreEvent.java   | 37 ++++++++++
 .../service/rest/TestIcebergTableOperations.java   | 79 ++++++++++++++++++++++
 15 files changed, 387 insertions(+)

diff --git a/core/src/main/java/org/apache/gravitino/audit/AuditLog.java 
b/core/src/main/java/org/apache/gravitino/audit/AuditLog.java
index 070941a750..42168a24e4 100644
--- a/core/src/main/java/org/apache/gravitino/audit/AuditLog.java
+++ b/core/src/main/java/org/apache/gravitino/audit/AuditLog.java
@@ -266,6 +266,8 @@ public interface AuditLog {
 
     PLAN_TABLE_SCAN,
 
+    FETCH_SCAN_TASKS,
+
     PURGE_TABLE,
 
     LIST_TABLE,
diff --git 
a/core/src/main/java/org/apache/gravitino/audit/v2/CompatibilityUtils.java 
b/core/src/main/java/org/apache/gravitino/audit/v2/CompatibilityUtils.java
index c968dc3c6e..fea95fa9e5 100644
--- a/core/src/main/java/org/apache/gravitino/audit/v2/CompatibilityUtils.java
+++ b/core/src/main/java/org/apache/gravitino/audit/v2/CompatibilityUtils.java
@@ -55,6 +55,7 @@ public class CompatibilityUtils {
           .put(OperationType.LOAD_TABLE, Operation.LOAD_TABLE)
           .put(OperationType.LOAD_TABLE_CREDENTIAL, 
Operation.LOAD_TABLE_CREDENTIAL)
           .put(OperationType.PLAN_TABLE_SCAN, Operation.PLAN_TABLE_SCAN)
+          .put(OperationType.FETCH_SCAN_TASKS, Operation.FETCH_SCAN_TASKS)
           .put(OperationType.LIST_TABLE, Operation.LIST_TABLE)
           .put(OperationType.ALTER_TABLE, Operation.ALTER_TABLE)
           .put(OperationType.RENAME_TABLE, Operation.RENAME_TABLE)
diff --git 
a/core/src/main/java/org/apache/gravitino/listener/api/event/OperationType.java 
b/core/src/main/java/org/apache/gravitino/listener/api/event/OperationType.java
index d3bd1aa697..1a2e7dd0ca 100644
--- 
a/core/src/main/java/org/apache/gravitino/listener/api/event/OperationType.java
+++ 
b/core/src/main/java/org/apache/gravitino/listener/api/event/OperationType.java
@@ -27,6 +27,7 @@ public enum OperationType {
   LOAD_TABLE,
   LOAD_TABLE_CREDENTIAL,
   PLAN_TABLE_SCAN,
+  FETCH_SCAN_TASKS,
   LIST_TABLE,
   ALTER_TABLE,
   RENAME_TABLE,
diff --git 
a/core/src/test/java/org/apache/gravitino/audit/v2/TestCompatibilityUtils.java 
b/core/src/test/java/org/apache/gravitino/audit/v2/TestCompatibilityUtils.java
index 8cb300de4f..9793591cdd 100644
--- 
a/core/src/test/java/org/apache/gravitino/audit/v2/TestCompatibilityUtils.java
+++ 
b/core/src/test/java/org/apache/gravitino/audit/v2/TestCompatibilityUtils.java
@@ -62,6 +62,7 @@ public class TestCompatibilityUtils {
       {OperationType.LOAD_TABLE, Operation.LOAD_TABLE},
       {OperationType.LOAD_TABLE_CREDENTIAL, Operation.LOAD_TABLE_CREDENTIAL},
       {OperationType.PLAN_TABLE_SCAN, Operation.PLAN_TABLE_SCAN},
+      {OperationType.FETCH_SCAN_TASKS, Operation.FETCH_SCAN_TASKS},
       {OperationType.TABLE_EXISTS, Operation.TABLE_EXISTS},
       {OperationType.LIST_TABLE, Operation.LIST_TABLE},
       {OperationType.RENAME_TABLE, Operation.RENAME_TABLE},
diff --git 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/CatalogWrapperForREST.java
 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/CatalogWrapperForREST.java
index c58547e4fc..64d5f090c0 100644
--- 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/CatalogWrapperForREST.java
+++ 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/CatalogWrapperForREST.java
@@ -59,13 +59,16 @@ import org.apache.iceberg.TableProperties;
 import org.apache.iceberg.TableScan;
 import org.apache.iceberg.catalog.Namespace;
 import org.apache.iceberg.catalog.TableIdentifier;
+import org.apache.iceberg.exceptions.NoSuchPlanTaskException;
 import org.apache.iceberg.exceptions.ServiceUnavailableException;
 import org.apache.iceberg.io.CloseableIterable;
 import org.apache.iceberg.rest.CatalogHandlers;
 import org.apache.iceberg.rest.PlanStatus;
 import org.apache.iceberg.rest.requests.CreateTableRequest;
+import org.apache.iceberg.rest.requests.FetchScanTasksRequest;
 import org.apache.iceberg.rest.requests.PlanTableScanRequest;
 import org.apache.iceberg.rest.requests.RegisterTableRequest;
+import org.apache.iceberg.rest.responses.FetchScanTasksResponse;
 import org.apache.iceberg.rest.responses.ImmutableLoadCredentialsResponse;
 import org.apache.iceberg.rest.responses.LoadCredentialsResponse;
 import org.apache.iceberg.rest.responses.LoadTableResponse;
@@ -498,6 +501,34 @@ public class CatalogWrapperForREST extends 
IcebergCatalogWrapper {
     }
   }
 
+  /**
+   * Fetch the scan tasks covered by a {@code plan-task} previously handed out 
by {@link
+   * #planTableScan}, completing the second step of the Iceberg REST two-step 
scan planning
+   * protocol.
+   *
+   * <p>{@link #planTableScan} currently returns every file scan task inline 
and hands out no {@code
+   * plan-tasks}, so no plan task presented here was issued by this server and 
every request is
+   * rejected as unknown. The endpoint is deliberately not advertised in 
{@code /v1/config} while
+   * that is the case, so a spec-compliant client never reaches it. Batching a 
plan into plan tasks,
+   * and redeeming them here, follows in a later change.
+   *
+   * @param tableIdentifier the table the plan task belongs to.
+   * @param request the request carrying the {@code plan-task}.
+   * @return the file scan tasks the plan task covers.
+   * @throws org.apache.iceberg.exceptions.NoSuchTableException if the table 
doesn't exist.
+   * @throws NoSuchPlanTaskException if the plan task was not issued for this 
table.
+   */
+  public FetchScanTasksResponse fetchScanTasks(
+      TableIdentifier tableIdentifier, FetchScanTasksRequest request) {
+    // Validate the table exists first, so a bad table reports 404 for the 
table rather than
+    // masking it as an unknown plan task. Consistent with planTableScan 
behavior.
+    getCatalog().loadTable(tableIdentifier);
+
+    LOG.info("Rejecting unknown plan task '{}' for table {}", 
request.planTask(), tableIdentifier);
+    throw new NoSuchPlanTaskException(
+        "Plan task %s was not issued for table %s", request.planTask(), 
tableIdentifier);
+  }
+
   /**
    * Inject vended credentials into a scan response using the already-loaded 
table, avoiding a
    * redundant {@code loadTable} call. Follows the same eligibility logic as 
{@link
diff --git 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/IcebergExceptionMapper.java
 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/IcebergExceptionMapper.java
index 940bf785e7..b22fe5b407 100644
--- 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/IcebergExceptionMapper.java
+++ 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/IcebergExceptionMapper.java
@@ -38,6 +38,7 @@ import org.apache.iceberg.exceptions.ForbiddenException;
 import org.apache.iceberg.exceptions.NamespaceNotEmptyException;
 import org.apache.iceberg.exceptions.NoSuchIcebergTableException;
 import org.apache.iceberg.exceptions.NoSuchNamespaceException;
+import org.apache.iceberg.exceptions.NoSuchPlanTaskException;
 import org.apache.iceberg.exceptions.NoSuchTableException;
 import org.apache.iceberg.exceptions.NoSuchViewException;
 import org.apache.iceberg.exceptions.NotAuthorizedException;
@@ -73,6 +74,7 @@ public class IcebergExceptionMapper implements 
ExceptionMapper<Throwable> {
           .put(NoSuchTableException.class, 404)
           .put(NoSuchIcebergTableException.class, 404)
           .put(NoSuchCatalogException.class, 404)
+          .put(NoSuchPlanTaskException.class, 404)
           .put(UnsupportedOperationException.class, 406)
           .put(NoSuchViewException.class, 404)
           .put(AlreadyExistsException.class, 409)
diff --git 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/IcebergTableEventDispatcher.java
 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/IcebergTableEventDispatcher.java
index 7482504625..dc05033e59 100644
--- 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/IcebergTableEventDispatcher.java
+++ 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/IcebergTableEventDispatcher.java
@@ -32,6 +32,9 @@ import 
org.apache.gravitino.listener.api.event.IcebergCreateTablePreEvent;
 import org.apache.gravitino.listener.api.event.IcebergDropTableEvent;
 import org.apache.gravitino.listener.api.event.IcebergDropTableFailureEvent;
 import org.apache.gravitino.listener.api.event.IcebergDropTablePreEvent;
+import org.apache.gravitino.listener.api.event.IcebergFetchScanTasksEvent;
+import 
org.apache.gravitino.listener.api.event.IcebergFetchScanTasksFailureEvent;
+import org.apache.gravitino.listener.api.event.IcebergFetchScanTasksPreEvent;
 import org.apache.gravitino.listener.api.event.IcebergListTableEvent;
 import org.apache.gravitino.listener.api.event.IcebergListTableFailureEvent;
 import org.apache.gravitino.listener.api.event.IcebergListTablePreEvent;
@@ -58,9 +61,11 @@ import org.apache.gravitino.utils.RequestContext;
 import org.apache.iceberg.catalog.Namespace;
 import org.apache.iceberg.catalog.TableIdentifier;
 import org.apache.iceberg.rest.requests.CreateTableRequest;
+import org.apache.iceberg.rest.requests.FetchScanTasksRequest;
 import org.apache.iceberg.rest.requests.PlanTableScanRequest;
 import org.apache.iceberg.rest.requests.RenameTableRequest;
 import org.apache.iceberg.rest.requests.UpdateTableRequest;
+import org.apache.iceberg.rest.responses.FetchScanTasksResponse;
 import org.apache.iceberg.rest.responses.ListTablesResponse;
 import org.apache.iceberg.rest.responses.LoadCredentialsResponse;
 import org.apache.iceberg.rest.responses.LoadTableResponse;
@@ -320,6 +325,36 @@ public class IcebergTableEventDispatcher implements 
IcebergTableOperationDispatc
     return planTableScanResponse;
   }
 
+  /**
+   * Fetch the scan tasks for a {@code plan-task} returned by a prior scan 
plan.
+   *
+   * @param context Iceberg REST request context information.
+   * @param tableIdentifier The Iceberg table identifier.
+   * @param request The request carrying the {@code plan-task}.
+   * @return A FetchScanTasksResponse containing the scan tasks for that plan 
task
+   */
+  @Override
+  public FetchScanTasksResponse fetchScanTasks(
+      IcebergRequestContext context,
+      TableIdentifier tableIdentifier,
+      FetchScanTasksRequest request) {
+    NameIdentifier gravitinoNameIdentifier =
+        IcebergRESTUtils.getGravitinoNameIdentifier(
+            metalakeName, context.catalogName(), tableIdentifier);
+    eventBus.dispatchEvent(new IcebergFetchScanTasksPreEvent(context, 
gravitinoNameIdentifier));
+    FetchScanTasksResponse fetchScanTasksResponse;
+    try {
+      fetchScanTasksResponse =
+          icebergTableOperationDispatcher.fetchScanTasks(context, 
tableIdentifier, request);
+    } catch (Exception e) {
+      eventBus.dispatchEvent(
+          new IcebergFetchScanTasksFailureEvent(context, 
gravitinoNameIdentifier, e));
+      throw e;
+    }
+    eventBus.dispatchEvent(new IcebergFetchScanTasksEvent(context, 
gravitinoNameIdentifier));
+    return fetchScanTasksResponse;
+  }
+
   @Override
   public Optional<String> getTableMetadataLocation(
       IcebergRequestContext context, TableIdentifier tableIdentifier) {
diff --git 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/IcebergTableHookDispatcher.java
 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/IcebergTableHookDispatcher.java
index bf95e0a5a3..d804c14c14 100644
--- 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/IcebergTableHookDispatcher.java
+++ 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/IcebergTableHookDispatcher.java
@@ -38,9 +38,11 @@ import org.apache.iceberg.UpdateRequirement;
 import org.apache.iceberg.catalog.Namespace;
 import org.apache.iceberg.catalog.TableIdentifier;
 import org.apache.iceberg.rest.requests.CreateTableRequest;
+import org.apache.iceberg.rest.requests.FetchScanTasksRequest;
 import org.apache.iceberg.rest.requests.PlanTableScanRequest;
 import org.apache.iceberg.rest.requests.RenameTableRequest;
 import org.apache.iceberg.rest.requests.UpdateTableRequest;
+import org.apache.iceberg.rest.responses.FetchScanTasksResponse;
 import org.apache.iceberg.rest.responses.ListTablesResponse;
 import org.apache.iceberg.rest.responses.LoadCredentialsResponse;
 import org.apache.iceberg.rest.responses.LoadTableResponse;
@@ -197,6 +199,23 @@ public class IcebergTableHookDispatcher implements 
IcebergTableOperationDispatch
     return dispatcher.planTableScan(context, tableIdentifier, scanRequest);
   }
 
+  /**
+   * Fetch the scan tasks for a {@code plan-task} returned by a prior scan 
plan. Read-only, so no
+   * hooks are needed and the call is passed straight through.
+   *
+   * @param context Iceberg REST request context information.
+   * @param tableIdentifier The Iceberg table identifier.
+   * @param request The request carrying the {@code plan-task}.
+   * @return A FetchScanTasksResponse containing the scan tasks for that plan 
task.
+   */
+  @Override
+  public FetchScanTasksResponse fetchScanTasks(
+      IcebergRequestContext context,
+      TableIdentifier tableIdentifier,
+      FetchScanTasksRequest request) {
+    return dispatcher.fetchScanTasks(context, tableIdentifier, request);
+  }
+
   @Override
   public Optional<String> getTableMetadataLocation(
       IcebergRequestContext context, TableIdentifier tableIdentifier) {
diff --git 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/IcebergTableOperationDispatcher.java
 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/IcebergTableOperationDispatcher.java
index 5a90842dbb..c05173621a 100644
--- 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/IcebergTableOperationDispatcher.java
+++ 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/IcebergTableOperationDispatcher.java
@@ -24,9 +24,11 @@ import 
org.apache.gravitino.listener.api.event.IcebergRequestContext;
 import org.apache.iceberg.catalog.Namespace;
 import org.apache.iceberg.catalog.TableIdentifier;
 import org.apache.iceberg.rest.requests.CreateTableRequest;
+import org.apache.iceberg.rest.requests.FetchScanTasksRequest;
 import org.apache.iceberg.rest.requests.PlanTableScanRequest;
 import org.apache.iceberg.rest.requests.RenameTableRequest;
 import org.apache.iceberg.rest.requests.UpdateTableRequest;
+import org.apache.iceberg.rest.responses.FetchScanTasksResponse;
 import org.apache.iceberg.rest.responses.ListTablesResponse;
 import org.apache.iceberg.rest.responses.LoadCredentialsResponse;
 import org.apache.iceberg.rest.responses.LoadTableResponse;
@@ -135,6 +137,20 @@ public interface IcebergTableOperationDispatcher {
       TableIdentifier tableIdentifier,
       PlanTableScanRequest scanRequest);
 
+  /**
+   * Fetch the scan tasks for a {@code plan-task} returned by a prior {@link 
#planTableScan} call,
+   * completing the second step of the Iceberg REST two-step scan planning 
protocol.
+   *
+   * @param context Iceberg REST request context information.
+   * @param tableIdentifier The Iceberg table identifier.
+   * @param request The request carrying the {@code plan-task}.
+   * @return A {@link FetchScanTasksResponse} containing the scan tasks for 
that plan task.
+   */
+  FetchScanTasksResponse fetchScanTasks(
+      IcebergRequestContext context,
+      TableIdentifier tableIdentifier,
+      FetchScanTasksRequest request);
+
   /**
    * Retrieves the metadata file location for a table without loading full 
table metadata. This is
    * an optional fast path for catalogs that support cheap metadata location 
retrieval.
diff --git 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/IcebergTableOperationExecutor.java
 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/IcebergTableOperationExecutor.java
index 954d2a8c27..5a8a21d71e 100644
--- 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/IcebergTableOperationExecutor.java
+++ 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/IcebergTableOperationExecutor.java
@@ -41,9 +41,11 @@ import org.apache.iceberg.TableMetadata;
 import org.apache.iceberg.catalog.Namespace;
 import org.apache.iceberg.catalog.TableIdentifier;
 import org.apache.iceberg.rest.requests.CreateTableRequest;
+import org.apache.iceberg.rest.requests.FetchScanTasksRequest;
 import org.apache.iceberg.rest.requests.PlanTableScanRequest;
 import org.apache.iceberg.rest.requests.RenameTableRequest;
 import org.apache.iceberg.rest.requests.UpdateTableRequest;
+import org.apache.iceberg.rest.responses.FetchScanTasksResponse;
 import org.apache.iceberg.rest.responses.ListTablesResponse;
 import org.apache.iceberg.rest.responses.LoadCredentialsResponse;
 import org.apache.iceberg.rest.responses.LoadTableResponse;
@@ -231,6 +233,16 @@ public class IcebergTableOperationExecutor implements 
IcebergTableOperationDispa
         .planTableScan(tableIdentifier, scanRequest, 
context.requestCredentialVending(), privilege);
   }
 
+  @Override
+  public FetchScanTasksResponse fetchScanTasks(
+      IcebergRequestContext context,
+      TableIdentifier tableIdentifier,
+      FetchScanTasksRequest request) {
+    return icebergCatalogWrapperManager
+        .getCatalogWrapper(context.catalogName())
+        .fetchScanTasks(tableIdentifier, request);
+  }
+
   @Override
   public Optional<String> getTableMetadataLocation(
       IcebergRequestContext context, TableIdentifier tableIdentifier) {
diff --git 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/rest/IcebergTableOperations.java
 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/rest/IcebergTableOperations.java
index 6f7fb4d80f..a5d2998b13 100644
--- 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/rest/IcebergTableOperations.java
+++ 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/rest/IcebergTableOperations.java
@@ -79,9 +79,11 @@ import org.apache.iceberg.catalog.Namespace;
 import org.apache.iceberg.catalog.TableIdentifier;
 import org.apache.iceberg.rest.RESTUtil;
 import org.apache.iceberg.rest.requests.CreateTableRequest;
+import org.apache.iceberg.rest.requests.FetchScanTasksRequest;
 import org.apache.iceberg.rest.requests.PlanTableScanRequest;
 import org.apache.iceberg.rest.requests.ReportMetricsRequest;
 import org.apache.iceberg.rest.requests.UpdateTableRequest;
+import org.apache.iceberg.rest.responses.FetchScanTasksResponse;
 import org.apache.iceberg.rest.responses.ListTablesResponse;
 import org.apache.iceberg.rest.responses.LoadCredentialsResponse;
 import org.apache.iceberg.rest.responses.LoadTableResponse;
@@ -518,6 +520,9 @@ public class IcebergTableOperations {
       @Encoded() @PathParam("table") @AuthorizationMetadata(type = 
EntityType.TABLE) String table,
       PlanTableScanRequest scanRequest,
       @HeaderParam(X_ICEBERG_ACCESS_DELEGATION) String accessDelegation) {
+    if (scanRequest == null) {
+      return missingRequestBody("plan table scan");
+    }
     boolean isCredentialVending = isCredentialVending(accessDelegation);
     String catalogName = IcebergRESTUtils.getCatalogName(prefix);
     Namespace icebergNS =
@@ -551,6 +556,68 @@ public class IcebergTableOperations {
     }
   }
 
+  /**
+   * Fetch scan tasks endpoint. Completes the second step of the Iceberg REST 
two-step scan planning
+   * protocol: a client exchanges a {@code plan-task} handed out by {@code POST
+   * .../tables/{table}/plan} for the scan tasks it covers.
+   *
+   * @param prefix The catalog prefix
+   * @param namespace The namespace
+   * @param table The table name
+   * @param request The request containing the {@code plan-task}
+   * @return Response containing the scan tasks for the given plan task
+   */
+  @POST
+  @Path("{table}/tasks")
+  @Produces(MediaType.APPLICATION_JSON)
+  @Consumes(MediaType.APPLICATION_JSON)
+  @Timed(name = "fetch-scan-tasks." + MetricNames.HTTP_PROCESS_DURATION, 
absolute = true)
+  @ResponseMetered(name = "fetch-scan-tasks", absolute = true)
+  @AuthorizationExpression(
+      expression =
+          "ANY(OWNER, METALAKE, CATALOG) || "
+              + "SCHEMA_OWNER_WITH_USE_CATALOG || "
+              + "ANY_USE_CATALOG && ANY_USE_SCHEMA && (TABLE::OWNER || 
ANY_SELECT_TABLE || ANY_MODIFY_TABLE)",
+      accessMetadataType = MetadataObject.Type.TABLE)
+  public Response fetchScanTasks(
+      @PathParam("prefix") @AuthorizationMetadata(type = EntityType.CATALOG) 
String prefix,
+      @Encoded() @PathParam("namespace") @AuthorizationMetadata(type = 
EntityType.SCHEMA)
+          String namespace,
+      @Encoded() @PathParam("table") @AuthorizationMetadata(type = 
EntityType.TABLE) String table,
+      FetchScanTasksRequest request) {
+    if (request == null) {
+      return missingRequestBody("fetch scan tasks");
+    }
+    String catalogName = IcebergRESTUtils.getCatalogName(prefix);
+    Namespace icebergNS =
+        RESTUtil.decodeNamespace(namespace, 
IcebergRESTUtils.NAMESPACE_SEPARATOR_URLENCODED_UTF_8);
+    String tableName = RESTUtil.decodeString(table);
+    LOG.info(
+        "Fetch scan tasks, catalog: {}, namespace: {}, table: {}, planTask: 
{}",
+        catalogName,
+        icebergNS,
+        tableName,
+        request.planTask());
+
+    try {
+      return Utils.doAs(
+          httpRequest,
+          () -> {
+            TableIdentifier tableIdentifier = TableIdentifier.of(icebergNS, 
tableName);
+            IcebergRequestContext context =
+                new IcebergRequestContext(httpServletRequest(), catalogName);
+
+            FetchScanTasksResponse response =
+                tableOperationDispatcher.fetchScanTasks(context, 
tableIdentifier, request);
+
+            return IcebergRESTUtils.ok(response);
+          });
+    } catch (Exception e) {
+      LOG.error("Failed to fetch scan tasks: {}", e.getMessage(), e);
+      return IcebergExceptionMapper.toRESTResponse(e);
+    }
+  }
+
   /**
    * Filters the {@link LoadTableResponse} to include only snapshots that are 
directly referenced by
    * the table's refs (branches and tags). This implements the {@code 
snapshots=refs} query
@@ -585,6 +652,16 @@ public class IcebergTableOperations {
     return builder.build();
   }
 
+  /**
+   * Builds a 400 response for a request that arrived without a body. Jersey 
passes a {@code null}
+   * entity when the body is absent, which would otherwise surface as a 500 
from a downstream NPE
+   * even though the Iceberg REST specification expects a 400 for a malformed 
request.
+   */
+  private static Response missingRequestBody(String operation) {
+    return IcebergExceptionMapper.toRESTResponse(
+        new IllegalArgumentException("Missing request body for " + operation));
+  }
+
   private static Response buildResponseWithETag(LoadTableResponse 
loadTableResponse) {
     return IcebergRESTUtils.buildResponseWithETag(loadTableResponse);
   }
diff --git 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/listener/api/event/IcebergFetchScanTasksEvent.java
 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/listener/api/event/IcebergFetchScanTasksEvent.java
new file mode 100644
index 0000000000..94a82a6774
--- /dev/null
+++ 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/listener/api/event/IcebergFetchScanTasksEvent.java
@@ -0,0 +1,37 @@
+/*
+ *  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.
+ */
+
+package org.apache.gravitino.listener.api.event;
+
+import org.apache.gravitino.NameIdentifier;
+import org.apache.gravitino.annotation.DeveloperApi;
+
+/** Represent an event after fetching Iceberg scan tasks for a plan task 
successfully. */
+@DeveloperApi
+public class IcebergFetchScanTasksEvent extends IcebergTableEvent {
+  public IcebergFetchScanTasksEvent(
+      IcebergRequestContext icebergRequestContext, NameIdentifier 
resourceIdentifier) {
+    super(icebergRequestContext, resourceIdentifier);
+  }
+
+  @Override
+  public OperationType operationType() {
+    return OperationType.FETCH_SCAN_TASKS;
+  }
+}
diff --git 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/listener/api/event/IcebergFetchScanTasksFailureEvent.java
 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/listener/api/event/IcebergFetchScanTasksFailureEvent.java
new file mode 100644
index 0000000000..594554f97d
--- /dev/null
+++ 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/listener/api/event/IcebergFetchScanTasksFailureEvent.java
@@ -0,0 +1,37 @@
+/*
+ *  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.
+ */
+
+package org.apache.gravitino.listener.api.event;
+
+import org.apache.gravitino.NameIdentifier;
+import org.apache.gravitino.annotation.DeveloperApi;
+
+/** Represent a failure event when fetching Iceberg scan tasks for a plan task 
failed. */
+@DeveloperApi
+public class IcebergFetchScanTasksFailureEvent extends 
IcebergTableFailureEvent {
+  public IcebergFetchScanTasksFailureEvent(
+      IcebergRequestContext icebergRequestContext, NameIdentifier 
nameIdentifier, Exception e) {
+    super(icebergRequestContext, nameIdentifier, e);
+  }
+
+  @Override
+  public OperationType operationType() {
+    return OperationType.FETCH_SCAN_TASKS;
+  }
+}
diff --git 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/listener/api/event/IcebergFetchScanTasksPreEvent.java
 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/listener/api/event/IcebergFetchScanTasksPreEvent.java
new file mode 100644
index 0000000000..5d92bb255e
--- /dev/null
+++ 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/listener/api/event/IcebergFetchScanTasksPreEvent.java
@@ -0,0 +1,37 @@
+/*
+ *  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.
+ */
+
+package org.apache.gravitino.listener.api.event;
+
+import org.apache.gravitino.NameIdentifier;
+import org.apache.gravitino.annotation.DeveloperApi;
+
+/** Represent a pre event before fetching Iceberg scan tasks for a plan task. 
*/
+@DeveloperApi
+public class IcebergFetchScanTasksPreEvent extends IcebergTablePreEvent {
+  public IcebergFetchScanTasksPreEvent(
+      IcebergRequestContext icebergRequestContext, NameIdentifier 
tableIdentifier) {
+    super(icebergRequestContext, tableIdentifier);
+  }
+
+  @Override
+  public OperationType operationType() {
+    return OperationType.FETCH_SCAN_TASKS;
+  }
+}
diff --git 
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/rest/TestIcebergTableOperations.java
 
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/rest/TestIcebergTableOperations.java
index c27d0a2f2b..db0610e59f 100644
--- 
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/rest/TestIcebergTableOperations.java
+++ 
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/rest/TestIcebergTableOperations.java
@@ -46,6 +46,8 @@ import 
org.apache.gravitino.listener.api.event.IcebergCreateTablePreEvent;
 import org.apache.gravitino.listener.api.event.IcebergDropTableEvent;
 import org.apache.gravitino.listener.api.event.IcebergDropTableFailureEvent;
 import org.apache.gravitino.listener.api.event.IcebergDropTablePreEvent;
+import 
org.apache.gravitino.listener.api.event.IcebergFetchScanTasksFailureEvent;
+import org.apache.gravitino.listener.api.event.IcebergFetchScanTasksPreEvent;
 import org.apache.gravitino.listener.api.event.IcebergListTableEvent;
 import org.apache.gravitino.listener.api.event.IcebergListTableFailureEvent;
 import org.apache.gravitino.listener.api.event.IcebergListTablePreEvent;
@@ -84,10 +86,12 @@ import org.apache.iceberg.metrics.ImmutableCommitReport;
 import org.apache.iceberg.rest.PlanStatus;
 import org.apache.iceberg.rest.RESTUtil;
 import org.apache.iceberg.rest.requests.CreateTableRequest;
+import org.apache.iceberg.rest.requests.FetchScanTasksRequest;
 import org.apache.iceberg.rest.requests.PlanTableScanRequest;
 import org.apache.iceberg.rest.requests.RenameTableRequest;
 import org.apache.iceberg.rest.requests.ReportMetricsRequest;
 import org.apache.iceberg.rest.requests.UpdateTableRequest;
+import org.apache.iceberg.rest.responses.ErrorResponse;
 import org.apache.iceberg.rest.responses.ListTablesResponse;
 import org.apache.iceberg.rest.responses.LoadTableResponse;
 import org.apache.iceberg.types.Types.NestedField;
@@ -246,6 +250,76 @@ public class TestIcebergTableOperations extends 
IcebergNamespaceTestBase {
     Assertions.assertTrue(dummyEventListener.popPostEvent() instanceof 
IcebergPlanTableScanEvent);
   }
 
+  @ParameterizedTest
+  
@MethodSource("org.apache.gravitino.iceberg.service.rest.IcebergRestTestUtil#testNamespaces")
+  void testFetchScanTasksUnknownPlanTask(Namespace namespace) {
+    verifyCreateNamespaceSucc(namespace);
+    verifyCreateTableSucc(namespace, "fetch_tasks_table", true);
+
+    dummyEventListener.clearEvent();
+
+    // Scan planning hands out no plan tasks yet, so every plan task presented 
here is one this
+    // server never issued, reported as 404 per the Iceberg REST spec.
+    Response response =
+        doFetchScanTasks(
+            namespace, "fetch_tasks_table", new 
FetchScanTasksRequest("not-a-plan-task"));
+    Assertions.assertEquals(Status.NOT_FOUND.getStatusCode(), 
response.getStatus());
+
+    // Assert on the error payload, not just the status: an unregistered route 
would also yield
+    // 404, which would let this test pass without the endpoint existing.
+    ErrorResponse error = response.readEntity(ErrorResponse.class);
+    Assertions.assertEquals("NoSuchPlanTaskException", error.type());
+    Assertions.assertTrue(
+        error.message().contains("not-a-plan-task"),
+        "Error message should name the rejected plan task, but was: " + 
error.message());
+
+    Assertions.assertTrue(
+        dummyEventListener.popPreEvent() instanceof 
IcebergFetchScanTasksPreEvent);
+    Assertions.assertTrue(
+        dummyEventListener.popPostEvent() instanceof 
IcebergFetchScanTasksFailureEvent);
+  }
+
+  @ParameterizedTest
+  
@MethodSource("org.apache.gravitino.iceberg.service.rest.IcebergRestTestUtil#testNamespaces")
+  void testFetchScanTasksTableNotFound(Namespace namespace) {
+    verifyCreateNamespaceSucc(namespace);
+    dummyEventListener.clearEvent();
+
+    // A missing table is reported as a missing table, not masked as an 
unknown plan task.
+    Response response =
+        doFetchScanTasks(namespace, "missing_table", new 
FetchScanTasksRequest("any-plan-task"));
+    Assertions.assertEquals(Status.NOT_FOUND.getStatusCode(), 
response.getStatus());
+
+    ErrorResponse error = response.readEntity(ErrorResponse.class);
+    Assertions.assertEquals("NoSuchTableException", error.type());
+
+    Assertions.assertTrue(
+        dummyEventListener.popPreEvent() instanceof 
IcebergFetchScanTasksPreEvent);
+    Assertions.assertTrue(
+        dummyEventListener.popPostEvent() instanceof 
IcebergFetchScanTasksFailureEvent);
+  }
+
+  @ParameterizedTest
+  
@MethodSource("org.apache.gravitino.iceberg.service.rest.IcebergRestTestUtil#testNamespaces")
+  void testScanPlanningEndpointsRejectMissingRequestBody(Namespace namespace) {
+    verifyCreateNamespaceSucc(namespace);
+    verifyCreateTableSucc(namespace, "empty_body_table", true);
+
+    // Jersey hands the resource method a null entity when the body is absent. 
Both scan planning
+    // endpoints must report that as a 400 rather than letting a downstream 
NPE become a 500.
+    for (String endpoint : new String[] {"plan", "tasks"}) {
+      Response response =
+          getTableClientBuilder(namespace, Optional.of("empty_body_table/" + 
endpoint))
+              .post(Entity.entity("", MediaType.APPLICATION_JSON_TYPE));
+      Assertions.assertEquals(
+          Status.BAD_REQUEST.getStatusCode(),
+          response.getStatus(),
+          "Empty body on /" + endpoint + " should be a 400, not a 500");
+    }
+    // No events are asserted here: the request is rejected at the REST 
boundary before it reaches
+    // the dispatcher chain, so no operation event is dispatched.
+  }
+
   @ParameterizedTest
   
@MethodSource("org.apache.gravitino.iceberg.service.rest.IcebergRestTestUtil#testNamespaces")
   void testDropTable(Namespace namespace) {
@@ -570,6 +644,11 @@ public class TestIcebergTableOperations extends 
IcebergNamespaceTestBase {
     return builder.post(Entity.entity(request, 
MediaType.APPLICATION_JSON_TYPE));
   }
 
+  private Response doFetchScanTasks(Namespace ns, String tableName, 
FetchScanTasksRequest request) {
+    Invocation.Builder builder = getTableClientBuilder(ns, 
Optional.of(tableName + "/tasks"));
+    return builder.post(Entity.entity(request, 
MediaType.APPLICATION_JSON_TYPE));
+  }
+
   private Response doUpdateTable(Namespace ns, String name, TableMetadata 
base) {
     TableMetadata newMetadata = base.updateSchema(newTableSchema);
     List<MetadataUpdate> metadataUpdates = newMetadata.changes();

Reply via email to