Skip to content

Add summarization, limit, and stats-skip to _cat APIs - #22502

Open
kkhatua wants to merge 6 commits into
opensearch-project:mainfrom
kkhatua:summaryAPI
Open

Add summarization, limit, and stats-skip to _cat APIs#22502
kkhatua wants to merge 6 commits into
opensearch-project:mainfrom
kkhatua:summaryAPI

Conversation

@kkhatua

@kkhatua kkhatua commented Jul 19, 2026

Copy link
Copy Markdown
Member

Description

Adds grouped + aggregated views, a limit parameter, and a stats fan-out optimization to the _cat APIs. Grouping and aggregation are expressed entirely through the existing h= parameter, so a bare column token (e.g. action) is a GROUP BY key and a func(field) token (sum, count, avg, min, max) is an aggregate over the grouped rows. This mirrors the SQL mental model

SELECT action, count(task_id) ... GROUP BY action

Since grouping is inferred from h=

  1. When h= contains no aggregation function, the table is returned unchanged — ordinary _cat listings behave exactly as before.
  2. The group key is always a bare h= token, so it is always rendered (no invisible grouping keys or silently dropped columns).

Aggregation preserves numeric source types (Long/Integer/Short/Byte, ByteSizeValue, SizeValue, TimeValue) so downstream bytes= / time= / size= rendering keeps working on aggregated values.

Also, avg() divides by the count of non-null values so null cells don’t drag the average toward zero.

This feature is centralized in RestTable.buildResponse, so every _cat endpoint that renders through RestTable inherits it (shards, tasks, segments, indices, nodes, aliases, recovery, …).

Examples

1. _cat/shards

Roll shards up per index — count, total docs, total store (largest first):

GET _cat/shards?h=index,count(shard),sum(docs),sum(store)&s=sum(store):desc&v`

index      count(shard)  sum(docs)  sum(store)
my-logs              10    5000000      12.3gb
my-events             6    1200000       3.1gb

Group by index + primary/replica:

GET _cat/shards?h=index,prirep,count(shard),avg(docs)&v

limit applies after sort, so this yields the top-5 indices by document count:

GET _cat/shards?h=index,sum(docs)&s=sum(docs):desc&limit=5&v

Plain listings are unchanged (no aggregation function present):

GET _cat/shards?h=index,shard,prirep,state,node&v

The perf commit additionally skips the cluster-wide IndicesStats broadcast entirely when every requested column is derivable from cluster state (e.g. the plain listing above), which is a meaningful savings on high-shard clusters.

2. _cat/tasks

Aggregation works identically for tasks. The tables below were produced from a real 510-task _tasks snapshot:

Tasks per action, with running-time stats (a TimeValue, so units are preserved), most frequent first:

GET _cat/tasks?h=action,count(task_id),avg(running_time),max(running_time)&s=count(task_id):desc&v

action                                          count(task_id)   avg(running_time)   max(running_time)
indices:data/read/search                                   267               13.0d               36.1d
indices:data/read/msearch                                  130               12.8d               36.1d
cluster:monitor/tasks/lists[n]                              63               1.6ms               8.6ms
indices:data/write/bulk[s]                                  13              48.3ms             376.1ms
indices:admin/seq_no/global_checkpoint_sync                  8               4.1ms               7.2ms
indices:data/write/bulk[s][p]                                6              80.4ms             376.1ms
indices:data/write/bulk[s][r]                                4             107.4ms             374.9ms
indices:data/write/bulk                                      3             138.0ms             376.2ms
...

Group by task type:

GET _cat/tasks?h=type,count(task_id),avg(running_time),max(running_time)&s=count(task_id):desc&v

type         count(task_id)   avg(running_time)   max(running_time)
transport               495               10.3d               36.1d
direct                   15              34.5ms             376.1ms

Top nodes by number of running tasks:

GET _cat/tasks?h=node,count(task_id),max(running_time)&s=count(task_id):desc&limit=10&v

Related Issues

Resolves #22500

Check List

  • Functionality includes testing.
  • API changes companion pull request created, if applicable.
  • Public documentation issue/PR created, if applicable.

By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.
For more information on following Developer Certificate of Origin and signing off your commits, please check here.

@kkhatua
kkhatua requested a review from a team as a code owner July 19, 2026 01:23
@github-actions github-actions Bot added the enhancement Enhancement or improvement to existing feature or request label Jul 19, 2026
@github-actions

github-actions Bot commented Jul 19, 2026

Copy link
Copy Markdown
Contributor

PR Reviewer Guide 🔍

(Review updated until commit e38cfd0)

Here are some key observations to aid the review process:

🧪 PR contains tests
🔒 No security concerns identified
✅ No TODO sections
🔀 Multiple PR themes

Sub-PR theme: Stats fan-out skip optimization for _cat/shards

Relevant files:

  • server/src/main/java/org/opensearch/action/admin/cluster/shards/CatShardsRequest.java
  • server/src/main/java/org/opensearch/action/admin/cluster/shards/TransportCatShardsAction.java
  • server/src/main/java/org/opensearch/rest/action/cat/RestShardsAction.java
  • server/src/test/java/org/opensearch/action/admin/cluster/shards/CatShardsRequestTests.java
  • server/src/test/java/org/opensearch/rest/action/cat/RestShardsActionTests.java

Sub-PR theme: Grouped aggregation and limit support in RestTable

Relevant files:

  • server/src/main/java/org/opensearch/rest/action/cat/TableSummarizer.java
  • server/src/main/java/org/opensearch/rest/action/cat/RestTable.java
  • server/src/test/java/org/opensearch/rest/action/cat/TableSummarizerTests.java
  • server/src/test/java/org/opensearch/rest/action/cat/RestTableTests.java

⚡ Recommended focus areas for review

Version Gate

The wire format is gated on Version.V_3_8_0. If this PR is not merged and backported before 3.8.0 is released, or if the target version is different, deserialization will fail against mixed-version clusters. Verify V_3_8_0 is the correct/unreleased version constant for this feature.

if (in.getVersion().onOrAfter(Version.V_3_8_0)) {
    indicesStatsRequired = in.readBoolean();
}
Incomplete fast-path check

requestNeedsIndicesStats only inspects h= and s=, but other query parameters like bytes=, time=, size=, pri, or paging (next_token/page size) may also affect required output. If pri grouping or other stats-dependent parameters are set with a routing-only h=, the fast path will skip IndicesStats and produce empty stats columns silently. Confirm no other query parameter can require stats when h=/s= are routing-only.

static boolean requestNeedsIndicesStats(RestRequest request) {
    String hParam = request.param("h");
    if (hParam == null || hParam.isEmpty()) {
        return true; // default headers include stats columns
    }
    for (String token : Strings.splitStringByCommaToArray(hParam)) {
        if (isRoutingOnlyToken(token) == false) {
            return true;
        }
    }
    String sParam = request.param("s");
    if (sParam != null && sParam.isEmpty() == false) {
        for (String token : Strings.splitStringByCommaToArray(sParam)) {
            // strip :asc / :desc suffix before checking (case-insensitive, whitespace-tolerant)
            String col = token.trim();
            String lower = col.toLowerCase(Locale.ROOT);
            if (lower.endsWith(":desc")) col = col.substring(0, col.length() - 5);
            else if (lower.endsWith(":asc")) col = col.substring(0, col.length() - 4);
            if (isRoutingOnlyToken(col) == false) {
                return true;
            }
        }
    }
    return false;
}
Attribute value corruption

copyHeaderAttrString silently rewrites ; and : inside attribute values to spaces to survive the parser round-trip. This mutates user-facing desc text (e.g. "contains: colon" becomes "contains colon"). If any real header description contains a colon (common for units like "size:bytes"), the rendered header will be visibly altered. Consider escaping or documenting this behavior more prominently.

static String copyHeaderAttrString(Table.Cell origHeader, String fallbackDesc) {
    if (origHeader == null || origHeader.attr == null || origHeader.attr.isEmpty()) {
        return "desc:" + fallbackDesc;
    }
    StringBuilder sb = new StringBuilder();
    boolean hasDesc = false;
    for (Map.Entry<String, String> e : origHeader.attr.entrySet()) {
        String v = e.getValue();
        if (v == null) continue;
        // Attribute strings are parsed on ';' and ':', so delimiter chars inside a value would
        // otherwise mangle the parse. Replace them with spaces so the attribute survives the
        // round-trip (the desc text is slightly reformatted; alias/text-align are unaffected
        // because those values never contain these characters in practice).
        if (v.indexOf(';') >= 0 || v.indexOf(':') >= 0) {
            v = v.replace(';', ' ').replace(':', ' ');
        }
        if (sb.length() > 0) sb.append(';');
        sb.append(e.getKey()).append(':').append(v);
        if ("desc".equals(e.getKey())) hasDesc = true;
    }
    if (!hasDesc) {
        if (sb.length() > 0) sb.append(';');
        sb.append("desc:").append(fallbackDesc);
    }
    return sb.toString();
}
Param consumption ordering

Reading h via request.params().get("h") bypasses the normal param-consumption tracking. If TableSummarizer.summarize throws (e.g., unknown column IllegalArgumentException), h remains unconsumed and the request may later be flagged as containing unused parameters, producing a confusing secondary error. Also, the IllegalArgumentException from summarize will propagate as a 500 rather than a 400 to the user.

public static RestResponse buildResponse(Table table, RestChannel channel) throws Exception {
    RestRequest request = channel.request();
    // Non-consuming read: buildDisplayHeaders later calls request.param("h") to mark it
    // consumed. Accessing the raw map here avoids reordering param-consumption semantics.
    String hParam = request.params().get("h");
    // Grouping/aggregation is inferred from h=: if any h= token is an aggregation function
    // (e.g. sum(docs)), summarize the table; bare tokens are treated as GROUP BY keys. Plain
    // h= listings (no functions) are returned unchanged by TableSummarizer.summarize.
    if (TableSummarizer.hasAggregation(hParam)) {
        table = TableSummarizer.summarize(table, hParam);
    }

@github-actions

github-actions Bot commented Jul 19, 2026

Copy link
Copy Markdown
Contributor

PR Code Suggestions ✨

Latest suggestions up to e38cfd0

Explore these optional code suggestions:

CategorySuggestion                                                                                                                                    Impact
General
Match routing-only column tokens case-insensitively

The alias s for shard collides with the s query parameter used for sorting. More
importantly, requestNeedsIndicesStats performs case-sensitive matching against these
tokens, but column aliases in cat APIs are typically case-insensitive. Consider
lowercasing tokens before comparison to avoid mistakenly forcing the slow path for
e.g. Index or SHARD.

server/src/main/java/org/opensearch/rest/action/cat/RestShardsAction.java [151-160]

+private static final Set<String> ROUTING_ONLY_COLUMNS = Set.of(
+    // index
+    "index",
+    "i",
+    "idx",
+    // shard
+    "shard",
+    "s",
+    "sh",
 
-
Suggestion importance[1-10]: 4

__

Why: Case-insensitive matching is a reasonable robustness improvement, though the improved_code is identical to existing_code and doesn't actually demonstrate the fix. The impact is minor since cat column names are typically lowercase.

Low
Trim after stripping sort direction suffix

Sort tokens can include a numeric direction like :0/:1 in some cat implementations,
and column names themselves can contain colons (e.g. unassigned.reason). Stripping
only :asc/:desc is fine, but the resulting col should be checked case-insensitively
against ROUTING_ONLY_COLUMNS since users may write Index:desc. Additionally consider
trimming again after stripping to handle index :desc gracefully.

server/src/main/java/org/opensearch/rest/action/cat/RestShardsAction.java [213-219]

 String col = token.trim();
 String lower = col.toLowerCase(Locale.ROOT);
-if (lower.endsWith(":desc")) col = col.substring(0, col.length() - 5);
-else if (lower.endsWith(":asc")) col = col.substring(0, col.length() - 4);
+if (lower.endsWith(":desc")) col = col.substring(0, col.length() - 5).trim();
+else if (lower.endsWith(":asc")) col = col.substring(0, col.length() - 4).trim();
 if (isRoutingOnlyToken(col) == false) {
     return true;
 }
Suggestion importance[1-10]: 4

__

Why: Adding trim after suffix stripping is a minor robustness improvement for edge cases like index :desc, but such input is unusual and the existing code already handles the common cases including the tested mixed-case suffix scenario.

Low
Consume h parameter to avoid unused-param errors

Reading h via request.params().get("h") does not mark the parameter as consumed. If
no downstream code consumes it, the request may fail with an "unrecognized
parameter" error. Use request.param("h") to ensure proper consumption semantics, or
verify a downstream consumer exists in all code paths.

server/src/main/java/org/opensearch/rest/action/cat/RestTable.java [77-83]

-String hParam = request.params().get("h");
-// Grouping/aggregation is inferred from h=: if any h= token is an aggregation function
-// (e.g. sum(docs)), summarize the table; bare tokens are treated as GROUP BY keys. Plain
-// h= listings (no functions) are returned unchanged by TableSummarizer.summarize.
+String hParam = request.param("h");
 if (TableSummarizer.hasAggregation(hParam)) {
     table = TableSummarizer.summarize(table, hParam);
 }
Suggestion importance[1-10]: 3

__

Why: The PR author intentionally used request.params().get("h") with a comment explaining this is a non-consuming read to preserve param-consumption ordering, since buildDisplayHeaders later consumes it. The suggestion contradicts the intentional design.

Low
Preserve sub-millisecond precision for TimeValue

For min/max of TimeValue, parseAsDouble collapses precision to milliseconds via
TimeValue.millis(). A 10-nanosecond input becomes 0 ms, and reconstructing via new
TimeValue(safeLong(num)) produces a millisecond-based value, losing sub-millisecond
precision even for min/max where the original value could have been preserved
verbatim. Consider tracking the original sample value for min/max instead of
reconstructing from a double.

server/src/main/java/org/opensearch/rest/action/cat/TableSummarizer.java [341-343]

 if (sampleValue instanceof ByteSizeValue) return new ByteSizeValue(safeLong(num));
 if (sampleValue instanceof SizeValue) return new SizeValue(safeLong(num));
-if (sampleValue instanceof TimeValue) return new TimeValue(safeLong(num));
+if (sampleValue instanceof TimeValue) return new TimeValue(safeLong(num), java.util.concurrent.TimeUnit.MILLISECONDS);
Suggestion importance[1-10]: 3

__

Why: The improved code merely makes the millisecond unit explicit but doesn't actually solve the precision loss issue since parseAsDouble already collapses to millis. The suggestion identifies a real precision concern but the proposed fix doesn't address it.

Low

Previous suggestions

Suggestions up to commit 4d1d7d7
CategorySuggestion                                                                                                                                    Impact
Possible issue
Remove incorrect shard alias from routing-only set

The token "s" is listed as an alias for the shard column, but s is also the
query-parameter name for sort. More importantly, RestShardsAction declares the shard
column with alias sh only (not s). Including "s" here allows requests like h=s to
bypass stats when the response would actually be empty/invalid. Remove "s" from the
routing-only set to match the actual column aliases.

server/src/main/java/org/opensearch/rest/action/cat/RestShardsAction.java [158]

 private static final Set<String> ROUTING_ONLY_COLUMNS = Set.of(
     // index
     "index",
     "i",
     "idx",
     // shard
     "shard",
-    "s",
     "sh",
     // prirep
Suggestion importance[1-10]: 7

__

Why: Valid concern: "s" in ROUTING_ONLY_COLUMNS conflicts with the sort query parameter name and may not be a documented alias for shard in RestShardsAction. Removing it reduces risk of misclassification, though the impact depends on actual alias declarations.

Medium
General
Avoid double precision loss on large sums

Accumulating large sums (e.g. many ByteSizeValues of GB scale) in a double loses
precision well before hitting Long.MAX_VALUE — doubles have only ~15-16 significant
decimal digits, so byte-count sums beyond ~2^53 will silently round. Consider
tracking sums for integral-typed columns in a separate long (or BigDecimal)
accumulator to avoid precision loss for typical cat-shards store totals on large
clusters.

server/src/main/java/org/opensearch/rest/action/cat/TableSummarizer.java [283-287]

 if (sum == null) sum = 0.0;
 sum += num;
 if (min == null || num < min) min = num;
 if (max == null || num > max) max = num;
 countContributing++;
+// TODO: use long/BigDecimal accumulator for integral inputs to avoid double precision loss
Suggestion importance[1-10]: 5

__

Why: Valid theoretical concern about double precision loss when summing very large byte counts (beyond 2^53), but the suggested improvement only adds a TODO comment rather than fixing the issue. Impact is real for large clusters but relatively minor.

Low
Trim after stripping sort direction suffix

The suffix stripping uses col.substring(0, col.length() - 5) on the trimmed original
but derives length from lower which has identical length — this is fine, but if col
had trailing whitespace inside the direction (e.g. "index :desc"), the check would
fail. Also, after stripping the suffix, the resulting col may have trailing
whitespace before the colon (e.g. "index :desc""index "), which won't match the
set. Trim again after suffix stripping to be safe.

server/src/main/java/org/opensearch/rest/action/cat/RestShardsAction.java [211-220]

 for (String token : Strings.splitStringByCommaToArray(sParam)) {
-    // strip :asc / :desc suffix before checking (case-insensitive, whitespace-tolerant)
     String col = token.trim();
     String lower = col.toLowerCase(Locale.ROOT);
-    if (lower.endsWith(":desc")) col = col.substring(0, col.length() - 5);
-    else if (lower.endsWith(":asc")) col = col.substring(0, col.length() - 4);
+    if (lower.endsWith(":desc")) col = col.substring(0, col.length() - 5).trim();
+    else if (lower.endsWith(":asc")) col = col.substring(0, col.length() - 4).trim();
     if (isRoutingOnlyToken(col) == false) {
         return true;
     }
 }
Suggestion importance[1-10]: 4

__

Why: Minor edge-case robustness improvement for handling whitespace between column name and :desc/:asc suffix. Unlikely to occur in practice but adds defensive parsing.

Low
Use param() to properly track consumption

Reading request.params().get("h") directly bypasses the parameter-consumption
tracking. Later code (e.g. buildDisplayHeaders) calls request.param("h"), but if any
code path does strict unused-parameter validation before that call, this raw read
wouldn't mark it consumed. More critically, if h is never subsequently consumed for
some output paths, the request may be flagged as containing an unrecognized
parameter. Consider using request.param("h") to align consumption semantics.

server/src/main/java/org/opensearch/rest/action/cat/RestTable.java [77-83]

-String hParam = request.params().get("h");
-// Grouping/aggregation is inferred from h=: if any h= token is an aggregation function
-// (e.g. sum(docs)), summarize the table; bare tokens are treated as GROUP BY keys. Plain
-// h= listings (no functions) are returned unchanged by TableSummarizer.summarize.
+String hParam = request.param("h");
 if (TableSummarizer.hasAggregation(hParam)) {
     table = TableSummarizer.summarize(table, hParam);
 }
Suggestion importance[1-10]: 3

__

Why: The PR author explicitly commented that request.params().get("h") is intentional to avoid reordering param-consumption semantics before buildDisplayHeaders. The suggestion contradicts the intentional design without strong justification.

Low
Suggestions up to commit 7daf310
CategorySuggestion                                                                                                                                    Impact
General
Make routing-only column matching case-insensitive

The ROUTING_ONLY_COLUMNS set uses case-sensitive matching, but cat API column
aliases are typically case-insensitive elsewhere. Normalize the token to lowercase
(via Locale.ROOT) before the contains check in isRoutingOnlyToken, so requests like
h=Index,Shard also benefit from the fast path.

server/src/main/java/org/opensearch/rest/action/cat/RestShardsAction.java [225-232]

-private static final Set<String> ROUTING_ONLY_COLUMNS = Set.of(
-    // index
-    "index",
-    "i",
-    "idx",
-    // shard
-    "shard",
-    "s",
-    "sh",
+private static boolean isRoutingOnlyToken(String token) {
+    if (token == null) return false;
+    String trimmed = token.trim().toLowerCase(Locale.ROOT);
+    if (trimmed.isEmpty()) return false;
+    if (trimmed.indexOf('*') >= 0 || trimmed.indexOf('?') >= 0) return false;
+    return ROUTING_ONLY_COLUMNS.contains(trimmed);
+}
Suggestion importance[1-10]: 5

__

Why: Reasonable suggestion, though cat API column names are conventionally lowercase. The change would harmlessly expand the fast path to case-variant inputs, but the actual downstream cat rendering may not accept mixed-case column names anyway.

Low
Avoid silent clamp for string-numeric sums

For very large integral doubles (e.g. sums of large string-numeric values), num ==
Math.floor(num) is true but the value can exceed Long.MAX_VALUE. safeLong(num) will
then clamp to Long.MAX_VALUE, silently corrupting the string result. Consider
formatting via BigDecimal/String.format("%.0f", num) to avoid a lossy clamp for the
String-preserving path.

server/src/main/java/org/opensearch/rest/action/cat/TableSummarizer.java [320-325]

 if (sampleValue instanceof String) {
     if (num == Math.floor(num) && !Double.isInfinite(num)) {
-        return String.valueOf(safeLong(num));
+        if (num < (double) Long.MAX_VALUE && num > (double) Long.MIN_VALUE) {
+            return String.valueOf(safeLong(num));
+        }
+        return String.format(Locale.ROOT, "%.0f", num);
     }
     return String.format(Locale.ROOT, "%.2f", num);
 }
Suggestion importance[1-10]: 5

__

Why: Valid concern about precision loss for string-numeric sums exceeding Long.MAX_VALUE. This is an edge case unlikely to occur in practice for cat APIs, but the suggested fix improves correctness.

Low
Trim column after stripping sort suffix

After stripping the :asc/:desc suffix, the remaining column may still have trailing
whitespace (e.g. "shard :desc""shard "). Trim the resulting col before passing it
to isRoutingOnlyToken, otherwise legitimate routing-only sort tokens with internal
whitespace will incorrectly force the slow path.

server/src/main/java/org/opensearch/rest/action/cat/RestShardsAction.java [211-220]

 for (String token : Strings.splitStringByCommaToArray(sParam)) {
     // strip :asc / :desc suffix before checking (case-insensitive, whitespace-tolerant)
     String col = token.trim();
     String lower = col.toLowerCase(Locale.ROOT);
-    if (lower.endsWith(":desc")) col = col.substring(0, col.length() - 5);
-    else if (lower.endsWith(":asc")) col = col.substring(0, col.length() - 4);
+    if (lower.endsWith(":desc")) col = col.substring(0, col.length() - 5).trim();
+    else if (lower.endsWith(":asc")) col = col.substring(0, col.length() - 4).trim();
     if (isRoutingOnlyToken(col) == false) {
         return true;
     }
 }
Suggestion importance[1-10]: 4

__

Why: Minor robustness improvement for handling whitespace between column name and :asc/:desc suffix. The impact is low since users rarely put spaces there, but the fix is correct and simple.

Low
Verify param consumption semantics unchanged

request.param("h") marks the parameter as consumed here. If a downstream code path
(e.g. buildDisplayHeaders) also reads h, that's fine, but be aware this early access
changes the "unrecognized parameter" bookkeeping. Consider using
request.rawParam("h") or an equivalent non-consuming accessor to avoid altering
param consumption semantics before the rest of the response pipeline runs.

server/src/main/java/org/opensearch/rest/action/cat/RestTable.java [75-81]

 String hParam = request.param("h");
-// Grouping/aggregation is inferred from h=: if any h= token is an aggregation function
-// (e.g. sum(docs)), summarize the table; bare tokens are treated as GROUP BY keys. Plain
-// h= listings (no functions) are returned unchanged by TableSummarizer.summarize.
 if (TableSummarizer.hasAggregation(hParam)) {
     table = TableSummarizer.summarize(table, hParam);
 }
Suggestion importance[1-10]: 2

__

Why: The suggestion only asks the reviewer to verify parameter consumption semantics and the improved_code is essentially identical to existing_code. Low impact and speculative.

Low
Suggestions up to commit bd043ec
CategorySuggestion                                                                                                                                    Impact
General
Normalize sort direction suffix parsing

The sort-token direction suffix stripping is case-sensitive and does not trim
whitespace, so tokens like index:DESC or index :desc will not match the routing-only
set and will incorrectly force the slow path (or worse, if a user writes docs:DESC
it correctly falls back, but valid routing-only sorts like index:ASC are
misclassified as unknown and forced to slow path). Normalize case and trim before
stripping.

server/src/main/java/org/opensearch/rest/action/cat/RestShardsAction.java [209-220]

 String sParam = request.param("s");
 if (sParam != null && sParam.isEmpty() == false) {
     for (String token : Strings.splitStringByCommaToArray(sParam)) {
-        // strip :asc / :desc suffix before checking
-        String col = token;
-        if (col.endsWith(":desc")) col = col.substring(0, col.length() - 5);
-        else if (col.endsWith(":asc")) col = col.substring(0, col.length() - 4);
+        String col = token.trim();
+        String lower = col.toLowerCase(Locale.ROOT);
+        if (lower.endsWith(":desc")) col = col.substring(0, col.length() - 5);
+        else if (lower.endsWith(":asc")) col = col.substring(0, col.length() - 4);
         if (isRoutingOnlyToken(col) == false) {
             return true;
         }
     }
 }
Suggestion importance[1-10]: 6

__

Why: Valid observation: the suffix stripping is case-sensitive and doesn't trim whitespace, so index:DESC or index:desc would be misclassified and force the slow path unnecessarily. The impact is a performance regression (correctness is preserved), so moderate importance.

Low
Guard double-to-long cast overflow

Casting a double sum to long can silently overflow for large aggregations (e.g.
summing byte sizes across many shards can exceed Long.MAX_VALUE as a double, or a
negative-looking long on overflow). Guard the cast by clamping or checking for
out-of-range values before constructing the wrapper types to avoid producing
negative/incorrect sizes.

server/src/main/java/org/opensearch/rest/action/cat/TableSummarizer.java [314-316]

-if (sampleValue instanceof ByteSizeValue) return new ByteSizeValue((long) num);
-if (sampleValue instanceof SizeValue) return new SizeValue((long) num);
-if (sampleValue instanceof TimeValue) return new TimeValue((long) num);
+if (sampleValue instanceof ByteSizeValue) return new ByteSizeValue(safeLong(num));
+if (sampleValue instanceof SizeValue) return new SizeValue(safeLong(num));
+if (sampleValue instanceof TimeValue) return new TimeValue(safeLong(num));
Suggestion importance[1-10]: 5

__

Why: Overflow in aggregate byte-size or time-value sums across very large clusters is theoretically possible, but the suggestion references a safeLong helper that isn't defined and doesn't specify clamping semantics. It's a valid edge-case concern of moderate importance.

Low
Avoid silently dropping header attributes

Silently dropping attribute entries whose values contain ; or : can lose important
metadata (e.g. a desc describing a metric that happens to contain a colon). Consider
escaping the delimiters or at minimum logging/warning when a value is dropped, so
downstream rendering does not silently lose header semantics like alignment or
description.

server/src/main/java/org/opensearch/rest/action/cat/TableSummarizer.java [183-190]

 for (Map.Entry<String, String> e : origHeader.attr.entrySet()) {
     String v = e.getValue();
     if (v == null) continue;
-    if (v.indexOf(';') >= 0 || v.indexOf(':') >= 0) continue; // skip un-encodable values
+    if (v.indexOf(';') >= 0 || v.indexOf(':') >= 0) {
+        // value cannot be safely encoded in attr string; retain key with sanitized value
+        v = v.replace(';', ' ').replace(':', ' ');
+    }
     if (sb.length() > 0) sb.append(';');
     sb.append(e.getKey()).append(':').append(v);
     if ("desc".equals(e.getKey())) hasDesc = true;
 }
Suggestion importance[1-10]: 4

__

Why: Silently dropping attributes with ; or : may lose metadata like desc content, but this is a minor edge case since header attribute values typically don't contain delimiters. The improvement provides a reasonable sanitization approach.

Low
Suggestions up to commit fc49a34
CategorySuggestion                                                                                                                                    Impact
General
count(field) should exclude nulls

count(field) currently returns countAll, which counts every row in the group
regardless of whether the field's value is null. In SQL semantics, COUNT(field)
counts non-null values, and COUNT() counts rows. Returning countAll here means
count(v) and count(
) are indistinguishable and null-heavy columns produce
misleading counts. Consider returning countContributing (or exposing both count(*)
and count(field) separately).

server/src/main/java/org/opensearch/rest/action/cat/TableSummarizer.java [267-270]

 Object getValue(String function) {
     switch (function) {
         case "count":
-            return countAll;
+            return countContributing;
Suggestion importance[1-10]: 6

__

Why: The SQL semantic concern is valid — count(field) conventionally excludes nulls. However, the existing test testAvgIgnoresNullValuesInDenominator explicitly asserts current behavior (count includes nulls), so this is a design choice trade-off.

Low
Validate referenced columns exist

If any group-by or aggregation field in h= refers to a column not present in the
table (e.g. user typo or column not registered), colMap.get(gb) returns null and the
code silently treats all rows as having null for that field, producing a single
meaningless group. Validate that every resolved group-by/aggregation field exists in
colMap and throw a clear IllegalArgumentException (or skip/return original) so
misuse surfaces instead of returning misleading aggregates.

server/src/main/java/org/opensearch/rest/action/cat/TableSummarizer.java [116-117]

 Map<String, List<Table.Cell>> colMap = table.getAsMap();
 int rowCount = table.getRows().size();
+for (String gb : resolvedGroupBy) {
+    if (colMap.containsKey(gb) == false) {
+        throw new IllegalArgumentException("Unknown column [" + gb + "] in h= for summarization");
+    }
+}
+for (AggColumn agg : aggColumns) {
+    if (colMap.containsKey(agg.field) == false) {
+        throw new IllegalArgumentException("Unknown column [" + agg.field + "] in aggregation " + agg.displayName);
+    }
+}
Suggestion importance[1-10]: 5

__

Why: Throwing on unknown columns would improve error surfacing, but the current silent-null behavior may be intentional for permissive _cat semantics. Moderate improvement to user experience.

Low
Handle sort suffix case-insensitively

The sort direction suffix comparison is case-sensitive, but s= values like docs:DESC
or index:Asc are commonly accepted case-insensitively elsewhere. Normalize the
suffix check to lowercase to avoid incorrectly treating index:DESC as an unknown
column and forcing the slow path (or worse, misclassifying it as routing-only when
the base column happens to match). Also trim the token before splitting.

server/src/main/java/org/opensearch/rest/action/cat/RestShardsAction.java [209-220]

 String sParam = request.param("s");
 if (sParam != null && sParam.isEmpty() == false) {
     for (String token : Strings.splitStringByCommaToArray(sParam)) {
-        // strip :asc / :desc suffix before checking
-        String col = token;
-        if (col.endsWith(":desc")) col = col.substring(0, col.length() - 5);
-        else if (col.endsWith(":asc")) col = col.substring(0, col.length() - 4);
+        String col = token.trim();
+        String lower = col.toLowerCase(Locale.ROOT);
+        if (lower.endsWith(":desc")) col = col.substring(0, col.length() - 5);
+        else if (lower.endsWith(":asc")) col = col.substring(0, col.length() - 4);
         if (isRoutingOnlyToken(col) == false) {
             return true;
         }
     }
 }
Suggestion importance[1-10]: 4

__

Why: Case-insensitive handling of :desc/:asc is a reasonable robustness improvement, though sort direction suffixes are typically lowercase in OpenSearch. Minor impact.

Low
Possible issue
Verify BWC version gate constant

Confirm that Version.V_3_7_0 actually exists in the codebase at merge time and
matches the release this change ships in. If the constant hasn't been added yet, or
the field is bumped to a different version before release, the serialization guard
will silently be wrong and older nodes may read/write an unexpected boolean, causing
wire-protocol corruption in mixed-version clusters. Ensure the version gate is
updated in lockstep with the actual release version.

server/src/main/java/org/opensearch/action/admin/cluster/shards/CatShardsRequest.java [52-54]

+if (in.getVersion().onOrAfter(Version.V_3_7_0)) {
+    indicesStatsRequired = in.readBoolean();
+}
 
-
Suggestion importance[1-10]: 4

__

Why: Verification-only suggestion asking to confirm the version constant matches the release; important for BWC but doesn't propose a concrete code change.

Low
Suggestions up to commit 680b92c
CategorySuggestion                                                                                                                                    Impact
General
Avoid truncation and overflow in aggregates

Casting a double to long via (long) num truncates toward zero and can also overflow
silently for large sums (e.g. summing large byte sizes across many shards). Consider
using Math.round for better rounding semantics and guarding against overflow by
clamping to Long.MAX_VALUE when the value exceeds it.

server/src/main/java/org/opensearch/rest/action/cat/TableSummarizer.java [314-316]

-if (sampleValue instanceof ByteSizeValue) return new ByteSizeValue((long) num);
-if (sampleValue instanceof SizeValue) return new SizeValue((long) num);
-if (sampleValue instanceof TimeValue) return new TimeValue((long) num);
+if (sampleValue instanceof ByteSizeValue) return new ByteSizeValue(num >= Long.MAX_VALUE ? Long.MAX_VALUE : Math.round(num));
+if (sampleValue instanceof SizeValue) return new SizeValue(num >= Long.MAX_VALUE ? Long.MAX_VALUE : Math.round(num));
+if (sampleValue instanceof TimeValue) return new TimeValue(num >= Long.MAX_VALUE ? Long.MAX_VALUE : Math.round(num));
Suggestion importance[1-10]: 6

__

Why: Using Math.round improves rounding accuracy over (long) num truncation for aggregated values like avg. The overflow guard is marginally useful but the rounding fix is valid.

Low
Normalize sort tokens before suffix stripping

The sort direction suffix check is case-sensitive and doesn't trim whitespace, but
OpenSearch sort parameters typically allow case-insensitive suffixes and may contain
surrounding spaces. Normalize the token (trim + lowercase) before stripping the
suffix to avoid incorrectly forcing the slow path for valid inputs like Index:DESC
or index:asc .

server/src/main/java/org/opensearch/rest/action/cat/RestShardsAction.java [209-220]

 String sParam = request.param("s");
 if (sParam != null && sParam.isEmpty() == false) {
     for (String token : Strings.splitStringByCommaToArray(sParam)) {
-        // strip :asc / :desc suffix before checking
-        String col = token;
-        if (col.endsWith(":desc")) col = col.substring(0, col.length() - 5);
-        else if (col.endsWith(":asc")) col = col.substring(0, col.length() - 4);
+        String col = token.trim();
+        String lower = col.toLowerCase(Locale.ROOT);
+        if (lower.endsWith(":desc")) col = col.substring(0, col.length() - 5);
+        else if (lower.endsWith(":asc")) col = col.substring(0, col.length() - 4);
         if (isRoutingOnlyToken(col) == false) {
             return true;
         }
     }
 }
Suggestion importance[1-10]: 5

__

Why: Reasonable robustness improvement for case-insensitive/whitespace handling of sort suffixes, though the existing code path only forces the slow path (correctness preserved), so impact is limited.

Low
Make aggregation function matching case-insensitive

AGG_FUNC_PATTERN is case-sensitive, but hasAggregation and summarize should behave
consistently for user input. A user passing SUM(docs) will fail to match here
(returning original table) while a downstream sort or other consumer may treat it
inconsistently. Compile the pattern with Pattern.CASE_INSENSITIVE so both detection
and parsing accept mixed-case function names uniformly.

server/src/main/java/org/opensearch/rest/action/cat/TableSummarizer.java [49]

-Matcher m = AGG_FUNC_PATTERN.matcher(h);
-if (m.matches()) {
-    String func = m.group(1).toLowerCase(Locale.ROOT);
-    String field = aliasMap.getOrDefault(m.group(2).trim(), m.group(2).trim());
-    aggColumns.add(new AggColumn(func, field, h));
+private static final Pattern AGG_FUNC_PATTERN = Pattern.compile("^(sum|count|avg|min|max)\\((.+)\\)$", Pattern.CASE_INSENSITIVE);
Suggestion importance[1-10]: 4

__

Why: Reasonable usability improvement, but there's no clear evidence the API contract intends to support mixed-case function names; low impact.

Low
Ensure limit param is universally consumed

request.paramAsInt will throw if limit is non-numeric, producing a confusing 500 for
user input errors. Also, the limit param may be consumed here but not marked as a
response param on every cat action, leading to "unrecognized parameter" errors.
Ensure the parameter is registered as a consumed/response param on all cat handlers
where this code is reached.

server/src/main/java/org/opensearch/rest/action/cat/RestTable.java [218-221]

+int limit = request.paramAsInt("limit", -1);
+if (limit > 0 && rowOrder.size() > limit) {
+    rowOrder = new ArrayList<>(rowOrder.subList(0, limit));
+}
 
-
Suggestion importance[1-10]: 3

__

Why: Raises a valid concern about registering limit as a consumed param, but the improved_code is identical to the existing_code, providing no concrete fix.

Low

@github-actions

Copy link
Copy Markdown
Contributor

❌ Gradle check result for 680b92c: FAILURE

Please examine the workflow log, locate, and copy-paste the failure(s) below, then iterate to green. Is the failure a flaky test unrelated to your change?

@cwperks

cwperks commented Jul 19, 2026

Copy link
Copy Markdown
Member
> Task :libs:opensearch-common:forbiddenApisTest
Forbidden class/interface use: java.security.AccessController [Deprecated in Java 25]
  in org.opensearch.common.annotation.processor.CompilerSupport (CompilerSupport.java:68)

To fix this use the AccessController replacement within this repo: https://github.com/opensearch-project/OpenSearch/blob/main/libs/agent-sm/agent-policy/src/main/java/org/opensearch/secure_sm/AccessController.java

Edit: That's not the cause of failure. The cause was further below

> Task :server:forbiddenApisTest FAILED
Forbidden method invocation: java.lang.Class#getDeclaredMethod(java.lang.String, java.lang.Class[]) [Do not violate java's access system: Use getMethod() instead]
  in org.opensearch.rest.action.cat.TableSummarizerTests (TableSummarizerTests.java:87)
Forbidden method invocation: java.lang.reflect.AccessibleObject#setAccessible(boolean) [Do not violate java's access system]
  in org.opensearch.rest.action.cat.TableSummarizerTests (TableSummarizerTests.java:88)
Scanned 4927 class file(s) for forbidden API invocations (in 3.01s), 2 error(s).

@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit fc49a34

@github-actions

Copy link
Copy Markdown
Contributor

❌ Gradle check result for fc49a34: FAILURE

Please examine the workflow log, locate, and copy-paste the failure(s) below, then iterate to green. Is the failure a flaky test unrelated to your change?

@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit bd043ec

@github-actions

Copy link
Copy Markdown
Contributor

❌ Gradle check result for bd043ec: FAILURE

Please examine the workflow log, locate, and copy-paste the failure(s) below, then iterate to green. Is the failure a flaky test unrelated to your change?

@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit 7daf310

@github-actions

Copy link
Copy Markdown
Contributor

✅ Gradle check result for 7daf310: SUCCESS

@codecov

codecov Bot commented Jul 20, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 76.19048% with 10 lines in your changes missing coverage. Please review.
✅ Project coverage is 71.34%. Comparing base (03a4de3) to head (e38cfd0).

Files with missing lines Patch % Lines
...g/opensearch/rest/action/cat/RestShardsAction.java 73.91% 0 Missing and 6 partials ⚠️
...admin/cluster/shards/TransportCatShardsAction.java 0.00% 4 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##               main   #22502      +/-   ##
============================================
- Coverage     71.42%   71.34%   -0.08%     
+ Complexity    76786    76745      -41     
============================================
  Files          6148     6148              
  Lines        357980   358021      +41     
  Branches      52177    52192      +15     
============================================
- Hits         255689   255433     -256     
- Misses        81937    82232     +295     
- Partials      20354    20356       +2     

☔ 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.

@kkhatua
kkhatua requested review from cwperks and jainankitk July 20, 2026 05:07
kkhatua pushed a commit to kkhatua/OpenSearch that referenced this pull request Jul 30, 2026
Address the remaining review comments on PR opensearch-project#22502:

- Version gate for the new indicesStatsRequired field in CatShardsRequest
  moves from V_3_7_0 to V_3_8_0. The 3.8.0 branch has been cut from main,
  so this change lands in 3.9.0-development; gating on V_3_8_0 keeps the
  intent (feature only serialized between peers that have it) without
  fabricating a V_3_9_0 constant that doesn't yet exist in Version.java.
  Adds mixed-version wire-compatibility tests covering the round-trip on
  Version.CURRENT and the safe default (indicesStatsRequired=true) when
  the negotiated peer version is before the gate.

- RestTable.buildResponse now reads h= via request.params().get("h")
  instead of request.param("h"). The buildDisplayHeaders call later in
  the pipeline still calls request.param("h") and marks it consumed for
  unrecognized-parameter bookkeeping; using the non-consuming raw map
  here keeps that ordering unchanged.

- TableSummarizer.copyHeaderAttrString now sanitizes ';' and ':' inside
  attribute values (replacing them with spaces) instead of silently
  dropping the whole attribute. This prevents a future header with a
  colon in its 'desc' from losing metadata during the summarize round
  trip. Includes a unit test that exercises the sanitizer directly with
  a manually-constructed attr map (Table.addCell's parser can't itself
  represent a value containing ':', which is precisely the scenario the
  sanitizer defends against).

- Aggregator now tracks countNonNull separately from countAll and
  countContributing. count(field) returns countNonNull, matching SQL
  COUNT(field) semantics — a group with three rows one of which is null
  now reports count(field)=2. Updated the two existing avg/null tests
  to assert the new semantics.

- TableSummarizer.summarize validates that every column referenced in
  h= (both bare group-by tokens and func(field) arguments) resolves to
  an existing table column, either directly or via aliasMap. Unknown
  tokens raise IllegalArgumentException with a message naming the
  offending token so users no longer get silent empty output from a
  typo. Tests cover the error paths and confirm alias references
  continue to work.

Test count after this change (cat suites): CatShardsRequestTests=6,
TableSummarizerTests=33, RestTableTests=28, all passing.

Signed-off-by: Kunal Khatua <kkhatua@amazon.com>
@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit 4d1d7d7

@github-actions

Copy link
Copy Markdown
Contributor

✅ Gradle check result for 4d1d7d7: SUCCESS

Kunal Khatua added 6 commits July 29, 2026 22:19
Add grouped + aggregated views to _cat APIs, expressed entirely through
the h= parameter: a bare column token (e.g. index) is a GROUP BY key and
a func(field) token (sum, count, avg, min, max) is an aggregate over the
grouped rows, mirroring SELECT index, sum(docs) ... GROUP BY index.

When h= contains no aggregation function the table is returned
unchanged, so ordinary listings behave exactly as before. Because the
group key is always a bare h= token, it is always rendered.

Aggregation preserves numeric source types (Long/Integer/Short/Byte,
ByteSizeValue, SizeValue, TimeValue) so downstream bytes=/time=/size=
rendering keeps working, and avg divides by the count of non-null values
so null cells do not drag the average toward zero.

Signed-off-by: Kunal Khatua <kkhatua@amazon.com>
Add a 'limit' query parameter that caps the number of rows in a _cat
response. Applied at the RestTable level after sort, so
?s=field:desc&limit=N yields the top-N rows by the sort key.

The limit applies uniformly to summarized and unsummarized output:
- Without aggregation: caps the response to N rows
- With aggregation: caps to N groups, with sort applied to the
  aggregated result so aggregation + sort + limit yields the top-N
  groups by sort key

Example:
  GET _cat/segments?h=index,sum(docs.count)&s=sum(docs.count):desc&limit=5
Signed-off-by: Kunal Khatua <kkhatua@amazon.com>
The _cat/shards transport always issued an IndicesStats broadcast to
every data node, even when the response did not need a single
stats-derived column. For high-shard clusters this is a substantial
fan-out cost that contributed nothing to the response.

Detect at the REST layer whether any column in h= or s= requires
per-shard stats. When all referenced columns are derivable from
cluster state (index, shard, prirep, state, node/ip/id, unassigned.*,
recoverysource.type, and their aliases), set a new
indicesStatsRequired=false flag on the CatShardsRequest. The transport
action honors the flag by returning an empty IndicesStatsResponse and
short-circuiting the broadcast.

The fast path is intentionally conservative:
- h= must be explicitly set (default headers include docs/store)
- every h= and s= token must match a known routing-only column
- wildcards force the slow path (we don't expand them here)
- unknown tokens force the slow path

Backwards compatibility for the new request field is gated on
Version.V_3_7_0 with the default (true) preserving today's behavior on
mixed-version clusters.

Signed-off-by: Kunal Khatua <kkhatua@amazon.com>
forbiddenApisTest failed because TableSummarizerTests reached the
private RestTable#renderValue via Class#getDeclaredMethod +
AccessibleObject#setAccessible, which the check bans.

Make RestTable#renderValue package-private (the test is in the same
package) and call it directly, removing the reflection and the now
unused RestRequest import.

Signed-off-by: Kunal Khatua <kkhatua@amazon.com>
Address review feedback on the _cat summarization/limit change:

- TableSummarizer.formatValue now converts aggregated doubles to long via
  a safeLong helper that rounds half-up and clamps to Long.MIN/MAX_VALUE
  instead of truncating and silently overflowing. This prevents a sum of
  large ByteSizeValues from wrapping to a negative value (which would
  throw from the ByteSizeValue constructor).
- AGG_FUNC_PATTERN is compiled CASE_INSENSITIVE so SUM(docs)/Count(shard)
  are recognized consistently by hasAggregation and summarize.
- RestShardsAction stats-skip sort parsing trims whitespace and strips
  the :asc/:desc suffix case-insensitively, so index:DESC or " shard:Asc"
  still resolve to routing-only columns and take the fast path.

Adds tests for case-insensitive aggregation, safeLong rounding/clamping,
and mixed-case/whitespace sort suffixes, and updates the TimeValue avg
assertion to the corrected round-half-up result.

Signed-off-by: Kunal Khatua <kkhatua@amazon.com>
Address the remaining review comments on PR opensearch-project#22502:

- Version gate for the new indicesStatsRequired field in CatShardsRequest
  moves from V_3_7_0 to V_3_8_0. The 3.8.0 branch has been cut from main,
  so this change lands in 3.9.0-development; gating on V_3_8_0 keeps the
  intent (feature only serialized between peers that have it) without
  fabricating a V_3_9_0 constant that doesn't yet exist in Version.java.
  Adds mixed-version wire-compatibility tests covering the round-trip on
  Version.CURRENT and the safe default (indicesStatsRequired=true) when
  the negotiated peer version is before the gate.

- RestTable.buildResponse now reads h= via request.params().get("h")
  instead of request.param("h"). The buildDisplayHeaders call later in
  the pipeline still calls request.param("h") and marks it consumed for
  unrecognized-parameter bookkeeping; using the non-consuming raw map
  here keeps that ordering unchanged.

- TableSummarizer.copyHeaderAttrString now sanitizes ';' and ':' inside
  attribute values (replacing them with spaces) instead of silently
  dropping the whole attribute. This prevents a future header with a
  colon in its 'desc' from losing metadata during the summarize round
  trip. Includes a unit test that exercises the sanitizer directly with
  a manually-constructed attr map (Table.addCell's parser can't itself
  represent a value containing ':', which is precisely the scenario the
  sanitizer defends against).

- Aggregator now tracks countNonNull separately from countAll and
  countContributing. count(field) returns countNonNull, matching SQL
  COUNT(field) semantics — a group with three rows one of which is null
  now reports count(field)=2. Updated the two existing avg/null tests
  to assert the new semantics.

- TableSummarizer.summarize validates that every column referenced in
  h= (both bare group-by tokens and func(field) arguments) resolves to
  an existing table column, either directly or via aliasMap. Unknown
  tokens raise IllegalArgumentException with a message naming the
  offending token so users no longer get silent empty output from a
  typo. Tests cover the error paths and confirm alias references
  continue to work.

Test count after this change (cat suites): CatShardsRequestTests=6,
TableSummarizerTests=33, RestTableTests=28, all passing.

Signed-off-by: Kunal Khatua <kkhatua@amazon.com>
@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit e38cfd0

@github-actions

Copy link
Copy Markdown
Contributor

❕ Gradle check result for e38cfd0: UNSTABLE

Please review all flaky tests that succeeded after retry and create an issue if one does not already exist to track the flaky failure.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement Enhancement or improvement to existing feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature Request] Grouping and aggregation for _cat APIs via the h= parameter

2 participants