Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 3 additions & 3 deletions crates/hotblocks-harness/src/chain/evm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ fn log_count(b: &Block) -> u32 {
}

/// Distinct from any block hash: block numbers never reach this domain.
fn tx_hash(b: &Block, index: u32) -> String {
pub fn transaction_hash(b: &Block, index: u32) -> String {
block_hash(
b.number.wrapping_mul(1_000_003).wrapping_add(u64::from(index)),
b.fork_id
Expand Down Expand Up @@ -72,7 +72,7 @@ impl Evm {
fn projected_tx(b: &Block, index: u32) -> Value {
json!({
"transactionIndex": index,
"hash": tx_hash(b, index),
"hash": transaction_hash(b, index),
"from": SENDER,
// A plain number here, unlike every other numeric field of a transaction.
"nonce": b.number
Expand Down Expand Up @@ -107,7 +107,7 @@ impl Evm {
let mut log = Self::projected_log(b, index);
log.as_object_mut()
.expect("log is an object")
.insert("transactionHash".into(), json!(tx_hash(b, 0)));
.insert("transactionHash".into(), json!(transaction_hash(b, 0)));
log
}
}
Expand Down
60 changes: 59 additions & 1 deletion crates/hotblocks-harness/src/compare.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ use crate::{
chain::Chain,
driver::{Client, Status},
model::Model,
types::{BlockNumber, BlockRef}
types::{Block, BlockNumber, BlockRef, TransactionRef}
};

#[derive(Clone, Debug)]
Expand Down Expand Up @@ -118,6 +118,64 @@ pub async fn assert_hash_index_conforms(client: &Client, model: &Model) -> Resul
}
}

/// Materializes the transaction positions present in the chain oracle's
/// projected block. Keeping this derivation at the binding boundary means the
/// reference model remains kind-agnostic.
pub fn expected_transactions(chain: &dyn Chain, block: &Block) -> Result<Vec<TransactionRef>> {
let emission = chain.expected_emission(block);
let Some(transactions) = emission.get("transactions") else {
return Ok(Vec::new());
};
let transactions = transactions
.as_array()
.ok_or_else(|| anyhow::anyhow!("expected transactions projection is not an array"))?;

transactions
.iter()
.map(|transaction| {
let transaction_index = transaction
.get("transactionIndex")
.and_then(Value::as_u64)
.ok_or_else(|| anyhow::anyhow!("projected transaction has no numeric transactionIndex"))?
.try_into()
.map_err(|_| anyhow::anyhow!("projected transactionIndex does not fit u32"))?;
let hash = transaction
.get("hash")
.and_then(Value::as_str)
.ok_or_else(|| anyhow::anyhow!("projected transaction has no string hash"))?;
Ok(TransactionRef::new(block.number, transaction_index, hash))
})
.collect()
}

/// Every transaction on the canonical model branch resolves to its exact
/// block/index position. This completeness assertion is valid only in the
/// harness's fresh-store, always-enabled EVM scenarios (spec 12 §2).
pub async fn assert_transaction_hash_index_conforms(client: &Client, model: &Model, chain: &dyn Chain) -> Result<()> {
let mut errs = Vec::new();
for block in &model.seg {
for transaction in expected_transactions(chain, block)? {
let expected = Some(transaction.clone());
let observed = client.transaction_by_hash(&transaction.hash).await?;
if observed != expected {
errs.push(format!(
"hash {}: expected {expected:?}, got {observed:?}",
transaction.hash
));
}
}
}

if errs.is_empty() {
Ok(())
} else {
bail!(
"the transaction-hash index diverged from the model:\n - {}",
errs.join("\n - ")
)
}
}

/// Diff every observable against the model, reporting *all* violations, not the first.
pub async fn assert_conforms(client: &Client, model: &Model, chain: &dyn Chain, dataset: &str) -> Result<()> {
let mut errs: Vec<String> = Vec::new();
Expand Down
47 changes: 46 additions & 1 deletion crates/hotblocks-harness/src/driver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ use serde_json::Value;

use crate::{
chain::Chain,
types::{BlockNumber, BlockRef}
types::{BlockNumber, BlockRef, TransactionRef}
};

/// One block as the service emitted it (IB-4: one JSON object per record).
Expand Down Expand Up @@ -165,6 +165,51 @@ impl Client {
}
}

/// Returns the raw binding status for malformed block-hash input checks.
pub async fn block_by_hash_status(&self, hash: &str) -> Result<u16> {
Ok(self
.http
.get(self.url(&format!("hashes/{hash}/block")))
.send()
.await?
.status()
.as_u16())
}

/// Resolves a transaction hash through the public hash-index endpoint. A
/// missing hash is a normal `None`; all other failures violate the binding.
pub async fn transaction_by_hash(&self, hash: &str) -> Result<Option<TransactionRef>> {
let res = self
.http
.get(self.url(&format!("hashes/{hash}/transaction")))
.send()
.await?;
let status = res.status();

match status.as_u16() {
200 => Ok(Some(
res.json().await.context("malformed transaction-hash lookup payload")?
)),
404 => Ok(None),
_ => bail!(
"transaction-hash lookup failed with {}: {}",
status.as_u16(),
res.text().await.unwrap_or_default()
)
}
}

/// Returns the raw binding status for malformed-input conformance checks.
pub async fn transaction_by_hash_status(&self, hash: &str) -> Result<u16> {
Ok(self
.http
.get(self.url(&format!("hashes/{hash}/transaction")))
.send()
.await?
.status()
.as_u16())
}

/// SET-RETENTION (DEF-9). Returns the status code: `403` unless the dataset is API-controlled.
pub async fn set_retention(&self, policy: &Value) -> Result<u16> {
let res = self.http.post(self.url("retention")).json(policy).send().await?;
Expand Down
6 changes: 6 additions & 0 deletions crates/hotblocks-harness/src/harness.rs
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,12 @@ impl Harness {
compare::assert_hash_index_conforms(&self.client, &self.model).await
}

/// Diff the opt-in transaction-hash endpoint against every canonical
/// transaction in the model's projected chain.
pub async fn assert_transaction_hash_index_conforms(&self) -> Result<()> {
compare::assert_transaction_hash_index_conforms(&self.client, &self.model, &*self.chain).await
}

/// An anchored follower positioned at the bottom of the window (04 §7).
pub fn follower(&self) -> Follower {
Follower::new(
Expand Down
19 changes: 19 additions & 0 deletions crates/hotblocks-harness/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,25 @@ pub struct BlockRef {
pub hash: String
}

/// DEF-17 transaction-hash lookup result.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TransactionRef {
pub block_number: BlockNumber,
pub transaction_index: u32,
pub hash: String
}

impl TransactionRef {
pub fn new(block_number: BlockNumber, transaction_index: u32, hash: impl Into<String>) -> Self {
Self {
block_number,
transaction_index,
hash: hash.into()
}
}
}

impl BlockRef {
pub fn new(number: BlockNumber, hash: impl Into<String>) -> Self {
Self {
Expand Down
2 changes: 1 addition & 1 deletion crates/hotblocks/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ sqd-data-core = { path = "../data-core" }
sqd-data-source = { path = "../data-source" }
sqd-dataset = { path = "../dataset" }
sqd-polars = { path = "../polars" }
sqd-primitives = { path = "../primitives", features = ["valuable"] }
sqd-primitives = { path = "../primitives", features = ["serde", "valuable"] }
sqd-query = { path = "../query", features = ["storage"] }
sqd-storage = { path = "../storage" }
tikv-jemallocator = "0.6.0"
Expand Down
17 changes: 17 additions & 0 deletions crates/hotblocks/spec/09-retention-and-space.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,23 @@ Requirements:
two are independently enabled (`P-BLOCK-INDEX`, `P-TX-INDEX`) rather than sharing a
switch.

A controlled Snappy/LZ4 sizing run on 2026-07-15 used identical random-looking 32-byte
hashes, production key/value encodings and Bloom filters. Each sample was flushed and fully
compacted; the figures are live SST bytes for the named index only, excluding WAL,
memtables and table data.

| Entries/index | Block, Snappy | Block, LZ4 | Transaction, Snappy | Transaction, LZ4 |
| ---: | ---: | ---: | ---: | ---: |
| 100,000 | 8,487,039 (84.87 B/e) | 8,486,380 (84.86 B/e) | 8,903,864 (89.04 B/e) | 8,904,184 (89.04 B/e) |
| 1,000,000 | 72,238,847 (72.24 B/e) | 71,885,934 (71.89 B/e) | 75,310,111 (75.31 B/e) | 75,037,601 (75.04 B/e) |

At one million entries LZ4 saves only 0.49% for `bidx` and 0.36% for `tidx`. That is not
enough to justify recompressing the existing block index, so `bidx` stays on Snappy while
the new `tidx` uses LZ4. Use **70–90 B per retained entry** as the initial planning range:
approximately 67 GiB per billion blocks and 70 GiB per billion transactions after
compaction. Reproduce with the ignored `measure_hash_index_compression_disk_size` release
test, then measure the deployment's real hash/key distribution and compaction state.

## 3. Interactions

- **Retention × finality:** RS-2 (dominates). FINALIZE below `first(D)` is ignored (WP
Expand Down
4 changes: 4 additions & 0 deletions crates/hotblocks/spec/11-observability.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ surface of the binding (13 §5) with bounded cardinality.
the write pipeline stages, commit-retry counts (HZ-11), maintenance backlog size
(HZ-2). The *existence* of a global write halt MUST be directly observable — the
historical multi-minute freezes were diagnosable only by inference (GAP-1).
`hotblocks_write_duration_seconds{dataset,stage,outcome}` exposes the bounded stages
`prepare`, `tables`, `commit`, `retention`, `block_hash_index`, and
`transaction_hash_index`; hash-index samples are nested within `commit` or `retention`
and include work repeated by optimistic-transaction retries.
- **OB-4 (Query metrics).** Per dataset × query class × outcome (success / each error
class / truncation per RP-15): counts, latency distributions (TTFB, total), emitted
bytes/blocks, coverage sizes; current in-flight and waiting counts against their caps
Expand Down
16 changes: 8 additions & 8 deletions crates/hotblocks/spec/12-conformance-tdd.md
Original file line number Diff line number Diff line change
Expand Up @@ -261,10 +261,10 @@ the same property under forks, crashes and retention is the business of CT-2/CT-
| INV-42 residue convergence | CT-2/7 | P | synthetic orphan purge tested; no crash-driven test |
| INV-43 boot validation | CT-5 | U | |
| INV-44 explicit destruction | CT-1/2 | U | |
| INV-45 index soundness | CT-1/2 | **P** | `block_hash_index.rs`: hits match the model on ingest, unknown hashes miss, the replaced branch stops resolving. No crash or trim coverage; `tidx` unbuilt (GAP-38) |
| INV-46 index maintenance | CT-1/4/7 | **P** | the fork path is covered black-box; trim / DROP / compaction paths have storage-level tests only (`crates/storage/tests/block_hash_index.rs`), never through the binding |
| INV-47 fork re-inclusion | CT-4 | **U** | untestable until `tidx` exists (GAP-38); the hazard is invisible on block hashes |
| RP-19/20 lookup contract | CT-5 | **P** | hit/miss shapes pinned; the length cap, the disabled-index case and the NOT_FOUND vs UNKNOWN_DATASET split (GAP-39) are unasserted |
| INV-45 index soundness | CT-1/2 | **P** | `block_hash_index.rs` + `transaction_hash_index.rs`: both indexes match the model on ingest and replacement; storage tests cover trim / DROP / compaction. Crash coverage remains open |
| INV-46 index maintenance | CT-1/4/7 | **P** | both fork paths are covered black-box; trim / DROP / compaction are storage-level only (`crates/storage/tests/{block,transaction}_hash_index.rs`) |
| INV-47 fork re-inclusion | CT-4 | **C** | black-box reorg re-includes the same transaction at a new block and asserts the new position; storage test independently pins remove-before-insert ordering |
| RP-19/20 lookup contract | CT-5 | **P** | hit/miss, disabled-index behavior, over-limit rejection before dataset lookup, and the 256-byte boundary are pinned; the NOT_FOUND vs UNKNOWN_DATASET split remains open (GAP-39) |
| LIV-1/2 progress/stall | CT-6/7 | **U — known-violated** | GAP-1 |
| LIV-3 query termination | CT-3/6 | U | |
| LIV-4 waiter termination | CT-1/3 | P | `ct1_happy_path`: a query above the head answers `NO_DATA` within `P-HEAD-WAIT` |
Expand All @@ -283,10 +283,10 @@ the same property under forks, crashes and retention is the business of CT-2/CT-
| FM-SRC-* corpus | CT-4 | U | one stale-pack crash-loop already occurred (GAP-5 class); no strike/quarantine substrate (GAP-30) |
| FM-STOR-2/3 disk pressure | CT-7 | U | incident-derived; no automated test |
| FM-OP-1..5 | CT-5 | U | |
| SLI-1..12 / PF-* | CT-6 | U | no benchmark harness exists |
| SLI-1..12 / PF-* | CT-6 | U | no scenario benchmark harness exists; the transaction-index ingest/lookup Criterion microbench is a component baseline only |
| OB-1 chain gauges | CT-1 | P | `first_block` / `last_block` / `last_finalized_block` diffed against the model; commit version and retention policy not exported (GAP-34) |
| OB-2..11 | all | P | query metrics exist; stall gauges pending on PR #83 (unmerged); OB-2 heartbeat, OB-6 debt accounting, OB-9 alarms, OB-11 forensics absent |
| OB-12 index state | CT-1 | **U** | nothing exported: no entry count, no bytes, no hit/miss (GAP-40) |
| OB-12 index state | CT-1 | **P** | CF-wide estimated keys / live SST bytes are exported for both indexes; per-dataset enabled/count/bytes and lookup hit/miss/latency remain absent (GAP-40) |

## 6. Gap register (dated 2026-07-12, informative)

Expand Down Expand Up @@ -333,16 +333,16 @@ rare, P3 = polish. **First test** names the cheapest failing-test-first entry po
| GAP-35 | The runtime External instruction `"None"` parks the dataset: the controller maps it to Idle and stops ingestion even on a non-empty dataset (`RetentionStrategy::None → State::Idle`, dataset_controller.rs), while the binding documents `"None"` as Unbounded (13 §6) and WP-5 requires a non-empty dataset to keep ingesting from its window. Whether an External instruction may change the policy *mode* at all is unspecified (WP-11) | WP-5, DEF-9, WP-11, IB §6 | P2 | CT-1/CT-5: SET-RETENTION `"None"` on a non-empty External dataset; assert ingestion continues (or the instruction is refused with a defined error) — the head must keep advancing |
| GAP-36 | `MALFORMED_REQUEST`, `RANGE_UNAVAILABLE`, `ITEM_UNAVAILABLE` and `KIND_MISMATCH` all surface as HTTP 400 with a free-text body (`api.rs error_to_response`); no machine-readable discriminant exists and IB-7 forbids keying on text — clients cannot distinguish "re-anchor upward" from "fix the request", and the CT-5 error matrix cannot verify INV-26 at the binding. 13 §5 marks the discrimination REQUIRED; the structured error body is the missing piece | INV-26, IB-7, 04 §8 | P2 | CT-5: trigger each 400 class; assert a structured field distinguishes them |
| GAP-37 | PF-1's memory ceiling is not configuration-derivable on the read side: INV-25/RP-17 require emitting the first covered block whole even above `P-RESP-WEIGHT`, and nothing bounds a single block at ingest (`P-BATCH-BYTES` is a soft *batch* bound — one oversized block still stores), so per-response memory is bounded only by the largest block a source ever served. No `P-MAX-BLOCK-BYTES` exists | PF-1, RP-17/INV-25, FM-CLI-2 | P2 | CT-6/CT-9: ingest a pathological giant block, query it; assert bounded RSS and whole-block emission (INV-25) |
| GAP-38 | `TX-BY-HASH` is specified (DEF-17, RP-19, IB §2) but not implemented — `tidx` does not exist. A consumer holding a bare transaction hash, which is the common case for anything reading logs or third-party feeds, has no way to reach a block number. Building it is not a variant of `bidx`: it costs one entry per *transaction* (RS-12 sizing) and it is the only index where the fork-ordering hazard of INV-47 can fire, since transaction hashes recur across branches and block hashes never do | DEF-17, RP-19, INV-47, IB §2 | P2 | CT-5: GET the transaction route (404 route-not-found today); then CT-4 re-inclusion per INV-47 |
| GAP-39 | A hash-lookup miss and an unknown dataset are both 404 with only a free-text body between them, and IB-7 forbids keying on text. This is worse than the GAP-36 family it belongs to: RP-19 makes "this hash is not indexed" a *deliberately uninformative* answer, so a client that cannot separate it from "this dataset does not exist" cannot tell a misconfiguration from a legitimate miss at all | INV-26, IB-7, RP-19 | P3 | CT-5: unknown dataset vs unknown hash; assert a structured discriminant |
| GAP-40 | The hash indexes export nothing (OB-12): no entry count, no bytes toward OB-6/RS-12, no hit/miss rate. Because a miss is uninformative by design, an index that is empty for a structural reason — enabled after the window had filled, wrong kind — is indistinguishable in production from one nobody queries. The `--block-hash-index` flag can be on, the endpoint can 404 every request, and no signal says so | OB-12, OB-6 | P3 | CT-1: scrape assertion once exported |
| GAP-40 | Hash-index CFs export only engine-wide estimated keys and live SST bytes. OB-12 still lacks per-dataset enabled state / entry count / bytes and hit-vs-miss lookup counts with latency. Because a miss is uninformative by design, an index empty for a structural reason — enabled after the window had filled, wrong kind — remains indistinguishable from one receiving only unknown hashes | OB-12, OB-6 | P3 | CT-1: scrape per-dataset state and exercise hit/miss counters once exported |

### 6.1 Closed

| GAP | Statement | Closed by |
|---|---|---|
| GAP-16 | No service-level automated tests | [`crates/hotblocks-harness`](../../hotblocks-harness) + `ct1_happy_path` (Phase 0, 2026-07-12) |
| GAP-19 | A source response whose final JSONL record carried no trailing newline panicked the line reader (`LineStream::take_final_line` left its scan position past the emptied buffer). The ingest task died, its buffered batch was lost, and the dataset parked for `P-EPOCH-RETRY` — then crash-looped, since the source served the same body on retry. Violated FM-1, LIV-2 | Found by CT-1 on the harness's first run; fixed in `crates/data-client/src/reqwest/lines.rs`; pinned by a unit test there and by `ct9_source_faults` (2026-07-12) |
| GAP-38 | `TX-BY-HASH` / `tidx` absent; fork re-inclusion ordering unimplemented | Transaction hash CF + independent flag + HTTP binding; storage transition suite and black-box ingest/reorg/re-inclusion tests (2026-07-15) |

## 7. Build order (recommended)

Expand Down
2 changes: 1 addition & 1 deletion crates/hotblocks/spec/13-interface-binding.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ annotated with the relevant GAP.
| FINALIZED-HEAD | `GET /datasets/{id}/finalized-head` | same shape |
| STATUS | `GET /datasets/{id}/status` | kind, retention, first/last block (+hash/time), finalized head |
| BLOCK-BY-HASH | `GET /datasets/{id}/hashes/{hash}/block` | `{"number":N,"hash":"…"}`; miss = `NOT_FOUND`, **not** proof of absence (RP-19) |
| TX-BY-HASH | `GET /datasets/{id}/hashes/{hash}/transaction` | `{"blockNumber":N,"transactionIndex":i,"hash":"…"}`**not implemented** (GAP-38) |
| TX-BY-HASH | `GET /datasets/{id}/hashes/{hash}/transaction` | `{"blockNumber":N,"transactionIndex":i,"hash":"…"}`; miss = `NOT_FOUND`, **not** proof of absence (RP-19) |
| METADATA | `GET /datasets/{id}/metadata` | start block, real-time flag, aliases |
| GET-RETENTION | `GET /datasets/{id}/retention` | current policy JSON |
| SET-RETENTION | `POST /datasets/{id}/retention` | policy JSON; only for `External` datasets, else `FORBIDDEN` (403) |
Expand Down
Loading
Loading