dig_blockstore/canonical/index.rs
1//! Canonical chain index: height-to-hash resolution, set_canonical, extend_chain.
2//!
3//! This module owns the canonical chain index operations:
4//! - [`BlockStore::get_hash_by_height`]: 3-tier lookup (BTreeMap → mmap → CF_CANONICAL).
5//! - [`BlockStore::set_canonical`] / [`BlockStore::set_canonical_batch`]: mark blocks canonical.
6//! - [`BlockStore::extend_chain`]: primary block ingestion (put + canonicalize + tip advance).
7//!
8//! # Requirements
9//!
10//! - [`CAN-003`](../../docs/requirements/domains/canonical_chain/specs/CAN-003.md) — set_canonical.
11//! - [`CAN-004`](../../docs/requirements/domains/canonical_chain/specs/CAN-004.md) — set_canonical_batch.
12//! - [`CAN-005`](../../docs/requirements/domains/canonical_chain/specs/CAN-005.md) — extend_chain.
13//! - [`CAN-006`](../../docs/requirements/domains/canonical_chain/specs/CAN-006.md) — get_hash_by_height.
14
15use std::sync::atomic::Ordering;
16
17use chia_protocol::Bytes32;
18use dig_block::L2Block;
19use rocksdb::WriteBatch;
20
21use crate::constants::CF_CANONICAL;
22use crate::encoding::{hash_key, height_key};
23use crate::error::{BlockStoreError, ERR_MUTATION_READ_ONLY};
24use crate::store::BlockStore;
25use crate::types::{BlockRecord, ChainTip};
26
27impl BlockStore {
28 /// Look up the canonical block hash at a given chain height.
29 ///
30 /// # Algorithm (dual-layer, [`CAN-006`](../docs/requirements/domains/canonical_chain/specs/CAN-006.md))
31 ///
32 /// 1. **Hot path — mmap** (`canonical.bin`): O(1) pointer-offset read at `height * 32`.
33 /// ~10ns when the page is OS-cache-resident. Consulted first.
34 /// 2. **Cold path — RocksDB** ([`CF_CANONICAL`]): 1-10us key lookup with big-endian
35 /// height key. Used when mmap is unavailable, disabled, or doesn't cover the height.
36 ///
37 /// # Returns
38 ///
39 /// - `Ok(Some(hash))` — height has a canonical block.
40 /// - `Ok(None)` — height is beyond the chain or was never canonicalized.
41 ///
42 /// # Chia analogy
43 ///
44 /// Corresponds to `Blockchain.height_to_hash(height)` in Chia, which reads from
45 /// an in-memory `BlockHeightMap` bytearray. DIG adds the durable RocksDB fallback.
46 ///
47 /// # Derived methods
48 ///
49 /// [`get_block_by_height`](Self::get_block_by_height),
50 /// [`get_header_by_height`](Self::get_header_by_height),
51 /// [`get_record_by_height`](Self::get_record_by_height), and
52 /// [`get_epoch_block_hashes`](Self::get_epoch_block_hashes) all delegate through this.
53 pub fn get_hash_by_height(&self, height: u64) -> Result<Option<Bytes32>, BlockStoreError> {
54 // PRN guard: pruned heights should not resolve even if stale mmap data exists
55 if height < self.min_retained_height_cached.load(Ordering::Acquire) {
56 return Ok(None);
57 }
58 // Tier 0: CAC-004 in-memory BTreeMap (fastest, O(log n) with no I/O)
59 if let Some(hash) = self.canonical_height_cache.read().get(&height).copied() {
60 return Ok(Some(hash));
61 }
62 // Tier 1: mmap canonical.bin (O(1) page-cache read, ~10ns)
63 if let Some(arr) = self.canonical_bin.read().read_hash_bytes(height) {
64 let hash = Bytes32::new(arr);
65 // Populate CAC-004 on read-through
66 self.insert_canonical_height_cache(height, hash);
67 return Ok(Some(hash));
68 }
69 // Tier 2: CF_CANONICAL RocksDB (1-10us)
70 let cf = self.cf(CF_CANONICAL)?;
71 let hk = height_key(height);
72 let Some(hash_bytes) = self.db.get_cf(cf, hk.as_slice())? else {
73 return Ok(None);
74 };
75 let arr: [u8; 32] = hash_bytes.as_slice().try_into().map_err(|_| {
76 BlockStoreError::Serialization(
77 "get_hash_by_height: CF_CANONICAL value must be exactly 32 bytes".into(),
78 )
79 })?;
80 let hash = Bytes32::new(arr);
81 // Populate CAC-004 on CF_CANONICAL read-through
82 self.insert_canonical_height_cache(height, hash);
83 Ok(Some(hash))
84 }
85
86 /// Insert a height→hash mapping into the CAC-004 BTreeMap, evicting the lowest
87 /// height if the cache exceeds its configured capacity.
88 pub(crate) fn insert_canonical_height_cache(&self, height: u64, hash: Bytes32) {
89 if self.canonical_height_cache_capacity == 0 {
90 return;
91 }
92 let mut cache = self.canonical_height_cache.write();
93 cache.insert(height, hash);
94 // Bounded eviction: remove lowest height when over capacity
95 while cache.len() > self.canonical_height_cache_capacity {
96 if let Some(&lowest) = cache.keys().next() {
97 cache.remove(&lowest);
98 } else {
99 break;
100 }
101 }
102 }
103
104 /// **[`CAN-003`](../docs/requirements/domains/canonical_chain/specs/CAN-003.md)** — Mark an **already stored** block as canonical at its header height.
105 ///
106 /// **Algorithm (normative order — durable first):**
107 /// 1. [`Self::get_record`] to prove the block is known (header row or cache); on miss → [`BlockStoreError::BlockNotInStore`].
108 /// 2. [`DB::put_cf`](rocksdb::DB::put_cf) on [`CF_CANONICAL`] with [`height_key`](crate::encoding::height_key)(`height`) → [`hash_key`](crate::encoding::hash_key)(`hash`).
109 /// 3. `canonical.bin` update via the same path as [`Self::put_block`] (`canonical_bin` + [`CanonicalDenseFile::write_hash`](crate::canonical::mmap::CanonicalDenseFile::write_hash)); skipped when mmap acceleration is disabled (reopen rebuilds from CF).
110 /// 4. Set [`BlockRecord::in_canonical_chain`](crate::types::BlockRecord::in_canonical_chain) = `true` in [`Self::record_cache`] (record remains RAM-only per [`TYP-004`](../docs/requirements/domains/storage_types/specs/TYP-004.md)) — **does not** change [`BlockRecord::status`](crate::types::BlockRecord::status); operators may still use [`Self::update_status`](Self::update_status) for lifecycle.
111 ///
112 /// **Idempotency:** Re-calling with the same hash overwrites CF/mmap with identical bytes and leaves the record flag `true` ([`CAN-003`](../docs/requirements/domains/canonical_chain/specs/CAN-003.md) § Idempotency).
113 ///
114 /// **Height collisions:** A second call for a **different** hash at the same height overwrites the height index (reorg staging); both blocks must exist in the store.
115 ///
116 /// **Read-only:** [`BlockStoreError::Serialization`] with [`ERR_MUTATION_READ_ONLY`](crate::error::ERR_MUTATION_READ_ONLY) — same contract as [`Self::put_block`].
117 pub fn set_canonical(&self, hash: &Bytes32) -> Result<(), BlockStoreError> {
118 if self.read_only {
119 return Err(BlockStoreError::Serialization(
120 ERR_MUTATION_READ_ONLY.into(),
121 ));
122 }
123 let Some(record) = self.get_record(hash)? else {
124 return Err(BlockStoreError::BlockNotInStore(*hash));
125 };
126 let height = record.height;
127 let cf = self.cf(CF_CANONICAL)?;
128 self.db
129 .put_cf(cf, height_key(height), hash_key(hash).as_slice())?;
130 self.canonical_bin.write().extend_write(height, hash)?;
131 // CAC-004: populate height→hash cache
132 self.insert_canonical_height_cache(height, *hash);
133 // CAC-005: populate hash→height cache
134 self.hash_to_height_cache.insert(*hash, height);
135 if let Some(r) = self.record_cache.lock().get_mut(hash) {
136 r.in_canonical_chain = true;
137 } else {
138 let mut r = record;
139 r.in_canonical_chain = true;
140 self.record_cache.lock().insert(*hash, r);
141 }
142 Ok(())
143 }
144
145 /// **[`CAN-004`](../docs/requirements/domains/canonical_chain/specs/CAN-004.md)** — Promote **many** already-stored blocks to the canonical height→hash index in one **atomic** RocksDB commit.
146 ///
147 /// **Why a separate API from [`Self::set_canonical`]:** Reorgs ([`ROR-003`](../docs/requirements/domains/rollback_reorg/specs/ROR-003.md)) must flip many heights at once; a single [`WriteBatch`](rocksdb::WriteBatch) gives all-or-nothing durability in [`CF_CANONICAL`](crate::constants::CF_CANONICAL) ([`NORMATIVE` § CAN-004](../docs/requirements/domains/canonical_chain/NORMATIVE.md#can-004-set_canonical_batch)).
148 ///
149 /// **Algorithm (matches CAN-004 spec — validate, durable batch, then best-effort hot path):**
150 /// 1. **Fail-fast validation:** For each input hash (in order), [`Self::get_record`]. First miss → [`BlockStoreError::BlockNotInStore`] **before** any `WriteBatch` mutation so callers never observe partial CF updates from this method.
151 /// 2. **Atomic CF write:** One [`WriteBatch`] with all `height_key(record.height) → hash_key(hash)` rows, then [`DB::write`](rocksdb::DB::write).
152 /// 3. **Post-commit:** Same as [`Self::set_canonical`] — [`Self::canonical_bin`]’s mmap writer ([`CanonicalDenseFile::write_hash`](crate::canonical::mmap::CanonicalDenseFile::write_hash) via `extend_write` in `src/canonical/mmap.rs`) per pair, then set [`BlockRecord::in_canonical_chain`](crate::types::BlockRecord::in_canonical_chain) in [`Self::record_cache`] (re-insert on eviction, same as CAN-003).
153 ///
154 /// **Empty slice:** [`Ok(())] immediately — no I/O ([`CAN-004`](../docs/requirements/domains/canonical_chain/specs/CAN-004.md) acceptance).
155 ///
156 /// **Crash window:** If the process dies after `db.write` but before mmap/cache finish, [`CAN-001`](../docs/requirements/domains/canonical_chain/specs/CAN-001.md) reopen rebuilds `canonical.bin` from [`CF_CANONICAL`].
157 ///
158 /// **Read-only:** Same [`BlockStoreError::Serialization`] + [`ERR_MUTATION_READ_ONLY`](crate::error::ERR_MUTATION_READ_ONLY) contract as [`Self::put_block`] / [`Self::set_canonical`].
159 pub fn set_canonical_batch(&self, hashes: &[Bytes32]) -> Result<(), BlockStoreError> {
160 if self.read_only {
161 return Err(BlockStoreError::Serialization(
162 ERR_MUTATION_READ_ONLY.into(),
163 ));
164 }
165 if hashes.is_empty() {
166 return Ok(());
167 }
168 let mut validated: Vec<(Bytes32, BlockRecord)> = Vec::with_capacity(hashes.len());
169 for hash in hashes {
170 let Some(record) = self.get_record(hash)? else {
171 return Err(BlockStoreError::BlockNotInStore(*hash));
172 };
173 validated.push((*hash, record));
174 }
175 let cf = self.cf(CF_CANONICAL)?;
176 let mut batch = WriteBatch::default();
177 for (hash, record) in &validated {
178 batch.put_cf(
179 &cf,
180 height_key(record.height).as_slice(),
181 hash_key(hash).as_slice(),
182 );
183 }
184 self.db.write(batch)?;
185 for (hash, record) in &validated {
186 self.canonical_bin
187 .write()
188 .extend_write(record.height, hash)?;
189 if let Some(r) = self.record_cache.lock().get_mut(hash) {
190 r.in_canonical_chain = true;
191 } else {
192 let mut r = record.clone();
193 r.in_canonical_chain = true;
194 self.record_cache.lock().insert(*hash, r);
195 }
196 }
197 Ok(())
198 }
199
200 /// Primary block ingestion API for normal chain-following operation.
201 ///
202 /// Combines three operations into one call:
203 /// 1. **Store** — [`Self::put`] writes body to `CF_BLOCKS`, header to `CF_HEADERS`,
204 /// and height→hash to `CF_CANONICAL` (canonical=true).
205 /// 2. **Tip advance** — [`Self::set_tip`] persists the new chain peak to `CF_METADATA`
206 /// and updates the in-memory `RwLock<Option<ChainTip>>`.
207 ///
208 /// # Returns
209 ///
210 /// - `Ok(true)` — block was novel; stored, canonicalized, and tip advanced.
211 /// - `Ok(false)` — block hash was already in the store (duplicate); no changes made.
212 ///
213 /// # Atomicity ([`CAN-005`](../docs/requirements/domains/canonical_chain/specs/CAN-005.md))
214 ///
215 /// The individual operations are not wrapped in a single RocksDB transaction, but the
216 /// ordering ensures safe crash recovery:
217 /// - Crash after `put` but before `set_tip`: block is stored and canonical, but tip
218 /// is stale. On restart, tip can be corrected by scanning CF_CANONICAL.
219 /// - The duplicate check via [`has_block`](Self::has_block) makes re-ingestion safe.
220 ///
221 /// # Chia analogy
222 ///
223 /// Corresponds to the storage portion of `Blockchain.receive_block` →
224 /// `BlockStore.add_full_block` in Chia, where the block is stored, the peak is
225 /// updated, and the height map is advanced.
226 ///
227 /// # Errors
228 ///
229 /// - [`BlockStoreError::Serialization`] with [`ERR_MUTATION_READ_ONLY`] on read-only handles.
230 /// - RocksDB or compression errors from the underlying `put` / `set_tip` calls.
231 pub fn extend_chain(&self, block: &L2Block) -> Result<bool, BlockStoreError> {
232 let hash = block.hash();
233
234 // Duplicate detection: has_block checks cache first, then RocksDB key existence
235 // (no deserialization). Matches Chia’s INSERT OR IGNORE semantics.
236 if self.has_block(&hash)? {
237 return Ok(false);
238 }
239
240 // Store block body + header + canonical index entry in one WriteBatch.
241 // `put(block, true)` handles CF_BLOCKS, CF_HEADERS, CF_CANONICAL, canonical.bin,
242 // and all cache inserts (block_cache, header_cache, record_cache).
243 self.put(block, true)?;
244
245 // Advance the chain tip. This is a separate RocksDB write (not in the same
246 // WriteBatch as put), but the ordering is safe for crash recovery — see
247 // CAN-005 spec § Atomicity Considerations.
248 self.set_tip(ChainTip {
249 hash,
250 height: block.height(),
251 })?;
252
253 Ok(true)
254 }
255}