From 13b3a980659189dbf3a0bd17225f9bd3b8b06742 Mon Sep 17 00:00:00 2001 From: Jonas Kunz Date: Wed, 14 Jan 2026 13:19:26 +0100 Subject: [PATCH 1/4] Implement heap attack tests for histograms --- .../xpack/esql/heap_attack/HeapAttackIT.java | 156 ++++++++++++++++++ 1 file changed, 156 insertions(+) diff --git a/test/external-modules/esql-heap-attack/src/javaRestTest/java/org/elasticsearch/xpack/esql/heap_attack/HeapAttackIT.java b/test/external-modules/esql-heap-attack/src/javaRestTest/java/org/elasticsearch/xpack/esql/heap_attack/HeapAttackIT.java index 00a7f5596072f..e6ad83ae1864f 100644 --- a/test/external-modules/esql-heap-attack/src/javaRestTest/java/org/elasticsearch/xpack/esql/heap_attack/HeapAttackIT.java +++ b/test/external-modules/esql-heap-attack/src/javaRestTest/java/org/elasticsearch/xpack/esql/heap_attack/HeapAttackIT.java @@ -19,8 +19,13 @@ import org.elasticsearch.client.WarningsHandler; import org.elasticsearch.common.Strings; import org.elasticsearch.common.breaker.CircuitBreakingException; +import org.elasticsearch.common.settings.Settings; import org.elasticsearch.common.unit.ByteSizeValue; import org.elasticsearch.core.TimeValue; +import org.elasticsearch.exponentialhistogram.ExponentialHistogram; +import org.elasticsearch.exponentialhistogram.ExponentialHistogramBuilder; +import org.elasticsearch.exponentialhistogram.ExponentialHistogramCircuitBreaker; +import org.elasticsearch.exponentialhistogram.ExponentialHistogramXContent; import org.elasticsearch.test.ListMatcher; import org.elasticsearch.test.MapMatcher; import org.elasticsearch.xcontent.XContentBuilder; @@ -42,6 +47,7 @@ import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.greaterThan; import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.isA; import static org.hamcrest.Matchers.matchesRegex; /** @@ -618,6 +624,57 @@ private Map fetchMvLongs() throws IOException { return responseAsMap(query(query.toString(), "columns")); } + public void testManyExponentialHistograms() throws IOException { + initManyExponentialHistograms(10_000, 100); + + // Run a successful query first as sanity check + queryAndVerifyDuplicatedHistograms("many_exponential_histograms", 1); + + // and now blow up the memory + assertCircuitBreaks(attempt -> queryDuplicatedHistograms("many_exponential_histograms", attempt * 10)); + } + + public void testManyTDigests() throws IOException { + initManyTDigests(10_000, 100); + + // Run a successful query first as sanity check + queryAndVerifyDuplicatedHistograms("many_tdigests", 1); + + // and now blow up the memory + assertCircuitBreaks(attempt -> queryDuplicatedHistograms("many_tdigests", attempt * 10)); + } + + private void queryAndVerifyDuplicatedHistograms(String index, int numDuplications) throws IOException { + Map responseMap = queryDuplicatedHistograms(index, numDuplications); + ListMatcher columns = matchesList().item(matchesMap().entry("name", "dummy").entry("type", "double")); + ListMatcher values = matchesList(List.of(matchesList(List.of(isA(Double.class))))); + assertResultMap(responseMap, columns, values); + } + + /** + * Creates a query which loads each histogram n-times into memory. + * We do this by querying n percentiles on each histogram, each with a different filter condition which however never is false. + * PERCENTILES() is implemented using a surrogate histogram merge, which means at the end of the STATS we + * have n copies of each histogram in memory. + */ + private Map queryDuplicatedHistograms(String index, int numDuplications) throws IOException { + StringBuilder query = startQuery(); + query.append("FROM " + index); + query.append("| STATS "); + query.append( + IntStream.range(0, numDuplications) + .mapToObj(i -> "val_" + i + " = PERCENTILE(histo, 50) WHERE histo_id != -" + i) + .collect(Collectors.joining(", ")) + ); + query.append("BY histo_id"); + // in the end aggregate it all to a single sum to not blow up the result set + query.append("| EVAL vals_sum = "); + query.append(IntStream.range(0, numDuplications).mapToObj(i -> "val_" + i).collect(Collectors.joining(" + "))); + query.append("| STATS dummy = SUM(vals_sum)\"}"); + String queryStr = query.toString().replace("\n", "\\n"); + return responseAsMap(query(queryStr, null)); + } + private void initManyLongs(int countPerLong) throws IOException { logger.info("loading many documents with longs"); StringBuilder bulk = new StringBuilder(); @@ -776,4 +833,103 @@ private void initMvLongsIndex(int docs, int fields, int fieldValues) throws IOEx initIndex("mv_longs", bulk.toString()); } + private void initManyExponentialHistograms(int numHistograms, int numBucketsPerHistogram) throws IOException { + logger.info("loading many documents with exponential histograms"); + + createIndex("many_exponential_histograms", Settings.EMPTY, """ + { + "properties": { + "histo": { + "type": "exponential_histogram" + }, + "histo_id": { + "type": "long" + } + } + } + """); + + StringBuilder bulk = new StringBuilder(); + int flush = 0; + for (int i = 0; i < numHistograms; i++) { + + // The scale doesn't actually matter here + ExponentialHistogramBuilder builder = ExponentialHistogram.builder(10, ExponentialHistogramCircuitBreaker.noop()); + for (int j = 0; j < numBucketsPerHistogram; j++) { + builder.setPositiveBucket(i + j, 1 + i + j * 2); + } + String histoJson; + try (XContentBuilder xContentBuilder = JsonXContent.contentBuilder()) { + ExponentialHistogramXContent.serialize(xContentBuilder, builder.build()); + histoJson = Strings.toString(xContentBuilder); + } + ; + + bulk.append(String.format(Locale.ROOT, """ + {"create":{}} + {"histo_id":%d,"histo":%s} + """, i + 1, histoJson)); + flush++; + if (flush % 10_000 == 0) { + bulk("many_exponential_histograms", bulk.toString()); + bulk.setLength(0); + logger.info("flushing {}/{} to many_exponential_histograms", flush, numHistograms); + } + } + // Load the remaining data and also do a force merge + initIndex("many_exponential_histograms", bulk.toString()); + } + + private void initManyTDigests(int numHistograms, int numCentroidsPerHistogram) throws IOException { + logger.info("loading many documents with tdigests"); + + createIndex("many_tdigests", Settings.EMPTY, """ + { + "properties": { + "histo": { + "type": "tdigest" + }, + "histo_id": { + "type": "long" + } + } + } + """); + + StringBuilder bulk = new StringBuilder(); + int flush = 0; + for (int i = 0; i < numHistograms; i++) { + + // The scale doesn't actually matter here + + StringBuilder histoJson = new StringBuilder("{"); + histoJson.append("\"centroids\":"); + histoJson.append( + IntStream.range(i, i + numCentroidsPerHistogram).mapToObj(Integer::toString).collect(Collectors.joining(",", "[", "]")) + ); + histoJson.append(",\"counts\":"); + int finalI = i; + histoJson.append( + IntStream.range(0, numCentroidsPerHistogram) + .map(j -> 1 + finalI + (j * 2)) + .mapToObj(Integer::toString) + .collect(Collectors.joining(",", "[", "]")) + ); + histoJson.append("}"); + + bulk.append(String.format(Locale.ROOT, """ + {"create":{}} + {"histo_id":%d,"histo":%s} + """, i + 1, histoJson)); + flush++; + if (flush % 10_000 == 0) { + bulk("many_tdigests", bulk.toString()); + bulk.setLength(0); + logger.info("flushing {}/{} to many_tdigests", flush, numHistograms); + } + } + // Load the remaining data and also do a force merge + initIndex("many_tdigests", bulk.toString()); + } + } From 570ab9e10a9913357c7a26b2ae2434dc274f52f9 Mon Sep 17 00:00:00 2001 From: Jonas Kunz Date: Thu, 15 Jan 2026 12:22:05 +0100 Subject: [PATCH 2/4] Remove invalid comment --- .../org/elasticsearch/xpack/esql/heap_attack/HeapAttackIT.java | 3 --- 1 file changed, 3 deletions(-) diff --git a/test/external-modules/esql-heap-attack/src/javaRestTest/java/org/elasticsearch/xpack/esql/heap_attack/HeapAttackIT.java b/test/external-modules/esql-heap-attack/src/javaRestTest/java/org/elasticsearch/xpack/esql/heap_attack/HeapAttackIT.java index e6ad83ae1864f..1d2680742977d 100644 --- a/test/external-modules/esql-heap-attack/src/javaRestTest/java/org/elasticsearch/xpack/esql/heap_attack/HeapAttackIT.java +++ b/test/external-modules/esql-heap-attack/src/javaRestTest/java/org/elasticsearch/xpack/esql/heap_attack/HeapAttackIT.java @@ -899,9 +899,6 @@ private void initManyTDigests(int numHistograms, int numCentroidsPerHistogram) t StringBuilder bulk = new StringBuilder(); int flush = 0; for (int i = 0; i < numHistograms; i++) { - - // The scale doesn't actually matter here - StringBuilder histoJson = new StringBuilder("{"); histoJson.append("\"centroids\":"); histoJson.append( From 8fe2dc24a63badc909586007bc1559faeb93f9bb Mon Sep 17 00:00:00 2001 From: Jonas Kunz Date: Wed, 18 Feb 2026 09:47:35 +0100 Subject: [PATCH 3/4] Add test for histogram column --- .../xpack/esql/heap_attack/HeapAttackIT.java | 41 +++++++++++++------ 1 file changed, 28 insertions(+), 13 deletions(-) diff --git a/test/external-modules/esql-heap-attack/src/javaRestTest/java/org/elasticsearch/xpack/esql/heap_attack/HeapAttackIT.java b/test/external-modules/esql-heap-attack/src/javaRestTest/java/org/elasticsearch/xpack/esql/heap_attack/HeapAttackIT.java index 316ca4e542f2f..cc50af5ccf65b 100644 --- a/test/external-modules/esql-heap-attack/src/javaRestTest/java/org/elasticsearch/xpack/esql/heap_attack/HeapAttackIT.java +++ b/test/external-modules/esql-heap-attack/src/javaRestTest/java/org/elasticsearch/xpack/esql/heap_attack/HeapAttackIT.java @@ -628,24 +628,34 @@ public void testManyExponentialHistograms() throws IOException { initManyExponentialHistograms(10_000, 100); // Run a successful query first as sanity check - queryAndVerifyDuplicatedHistograms("many_exponential_histograms", 1); + queryAndVerifyDuplicatedHistograms("many_exponential_histograms", "histo",1); // and now blow up the memory - assertCircuitBreaks(attempt -> queryDuplicatedHistograms("many_exponential_histograms", attempt * 10)); + assertCircuitBreaks(attempt -> queryDuplicatedHistograms("many_exponential_histograms", "histo",attempt * 10)); } public void testManyTDigests() throws IOException { - initManyTDigests(10_000, 100); + initManyTDigests(10_000, 100, TDigestFieldType.TDIGEST); // Run a successful query first as sanity check - queryAndVerifyDuplicatedHistograms("many_tdigests", 1); + queryAndVerifyDuplicatedHistograms("many_tdigests", "histo", 1); // and now blow up the memory - assertCircuitBreaks(attempt -> queryDuplicatedHistograms("many_tdigests", attempt * 10)); + assertCircuitBreaks(attempt -> queryDuplicatedHistograms("many_tdigests", "histo", attempt * 10)); } - private void queryAndVerifyDuplicatedHistograms(String index, int numDuplications) throws IOException { - Map responseMap = queryDuplicatedHistograms(index, numDuplications); + public void testManyHistograms() throws IOException { + initManyTDigests(10_000, 100, TDigestFieldType.HISTOGRAM); + + // Run a successful query first as sanity check + queryAndVerifyDuplicatedHistograms("many_tdigests", "TO_TDIGEST(histo)", 1); + + // and now blow up the memory + assertCircuitBreaks(attempt -> queryDuplicatedHistograms("many_tdigests", "TO_TDIGEST(histo)", attempt * 10)); + } + + private void queryAndVerifyDuplicatedHistograms(String index, String column, int numDuplications) throws IOException { + Map responseMap = queryDuplicatedHistograms(index, column, numDuplications); ListMatcher columns = matchesList().item(matchesMap().entry("name", "dummy").entry("type", "double")); ListMatcher values = matchesList(List.of(matchesList(List.of(isA(Double.class))))); assertResultMap(responseMap, columns, values); @@ -657,13 +667,13 @@ private void queryAndVerifyDuplicatedHistograms(String index, int numDuplication * PERCENTILES() is implemented using a surrogate histogram merge, which means at the end of the STATS we * have n copies of each histogram in memory. */ - private Map queryDuplicatedHistograms(String index, int numDuplications) throws IOException { + private Map queryDuplicatedHistograms(String index, String column, int numDuplications) throws IOException { StringBuilder query = startQuery(); query.append("FROM " + index); query.append("| STATS "); query.append( IntStream.range(0, numDuplications) - .mapToObj(i -> "val_" + i + " = PERCENTILE(histo, 50) WHERE histo_id != -" + i) + .mapToObj(i -> "val_" + i + " = PERCENTILE(" + column +", 50) WHERE histo_id != -" + i) .collect(Collectors.joining(", ")) ); query.append("BY histo_id"); @@ -945,27 +955,32 @@ private void initManyExponentialHistograms(int numHistograms, int numBucketsPerH initIndex("many_exponential_histograms", bulk.toString()); } - private void initManyTDigests(int numHistograms, int numCentroidsPerHistogram) throws IOException { + enum TDigestFieldType { + TDIGEST, HISTOGRAM + } + + private void initManyTDigests(int numHistograms, int numCentroidsPerHistogram, TDigestFieldType fieldType) throws IOException { logger.info("loading many documents with tdigests"); createIndex("many_tdigests", Settings.EMPTY, """ { "properties": { "histo": { - "type": "tdigest" + "type": "%s" }, "histo_id": { "type": "long" } } } - """); + """.formatted(fieldType == TDigestFieldType.TDIGEST ? "tdigest" : "histogram")); StringBuilder bulk = new StringBuilder(); int flush = 0; + String centroidsFieldName = fieldType == TDigestFieldType.TDIGEST ? "centroids" : "values"; for (int i = 0; i < numHistograms; i++) { StringBuilder histoJson = new StringBuilder("{"); - histoJson.append("\"centroids\":"); + histoJson.append("\"").append(centroidsFieldName).append("\":"); histoJson.append( IntStream.range(i, i + numCentroidsPerHistogram).mapToObj(Integer::toString).collect(Collectors.joining(",", "[", "]")) ); From 2e554eb404f49d3559dddbeea029f98c3c661090 Mon Sep 17 00:00:00 2001 From: elasticsearchmachine Date: Wed, 18 Feb 2026 08:53:54 +0000 Subject: [PATCH 4/4] [CI] Auto commit changes from spotless --- .../xpack/esql/heap_attack/HeapAttackIT.java | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/test/external-modules/esql-heap-attack/src/javaRestTest/java/org/elasticsearch/xpack/esql/heap_attack/HeapAttackIT.java b/test/external-modules/esql-heap-attack/src/javaRestTest/java/org/elasticsearch/xpack/esql/heap_attack/HeapAttackIT.java index cc50af5ccf65b..91da46da6c842 100644 --- a/test/external-modules/esql-heap-attack/src/javaRestTest/java/org/elasticsearch/xpack/esql/heap_attack/HeapAttackIT.java +++ b/test/external-modules/esql-heap-attack/src/javaRestTest/java/org/elasticsearch/xpack/esql/heap_attack/HeapAttackIT.java @@ -628,10 +628,10 @@ public void testManyExponentialHistograms() throws IOException { initManyExponentialHistograms(10_000, 100); // Run a successful query first as sanity check - queryAndVerifyDuplicatedHistograms("many_exponential_histograms", "histo",1); + queryAndVerifyDuplicatedHistograms("many_exponential_histograms", "histo", 1); // and now blow up the memory - assertCircuitBreaks(attempt -> queryDuplicatedHistograms("many_exponential_histograms", "histo",attempt * 10)); + assertCircuitBreaks(attempt -> queryDuplicatedHistograms("many_exponential_histograms", "histo", attempt * 10)); } public void testManyTDigests() throws IOException { @@ -673,7 +673,7 @@ private Map queryDuplicatedHistograms(String index, String colum query.append("| STATS "); query.append( IntStream.range(0, numDuplications) - .mapToObj(i -> "val_" + i + " = PERCENTILE(" + column +", 50) WHERE histo_id != -" + i) + .mapToObj(i -> "val_" + i + " = PERCENTILE(" + column + ", 50) WHERE histo_id != -" + i) .collect(Collectors.joining(", ")) ); query.append("BY histo_id"); @@ -956,7 +956,8 @@ private void initManyExponentialHistograms(int numHistograms, int numBucketsPerH } enum TDigestFieldType { - TDIGEST, HISTOGRAM + TDIGEST, + HISTOGRAM } private void initManyTDigests(int numHistograms, int numCentroidsPerHistogram, TDigestFieldType fieldType) throws IOException {