The distributed chapters explain the cluster path in detail: job graphs, drivers, workers, task regions, streams, and shuffles. This chapter fills in two execution views that deserve their own concise treatment:
Both use the same JobRunner abstraction, which is
exactly why the abstraction is valuable.
| Concern | File |
|---|---|
| Job runner trait | crates/sail-common-datafusion/src/session/job.rs |
| Local and cluster runners | crates/sail-execution/src/job_runner.rs |
| Streaming rewriter | crates/sail-plan/src/streaming/rewriter.rs |
| Streaming source trait | crates/sail-common-datafusion/src/streaming/source.rs |
| Flow event schema | crates/sail-common-datafusion/src/streaming/event/schema.rs |
| Flow event streams | crates/sail-common-datafusion/src/streaming/event/stream.rs |
| Streaming logical nodes | crates/sail-logical-plan/src/streaming/ |
| Streaming physical nodes | crates/sail-physical-plan/src/streaming/ |
| Streaming query manager | crates/sail-spark-connect/src/streaming.rs |
| Rate source | crates/sail-data-source/src/formats/rate/reader.rs |
| Socket source | crates/sail-data-source/src/formats/socket/reader.rs |
The JobRunner trait hides the execution backend:
#[tonic::async_trait]
pub trait JobRunner: StateObservable<JobRunnerObserver> + Send + Sync + 'static {
async fn execute(
&self,
ctx: &SessionContext,
plan: Arc<dyn ExecutionPlan>,
) -> Result<SendableRecordBatchStream>;
async fn stop(&self, history: oneshot::Sender<JobRunnerHistory>);
}The local and cluster runners implement the same method. Protocol
code does not need to know which mode is active. It retrieves
JobService from the session and calls:
service.runner().execute(ctx, plan).awaitThat line is one of Sail’s key architecture compression points.
LocalJobRunner is intentionally small:
let plan = trace_execution_plan(plan, options)?;
Ok(execute_stream(plan, ctx.task_ctx())?)The runner wraps the plan with telemetry tracing and then delegates
to DataFusion’s execute_stream. DataFusion handles
partition execution inside the process, and Sail receives a
SendableRecordBatchStream.
Local mode is not a lesser engine. It is the same planning path with a simpler execution backend. That makes it useful for:
ClusterJobRunner sends the physical plan to the driver
actor:
self.driver.send(DriverEvent::ExecuteJob {
plan,
context: ctx.task_ctx(),
result: tx,
}).await?;The driver builds a job graph and eventually returns a stream. From
the caller’s perspective, local and cluster modes both produce
SendableRecordBatchStream.
The difference is below the trait boundary.
Streaming is not a different protocol. A streaming query still enters through Spark Connect, resolves to a logical plan, and becomes a physical plan. The key difference is that Sail rewrites the logical plan before physical planning:
let plan = if is_streaming_plan(&plan)? {
rewrite_streaming_plan(plan)?
} else {
plan
};The rewriter turns ordinary plan nodes into streaming-aware extension nodes where needed. For example:
StreamSourceWrapperNode,RangeNode can be wrapped by
StreamSourceAdapterNode,A streaming source implements StreamSource:
#[async_trait::async_trait]
pub trait StreamSource: Send + Sync + fmt::Debug {
fn data_schema(&self) -> SchemaRef;
async fn scan(
&self,
state: &dyn Session,
projection: Option<&Vec<usize>>,
filters: &[Expr],
limit: Option<usize>,
) -> Result<Arc<dyn ExecutionPlan>>;
}The source returns an ExecutionPlan, not a raw stream.
That keeps it inside the DataFusion physical-planning model. Current
concrete examples include Sail’s rate and socket sources.
Streaming records carry more than user columns. Sail prepends flow-event fields:
_marker
_retracted
<user columns...>
_marker is for control messages. _retracted
distinguishes normal insert events from retraction/delete events.
This design lets streaming physical operators process one Arrow
RecordBatch shape while preserving event semantics. It also
gives future stateful operators a place to represent retract-mode
updates.
The streaming logical nodes are planned by
ExtensionPhysicalPlanner, just like other custom nodes.
Their physical counterparts live under
crates/sail-physical-plan/src/streaming/.
The important nodes are:
| Node | Role |
|---|---|
StreamSourceWrapperNode |
Scans a real streaming source |
StreamSourceAdapterNode |
Adapts a bounded source into flow events |
StreamFilterNode |
Filters flow-event batches |
StreamLimitNode |
Applies streaming limit/offset behavior |
StreamCollectorNode |
Collects or strips flow-event fields near output |
BarrierNode and BarrierExec provide
checkpoint-like coordination points.
Spark Connect exposes streaming operations such as start, stop,
status, and await. Sail tracks running queries with
StreamingQuery and StreamingQueryManager.
The lifecycle uses asynchronous coordination primitives:
watch channels for stopped/error state,oneshot channels for stop signals,JoinHandle for the background task.The manager lives in SparkSessionState, so streaming
query state is scoped to the Spark session.
Streaming support is real, but not all Spark streaming semantics are complete. Readers should distinguish:
The flow-event schema, streaming rewriter, and query manager are the architectural foundation. Full stateful aggregations, event-time triggers, and continuous-mode coverage are areas to verify against the current code before making claims.
Local execution and cluster execution share JobRunner.
Streaming and batch execution share the planning/execution stack below a
logical rewrite. That is the pattern to preserve: new execution behavior
should enter through clear boundaries, not by bypassing the common plan
and stream model.
Navigation: Previous: Chapter 15, Custom Nodes And Optimizers | Next: Chapter 17, Testing Spark Compatibility | Reader Guide