Skip to content

feat: let a standalone cluster run push-staged - #2669

Merged
andygrove merged 3 commits into
apache:mainfrom
andygrove:standalone-push-staged
Oct 30, 2026
Merged

andygrove merged 3 commits into
apache:mainfrom
andygrove:standalone-push-staged

Conversation

@andygrove

Copy link
Copy Markdown
Member

Which issue does this PR close?

Part of #2652. This is the first of a series of smaller PRs split out of #2657 (pipelined execution).

Rationale for this change

A standalone (in-process) cluster always runs pull-staged. Pipelined execution needs push-staged scheduling, and its end-to-end tests run on standalone clusters, so the standalone cluster needs a push-staged option. That option is also useful by itself, for testing push-staged scheduling without starting separate processes.

Running push-staged in standalone mode exposed a bug. The standalone scheduler advertised the hardcoded endpoint localhost:50050 instead of the port it actually bound. Pull-staged executors never use that endpoint, but push-staged executors report task status to the endpoint each launch names.

What changes are included in this PR?

  • A new setting, ballista.standalone.task_scheduling_policy: pull-staged (the default, unchanged behavior) or push-staged.
  • new_standalone_scheduler_with_builder binds its listener before it builds the scheduler, advertises localhost:<port>, and uses the configured policy.
  • With push-staged, new_standalone_executor_from_builder takes a free port for its task-launch gRPC server and starts through executor_server::startup, instead of running the poll loop.
  • A test helper, setup_test_cluster_with_executors, starts a scheduler and several executors in one process.

How are these changes tested?

  • A config unit test covers the default, case-insensitive parsing and a rejected value.
  • ballista/client/tests/standalone_push_staged.rs runs a scan, an aggregate and a join on a push-staged standalone cluster, and on a push-staged scheduler with two executors. It compares each result with a pull-staged standalone cluster.

Are there any user-facing changes?

There is one new setting, ballista.standalone.task_scheduling_policy, documented in the config reference. The default behavior is unchanged.

Add ballista.standalone.task_scheduling_policy (default pull-staged). With
push-staged, the standalone scheduler pushes tasks and the standalone
executor registers on a free gRPC port to receive them.

The standalone scheduler now binds its listener before it is built and
advertises its real address. Push-staged executors report task status to
the endpoint each launch names, which was hardcoded to localhost:50050.

@mbutrovich mbutrovich left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

First pass, thanks @andygrove!

Comment on lines +114 to +123
// A push-staged executor serves the scheduler's task launches on a gRPC
// port of its own, which `executor_server::startup` binds. Take a free one.
let grpc_port = if push_staged {
std::net::TcpListener::bind("127.0.0.1:0")?
.local_addr()?
.port()
} else {
// TODO Make it configurable
50020
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Could the executor keep the gRPC listener bound instead of releasing the port and binding it again in executor_server::startup? Between the drop on line 117 and the bind in startup, any other bind to port 0 in the process can get the same number. The two new tests run in parallel and each binds several port-0 listeners, so this can happen in this test binary. When it does, startup fails with AddrInUse and the cluster doesn't come up. The repo already ran into this with free_ports in chaos-testing: "Releasing each before taking the next lets the kernel hand the same number back" (cluster.rs). One option is a variant of startup that takes an already-bound TcpIncoming, with startup calling it. That variant would be a new public item in executor_server, so it might be better to keep it pub(crate) if the standalone executor is its only caller.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Good catch, thanks! The standalone executor now keeps its listener bound and passes it to a new pub(crate) startup_with_incoming. startup binds as before and delegates to it, so nothing new is public.

Comment thread ballista/executor/src/standalone.rs Outdated
Comment on lines +190 to +191
// The executor's loops stop when the notifier or the stop channel is
// dropped, so keep both for the life of the runtime.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Could this comment describe what happens if the receiver is dropped? Dropping the notifier does stop the loops, because Shutdown::recv returns once the broadcast sender is gone. Dropping stop_receive doesn't stop them. Instead, the next StopExecutor request panics at stop_sender.send(force).await.unwrap() (executor_server.rs).

Suggested change
// The executor's loops stop when the notifier or the stop channel is
// dropped, so keep both for the life of the runtime.
// The executor's loops stop when the notifier is dropped, and its
// `stop_executor` handler panics if the stop channel's receiver is
// dropped, so keep both for the life of the runtime.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Thanks, applied your wording.

let expected = expected(dir.path()).await;

let state = state("push-staged");
let (host, port) = setup_test_cluster_with_executors(&state, 2, 2).await;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Should these tests check that the cluster really runs push-staged? Their results match pull-staged by design, so they would also pass if the setting were ignored. I tried that locally on the head commit. I hard-coded PullStaged in new_standalone_scheduler_with_builder, forced push_staged to false in the executor, and both tests still passed. A push-staged scheduler rejects poll_work with FailedPrecondition (grpc.rs), so the two-executor test could assert that after the cluster starts:

use ballista_core::serde::protobuf::PollWorkParams;
use ballista_core::serde::protobuf::scheduler_grpc_client::SchedulerGrpcClient;

let mut scheduler = SchedulerGrpcClient::connect(format!("http://{host}:{port}"))
    .await
    .unwrap();
let status = scheduler
    .poll_work(PollWorkParams::default())
    .await
    .unwrap_err();
assert_eq!(status.code(), tonic::Code::FailedPrecondition);

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Nice find. The two-executor test now asserts poll_work gets FailedPrecondition. I checked it against a pull-staged cluster and it fails there with InvalidArgument, so it catches the setting being ignored.

Pass the bound listener to a new crate-private
executor_server::startup_with_incoming instead of releasing the port and
binding it again, which let another port-0 bind in the process take it.
Correct the keep-alive comment about the stop channel, and make the
two-executor test check the scheduler rejects poll_work, so it fails if
the push-staged setting is ignored.
andygrove added a commit to andygrove/datafusion-ballista that referenced this pull request Oct 30, 2026
Stack on apache#2669. Its standalone push-staged change replaces this branch's
copy, and pipelined execution now overrides
ballista.standalone.task_scheduling_policy to push-staged.

@mbutrovich mbutrovich left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Thanks @andygrove, the updates look good to me!

@andygrove
andygrove enabled auto-merge October 30, 2026 00:34
@andygrove

Copy link
Copy Markdown
Member Author

Thanks @mbutrovich

@andygrove
andygrove added this pull request to the merge queue Oct 30, 2026
Merged via the queue into apache:main with commit 1fe8e2f Oct 30, 2026
24 checks passed
@andygrove
andygrove deleted the standalone-push-staged branch October 30, 2026 02:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants