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

yuqi1129 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 50c5b9531e [#11135] improvement(authz): Cache parsed OGNL AST to avoid 
re-parsing per authorization (#12253)
50c5b9531e is described below

commit 50c5b9531e2138eb3e394aeb73aff63f6e1256b2
Author: YangJie <[email protected]>
AuthorDate: Fri Jul 31 21:32:47 2026 +0800

    [#11135] improvement(authz): Cache parsed OGNL AST to avoid re-parsing per 
authorization (#12253)
    
    ### What changes were proposed in this pull request?
    
    `AuthorizationExpressionEvaluator` stored the converted OGNL expression
    as a `String` and evaluated it via `Ognl.getValue(String, ctx)`, which
    re-parses the expression into an AST on every call. This PR parses the
    expression once at construction and caches the AST in a process-level
    `ConcurrentHashMap` keyed by the converted OGNL string, then evaluates
    with `Ognl.getValue(tree, ctx)`.
    
    - The cache keys all originate from compile-time constants in
    `AuthorizationExpressionConstants` and `@AuthorizationExpression`
    annotations, so the map is naturally bounded and needs no eviction.
    - Semantics are preserved: `getValue(String, ctx)` already parses and
    then evaluates against the same context-as-root, so passing the
    pre-parsed tree only skips the re-parse. Verified against the OGNL 3.4.7
    bytecode.
    - The shared tree is evaluated concurrently
    (`MetadataAuthzHelper.doFilter` runs on a thread pool). This is safe:
    OGNL only memoizes context-independent constant nodes during evaluation,
    every thread writes the same immutable value, publication is guarded by
    a volatile flag, and each evaluation builds its own `OgnlContext`.
    - Invalid expressions now fail fast at construction instead of on first
    evaluation.
    
    ### Why are the changes needed?
    
    On every authorized request the evaluator is constructed and evaluated,
    and in the list-filter fallback (`MetadataAuthzHelper.doFilter`) once
    per listed object, so the repeated parse cost scales with request volume
    and namespace size. Caching the parsed AST removes the redundant parse
    from these hot paths.
    
    Fix: #11135
    
    ### Does this PR introduce _any_ user-facing change?
    
    No.
    
    ### How was this patch tested?
    
    Added unit tests in `TestAuthorizationExpressionEvaluator`:
    - parsed AST is reused across evaluators for the same expression, and
    differs for different expressions;
    - concurrent evaluation on a shared AST (16 threads x 200 iterations)
    produces correct allow/deny results with no error;
    - an invalid expression fails fast at construction with the
    `OgnlException` cause preserved.
    
    Existing allow/deny/owner regression tests continue to guard evaluation
    semantics. Ran `./gradlew :server-common:test --tests
    "*AuthorizationExpressionEvaluator*"`, plus `spotlessApply` and
    `javadoc`.
---
 .../AuthorizationExpressionEvaluator.java          |  67 ++++++++++-
 .../TestAuthorizationExpressionEvaluator.java      | 127 +++++++++++++++++++++
 2 files changed, 188 insertions(+), 6 deletions(-)

diff --git 
a/server-common/src/main/java/org/apache/gravitino/server/authorization/expression/AuthorizationExpressionEvaluator.java
 
b/server-common/src/main/java/org/apache/gravitino/server/authorization/expression/AuthorizationExpressionEvaluator.java
index 59d5375774..0311de01f0 100644
--- 
a/server-common/src/main/java/org/apache/gravitino/server/authorization/expression/AuthorizationExpressionEvaluator.java
+++ 
b/server-common/src/main/java/org/apache/gravitino/server/authorization/expression/AuthorizationExpressionEvaluator.java
@@ -17,12 +17,14 @@
 
 package org.apache.gravitino.server.authorization.expression;
 
+import com.google.common.annotations.VisibleForTesting;
 import java.security.Principal;
 import java.util.Arrays;
 import java.util.HashMap;
 import java.util.Map;
 import java.util.Objects;
 import java.util.Optional;
+import java.util.concurrent.ConcurrentHashMap;
 import ognl.Ognl;
 import ognl.OgnlContext;
 import ognl.OgnlException;
@@ -40,12 +42,28 @@ import org.slf4j.LoggerFactory;
 /** Evaluate the runtime result of the AuthorizationExpression. */
 public class AuthorizationExpressionEvaluator {
 
-  private final String ognlAuthorizationExpression;
-  private final GravitinoAuthorizer authorizer;
-
   private static final Logger LOGGER =
       LoggerFactory.getLogger(AuthorizationExpressionEvaluator.class);
 
+  /**
+   * Caches the OGNL AST parsed from an authorization expression string, so a 
single parsed tree can
+   * be reused across evaluators and threads instead of re-parsing on every 
{@link #evaluate} call.
+   *
+   * <p>The shared tree is evaluated concurrently (see {@code 
MetadataAuthzHelper.doFilter}, which
+   * evaluates on a thread pool). This is safe even though OGNL nodes lazily 
memoize constant
+   * subexpressions during evaluation: only context-independent nodes are 
folded, every thread
+   * writes the same immutable value, the write is published through a 
volatile flag, and each
+   * evaluation builds its own {@link OgnlContext}, so no per-request state is 
shared.
+   *
+   * <p>The keys are the converted OGNL expression strings, which all 
originate from compile-time
+   * constants in {@link AuthorizationExpressionConstants} and {@code 
@AuthorizationExpression}
+   * annotations, so the cache is naturally bounded.
+   */
+  private static final Map<String, Object> PARSED_EXPRESSION_CACHE = new 
ConcurrentHashMap<>();
+
+  private final Object ognlAuthorizationExpressionTree;
+  private final GravitinoAuthorizer authorizer;
+
   /**
    * Use {@link AuthorizationExpressionConverter} to convert the authorization 
expression into an
    * OGNL expression, and then call {@link GravitinoAuthorizer} to perform 
permission verification.
@@ -64,11 +82,31 @@ public class AuthorizationExpressionEvaluator {
    * @param authorizer GravitinoAuthorizer instance
    */
   public AuthorizationExpressionEvaluator(String expression, 
GravitinoAuthorizer authorizer) {
-    this.ognlAuthorizationExpression =
-        AuthorizationExpressionConverter.convertToOgnlExpression(expression);
+    this.ognlAuthorizationExpressionTree =
+        
parseExpression(AuthorizationExpressionConverter.convertToOgnlExpression(expression));
     this.authorizer = authorizer;
   }
 
+  /**
+   * Parses the OGNL expression into an AST, reusing the cached tree when the 
same expression was
+   * parsed before.
+   *
+   * @param ognlExpression the converted OGNL expression string
+   * @return the parsed OGNL AST, shared across evaluators for the same 
expression
+   */
+  private static Object parseExpression(String ognlExpression) {
+    return PARSED_EXPRESSION_CACHE.computeIfAbsent(
+        ognlExpression,
+        expression -> {
+          try {
+            return Ognl.parseExpression(expression);
+          } catch (OgnlException e) {
+            throw new RuntimeException(
+                "Failed to parse OGNL authorization expression: " + 
expression, e);
+          }
+        });
+  }
+
   /**
    * Use OGNL expressions to invoke GravitinoAuthorizer for authorizing 
multiple types of metadata
    * IDs.
@@ -155,7 +193,7 @@ public class AuthorizationExpressionEvaluator {
     ognlContext.put(
         "METALAKE_NAME", 
Optional.ofNullable(nameIdentifier).map(NameIdentifier::name).orElse(""));
     try {
-      return (boolean) Ognl.getValue(ognlAuthorizationExpression, ognlContext);
+      return (boolean) Ognl.getValue(ognlAuthorizationExpressionTree, 
ognlContext);
     } catch (OgnlException e) {
       throw new RuntimeException(e);
     }
@@ -165,4 +203,21 @@ public class AuthorizationExpressionEvaluator {
     return Arrays.stream(MetadataObject.Type.values())
         .anyMatch(e -> Objects.equals(e.name(), type.name()));
   }
+
+  /**
+   * Returns the parsed OGNL AST backing this evaluator, for tests asserting 
that the same
+   * expression reuses one cached tree.
+   *
+   * @return the parsed OGNL AST
+   */
+  @VisibleForTesting
+  Object getOgnlAuthorizationExpressionTree() {
+    return ognlAuthorizationExpressionTree;
+  }
+
+  /** Clears the parsed-expression cache so tests can run in isolation. */
+  @VisibleForTesting
+  static void clearParsedExpressionCache() {
+    PARSED_EXPRESSION_CACHE.clear();
+  }
 }
diff --git 
a/server-common/src/test/java/org/apache/gravitino/server/authorization/expression/TestAuthorizationExpressionEvaluator.java
 
b/server-common/src/test/java/org/apache/gravitino/server/authorization/expression/TestAuthorizationExpressionEvaluator.java
index ed3f291dc8..03f2549933 100644
--- 
a/server-common/src/test/java/org/apache/gravitino/server/authorization/expression/TestAuthorizationExpressionEvaluator.java
+++ 
b/server-common/src/test/java/org/apache/gravitino/server/authorization/expression/TestAuthorizationExpressionEvaluator.java
@@ -23,6 +23,15 @@ import static org.mockito.Mockito.when;
 
 import java.util.HashMap;
 import java.util.Map;
+import java.util.Optional;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+import ognl.OgnlException;
 import org.apache.gravitino.Entity;
 import org.apache.gravitino.NameIdentifier;
 import org.apache.gravitino.UserPrincipal;
@@ -32,12 +41,18 @@ import 
org.apache.gravitino.server.authorization.MockGravitinoAuthorizer;
 import org.apache.gravitino.utils.NameIdentifierUtil;
 import org.apache.gravitino.utils.PrincipalUtils;
 import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 import org.mockito.MockedStatic;
 
 /** Test for {@link AuthorizationExpressionEvaluator} */
 public class TestAuthorizationExpressionEvaluator {
 
+  @BeforeEach
+  public void clearCache() {
+    AuthorizationExpressionEvaluator.clearParsedExpressionCache();
+  }
+
   @Test
   public void testEvaluator() {
     String expression =
@@ -109,4 +124,116 @@ public class TestAuthorizationExpressionEvaluator {
               metadataNames, new AuthorizationRequestContext()));
     }
   }
+
+  @Test
+  public void testParsedExpressionTreeIsReused() {
+    String expression = "CATALOG::USE_CATALOG && SCHEMA::USE_SCHEMA";
+    try (MockedStatic<GravitinoAuthorizerProvider> mockStatic =
+        mockStatic(GravitinoAuthorizerProvider.class)) {
+      GravitinoAuthorizerProvider mockedProvider = 
mock(GravitinoAuthorizerProvider.class);
+      
mockStatic.when(GravitinoAuthorizerProvider::getInstance).thenReturn(mockedProvider);
+      when(mockedProvider.getGravitinoAuthorizer()).thenReturn(new 
MockGravitinoAuthorizer());
+
+      AuthorizationExpressionEvaluator first = new 
AuthorizationExpressionEvaluator(expression);
+      AuthorizationExpressionEvaluator second = new 
AuthorizationExpressionEvaluator(expression);
+      Assertions.assertSame(
+          first.getOgnlAuthorizationExpressionTree(),
+          second.getOgnlAuthorizationExpressionTree(),
+          "The same expression should reuse one cached parsed AST");
+
+      AuthorizationExpressionEvaluator other =
+          new AuthorizationExpressionEvaluator("CATALOG::USE_CATALOG");
+      Assertions.assertNotSame(
+          first.getOgnlAuthorizationExpressionTree(),
+          other.getOgnlAuthorizationExpressionTree(),
+          "Different expressions should not share a parsed AST");
+    }
+  }
+
+  @Test
+  public void testConcurrentEvaluationOnSharedTree() throws Exception {
+    String expression =
+        "CATALOG::USE_CATALOG && SCHEMA::USE_SCHEMA && (TABLE::SELECT_TABLE || 
TABLE::MODIFY_TABLE)";
+    // No static mocks here: the evaluate overload takes an explicit principal 
and the constructor
+    // an
+    // explicit authorizer, so worker threads never depend on thread-confined 
Mockito static mocks.
+    UserPrincipal principal = new UserPrincipal("tester");
+    AuthorizationExpressionEvaluator evaluator =
+        new AuthorizationExpressionEvaluator(expression, new 
MockGravitinoAuthorizer());
+
+    int threads = 16;
+    int iterations = 200;
+    ExecutorService pool = Executors.newFixedThreadPool(threads);
+    CountDownLatch startGate = new CountDownLatch(1);
+    AtomicInteger failures = new AtomicInteger();
+    AtomicReference<Throwable> firstError = new AtomicReference<>();
+    Future<?>[] results = new Future<?>[threads];
+    for (int t = 0; t < threads; t++) {
+      boolean expectAuthorized = t % 2 == 0;
+      results[t] =
+          pool.submit(
+              () -> {
+                try {
+                  startGate.await();
+                  for (int i = 0; i < iterations; i++) {
+                    boolean actual =
+                        evaluator.evaluate(
+                            metadataNames(expectAuthorized),
+                            new AuthorizationRequestContext(),
+                            principal,
+                            Optional.empty());
+                    if (actual != expectAuthorized) {
+                      failures.incrementAndGet();
+                    }
+                  }
+                } catch (Throwable e) {
+                  failures.incrementAndGet();
+                  firstError.compareAndSet(null, e);
+                }
+              });
+    }
+    startGate.countDown();
+    for (Future<?> result : results) {
+      result.get(30, TimeUnit.SECONDS);
+    }
+    pool.shutdownNow();
+    Assertions.assertEquals(
+        0,
+        failures.get(),
+        "Concurrent evaluation on a shared AST produced wrong or failed 
results; first error: "
+            + firstError.get());
+  }
+
+  @Test
+  public void testConstructorFailsFastOnInvalidExpression() {
+    try (MockedStatic<AuthorizationExpressionConverter> converterMocked =
+        mockStatic(AuthorizationExpressionConverter.class)) {
+      converterMocked
+          .when(() -> 
AuthorizationExpressionConverter.convertToOgnlExpression("BAD"))
+          .thenReturn("a b c ((");
+      RuntimeException e =
+          Assertions.assertThrows(
+              RuntimeException.class,
+              () -> new AuthorizationExpressionEvaluator("BAD", new 
MockGravitinoAuthorizer()));
+      Assertions.assertInstanceOf(OgnlException.class, e.getCause());
+    }
+  }
+
+  private static Map<Entity.EntityType, NameIdentifier> metadataNames(boolean 
authorized) {
+    Map<Entity.EntityType, NameIdentifier> metadataNames = new HashMap<>();
+    metadataNames.put(Entity.EntityType.METALAKE, 
NameIdentifierUtil.ofMetalake("testMetalake"));
+    metadataNames.put(
+        Entity.EntityType.CATALOG, 
NameIdentifierUtil.ofCatalog("testMetalake", "testCatalog"));
+    metadataNames.put(
+        Entity.EntityType.SCHEMA,
+        NameIdentifierUtil.ofSchema("testMetalake", "testCatalog", 
"testSchema"));
+    metadataNames.put(
+        Entity.EntityType.TABLE,
+        NameIdentifierUtil.ofTable(
+            "testMetalake",
+            "testCatalog",
+            "testSchema",
+            authorized ? "testTable" : "testTableHasNotPermission"));
+    return metadataNames;
+  }
 }

Reply via email to