Skip to content

Commit 181ceed

Browse files
authored
test(e2e): split node-killing HA test into its own composed suite file (#24503)
Round-2 e2e consolidation, PR 9/9 — owns `composed/**`. ## Clock-skew suite stays on the composed HA Postgres cluster The four `Clock Skew and Timezone Safety` its in `composed/ha/e2e_ha_full.parallel.test.ts` exercise `PostgresSlashingProtectionDatabase` directly against the running 5-node HA cluster's **real dockerized PostgreSQL** slashing-protection DB: - TZ-independent duty timestamps (absolute-time storage) - `cleanupOldDuties` keeps recent duties when the node clock is 2h ahead - `cleanupOldDuties` deletes old duties by DB time even when the node clock is 1h behind - `cleanupOwnStuckDuties` keeps recent stuck duties when the node clock is 3h ahead The property under test is that these DB-clock semantics (`CURRENT_TIMESTAMP`, `timestamptz`) are immune to node clock/timezone skew — the node and its database must be able to genuinely diverge in clock and timezone environment for the assertion to mean anything. That only holds against a real, separate Postgres process. An earlier revision moved these into an in-process PGlite file; since PGlite runs inside the Node process, the DB and node can never actually diverge, so that variant is dropped. The tests keep riding the composed cluster's docker Postgres via the shared `HaFullTestContext` (`t.mainPool` / `t.dateProvider`), exactly as before; `dateProvider` simulates the skewed node clock while the DB uses its own clock. The `@electric-sql/pglite` dependency added for the PGlite variant is removed from end-to-end (other workspaces that use pglite for unit tests are unaffected). ## Order-dependent HA test → own file The `should distribute work across multiple HA nodes` test was annotated `must run last` because it permanently kills every node. Extracted verbatim into `composed/ha/e2e_ha_distribute_work.test.ts`, which shares the cluster setup with the remaining suite via a new non-test `ha_full_setup.ts` module (the ~380-line `beforeAll`/`afterAll`/helpers moved verbatim into an `HaFullTestContext` class). Result: - The killer test gets its own cluster; the ordering contract is gone (no hidden reliance on definition order). - It has a single `it`, so it is a plain `.test.ts` (not `.parallel`) and runs as one whole-file CI container. - The remaining `e2e_ha_full.parallel.test.ts` keeps its 3 non-destructive its (block production, governance voting, keystore reload) plus the clock-skew describe, all order-independent. - Zero CI runtime change: setup was moved, not rewritten, so behavior is unchanged. No assertions dropped. Added a `.test_patterns.yml` flaky entry for the new file mirroring the existing `e2e_ha_full` one (owner unchanged). ## Stranded composed tests — left in place, still excluded `composed/e2e_persistence.test.ts` and `composed/integration_proof_verification.test.ts` are excluded from every CI list and have run nowhere since April 2025. I moved each into a category dir, ran it, and reverted the move because both are broken on the current branch — root causes outside this diff's editable scope: | File | Disposition | Why | |---|---|---| | `integration_proof_verification.test.ts` | left in `composed/`, excluded, documented | Committed `fixtures/dumps/epoch_proof_result.json` is stale (last regenerated Feb 2026; circuits/VK changed since). bb and the on-chain HonkVerifier both reject the proof (`Failed to verify RootRollupArtifact proof!`). Needs the fixture regenerated; better relocated to the bb-prover circuit tests. | | `e2e_persistence.test.ts` | left in `composed/`, excluded, documented | `beforeAll` no longer completes: the single-node sequencer stalls in checkpoint proposal (`waitForAttestationsAndEnqueueSubmissionAsync`) and the 600s hook times out. The root cause is in shared setup/sequencer, not this file. | Each file's header comment and the `bootstrap.sh` exclusion now document the real reason (previously "excluded for unknown reasons"). **Decision items for follow-up:** regenerate the epoch-proof fixture and relocate `integration_proof_verification` to bb-prover; fix the single-node checkpoint stall and refile `e2e_persistence` under `single-node/`. ## Verified locally - `yarn build` (full), `yarn format end-to-end`, `yarn lint end-to-end` — all clean. - Confirmed both stranded tests fail on this branch (evidence above), which is why they stay excluded. - The compose/ha suites need docker (Postgres/Web3Signer) and were not run locally; the `e2e_ha_full` / `e2e_ha_distribute_work` split and the clock-skew describe rely on CI. Setup is moved verbatim and each `.parallel` it is already isolated per container, so runtime behavior is unchanged.
1 parent a5b3c78 commit 181ceed

7 files changed

Lines changed: 740 additions & 598 deletions

File tree

.test_patterns.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -397,6 +397,10 @@ tests:
397397
owners:
398398
- *spyros
399399

400+
- regex: "yarn-project/end-to-end/scripts/run_test.sh ha src/composed/ha/e2e_ha_distribute_work.test.ts"
401+
owners:
402+
- *spyros
403+
400404
# http://ci.aztec-labs.com/98d59d04f85223f8
401405
# Build-cache flake: module not found during Jest startup
402406
- regex: "src/single-node/sequencer/gov_proposal.parallel.test.ts"

yarn-project/end-to-end/bootstrap.sh

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,10 @@ function test_cmds {
119119

120120
# compose-based tests (use running local network)
121121
tests=(
122+
# integration_proof_verification and e2e_persistence are excluded and run nowhere: the former's committed
123+
# epoch-proof fixture is stale (the proof no longer verifies), and the latter's beforeAll no longer
124+
# completes on the current branch (the single-node sequencer stalls in checkpoint proposal). See each
125+
# file's header comment. Both stay excluded until fixed/regenerated.
122126
src/composed/!(integration_proof_verification|e2e_persistence).test.ts
123127
src/guides/*.test.ts
124128
)

yarn-project/end-to-end/src/composed/e2e_persistence.test.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,15 @@ import type { TestWallet } from '../test-wallet/test_wallet.js';
2222

2323
jest.setTimeout(15 * 60 * 1000);
2424

25-
// Node and PXE persistence tests. Uses setup() directly with PIPELINING_SETUP_OPTS; excluded from the
26-
// compose glob for unknown reasons (migrate-later candidate). Spawns and tears down node/PXE with
27-
// varying combinations of persisted vs empty data directories to cover five restart scenarios.
25+
// Node and PXE persistence tests: an in-process single-node test (uses setup() directly with
26+
// PIPELINING_SETUP_OPTS) that spawns and tears down node/PXE across five persisted-vs-empty data-directory
27+
// restart scenarios.
28+
//
29+
// EXCLUDED from every CI test list (see bootstrap.sh) and does NOT run anywhere. It is a candidate to
30+
// refile under single-node/, but on the current branch its beforeAll no longer completes: the single-node
31+
// sequencer stalls in checkpoint proposal (waitForAttestationsAndEnqueueSubmissionAsync) and the 600s hook
32+
// times out before setup finishes. Re-enabling it needs that setup stall fixed (the root cause is in the
33+
// shared setup/sequencer, not this file); until then it stays excluded.
2834
describe('Aztec persistence', () => {
2935
/**
3036
* These tests check that the Aztec Node and PXE can be shutdown and restarted without losing data.
Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
/**
2+
* HA work-distribution resilience test.
3+
*
4+
* Extracted from `e2e_ha_full.parallel.test.ts`: this test kills each block's producer node in turn, so it
5+
* leaves the cluster unusable and previously carried a "must run last" ordering contract. Giving it its
6+
* own file (and therefore its own cluster via the shared `HaFullTestContext`) removes that contract while
7+
* preserving every assertion. Requires the docker-compose HA suite (run_test.sh ha).
8+
*/
9+
import { type AttestationInfo, getAttestationInfoFromPublishedCheckpoint } from '@aztec/stdlib/block';
10+
import { Checkpoint } from '@aztec/stdlib/checkpoint';
11+
import { OffenseType } from '@aztec/stdlib/slashing';
12+
13+
import { jest } from '@jest/globals';
14+
15+
import { getValidatorDuties, verifyNoDuplicateAttestations } from '../../fixtures/ha_setup.js';
16+
import { COMMITTEE_SIZE, HaFullTestContext, NODE_COUNT } from './ha_full_setup.js';
17+
18+
describe('HA Distribute Work', () => {
19+
jest.setTimeout(20 * 60 * 1000); // 20 minutes
20+
21+
const t = new HaFullTestContext();
22+
23+
beforeAll(async () => {
24+
await t.setup();
25+
});
26+
27+
afterAll(async () => {
28+
await t.teardown();
29+
});
30+
31+
it('should distribute work across multiple HA nodes', async () => {
32+
const { logger, haNodeServices, sendTriggerTx, aztecNode, mainPool, getSignatureContext, stopHANode } = t;
33+
34+
logger.info('Testing HA resilience by killing nodes after they produce blocks');
35+
36+
// We'll produce NODE_COUNT blocks (5 total with NODE_COUNT=5)
37+
// Each node produces exactly 1 block, and we kill it after it produces
38+
// The last remaining node will produce the final block
39+
const blockCount = NODE_COUNT;
40+
const receipts = [];
41+
const killedNodes: number[] = []; // Track indices of killed nodes
42+
const blockProducers = new Map<number, string>(); // Map block index to node ID
43+
let previousBlockNumber: number | undefined;
44+
45+
const nodeIds: string[] = [];
46+
for (const service of haNodeServices) {
47+
nodeIds.push((await service.getConfig()).nodeId);
48+
}
49+
50+
for (let i = 0; i < blockCount; i++) {
51+
logger.info(`\n=== Producing block ${i + 1}/${blockCount} ===`);
52+
logger.info(`Active nodes: ${haNodeServices.length - killedNodes.length}/${NODE_COUNT}`);
53+
54+
const receipt = await sendTriggerTx();
55+
56+
expect(receipt.blockNumber).toBeDefined();
57+
58+
// Verify this transaction is in a different block than the previous one
59+
if (previousBlockNumber !== undefined) {
60+
expect(receipt.blockNumber).toBeGreaterThan(previousBlockNumber);
61+
}
62+
63+
previousBlockNumber = receipt.blockNumber;
64+
receipts.push(receipt);
65+
66+
// Find which node produced this block
67+
const [block] = await aztecNode.getBlocks(receipt.blockNumber!, 1, {
68+
includeL1PublishInfo: true,
69+
includeAttestations: true,
70+
includeTransactions: true,
71+
onlyCheckpointed: true,
72+
});
73+
if (!block) {
74+
throw new Error(`Block ${receipt.blockNumber} not found`);
75+
}
76+
const slotNumber = BigInt(block.header.globalVariables.slotNumber);
77+
const duties = await getValidatorDuties(mainPool, slotNumber);
78+
const blockProposalDuty = duties.find(d => d.dutyType === 'BLOCK_PROPOSAL');
79+
80+
if (!blockProposalDuty) {
81+
throw new Error(`No block proposal duty found for slot ${slotNumber}`);
82+
}
83+
84+
blockProducers.set(i, blockProposalDuty.nodeId);
85+
logger.info(`Block ${receipt.blockNumber} produced by node ${blockProposalDuty.nodeId}`);
86+
87+
const producerNodeId = blockProposalDuty.nodeId;
88+
const producerNodeIndex = nodeIds.findIndex(nodeId => nodeId === producerNodeId);
89+
90+
if (producerNodeIndex === -1) {
91+
throw new Error(`Could not find active node with ID ${producerNodeId}`);
92+
}
93+
94+
// Kill the node that produced this block, unless it's the last block
95+
if (i < blockCount - 1) {
96+
logger.info(`Killing node ${producerNodeId} that produced this block`);
97+
await stopHANode(producerNodeIndex);
98+
killedNodes.push(producerNodeIndex);
99+
} else {
100+
// The final survivor is kept online for the slash-offense assertion below, but its sequencer
101+
// is no longer needed. Stop it before running the remaining assertions so it cannot start a
102+
// new empty checkpoint and then block service shutdown while awaiting a delayed L1 publish.
103+
logger.info(`Last block produced; stopping sequencer for survivor ${producerNodeId}`);
104+
await haNodeServices[producerNodeIndex].getSequencer()?.stop();
105+
}
106+
107+
logger.info(`Block ${i + 1}/${blockCount} completed. Killed nodes: ${killedNodes.length}/${NODE_COUNT}`);
108+
}
109+
110+
// Verify we got the expected number of distinct blocks
111+
const blockNumbers = receipts.map(r => r.blockNumber!).sort((a, b) => a - b);
112+
const uniqueBlockNumbers = new Set(blockNumbers);
113+
expect(uniqueBlockNumbers.size).toBe(blockCount);
114+
logger.info(`Created ${uniqueBlockNumbers.size} distinct blocks: ${Array.from(uniqueBlockNumbers).join(', ')}`);
115+
116+
// Verify each node produced at least 1 block
117+
const nodeBlockCounts = new Map<string, number>();
118+
for (const nodeId of blockProducers.values()) {
119+
const count = nodeBlockCounts.get(nodeId) || 0;
120+
nodeBlockCounts.set(nodeId, count + 1);
121+
}
122+
123+
logger.info(`Block production by node: ${JSON.stringify(Array.from(nodeBlockCounts.entries()))}`);
124+
125+
// Verify: each node should have produced at least 1 block
126+
// (there may be empty blocks produced during node transitions)
127+
for (const [nodeId, count] of nodeBlockCounts.entries()) {
128+
expect(count).toBeGreaterThanOrEqual(1);
129+
logger.info(`Node ${nodeId} produced ${count} block(s) as expected`);
130+
}
131+
132+
// Verify all nodes participated (NODE_COUNT nodes total)
133+
expect(nodeBlockCounts.size).toBe(NODE_COUNT);
134+
logger.info(`All ${NODE_COUNT} nodes participated in block production`);
135+
136+
// Verify no double-signing occurred across all blocks
137+
const quorum = Math.floor((COMMITTEE_SIZE * 2) / 3) + 1;
138+
for (const receipt of receipts) {
139+
const [block] = await aztecNode.getBlocks(receipt.blockNumber!, 1, {
140+
includeL1PublishInfo: true,
141+
includeAttestations: true,
142+
includeTransactions: true,
143+
onlyCheckpointed: true,
144+
});
145+
if (!block) {
146+
throw new Error(`Block ${receipt.blockNumber} not found`);
147+
}
148+
const slotNumber = BigInt(block.header.globalVariables.slotNumber);
149+
150+
// PRIMARY CHECK: Database records show all attestation duties attempted/completed
151+
const duties = await getValidatorDuties(mainPool, slotNumber);
152+
const attestationDuties = duties.filter(d => d.dutyType === 'ATTESTATION');
153+
154+
// Verify no duplicate attestation duties per validator (HA protection ensures 1 per validator)
155+
const dutiesByValidator = verifyNoDuplicateAttestations(attestationDuties, logger);
156+
expect(dutiesByValidator.size).toBeGreaterThanOrEqual(quorum);
157+
logger.info(
158+
`Block ${receipt.blockNumber}: Database shows ${dutiesByValidator.size} unique validators attested (quorum: ${quorum}), no double-signing detected in DB`,
159+
);
160+
161+
// SECONDARY CHECK: Verify checkpoint attestations match database records
162+
const [publishedCheckpoint] = await aztecNode.getCheckpoints(block.checkpointNumber, 1, {
163+
includeAttestations: true,
164+
});
165+
const attestationInfos = getAttestationInfoFromPublishedCheckpoint(
166+
{
167+
attestations: publishedCheckpoint.attestations ?? [],
168+
checkpoint: new Checkpoint(
169+
publishedCheckpoint.archive,
170+
publishedCheckpoint.header,
171+
[],
172+
publishedCheckpoint.number,
173+
publishedCheckpoint.feeAssetPriceModifier,
174+
),
175+
},
176+
getSignatureContext(),
177+
);
178+
179+
// Filter to only valid attestations with recovered addresses
180+
const validAttestations = attestationInfos.filter(
181+
(info: AttestationInfo) => info.status === 'recovered-from-signature' && info.address !== undefined,
182+
);
183+
184+
// Verify checkpoint has exactly quorum attestations (trimmed to minimum required)
185+
const checkpointValidatorAddresses = new Set<string>(validAttestations.map(info => info.address!.toString()));
186+
expect(checkpointValidatorAddresses.size).toBe(quorum);
187+
188+
// Verify every validator in the checkpoint has a corresponding DB duty record
189+
// (checkpoint is trimmed to quorum, so it's a subset of DB records)
190+
for (const validatorAddress of checkpointValidatorAddresses) {
191+
expect(dutiesByValidator.has(validatorAddress)).toBe(true);
192+
}
193+
}
194+
195+
// GOSSIP-LAYER CHECK: each HA node's libp2p service detects when a signer attests to two
196+
// distinct payloads at the same slot and fires `duplicateAttestationCallback` -> validator
197+
// client emits WANT_TO_SLASH_EVENT -> SlashOffensesCollector persists a DUPLICATE_ATTESTATION
198+
// offense. We assert no such offense (or DUPLICATE_PROPOSAL) was collected on any surviving
199+
// HA node. Killed nodes are unreachable, but the surviving node — which has been alive the
200+
// whole test — has observed all gossiped attestations and proposals across every slot.
201+
const aliveNodes = haNodeServices.filter((_, idx) => !killedNodes.includes(idx));
202+
const allOffenses = (await Promise.all(aliveNodes.map(n => n.getSlashOffenses('all')))).flat();
203+
const equivocationOffenses = allOffenses.filter(
204+
o => o.offenseType === OffenseType.DUPLICATE_ATTESTATION || o.offenseType === OffenseType.DUPLICATE_PROPOSAL,
205+
);
206+
expect(equivocationOffenses).toEqual([]);
207+
208+
await Promise.all(haNodeServices.map((_, nodeIndex) => stopHANode(nodeIndex)));
209+
});
210+
});

0 commit comments

Comments
 (0)