Skip to content

feat: add BALLISTA_PROTOCOL_VERSION + k8s health probes - #2088

Merged
avantgardnerio merged 5 commits into
apache:mainfrom
avantgardnerio:brent/protocol-version
Jul 19, 2026
Merged

feat: add BALLISTA_PROTOCOL_VERSION + k8s health probes#2088
avantgardnerio merged 5 commits into
apache:mainfrom
avantgardnerio:brent/protocol-version

Conversation

@avantgardnerio

@avantgardnerio avantgardnerio commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Summary

Closes #2071.

My notes:

  1. caution, I do not run upstream ballista in production
  2. however, this is the strategy that until recently Coralogix used with mostly success
  3. I still feel confident submitting this, because it does not appear we have this functionality at all yet?
  4. my motivation is purely (well, mostly) selfish: I would like to release multi-partition-tasks without a multi-major-version-upgrade cycle

With that out of the way, onto the Claude ramblings:

Adds a compile-time BALLISTA_PROTOCOL_VERSION: u32 = 1 in
ballista_core that gates executor↔scheduler traffic, plus
Kubernetes-style /healthz and /readyz probes on both binaries so
live upgrades have something to observe.

Protocol version

  • New uint32 ballista_protocol_version = 7 on ExecutorRegistration.
  • Executor populates it on registration, heartbeat, and poll_work.
  • Scheduler rejects mismatches (or missing metadata on heartbeat) with
    Status::failed_precondition, an info! log, and increments
    ballista_scheduler_rejected_by_protocol_version_total (prometheus
    feature).
  • Executor /readyz flips off on RPC failure, and after 5 consecutive
    heartbeat failures the executor self-terminates so a mismatched pod
    crash-loops until its image is bumped rather than sitting idle.

Health probes

  • Scheduler: mounted on the existing axum router (same port as gRPC/REST).
    /healthz is always 200. /readyz returns 200 iff at least
    --min-ready-executors executors are registered (default 1; set to 0
    for dev/single-node).
  • Executor: new HTTP server on --bind-health-port (default 50053),
    since Arrow Flight isn't HTTP. /healthz is always 200 — deliberately
    independent of scheduler connectivity to prevent restart cascades.
    /readyz reflects the last heartbeat.

Rolling upgrade semantics (documented in kubernetes.md)

scheduler ↔ executor outcome
old ↔ old works
old ↔ new works (old scheduler ignores the unknown field)
new ↔ new works
new ↔ old rejected — the case the check exists for

Bump both Deployments' image tags together in the same release; each
rolls independently under its own maxSurge, and the scheduler's
/readyz gate keeps Service traffic on the old scheduler until the
new one has matching-version executors registered.

Bump policy: bump BALLISTA_PROTOCOL_VERSION only on releases that
change the executor↔scheduler wire format. Most releases won't.

Test plan

  • cargo test -p ballista-scheduler --lib (236 pass, incl. 8 new)
  • cargo test -p ballista-executor --lib (45 pass)
  • cargo clippy --all-targets -p ballista-core -p ballista-executor -p ballista-scheduler (clean)
  • cargo fmt --all
  • End-to-end smoke against a 2-executor local cluster: all four
    /healthz and /readyz endpoints return 200; scheduler /readyz
    shows ready: 2 executors registered.

New unit tests cover:

  • Register rejected on mismatched protocol version
  • Heartbeat rejected on mismatched protocol version
  • Heartbeat rejected when metadata is missing entirely
  • poll_work rejected on mismatched protocol version
  • /healthz always OK
  • /readyz fails with no executors
  • /readyz OK when min_ready_executors = 0
  • /readyz OK after an active heartbeat is recorded

Notes for reviewers

  • The zero default of the new proto field naturally means "old
    executor" and is rejected once we start at
    BALLISTA_PROTOCOL_VERSION = 1. This is intentional per the issue's
    "no dual-implementation window" design.
  • The old scheduler is permissive because it doesn't know the field
    exists — so during a rolling upgrade, old-scheduler ↔ new-executor
    keeps working. Only new-scheduler ↔ old-executor is rejected.
  • Health probes are unconditional: they mount regardless of the
    rest-api feature or --disable-rest-api, since they're
    operational, not part of the REST API surface.

🤖 Generated with Claude Code

Introduces a compile-time `BALLISTA_PROTOCOL_VERSION` that gates
executor↔scheduler communication and adds `/healthz` and `/readyz`
endpoints so live-upgrade rollouts have something for Kubernetes to
observe.

Scheduler rejects executors whose protocol version does not match with
`FailedPrecondition`, an `info!` log, and a
`ballista_scheduler_rejected_by_protocol_version_total` counter.
Missing registration metadata on heartbeats is treated as a mismatch.

Executor flips /readyz off on RPC failure and self-terminates after 5
consecutive heartbeat failures, so a mismatched pod crash-loops until
its image is bumped rather than silently sitting idle.

Health probes are always mounted regardless of the `rest-api` feature:
scheduler on the existing axum router, executor on a new
`--bind-health-port` (default 50053). Livez is deliberately dumb —
scheduler flapping must not restart executors in a cascade.

Fixes apache#2071.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@github-actions github-actions Bot added the documentation Improvements or additions to documentation label Jul 17, 2026
@avantgardnerio

Copy link
Copy Markdown
Contributor Author

@phillipleblanc FYI

@phillipleblanc phillipleblanc left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think the protocol version makes sense. I have one comment about the health service specifically below, but in general I think this points to a tension in what Ballista is and how we expect consumers to use it.

We use Ballista as a library that we embed into our existing process (i.e. we call scheduler_process::create_scheduler() and serve this on our existing gRPC port rather than let Ballista create it).

I think it would be useful to have a split between the Ballista standalone processes (i.e. ballista-scheduler, ballista-executor) vs the libraries that those binaries (and other consumers) can use to build their own distributed query engines. So for the health probes specifically, I think it would make sense to have it live in the binary/process startup, not as part of the library component. That way its still usable out of the box and is a good reference implementation for anyone that is integrating with ballista, but doesn't force them to take this specific implementation.

/// Port for the executor's gRPC service.
pub grpc_port: u16,
/// Port for the executor's HTTP health server (`/healthz` and `/readyz`).
pub health_port: u16,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

In our project we embed Ballista in our process that already defines health/readiness probes that are specific to our implementation. This would add a second health surface that we can't turn off without removing this code in our fork.

Perhaps similar to the rest-api feature flag, we could add a health-probes flag that controls this? (or bundle this with the rest-api feature flag?) Alternatively a separate option to control whether ballista starts the health service at all that we could disable.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@phillipleblanc , I wasn't aware you (or anyone) was using it that way. Given your use case (should we add this to a persona @andygrove ?) I think you are absolutely correct about architecture, and I was negligent in the design. PTAL at the current state. I have:

  1. removed axum from the executor library
  2. exposed a boolean is_healthy() method in both scheduler and executor
  3. moved axum & the health check server into the executor binary
  4. not changed the scheduler (this is the one I'm not sure about)

My thought is that if schedulers are already starting a REST API, it is natural to put the health check endpoint there so as to not require another port binding. However, I can see how this might be a separate concern from the REST API.

Happy to entertain either option...

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@avantgardnerio no problem! Originally Ballista was started as a standalone binary process, and it was relatively recently that @milenkovicm started to focus it to be more of a library (which is one of the reasons we decided to go with Ballista rather than something like datafusion-distributed which is also a library, but has different design goals)

I agree that putting the health APIs on the scheduler rest API make sense. Everything else looks good now, thanks for reworking this!

avantgardnerio and others added 3 commits July 18, 2026 17:49
The library was spinning up its own axum HTTP server on
`--bind-health-port` from inside `start_executor_process`, adding a hard
axum dep and a second health surface that library embedders had no way
to disable short of forking the code.

Health probes are a deployment concern, so push them to where the
deployment lives: `ballista/executor/src/bin/main.rs`. The library keeps
`ExecutorHealth` — a cheap `Arc<AtomicBool>` that the heartbeat loops
flip on every RPC outcome — and exposes it on `ExecutorProcessConfig` so
callers can observe it however they like. The axum server and its
handlers are gated behind `#[cfg(feature = "build-binary")]`, and the
`axum` dep moves from unconditional to `optional = true` under
`build-binary`. With `--no-default-features`, `ballista-executor` no
longer has a direct edge to axum in `cargo tree`.

Verified end-to-end against the local native cluster: scheduler
`/readyz` reports "ready: 2 executors registered" and both executors'
`/healthz` and `/readyz` return 200, so the handle is threaded through
bin -> config -> heartbeat loop -> probe correctly.

Addresses apache#2088 (comment)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- examples/mtls-cluster.rs: add ballista_protocol_version to
  ExecutorRegistration and pass ExecutorHealth to poll_loop so the
  example compiles under `--all-targets` clippy.
- docs/.../kubernetes.md: prettier reformatting — _emphasis_ instead of
  *emphasis*, and pad the rolling-upgrade table columns to match the
  longest row.
- python/Cargo.lock: `cd python && cargo update` to resync the lockfile
  with the workspace manifests, so `cargo metadata --locked` inside
  `python/` succeeds again.

The TPC-DS SF1 CI failure was infrastructure (`No space left on device`
on the runner), not a code issue — re-running the job should clear it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phillip's fork wraps the Ballista scheduler in a larger process whose
health check is a superset of ours — his handler already reports
app-level state and would like to AND the scheduler's readiness in as
one factor.

Add a public `SchedulerServer::is_ready() -> bool` that returns true
when at least `min_ready_executors` executors have live heartbeats. The
built-in `/readyz` handler now delegates to it (single source of truth),
and embedders can call the same method from whatever health surface
they expose. This is orthogonal to the `/healthz` and `/readyz` routes
mounted on the axum server — those stay unconditional, so operators
who don't embed still get the k8s probes for free.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mirror what SchedulerServer::is_ready() offers on the executor side.
The heartbeat loops already flip the internal atomic on every RPC
outcome, and the built-in `/readyz` axum handler reads it via this
method under `build-binary`. Making the method `pub` and dropping the
cfg gate lets an embedder observe the same signal directly and fold it
into their own health surface, without linking axum.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

@phillipleblanc phillipleblanc left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks! This looks good to me now.

@avantgardnerio
avantgardnerio merged commit 847ee7c into apache:main Jul 19, 2026
30 checks passed
@milenkovicm

Copy link
Copy Markdown
Contributor

https://datafusion.apache.org/ballista/contributors-guide/user-personas.html

Persona 3 should cover @phillipleblanc case if im not mistaken

@avantgardnerio
avantgardnerio deleted the brent/protocol-version branch July 19, 2026 23:26
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 python

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Live upgrade strategy

3 participants