Skip to content

Commit 45bf5ea

Browse files
mo4islonaclaude
andcommitted
fix(hotblocks): enforce finalized fork floor
A fork whose common ancestor lies inside a finality-straddling chunk cannot resume at fin+1: that is a mid-chunk position, and insert_fork only replaces whole chunks, so the commit wedges the dataset on a 60s restart loop. Resuming below fin instead — the fallback's behaviour — silently rewrites the finalized prefix (GAP-22). An honest tip reorg triggers this whenever fin sits inside the head chunk, which is the common case, not just equivocation. Resolve the fork at the stored chunk boundary (which may sit at or below fin) and move the finalized-prefix guard to the write path: a replacement reaching into the finalized region is admitted only when it reproduces the finalized block's hash there, and must span fin so the whole-chunk rewrite never transiently drops it; otherwise it is refused atomically. This keeps the existing whole-chunk replace — no chunk-splitting primitive — and routes every equivocation shape through one loud, bounded 60s loop instead of the clamp's silent retry spin. Honest reorgs above finality now recover. Pinned by seven write-controller unit tests and three ct4_finality black-box scenarios (equivocation refused at both the window floor and a straddling chunk; honest reorg recovers) — the recovery scenario was verified red against the clamp. Co-Authored-By: Claude Opus 4.8 <[email protected]>
1 parent 546c1ac commit 45bf5ea

12 files changed

Lines changed: 687 additions & 58 deletions

File tree

crates/data-client/src/reqwest/client.rs

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,7 @@ pub struct ReqwestDataClient {
3636

3737
impl Debug for ReqwestDataClient {
3838
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
39-
f.debug_struct("ReqwestDataClient")
40-
.field("url", &self.url.as_str())
41-
.finish()
39+
f.write_str(self.url.as_str())
4240
}
4341
}
4442

crates/data-source/src/standard.rs

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ use futures::{future::BoxFuture, stream::BoxStream, FutureExt, Stream, StreamExt
55
use sqd_data_client::{BlockStreamRequest, BlockStreamResponse, DataClient};
66
use sqd_primitives::{Block, BlockNumber, BlockRef};
77
use tokio::time::Sleep;
8-
use tracing::warn;
8+
use tracing::{info, warn};
99

1010
use crate::types::{DataEvent, DataSource};
1111

@@ -78,6 +78,14 @@ impl<F> DataSourceState<F> {
7878
}
7979
Poll::Ready(Ok(BlockStreamResponse::Fork(prev_blocks))) => {
8080
ep.error_counter = 0;
81+
info!(
82+
data_source =? ep.client,
83+
stream_from = req.first_block,
84+
hint_count = prev_blocks.len(),
85+
oldest_hint =? prev_blocks.first().map(|b| b.number),
86+
newest_hint =? prev_blocks.last().map(|b| b.number),
87+
"upstream reported a fork"
88+
);
8189
ep.state = EndpointState::Fork {
8290
req: req.clone(),
8391
prev_blocks
@@ -289,11 +297,19 @@ where
289297

290298
let forks = self.endpoints.iter().filter(|ep| ep.is_on_fork()).count();
291299
if forks > 0 {
292-
if forks > self.endpoints.len() / 2
293-
|| forks == self.endpoints.iter().filter(|ep| ep.is_active()).count()
294-
|| self.fork_consensus_timeout(cx)
295-
{
296-
return Poll::Ready(DataEvent::Fork(self.extract_fork()));
300+
let active = self.endpoints.iter().filter(|ep| ep.is_active()).count();
301+
if forks > self.endpoints.len() / 2 || forks == active || self.fork_consensus_timeout(cx) {
302+
let chain = self.extract_fork();
303+
info!(
304+
forked_endpoints = forks,
305+
active_endpoints = active,
306+
total_endpoints = self.endpoints.len(),
307+
hint_count = chain.len(),
308+
oldest_hint =? chain.first().map(|b| b.number),
309+
newest_hint =? chain.last().map(|b| b.number),
310+
"fork consensus reached"
311+
);
312+
return Poll::Ready(DataEvent::Fork(chain));
297313
}
298314
} else {
299315
self.state.fork_consensus_timeout = None

crates/hotblocks-harness/README.md

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ is what makes the crash/restart and shutdown classes expressible at all.
2222
```bash
2323
cargo test -p sqd-hotblocks-harness # the harness's own unit tests (model, chain, simulator)
2424
cargo test -p sqd-hotblocks --test ct1_happy_path # CT-1 — the Phase 0 exit criterion
25+
cargo test -p sqd-hotblocks --test ct4_finality # CT-4 — finalized-prefix equivocation
2526
cargo test -p sqd-hotblocks --test ct9_source_faults
2627
```
2728

@@ -33,7 +34,7 @@ reusable lives here, so a future soak or benchmark runner can use it outside `ca
3334

3435
| Module | What it is | Spec |
3536
|---|---|---|
36-
| [`sim`](src/sim.rs) | source simulator: scripted chain, fork signals, finality headers, fault knobs | 13 §7, DEF-12 |
37+
| [`sim`](src/sim.rs) | source simulator: scripted chain, fork signals, finality headers, fault knobs including explicit finalized-prefix equivocation | 13 §7, DEF-12 |
3738
| [`model`](src/model.rs) | the reference model — the oracle. Block-exact, well-formedness asserted after every transition | 12 §2 |
3839
| [`driver`](src/driver.rs) | client: the read binding, the structural validators, the anchored follower and backfill scanner | 04 §7, 12 §4 |
3940
| [`compare`](src/compare.rs) | quiescence comparator: diffs every observable, collects *all* violations before failing | 12 §1 |
@@ -122,9 +123,12 @@ Fixed in `crates/data-client/src/reqwest/lines.rs`; pinned by a unit test there
122123
- **CT-2 (crash/restart)**`Sut::crash()`, `Sut::stop()`, `Sut::restart()` already exist and
123124
keep the same database directory and port across boots. What is missing is the kill-point
124125
matrix.
125-
- **CT-4 (fork/finality corpus)**`Harness::fork()` and the model's `resolve_fork` /
126-
`Finalize::IntegrityFault` are implemented and unit-tested; the follower implements the
127-
normative CONFLICT recovery of 04 §7. What is missing is the scripts.
126+
- **CT-4 (fork/finality corpus)**`ct4_finality` drives source-equivocation faults through the
127+
real binary at both the retained-window floor and a deterministic two-chunk layout with finality
128+
strictly inside the second chunk. Both prove rollback resumes at `fin + 1` and the accepted
129+
finalized prefix remains unchanged. `Harness::fork()`, the model's `resolve_fork` /
130+
`Finalize::IntegrityFault`, and the follower's normative CONFLICT recovery of 04 §7 support the
131+
remaining successful-reorg, below-window, malformed-finality, fork-storm and alarm scripts.
128132
- **CT-5 (error taxonomy)**`ct5_error_soundness` covers unsupported-dialect containment,
129133
error classification, and mid-stream worker-panic abort; the anchored check across large
130134
sparse-number holes is deferred (GAP-21, test `#[ignore]`d). `Model::predict_query` supplies

crates/hotblocks-harness/src/harness.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ pub struct HarnessConfig {
2929
/// Dense (evm, hyperliquid) or sparse (Solana slots) block numbering.
3030
pub numbering: Numbering,
3131
pub retention: Retention,
32+
/// Keep physical ingest chunks separate when a test needs a deterministic storage layout.
33+
pub disable_compaction: bool,
3234
/// Whether the service is told the anchor hash. If not, the anchor is `⊥` (DEF-7) and the
3335
/// first block's parent is unverifiable.
3436
pub anchored: bool,
@@ -54,6 +56,7 @@ impl HarnessConfig {
5456
number: start_block,
5557
parent_hash: Some(block_hash(start_block - 1, 0))
5658
},
59+
disable_compaction: false,
5760
anchored: true,
5861
source_poll: Duration::from_millis(200),
5962
rust_log: "info".to_string(),
@@ -96,6 +99,7 @@ impl Harness {
9699
id: cfg.dataset.clone(),
97100
kind: cfg.chain.config_kind().to_string(),
98101
retention: cfg.retention.clone(),
102+
disable_compaction: cfg.disable_compaction,
99103
sources: vec![sim.base_url(&cfg.dataset)]
100104
}]
101105
);

crates/hotblocks-harness/src/sim.rs

Lines changed: 48 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,8 @@ pub struct SimFaults {
8787
#[derive(Clone, Copy, Debug, Default)]
8888
pub struct SimStats {
8989
pub stream_requests: u64,
90+
/// `fromBlock` on the most recent request, for rollback-position assertions.
91+
pub last_stream_from: Option<BlockNumber>,
9092
pub blocks_served: u64,
9193
pub fork_signals: u64,
9294
pub no_data: u64,
@@ -161,6 +163,18 @@ impl SourceSim {
161163
Ok(blocks)
162164
}
163165

166+
/// Fault injection for CT-4/FM-SRC-5: replace a suffix that includes the source's own
167+
/// finalized head and claim the replacement tip as final.
168+
///
169+
/// Unlike [`Self::fork`], this deliberately violates source finality. It has no model-side
170+
/// counterpart: the last accepted model state remains the oracle while the SUT rejects the
171+
/// equivocating source.
172+
pub fn equivocate_finalized_prefix(&self, dataset: &str, from: BlockNumber, len: u32) -> Result<()> {
173+
self.try_with(dataset, |d| d.equivocate_finalized_prefix(from, len))?;
174+
self.bump();
175+
Ok(())
176+
}
177+
164178
/// Declare `number` (and everything below it) final.
165179
pub fn finalize(&self, dataset: &str, number: BlockNumber) -> Result<BlockRef> {
166180
let r = self.try_with(dataset, |d| d.finalize(number))?;
@@ -301,22 +315,49 @@ impl DatasetSim {
301315
}
302316

303317
fn fork(&mut self, from: BlockNumber, len: u32) -> Result<Vec<Block>> {
318+
self.validate_fork_position(from)?;
319+
ensure!(
320+
self.fin.as_ref().is_none_or(|f| f.number < from),
321+
"the script forks at or below the source's own finalized head — an equivocating source \
322+
belongs to the CT-4 fault corpus, not to a well-formed script"
323+
);
324+
Ok(self.replace_suffix(from, len))
325+
}
326+
327+
fn equivocate_finalized_prefix(&mut self, from: BlockNumber, len: u32) -> Result<()> {
328+
self.validate_fork_position(from)?;
329+
ensure!(len > 0, "a finality-equivocation fault must mint a replacement tip");
330+
let finalized = self
331+
.fin
332+
.as_ref()
333+
.context("a finality-equivocation fault requires an existing finalized head")?;
334+
ensure!(
335+
from <= finalized.number,
336+
"equivocation at {from} does not replace finalized block {}",
337+
finalized.number
338+
);
339+
340+
let replacement = self.replace_suffix(from, len);
341+
self.fin = Some(replacement.last().expect("a non-empty replacement has a tip").as_ref());
342+
Ok(())
343+
}
344+
345+
fn validate_fork_position(&self, from: BlockNumber) -> Result<()> {
304346
ensure!(
305347
from >= self.start,
306348
"fork at {from} is below the source's first block {}",
307349
self.start
308350
);
309351
ensure!(from <= self.next_number(), "fork at {from} is above the source's chain");
310-
ensure!(
311-
self.fin.as_ref().is_none_or(|f| f.number < from),
312-
"the script forks at or below the source's own finalized head — an equivocating source \
313-
belongs to the CT-4 fault corpus, not to a well-formed script"
314-
);
352+
Ok(())
353+
}
354+
355+
fn replace_suffix(&mut self, from: BlockNumber, len: u32) -> Vec<Block> {
315356
let keep = self.chain.partition_point(|b| b.number < from);
316357
self.chain.truncate(keep);
317358
self.fork_id = self.next_fork_id;
318359
self.next_fork_id += 1;
319-
Ok(self.produce(len))
360+
self.produce(len)
320361
}
321362

322363
fn finalize(&mut self, number: BlockNumber) -> Result<BlockRef> {
@@ -342,6 +383,7 @@ impl DatasetSim {
342383

343384
fn respond(&mut self, req: &StreamReq) -> Reply {
344385
self.stats.stream_requests += 1;
386+
self.stats.last_stream_from = Some(req.from_block);
345387

346388
let parent_pos = req.from_block.saturating_sub(1);
347389
if let Some(asserted) = &req.parent_block_hash {

crates/hotblocks-harness/src/sut.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ pub struct DatasetSpec {
3939
pub id: String,
4040
pub kind: String,
4141
pub retention: Retention,
42+
/// Preserve response-aligned chunks for tests that exercise physical layout boundaries.
43+
pub disable_compaction: bool,
4244
pub sources: Vec<String>
4345
}
4446

@@ -235,6 +237,7 @@ impl Sut {
235237
Retention::Api => yaml.push_str(" retention_strategy: Api\n"),
236238
Retention::None => yaml.push_str(" retention_strategy: None\n")
237239
}
240+
yaml.push_str(&format!(" disable_compaction: {}\n", ds.disable_compaction));
238241
yaml.push_str(" data_sources:\n");
239242
for src in &ds.sources {
240243
yaml.push_str(&format!(" - \"{src}\"\n"));

0 commit comments

Comments
 (0)