Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -1035,14 +1035,14 @@ public static Literal randomLiteral(DataType type) {
}
case TSID_DATA_TYPE -> randomTsId().toBytesRef();
case DENSE_VECTOR -> Arrays.asList(randomArray(10, 10, i -> new Float[10], ESTestCase::randomFloat));
case EXPONENTIAL_HISTOGRAM -> new WriteableExponentialHistogram(EsqlTestUtils.randomExponentialHistogram());
case EXPONENTIAL_HISTOGRAM -> EsqlTestUtils.randomExponentialHistogram();
case UNSUPPORTED, OBJECT, DOC_DATA_TYPE, PARTIAL_AGG -> throw new IllegalArgumentException(
"can't make random values for [" + type.typeName() + "]"
);
}, type);
}

private static ExponentialHistogram randomExponentialHistogram() {
public static ExponentialHistogram randomExponentialHistogram() {
// TODO(b/133393): allow (index,scale) based zero thresholds as soon as we support them in the block
// ideally Replace this with the shared random generation in ExponentialHistogramTestUtils
boolean hasNegativeValues = randomBoolean();
Expand All @@ -1062,7 +1062,8 @@ private static ExponentialHistogram randomExponentialHistogram() {
ExponentialHistogramCircuitBreaker.noop(),
rawValues
);
return histo;
// Make the result histogram writeable to allow usage in Literals for testing
return new WriteableExponentialHistogram(histo);
}

static Version randomVersion() {
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
import org.elasticsearch.xpack.esql.expression.function.scalar.date.DayName;
import org.elasticsearch.xpack.esql.expression.function.scalar.date.MonthName;
import org.elasticsearch.xpack.esql.expression.function.scalar.date.Now;
import org.elasticsearch.xpack.esql.expression.function.scalar.histogram.HistogramPercentile;
import org.elasticsearch.xpack.esql.expression.function.scalar.ip.CIDRMatch;
import org.elasticsearch.xpack.esql.expression.function.scalar.ip.IpPrefix;
import org.elasticsearch.xpack.esql.expression.function.scalar.ip.NetworkDirection;
Expand Down Expand Up @@ -118,6 +119,7 @@ public static List<NamedWriteableRegistry.Entry> getNamedWriteables() {
entries.add(Tau.ENTRY);
entries.add(ToLower.ENTRY);
entries.add(ToUpper.ENTRY);
entries.add(HistogramPercentile.ENTRY);

entries.addAll(GroupingWritables.getNamedWriteables());
return entries;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

package org.elasticsearch.xpack.esql.expression.function.scalar.histogram;

import org.elasticsearch.common.io.stream.NamedWriteableRegistry;
import org.elasticsearch.common.io.stream.StreamInput;
import org.elasticsearch.common.io.stream.StreamOutput;
import org.elasticsearch.compute.ann.Evaluator;
import org.elasticsearch.compute.data.DoubleBlock;
import org.elasticsearch.compute.operator.EvalOperator;
import org.elasticsearch.exponentialhistogram.ExponentialHistogram;
import org.elasticsearch.exponentialhistogram.ExponentialHistogramQuantile;
import org.elasticsearch.xpack.esql.core.expression.Expression;
import org.elasticsearch.xpack.esql.core.tree.NodeInfo;
import org.elasticsearch.xpack.esql.core.tree.Source;
import org.elasticsearch.xpack.esql.core.type.DataType;
import org.elasticsearch.xpack.esql.expression.function.FunctionInfo;
import org.elasticsearch.xpack.esql.expression.function.Param;
import org.elasticsearch.xpack.esql.expression.function.scalar.EsqlScalarFunction;
import org.elasticsearch.xpack.esql.expression.function.scalar.math.Cast;
import org.elasticsearch.xpack.esql.io.stream.PlanStreamInput;

import java.io.IOException;
import java.util.List;

import static org.elasticsearch.xpack.esql.core.expression.TypeResolutions.ParamOrdinal.DEFAULT;
import static org.elasticsearch.xpack.esql.core.expression.TypeResolutions.isType;

/**
* Extracts a percentile value from a single histogram value.
* Note that this function is currently only intended for usage in surrogates and not available as a user-facing function.
* Therefore, it is intentionally not registered in {@link org.elasticsearch.xpack.esql.expression.function.EsqlFunctionRegistry}.
*/
public class HistogramPercentile extends EsqlScalarFunction {

public static final NamedWriteableRegistry.Entry ENTRY = new NamedWriteableRegistry.Entry(
Expression.class,
"HistogramPercentile",
HistogramPercentile::new
);

private final Expression histogram;
private final Expression percentile;

@FunctionInfo(returnType = { "double" })
public HistogramPercentile(
Source source,
@Param(name = "histogram", type = { "exponential_histogram" }) Expression histogram,
@Param(name = "percentile", type = { "double", "integer", "long", "unsigned_long" }) Expression percentile
) {
super(source, List.of(histogram, percentile));
this.histogram = histogram;
this.percentile = percentile;
}

private HistogramPercentile(StreamInput in) throws IOException {
this(Source.readFrom((PlanStreamInput) in), in.readNamedWriteable(Expression.class), in.readNamedWriteable(Expression.class));
}

Expression histogram() {
return histogram;
}

Expression percentile() {
return percentile;
}

@Override
protected TypeResolution resolveType() {
return isType(histogram, dt -> dt == DataType.EXPONENTIAL_HISTOGRAM, sourceText(), DEFAULT, "exponential_histogram").and(
isType(percentile, DataType::isNumeric, sourceText(), DEFAULT, "numeric types")
);
}

@Override
public DataType dataType() {
return DataType.DOUBLE;
}

@Override
public Expression replaceChildren(List<Expression> newChildren) {
return new HistogramPercentile(source(), newChildren.get(0), newChildren.get(1));
}

@Override
public boolean foldable() {
return histogram.foldable() && percentile.foldable();
}

@Override
protected NodeInfo<? extends Expression> info() {
return NodeInfo.create(this, HistogramPercentile::new, histogram, percentile);
}

@Override
public String getWriteableName() {
return ENTRY.name;
}

@Override
public void writeTo(StreamOutput out) throws IOException {
source().writeTo(out);
out.writeNamedWriteable(histogram);
out.writeNamedWriteable(percentile);
}

@Evaluator(warnExceptions = ArithmeticException.class)
static void process(DoubleBlock.Builder resultBuilder, ExponentialHistogram value, double percentile) {
if (percentile < 0.0 || percentile > 100.0) {
throw new ArithmeticException("Percentile value must be in the range [0, 100], got: " + percentile);
Copy link
Contributor Author

@JonasKunz JonasKunz Nov 4, 2025

Choose a reason for hiding this comment

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

Question: Is it a good or a bad practice to include the invalid value in the warning message? E.g. Asin doesn't include the wrong value in its warnings.

Copy link
Member

Choose a reason for hiding this comment

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

If the percentile is a constant with the integration, we should verify it and reject invalid values instead. But this should be okay now.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

IIUC you mean that if someone writes a query like STATS PERCENTILE(my_histo, 200) the query shouldn't even get to the execution phase, but we should reject it with an error.

Am I understanding this correctly?
I unfortunately don't know what the correct callback / hook would be to perform this verification. I looked in the PERCENTILE aggregation and the POW function for examples, but they don't seem to be doing this kind of verification either.

If you could provide me with an example on how to do this, I can add it in a follow-up.

}
double result = ExponentialHistogramQuantile.getQuantile(value, percentile / 100.0);
if (Double.isNaN(result)) { // can happen if the histogram is empty
resultBuilder.appendNull();
} else {
resultBuilder.appendDouble(result);
}
}

@Override
public EvalOperator.ExpressionEvaluator.Factory toEvaluator(ToEvaluator toEvaluator) {
var fieldEvaluator = toEvaluator.apply(histogram);
var percentileEvaluator = Cast.cast(source(), percentile.dataType(), DataType.DOUBLE, toEvaluator.apply(percentile));
return new HistogramPercentileEvaluator.Factory(source(), fieldEvaluator, percentileEvaluator);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -1509,6 +1509,19 @@ public static List<TypedDataSupplier> aggregateMetricDoubleCases() {
);
}

/**
* Generate cases for {@link DataType#EXPONENTIAL_HISTOGRAM}.
*/
public static List<TypedDataSupplier> exponentialHistogramCases() {
return List.of(
new TypedDataSupplier(
"<random exponential histogram>",
EsqlTestUtils::randomExponentialHistogram,
DataType.EXPONENTIAL_HISTOGRAM
)
);
}

public static String getCastEvaluator(String original, DataType current, DataType target) {
if (current == target) {
return original;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

package org.elasticsearch.xpack.esql.expression.function.scalar.histogram;

import org.elasticsearch.xpack.esql.core.expression.Expression;
import org.elasticsearch.xpack.esql.core.tree.Source;
import org.elasticsearch.xpack.esql.core.type.DataType;
import org.elasticsearch.xpack.esql.expression.function.ErrorsForCasesWithoutExamplesTestCase;
import org.elasticsearch.xpack.esql.expression.function.TestCaseSupplier;
import org.hamcrest.Matcher;

import java.util.List;
import java.util.Set;

import static org.hamcrest.Matchers.equalTo;

public class HistogramPercentileErrorTests extends ErrorsForCasesWithoutExamplesTestCase {

@Override
protected List<TestCaseSupplier> cases() {
return paramsToSuppliers(HistogramPercentileTests.parameters());
}

@Override
protected Expression build(Source source, List<Expression> args) {
return new HistogramPercentile(source, args.get(0), args.get(1));
}

@Override
protected Matcher<String> expectedTypeErrorMatcher(List<Set<DataType>> validPerPosition, List<DataType> signature) {
return equalTo(typeErrorMessage(false, validPerPosition, signature, (v, p) -> switch (p) {
case 0 -> "exponential_histogram";
case 1 -> "numeric types";
default -> throw new IllegalArgumentException("Unexpected parameter position: " + p);
}));
}
}
Loading