andygrove commented on PR #2212:
URL: 
https://github.com/apache/datafusion-ballista/pull/2212#issuecomment-5134333623

   Thanks for picking this up — the diagnosis is right and the shape of the fix 
(bounded wait, scoped to the `ExecutorLost` path, config-gated, re-check before 
failing) is the shape I'd want.
   
   *Disclosure: this review was produced with LLM assistance (Claude Code). 
Code references were checked against the tree; the Scenario G timing analysis 
below is derived from reading the code, not from an executed run.*
   
   ## First: there's already a regression test for this, and it's waiting on you
   
   #2026 added an HA chaos harness whose whole purpose was to drive these 
fault-tolerance paths on a real multi-process cluster, and #2029 is one of the 
three bugs it surfaced. It ships a dedicated reproducer:
   
   `chaos-testing/tests/ha.rs` → Scenario G, 
`killing_every_executor_terminates_the_job`, `#[case::aqe_off]` / 
`#[case::aqe_on]`, currently:
   
   ```rust
   #[ignore = "reproduces #2029: killing every executor hangs the job instead 
of failing it"]
   ```
   
   `chaos-testing/README.md` (Finding 3) says explicitly: *"Un-ignore it as the 
regression test when #2029 is fixed."* This PR is that fix, so please enable it 
here rather than leaving the harness carrying a stale `#[ignore]`.
   
   By my reading it should pass as-is, with no assertion changes:
   
   - `ChaosRun::start(aqe, 2)` uses `executor_timeout_seconds: 5` / 
`expire_dead_executor_interval_seconds: 1`, so both SIGKILLed executors are 
reaped and `ExecutorLost` posted at ~6s.
   - `chaos-testing/src/bin/chaos-scheduler.rs` builds `SchedulerConfig { .., 
..Default::default() }`, so it inherits the new 30s default → 
`JobRunningFailed` at ~36s → client errors out, comfortably inside the 
scenario's 120s budget.
   
   Suggested follow-through:
   
   1. Remove both `#[ignore]`s, and update the Scenario G doc comment (it 
currently reads "Both cases reproduce #2029") plus the README table row and 
Finding 3.
   2. Plumb the new knob into the harness — `CHAOS_NO_EXECUTORS_GRACE_SECONDS` 
in `chaos-scheduler.rs` and a builder on `TestCluster`, mirroring the existing 
`executor_timeout_seconds` knob and its "defaults would make every 
executor-kill scenario take three minutes" comment. With a ~1s grace, G costs 
~7s instead of ~37s. That matters: `rust.yml` runs `cargo test` workspace-wide, 
so an un-ignored G lands on every PR's critical path.
   3. Tighten the assertion. The comment calls it "deliberately weak: this is a 
hang detector". Now that termination is achievable, assert the outcome is an 
`Err` whose message names executor loss — that's the contract this PR 
advertises, and it's worth locking in.
   4. Please double-check the `aqe_on` case specifically. AQE keeps its own 
status/graph bookkeeping (`state/aqe/mod.rs:181,265`); the new guard sits on 
the shared `ExecutorLost` path so it should apply, but that's the case I'd 
least want to assume.
   5. The branch may need a rebase — #2026 merged very recently, so 
`chaos-testing/` may not be on the head branch at all yet.
   
   Worth being explicit about what Scenario G does **not** cover, because it's 
blind to finding 1 below: it kills two executors and asserts only that the job 
terminates, so it passes even with duplicate failure events. It also never 
exercises the recovery path (executor returns inside the grace window), which 
is the actual new behaviour being introduced.
   
   ## Review findings
   
   ### 1. Duplicate grace timers → N `JobRunningFailed` per job
   
   This misfires in exactly the scenario the PR targets. When the whole cluster 
dies at once, `expire_dead_executors` (`scheduler_server/mod.rs:326`) returns 
*all* stale heartbeats in a single pass and calls `remove_executor` for each. 
Meanwhile `get_alive_executors` (`executor_manager.rs:466`) already filters on 
`executor_timeout_seconds` staleness, so by the time the first `ExecutorLost` 
is handled every other dying executor is already "not alive". Each of the N 
`ExecutorLost` events therefore sees an empty set and arms its own grace timer, 
and each timer posts `JobRunningFailed` for every running job.
   
   Per job that means: `record_failed` N times (inflated failure metrics), 
`abort_job` logging `"Fail to find job {job_id} in the cache, unable to cancel 
tasks"` for every attempt after the first, and `clean_up_failed_job` running N 
times with N delayed state cleanups queued. Nothing dedupes.
   
   Cleanest fix is to stop deciding inside the spawned task and let the event 
loop decide — which also removes finding 3:
   
   ```rust
   // in the ExecutorLost arm
   if self.state.executor_manager.get_alive_executors().is_empty()
       && !self.no_executor_check_pending.swap(true, Ordering::SeqCst)
   {
       let sender = event_sender.clone();
       let grace = 
Duration::from_secs(self.state.config.no_executors_grace_period_seconds);
       let lost_at = timestamp_millis();
       tokio::spawn(async move {
           tokio::time::sleep(grace).await;
           let _ = sender.post_event(QueryStageSchedulerEvent::NoExecutorsCheck 
{ lost_at }).await;
       });
   }
   ```
   
   …with a new arm that clears the flag and does the alive-check plus fail loop 
inline. A plain `AtomicBool` on `QueryStageScheduler` is enough if a full event 
variant feels heavy.
   
   ### 2. Jobs submitted during the grace window get a truncated grace and a 
misleading message
   
   A job reaches `Status::Running` as soon as planning succeeds 
(`execution_graph.rs:365`) — executor availability doesn't enter into it. So 
the comment's claim that "jobs merely queued waiting for their first executor 
(autoscaling cold start) are never affected" only holds for jobs submitted 
*before* the timer was armed. A job submitted at t=29s of a 30s window is 
failed ~1s later, told that no executor re-registered within 30s. That's both a 
wrong message and the opposite of the autoscaling tolerance the PR is aiming 
for.
   
   Capturing `lost_at` (as above) and skipping jobs whose `running.started_at > 
lost_at` handles this precisely.
   
   ### 3. Snapshot-then-post race (low severity, but `grace = 0` is a 
documented setting)
   
   The task snapshots the running-job cache and then `await`s a `post_event` 
per job. With `grace = 0`, `sleep(0)` merely yields, so a `JobFinished` already 
queued behind the `ExecutorLost` can be processed after the snapshot but before 
the `JobRunningFailed` lands. The job then takes `record_failed` and 
`clean_up_failed_job` → `clean_up_job_data` despite having succeeded. Moving 
the check into the event loop makes this impossible; otherwise re-verify 
`job_info.status` is still `Running` immediately before posting.
   
   ### 4. Test coverage
   
   - **Both new tests use `with_no_executors_grace_period_seconds(0)`**, so the 
grace behaviour — the core of the new logic — is never exercised. The 
highest-value missing test: grace > 0, lose the only executor, re-register one 
inside the window, assert the job is *not* failed. `#[tokio::test(start_paused 
= true)]` keeps it fast.
   - Nothing asserts the failure reason reaches the client. Asserting the 
`FailedJob` message mentions executor loss locks in the user-visible half of 
this fix.
   - Finding 1 is unit-testable and cheap: `TestMetricsCollector::job_events` 
(`test_utils.rs:716`) lets you assert exactly one `MetricEvent::Failed` per job 
after losing N executors at once.
   - `test_running_job_survives_partial_executor_loss` leans on a fixed 
`tokio::time::sleep(500ms)`; a paused clock is both faster and less of a timing 
guess.
   - "Wait until the job is actually running with tasks in flight" over-claims 
— `Running` is set at planning time, so the condition can be satisfied before 
any task is launched.
   
   ### 5. Nits
   
   - `_ => timestamp_millis()` for `queued_at` is unreachable: 
`get_running_job_cache` already filters to `Running` (`task_manager.rs:398`). 
An `else { continue }` reads better. (`scheduler_server/mod.rs:222` does the 
same thing, so this is cosmetic.)
   - `for pair in ... { let (job_id, job_info) = pair; }` → `for (job_id, 
job_info) in ...`.
   - `fail_message` starts with `"Job {job_id} failed: "`, but the handler 
already logs `"Job failed: [{job_id}]"` and other producers 
(`execution_graph.rs:430`) don't prefix the id — dropping it reads better in 
the client-facing error.
   - Comment convention in this tree leans toward the full URL (`// See 
https://github.com/apache/datafusion-ballista/issues/2029`) over a bare `#2029`.
   - Naming: the neighbouring field is `executor_termination_grace_period`. 
Something like `executor_loss_grace_period_seconds` sits closer to both the 
trigger and existing naming, though `no_executors_...` is fine if you prefer it.
   - The spawned timer isn't tied to scheduler shutdown, so a stopping 
scheduler can hold a task for up to `grace`. Harmless, just worth knowing.
   
   No security concerns, and the performance cost is negligible (one 
`get_alive_executors()` snapshot per `ExecutorLost`).
   
   **Overall:** worth landing. I'd want finding 1 fixed before merge since it 
fires on the primary scenario, finding 2 is a small addition on the same 
change, and I'd like either the grace-period recovery unit test or un-ignored 
Scenario G (ideally both) so the new config isn't shipping untested.
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to