exec sync: the plan (what happened, the fork commits, the copy measurements, the per-tier consequence) and the bench-log entry; the exec-sync harness
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
b06f7ae09c
commit
72a0a05cf4
3 changed files with 199 additions and 0 deletions
|
|
@ -1884,3 +1884,18 @@ Measurement (`/Users/joshm/Projects/igneum/tools/lock/with-lock.sh measure bash
|
|||
| v1 shard | cpu (no feature, `target/cpu`) | 20 | 10,049,917; 11; 0.31 | 585.8 (587.71) | 11; 11,534,336; 1,093,455; 390,964 | 10,583,965,696 (10.58) | 9,005.0 | 223,882 (222,668) | 0.010 | 0.21 / 0.009 to 0.012 / 32.9 MB, three times | yes |
|
||||
|
||||
Reading. (1) The same statement costs RISC Zero 10,049,917 user cycles against SP1's 4,717,439 (bench-log 5 October, the S_p curve), 2.13x, with 10.7% more in paging and reserved cycles at po2 20. (2) On this CPU the v1 shard takes 574 s at po2 20; SP1's CPU prover on the same Mac under the same lock this morning took 71.7 s for the same shard (the proof-system watch, `bench/proof-systems/rows/20261006-1146-MacBook-Pro-sp1.json`, another agent's row): 8.0x. (3) The succinct receipt is 223,882 bytes against SP1's 1,272,897-byte compressed proof, 5.7x smaller, and the same size at every po2 and for the empty shard. (4) The verify is 9 to 10 ms in process; the whole verify mode is 0.21 s, of which 0.18 to 0.19 s is `compute_image_id` over the 3.67 MB program binary at start (the SP1 host's verify mode ran 0.26 s on the watch's row). (5) Memory against the segment limit: 8.3 to 8.4 GB of RSS at po2 19 and for any one-segment shard, 10.7 GB at po2 20, 20.4 GB at po2 21; the limit is the dial, and po2 19 costs 22% more time (24 segments instead of 11). (6) The empty shard is one 2^19 segment and still 47.9 s: the per-receipt floor of segment prove plus lift to a succinct receipt on this CPU. (7) `/usr/bin/time` reports 15.7 of the 18 cores busy over the po2 20 run (9,035 s user in 575 s wall). (8) The ratio `metal` build to CPU build on the v1 shard at po2 20 is 574.4 s to 585.8 s, 0.98: the same code, run-to-run noise, as the sources say. (9) Nothing here is a GPU number: PC 2 (CUDA) measures the GPU memory and time; the Mac's Metal number does not exist in 3.0.6.
|
||||
|
||||
### 6 October 2026, 13:17Z to 13:45Z, the exec restart on a copy of node 1's data dir (Mac, `tools/lock/with-lock.sh run`)
|
||||
|
||||
`cp -c -R /tmp/igneum-devnet/node1 /tmp/igneum-devnet/node1-copy`, the exec-sync node (branch exec-sync-0313) on ports 29990+ with node 1's override file plus `exec_restart_number` 27276, `exec_restart_hash` bb45cf0d..., `exec_restart_trust_daa` 200000 (the script `node1-copy-test.sh`, every figure a RESULT line):
|
||||
|
||||
| Figure | Value |
|
||||
|---|---|
|
||||
| Chain facts | sink chain block 130,073 (later 130,272); pruning point = retention root = chain block 27,276, DAA 45,537, body held; bodies below gone |
|
||||
| Replay from 27,276 to the sink | 92 s (run 4, no trust rule), 93 s (run 5, trust rule) wall from the node's start |
|
||||
| Paid shards without the trust rule | 0 (every record vetoed by the native-statement check) |
|
||||
| Paid shards with the trust rule | 1,482, 1,825.699 IGN |
|
||||
| PC 2's payout balance at the tip | 265,359.6 IGN (run 4), 267,648.4 IGN (run 5) |
|
||||
| Persisted file | 114,375,262 B then 114,830,936 B; 31 accounts; 1,200 full records |
|
||||
| Resume after a restart | 8 s and 9 s to answer, startedFrom "snapshot", no replay |
|
||||
|
||||
|
|
|
|||
47
docs/plans/exec-sync.md
Normal file
47
docs/plans/exec-sync.md
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
# Exec sync: the executor's state after the pruning point left genesis (6 October 2026)
|
||||
|
||||
## What happened
|
||||
|
||||
The devnet's pruning point left genesis on 6 October 2026 at about 11:40Z (chain block 27,276, DAA 45,537). Every
|
||||
node pruned the block bodies and acceptance rows below it and kept the headers and ghostdag rows. The execution
|
||||
layer's state lived only in memory and was rebuilt from genesis at every start; every node had restarted for
|
||||
0.3.11 and 0.3.12. From 11:40Z every node read `eth_blockNumber` 0x0 and `igneum_getProvingStatus` active false:
|
||||
no balances, no proving work list, no payouts, mining untouched. No hand ran `--archival`, so no data dir on the
|
||||
network holds the bodies the state was built from, and the state cannot be rebuilt from genesis anywhere.
|
||||
|
||||
## The fix, fork branch `exec-sync-0313` (off the 0.3.12 node 83089544), node only
|
||||
|
||||
| Commit | What |
|
||||
|---|---|
|
||||
| 4f05e56a | the exec state persisted to the data dir every 5 minutes and at stop (two generations; resumed at start when its tip is still a chain block); `--igneum-exec-snapshot=<path>[,<sha256>]` and `igneum_exportExecSnapshot`; the loud status when the follower cannot execute (warn every minute, `igneum_getExecStatus.blocked`, the proving status's `execSync`) |
|
||||
| 3bd31a20 | the snapshot served and fetched over p2p at protocol 16 (messages 76 and 77, 1 MiB chunks, the sha256 on every chunk); a blocked executor asks one peer every 20 s; `--igneum-exec-snapshot=peer` |
|
||||
| efa6924d, 7cb712f5, 80674943 | the archival walk: the selected-parent chain read from the ghostdag store when the virtual-chain query refuses a tip below the retention root; the mergeset order rebuilt from ghostdag rows when the acceptance row is pruned |
|
||||
| 3dd9b2c9 | the exec restart: `exec_restart_number` and `exec_restart_hash` in the override object (in the digest once set): header-only records below R, the EVM state fresh at R, executed from R's body on; the status reports the pruning point and the retention root with their chain block numbers |
|
||||
| 05e93f0e | `exec_restart_trust_daa`: a shard record carried below it pays as carried, without the native-statement veto and the assignee check |
|
||||
|
||||
## Measured on a copy of node 1's data dir (the Mac, `tools/lock/with-lock.sh run`)
|
||||
|
||||
| Figure | Value |
|
||||
|---|---|
|
||||
| The pruning point and retention root, node 1 and the observer | chain block 27,276, hash bb45cf0dd2d7cc97ebfa5a2701527c09a8ede5d32de74efead9caa293b15688a, DAA 45,537; its body held on both |
|
||||
| Bodies below it | gone ("cannot find full block" at chain block 1) |
|
||||
| Replay from 27,276 to the sink (130,073, then 130,272) | 92 s and 93 s wall from the node's start, over 2,000 chain blocks a second |
|
||||
| Without the trust rule | paidShards 0: every historical record vetoed (the records attest the original state roots) |
|
||||
| With `exec_restart_trust_daa` 200,000 | paidShards 1,482, paidWei 1,825.70 IGN (node 1 read 1,261 and 1,573 at 08:30Z; the chain moved on) |
|
||||
| PC 2's payout address at the tip | 267,648 IGN |
|
||||
| The persisted file | 114,830,936 bytes, 130,273 records (1,200 full), 31 accounts |
|
||||
| Resume after a restart | 9 s, no replay |
|
||||
|
||||
## What it means
|
||||
|
||||
| Tier | Before the fix | After the cut with the three fields |
|
||||
|---|---|---|
|
||||
| Every node, home miner, rig, pool | an empty EVM since 11:40Z: no balances, no proving, no payouts; mining fine | the chain's state from chain block 27,276 on, rebuilt in about 90 s at the switch; every restart after that resumes in seconds |
|
||||
| Miners | rewards invisible | rewards since R back (PC 2: 267,648 IGN); the rewards of chain blocks 0 to 27,275 (the devnet's first 12 hours) lost, the project lead's call on seeding them |
|
||||
| Provers | payouts invisible | every carried record since proving v0 paid as carried (1,482 shards); new proofs verify against the new state from the trust DAA on |
|
||||
| Joiners today | an empty EVM for ever | IBD gives the pruning point 27,276 and the bodies from it, so a fresh node replays the same 90 s with no snapshot; once the pruning point moves past R the p2p snapshot (protocol 16) starts it |
|
||||
|
||||
## Open
|
||||
|
||||
The fresh-join time on a rented box (in progress); the trust DAA's value (at or above the switch); the first 12
|
||||
hours' rewards (the project lead); the p2p snapshot's fast-time harness case; `--archival` on at least one hand from now on.
|
||||
137
tools/exec-sync/net.mjs
Normal file
137
tools/exec-sync/net.mjs
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
#!/usr/bin/env node
|
||||
// Exec sync harness (6 October 2026): the executor's persisted state and the snapshot file, on a private fast-time
|
||||
// simnet (ports 29970+, suffix 957; never the live devnet). Cases, each asserted:
|
||||
// 1. known-finished: node A mines past N chain blocks; `igneum_exportExecSnapshot` writes a file; its tip, state
|
||||
// root and sha256 are reported; A is stopped (the state is persisted at stop) and started again: it resumes from
|
||||
// the data dir's snapshot (startedFrom "snapshot", snapshotTip = the persisted tip) and keeps executing
|
||||
// 2. a fresh node B with --igneum-exec-snapshot=<file>,<sha256> and no bodies of its own beyond what it syncs:
|
||||
// startedFrom "snapshot", snapshotTip = the file's tip, eth_blockNumber reaches A's tip, every miner balance
|
||||
// and the state root at the file's tip equal A's
|
||||
// 3. known-failed: a fresh node C with the file and a WRONG pin ignores the file (startedFrom "genesis" here, since
|
||||
// a simnet peer still serves every body) and says why in its log
|
||||
// 4. a fresh node D with the file and NO consensus data of its own but the file's tip unknown: refused with
|
||||
// "unknown to consensus" (the file's tip is from another chain: a second simnet)
|
||||
// Usage: node tools/exec-sync/net.mjs [--blocks 60]
|
||||
// IGNEUM_EXEC_BIN=<dir with igneumd and igneum-miner> (default vendor/igneum-node/target-exec-sync/release)
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync, openSync } from 'node:fs';
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
const ROOT = new URL('../../', import.meta.url).pathname;
|
||||
const REL = process.env.IGNEUM_EXEC_BIN || `${ROOT}vendor/igneum-node/target-exec-sync/release`;
|
||||
const IGNEUMD = `${REL}/igneumd`;
|
||||
const MINER = `${REL}/igneum-miner`;
|
||||
const TMP = '/tmp/igneum-exec-sync';
|
||||
const BASE = 29970, SUFFIX = 957;
|
||||
const flag = (name, d) => { const i = process.argv.indexOf(name); return i >= 0 ? Number(process.argv[i + 1]) : d; };
|
||||
const BLOCKS = flag('--blocks', 60);
|
||||
const FILE = `${ROOT}infra/fast-time/override-60x.json`;
|
||||
for (const b of [IGNEUMD, MINER]) if (!existsSync(b)) { console.error(`missing ${b}`); process.exit(2); }
|
||||
rmSync(TMP, { recursive: true, force: true }); mkdirSync(TMP, { recursive: true });
|
||||
const override = `${TMP}/override.json`;
|
||||
// the exec-sync node is cut from 0.3.12 and knows no proof-system-2 field: drop it as text (JSON.parse would turn
|
||||
// the u64::MAX "never" values into floats the node refuses)
|
||||
writeFileSync(override, readFileSync(FILE, 'utf8').replace(/\s*"proving_v2_activation_daa":\s*\d+,?/, ''));
|
||||
const t0 = Date.now();
|
||||
const log = (m) => console.log(`${new Date().toISOString().slice(11, 23)} t=${((Date.now() - t0) / 1000).toFixed(1)}s ${m}`);
|
||||
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
||||
const hexn = (h) => Number(BigInt(h));
|
||||
const started = [];
|
||||
|
||||
class Node {
|
||||
constructor(i, connect = [], extra = [], suffix = SUFFIX) {
|
||||
this.i = i; this.grpcPort = BASE + i * 10; this.p2pPort = BASE + i * 10 + 1; this.jsonPort = BASE + i * 10 + 2; this.evmPort = BASE + i * 10 + 3;
|
||||
this.connect = connect; this.extra = extra; this.suffix = suffix; this.dir = `${TMP}/n${i}`; this.logFile = `${this.dir}/node.log`;
|
||||
}
|
||||
get evm() { return `http://127.0.0.1:${this.evmPort}`; }
|
||||
async start() {
|
||||
mkdirSync(this.dir, { recursive: true });
|
||||
const a = ['--devnet', `--devnet-suffix=${this.suffix}`, '--nodnsseed', '--disable-upnp', '--nologfiles', '--enable-unsynced-mining', '--utxoindex', '--unsaferpc',
|
||||
`--appdir=${this.dir}`, `--rpclisten=127.0.0.1:${this.grpcPort}`, `--rpclisten-json=127.0.0.1:${this.jsonPort}`, `--evm-rpclisten=127.0.0.1:${this.evmPort}`,
|
||||
`--listen=127.0.0.1:${this.p2pPort}`, `--override-params-file=${override}`, '--loglevel=info', '--yes', ...this.connect.map(c => `--addpeer=${c}`), ...this.extra];
|
||||
const out = openSync(this.logFile, 'a');
|
||||
this.proc = spawn(IGNEUMD, a, { stdio: ['ignore', out, out], env: { ...process.env, IGNEUM_PROOF_VERIFY: 'trust' } });
|
||||
started.push(this.proc);
|
||||
for (let k = 0; k < 120; k++) { try { await this.eth('eth_chainId'); return this; } catch { await sleep(500); } }
|
||||
throw new Error(`node ${this.i} did not answer on ${this.evm}`);
|
||||
}
|
||||
async stop() { try { this.proc.kill('SIGINT'); } catch { } for (let k = 0; k < 60; k++) { if (this.proc.exitCode !== null) return; await sleep(500); } try { this.proc.kill('SIGKILL'); } catch { } }
|
||||
async eth(method, params = []) {
|
||||
const r = await fetch(this.evm, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }) });
|
||||
const j = await r.json(); if (j.error) throw new Error(`${method}: ${JSON.stringify(j.error)}`); return j.result;
|
||||
}
|
||||
logText() { try { return readFileSync(this.logFile, 'utf8'); } catch { return ''; } }
|
||||
}
|
||||
function mine(node, label) {
|
||||
const out = openSync(`${TMP}/miner-${label}.log`, 'a');
|
||||
const p = spawn(MINER, ['mine', `127.0.0.1:${node.grpcPort}`, '--label', label, '--payout', '0x4343434343434343434343434343434343434343', '--threads', '1'], { stdio: ['ignore', out, out] });
|
||||
started.push(p); return p;
|
||||
}
|
||||
const checks = [];
|
||||
const check = (name, ok, detail) => { checks.push({ name, ok }); log(`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ': ' + JSON.stringify(detail).slice(0, 300) : ''}`); if (!ok) throw new Error(`check failed: ${name}`); };
|
||||
async function waitTip(n, want, secs = 600) { for (let k = 0; k < secs * 2; k++) { const tip = hexn(await n.eth('eth_blockNumber')); if (tip >= want) return tip; await sleep(500); } throw new Error(`node ${n.i} did not reach ${want}`); }
|
||||
function sha256(path) { return '0x' + createHash('sha256').update(readFileSync(path)).digest('hex'); }
|
||||
async function main() {
|
||||
const a = await new Node(0).start();
|
||||
log(`node A up on ${a.evm}`);
|
||||
const miner = mine(a, 'exec-sync-a');
|
||||
await waitTip(a, BLOCKS, 900);
|
||||
const tipA = hexn(await a.eth('eth_blockNumber'));
|
||||
const st0 = await a.eth('igneum_getExecStatus');
|
||||
check('A executes from genesis', st0.startedFrom === 'genesis' && st0.blocked === null && hexn(st0.executedTip) >= BLOCKS, st0);
|
||||
// case 1: the export, the stop, the resume
|
||||
const file = `${TMP}/snapshot.bin`;
|
||||
const ex = await a.eth('igneum_exportExecSnapshot', [file]);
|
||||
const sha = sha256(file);
|
||||
check('the export writes the file with its tip, root and sha256', existsSync(file) && ex.sha256 === sha && hexn(ex.tip) >= BLOCKS && ex.records === hexn(ex.tip) + 1, { tip: ex.tip, bytes: ex.bytes, sha256: ex.sha256, accounts: ex.accounts });
|
||||
const blockAtTip = await a.eth('eth_getBlockByNumber', [ex.tip, false]);
|
||||
check('the export\'s state root is the tip block\'s', blockAtTip.stateRoot === ex.stateRoot, { root: ex.stateRoot });
|
||||
try { miner.kill('SIGINT'); } catch { }
|
||||
await a.stop();
|
||||
const persistedLine = a.logText().split('\n').find(l => l.includes('exec state persisted at stop'));
|
||||
check('A persisted its state at stop', !!persistedLine, persistedLine && persistedLine.slice(-120));
|
||||
await a.start();
|
||||
const resumed = a.logText().split('\n').filter(l => l.includes('exec sync: resumed from')).pop();
|
||||
const st1 = await a.eth('igneum_getExecStatus');
|
||||
check('A resumed from the data dir\'s snapshot', st1.startedFrom === 'snapshot' && hexn(st1.snapshotTip) >= BLOCKS && !!resumed, { startedFrom: st1.startedFrom, snapshotTip: st1.snapshotTip, line: resumed && resumed.slice(-160) });
|
||||
const miner2 = mine(a, 'exec-sync-a2');
|
||||
await waitTip(a, hexn(st1.snapshotTip) + 5, 300);
|
||||
check('A keeps executing after the resume', hexn(await a.eth('eth_blockNumber')) > hexn(st1.snapshotTip));
|
||||
// case 2: a fresh node from the file with the right pin
|
||||
const b = await new Node(1, [`127.0.0.1:${a.p2pPort}`], [`--igneum-exec-snapshot=${file},${sha}`]).start();
|
||||
const tipNow = hexn(await a.eth('eth_blockNumber'));
|
||||
await waitTip(b, tipNow, 600);
|
||||
const stB = await b.eth('igneum_getExecStatus');
|
||||
check('B started from the file', stB.startedFrom === 'snapshot' && hexn(stB.snapshotTip) === hexn(ex.tip) && stB.snapshotSha256 === sha, { startedFrom: stB.startedFrom, snapshotTip: stB.snapshotTip });
|
||||
const blkB = await b.eth('eth_getBlockByNumber', [ex.tip, false]);
|
||||
check('B\'s state root at the file\'s tip equals A\'s', blkB.stateRoot === ex.stateRoot, { b: blkB.stateRoot });
|
||||
const payout = '0x4343434343434343434343434343434343434343';
|
||||
const h = '0x' + tipNow.toString(16);
|
||||
const [balA, balB] = await Promise.all([a.eth('eth_getBalance', [payout, h]), b.eth('eth_getBalance', [payout, h])]);
|
||||
check('the miner\'s balance at the same height equals on A and B', balA === balB && BigInt(balA) > 0n, { balA, balB, height: tipNow });
|
||||
const [rootA, rootB] = await Promise.all([a.eth('eth_getBlockByNumber', [h, false]), b.eth('eth_getBlockByNumber', [h, false])]);
|
||||
check('the state root at the same height equals on A and B', rootA.stateRoot === rootB.stateRoot, { height: tipNow });
|
||||
// case 3: known-failed, the wrong pin
|
||||
const wrong = '0x' + 'ab'.repeat(32);
|
||||
const c = await new Node(2, [`127.0.0.1:${a.p2pPort}`], [`--igneum-exec-snapshot=${file},${wrong}`]).start();
|
||||
await sleep(3000);
|
||||
const stC = await c.eth('igneum_getExecStatus');
|
||||
const cline = c.logText().split('\n').find(l => l.includes('is not the pinned'));
|
||||
check('known-failed: C refuses the file under a wrong pin and says so', stC.startedFrom !== 'snapshot' && !!cline, { startedFrom: stC.startedFrom, line: cline && cline.slice(-140) });
|
||||
// case 4: known-failed, a file from another chain
|
||||
const z = await new Node(3, [], [], 958).start();
|
||||
const zm = mine(z, 'exec-sync-z');
|
||||
await waitTip(z, 12, 300);
|
||||
const fileZ = `${TMP}/snapshot-z.bin`;
|
||||
const exZ = await z.eth('igneum_exportExecSnapshot', [fileZ]);
|
||||
try { zm.kill('SIGINT'); } catch { }
|
||||
const d = await new Node(4, [`127.0.0.1:${a.p2pPort}`], [`--igneum-exec-snapshot=${fileZ},${exZ.sha256}`]).start();
|
||||
await sleep(3000);
|
||||
const stD = await d.eth('igneum_getExecStatus');
|
||||
const dline = d.logText().split('\n').find(l => l.includes('unknown to consensus') || l.includes('not on this node') || l.includes('is not this network'));
|
||||
check('known-failed: D refuses a snapshot from another chain and says why', stD.startedFrom !== 'snapshot' && !!dline, { startedFrom: stD.startedFrom, line: dline && dline.slice(-160) });
|
||||
try { miner2.kill('SIGINT'); } catch { }
|
||||
log(`RESULT exec sync harness: PASSED (${checks.length} checks) in ${((Date.now() - t0) / 1000).toFixed(1)} s; snapshot ${ex.bytes} bytes at tip ${hexn(ex.tip)}, ${ex.accounts} accounts`);
|
||||
}
|
||||
main().then(() => cleanup(0)).catch(e => { log(`FAILED: ${e.message}`); cleanup(1); });
|
||||
function cleanup(code) { for (const p of started.reverse()) { try { p.kill('SIGINT'); } catch { } } setTimeout(() => { for (const p of started) { try { p.kill('SIGKILL'); } catch { } } process.exit(code); }, 3000); }
|
||||
Loading…
Reference in a new issue