Skip to content

test: add HA chaos harness that drives the scheduler's fault-tolerance paths on a real cluster - #2026

Merged
andygrove merged 17 commits into
apache:mainfrom
andygrove:feat/ha-chaos-harness
Jul 29, 2026
Merged

test: add HA chaos harness that drives the scheduler's fault-tolerance paths on a real cluster#2026
andygrove merged 17 commits into
apache:mainfrom
andygrove:feat/ha-chaos-harness

Conversation

@andygrove

@andygrove andygrove commented Jul 13, 2026

Copy link
Copy Markdown
Member

Which issue does this PR close?

Closes #2030.

This harness reproduces the following, each of which is filed separately:

Rationale for this change

Ballista has a substantial high-availability state machine in
ballista/scheduler/src/state/execution_graph.rs (update_task_status): failures are classified
retryable / count_to_failures, tasks retry up to task_max_failures, a FetchPartitionError
rolls back the running stage and resubmits the map stage, and a lost executor resets its running
tasks and removes the shuffle output it produced so map stages re-run.

None of it was tested end to end. Every existing test of these paths fabricates TaskStatus
protobuf messages by hand (execution_graph.rs tests, scheduler/src/test_utils.rs). No test ever
drove the HA state machine from a real query on a real cluster, and no test ever killed an executor.

Running the state machine for real immediately surfaced three bugs, two of which mean Ballista
fails queries that it is designed to recover from. See the linked issues.

There is an existing chaos operator (ChaosExec), but it is unusable as a regression harness: it is
wired only into the AQE planner (state/aqe/planner.rs), so it cannot inject faults with AQE off; it
wraps a randomly chosen plan node; and it fires probabilistically. No test uses the
ballista.testing.chaos_execution.* keys today.

What changes are included in this PR?

A new non-published workspace crate, chaos-testing (ballista-chaos). No production crate is
modified
— the only change outside chaos-testing/ is adding the member to the workspace
Cargo.toml.

  • Fault injection via UDFs, not the planner. chaos_fail(guard, mode, budget_dir) and
    chaos_delay(guard, ms) are pass-through scalar UDFs spliced into the query text, so the identical
    injection works with AQE on and AQE off with zero planner wiring. mode selects which of Ballista's
    three failure classifications is exercised: io (retryable), exec (non-retryable), panic
    (caught by the executor's catch_unwind, non-retryable).
  • Determinism without RNG. The guard predicate over fixed data selects which partitions fault;
    a filesystem token budget bounds how many attempts fault, cluster-wide, across task retries and
    executor restarts (consuming a token is fs::remove_file, which is atomic across processes). Budget 1
    means exactly one attempt faults anywhere, so a retry must succeed; budget ≫ task_max_failures
    means retries must exhaust.
  • A real multi-process cluster. TestCluster spawns real chaos-scheduler / chaos-executor
    binaries as OS processes (they inject the UDFs via the existing override_function_registry /
    override_session_builder hooks, following examples/custom-executor.rs), so executors can be
    SIGKILLed and restarted. It tunes executor_timeout_seconds / heartbeat down from the production
    defaults of 180s/60s, without which a killed executor would not be noticed for three minutes.
    Clusters serialize themselves via a process-wide mutex plus a machine-wide flock, and a
    registration timeout attaches every child process's log tail to the error so CI failures are
    diagnosable from test output alone.
  • Kills land at a known point. The scheduler's REST API is polled (/api/job/{id}/stages) so a kill
    happens while a stage genuinely has running tasks, rather than after a guessed sleep.
  • Seven scenarios, each run under both AQE settings: retryable fault recovered; retries exhausted;
    panicking task; executor killed mid-stage; executor killed after writing shuffle output; executor
    killed and restarted; every executor killed.

The load-bearing assertion in every recovery scenario is result equality against a chaos-free baseline
run
, not merely "it did not error" — re-running a stage is exactly where duplicated or dropped
partitions would appear, and only a correctness check catches that.

Current test status

cargo test -p ballista-chaoslib: 18 passed. ha scenarios: 8 passed, 8 ignored, 0 failed.

Every scenario that reproduces a known bug is #[ignore]d against the issue it reproduces, with its
original assertions intact — nothing is relaxed to manufacture a pass. Run them with
cargo test -p ballista-chaos -- --ignored to see the failures. Once the linked issues are fixed,
un-ignoring these scenarios turns them into the regression tests for those fixes. Each is documented
in chaos-testing/README.md with its root cause:

Passing: baseline correctness (both AQE settings), retry exhaustion, panicking task (job fails
cleanly, executor survives), and executor kill + restart.

Are there any user-facing changes?

No. The crate is test-only and publish = false; no production crate is touched.

andygrove added 13 commits July 13, 2026 10:23
Disable reqwest's default-tls feature in ballista-chaos so it no longer
pulls native-tls/openssl-sys into the workspace's dependency graph;
object_store already depends on the same reqwest version with rustls
only, and cargo unifies features across that shared node. The harness
only ever talks to http://127.0.0.1, so no TLS backend is needed at all.

Also redirect each spawned scheduler/executor child's stdout and stderr
to per-process log files under the cluster's temp directory instead of
Stdio::piped() with nothing reading the pipes. Once a child's output
exceeds the OS pipe buffer, its next write blocks forever, which turns
long-running kill/restart scenarios into a silent hang. A restarted
executor appends to its existing log rather than truncating it, so the
killed process's output survives. Add TestCluster::log_dir() so tests
can locate the logs, and have Drop log a warning pointing at a child's
log file when it exited non-zero.
…on, and panic

Scenario A's AQE-on case reproduces a real Ballista bug: an IO fault
during a broadcast join's shared build-side collection surfaces as
DataFusionError::Shared(IoError), which the failure classifier in
ballista/core/src/error.rs does not recognize as retryable, so the job
fails on the first attempt instead of retrying. Marked #[ignore] with
the finding recorded inline; not fixed here since it requires touching
a production crate outside this plan's scope.
TestCluster::job_status was reading the "job_status" REST field, which
the scheduler populates with a long human-readable sentence (e.g.
"Completed. Produced 1 partition containing 50 rows. Elapsed time: 49
ms."), never the short "Successful"/"Failed" values its doc comment
promised. The short, matchable categorical value ("Queued", "Running",
"Completed", "Failed", "Invalid") lives in the sibling "status" field.
Read that instead so callers can assert on job state.
…scenarios

The harness located its child-process binaries by inferring the profile
directory from cfg!(debug_assertions), which only holds for the stock dev and
release profiles. CI builds with --profile ci, which inherits dev but disables
debug assertions, so every cluster-spawning test looked in target/release and
panicked on a binary that was sitting in target/ci. Derive the directory from
the running test executable instead, which is correct under any profile.

Ignore the five scenarios that reproduce known bugs, each against its issue:
scenario A under AQE (apache#2028), scenario D (apache#2027), and scenario G (apache#2029). Their
assertions are unchanged, so un-ignoring them once the bugs are fixed turns them
into the regression tests. Note that scenario D is a race between the fetch-
failure and heartbeat-expiry recovery paths, and only fails when the former wins.

Hold a process-wide lock for a TestCluster's lifetime so cluster-spawning tests
serialize themselves. The suite required --test-threads=1, but CI runs a plain
cargo test over the workspace and cannot pass it.
- Rename concurrent_tasks to vcores in chaos-executor for the
  ExecutorProcessConfig change on main
- Ignore executor_killed_after_shuffle_write_is_recovered against apache#2027:
  CI showed the reduce stage hitting the dead executor's FetchFailed
  flattened inside DataFusionError::Shared, so the map stage is never
  resubmitted; the scenario only passes when the kill loses the race
- Ignore both retryable-fault scenarios against apache#2027's error
  flattening: apache#2028 was fixed on main (apache#2119) but the sort-shuffle
  writer refactor now Debug-formats task errors into opaque Execution
  strings before classification, so the injected IoError is
  misclassified as non-retryable under both AQE settings
- Serialize clusters machine-wide with an advisory flock so concurrent
  cargo invocations or binary-parallel runners cannot start two
  clusters at once
- Raise the executor registration deadline from 30s to 120s and attach
  every child process log tail to the timeout error, making the CI
  startup timeouts diagnosable if they recur
- Update the README findings to match
@andygrove andygrove changed the title test: add HA chaos harness that drives the scheduler's fault-tolerance paths on a real cluster [WIP] test: add HA chaos harness that drives the scheduler's fault-tolerance paths on a real cluster Jul 28, 2026
@andygrove
andygrove marked this pull request as ready for review July 28, 2026 21:55
@andygrove

Copy link
Copy Markdown
Member Author

cc @phillipleblanc

@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.

This is great, looking forward to integrating this into our project's ballista testing setup.

@andygrove
andygrove merged commit b34144e into apache:main Jul 29, 2026
25 checks passed
@andygrove

Copy link
Copy Markdown
Member Author

Thanks for the review @phillipleblanc. I'll go ahead and merge so that others can iterate on improving this.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add an end-to-end HA chaos harness: the scheduler's fault-tolerance paths are never tested on a real cluster

2 participants