Skip to content

feat: event log and history server (Spark History Server equivalent) - #2259

Closed
andygrove wants to merge 5 commits into
apache:mainfrom
andygrove:history-server
Closed

feat: event log and history server (Spark History Server equivalent)#2259
andygrove wants to merge 5 commits into
apache:mainfrom
andygrove:history-server

Conversation

@andygrove

Copy link
Copy Markdown
Member

Which issue does this PR close?

Closes #1923.

Stacked on #2256. GitHub shows the union of both branches until that one merges, so review the last commit here (feat: event log and history server) rather than the full diff. It builds on the dto_build extraction and the ballista-api-types crate that #2256 introduces.

This supersedes #1925, which was the same feature as a single unreviewable change against a base that is now 189 commits stale.

Rationale for this change

Ballista's TUI shows jobs, stages, tasks 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 is gone when the scheduler restarts. There is no way to look at a job after the fact, which is exactly when you usually want to.

user-personas.md lists "a history server / UI" among the things Persona 2 depends on, so this is filling in a guarantee already written down rather than adding a new one.

What changes are included in this PR?

New ballista-history crate. Depends on ballista-api-types plus serde, tokio and log.

  • A versioned JSONL event schema. JobStart, StageStart, StageEnd and TaskEnd form an incremental timeline; the terminal JobEnd embeds the finished REST responses.
  • An async buffered EventLogWriter. All file I/O happens on a background task, so the scheduler's event loop never waits on disk. Timeline events are dropped rather than allowed to block if the queue backs up, on the grounds that losing a progress record beats stalling scheduling. JobEnd is the exception and waits for capacity, because a job missing it is invisible to the history server.
  • A reader that folds a completed log back into the served payload.

Scheduler. A new --event-log-dir flag, off by default. When it is unset there is no channel, no task, no file, and no per-event work beyond one Option check.

When it is set, a tee at the top of QueryStageScheduler::on_receive maps JobSubmitted, TaskUpdating, JobFinished, JobRunningFailed and JobCancel onto history events. JobCancel is handled there specifically because the handler below it drops the graph, so that is the last point at which a cancelled job can be recorded. JobPlanningFailed is deliberately absent: it is posted instead of JobSubmitted, so the job has neither a graph nor an open log.

New ballista-history-server binary. ballista-history-server --event-log-dir <dir> loads completed logs at startup and serves /api/* from the stored responses. Corrupt or partial logs are skipped rather than failing startup, so one bad file cannot hide every other job.

Docs. A new History Server user-guide page covering both flags, the endpoints, and the operational caveats below.

Why replayed output can be trusted

The history server never re-derives a response. JobEnd stores the JobResponse and QueryStagesResponse the scheduler already built from its live execution graph, and replay deserializes and re-serializes them unchanged. Byte-identity is a structural property, not two implementations agreeing.

history_store_serves_byte_identical_json_to_live_scheduler pins this: it builds the live DTOs, emits a real JobEnd through the real async writer, loads it back through the history server's own HistoryStore::load, and asserts the serialized JSON matches.

Two things fell out of getting that test deterministic, and both are improvements in their own right:

  • JobEnd renders its stage snapshot as of completed_at rather than the wall clock, so the stored record is a snapshot of the job at the moment it ended.
  • get_job_config now returns the sorted JobConfig map. It previously served SessionConfig::to_props() directly, which is a HashMap, so live output had non-deterministic key order and could never have matched a replay.

Are there any user-facing changes?

Yes, all additive and opt-in:

  • New scheduler flag --event-log-dir <dir>, default disabled.
  • New ballista-history-server binary.
  • The TUI can point at a history server with --host / --port to browse completed jobs with no live scheduler.
  • GET /api/job/{job_id}/config now returns keys in sorted order. Same content, deterministic ordering.

No breaking API changes.

Verified locally: cargo test -p ballista-scheduler --lib passes (338 tests, 13 of them new), cargo test -p ballista-history passes, clippy is clean for all three crates with --all-features -D warnings, fmt/taplo/prettier are clean, and the --no-default-features check still passes.

I also smoke-tested the binary against a hand-written event log: it loads the job and serves /api/jobs, /api/job/{id}, /api/job/{id}/stages and /api/job/{id}/config correctly, and 404s on an unknown job.

Known limitations

Called out in the docs rather than left to be discovered:

  • Local filesystem only, and logs are read once at startup. Restart the history server to pick up newly finished jobs.
  • Disk is not reclaimed automatically. Logs accumulate until pruned.
  • Plans are rendered once, at write time, so ?plan_format= has no effect against a history server.
  • Only the terminal record is served. The TaskEnd timeline is captured so a future UI can show a job progressing, but nothing reads it yet.

Note on size

This is ~1700 lines, which is large for one review. It was originally scoped as three PRs (event crate, scheduler wiring, history server) and can still be split that way if reviewers would prefer. The three parts are cleanly separable: the crate has no scheduler dependencies, and the wiring and the server touch disjoint files.

…or out dto_build

Move the scheduler's REST response types into a new leaf crate,
`ballista-history`, and pull the graph-to-DTO construction out of the
axum handlers into a pure `api::dto_build` module.

Behavior preserving: the same DTOs are produced from the same state, so
live REST responses are byte-identical. The existing handler tests cover
this, and the helper unit tests move alongside the functions they test.

This is the first step toward a history server that replays completed
jobs and serves the same `/api/*` responses without a live scheduler.
Splitting the DTOs into a serde-only crate lets that server build the
identical wire types without depending on the scheduler's live
execution graph, and moving construction out of the handlers means it
can run against state that did not come from a handler request.

`JobResponse::job_id` becomes a `String` rather than `ballista_core::JobId`
so the new crate stays serde-only. `JobId` is `#[serde(transparent)]`
over `String`, so the JSON is unchanged.
Follow-up cleanups on the extraction:

- Collapse the three near-identical ExecutionStage arms in
  graph_to_query_stages into one destructuring match, dropping the
  mutable placeholder-zero summary.
- Take PlanFormat by value instead of &JobQueryParams, and move
  PlanFormat into ballista-history. It is part of the wire contract, and
  the pure builder no longer imports an axum query-param type back out of
  the handler module.
- Inject `now` into graph_to_query_stages rather than reading the clock,
  so replaying a stored log renders stable elapsed times.
- Share percent_complete and min_start_time; use displayable() instead of
  the longhand DisplayableExecutionPlan::new().
- Drop the dead JobConfig alias and the unused serde_json dev-dependency,
  make task_status_to_dto private, and remove a duplicated test.
- Enable #![warn(missing_docs)] on ballista-history and document the
  types, matching the other Ballista crates.
- Register ballista-history with the release tooling: version bump
  script, publish order, and crate dependency graph.
The previous wording left it ambiguous whether the history server
re-derives responses from stored execution state or replays stored DTOs.
It replays them: the scheduler builds each response once against the live
graph and writes it to the event log, so byte-identical output is a
structural property rather than two implementations agreeing.

Also records the consequence, that anything not captured at write time
cannot be recovered at replay time.
The crate holds the /api/* wire types, and it has three parties, not one:
the scheduler serves them, the web TUI deserializes them, and a future
history server will serve replayed copies. Naming it after the history
server made it awkward for the TUI, which parses live scheduler responses
and today keeps its own duplicate declarations.

Renaming it after the contract it defines removes that friction. The
event-log schema, writer, and reader can then land as a separate
ballista-history crate that depends on this one.
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
the existing TUI browses completed jobs with no scheduler running.

New ballista-history crate:
- A versioned JSONL event schema. JobStart / StageStart / StageEnd / TaskEnd
  form an incremental timeline; the terminal JobEnd embeds the finished REST
  responses.
- An async buffered EventLogWriter. Timeline events are dropped rather than
  allowed to block if the queue backs up, so logging cannot stall scheduling.
  JobEnd is the exception and waits for capacity, since a job missing it is
  invisible to the history server.
- A reader that folds a completed log back into the served payload.

Scheduler:
- New --event-log-dir flag, off by default. When unset there is no channel,
  no task, no file, and no per-event work beyond one Option check.
- An event-log tee at the top of QueryStageScheduler::on_receive maps
  JobSubmitted, TaskUpdating, JobFinished, JobRunningFailed and JobCancel
  onto history events. Cancellation is recorded there because the handler
  below it drops the graph.
- JobEnd renders its stage snapshot as of completed_at rather than the wall
  clock, so replay is deterministic.

History server:
- ballista-history-server --event-log-dir <dir> loads completed logs and
  serves /api/* from the stored responses. Corrupt or partial logs are
  skipped rather than failing startup.

Because JobEnd stores responses the scheduler already built, replay never
re-derives them and there is no second implementation to drift. A test
asserts the history server serves byte-identical JSON to the live scheduler
for the same graph.

get_job_config now returns the sorted JobConfig map so live and replayed
output agree on key order.

Scope for this first cut: local filesystem storage, completed jobs only. The
TaskEnd timeline is captured but not yet surfaced in a UI.
@github-actions github-actions Bot added documentation Improvements or additions to documentation development-process labels Aug 8, 2026
@andygrove

Copy link
Copy Markdown
Member Author

Closing this: it bundles slices 2-4 of the split and so just duplicates #1925, which already has the whole feature. Reopening the series as the intended small pieces instead, starting with the event-log crate on its own. The forward-porting done here (the TaskStatus.partition_id -> task_id schema fix, the completed_at snapshot, and the sorted JobConfig) carries over into those.

@andygrove andygrove closed this Aug 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

development-process documentation Improvements or additions to documentation

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Implement equivalent of Spark History Server

1 participant