feat: fenced, progress-preserving driver restarts (FencedRestart, alpha)#3041
Draft
gangavhi wants to merge 2 commits into
Draft
feat: fenced, progress-preserving driver restarts (FencedRestart, alpha)#3041gangavhi wants to merge 2 commits into
gangavhi wants to merge 2 commits into
Conversation
When a driver dies, restartPolicy today restarts the application from zero and nothing prevents a zombie of the old driver (e.g. on a partitioned node) from still committing output. This adds an opt-in spec.restartPolicy.recovery policy behind the FencedRestart feature gate: - Fencing: before each rerun the controller atomically advances a monotonic epoch in an external state store (Redis; pluggable interface in pkg/statestore). Epoch-guarded writes from the previous run are rejected atomically (Lua check-and-write), so correctness never depends on the old driver actually being dead. Order: fence -> delete -> submit. - Progress preservation: the pod webhook injects a spark-recovery-agent sidecar (new 'agent' subcommand of the operator binary) that heartbeats, serves a localhost progress-marker API to the driver, and restores the latest committed marker before the restarted driver boots. The rerun is the native spark-submit path with SPARK_RECOVERY_* driver env injected. - Honest surface: status.recoveryStatus reports epoch/restoredFromEpoch; a warning event fires when a recovery-enabled app restarts having never committed a marker (fenced restart-from-zero, not progress preservation). Includes: API types + validation, statestore contract with miniredis conformance-style tests, webhook injection + tests, submission env injection + tests, docs/fenced-restart.md, a resumable example job, a minikube demo script that collects testing evidence, and the design proposal. Run 'make generate manifests' and 'go mod tidy' to refresh generated artifacts after checkout. Signed-off-by: Ganga <gangavh@gmail.com>
Found and fixed while testing FencedRestart end to end against a real kind cluster (unit tests alone did not catch any of these): - Epoch race between the webhook and status persistence. The webhook read the sidecar's epoch from status.recoveryStatus.epoch, but submitSparkApplication creates the driver pod (triggering the webhook) before the subsequent status update persists the freshly advanced epoch. The webhook could observe the previous epoch and inject a mismatched value into the recovery-agent sidecar, which then self-fenced a healthy driver on its first heartbeat and crash-looped the job until onFailureRetries was exhausted. Fixed by reading SPARK_RECOVERY_EPOCH from the driver container's own env in the same pod object being mutated -- submission.go already sets it from the identical in-memory epoch used for fencing, so this is race-free by construction. - Completion detection breaks once the driver pod carries a sidecar, in two independent layers. Kubernetes only reports PodSucceeded once every container exits, so the driver container can exit 0 while the recovery agent keeps running, holding the pod at Running. Both the pod-update admission predicate (event_filter.go) and the pod event handler (event_handler.go) only checked pod.Status.Phase, so neither noticed the driver's completion -- fixing one alone was not enough, since the predicate silently dropped the event before the handler ever ran. Fixed both to also react when the driver container's own terminated state changes. Also: examples/spark-fenced-restart.yaml gained a deterministic per-stage floor (STAGE_MIN_SECONDS) so the demo job's timing doesn't race real Spark compute, which is sub-second on fast hardware; added hack/fenced-restart-demo/run-demo-kind.sh, a kind-based counterpart to the existing minikube demo script; and regenerated CRDs/deepcopy/docs after go mod tidy pulled in the statestore dependencies. Regression tests: internal/webhook/recovery_sidecar_test.go (driver container missing the epoch env), internal/controller/sparkapplication/ event_handler_test.go (new), and a new case in event_filter_test.go's TestSparkPodEventFilter_Update table. Verified end to end on kind: killed the driver mid-run, confirmed the operator fenced (epoch advance + a stale-epoch write rejected with FENCED), the resumed driver skipped already-completed stages, and the application reached COMPLETED with no operator restart in between. Signed-off-by: gangavhi <gangavh@gmail.com>
Contributor
|
[APPROVALNOTIFIER] This PR is NOT APPROVED This pull-request has been approved by: The full list of commands accepted by this bot can be found here. DetailsNeeds approval from an approver in each of these files:Approvers can indicate their approval by writing |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Purpose of this PR
Add fenced, progress-preserving driver restarts behind a new alpha
feature gate
FencedRestart(opt-in per application viaspec.restartPolicy.recovery).Closes #3040
Problem. The driver is the SPoF of Spark on Kubernetes. Today
restartPolicy: OnFailure/Alwayshas two gaps:zombie of the old driver on non-transactional sinks (plain files, JDBC,
custom sinks). Nothing fences the old driver at submission time.
completed most of its work, because the operator gives applications no
standard place to record and read back progress.
Solution. With
spec.restartPolicy.recoveryset:in an external state store (Redis in this PR; small pluggable interface in
pkg/statestore). All store writes on behalf of a driver are epoch-guardedand atomic (Lua check-and-write), so once fenced, the old driver can never
commit again — correctness never depends on the old pod actually being
dead. Order: fence → delete → resubmit (native
spark-submitpath).spark-recovery-agentsidecar (anew
agentsubcommand of the operator binary — no new image) thatheartbeats to the store, serves a localhost progress-marker API to the
driver, and restores the latest committed marker before the restarted
driver boots (
SPARK_RECOVERY_*driver env carries the contract).status.recoveryStatusreportsepoch/restoredFromEpoch; awarning event fires when a recovery-enabled app restarts having never
committed a marker — a fenced restart-from-zero must never masquerade as
progress preservation.
Explicitly not claimed: Spark has no driver checkpoint/restore;
executors, shuffle, and scheduler state die with the driver regardless.
This is restart minus redone work plus a fencing guarantee — never
mid-stage resume.
docs/fenced-restart.mdincludes a "you don't need thisif…" section.
Design doc:
proposals/fenced-progress-preserving-restart.md(included).Change Category
Gate off (default): zero behavior change; the validating webhook rejects
spec.restartPolicy.recoverywith a clear message.Rationale
See the included proposal for goals/non-goals, the fencing protocol,
alternatives considered (external companion operator, CRIU/process
checkpointing, Lease-based fencing, sink-side-transactions-only), and
graduation criteria. Highlights of the integration approach:
FAILING → PENDING_RERUNtransition; rerun is the native submissionpath, so the restarted driver is a first-class operator-managed driver.
cert path.
OnFailureRetries/OnFailureRetryIntervalkeep their semantics andbound fenced recoveries too: one attempt-tracking system.
Testing evidence (local kind cluster, long-running job)
Reproducible via
hack/fenced-restart-demo/run-demo-kind.sh(builds theimage from this branch, creates a kind cluster, installs the chart,
deploys Redis + a 20-stage resumable PySpark job, kills the driver
mid-run, and collects all artifacts referenced below into
evidence-20260712-112419/).v1.35.0,Spark 4.0.1.
01-driver-containers.txtshowsspark-kubernetes-driver+spark-recovery-agent.8s/stage floor was added to the demo job so timing doesn't race real
Spark compute, which is sub-second on this hardware; see "Also fixed"
below), committing a marker after each stage (
02-driver-run1.log).SparkApplicationEpochAdvancedin04-events.txt), deleted resources,resubmitted natively.
rejected with
FENCED(06-stale-write-rejected.txt).epoch=1 RESUMING from stage 4 (skipping 4 completed stages)(07-driver-run2.log) and completed all 20 stages in 128.2s;08-final-status.jsonshowsCOMPLETEDwithexecutionAttempts: 2,recoveryStatus: {epoch: 1, restoredFromEpoch: 0}.across a hard driver kill; without this feature run 2 would have
re-executed all 20 stages.
Unit tests:
pkg/statestore(fencing monotonicity, stale-epoch rejection,heartbeat seq, pruning — against miniredis), webhook injection
(idempotency, gate/role/profile guards, driver-env consistency), event
filter/handler (driver-container-termination detection), validator rules,
and submission env injection.
go test ./pkg/statestore/... ./internal/webhook/... ./internal/controller/sparkapplication/....Bugs found and fixed while getting this evidence
Running the feature end-to-end against a real kind cluster (rather than
only the envtest-based unit suite, which passed throughout and did not
catch any of these) surfaced three real bugs, all fixed in this PR:
1. Epoch race between the webhook and status persistence. The webhook
read the sidecar's epoch from
app.Status.RecoveryStatus.Epoch, butsubmitSparkApplicationcreates the driver pod (synchronously invokingspark-submit, which triggers the webhook) before the reconciler'ssubsequent
updateSparkApplicationStatuscall persists the freshlyadvanced epoch to the API server. The webhook could therefore observe the
previous epoch and inject a mismatched value into the sidecar.
Observed directly: the recovery agent's first heartbeat (10s in) found
the real, newer epoch in Redis, concluded it was stale, and self-fenced —
crashing a driver that had never actually been re-run. This repeated on
every subsequent rerun until
onFailureRetrieswas exhausted and the jobwent
FAILED. Fixed ininternal/webhook/recovery_sidecar.goto readSPARK_RECOVERY_EPOCHfrom the driver container's own env within thesame pod object being mutated —
submission.goalready sets it from theidentical in-memory epoch used for fencing, so this is race-free by
construction. Regression test added in
recovery_sidecar_test.go.2 & 3. Completion detection breaks once the driver pod has a sidecar
(two layers). Kubernetes only reports
pod.Status.Phase = Succeededonce every container exits. With the recovery-agent sidecar present,
the driver container can exit 0 while the sidecar keeps running, holding
the pod at
Running. Two independent layers that gate whether theoperator re-evaluates a Spark pod update — the
predicate.Predicateininternal/controller/sparkapplication/event_filter.goand thehandler.EventHandlerinevent_handler.go— both only checkedpod.Status.Phase, so neither ever noticed the driver's completion.Observed directly: the
SparkApplicationsat reportingRUNNINGindefinitely after the driver had already logged "ALL 20 stages complete"
and exited cleanly (confirmed by inspecting container statuses directly:
driver
terminated, agent stillrunning, with no operator reconcilelogged for the app for over a minute). Fixing only
event_handler.gowasnot sufficient —
event_filter.go's predicate was still dropping theevent a layer higher. Fixed both to also let the event through when the
driver container's own terminated state changes, independent of pod
phase (shared helper
driverContainerStateUnchanged). Regression testsadded: new file
event_handler_test.go, plus a new case in the existingTestSparkPodEventFilter_Updatetable inevent_filter_test.go.Checklist
docs/fenced-restart.md).Additional Notes
make generate manifests api-docsandgo mod tidywere run after theAPI changes; generated CRD/deepcopy/docs updates are included. New
dependencies:
github.com/redis/go-redis/v9,github.com/alicebob/miniredis/v2(test only).
examples/spark-fenced-restart.yaml's demo job got a deterministicSTAGE_MIN_SECONDS=8floor per stage: on fast hardware the original300M-row-sum-per-stage compute finished all 20 stages in ~5 seconds,
too fast to kill mid-run in any reproducible way.
reviewable): S3-compatible store backend via conditional writes, Helm
chart values for the new flags, suspect detection from heartbeat
staleness, Prometheus metrics for epoch/recovery duration.