Skip to content

Fix singleton stack-corruption NPE in DatetimeUdtNormalizeRule - #5458

Merged
ahkcs merged 4 commits into
opensearch-project:mainfrom
ahkcs:fix/dashboard-ppl-queries
May 21, 2026
Merged

Fix singleton stack-corruption NPE in DatetimeUdtNormalizeRule#5458
ahkcs merged 4 commits into
opensearch-project:mainfrom
ahkcs:fix/dashboard-ppl-queries

Conversation

@ahkcs

@ahkcs ahkcs commented May 20, 2026

Copy link
Copy Markdown
Collaborator

What this fixes

Some PPL queries to parquet-backed indices return HTTP 500 with
NoSuchElementException when several queries hit the cluster at the same time
— the failure mode reported on dashboard "field statistics" panels that probe
many fields in parallel. Affected query shape:

source = my_index | stats count() as field_count, distinct_count(<datetime_field>) as distinct_count

The failure is intermittent: same query passes or fails depending on what
other queries run at the same time.

Why it happens

The unified query API runs two Calcite rewrites on every plan — one normalizes
datetime UDT types (DatetimeUdtNormalizeRule), one casts datetime output to
varchar (DatetimeOutputCastRule). Both extend RelHomogeneousShuttle, which
in Calcite inherits a Deque<RelNode> stack field used internally for
push/pop during tree traversal. That stack is a plain non-thread-safe
ArrayDeque.

DatetimeExtension.postAnalysisRules() returned a static INSTANCE of each
rule, so every plan() call shared the same shuttle — and the same stack.
When dashboard issues N field-stats queries at once, N cluster threads call
plan() concurrently. Their push and pop operations on the shared
ArrayDeque interleave, leaving residual entries on the stack. A subsequent
traversal then tries to pop() an entry that isn't there, and the cluster
returns 500 with this stack trace:

java.util.NoSuchElementException
  at java.util.ArrayDeque.pop(ArrayDeque.java:591)
  at org.apache.calcite.rel.RelShuttleImpl.visitChild(RelShuttleImpl.java:67)  ← finally{ stack.pop() }
  at org.apache.calcite.rel.RelShuttleImpl.visit(RelShuttleImpl.java:151)
  at org.opensearch.sql.api.spec.datetime.DatetimeUdtNormalizeRule.visit(DatetimeUdtNormalizeRule.java:33)
  at org.apache.calcite.rel.RelHomogeneousShuttle.visit(RelHomogeneousShuttle.java:43)
  at org.apache.calcite.rel.logical.LogicalAggregate.accept(LogicalAggregate.java:159)

RelShuttleImpl was never designed to be a singleton: the stack is per-shuttle
mutable state. Sharing it across queries is unsafe regardless of timing — the
race just amplifies the chance of a visible failure.

What this PR changes

api/src/main/java/org/opensearch/sql/api/spec/datetime/DatetimeExtension.java
now returns a fresh instance per plan() call:

   @Override
   public List<RelShuttle> postAnalysisRules() {
-    return List.of(DatetimeUdtNormalizeRule.INSTANCE, DatetimeOutputCastRule.INSTANCE);
+    // Fresh instances per plan() because RelHomogeneousShuttle inherits a stateful stack.
+    return List.of(new DatetimeUdtNormalizeRule(), new DatetimeOutputCastRule());
   }

The INSTANCE constants and the Lombok @NoArgsConstructor(PRIVATE) are
removed from both rules. Class JavaDoc on each rule now explicitly warns
against making it a singleton again.

Test plan

Cluster state testConcurrentStatsDistinctCountOverDatetime testConcurrentMixedDatetimePlans
Without fix (singleton INSTANCE) 2/80 queries fail with NoSuchElementException (HTTP 500) 3/80 fail with same error
With fix (fresh instances per plan()) 80/80 pass 80/80 pass

Reproduced and verified end-to-end via the analytics-engine path with
parquet-backed indices:

./gradlew :integ-test:integTestRemote \
  -Dtests.rest.cluster=localhost:9200 -Dtests.cluster=localhost:9300 \
  -Dtests.clustername=runTask \
  -Dtests.analytics.force_routing=true \
  -Dtests.analytics.parquet_indices=true \
  --tests "*CalciteDatetimeUdtNormalizeRegressionIT"

The IT (CalciteDatetimeUdtNormalizeRegressionIT) fires 80 queries across an
8-thread pool against a parquet-backed DATE_FORMATS index with multiple
datetime columns — mirroring the dashboard field-stats workload.

  • :api:test and :core:test pass
  • :api:spotlessCheck passes
  • Concurrent IT reliably reproduces the failure against unfixed code
  • Same IT passes 0/80 failures against fixed code
  • Unit test testSequentialPlanCallsDoNotCorruptShuttleStack in
    DatetimeExtensionTest guards against future reintroduction of the
    singleton

DatetimeUdtNormalizeRule and DatetimeOutputCastRule extend
RelHomogeneousShuttle, which inherits a stateful Deque<RelNode> stack
from RelShuttleImpl. DatetimeExtension.postAnalysisRules() returned
the static INSTANCE of each rule, sharing the same shuttle (and the
same stack) across every UnifiedQueryPlanner.plan() invocation.

If any traversal ever ends with an unbalanced stack, residual entries
persist to the next query. The next query's visitChild() then pops a
stale or empty stack and throws NoSuchElementException at
RelShuttleImpl.visitChild line 67 (the stack.pop() in the finally
block) — surfacing as the cluster-side stack trace reported on
analytics-engine-routed parquet indices for queries that combine
aggregations over datetime UDT columns (e.g.
"stats count() as field_count, distinct_count(field)").

Return fresh instances per plan() instead. Drop the INSTANCE
constants and the Lombok @NoArgsConstructor on both rules; document
the singleton-unsafety on each class JavaDoc.

Add a regression test that runs several plan() calls in sequence
against the same context, covering stats+distinct_count over both
schema-declared and eval-derived datetime columns.

Signed-off-by: Kai Huang <ahkcs@amazon.com>
@github-actions

github-actions Bot commented May 20, 2026

Copy link
Copy Markdown
Contributor

PR Reviewer Guide 🔍

(Review updated until commit 0c1b701)

Here are some key observations to aid the review process:

🧪 PR contains tests
🔒 No security concerns identified
✅ No TODO sections
🔀 No multiple PR themes
⚡ No major issues detected

@github-actions

github-actions Bot commented May 20, 2026

Copy link
Copy Markdown
Contributor

PR Code Suggestions ✨

Latest suggestions up to 0c1b701

Explore these optional code suggestions:

CategorySuggestion                                                                                                                                    Impact
General
Handle timeout exceptions properly

The timeout exception is caught but not added to the errors list. Add
TimeoutException handling to track queries that exceed the 60-second limit, ensuring
all failure modes are properly reported in the assertion message.

integ-test/src/test/java/org/opensearch/sql/calcite/remote/CalcitePlannerConcurrencyIT.java [132]

-f.get(60, TimeUnit.SECONDS);
+try {
+  f.get(60, TimeUnit.SECONDS);
+} catch (java.util.concurrent.TimeoutException e) {
+  failures.incrementAndGet();
+  synchronized (errors) {
+    errors.add(e);
+  }
+}
Suggestion importance[1-10]: 7

__

Why: The suggestion correctly identifies that TimeoutException is not being caught and tracked. This is a valid improvement for comprehensive error reporting in the test, though the test would still fail appropriately without it since the timeout would propagate.

Medium
Force shutdown on termination timeout

If awaitTermination times out, the executor may still have running tasks. Call
shutdownNow() if termination fails to forcefully stop any lingering threads and
prevent resource leaks.

integ-test/src/test/java/org/opensearch/sql/calcite/remote/CalcitePlannerConcurrencyIT.java [151-152]

 executor.shutdown();
-executor.awaitTermination(30, TimeUnit.SECONDS);
+if (!executor.awaitTermination(30, TimeUnit.SECONDS)) {
+  executor.shutdownNow();
+}
Suggestion importance[1-10]: 6

__

Why: Valid suggestion to prevent potential resource leaks if awaitTermination times out. Using shutdownNow() is a best practice for executor cleanup, though in a test context the impact is less critical than in production code.

Low

Previous suggestions

Suggestions up to commit 885052e
CategorySuggestion                                                                                                                                    Impact
Possible issue
Handle TimeoutException in concurrent test

The get() call can throw TimeoutException which is not caught, potentially causing
the test to fail unexpectedly. Add a catch block for TimeoutException to handle
timeout scenarios gracefully and include them in the failure count.

integ-test/src/test/java/org/opensearch/sql/calcite/remote/CalciteDatetimeUdtNormalizeRegressionIT.java [128-137]

 for (CompletableFuture<Void> f : futures) {
   try {
     f.get(60, TimeUnit.SECONDS);
   } catch (ExecutionException e) {
     failures.incrementAndGet();
     synchronized (errors) {
       errors.add(e.getCause());
     }
+  } catch (TimeoutException e) {
+    failures.incrementAndGet();
+    synchronized (errors) {
+      errors.add(e);
+    }
   }
 }
Suggestion importance[1-10]: 7

__

Why: The suggestion correctly identifies that f.get(60, TimeUnit.SECONDS) can throw TimeoutException, which is currently not caught. This could cause the test to fail unexpectedly without proper error tracking. Adding the catch block improves error handling robustness in the concurrent test scenario.

Medium
Suggestions up to commit 35bf78f
CategorySuggestion                                                                                                                                    Impact
General
Add assertions for plan execution

The test executes planner.plan() calls without capturing or asserting on their
results. Add assertions to verify the plans execute successfully without throwing
exceptions, ensuring the regression is properly validated.

api/src/test/java/org/opensearch/sql/api/spec/datetime/DatetimeExtensionTest.java [183-193]

 for (int i = 0; i < 5; i++) {
-  planner.plan(
+  assertDoesNotThrow(() -> planner.plan(
       "source = catalog.events"
-          + " | stats count() as field_count, distinct_count(created_at) as distinct_count");
-  planner.plan(
+          + " | stats count() as field_count, distinct_count(created_at) as distinct_count"));
+  assertDoesNotThrow(() -> planner.plan(
       "source = catalog.events"
           + " | eval ts = TIMESTAMP(name)"
-          + " | stats count() as field_count, distinct_count(ts) as distinct_count");
-  planner.plan(
-      "source = catalog.events | where created_at > \"2024-01-01\" | fields hire_date");
+          + " | stats count() as field_count, distinct_count(ts) as distinct_count"));
+  assertDoesNotThrow(() -> planner.plan(
+      "source = catalog.events | where created_at > \"2024-01-01\" | fields hire_date"));
 }
Suggestion importance[1-10]: 5

__

Why: While adding assertDoesNotThrow() would make the test more explicit, the current implementation already validates that no exceptions are thrown - if any planner.plan() call threw an exception, the test would fail. The suggestion improves test clarity but doesn't fix a critical issue.

Low

@ahkcs ahkcs added the enhancement New feature or request label May 20, 2026
CalciteDatetimeUdtNormalizeRegressionIT exercises the failure pattern
that triggered the cluster-side NoSuchElementException:
stats + distinct_count over datetime columns, repeated 20 times to
amplify any plan() carry-over.

The IT is harness-aware:
- Without `-Dtests.analytics.force_routing=true`: queries go through
  the V2 / Calcite engine path. The DatetimeUdtNormalizeRule path is
  not exercised, so the IT passes as a baseline correctness check.
- With `-Dtests.analytics.force_routing=true
  -Dtests.analytics.parquet_indices=true`: every query routes through
  the analytics-engine path and hits the DatetimeUdtNormalizeRule
  shuttle that this PR fixes. The 20-iteration pattern surfaces any
  remaining singleton-stack carry-over.

CI's :integTest task (in-process testCluster without analytics-engine)
runs the IT through the V2 path, which is safe and fast. The
analytics-engine verification path is via :integTestRemote against an
externally-managed cluster built per
`docs/dev/ppl-analytics-engine-routing.md`.

Signed-off-by: Kai Huang <ahkcs@amazon.com>
@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit 6e7e1f0

RyanL1997
RyanL1997 previously approved these changes May 20, 2026
The sequential iteration variant passed even with the singleton bug in
place — local cluster doesn't carry over enough state between calls in
one thread. The actual production trigger is parallel queries from a
dashboard "field statistics" panel: multiple cluster threads call
plan() simultaneously, all using the shared singleton's non-thread-safe
ArrayDeque. Their push/pop operations interleave and corrupt the stack.

Verified locally against analytics-engine path with parquet indices:
- Unfixed cluster: 2-3 / 80 queries fail with NoSuchElementException
  (HTTP 500), matching the production stack trace exactly.
- Fixed cluster: 0 / 80 failures.

Uses CompletableFuture + 8-thread pool to fire 80 queries per test
across:
- testConcurrentStatsDistinctCountOverDatetime: same shape, varied
  datetime fields.
- testConcurrentMixedDatetimePlans: three different plan shapes
  interleaved — mixed visitChild call counts amplify the race.

Signed-off-by: Kai Huang <ahkcs@amazon.com>
@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit 885052e

@ahkcs
ahkcs requested a review from RyanL1997 May 20, 2026 22:55
dai-chen
dai-chen previously approved these changes May 20, 2026

@dai-chen dai-chen left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the fix!

* --tests org.opensearch.sql.calcite.remote.CalciteDatetimeUdtNormalizeRegressionIT
* }</pre>
*/
public class CalciteDatetimeUdtNormalizeRegressionIT extends PPLIntegTestCase {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

np: we can rename this as general test for our planner.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Renamed to CalcitePlannerConcurrencyIT and reframed the JavaDoc as a general bucket for planner-level concurrency / state-isolation regressions. The current test methods are noted as the motivating regression — future planner-concurrency tests can land alongside. Pushed as 0c1b7017a.

RyanL1997
RyanL1997 previously approved these changes May 20, 2026
@dai-chen flagged that the IT name was over-scoped to a single rule and
the file would read better as a general bucket for planner-level
concurrency / state-isolation regressions. The actual surface under test
is UnifiedQueryPlanner's post-analysis pipeline — any RelShuttle
extension that doesn't isolate per-call state is unsafe under concurrent
load, not just the datetime rules.

Renames the file and class, updates the JavaDoc to describe the planner-
level invariant rather than the specific Datetime* rules, and notes the
current cases as the regression that motivated the suite. Test method
bodies and assertions are unchanged.

Signed-off-by: Kai Huang <ahkcs@amazon.com>
@ahkcs
ahkcs dismissed stale reviews from RyanL1997 and dai-chen via 0c1b701 May 21, 2026 16:53
@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit 0c1b701

1 similar comment
@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit 0c1b701

@ahkcs
ahkcs requested review from RyanL1997 and dai-chen May 21, 2026 17:50
@ahkcs
ahkcs merged commit c4cac2a into opensearch-project:main May 21, 2026
72 of 77 checks passed
asifabashar pushed a commit to asifabashar/sql that referenced this pull request Jul 21, 2026
…earch-project#5458)

* fix: instantiate DatetimeUdt normalize/output-cast rules per plan() call

DatetimeUdtNormalizeRule and DatetimeOutputCastRule extend
RelHomogeneousShuttle, which inherits a stateful Deque<RelNode> stack
from RelShuttleImpl. DatetimeExtension.postAnalysisRules() returned
the static INSTANCE of each rule, sharing the same shuttle (and the
same stack) across every UnifiedQueryPlanner.plan() invocation.

If any traversal ever ends with an unbalanced stack, residual entries
persist to the next query. The next query's visitChild() then pops a
stale or empty stack and throws NoSuchElementException at
RelShuttleImpl.visitChild line 67 (the stack.pop() in the finally
block) — surfacing as the cluster-side stack trace reported on
analytics-engine-routed parquet indices for queries that combine
aggregations over datetime UDT columns (e.g.
"stats count() as field_count, distinct_count(field)").

Return fresh instances per plan() instead. Drop the INSTANCE
constants and the Lombok @NoArgsConstructor on both rules; document
the singleton-unsafety on each class JavaDoc.

Add a regression test that runs several plan() calls in sequence
against the same context, covering stats+distinct_count over both
schema-declared and eval-derived datetime columns.

Signed-off-by: Kai Huang <ahkcs@amazon.com>

* test: add analytics-engine regression IT for singleton stack-corruption

CalciteDatetimeUdtNormalizeRegressionIT exercises the failure pattern
that triggered the cluster-side NoSuchElementException:
stats + distinct_count over datetime columns, repeated 20 times to
amplify any plan() carry-over.

The IT is harness-aware:
- Without `-Dtests.analytics.force_routing=true`: queries go through
  the V2 / Calcite engine path. The DatetimeUdtNormalizeRule path is
  not exercised, so the IT passes as a baseline correctness check.
- With `-Dtests.analytics.force_routing=true
  -Dtests.analytics.parquet_indices=true`: every query routes through
  the analytics-engine path and hits the DatetimeUdtNormalizeRule
  shuttle that this PR fixes. The 20-iteration pattern surfaces any
  remaining singleton-stack carry-over.

CI's :integTest task (in-process testCluster without analytics-engine)
runs the IT through the V2 path, which is safe and fast. The
analytics-engine verification path is via :integTestRemote against an
externally-managed cluster built per
`docs/dev/ppl-analytics-engine-routing.md`.

Signed-off-by: Kai Huang <ahkcs@amazon.com>

* test: switch regression IT to concurrent query pattern

The sequential iteration variant passed even with the singleton bug in
place — local cluster doesn't carry over enough state between calls in
one thread. The actual production trigger is parallel queries from a
dashboard "field statistics" panel: multiple cluster threads call
plan() simultaneously, all using the shared singleton's non-thread-safe
ArrayDeque. Their push/pop operations interleave and corrupt the stack.

Verified locally against analytics-engine path with parquet indices:
- Unfixed cluster: 2-3 / 80 queries fail with NoSuchElementException
  (HTTP 500), matching the production stack trace exactly.
- Fixed cluster: 0 / 80 failures.

Uses CompletableFuture + 8-thread pool to fire 80 queries per test
across:
- testConcurrentStatsDistinctCountOverDatetime: same shape, varied
  datetime fields.
- testConcurrentMixedDatetimePlans: three different plan shapes
  interleaved — mixed visitChild call counts amplify the race.

Signed-off-by: Kai Huang <ahkcs@amazon.com>

* test: rename to CalcitePlannerConcurrencyIT (review nit)

@dai-chen flagged that the IT name was over-scoped to a single rule and
the file would read better as a general bucket for planner-level
concurrency / state-isolation regressions. The actual surface under test
is UnifiedQueryPlanner's post-analysis pipeline — any RelShuttle
extension that doesn't isolate per-call state is unsafe under concurrent
load, not just the datetime rules.

Renames the file and class, updates the JavaDoc to describe the planner-
level invariant rather than the specific Datetime* rules, and notes the
current cases as the regression that motivated the suite. Test method
bodies and assertions are unchanged.

Signed-off-by: Kai Huang <ahkcs@amazon.com>

---------

Signed-off-by: Kai Huang <ahkcs@amazon.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants