diff --git a/Cargo.lock b/Cargo.lock index bbcbcdf546d..73af20c8a40 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,18 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "TinyUFO" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "011f852ef553046d2f180ecea9e91b9460387d10a75e3a995392e5130d626e20" +dependencies = [ + "ahash", + "crossbeam-queue", + "crossbeam-skiplist", + "flurry", +] + [[package]] name = "addr2line" version = "0.21.0" @@ -25,14 +37,16 @@ checksum = "aae1277d39aeec15cb388266ecc24b11c80469deae6067e17a1a7aa9e5c1f234" [[package]] name = "ahash" -version = "0.8.8" +version = "0.8.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42cd52102d3df161c77a887b608d7a4897d7cc112886a9537b738a887a03aaff" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" dependencies = [ "cfg-if 1.0.0", + "const-random", + "getrandom 0.3.4", "once_cell", "version_check", - "zerocopy 0.7.32", + "zerocopy", ] [[package]] @@ -804,6 +818,7 @@ checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" name = "clarity" version = "0.0.1" dependencies = [ + "TinyUFO", "assert-json-diff 1.1.0", "clarity-types 0.0.1", "criterion", @@ -935,6 +950,26 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom 0.2.12", + "once_cell", + "tiny-keccak", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -1033,6 +1068,25 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "crossbeam-queue" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-skiplist" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df29de440c58ca2cc6e587ec3d22347551a32435fbde9d2bff64e78a9ffa151b" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.19" @@ -1452,6 +1506,18 @@ dependencies = [ "libredox 0.1.12", ] +[[package]] +name = "flurry" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf5efcf77a4da27927d3ab0509dec5b0954bb3bc59da5a1de9e52642ebd4cdf9" +dependencies = [ + "ahash", + "num_cpus", + "parking_lot", + "seize", +] + [[package]] name = "fnv" version = "1.0.7" @@ -1734,7 +1800,7 @@ checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" dependencies = [ "cfg-if 1.0.0", "crunchy", - "zerocopy 0.8.27", + "zerocopy", ] [[package]] @@ -1818,6 +1884,12 @@ version = "0.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bd5256b483761cd23699d0da46cc6fd2ee3be420bbe6d020ae4a091e70b7e9fd" +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + [[package]] name = "hex" version = "0.4.3" @@ -2780,6 +2852,16 @@ dependencies = [ "autocfg", ] +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi 0.5.2", + "libc", +] + [[package]] name = "num_threads" version = "0.1.6" @@ -3948,6 +4030,12 @@ dependencies = [ "libc", ] +[[package]] +name = "seize" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "689224d06523904ebcc9b482c6a3f4f7fb396096645c4cd10c0d2ff7371a34d3" + [[package]] name = "semver" version = "1.0.21" @@ -4873,6 +4961,15 @@ dependencies = [ "time-core", ] +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + [[package]] name = "tiny_http" version = "0.12.0" @@ -5778,33 +5875,13 @@ dependencies = [ "rustix 1.1.3", ] -[[package]] -name = "zerocopy" -version = "0.7.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74d4d3961e53fa4c9a25a8637fc2bfaf2595b3d3ae34875568a5cf64787716be" -dependencies = [ - "zerocopy-derive 0.7.32", -] - [[package]] name = "zerocopy" version = "0.8.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0894878a5fa3edfd6da3f88c4805f4c8558e2b996227a3d864f47fe11e38282c" dependencies = [ - "zerocopy-derive 0.8.27", -] - -[[package]] -name = "zerocopy-derive" -version = "0.7.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ce1b18ccd8e73a9321186f97e46f9f04b778851177567b1975109d26a08d2a6" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", + "zerocopy-derive", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 41800434064..bd2cc188d02 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,6 +37,7 @@ rusqlite = { version = "0.31.0", features = ["blob", "serde_json", "i128_blob", thiserror = "1.0.65" tikv-jemallocator = "0.5.4" toml = "0.5.6" +TinyUFO = { version = "0.8.0", default-features = false } # Use a bit more than default optimization for # dev builds to speed up test execution diff --git a/changelog.d/7049-clarity-type-size-approximation.added b/changelog.d/7049-clarity-type-size-approximation.added new file mode 100644 index 00000000000..b79a3077027 --- /dev/null +++ b/changelog.d/7049-clarity-type-size-approximation.added @@ -0,0 +1 @@ +New `ResidentBytes` trait for types which can approximate their resident memory size (stack+heap) \ No newline at end of file diff --git a/changelog.d/7082-clarity-contract-cache b/changelog.d/7082-clarity-contract-cache new file mode 100644 index 00000000000..af378100609 --- /dev/null +++ b/changelog.d/7082-clarity-contract-cache @@ -0,0 +1 @@ +Implements caching of parsed Clarity `Contract`s (deserialized ASTs) \ No newline at end of file diff --git a/changelog.d/README.md b/changelog.d/README.md index d29d8d5c7ad..3e4f4bd8632 100644 --- a/changelog.d/README.md +++ b/changelog.d/README.md @@ -24,7 +24,7 @@ CHANGELOG.md. 2. Write the changelog entry text in the file (one or more lines of markdown): - ``` + ```text Added `marf_compress` as a node configuration parameter to enable MARF compression feature ([#6811](https://github.com/stacks-network/stacks-core/pull/6811)) ``` diff --git a/clarity-types/src/lib.rs b/clarity-types/src/lib.rs index 1b1bef2c501..ca9cb1f286c 100644 --- a/clarity-types/src/lib.rs +++ b/clarity-types/src/lib.rs @@ -26,6 +26,7 @@ pub use stacks_common::{ pub mod errors; pub mod representations; +pub mod resident_bytes; pub mod types; pub use errors::{ClarityTypeError, IncomparableError}; diff --git a/clarity-types/src/resident_bytes.rs b/clarity-types/src/resident_bytes.rs new file mode 100644 index 00000000000..8935bf5459d --- /dev/null +++ b/clarity-types/src/resident_bytes.rs @@ -0,0 +1,1201 @@ +// Copyright (C) 2026 Stacks Open Internet Foundation +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; +use std::mem::size_of; +use std::sync::Arc; + +#[cfg(feature = "developer-mode")] +use crate::representations::Span; +use crate::representations::{ + ClarityName, ContractName, SymbolicExpression, SymbolicExpressionType, TraitDefinition, +}; +use crate::types::signatures::{ + BufferLength, CallableSubtype, ListTypeData, SequenceSubtype, StringSubtype, StringUTF8Length, + TupleTypeSignature, TypeSignature, +}; +use crate::types::{ + ASCIIData, BuffData, CallableData, CharType, FunctionIdentifier, ListData, OptionalData, + PrincipalData, QualifiedContractIdentifier, ResponseData, SequenceData, StandardPrincipalData, + TraitIdentifier, TupleData, UTF8Data, Value, +}; + +/// Estimated overhead for `Arc`: `strong + weak counts + allocation header`. +const ARC_OVERHEAD: usize = 16; + +// The `btree` and `hashmap` modules below contain heuristic constants derived from std's internal +// implementations (as of Rust 1.94 / hashbrown 0.15). They provide reasonable estimates of +// structural overhead, not exact byte counts. + +/// Layout constants for `std::collections::BTreeMap` / `BTreeSet`. +/// +/// BTreeMap uses `B=6`, so nodes hold up to `2*B-1 = 11` entries. Leaf nodes store keys+values; +/// internal nodes add 12 edge pointers. ~32 bytes overhead per node (metadata + allocator header), +/// ~2/3 average fill (~7 entries/node, ~8 children for internal nodes). +mod btree { + use std::mem::size_of; + + /// Maximum entries per node (`B=6` → `2*B-1 = 11`). + pub const NODE_CAPACITY: usize = 11; + /// Estimated average entries per node in a steady-state B-tree (~2/3 fill). + pub const AVERAGE_FILL: usize = 7; + /// Average children per internal node at ~2/3 fill. + pub const AVG_FANOUT: usize = AVERAGE_FILL + 1; + /// Per-node overhead: `(parent ptr + idx + len + padding) + allocator header`. + pub const NODE_OVERHEAD: usize = 32; + /// Additional per-node size for internal nodes: `[MaybeUninit>; CAPACITY + 1]`. + pub const EDGE_ARRAY_SIZE: usize = (NODE_CAPACITY + 1) * size_of::(); + + /// Estimate total BTree node count (leaves + internal) and how many are internal. + pub fn node_counts(len: usize) -> (usize, usize) { + let leaves = len.div_ceil(AVERAGE_FILL); + let mut internal = 0; + let mut children_at_level = leaves; + while children_at_level > 1 { + let parents = children_at_level.div_ceil(AVG_FANOUT); + internal += parents; + children_at_level = parents; + } + (leaves + internal, internal) + } + + #[cfg(test)] + mod tests { + use super::*; + + #[test] + fn btree_node_counts() { + // 7 entries: 1 leaf, 0 internal + assert_eq!(node_counts(7), (1, 0)); + // 12 entries: 2 leaves + 1 internal root + let (total, internal) = node_counts(12); + assert_eq!(total, 3); + assert_eq!(internal, 1); + // 0 entries edge case + assert_eq!(node_counts(0), (0, 0)); + } + } +} + +/// Layout constants for [`HashMap`] / [`HashSet`] (hashbrown-backed since Rust 1.36). +/// +/// `hashbrown` uses a 7/8 max load factor and 1-byte control tags per bucket. +/// +/// The control array is padded by `Group::WIDTH` (4/8/16 depending on SIMD support); we use 16 as +/// an upper bound. +mod hashmap { + /// Inverse of `hashbrown`'s max load factor (7/8), as a fraction: `buckets ~= (capacity * 8/7)`. + pub const LOAD_FACTOR_INV_NUM: usize = 8; + pub const LOAD_FACTOR_INV_DEN: usize = 7; + /// Upper bound for SIMD group-width padding. In hashbrown 0.15, Group::WIDTH varies by target + /// and implementation (4/8/16 bytes), so we use 16 as a conservative upper bound for + /// control-byte padding overhead. + pub const CONTROL_GROUP_PADDING: usize = 16; + + /// Calculate the number of buckets for a given `HashMap` capacity, based on hashbrown's growth + /// strategy and load factor. + pub fn buckets_for_capacity(cap: usize) -> usize { + (cap * LOAD_FACTOR_INV_NUM).div_ceil(LOAD_FACTOR_INV_DEN) + } +} + +/// Approximate in-memory footprint, in bytes. +/// +/// Split into [`heap_bytes()`](Self::heap_bytes) (children only) and +/// [`resident_bytes()`](Self::resident_bytes) (inline + heap) to avoid double-counting in nested +/// types — containers call `heap_bytes()` on children, only the outermost caller should use +/// `resident_bytes()`. +pub trait ResidentBytes: Sized { + /// Total approximate memory footprint of this instance. + /// + /// Default implementation: [`size_of::()`](size_of) (inline size) + + /// [`heap_bytes()`](Self::heap_bytes) (additional heap allocations). + fn resident_bytes(&self) -> usize { + // Note: if we ever need to support unsized types, we should switch to size_of_val(self) + // here instead of size_of::() and remove the Sized trait bound. + std::mem::size_of::() + self.heap_bytes() + } + + /// Heap allocations only, beyond the inline size reported by [`size_of::()`](size_of). + fn heap_bytes(&self) -> usize; +} + +impl ResidentBytes for String { + fn heap_bytes(&self) -> usize { + self.capacity() + } +} + +impl ResidentBytes for Vec { + fn heap_bytes(&self) -> usize { + // Backing array: capacity slots (inline size per slot) + let backing = self.capacity() * size_of::(); + + // Children's heap allocations + let children: usize = self.iter().map(|v| v.heap_bytes()).sum(); + + // Total heap + backing + children + } +} + +impl ResidentBytes for Box { + fn heap_bytes(&self) -> usize { + // Box heap-allocates the pointee: its inline size + its own heap + size_of::() + (**self).heap_bytes() + } +} + +impl ResidentBytes for Option { + fn heap_bytes(&self) -> usize { + match self { + // For Some, the T is inline in the Option; only count T's heap + Some(v) => v.heap_bytes(), + None => 0, + } + } +} + +impl ResidentBytes for Arc { + fn heap_bytes(&self) -> usize { + // Counts the Arc allocation (header + pointee). Shared backing may be overcounted if + // multiple Arc handles to the same allocation are reachable in one measured graph. + ARC_OVERHEAD + size_of::() + (**self).heap_bytes() + } +} + +impl ResidentBytes for HashMap { + fn heap_bytes(&self) -> usize { + let cap = self.capacity(); + if cap == 0 { + // HashMap::new() does not allocate until first insert. + return 0; + } + + let buckets = hashmap::buckets_for_capacity(cap); + let backing = buckets * size_of::<(K, V)>() + buckets + hashmap::CONTROL_GROUP_PADDING; + + // Children's heap allocations (only for occupied entries) + let children: usize = self + .iter() + .map(|(k, v)| k.heap_bytes() + v.heap_bytes()) + .sum(); + + backing + children + } +} + +impl ResidentBytes for BTreeMap { + fn heap_bytes(&self) -> usize { + if self.is_empty() { + return 0; // Empty BTreeMaps do not allocate on the heap. + } + + let (total_nodes, internal_nodes) = btree::node_counts(self.len()); + + // Base node size (shared by leaf and internal): overhead + key/value arrays + let leaf_size = btree::NODE_OVERHEAD + + (btree::NODE_CAPACITY * size_of::()) + + (btree::NODE_CAPACITY * size_of::()); + // Internal nodes additionally carry an edge pointer array + let structural = total_nodes * leaf_size + internal_nodes * btree::EDGE_ARRAY_SIZE; + + // Children's heap allocations (only for occupied entries) + let children: usize = self + .iter() + .map(|(k, v)| k.heap_bytes() + v.heap_bytes()) + .sum(); + + structural + children + } +} + +impl ResidentBytes for BTreeSet { + fn heap_bytes(&self) -> usize { + if self.is_empty() { + return 0; + } + + let (total_nodes, internal_nodes) = btree::node_counts(self.len()); + + // BTreeSet is backed by BTreeMap — vals array is zero-size + let leaf_size = btree::NODE_OVERHEAD + (btree::NODE_CAPACITY * size_of::()); + let structural = total_nodes * leaf_size + internal_nodes * btree::EDGE_ARRAY_SIZE; + let children: usize = self.iter().map(|v| v.heap_bytes()).sum(); + structural + children + } +} + +impl ResidentBytes for HashSet { + fn heap_bytes(&self) -> usize { + let cap = self.capacity(); + if cap == 0 { + return 0; + } + + let buckets = hashmap::buckets_for_capacity(cap); + let backing = buckets * size_of::() + buckets + hashmap::CONTROL_GROUP_PADDING; + let children: usize = self.iter().map(|v| v.heap_bytes()).sum(); + backing + children + } +} + +impl ResidentBytes for (A, B) { + fn heap_bytes(&self) -> usize { + self.0.heap_bytes() + self.1.heap_bytes() + } +} + +// Primitive types: no heap allocation (stack-only) + +impl ResidentBytes for bool { + fn heap_bytes(&self) -> usize { + 0 + } +} +impl ResidentBytes for u8 { + fn heap_bytes(&self) -> usize { + 0 + } +} +impl ResidentBytes for u32 { + fn heap_bytes(&self) -> usize { + 0 + } +} +impl ResidentBytes for u64 { + fn heap_bytes(&self) -> usize { + 0 + } +} +impl ResidentBytes for u128 { + fn heap_bytes(&self) -> usize { + 0 + } +} +impl ResidentBytes for i128 { + fn heap_bytes(&self) -> usize { + 0 + } +} + +impl ResidentBytes for Value { + fn heap_bytes(&self) -> usize { + match self { + Value::Int(_) | Value::UInt(_) | Value::Bool(_) => 0, + Value::Sequence(data) => data.heap_bytes(), + Value::Principal(data) => data.heap_bytes(), + Value::Tuple(data) => data.heap_bytes(), + Value::Optional(data) => data.heap_bytes(), + Value::Response(data) => data.heap_bytes(), + Value::CallableContract(data) => data.heap_bytes(), + } + } +} + +impl ResidentBytes for SequenceData { + fn heap_bytes(&self) -> usize { + match self { + SequenceData::Buffer(buf) => buf.heap_bytes(), + SequenceData::List(list) => list.heap_bytes(), + SequenceData::String(char_type) => char_type.heap_bytes(), + } + } +} + +impl ResidentBytes for BuffData { + fn heap_bytes(&self) -> usize { + self.data.heap_bytes() + } +} + +impl ResidentBytes for ListData { + fn heap_bytes(&self) -> usize { + self.data.heap_bytes() + self.type_signature.heap_bytes() + } +} + +impl ResidentBytes for CharType { + fn heap_bytes(&self) -> usize { + match self { + CharType::ASCII(data) => data.heap_bytes(), + CharType::UTF8(data) => data.heap_bytes(), + } + } +} + +impl ResidentBytes for ASCIIData { + fn heap_bytes(&self) -> usize { + self.data.heap_bytes() + } +} + +impl ResidentBytes for UTF8Data { + fn heap_bytes(&self) -> usize { + self.data.heap_bytes() + } +} + +impl ResidentBytes for TupleData { + fn heap_bytes(&self) -> usize { + self.type_signature.heap_bytes() + self.data_map.heap_bytes() + } +} + +impl ResidentBytes for OptionalData { + fn heap_bytes(&self) -> usize { + self.data.heap_bytes() + } +} + +impl ResidentBytes for ResponseData { + fn heap_bytes(&self) -> usize { + self.data.heap_bytes() + } +} + +impl ResidentBytes for CallableData { + fn heap_bytes(&self) -> usize { + self.contract_identifier.heap_bytes() + self.trait_identifier.heap_bytes() + } +} + +impl ResidentBytes for PrincipalData { + fn heap_bytes(&self) -> usize { + match self { + PrincipalData::Standard(data) => data.heap_bytes(), + PrincipalData::Contract(data) => data.heap_bytes(), + } + } +} + +impl ResidentBytes for StandardPrincipalData { + fn heap_bytes(&self) -> usize { + 0 // Fixed-size: u8 + [u8; 20], no heap allocation + } +} + +impl ResidentBytes for QualifiedContractIdentifier { + fn heap_bytes(&self) -> usize { + self.issuer.heap_bytes() + self.name.heap_bytes() + } +} + +impl ResidentBytes for ClarityName { + fn heap_bytes(&self) -> usize { + self.heap_capacity() + } +} + +impl ResidentBytes for ContractName { + fn heap_bytes(&self) -> usize { + self.heap_capacity() + } +} + +impl ResidentBytes for TraitIdentifier { + fn heap_bytes(&self) -> usize { + self.name.heap_bytes() + self.contract_identifier.heap_bytes() + } +} + +impl ResidentBytes for FunctionIdentifier { + fn heap_bytes(&self) -> usize { + self.heap_capacity() + } +} + +impl ResidentBytes for TypeSignature { + fn heap_bytes(&self) -> usize { + match self { + TypeSignature::NoType + | TypeSignature::IntType + | TypeSignature::UIntType + | TypeSignature::BoolType + | TypeSignature::PrincipalType => 0, + TypeSignature::SequenceType(subtype) => subtype.heap_bytes(), + TypeSignature::TupleType(tuple_sig) => tuple_sig.heap_bytes(), + TypeSignature::OptionalType(inner) => inner.heap_bytes(), + TypeSignature::ResponseType(inner) => inner.heap_bytes(), + TypeSignature::CallableType(subtype) => subtype.heap_bytes(), + TypeSignature::ListUnionType(set) => set.heap_bytes(), + TypeSignature::TraitReferenceType(trait_id) => trait_id.heap_bytes(), + } + } +} + +impl ResidentBytes for TupleTypeSignature { + fn heap_bytes(&self) -> usize { + // TupleTypeSignature wraps Arc>. get_type_map() + // returns &BTreeMap — count Arc overhead + map header + contents. + let map_header = size_of::>(); + ARC_OVERHEAD + map_header + self.get_type_map().heap_bytes() + } +} + +impl ResidentBytes for SequenceSubtype { + fn heap_bytes(&self) -> usize { + match self { + SequenceSubtype::BufferType(len) => len.heap_bytes(), + SequenceSubtype::ListType(list) => list.heap_bytes(), + SequenceSubtype::StringType(string) => string.heap_bytes(), + } + } +} + +impl ResidentBytes for ListTypeData { + fn heap_bytes(&self) -> usize { + // max_len: u32 (no heap), entry_type: Box + size_of::() + self.get_list_item_type().heap_bytes() + } +} + +impl ResidentBytes for StringSubtype { + fn heap_bytes(&self) -> usize { + 0 // Both variants (ASCII, UTF8) contain only u32 newtypes + } +} + +impl ResidentBytes for BufferLength { + fn heap_bytes(&self) -> usize { + 0 // u32 newtype + } +} + +impl ResidentBytes for StringUTF8Length { + fn heap_bytes(&self) -> usize { + 0 // u32 newtype + } +} + +impl ResidentBytes for CallableSubtype { + fn heap_bytes(&self) -> usize { + match self { + CallableSubtype::Principal(id) => id.heap_bytes(), + CallableSubtype::Trait(trait_id) => trait_id.heap_bytes(), + } + } +} + +#[cfg(feature = "developer-mode")] +impl ResidentBytes for Span { + fn heap_bytes(&self) -> usize { + 0 // 4 × u32, all inline + } +} + +impl ResidentBytes for SymbolicExpression { + fn heap_bytes(&self) -> usize { + #[allow(unused_mut)] + let mut total = self.expr.heap_bytes(); + // id is u64 — no heap allocation + + #[cfg(feature = "developer-mode")] + { + // span is inline (no heap), but pre_comments, end_line_comment, and + // post_comments have heap allocations via Vec/String. + total += self.pre_comments.heap_bytes(); + total += self.end_line_comment.heap_bytes(); + total += self.post_comments.heap_bytes(); + } + + total + } +} + +impl ResidentBytes for SymbolicExpressionType { + fn heap_bytes(&self) -> usize { + match self { + SymbolicExpressionType::AtomValue(value) + | SymbolicExpressionType::LiteralValue(value) => value.heap_bytes(), + SymbolicExpressionType::Atom(name) => name.heap_bytes(), + SymbolicExpressionType::List(exprs) => exprs.heap_bytes(), + SymbolicExpressionType::Field(trait_id) => trait_id.heap_bytes(), + SymbolicExpressionType::TraitReference(name, defn) => { + name.heap_bytes() + defn.heap_bytes() + } + } + } +} + +impl ResidentBytes for TraitDefinition { + fn heap_bytes(&self) -> usize { + match self { + TraitDefinition::Defined(id) | TraitDefinition::Imported(id) => id.heap_bytes(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + mod primitives { + use super::*; + + #[test] + fn primitive_heap_bytes_zero() { + assert_eq!(true.heap_bytes(), 0); + assert_eq!(0u8.heap_bytes(), 0); + assert_eq!(0u32.heap_bytes(), 0); + assert_eq!(0u64.heap_bytes(), 0); + assert_eq!(0u128.heap_bytes(), 0); + assert_eq!(0i128.heap_bytes(), 0); + } + + #[test] + fn u64_resident_bytes() { + let v: u64 = 42; + assert_eq!(v.resident_bytes(), 8); + assert_eq!(v.heap_bytes(), 0); + } + } + + mod std_containers { + use super::*; + + const HASHMAP_CAPACITY_TRANSITIONS: &[(usize, usize, usize)] = &[ + (0, 0, 0), + (1, 3, 4), + (4, 7, 8), + (8, 14, 16), + (15, 28, 32), + (29, 56, 64), + (57, 112, 128), + (113, 224, 256), + (225, 448, 512), + ]; + + #[test] + fn string() { + let s = String::from("hello world"); + assert!(s.resident_bytes() >= size_of::() + 11); + assert!(s.heap_bytes() >= 11); + } + + #[test] + fn vec() { + let v: Vec = vec![1, 2, 3, 4, 5]; + assert!(v.heap_bytes() >= 40); + assert!(v.resident_bytes() >= size_of::>() + 40); + } + + #[test] + fn boxed() { + let b = Box::new(String::from("boxed")); + assert!(b.heap_bytes() >= size_of::() + 5); + } + + #[test] + fn option_none() { + let opt: Option> = None; + assert_eq!(opt.heap_bytes(), 0); + assert!(opt.resident_bytes() >= size_of::>>()); + } + + #[test] + fn option_some() { + let opt: Option> = Some(Box::new(Value::Int(42))); + assert!(opt.heap_bytes() >= size_of::()); + } + + #[test] + fn arc() { + let a = Arc::new(String::from("hello")); + assert!(a.heap_bytes() >= ARC_OVERHEAD + size_of::() + 5); + } + + #[test] + fn tuple_pair() { + let t = ("hello".to_string(), 42u64); + assert!(t.heap_bytes() >= 5); + assert_eq!(42u64.heap_bytes(), 0); + } + + #[test] + fn hashmap() { + let mut m: HashMap = HashMap::new(); + m.insert("key1".into(), 1); + m.insert("key2".into(), 2); + + let cap = m.capacity(); + let buckets = hashmap::buckets_for_capacity(cap); + // Structural lower bound: buckets * entry_size + control bytes + let min_structural = buckets * size_of::<(String, u64)>() + buckets; + // Child heap: each key String has at least 4 bytes of heap + let min_child_heap = 2 * 4; + assert!( + m.heap_bytes() >= min_structural + min_child_heap, + "heap_bytes {} < expected minimum {}", + m.heap_bytes(), + min_structural + min_child_heap, + ); + } + + #[test] + fn hashmap_empty() { + let m: HashMap = HashMap::new(); + assert_eq!(m.heap_bytes(), 0); + } + + #[test] + fn hashmap_with_capacity_progression_matches_expected_boundaries() { + let mut observed = Vec::new(); + let mut previous = None; + + for requested in 0usize..=256 { + let map = HashMap::::with_capacity(requested); + let cap = map.capacity(); + let buckets = if cap == 0 { + 0 + } else { + hashmap::buckets_for_capacity(cap) + }; + + if previous != Some((cap, buckets)) { + observed.push((requested, cap, buckets)); + previous = Some((cap, buckets)); + } + } + + assert_eq!(observed.as_slice(), HASHMAP_CAPACITY_TRANSITIONS); + } + + #[test] + fn hashmap_capacity_boundaries_match_bucket_accounting() { + for (_, expected_cap, _) in HASHMAP_CAPACITY_TRANSITIONS.iter().copied().skip(1) { + let mut map = HashMap::with_capacity(expected_cap); + + assert_eq!( + map.capacity(), + expected_cap, + "HashMap::with_capacity({expected_cap}) returned capacity {cap}", + cap = map.capacity(), + ); + + for entry in 0..expected_cap { + map.insert(entry as u64, entry as u64); + assert_eq!( + map.capacity(), + expected_cap, + "HashMap grew before reaching capacity {expected_cap}; capacity is {cap} after {inserts} inserts", + cap = map.capacity(), + inserts = entry + 1, + ); + } + + map.insert(expected_cap as u64, expected_cap as u64); + assert!( + map.capacity() > expected_cap, + "HashMap did not grow after exceeding capacity {expected_cap}; capacity remained {cap}", + cap = map.capacity(), + ); + } + } + + #[test] + fn hashset() { + let mut s: HashSet = HashSet::new(); + for i in 0..10 { + s.insert(i); + } + + let cap = s.capacity(); + let buckets = hashmap::buckets_for_capacity(cap); + let min_structural = buckets * size_of::() + buckets; + assert!( + s.heap_bytes() >= min_structural, + "heap_bytes {} < expected minimum {}", + s.heap_bytes(), + min_structural, + ); + } + + #[test] + fn hashset_empty() { + let s: HashSet = HashSet::new(); + assert_eq!(s.heap_bytes(), 0); + } + + #[test] + fn btreemap() { + let mut m = BTreeMap::new(); + for i in 0..20u64 { + m.insert(i, i); + } + + let (total_nodes, internal_nodes) = btree::node_counts(20); + let leaf_size = btree::NODE_OVERHEAD + + btree::NODE_CAPACITY * size_of::() + + btree::NODE_CAPACITY * size_of::(); + let min_structural = total_nodes * leaf_size + internal_nodes * btree::EDGE_ARRAY_SIZE; + assert!( + m.heap_bytes() >= min_structural, + "heap_bytes {} < expected minimum {}", + m.heap_bytes(), + min_structural, + ); + // Must account for internal nodes (20 entries > single-leaf capacity of 11) + assert!(internal_nodes >= 1); + } + + #[test] + fn btreemap_empty() { + let m: BTreeMap = BTreeMap::new(); + assert_eq!(m.heap_bytes(), 0); + } + + #[test] + fn btreeset() { + let s: BTreeSet = (0..15).collect(); + + let (total_nodes, _) = btree::node_counts(15); + let leaf_size = btree::NODE_OVERHEAD + btree::NODE_CAPACITY * size_of::(); + assert!( + s.heap_bytes() >= total_nodes * leaf_size, + "heap_bytes {} < expected minimum {}", + s.heap_bytes(), + total_nodes * leaf_size, + ); + } + + #[test] + fn btreeset_empty() { + let s: BTreeSet = BTreeSet::new(); + assert_eq!(s.heap_bytes(), 0); + } + } + + mod clarity_values { + use super::*; + + #[test] + fn int_uint_bool_no_heap() { + assert_eq!(Value::Int(42).heap_bytes(), 0); + assert_eq!(Value::UInt(42).heap_bytes(), 0); + assert_eq!(Value::Bool(true).heap_bytes(), 0); + let int_size = Value::Int(42).resident_bytes(); + assert!( + int_size >= size_of::(), + "Int resident_bytes ({int_size}) should be >= size_of::()" + ); + } + + #[test] + fn sequence_buffer() { + let buf = BuffData { + data: vec![0u8; 100], + }; + assert!(buf.heap_bytes() >= 100); + } + + #[test] + fn sequence_data_buffer_variant() { + let seq = SequenceData::Buffer(BuffData { + data: vec![0u8; 64], + }); + assert!(seq.heap_bytes() >= 64); + } + + #[test] + fn sequence_ascii() { + let v = Value::Sequence(SequenceData::String(CharType::ASCII(ASCIIData { + data: vec![b'a', b'b', b'c'], + }))); + assert!(v.heap_bytes() >= 3); + } + + #[test] + fn sequence_utf8() { + let v = Value::Sequence(SequenceData::String(CharType::UTF8(UTF8Data { + data: vec![vec![0xC3, 0xA9], vec![0xC3, 0xB1]], + }))); + assert!(v.heap_bytes() > 0); + } + + #[test] + fn list_data() { + let list = ListData { + data: vec![Value::Int(1), Value::Int(2), Value::Int(3)], + type_signature: ListTypeData::new_list(TypeSignature::IntType, 10).unwrap(), + }; + assert!(list.heap_bytes() > 0); + } + + #[test] + fn sequence_data_list_variant() { + let seq = SequenceData::List(ListData { + data: vec![Value::Int(1), Value::Int(2)], + type_signature: ListTypeData::new_list(TypeSignature::IntType, 10).unwrap(), + }); + assert!(seq.heap_bytes() > 0); + } + + #[test] + fn principal_standard() { + let v = Value::Principal(PrincipalData::Standard(StandardPrincipalData::transient())); + assert_eq!(v.heap_bytes(), 0); + } + + #[test] + fn principal_contract() { + let v = Value::Principal(PrincipalData::Contract( + QualifiedContractIdentifier::transient(), + )); + assert!(v.heap_bytes() > 0); + } + + #[test] + fn tuple() { + let tuple = TupleData::from_data(vec![ + ( + ClarityName::try_from("a".to_string()).unwrap(), + Value::Int(1), + ), + ( + ClarityName::try_from("b".to_string()).unwrap(), + Value::Bool(true), + ), + ]) + .unwrap(); + assert!(Value::Tuple(tuple).heap_bytes() > 0); + } + + #[test] + fn optional() { + let opt = OptionalData { + data: Some(Box::new(Value::Int(42))), + }; + assert!(opt.heap_bytes() > 0); + } + + #[test] + fn value_optional_variant() { + let value = Value::Optional(OptionalData { + data: Some(Box::new(Value::Int(42))), + }); + assert!(value.heap_bytes() >= size_of::()); + } + + #[test] + fn response() { + let ok = Value::Response(ResponseData { + committed: true, + data: Box::new(Value::Int(42)), + }); + let err = Value::Response(ResponseData { + committed: false, + data: Box::new(Value::Bool(false)), + }); + assert!(ok.heap_bytes() >= size_of::()); + assert!(err.heap_bytes() >= size_of::()); + } + + #[test] + fn callable_contract() { + let v = Value::CallableContract(CallableData { + contract_identifier: QualifiedContractIdentifier::transient(), + trait_identifier: None, + }); + assert!(v.heap_bytes() > 0); + } + } + + mod clarity_identifiers { + use super::*; + + #[test] + fn clarity_name() { + let name = ClarityName::try_from("my-variable".to_string()).unwrap(); + assert!(name.heap_bytes() >= 11); + assert!(name.resident_bytes() > name.heap_bytes()); + } + + #[test] + fn contract_name() { + let name = ContractName::try_from("my-contract".to_string()).unwrap(); + assert!(name.heap_bytes() >= 11); + } + + #[test] + fn standard_principal_data() { + let p = StandardPrincipalData::transient(); + assert_eq!(p.heap_bytes(), 0); + } + + #[test] + fn qualified_contract_identifier() { + let id = QualifiedContractIdentifier::transient(); + assert!(id.heap_bytes() > 0); + } + + #[test] + fn trait_identifier() { + let id = TraitIdentifier::new( + StandardPrincipalData::transient(), + ContractName::try_from("contract".to_string()).unwrap(), + ClarityName::try_from("my-trait".to_string()).unwrap(), + ); + assert!(id.heap_bytes() > 0); + } + + #[test] + fn function_identifier() { + let fid = FunctionIdentifier::new_native_function("map"); + assert!(fid.heap_bytes() > 0); + } + } + + mod type_signatures { + use super::*; + + #[test] + fn scalar_no_heap() { + assert_eq!(TypeSignature::IntType.heap_bytes(), 0); + assert_eq!(TypeSignature::UIntType.heap_bytes(), 0); + assert_eq!(TypeSignature::BoolType.heap_bytes(), 0); + assert_eq!(TypeSignature::PrincipalType.heap_bytes(), 0); + assert_eq!(TypeSignature::NoType.heap_bytes(), 0); + assert!(TypeSignature::IntType.resident_bytes() > 0); + } + + #[test] + fn optional() { + let sig = TypeSignature::OptionalType(Box::new(TypeSignature::IntType)); + assert!(sig.heap_bytes() > 0); + assert!(sig.resident_bytes() > sig.heap_bytes()); + } + + #[test] + fn response() { + let sig = TypeSignature::ResponseType(Box::new(( + TypeSignature::IntType, + TypeSignature::BoolType, + ))); + assert!(sig.heap_bytes() > 0); + } + + #[test] + fn sequence() { + let sig = TypeSignature::SequenceType(SequenceSubtype::BufferType( + BufferLength::try_from(64u32).unwrap(), + )); + assert_eq!(sig.heap_bytes(), 0); + } + + #[test] + fn tuple() { + let sig = TypeSignature::TupleType( + TupleTypeSignature::try_from(vec![( + ClarityName::try_from("f".to_string()).unwrap(), + TypeSignature::IntType, + )]) + .unwrap(), + ); + assert!(sig.heap_bytes() > 0); + } + + #[test] + fn callable() { + let sig = TypeSignature::CallableType(CallableSubtype::Principal( + QualifiedContractIdentifier::transient(), + )); + assert!(sig.heap_bytes() > 0); + } + + #[test] + fn list_union() { + let mut set = BTreeSet::new(); + set.insert(CallableSubtype::Principal( + QualifiedContractIdentifier::transient(), + )); + let sig = TypeSignature::ListUnionType(set); + assert!(sig.heap_bytes() > 0); + } + + #[test] + fn trait_reference() { + let id = TraitIdentifier::new( + StandardPrincipalData::transient(), + ContractName::try_from("c".to_string()).unwrap(), + ClarityName::try_from("t".to_string()).unwrap(), + ); + let sig = TypeSignature::TraitReferenceType(id); + assert!(sig.heap_bytes() > 0); + } + + #[test] + fn tuple_type_signature() { + let sig = TupleTypeSignature::try_from(vec![ + ( + ClarityName::try_from("x".to_string()).unwrap(), + TypeSignature::IntType, + ), + ( + ClarityName::try_from("y".to_string()).unwrap(), + TypeSignature::BoolType, + ), + ]) + .unwrap(); + assert!(sig.heap_bytes() > ARC_OVERHEAD); + } + + #[test] + fn sequence_subtype() { + assert_eq!( + SequenceSubtype::BufferType(BufferLength::try_from(32u32).unwrap()).heap_bytes(), + 0, + ); + let list = SequenceSubtype::ListType( + ListTypeData::new_list(TypeSignature::IntType, 5).unwrap(), + ); + assert!(list.heap_bytes() > 0); + assert_eq!( + SequenceSubtype::StringType(StringSubtype::ASCII( + BufferLength::try_from(10u32).unwrap() + )) + .heap_bytes(), + 0, + ); + } + + #[test] + fn string_subtype_no_heap() { + assert_eq!( + StringSubtype::ASCII(BufferLength::try_from(10u32).unwrap()).heap_bytes(), + 0 + ); + assert_eq!( + StringSubtype::UTF8(StringUTF8Length::try_from(10u32).unwrap()).heap_bytes(), + 0 + ); + } + + #[test] + fn buffer_length_no_heap() { + assert_eq!(BufferLength::try_from(100u32).unwrap().heap_bytes(), 0); + } + + #[test] + fn string_utf8_length_no_heap() { + assert_eq!(StringUTF8Length::try_from(100u32).unwrap().heap_bytes(), 0); + } + + #[test] + fn callable_subtype_principal() { + let sub = CallableSubtype::Principal(QualifiedContractIdentifier::transient()); + assert!(sub.heap_bytes() > 0); + } + + #[test] + fn callable_subtype_trait() { + let id = TraitIdentifier::new( + StandardPrincipalData::transient(), + ContractName::try_from("c".to_string()).unwrap(), + ClarityName::try_from("t".to_string()).unwrap(), + ); + assert!(CallableSubtype::Trait(id).heap_bytes() > 0); + } + } + + mod symbolic_expressions { + use super::*; + + #[test] + fn atom() { + let inner = SymbolicExpression::atom(ClarityName::try_from("x".to_string()).unwrap()); + let list = SymbolicExpression::list(vec![inner.clone(), inner.clone(), inner]); + assert!(list.heap_bytes() > 0); + assert!(list.resident_bytes() > list.heap_bytes()); + } + + #[test] + fn atom_value() { + let expr = SymbolicExpression::atom_value(Value::Int(1)); + assert_eq!(expr.heap_bytes(), 0); + } + + #[test] + fn literal_value() { + let expr = SymbolicExpression::literal_value(Value::Bool(true)); + assert_eq!(expr.heap_bytes(), 0); + } + + #[test] + fn field() { + let id = TraitIdentifier::new( + StandardPrincipalData::transient(), + ContractName::try_from("c".to_string()).unwrap(), + ClarityName::try_from("f".to_string()).unwrap(), + ); + let expr = SymbolicExpression::field(id); + assert!(expr.heap_bytes() > 0); + } + + #[test] + fn trait_reference() { + let id = TraitIdentifier::new( + StandardPrincipalData::transient(), + ContractName::try_from("c".to_string()).unwrap(), + ClarityName::try_from("t".to_string()).unwrap(), + ); + let expr = SymbolicExpression::trait_reference( + ClarityName::try_from("name".to_string()).unwrap(), + TraitDefinition::Defined(id), + ); + assert!(expr.heap_bytes() > 0); + } + + #[test] + fn trait_definition() { + let id = TraitIdentifier::new( + StandardPrincipalData::transient(), + ContractName::try_from("c".to_string()).unwrap(), + ClarityName::try_from("t".to_string()).unwrap(), + ); + let defined = TraitDefinition::Defined(id.clone()); + let imported = TraitDefinition::Imported(id); + assert!(defined.heap_bytes() > 0); + assert_eq!(defined.heap_bytes(), imported.heap_bytes()); + } + } + + #[cfg(feature = "developer-mode")] + mod developer_mode { + use super::*; + + #[test] + fn symbolic_expression_comment_fields() { + let mut expr = + SymbolicExpression::atom(ClarityName::try_from("x".to_string()).unwrap()); + expr.pre_comments = vec![ + ("comment1".to_string(), Span::zero()), + ("comment2".to_string(), Span::zero()), + ]; + expr.end_line_comment = Some("end comment".to_string()); + expr.post_comments = vec![("post".to_string(), Span::zero())]; + + let with_comments = expr.heap_bytes(); + let plain = SymbolicExpression::atom(ClarityName::try_from("x".to_string()).unwrap()); + assert!(with_comments > plain.heap_bytes()); + } + + #[test] + fn span_no_heap() { + let s = Span::zero(); + assert_eq!(s.heap_bytes(), 0); + } + } +} diff --git a/clarity-types/src/types/mod.rs b/clarity-types/src/types/mod.rs index af31bc679d4..a028bc4322a 100644 --- a/clarity-types/src/types/mod.rs +++ b/clarity-types/src/types/mod.rs @@ -1837,6 +1837,11 @@ impl fmt::Display for FunctionIdentifier { } impl FunctionIdentifier { + /// Returns the heap capacity of the backing `String` buffer. + pub fn heap_capacity(&self) -> usize { + self.identifier.capacity() + } + pub fn new_native_function(name: &str) -> FunctionIdentifier { let identifier = format!("_native_:{name}"); FunctionIdentifier { identifier } diff --git a/clarity/Cargo.toml b/clarity/Cargo.toml index 7c460f2a77f..7bcb28e5914 100644 --- a/clarity/Cargo.toml +++ b/clarity/Cargo.toml @@ -28,6 +28,7 @@ stacks_common = { package = "stacks-common", path = "../stacks-common", default- rstest = { version = "0.17.0", optional = true } rstest_reuse = { version = "0.5.0", optional = true } rusqlite = { workspace = true, optional = true } +TinyUFO = { workspace = true } [dev-dependencies] assert-json-diff = "1.0.0" @@ -52,6 +53,11 @@ harness = false name = "sequence_higher_order" harness = false +[[bench]] +name = "contract_cache" +harness = false +required-features = ["testing"] + [target.'cfg(not(target_family = "wasm"))'.dependencies] serde_stacker = "0.1" diff --git a/clarity/benches/contract_cache.rs b/clarity/benches/contract_cache.rs new file mode 100644 index 00000000000..ea98ae3f5e1 --- /dev/null +++ b/clarity/benches/contract_cache.rs @@ -0,0 +1,291 @@ +// Copyright (C) 2026 Stacks Open Internet Foundation +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +//! Benchmark comparing contract execution with and without the contract cache. +//! +//! Run with: `cargo bench -p clarity --bench contract_cache` + +use std::hint::black_box; +use std::time::Duration; + +use clarity::vm::contexts::OwnedEnvironment; +use clarity::vm::database::{ClarityDatabase, ContractCache, MemoryBackingStore}; +use clarity::vm::test_util::symbols_from_values; +use clarity::vm::types::{PrincipalData, QualifiedContractIdentifier, Value}; +use criterion::{BenchmarkId, Criterion, criterion_group, criterion_main}; +use stacks_common::types::StacksEpochId; +#[cfg(not(any(target_os = "macos", target_os = "windows", target_arch = "arm")))] +use tikv_jemallocator::Jemalloc; + +#[cfg(not(any(target_os = "macos", target_os = "windows", target_arch = "arm")))] +#[global_allocator] +static GLOBAL: Jemalloc = Jemalloc; + +const EPOCH: StacksEpochId = StacksEpochId::Epoch21; + +const CALLEE_CONTRACT: &str = " + (define-data-var counter uint u0) + (define-map balances { owner: principal } { amount: uint }) + + (define-read-only (get-counter) + (var-get counter)) + + (define-public (increment (amount uint)) + (begin + (var-set counter (+ (var-get counter) amount)) + (ok (var-get counter)))) + + (define-public (store (owner principal) (amount uint)) + (begin + (map-set balances { owner: owner } { amount: amount }) + (try! (increment amount)) + (ok true))) +"; + +const CALLER_CONTRACT: &str = " + (define-public (proxy-increment (amount uint)) + (contract-call? .callee increment amount)) + + (define-public (proxy-store (owner principal) (amount uint)) + (contract-call? .callee store owner amount)) +"; + +// Deep call chain: 4 layers + orchestrator with fold. + +const LAYER_0: &str = " + (define-data-var counter uint u0) + (define-public (execute (amount uint)) + (begin + (var-set counter (+ (var-get counter) amount)) + (ok (var-get counter)))) +"; + +const LAYER_1: &str = " + (define-public (execute (amount uint)) + (contract-call? .layer-0 execute amount)) +"; + +const LAYER_2: &str = " + (define-public (execute (amount uint)) + (contract-call? .layer-1 execute amount)) +"; + +const LAYER_3: &str = " + (define-public (execute (amount uint)) + (contract-call? .layer-2 execute amount)) +"; + +const ORCHESTRATOR: &str = " + (define-private (process-one (amount uint) (acc uint)) + (let ((result (unwrap-panic (contract-call? .layer-3 execute amount)))) + (+ acc result))) + + (define-public (run-batch) + (ok (fold process-one + (list u1 u1 u1 u1 u1 u1 u1 u1 u1 u1 u1 u1 u1 u1 u1 u1 u1 u1 u1 u1) + u0))) +"; + +fn sender() -> PrincipalData { + PrincipalData::parse("SP3FBR2AGK5H9QBDH3EEN6DF8EK8JY7RX8QJ5SVTE").unwrap() +} + +/// Deploy test contracts and prepare the store for benchmarking. +fn setup_store(store: &mut MemoryBackingStore) { + let mut db = store.as_clarity_db(); + db.begin(); + db.set_clarity_epoch_version(EPOCH).unwrap(); + db.commit().unwrap(); + + let mut owned_env = OwnedEnvironment::new(db, EPOCH); + + let callee_id = QualifiedContractIdentifier::local("callee").unwrap(); + owned_env + .initialize_contract(callee_id, CALLEE_CONTRACT, None) + .unwrap(); + + let caller_id = QualifiedContractIdentifier::local("caller").unwrap(); + owned_env + .initialize_contract(caller_id, CALLER_CONTRACT, None) + .unwrap(); +} + +/// Deploy the deep call chain contracts (layer-0 through layer-3 + orchestrator). +fn setup_deep_chain(store: &mut MemoryBackingStore) { + let mut db = store.as_clarity_db(); + db.begin(); + db.set_clarity_epoch_version(EPOCH).unwrap(); + db.commit().unwrap(); + + let mut owned_env = OwnedEnvironment::new(db, EPOCH); + for (name, src) in [ + ("layer-0", LAYER_0), + ("layer-1", LAYER_1), + ("layer-2", LAYER_2), + ("layer-3", LAYER_3), + ("orchestrator", ORCHESTRATOR), + ] { + let id = QualifiedContractIdentifier::local(name).unwrap(); + owned_env.initialize_contract(id, src, None).unwrap(); + } +} + +/// Create an OwnedEnvironment from a store, optionally with a cache attached. +fn make_env<'a>( + db: ClarityDatabase<'a>, + cache: Option<&'a ContractCache>, +) -> OwnedEnvironment<'a, 'a> { + let mut db = db; + db.set_contract_cache(cache); + OwnedEnvironment::new(db, EPOCH) +} + +fn bench_direct_call(c: &mut Criterion) { + let mut group = c.benchmark_group("direct_call"); + let sender = sender(); + let callee_id = QualifiedContractIdentifier::local("callee").unwrap(); + let args = symbols_from_values(vec![Value::UInt(1)]); + + for (label, use_cache) in [("no_cache", false), ("cached", true)] { + group.bench_function(BenchmarkId::new("increment", label), |b| { + let mut store = MemoryBackingStore::new(); + setup_store(&mut store); + let cache = ContractCache::new(64 * 1024 * 1024); + let db = store.as_clarity_db(); + let mut env = make_env(db, if use_cache { Some(&cache) } else { None }); + + b.iter_batched( + || (sender.clone(), callee_id.clone()), + |(s, id)| { + black_box( + env.execute_transaction(s, None, id, "increment", &args) + .unwrap(), + ) + }, + criterion::BatchSize::SmallInput, + ); + }); + } + + group.finish(); +} + +fn bench_contract_call(c: &mut Criterion) { + let mut group = c.benchmark_group("contract_call"); + let sender = sender(); + let caller_id = QualifiedContractIdentifier::local("caller").unwrap(); + let args = symbols_from_values(vec![Value::UInt(1)]); + + for (label, use_cache) in [("no_cache", false), ("cached", true)] { + group.bench_function(BenchmarkId::new("proxy_increment", label), |b| { + let mut store = MemoryBackingStore::new(); + setup_store(&mut store); + let cache = ContractCache::new(64 * 1024 * 1024); + let db = store.as_clarity_db(); + let mut env = make_env(db, if use_cache { Some(&cache) } else { None }); + + b.iter_batched( + || (sender.clone(), caller_id.clone()), + |(s, id)| { + black_box( + env.execute_transaction(s, None, id, "proxy-increment", &args) + .unwrap(), + ) + }, + criterion::BatchSize::SmallInput, + ); + }); + } + + group.finish(); +} + +fn bench_repeated_calls(c: &mut Criterion) { + let mut group = c.benchmark_group("repeated_calls"); + group.sample_size(150); + group.measurement_time(Duration::from_secs(20)); + let sender = sender(); + let caller_id = QualifiedContractIdentifier::local("caller").unwrap(); + let args = symbols_from_values(vec![Value::UInt(1)]); + + for (label, use_cache) in [("no_cache", false), ("cached", true)] { + group.bench_function(BenchmarkId::new("10x_proxy_increment", label), |b| { + let mut store = MemoryBackingStore::new(); + setup_store(&mut store); + let cache = ContractCache::new(64 * 1024 * 1024); + let db = store.as_clarity_db(); + let mut env = make_env(db, if use_cache { Some(&cache) } else { None }); + + b.iter_batched( + || std::array::from_fn::<_, 10, _>(|_| (sender.clone(), caller_id.clone())), + |batch| { + for (s, id) in batch { + black_box( + env.execute_transaction(s, None, id, "proxy-increment", &args) + .unwrap(), + ); + } + }, + criterion::BatchSize::SmallInput, + ); + }); + } + + group.finish(); +} + +/// 4-deep `contract-call?` chain × 20 fold iterations including nested calls. +fn bench_deep_fold(c: &mut Criterion) { + let mut group = c.benchmark_group("deep_fold"); + group.sample_size(150); + group.measurement_time(Duration::from_secs(20)); + let sender = sender(); + let orch_id = QualifiedContractIdentifier::local("orchestrator").unwrap(); + + for (label, use_cache) in [("no_cache", false), ("cached", true)] { + group.bench_function(BenchmarkId::new("4_deep_x20_fold", label), |b| { + let mut store = MemoryBackingStore::new(); + setup_deep_chain(&mut store); + let cache = ContractCache::new(64 * 1024 * 1024); + let db = store.as_clarity_db(); + let mut env = make_env(db, if use_cache { Some(&cache) } else { None }); + + b.iter_batched( + || (sender.clone(), orch_id.clone()), + |(s, id)| { + black_box( + env.execute_transaction(s, None, id, "run-batch", &[]) + .unwrap(), + ) + }, + criterion::BatchSize::SmallInput, + ); + }); + } + + group.finish(); +} + +criterion_group! { + name = benches; + config = Criterion::default() + .sample_size(300) + .measurement_time(Duration::from_secs(10)) + .warm_up_time(Duration::from_secs(5)) + .noise_threshold(0.03) + .nresamples(200_000); + targets = bench_direct_call, bench_contract_call, bench_repeated_calls, bench_deep_fold +} +criterion_main!(benches); diff --git a/clarity/fuzz/Cargo.lock b/clarity/fuzz/Cargo.lock index 63c182c5610..563ab1eac3b 100644 --- a/clarity/fuzz/Cargo.lock +++ b/clarity/fuzz/Cargo.lock @@ -2,6 +2,18 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "TinyUFO" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "011f852ef553046d2f180ecea9e91b9460387d10a75e3a995392e5130d626e20" +dependencies = [ + "ahash", + "crossbeam-queue", + "crossbeam-skiplist", + "flurry", +] + [[package]] name = "ahash" version = "0.8.12" @@ -9,6 +21,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" dependencies = [ "cfg-if", + "const-random", + "getrandom 0.3.3", "once_cell", "version_check", "zerocopy", @@ -142,13 +156,14 @@ dependencies = [ "android-tzdata", "iana-time-zone", "num-traits", - "windows-link", + "windows-link 0.1.3", ] [[package]] name = "clarity" version = "0.0.1" dependencies = [ + "TinyUFO", "clarity-types", "integer-sqrt", "lazy_static", @@ -193,6 +208,26 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom 0.2.16", + "once_cell", + "tiny-keccak", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -208,6 +243,40 @@ dependencies = [ "libc", ] +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-skiplist" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df29de440c58ca2cc6e587ec3d22347551a32435fbde9d2bff64e78a9ffa151b" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + [[package]] name = "crunchy" version = "0.2.4" @@ -424,6 +493,18 @@ version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" +[[package]] +name = "flurry" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf5efcf77a4da27927d3ab0509dec5b0954bb3bc59da5a1de9e52642ebd4cdf9" +dependencies = [ + "ahash", + "num_cpus", + "parking_lot", + "seize", +] + [[package]] name = "foldhash" version = "0.1.5" @@ -807,6 +888,15 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + [[package]] name = "log" version = "0.4.27" @@ -856,6 +946,16 @@ dependencies = [ "autocfg", ] +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + [[package]] name = "once_cell" version = "1.21.3" @@ -881,6 +981,29 @@ dependencies = [ "sha2 0.10.9", ] +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link 0.2.1", +] + [[package]] name = "pin-project-lite" version = "0.2.16" @@ -997,6 +1120,15 @@ dependencies = [ "getrandom 0.2.16", ] +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.9.1", +] + [[package]] name = "redox_users" version = "0.4.6" @@ -1130,6 +1262,12 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + [[package]] name = "sec1" version = "0.7.3" @@ -1164,6 +1302,12 @@ dependencies = [ "cc", ] +[[package]] +name = "seize" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "689224d06523904ebcc9b482c6a3f4f7fb396096645c4cd10c0d2ff7371a34d3" + [[package]] name = "semver" version = "1.0.26" @@ -1468,6 +1612,15 @@ dependencies = [ "time-core", ] +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + [[package]] name = "toml" version = "0.5.11" @@ -1604,7 +1757,7 @@ checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" dependencies = [ "windows-implement", "windows-interface", - "windows-link", + "windows-link 0.1.3", "windows-result", "windows-strings", ] @@ -1637,13 +1790,19 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + [[package]] name = "windows-result" version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" dependencies = [ - "windows-link", + "windows-link 0.1.3", ] [[package]] @@ -1652,7 +1811,7 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" dependencies = [ - "windows-link", + "windows-link 0.1.3", ] [[package]] diff --git a/clarity/src/vm/callables.rs b/clarity/src/vm/callables.rs index 5e8c156ac1f..043cc7c3fe8 100644 --- a/clarity/src/vm/callables.rs +++ b/clarity/src/vm/callables.rs @@ -17,6 +17,7 @@ use std::collections::BTreeMap; use clarity_types::representations::ClarityName; +use clarity_types::resident_bytes::ResidentBytes; pub use clarity_types::types::FunctionIdentifier; use stacks_common::types::StacksEpochId; @@ -76,6 +77,17 @@ pub struct DefinedFunction { body: SymbolicExpression, } +impl ResidentBytes for DefinedFunction { + fn heap_bytes(&self) -> usize { + self.identifier.heap_bytes() + + self.name.heap_bytes() + + self.arg_types.heap_bytes() + + self.arguments.heap_bytes() + + self.body.heap_bytes() + // define_type is a fieldless enum — no heap allocation + } +} + /// This enum handles the actual invocation of the method /// implementing a native function. Each variant handles /// different expected number of arguments. @@ -773,4 +785,27 @@ mod test { TypeSignature::CallableType(CallableSubtype::Trait(trait_id)) ); } + + #[test] + fn resident_bytes_defined_function_counts_all_heap_fields() { + let function = DefinedFunction { + identifier: FunctionIdentifier::new_native_function("map"), + name: ClarityName::try_from("resident-bytes-fn".to_string()).unwrap(), + arg_types: vec![TypeSignature::OptionalType(Box::new( + TypeSignature::UIntType, + ))], + define_type: DefineType::Private, + arguments: vec![ClarityName::try_from("arg".to_string()).unwrap()], + body: SymbolicExpression::atom_value(Value::Bool(true)), + }; + + let expected = function.identifier.heap_bytes() + + function.name.heap_bytes() + + function.arg_types.heap_bytes() + + function.arguments.heap_bytes() + + function.body.heap_bytes(); + + assert_eq!(function.heap_bytes(), expected); + assert!(function.heap_bytes() > 0); + } } diff --git a/clarity/src/vm/contexts.rs b/clarity/src/vm/contexts.rs index 784edf564dc..ebc3451a23a 100644 --- a/clarity/src/vm/contexts.rs +++ b/clarity/src/vm/contexts.rs @@ -20,6 +20,7 @@ use std::mem::replace; use std::time::{Duration, Instant}; use clarity_types::representations::ClarityName; +use clarity_types::resident_bytes::ResidentBytes; use serde::Serialize; use serde_json::json; use stacks_common::alloc_tracker::{AllocationCounter, thread_allocated}; @@ -388,6 +389,40 @@ pub struct ContractContext { pub is_deploying: bool, } +impl ResidentBytes for ContractContext { + fn heap_bytes(&self) -> usize { + // Destructure to get a compile error when a field is added without accounting for it. + let ContractContext { + // Heap-allocated fields: accounted for by heap_bytes() calls below + contract_identifier, + variables, + functions, + defined_traits, + implemented_traits, + persisted_names, + meta_data_map, + meta_data_var, + meta_nft, + meta_ft, + // Inline-only fields: covered by size_of::() + data_size: _, + clarity_version: _, + is_deploying: _, + } = self; + + contract_identifier.heap_bytes() + + variables.heap_bytes() + + functions.heap_bytes() + + defined_traits.heap_bytes() + + implemented_traits.heap_bytes() + + persisted_names.heap_bytes() + + meta_data_map.heap_bytes() + + meta_data_var.heap_bytes() + + meta_nft.heap_bytes() + + meta_ft.heap_bytes() + } +} + pub struct LocalContext<'a> { pub function_context: Option<&'a LocalContext<'a>>, pub parent: Option<&'a LocalContext<'a>>, @@ -1140,10 +1175,10 @@ impl<'a, 'b, 'hooks> ExecutionState<'a, 'b, 'hooks> { self.global_context.begin(); - let contract = self + let cached = self .global_context .database - .get_contract(contract_identifier) + .get_contract_cached(contract_identifier) .or_else(|e| { self.global_context.roll_back()?; Err(e) @@ -1151,7 +1186,7 @@ impl<'a, 'b, 'hooks> ExecutionState<'a, 'b, 'hooks> { let result = { let nested_view = InvocationContext { - contract_context: &contract.contract_context, + contract_context: &cached.contract.contract_context, sender: invoke_ctx.sender.clone(), caller: invoke_ctx.caller.clone(), sponsor: invoke_ctx.sponsor.clone(), @@ -1267,19 +1302,27 @@ impl<'a, 'b, 'hooks> ExecutionState<'a, 'b, 'hooks> { read_only: bool, allow_private: bool, ) -> Result { - let contract_size = self + // Charge the load cost before loading the full contract. This matters when + // canonicalize_types() fails (e.g. CouldNotDetermineType); the cost must be charged even + // though the load itself errors. + let load_cost_size = self .global_context .database - .get_contract_size(contract_identifier)?; - runtime_cost(ClarityCostFunction::LoadContract, self, contract_size)?; + .get_contract_load_cost_size(contract_identifier)?; - self.global_context.add_memory(contract_size)?; + runtime_cost(ClarityCostFunction::LoadContract, self, load_cost_size)?; - finally_drop_memory!(self.global_context, contract_size; { - let contract = self.global_context.database.get_contract(contract_identifier)?; + self.global_context.add_memory(load_cost_size)?; - let func = contract.contract_context.lookup_function(tx_name) + finally_drop_memory!(self.global_context, load_cost_size; { + let cached = self + .global_context + .database + .get_contract_cached(contract_identifier)?; + + let func = cached.contract.contract_context.lookup_function(tx_name) .ok_or_else(|| { RuntimeCheckErrorKind::UndefinedFunction(tx_name.to_string()) })?; + if !allow_private && !func.is_public() { return Err(RuntimeCheckErrorKind::NoSuchPublicFunction(contract_identifier.to_string(), tx_name.to_string()).into()); } else if read_only && !func.is_read_only() { @@ -1314,7 +1357,7 @@ impl<'a, 'b, 'hooks> ExecutionState<'a, 'b, 'hooks> { return Err(RuntimeCheckErrorKind::CircularReference(vec![func_identifier.to_string()]).into()) } self.call_stack.insert(&func_identifier, true); - let res = self.execute_function_as_transaction(invoke_ctx, &func, &args, Some(&contract.contract_context), allow_private); + let res = self.execute_function_as_transaction(invoke_ctx, &func, &args, Some(&cached.contract.contract_context), allow_private); self.call_stack.remove(&func_identifier, true)?; match res { diff --git a/clarity/src/vm/contracts.rs b/clarity/src/vm/contracts.rs index df4cee68dcb..ae753877382 100644 --- a/clarity/src/vm/contracts.rs +++ b/clarity/src/vm/contracts.rs @@ -14,6 +14,7 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . +use clarity_types::resident_bytes::ResidentBytes; use stacks_common::types::StacksEpochId; use crate::vm::ast::ContractAST; @@ -28,8 +29,12 @@ pub struct Contract { pub contract_context: ContractContext, } -// AARON: this is an increasingly useless wrapper around a ContractContext struct. -// will probably be removed soon. +impl ResidentBytes for Contract { + fn heap_bytes(&self) -> usize { + self.contract_context.heap_bytes() + } +} + impl Contract { pub fn initialize_from_ast( contract_identifier: QualifiedContractIdentifier, @@ -56,3 +61,254 @@ impl Contract { self.contract_context.canonicalize_types(epoch) } } + +#[cfg(test)] +mod tests { + use std::mem::size_of; + + use clarity_types::resident_bytes::ResidentBytes; + use stacks_common::consts::CHAIN_ID_TESTNET; + use stacks_common::types::StacksEpochId; + + use crate::vm::GlobalContext; + use crate::vm::ast::build_ast; + use crate::vm::contexts::ContractContext; + use crate::vm::contracts::Contract; + use crate::vm::costs::LimitedCostTracker; + use crate::vm::database::MemoryBackingStore; + use crate::vm::types::QualifiedContractIdentifier; + use crate::vm::version::ClarityVersion; + + /// Verify that `Contract::heap_bytes` delegates cleanly to `ContractContext::heap_bytes`, and + /// that `resident_bytes` adds exactly `size_of::()`. + /// + /// Field-coverage (compile-time guard against forgotten fields) is enforced by the + /// destructuring inside `ContractContext::heap_bytes()` itself. + #[track_caller] + fn assert_contract_bytes_consistent(contract: &Contract) { + let ctx_heap = contract.contract_context.heap_bytes(); + assert_eq!(contract.heap_bytes(), ctx_heap); + assert_eq!(contract.resident_bytes(), size_of::() + ctx_heap); + } + + #[track_caller] + fn initialize_contract_with_store( + marf: &mut MemoryBackingStore, + source: &str, + contract_name: &str, + ) -> Contract { + let version = ClarityVersion::Clarity2; + let epoch = StacksEpochId::Epoch21; + let contract_identifier = QualifiedContractIdentifier::local(contract_name).unwrap(); + let conn = marf.as_clarity_db(); + let mut global_context = GlobalContext::new( + false, + CHAIN_ID_TESTNET, + conn, + LimitedCostTracker::new_free(), + epoch, + ); + let contract_ast = build_ast(&contract_identifier, source, &mut (), version, epoch) + .expect("contract source should parse"); + + global_context + .execute(|g| { + Contract::initialize_from_ast(contract_identifier, &contract_ast, None, g, version) + }) + .expect("contract source should initialize") + } + + fn initialize_contract(source: &str, contract_name: &str) -> Contract { + let mut marf = MemoryBackingStore::new(); + initialize_contract_with_store(&mut marf, source, contract_name) + } + + #[test] + fn resident_bytes_matches_empty_contract_context() { + let contract_identifier = + QualifiedContractIdentifier::local("resident-bytes-empty").unwrap(); + let contract = Contract { + contract_context: ContractContext::new(contract_identifier, ClarityVersion::Clarity2), + }; + + assert!(contract.contract_context.variables.is_empty()); + assert!(contract.contract_context.functions.is_empty()); + assert!(contract.contract_context.defined_traits.is_empty()); + assert!(contract.contract_context.implemented_traits.is_empty()); + assert!(contract.contract_context.persisted_names.is_empty()); + assert!(contract.contract_context.meta_data_map.is_empty()); + assert!(contract.contract_context.meta_data_var.is_empty()); + assert!(contract.contract_context.meta_nft.is_empty()); + assert!(contract.contract_context.meta_ft.is_empty()); + + assert_contract_bytes_consistent(&contract); + assert_eq!( + contract.heap_bytes(), + contract.contract_context.contract_identifier.heap_bytes() + ); + } + + #[test] + fn resident_bytes_covers_all_fields_in_rich_contract() { + let contract = initialize_contract( + r#" + (define-data-var counter uint u0) + (define-map balances { owner: principal } { amount: uint, memo: (string-ascii 32) }) + (define-constant label "resident-bytes") + + (define-private (helper (amount uint)) + (begin + (var-set counter (+ (var-get counter) amount)) + (ok (var-get counter)))) + + (define-read-only (lookup (owner principal)) + (default-to { amount: u0, memo: "none" } + (map-get? balances { owner: owner }))) + + (define-public (store (owner principal) (amount uint)) + (begin + (map-set balances { owner: owner } { amount: amount, memo: "cache-entry" }) + (try! (helper amount)) + (ok true))) + "#, + "resident-bytes-rich", + ); + + assert_eq!(contract.contract_context.variables.len(), 1); + assert_eq!(contract.contract_context.functions.len(), 3); + assert_eq!(contract.contract_context.meta_data_map.len(), 1); + assert_eq!(contract.contract_context.meta_data_var.len(), 1); + assert!(contract.contract_context.persisted_names.len() >= 2); + + assert_contract_bytes_consistent(&contract); + + // Magnitude check: a contract with 3 functions, a map, a var, and a constant must have + // substantial heap allocation beyond the bare struct size. + assert!( + contract.resident_bytes() > size_of::() + 1000, + "rich contract resident_bytes ({}) should exceed struct size + 1000", + contract.resident_bytes() + ); + } + + #[test] + fn resident_bytes_counts_ft_nft_and_traits() { + let contract = initialize_contract( + r#" + (define-fungible-token gold) + (define-fungible-token silver u1000000) + (define-non-fungible-token deed uint) + (define-non-fungible-token badge { class: uint, level: uint }) + (define-trait transferable ( + (transfer (uint principal principal) (response bool uint)) + (get-balance (principal) (response uint uint)))) + "#, + "resident-bytes-ft-nft-trait", + ); + + assert_eq!(contract.contract_context.meta_ft.len(), 2); + assert_eq!(contract.contract_context.meta_nft.len(), 2); + assert_eq!(contract.contract_context.defined_traits.len(), 1); + + assert_contract_bytes_consistent(&contract); + + // meta_nft contains a tuple key type (badge) — verify it contributes heap bytes + let nft_heap: usize = contract + .contract_context + .meta_nft + .values() + .map(|m| m.heap_bytes()) + .sum(); + assert!( + nft_heap > 0, + "NFT metadata with tuple key type should have non-zero heap bytes" + ); + + // defined_traits contains function signatures — verify they contribute heap bytes + let trait_heap: usize = contract + .contract_context + .defined_traits + .values() + .map(|m| m.heap_bytes()) + .sum(); + assert!( + trait_heap > 0, + "defined traits with function signatures should have non-zero heap bytes" + ); + } + + #[test] + fn resident_bytes_counts_implemented_traits() { + let mut marf = MemoryBackingStore::new(); + + // First contract defines the trait + let _trait_contract = initialize_contract_with_store( + &mut marf, + r#" + (define-trait transferable ( + (transfer (uint principal principal) (response bool uint)))) + "#, + "trait-definer", + ); + + // Second contract implements the trait (requires the first to be deployed) + let impl_contract = initialize_contract_with_store( + &mut marf, + r#" + (impl-trait .trait-definer.transferable) + (define-public (transfer (id uint) (from principal) (to principal)) + (ok true)) + "#, + "trait-impl", + ); + + assert_eq!(impl_contract.contract_context.implemented_traits.len(), 1); + + assert_contract_bytes_consistent(&impl_contract); + + // implemented_traits contains a TraitIdentifier; verify non-zero heap + let impl_heap = impl_contract + .contract_context + .implemented_traits + .heap_bytes(); + assert!( + impl_heap > 0, + "implemented_traits with a TraitIdentifier should have non-zero heap bytes" + ); + } + + #[test] + fn resident_bytes_grows_with_additional_initialized_content() { + let single_function = initialize_contract( + r#" + (define-public (echo (value uint)) + (ok value)) + "#, + "resident-bytes-single-fn", + ); + let many_functions = initialize_contract( + r#" + (define-private (double (value uint)) (+ value value)) + (define-private (triple (value uint)) (+ value (+ value value))) + (define-read-only (project (value uint)) + { original: value, doubled: (double value), tripled: (triple value) }) + (define-public (accumulate (a uint) (b uint) (c uint)) + (let ( + (first (double a)) + (second (triple b)) + (third (+ c u7))) + (ok (+ first (+ second third))))) + "#, + "resident-bytes-many-fns", + ); + + assert_contract_bytes_consistent(&single_function); + assert_contract_bytes_consistent(&many_functions); + assert!( + many_functions.contract_context.functions.len() + > single_function.contract_context.functions.len() + ); + assert!(many_functions.heap_bytes() > single_function.heap_bytes()); + assert!(many_functions.resident_bytes() > single_function.resident_bytes()); + } +} diff --git a/clarity/src/vm/costs/mod.rs b/clarity/src/vm/costs/mod.rs index 612ef3114a9..f8d40533bd0 100644 --- a/clarity/src/vm/costs/mod.rs +++ b/clarity/src/vm/costs/mod.rs @@ -969,19 +969,19 @@ impl TrackerData { ClarityCostFunctionReference::new(boot_costs_id.clone(), f.get_name()) }); if !cost_contracts.contains_key(&cost_function_ref.contract_id) { - let contract_context = match clarity_db.get_contract(&cost_function_ref.contract_id) - { - Ok(contract) => contract.contract_context, - Err(e) => { - error!("Failed to load intended Clarity cost contract"; + let contract_context = + match clarity_db.get_contract_cached(&cost_function_ref.contract_id) { + Ok(cached) => cached.contract.contract_context.clone(), + Err(e) => { + error!("Failed to load intended Clarity cost contract"; "contract" => %cost_function_ref.contract_id, "error" => ?e); - clarity_db - .roll_back() - .map_err(|e| CostErrors::Expect(e.to_string()))?; - return Err(CostErrors::CostContractLoadFailure); - } - }; + clarity_db + .roll_back() + .map_err(|e| CostErrors::Expect(e.to_string()))?; + return Err(CostErrors::CostContractLoadFailure); + } + }; cost_contracts.insert(cost_function_ref.contract_id.clone(), contract_context); } @@ -997,18 +997,19 @@ impl TrackerData { for (_, circuit_target) in self.contract_call_circuits.iter() { if !cost_contracts.contains_key(&circuit_target.contract_id) { - let contract_context = match clarity_db.get_contract(&circuit_target.contract_id) { - Ok(contract) => contract.contract_context, - Err(e) => { - error!("Failed to load intended Clarity cost contract"; - "contract" => %boot_costs_id.to_string(), + let contract_context = + match clarity_db.get_contract_cached(&circuit_target.contract_id) { + Ok(cached) => cached.contract.contract_context.clone(), + Err(e) => { + error!("Failed to load intended Clarity cost contract"; + "contract" => %circuit_target.contract_id.to_string(), "error" => %format!("{:?}", e)); - clarity_db - .roll_back() - .map_err(|e| CostErrors::Expect(e.to_string()))?; - return Err(CostErrors::CostContractLoadFailure); - } - }; + clarity_db + .roll_back() + .map_err(|e| CostErrors::Expect(e.to_string()))?; + return Err(CostErrors::CostContractLoadFailure); + } + }; cost_contracts.insert(circuit_target.contract_id.clone(), contract_context); } } diff --git a/clarity/src/vm/database/clarity_db.rs b/clarity/src/vm/database/clarity_db.rs index 30728694fad..64ef8a1c8dc 100644 --- a/clarity/src/vm/database/clarity_db.rs +++ b/clarity/src/vm/database/clarity_db.rs @@ -14,6 +14,7 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . +use clarity_types::resident_bytes::ResidentBytes; use stacks_common::consts::{ BITCOIN_REGTEST_FIRST_BLOCK_HASH, BITCOIN_REGTEST_FIRST_BLOCK_HEIGHT, BITCOIN_REGTEST_FIRST_BLOCK_TIMESTAMP, FIRST_BURNCHAIN_CONSENSUS_HASH, FIRST_STACKS_BLOCK_HASH, @@ -27,6 +28,7 @@ use stacks_common::types::{StacksEpoch as GenericStacksEpoch, StacksEpochId}; use stacks_common::util::hash::{Hash160, Sha512Trunc256Sum, to_hex}; use super::clarity_store::SpecialCaseHandler; +use super::contract_cache::{CachedContract, ContractCache}; use super::key_value_wrapper::ValueResult; use crate::vm::analysis::{AnalysisDatabase, ContractAnalysis}; use crate::vm::contracts::Contract; @@ -136,6 +138,9 @@ pub struct ClarityDatabase<'a> { pub store: RollbackWrapper<'a>, headers_db: &'a dyn HeadersDB, burn_state_db: &'a dyn BurnStateDB, + /// Optional parsed-contract cache. Callers should only attach it when it is valid for the + /// view/block being queried. + contract_cache: Option<&'a ContractCache>, } pub trait HeadersDB { @@ -449,6 +454,7 @@ impl<'a> ClarityDatabase<'a> { store: RollbackWrapper::new(store), headers_db, burn_state_db, + contract_cache: None, } } @@ -461,9 +467,21 @@ impl<'a> ClarityDatabase<'a> { store, headers_db, burn_state_db, + contract_cache: None, } } + /// Set the contract cache to this database. + pub fn set_contract_cache(&mut self, cache: impl Into>) { + self.contract_cache = cache.into(); + } + + /// Use the specified [`ContractCache`] for this instance. + pub fn with_contract_cache(mut self, cache: &'a ContractCache) -> Self { + self.contract_cache = Some(cache); + self + } + pub fn initialize(&mut self) {} pub fn is_stack_empty(&self) -> bool { @@ -854,6 +872,68 @@ impl<'a> ClarityDatabase<'a> { Ok(data) } + /// Returns just the load-contract cost size (contract_size + data_size) from the cache on hit, + /// or from the database on miss. + pub fn get_contract_load_cost_size( + &mut self, + contract_identifier: &QualifiedContractIdentifier, + ) -> Result { + // Only attempt the cache if the store is not retargeted to a historical block (e.g. via + // at-block). + if !self.store.is_retargeted() { + let cached = self + .contract_cache + .as_ref() + .and_then(|cc| cc.get(contract_identifier)); + + if let Some(entry) = cached { + return Ok(entry.load_cost_size); + } + } + + self.get_contract_size(contract_identifier) + } + + /// Load a contract, returning it from the cache on hit or from the backing store on miss. + /// + /// On a miss, the entry is inserted into the cache (when one is attached). + /// + /// When the store is retargeted to a historical block (e.g. `(at-block ...)`), the cache is + /// bypassed entirely to avoid serving stale data. + pub fn get_contract_cached( + &mut self, + contract_identifier: &QualifiedContractIdentifier, + ) -> Result { + // Bypass the cache when the store has been retargeted to a historical block (e.g. via + // at-block). + if self.store.is_retargeted() { + let contract_size = self.get_contract_size(contract_identifier)?; + let contract = self.get_contract(contract_identifier)?; + let resident = contract.resident_bytes() as u64; + return Ok(CachedContract::new(contract, contract_size, resident)); + } + + // Cache hit + if let Some(entry) = self + .contract_cache + .as_ref() + .and_then(|cc| cc.get(contract_identifier)) + { + return Ok(entry); + } + + // Cache miss, safe to cache: contract defs are immutable once deployed, and contract-call + // can only target already-committed contracts. + let contract_size = self.get_contract_size(contract_identifier)?; + let contract = self.get_contract(contract_identifier)?; + let resident = contract.resident_bytes() as u64; + let entry = CachedContract::new(contract, contract_size, resident); + if let Some(cc) = self.contract_cache.as_ref() { + cc.insert(contract_identifier.clone(), entry.clone()); + } + Ok(entry) + } + pub fn ustx_liquid_supply_key() -> &'static str { "_stx-data::ustx_liquid_supply" } @@ -2455,141 +2535,421 @@ impl ClarityDatabase<'_> { } } -#[test] -fn increment_ustx_liquid_supply_overflow() { +#[cfg(test)] +mod contract_cache_tests { + use super::*; use crate::vm::database::MemoryBackingStore; - use crate::vm::errors::{RuntimeError, VmExecutionError}; - - let mut store = MemoryBackingStore::new(); - let mut db = store.as_clarity_db(); - - db.begin(); - // Set the liquid supply to one less than the max - db.set_ustx_liquid_supply(u128::MAX - 1) - .expect("Failed to set liquid supply"); - // Trust but verify. - assert_eq!( - db.get_total_liquid_ustx().unwrap(), - u128::MAX - 1, - "Supply should now be u128::MAX - 1" - ); - - db.increment_ustx_liquid_supply(1) - .expect("Increment by 1 should succeed"); - - // Trust but verify. - assert_eq!( - db.get_total_liquid_ustx().unwrap(), - u128::MAX, - "Supply should now be u128::MAX" - ); - - // Attempt to overflow - let err = db.increment_ustx_liquid_supply(1).unwrap_err(); - assert!(matches!( - err, - VmExecutionError::Runtime(RuntimeError::ArithmeticOverflow, _) - )); - - // Verify adding 0 doesn't overflow - db.increment_ustx_liquid_supply(0) - .expect("Increment by 0 should succeed"); - - assert_eq!(db.get_total_liquid_ustx().unwrap(), u128::MAX); - - db.commit().unwrap(); -} + use crate::vm::{ClarityVersion, ContractContext}; + + /// Write the rows for a minimal contract. Caller is responsible for the surrounding + /// `begin`/`commit` transaction. + #[track_caller] + fn write_test_contract(db: &mut ClarityDatabase, id: &QualifiedContractIdentifier) { + let contract = Contract { + contract_context: ContractContext::new(id.clone(), ClarityVersion::Clarity2), + }; + db.insert_contract_hash(id, "(define-public (noop) (ok true))") + .expect("insert_contract_hash"); + db.set_contract_data_size(id, 0) + .expect("set_contract_data_size"); + db.insert_contract(id, contract).expect("insert_contract"); + } + + /// Insert a minimal contract inside its own committed transaction. + #[track_caller] + fn deploy_test_contract(db: &mut ClarityDatabase, id: &QualifiedContractIdentifier) { + db.begin(); + write_test_contract(db, id); + db.commit() + .expect("failed to commit test contract deployment"); + } + + #[test] + fn increment_ustx_liquid_supply_overflow() { + use crate::vm::database::MemoryBackingStore; + use crate::vm::errors::{RuntimeError, VmExecutionError}; + + let mut store = MemoryBackingStore::new(); + let mut db = store.as_clarity_db(); + + db.begin(); + // Set the liquid supply to one less than the max + db.set_ustx_liquid_supply(u128::MAX - 1) + .expect("Failed to set liquid supply"); + // Trust but verify. + assert_eq!( + db.get_total_liquid_ustx().unwrap(), + u128::MAX - 1, + "Supply should now be u128::MAX - 1" + ); + + db.increment_ustx_liquid_supply(1) + .expect("Increment by 1 should succeed"); -#[test] -fn checked_decrease_token_supply_underflow() { - use crate::vm::database::{MemoryBackingStore, StoreType}; - use crate::vm::errors::{RuntimeError, VmExecutionError}; - - let mut store = MemoryBackingStore::new(); - let mut db = store.as_clarity_db(); - let contract_id = QualifiedContractIdentifier::transient(); - let token_name = "token".to_string(); - - db.begin(); - - // Set initial supply to 1000 - let key = - ClarityDatabase::make_key_for_trip(&contract_id, StoreType::CirculatingSupply, &token_name); - db.put_data(&key, &1000u128) - .expect("Failed to set initial token supply"); - - // Trust but verify. - let current_supply: u128 = db.get_data(&key).unwrap().unwrap(); - assert_eq!(current_supply, 1000, "Initial supply should be 1000"); - - // Decrease by 500: should succeed - db.checked_decrease_token_supply(&contract_id, &token_name, 500) - .expect("Decreasing by 500 should succeed"); - - let new_supply: u128 = db.get_data(&key).unwrap().unwrap(); - assert_eq!(new_supply, 500, "Supply should now be 500"); - - // Decrease by 0: should succeed (no change) - db.checked_decrease_token_supply(&contract_id, &token_name, 0) - .expect("Decreasing by 0 should succeed"); - let supply_after_zero: u128 = db.get_data(&key).unwrap().unwrap(); - assert_eq!( - supply_after_zero, 500, - "Supply should remain 500 after decreasing by 0" - ); - - // Attempt to decrease by 501; should trigger SupplyUnderflow - let err = db - .checked_decrease_token_supply(&contract_id, &token_name, 501) - .unwrap_err(); - - assert!( - matches!( + // Trust but verify. + assert_eq!( + db.get_total_liquid_ustx().unwrap(), + u128::MAX, + "Supply should now be u128::MAX" + ); + + // Attempt to overflow + let err = db.increment_ustx_liquid_supply(1).unwrap_err(); + assert!(matches!( err, - VmExecutionError::Runtime(RuntimeError::SupplyUnderflow(500, 501), _) - ), - "Expected SupplyUnderflow(500, 501), got: {err:?}" - ); - - // Supply should remain unchanged after failed underflow - let final_supply: u128 = db.get_data(&key).unwrap().unwrap(); - assert_eq!( - final_supply, 500, - "Supply should not change after underflow error" - ); - - db.commit().unwrap(); -} + VmExecutionError::Runtime(RuntimeError::ArithmeticOverflow, _) + )); -#[test] -fn trigger_no_such_token_rust() { - use crate::vm::database::MemoryBackingStore; - use crate::vm::errors::{RuntimeError, VmExecutionError}; - // Set up a memory backing store and Clarity database - let mut store = MemoryBackingStore::default(); - let mut db = store.as_clarity_db(); - - db.begin(); - // Define a fake contract identifier - let contract_id = QualifiedContractIdentifier::transient(); - - // Simulate querying a non-existent NFT - let asset_id = Value::Bool(false); // this token does not exist - let asset_name = "test-nft"; - - // Call get_nft_owner directly - let err = db - .get_nft_owner( + // Verify adding 0 doesn't overflow + db.increment_ustx_liquid_supply(0) + .expect("Increment by 0 should succeed"); + + assert_eq!(db.get_total_liquid_ustx().unwrap(), u128::MAX); + + db.commit().unwrap(); + } + + #[test] + fn checked_decrease_token_supply_underflow() { + use crate::vm::database::{MemoryBackingStore, StoreType}; + use crate::vm::errors::{RuntimeError, VmExecutionError}; + + let mut store = MemoryBackingStore::new(); + let mut db = store.as_clarity_db(); + let contract_id = QualifiedContractIdentifier::transient(); + let token_name = "token".to_string(); + + db.begin(); + + // Set initial supply to 1000 + let key = ClarityDatabase::make_key_for_trip( &contract_id, - asset_name, - &asset_id, - &TypeSignature::BoolType, - ) - .unwrap_err(); + StoreType::CirculatingSupply, + &token_name, + ); + db.put_data(&key, &1000u128) + .expect("Failed to set initial token supply"); + + // Trust but verify. + let current_supply: u128 = db.get_data(&key).unwrap().unwrap(); + assert_eq!(current_supply, 1000, "Initial supply should be 1000"); + + // Decrease by 500: should succeed + db.checked_decrease_token_supply(&contract_id, &token_name, 500) + .expect("Decreasing by 500 should succeed"); + + let new_supply: u128 = db.get_data(&key).unwrap().unwrap(); + assert_eq!(new_supply, 500, "Supply should now be 500"); + + // Decrease by 0: should succeed (no change) + db.checked_decrease_token_supply(&contract_id, &token_name, 0) + .expect("Decreasing by 0 should succeed"); + let supply_after_zero: u128 = db.get_data(&key).unwrap().unwrap(); + assert_eq!( + supply_after_zero, 500, + "Supply should remain 500 after decreasing by 0" + ); + + // Attempt to decrease by 501; should trigger SupplyUnderflow + let err = db + .checked_decrease_token_supply(&contract_id, &token_name, 501) + .unwrap_err(); + + assert!( + matches!( + err, + VmExecutionError::Runtime(RuntimeError::SupplyUnderflow(500, 501), _) + ), + "Expected SupplyUnderflow(500, 501), got: {err:?}" + ); + + // Supply should remain unchanged after failed underflow + let final_supply: u128 = db.get_data(&key).unwrap().unwrap(); + assert_eq!( + final_supply, 500, + "Supply should not change after underflow error" + ); + + db.commit().unwrap(); + } + + #[test] + fn trigger_no_such_token_rust() { + use crate::vm::database::MemoryBackingStore; + use crate::vm::errors::{RuntimeError, VmExecutionError}; + // Set up a memory backing store and Clarity database + let mut store = MemoryBackingStore::default(); + let mut db = store.as_clarity_db(); + + db.begin(); + // Define a fake contract identifier + let contract_id = QualifiedContractIdentifier::transient(); + + // Simulate querying a non-existent NFT + let asset_id = Value::Bool(false); // this token does not exist + let asset_name = "test-nft"; + + // Call get_nft_owner directly + let err = db + .get_nft_owner( + &contract_id, + asset_name, + &asset_id, + &TypeSignature::BoolType, + ) + .unwrap_err(); + + // Assert that it produces NoSuchToken + assert!( + matches!(err, VmExecutionError::Runtime(RuntimeError::NoSuchToken, _)), + "Expected NoSuchToken. Got: {err}" + ); + } + + #[test] + fn get_contract_cached_populates_cache_on_miss() { + let cache = ContractCache::new(64 * 1024 * 1024); + let mut store = MemoryBackingStore::new(); + let mut db = store.as_clarity_db(); + db.set_contract_cache(&cache); + + let id = QualifiedContractIdentifier::local("cached-contract").unwrap(); + + // TX1: deploy and commit + deploy_test_contract(&mut db, &id); + + // Before the call the cache should be empty + assert!(cache.get(&id).is_none(), "cache should start empty"); - // Assert that it produces NoSuchToken - assert!( - matches!(err, VmExecutionError::Runtime(RuntimeError::NoSuchToken, _)), - "Expected NoSuchToken. Got: {err}" - ); + // TX2: First call: cache miss → populates cache + db.begin(); + let first = db.get_contract_cached(&id).expect("first load"); + assert_eq!(first.load_cost_size, db.get_contract_size(&id).unwrap()); + + // The cache must now contain the entry + assert!( + cache.get(&id).is_some(), + "cache should be populated after first load" + ); + db.roll_back().unwrap(); + } + + /// Deploy commits in one transaction context, then the next transaction + /// loads the contract and populates the cache from committed data. + #[test] + fn get_contract_cached_after_committed_deploy() { + let cache = ContractCache::new(64 * 1024 * 1024); + let mut store = MemoryBackingStore::new(); + let mut db = store.as_clarity_db(); + db.set_contract_cache(&cache); + + let id = QualifiedContractIdentifier::local("deploy-then-call").unwrap(); + + // TX1: deploy and commit + deploy_test_contract(&mut db, &id); + + // TX2: load the contract + db.begin(); + let cached = db.get_contract_cached(&id).expect("load after commit"); + assert_eq!(cached.contract.contract_context.contract_identifier, id); + + // Cache should be populated from committed data + assert!( + cache.get(&id).is_some(), + "cache should be populated after loading a committed contract" + ); + + db.roll_back().unwrap(); + } + + #[test] + fn is_retargeted_default_state() { + let mut store = MemoryBackingStore::new(); + let mut db = store.as_clarity_db(); + db.begin(); + assert!( + !db.store.is_retargeted(), + "MemoryBackingStore should not be retargeted by default" + ); + db.roll_back().unwrap(); + } + + #[test] + fn get_contract_load_cost_size_returns_from_cache() { + // Pre-populate the cache directly with a sentinel load_cost_size that the backing store + // does NOT contain. If get_contract_load_cost_size returns it, the value must have come + // from the cache. + let cache = ContractCache::new(64 * 1024 * 1024); + let id = QualifiedContractIdentifier::local("cost-size-test").unwrap(); + let sentinel_cost: u64 = 999_999; + + let contract = Contract { + contract_context: ContractContext::new(id.clone(), ClarityVersion::Clarity2), + }; + cache.insert( + id.clone(), + CachedContract::new(contract, sentinel_cost, 128), + ); + + let mut store = MemoryBackingStore::new(); + let mut db = store.as_clarity_db(); + db.set_contract_cache(&cache); + + // No contract deployed to the backing store — a DB lookup would fail. + // The cache hit must return our sentinel value. + db.begin(); + let cost_size = db.get_contract_load_cost_size(&id).unwrap(); + assert_eq!(cost_size, sentinel_cost); + db.roll_back().unwrap(); + } + + #[test] + fn get_contract_load_cost_size_falls_back_without_cache() { + // No cache attached — should fall back to get_contract_size + let mut store = MemoryBackingStore::new(); + let mut db = store.as_clarity_db(); + + let id = QualifiedContractIdentifier::local("no-cache-cost").unwrap(); + + // TX1: deploy and commit + deploy_test_contract(&mut db, &id); + + // TX2: call get_contract_load_cost_size, which should fall back to the DB value since no + // cache is attached + db.begin(); + let cost_size = db.get_contract_load_cost_size(&id).unwrap(); + let expected = db.get_contract_size(&id).unwrap(); + assert_eq!(cost_size, expected); + db.roll_back().unwrap(); + } + + #[test] + fn get_contract_cached_without_cache_returns_contract() { + // No cache attached — get_contract_cached should still work, just no caching + let mut store = MemoryBackingStore::new(); + let mut db = store.as_clarity_db(); + + let id = QualifiedContractIdentifier::local("no-cache-load").unwrap(); + + // TX1: deploy and commit + deploy_test_contract(&mut db, &id); + + // TX2: call get_contract_cached, which should succeed and return the contract even without + // a cache attached + db.begin(); + let result = db.get_contract_cached(&id).unwrap(); + assert_eq!(result.contract.contract_context.contract_identifier, id); + db.roll_back().unwrap(); + } + + #[test] + fn retargeted_bypasses_cache_for_get_contract_cached() { + let cache = ContractCache::new(64 * 1024 * 1024); + let mut store = MemoryBackingStore::new(); + let mut db = store.as_clarity_db(); + db.set_contract_cache(&cache); + + let id = QualifiedContractIdentifier::local("retarget-test").unwrap(); + + // TX1: deploy and commit + deploy_test_contract(&mut db, &id); + + // Pre-populate the cache with a sentinel load_cost_size that differs from the DB value. If + // the retargeted path still returns this sentinel, the cache was NOT bypassed. + let sentinel_cost: u64 = 777_777; + let sentinel_contract = Contract { + contract_context: ContractContext::new(id.clone(), ClarityVersion::Clarity2), + }; + cache.insert( + id.clone(), + CachedContract::new(sentinel_contract, sentinel_cost, 128), + ); + + // TX2: retargeted call to get_contract_cached should bypass the cache and return the DB + // value, not the sentinel + db.begin(); + db.store.test_set_retargeted(true); + + let result = db.get_contract_cached(&id).unwrap(); + assert_ne!( + result.load_cost_size, sentinel_cost, + "retargeted path should bypass the cache sentinel" + ); + + // The value should match what the DB returns + let db_size = db.get_contract_size(&id).unwrap(); + assert_eq!(result.load_cost_size, db_size); + + db.store.test_set_retargeted(false); + db.roll_back().unwrap(); + } + + #[test] + fn retargeted_bypasses_cache_for_get_contract_load_cost_size() { + let cache = ContractCache::new(64 * 1024 * 1024); + let mut store = MemoryBackingStore::new(); + let mut db = store.as_clarity_db(); + db.set_contract_cache(&cache); + + let id = QualifiedContractIdentifier::local("retarget-cost").unwrap(); + + // TX1: deploy and commit + deploy_test_contract(&mut db, &id); + + // Pre-populate the cache with a sentinel that differs from the DB value. + let sentinel_cost: u64 = 888_888; + let sentinel_contract = Contract { + contract_context: ContractContext::new(id.clone(), ClarityVersion::Clarity2), + }; + cache.insert( + id.clone(), + CachedContract::new(sentinel_contract, sentinel_cost, 128), + ); + + // TX2: retargeted call to get_contract_load_cost_size should bypass the cache and return the DB + // value, not the sentinel + db.begin(); + db.store.test_set_retargeted(true); + + let cost_size = db.get_contract_load_cost_size(&id).unwrap(); + assert_ne!( + cost_size, sentinel_cost, + "retargeted path should bypass the cache sentinel" + ); + let db_size = db.get_contract_size(&id).unwrap(); + assert_eq!(cost_size, db_size); + + db.store.test_set_retargeted(false); + db.roll_back().unwrap(); + } + + #[test] + fn retargeted_does_not_populate_cache() { + let cache = ContractCache::new(64 * 1024 * 1024); + let mut store = MemoryBackingStore::new(); + let mut db = store.as_clarity_db(); + db.set_contract_cache(&cache); + + let id = QualifiedContractIdentifier::local("retarget-no-populate").unwrap(); + + // TX1: deploy and commit + deploy_test_contract(&mut db, &id); + + // TX2: retargeted call to get_contract_cached should bypass the cache and also NOT populate + // it, since retargeted loads must not populate the cache + db.begin(); + + // Retarget before any cache interaction + db.store.test_set_retargeted(true); + db.get_contract_cached(&id).unwrap(); + + // Cache should remain empty — retargeted loads must not populate + assert!( + cache.get(&id).is_none(), + "retargeted load should not populate the cache" + ); + + db.store.test_set_retargeted(false); + db.roll_back().unwrap(); + } } diff --git a/clarity/src/vm/database/contract_cache.rs b/clarity/src/vm/database/contract_cache.rs new file mode 100644 index 00000000000..cc035503d35 --- /dev/null +++ b/clarity/src/vm/database/contract_cache.rs @@ -0,0 +1,383 @@ +// Copyright (C) 2026 Stacks Open Internet Foundation +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +//! TinyUFO-based in-memory cache for parsed Clarity contracts. + +use std::cell::RefCell; +use std::mem::size_of; +use std::ops::Deref; +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; + +use clarity_types::resident_bytes::ResidentBytes; +use stacks_common::types::StacksEpochId; +use stacks_common::types::chainstate::StacksBlockId; +use tinyufo::TinyUfo; + +use crate::vm::contracts::Contract; +use crate::vm::types::QualifiedContractIdentifier; + +/// Per-entry overhead: wrapper fields + Arc header (~16 bytes) + TinyUFO bookkeeping (~64 bytes). +const ENTRY_OVERHEAD: u64 = + (size_of::() - size_of::()) as u64 + 16 + 64; + +/// Weight unit (256 bytes) for LFU eviction. Allows up to ~16 MiB per entry +/// (`u16::MAX * 256`), which covers the largest possible parsed contract AST. +const CACHE_WEIGHT_UNIT: u64 = 256; + +/// Backing data for [`CachedContract`]. +pub struct CachedContractInner { + pub contract: Contract, + /// `contract_size` + `data_size` (for load-contract runtime cost) + pub load_cost_size: u64, + /// Actual heap footprint (for cache eviction weight) + pub resident_bytes: u64, +} + +/// Handle to a cached contract (`Arc`-backed, cheap to clone). +/// +/// Derefs to [`CachedContractInner`]. +#[derive(Clone)] +pub struct CachedContract(Arc); + +impl CachedContract { + /// Create a new cached contract entry. + pub fn new(contract: Contract, load_cost_size: u64, resident_bytes: u64) -> Self { + CachedContract(Arc::new(CachedContractInner { + contract, + load_cost_size, + resident_bytes, + })) + } +} + +impl Deref for CachedContract { + type Target = CachedContractInner; + + fn deref(&self) -> &CachedContractInner { + &self.0 + } +} + +/// LFU cache of parsed contracts (`TinyUFO`-backed), weighted by in-memory footprint. +/// +/// Invalidated on reorg or epoch transition. Call [`check_and_advance()`](Self::check_and_advance) +/// at each block start. +pub struct ContractCache { + cache: TinyUfo, + byte_limit: usize, + last_epoch: Option, + last_block: RefCell>, + hits: AtomicU64, + misses: AtomicU64, +} + +impl ContractCache { + /// Create a new cache with the given byte budget. + /// + /// Cache capacity is derived by dividing `byte_limit` by [`CACHE_WEIGHT_UNIT`]. + pub fn new(byte_limit: usize) -> Self { + Self { + cache: TinyUfo::new(byte_limit / CACHE_WEIGHT_UNIT as usize, 256), + byte_limit, + last_epoch: None, + last_block: RefCell::new(None), + hits: AtomicU64::new(0), + misses: AtomicU64::new(0), + } + } + + /// Look up a cached contract. Returns `None` on miss. + /// + /// Increments the `hits` or `misses` counter accordingly. + pub fn get(&self, key: &QualifiedContractIdentifier) -> Option { + let result = self.cache.get(key); + if result.is_some() { + self.hits.fetch_add(1, Ordering::Relaxed); + } else { + self.misses.fetch_add(1, Ordering::Relaxed); + } + result + } + + /// Gets the total number of cache hits since creation. + pub fn hits(&self) -> u64 { + self.hits.load(Ordering::Relaxed) + } + + /// Gets the total number of cache misses since creation. + pub fn misses(&self) -> u64 { + self.misses.load(Ordering::Relaxed) + } + + /// Insert a contract into the cache. + /// + /// The entry is silently dropped if its weight (resident size of the key + contract / + /// [`CACHE_WEIGHT_UNIT`]) exceeds `u16::MAX`. + pub fn insert(&self, key: QualifiedContractIdentifier, entry: CachedContract) { + let total = key.resident_bytes() as u64 + entry.resident_bytes + ENTRY_OVERHEAD; + let units = total.div_ceil(CACHE_WEIGHT_UNIT); + + // Don't cache entries that exceed the maximum representable weight. This _shouldn't_ happen + // in practice, but we guard against it because deploy-time contract size != resident + // runtime size. + let Some(weight) = u16::try_from(units.max(1)).ok() else { + return; + }; + + self.cache.put(key, entry, weight); + } + + /// Returns `Some(self)` if the cache was built for (or advanced through) `block`, + /// `None` otherwise. + /// + /// Read-only and ephemeral connections should use this to obtain a cache reference, + /// ensuring they never serve contracts from a different tip. + pub fn for_block(&self, block: &StacksBlockId) -> Option<&Self> { + (self.last_block.borrow().as_ref() == Some(block)).then_some(self) + } + + /// Mark the cache as stale so `for_block` returns `None` until the next + /// `check_and_advance`. + pub fn invalidate(&self) { + *self.last_block.borrow_mut() = None; + } + + /// Validate cache against the current block and epoch. + /// + /// If the parent block doesn't match the last seen block, or if the epoch has changed, we + /// assume a reorg or epoch transition has occurred and clear the cache to maintain correctness. + /// + /// Otherwise, the cache is updated to reflect the new `current_block` and preserved for + /// continued use. + pub fn check_and_advance( + &mut self, + parent_block: &StacksBlockId, + current_block: &StacksBlockId, + epoch: StacksEpochId, + ) { + let last_block = self.last_block.get_mut(); + if last_block.as_ref() != Some(parent_block) || self.last_epoch != Some(epoch) { + // TinyUFO doesn't have a clear() method, so replace it with a new instance. + self.cache = TinyUfo::new(self.byte_limit / CACHE_WEIGHT_UNIT as usize, 256); + self.last_epoch = Some(epoch); + } + *last_block = Some(current_block.clone()); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::vm::contexts::ContractContext; + use crate::vm::version::ClarityVersion; + + fn make_contract_id(name: &str) -> QualifiedContractIdentifier { + QualifiedContractIdentifier::local(name).unwrap() + } + + fn make_cached(load_cost_size: u64) -> CachedContract { + make_cached_with_size(load_cost_size, None) + } + + /// Create a [`CachedContract`] with an explicit `resident_bytes` override for eviction tests. + fn make_cached_with_size( + load_cost_size: u64, + resident_override: Option, + ) -> CachedContract { + let id = make_contract_id("test"); + let contract = Contract { + contract_context: ContractContext::new(id, ClarityVersion::Clarity4), + }; + let resident = resident_override.unwrap_or_else(|| contract.resident_bytes() as u64); + CachedContract::new(contract, load_cost_size, resident) + } + + #[test] + fn cache_hit_and_miss() { + let cache = ContractCache::new(64 * 1024 * 1024); + let id = make_contract_id("my-contract"); + assert!(cache.get(&id).is_none()); + + let entry = make_cached(1000); + cache.insert(id.clone(), entry.clone()); + let hit = cache.get(&id).unwrap(); + assert_eq!(hit.load_cost_size, 1000); + } + + #[test] + fn invalidate_clears_last_block() { + let mut cache = ContractCache::new(64 * 1024 * 1024); + let block_a = StacksBlockId([0x01; 32]); + let block_b = StacksBlockId([0x02; 32]); + + cache.check_and_advance(&block_a, &block_b, StacksEpochId::Epoch21); + assert!(cache.for_block(&block_b).is_some()); + + // Simulate rollback: invalidate through a shared reference + let cache_ref: &ContractCache = &cache; + cache_ref.invalidate(); + + assert!( + cache.for_block(&block_b).is_none(), + "for_block should return None after invalidate" + ); + } + + #[test] + fn check_and_advance_clears_on_epoch_change() { + let mut cache = ContractCache::new(64 * 1024 * 1024); + let block_a = StacksBlockId([0x01; 32]); + let block_b = StacksBlockId([0x02; 32]); + + cache.check_and_advance(&block_a, &block_b, StacksEpochId::Epoch21); + + let id = make_contract_id("cached"); + cache.insert(id.clone(), make_cached(500)); + assert!(cache.get(&id).is_some()); + + // Same parent, new block, different epoch → clear + cache.check_and_advance(&block_b, &StacksBlockId([0x03; 32]), StacksEpochId::Epoch25); + assert!(cache.get(&id).is_none()); + } + + #[test] + fn check_and_advance_clears_on_reorg() { + let mut cache = ContractCache::new(64 * 1024 * 1024); + let block_a = StacksBlockId([0x01; 32]); + let block_b = StacksBlockId([0x02; 32]); + + cache.check_and_advance(&block_a, &block_b, StacksEpochId::Epoch21); + + let id = make_contract_id("cached"); + cache.insert(id.clone(), make_cached(500)); + assert!(cache.get(&id).is_some()); + + // Parent doesn't match last block (block_b) → reorg → clear + let fork_parent = StacksBlockId([0xAA; 32]); + cache.check_and_advance( + &fork_parent, + &StacksBlockId([0xBB; 32]), + StacksEpochId::Epoch21, + ); + assert!(cache.get(&id).is_none()); + } + + #[test] + fn check_and_advance_preserves_on_linear_chain() { + let mut cache = ContractCache::new(64 * 1024 * 1024); + let block_a = StacksBlockId([0x01; 32]); + let block_b = StacksBlockId([0x02; 32]); + let block_c = StacksBlockId([0x03; 32]); + + cache.check_and_advance(&block_a, &block_b, StacksEpochId::Epoch21); + + let id = make_contract_id("cached"); + cache.insert(id.clone(), make_cached(500)); + + // Linear progression: parent = last block → preserve + cache.check_and_advance(&block_b, &block_c, StacksEpochId::Epoch21); + assert!(cache.get(&id).is_some()); + } + + #[test] + fn cached_contract_deref() { + let entry = make_cached(42); + assert_eq!(entry.load_cost_size, 42); + assert!(entry.resident_bytes > 0); + assert_eq!( + entry.contract.contract_context.contract_identifier, + make_contract_id("test") + ); + } + + #[test] + fn eviction_under_pressure() { + // Budget: 2 KiB = 2048 bytes. Each entry claims ~900 bytes of resident data. + // With ENTRY_OVERHEAD (96) + key overhead, each entry weighs roughly 1 KiB in weight units + // (≥4 × 256-byte units). Two entries should fit; a third should trigger eviction of an + // earlier one. + let cache = ContractCache::new(2048); + + let id_a = make_contract_id("contract-a"); + let id_b = make_contract_id("contract-b"); + let id_c = make_contract_id("contract-c"); + + cache.insert(id_a.clone(), make_cached_with_size(1, Some(900))); + cache.insert(id_b.clone(), make_cached_with_size(2, Some(900))); + cache.insert(id_c.clone(), make_cached_with_size(3, Some(900))); + + // At least one of the earlier entries should have been evicted + let hits: usize = [&id_a, &id_b, &id_c] + .iter() + .filter(|id| cache.get(id).is_some()) + .count(); + assert!( + hits < 3, + "expected eviction under a 2 KiB budget, but all 3 entries survived" + ); + + // The most recently inserted entry should still be present + assert!( + cache.get(&id_c).is_some(), + "most recent entry should survive eviction" + ); + } + + #[test] + fn oversized_entry_silently_dropped() { + let cache = ContractCache::new(64 * 1024 * 1024); + let id = make_contract_id("huge"); + + // resident_bytes large enough that weight > u16::MAX: + // u16::MAX * CACHE_WEIGHT_UNIT = 65535 * 256 = 16,776,960 + let huge_resident = u16::MAX as u64 * CACHE_WEIGHT_UNIT + 1; + let entry = make_cached_with_size(1, Some(huge_resident)); + cache.insert(id.clone(), entry); + + assert!( + cache.get(&id).is_none(), + "entry exceeding u16::MAX weight units should not be cached" + ); + } + + #[test] + fn weight_reflects_resident_bytes() { + // Verify that a larger contract gets a proportionally larger weight by checking that + // inserting two 8 KiB contracts fills a 16 KiB cache (leaving no room for a third), while + // two 1 KiB contracts would leave room. + let cache = ContractCache::new(16 * 1024); + + let id_a = make_contract_id("large-a"); + let id_b = make_contract_id("large-b"); + let id_c = make_contract_id("large-c"); + + // Each entry: ~8 KiB resident → weight ≈ 32 units (8192/256). + // Two entries ≈ 64 units; cache capacity = 16384/256 = 64 units → full. + cache.insert(id_a.clone(), make_cached_with_size(1, Some(8 * 1024))); + cache.insert(id_b.clone(), make_cached_with_size(2, Some(8 * 1024))); + cache.insert(id_c.clone(), make_cached_with_size(3, Some(8 * 1024))); + + // Third insert should have caused eviction + let hits: usize = [&id_a, &id_b, &id_c] + .iter() + .filter(|id| cache.get(id).is_some()) + .count(); + assert!( + hits < 3, + "cache should evict when filled with entries matching its capacity" + ); + } +} diff --git a/clarity/src/vm/database/key_value_wrapper.rs b/clarity/src/vm/database/key_value_wrapper.rs index 225de2a08e9..edc0bf87769 100644 --- a/clarity/src/vm/database/key_value_wrapper.rs +++ b/clarity/src/vm/database/key_value_wrapper.rs @@ -595,4 +595,20 @@ impl RollbackWrapper<'_> { ) -> bool { matches!(self.get_metadata(contract, key), Ok(Some(_))) } + + /// Returns `true` when the store has been retargeted (e.g. via `(at-block ...)`) and is no + /// longer reading from the current chain tip. + /// + /// In this state, pending data is not consulted and caches over the current tip must be + /// bypassed. + pub fn is_retargeted(&self) -> bool { + !self.query_pending_data + } + + /// Simulate a retarget for testing. In production this happens via + /// [`set_block_hash()`](Self::set_block_hash) with `query_pending_data: false`. + #[cfg(test)] + pub fn test_set_retargeted(&mut self, retargeted: bool) { + self.query_pending_data = !retargeted; + } } diff --git a/clarity/src/vm/database/mod.rs b/clarity/src/vm/database/mod.rs index a5a2ca8f894..91a24957804 100644 --- a/clarity/src/vm/database/mod.rs +++ b/clarity/src/vm/database/mod.rs @@ -21,6 +21,7 @@ pub use self::clarity_db::{ STORE_CONTRACT_SRC_INTERFACE, StoreType, }; pub use self::clarity_store::{ClarityBackingStore, SpecialCaseHandler}; +pub use self::contract_cache::{CachedContract, ContractCache}; pub use self::key_value_wrapper::{RollbackWrapper, RollbackWrapperPersistedLog}; #[cfg(feature = "rusqlite")] pub use self::sqlite::SqliteConnection; @@ -31,6 +32,7 @@ pub use self::structures::{ pub mod clarity_db; pub mod clarity_store; +mod contract_cache; mod key_value_wrapper; #[cfg(feature = "rusqlite")] pub mod sqlite; diff --git a/clarity/src/vm/database/structures.rs b/clarity/src/vm/database/structures.rs index 9741f3fb4ff..787a5a58486 100644 --- a/clarity/src/vm/database/structures.rs +++ b/clarity/src/vm/database/structures.rs @@ -16,6 +16,7 @@ use std::io::Write; +use clarity_types::resident_bytes::ResidentBytes; use serde::Deserialize; use stacks_common::util::hash::{hex_bytes, to_hex}; @@ -105,6 +106,30 @@ pub struct DataVariableMetadata { clarity_serializable!(DataVariableMetadata); +impl ResidentBytes for FungibleTokenMetadata { + fn heap_bytes(&self) -> usize { + 0 // Option — no heap allocation + } +} + +impl ResidentBytes for NonFungibleTokenMetadata { + fn heap_bytes(&self) -> usize { + self.key_type.heap_bytes() + } +} + +impl ResidentBytes for DataMapMetadata { + fn heap_bytes(&self) -> usize { + self.key_type.heap_bytes() + self.value_type.heap_bytes() + } +} + +impl ResidentBytes for DataVariableMetadata { + fn heap_bytes(&self) -> usize { + self.value_type.heap_bytes() + } +} + #[derive(Serialize, Deserialize)] pub struct ContractMetadata { pub contract: Contract, @@ -1475,3 +1500,60 @@ impl STXBalance { )? >= amount) } } + +#[cfg(test)] +mod tests { + use super::*; + + fn heap_allocating_type() -> TypeSignature { + TypeSignature::OptionalType(Box::new(TypeSignature::UIntType)) + } + + #[test] + fn resident_bytes_fungible_token_metadata_has_no_heap() { + let none = FungibleTokenMetadata { total_supply: None }; + let some = FungibleTokenMetadata { + total_supply: Some(1), + }; + + assert_eq!(none.heap_bytes(), 0); + assert_eq!(some.heap_bytes(), 0); + } + + #[test] + fn resident_bytes_non_fungible_token_metadata_counts_key_type() { + let metadata = NonFungibleTokenMetadata { + key_type: heap_allocating_type(), + }; + + assert_eq!(metadata.heap_bytes(), metadata.key_type.heap_bytes()); + assert!(metadata.heap_bytes() > 0); + } + + #[test] + fn resident_bytes_data_map_metadata_counts_key_and_value_types() { + let metadata = DataMapMetadata { + key_type: heap_allocating_type(), + value_type: TypeSignature::ResponseType(Box::new(( + TypeSignature::BoolType, + TypeSignature::UIntType, + ))), + }; + + assert_eq!( + metadata.heap_bytes(), + metadata.key_type.heap_bytes() + metadata.value_type.heap_bytes() + ); + assert!(metadata.heap_bytes() > 0); + } + + #[test] + fn resident_bytes_data_variable_metadata_counts_value_type() { + let metadata = DataVariableMetadata { + value_type: heap_allocating_type(), + }; + + assert_eq!(metadata.heap_bytes(), metadata.value_type.heap_bytes()); + assert!(metadata.heap_bytes() > 0); + } +} diff --git a/clarity/src/vm/functions/database.rs b/clarity/src/vm/functions/database.rs index 7167c6d5358..0eb4397ba8e 100644 --- a/clarity/src/vm/functions/database.rs +++ b/clarity/src/vm/functions/database.rs @@ -149,14 +149,14 @@ pub fn special_contract_call( .into()); } - let contract_to_check = exec_state + let cached_to_check = exec_state .global_context .database - .get_contract(&contract_identifier) + .get_contract_cached(&contract_identifier) .map_err(|_e| { RuntimeCheckErrorKind::NoSuchContract(contract_identifier.to_string()) })?; - let contract_context_to_check = contract_to_check.contract_context; + let contract_context_to_check = &cached_to_check.contract.contract_context; // Attempt to short circuit the dynamic dispatch checks: // If the contract is explicitely implementing the trait with `impl-trait`, @@ -168,17 +168,17 @@ pub fn special_contract_call( let trait_name = trait_identifier.name.to_string(); // Retrieve, from the trait definition, the expected method signature - let contract_defining_trait = exec_state + let cached_defining_trait = exec_state .global_context .database - .get_contract(&trait_identifier.contract_identifier) + .get_contract_cached(&trait_identifier.contract_identifier) .map_err(|_e| { RuntimeCheckErrorKind::NoSuchContract( trait_identifier.contract_identifier.to_string(), ) })?; let contract_context_defining_trait = - contract_defining_trait.contract_context; + &cached_defining_trait.contract.contract_context; // Retrieve the function that will be invoked let function_to_check = contract_context_to_check @@ -208,7 +208,7 @@ pub fn special_contract_call( // If this check succeeds, the subsequent trait reference and method checks cannot fail function_to_check.check_trait_expectations( exec_state.epoch(), - &contract_context_defining_trait, + contract_context_defining_trait, &trait_identifier, )?; diff --git a/clarity/src/vm/functions/mod.rs b/clarity/src/vm/functions/mod.rs index d63aa8937c2..8c7976b6d08 100644 --- a/clarity/src/vm/functions/mod.rs +++ b/clarity/src/vm/functions/mod.rs @@ -900,7 +900,7 @@ fn special_contract_of( exec_state .global_context .database - .get_contract(&trait_data.contract_identifier) + .get_contract_cached(&trait_data.contract_identifier) .map_err(|_e| { RuntimeCheckErrorKind::NoSuchContract( trait_data.contract_identifier.to_string(), diff --git a/clarity/src/vm/types/signatures.rs b/clarity/src/vm/types/signatures.rs index 62659be5daa..b2ef4a5cb1f 100644 --- a/clarity/src/vm/types/signatures.rs +++ b/clarity/src/vm/types/signatures.rs @@ -18,6 +18,7 @@ use std::collections::BTreeMap; use std::fmt; use clarity_types::ClarityTypeError; +use clarity_types::resident_bytes::ResidentBytes; pub use clarity_types::types::Value; pub use clarity_types::types::signatures::{ AssetIdentifier, BufferLength, CallableSubtype, ListTypeData, SequenceSubtype, StringSubtype, @@ -40,6 +41,12 @@ pub struct FunctionSignature { pub returns: TypeSignature, } +impl ResidentBytes for FunctionSignature { + fn heap_bytes(&self) -> usize { + self.args.heap_bytes() + self.returns.heap_bytes() + } +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct FixedFunction { pub args: Vec, @@ -811,3 +818,25 @@ mod test { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn resident_bytes_function_signature_counts_args_and_return() { + let signature = FunctionSignature { + args: vec![ + TypeSignature::PrincipalType, + TypeSignature::OptionalType(Box::new(TypeSignature::UIntType)), + ], + returns: TypeSignature::OptionalType(Box::new(TypeSignature::BoolType)), + }; + + assert_eq!( + signature.heap_bytes(), + signature.args.heap_bytes() + signature.returns.heap_bytes() + ); + assert!(signature.heap_bytes() > 0); + } +} diff --git a/stacks-common/src/util/macros.rs b/stacks-common/src/util/macros.rs index 3b272d17638..2dbdb74d277 100644 --- a/stacks-common/src/util/macros.rs +++ b/stacks-common/src/util/macros.rs @@ -234,6 +234,11 @@ macro_rules! guarded_string { self.len() == 0 } + /// Returns the heap capacity of the backing `String` buffer. + pub fn heap_capacity(&self) -> usize { + self.0.capacity() + } + /// The caller must guarantee that the conversion will succeed, because the method /// will panic otherwise. This is made for converting `&str` into things /// like `ClarityName`s, where the source value is hardcoded and thus it's visible diff --git a/stackslib/fuzz/Cargo.lock b/stackslib/fuzz/Cargo.lock index 87e919cfd88..c6395371316 100644 --- a/stackslib/fuzz/Cargo.lock +++ b/stackslib/fuzz/Cargo.lock @@ -2,6 +2,18 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "TinyUFO" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "011f852ef553046d2f180ecea9e91b9460387d10a75e3a995392e5130d626e20" +dependencies = [ + "ahash", + "crossbeam-queue", + "crossbeam-skiplist", + "flurry", +] + [[package]] name = "ahash" version = "0.8.12" @@ -9,6 +21,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" dependencies = [ "cfg-if 1.0.3", + "const-random", + "getrandom 0.3.3", "once_cell", "version_check", "zerocopy", @@ -151,6 +165,7 @@ dependencies = [ name = "clarity" version = "0.0.1" dependencies = [ + "TinyUFO", "clarity-types", "integer-sqrt", "lazy_static", @@ -184,6 +199,26 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom 0.2.16", + "once_cell", + "tiny-keccak", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -199,6 +234,40 @@ dependencies = [ "libc", ] +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-skiplist" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df29de440c58ca2cc6e587ec3d22347551a32435fbde9d2bff64e78a9ffa151b" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + [[package]] name = "crunchy" version = "0.2.4" @@ -411,6 +480,18 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7fd99930f64d146689264c637b5af2f0233a933bef0d8570e2526bf9e083192d" +[[package]] +name = "flurry" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf5efcf77a4da27927d3ab0509dec5b0954bb3bc59da5a1de9e52642ebd4cdf9" +dependencies = [ + "ahash", + "num_cpus", + "parking_lot", + "seize", +] + [[package]] name = "foldhash" version = "0.1.5" @@ -856,6 +937,15 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956" +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + [[package]] name = "log" version = "0.4.28" @@ -947,6 +1037,16 @@ dependencies = [ "autocfg", ] +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + [[package]] name = "once_cell" version = "1.21.3" @@ -972,6 +1072,29 @@ dependencies = [ "sha2 0.10.9", ] +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if 1.0.3", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -1100,6 +1223,15 @@ dependencies = [ "getrandom 0.2.16", ] +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.9.4", +] + [[package]] name = "regex" version = "1.11.2" @@ -1184,6 +1316,12 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + [[package]] name = "sec1" version = "0.7.3" @@ -1218,6 +1356,12 @@ dependencies = [ "cc", ] +[[package]] +name = "seize" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "689224d06523904ebcc9b482c6a3f4f7fb396096645c4cd10c0d2ff7371a34d3" + [[package]] name = "semver" version = "1.0.27" @@ -1584,6 +1728,15 @@ dependencies = [ "time-core", ] +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + [[package]] name = "tinystr" version = "0.8.1" diff --git a/stackslib/src/clarity_vm/clarity.rs b/stackslib/src/clarity_vm/clarity.rs index 21d0e07a44b..e788c60dd3f 100644 --- a/stackslib/src/clarity_vm/clarity.rs +++ b/stackslib/src/clarity_vm/clarity.rs @@ -24,7 +24,7 @@ pub use clarity::vm::clarity::{ClarityConnection, ClarityError}; use clarity::vm::contexts::{AbortCallback, AssetMap, OwnedEnvironment}; use clarity::vm::costs::{CostTracker, ExecutionCost, LimitedCostTracker}; use clarity::vm::database::{ - BurnStateDB, ClarityBackingStore, ClarityDatabase, HeadersDB, RollbackWrapper, + BurnStateDB, ClarityBackingStore, ClarityDatabase, ContractCache, HeadersDB, RollbackWrapper, RollbackWrapperPersistedLog, STXBalance, NULL_BURN_STATE_DB, NULL_HEADER_DB, }; use clarity::vm::errors::VmExecutionError; @@ -62,7 +62,9 @@ use crate::util_lib::strings::StacksString; pub const SIP_031_INITIAL_MINT: u128 = 200_000_000_000_000; -/// +/// Default byte budget for the parsed-contract cache (64 MiB). +const DEFAULT_CONTRACT_CACHE_SIZE: usize = 64 * 1024 * 1024; + /// A high-level interface for interacting with the Clarity VM. /// /// ClarityInstance takes ownership of a MARF + Sqlite store used for @@ -83,11 +85,14 @@ pub const SIP_031_INITIAL_MINT: u128 = 200_000_000_000_000; /// wish to benefit from some abstraction of high-level interfaces should implement the /// `TransactionConnection` trait, which contains auto implementations for the typical transaction /// types in a Clarity-based blockchain. -/// + pub struct ClarityInstance { datastore: MarfedKV, mainnet: bool, chain_id: u32, + /// Parsed-contract cache that persists across blocks. Invalidated on reorg or epoch change + /// via [`ContractCache::check_and_advance`]. + contract_cache: ContractCache, } /// @@ -117,6 +122,8 @@ pub struct ClarityBlockConnection<'a, 'b> { mainnet: bool, chain_id: u32, epoch: StacksEpochId, + /// Borrowed from [`ClarityInstance`]. `None` for genesis and test contexts. + contract_cache: Option<&'a ContractCache>, /// Callback checked at every Clarity `eval` call. Used by the miner to /// abort block assembly when a resource limit is exceeded (e.g. heap /// memory). Propagated to each `ClarityTransactionConnection` and from @@ -140,6 +147,8 @@ pub struct ClarityTransactionConnection<'a, 'b> { mainnet: bool, chain_id: u32, epoch: StacksEpochId, + /// Borrowed from [`ClarityInstance`]. `None` for genesis and test contexts. + contract_cache: Option<&'a ContractCache>, abort_callback: AbortCallback, } @@ -258,6 +267,7 @@ impl<'a, 'b> ClarityTransactionConnection<'a, 'b> { mainnet: bool, chain_id: u32, epoch: StacksEpochId, + contract_cache: Option<&'a ContractCache>, abort_callback: AbortCallback, ) -> ClarityTransactionConnection<'a, 'b> { let mut log = RollbackWrapperPersistedLog::new(); @@ -271,6 +281,7 @@ impl<'a, 'b> ClarityTransactionConnection<'a, 'b> { mainnet, chain_id, epoch, + contract_cache, abort_callback, } } @@ -281,6 +292,7 @@ pub struct ClarityReadOnlyConnection<'a> { header_db: &'a dyn HeadersDB, burn_state_db: &'a dyn BurnStateDB, epoch: StacksEpochId, + contract_cache: Option<&'a ContractCache>, } impl From for ClarityError { @@ -328,6 +340,7 @@ impl ClarityBlockConnection<'_, '_> { mainnet: false, chain_id: CHAIN_ID_TESTNET, epoch, + contract_cache: None, abort_callback: AbortCallback::None, } } @@ -392,9 +405,16 @@ impl ClarityInstance { datastore, mainnet, chain_id, + contract_cache: ContractCache::new(DEFAULT_CONTRACT_CACHE_SIZE), } } + /// Inspect the contract cache (test-only). + #[cfg(test)] + pub fn contract_cache(&self) -> &ContractCache { + &self.contract_cache + } + pub fn with_marf(&mut self, f: F) -> R where F: FnOnce(&mut MARF) -> R, @@ -438,8 +458,15 @@ impl ClarityInstance { let mut datastore = self.datastore.begin(current, next); let epoch = Self::get_epoch_of(current, header_db, burn_state_db); + + // Validate the cache before cost tracker init so that cost contract loads can hit it. + self.contract_cache + .check_and_advance(current, next, epoch.epoch_id); + let cost_track = { - let mut clarity_db = datastore.as_clarity_db(&NULL_HEADER_DB, &NULL_BURN_STATE_DB); + let mut clarity_db = datastore + .as_clarity_db(&NULL_HEADER_DB, &NULL_BURN_STATE_DB) + .with_contract_cache(&self.contract_cache); Some( LimitedCostTracker::new( self.mainnet, @@ -460,6 +487,7 @@ impl ClarityInstance { mainnet: self.mainnet, chain_id: self.chain_id, epoch: epoch.epoch_id, + contract_cache: Some(&self.contract_cache), abort_callback: AbortCallback::None, } } @@ -485,6 +513,7 @@ impl ClarityInstance { mainnet: self.mainnet, chain_id: self.chain_id, epoch, + contract_cache: None, abort_callback: AbortCallback::None, } } @@ -512,6 +541,7 @@ impl ClarityInstance { mainnet: self.mainnet, chain_id: self.chain_id, epoch, + contract_cache: None, abort_callback: AbortCallback::None, }; @@ -609,6 +639,7 @@ impl ClarityInstance { mainnet: self.mainnet, chain_id: self.chain_id, epoch, + contract_cache: None, abort_callback: AbortCallback::None, }; @@ -718,6 +749,7 @@ impl ClarityInstance { mainnet: self.mainnet, chain_id: self.chain_id, epoch: epoch.epoch_id, + contract_cache: None, abort_callback: AbortCallback::None, } } @@ -759,6 +791,7 @@ impl ClarityInstance { mainnet: self.mainnet, chain_id: self.chain_id, epoch: epoch.epoch_id, + contract_cache: None, abort_callback: AbortCallback::None, } } @@ -783,6 +816,8 @@ impl ClarityInstance { header_db: &'a dyn HeadersDB, burn_state_db: &'a dyn BurnStateDB, ) -> Result, ClarityError> { + let contract_cache = self.contract_cache.for_block(at_block); + let mut datastore = self.datastore.begin_read_only_checked(Some(at_block))?; let epoch = { let mut db = datastore.as_clarity_db(header_db, burn_state_db); @@ -797,6 +832,7 @@ impl ClarityInstance { header_db, burn_state_db, epoch, + contract_cache, }) } @@ -817,6 +853,9 @@ impl ClarityInstance { ) -> Result { let mut read_only_conn = self.datastore.begin_read_only(Some(at_block)); let mut clarity_db = read_only_conn.as_clarity_db(header_db, burn_state_db); + + clarity_db.set_contract_cache(self.contract_cache.for_block(at_block)); + let epoch_id = { clarity_db.begin(); let result = clarity_db.get_clarity_epoch_version(); @@ -842,6 +881,7 @@ impl ClarityConnection for ClarityBlockConnection<'_, '_> { F: FnOnce(ClarityDatabase) -> (R, ClarityDatabase), { let mut db = ClarityDatabase::new(&mut self.datastore, self.header_db, self.burn_state_db); + db.set_contract_cache(self.contract_cache); db.begin(); let (result, mut db) = to_do(db); db.roll_back() @@ -875,6 +915,7 @@ impl ClarityConnection for ClarityReadOnlyConnection<'_> { let mut db = self .datastore .as_clarity_db(self.header_db, self.burn_state_db); + db.set_contract_cache(self.contract_cache); db.begin(); let (result, mut db) = to_do(db); db.roll_back() @@ -916,6 +957,9 @@ impl<'a, 'b> ClarityBlockConnection<'a, 'b> { // this is a "lower-level" rollback than the roll backs performed in // ClarityDatabase or AnalysisDatabase -- this is done at the backing store level. debug!("Rollback Clarity datastore"); + if let Some(cc) = self.contract_cache { + cc.invalidate(); + } self.datastore.drop_current_trie(); } @@ -1979,6 +2023,7 @@ impl<'a, 'b> ClarityBlockConnection<'a, 'b> { self.mainnet, self.chain_id, self.epoch, + self.contract_cache, self.abort_callback.clone(), ) } @@ -2048,6 +2093,7 @@ impl ClarityConnection for ClarityTransactionConnection<'_, '_> { self.header_db, self.burn_state_db, ); + db.set_contract_cache(self.contract_cache); db.begin(); let (r, mut db) = to_do(db); db.roll_back() @@ -2114,6 +2160,9 @@ impl TransactionConnection for ClarityTransactionConnection<'_, '_> { self.burn_state_db, ); + // Use the contract cache. + db.set_contract_cache(self.contract_cache); + // wrap the whole contract-call in a claritydb transaction, // so we can abort on call_back's boolean retun db.begin(); @@ -2187,6 +2236,9 @@ impl ClarityTransactionConnection<'_, '_> { self.burn_state_db, ); + // Use the contract cache if one is set. + db.set_contract_cache(self.contract_cache); + db.begin(); let result = to_do(&mut db); let db_result = if result.is_ok() { @@ -3357,4 +3409,380 @@ mod tests { conn.commit_block(); } } + + #[test] + pub fn contract_cache_persists_across_blocks() { + let marf = MarfedKV::temporary(); + let mut clarity_instance = ClarityInstance::new(false, CHAIN_ID_TESTNET, marf); + let contract_identifier = QualifiedContractIdentifier::local("counter").unwrap(); + let sender = StandardPrincipalData::transient().into(); + + // Genesis + clarity_instance + .begin_test_genesis_block( + &StacksBlockId::sentinel(), + &StacksBlockId([0; 32]), + &TEST_HEADER_DB, + &TEST_BURN_STATE_DB, + ) + .commit_block(); + + // Block 1: deploy the contract + { + let mut conn = clarity_instance.begin_block( + &StacksBlockId([0; 32]), + &StacksBlockId([1; 32]), + &TEST_HEADER_DB, + &TEST_BURN_STATE_DB, + ); + + let contract = "(define-data-var counter int 0) + (define-public (increment) (begin (var-set counter (+ (var-get counter) 1)) (ok (var-get counter))))"; + + conn.as_transaction(|tx| { + let (ct_ast, ct_analysis) = tx + .analyze_smart_contract( + &contract_identifier, + ClarityVersion::Clarity1, + contract, + ) + .unwrap(); + tx.initialize_smart_contract( + &contract_identifier, + ClarityVersion::Clarity1, + &ct_ast, + contract, + None, + |_, _| None, + None, + ) + .unwrap(); + tx.save_analysis(&contract_identifier, &ct_analysis) + .unwrap(); + }); + + conn.commit_block(); + } + + // No hits yet: begin_block loaded the cost contract via get_contract_cached (a cold miss), + // and deploy doesn't go through the cache path. + assert_eq!(clarity_instance.contract_cache().hits(), 0); + + // Block 2: call the contract (cache miss → populates) + let misses_before_block2 = clarity_instance.contract_cache().misses(); + { + let mut conn = clarity_instance.begin_block( + &StacksBlockId([1; 32]), + &StacksBlockId([2; 32]), + &TEST_HEADER_DB, + &TEST_BURN_STATE_DB, + ); + + let result = conn + .as_transaction(|tx| { + tx.run_contract_call( + &sender, + None, + &contract_identifier, + "increment", + &[], + |_, _| None, + None, + ) + }) + .unwrap() + .0; + assert_eq!(result, Value::okay(Value::Int(1)).unwrap()); + + conn.commit_block(); + } + + // The contract load should have been a cache miss + assert!( + clarity_instance.contract_cache().misses() > misses_before_block2, + "block 2 should record at least one cache miss" + ); + + // Block 3: call again — the cached contract should produce a hit. Snapshot hits *after + // begin_block (via the block connection) so that cost-contract hits during block + // initialization don't inflate the delta. + { + let mut conn = clarity_instance.begin_block( + &StacksBlockId([2; 32]), + &StacksBlockId([3; 32]), + &TEST_HEADER_DB, + &TEST_BURN_STATE_DB, + ); + + let hits_before_call = conn.contract_cache.unwrap().hits(); + + let result = conn + .as_transaction(|tx| { + tx.run_contract_call( + &sender, + None, + &contract_identifier, + "increment", + &[], + |_, _| None, + None, + ) + }) + .unwrap() + .0; + assert_eq!(result, Value::okay(Value::Int(2)).unwrap()); + + let hits_after_call = conn.contract_cache.unwrap().hits(); + conn.commit_block(); + + assert!( + hits_after_call > hits_before_call, + "block 3 should record at least one cache hit (cross-block persistence)" + ); + } + } + + /// Read-only connections at a historical block must not use the shared cache, because the cache + /// reflects the current tip and could contain contracts deployed after the requested block. + #[test] + pub fn readonly_at_historical_block_bypasses_cache() { + let marf = MarfedKV::temporary(); + let mut clarity_instance = ClarityInstance::new(false, CHAIN_ID_TESTNET, marf); + let contract_id = QualifiedContractIdentifier::local("counter").unwrap(); + + // Genesis (block 0) + clarity_instance + .begin_test_genesis_block( + &StacksBlockId::sentinel(), + &StacksBlockId([0; 32]), + &TEST_HEADER_DB, + &TEST_BURN_STATE_DB, + ) + .commit_block(); + + // Block 1: deploy the contract + { + let mut conn = clarity_instance.begin_block( + &StacksBlockId([0; 32]), + &StacksBlockId([1; 32]), + &TEST_HEADER_DB, + &TEST_BURN_STATE_DB, + ); + let src = "(define-data-var counter int 0) + (define-public (increment) (begin (var-set counter (+ (var-get counter) 1)) (ok (var-get counter))))"; + conn.as_transaction(|tx| { + let (ast, analysis) = tx + .analyze_smart_contract(&contract_id, ClarityVersion::Clarity1, src) + .unwrap(); + tx.initialize_smart_contract( + &contract_id, + ClarityVersion::Clarity1, + &ast, + src, + None, + |_, _| None, + None, + ) + .unwrap(); + tx.save_analysis(&contract_id, &analysis).unwrap(); + }); + conn.commit_block(); + } + + // Block 2: call the contract so it populates the cache + { + let mut conn = clarity_instance.begin_block( + &StacksBlockId([1; 32]), + &StacksBlockId([2; 32]), + &TEST_HEADER_DB, + &TEST_BURN_STATE_DB, + ); + let sender = StandardPrincipalData::transient().into(); + conn.as_transaction(|tx| { + tx.run_contract_call( + &sender, + None, + &contract_id, + "increment", + &[], + |_, _| None, + None, + ) + }) + .unwrap(); + conn.commit_block(); + } + + // The contract should be in the cache now (from block 2 execution). + assert!( + clarity_instance.contract_cache().hits() > 0 + || clarity_instance.contract_cache().misses() > 0, + "cache should have been exercised during block 2" + ); + + // Read-only at block 0 (before the contract was deployed). The cache has the contract, but + // the guard should prevent attachment because block 0 != the cache's last_block (block 2). + let result = clarity_instance.eval_read_only( + &StacksBlockId([0; 32]), + &TEST_HEADER_DB, + &TEST_BURN_STATE_DB, + &contract_id, + "(var-get counter)", + ); + + assert!( + result.is_err(), + "contract should not be visible at block 0 (deployed in block 1), \ + but the read-only connection returned: {:?}", + result, + ); + + // Read-only at block 2 (current tip) should work fine. + let result = clarity_instance + .eval_read_only( + &StacksBlockId([2; 32]), + &TEST_HEADER_DB, + &TEST_BURN_STATE_DB, + &contract_id, + "(var-get counter)", + ) + .expect("read-only at current tip should succeed"); + assert_eq!(result, Value::Int(1)); + } + + /// After rollback_block(), the cache must be invalidated immediately so that a read-only + /// connection opened before the next begin_block() does not serve contracts from the + /// rolled-back block. + #[test] + pub fn rollback_invalidates_cache_before_next_begin_block() { + let marf = MarfedKV::temporary(); + let mut clarity_instance = ClarityInstance::new(false, CHAIN_ID_TESTNET, marf); + let contract_id = QualifiedContractIdentifier::local("counter").unwrap(); + let sender = StandardPrincipalData::transient().into(); + + // Genesis + clarity_instance + .begin_test_genesis_block( + &StacksBlockId::sentinel(), + &StacksBlockId([0; 32]), + &TEST_HEADER_DB, + &TEST_BURN_STATE_DB, + ) + .commit_block(); + + // Block 1: deploy and commit + { + let mut conn = clarity_instance.begin_block( + &StacksBlockId([0; 32]), + &StacksBlockId([1; 32]), + &TEST_HEADER_DB, + &TEST_BURN_STATE_DB, + ); + let src = "(define-data-var counter int 0) + (define-public (increment) (begin (var-set counter (+ (var-get counter) 1)) (ok (var-get counter))))"; + conn.as_transaction(|tx| { + let (ast, analysis) = tx + .analyze_smart_contract(&contract_id, ClarityVersion::Clarity1, src) + .unwrap(); + tx.initialize_smart_contract( + &contract_id, + ClarityVersion::Clarity1, + &ast, + src, + None, + |_, _| None, + None, + ) + .unwrap(); + tx.save_analysis(&contract_id, &analysis).unwrap(); + }); + conn.commit_block(); + } + + // Block 2: call the contract (populates the cache), then commit + { + let mut conn = clarity_instance.begin_block( + &StacksBlockId([1; 32]), + &StacksBlockId([2; 32]), + &TEST_HEADER_DB, + &TEST_BURN_STATE_DB, + ); + conn.as_transaction(|tx| { + tx.run_contract_call( + &sender, + None, + &contract_id, + "increment", + &[], + |_, _| None, + None, + ) + }) + .unwrap(); + conn.commit_block(); + } + + // Cache should be valid for block 2 now. + assert!( + clarity_instance + .contract_cache() + .for_block(&StacksBlockId([2; 32])) + .is_some(), + "cache should be valid for block 2 after commit" + ); + + // Block 3: begin, call the contract (cache hit), then ROLLBACK. + { + let mut conn = clarity_instance.begin_block( + &StacksBlockId([2; 32]), + &StacksBlockId([3; 32]), + &TEST_HEADER_DB, + &TEST_BURN_STATE_DB, + ); + conn.as_transaction(|tx| { + tx.run_contract_call( + &sender, + None, + &contract_id, + "increment", + &[], + |_, _| None, + None, + ) + }) + .unwrap(); + conn.rollback_block(); + } + + // After rollback, the cache must be invalidated. A read-only connection at block 2 (the + // last committed block) must not use stale cache state. + assert!( + clarity_instance + .contract_cache() + .for_block(&StacksBlockId([3; 32])) + .is_none(), + "cache should not be valid for the rolled-back block" + ); + assert!( + clarity_instance + .contract_cache() + .for_block(&StacksBlockId([2; 32])) + .is_none(), + "cache should not be valid for any block after invalidation" + ); + + // A read-only eval at block 2 should still work (goes through the backing store, not the + // invalidated cache). + let result = clarity_instance + .eval_read_only( + &StacksBlockId([2; 32]), + &TEST_HEADER_DB, + &TEST_BURN_STATE_DB, + &contract_id, + "(var-get counter)", + ) + .expect("read-only at last committed block should succeed"); + // counter was incremented once in block 2 (committed), block 3 was rolled back + assert_eq!(result, Value::Int(1)); + } }