Skip to content

Prune indices by request-level time bounds before the schema is resolved - #5766

Open
ahkcs wants to merge 16 commits into
opensearch-project:mainfrom
ahkcs:feat/time-range-index-pruning
Open

ahkcs wants to merge 16 commits into
opensearch-project:mainfrom
ahkcs:feat/time-range-index-pruning

Conversation

@ahkcs

@ahkcs ahkcs commented Sep 14, 2026

Copy link
Copy Markdown
Collaborator

Description

Implements the schema-conflict half of Approach 1 that #5727 left open, as Approach 3 of that design: a request-level time range, applied while the table is still being resolved.

A wildcard index expression is expanded — and the mapping of every index it matches merged — before any predicate in the query text has been parsed, so nothing downstream of that resolution can narrow it. #5727 reads its range from the filter already sitting in the pushdown context, which is the broadest coverage available without touching the request contract but runs after the merge. Declaring the range on the request puts it in hand before planning starts:

start_time / end_time / time_field
  → PPLQueryRequest.getTimeBounds
  → AbstractPlan → QueryService.buildFrameworkConfig   (seeded once)
  → OpenSearchSchema.registerTable
  → OpenSearchStorageEngine.getTable(.., TimeBounds)
  → IndexPruner → OpenSearchIndex                      (constructed pre-narrowed)

Following the design's shape:

  • Bounds are global — applied to every table the query resolves rather than attributed to one relation, the scope Splunk's time range picker and ES|QL's request-level filter have. That is what lets them be a plain request parameter with no AST work, no visitor change and no ordering problem.
  • Narrowing happens as the table is built, via a new SupportsIndexPruning the storage engine implements, not by mutating a table afterwards: a table's row type is derived from the merge, so once one exists the cost is already paid. The interface keeps the probe in the module owning a client to probe with, and core free of OpenSearch types.
  • Bounds are carried as the strings the request sent and handed to _field_caps as-is, so OpenSearch's own date parser reads them and date math works. Re-interpreting them here risks a narrower window than the caller meant, which would drop an index that can match — the divergence hazard the design flags for Approach 1.

Gated by plugins.query.pruning.enabled, which #5759 turns on by default.

Two additions the design leaves implicit

time_field. The design assumes @timestamp. A Dashboards index pattern is routinely configured on another field — OpenSearch Dashboards' own sample-data pattern uses timestamp — and would otherwise never prune. Defaults to @timestamp, so a request that says nothing behaves as designed.

An index that does not map time_field is pruned like any other that cannot match: under a request-level time range a document with no time value is in no window, which is also what the pushed-down-filter path does (index_pruning.yml asserts it). The consequence is deliberate and documented — such an index's rows, and its schema, go with it, so a query naming a field only it maps fails with Field [x] not found. rather than quietly returning nothing.

A format list on the probe range. Passing bounds through raw means the field's own format applies, and OpenSearch's default rejects YYYY-MM-DD HH:mm:ss.SSS — the spelling a client that also writes the bound into the query text produces, since PPL accepts it there. Without this the probe throws, the exception is caught, and pruning silently declines. This was caught only by the ITs; 2 of 8 failed with "nothing was pruned" and the sole symptom was absence. The list is strict_date_optional_time||epoch_millis||yyyy-MM-dd HH:mm:ss.SSS; date math is resolved before any of it applies.

Example

Eight monthly indices. The newest renamed a field's shape — attributes.cluster was an object holding name, and is a plain keyword after the roll — so merging them makes charting by it fail.

Without bounds, every index is merged and the object wins:

POST _plugins/_ppl
{"query": "source=prune-demo-2026.* | where `timestamp` >= '...' and `timestamp` <= '...'
           | chart count() over timestamp by attributes.cluster"}

{"error": {"reason": "Cannot chart by [attributes.cluster] because it is an object.", "status": 400}}

With them, only the index that can hold data in the window is resolved:

{"query": "...", "time_field": "timestamp", "start_time": "now-15m", "end_time": "now"}

{"schema": [{"name": "timestamp"}, {"name": "attributes.cluster"}, {"name": "count()"}],
 "total": 50}
[INFO ][o.o.s.o.s.OpenSearchStorageEngine] Pruned index expression from prune-demo-2026.* to prune-demo-2026.09

A wider picker range prunes proportionally rather than all-or-nothing — prune-demo-2026.* → .07,.08,.09 for two months — and a range no index can match declines rather than pruning everything:

[INFO ][o.o.s.o.r.IndexPruner] Index pruning declined: 0 of 8 indices matched

Testing the changes

Suite Result
core + ppl + opensearch unit 9119 passed / 0 failed
CalciteTimeBoundsPruningIT (new) 9 passed (18 across both pushdown configurations)
CalciteExplainIT 288 passed
CalcitePPLBasicIT 94 passed
yamlRestTest (incl. #5727's index_pruning.yml) 34 passed
TimeBoundsPruningSecurityIT (new) 5 passed

The explain and basic suites are regression cover for the QueryService/QueryPlan/ExplainPlan signature changes.

New IT cases: the schema resolves from the in-range indices only (a field only the out-of-range ones map stops resolving); the whole pattern resolves without bounds; row counts are identical with and without; a range covering every index prunes none; bounds are ignored when the setting is off, when unusable, and when the field is unmapped; an index without the time field is pruned along with its schema; and bounds reach a subsearch's source too.

Also verified live on a security-enabled cluster, since the bounds cross the transport→worker handoff that #5739 and #5758 each had to fix for a different per-request signal — here they ride the object graph, so there is nothing on ThreadContext to drop. TimeBoundsPruningSecurityIT covers it; note integTestWithSecurity cannot run locally at the moment because today's snapshot distro ships jackson-core-2.22.1 while the security plugin's own zip carries 2.22.2 (the existing PartialResultSecurityIT fails identically), so that suite is CI-verified only.

Unusable bounds are dropped rather than rejected: they only decide which indices are read, so failing a query over a parameter it does not need is the worse outcome.

API surface

Three new request-body parameters — start_time, end_time, time_field — and nothing else:

  • No new endpoint. POST /_plugins/_ppl is unchanged.
  • No transport or BWC work. The body already travels as one JSON string in TransportPPLQueryRequest.writeTo, so there is no StreamOutput change and no version gate. fetch_size and partial_result are the precedents for a planning-affecting body field read the same way.
  • Reading them is additive. A request that omits them behaves exactly as before, and an older cluster ignores them, so the Dashboards side can ship independently.

They do change behaviour when present, which is the point, so this is an API addition rather than a purely internal change — the api-specification companion PR is noted unchecked below.

Internally it also adds SupportsIndexPruning to core's storage package. That is plugin-internal, optional, and implemented only by OpenSearchStorageEngine; other engines are untouched.

Files touched

File Change
core/.../executor/TimeBounds.java new — the request-level window, kept as the strings the request sent
core/.../storage/SupportsIndexPruning.java new — narrow interface so core needs no OpenSearch types and other engines are unaffected
core/.../calcite/OpenSearchSchema.java seeded with the bounds; offers them to every table it resolves
core/.../executor/QueryService.java threads the bounds to buildFrameworkConfig, on the execute, explain and analyze paths
core/.../execution/{AbstractPlan,QueryPlan,ExplainPlan,AnalyzePlan}.java carries them from the request to the worker thread
ppl/.../domain/PPLQueryRequest.java reads start_time / end_time / time_field off the body
ppl/.../PPLService.java sets them on the plan
opensearch/.../storage/OpenSearchStorageEngine.java implements SupportsIndexPruning; builds the table already narrowed
opensearch/.../request/IndexPruner.java bounds-driven overload beside #5727's filter-driven one
opensearch/.../scan/PartialResultAggregatePushdown.java warning names only the conflicting fields (see below)
docs/user/admin/settings.rst the parameters, their scope, and what they do and do not guarantee

Also fixed here: three partial-result banners for one finding

Surfaced by this work but independent of it, and reproducible on main with no bounds and pruning off — a plain text/keyword conflict plus chart count() over ts by env:

Results exclude 1 of 2 indices due to a mapping conflict on [env, ts].
Results exclude 1 of 2 indices due to a mapping conflict on [ts, env].
Results exclude 1 of 2 indices due to a mapping conflict on [env].

The warning listed every group key rather than the fields that could not be aggregated. That made it inaccurate — ts is a date, aggregatable in both indices, and nothing about it needed fixing — and impossible to de-duplicate: chart raises this once per equivalent plan alternative, which drainWarnings() collapses by value and #5657 wrote it for exactly that, but the keys arrive in whatever order the alternative had them, so logically identical findings differed as strings and survived as separate banners.

Naming only the offending fields makes those three identical, so the existing dedup collapses them to one, and the one that remains names what to fix. Sorted for the same reason the excluded-index list already is, so plan ordering cannot reach the text. Verified against a cluster: three banners before, one after, [env] alone.

Related Issues

Part of #5698. Builds on #5727; expects #5759.

Front-end counterpart: opensearch-project/OpenSearch-Dashboards#12722.

Check List

  • New functionality includes testing.
  • New functionality has been documented.
  • New functionality has javadoc added.
  • New functionality has a user manual doc added.
  • New PPL command checklist all confirmed.
  • API changes companion pull request created.
  • Commits are signed per the DCO using --signoff or -s.
  • Public documentation issue/PR created.

By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.

Review notes

A self-review and a joint review against the Dashboards side turned up several defects, fixed in the later commits and worth recording:

  1. An index that does not map the time field. _field_caps reports it as not-matching, indistinguishably from one whose values fall outside the range. A first attempt retained such indices via an extra include_unmapped probe, but that regressed Avoid PIT context exhaustion by pruning indices that cannot match #5727's own index_pruning.yml case (on the pushdown path the range is the query's filter, so dropping the index is lossless there) and it left the partial-result feature something to exclude, producing a spurious "mapping conflict" banner on a field that was merely absent. Both paths now prune it, consistently.
  2. analyze dropped the bounds on both of its phases, so "analyze": true reported a plan over an index expression the execution would not have used.
  3. URL parameters could not work. BaseRestHandler rejects parameters absent from responseParams(), so ?start_time=… was a 400, and a GET carries no body for the loop to write into. Removed rather than half-supported.
  4. The security IT could not pass. createRoleWithIndexAccess granted neither indices:admin/resolve/index nor indices:data/read/field_caps*, so both probes were denied and pruning declined silently. Granting them is load-bearing: taking them back out makes the positive case fail again, which is also the first direct evidence for the permission limitation the manual documents.
  5. Both ITs pinned the pruning setting to false on teardown, which since Enable plugins.query.pruning.enabled by default #5759 disables the feature for every later class sharing the cluster. They clear the override instead.
  6. The probe's format list was needed for a bound spelled as a UTC wall clock — the spelling a client that also writes it into the query text produces. Without it the probe throws, the exception is swallowed, and pruning silently declines; two ITs failed with absence as their only symptom.

@github-actions

github-actions Bot commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

PR Reviewer Guide 🔍

(Review updated until commit 336be69)

Here are some key observations to aid the review process:

🧪 PR contains tests
🔒 No security concerns identified
✅ No TODO sections
🔀 No multiple PR themes
⚡ Recommended focus areas for review

Thread-local leak

chartPlanned is initialized with withInitial(() -> false) but cleared with set(false) instead of remove(). On a pooled worker thread, if a chart query runs and sets it to true, then a non-chart query runs, the second query sees true from the first. The test in ChartPartialResultGateTest.gateDoesNotLeakOntoNextQueryOnSameThread verifies clearTimewrapSignals() clears it, but that only runs inside CalcitePlanContext.run()'s finally block. If execution bypasses that path or an exception prevents the finally from completing, the flag leaks. Use remove() in clearTimewrapSignals() or change the ThreadLocal to not use withInitial.

private static final ThreadLocal<Boolean> chartPlanned = ThreadLocal.withInitial(() -> false);
Incorrect epoch handling

The documentation states epoch seconds are not accepted and a ten-digit number is read as milliseconds, meaning 1789329600 (epoch seconds for a 2026 date) becomes January 1970. However, the BOUND_FORMATS string does not include epoch_second, only epoch_millis, so OpenSearch's date parser will reject a ten-digit epoch as invalid rather than misinterpret it as milliseconds. The actual behavior differs from what the docs claim. Either the format list should include epoch_second with a warning, or the docs should state that ten-digit epochs are rejected, not misread.

private static final String BOUND_FORMATS =
    "strict_date_optional_time||epoch_millis||yyyy-MM-dd HH:mm:ss.SSS||yyyy-MM-dd HH:mm:ss";
Null dereference risk

In resolve(), if timeBounds is not null and engine instanceof SupportsIndexPruning, the code calls pruning.getTable(). However, the cast to SupportsIndexPruning is done inline without storing the result, and the subsequent call to engine.getTable() in the else branch uses the original engine reference. If engine is null (though unlikely given the flow), or if the instanceof check passes but the method call fails, the fallback to engine.getTable() could throw NullPointerException. The current code is safe only if engine is guaranteed non-null, which it should be, but the pattern is fragile. Consider storing the cast result or adding an explicit null check.

private org.opensearch.sql.storage.Table resolve(
    StorageEngine engine,
    DataSourceSchemaName schemaName,
    DataSourceSchemaIdentifierNameResolver nameResolver) {
  if (timeBounds != null && engine instanceof SupportsIndexPruning pruning) {
    return pruning.getTable(schemaName, nameResolver.getIdentifierName(), timeBounds);
  }
  return engine.getTable(schemaName, nameResolver.getIdentifierName());
}

@github-actions

github-actions Bot commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

PR Code Suggestions ✨

Latest suggestions up to 336be69

Explore these optional code suggestions:

CategorySuggestion                                                                                                                                    Impact
General
Add null check for bounds parameter

The method creates a RangeQueryBuilder without validating that bounds is non-null.
While the caller may ensure this, adding a null check would prevent potential
NullPointerException and make the method more defensive, especially since this is a
public API.

opensearch/src/main/java/org/opensearch/sql/opensearch/request/IndexPruner.java [66-73]

 public IndexName prune(IndexName indexName, TimeBounds bounds) {
+  if (bounds == null) {
+    return indexName;
+  }
   QueryBuilder range =
       new RangeQueryBuilder(bounds.getTimeField())
           .gte(bounds.getStart())
           .lte(bounds.getEnd())
           .format(BOUND_FORMATS);
   return prune(indexName, range, true, bounds.getTimeField());
 }
Suggestion importance[1-10]: 5

__

Why: Adding a null check makes the method more defensive and prevents potential NullPointerException. However, the method signature doesn't indicate bounds can be null (no @Nullable annotation), and the caller context suggests it's always provided when this overload is called.

Low
Avoid unnecessary Boolean object allocation

Using ThreadLocal.withInitial(() -> false) creates a new Boolean object for each
thread. Consider using a plain ThreadLocal<> and explicitly setting false in
clearTimewrapSignals() to avoid unnecessary object allocation, since the value is
always reset per query anyway.

core/src/main/java/org/opensearch/sql/calcite/CalcitePlanContext.java [102]

-private static final ThreadLocal<Boolean> chartPlanned = ThreadLocal.withInitial(() -> false);
+private static final ThreadLocal<Boolean> chartPlanned = new ThreadLocal<>();
Suggestion importance[1-10]: 3

__

Why: While the suggestion is technically correct that withInitial(() -> false) creates a supplier, the performance impact is negligible and the current code is more explicit about the default value. The suggestion offers only marginal optimization.

Low

Previous suggestions

Suggestions up to commit abd9510
CategorySuggestion                                                                                                                                    Impact
Possible issue
Add null check for probe response

The probeMatching method now returns a FieldCapabilitiesResponse but only
getIndices() is used. If the probe fails or returns an empty response, calling
getIndices() might return null, causing a NullPointerException. Add a null check
before accessing the array length.

opensearch/src/main/java/org/opensearch/sql/opensearch/request/IndexPruner.java [85-86]

-String[] candidates = indexExpr.probeMatching(filter, timeField).getIndices();
-if (0 < candidates.length && indexExpr.isPrunedBy(candidates.length)) {
+FieldCapabilitiesResponse response = indexExpr.probeMatching(filter, timeField);
+String[] candidates = response.getIndices();
+if (candidates != null && 0 < candidates.length && indexExpr.isPrunedBy(candidates.length)) {
Suggestion importance[1-10]: 7

__

Why: Valid concern about potential NullPointerException if getIndices() returns null. The suggestion correctly identifies that the FieldCapabilitiesResponse should be checked before accessing its indices array, improving robustness of the pruning logic.

Medium
General
Remove unnecessary ThreadLocal initializer

Using ThreadLocal.withInitial() for a boolean flag that's explicitly cleared per
query is unnecessary overhead. Since clearTimewrapSignals() already sets it to
false, initialize it with a simple new ThreadLocal<>() and rely on the explicit
clearing.

core/src/main/java/org/opensearch/sql/calcite/CalcitePlanContext.java [102]

-private static final ThreadLocal<Boolean> chartPlanned = ThreadLocal.withInitial(() -> false);
+private static final ThreadLocal<Boolean> chartPlanned = new ThreadLocal<>();
Suggestion importance[1-10]: 3

__

Why: While technically correct that clearTimewrapSignals() sets the value to false, the withInitial() provides a clear default value for threads that haven't been initialized yet. The overhead is minimal and the explicit initializer improves code clarity.

Low
Suggestions up to commit d676381
CategorySuggestion                                                                                                                                    Impact
General
Add null safety for probe response

The method calls probeMatching which returns a FieldCapabilitiesResponse, but only
uses the indices array. If the response indicates an error or has no field
capabilities, the pruning should handle this gracefully. Consider checking the
response status before extracting indices to avoid potential issues with empty or
invalid responses.

opensearch/src/main/java/org/opensearch/sql/opensearch/request/IndexPruner.java [75-87]

-private IndexName prune(
-    IndexName indexName, QueryBuilder filter, boolean hasTimeRange, String timeField) {
-  try {
-    IndexExpression indexExpr = new IndexExpression(indexName, node);
-    if (!isPrunable(indexExpr, hasTimeRange)) {
-      log.info("Index pruning skipped: {}", indexExpr);
-      return indexName;
-    }
+FieldCapabilitiesResponse response = indexExpr.probeMatching(filter, timeField);
+if (response == null || response.getIndices() == null) {
+  log.warn("Index pruning probe returned invalid response");
+  return indexName;
+}
+String[] candidates = response.getIndices();
+if (0 < candidates.length && indexExpr.isPrunedBy(candidates.length)) {
+  return new IndexName(String.join(",", candidates));
+}
 
-    String[] candidates = indexExpr.probeMatching(filter, timeField).getIndices();
-    if (0 < candidates.length && indexExpr.isPrunedBy(candidates.length)) {
-      return new IndexName(String.join(",", candidates));
-    }
-
Suggestion importance[1-10]: 7

__

Why: The suggestion adds defensive null checking for the FieldCapabilitiesResponse before extracting indices. This is a valid safety improvement, though the existing try-catch block at line 92-94 already handles exceptions. The explicit null check would provide clearer error handling and prevent potential NullPointerException.

Medium
Improve null and blank validation logic

The validation logic should check for blank strings before trimming to avoid
potential issues. If value is a string with only whitespace, isBlank() returns true,
but the error message says "must not be blank" after the value has already been
determined to be blank. Consider checking for null first, then trimming, then
checking if the trimmed result is empty.

core/src/main/java/org/opensearch/sql/executor/TimeBounds.java [33-38]

 private static String requireText(String value, String name) {
-  if (value == null || value.isBlank()) {
+  if (value == null) {
+    throw new IllegalArgumentException(String.format(Locale.ROOT, "%s must not be null", name));
+  }
+  String trimmed = value.trim();
+  if (trimmed.isEmpty()) {
     throw new IllegalArgumentException(String.format(Locale.ROOT, "%s must not be blank", name));
   }
-  return value.trim();
+  return trimmed;
 }
Suggestion importance[1-10]: 6

__

Why: The suggestion improves the validation logic by separating null checks from blank checks and providing clearer error messages. However, the current implementation using isBlank() already handles both null and whitespace-only strings correctly, so this is a minor improvement in clarity rather than a bug fix.

Low
Add logging for pruning fallback cases

The pattern matching with instanceof and cast is used correctly, but the fallback to
engine.getTable should be more explicit about why it's being called. When timeBounds
is null or the engine doesn't support pruning, the method silently falls back.
Consider adding debug logging to track when pruning is skipped to aid
troubleshooting.

core/src/main/java/org/opensearch/sql/calcite/OpenSearchSchema.java [64-72]

 private org.opensearch.sql.storage.Table resolve(
     StorageEngine engine,
     DataSourceSchemaName schemaName,
     DataSourceSchemaIdentifierNameResolver nameResolver) {
   if (timeBounds != null && engine instanceof SupportsIndexPruning pruning) {
     return pruning.getTable(schemaName, nameResolver.getIdentifierName(), timeBounds);
   }
+  if (timeBounds != null) {
+    log.debug("Engine does not support index pruning, using unbounded resolution");
+  }
   return engine.getTable(schemaName, nameResolver.getIdentifierName());
 }
Suggestion importance[1-10]: 5

__

Why: Adding debug logging when pruning is skipped would help with troubleshooting, but this is a minor observability improvement. The code already functions correctly without it, and the suggestion only adds diagnostic value rather than fixing any issue.

Low
Suggestions up to commit c128ebd
CategorySuggestion                                                                                                                                    Impact
General
Validate trimmed value for consistency

The requireText method trims the value after validation, but the validation checks
if the original value is blank. This could allow strings with only whitespace to
pass validation before being trimmed to empty strings. Validate the trimmed value
instead to ensure consistency.

core/src/main/java/org/opensearch/sql/executor/TimeBounds.java [33-38]

 private static String requireText(String value, String name) {
-  if (value == null || value.isBlank()) {
+  if (value == null) {
     throw new IllegalArgumentException(String.format(Locale.ROOT, "%s must not be blank", name));
   }
-  return value.trim();
+  String trimmed = value.trim();
+  if (trimmed.isEmpty()) {
+    throw new IllegalArgumentException(String.format(Locale.ROOT, "%s must not be blank", name));
+  }
+  return trimmed;
 }
Suggestion importance[1-10]: 7

__

Why: The suggestion correctly identifies a subtle issue where isBlank() checks the original value but trim() is called after validation. However, isBlank() already checks for whitespace-only strings, so the current code works correctly. The suggested improvement is clearer and more explicit, making the validation logic easier to understand.

Medium
Optimize conflicting fields collection

The conflictingFields set accumulates fields from all excluded indices, but if the
same field appears multiple times across different indices, it's added repeatedly.
While Set prevents duplicates, calling addAll for every excluded index is
inefficient. Consider collecting fields only once after the loop or optimizing the
collection logic.

opensearch/src/main/java/org/opensearch/sql/opensearch/storage/scan/PartialResultAggregatePushdown.java [73-85]

 for (Map.Entry<String, IndexMapping> entry : mappings.entrySet()) {
   Map<String, OpenSearchDataType> flatMapping =
       OpenSearchDataType.traverseAndFlatten(entry.getValue().getFieldMappings());
   String signature = resolveBucketSignature(flatMapping, bucketNames);
   if (signature == null) {
     excludedIndices.add(entry.getKey());
-    conflictingFields.addAll(nonAggregatableFields(flatMapping, bucketNames));
+    if (conflictingFields.isEmpty()) {
+      conflictingFields.addAll(nonAggregatableFields(flatMapping, bucketNames));
+    }
   } else {
     aggregatableGroups.computeIfAbsent(signature, k -> new ArrayList<>()).add(entry.getKey());
   }
 }
Suggestion importance[1-10]: 3

__

Why: The suggestion attempts to optimize by collecting fields only once, but the logic is flawed. The isEmpty() check would only collect fields from the first excluded index, missing fields from subsequent indices. The current implementation using a LinkedHashSet is correct and already prevents duplicates efficiently. The suggested optimization would introduce a bug.

Low
Suggestions up to commit 2ee666e
CategorySuggestion                                                                                                                                    Impact
Possible issue
Add exception handling for pruning failures

The method should handle potential exceptions from the pruning operation to prevent
table resolution failures. If pruning throws an exception, fall back to the standard
resolution path to maintain query availability.

core/src/main/java/org/opensearch/sql/calcite/OpenSearchSchema.java [67-75]

 private org.opensearch.sql.storage.Table resolve(
     StorageEngine engine,
     DataSourceSchemaName schemaName,
     DataSourceSchemaIdentifierNameResolver nameResolver) {
   if (timeBounds != null && engine instanceof SupportsIndexPruning pruning) {
-    return pruning.getTable(schemaName, nameResolver.getIdentifierName(), timeBounds);
+    try {
+      return pruning.getTable(schemaName, nameResolver.getIdentifierName(), timeBounds);
+    } catch (Exception e) {
+      // Fall back to unpruned resolution if pruning fails
+    }
   }
   return engine.getTable(schemaName, nameResolver.getIdentifierName());
 }
Suggestion importance[1-10]: 7

__

Why: Adding exception handling for pruning failures would improve robustness by falling back to unpruned resolution. However, the PR already handles failures at the IndexPruner level (line 99-102 in IndexPruner.java), so this is a defensive measure rather than addressing a critical gap.

Medium
Add null check for candidates array

The method should validate that candidates is not null before accessing its length.
The getIndices() call could potentially return null, leading to a
NullPointerException when checking candidates.length.

opensearch/src/main/java/org/opensearch/sql/opensearch/request/IndexPruner.java [84-98]

 private IndexName prune(
     IndexName indexName, QueryBuilder filter, boolean hasTimeRange, String timeField) {
   try {
     IndexExpression indexExpr = new IndexExpression(indexName, node);
     if (!isPrunable(indexExpr, hasTimeRange)) {
       log.info("Index pruning skipped: {}", indexExpr);
       return indexName;
     }
 
     String[] candidates = indexExpr.probeMatching(filter, timeField).getIndices();
-    if (0 < candidates.length && indexExpr.isPrunedBy(candidates.length)) {
+    if (candidates != null && 0 < candidates.length && indexExpr.isPrunedBy(candidates.length)) {
       return new IndexName(String.join(",", candidates));
     }
     ...
Suggestion importance[1-10]: 6

__

Why: While FieldCapabilitiesResponse.getIndices() is unlikely to return null based on the OpenSearch API, adding a null check is a defensive practice that prevents potential NullPointerException. The impact is moderate as the existing code may already handle this implicitly.

Low
General
Optimize conflicting fields collection

The conflictingFields set should be populated only once per unique field, but
nonAggregatableFields is called for every excluded index. This could lead to
redundant computation. Consider collecting fields more efficiently or documenting
why per-index collection is necessary.

opensearch/src/main/java/org/opensearch/sql/opensearch/storage/scan/PartialResultAggregatePushdown.java [75-87]

+Set<String> seenConflicts = new LinkedHashSet<>();
 for (Map.Entry<String, IndexMapping> entry : mappings.entrySet()) {
   Map<String, OpenSearchDataType> flatMapping =
       OpenSearchDataType.traverseAndFlatten(entry.getValue().getFieldMappings());
   String signature = resolveBucketSignature(flatMapping, bucketNames);
   if (signature == null) {
     excludedIndices.add(entry.getKey());
-    conflictingFields.addAll(nonAggregatableFields(flatMapping, bucketNames));
+    List<String> conflicts = nonAggregatableFields(flatMapping, bucketNames);
+    seenConflicts.addAll(conflicts);
   } else {
     aggregatableGroups.computeIfAbsent(signature, k -> new ArrayList<>()).add(entry.getKey());
   }
 }
+conflictingFields.addAll(seenConflicts);
Suggestion importance[1-10]: 4

__

Why: The suggestion optimizes field collection by avoiding redundant calls, but the performance gain is minimal since nonAggregatableFields is lightweight and the number of excluded indices is typically small. The existing code is clear and correct.

Low
Suggestions up to commit efcd23d
CategorySuggestion                                                                                                                                    Impact
Possible issue
Add exception handling for pruning failures

The method should handle potential exceptions from getTable calls to prevent
failures from propagating unchecked. If pruning fails, it should fall back to the
unbounded resolution rather than failing the query, consistent with the documented
behavior that "any failure while probing the cluster falls back to querying the full
expression."

core/src/main/java/org/opensearch/sql/calcite/OpenSearchSchema.java [67-75]

 private org.opensearch.sql.storage.Table resolve(
     StorageEngine engine,
     DataSourceSchemaName schemaName,
     DataSourceSchemaIdentifierNameResolver nameResolver) {
   if (timeBounds != null && engine instanceof SupportsIndexPruning pruning) {
-    return pruning.getTable(schemaName, nameResolver.getIdentifierName(), timeBounds);
+    try {
+      return pruning.getTable(schemaName, nameResolver.getIdentifierName(), timeBounds);
+    } catch (Exception e) {
+      log.warn("Index pruning failed for {}, falling back to unbounded resolution", 
+               nameResolver.getIdentifierName(), e);
+    }
   }
   return engine.getTable(schemaName, nameResolver.getIdentifierName());
 }
Suggestion importance[1-10]: 7

__

Why: The suggestion correctly identifies that pruning failures should fall back to unbounded resolution, consistent with documented behavior. However, the improved_code shows exception handling that may already be present at a higher level (in IndexPruner.prune), and the PR already demonstrates fallback behavior in the pruner itself.

Medium
General
Handle null mapping type safely

The method should handle the case where getMappingType() returns null to prevent
potential NullPointerExceptions. While the current code may work if getMappingType()
never returns null, defensive checks improve robustness.

opensearch/src/main/java/org/opensearch/sql/opensearch/storage/scan/PartialResultAggregatePushdown.java [134-152]

 private static List<String> nonAggregatableFields(
     Map<String, OpenSearchDataType> flatMapping, List<String> bucketNames) {
   List<String> offenders = new ArrayList<>();
   for (String field : bucketNames) {
     OpenSearchDataType type = flatMapping.get(field);
     if (type == null) {
       offenders.add(field);
       continue;
     }
     MappingType mappingType = type.getMappingType();
-    if (mappingType == MappingType.Text || mappingType == MappingType.MatchOnlyText) {
+    if (mappingType == null || mappingType == MappingType.Text || mappingType == MappingType.MatchOnlyText) {
       offenders.add(field);
     }
   }
   return offenders;
 }
Suggestion importance[1-10]: 4

__

Why: The suggestion adds defensive null checking for getMappingType(). However, this appears to be a minor defensive improvement rather than fixing an actual bug, as OpenSearchDataType.getMappingType() likely never returns null based on the codebase design. The impact is low since this would only matter if the API contract changes.

Low
Validate null bounds parameter

The method should validate that bounds is not null before dereferencing it. While
callers may check, defensive programming at the API boundary prevents potential
NullPointerExceptions if the contract is violated.

opensearch/src/main/java/org/opensearch/sql/opensearch/request/IndexPruner.java [71-79]

 public IndexName prune(IndexName indexName, TimeBounds bounds) {
+  if (bounds == null) {
+    throw new IllegalArgumentException("TimeBounds cannot be null");
+  }
   QueryBuilder range =
       new RangeQueryBuilder(bounds.getTimeField())
           .gte(bounds.getStart())
           .lte(bounds.getEnd())
           .format(BOUND_FORMATS);
   return prune(indexName, range, true, bounds.getTimeField());
 }
Suggestion importance[1-10]: 3

__

Why: While null validation is generally good practice, the method signature already uses TimeBounds bounds without @Nullable, suggesting null is not expected. The calling code in OpenSearchStorageEngine.prune already checks for null before calling this method, making this validation redundant.

Low

The schema-conflict half of Approach 1 that opensearch-project#5727 left open, implemented as Approach 3
of the design on opensearch-project#5698: a request-level time range, applied while the table is still
being resolved.

A wildcard index expression is expanded -- and the mapping of every index it matches
merged -- before any predicate in the query text has been parsed, so nothing
downstream of that resolution can narrow it. opensearch-project#5727 reads its range from the filter
already sitting in the pushdown context, which is the broadest coverage available
without an API change but runs too late for the merge. Declaring the range on the
request puts it in hand before planning starts:

  start_time/end_time/time_field -> PPLQueryRequest.getTimeBounds -> AbstractPlan
  -> QueryService.buildFrameworkConfig (seeded once) -> OpenSearchSchema.registerTable
  -> OpenSearchStorageEngine.getTable(.., TimeBounds) -> IndexPruner -> OpenSearchIndex

Following the design's shape:

- Bounds are global, applied to every table the query resolves rather than attributed
  to one relation -- the scope Splunk's time range picker and ES|QL's request-level
  filter have. That is what lets them be a plain request parameter with no AST work,
  no visitor change and no ordering problem.
- Narrowing happens as the table is built, via a new SupportsIndexPruning the storage
  engine implements, not by mutating a table afterwards: a table's row type is derived
  from the merge, so once one exists the cost is already paid. The interface keeps the
  probe in the module owning a client to probe with, and core free of OpenSearch types.
- Bounds are carried as the strings the request sent and handed to _field_caps as-is,
  so OpenSearch's own date parser reads them and date math works. Re-interpreting them
  here risks a narrower window than the caller meant, which would drop an index that
  can match -- the divergence hazard the design flags for Approach 1.

Two additions the design leaves implicit. time_field makes the field explicit rather
than assuming @timestamp, because a Dashboards index pattern is routinely configured
on another field and would otherwise never prune. And the probe's format list accepts
a UTC wall clock alongside the OpenSearch defaults, since a client that also writes
the bound into the query text produces the spelling PPL accepts there; without it such
a bound fails to parse and pruning silently declines.

A source whose time_field is not a date excludes itself, read off the same probe
response, so a range that cannot prove an index disjoint prunes nothing.

Unusable bounds are dropped rather than rejected: they only decide which indices are
read, so failing a query over a parameter it does not need is the worse outcome.
Gated by plugins.query.pruning.enabled, which opensearch-project#5759 turns on by default.

Verified on a security-enabled cluster over eight monthly indices whose newest one
renamed a field's shape from object to keyword. Without bounds the merge sees the
object and charting by it fails; with them only the newest is resolved and the same
query returns its rows. Row counts are identical either way, a range covering every
index prunes none, and a range no index can match declines rather than pruning all.

Signed-off-by: Kai Huang <ahkcs@amazon.com>
@ahkcs
ahkcs force-pushed the feat/time-range-index-pruning branch from c16b768 to c90cee5 Compare September 14, 2026 17:24
@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit c90cee5

Correctness

An index that does not map the time field was pruned away with its rows. A range on an
unmapped field is *disjoint* for that index, not unknown, so _field_caps omits it
exactly as it omits an index whose values fall outside the window -- the two are
indistinguishable in the response, and the previous type check saw only the date entry
the mapping indices reported and let pruning proceed. Verified against a cluster: a
wildcard over one index mapping `ts` and one not returns only the former from the
probe. The gate now runs on its own unfiltered probe with include_unmapped, which is
the only way to see the indices a filtered probe has already hidden, and declines when
the field is unmapped anywhere, not a date anywhere, or mapped by nothing. Costs one
metadata probe, paid before the filtered one so a declining query stops early. New IT
keeps a row that lives only in an index without the field.

The gate also ran before the candidate count, so a window matching no index reported
"[ts] is not a date field" -- field-caps returns an empty field map along with an empty
index list -- and the "0 of N indices matched" line could never fire. Reordered.

analyze dropped the bounds on both phases, so `"analyze": true` merged every matched
index's mapping and reported a plan over an expression the execution would not have
used. Threaded, as explain already was.

Broken surface removed

The URL-parameter loop could not work: BaseRestHandler rejects parameters absent from
responseParams(), so start_time in a query string was a 400, and a real GET carries no
jsonContent for the loop to write into. Dropped rather than half-supported.

Tests

createRoleWithIndexAccess granted neither indices:admin/resolve/index nor
indices:data/read/field_caps*, so under security both probes were denied, pruning
declined silently, and TimeBoundsPruningSecurityIT's positive case could not pass.
Granted, matching what ppl_full_access carries since 3.9.

Both ITs pinned the pruning setting to false on teardown; since opensearch-project#5759 made it default
true, that left every later class in the cluster running with pruning off. They clear
the override instead.

Registered CalciteTimeBoundsPruningIT in CalciteNoPushdownIT, which the repo requires
and which matters here: request-level pruning is meant to be independent of pushdown.

Accuracy

Documented that these bounds are not free of effect. Pruning drops whole indices, so a
query whose text already constrains the same field to the same window returns the same
rows -- the intended use -- while one that does not returns fewer. The previous claim
that a request is "answered identically" was false, and this PR's own subsearch IT
asserts a row count changing.

Corrected the claim that a bound is read by the index's own date parser: the probe's
format list replaces the field's, so a field with some other custom format is not
pruned on. Said so, and noted that declining costs the optimization and never a row.

Stopped promising the unified query path reads these; it builds its own schema and
ignores them.

The "Request-level time bounds" heading was a section sibling, sweeping the setting's
own disable/result-set examples under it. Demoted to prose.

Renamed the leftover "hint" and "time_range" vocabulary in tests to the shipped
start_time/end_time/TimeBounds, reused OpenSearchConstants.IMPLICIT_FIELD_TIMESTAMP
instead of a second copy, and made a one-sided window warn rather than vanish.

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

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit 1380ef7

…hose indices

Reviewing this against the Dashboards side turned up two things the separate reviews
could not see.

The guard added for the unmapped-field case was applied to both pruning paths, which
regressed opensearch-project#5727's own yaml case "Prunes an index that does not map the timestamp". The
two paths are not alike. When the range is read out of the query's own pushed-down
filter, an index that does not map the field cannot satisfy that filter either, so its
documents are already excluded from the answer and dropping the index is lossless --
which is exactly what that test asserts. When the range arrives as a request parameter
the query text need not mention the field at all, so those documents are still wanted.
The guard now applies only to the bounds path; the filter path is untouched, and the
yaml suite passes again.

And rather than declining outright when the field is unmapped somewhere, the bounds
path now keeps those indices and prunes among the rest. _field_caps already names them
in the unmapped bucket of an include_unmapped probe, so this costs nothing and turns a
case that gave up into one that prunes correctly -- worth having because a Dashboards
index pattern spanning a stray index without the time field would otherwise never
prune at all.

Declining is kept only where nothing better is possible: no index maps the field, it is
mapped as a non-date somewhere, or the probe does not name the unmapped indices.

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

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit d7b6b4c

Got integTestWithSecurity running locally for the first time on this branch, and the
suite failed two of its four cases -- not on the handoff it was written for, but because
of how it observed pruning.

It asserted that charting by a field mapped as an object in one index and a keyword in
another fails without bounds. Which side of that conflict wins the merge depends on hash
iteration order, so it varies between JVMs: the earlier manual run on a standalone
cluster returned the expected 400, this one returned 200 and the two negative cases
failed. The assertion was never sound.

Observes the resolved schema instead, as CalcitePPLTimeBounds... already does: a field
that exists only in the out-of-range index either resolves or does not, which is decided
by which indices the merge saw and nothing else. Adds a row-count parity case while here,
since that is the property that matters most and the old shape could not express it.

Confirmed the permission grant added earlier is load-bearing by taking it back out: the
positive case then fails with "nothing was thrown", pruning having declined on a denied
probe exactly as predicted. Restored, and 5/5 pass.

Signed-off-by: Kai Huang <ahkcs@amazon.com>
@ahkcs ahkcs added the enhancement New feature or request label Sep 14, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit c792001

@codecov

codecov Bot commented Sep 14, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 64.48598% with 38 lines in your changes missing coverage. Please review.
✅ Project coverage is 62.88%. Comparing base (6116c33) to head (336be69).
⚠️ Report is 1657 commits behind head on main.

Files with missing lines Patch % Lines
...ql/opensearch/storage/OpenSearchStorageEngine.java 25.00% 11 Missing and 1 partial ⚠️
...java/org/opensearch/sql/executor/QueryService.java 26.66% 11 Missing ⚠️
...org/opensearch/sql/calcite/CalcitePlanContext.java 50.00% 5 Missing ⚠️
...c/main/java/org/opensearch/sql/ppl/PPLService.java 20.00% 4 Missing ⚠️
...a/org/opensearch/sql/calcite/OpenSearchSchema.java 85.71% 2 Missing ⚠️
...ensearch/storage/scan/CalciteLogicalIndexScan.java 0.00% 2 Missing ⚠️
.../opensearch/sql/calcite/CalciteRelNodeVisitor.java 0.00% 1 Missing ⚠️
...opensearch/sql/executor/execution/AnalyzePlan.java 0.00% 1 Missing ⚠️

❌ Your project check has failed because the head coverage (62.88%) is below the target coverage (99.00%). You can increase the head coverage or adjust the target coverage.

❗ There is a different number of reports uploaded between BASE (6116c33) and HEAD (336be69). Click for more details.

HEAD has 5 uploads less than BASE
Flag BASE (6116c33) HEAD (336be69)
sql-engine 6 1
Additional details and impacted files
@@              Coverage Diff              @@
##               main    #5766       +/-   ##
=============================================
- Coverage     98.40%   62.88%   -35.52%     
- Complexity     2746     8801     +6055     
=============================================
  Files           266      938      +672     
  Lines          6758    40198    +33440     
  Branches        426     4520     +4094     
=============================================
+ Hits           6650    25280    +18630     
- Misses          107    14117    +14010     
- Partials          1      801      +800     
Flag Coverage Δ
sql-engine 62.88% <64.48%> (-35.52%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

…cannot match

Reverts the retain-unmapped behaviour, and with it the mapping probe that existed to
detect the case.

Under a request-level time range a document with no time value is in no window, so the
index holding it can be excluded on the same evidence as one whose values fall outside
the range. That is what the pushed-down-filter path does -- opensearch-project#5727's own yaml case asserts
it -- and what ES|QL's request filter and Splunk's picker do. Retaining such an index was
the inconsistent special case, and it protected only a usage the manual already tells
callers not to attempt: bounds sent without an equivalent predicate in the query text,
where rows are dropped for in-range indices anyway.

It also cost a warning. Keeping an index that maps none of the group fields gives the
partial-result feature something to exclude, so a query that had no mapping conflict
gained a "mapping conflict" banner naming a field that is merely absent, with a rerun
prompt that would buy nothing.

The probe goes with it. An unmapped time field now self-declines through the existing
empty-candidate path -- every index reports as non-matching, so nothing is substituted --
which is one fewer blocking round trip per distinct source on the bounds path.

The consequence is deliberate and now documented: pruning removes the index's schema
along with its rows, so a query naming a field only that index maps fails with "Field
[x] not found." rather than quietly returning nothing. The IT asserts the loud failure.

Signed-off-by: Kai Huang <ahkcs@amazon.com>
The warning listed every group key rather than the fields that could not be aggregated,
which made it both inaccurate and impossible to de-duplicate.

Inaccurate because a key that is fine everywhere gets named as the problem: charting by a
keyword field over a date produced "mapping conflict on [attributes.cluster, timestamp]"
when the date was aggregatable in every index and nothing about it needed fixing.

Impossible to de-duplicate because the planner raises this once per equivalent plan
alternative -- which drainWarnings() collapses by value, and opensearch-project#5657 wrote it for exactly
that -- but the group keys arrive in whatever order the alternative had them, so
logically identical findings differed as strings and survived as separate banners. A
single chart query showed three:

  Results exclude 1 of 2 indices due to a mapping conflict on [attributes.cluster, timestamp].
  Results exclude 1 of 2 indices due to a mapping conflict on [timestamp, attributes.cluster].
  Results exclude 1 of 2 indices due to a mapping conflict on [attributes.cluster].

Reporting only the offending fields makes those three identical, so the existing dedup
collapses them to one, and the one that remains names what to fix. The list is sorted for
the same reason the excluded-index list already is: so plan ordering cannot reach the text.

Reproducible without index pruning involved -- a plain text/keyword conflict and
`chart count() over ts by env` -- and verified against a cluster: three banners before,
one after, naming [env] alone.

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

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit 7a3d063

Comment thread docs/user/admin/settings.rst Outdated

Pruning is also skipped when it would not reduce the read, that is when no index is excluded. The query then uses the original wildcard expression and reads exactly the same indices.

**Request-level time bounds.** ``start_time`` and ``end_time`` declare the window the request is asking about, so the engine has it before it resolves the queried index expression. They are inclusive, and accept OpenSearch date math (``now-7d``) and absolute timestamps alike. The bounds are handed to the probe as sent rather than reinterpreted, so a relative range is resolved once, by OpenSearch. Absolute bounds are parsed as ``strict_date_optional_time``, epoch milliseconds, or ``yyyy-MM-dd HH:mm:ss.SSS``; a field declaring some other custom format is not pruned on, since the bound fails to parse and pruning then declines. ``time_field`` names the field they constrain, defaulting to ``@timestamp`` -- a caller whose index pattern is configured on another field has to say so, or nothing is pruned. Bounds that cannot be used are ignored rather than failing the query.

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.

so the engine has it before it resolves the queried index expression.
This is user facing doc. Do not need to explain the engine details.

Comment thread docs/user/admin/settings.rst Outdated

**Request-level time bounds.** ``start_time`` and ``end_time`` declare the window the request is asking about, so the engine has it before it resolves the queried index expression. They are inclusive, and accept OpenSearch date math (``now-7d``) and absolute timestamps alike. The bounds are handed to the probe as sent rather than reinterpreted, so a relative range is resolved once, by OpenSearch. Absolute bounds are parsed as ``strict_date_optional_time``, epoch milliseconds, or ``yyyy-MM-dd HH:mm:ss.SSS``; a field declaring some other custom format is not pruned on, since the bound fails to parse and pruning then declines. ``time_field`` names the field they constrain, defaulting to ``@timestamp`` -- a caller whose index pattern is configured on another field has to say so, or nothing is pruned. Bounds that cannot be used are ignored rather than failing the query.

Their scope is the whole request: every source the query reads is narrowed, subsearches included, as with Splunk's time range picker and OpenSearch SQL's own PPL ``earliest``/``latest`` at request level.

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.

What if JOIN? Applied to all indices?

Comment thread docs/user/admin/settings.rst Outdated

Their scope is the whole request: every source the query reads is narrowed, subsearches included, as with Splunk's time range picker and OpenSearch SQL's own PPL ``earliest``/``latest`` at request level.

They are not a filter, and they are not free of effect either. Pruning drops whole indices, so a query whose text already constrains the same field to the same window returns exactly the same rows -- that is the intended use, and how a client appending its own ``where`` should send them. A query whose text does not carry that constraint returns fewer rows: documents outside the window still count inside a retained index, while an index wholly outside it contributes nothing. Send bounds only for a window the query itself already restricts.

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.

what is the intend of this section?

Comment thread docs/user/admin/settings.rst Outdated
Comment on lines +248 to +249
"start_time" : "now-7d",
"end_time" : "now"

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.

what are supported literal of start_time and end_time. The doc should clear on it.

Comment thread docs/user/admin/settings.rst Outdated
{
"query" : "source=logs-* | stats count() by span(@timestamp, 1h)",
"time_field" : "@timestamp",
"start_time" : "now-7d",

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.

Comment on lines +211 to +212
// Say so: a one-sided window is almost always a typo in the other key, and dropping it in
// silence leaves no trace of why nothing was pruned.

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.

Remove AI comments!

package org.opensearch.sql.opensearch.storage.scan;

import java.util.ArrayList;
import java.util.Collection;

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.

why change this file? seems not releated to this PR.

* @param bounds request-level bounds
* @return expression to read, never null
*/
public IndexName prune(IndexName indexName, TimeBounds bounds) {

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.

why add new interface? Instead, build a range query and reuse exsting prune API

@penghuo on opensearch-project#5766: this is a user-facing document and does not need to explain engine
internals.

Rewrote the section around what a caller does and gets. Gone: when the bounds arrive
relative to index resolution, that they are handed to a probe rather than reinterpreted,
that a relative range is resolved once by OpenSearch, and that a failure to parse makes
pruning "decline" -- all true, none of it actionable from outside.

The same lens applied to the two paragraphs above it, which had the same problem: the
list of "ways the range can reach the pruner" described the mapping merge and the PIT
rather than saying that a query filtering on @timestamp needs no configuration and
everything else needs the parameters.

Two fixes while there. The heading was underlined at the same level as Version and
Description, making it their sibling and pulling the setting's own disable and result-set
examples underneath it; it is now nested under Description. And the example sent bounds
for a window its query did not restrict, contradicting the rule stated directly above it
-- it now shows the intended shape, bounds repeating a where clause on the same field and
range.

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

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit dfaf49e

Comments across the change were explaining at length what the code and the user manual
already say. Trimmed from 276 lines to 184, keeping only what is not evident from
reading: why narrowing happens as the table is built rather than after, why the probe's
format list replaces the field's own, why the warning's field list is sorted, and why
the security IT's role needs the two probe actions.

Removed the restatements of the ordering argument, which appeared in five places and
belongs in the PR and the manual rather than in every javadoc that touches it.

No behaviour change; unit suites unchanged at 9119.

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

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit 616e021

@penghuo on opensearch-project#5766: what if JOIN -- applied to all indices?

Yes, and it changes the answer. Verified against a cluster: a join whose other side reads
its own wildcard, matching one in-window and one out-of-window index, contributes 2 rows
without bounds and 1 with them, the pruner reporting joinref-* -> joinref-new. The outer
query never names that pattern.

Two limits, both tested. A concrete name on the other side is untouched, since pruning
needs a wildcard. And a side lying entirely outside the window is not emptied: no index
matches, so the probe returns nothing and pruning declines rather than substituting an
empty list.

The manual said "every source the query reads is narrowed" and left the reader to work
out what that means for a join. It now says it, and says what to do instead.

The existing subsearch IT could not have caught this -- both sides read the same pattern,
so one cached table served both. The new case gives the join side a pattern of its own.

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

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit 42f9f71

@penghuo on opensearch-project#5766: what is the intent of this section?

It had three intents and led with none of them -- a usage rule, the mechanism behind it,
and two edge cases -- and it restated its own conclusion in the paragraph above, which
ended with the same instruction.

Now one rule, first sentence: send the bounds only alongside an equivalent filter in the
query. Then why, in one clause: they exclude whole indices, so a window the query does not
also restrict returns fewer rows. The mechanism is gone; a reader does not need to know
that documents outside the window still count inside a retained index to follow the rule.

The join and subsearch scope follows as a consequence rather than repeating the
instruction.

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

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit 9a5849c

@penghuo on opensearch-project#5766: what are the supported literals -- the doc should be clear on it.

Replaced the prose list with a table, one row per form, each with examples. Testing every
candidate against a cluster rather than reading it off the format string turned up two
things the prose had wrong.

`yyyy-MM-dd HH:mm:ss` without milliseconds did not parse, only the `.SSS` form did. PPL
accepts that exact literal in a `where` clause, so a client mirroring its own filter into
these parameters would have got silent non-pruning. Added to the accepted formats, with
the date-only form pinned in TimeBoundsTest alongside it.

Epoch seconds are not accepted and cannot be: a ten-digit number parses as milliseconds,
so it means January 1970 rather than failing. Nothing is pruned, since no index matches
that window, but the doc now says so instead of leaving it to be discovered.

Verified after the change: every listed form prunes, epoch seconds and malformed values
decline.

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

github-actions Bot commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

PR Code Analyzer ❗

AI-powered 'Code-Diff-Analyzer' found issues on commit abd9510.

PathLineSeverityDescription
ppl/src/main/java/org/opensearch/sql/ppl/domain/PPLQueryRequest.java208lowUser-supplied `time_field` value is read directly from the request JSON and passed as a field name into a RangeQueryBuilder and FieldCapabilitiesRequest without sanitization. A caller can name any field, including internal or unmapped ones, which lets them probe index schema topology (learn whether a field exists across indices) by observing which indices are pruned. This is an information-disclosure side-channel, not injection, because OpenSearch's query builders serialize field names safely. It is an inherent design trade-off of the feature, not deliberate malicious code, but worth acknowledging for threat-model review.

The table above displays the top 10 most important findings.

Total: 1 | Critical: 0 | High: 0 | Medium: 0 | Low: 1


Pull Requests Author(s): Please update your Pull Request according to the report above.

Repository Maintainer(s): You can bypass diff analyzer by adding label skip-diff-analyzer after reviewing the changes carefully, then re-run failed actions. To re-enable the analyzer, remove the label, then re-run all actions.


⚠️ Note: The Code-Diff-Analyzer helps protect against potentially harmful code patterns. Please ensure you have thoroughly reviewed the changes beforehand.

Thanks.

@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit efcd23d

@penghuo on opensearch-project#5766: update docs/user/interfaces/endpoint.rst too.

That is where a PPL request-body parameter belongs -- fetch_size is documented there, not
only in the settings manual -- so the parameters had been described where the setting
lives and nowhere a reader looking up the API would find them.

Adds a "Time Bounds (PPL) [Experimental]" section following the Fetch Size one: what the
three parameters do, the rule that they go alongside an equivalent filter rather than
instead of one, the accepted literals as a table, and a worked example. Cross-references
the setting for the limitations rather than repeating them.

The example is a real transcript. Ran it against a cluster with two monthly indices and
pasted what came back, including that the engine read only February. Also checked the
command is valid shell as written: PPL string literals are double-quoted, since a
single-quoted literal cannot be escaped inside curl's single-quoted -d argument.

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

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit 2ee666e

@penghuo on opensearch-project#5766: remove AI comments.

An earlier pass shortened them, which was not the ask. Deleted instead: 276 lines
originally, 184 after shortening, 107 now, and what is left is javadoc on the public
surface plus two comments carrying facts the code cannot.

Gone entirely are the inline comments restating the line beneath them -- that explain
resolves the same tables, that bounds are passed as sent, that a LOG.warn exists because
a one-sided window is usually a typo -- and the paragraphs of rationale in test javadoc,
which belong in the PR and the manual.

Kept: that the alias and data-stream gates must come last because they resolve the
expression, that ppl_full_access grants the two probe actions, and one-line contracts on
the new public types.

Also removes a comment in IndexPrunerTest that had gone stale, still describing the
unfiltered mapping probe deleted when the unmapped-field guard was reverted -- worse than
verbose, since it described behaviour that no longer exists.

Unit 9121, pruning IT 10, both unchanged.

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

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit c128ebd

@penghuo on opensearch-project#5766: why change this file, it seems unrelated -- is it absolutely needed,
can we remove it?

Removed. It is not needed here, and the reason I had for carrying it no longer holds.

The three-banner behaviour it fixed is reproducible on main with pruning off and no bounds
at all -- a text/keyword conflict plus `chart count() over ts by env` -- so this PR never
caused it. I originally carried the fix because an earlier revision of this PR retained an
index that did not map the time field, which gave the partial-result feature something to
exclude and so surfaced the banners. That retention was reverted; without it, pruning only
ever reduces the index count, and partial-result needs two disagreeing indices, so this
change makes those warnings less likely rather than more.

Kept as a patch for its own PR against opensearch-project#5657, where it can be reviewed on its merits: the
warning names every group key rather than the ones that cannot be aggregated, which is
both inaccurate and prevents drainWarnings() from de-duplicating repeated plan
alternatives.

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

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit d676381

chart and timechart desugar into two aggregations over the same scan --
one for the rows charted, one to rank the top-N columns -- each with its
own group keys. Partial-result mode partitions indices per aggregate, so
the two can exclude different index sets: the ranking is then computed
over indices whose rows are not in the chart. It also raised one warning
per aggregate, which de-duplication by rendered text could not collapse.

Mark the plan when a chart is visited and skip the partial-result path
for such a query, returning the complete result instead. The mark rides
the thread-local snapshot because pushdown can run on the complex worker
pool.

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

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit abd9510

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

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit 336be69

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.

2 participants