@aztec/prover-node 0.0.1-commit.2ed92850 → 0.0.1-commit.3100065
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +511 -0
- package/dest/actions/download-epoch-proving-job.js +1 -1
- package/dest/actions/rerun-epoch-proving-job.d.ts +5 -4
- package/dest/actions/rerun-epoch-proving-job.d.ts.map +1 -1
- package/dest/actions/rerun-epoch-proving-job.js +102 -22
- package/dest/actions/upload-epoch-proof-failure.d.ts +2 -2
- package/dest/actions/upload-epoch-proof-failure.d.ts.map +1 -1
- package/dest/bin/run-failed-epoch.js +6 -5
- package/dest/checkpoint-store.d.ts +88 -0
- package/dest/checkpoint-store.d.ts.map +1 -0
- package/dest/checkpoint-store.js +169 -0
- package/dest/config.d.ts +5 -8
- package/dest/config.d.ts.map +1 -1
- package/dest/config.js +17 -20
- package/dest/factory.d.ts +19 -13
- package/dest/factory.d.ts.map +1 -1
- package/dest/factory.js +40 -65
- package/dest/index.d.ts +2 -1
- package/dest/index.d.ts.map +1 -1
- package/dest/index.js +1 -0
- package/dest/job/checkpoint-prover.d.ts +124 -0
- package/dest/job/checkpoint-prover.d.ts.map +1 -0
- package/dest/job/checkpoint-prover.js +330 -0
- package/dest/job/epoch-session.d.ts +146 -0
- package/dest/job/epoch-session.d.ts.map +1 -0
- package/dest/job/{epoch-proving-job.js → epoch-session.js} +277 -295
- package/dest/job/top-tree-job.d.ts +82 -0
- package/dest/job/top-tree-job.d.ts.map +1 -0
- package/dest/job/top-tree-job.js +152 -0
- package/dest/metrics.d.ts +40 -3
- package/dest/metrics.d.ts.map +1 -1
- package/dest/metrics.js +112 -7
- package/dest/monitors/epoch-monitor.d.ts +1 -1
- package/dest/monitors/epoch-monitor.d.ts.map +1 -1
- package/dest/monitors/epoch-monitor.js +11 -9
- package/dest/proof-publishing-service.d.ts +161 -0
- package/dest/proof-publishing-service.d.ts.map +1 -0
- package/dest/proof-publishing-service.js +335 -0
- package/dest/prover-node-publisher.d.ts +25 -17
- package/dest/prover-node-publisher.d.ts.map +1 -1
- package/dest/prover-node-publisher.js +201 -63
- package/dest/prover-node.d.ts +118 -70
- package/dest/prover-node.d.ts.map +1 -1
- package/dest/prover-node.js +487 -226
- package/dest/prover-publisher-factory.d.ts +7 -5
- package/dest/prover-publisher-factory.d.ts.map +1 -1
- package/dest/prover-publisher-factory.js +7 -5
- package/dest/session-manager.d.ts +158 -0
- package/dest/session-manager.d.ts.map +1 -0
- package/dest/session-manager.js +482 -0
- package/dest/test/index.d.ts +7 -6
- package/dest/test/index.d.ts.map +1 -1
- package/package.json +23 -22
- package/src/actions/download-epoch-proving-job.ts +1 -1
- package/src/actions/rerun-epoch-proving-job.ts +115 -28
- package/src/actions/upload-epoch-proof-failure.ts +1 -1
- package/src/bin/run-failed-epoch.ts +5 -3
- package/src/checkpoint-store.ts +194 -0
- package/src/config.ts +25 -32
- package/src/factory.ts +68 -111
- package/src/index.ts +1 -0
- package/src/job/checkpoint-prover.ts +442 -0
- package/src/job/epoch-session.ts +436 -0
- package/src/job/top-tree-job.ts +227 -0
- package/src/metrics.ts +129 -12
- package/src/monitors/epoch-monitor.ts +5 -6
- package/src/proof-publishing-service.ts +427 -0
- package/src/prover-node-publisher.ts +236 -80
- package/src/prover-node.ts +562 -254
- package/src/prover-publisher-factory.ts +16 -10
- package/src/session-manager.ts +583 -0
- package/src/test/index.ts +6 -6
- package/dest/job/epoch-proving-job.d.ts +0 -62
- package/dest/job/epoch-proving-job.d.ts.map +0 -1
- package/src/job/epoch-proving-job.ts +0 -430
|
@@ -1,27 +1,29 @@
|
|
|
1
1
|
import type { RollupContract } from '@aztec/ethereum/contracts';
|
|
2
2
|
import type { L1TxUtils } from '@aztec/ethereum/l1-tx-utils';
|
|
3
3
|
import type { PublisherManager } from '@aztec/ethereum/publisher-manager';
|
|
4
|
-
import type {
|
|
4
|
+
import type { LoggerBindings } from '@aztec/foundation/log';
|
|
5
|
+
import type { ProverPublisherConfig, ProverTxSenderConfig } from '@aztec/sequencer-client';
|
|
5
6
|
import type { TelemetryClient } from '@aztec/telemetry-client';
|
|
6
7
|
|
|
7
8
|
import { ProverNodePublisher } from './prover-node-publisher.js';
|
|
8
9
|
|
|
9
10
|
export class ProverPublisherFactory {
|
|
10
11
|
constructor(
|
|
11
|
-
private config:
|
|
12
|
+
private config: ProverTxSenderConfig & ProverPublisherConfig,
|
|
12
13
|
private deps: {
|
|
13
14
|
rollupContract: RollupContract;
|
|
14
15
|
publisherManager: PublisherManager<L1TxUtils>;
|
|
15
16
|
telemetry?: TelemetryClient;
|
|
16
17
|
},
|
|
18
|
+
private bindings?: LoggerBindings,
|
|
17
19
|
) {}
|
|
18
20
|
|
|
19
21
|
public async start() {
|
|
20
|
-
await this.deps.publisherManager.
|
|
22
|
+
await this.deps.publisherManager.start();
|
|
21
23
|
}
|
|
22
24
|
|
|
23
|
-
public stop() {
|
|
24
|
-
this.deps.publisherManager.
|
|
25
|
+
public async stop() {
|
|
26
|
+
await this.deps.publisherManager.stop();
|
|
25
27
|
}
|
|
26
28
|
|
|
27
29
|
/**
|
|
@@ -30,10 +32,14 @@ export class ProverPublisherFactory {
|
|
|
30
32
|
*/
|
|
31
33
|
public async create(): Promise<ProverNodePublisher> {
|
|
32
34
|
const l1Publisher = await this.deps.publisherManager.getAvailablePublisher();
|
|
33
|
-
return new ProverNodePublisher(
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
35
|
+
return new ProverNodePublisher(
|
|
36
|
+
this.config,
|
|
37
|
+
{
|
|
38
|
+
rollupContract: this.deps.rollupContract,
|
|
39
|
+
l1TxUtils: l1Publisher,
|
|
40
|
+
telemetry: this.deps.telemetry,
|
|
41
|
+
},
|
|
42
|
+
this.bindings,
|
|
43
|
+
);
|
|
38
44
|
}
|
|
39
45
|
}
|
|
@@ -0,0 +1,583 @@
|
|
|
1
|
+
import { BlockNumber, type EpochNumber } from '@aztec/foundation/branded-types';
|
|
2
|
+
import { Fr } from '@aztec/foundation/curves/bn254';
|
|
3
|
+
import type { EthAddress } from '@aztec/foundation/eth-address';
|
|
4
|
+
import { type Logger, type LoggerBindings, createLogger } from '@aztec/foundation/log';
|
|
5
|
+
import { SerialQueue } from '@aztec/foundation/queue';
|
|
6
|
+
import { RunningPromise } from '@aztec/foundation/running-promise';
|
|
7
|
+
import type { DateProvider } from '@aztec/foundation/timer';
|
|
8
|
+
import type { EpochProverFactory } from '@aztec/prover-client';
|
|
9
|
+
import type { L2BlockSource } from '@aztec/stdlib/block';
|
|
10
|
+
import type { PublishedCheckpoint } from '@aztec/stdlib/checkpoint';
|
|
11
|
+
import {
|
|
12
|
+
type L1RollupConstants,
|
|
13
|
+
getEpochAtSlot,
|
|
14
|
+
getProofSubmissionDeadlineTimestamp,
|
|
15
|
+
getSlotRangeForEpoch,
|
|
16
|
+
} from '@aztec/stdlib/epoch-helpers';
|
|
17
|
+
import type { EpochProvingJobState } from '@aztec/stdlib/interfaces/server';
|
|
18
|
+
|
|
19
|
+
import type { CheckpointStore } from './checkpoint-store.js';
|
|
20
|
+
import { CheckpointProver } from './job/checkpoint-prover.js';
|
|
21
|
+
import type { EpochProvingJobData } from './job/epoch-proving-job-data.js';
|
|
22
|
+
import {
|
|
23
|
+
EpochSession,
|
|
24
|
+
type EpochSessionDeps,
|
|
25
|
+
type EpochSessionHooks,
|
|
26
|
+
type EpochSessionOptions,
|
|
27
|
+
type SessionSpec,
|
|
28
|
+
specKey,
|
|
29
|
+
} from './job/epoch-session.js';
|
|
30
|
+
import type { ProverNodeJobMetrics } from './metrics.js';
|
|
31
|
+
import type { ProofPublishingService } from './proof-publishing-service.js';
|
|
32
|
+
|
|
33
|
+
/** Trigger payload for `reconcile`. */
|
|
34
|
+
export type ReconcileTrigger =
|
|
35
|
+
| { kind: 'checkpoint'; epoch: EpochNumber }
|
|
36
|
+
| { kind: 'prune'; affectedEpochs: EpochNumber[] }
|
|
37
|
+
| { kind: 'tick' }
|
|
38
|
+
| { kind: 'start-proof'; spec: SessionSpec };
|
|
39
|
+
|
|
40
|
+
/** Config bag for session lifecycle decisions. */
|
|
41
|
+
export type SessionManagerConfig = {
|
|
42
|
+
/** Cap on the number of non-terminal sessions (full + partial). 0 disables. */
|
|
43
|
+
maxPendingJobs: number;
|
|
44
|
+
/** Interval at which the internal periodic tick fires `reconcile({ kind: 'tick' })`. */
|
|
45
|
+
tickIntervalMs: number;
|
|
46
|
+
/** Forwarded to every session: delay before top-tree proving, letting late reorgs settle. */
|
|
47
|
+
finalizationDelayMs: number | undefined;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export type SessionManagerDeps = {
|
|
51
|
+
checkpointStore: CheckpointStore;
|
|
52
|
+
l2BlockSource: Pick<
|
|
53
|
+
L2BlockSource,
|
|
54
|
+
'isEpochComplete' | 'getCheckpoints' | 'getL1Constants' | 'getBlockNumber' | 'getBlockData'
|
|
55
|
+
>;
|
|
56
|
+
proverFactory: EpochProverFactory;
|
|
57
|
+
proverId: EthAddress;
|
|
58
|
+
publishingService: ProofPublishingService;
|
|
59
|
+
metrics: ProverNodeJobMetrics;
|
|
60
|
+
dateProvider: DateProvider;
|
|
61
|
+
config: SessionManagerConfig;
|
|
62
|
+
/**
|
|
63
|
+
* Optional callback fired when a session terminates with `failed`. The session manager
|
|
64
|
+
* doesn't own the failure-upload action; it just notifies the owner.
|
|
65
|
+
*/
|
|
66
|
+
onSessionFailed?: (session: EpochSession) => Promise<void>;
|
|
67
|
+
bindings?: LoggerBindings;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Owns the lifecycle of every `EpochSession`. Each L2BlockStream event and periodic tick
|
|
72
|
+
* arrives via a dedicated entry point (`onCheckpointAdded`, `onPrune`, `onTick`, etc.) which
|
|
73
|
+
* schedules a `reconcile(trigger)` on a serial queue. Reconcile walks both session
|
|
74
|
+
* maps, cancels any session whose canonical content has shifted, re-creates it with
|
|
75
|
+
* the same spec but new content, and opens fresh full sessions for any epoch implicated
|
|
76
|
+
* by the trigger.
|
|
77
|
+
*/
|
|
78
|
+
export class SessionManager {
|
|
79
|
+
private readonly log: Logger;
|
|
80
|
+
private readonly fullSessions: Map<EpochNumber, EpochSession> = new Map();
|
|
81
|
+
private readonly partialSessions: Map<string, EpochSession> = new Map();
|
|
82
|
+
/**
|
|
83
|
+
* Serialises every reconcile call. The trigger sources (L2BlockStream events, the
|
|
84
|
+
* periodic tick, JSON-RPC `startProof`) run independently, so without this queue two
|
|
85
|
+
* reconciles could interleave on the `await session.cancel(...)` step and orphan a
|
|
86
|
+
* freshly-constructed session.
|
|
87
|
+
*/
|
|
88
|
+
private readonly reconcileQueue = new SerialQueue();
|
|
89
|
+
/** Cached L1 constants, populated on first read. */
|
|
90
|
+
private cachedL1Constants: L1RollupConstants | undefined;
|
|
91
|
+
/**
|
|
92
|
+
* Highest epoch for which the periodic tick has successfully created a full session.
|
|
93
|
+
* Monotonic high-water mark: once the tick observes a session for epoch X, it stops
|
|
94
|
+
* trying to open one — even if that session subsequently fails (only a new checkpoint
|
|
95
|
+
* event reopens it). Crucially, the mark only advances when a session actually exists
|
|
96
|
+
* post-open, so transient blockers (atMaxSessionLimit, archiver still indexing) leave
|
|
97
|
+
* the mark in place and the next tick retries.
|
|
98
|
+
*/
|
|
99
|
+
private lastTickEpoch: EpochNumber | undefined;
|
|
100
|
+
/** Test-only hooks applied to every session this manager constructs. */
|
|
101
|
+
private sessionHooks: EpochSessionHooks | undefined;
|
|
102
|
+
/** Periodic tick that nudges reconcile to pick up newly-complete epochs. Started by `start()`. */
|
|
103
|
+
private epochTicker: RunningPromise | undefined;
|
|
104
|
+
|
|
105
|
+
constructor(private readonly deps: SessionManagerDeps) {
|
|
106
|
+
this.log = createLogger('prover-node:session-manager', deps.bindings);
|
|
107
|
+
this.reconcileQueue.start();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Starts the periodic tick. Separated from the constructor so tests can drive `onTick()`
|
|
112
|
+
* manually without the background ticker interleaving. Idempotent.
|
|
113
|
+
*/
|
|
114
|
+
public start(): void {
|
|
115
|
+
if (this.epochTicker) {
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
this.epochTicker = new RunningPromise(() => this.onTick(), this.log, this.deps.config.tickIntervalMs);
|
|
119
|
+
this.epochTicker.start();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Installs hooks applied to every session constructed from now on. Used by the e2e
|
|
124
|
+
* harness to interpose around top-tree proving (gate it, override it, observe it)
|
|
125
|
+
* without monkey-patching the orchestrator factory.
|
|
126
|
+
*/
|
|
127
|
+
public setSessionHooks(hooks: EpochSessionHooks): void {
|
|
128
|
+
this.sessionHooks = hooks;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ---------------- read-only views ----------------
|
|
132
|
+
|
|
133
|
+
/** Every live (non-terminal) session. */
|
|
134
|
+
public allSessions(): EpochSession[] {
|
|
135
|
+
return [...this.fullSessions.values(), ...this.partialSessions.values()];
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Returns the full session for `epoch`, if any. */
|
|
139
|
+
public getFullSession(epoch: EpochNumber): EpochSession | undefined {
|
|
140
|
+
return this.fullSessions.get(epoch);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Returns the partial session for `spec`, if any. */
|
|
144
|
+
public getPartialSession(spec: SessionSpec): EpochSession | undefined {
|
|
145
|
+
return this.partialSessions.get(specKey(spec));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Observability summary used by the prover-node API. */
|
|
149
|
+
public getJobs(): { uuid: string; status: EpochProvingJobState; epochNumber: EpochNumber }[] {
|
|
150
|
+
return this.allSessions().map(s => ({
|
|
151
|
+
uuid: s.getId(),
|
|
152
|
+
status: s.getState(),
|
|
153
|
+
epochNumber: s.getEpochNumber(),
|
|
154
|
+
}));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ---------------- event entry points ----------------
|
|
158
|
+
|
|
159
|
+
/** Called by ProverNode after a chain-checkpointed event has been added to the store. */
|
|
160
|
+
public onCheckpointAdded(epoch: EpochNumber): Promise<void> {
|
|
161
|
+
return this.scheduleReconcile({ kind: 'checkpoint', epoch });
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Called by ProverNode after a chain-pruned event has flipped store provers to pruned. */
|
|
165
|
+
public onPrune(affectedEpochs: EpochNumber[]): Promise<void> {
|
|
166
|
+
return this.scheduleReconcile({ kind: 'prune', affectedEpochs });
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Called periodically by ProverNode's ticker. Picks up epochs that have become complete
|
|
171
|
+
* by time without a fresh checkpoint event (e.g. the epoch's last slots are empty), and
|
|
172
|
+
* advances to the next epoch once the previous one is proven on L1.
|
|
173
|
+
*/
|
|
174
|
+
public onTick(): Promise<void> {
|
|
175
|
+
return this.scheduleReconcile({ kind: 'tick' });
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ---------------- public API ----------------
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Schedules a proof attempt for the supplied epoch and returns the job id without waiting for
|
|
182
|
+
* the proof to complete — proving can far outlast an HTTP request, so callers poll `getJobs()`
|
|
183
|
+
* for the outcome. Every session — full or partial — begins at the epoch's first slot; the
|
|
184
|
+
* partial's spec stops at the last canonical slot, while the full's stops at the epoch's last
|
|
185
|
+
* slot. Dedupes against any existing session covering the same range, returning its id.
|
|
186
|
+
*/
|
|
187
|
+
public async startProof(epoch: EpochNumber): Promise<string> {
|
|
188
|
+
const canonical = await this.deps.checkpointStore.listForEpoch(epoch);
|
|
189
|
+
if (canonical.length === 0) {
|
|
190
|
+
throw new EmptyEpochError(epoch);
|
|
191
|
+
}
|
|
192
|
+
// Don't re-prove an epoch the L1 proven chain already encompasses — it was already proven
|
|
193
|
+
// (possibly by another prover node), so a fresh proof would be wasted work.
|
|
194
|
+
if (await this.isProvenChainEncompassing(canonical)) {
|
|
195
|
+
throw new EpochAlreadyProvenError(epoch);
|
|
196
|
+
}
|
|
197
|
+
const l1Constants = await this.getL1Constants();
|
|
198
|
+
const [fromSlot] = getSlotRangeForEpoch(epoch, l1Constants);
|
|
199
|
+
const toSlot = canonical[canonical.length - 1].slotNumber;
|
|
200
|
+
const spec: SessionSpec = { kind: 'partial', epochNumber: epoch, fromSlot, toSlot };
|
|
201
|
+
|
|
202
|
+
// Reuse a session already covering this exact range rather than scheduling a duplicate.
|
|
203
|
+
const existingFull = this.getFullSession(epoch);
|
|
204
|
+
if (
|
|
205
|
+
existingFull &&
|
|
206
|
+
!existingFull.isTerminal() &&
|
|
207
|
+
existingFull.getSpec().fromSlot === fromSlot &&
|
|
208
|
+
existingFull.getSpec().toSlot === toSlot
|
|
209
|
+
) {
|
|
210
|
+
return existingFull.getId();
|
|
211
|
+
}
|
|
212
|
+
const existingPartial = this.getPartialSession(spec);
|
|
213
|
+
if (existingPartial && !existingPartial.isTerminal()) {
|
|
214
|
+
return existingPartial.getId();
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
await this.scheduleReconcile({ kind: 'start-proof', spec });
|
|
218
|
+
const created = this.getPartialSession(spec);
|
|
219
|
+
if (!created) {
|
|
220
|
+
throw new Error(`Failed to schedule partial proof for epoch ${epoch}`);
|
|
221
|
+
}
|
|
222
|
+
return created.getId();
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Stops the tick, drains the reconcile queue, and cancels every live session. */
|
|
226
|
+
public async stop(): Promise<void> {
|
|
227
|
+
await this.epochTicker?.stop();
|
|
228
|
+
await this.reconcileQueue.cancel();
|
|
229
|
+
const sessions = this.allSessions();
|
|
230
|
+
await Promise.allSettled(sessions.map(s => s.cancel('prover-node stopping')));
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ---------------- reconcile ----------------
|
|
234
|
+
|
|
235
|
+
private scheduleReconcile(trigger: ReconcileTrigger): Promise<void> {
|
|
236
|
+
return this.reconcileQueue.put(() => this.reconcile(trigger));
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
private async reconcile(trigger: ReconcileTrigger): Promise<void> {
|
|
240
|
+
this.log.debug(`Reconciling`, { trigger });
|
|
241
|
+
|
|
242
|
+
this.recreateInvalidSessions();
|
|
243
|
+
|
|
244
|
+
const implicatedEpochs = await this.epochsForTrigger(trigger);
|
|
245
|
+
for (const epoch of implicatedEpochs) {
|
|
246
|
+
await this.openFullSessionIfReady(epoch);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Advance the tick high-water mark only once a session actually exists for the epoch.
|
|
250
|
+
// `openFullSessionIfReady` can early-return without creating one (atMaxSessionLimit,
|
|
251
|
+
// archiver still indexing, etc.); in those cases we want the next tick to try again
|
|
252
|
+
// rather than skip the epoch forever.
|
|
253
|
+
if (trigger.kind === 'tick' && implicatedEpochs.length === 1) {
|
|
254
|
+
const epoch = implicatedEpochs[0];
|
|
255
|
+
if (this.fullSessions.has(epoch)) {
|
|
256
|
+
this.lastTickEpoch = epoch;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (trigger.kind === 'start-proof') {
|
|
261
|
+
this.openPartialSession(trigger.spec);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
private recreateInvalidSessions(): void {
|
|
266
|
+
for (const [key, session] of Array.from(this.fullSessions.entries())) {
|
|
267
|
+
if (session.isTerminal()) {
|
|
268
|
+
this.fullSessions.delete(key);
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
const canonical = this.checkpointsForSpec(session.getSpec());
|
|
272
|
+
if (!this.checkpointsMatch(session.getCheckpoints(), canonical)) {
|
|
273
|
+
this.fireAndForgetCancel(session, 'canonical content changed');
|
|
274
|
+
this.fullSessions.delete(key);
|
|
275
|
+
if (canonical.length > 0) {
|
|
276
|
+
const newSession = this.constructSession(session.getSpec(), canonical);
|
|
277
|
+
this.fullSessions.set(key, newSession);
|
|
278
|
+
void this.runSession(newSession);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
for (const [key, session] of Array.from(this.partialSessions.entries())) {
|
|
283
|
+
if (session.isTerminal()) {
|
|
284
|
+
this.partialSessions.delete(key);
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
const canonical = this.checkpointsForSpec(session.getSpec());
|
|
288
|
+
if (!this.checkpointsMatch(session.getCheckpoints(), canonical)) {
|
|
289
|
+
this.fireAndForgetCancel(session, 'canonical content changed');
|
|
290
|
+
this.partialSessions.delete(key);
|
|
291
|
+
if (canonical.length > 0) {
|
|
292
|
+
const newSession = this.constructSession(session.getSpec(), canonical);
|
|
293
|
+
this.partialSessions.set(key, newSession);
|
|
294
|
+
void this.runSession(newSession);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
private async openFullSessionIfReady(epoch: EpochNumber): Promise<void> {
|
|
301
|
+
// `recreateInvalidSessions` runs at the top of every reconcile and deletes terminal sessions
|
|
302
|
+
// before this is called, so a session present here is live and already covers the epoch.
|
|
303
|
+
if (this.fullSessions.has(epoch)) {
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
if (this.atMaxSessionLimit()) {
|
|
307
|
+
this.log.debug(`Skipping full-session open for epoch ${epoch}: max pending jobs reached`);
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
if (!(await this.deps.l2BlockSource.isEpochComplete(epoch))) {
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
const l1Constants = await this.getL1Constants();
|
|
314
|
+
const archiverCps = await this.deps.l2BlockSource.getCheckpoints({ epoch });
|
|
315
|
+
if (archiverCps.length === 0) {
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
const [fromSlot, toSlot] = getSlotRangeForEpoch(epoch, l1Constants);
|
|
319
|
+
const canonical = this.deps.checkpointStore.listInSlotRange(fromSlot, toSlot);
|
|
320
|
+
if (!this.archiverFullyCovered(archiverCps, canonical)) {
|
|
321
|
+
this.log.debug(`Skipping full-session open for epoch ${epoch}: archiver checkpoints not all in store`, {
|
|
322
|
+
archiverCount: archiverCps.length,
|
|
323
|
+
storeCount: canonical.length,
|
|
324
|
+
});
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
const spec: SessionSpec = { kind: 'full', epochNumber: epoch, fromSlot, toSlot };
|
|
328
|
+
const session = this.constructSession(spec, canonical);
|
|
329
|
+
this.fullSessions.set(epoch, session);
|
|
330
|
+
void this.runSession(session);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
private openPartialSession(spec: SessionSpec): void {
|
|
334
|
+
const canonical = this.deps.checkpointStore.listInSlotRange(spec.fromSlot, spec.toSlot);
|
|
335
|
+
if (canonical.length === 0) {
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
// Reuse a live partial session for this epoch whose checkpoint set already matches the
|
|
339
|
+
// canonical content — e.g. a repeated `startProof` with no new checkpoints mined since the
|
|
340
|
+
// last one. Reconstructing would re-prove identical content and burn a pending-job slot.
|
|
341
|
+
const existing = Array.from(this.partialSessions.values()).find(
|
|
342
|
+
s =>
|
|
343
|
+
s.getSpec().epochNumber === spec.epochNumber &&
|
|
344
|
+
!s.isTerminal() &&
|
|
345
|
+
this.checkpointsMatch(s.getCheckpoints(), canonical),
|
|
346
|
+
);
|
|
347
|
+
if (existing) {
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
if (this.atMaxSessionLimit()) {
|
|
351
|
+
throw new Error(`Maximum pending proving jobs ${this.deps.config.maxPendingJobs} reached.`);
|
|
352
|
+
}
|
|
353
|
+
const session = this.constructSession(spec, canonical);
|
|
354
|
+
this.partialSessions.set(specKey(spec), session);
|
|
355
|
+
void this.runSession(session);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// ---------------- session construction ----------------
|
|
359
|
+
|
|
360
|
+
protected constructSession(spec: SessionSpec, checkpoints: readonly CheckpointProver[]): EpochSession {
|
|
361
|
+
return this.doConstructSession(spec, checkpoints, this.buildSessionDeps(spec.epochNumber), this.sessionHooks);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** Extracted for test override. */
|
|
365
|
+
protected doConstructSession(
|
|
366
|
+
spec: SessionSpec,
|
|
367
|
+
checkpoints: readonly CheckpointProver[],
|
|
368
|
+
sessionDeps: EpochSessionDeps,
|
|
369
|
+
hooks?: EpochSessionHooks,
|
|
370
|
+
): EpochSession {
|
|
371
|
+
return new EpochSession(spec, checkpoints, { ...sessionDeps, hooks });
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
private buildSessionDeps(epochNumber: EpochNumber): EpochSessionDeps {
|
|
375
|
+
const config: EpochSessionOptions = {
|
|
376
|
+
finalizationDelayMs: this.deps.config.finalizationDelayMs,
|
|
377
|
+
};
|
|
378
|
+
return {
|
|
379
|
+
proverFactory: this.deps.proverFactory,
|
|
380
|
+
proverId: this.deps.proverId,
|
|
381
|
+
publishingService: this.deps.publishingService,
|
|
382
|
+
metrics: this.deps.metrics,
|
|
383
|
+
dateProvider: this.deps.dateProvider,
|
|
384
|
+
deadline: this.computeDeadline(epochNumber),
|
|
385
|
+
config,
|
|
386
|
+
bindings: this.deps.bindings,
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
private computeDeadline(epochNumber: EpochNumber): Date | undefined {
|
|
391
|
+
if (!this.cachedL1Constants) {
|
|
392
|
+
return undefined;
|
|
393
|
+
}
|
|
394
|
+
const ts = getProofSubmissionDeadlineTimestamp(epochNumber, this.cachedL1Constants);
|
|
395
|
+
return new Date(Number(ts) * 1000);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
private async runSession(session: EpochSession): Promise<void> {
|
|
399
|
+
// A reconcile may have cancelled this session before it starts (content-change
|
|
400
|
+
// recreation). Don't proceed — start() would build a TopTreeJob that should never run.
|
|
401
|
+
if (session.isTerminal()) {
|
|
402
|
+
this.log.debug(`Skipping start for ${session.getId()}: already terminal (${session.getState()})`);
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
const state = await session.start();
|
|
406
|
+
this.log.info(`Session ${session.getId()} exited with state ${state}`);
|
|
407
|
+
if (state === 'failed' && this.deps.onSessionFailed) {
|
|
408
|
+
// Best-effort suppression of the spurious post-mortem upload a prune produces: if the session's
|
|
409
|
+
// checkpoints no longer match the store's current set, the failure was caused by the content
|
|
410
|
+
// changing under it, not a genuine proving fault, so skip the upload. This is inherently racy —
|
|
411
|
+
// the store lags the world-state unwind, so a fault observed before the prune is reconciled here
|
|
412
|
+
// still uploads. The epoch is recovered regardless by recreating the session on re-add.
|
|
413
|
+
if (!this.checkpointsMatch(session.getCheckpoints(), this.checkpointsForSpec(session.getSpec()))) {
|
|
414
|
+
this.log.info(`Skipping failure upload for session ${session.getId()}: canonical content changed`, {
|
|
415
|
+
...session.getSpec(),
|
|
416
|
+
});
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
try {
|
|
420
|
+
await this.deps.onSessionFailed(session);
|
|
421
|
+
} catch (err) {
|
|
422
|
+
this.log.error(`Error in onSessionFailed callback for ${session.getSpec().epochNumber}`, err);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* Builds the EpochProvingJobData snapshot for failure upload. Includes every checkpoint
|
|
429
|
+
* referenced by the session, regardless of whether sub-tree proving completed —
|
|
430
|
+
* partial state is still useful for post-mortem analysis.
|
|
431
|
+
*/
|
|
432
|
+
public static buildSessionProvingData(session: EpochSession): EpochProvingJobData {
|
|
433
|
+
const checkpoints = session.getCheckpoints();
|
|
434
|
+
const txs = new Map();
|
|
435
|
+
const l1ToL2Messages: Record<number, Fr[]> = {};
|
|
436
|
+
for (const c of checkpoints) {
|
|
437
|
+
for (const [hash, tx] of c.txs) {
|
|
438
|
+
txs.set(hash, tx);
|
|
439
|
+
}
|
|
440
|
+
l1ToL2Messages[c.checkpoint.number] = c.l1ToL2Messages;
|
|
441
|
+
}
|
|
442
|
+
return {
|
|
443
|
+
epochNumber: session.getSpec().epochNumber,
|
|
444
|
+
checkpoints: checkpoints.map(c => c.checkpoint),
|
|
445
|
+
txs,
|
|
446
|
+
l1ToL2Messages,
|
|
447
|
+
previousBlockHeader: checkpoints[0].previousBlockHeader,
|
|
448
|
+
attestations: [],
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// ---------------- reconcile helpers ----------------
|
|
453
|
+
|
|
454
|
+
private atMaxSessionLimit(): boolean {
|
|
455
|
+
const { maxPendingJobs: max } = this.deps.config;
|
|
456
|
+
if (!max || max <= 0) {
|
|
457
|
+
return false;
|
|
458
|
+
}
|
|
459
|
+
const live = this.allSessions().filter(s => !s.isTerminal()).length;
|
|
460
|
+
return live >= max;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Maps a reconcile trigger to the epochs whose full session should be (re)opened.
|
|
465
|
+
*
|
|
466
|
+
* This is where the "don't retry a genuinely-failed epoch, but do recover a pruned one" invariant
|
|
467
|
+
* lives — enforced by which triggers are gated by `lastTickEpoch`:
|
|
468
|
+
*
|
|
469
|
+
* - The periodic `tick` IS gated: once a tick has opened a session for an epoch, `lastTickEpoch`
|
|
470
|
+
* advances to it and later ticks skip it (`epoch <= lastTickEpoch`). So a failed attempt is never
|
|
471
|
+
* resubmitted on a loop by the tick.
|
|
472
|
+
* - `checkpoint` and `prune` are deliberately NOT gated. They only fire when the epoch's canonical
|
|
473
|
+
* content actually changes — a checkpoint arrives, or a reorg prunes/replaces one — which is
|
|
474
|
+
* exactly when re-attempting is correct.
|
|
475
|
+
*
|
|
476
|
+
* A genuine proving failure produces no content change, hence no checkpoint/prune event, so only
|
|
477
|
+
* the gated tick could reopen it — and it won't. A prune + re-add fires ungated events, so the
|
|
478
|
+
* epoch is reopened through this path (and `openFullSessionIfReady` rebuilds over the fresh
|
|
479
|
+
* provers). See the "onTick does not retry ... but recovers ... re-added" test.
|
|
480
|
+
*/
|
|
481
|
+
private async epochsForTrigger(trigger: ReconcileTrigger): Promise<EpochNumber[]> {
|
|
482
|
+
switch (trigger.kind) {
|
|
483
|
+
case 'checkpoint':
|
|
484
|
+
return [trigger.epoch];
|
|
485
|
+
case 'prune':
|
|
486
|
+
return trigger.affectedEpochs;
|
|
487
|
+
case 'tick': {
|
|
488
|
+
const epoch = await this.nextUnprovenEpoch();
|
|
489
|
+
if (epoch === undefined || (this.lastTickEpoch !== undefined && epoch <= this.lastTickEpoch)) {
|
|
490
|
+
return [];
|
|
491
|
+
}
|
|
492
|
+
return [epoch];
|
|
493
|
+
}
|
|
494
|
+
case 'start-proof':
|
|
495
|
+
return [];
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* The next epoch to prove: the epoch containing the first block after the proven tip.
|
|
501
|
+
* Returns undefined when that block has not been mined yet (e.g. nothing new to prove).
|
|
502
|
+
* Subsequent ticks advance only once the chain's proven height moves forward, so epochs
|
|
503
|
+
* are proven in order rather than all at once.
|
|
504
|
+
*/
|
|
505
|
+
private async nextUnprovenEpoch(): Promise<EpochNumber | undefined> {
|
|
506
|
+
const lastProven = (await this.deps.l2BlockSource.getBlockNumber({ tag: 'proven' })) ?? BlockNumber.ZERO;
|
|
507
|
+
const firstToProve = BlockNumber(lastProven + 1);
|
|
508
|
+
const header = (await this.deps.l2BlockSource.getBlockData({ number: firstToProve }))?.header;
|
|
509
|
+
if (!header) {
|
|
510
|
+
return undefined;
|
|
511
|
+
}
|
|
512
|
+
return getEpochAtSlot(header.getSlot(), await this.getL1Constants());
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
private checkpointsForSpec(spec: SessionSpec): CheckpointProver[] {
|
|
516
|
+
return this.deps.checkpointStore.listInSlotRange(spec.fromSlot, spec.toSlot);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
private fireAndForgetCancel(session: EpochSession, reason: string): void {
|
|
520
|
+
void session.cancel(reason).catch(err => this.log.warn(`Error cancelling session ${session.getId()}`, err));
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
private checkpointsMatch(a: readonly CheckpointProver[], b: readonly CheckpointProver[]): boolean {
|
|
524
|
+
if (a.length !== b.length) {
|
|
525
|
+
return false;
|
|
526
|
+
}
|
|
527
|
+
for (let i = 0; i < a.length; i++) {
|
|
528
|
+
if (a[i].id !== b[i].id || a[i].isCancelled()) {
|
|
529
|
+
return false;
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
return true;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
private archiverFullyCovered(
|
|
536
|
+
archiverCps: readonly PublishedCheckpoint[],
|
|
537
|
+
storeCps: readonly CheckpointProver[],
|
|
538
|
+
): boolean {
|
|
539
|
+
if (storeCps.length < archiverCps.length) {
|
|
540
|
+
return false;
|
|
541
|
+
}
|
|
542
|
+
// Compare by content-addressed id (number, slot, archive root) rather than checkpoint number:
|
|
543
|
+
// a reorg can keep the number while changing the checkpoint's post-state archive root.
|
|
544
|
+
const storeIds = new Set(storeCps.map(p => p.id));
|
|
545
|
+
return archiverCps.every(cp => storeIds.has(CheckpointProver.idFor(cp.checkpoint)));
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* Returns true if the L1 proven tip already covers every canonical checkpoint in the set — i.e.
|
|
550
|
+
* the epoch has already been fully proven, so there is no point starting a new proof for it.
|
|
551
|
+
* Conservatively returns false when nothing is proven yet.
|
|
552
|
+
*/
|
|
553
|
+
private async isProvenChainEncompassing(canonical: readonly CheckpointProver[]): Promise<boolean> {
|
|
554
|
+
const provenBlock = await this.deps.l2BlockSource.getBlockNumber({ tag: 'proven' });
|
|
555
|
+
if (!provenBlock || provenBlock <= 0) {
|
|
556
|
+
return false;
|
|
557
|
+
}
|
|
558
|
+
const lastCheckpoint = canonical[canonical.length - 1].checkpoint;
|
|
559
|
+
const lastBlock = lastCheckpoint.blocks[lastCheckpoint.blocks.length - 1].number;
|
|
560
|
+
return provenBlock >= lastBlock;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
private async getL1Constants(): Promise<L1RollupConstants> {
|
|
564
|
+
if (!this.cachedL1Constants) {
|
|
565
|
+
this.cachedL1Constants = await this.deps.l2BlockSource.getL1Constants();
|
|
566
|
+
}
|
|
567
|
+
return this.cachedL1Constants;
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
class EmptyEpochError extends Error {
|
|
572
|
+
constructor(epochNumber: EpochNumber) {
|
|
573
|
+
super(`No blocks found for epoch ${epochNumber}`);
|
|
574
|
+
this.name = 'EmptyEpochError';
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
class EpochAlreadyProvenError extends Error {
|
|
579
|
+
constructor(epochNumber: EpochNumber) {
|
|
580
|
+
super(`Epoch ${epochNumber} is already proven on L1`);
|
|
581
|
+
this.name = 'EpochAlreadyProvenError';
|
|
582
|
+
}
|
|
583
|
+
}
|
package/src/test/index.ts
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
|
+
import type { EpochProverFactory } from '@aztec/prover-client';
|
|
1
2
|
import type { EpochProverManager } from '@aztec/stdlib/interfaces/server';
|
|
2
3
|
|
|
3
|
-
import type {
|
|
4
|
-
import type { ProverNodePublisher } from '../prover-node-publisher.js';
|
|
4
|
+
import type { ProofPublishingService } from '../proof-publishing-service.js';
|
|
5
5
|
import { ProverNode } from '../prover-node.js';
|
|
6
|
+
import type { SessionManager } from '../session-manager.js';
|
|
6
7
|
|
|
7
8
|
abstract class TestProverNodeClass extends ProverNode {
|
|
8
|
-
declare public prover: EpochProverManager;
|
|
9
|
-
declare public
|
|
10
|
-
|
|
11
|
-
public abstract override tryUploadEpochFailure(job: EpochProvingJob): Promise<string | undefined>;
|
|
9
|
+
declare public prover: EpochProverManager & EpochProverFactory;
|
|
10
|
+
declare public publishingService: ProofPublishingService;
|
|
11
|
+
declare public sessionManager: SessionManager;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
14
|
export type TestProverNode = TestProverNodeClass;
|