Tutorial: automatic recovery, explained

When a run fails, FlashRuntime does not guess what to do. It turns the failure into typed signals, classifies them into one failure class, and looks that class up in a versioned, deterministic policy table. Same failure + same policy version ⇒ same action, every time. No LLM, no scoring, no learned model in the loop.

This tutorial follows one crash all the way through that pipeline, using the local flash.submit() path. It assumes you have done the ConvNet tutorial, whose kill-and-resume run is the worked example here.


The budget: max_restarts

Recovery on the local path is opt-in through one argument:

import flashruntime as flash
from flashruntime.integrations import pytorch as fr_torch

run = flash.submit(
    fr_torch.ddp("train.py", source=".", nproc_per_node=1,
                 script_args="--steps 24 --checkpoint-every 8 --kill-at-step 8"),
    output_dir="out/convnet",
    max_restarts=1,     # the automatic fault-tolerance budget (default 0 = no retry)
)

max_restarts=0 (the default) means "run once, no retry" — the original behavior. Any higher number is the recovery budget: on a FAILED attempt, FlashRuntime consults the policy and, unless the failure is a deterministic application error, relaunches the same spec from the job-scoped checkpoint, up to that many times.


Step 1 — signals: what a local process can actually tell you

The first thing FlashRuntime does with a dead attempt is translate it into FailureSignals. On the local path the translator is recovery.signals.from_local_launch(exit_code, log_tail), and it is deliberately narrow: a single local process can only evidence two things — its OS exit code and the tail of its captured stdout+stderr. It never fabricates node, accelerator, communication, or storage signals it cannot observe; those classes belong to the distributed coordinator, not a local launcher.

It is a transparent lookup table, not an inference engine. The rules, checked top to bottom, first match wins:

# Evidence Signals produced
1 exit 0 / never started neutral (nothing broke — a guard that keeps the function total)
2 a named deterministic exception on a traceback's terminal line (ModuleNotFoundError:, NameError:, SyntaxError:, ImportError:, IndentationError:, AttributeError:) exit_deterministic=True
3 a bare Traceback that is not the torchrun wrapper exit_deterministic=True
4 everything else (signal death / OOM / bare SystemExit / torchrun ChildFailedError) plain crash (transient)

Two subtleties worth knowing, because they are where naive implementations get it wrong:


Step 2 — classify: signals to one failure class

recovery.classify(signals) maps the signals to exactly one FailureClass, precedence-ordered so systemic evidence beats node evidence beats process evidence beats application evidence — a worker crash during a correlated incident is the incident, not the worker. The classes classify() can produce locally (the full FailureClass enum has two more — data_error, network_degradation — reachable only with coordinator-side signals):

correlated_incident      systemic: many failures in the window — stop acting
control_plane_failure    coordinator unreachable
preemption               spot capacity reclaimed
accelerator_failure      GPU / driver / XID fault
node_loss                heartbeat lost / node unreachable
communication_error      NCCL / RCCL / rendezvous
storage_timeout          object-store errors
artifact_corruption      hash mismatch / failed validation
application_error        deterministic bug (exit_deterministic) ── fail fast
worker_crash             non-deterministic process death ── one fresh attempt
unknown                  nothing matched

On the local path only the last three are reachable, because those are the only classes a single process can evidence. A crashed torchrun worker with no deterministic terminal is a worker_crash.


Step 3 — decide: class × mode to a typed action

recovery.decide(failure_class, mode) is a pure table lookup returning a typed RecoveryDecision stamped with POLICY_VERSION. The mode matters because the same failure has a different blast radius:

flash.submit() picks the mode from the workload: a fan-out is independent_tasks, anything else (including a torchrun DDP run) is coordinated_training. A few rows of the table:

Failure class independent_tasks coordinated_training
application_error fail_job (retrying burns money on a bug) fail_job
worker_crash retry_task restart_group (from latest valid checkpoint)
node_loss retry_task (cordon + requeue) replace_node
storage_timeout pause_job (don't burn compute on dead storage) pause_job
correlated_incident freeze_automation (no retry storms) freeze_automation

The most important action in the table is freeze_automation: during a correlated incident, the policy's job is to know when to stop acting.


Putting it together: the ConvNet crash

The kill-and-resume run from the ConvNet tutorial produces exactly this trail in run.events (FAILURE_CLASSIFIED is amber and RECOVERY_ACTION_SELECTED is cyan on the live page):

LAUNCH_STARTED           task-000 launched (pid ...)
FAILURE_CLASSIFIED       task-000: worker_crash (exit 1)
RECOVERY_ACTION_SELECTED task-000: restart_group — a lost rank stops the group —
                         restart all workers from latest valid checkpoint
LAUNCH_STARTED           task-000-r1 launched (pid ...)

Reading it back through the pipeline: the torchrun child raised SystemExit(3); torchrun re-raised a ChildFailedError (so rules 2 and 3 did not fire) and exited non-zero → signals say "plain crash" → classify returns worker_crashdecide(worker_crash, "coordinated_training") returns restart_group. The retry (task-000-r1) keeps the same job id, so its ft.prepare() finds the predecessor's newest valid manifest and resumes — resumed_from: 8, not a restart from zero.

And the fail-fast case: introduce a NameError in the script and the terminal traceback trips rule 2 → application_errorfail_job. FlashRuntime does not spend your restart budget re-hitting a deterministic bug; it fails immediately and tells you.


Honest scope


Where to go next