feat: event log and history server (Spark History Server equivalent) - #1925
feat: event log and history server (Spark History Server equivalent)#1925andygrove wants to merge 14 commits into
Conversation
Move the scheduler REST API's JobResponse/TaskSummary/TaskStatus/Percentiles/ QueryStageSummary/QueryStagesResponse types onto the ballista-history crate's shared DTOs, and extract the graph-to-DTO construction logic that previously lived inline in the handlers into pub(crate) builder functions in a new api::dto_build module. Handlers become thin wrappers over these builders, which a later event-log writer will also call. Behavior is unchanged; the existing handler unit tests move to dto_build.rs alongside the moved helper functions they exercise, plus one new plan-string assertion test.
Emit HistoryEvents from the QueryStageScheduler event loop:
JobSubmitted -> JobStart, TaskUpdating -> TaskEnd (one per finished
task), and JobFinished/JobRunningFailed -> JobEnd. Event builders live
in a new scheduler_server::event_log module and reuse the same
dto_build DTOs the REST API serves, so a job's JobEnd event matches
its live GET /api/job/{id} response. The EventLogWriter is constructed
from SchedulerConfig::event_log_dir and threaded into
QueryStageScheduler; emission is best-effort and a no-op when
event_log_dir is unset.
… missing jobs
- HistoryStore::load now skips unreadable/corrupt .eventlog files with a
warning instead of failing the whole load, so one bad log can't hide
every other completed job.
- Job endpoints (/api/job/{id}, /stages, /config, /dot) now return 404
for unknown job ids instead of 200 with a null body, matching the
live scheduler's behavior.
- Add /api/state with a static payload matching the shape the TUI
deserializes at startup.
- Strengthen router tests: assert stage-response body contents, cover
the corrupt-eventlog skip path, and cover the 404 behavior.
…e scheduler Add an end-to-end DTO parity test that builds live JobResponse/ QueryStagesResponse DTOs, writes the same JobEnd event through the real EventLogWriter, reloads it via HistoryStore::load, and asserts the serialized JSON matches exactly -- proving the history server would serve the same data a running scheduler produced.
EventLogWriter::append enqueues via non-blocking try_send, which drops the event when the channel is saturated. Using it for the terminal JobEnd event meant a completed job's log could end up with JobStart and TaskEnds but no JobEnd, making read_completed_job return None and the job silently disappear from the history server. Add append_final, which awaits channel capacity instead of dropping, and finish_job, which flushes and then closes the per-job file handle (also fixing fd accumulation). Wire both into the JobFinished and JobRunningFailed on_receive arms in place of append + flush_job.
…-api All ballista_history usage in the scheduler is behind the rest-api feature (DTO builders, event-log wiring, history module). Gating the dependency on rest-api keeps it out of the graph for consumers that build the scheduler with default-features = false (e.g. pyballista), and out of non-rest-api builds.
serde_json is only used in production by the rest-api-gated history module (all other uses are in tests, covered by the dev-dependency). Gating it keeps it out of the dependency graph for default-features = false consumers such as pyballista, so python/Cargo.lock stays in sync.
| } | ||
| log.finish_job(job_id.as_str()).await; | ||
| } | ||
| _ => {} |
There was a problem hiding this comment.
Shouldn't JobCancel and JobPlanningFailed be handled too ? IMO they should call log.finish_job(job_id.as_str()).await; too
There was a problem hiding this comment.
Good catch, and it was worse than just a missing record. Cancellation is terminal for a job (the handler below removes the graph), so a cancelled job never got a JobEnd, which meant it was invisible to the history server and its file handle stayed open for the life of the process. Added a JobCancel arm that appends a terminal event and calls finish_job.
One wrinkle: the tee runs before the cancel is applied to the graph, so the graph still reports the job as running at that point. The new job_cancel_event stamps the cancelled status onto the DTO so the history view does not show a cancelled job as perpetually Running. Added JobEndStatus::Cancelled plus a test for it.
JobPlanningFailed I left alone on purpose, and added a comment saying why: it is only posted when submit_job fails, so it happens instead of JobSubmitted. The job has neither an execution graph nor an open log file at that point, so there is nothing to write or close.
| let percent_complete = | ||
| ((completed_stages as f32 / num_stages as f32) * 100_f32) as u8; |
There was a problem hiding this comment.
num_stages could be 0
| let percent_complete = | |
| ((completed_stages as f32 / num_stages as f32) * 100_f32) as u8; | |
| let percent_complete = if num_stages == 0 { | |
| 0 | |
| } else { | |
| ((completed_stages as f32 / num_stages as f32) * 100_f32) as u8 | |
| }; |
There was a problem hiding this comment.
Applied. It does not actually panic today since this is float division and NaN as u8 saturates to 0 in Rust, but relying on that is not great, and build_job_response_from_overview right below already guards the same way. Now they match.
| if !handles.contains_key(job_id) { | ||
| let path = log_dir.join(format!("{job_id}.eventlog")); | ||
| match tokio::fs::OpenOptions::new() | ||
| .create(true) | ||
| .append(true) | ||
| .open(&path) | ||
| .await | ||
| { | ||
| Ok(f) => { | ||
| handles.insert(job_id.to_string(), f); | ||
| } | ||
| Err(e) => { | ||
| eprintln!("event-log writer: cannot open {}: {e}", path.display()); | ||
| return None; | ||
| } | ||
| } | ||
| } | ||
| handles.get_mut(job_id) |
There was a problem hiding this comment.
Using https://doc.rust-lang.org/stable/std/collections/struct.HashMap.html#method.entry will be more idiomatic:
| if !handles.contains_key(job_id) { | |
| let path = log_dir.join(format!("{job_id}.eventlog")); | |
| match tokio::fs::OpenOptions::new() | |
| .create(true) | |
| .append(true) | |
| .open(&path) | |
| .await | |
| { | |
| Ok(f) => { | |
| handles.insert(job_id.to_string(), f); | |
| } | |
| Err(e) => { | |
| eprintln!("event-log writer: cannot open {}: {e}", path.display()); | |
| return None; | |
| } | |
| } | |
| } | |
| handles.get_mut(job_id) | |
| match handles.entry(job_id.to_string()) { | |
| std::collections::hash_map::Entry::Occupied(entry) => Some(entry.into_mut()), | |
| std::collections::hash_map::Entry::Vacant(entry) => { | |
| let path = log_dir.join(format!("{job_id}.eventlog")); | |
| match tokio::fs::OpenOptions::new() | |
| .create(true) | |
| .append(true) | |
| .open(&path) | |
| .await | |
| { | |
| Ok(f) => Some(entry.insert(f)), | |
| Err(e) => { | |
| eprintln!("event-log writer: cannot open {}: {e}", path.display()); | |
| None | |
| } | |
| } | |
| } | |
| } |
There was a problem hiding this comment.
I would rather keep this one as is. entry() takes an owned key, so it allocates a String on every event, including the already-open case which is the overwhelming majority of calls. The contains_key plus get_mut shape is the usual workaround for the borrow checker rejecting the get_mut-then-insert form, and the extra lookup only happens on the first event for a job.
Happy to switch if you feel strongly, the allocation is small, it just seemed like paying it on every event to save one lookup per job.
|
|
||
| let args = Args::parse(); | ||
|
|
||
| let store = Arc::new(HistoryStore::load(&args.event_log_dir)?); |
There was a problem hiding this comment.
execute the synchronous HistoryStore::load() in spawn_blocking():
| let store = Arc::new(HistoryStore::load(&args.event_log_dir)?); | |
| let event_log_dir = args.event_log_dir.clone(); | |
| let store = Arc::new( | |
| tokio::task::spawn_blocking(move || HistoryStore::load(&event_log_dir)) | |
| .await | |
| .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))??, | |
| ); |
or make load() async and use tokio::fs::** APIs inside it.
There was a problem hiding this comment.
Agreed on the problem, though I went a slightly different way: the load now happens in main() before the tokio runtime is built at all, rather than in spawn_blocking. Nothing else is running at startup, so there is no reason for a runtime to exist yet, and it avoids having to map a JoinError into BallistaError.
| status: String, | ||
| }, | ||
| TaskEnd { | ||
| stage_id: u32, |
There was a problem hiding this comment.
Why u32 ?
Above it uses usize (lines 55 and 59)
There was a problem hiding this comment.
The inconsistency was real. TaskEnd came straight off the protobuf TaskStatus (u32) while the stage events came off the graph (usize), and I never reconciled them.
Resolved it toward fixed width: StageStart and StageEnd are now u32 too, with a doc comment on the enum stating the rule. This is a durable, cross-machine format, so a pointer-width type does not really belong in it. Callers holding the scheduler usize stage ids cast on the way in.
| .status | ||
| .as_ref() | ||
| .map(to_api_task_status) | ||
| .unwrap_or(ApiTaskStatus::Running); |
There was a problem hiding this comment.
At line 72 you filter out the Running tasks but here if the status is None you map it to Running. The method doc also says that Running should be skipped
There was a problem hiding this comment.
Right, that fallback contradicted both the filter above it and the doc comment. Rewrote it as a filter_map so a status-less update is skipped outright rather than being recorded as a TaskEnd that claims Running. Added a test for the status: None case.
| if let Ok(Some(graph)) = self | ||
| .state | ||
| .task_manager | ||
| .get_job_execution_graph(job_id) |
There was a problem hiding this comment.
What if this returns Err ?
job_end_event() won't be called but log.finish_job(job_id.as_str()).await; is still executed.
There was a problem hiding this comment.
The finish_job call is intentional there, and I have added a comment explaining it. It closes the per-job file handle, so a job whose JobEnd could not be built still releases its fd instead of leaking one. The resulting log has no JobEnd line, and the reader treats a log without one as incomplete and skips it, so the history server never serves a half-written job.
What was wrong is that the Err was swallowed silently. Pulled the lookup into an event_log_graph helper that logs both the Err case and the Ok(None) case at warn level, and reused it in all three arms. Event logging still never fails scheduling, it just costs the job its event.
- record cancelled jobs: `JobCancel` is terminal for a job, so without a `JobEnd` the log was left without a terminal record (invisible to the history server) and its file handle stayed open for the life of the process. The tee runs before the cancel is applied to the graph, so the event stamps the cancelled status onto the DTO rather than recording the job as perpetually `Running`. - guard `percent_complete` against a zero stage count, matching `build_job_response_from_overview`. - skip status-less task updates in `task_end_events` instead of recording them as `Running`, which contradicted both the filter and the doc. - report (rather than swallow) a failed or missing execution-graph lookup on the event-log path, and document why `finish_job` still runs. - use fixed-width stage/partition ids throughout the on-disk event schema. - load the history store before the tokio runtime starts, so its blocking directory walk does not park a runtime worker.
|
Thanks for the reviews so far @martin-g. I'm going to break this work down into some smaller PRs to make the review process easier. |
|
First PR split out of this one is #2256 |
|
More PRs split out from this one: |
|
Final PRs, which replace this one |
Which issue does this PR close?
Closes #1923.
Rationale for this change
Ballista has a live TUI that shows jobs, stages, tasks, executors, and metrics by
reading the scheduler's REST API — but that state is ephemeral. Completed jobs are
cleaned up after
finished_job_state_clean_up_interval_seconds, and everything isgone when the scheduler restarts. There is no way to inspect a job after the fact,
the way Spark's History Server lets you.
This PR adds a Spark-History-Server equivalent: the scheduler can record a durable
per-job event log during execution, and a standalone history server replays those
logs and serves the same
/api/*responses the live scheduler does — so theexisting TUI browses completed jobs unchanged, with no scheduler running.
What changes are included in this PR?
New
ballista-historycrate (a leaf crate; depends only onserde,serde_json,tokio):HistoryEvent):JobStart,StageStart/StageEnd,TaskEnd(the incremental timeline), and a terminalJobEndthat embeds the fully-built REST DTOs.JobResponse,QueryStagesResponse, …), shared sothe live scheduler and the history server serialize byte-identical JSON.
EventLogWriter(appends never block the scheduler hot path)and a reader that folds a completed log into the DTO bundle the history server
serves.
Scheduler:
reusable
dto_buildbuilders (behavior-preserving — existing API output andtests are unchanged).
event_log_dirconfig option (off by default). When set, anEventLogWriteris tee'd off the internalQueryStageSchedulerEventbus inQueryStageScheduler::on_receive, mappingJobSubmitted→JobStart,TaskUpdating→TaskEnd, andJobFinished/JobRunningFailed→JobEnd. Whendisabled there is no channel, no task, no file, and no per-event work beyond one
Optioncheck.JobEndis enqueued with a blocking send and flushed before theloop proceeds, so a completed job's record cannot be dropped under load; its file
handle is then closed.
New
ballista-history-serverbinary:ballista-history-server --event-log-dir <dir> --bind-host <h> --bind-port <p>loads completed logs and serves an axum router over the same
/api/*paths fromthe stored DTOs. Corrupt/partial logs are skipped rather than failing startup;
missing jobs return 404 like the live scheduler.
Scope for this first cut: local-filesystem storage and completed jobs only.
Incremental
TaskEndtimeline events are captured but not yet surfaced in a UI.Verification: the DTO builders are shared by both the live handler and the writer,
and an end-to-end test asserts the history server serves byte-identical JSON to
the live scheduler for the same job.
Are there any user-facing changes?
Yes, all additive and opt-in:
--event-log-dir <dir>(default: disabled).ballista-history-serverbinary.--host/--port) to browsecompleted jobs with no live scheduler.
No breaking changes to public APIs; live REST responses are unchanged.