Fix singleton stack-corruption NPE in DatetimeUdtNormalizeRule - #5458
Conversation
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>
PR Reviewer Guide 🔍(Review updated until commit 0c1b701)Here are some key observations to aid the review process:
|
PR Code Suggestions ✨Latest suggestions up to 0c1b701 Explore these optional code suggestions:
Previous suggestionsSuggestions up to commit 885052e
Suggestions up to commit 35bf78f
|
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>
|
Persistent review updated to latest commit 6e7e1f0 |
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>
|
Persistent review updated to latest commit 885052e |
| * --tests org.opensearch.sql.calcite.remote.CalciteDatetimeUdtNormalizeRegressionIT | ||
| * }</pre> | ||
| */ | ||
| public class CalciteDatetimeUdtNormalizeRegressionIT extends PPLIntegTestCase { |
There was a problem hiding this comment.
np: we can rename this as general test for our planner.
There was a problem hiding this comment.
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.
@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>
|
Persistent review updated to latest commit 0c1b701 |
1 similar comment
|
Persistent review updated to latest commit 0c1b701 |
…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>
What this fixes
Some PPL queries to parquet-backed indices return HTTP 500 with
NoSuchElementExceptionwhen 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:
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 tovarchar (
DatetimeOutputCastRule). Both extendRelHomogeneousShuttle, whichin Calcite inherits a
Deque<RelNode> stackfield used internally forpush/pop during tree traversal. That stack is a plain non-thread-safe
ArrayDeque.DatetimeExtension.postAnalysisRules()returned a staticINSTANCEof eachrule, 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. Theirpushandpopoperations on the sharedArrayDequeinterleave, leaving residual entries on the stack. A subsequenttraversal then tries to
pop()an entry that isn't there, and the clusterreturns 500 with this stack trace:
RelShuttleImplwas never designed to be a singleton: the stack is per-shuttlemutable 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.javanow 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
INSTANCEconstants and the Lombok@NoArgsConstructor(PRIVATE)areremoved from both rules. Class JavaDoc on each rule now explicitly warns
against making it a singleton again.
Test plan
testConcurrentStatsDistinctCountOverDatetimetestConcurrentMixedDatetimePlansINSTANCE)NoSuchElementException(HTTP 500)plan())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 an8-thread pool against a parquet-backed
DATE_FORMATSindex with multipledatetime columns — mirroring the dashboard field-stats workload.
:api:testand:core:testpass:api:spotlessCheckpassestestSequentialPlanCallsDoNotCorruptShuttleStackinDatetimeExtensionTestguards against future reintroduction of thesingleton