Skip to content

Commit a49a475

Browse files
sandeshkr419bkhishor
authored andcommitted
[analytics-engine] Per-shard bucket oversampling for TopK aggregation queries (opensearch-project#21775)
Signed-off-by: Sandesh Kumar <sandeshkr419@gmail.com>
1 parent 9ade704 commit a49a475

12 files changed

Lines changed: 669 additions & 2 deletions

File tree

sandbox/plugins/analytics-backend-datafusion/rust/src/udf/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,7 @@ pub mod str_to_date;
158158
pub mod strftime;
159159
pub mod time_format;
160160
pub mod width_bucket;
161+
pub mod reduce_eval;
161162

162163
// Dev note: if a freshly added UDF here fails at runtime with
163164
// "Unsupported function name: <X>" despite the Java side being wired, the
@@ -203,6 +204,7 @@ pub fn register_all(ctx: &SessionContext) {
203204
strftime::register_all(ctx);
204205
time_format::register_all(ctx);
205206
width_bucket::register_all(ctx);
207+
reduce_eval::register_all(ctx);
206208
log::info!(
207209
"OpenSearch UDF register_all: convert_tz, conversion(numeric_conversion: num/auto/memk/rmcomma/rmunit/dur2sec/mstime, time_conversion: ctime/mktime), crc32, date_format, extract, from_unixtime, item, json_append, json_array_length, json_delete, json_extend, json_extract, json_extract_all, json_keys, json_set, makedate, maketime, minspan_bucket, mvappend, mvfind, mvzip, parse, range_bucket, rex_extract, rex_extract_multi, rex_offset, sha1, span_bucket, str_to_date, strftime, time_format, width_bucket registered"
208210
);
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
/*
2+
* SPDX-License-Identifier: Apache-2.0
3+
*
4+
* The OpenSearch Contributors require contributions made to
5+
* this file be licensed under the Apache-2.0 license or a
6+
* compatible open source license.
7+
*/
8+
9+
//! `reduce_eval(agg_name, state)` — evaluates an opaque aggregate's partial state
10+
//! to produce a sortable scalar. Used by the TopK rewriter's reduce Project.
11+
12+
use std::sync::Arc;
13+
14+
use datafusion::arrow::array::{Array, ArrayRef, BinaryArray, UInt64Array};
15+
use datafusion::arrow::datatypes::{DataType, Field};
16+
use datafusion::common::{DataFusionError, Result, ScalarValue};
17+
use datafusion::execution::context::SessionContext;
18+
use datafusion::functions_aggregate::approx_distinct::approx_distinct_udaf;
19+
use datafusion::logical_expr::function::AccumulatorArgs;
20+
use datafusion::logical_expr::{ColumnarValue, ScalarFunctionArgs, ScalarUDF, ScalarUDFImpl, Signature, Volatility};
21+
use datafusion::physical_expr::expressions::Column;
22+
23+
pub fn register_all(ctx: &SessionContext) {
24+
ctx.register_udf(ScalarUDF::new_from_impl(ReduceEvalUdf));
25+
}
26+
27+
#[derive(Debug, PartialEq, Eq, Hash)]
28+
struct ReduceEvalUdf;
29+
30+
impl ScalarUDFImpl for ReduceEvalUdf {
31+
fn as_any(&self) -> &dyn std::any::Any { self }
32+
fn name(&self) -> &str { "reduce_eval" }
33+
34+
fn signature(&self) -> &Signature {
35+
static SIG: std::sync::LazyLock<Signature> = std::sync::LazyLock::new(|| {
36+
Signature::any(2, Volatility::Immutable)
37+
});
38+
&SIG
39+
}
40+
41+
fn return_type(&self, _args: &[DataType]) -> Result<DataType> {
42+
Ok(DataType::UInt64)
43+
}
44+
45+
fn invoke_with_args(&self, args: ScalarFunctionArgs) -> Result<ColumnarValue> {
46+
let agg_name = match &args.args[0] {
47+
ColumnarValue::Scalar(ScalarValue::Utf8(Some(s))) => s.clone(),
48+
_ => return Err(DataFusionError::Execution("reduce_eval: first arg must be literal agg name".into())),
49+
};
50+
let state_col = match &args.args[1] {
51+
ColumnarValue::Array(a) => a.clone(),
52+
ColumnarValue::Scalar(s) => s.to_array_of_size(args.number_rows)?,
53+
};
54+
55+
match agg_name.as_str() {
56+
"approx_distinct" => eval_approx_distinct(&state_col),
57+
other => Err(DataFusionError::Execution(format!("reduce_eval: unsupported aggregate '{other}'"))),
58+
}
59+
}
60+
}
61+
62+
fn eval_approx_distinct(state_col: &ArrayRef) -> Result<ColumnarValue> {
63+
let binary = state_col.as_any().downcast_ref::<BinaryArray>()
64+
.ok_or_else(|| DataFusionError::Execution("reduce_eval(approx_distinct): expected Binary state".into()))?;
65+
66+
let field: Arc<Field> = Arc::new(Field::new("x", DataType::Int64, true));
67+
let schema = Arc::new(datafusion::arrow::datatypes::Schema::new(vec![field.as_ref().clone()]));
68+
let expr: Arc<dyn datafusion::physical_plan::PhysicalExpr> = Arc::new(Column::new("x", 0));
69+
let ret_field: Arc<Field> = Arc::new(Field::new("r", DataType::UInt64, true));
70+
71+
let mut results = Vec::with_capacity(binary.len());
72+
for i in 0..binary.len() {
73+
if binary.is_null(i) {
74+
results.push(0u64);
75+
continue;
76+
}
77+
let mut acc = approx_distinct_udaf().accumulator(AccumulatorArgs {
78+
return_field: ret_field.clone(),
79+
schema: &schema,
80+
ignore_nulls: false,
81+
order_bys: &[],
82+
name: "x",
83+
is_distinct: false,
84+
exprs: &[expr.clone()],
85+
expr_fields: &[field.clone()],
86+
is_reversed: false,
87+
})?;
88+
let state_array: ArrayRef = Arc::new(BinaryArray::from(vec![binary.value(i)]));
89+
acc.merge_batch(&[state_array])?;
90+
match acc.evaluate()? {
91+
ScalarValue::UInt64(Some(v)) => results.push(v),
92+
_ => results.push(0),
93+
}
94+
}
95+
Ok(ColumnarValue::Array(Arc::new(UInt64Array::from(results))))
96+
}
97+
98+
#[cfg(test)]
99+
mod tests {
100+
use super::*;
101+
use datafusion::arrow::array::BinaryArray;
102+
use datafusion::functions_aggregate::approx_distinct::approx_distinct_udaf;
103+
use datafusion::logical_expr::Accumulator;
104+
105+
#[test]
106+
fn test_reduce_eval_approx_distinct() {
107+
// Build an HLL state by updating an accumulator
108+
let field: Arc<Field> = Arc::new(Field::new("x", DataType::Int64, true));
109+
let schema = Arc::new(datafusion::arrow::datatypes::Schema::new(vec![field.as_ref().clone()]));
110+
let expr: Arc<dyn datafusion::physical_plan::PhysicalExpr> = Arc::new(Column::new("x", 0));
111+
let ret_field: Arc<Field> = Arc::new(Field::new("r", DataType::UInt64, true));
112+
let mut acc = approx_distinct_udaf().accumulator(AccumulatorArgs {
113+
return_field: ret_field.clone(), schema: &schema, ignore_nulls: false,
114+
order_bys: &[], name: "x", is_distinct: false,
115+
exprs: &[expr.clone()], expr_fields: &[field.clone()], is_reversed: false,
116+
}).unwrap();
117+
118+
// Feed some values
119+
let values: ArrayRef = Arc::new(datafusion::arrow::array::Int64Array::from(vec![1, 2, 3, 4, 5]));
120+
acc.update_batch(&[values]).unwrap();
121+
let state = acc.state().unwrap();
122+
123+
// Extract the Binary state
124+
let state_array = state[0].to_array_of_size(1).unwrap();
125+
let binary = state_array.as_any().downcast_ref::<BinaryArray>().unwrap();
126+
127+
// Run reduce_eval
128+
let result = eval_approx_distinct(&(Arc::new(binary.clone()) as ArrayRef)).unwrap();
129+
match result {
130+
ColumnarValue::Array(arr) => {
131+
let uint_arr = arr.as_any().downcast_ref::<UInt64Array>().unwrap();
132+
assert_eq!(uint_arr.value(0), 5); // 5 distinct values
133+
}
134+
_ => panic!("expected Array"),
135+
}
136+
}
137+
}

sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/DataFusionFragmentConvertor.java

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,8 +106,19 @@ public class DataFusionFragmentConvertor implements FragmentConvertor {
106106
SqlFunctionCategory.USER_DEFINED_FUNCTION
107107
);
108108

109+
/** TopK reduce expression: evaluates opaque aggregate state to a sortable scalar. */
110+
static final SqlOperator REDUCE_EVAL_OP = new SqlFunction(
111+
"reduce_eval",
112+
SqlKind.OTHER_FUNCTION,
113+
ReturnTypes.BIGINT_NULLABLE,
114+
null,
115+
OperandTypes.ANY_ANY,
116+
SqlFunctionCategory.USER_DEFINED_FUNCTION
117+
);
118+
109119
private static final List<FunctionMappings.Sig> ADDITIONAL_SCALAR_SIGS = List.of(
110120
FunctionMappings.s(DelegatedPredicateFunction.FUNCTION, DelegatedPredicateFunction.NAME),
121+
FunctionMappings.s(REDUCE_EVAL_OP, "reduce_eval"),
111122
FunctionMappings.s(DelegationPossibleFunction.FUNCTION, DelegationPossibleFunction.NAME),
112123
FunctionMappings.s(SqlStdOperatorTable.ASCII, "ascii"),
113124
FunctionMappings.s(SqlStdOperatorTable.CHAR_LENGTH, "length"),

sandbox/plugins/analytics-backend-datafusion/src/main/resources/opensearch_scalar_functions.yaml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1190,3 +1190,12 @@ scalar_functions:
11901190
- { name: format, value: "varchar<L2>" }
11911191
nullability: DECLARED_OUTPUT
11921192
return: fp64
1193+
- name: "reduce_eval"
1194+
description: "Evaluates opaque aggregate partial state to a sortable scalar for TopK."
1195+
impls:
1196+
- args:
1197+
- name: agg_name
1198+
value: string
1199+
- name: state
1200+
value: any1
1201+
return: i64

sandbox/plugins/analytics-engine/src/main/java/org/opensearch/analytics/AnalyticsPlugin.java

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,11 @@ public Collection<Module> createGuiceModules() {
171171

172172
@Override
173173
public List<Setting<?>> getSettings() {
174-
return List.of(COORDINATOR_BUFFER_LIMIT, ReaderContextStore.READER_CONTEXT_KEEP_ALIVE);
174+
List<Setting<?>> settings = new java.util.ArrayList<>();
175+
settings.add(COORDINATOR_BUFFER_LIMIT);
176+
settings.add(ReaderContextStore.READER_CONTEXT_KEEP_ALIVE);
177+
settings.addAll(org.opensearch.analytics.settings.AnalyticsApproximationSettings.all());
178+
return List.copyOf(settings);
175179
}
176180

177181
@Override

sandbox/plugins/analytics-engine/src/main/java/org/opensearch/analytics/planner/PlannerImpl.java

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
import org.opensearch.analytics.planner.rules.OpenSearchSortRule;
4242
import org.opensearch.analytics.planner.rules.OpenSearchSortSplitRule;
4343
import org.opensearch.analytics.planner.rules.OpenSearchTableScanRule;
44+
import org.opensearch.analytics.planner.rules.OpenSearchTopKRewriter;
4445
import org.opensearch.analytics.planner.rules.OpenSearchUnionRule;
4546
import org.opensearch.analytics.planner.rules.OpenSearchUnionSplitRule;
4647
import org.opensearch.analytics.planner.rules.OpenSearchValuesRule;
@@ -108,6 +109,11 @@ public static RelNode runAllOptimizations(RelNode rawRelNode, PlannerContext con
108109
modifiedRelNode = lateMat.get();
109110
LOGGER.info("After late-materialization:\n{}", RelOptUtil.toString(modifiedRelNode));
110111
}
112+
Optional<RelNode> topK = OpenSearchTopKRewriter.rewrite(modifiedRelNode, context);
113+
if (topK.isPresent()) {
114+
modifiedRelNode = topK.get();
115+
LOGGER.info("After TopK rewrite:\n{}", RelOptUtil.toString(modifiedRelNode));
116+
}
111117

112118
if (listener != null) {
113119
RuleProfilingListener.PlannerProfile profile = listener.snapshot();
@@ -288,6 +294,7 @@ private static RelNode mark(RelNode input, PlannerContext context, RuleProfiling
288294
}
289295

290296
/** Phase 2: VolcanoPlanner for trait propagation + exchange insertion. */
297+
291298
private static RelNode cbo(RelNode marked, RelNode rawRelNode, PlannerContext context, RuleProfilingListener listener) {
292299
VolcanoPlanner volcanoPlanner = new VolcanoPlanner();
293300
volcanoPlanner.addRelTraitDef(ConventionTraitDef.INSTANCE);

sandbox/plugins/analytics-engine/src/main/java/org/opensearch/analytics/planner/rel/OpenSearchSort.java

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
public class OpenSearchSort extends Sort implements OpenSearchRelNode {
3535

3636
private final List<String> viableBackends;
37+
private final boolean perPartition;
3738

3839
public OpenSearchSort(
3940
RelOptCluster cluster,
@@ -43,9 +44,28 @@ public OpenSearchSort(
4344
RexNode offset,
4445
RexNode fetch,
4546
List<String> viableBackends
47+
) {
48+
this(cluster, traitSet, input, collation, offset, fetch, viableBackends, false);
49+
}
50+
51+
public OpenSearchSort(
52+
RelOptCluster cluster,
53+
RelTraitSet traitSet,
54+
RelNode input,
55+
RelCollation collation,
56+
RexNode offset,
57+
RexNode fetch,
58+
List<String> viableBackends,
59+
boolean perPartition
4660
) {
4761
super(cluster, traitSet, input, collation, offset, fetch);
4862
this.viableBackends = viableBackends;
63+
this.perPartition = perPartition;
64+
}
65+
66+
/** True when this Sort runs per-shard (shard-bucket oversampling). */
67+
public boolean isPerPartition() {
68+
return perPartition;
4969
}
5070

5171
@Override
@@ -65,7 +85,7 @@ public List<FieldStorageInfo> getOutputFieldStorage() {
6585

6686
@Override
6787
public Sort copy(RelTraitSet traitSet, RelNode input, RelCollation collation, RexNode offset, RexNode fetch) {
68-
return new OpenSearchSort(getCluster(), traitSet, input, collation, offset, fetch, viableBackends);
88+
return new OpenSearchSort(getCluster(), traitSet, input, collation, offset, fetch, viableBackends, perPartition);
6989
}
7090

7191
/**

0 commit comments

Comments
 (0)