Overview
The StarTree Execution Engine is an alternative, high-performance execution runtime for the Multi-Stage Query Engine (MSQE). It keeps everything you already know about MSQE — the SQL dialect, the query planner, and Pinot’s segment/index-powered table scans — but executes most of the query’s operators (joins, aggregations, sorts, window functions, set operations) in native code instead of Java. Under the hood it is built on Apache DataFusion, a vectorized query engine written in Rust, extended with a native Arrow-based data exchange layer for moving data between query stages.What changes — and what doesn’t
A multi-stage query runs in three kinds of stages:
Query planning is unchanged: the broker still parses, plans, and optimizes the query the same way, so
EXPLAIN plans, query options, and the general query lifecycle look the same. Because leaf stages stay in Java, all of Pinot’s indexing (inverted, sorted, star-tree, JSON, etc.) keeps working exactly as before.
Enabling the StarTree Execution Engine
The native runtime is available on every broker and server. Queries still run on the Java MSQE runtime until something routes them to it — either the per-query option, or the broker-wide default, which routes every multi-stage query without a per-query choice.Use
v2.196.47 or later on the 0.16 line. Earlier 0.16 patches lack several controls on this page, and on those patches a setting from this page can be rejected or have no effect. Contact StarTree support to confirm the version that your cluster runs.The native runtime
The runtime starts by default —pinot.startree.rust.mailbox.port defaults to 0, which picks an ephemeral free port. No configuration and no restart are needed to make it available.
To stop a broker or server from starting it, set the port to a negative value:
workerRuntime = 'startree' against a process that has opted out, or whose runtime failed to start, fails with a runtimeKey is not registered error.
In StarTree Cloud these settings are managed as part of the deployment. Contact StarTree support if the StarTree Execution Engine is not available in your environment.
Memory and spill-to-disk behavior has its own set of settings — see Spill to disk for large queries.
Per-query switch
Route an individual multi-stage query to the StarTree Execution Engine with theworkerRuntime query option. Using the SET command:
The StarTree Execution Engine only applies to multi-stage queries. Single-stage queries are unaffected by the
workerRuntime option — workerRuntime on its own does nothing unless useMultistageEngine = true is also set.Broker-wide default
To route every multi-stage query to the StarTree Execution Engine without setting the option on each one, set the following in the cluster configuration:Returning a query to Java MSQE
DEFAULT is the explicit identifier for the Java runtime:
workerRuntime always beats the broker-wide default, so this works even on a cluster where pinot.startree.datafusion.default.enabled is true. Use it to A/B a single query, and to unblock one that misbehaves without changing anything for other queries.
Confirming which runtime ran
Set the option and the query still looks unchanged? Check before concluding anything about performance:EXPLAINshowing aBROKER_REDUCEnode means the query never reached the multi-stage engine at all —useMultistageEngineis missing.- A
queryEngineDowngradedfield in the response metadata means the query reached MSQE but was routed back to Java — see Monitoring for how to read it.
Leaf-stage work and the columnar pull-up
TheLeafOperator keeps running in Java, and it normally carries more than a scan: the filters, the projections, and the partial aggregate are all folded into it. Pushing work down into the leaf is the right choice on Java MSQE, where every row is a Java object either way. On the StarTree Execution Engine it can backfire — work done in the leaf is done on Java objects, and the result then has to be converted into Arrow to cross into the native runtime, which is exactly the allocation the engine exists to avoid.
So the engine does the opposite where it pays: it takes the projection, and sometimes the partial aggregate, out of the LeafOperator and runs it natively above. The scan then emits Arrow columns directly and nothing is ever built as a Java row.
What moves, and what stays
The type-specific entries above are not arbitrary. With the engine on, the planner renames
SUM over INT/LONG to SUMINT/SUMLONG, and MIN/MAX over LONG/STRING to type-specific names; those names are not on the pull-up list, so that work stays in the leaf. AVG and variance are rewritten into SUM and COUNT first, which is why they pull up over FLOAT/DOUBLE.
The pull-up decides where an aggregate runs. Every aggregate it can take has a native implementation. An aggregate that needs the Java bridge (see Function support) is never pulled up.
Overriding it
'on' only skips the cost check — the structural gates in the table above still apply, so an aggregate with no GROUP BY, a group-trim aggregate, a multi-value key, a star-tree-servable query, or an aggregate that is not on the list stays in the leaf regardless. Only 'on' forces the pull-up. 'true' keeps the default, and 'false', 'disabled' or 'none' act like 'off'.
When to expect benefits
The StarTree Execution Engine accelerates the parts of the query that run above the table scan. It shines when intermediate stages do significant work:- Join-heavy queries, especially large hash joins between fact tables.
- Large aggregations and group-bys over data shuffled between servers.
- Sorts and window functions over sizeable intermediate results.
- Queries that move a lot of data between stages — the native exchange layer transfers Arrow batches with far less serialization and garbage-collection overhead than the Java mailbox.
- Memory-intensive queries: the native runtime uses a bounded off-heap memory pool with spill-to-disk for sorts and aggregations, so large queries degrade gracefully instead of pressuring the JVM heap.
Single-query latency vs. behavior under load
These are two different results, and conflating them is the most common way to misread a benchmark. Single-query latency is mixed and shape-dependent. In internal benchmarks, join- and aggregation-heavy queries — where the work above the leaf dominates — run meaningfully faster than Java MSQE, while queries whose cost sits in the leaf, or which lean on shapes listed under When it doesn’t help, can be slower. Benchmark the queries you actually care about rather than assuming a uniform speedup. Behavior under concurrency is the dependable win. Because intermediate rows stop being Java objects and the exchange moves Arrow buffers instead of serialized Java rows, the JVM stops allocating and collecting that working set. In internal concurrency runs — a synthetic workload on a single machine, so directional rather than a guarantee — this showed up as a large reduction in both allocation and total GC pause time, and in queries completing that had been killed under memory pressure on Java MSQE. Both effects scale with load, not with query size, which is precisely why a single-query benchmark hides them. Note that the leaf stage is not covered by this: see The leaf stage is not covered by any of this.When it doesn’t help
- Leaf-dominated queries. If most of the time is spent scanning segments (highly selective filters, simple aggregations pushed down to the leaf), the leaf stage is identical in both runtimes and the StarTree Execution Engine has little to accelerate.
- Very fast, small queries. Crossing between the JVM and the native runtime has a fixed cost per query and per batch. Below roughly 100 ms on Java MSQE, that crossing cost dominates and the query can come out slower.
- Bulk casts out of
BIG_DECIMAL. There is no native decimal type, soBIG_DECIMALis carried as order-preserving binary. That is why comparison, sort, join, andGROUP BYon it are native and lossless — and why converting out of it pays per value. A query that casts a large number ofBIG_DECIMALvalues, for exampleCAST(price AS FLOAT)over millions of distinct decimals, pays that cost on every one. - A few specific query shapes currently regress and are being actively closed (for example, some ASOF joins and certain UNION ALL patterns).
Join strategies
Beyond the default hash join, two join strategies are available natively through the standard Calcite join hint:Spill to disk for large queries
Aggregations (GROUP BY/DISTINCT), sorts, and sort-merge joins can spill to local disk under memory pressure instead of failing with an out-of-memory error — the query gets slower, not killed. Spill is backed by a shared native memory pool on each broker and server, sized from the available off-heap memory (container memory minus the JVM max heap).
Native operators reserve memory from the pool before consuming it. If the pool cannot grant a reservation the engine knows before the allocation happens and can act on it — spilling, or cancelling that one query. This is the structural difference from the Java heap, where a query finds out it is out of memory only after the fact.
Two things about the pool are easy to get wrong:
- It is per worker process, not per query. One pool is created at startup and shared by every query and every stage that worker runs. A query that succeeds when run alone can therefore be cancelled under concurrency, because its neighbours take a share of the pool.
- The pool is divided evenly between op-chains, not between queries. The engine splits a multi-stage query into stages, and each stage into op-chains. Each op-chain registers with the pool as a separate consumer. If a server runs one op-chain of query Q1 and three op-chains of query Q2, each op-chain gets one quarter of the pool, so Q2 holds three quarters. The share of a query thus depends on how many op-chains its neighbours run on that server, not on how much memory they use. The same query can also get a different share on each server.
- It does not bound the process’s memory. It bounds native intermediate execution only. The JVM heap — including everything in the leaf stage — sits outside it.
Malformed or out-of-range values for these settings fail broker/server startup rather than silently falling back to a default.
Under memory pressure a query therefore meets one of three outcomes:
The leaf stage is not covered by any of this
Everything above describes the native pool. TheLeafOperator does not use it: it runs the original model, on the JVM heap, unbounded and without spilling. A large partial aggregate in the leaf can still cause GC pressure and still kill the query — in exactly the situations where Java MSQE kills it today. Moving a query to this engine does not, by itself, fix a leaf-side memory problem.
Three things help:
- Streaming leaf aggregates, which flush partial groups instead of holding them all, are on by default — this is why the problem is rarer than it used to be.
- Let the pull-up take the aggregate. If it qualifies, the work moves into the native runtime and onto the bounded pool — see Leaf-stage work and the columnar pull-up.
-
Skip the leaf aggregate entirely with the standard Pinot hint, so grouping happens above the leaf:
is_skip_leaf_stage_group_byis an existing Pinot aggregate hint and is not specific to this engine.
Unsupported cases
Not every query pattern runs natively yet. There are two very different behaviors, depending on when the engine detects the unsupported pattern.Transparent fallback to Java MSQE (query still succeeds)
At planning time the broker checks the query plan for patterns the StarTree Execution Engine does not support. If it finds one, it silently ignores theworkerRuntime option and runs the whole query on the Java MSQE runtime. The query succeeds and returns the same results as if you had never set the option. The response metadata reports the fallback with queryEngineDowngraded and its reason, and the broker writes an INFO log line (Disabling DataFusion for this query: <reason> ...). See Monitoring.
Patterns that currently fall back at planning time:
INTERSECT ALLandEXCEPT ALL/MINUS ALLset operations.- Aggregations that return arrays or collections (e.g.
ARRAY_AGG). - Certain optimizer-hint-driven aggregation paths that have no native equivalent, such as
is_leaf_return_final_resultcombined withSUMPRECISION, funnel functions, or raw sketch aggregations. UNNESTwhen the column-pruning optimization is enabled for it.GROUPING SETS,ROLLUP, andCUBE.- Window frames using
EXCLUDE, andLEAD/LAG ... IGNORE NULLSalongside another call. - Unsupported column types, when they are visible in the logical plan (see below).
Query fails with an error (no fallback)
Some patterns can only be detected after planning, while the plan is being prepared for native execution on the servers. At that point there is no fallback: the query fails and the client receives an error. To run such a query, setworkerRuntime = 'DEFAULT' on it (see Returning a query to Java MSQE), or rewrite the query. Removing the option is not enough when the broker-wide default is on: the broker sets it again, and the query runs on the StarTree Execution Engine.
Cases that fail at runtime:
- Unsupported column types reaching an intermediate stage when not detectable at planning time:
MAP,OBJECT, and exotic array shapes. Supported types are INT, LONG, FLOAT, DOUBLE, BOOLEAN, TIMESTAMP, STRING, JSON, BIG_DECIMAL, BYTES, and their common array variants. - Unsupported join sub-cases: LOOKUP joins outside INNER/LEFT/SEMI/ANTI, SEMI/ANTI lookup joins with non-equality conditions, and certain ASOF join match conditions.
- Functions outside the supported envelope — see the function support section below for the specific lists of native, bridged, and unsupported functions.
Function support
Aggregation functions. The common aggregations run fully natively:COUNT, SUM, MIN, MAX, AVG, DISTINCTCOUNT (except over BIG_DECIMAL), BOOL_AND, BOOL_OR, and the statistics VAR_POP, STDDEV_POP, COVAR_POP, COVAR_SAMP, VAR_SAMP, and STDDEV_SAMP. Every other Pinot aggregation function — percentiles, the DISTINCTCOUNT sketch variants, SUMPRECISION, funnel functions, and so on — executes through a built-in Java bridge: results are identical to Java MSQE, at some extra cost when aggregation state crosses the native/Java boundary, and with a cap of 1,000,000 simultaneously-live groups (udaf.groupby.max.capacity).
Aggregations that return arrays or collections (e.g. ARRAY_AGG) fall back to Java MSQE at planning time, as noted above.
Scalar functions. Comparison, arithmetic, and logical operators, CASE, IN/NOT IN, and array construction run natively. All other registered Pinot scalar functions are automatically bridged to their Java implementation as long as their argument and return types are among INT, LONG, FLOAT, DOUBLE, BOOLEAN, STRING, BYTES, and TIMESTAMP (BIG_DECIMAL and JSON results are carried as strings). A scalar function outside that envelope — for example one that takes or returns arrays or Java objects (theta-sketch set operations being a supported exception) — fails at runtime with an Unsupported function error.
Window functions. The standard window functions run natively: ROW_NUMBER, RANK, DENSE_RANK, PERCENT_RANK, CUME_DIST, NTILE, LAG, LEAD, FIRST_VALUE, LAST_VALUE, and NTH_VALUE, plus the native aggregations listed above used with an OVER clause. There is no Java bridge for window functions: a Pinot-specific window function outside this set fails at runtime.
BIG_DECIMAL precision. BIG_DECIMAL columns are fully supported in comparisons, sorting, joins, GROUP BY, DISTINCT, and MIN/MAX/SUM/AVG/COUNT, using an order-preserving binary encoding. However, arithmetic (+ - * / %) and aggregation over BIG_DECIMAL are computed in double-precision floating point — matching Java MSQE behavior, not arbitrary-precision decimal arithmetic. If your workload depends on exact decimal arithmetic beyond double precision, verify results before relying on it. Custom Java UDFs/UDAFs over BIG_DECIMAL are not yet supported on the StarTree Execution Engine.Behavioral differences to be aware of
Results are identical to Java MSQE apart from a short list of cases. Two of them are worth flagging to users before you enable the engine for their queries, because they change what comes back rather than only how it is computed.- Detailed stats require the streaming stats channel. Per-stage and per-operator response stats for natively executed stages — including leaf counters such as
numSegmentsQueried— are only delivered when MSQE’s streaming stats channel is active. Enable it per query withSET streamStats = true;or cluster-wide with the broker configurationpinot.broker.mse.stream.stats=true(all servers must run a version that supports it). Without it, StarTree Execution Engine responses carry only broker-side stats. In addition, a few plan shapes (e.g. semi-join and dynamic-broadcast sub-plans) do not yet surface their leaf-stage stats. All of this affects observability, not results.
Reading per-operator statistics
Every native operator reports into the query’s stage-stats tree, so a slow query can be read operator by operator instead of guessed at. These stats travel over MSQE’s streaming stats channel —SET streamStats = true; if your build does not have it on by default.
The tree uses thirteen native node types: NATIVE_JOIN, NATIVE_AGGREGATE, NATIVE_SORT, NATIVE_WINDOW, NATIVE_FILTER, NATIVE_TRANSFORM, NATIVE_SET, NATIVE_LIMIT, NATIVE_LITERAL, NATIVE_MAILBOX_SEND, NATIVE_MAILBOX_RECEIVE, NATIVE_LEAF_BRIDGE, and NATIVE_ROOT.
Finding the operator that is actually slow
Compare each node’s wall time against its self time. Do not useEXECUTION_TIME_MS for this: it is cumulative, covering every operator below the node as well as the Java leaf. The broker emits self time as selfExecutionTimeMs.
Java MSQE has no per-operator wall time, so a starved operator and a busy one are harder to tell apart there. Its mailbox receive does report wait time.
Node types are written in upper snake case, but the keys in the response JSON are camelCase —
WALL_TIME_MS appears as wallTimeMs.UPSTREAM_WAIT_MS/DOWNSTREAM_WAIT_MS— these turn a wall-vs-self gap into a direction. A highUPSTREAM_WAIT_MSmeans the operator was starved by its input, so the bottleneck is below it in the tree; a highDOWNSTREAM_WAIT_MSmeans it was back-pressured by its consumer, so the bottleneck is above it. Reading the gap on its own and always walking downwards can lead away from the real cause.TIME_BUILDING_HASH_TABLE_MS/TIME_PROBING_HASH_TABLE_MS— a join split into its two halves. Build-heavy means a large right input, which is the build side (as in Java MSQE).BUILD_MEM_BYTESshows its size.
Two gaps to know about:
NUM_GROUPS is always 0, because the native runtime emits no such metric; and pipeline-breaker sub-plans (semi-join, dynamic broadcast) do not surface their leaf-stage stats.Did this query spill to disk?
Per query, the clearest signal is on the operator itself. WithSET streamStats = true;, NATIVE_SORT and NATIVE_AGGREGATE nodes carry spillCount, spilledBytes, and spilledRows in stageStats. Zero values are omitted from the JSON, so the key being present means that operator spilled, and its absence means it did not.
Across the process, the native metrics endpoint carries the same story: rust_operator_spills_total and rust_operator_spilled_bytes_total for spill activity, and rust_spill_pool_reserved_bytes against rust_spill_pool_size_bytes for how full the pool is.
Both failures surface as ResourcesExhausted, so the error type does not tell them apart — the message text does:
...exceeded the allowable limit... max_temp_directory_size— the query spilled and then hit the disk cap (pinot.startree.datafusion.spill.max.disk.size, default100G). All queries on a worker share that cap.Failed to allocate additional ...— the pool had no memory left, which usually means an operator that cannot spill.
Monitoring
Did this query run natively?
When the planner routes a query back to Java MSQE, the response metadata says so:Executed counts the planner’s choice, not the outcome: a query the engine accepted and then failed on still increments it. Read it next to your error rate rather than as a success count.
Because both are read at the same decision point:
What this ratio does and does not measure. It is the share of queries that asked for the engine and got it — not the share of your workload running natively. When you route per query, every multi-stage query you did not select runs on Java MSQE and appears in neither meter, so this can read 100% while most traffic is still on Java. Compare against
pinot_broker_multiStageQueriesGlobal_Count for a workload-wide denominator. Queries that fail before planning — on parse, compile, or authorization — increment neither meter and sit outside the ratio entirely.Other signals
-
The native runtime exposes its own Prometheus metrics endpoint (
pinot.startree.rust.metrics.port, default8079). All metrics are prefixedrust_: -
In addition, brokers and servers export three engine-related meters through the standard Pinot metrics pipeline.
datafusionOpchainAllocatorLeakCloseCountanddatafusionOpchainAllocatorLeakedBytescount native memory reclaimed when a query’s allocator is closed while it still holds buffers.datafusionOpchainResourceLeakReclaimedCountcounts op-chains whose native resources a backstop reclaimed because the normal close never ran. All three are safety-net counters and must stay at zero; a non-zero rate means a query path leaks. -
To watch for a leak while it develops, the live-count gauges are the earlier signal. They follow the standard Pinot naming scheme (
pinot_<component>_<metric>_Value) and are exported by both brokers and servers:Any of these failing to return to zero when the cluster goes idle is the thing to investigate — the allocator-leak meters above only confirm a leak afterwards.
There is no dedicated Grafana panel for these yet; add them to a dashboard by hand. Watching three questions covers most of an evaluation: is the engine actually being used (the executed/degraded ratio), is memory getting tight (
rust_spill_pool_reserved_bytes climbing toward rust_spill_pool_size_bytes, and rust_operator_spills_total rising), and is anything leaking (the live gauges above).- Standard Pinot query response metadata is returned for all queries, with the stats-coverage caveat noted above.

