Skip to content

Commit 6795246

Browse files
authored
feat(prover-node): make epoch proving robust to prune-induced fork faults (#24678)
Follow-up to #24436 (A-1290). That PR fixed the immediate prune-induced failure but left a class of race conditions around L1-reorg prunes: every "was this failure a prune or a genuine failure?" predicate samples control-plane state that lags the data-plane world-state unwind, so it is racy by construction. The fix pushes the fact down to where it is unambiguous — **decouple "a checkpoint prover failed" (a fact about the prover) from "the epoch failed" (a decision)**, and never treat a *cancel* (prune / reap / shutdown) as a failure. A genuine failure is then told apart from a prune with no control-plane sampling. ## What changes - **`CheckpointProver`** — gains `isFailed()`, set when its block proofs reject for a non-cancel reason (a sub-tree fault or a prune-induced fork fault). On failure it fires an `onFailed` callback so the owner can upload that single checkpoint's post-mortem. A cancel leaves `isFailed()` false. - **`EpochSession`** — a fault now settles in one of two terminal states, decided by the provers: - `stopped` — a `CheckpointProver` under it failed. May be a prune, so it is **not** uploaded and the session is dropped; the epoch recovers when a prune/re-add installs a fresh prover. - `failed` (`hasFailed()`) — the session's **own** top-tree/submit work failed while *every* prover was healthy. Healthy provers rule out a prune, so this is a genuine, race-free failure. - **`SessionManager`** — removed `lastTickEpoch`. Two mechanisms stop a doomed epoch being re-proved every tick: `openFullSessionIfReady` refuses to build over a set containing a failed prover, and `recreateInvalidSessions` **retains** a genuinely-`failed` full session as a do-not-re-prove marker, replacing it only when its canonical content changes (a re-add). A `failed` session fires `onSessionFailed` (session-level upload). Recovery from prune/reorg flows through the ungated `checkpoint`/`prune` triggers, reusing already-completed sub-proofs from the content-addressed broker. - **`ProverNode`** — uploads post-mortems at **two levels**: per checkpoint (`tryUploadCheckpointFailure`, wired to `CheckpointProver.onFailed` — fires for prune-induced faults too, on purpose) and per session (`tryUploadEpochFailure`, wired to `onSessionFailed`). `expireEpoch` only releases the chonk cache and reaps — it does **not** upload, since a missed-window epoch's provers may already be pruned by then. - **Re-proving** — add `rerunCheckpointProvingJob` to re-prove a single downloaded checkpoint in isolation (sub-tree only; no epoch top-tree or L1 submit), sharing offline setup with `rerunEpochProvingJob`. `downloadEpochProvingJob` is unchanged (same serialized format). ## Tests - `epoch-session.test.ts` — a fault ends `stopped` (a prover failed) vs `failed` (top-tree/submit with healthy provers). - `checkpoint-prover.test.ts` — a mid-proof fork fault sets `isFailed()` and fires `onFailed` once; a cancel does neither. - `session-manager.test.ts` — skips building over a failed prover; retains + uploads a `failed` session and replaces it on content change; does not upload on `stopped`; data-plane fault + re-add (identical and different content) recovers and completes. - `prover-node.test.ts` — a registered checkpoint prover failure routes to `tryUploadCheckpointFailure`; `expireEpoch` reaps without uploading. - `upload_failed_proof.test.ts` (e2e) — the existing epoch upload/rerun test, plus a new checkpoint test: force a sub-tree failure via the `checkpointProveOverride` hook, capture the per-checkpoint upload, and re-prove it via `rerunCheckpointProvingJob`. - prover-node README rewritten to the failed-prover / two-level-upload model, with the "how a genuine failure is told apart from a prune" rationale. prover-node unit suite green; full `yarn build`; format + lint clean. Resolves A-1418.
2 parents f403d88 + 7133758 commit 6795246

12 files changed

Lines changed: 970 additions & 342 deletions

yarn-project/end-to-end/src/single-node/proving/upload_failed_proof.test.ts

Lines changed: 71 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import type { Logger } from '@aztec/aztec.js/log';
44
import { tryRmDir } from '@aztec/foundation/fs';
55
import { promiseWithResolvers } from '@aztec/foundation/promise';
66
import { sleep } from '@aztec/foundation/sleep';
7-
import { downloadEpochProvingJob, rerunEpochProvingJob } from '@aztec/prover-node';
7+
import { downloadEpochProvingJob, rerunCheckpointProvingJob, rerunEpochProvingJob } from '@aztec/prover-node';
88
import type { TestProverNode } from '@aztec/prover-node/test';
99

1010
import { jest } from '@jest/globals';
@@ -61,11 +61,11 @@ describe('single-node/proving/upload_failed_proof', () => {
6161
await tryRmDir(rerunDownloadDir, logger);
6262
});
6363

64-
// Makes the prover's top-tree prove always throw (v5 uses the session's topTreeProveOverride hook;
65-
// pre-v5 it patched finalizeEpoch), intercepts tryUploadSessionFailure (pre-v5 tryUploadEpochFailure)
66-
// to capture the upload URL, then waits for epoch 1 to start and for the upload to complete. Tears
67-
// down the live context, downloads the proving job data, and re-runs it via rerunEpochProvingJob with
68-
// fake proofs on a fresh config.
64+
// Makes the prover's top-tree prove always throw. Because every checkpoint prover still succeeds, the
65+
// session fails on its own account (state 'failed'), which the session manager treats as a genuine,
66+
// race-free failure and uploads a post-mortem eagerly via tryUploadEpochFailure(sessionId, checkpoints).
67+
// Intercepts that to capture the upload URL, then tears down the live context, downloads the proving
68+
// job data, and re-runs it via rerunEpochProvingJob with fake proofs on a fresh config.
6969
it('uploads failed proving job state and re-runs it on a fresh instance', async () => {
7070
// Make initial prover node fail to prove, via the session's top-tree-prove hook.
7171
const proverNode = test.proverNodes[0].getProverNode() as TestProverNode;
@@ -77,19 +77,20 @@ describe('single-node/proving/upload_failed_proof', () => {
7777
},
7878
});
7979

80-
// And track when the epoch failure upload is complete
80+
// Track when the epoch failure upload is complete. It fires eagerly when a full session fails with
81+
// healthy provers (a top-tree failure here).
8182
const { promise: epochUploaded, resolve: onEpochUploaded } = promiseWithResolvers<string>();
82-
const origTryUploadEpochFailure = proverNode.tryUploadSessionFailure.bind(proverNode);
83-
proverNode.tryUploadSessionFailure = async (session: any) => {
84-
const url = await origTryUploadEpochFailure(session);
83+
const origTryUploadEpochFailure = proverNode.tryUploadEpochFailure.bind(proverNode);
84+
proverNode.tryUploadEpochFailure = async (id: any, checkpoints: any) => {
85+
const url = await origTryUploadEpochFailure(id, checkpoints);
8586
if (url !== undefined) {
8687
onEpochUploaded(url);
8788
}
8889
return url;
8990
};
9091

91-
// Warp to the start of epoch one so prover node starts proving epoch 0,
92-
// and wait for the data to be uploaded to the remote file store
92+
// Warp to the start of epoch one so the prover node starts proving, fails at the top tree, and
93+
// uploads the failed proving-job data to the remote file store.
9394
await test.warpToEpochStart(1);
9495
const epochUploadUrl = await epochUploaded;
9596

@@ -122,4 +123,62 @@ describe('single-node/proving/upload_failed_proof', () => {
122123

123124
logger.info(`Test succeeded`);
124125
});
126+
127+
// Same shape as above, one level down: forces a single checkpoint's sub-tree to fail (every checkpoint
128+
// prover fails, via the test hook), which uploads that checkpoint's proving data on its own. Intercepts
129+
// tryUploadCheckpointFailure for the URL, then downloads and re-proves just that checkpoint via
130+
// rerunCheckpointProvingJob (no epoch top-tree / L1 submit).
131+
it('uploads failed checkpoint proving state and re-proves it on a fresh instance', async () => {
132+
const proverNode = test.proverNodes[0].getProverNode() as TestProverNode;
133+
proverNode.setCheckpointHooks({
134+
checkpointProveOverride: async () => {
135+
await sleep(1000);
136+
logger.warn(`Triggering error on checkpoint sub-tree prove`);
137+
throw new Error(`Fake error while proving checkpoint`);
138+
},
139+
});
140+
141+
// The checkpoint post-mortem upload fires eagerly when a CheckpointProver's block proofs reject.
142+
const { promise: checkpointUploaded, resolve: onCheckpointUploaded } = promiseWithResolvers<string>();
143+
const origTryUploadCheckpointFailure = proverNode.tryUploadCheckpointFailure.bind(proverNode);
144+
proverNode.tryUploadCheckpointFailure = async (prover: any) => {
145+
const url = await origTryUploadCheckpointFailure(prover);
146+
if (url !== undefined) {
147+
onCheckpointUploaded(url);
148+
}
149+
return url;
150+
};
151+
152+
// Warp so the prover node starts registering checkpoints; the first one's sub-tree fails and uploads.
153+
await test.warpToEpochStart(1);
154+
const checkpointUploadUrl = await checkpointUploaded;
155+
156+
await test.teardown();
157+
158+
const rerunDownloadPath = join(rerunDownloadDir, 'data.bin');
159+
logger.warn(`Downloading checkpoint proving job data and state`, { rerunDataDir, rerunDownloadPath });
160+
await downloadEpochProvingJob(checkpointUploadUrl, logger, {
161+
dataDirectory: rerunDataDir,
162+
jobDataDownloadPath: rerunDownloadPath,
163+
});
164+
165+
logger.warn(`Rerunning checkpoint proving job from ${rerunDownloadPath}`);
166+
await rerunCheckpointProvingJob(
167+
rerunDownloadPath,
168+
logger,
169+
{
170+
...config,
171+
realProofs: false,
172+
dataStoreMapSizeKb: 1024 * 1024,
173+
dataDirectory: rerunDataDir,
174+
proverAgentCount: 2,
175+
proverId: EthAddress.random(),
176+
...(await getACVMConfig(logger)),
177+
...(await getBBConfig(logger)),
178+
},
179+
context.genesis,
180+
);
181+
182+
logger.info(`Test succeeded`);
183+
});
125184
});

yarn-project/prover-node/README.md

Lines changed: 95 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -48,10 +48,12 @@ The prover-node splits responsibility between four classes:
4848

4949
- **`ProverNode`** — owns the long-lived collections, wires the L2BlockStream, and
5050
translates each chain event into a single method call on the `SessionManager` or
51-
`ProofPublishingService`. It also performs the per-event side effects that don't
52-
belong on an `EpochSession` (registering new checkpoints with the store, sweeping
53-
expired epochs out of the cache and the store, etc.) and runs the failure-upload
54-
action when an `EpochSession` exits with `failed`.
51+
`ProofPublishingService`. It also performs side effects that don't belong on an
52+
`EpochSession`: registering new checkpoints with the store per event, and sweeping expired
53+
epochs out of the cache and the store on a periodic ticker (see below). It uploads post-mortem
54+
snapshots on failure at two levels: per checkpoint when a `CheckpointProver` fails (its `onFailed`
55+
callback), and per session when a full session fails on its own account (`onSessionFailed` /
56+
`EpochSession.hasFailed()`).
5557
- **`CheckpointStore`** — a registry of `CheckpointProver` instances keyed by
5658
`(checkpointNumber, slot, archiveRoot)`. Each `CheckpointProver` runs its own sub-tree pipeline
5759
(tx gather → block processing → block-rollup proofs), starting eagerly the moment a
@@ -64,8 +66,12 @@ The prover-node splits responsibility between four classes:
6466
translated into a `reconcile(trigger)` call, a single idempotent function that walks
6567
all `EpochSession`s, cancels any whose canonical content has shifted, re-creates them with
6668
the new content, and opens fresh full `EpochSession`s for any epoch that has become provable.
67-
Reconcile runs on a `SerialQueue` (from `@aztec/foundation/queue`), so two concurrent
68-
triggers can never interleave on an `await` and race on the `EpochSession` maps.
69+
It never builds a session over a **failed** `CheckpointProver` (one whose block proofs rejected —
70+
a sub-tree fault or a prune-induced fork fault): a session over it could only fail, so the epoch is
71+
cheaply skipped until a prune/re-add installs a fresh prover in its place, at which point a rebuilt
72+
session reuses every already-completed sub-proof from the content-addressed broker. Reconcile runs on
73+
a `SerialQueue` (from `@aztec/foundation/queue`), so two concurrent triggers can never interleave on an
74+
`await` and race on the `EpochSession` maps.
6975
- **`ProofPublishingService`** — central owner of L1 proof submission. `EpochSession`s hand
7076
their top-tree proofs to the service as `PublishCandidate`s; the service serialises
7177
one publish at a time against a freshly-created `ProverNodePublisher`, gates eligibility
@@ -150,9 +156,9 @@ stateDiagram-v2
150156
awaiting_root --> publishing_proof: epoch proof ready, submit to L1
151157
publishing_proof --> completed: publish succeeds
152158
publishing_proof --> superseded: longer same-epoch candidate wins
153-
publishing_proof --> failed: L1 submission errored
154-
awaiting_checkpoints --> failed: top-tree prove errored
155-
awaiting_root --> failed: top-tree prove errored
159+
publishing_proof --> failed: L1 submission errored (provers healthy)
160+
awaiting_checkpoints --> stopped: a checkpoint prover failed
161+
awaiting_root --> failed: top-tree prove errored (provers healthy)
156162
initialized --> timed_out: deadline
157163
awaiting_checkpoints --> timed_out: deadline (EpochSession or candidate)
158164
awaiting_root --> timed_out: deadline (EpochSession or candidate)
@@ -162,9 +168,22 @@ stateDiagram-v2
162168
superseded --> [*]
163169
cancelled --> [*]
164170
timed_out --> [*]
171+
stopped --> [*]
165172
failed --> [*]
166173
```
167174

175+
A fault settles the `EpochSession` in one of two terminal states:
176+
177+
- **`stopped`** — a `CheckpointProver` under the session failed (its block proofs rejected). This may
178+
be a prune-induced fork fault, so it is *not* a verdict on the epoch and is *not* uploaded. The
179+
reconciler drops the session and, guarded by `isFailed()`, does not rebuild over the failed prover;
180+
a prune/re-add installs a fresh prover and the epoch is retried.
181+
- **`failed`** — the session's own work (top-tree prove or L1 submit) failed while *every* checkpoint
182+
prover succeeded. Because healthy provers rule out a prune, this is a genuine, race-free failure
183+
(`EpochSession.hasFailed()`). The reconciler retains such a full session (so the tick does not
184+
re-prove a deterministically-failing epoch) and uploads a post-mortem exactly once. No prune/fault
185+
classification is needed — the two states carry it.
186+
168187
The non-terminal states track the window between `start()` and the L1 submission:
169188

170189
- `awaiting-checkpoints` — a `TopTreeJob` is awaiting each checkpoint's sub-tree result
@@ -323,44 +342,57 @@ sequenceDiagram
323342
PS->>PS: drain reads proven afresh, re-checks eligibility
324343
```
325344

326-
### Per-event expiry sweep
345+
### Periodic expiry sweep
327346

328347
```mermaid
329348
sequenceDiagram
330-
participant L2 as L2BlockStream
349+
participant T as expiryTicker (RunningPromise)
331350
participant PN as ProverNode
351+
participant L2 as L2BlockStream
332352
participant CC as ChonkCache
333353
participant CS as CheckpointStore
334354
335-
L2->>PN: any event
355+
T->>PN: checkEpochExpiry() (every poll interval)
336356
PN->>L2: getSyncedL2SlotNumber()
337357
PN->>PN: latestEpoch = getEpochAtSlot(latestSlot)
338358
PN->>PN: newlyExpiredUpTo = latestEpoch - (proofSubmissionEpochs + 1)
339359
loop for each newly-expired epoch
340-
PN->>L2: getCheckpointsData({epoch}) + getBlocks(...)
360+
PN->>L2: getBlocks({epoch, onlyCheckpointed})
341361
PN->>CC: releaseForBlocks(blocks)
342362
PN->>CS: reapExpired(epoch)
343363
end
344364
```
345365

346-
Expiry runs at the end of every `handleBlockStreamEvent` call (not on any specific
347-
event type). An epoch `E` is expired once the chain reaches the start of epoch
348-
`E + proofSubmissionEpochs + 1` — the deadline beyond which an L1 submission for
349-
`E` would be rejected. A monotonic high-water mark (`lastExpiredEpoch`) makes the
350-
sweep cheap: it advances per event and never revisits an epoch. It is seeded at
351-
`start()` from the last fully-proven epoch (computed in `computeStartupState`),
352-
so on a restart we never re-sweep epochs that already reached L1.
366+
The sweep is driven solely by the `expiryTicker` (a `RunningPromise` armed in `start()`), which
367+
calls `checkEpochExpiry()` every poll interval whether or not block-stream events arrive. It is
368+
deliberately **not** run from `handleBlockStreamEvent` — expiry is a background sweep keyed off the
369+
archiver's synced slot, not part of event processing — and because a `RunningPromise` never overlaps
370+
its own runs, no two sweeps can race. An epoch `E` is expired once the chain reaches the start of epoch
371+
`E + proofSubmissionEpochs + 1` — the deadline beyond which an L1 submission for `E` would be rejected.
372+
Expiry only releases the chonk cache and reaps `E`'s provers.
373+
A monotonic high-water mark (`lastExpiredEpoch`) makes the sweep cheap: it only
374+
advances after a sweep completes and never revisits an epoch. It is seeded at `start()` from the last
375+
fully-proven epoch (`resolveLastFullyProvenEpoch`), so on a restart we never re-sweep epochs that already
376+
reached L1.
353377

354378
### Periodic tick
355379

356380
`SessionManager.start()` arms a `RunningPromise` that fires
357381
`reconcile({ kind: 'tick' })` every `tickIntervalMs`. The tick picks up epochs that
358382
became complete by time alone (no fresh checkpoint event) and advances to the
359-
next unproven epoch once the previous one lands on L1. A monotonic high-water
360-
mark (`lastTickEpoch`) prevents the tick from re-opening an epoch whose `EpochSession`
361-
already terminated; the mark advances only after an `EpochSession` actually exists for
362-
the epoch, so transient blockers (max-pending-jobs reached, archiver still
363-
indexing) leave the mark in place and the next tick retries.
383+
next unproven epoch once the previous one lands on L1. The tick is not gated by any per-epoch
384+
bookkeeping; two things keep it from re-proving a doomed epoch:
385+
386+
- `openFullSessionIfReady` refuses to build a session when any `CheckpointProver` in the set has
387+
**failed** (`isFailed()`). So a prover fault (possibly a prune) skips the epoch cheaply each tick —
388+
no session, no proving — until a prune/re-add installs a fresh prover.
389+
- A session that failed on its own account (`hasFailed()` — top-tree/submit failed with every prover
390+
healthy) is **retained** by `recreateInvalidSessions` rather than deleted, so the tick's
391+
`fullSessions.has(epoch)` check skips it. It is replaced only when its canonical content changes (a
392+
re-add), so a deterministically-failing epoch is not re-proved every tick.
393+
394+
Transient blockers (max-pending-jobs reached, archiver still indexing) create no session and no failed
395+
prover, so the next tick simply tries again.
364396

365397
## Walkthroughs
366398

@@ -467,6 +499,20 @@ by keeping the prover around. Removing it on prune and rebuilding on re-add is
467499
correct and simpler; the expensive block-rollup proofs are still reused when content re-appears,
468500
because the proving broker is content-addressed independently of the prover's lifetime.
469501

502+
### How is a genuine failure told apart from a prune, without a racy classification?
503+
504+
Knowledge of an L1 reorg reaches the prover-node on two causally unordered channels: the control
505+
plane (`chain-pruned``onPrune`) and the data plane (world-state unwinds, and an in-flight fork
506+
read faults inside a `CheckpointProver` mid-proof). If the `EpochSession` reacted to a fault by asking
507+
"was this a prune or a genuine failure?", every answer would sample control-plane state that lags the
508+
data-plane unwind — racy by construction. We remove the question by pushing the fact down to where it
509+
is unambiguous: a `CheckpointProver` records `isFailed()` when *its* block proofs reject. The session
510+
then reads a clean signal: if any prover failed, the fault might be a prune (`stopped`) — don't upload,
511+
don't rebuild over it, recover on re-add; if *no* prover failed yet the session's own top-tree/submit
512+
work failed, that is definitively **not** a prune (`failed`) — a genuine, race-free failure that is
513+
retained (so a deterministic failure isn't re-proved every tick) and uploaded once. No control-plane
514+
sampling, no timing race — the two prover-level and session-level facts carry the whole decision.
515+
470516
## Configuration
471517

472518
| Env var | Description |
@@ -475,7 +521,7 @@ because the proving broker is content-addressed independently of the prover's li
475521
| `PROVER_NODE_MAX_PENDING_JOBS` | Cap on the number of non-terminal `EpochSession`s (full + partial). When at limit, reconcile defers opening new full `EpochSession`s; explicit `startProof` calls throw. |
476522
| `PROVER_NODE_EPOCH_PROVING_DELAY_MS` | Optional sleep at the start of each `EpochSession`, before the TopTreeJob is constructed. Used in tests to give late events time to land. |
477523
| `TX_GATHERING_TIMEOUT_MS` | Per-block tx gather deadline used by each `CheckpointProver`. |
478-
| `PROVER_NODE_FAILED_EPOCH_STORE` | If set, failed `EpochSession`s upload their proving data (every `CheckpointProver`'s txs + register-time data, regardless of sub-tree completion) to this file store. |
524+
| `PROVER_NODE_FAILED_EPOCH_STORE` | If set, a full session that fails on its own account (top-tree/submit, provers healthy) uploads its proving data (every `CheckpointProver`'s txs + register-time data, regardless of sub-tree completion) to this file store. |
479525
| `PROVER_NODE_DISABLE_PROOF_PUBLISH` | If true, the publishing service runs `analyzeEpochProofSubmission` (estimates L1 fees) instead of actually submitting. |
480526

481527
## Failure handling and observability
@@ -493,14 +539,29 @@ Loggers:
493539
- `prover-node:checkpoint-prover` — sub-tree pipeline (gather, block processing).
494540
- `prover-client:chonk-cache` — chonk-verifier cache enqueue / release events.
495541

496-
On `failed` exit, `SessionManager.runSession` invokes the `onSessionFailed` callback
497-
the manager was constructed with. `ProverNode` wires this to `tryUploadSessionFailure`,
498-
which calls `SessionManager.buildSessionProvingData(session)` to walk every `CheckpointProver`
499-
referenced by the `EpochSession` and assemble an `EpochProvingJobData` snapshot — including
500-
every `CheckpointProver`'s txs and register-time data even if its sub-tree never reached
501-
`isCompleted()`. This snapshot is what `uploadEpochProofFailure` ships to the
502-
configured file store along with a world-state + archiver backup, so the failure
503-
can be reproduced offline via `rerunEpochProvingJob`.
542+
Failed proving data is uploaded to the configured file store (`PROVER_NODE_FAILED_EPOCH_STORE`) at two
543+
levels, and re-proved offline by the matching downloader/rerunner: an epoch job via
544+
`downloadEpochProvingJob` + `rerunEpochProvingJob`, and a single checkpoint via the same download plus
545+
`rerunCheckpointProvingJob` (which rebuilds just that checkpoint's sub-tree prover and awaits its block
546+
proofs — no epoch top-tree or L1 submit). Both upload levels build an `EpochProvingJobData` snapshot with
547+
`SessionManager.buildProvingData(...)` — every `CheckpointProver`'s txs and register-time data, even if
548+
its sub-tree never reached `isCompleted()` — and ship it via `uploadEpochProofFailure` alongside a
549+
world-state + archiver backup:
550+
551+
- **Per checkpoint.** When a `CheckpointProver`'s block proofs reject for a non-cancel reason (a sub-tree
552+
fault or a prune-induced fork fault), it fires its `onFailed` callback and `ProverNode` uploads that one
553+
checkpoint via `tryUploadCheckpointFailure(prover)`. This fires for prune-induced faults too — that is
554+
intentional; a prune-caused checkpoint snapshot is harmless, and *not* trying to tell prune from genuine
555+
failure is exactly what keeps this race-free. A cancelled prover (control-plane prune / shutdown) is not
556+
a failure and does not upload.
557+
- **Per session.** When a full session ends in its own genuine failure (`EpochSession.hasFailed()`
558+
top-tree/submit failed with every prover healthy), the session manager fires `onSessionFailed` and
559+
`ProverNode` uploads all the session's checkpoints via `tryUploadEpochFailure(...)`. This is race-free
560+
(healthy provers rule out a prune) and fires once, since the failed session is retained and never re-run.
561+
562+
A `stopped` session (a prover under it failed) does not upload at the session level — its failed checkpoint
563+
already uploaded itself. Each backup is a full world-state + archiver snapshot, so a prune that faults
564+
several in-flight provers produces several uploads; this is accepted for the diagnostic value.
504565

505566
Metrics emitted by `EpochSession`s:
506567

0 commit comments

Comments
 (0)