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 4a53719d7b [#12722] improvement(iceberg): let inner dispatchers 
contribute customInfo to Iceberg table events (#12723)
4a53719d7b is described below

commit 4a53719d7b8c0063297db180e90274d1abd85a83
Author: Nevin Zheng <[email protected]>
AuthorDate: Sat Aug 29 08:29:38 2026 -0700

    [#12722] improvement(iceberg): let inner dispatchers contribute customInfo 
to Iceberg table events (#12723)
    
    ## Summary
    
    An inner Iceberg REST dispatcher layer can now attach `Map<String,
    String>` audit facts to the terminal create/update/load event that
    `IcebergTableEventDispatcher` already emits, so one operation still
    produces one audit record and that record can say what the dispatch
    chain knew about it.
    
    This is the Iceberg REST counterpart of #12692. The change is
    deliberately confined to carrying facts. Dispatch semantics are
    untouched: events still go out through `eventBus.dispatchEvent`, and a
    listener exception propagates exactly as it does on `main`, whether or
    not extras were stashed. Listener-failure isolation is out of scope.
    
    ## Context
    
    Iceberg REST table events already expose `customInfo()`, but today that
    map is only the request HTTP headers (`IcebergEvent.customInfo()`
    returns `icebergRequestContext.httpHeaders()`). An inner dispatcher that
    applied a policy or enforced a validation had no way to get that fact
    into the terminal Iceberg create/update/load event the outer layer
    publishes.
    
    The layer's only options today are to publish a separate event, which
    breaks the one-operation-one-event shape audit consumers rely on, or to
    drop the fact. #12692 already landed the same hand-off for Gravitino
    `TableEventDispatcher` via `RequestContext.setAuditExtras` /
    `takeAuditExtras`. Iceberg REST has the same layered shape; this reuses
    that stash rather than adding a parallel mechanism.
    
    ## Changes
    
    - **New**: `IcebergRequestContext` gains an immutable extras map
    (`auditExtras` / `withAuditExtras`). Extras are not part of
    `httpHeaders()`.
    - **New**: `IcebergEvent` and `IcebergFailureEvent` `customInfo()` is
    headers ∪ extras. When extras are empty, `customInfo()` still returns
    today's header-only map.
    - **New**: `IcebergTableEventDispatcher` takes
    `RequestContext.takeAuditExtras()` on create/update/load success and
    failure and attaches them to the emitted Iceberg context. The take is
    destructive, and `RequestContext.clear()` still removes the stash at
    request teardown.
    - **Unchanged**: drop/list/rename and every other Iceberg operation.
    `EventBus` is untouched. Error propagation and operation results are
    untouched.
    - **Deferred**: listener-failure isolation. Today Iceberg dispatch lets
    a success-path listener exception propagate. Whether to isolate that is
    a question about dispatcher semantics that applies equally to events
    carrying no custom info.
    
    ## Impact and risks
    
    No behavior change for existing deployments and no configuration change.
    Audit output changes only when a layer actually stashes facts. In that
    case `customInfo()` carries those facts beside headers; the record's
    shape, count, and every other field are unchanged. Facts never appear on
    `httpHeaders()`.
    
    The threading contract is the same one #12692 already accepted: the
    contributor and the dispatcher must run on the same thread. That holds
    for the servlet request path.
    
    ## Reviewer focus
    
    The attach path is the thing I most want a read on.
    `IcebergTableEventDispatcher` takes the stash after the inner call
    returns or throws, then copies extras onto a new `IcebergRequestContext`
    so the event's `customInfo()` can merge them without mutating the
    caller's headers.
    
    Second, the empty-extras identity: `customInfo()` returns the same
    headers map it does today when nothing was stashed. If you would rather
    always copy, say so.
    
    ## Related work
    
    Fixes #12722
    
    Counterpart of #12692 for Iceberg REST table events.
    
    Nevin
    
    Co-authored-by: Cursor Agent <[email protected]>
    Co-authored-by: Nevin Zheng <[email protected]>
---
 .../dispatcher/IcebergTableEventDispatcher.java    |  36 ++-
 .../gravitino/listener/api/event/IcebergEvent.java |   2 +-
 .../listener/api/event/IcebergFailureEvent.java    |   2 +-
 .../listener/api/event/IcebergRequestContext.java  |  87 +++++-
 .../TestIcebergTableEventDispatcher.java           | 325 +++++++++++++++++++++
 .../api/event/TestIcebergRequestContext.java       |  73 +++++
 6 files changed, 512 insertions(+), 13 deletions(-)

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 9a6cb21ee2..7482504625 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
@@ -20,6 +20,7 @@
 package org.apache.gravitino.iceberg.service.dispatcher;
 
 import java.util.List;
+import java.util.Map;
 import java.util.Optional;
 import org.apache.gravitino.NameIdentifier;
 import org.apache.gravitino.iceberg.service.IcebergRESTUtils;
@@ -53,6 +54,7 @@ import 
org.apache.gravitino.listener.api.event.IcebergTableExistsPreEvent;
 import org.apache.gravitino.listener.api.event.IcebergUpdateTableEvent;
 import org.apache.gravitino.listener.api.event.IcebergUpdateTableFailureEvent;
 import org.apache.gravitino.listener.api.event.IcebergUpdateTablePreEvent;
+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;
@@ -68,6 +70,10 @@ import 
org.apache.iceberg.rest.responses.PlanTableScanResponse;
  * {@code IcebergTableEventDispatcher} is a decorator for {@link 
IcebergTableOperationExecutor} that
  * not only delegates table operations to the underlying dispatcher but also 
dispatches
  * corresponding events to an {@link org.apache.gravitino.listener.EventBus}.
+ *
+ * <p>Create, update, and load attach optional extras stashed on {@link 
RequestContext} so an inner
+ * dispatcher can contribute {@code customInfo} to the terminal Iceberg event 
without publishing a
+ * sibling event that consumers would have to correlate.
  */
 public class IcebergTableEventDispatcher implements 
IcebergTableOperationDispatcher {
 
@@ -104,12 +110,15 @@ public class IcebergTableEventDispatcher implements 
IcebergTableOperationDispatc
     } catch (Exception e) {
       eventBus.dispatchEvent(
           new IcebergCreateTableFailureEvent(
-              context, nameIdentifier, 
transformedCreateEvent.createTableRequest(), e));
+              contextWithAuditExtras(context),
+              nameIdentifier,
+              transformedCreateEvent.createTableRequest(),
+              e));
       throw e;
     }
     eventBus.dispatchEvent(
         new IcebergCreateTableEvent(
-            context,
+            contextWithAuditExtras(context),
             nameIdentifier,
             transformedCreateEvent.createTableRequest(),
             loadTableResponse));
@@ -137,12 +146,15 @@ public class IcebergTableEventDispatcher implements 
IcebergTableOperationDispatc
     } catch (Exception e) {
       eventBus.dispatchEvent(
           new IcebergUpdateTableFailureEvent(
-              context, gravitinoNameIdentifier, 
transformedUpdateEvent.updateTableRequest(), e));
+              contextWithAuditExtras(context),
+              gravitinoNameIdentifier,
+              transformedUpdateEvent.updateTableRequest(),
+              e));
       throw e;
     }
     eventBus.dispatchEvent(
         new IcebergUpdateTableEvent(
-            context,
+            contextWithAuditExtras(context),
             gravitinoNameIdentifier,
             transformedUpdateEvent.updateTableRequest(),
             loadTableResponse));
@@ -179,11 +191,14 @@ public class IcebergTableEventDispatcher implements 
IcebergTableOperationDispatc
     try {
       loadTableResponse = icebergTableOperationDispatcher.loadTable(context, 
tableIdentifier);
     } catch (Exception e) {
-      eventBus.dispatchEvent(new IcebergLoadTableFailureEvent(context, 
gravitinoNameIdentifier, e));
+      eventBus.dispatchEvent(
+          new IcebergLoadTableFailureEvent(
+              contextWithAuditExtras(context), gravitinoNameIdentifier, e));
       throw e;
     }
     eventBus.dispatchEvent(
-        new IcebergLoadTableEvent(context, gravitinoNameIdentifier, 
loadTableResponse));
+        new IcebergLoadTableEvent(
+            contextWithAuditExtras(context), gravitinoNameIdentifier, 
loadTableResponse));
     return loadTableResponse;
   }
 
@@ -310,4 +325,13 @@ public class IcebergTableEventDispatcher implements 
IcebergTableOperationDispatc
       IcebergRequestContext context, TableIdentifier tableIdentifier) {
     return icebergTableOperationDispatcher.getTableMetadataLocation(context, 
tableIdentifier);
   }
+
+  /**
+   * Takes extras stashed on {@link RequestContext} and returns a context that 
carries them. The
+   * take is destructive so a later operation on the same thread cannot see 
this request's extras.
+   */
+  private static IcebergRequestContext 
contextWithAuditExtras(IcebergRequestContext context) {
+    Map<String, String> extras = RequestContext.takeAuditExtras();
+    return extras.isEmpty() ? context : context.withAuditExtras(extras);
+  }
 }
diff --git 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/listener/api/event/IcebergEvent.java
 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/listener/api/event/IcebergEvent.java
index 08d7cb1743..044000e831 100644
--- 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/listener/api/event/IcebergEvent.java
+++ 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/listener/api/event/IcebergEvent.java
@@ -55,6 +55,6 @@ public abstract class IcebergEvent extends Event {
 
   @Override
   public Map<String, String> customInfo() {
-    return icebergRequestContext.httpHeaders();
+    return icebergRequestContext.customInfo();
   }
 }
diff --git 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/listener/api/event/IcebergFailureEvent.java
 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/listener/api/event/IcebergFailureEvent.java
index 3ffc40ff99..403299db12 100644
--- 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/listener/api/event/IcebergFailureEvent.java
+++ 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/listener/api/event/IcebergFailureEvent.java
@@ -55,6 +55,6 @@ public abstract class IcebergFailureEvent extends 
FailureEvent {
 
   @Override
   public Map<String, String> customInfo() {
-    return icebergRequestContext.httpHeaders();
+    return icebergRequestContext.customInfo();
   }
 }
diff --git 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/listener/api/event/IcebergRequestContext.java
 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/listener/api/event/IcebergRequestContext.java
index e10eb41734..d03cbde592 100644
--- 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/listener/api/event/IcebergRequestContext.java
+++ 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/listener/api/event/IcebergRequestContext.java
@@ -19,13 +19,20 @@
 
 package org.apache.gravitino.listener.api.event;
 
+import com.google.common.collect.ImmutableMap;
+import java.util.LinkedHashMap;
 import java.util.Map;
 import javax.servlet.http.HttpServletRequest;
 import org.apache.commons.lang3.StringUtils;
 import org.apache.gravitino.iceberg.service.IcebergRESTUtils;
 import org.apache.gravitino.utils.PrincipalUtils;
 
-/** The general request context information for Iceberg REST operations. */
+/**
+ * The general request context information for Iceberg REST operations.
+ *
+ * <p>Optional {@link #auditExtras()} are facts an inner dispatcher wants on 
the terminal Iceberg
+ * event. They are not HTTP headers and never appear in {@link #httpHeaders()}.
+ */
 public class IcebergRequestContext {
 
   /** Header that opts a drop purge request into asynchronous file cleanup. */
@@ -41,6 +48,7 @@ public class IcebergRequestContext {
   private final String remoteHostName;
   private final Map<String, String> httpHeaders;
   private final boolean requestCredentialVending;
+  private final Map<String, String> auditExtras;
 
   /**
    * Constructs a new {@code IcebergRequestContext} instance.
@@ -61,12 +69,31 @@ public class IcebergRequestContext {
    */
   public IcebergRequestContext(
       HttpServletRequest httpRequest, String catalogName, boolean 
requestCredentialVending) {
-    this.httpServletRequest = httpRequest;
-    this.remoteHostName = resolveClientAddress(httpRequest);
-    this.httpHeaders = IcebergRESTUtils.getHttpHeaders(httpRequest);
+    this(
+        httpRequest,
+        catalogName,
+        PrincipalUtils.getCurrentUserName(),
+        resolveClientAddress(httpRequest),
+        IcebergRESTUtils.getHttpHeaders(httpRequest),
+        requestCredentialVending,
+        ImmutableMap.of());
+  }
+
+  private IcebergRequestContext(
+      HttpServletRequest httpServletRequest,
+      String catalogName,
+      String userName,
+      String remoteHostName,
+      Map<String, String> httpHeaders,
+      boolean requestCredentialVending,
+      Map<String, String> auditExtras) {
+    this.httpServletRequest = httpServletRequest;
     this.catalogName = catalogName;
-    this.userName = PrincipalUtils.getCurrentUserName();
+    this.userName = userName;
+    this.remoteHostName = remoteHostName;
+    this.httpHeaders = httpHeaders;
     this.requestCredentialVending = requestCredentialVending;
+    this.auditExtras = auditExtras;
   }
 
   private static String resolveClientAddress(HttpServletRequest request) {
@@ -116,6 +143,49 @@ public class IcebergRequestContext {
     return httpHeaders;
   }
 
+  /**
+   * Returns the immutable audit extras attached to this context. Empty when 
none were attached.
+   *
+   * @return audit extras, never {@code null}
+   */
+  public Map<String, String> auditExtras() {
+    return auditExtras;
+  }
+
+  /**
+   * Returns a copy of this context with the given audit extras. Extras are 
not part of {@link
+   * #httpHeaders()}. A {@code null} or empty map yields a context with empty 
extras.
+   *
+   * @param extras optional facts for {@code customInfo()}, or {@code null} 
for none
+   * @return a new context; this instance is unchanged
+   */
+  public IcebergRequestContext withAuditExtras(Map<String, String> extras) {
+    return new IcebergRequestContext(
+        httpServletRequest,
+        catalogName,
+        userName,
+        remoteHostName,
+        httpHeaders,
+        requestCredentialVending,
+        copyAuditExtras(extras));
+  }
+
+  /**
+   * Returns HTTP headers merged with {@link #auditExtras()}. When extras are 
empty, returns {@link
+   * #httpHeaders()} unchanged so existing callers keep today's header-only 
map. Extra keys overlay
+   * headers on conflict.
+   *
+   * @return headers, or headers overlaid with extras
+   */
+  public Map<String, String> customInfo() {
+    if (auditExtras.isEmpty()) {
+      return httpHeaders;
+    }
+    Map<String, String> merged = new LinkedHashMap<>(httpHeaders);
+    merged.putAll(auditExtras);
+    return ImmutableMap.copyOf(merged);
+  }
+
   /**
    * Checks whether this request opted into asynchronous table purge.
    *
@@ -154,4 +224,11 @@ public class IcebergRequestContext {
   public HttpServletRequest getHttpServletRequest() {
     return httpServletRequest;
   }
+
+  private static Map<String, String> copyAuditExtras(Map<String, String> 
extras) {
+    if (extras == null || extras.isEmpty()) {
+      return ImmutableMap.of();
+    }
+    return ImmutableMap.copyOf(extras);
+  }
 }
diff --git 
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/dispatcher/TestIcebergTableEventDispatcher.java
 
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/dispatcher/TestIcebergTableEventDispatcher.java
new file mode 100644
index 0000000000..4bbc70bb18
--- /dev/null
+++ 
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/dispatcher/TestIcebergTableEventDispatcher.java
@@ -0,0 +1,325 @@
+/*
+ * 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.iceberg.service.dispatcher;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import com.google.common.collect.ImmutableMap;
+import java.util.Collections;
+import java.util.Enumeration;
+import java.util.LinkedList;
+import java.util.Map;
+import javax.servlet.http.HttpServletRequest;
+import org.apache.gravitino.listener.EventBus;
+import org.apache.gravitino.listener.api.EventListenerPlugin;
+import org.apache.gravitino.listener.api.event.Event;
+import org.apache.gravitino.listener.api.event.IcebergCreateTableEvent;
+import org.apache.gravitino.listener.api.event.IcebergCreateTableFailureEvent;
+import org.apache.gravitino.listener.api.event.IcebergEvent;
+import org.apache.gravitino.listener.api.event.IcebergFailureEvent;
+import org.apache.gravitino.listener.api.event.IcebergLoadTableEvent;
+import org.apache.gravitino.listener.api.event.IcebergLoadTableFailureEvent;
+import org.apache.gravitino.listener.api.event.IcebergRequestContext;
+import org.apache.gravitino.listener.api.event.IcebergUpdateTableEvent;
+import org.apache.gravitino.listener.api.event.IcebergUpdateTableFailureEvent;
+import org.apache.gravitino.utils.RequestContext;
+import org.apache.iceberg.PartitionSpec;
+import org.apache.iceberg.Schema;
+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.UpdateTableRequest;
+import org.apache.iceberg.rest.responses.LoadTableResponse;
+import org.apache.iceberg.types.Types.LongType;
+import org.apache.iceberg.types.Types.NestedField;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Covers the audit-extras path on {@link IcebergTableEventDispatcher}: how 
facts stashed on {@link
+ * RequestContext} reach the emitted Iceberg event, and that listener-failure 
propagation is
+ * identical with or without extras. Event field mapping itself is covered by 
{@code
+ * TestIcebergRequestContext}.
+ */
+public class TestIcebergTableEventDispatcher {
+
+  private static final String METALAKE = "metalake";
+  private static final String CATALOG = "catalog";
+  private static final Namespace NAMESPACE = Namespace.of("ns");
+  private static final TableIdentifier TABLE_ID = 
TableIdentifier.of(NAMESPACE, "table");
+  private static final Schema TABLE_SCHEMA =
+      new Schema(NestedField.required(1, "id", LongType.get()));
+  private static final String REQUEST_HEADER = "X-Request-Id";
+  private static final String REQUEST_HEADER_VALUE = "req-1";
+  private static final String EXTRA_KEY = "audit.reason";
+
+  @AfterEach
+  void cleanup() {
+    RequestContext.clear();
+    Thread.interrupted();
+  }
+
+  /**
+   * This change is meant to be purely additive: extras enrich the event, and 
nothing about error
+   * propagation moves. A synchronous listener failure therefore has to reach 
the caller exactly as
+   * it does today, whether or not extras were stashed. Asserting both halves 
in one test is what
+   * makes a future swallow branch visible, since adding one would only break 
the extras case.
+   */
+  @Test
+  void testAttachingExtrasDoesNotChangeListenerFailurePropagation() {
+    EventListenerPlugin listener = mock(EventListenerPlugin.class);
+    when(listener.transformPreEvent(any())).thenAnswer(invocation -> 
invocation.getArgument(0));
+    doThrow(new RuntimeException("listener 
failed")).when(listener).onPostEvent(any(Event.class));
+
+    IcebergTableEventDispatcher dispatcher = dispatcher(listener, 
succeedingInner());
+
+    RuntimeException withoutExtras =
+        Assertions.assertThrows(RuntimeException.class, () -> 
createTable(dispatcher));
+    Assertions.assertEquals("listener failed", withoutExtras.getMessage());
+
+    RequestContext.setAuditExtras(ImmutableMap.of(EXTRA_KEY, 
"policy-applied"));
+    RuntimeException withExtras =
+        Assertions.assertThrows(RuntimeException.class, () -> 
createTable(dispatcher));
+    Assertions.assertEquals("listener failed", withExtras.getMessage());
+  }
+
+  /**
+   * The point of routing extras through the request context is to enrich the 
event the dispatcher
+   * already emits rather than to publish a second one. Pins both halves: 
headers ∪ extras reach
+   * {@code customInfo}, extras stay off {@code httpHeaders}, and exactly one 
post event is
+   * produced.
+   */
+  @Test
+  void testCreateWithExtrasMergesHeadersAndDoesNotTouchHttpHeaders() {
+    RecordingListener listener = new RecordingListener();
+    IcebergTableEventDispatcher dispatcher = dispatcher(listener, 
succeedingInner());
+    RequestContext.setAuditExtras(ImmutableMap.of(EXTRA_KEY, 
"policy-applied"));
+
+    createTable(dispatcher);
+
+    Event event = listener.popPostEvent();
+    Assertions.assertEquals(IcebergCreateTableEvent.class, event.getClass());
+    assertHeadersMergedWithExtras(event, "policy-applied");
+    Assertions.assertTrue(listener.postEvents.isEmpty());
+  }
+
+  @Test
+  void testCreateWithoutExtrasKeepsHeaderOnlyCustomInfo() {
+    RecordingListener listener = new RecordingListener();
+    IcebergTableEventDispatcher dispatcher = dispatcher(listener, 
succeedingInner());
+
+    IcebergRequestContext context = requestContext();
+    dispatcher.createTable(context, NAMESPACE, createTableRequest());
+
+    Event event = listener.popPostEvent();
+    Assertions.assertEquals(IcebergCreateTableEvent.class, event.getClass());
+    Assertions.assertSame(context.httpHeaders(), event.customInfo());
+    Assertions.assertFalse(event.customInfo().containsKey(EXTRA_KEY));
+  }
+
+  /**
+   * A contributor that rejected the operation is exactly the case where the 
reason matters most, so
+   * extras have to survive the exception path and reach the failure event.
+   */
+  @Test
+  void testCreateFailureEventMergesHeadersAndExtras() {
+    RecordingListener listener = new RecordingListener();
+    IcebergTableOperationDispatcher inner = 
mock(IcebergTableOperationDispatcher.class);
+    when(inner.createTable(any(), any(), any())).thenThrow(new 
RuntimeException("create failed"));
+    IcebergTableEventDispatcher dispatcher = dispatcher(listener, inner);
+    RequestContext.setAuditExtras(ImmutableMap.of(EXTRA_KEY, 
"validation-failed"));
+
+    RuntimeException thrown =
+        Assertions.assertThrows(RuntimeException.class, () -> 
createTable(dispatcher));
+    Assertions.assertEquals("create failed", thrown.getMessage());
+
+    Event event = listener.popPostEvent();
+    Assertions.assertEquals(IcebergCreateTableFailureEvent.class, 
event.getClass());
+    assertHeadersMergedWithExtras(event, "validation-failed");
+  }
+
+  /**
+   * Each Iceberg table operation reads the stash in its own hand-written 
branch, so create passing
+   * does not imply update and load pass. Covers the two remaining operations 
and, by stashing a
+   * second fact between them, that consecutive operations on one thread get 
their own value.
+   */
+  @Test
+  void testUpdateAndLoadEventsAttachStashedExtras() {
+    RecordingListener listener = new RecordingListener();
+    IcebergTableEventDispatcher dispatcher = dispatcher(listener, 
succeedingInner());
+
+    RequestContext.setAuditExtras(ImmutableMap.of(EXTRA_KEY, 
"policy-applied"));
+    dispatcher.updateTable(requestContext(), TABLE_ID, updateTableRequest());
+    Event updateEvent = listener.popPostEvent();
+    Assertions.assertEquals(IcebergUpdateTableEvent.class, 
updateEvent.getClass());
+    assertHeadersMergedWithExtras(updateEvent, "policy-applied");
+
+    RequestContext.setAuditExtras(ImmutableMap.of(EXTRA_KEY, "cache-miss"));
+    dispatcher.loadTable(requestContext(), TABLE_ID);
+    Event loadEvent = listener.popPostEvent();
+    Assertions.assertEquals(IcebergLoadTableEvent.class, loadEvent.getClass());
+    assertHeadersMergedWithExtras(loadEvent, "cache-miss");
+  }
+
+  @Test
+  void testUpdateAndLoadFailureEventsAttachStashedExtras() {
+    RecordingListener listener = new RecordingListener();
+    IcebergTableOperationDispatcher inner = 
mock(IcebergTableOperationDispatcher.class);
+    when(inner.updateTable(any(), any(), any())).thenThrow(new 
RuntimeException("update failed"));
+    when(inner.loadTable(any(), any())).thenThrow(new RuntimeException("load 
failed"));
+    IcebergTableEventDispatcher dispatcher = dispatcher(listener, inner);
+
+    RequestContext.setAuditExtras(ImmutableMap.of(EXTRA_KEY, 
"validation-failed"));
+    RuntimeException updateThrown =
+        Assertions.assertThrows(
+            RuntimeException.class,
+            () -> dispatcher.updateTable(requestContext(), TABLE_ID, 
updateTableRequest()));
+    Assertions.assertEquals("update failed", updateThrown.getMessage());
+    Event updateEvent = listener.popPostEvent();
+    Assertions.assertEquals(IcebergUpdateTableFailureEvent.class, 
updateEvent.getClass());
+    assertHeadersMergedWithExtras(updateEvent, "validation-failed");
+
+    RequestContext.setAuditExtras(ImmutableMap.of(EXTRA_KEY, "not-found"));
+    RuntimeException loadThrown =
+        Assertions.assertThrows(
+            RuntimeException.class, () -> 
dispatcher.loadTable(requestContext(), TABLE_ID));
+    Assertions.assertEquals("load failed", loadThrown.getMessage());
+    Event loadEvent = listener.popPostEvent();
+    Assertions.assertEquals(IcebergLoadTableFailureEvent.class, 
loadEvent.getClass());
+    assertHeadersMergedWithExtras(loadEvent, "not-found");
+  }
+
+  /**
+   * Server threads are pooled, so a fact left behind by one request would be 
mis-attributed to
+   * whichever request reuses the thread next. Pins that the dispatcher 
consumes the stash rather
+   * than reading it, by running a second operation on the same thread and 
requiring clean extras.
+   */
+  @Test
+  void testExtrasDoNotLeakIntoTheNextOperationOnTheSameThread() {
+    RecordingListener listener = new RecordingListener();
+    IcebergTableEventDispatcher dispatcher = dispatcher(listener, 
succeedingInner());
+    RequestContext.setAuditExtras(ImmutableMap.of(EXTRA_KEY, 
"policy-applied"));
+
+    createTable(dispatcher);
+    Assertions.assertEquals("policy-applied", 
listener.popPostEvent().customInfo().get(EXTRA_KEY));
+
+    createTable(dispatcher);
+
+    Event second = listener.popPostEvent();
+    Assertions.assertEquals(IcebergCreateTableEvent.class, second.getClass());
+    Assertions.assertFalse(
+        second.customInfo().containsKey(EXTRA_KEY),
+        "Extras must not survive into a later operation");
+    Assertions.assertSame(
+        ((IcebergEvent) second).icebergRequestContext().httpHeaders(), 
second.customInfo());
+  }
+
+  private static void assertHeadersMergedWithExtras(Event event, String 
extraValue) {
+    Assertions.assertEquals(REQUEST_HEADER_VALUE, 
event.customInfo().get(REQUEST_HEADER));
+    Assertions.assertEquals(extraValue, event.customInfo().get(EXTRA_KEY));
+    Map<String, String> headers;
+    if (event instanceof IcebergEvent) {
+      headers = ((IcebergEvent) event).icebergRequestContext().httpHeaders();
+    } else {
+      headers = ((IcebergFailureEvent) 
event).icebergRequestContext().httpHeaders();
+    }
+    Assertions.assertFalse(headers.containsKey(EXTRA_KEY));
+    Assertions.assertEquals(REQUEST_HEADER_VALUE, headers.get(REQUEST_HEADER));
+  }
+
+  private static IcebergTableEventDispatcher dispatcher(
+      EventListenerPlugin listener, IcebergTableOperationDispatcher inner) {
+    return new IcebergTableEventDispatcher(
+        inner, new EventBus(Collections.singletonList(listener)), METALAKE);
+  }
+
+  private static IcebergTableOperationDispatcher succeedingInner() {
+    IcebergTableOperationDispatcher inner = 
mock(IcebergTableOperationDispatcher.class);
+    LoadTableResponse response = loadTableResponse();
+    when(inner.createTable(any(), any(), any())).thenReturn(response);
+    when(inner.updateTable(any(), any(), any())).thenReturn(response);
+    when(inner.loadTable(any(), any())).thenReturn(response);
+    return inner;
+  }
+
+  private static void createTable(IcebergTableEventDispatcher dispatcher) {
+    dispatcher.createTable(requestContext(), NAMESPACE, createTableRequest());
+  }
+
+  private static IcebergRequestContext requestContext() {
+    return new IcebergRequestContext(
+        requestWithHeader(REQUEST_HEADER, REQUEST_HEADER_VALUE), CATALOG);
+  }
+
+  private static CreateTableRequest createTableRequest() {
+    return 
CreateTableRequest.builder().withName(TABLE_ID.name()).withSchema(TABLE_SCHEMA).build();
+  }
+
+  private static UpdateTableRequest updateTableRequest() {
+    return new UpdateTableRequest(Collections.emptyList(), 
Collections.emptyList());
+  }
+
+  private static LoadTableResponse loadTableResponse() {
+    TableMetadata metadata =
+        TableMetadata.newTableMetadata(
+            TABLE_SCHEMA,
+            PartitionSpec.unpartitioned(),
+            "file:///tmp/iceberg-audit-extras",
+            ImmutableMap.of());
+    return LoadTableResponse.builder().withTableMetadata(metadata).build();
+  }
+
+  private static HttpServletRequest requestWithHeader(String name, String 
value) {
+    HttpServletRequest request = mock(HttpServletRequest.class);
+    Enumeration<String> headerNames = 
Collections.enumeration(Collections.singleton(name));
+    when(request.getRemoteHost()).thenReturn("localhost");
+    when(request.getHeaderNames()).thenReturn(headerNames);
+    when(request.getHeader(name)).thenReturn(value);
+    return request;
+  }
+
+  private static final class RecordingListener implements EventListenerPlugin {
+    private final LinkedList<Event> postEvents = new LinkedList<>();
+
+    @Override
+    public void init(Map<String, String> properties) {}
+
+    @Override
+    public void start() {}
+
+    @Override
+    public void stop() {}
+
+    @Override
+    public void onPostEvent(Event event) {
+      postEvents.add(event);
+    }
+
+    Event popPostEvent() {
+      Assertions.assertFalse(postEvents.isEmpty(), "No post events to pop");
+      return postEvents.removeLast();
+    }
+  }
+}
diff --git 
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/listener/api/event/TestIcebergRequestContext.java
 
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/listener/api/event/TestIcebergRequestContext.java
index eeff0219d2..95035cf649 100644
--- 
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/listener/api/event/TestIcebergRequestContext.java
+++ 
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/listener/api/event/TestIcebergRequestContext.java
@@ -22,9 +22,11 @@ package org.apache.gravitino.listener.api.event;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.when;
 
+import com.google.common.collect.ImmutableMap;
 import java.util.Collections;
 import java.util.Enumeration;
 import javax.servlet.http.HttpServletRequest;
+import org.apache.gravitino.NameIdentifier;
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.Test;
 
@@ -67,6 +69,77 @@ class TestIcebergRequestContext {
             .asyncPurge());
   }
 
+  /**
+   * Extras are a parallel map, not headers. A later audit formatter that 
reads {@code
+   * httpHeaders()} must not see a fact that was never on the wire.
+   */
+  @Test
+  void testExtrasDoNotAppearOnHttpHeaders() {
+    IcebergRequestContext original =
+        new IcebergRequestContext(requestWithHeader("X-Request-Id", "req-1"), 
"cat");
+    IcebergRequestContext enriched =
+        original.withAuditExtras(ImmutableMap.of("audit.reason", 
"policy-applied"));
+
+    Assertions.assertNotSame(original, enriched);
+    Assertions.assertTrue(original.auditExtras().isEmpty());
+    Assertions.assertEquals("policy-applied", 
enriched.auditExtras().get("audit.reason"));
+    Assertions.assertFalse(enriched.httpHeaders().containsKey("audit.reason"));
+    Assertions.assertEquals("req-1", 
enriched.httpHeaders().get("X-Request-Id"));
+  }
+
+  /**
+   * Today's {@code customInfo()} is the headers map. Empty extras have to 
keep that identity so
+   * existing callers do not observe a copy.
+   */
+  @Test
+  void testCustomInfoIsHeadersWhenExtrasEmpty() {
+    IcebergRequestContext context =
+        new IcebergRequestContext(requestWithHeader("X-Request-Id", "req-1"), 
"cat");
+    Assertions.assertSame(context.httpHeaders(), context.customInfo());
+    Assertions.assertSame(
+        context.httpHeaders(), 
context.withAuditExtras(ImmutableMap.of()).customInfo());
+    Assertions.assertSame(context.httpHeaders(), 
context.withAuditExtras(null).customInfo());
+  }
+
+  @Test
+  void testCustomInfoMergesHeadersAndExtras() {
+    IcebergRequestContext context =
+        new IcebergRequestContext(requestWithHeader("X-Request-Id", "req-1"), 
"cat")
+            .withAuditExtras(ImmutableMap.of("audit.reason", 
"policy-applied"));
+    Assertions.assertEquals("req-1", context.customInfo().get("X-Request-Id"));
+    Assertions.assertEquals("policy-applied", 
context.customInfo().get("audit.reason"));
+    Assertions.assertFalse(context.httpHeaders().containsKey("audit.reason"));
+  }
+
+  /**
+   * Failure events are the case where the reason matters most. Pins that 
{@link
+   * IcebergFailureEvent#customInfo()} is headers ∪ extras and that extras 
stay off {@code
+   * httpHeaders()}.
+   */
+  @Test
+  void testFailureEventCustomInfoMergesHeadersAndExtras() {
+    IcebergRequestContext context =
+        new IcebergRequestContext(requestWithHeader("X-Request-Id", "req-1"), 
"cat")
+            .withAuditExtras(ImmutableMap.of("audit.reason", 
"validation-failed"));
+    IcebergLoadTableFailureEvent event =
+        new IcebergLoadTableFailureEvent(
+            context, NameIdentifier.of("ml", "cat", "ns", "t"), new 
RuntimeException("boom"));
+
+    Assertions.assertEquals("req-1", event.customInfo().get("X-Request-Id"));
+    Assertions.assertEquals("validation-failed", 
event.customInfo().get("audit.reason"));
+    
Assertions.assertFalse(event.icebergRequestContext().httpHeaders().containsKey("audit.reason"));
+  }
+
+  @Test
+  void testFailureEventCustomInfoIsHeaderOnlyWhenExtrasEmpty() {
+    IcebergRequestContext context =
+        new IcebergRequestContext(requestWithHeader("X-Request-Id", "req-1"), 
"cat");
+    IcebergLoadTableFailureEvent event =
+        new IcebergLoadTableFailureEvent(
+            context, NameIdentifier.of("ml", "cat", "ns", "t"), new 
RuntimeException("boom"));
+    Assertions.assertSame(context.httpHeaders(), event.customInfo());
+  }
+
   private static HttpServletRequest requestWithoutHeader() {
     HttpServletRequest request = mock(HttpServletRequest.class);
     when(request.getRemoteHost()).thenReturn("localhost");

Reply via email to