diff --git a/sandbox/libs/analytics-framework/src/main/java/org/opensearch/analytics/backend/ExecutionContext.java b/sandbox/libs/analytics-framework/src/main/java/org/opensearch/analytics/backend/ShardScanExecutionContext.java
similarity index 90%
rename from sandbox/libs/analytics-framework/src/main/java/org/opensearch/analytics/backend/ExecutionContext.java
rename to sandbox/libs/analytics-framework/src/main/java/org/opensearch/analytics/backend/ShardScanExecutionContext.java
index c4a1ffd52c916..fb0df3f1301d3 100644
--- a/sandbox/libs/analytics-framework/src/main/java/org/opensearch/analytics/backend/ExecutionContext.java
+++ b/sandbox/libs/analytics-framework/src/main/java/org/opensearch/analytics/backend/ShardScanExecutionContext.java
@@ -9,6 +9,7 @@
package org.opensearch.analytics.backend;
import org.apache.arrow.memory.BufferAllocator;
+import org.opensearch.analytics.spi.CommonExecutionContext;
import org.opensearch.index.engine.exec.IndexReaderProvider.Reader;
import org.opensearch.tasks.Task;
@@ -18,7 +19,7 @@
*
* @opensearch.internal
*/
-public class ExecutionContext {
+public class ShardScanExecutionContext implements CommonExecutionContext {
private final String tableName;
private final Reader reader;
@@ -32,7 +33,7 @@ public class ExecutionContext {
* @param task the transport-created task for this fragment execution
* @param reader the data-format aware reader
*/
- public ExecutionContext(String tableName, Task task, Reader reader) {
+ public ShardScanExecutionContext(String tableName, Task task, Reader reader) {
this.tableName = tableName;
this.task = task;
this.reader = reader;
diff --git a/sandbox/libs/analytics-framework/src/main/java/org/opensearch/analytics/spi/AnalyticsSearchBackendPlugin.java b/sandbox/libs/analytics-framework/src/main/java/org/opensearch/analytics/spi/AnalyticsSearchBackendPlugin.java
index e580b9824e36d..e4722784197f6 100644
--- a/sandbox/libs/analytics-framework/src/main/java/org/opensearch/analytics/spi/AnalyticsSearchBackendPlugin.java
+++ b/sandbox/libs/analytics-framework/src/main/java/org/opensearch/analytics/spi/AnalyticsSearchBackendPlugin.java
@@ -70,4 +70,15 @@ default ExchangeSinkProvider getExchangeSinkProvider() {
return null;
}
+ /**
+ * Returns the instruction handler factory for this backend. Used at the coordinator
+ * to create instruction nodes (backend attaches custom config) and at the data node
+ * to create handlers that apply instructions to the execution context.
+ *
+ *
Backends that declare {@code supportedDelegations} or participate in multi-stage
+ * execution MUST implement this. Validation at startup ensures consistency.
+ */
+ default FragmentInstructionHandlerFactory getInstructionHandlerFactory() {
+ throw new UnsupportedOperationException("getInstructionHandlerFactory not implemented for [" + name() + "]");
+ }
}
diff --git a/sandbox/libs/analytics-framework/src/main/java/org/opensearch/analytics/spi/BackendExecutionContext.java b/sandbox/libs/analytics-framework/src/main/java/org/opensearch/analytics/spi/BackendExecutionContext.java
new file mode 100644
index 0000000000000..ac3ca2508a2c7
--- /dev/null
+++ b/sandbox/libs/analytics-framework/src/main/java/org/opensearch/analytics/spi/BackendExecutionContext.java
@@ -0,0 +1,22 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * The OpenSearch Contributors require contributions made to
+ * this file be licensed under the Apache-2.0 license or a
+ * compatible open source license.
+ */
+
+package org.opensearch.analytics.spi;
+
+/**
+ * Marker interface for backend-specific execution context that flows between
+ * successive instruction handler calls. The first handler in the chain receives
+ * {@code null} and bootstraps the context; subsequent handlers receive and build
+ * upon the previous handler's output.
+ *
+ *
Each backend defines its own concrete implementation (e.g.,
+ * {@code DataFusionSessionState} holding a native SessionContext handle).
+ *
+ * @opensearch.internal
+ */
+public interface BackendExecutionContext {}
diff --git a/sandbox/libs/analytics-framework/src/main/java/org/opensearch/analytics/spi/BackendExecutionState.java b/sandbox/libs/analytics-framework/src/main/java/org/opensearch/analytics/spi/BackendExecutionState.java
new file mode 100644
index 0000000000000..f5ae62ce81424
--- /dev/null
+++ b/sandbox/libs/analytics-framework/src/main/java/org/opensearch/analytics/spi/BackendExecutionState.java
@@ -0,0 +1,22 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * The OpenSearch Contributors require contributions made to
+ * this file be licensed under the Apache-2.0 license or a
+ * compatible open source license.
+ */
+
+package org.opensearch.analytics.spi;
+
+/**
+ * Marker interface for backend-specific execution state that flows between
+ * successive instruction handler calls. The first handler in the chain receives
+ * {@code null} and bootstraps the state; subsequent handlers receive and build
+ * upon the previous handler's output.
+ *
+ *
Each backend defines its own concrete implementation (e.g.,
+ * {@code DataFusionSessionState} holding a native SessionContext handle).
+ *
+ * @opensearch.internal
+ */
+public interface BackendExecutionState {}
diff --git a/sandbox/libs/analytics-framework/src/main/java/org/opensearch/analytics/spi/CommonExecutionContext.java b/sandbox/libs/analytics-framework/src/main/java/org/opensearch/analytics/spi/CommonExecutionContext.java
new file mode 100644
index 0000000000000..db68ec841e11e
--- /dev/null
+++ b/sandbox/libs/analytics-framework/src/main/java/org/opensearch/analytics/spi/CommonExecutionContext.java
@@ -0,0 +1,21 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * The OpenSearch Contributors require contributions made to
+ * this file be licensed under the Apache-2.0 license or a
+ * compatible open source license.
+ */
+
+package org.opensearch.analytics.spi;
+
+/**
+ * Marker interface for execution contexts provided by Core to instruction handlers.
+ * Concrete implementations carry the information relevant to their execution path:
+ *
+ *
{@code ShardScanExecutionContext} — shard fragment execution (reader, task, tableName)
+ *
+ * @opensearch.internal
+ */
+public interface CommonExecutionContext {}
diff --git a/sandbox/libs/analytics-framework/src/main/java/org/opensearch/analytics/spi/DelegatedExpression.java b/sandbox/libs/analytics-framework/src/main/java/org/opensearch/analytics/spi/DelegatedExpression.java
new file mode 100644
index 0000000000000..d914642ede6fd
--- /dev/null
+++ b/sandbox/libs/analytics-framework/src/main/java/org/opensearch/analytics/spi/DelegatedExpression.java
@@ -0,0 +1,60 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * The OpenSearch Contributors require contributions made to
+ * this file be licensed under the Apache-2.0 license or a
+ * compatible open source license.
+ */
+
+package org.opensearch.analytics.spi;
+
+import org.opensearch.core.common.io.stream.StreamInput;
+import org.opensearch.core.common.io.stream.StreamOutput;
+import org.opensearch.core.common.io.stream.Writeable;
+
+import java.io.IOException;
+
+/**
+ * A single delegated predicate — carries the annotation ID, the accepting backend,
+ * and the serialized bytes produced by the accepting backend's
+ * {@link DelegatedPredicateSerializer} or anything similar.
+ *
+ * @opensearch.internal
+ */
+public class DelegatedExpression implements Writeable {
+
+ private final int annotationId;
+ private final String acceptingBackendId;
+ private final byte[] expressionBytes;
+
+ public DelegatedExpression(int annotationId, String acceptingBackendId, byte[] expressionBytes) {
+ this.annotationId = annotationId;
+ this.acceptingBackendId = acceptingBackendId;
+ this.expressionBytes = expressionBytes;
+ }
+
+ public DelegatedExpression(StreamInput in) throws IOException {
+ this.annotationId = in.readInt();
+ this.acceptingBackendId = in.readString();
+ this.expressionBytes = in.readByteArray();
+ }
+
+ @Override
+ public void writeTo(StreamOutput out) throws IOException {
+ out.writeInt(annotationId);
+ out.writeString(acceptingBackendId);
+ out.writeByteArray(expressionBytes);
+ }
+
+ public int getAnnotationId() {
+ return annotationId;
+ }
+
+ public String getAcceptingBackendId() {
+ return acceptingBackendId;
+ }
+
+ public byte[] getExpressionBytes() {
+ return expressionBytes;
+ }
+}
diff --git a/sandbox/libs/analytics-framework/src/main/java/org/opensearch/analytics/spi/ExchangeSinkContext.java b/sandbox/libs/analytics-framework/src/main/java/org/opensearch/analytics/spi/ExchangeSinkContext.java
index 2df1062a60988..22b755a73772a 100644
--- a/sandbox/libs/analytics-framework/src/main/java/org/opensearch/analytics/spi/ExchangeSinkContext.java
+++ b/sandbox/libs/analytics-framework/src/main/java/org/opensearch/analytics/spi/ExchangeSinkContext.java
@@ -43,7 +43,7 @@
* @opensearch.internal
*/
public record ExchangeSinkContext(String queryId, int stageId, byte[] fragmentBytes, BufferAllocator allocator, List<
- ChildInput> childInputs, ExchangeSink downstream) {
+ ChildInput> childInputs, ExchangeSink downstream) implements CommonExecutionContext {
/** Per-child input descriptor: the child stage id and the schema of its outgoing batches. */
public record ChildInput(int childStageId, Schema schema) {
diff --git a/sandbox/libs/analytics-framework/src/main/java/org/opensearch/analytics/spi/FilterDelegationInstructionNode.java b/sandbox/libs/analytics-framework/src/main/java/org/opensearch/analytics/spi/FilterDelegationInstructionNode.java
new file mode 100644
index 0000000000000..d56a5c5bed775
--- /dev/null
+++ b/sandbox/libs/analytics-framework/src/main/java/org/opensearch/analytics/spi/FilterDelegationInstructionNode.java
@@ -0,0 +1,68 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * The OpenSearch Contributors require contributions made to
+ * this file be licensed under the Apache-2.0 license or a
+ * compatible open source license.
+ */
+
+package org.opensearch.analytics.spi;
+
+import org.opensearch.core.common.io.stream.StreamInput;
+import org.opensearch.core.common.io.stream.StreamOutput;
+
+import java.io.IOException;
+import java.util.List;
+
+/**
+ * Instruction node for filter delegation to an index backend.
+ * Carries the tree shape, predicate count, and serialized delegated queries.
+ *
+ * @opensearch.internal
+ */
+public class FilterDelegationInstructionNode implements InstructionNode {
+
+ private final FilterTreeShape treeShape;
+ private final int delegatedPredicateCount;
+ private final List delegatedQueries;
+
+ public FilterDelegationInstructionNode(
+ FilterTreeShape treeShape,
+ int delegatedPredicateCount,
+ List delegatedQueries
+ ) {
+ this.treeShape = treeShape;
+ this.delegatedPredicateCount = delegatedPredicateCount;
+ this.delegatedQueries = delegatedQueries;
+ }
+
+ public FilterDelegationInstructionNode(StreamInput in) throws IOException {
+ this.treeShape = in.readEnum(FilterTreeShape.class);
+ this.delegatedPredicateCount = in.readInt();
+ this.delegatedQueries = in.readList(DelegatedExpression::new);
+ }
+
+ @Override
+ public InstructionType type() {
+ return InstructionType.SETUP_FILTER_DELEGATION_FOR_INDEX;
+ }
+
+ @Override
+ public void writeTo(StreamOutput out) throws IOException {
+ out.writeEnum(treeShape);
+ out.writeInt(delegatedPredicateCount);
+ out.writeCollection(delegatedQueries);
+ }
+
+ public FilterTreeShape getTreeShape() {
+ return treeShape;
+ }
+
+ public int getDelegatedPredicateCount() {
+ return delegatedPredicateCount;
+ }
+
+ public List getDelegatedQueries() {
+ return delegatedQueries;
+ }
+}
diff --git a/sandbox/libs/analytics-framework/src/main/java/org/opensearch/analytics/spi/FilterTreeShape.java b/sandbox/libs/analytics-framework/src/main/java/org/opensearch/analytics/spi/FilterTreeShape.java
new file mode 100644
index 0000000000000..8081ba7d63cb6
--- /dev/null
+++ b/sandbox/libs/analytics-framework/src/main/java/org/opensearch/analytics/spi/FilterTreeShape.java
@@ -0,0 +1,32 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * The OpenSearch Contributors require contributions made to
+ * this file be licensed under the Apache-2.0 license or a
+ * compatible open source license.
+ */
+
+package org.opensearch.analytics.spi;
+
+/**
+ * Backend-agnostic description of the boolean tree shape when filter delegation is active.
+ * Provided by the planner so backends can choose their execution strategy without
+ * re-inspecting the Substrait plan.
+ *
+ * @opensearch.internal
+ */
+public enum FilterTreeShape {
+ /** No delegation — all predicates handled natively by the driving backend. */
+ NO_DELEGATION,
+ /**
+ * All predicates (delegated + native) are under a single AND — no interleaving
+ * under OR/NOT. Backend can handle delegated bitsets and native predicates independently.
+ */
+ CONJUNCTIVE,
+ /**
+ * Delegated and native predicates are interleaved under OR/NOT — the boolean tree
+ * mixes predicates from different backends under non-AND operators. Backend needs a
+ * tree evaluator to combine bitsets from both backends per the boolean structure.
+ */
+ INTERLEAVED_BOOLEAN_EXPRESSION
+}
diff --git a/sandbox/libs/analytics-framework/src/main/java/org/opensearch/analytics/spi/FinalAggregateInstructionNode.java b/sandbox/libs/analytics-framework/src/main/java/org/opensearch/analytics/spi/FinalAggregateInstructionNode.java
new file mode 100644
index 0000000000000..87bfc2c5081d8
--- /dev/null
+++ b/sandbox/libs/analytics-framework/src/main/java/org/opensearch/analytics/spi/FinalAggregateInstructionNode.java
@@ -0,0 +1,41 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * The OpenSearch Contributors require contributions made to
+ * this file be licensed under the Apache-2.0 license or a
+ * compatible open source license.
+ */
+
+package org.opensearch.analytics.spi;
+
+import org.opensearch.core.common.io.stream.StreamInput;
+import org.opensearch.core.common.io.stream.StreamOutput;
+
+import java.io.IOException;
+
+/**
+ * Instruction node for final aggregate in coordinator reduce — ExchangeSink path,
+ * remove partial agg, preserve final-only for the driving backend's reduce execution.
+ *
+ *
TODO: add backend-specific config fields as final aggregate implementation is built out.
+ *
+ * @opensearch.internal
+ */
+public class FinalAggregateInstructionNode implements InstructionNode {
+
+ public FinalAggregateInstructionNode() {}
+
+ public FinalAggregateInstructionNode(StreamInput in) throws IOException {
+ // TODO: read config fields when added
+ }
+
+ @Override
+ public InstructionType type() {
+ return InstructionType.SETUP_FINAL_AGGREGATE;
+ }
+
+ @Override
+ public void writeTo(StreamOutput out) throws IOException {
+ // TODO: write config fields when added
+ }
+}
diff --git a/sandbox/libs/analytics-framework/src/main/java/org/opensearch/analytics/spi/FragmentInstructionHandler.java b/sandbox/libs/analytics-framework/src/main/java/org/opensearch/analytics/spi/FragmentInstructionHandler.java
new file mode 100644
index 0000000000000..db70c1c9fdd33
--- /dev/null
+++ b/sandbox/libs/analytics-framework/src/main/java/org/opensearch/analytics/spi/FragmentInstructionHandler.java
@@ -0,0 +1,31 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * The OpenSearch Contributors require contributions made to
+ * this file be licensed under the Apache-2.0 license or a
+ * compatible open source license.
+ */
+
+package org.opensearch.analytics.spi;
+
+/**
+ * Applies an {@link InstructionNode} to the execution context at the data node.
+ * Each handler is created per-execution by the backend's
+ * {@link FragmentInstructionHandlerFactory#createHandler(InstructionNode)}.
+ *
+ * @param the concrete instruction node type this handler processes
+ * @opensearch.internal
+ */
+public interface FragmentInstructionHandler {
+
+ /**
+ * Applies the instruction, reading from Core's context and building upon the
+ * backend's accumulated execution context from previous handlers.
+ *
+ * @param node the instruction node
+ * @param commonContext Core-provided context (shard info or reduce info)
+ * @param backendContext backend state from previous handler, or {@code null} for the first handler
+ * @return updated backend execution context for the next handler or final consumer
+ */
+ BackendExecutionContext apply(N node, CommonExecutionContext commonContext, BackendExecutionContext backendContext);
+}
diff --git a/sandbox/libs/analytics-framework/src/main/java/org/opensearch/analytics/spi/FragmentInstructionHandlerFactory.java b/sandbox/libs/analytics-framework/src/main/java/org/opensearch/analytics/spi/FragmentInstructionHandlerFactory.java
new file mode 100644
index 0000000000000..6e62fa10f012e
--- /dev/null
+++ b/sandbox/libs/analytics-framework/src/main/java/org/opensearch/analytics/spi/FragmentInstructionHandlerFactory.java
@@ -0,0 +1,52 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * The OpenSearch Contributors require contributions made to
+ * this file be licensed under the Apache-2.0 license or a
+ * compatible open source license.
+ */
+
+package org.opensearch.analytics.spi;
+
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * Factory for creating {@link InstructionNode}s at the coordinator and
+ * {@link FragmentInstructionHandler}s at the data node. One factory per backend,
+ * accessed via {@code AnalyticsSearchBackendPlugin.getInstructionHandlerFactory()}.
+ *
+ *
Coordinator-side creation methods return {@link Optional#empty()} if the backend
+ * does not support the instruction type. Core logs and skips unsupported instructions.
+ *
+ * @opensearch.internal
+ */
+public interface FragmentInstructionHandlerFactory {
+
+ // ── Coordinator-side: create instruction nodes ──
+
+ /** Creates a shard scan instruction node. */
+ Optional createShardScanNode();
+
+ /** Creates a filter delegation instruction node with the given delegation metadata. */
+ Optional createFilterDelegationNode(
+ FilterTreeShape treeShape,
+ int delegatedPredicateCount,
+ List delegatedQueries
+ );
+
+ /** Creates a partial aggregate instruction node. */
+ Optional createPartialAggregateNode();
+
+ /** Creates a final aggregate instruction node for coordinator reduce. */
+ Optional createFinalAggregateNode();
+
+ // ── Data-node-side: create handler for an instruction node ──
+
+ /**
+ * Creates a handler for the given instruction node. The handler's
+ * {@link FragmentInstructionHandler#apply} will be called with the node
+ * and the execution context.
+ */
+ FragmentInstructionHandler> createHandler(InstructionNode node);
+}
diff --git a/sandbox/libs/analytics-framework/src/main/java/org/opensearch/analytics/spi/InstructionNode.java b/sandbox/libs/analytics-framework/src/main/java/org/opensearch/analytics/spi/InstructionNode.java
new file mode 100644
index 0000000000000..e52e545d0384b
--- /dev/null
+++ b/sandbox/libs/analytics-framework/src/main/java/org/opensearch/analytics/spi/InstructionNode.java
@@ -0,0 +1,27 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * The OpenSearch Contributors require contributions made to
+ * this file be licensed under the Apache-2.0 license or a
+ * compatible open source license.
+ */
+
+package org.opensearch.analytics.spi;
+
+import org.opensearch.core.common.io.stream.Writeable;
+
+/**
+ * Metadata node produced by the planner (via backend's factory) at the coordinator
+ * and consumed by the backend's handler at the data node. Carries typed configuration
+ * that the handler uses to configure the execution environment.
+ *
+ *
Generic parent interface — backends extend with concrete classes if they need
+ * additional coordinator-side context beyond what the framework provides.
+ *
+ * @opensearch.internal
+ */
+public interface InstructionNode extends Writeable {
+
+ /** The instruction type — used to look up the handler factory at the data node. */
+ InstructionType type();
+}
diff --git a/sandbox/libs/analytics-framework/src/main/java/org/opensearch/analytics/spi/InstructionType.java b/sandbox/libs/analytics-framework/src/main/java/org/opensearch/analytics/spi/InstructionType.java
new file mode 100644
index 0000000000000..490f60a967707
--- /dev/null
+++ b/sandbox/libs/analytics-framework/src/main/java/org/opensearch/analytics/spi/InstructionType.java
@@ -0,0 +1,48 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * The OpenSearch Contributors require contributions made to
+ * this file be licensed under the Apache-2.0 license or a
+ * compatible open source license.
+ */
+
+package org.opensearch.analytics.spi;
+
+import org.opensearch.core.common.io.stream.StreamInput;
+
+import java.io.IOException;
+
+/**
+ * Types of instructions that the planner can produce for backend execution.
+ * Each type corresponds to a specific execution concern that the backend
+ * must handle during the prepare phase on the data node.
+ *
+ * @opensearch.internal
+ */
+public enum InstructionType {
+ /** Base scan setup — reader acquisition, SessionContext creation, default table provider. */
+ SETUP_SHARD_SCAN,
+ /**
+ * Filter delegation to an index backend — bridge setup, UDF registration, IndexedTableProvider.
+ *
+ *
TODO: add a DelegationStrategy field (BACKEND_DRIVEN vs CENTRALLY_DRIVEN) to the
+ * instruction node when centrally-driven delegation is implemented. Currently only
+ * BACKEND_DRIVEN exists — derived from the backend declaring
+ * {@code supportedDelegations(DelegationType.FILTER)}.
+ */
+ SETUP_FILTER_DELEGATION_FOR_INDEX,
+ /** Partial aggregate mode — disable combine optimizer, cut plan to partial-only. */
+ SETUP_PARTIAL_AGGREGATE,
+ /** Final aggregate for coordinator reduce — ExchangeSink path, final-only agg. */
+ SETUP_FINAL_AGGREGATE;
+
+ /** Deserializes an {@link InstructionNode} from the stream based on this type. */
+ public InstructionNode readNode(StreamInput in) throws IOException {
+ return switch (this) {
+ case SETUP_SHARD_SCAN -> new ShardScanInstructionNode(in);
+ case SETUP_FILTER_DELEGATION_FOR_INDEX -> new FilterDelegationInstructionNode(in);
+ case SETUP_PARTIAL_AGGREGATE -> new PartialAggregateInstructionNode(in);
+ case SETUP_FINAL_AGGREGATE -> new FinalAggregateInstructionNode(in);
+ };
+ }
+}
diff --git a/sandbox/libs/analytics-framework/src/main/java/org/opensearch/analytics/spi/PartialAggregateInstructionNode.java b/sandbox/libs/analytics-framework/src/main/java/org/opensearch/analytics/spi/PartialAggregateInstructionNode.java
new file mode 100644
index 0000000000000..2f94d08f3ef0f
--- /dev/null
+++ b/sandbox/libs/analytics-framework/src/main/java/org/opensearch/analytics/spi/PartialAggregateInstructionNode.java
@@ -0,0 +1,40 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * The OpenSearch Contributors require contributions made to
+ * this file be licensed under the Apache-2.0 license or a
+ * compatible open source license.
+ */
+
+package org.opensearch.analytics.spi;
+
+import org.opensearch.core.common.io.stream.StreamInput;
+import org.opensearch.core.common.io.stream.StreamOutput;
+
+import java.io.IOException;
+
+/**
+ * Instruction node for partial aggregate mode — disable combine optimizer, cut plan to partial-only.
+ *
+ *
TODO: add backend-specific config fields as partial aggregate implementation is built out.
+ *
+ * @opensearch.internal
+ */
+public class PartialAggregateInstructionNode implements InstructionNode {
+
+ public PartialAggregateInstructionNode() {}
+
+ public PartialAggregateInstructionNode(StreamInput in) throws IOException {
+ // TODO: read config fields when added
+ }
+
+ @Override
+ public InstructionType type() {
+ return InstructionType.SETUP_PARTIAL_AGGREGATE;
+ }
+
+ @Override
+ public void writeTo(StreamOutput out) throws IOException {
+ // TODO: write config fields when added
+ }
+}
diff --git a/sandbox/libs/analytics-framework/src/main/java/org/opensearch/analytics/spi/SearchExecEngineProvider.java b/sandbox/libs/analytics-framework/src/main/java/org/opensearch/analytics/spi/SearchExecEngineProvider.java
index f16b8f36d9021..8edd8d0a71dc6 100644
--- a/sandbox/libs/analytics-framework/src/main/java/org/opensearch/analytics/spi/SearchExecEngineProvider.java
+++ b/sandbox/libs/analytics-framework/src/main/java/org/opensearch/analytics/spi/SearchExecEngineProvider.java
@@ -9,8 +9,8 @@
package org.opensearch.analytics.spi;
import org.opensearch.analytics.backend.EngineResultStream;
-import org.opensearch.analytics.backend.ExecutionContext;
import org.opensearch.analytics.backend.SearchExecEngine;
+import org.opensearch.analytics.backend.ShardScanExecutionContext;
/**
* Execution engine factory for backend plugins.
@@ -23,6 +23,10 @@ public interface SearchExecEngineProvider {
/**
* Creates a search execution engine bound to the given execution context.
* The context carries the reader snapshot and task metadata.
+ * The backendContext carries backend-specific state produced by instruction handlers.
*/
- SearchExecEngine createSearchExecEngine(ExecutionContext ctx);
+ SearchExecEngine createSearchExecEngine(
+ ShardScanExecutionContext ctx,
+ BackendExecutionContext backendContext
+ );
}
diff --git a/sandbox/libs/analytics-framework/src/main/java/org/opensearch/analytics/spi/ShardScanInstructionNode.java b/sandbox/libs/analytics-framework/src/main/java/org/opensearch/analytics/spi/ShardScanInstructionNode.java
new file mode 100644
index 0000000000000..8000d34f68844
--- /dev/null
+++ b/sandbox/libs/analytics-framework/src/main/java/org/opensearch/analytics/spi/ShardScanInstructionNode.java
@@ -0,0 +1,39 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * The OpenSearch Contributors require contributions made to
+ * this file be licensed under the Apache-2.0 license or a
+ * compatible open source license.
+ */
+
+package org.opensearch.analytics.spi;
+
+import org.opensearch.core.common.io.stream.StreamInput;
+import org.opensearch.core.common.io.stream.StreamOutput;
+
+import java.io.IOException;
+
+/**
+ * Instruction node for base shard scan setup — reader acquisition, SessionContext creation,
+ * default table provider registration.
+ *
+ * @opensearch.internal
+ */
+public class ShardScanInstructionNode implements InstructionNode {
+
+ public ShardScanInstructionNode() {}
+
+ public ShardScanInstructionNode(StreamInput in) throws IOException {
+ // No fields to read
+ }
+
+ @Override
+ public InstructionType type() {
+ return InstructionType.SETUP_SHARD_SCAN;
+ }
+
+ @Override
+ public void writeTo(StreamOutput out) throws IOException {
+ // No fields to write
+ }
+}
diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/ffm.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/ffm.rs
index 7c9a67827f326..ce180c060f563 100644
--- a/sandbox/plugins/analytics-backend-datafusion/rust/src/ffm.rs
+++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/ffm.rs
@@ -412,6 +412,29 @@ pub unsafe extern "C" fn df_cache_manager_add_files(
Ok(0)
}
+// ---------------------------------------------------------------------------
+// SessionContext decomposition — instruction-based execution
+// ---------------------------------------------------------------------------
+
+#[ffm_safe]
+#[no_mangle]
+pub unsafe extern "C" fn df_create_session_context(
+ shard_view_ptr: i64,
+ runtime_ptr: i64,
+ table_name_ptr: *const u8,
+ table_name_len: i64,
+ context_id: i64,
+) -> i64 {
+ let table_name = str_from_raw(table_name_ptr, table_name_len)
+ .map_err(|e| format!("df_create_session_context: {}", e))?;
+ let mgr = get_rt_manager()?;
+ mgr.io_runtime
+ .block_on(crate::session_context::create_session_context(
+ runtime_ptr, shard_view_ptr, table_name, context_id,
+ ))
+ .map_err(|e| e.to_string())
+}
+
#[ffm_safe]
#[no_mangle]
pub unsafe extern "C" fn df_cache_manager_remove_files(
@@ -526,3 +549,27 @@ pub unsafe extern "C" fn df_cache_manager_contains_by_type(
.ok_or_else(|| "df_cache_manager_contains_by_type: no cache manager configured".to_string())?;
Ok(if manager.contains_file_by_type(file_path, cache_type) { 1 } else { 0 })
}
+
+#[no_mangle]
+pub unsafe extern "C" fn df_close_session_context(ptr: i64) {
+ crate::session_context::close_session_context(ptr);
+}
+
+#[ffm_safe]
+#[no_mangle]
+pub unsafe extern "C" fn df_execute_with_context(
+ session_ctx_ptr: i64,
+ plan_ptr: *const u8,
+ plan_len: i64,
+) -> i64 {
+ let mgr = get_rt_manager()?;
+ let plan_bytes = slice::from_raw_parts(plan_ptr, plan_len as usize);
+ let cpu_executor = mgr.cpu_executor();
+ mgr.io_runtime
+ .block_on(crate::query_executor::execute_with_context(
+ session_ctx_ptr,
+ plan_bytes,
+ cpu_executor,
+ ))
+ .map_err(|e| e.to_string())
+}
diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/lib.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/lib.rs
index d12fa24fa3acc..ee876888450c9 100644
--- a/sandbox/plugins/analytics-backend-datafusion/rust/src/lib.rs
+++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/lib.rs
@@ -27,4 +27,5 @@ pub mod partition_stream;
pub mod query_executor;
pub mod query_memory_pool_tracker;
pub mod runtime_manager;
+pub mod session_context;
pub mod statistics_cache;
diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/query_executor.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/query_executor.rs
index 766baffcc7afe..8ba9c93b3caea 100644
--- a/sandbox/plugins/analytics-backend-datafusion/rust/src/query_executor.rs
+++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/query_executor.rs
@@ -29,9 +29,15 @@ use substrait::proto::Plan;
use crate::cross_rt_stream::CrossRtStream;
use crate::executor::DedicatedExecutor;
use crate::api::DataFusionRuntime;
+use crate::session_context::SessionContextHandle;
/// Execute a vanilla parquet query: substrait plan → DataFusion → CrossRtStream.
/// File access goes through DataFusion's registered object store.
+///
+/// Deprecated: Production now uses the decomposed `create_session_context` +
+/// `execute_with_context` path (via `api::execute_query`).
+/// TODO: Remove this function and migrate benchmarks to the decomposed path.
+/// Retained only for benchmarks. TODO: migrate benchmarks and remove.
pub async fn execute_query(
table_path: ListingTableUrl,
object_metas: Arc>,
@@ -147,3 +153,35 @@ pub async fn execute_query(
Ok(Box::into_raw(Box::new(wrapped)) as i64)
}
+
+/// Executes a Substrait plan against a pre-configured SessionContext.
+/// Consumes the handle — SessionContext lifetime is tied to the returned stream.
+pub async unsafe fn execute_with_context(
+ session_ctx_ptr: i64,
+ plan_bytes: &[u8],
+ cpu_executor: DedicatedExecutor,
+) -> Result {
+ let handle = *Box::from_raw(session_ctx_ptr as *mut SessionContextHandle);
+
+ let substrait_plan = Plan::decode(plan_bytes).map_err(|e| {
+ DataFusionError::Execution(format!("Failed to decode Substrait: {}", e))
+ })?;
+
+ let logical_plan = from_substrait_plan(&handle.ctx.state(), &substrait_plan).await?;
+ let dataframe = handle.ctx.execute_logical_plan(logical_plan).await?;
+ let physical_plan = dataframe.create_physical_plan().await?;
+
+ let df_stream = execute_stream(physical_plan, handle.ctx.task_ctx()).map_err(|e| {
+ error!("execute_with_context: failed to create stream: {}", e);
+ e
+ })?;
+
+ let cross_rt_stream = CrossRtStream::new_with_df_error_stream(df_stream, cpu_executor);
+ let wrapped = datafusion::physical_plan::stream::RecordBatchStreamAdapter::new(
+ cross_rt_stream.schema(),
+ cross_rt_stream,
+ );
+
+ let stream_handle = crate::api::QueryStreamHandle::new(wrapped, handle.query_context);
+ Ok(Box::into_raw(Box::new(stream_handle)) as i64)
+}
diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/session_context.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/session_context.rs
new file mode 100644
index 0000000000000..9de9caaa968a5
--- /dev/null
+++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/session_context.rs
@@ -0,0 +1,149 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * The OpenSearch Contributors require contributions made to
+ * this file be licensed under the Apache-2.0 license or a
+ * compatible open source license.
+ */
+
+//! SessionContext lifecycle for instruction-based execution.
+//!
+//! `create_session_context` creates a fully configured SessionContext with
+//! the default ListingTable registered. Called by ShardScanInstruction handler.
+
+use std::sync::Arc;
+
+use datafusion::{
+ common::DataFusionError,
+ datasource::file_format::parquet::ParquetFormat,
+ datasource::listing::{ListingOptions, ListingTable, ListingTableConfig, ListingTableUrl},
+ execution::cache::cache_manager::CacheManagerConfig,
+ execution::cache::{CacheAccessor, DefaultListFilesCache},
+ execution::context::SessionContext,
+ execution::memory_pool::MemoryPool,
+ execution::runtime_env::RuntimeEnvBuilder,
+ execution::SessionStateBuilder,
+ prelude::*,
+};
+use log::error;
+use object_store::ObjectMeta;
+
+use crate::api::{DataFusionRuntime, ShardView};
+use crate::query_memory_pool_tracker::QueryTrackingContext;
+
+/// Opaque handle holding a configured SessionContext between FFM calls.
+pub struct SessionContextHandle {
+ pub ctx: SessionContext,
+ pub table_path: ListingTableUrl,
+ pub object_metas: Arc>,
+ pub query_context: QueryTrackingContext,
+}
+
+/// Creates a SessionContext with per-query RuntimeEnv and registers the default
+/// ListingTable provider for parquet scans.
+pub async unsafe fn create_session_context(
+ runtime_ptr: i64,
+ shard_view_ptr: i64,
+ table_name: &str,
+ context_id: i64,
+) -> Result {
+ let runtime = &*(runtime_ptr as *const DataFusionRuntime);
+ let shard_view = &*(shard_view_ptr as *const ShardView);
+
+ let global_pool = runtime.runtime_env.memory_pool.clone();
+ let query_context = QueryTrackingContext::new(context_id, global_pool);
+ let query_memory_pool = query_context
+ .memory_pool()
+ .map(|p| p as Arc);
+
+ let list_file_cache = Arc::new(DefaultListFilesCache::default());
+ list_file_cache.put(
+ &datafusion::execution::cache::TableScopedPath {
+ table: None,
+ path: shard_view.table_path.prefix().clone(),
+ },
+ shard_view.object_metas.clone(),
+ );
+
+ let mut runtime_env_builder = RuntimeEnvBuilder::from_runtime_env(&runtime.runtime_env)
+ .with_cache_manager(
+ CacheManagerConfig::default()
+ .with_list_files_cache(Some(list_file_cache))
+ .with_file_metadata_cache(Some(
+ runtime.runtime_env.cache_manager.get_file_metadata_cache(),
+ ))
+ .with_files_statistics_cache(
+ runtime.runtime_env.cache_manager.get_file_statistic_cache(),
+ ),
+ );
+
+ if let Some(pool) = query_memory_pool {
+ runtime_env_builder = runtime_env_builder.with_memory_pool(pool);
+ }
+
+ let runtime_env = runtime_env_builder.build().map_err(|e| {
+ error!("create_session_context: failed to build runtime env: {}", e);
+ e
+ })?;
+
+ let query_config = crate::datafusion_query_config::DatafusionQueryConfig::default();
+ let mut config = SessionConfig::new();
+ config.options_mut().execution.parquet.pushdown_filters = query_config.parquet_pushdown_filters;
+ config.options_mut().execution.target_partitions = query_config.target_partitions;
+ config.options_mut().execution.batch_size = query_config.batch_size;
+
+ let state = SessionStateBuilder::new()
+ .with_config(config)
+ .with_runtime_env(Arc::from(runtime_env))
+ .with_default_features()
+ .build();
+
+ let ctx = SessionContext::new_with_state(state);
+
+ // Register default ListingTable for parquet scans
+ let listing_options = ListingOptions::new(Arc::new(ParquetFormat::new()))
+ .with_file_extension(".parquet")
+ .with_collect_stat(true);
+
+ let resolved_schema = listing_options
+ .infer_schema(&ctx.state(), &shard_view.table_path)
+ .await
+ .map_err(|e| {
+ error!("create_session_context: failed to infer schema: {}", e);
+ e
+ })?;
+
+ let table_config = ListingTableConfig::new(shard_view.table_path.clone())
+ .with_listing_options(listing_options)
+ .with_schema(resolved_schema);
+
+ let provider = Arc::new(ListingTable::try_new(table_config).map_err(|e| {
+ error!("create_session_context: failed to create listing table: {}", e);
+ e
+ })?);
+
+ ctx.register_table(table_name, provider).map_err(|e| {
+ error!("create_session_context: failed to register table '{}': {}", table_name, e);
+ e
+ })?;
+
+ error!("create_session_context: successfully registered table '{}', table_name_len={}", table_name, table_name.len());
+
+ let handle = SessionContextHandle {
+ ctx,
+ table_path: shard_view.table_path.clone(),
+ object_metas: shard_view.object_metas.clone(),
+ query_context,
+ };
+ Ok(Box::into_raw(Box::new(handle)) as i64)
+}
+
+/// Closes a SessionContext handle without executing. Used for cleanup on failure.
+///
+/// # Safety
+/// `ptr` must be 0 or a valid pointer returned by `create_session_context`.
+pub unsafe fn close_session_context(ptr: i64) {
+ if ptr != 0 {
+ let _ = Box::from_raw(ptr as *mut SessionContextHandle);
+ }
+}
diff --git a/sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/DataFusionAnalyticsBackendPlugin.java b/sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/DataFusionAnalyticsBackendPlugin.java
index f5ed1de1033bd..b3730c709822a 100644
--- a/sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/DataFusionAnalyticsBackendPlugin.java
+++ b/sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/DataFusionAnalyticsBackendPlugin.java
@@ -17,6 +17,7 @@
import org.opensearch.analytics.spi.FieldType;
import org.opensearch.analytics.spi.FilterCapability;
import org.opensearch.analytics.spi.FragmentConvertor;
+import org.opensearch.analytics.spi.FragmentInstructionHandlerFactory;
import org.opensearch.analytics.spi.ProjectCapability;
import org.opensearch.analytics.spi.ScalarFunction;
import org.opensearch.analytics.spi.ScalarFunctionAdapter;
@@ -153,7 +154,7 @@ public FragmentConvertor getFragmentConvertor() {
@Override
public SearchExecEngineProvider getSearchExecEngineProvider() {
- return ctx -> {
+ return (ctx, backendContext) -> {
DataFusionService dataFusionService = plugin.getDataFusionService();
if (dataFusionService == null) {
throw new IllegalStateException("DataFusionService not initialized — createComponents() may not have been called");
@@ -175,12 +176,21 @@ public SearchExecEngineProvider getSearchExecEngineProvider() {
throw new IllegalStateException("No DatafusionReader available in the acquired reader");
}
DatafusionContext context = new DatafusionContext(ctx.getTask(), dfReader, dataFusionService.getNativeRuntime());
+ if (backendContext != null) {
+ DataFusionSessionState sessionState = (DataFusionSessionState) backendContext;
+ context.setSessionContextHandle(sessionState.sessionContextHandle());
+ }
DatafusionSearchExecEngine engine = new DatafusionSearchExecEngine(context);
engine.prepare(ctx);
return engine;
};
}
+ @Override
+ public FragmentInstructionHandlerFactory getInstructionHandlerFactory() {
+ return new DataFusionInstructionHandlerFactory(plugin);
+ }
+
@Override
public ExchangeSinkProvider getExchangeSinkProvider() {
return ctx -> {
diff --git a/sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/DataFusionInstructionHandlerFactory.java b/sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/DataFusionInstructionHandlerFactory.java
new file mode 100644
index 0000000000000..88ec47e2da9f9
--- /dev/null
+++ b/sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/DataFusionInstructionHandlerFactory.java
@@ -0,0 +1,78 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * The OpenSearch Contributors require contributions made to
+ * this file be licensed under the Apache-2.0 license or a
+ * compatible open source license.
+ */
+
+package org.opensearch.be.datafusion;
+
+import org.opensearch.analytics.spi.DelegatedExpression;
+import org.opensearch.analytics.spi.FilterDelegationInstructionNode;
+import org.opensearch.analytics.spi.FilterTreeShape;
+import org.opensearch.analytics.spi.FinalAggregateInstructionNode;
+import org.opensearch.analytics.spi.FragmentInstructionHandler;
+import org.opensearch.analytics.spi.FragmentInstructionHandlerFactory;
+import org.opensearch.analytics.spi.InstructionNode;
+import org.opensearch.analytics.spi.ShardScanInstructionNode;
+
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * DataFusion backend's instruction handler factory.
+ *
+ *