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 b0717c3a49 [#12691] feat(core): let inner dispatchers contribute
customInfo to table events (#12692)
b0717c3a49 is described below
commit b0717c3a4949420a1004c638338cc6b6d5e40302
Author: Nevin Zheng <[email protected]>
AuthorDate: Fri Aug 28 23:55:20 2026 -0700
[#12691] feat(core): let inner dispatchers contribute customInfo to table
events (#12692)
## Summary
An inner `TableDispatcher` layer can now attach `Map<String, String>`
audit facts to the terminal table event that `TableEventDispatcher`
already emits, so one operation still produces one audit record and that
record can say what the dispatch chain knew about it.
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.
## Context
Gravitino's dispatch chain is layered, and the layer that knows
something audit-worthy about an operation is usually not the layer that
emits the event. An inner layer that applied a policy or enforced a
validation had no way to get that fact into the `CreateTableEvent` the
outer dispatcher publishes — it could publish a separate event, which
breaks the one-operation-one-event shape audit consumers rely on, or
drop the information.
This fills a gap in a facility the event API already has rather than
proposing a new one. `BaseEvent` declares `customInfo()` returning an
empty map, marked `@since 0.8.0`
(`core/src/main/java/org/apache/gravitino/listener/api/event/BaseEvent.java:131`).
`AuthorizationDenialFailureEvent` and `HttpRequestFailureEvent` already
override it, both cases where the reason for an outcome was the point.
Both audit formatters already serialize it with redaction
(`audit/JsonAuditFormatter.java:69`,
`audit/v2/SimpleAuditLogV2.java:101`, `audit/AuditLogRedactor.java`).
The consumption side was therefore already built end to end. What was
missing is that only the code constructing an event can populate
`customInfo`, and for table events that is always the outermost
dispatcher. No listener or formatter changes are needed.
## Changes
- **New**: `TableEvent` and `TableFailureEvent` gain a `protected`
constructor accepting custom info and an override of `customInfo()` that
returns it, defensively copied and normalized so `null` and empty both
yield an empty map. The six concrete table events gain a matching
`public` overload.
- **New**: `RequestContext.setAuditExtras` and `takeAuditExtras`. A
contributing layer stashes facts before it returns or throws;
`TableEventDispatcher` takes them when building the terminal event. The
read is read-and-clear, and `RequestContext.clear()` now removes the
stash during request teardown, so a fact cannot follow a pooled thread
into a later request.
- **Unchanged**: every existing constructor keeps its signature and
delegates with an empty map. `EventBus` is untouched. Error propagation
and operation results are untouched.
- **Deferred**: the other fourteen dispatchers (`Catalog`, `Schema`,
`Fileset`, `Topic`, `Model`, `View`, `Function`, `Partition`, `Policy`,
`Tag`, `Statistic`, `Job`, `Metalake`, `AccessControl`) have the same
gap. Kept out to keep this reviewable.
## Impact and risks
No behavior change for existing deployments and no configuration change.
The API surface is additive only: new constructor overloads on the six
table events, new protected constructors on the two base classes, and
the two `RequestContext` methods. Existing constructors now produce
events whose `customInfo()` is an empty map, which is what `BaseEvent`
already returned for them.
Audit output changes only when a layer actually stashes facts. In that
case the existing `customInfo` field is populated instead of empty; the
record's shape, count, and every other field are unchanged.
The risk worth naming is the threading contract — see below.
## Reviewer focus
**The `ThreadLocal` is the debatable part and the thing I most want a
read on.** Passing the facts as an argument would be cleaner, but the
contributing layer and the emitting layer are not in the same call
frame: `TableEventDispatcher.createTable` calls
`dispatcher.createTable(...)`, gets back a `Table`, then builds the
event. Threading a value through means changing the `TableDispatcher`
interface so every implementation returns or accepts an audit channel —
a wide blast radius for a facility most implementations will never use.
`RequestContext` already holds per-request state of exactly this shape,
so this reuses it rather than adding a parallel mechanism.
The cost is real: correctness depends on the contributor and the
dispatcher running on the same thread. That holds for the servlet
request path, but it is an invariant a future async dispatch layer could
break. `TestRequestContext` pins the thread-confinement assumption
explicitly so the constraint is visible and a violation fails loudly
rather than silently mis-attributing a fact.
Second, scope. If you would rather see the pattern applied across all
fifteen dispatchers in one change than land it on tables first, say so
and I will extend it.
## Verification
Environment: macOS, Gradle 8.2 wrapper, Amazon Corretto JDK 17.0.19. All
from the repository root.
- `./gradlew :core:compileJava :core:compileTestJava` — BUILD
SUCCESSFUL.
- `./gradlew :core:test -PskipITs --tests '*TableEventDispatcher*'
--tests '*TestTableEvent*' --tests '*TestRequestContext*' --tests
'*TestOperation*'` — BUILD SUCCESSFUL. Confirmed against the JUnit XML
that the tests ran rather than being filtered out: 41 tests, 0 failures,
0 errors (`TestTableEventDispatcher` 3, `TestTableEvent` 16,
`TestRequestContext` 9, `TestOperation` 13; the latter three include
pre-existing tests in those classes).
- `./gradlew spotlessApply` then `./gradlew spotlessJavaCheck` — BUILD
SUCCESSFUL, no drift.
Coverage added:
- `TestTableEventDispatcher` (new): stashed facts reach
`CreateTableEvent.customInfo()` with exactly one post event produced;
facts do not leak into a second operation on the same thread; and
listener-failure propagation is identical with and without extras, which
is the assertion that would catch this change starting to affect error
handling.
- `TestTableEvent`: success, failure, and alter/load cases driven
through a real dispatcher, asserting empty `customInfo()` on the
pre-existing cases so the default stays empty.
- `TestRequestContext`: set/take/clear semantics, empty-and-null
retraction, and thread confinement of the stash.
- `TestOperation`: events built with the new constructors still classify
as `CREATE_TABLE`.
**Not run**: the full `./gradlew build`, integration tests, and other
modules' suites. The change is confined to `core`; CI is the check for
the rest.
## Related work
Fix: #12691
cc @roryqi @jerryshao @lasdf for review.
Nevin
Sent from my 🤖 (Cursor)
---
.../gravitino/listener/TableEventDispatcher.java | 49 +++++-
.../listener/api/event/AlterTableEvent.java | 22 ++-
.../listener/api/event/AlterTableFailureEvent.java | 21 ++-
.../listener/api/event/CreateTableEvent.java | 19 ++-
.../api/event/CreateTableFailureEvent.java | 21 ++-
.../listener/api/event/LoadTableEvent.java | 16 +-
.../listener/api/event/LoadTableFailureEvent.java | 16 +-
.../gravitino/listener/api/event/TableEvent.java | 25 +++
.../listener/api/event/TableFailureEvent.java | 27 ++++
.../org/apache/gravitino/utils/RequestContext.java | 38 ++++-
.../org/apache/gravitino/audit/TestOperation.java | 22 +++
.../listener/TestTableEventDispatcher.java | 170 +++++++++++++++++++++
.../listener/api/event/TestTableEvent.java | 85 +++++++++++
.../apache/gravitino/utils/TestRequestContext.java | 73 +++++++++
14 files changed, 589 insertions(+), 15 deletions(-)
diff --git
a/core/src/main/java/org/apache/gravitino/listener/TableEventDispatcher.java
b/core/src/main/java/org/apache/gravitino/listener/TableEventDispatcher.java
index 435322ec08..fd235bb508 100644
--- a/core/src/main/java/org/apache/gravitino/listener/TableEventDispatcher.java
+++ b/core/src/main/java/org/apache/gravitino/listener/TableEventDispatcher.java
@@ -54,12 +54,17 @@ import org.apache.gravitino.rel.expressions.sorts.SortOrder;
import org.apache.gravitino.rel.expressions.transforms.Transform;
import org.apache.gravitino.rel.indexes.Index;
import org.apache.gravitino.utils.PrincipalUtils;
+import org.apache.gravitino.utils.RequestContext;
/**
* {@code TableEventDispatcher} is a decorator for {@link TableDispatcher}
that not only delegates
* table operations to the underlying catalog dispatcher but also dispatches
corresponding events to
* an {@link org.apache.gravitino.listener.EventBus} after each operation is
completed. This allows
* for event-driven workflows or monitoring of table operations.
+ *
+ * <p>Create, alter, and load attach optional extras stashed on {@link
RequestContext} so an inner
+ * dispatcher can contribute {@code customInfo} to the terminal event without
publishing a sibling
+ * event that consumers would have to correlate.
*/
public class TableEventDispatcher implements TableDispatcher {
@@ -101,12 +106,19 @@ public class TableEventDispatcher implements
TableDispatcher {
eventBus.dispatchEvent(new
LoadTablePreEvent(PrincipalUtils.getCurrentUserName(), ident));
try {
Table table = dispatcher.loadTable(ident);
+ Map<String, String> extras = RequestContext.takeAuditExtras();
eventBus.dispatchEvent(
- new LoadTableEvent(PrincipalUtils.getCurrentUserName(), ident, new
TableInfo(table)));
+ extras.isEmpty()
+ ? new LoadTableEvent(PrincipalUtils.getCurrentUserName(), ident,
new TableInfo(table))
+ : new LoadTableEvent(
+ PrincipalUtils.getCurrentUserName(), ident, new
TableInfo(table), extras));
return table;
} catch (Exception e) {
+ Map<String, String> extras = RequestContext.takeAuditExtras();
eventBus.dispatchEvent(
- new LoadTableFailureEvent(PrincipalUtils.getCurrentUserName(),
ident, e));
+ extras.isEmpty()
+ ? new LoadTableFailureEvent(PrincipalUtils.getCurrentUserName(),
ident, e)
+ : new LoadTableFailureEvent(PrincipalUtils.getCurrentUserName(),
ident, e, extras));
throw e;
}
}
@@ -139,13 +151,22 @@ public class TableEventDispatcher implements
TableDispatcher {
Table table =
dispatcher.createTable(
ident, columns, comment, properties, partitions, distribution,
sortOrders, indexes);
+ Map<String, String> extras = RequestContext.takeAuditExtras();
eventBus.dispatchEvent(
- new CreateTableEvent(PrincipalUtils.getCurrentUserName(), ident, new
TableInfo(table)));
+ extras.isEmpty()
+ ? new CreateTableEvent(
+ PrincipalUtils.getCurrentUserName(), ident, new
TableInfo(table))
+ : new CreateTableEvent(
+ PrincipalUtils.getCurrentUserName(), ident, new
TableInfo(table), extras));
return table;
} catch (Exception e) {
+ Map<String, String> extras = RequestContext.takeAuditExtras();
eventBus.dispatchEvent(
- new CreateTableFailureEvent(
- PrincipalUtils.getCurrentUserName(), ident, e,
createTableRequest));
+ extras.isEmpty()
+ ? new CreateTableFailureEvent(
+ PrincipalUtils.getCurrentUserName(), ident, e,
createTableRequest)
+ : new CreateTableFailureEvent(
+ PrincipalUtils.getCurrentUserName(), ident, e,
createTableRequest, extras));
throw e;
}
}
@@ -157,13 +178,25 @@ public class TableEventDispatcher implements
TableDispatcher {
new AlterTablePreEvent(PrincipalUtils.getCurrentUserName(), ident,
changes));
try {
Table table = dispatcher.alterTable(ident, changes);
+ Map<String, String> extras = RequestContext.takeAuditExtras();
eventBus.dispatchEvent(
- new AlterTableEvent(
- PrincipalUtils.getCurrentUserName(), ident, changes, new
TableInfo(table)));
+ extras.isEmpty()
+ ? new AlterTableEvent(
+ PrincipalUtils.getCurrentUserName(), ident, changes, new
TableInfo(table))
+ : new AlterTableEvent(
+ PrincipalUtils.getCurrentUserName(),
+ ident,
+ changes,
+ new TableInfo(table),
+ extras));
return table;
} catch (Exception e) {
+ Map<String, String> extras = RequestContext.takeAuditExtras();
eventBus.dispatchEvent(
- new AlterTableFailureEvent(PrincipalUtils.getCurrentUserName(),
ident, e, changes));
+ extras.isEmpty()
+ ? new
AlterTableFailureEvent(PrincipalUtils.getCurrentUserName(), ident, e, changes)
+ : new AlterTableFailureEvent(
+ PrincipalUtils.getCurrentUserName(), ident, e, changes,
extras));
throw e;
}
}
diff --git
a/core/src/main/java/org/apache/gravitino/listener/api/event/AlterTableEvent.java
b/core/src/main/java/org/apache/gravitino/listener/api/event/AlterTableEvent.java
index fd6365a71c..da57c5e9cb 100644
---
a/core/src/main/java/org/apache/gravitino/listener/api/event/AlterTableEvent.java
+++
b/core/src/main/java/org/apache/gravitino/listener/api/event/AlterTableEvent.java
@@ -19,6 +19,7 @@
package org.apache.gravitino.listener.api.event;
+import java.util.Map;
import org.apache.gravitino.NameIdentifier;
import org.apache.gravitino.annotation.DeveloperApi;
import org.apache.gravitino.listener.api.info.TableInfo;
@@ -46,7 +47,26 @@ public final class AlterTableEvent extends TableEvent {
NameIdentifier identifier,
TableChange[] tableChanges,
TableInfo updatedTableInfo) {
- super(user, identifier);
+ this(user, identifier, tableChanges, updatedTableInfo, null);
+ }
+
+ /**
+ * Constructs an instance of {@code AlterTableEvent} with optional audit
extras.
+ *
+ * @param user The username of the individual responsible for initiating the
table alteration.
+ * @param identifier The unique identifier of the altered table.
+ * @param tableChanges An array of {@link TableChange} objects representing
the specific changes
+ * applied to the table during the alteration process.
+ * @param updatedTableInfo The post-alteration state of the table.
+ * @param customInfo optional audit facts contributed by an inner dispatcher
+ */
+ public AlterTableEvent(
+ String user,
+ NameIdentifier identifier,
+ TableChange[] tableChanges,
+ TableInfo updatedTableInfo,
+ Map<String, String> customInfo) {
+ super(user, identifier, customInfo);
this.tableChanges = tableChanges.clone();
this.updatedTableInfo = updatedTableInfo;
}
diff --git
a/core/src/main/java/org/apache/gravitino/listener/api/event/AlterTableFailureEvent.java
b/core/src/main/java/org/apache/gravitino/listener/api/event/AlterTableFailureEvent.java
index 84a998adb8..69d5916840 100644
---
a/core/src/main/java/org/apache/gravitino/listener/api/event/AlterTableFailureEvent.java
+++
b/core/src/main/java/org/apache/gravitino/listener/api/event/AlterTableFailureEvent.java
@@ -19,6 +19,7 @@
package org.apache.gravitino.listener.api.event;
+import java.util.Map;
import org.apache.gravitino.NameIdentifier;
import org.apache.gravitino.annotation.DeveloperApi;
import org.apache.gravitino.rel.TableChange;
@@ -41,7 +42,25 @@ public final class AlterTableFailureEvent extends
TableFailureEvent {
*/
public AlterTableFailureEvent(
String user, NameIdentifier identifier, Exception exception,
TableChange[] tableChanges) {
- super(user, identifier, exception);
+ this(user, identifier, exception, tableChanges, null);
+ }
+
+ /**
+ * Constructs an {@code AlterTableFailureEvent} with optional audit extras.
+ *
+ * @param user The user who initiated the table alteration operation.
+ * @param identifier The identifier of the table that was attempted to be
altered.
+ * @param exception The exception that was thrown during the table
alteration operation.
+ * @param tableChanges The changes that were attempted on the table.
+ * @param customInfo optional audit facts contributed by an inner dispatcher
+ */
+ public AlterTableFailureEvent(
+ String user,
+ NameIdentifier identifier,
+ Exception exception,
+ TableChange[] tableChanges,
+ Map<String, String> customInfo) {
+ super(user, identifier, exception, customInfo);
this.tableChanges = tableChanges.clone();
}
diff --git
a/core/src/main/java/org/apache/gravitino/listener/api/event/CreateTableEvent.java
b/core/src/main/java/org/apache/gravitino/listener/api/event/CreateTableEvent.java
index 482162c49d..419ca9d874 100644
---
a/core/src/main/java/org/apache/gravitino/listener/api/event/CreateTableEvent.java
+++
b/core/src/main/java/org/apache/gravitino/listener/api/event/CreateTableEvent.java
@@ -19,6 +19,7 @@
package org.apache.gravitino.listener.api.event;
+import java.util.Map;
import org.apache.gravitino.NameIdentifier;
import org.apache.gravitino.annotation.DeveloperApi;
import org.apache.gravitino.listener.api.info.TableInfo;
@@ -37,7 +38,23 @@ public final class CreateTableEvent extends TableEvent {
* @param createdTableInfo The final state of the table post-creation.
*/
public CreateTableEvent(String user, NameIdentifier identifier, TableInfo
createdTableInfo) {
- super(user, identifier);
+ this(user, identifier, createdTableInfo, null);
+ }
+
+ /**
+ * Constructs an instance of {@code CreateTableEvent} with optional audit
extras.
+ *
+ * @param user The username of the individual who initiated the table
creation.
+ * @param identifier The unique identifier of the table that was created.
+ * @param createdTableInfo The final state of the table post-creation.
+ * @param customInfo optional audit facts contributed by an inner dispatcher
+ */
+ public CreateTableEvent(
+ String user,
+ NameIdentifier identifier,
+ TableInfo createdTableInfo,
+ Map<String, String> customInfo) {
+ super(user, identifier, customInfo);
this.createdTableInfo = createdTableInfo;
}
diff --git
a/core/src/main/java/org/apache/gravitino/listener/api/event/CreateTableFailureEvent.java
b/core/src/main/java/org/apache/gravitino/listener/api/event/CreateTableFailureEvent.java
index 6bb27ce922..17470cd636 100644
---
a/core/src/main/java/org/apache/gravitino/listener/api/event/CreateTableFailureEvent.java
+++
b/core/src/main/java/org/apache/gravitino/listener/api/event/CreateTableFailureEvent.java
@@ -19,6 +19,7 @@
package org.apache.gravitino.listener.api.event;
+import java.util.Map;
import org.apache.gravitino.NameIdentifier;
import org.apache.gravitino.annotation.DeveloperApi;
import org.apache.gravitino.listener.api.info.TableInfo;
@@ -45,7 +46,25 @@ public final class CreateTableFailureEvent extends
TableFailureEvent {
*/
public CreateTableFailureEvent(
String user, NameIdentifier identifier, Exception exception, TableInfo
createTableRequest) {
- super(user, identifier, exception);
+ this(user, identifier, exception, createTableRequest, null);
+ }
+
+ /**
+ * Constructs a {@code CreateTableFailureEvent} with optional audit extras.
+ *
+ * @param user The user who initiated the table creation operation.
+ * @param identifier The identifier of the table that was attempted to be
created.
+ * @param exception The exception that was thrown during the table creation
operation.
+ * @param createTableRequest The original request information used to
attempt to create the table.
+ * @param customInfo optional audit facts contributed by an inner dispatcher
+ */
+ public CreateTableFailureEvent(
+ String user,
+ NameIdentifier identifier,
+ Exception exception,
+ TableInfo createTableRequest,
+ Map<String, String> customInfo) {
+ super(user, identifier, exception, customInfo);
this.createTableRequest = createTableRequest;
}
diff --git
a/core/src/main/java/org/apache/gravitino/listener/api/event/LoadTableEvent.java
b/core/src/main/java/org/apache/gravitino/listener/api/event/LoadTableEvent.java
index 5ca5ab35fd..003109553b 100644
---
a/core/src/main/java/org/apache/gravitino/listener/api/event/LoadTableEvent.java
+++
b/core/src/main/java/org/apache/gravitino/listener/api/event/LoadTableEvent.java
@@ -19,6 +19,7 @@
package org.apache.gravitino.listener.api.event;
+import java.util.Map;
import org.apache.gravitino.NameIdentifier;
import org.apache.gravitino.annotation.DeveloperApi;
import org.apache.gravitino.listener.api.info.TableInfo;
@@ -36,7 +37,20 @@ public final class LoadTableEvent extends TableEvent {
* @param tableInfo The state of the table post-loading.
*/
public LoadTableEvent(String user, NameIdentifier identifier, TableInfo
tableInfo) {
- super(user, identifier);
+ this(user, identifier, tableInfo, null);
+ }
+
+ /**
+ * Constructs an instance of {@code LoadTableEvent} with optional audit
extras.
+ *
+ * @param user The username of the individual who initiated the table
loading.
+ * @param identifier The unique identifier of the table that was loaded.
+ * @param tableInfo The state of the table post-loading.
+ * @param customInfo optional audit facts contributed by an inner dispatcher
+ */
+ public LoadTableEvent(
+ String user, NameIdentifier identifier, TableInfo tableInfo, Map<String,
String> customInfo) {
+ super(user, identifier, customInfo);
this.loadedTableInfo = tableInfo;
}
diff --git
a/core/src/main/java/org/apache/gravitino/listener/api/event/LoadTableFailureEvent.java
b/core/src/main/java/org/apache/gravitino/listener/api/event/LoadTableFailureEvent.java
index 1c6e6e3eff..df12ad6b43 100644
---
a/core/src/main/java/org/apache/gravitino/listener/api/event/LoadTableFailureEvent.java
+++
b/core/src/main/java/org/apache/gravitino/listener/api/event/LoadTableFailureEvent.java
@@ -19,6 +19,7 @@
package org.apache.gravitino.listener.api.event;
+import java.util.Map;
import org.apache.gravitino.NameIdentifier;
import org.apache.gravitino.annotation.DeveloperApi;
@@ -34,7 +35,20 @@ public final class LoadTableFailureEvent extends
TableFailureEvent {
* insight into the issues encountered.
*/
public LoadTableFailureEvent(String user, NameIdentifier identifier,
Exception exception) {
- super(user, identifier, exception);
+ this(user, identifier, exception, null);
+ }
+
+ /**
+ * Constructs a {@code LoadTableFailureEvent} with optional audit extras.
+ *
+ * @param user The user who initiated the table loading operation.
+ * @param identifier The identifier of the table that the loading attempt
was made for.
+ * @param exception The exception that was thrown during the table loading
operation.
+ * @param customInfo optional audit facts contributed by an inner dispatcher
+ */
+ public LoadTableFailureEvent(
+ String user, NameIdentifier identifier, Exception exception, Map<String,
String> customInfo) {
+ super(user, identifier, exception, customInfo);
}
/**
diff --git
a/core/src/main/java/org/apache/gravitino/listener/api/event/TableEvent.java
b/core/src/main/java/org/apache/gravitino/listener/api/event/TableEvent.java
index 32407ed62c..1574dbbcac 100644
--- a/core/src/main/java/org/apache/gravitino/listener/api/event/TableEvent.java
+++ b/core/src/main/java/org/apache/gravitino/listener/api/event/TableEvent.java
@@ -19,6 +19,8 @@
package org.apache.gravitino.listener.api.event;
+import com.google.common.collect.ImmutableMap;
+import java.util.Map;
import org.apache.gravitino.NameIdentifier;
import org.apache.gravitino.annotation.DeveloperApi;
@@ -33,6 +35,8 @@ import org.apache.gravitino.annotation.DeveloperApi;
*/
@DeveloperApi
public abstract class TableEvent extends Event {
+ private final Map<String, String> customInfo;
+
/**
* Constructs a new {@code TableEvent} with the specified user and table
identifier.
*
@@ -41,11 +45,32 @@ public abstract class TableEvent extends Event {
* details such as the metalake, catalog, schema, and table name.
*/
protected TableEvent(String user, NameIdentifier identifier) {
+ this(user, identifier, ImmutableMap.of());
+ }
+
+ /**
+ * Constructs a new {@code TableEvent} with optional audit extras.
+ *
+ * @param user The user responsible for triggering the table operation.
+ * @param identifier The identifier of the table involved in the operation.
+ * @param customInfo optional audit facts contributed by an inner dispatcher
+ */
+ protected TableEvent(String user, NameIdentifier identifier, Map<String,
String> customInfo) {
super(user, identifier);
+ this.customInfo =
+ customInfo == null || customInfo.isEmpty()
+ ? ImmutableMap.of()
+ : ImmutableMap.copyOf(customInfo);
}
@Override
public OperationStatus operationStatus() {
return OperationStatus.SUCCESS;
}
+
+ /** {@inheritDoc} */
+ @Override
+ public Map<String, String> customInfo() {
+ return customInfo;
+ }
}
diff --git
a/core/src/main/java/org/apache/gravitino/listener/api/event/TableFailureEvent.java
b/core/src/main/java/org/apache/gravitino/listener/api/event/TableFailureEvent.java
index a1293d523f..d8ad097ad0 100644
---
a/core/src/main/java/org/apache/gravitino/listener/api/event/TableFailureEvent.java
+++
b/core/src/main/java/org/apache/gravitino/listener/api/event/TableFailureEvent.java
@@ -19,6 +19,8 @@
package org.apache.gravitino.listener.api.event;
+import com.google.common.collect.ImmutableMap;
+import java.util.Map;
import org.apache.gravitino.NameIdentifier;
import org.apache.gravitino.annotation.DeveloperApi;
@@ -34,6 +36,8 @@ import org.apache.gravitino.annotation.DeveloperApi;
*/
@DeveloperApi
public abstract class TableFailureEvent extends FailureEvent {
+ private final Map<String, String> customInfo;
+
/**
* Constructs a new {@code TableFailureEvent} instance, capturing
information about the failed
* table operation.
@@ -44,6 +48,29 @@ public abstract class TableFailureEvent extends FailureEvent
{
* of the failure.
*/
protected TableFailureEvent(String user, NameIdentifier identifier,
Exception exception) {
+ this(user, identifier, exception, ImmutableMap.of());
+ }
+
+ /**
+ * Constructs a new {@code TableFailureEvent} with optional audit extras.
+ *
+ * @param user The user associated with the failed table operation.
+ * @param identifier The identifier of the table that was involved in the
failed operation.
+ * @param exception The exception that was thrown during the table operation.
+ * @param customInfo optional audit facts contributed by an inner dispatcher
+ */
+ protected TableFailureEvent(
+ String user, NameIdentifier identifier, Exception exception, Map<String,
String> customInfo) {
super(user, identifier, exception);
+ this.customInfo =
+ customInfo == null || customInfo.isEmpty()
+ ? ImmutableMap.of()
+ : ImmutableMap.copyOf(customInfo);
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ public Map<String, String> customInfo() {
+ return customInfo;
}
}
diff --git a/core/src/main/java/org/apache/gravitino/utils/RequestContext.java
b/core/src/main/java/org/apache/gravitino/utils/RequestContext.java
index bfdbb9c60e..e674dba595 100644
--- a/core/src/main/java/org/apache/gravitino/utils/RequestContext.java
+++ b/core/src/main/java/org/apache/gravitino/utils/RequestContext.java
@@ -19,11 +19,14 @@
package org.apache.gravitino.utils;
+import com.google.common.collect.ImmutableMap;
+import java.util.Map;
+
/**
* Holds per-request context data in a {@link ThreadLocal} so that event
classes constructed on the
* servlet thread can capture it without carrying a servlet dependency.
*
- * <p>Currently tracks two pieces of state:
+ * <p>Currently tracks three pieces of state:
*
* <ul>
* <li><b>remoteAddress</b> — the client IP resolved from {@code
X-Forwarded-For} or {@link
@@ -33,6 +36,8 @@ package org.apache.gravitino.utils;
* org.apache.gravitino.listener.api.event.FailureEvent} is dispatched,
so that {@code
* HttpAuditFilter} can skip emitting a redundant HTTP-level failure
event for the same
* request.
+ * <li><b>auditExtras</b> — optional {@code customInfo} facts stashed by an
inner dispatcher and
+ * consumed by the outer event dispatcher so one operation still
produces one event.
* </ul>
*
* <p><b>Threading contract:</b> values must be set and cleared on the same
(servlet) thread. Event
@@ -43,6 +48,7 @@ public class RequestContext {
private static final ThreadLocal<String> REMOTE_ADDRESS = new
ThreadLocal<>();
private static final ThreadLocal<Boolean> OPERATION_FAILURE_FIRED = new
ThreadLocal<>();
+ private static final ThreadLocal<Map<String, String>> AUDIT_EXTRAS = new
ThreadLocal<>();
private RequestContext() {}
@@ -93,6 +99,35 @@ public class RequestContext {
OPERATION_FAILURE_FIRED.remove();
}
+ /**
+ * Stashes optional audit extras for the current request thread. An inner
dispatcher calls this
+ * before returning or throwing so the outer event dispatcher can attach the
extras to the
+ * existing table event.
+ *
+ * <p>A {@code null} or empty map clears any previously stashed extras.
+ *
+ * @param extras optional {@code customInfo} facts, or {@code null} to clear
+ */
+ public static void setAuditExtras(Map<String, String> extras) {
+ if (extras == null || extras.isEmpty()) {
+ AUDIT_EXTRAS.remove();
+ return;
+ }
+ AUDIT_EXTRAS.set(ImmutableMap.copyOf(extras));
+ }
+
+ /**
+ * Returns and clears audit extras stashed on this thread. The outer event
dispatcher calls this
+ * when constructing the terminal table event.
+ *
+ * @return an immutable extras map, or an empty map when none were stashed
+ */
+ public static Map<String, String> takeAuditExtras() {
+ Map<String, String> extras = AUDIT_EXTRAS.get();
+ AUDIT_EXTRAS.remove();
+ return extras == null ? ImmutableMap.of() : extras;
+ }
+
/**
* Removes all per-request bindings from the current thread. Must be called
in a {@code finally}
* block after the request completes to prevent thread-pool leaks.
@@ -100,5 +135,6 @@ public class RequestContext {
public static void clear() {
REMOTE_ADDRESS.remove();
OPERATION_FAILURE_FIRED.remove();
+ AUDIT_EXTRAS.remove();
}
}
diff --git a/core/src/test/java/org/apache/gravitino/audit/TestOperation.java
b/core/src/test/java/org/apache/gravitino/audit/TestOperation.java
index 5531da02fe..7dca8dfa49 100644
--- a/core/src/test/java/org/apache/gravitino/audit/TestOperation.java
+++ b/core/src/test/java/org/apache/gravitino/audit/TestOperation.java
@@ -607,6 +607,28 @@ public class TestOperation {
AuditLog.Operation.fromEvent(authzDenialNullIdentifier));
}
+ /**
+ * {@code Operation.fromEvent} dispatches on event class, and the extras
support added a second
+ * constructor to each table event. Pins that events built through the new
constructor are still
+ * classified as their operation rather than falling through to {@code
UNKNOWN}.
+ */
+ @Test
+ public void testCreateTableWithAuditExtrasKeepsCreateTableOperation() {
+ Event success =
+ new CreateTableEvent(
+ USER, tableIdentifier, tableInfo, ImmutableMap.of("audit.reason",
"policy-applied"));
+ Event failure =
+ new CreateTableFailureEvent(
+ USER,
+ tableIdentifier,
+ new Exception("create failed"),
+ tableInfo,
+ ImmutableMap.of("audit.reason", "validation-failed"));
+
+ Assertions.assertEquals(AuditLog.Operation.CREATE_TABLE,
AuditLog.Operation.fromEvent(success));
+ Assertions.assertEquals(AuditLog.Operation.CREATE_TABLE,
AuditLog.Operation.fromEvent(failure));
+ }
+
@Test
public void testHttpRequestFailureEventMapsToUnknownOperation() {
// HttpRequestFailureEvent has no specific business operation — it always
maps to UNKNOWN
diff --git
a/core/src/test/java/org/apache/gravitino/listener/TestTableEventDispatcher.java
b/core/src/test/java/org/apache/gravitino/listener/TestTableEventDispatcher.java
new file mode 100644
index 0000000000..e78f864d7c
--- /dev/null
+++
b/core/src/test/java/org/apache/gravitino/listener/TestTableEventDispatcher.java
@@ -0,0 +1,170 @@
+/*
+ * 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;
+
+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.Map;
+import org.apache.gravitino.NameIdentifier;
+import org.apache.gravitino.catalog.TableDispatcher;
+import org.apache.gravitino.listener.api.EventListenerPlugin;
+import org.apache.gravitino.listener.api.event.CreateTableEvent;
+import org.apache.gravitino.listener.api.event.Event;
+import org.apache.gravitino.rel.Column;
+import org.apache.gravitino.rel.Table;
+import org.apache.gravitino.rel.expressions.distributions.Distribution;
+import org.apache.gravitino.rel.expressions.distributions.Distributions;
+import org.apache.gravitino.rel.expressions.sorts.SortOrder;
+import org.apache.gravitino.rel.expressions.transforms.Transform;
+import org.apache.gravitino.rel.indexes.Index;
+import org.apache.gravitino.rel.indexes.Indexes;
+import org.apache.gravitino.rel.types.Types;
+import org.apache.gravitino.utils.RequestContext;
+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 TableEventDispatcher}: how facts
stashed on {@link
+ * RequestContext} reach the emitted event, and how listener failures are
contained once an
+ * operation opts in. Event field mapping itself is covered by {@code
TestTableEvent}.
+ */
+public class TestTableEventDispatcher {
+
+ private static final NameIdentifier IDENT =
+ NameIdentifier.of("metalake", "catalog", "schema", "table");
+
+ @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);
+ doThrow(new RuntimeException("listener
failed")).when(listener).onPostEvent(any(Event.class));
+
+ RuntimeException withoutExtras =
+ Assertions.assertThrows(
+ RuntimeException.class, () -> createTable(dispatcher(listener,
mockTable())));
+ Assertions.assertEquals("listener failed", withoutExtras.getMessage());
+
+ RequestContext.setAuditExtras(ImmutableMap.of("audit.reason",
"policy-applied"));
+ RuntimeException withExtras =
+ Assertions.assertThrows(
+ RuntimeException.class, () -> createTable(dispatcher(listener,
mockTable())));
+ 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: the
fact reaches {@code
+ * customInfo}, and exactly one post event is produced for the operation.
+ */
+ @Test
+ void testCreateWithExtrasDispatchesOneCreateTableEvent() {
+ DummyEventListener listener = new DummyEventListener();
+ TableEventDispatcher dispatcher = dispatcher(listener, mockTable());
+ RequestContext.setAuditExtras(ImmutableMap.of("audit.reason",
"policy-applied"));
+
+ createTable(dispatcher);
+
+ Event event = listener.popPostEvent();
+ Assertions.assertEquals(CreateTableEvent.class, event.getClass());
+ Assertions.assertEquals("policy-applied",
event.customInfo().get("audit.reason"));
+ Assertions.assertTrue(listener.getPostEvents().isEmpty());
+ }
+
+ /**
+ * 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() {
+ DummyEventListener listener = new DummyEventListener();
+ TableEventDispatcher dispatcher = dispatcher(listener, mockTable());
+ RequestContext.setAuditExtras(ImmutableMap.of("audit.reason",
"policy-applied"));
+
+ createTable(dispatcher);
+ Assertions.assertEquals(
+ "policy-applied",
listener.popPostEvent().customInfo().get("audit.reason"));
+
+ createTable(dispatcher);
+
+ Event second = listener.popPostEvent();
+ Assertions.assertEquals(CreateTableEvent.class, second.getClass());
+ Assertions.assertTrue(
+ second.customInfo().isEmpty(), "Extras must not survive into a later
operation");
+ }
+
+ private static TableEventDispatcher dispatcher(EventListenerPlugin listener,
Table table) {
+ TableDispatcher catalog = mock(TableDispatcher.class);
+ when(catalog.createTable(
+ any(NameIdentifier.class),
+ any(Column[].class),
+ any(String.class),
+ any(Map.class),
+ any(Transform[].class),
+ any(Distribution.class),
+ any(SortOrder[].class),
+ any(Index[].class)))
+ .thenReturn(table);
+ return new TableEventDispatcher(new
EventBus(Collections.singletonList(listener)), catalog);
+ }
+
+ private static void createTable(TableEventDispatcher dispatcher) {
+ dispatcher.createTable(
+ IDENT,
+ new Column[] {Column.of("id", Types.LongType.get())},
+ "comment",
+ ImmutableMap.of(),
+ new Transform[0],
+ Distributions.NONE,
+ new SortOrder[0],
+ Indexes.EMPTY_INDEXES);
+ }
+
+ private static Table mockTable() {
+ Table table = mock(Table.class);
+ when(table.name()).thenReturn("table");
+ when(table.comment()).thenReturn("comment");
+ when(table.columns()).thenReturn(new Column[] {Column.of("id",
Types.LongType.get())});
+ when(table.properties()).thenReturn(ImmutableMap.of());
+ when(table.partitioning()).thenReturn(new Transform[0]);
+ when(table.distribution()).thenReturn(Distributions.NONE);
+ when(table.sortOrder()).thenReturn(new SortOrder[0]);
+ when(table.index()).thenReturn(Indexes.EMPTY_INDEXES);
+ when(table.auditInfo()).thenReturn(null);
+ return table;
+ }
+}
diff --git
a/core/src/test/java/org/apache/gravitino/listener/api/event/TestTableEvent.java
b/core/src/test/java/org/apache/gravitino/listener/api/event/TestTableEvent.java
index bd07402602..271911ee7b 100644
---
a/core/src/test/java/org/apache/gravitino/listener/api/event/TestTableEvent.java
+++
b/core/src/test/java/org/apache/gravitino/listener/api/event/TestTableEvent.java
@@ -48,6 +48,8 @@ import
org.apache.gravitino.rel.expressions.transforms.Transforms;
import org.apache.gravitino.rel.indexes.Index;
import org.apache.gravitino.rel.indexes.Indexes;
import org.apache.gravitino.rel.types.Types;
+import org.apache.gravitino.utils.RequestContext;
+import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
@@ -72,6 +74,11 @@ public class TestTableEvent {
this.failureDispatcher = new TableEventDispatcher(eventBus,
tableExceptionDispatcher);
}
+ @AfterEach
+ void clearRequestContext() {
+ RequestContext.clear();
+ }
+
@Test
void testCreateTableEvent() {
NameIdentifier identifier = NameIdentifier.of("metalake", "catalog",
table.name());
@@ -92,6 +99,7 @@ public class TestTableEvent {
checkTableInfo(tableInfo, table);
Assertions.assertEquals(OperationType.CREATE_TABLE, event.operationType());
Assertions.assertEquals(OperationStatus.SUCCESS, event.operationStatus());
+ Assertions.assertTrue(event.customInfo().isEmpty());
PreEvent preEvent = dummyEventListener.popPreEvent();
Assertions.assertEquals(identifier, preEvent.identifier());
@@ -114,6 +122,7 @@ public class TestTableEvent {
checkTableInfo(tableInfo, table);
Assertions.assertEquals(OperationType.LOAD_TABLE, event.operationType());
Assertions.assertEquals(OperationStatus.SUCCESS, event.operationStatus());
+ Assertions.assertTrue(event.customInfo().isEmpty());
PreEvent preEvent = dummyEventListener.popPreEvent();
Assertions.assertEquals(identifier, preEvent.identifier());
@@ -137,6 +146,7 @@ public class TestTableEvent {
Assertions.assertEquals(change, ((AlterTableEvent)
event).tableChanges()[0]);
Assertions.assertEquals(OperationType.ALTER_TABLE, event.operationType());
Assertions.assertEquals(OperationStatus.SUCCESS, event.operationStatus());
+ Assertions.assertTrue(event.customInfo().isEmpty());
PreEvent preEvent = dummyEventListener.popPreEvent();
Assertions.assertEquals(identifier, preEvent.identifier());
@@ -206,6 +216,81 @@ public class TestTableEvent {
Assertions.assertEquals(OperationStatus.UNPROCESSED,
preEvent.operationStatus());
}
+ /**
+ * End-to-end check through the real dispatcher that a stashed fact lands on
the success event and
+ * that the dispatcher consumed the stash. Pins the success half of the
contract that {@code
+ * TestTableEventDispatcher} exercises against mocks.
+ */
+ @Test
+ void testCreateTableEventAttachesStashedExtras() {
+ NameIdentifier identifier = NameIdentifier.of("metalake", "catalog",
table.name());
+ RequestContext.setAuditExtras(ImmutableMap.of("audit.reason",
"policy-applied"));
+ dispatcher.createTable(
+ identifier,
+ table.columns(),
+ table.comment(),
+ table.properties(),
+ table.partitioning(),
+ table.distribution(),
+ table.sortOrder(),
+ table.index());
+
+ Event event = dummyEventListener.popPostEvent();
+ Assertions.assertEquals(CreateTableEvent.class, event.getClass());
+ Assertions.assertEquals("policy-applied",
event.customInfo().get("audit.reason"));
+ dummyEventListener.popPreEvent();
+ Assertions.assertTrue(RequestContext.takeAuditExtras().isEmpty());
+ }
+
+ /**
+ * 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.
The success and failure
+ * paths read the stash in separate branches, so both need pinning.
+ */
+ @Test
+ void testCreateTableFailureEventAttachesStashedExtras() {
+ NameIdentifier identifier = NameIdentifier.of("metalake", "catalog",
table.name());
+ RequestContext.setAuditExtras(ImmutableMap.of("audit.reason",
"validation-failed"));
+ Assertions.assertThrowsExactly(
+ GravitinoRuntimeException.class,
+ () ->
+ failureDispatcher.createTable(
+ identifier,
+ table.columns(),
+ table.comment(),
+ table.properties(),
+ table.partitioning(),
+ table.distribution(),
+ table.sortOrder(),
+ table.index()));
+ Event event = dummyEventListener.popPostEvent();
+ Assertions.assertEquals(CreateTableFailureEvent.class, event.getClass());
+ Assertions.assertEquals("validation-failed",
event.customInfo().get("audit.reason"));
+ }
+
+ /**
+ * Each table operation reads the stash in its own hand-written branch, so
create passing does not
+ * imply alter 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 testAlterAndLoadEventsAttachStashedExtras() {
+ NameIdentifier identifier = NameIdentifier.of("metalake", "catalog",
table.name());
+ RequestContext.setAuditExtras(ImmutableMap.of("audit.reason",
"policy-applied"));
+ dispatcher.alterTable(identifier, TableChange.setProperty("a", "b"));
+ Event alterEvent = dummyEventListener.popPostEvent();
+ Assertions.assertEquals(AlterTableEvent.class, alterEvent.getClass());
+ Assertions.assertEquals("policy-applied",
alterEvent.customInfo().get("audit.reason"));
+ dummyEventListener.popPreEvent();
+
+ RequestContext.setAuditExtras(ImmutableMap.of("audit.reason",
"cache-miss"));
+ dispatcher.loadTable(identifier);
+ Event loadEvent = dummyEventListener.popPostEvent();
+ Assertions.assertEquals(LoadTableEvent.class, loadEvent.getClass());
+ Assertions.assertEquals("cache-miss",
loadEvent.customInfo().get("audit.reason"));
+ dummyEventListener.popPreEvent();
+ }
+
@Test
void testCreateTableFailureEvent() {
NameIdentifier identifier = NameIdentifier.of("metalake", "catalog",
table.name());
diff --git
a/core/src/test/java/org/apache/gravitino/utils/TestRequestContext.java
b/core/src/test/java/org/apache/gravitino/utils/TestRequestContext.java
index 5c70284446..faf707e168 100644
--- a/core/src/test/java/org/apache/gravitino/utils/TestRequestContext.java
+++ b/core/src/test/java/org/apache/gravitino/utils/TestRequestContext.java
@@ -19,6 +19,8 @@
package org.apache.gravitino.utils;
+import java.util.Collections;
+import java.util.Map;
import java.util.concurrent.atomic.AtomicReference;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Assertions;
@@ -67,4 +69,75 @@ public class TestRequestContext {
Assertions.assertEquals(
"main-thread-ip", RequestContext.getRemoteAddress(), "Main thread
value unchanged");
}
+
+ /**
+ * Callers read the stash unconditionally on every table operation, so the
common case is that
+ * nothing was stashed. Pins that this returns an empty map rather than
{@code null}, which keeps
+ * the dispatcher free of a null check on the hot path.
+ */
+ @Test
+ public void testTakeAuditExtrasReturnsEmptyWhenUnset() {
+ Assertions.assertTrue(RequestContext.takeAuditExtras().isEmpty());
+ }
+
+ /**
+ * The stash is a hand-off between two dispatcher layers, not a
request-scoped register: exactly
+ * one reader is expected to consume each set. Pins the take-and-clear
semantics that the
+ * dispatcher relies on to avoid attributing a fact to a later operation.
+ */
+ @Test
+ public void testSetAndTakeAuditExtras() {
+ RequestContext.setAuditExtras(Collections.singletonMap("audit.reason",
"policy-applied"));
+ Assertions.assertEquals("policy-applied",
RequestContext.takeAuditExtras().get("audit.reason"));
+ Assertions.assertTrue(RequestContext.takeAuditExtras().isEmpty(), "take
must clear extras");
+ }
+
+ /**
+ * A contributor that decides it has nothing to report will naturally pass
an empty or {@code
+ * null} map. Pins that this retracts an earlier stash instead of being a
no-op, so a stale fact
+ * from an earlier decision cannot be attached to the event.
+ */
+ @Test
+ public void testEmptyOrNullAuditExtrasClearsStash() {
+ RequestContext.setAuditExtras(Collections.singletonMap("k", "v"));
+ RequestContext.setAuditExtras(Collections.emptyMap());
+ Assertions.assertTrue(RequestContext.takeAuditExtras().isEmpty());
+
+ RequestContext.setAuditExtras(Collections.singletonMap("k", "v"));
+ RequestContext.setAuditExtras(null);
+ Assertions.assertTrue(RequestContext.takeAuditExtras().isEmpty());
+ }
+
+ /**
+ * Extras are a third {@link ThreadLocal} on a pooled servlet thread, so the
request-teardown
+ * sweep has to know about them. Pins that {@code clear()} was extended
alongside the new state
+ * and cannot silently leave it bound to the thread.
+ */
+ @Test
+ public void testClearRemovesAuditExtras() {
+ RequestContext.setAuditExtras(Collections.singletonMap("k", "v"));
+ RequestContext.clear();
+ Assertions.assertTrue(RequestContext.takeAuditExtras().isEmpty());
+ }
+
+ /**
+ * The stash is only safe because a contributor and the dispatcher share one
servlet thread. Pins
+ * that a fact set on one thread is invisible to another, which is the
assumption that makes a
+ * {@link ThreadLocal} acceptable here in place of a threaded-through
parameter.
+ */
+ @Test
+ public void testAuditExtrasAreThreadConfined() throws InterruptedException {
+ RequestContext.setAuditExtras(Collections.singletonMap("audit.reason",
"policy-applied"));
+ AtomicReference<Map<String, String>> childValue = new AtomicReference<>();
+
+ Thread child = new Thread(() ->
childValue.set(RequestContext.takeAuditExtras()));
+ child.start();
+ child.join();
+
+ Assertions.assertTrue(childValue.get().isEmpty(), "Child thread must not
see stashed extras");
+ Assertions.assertEquals(
+ "policy-applied",
+ RequestContext.takeAuditExtras().get("audit.reason"),
+ "Child thread must not consume the parent's stash");
+ }
}