Skip to main content

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}