greentic_runner_host/runner/
contract_cache.rs1use std::collections::{HashMap, VecDeque};
2use std::sync::Arc;
3
4use parking_lot::Mutex;
5use serde::{Deserialize, Serialize};
6
7const DEFAULT_CONTRACT_CACHE_MAX_BYTES: u64 = 256 * 1024 * 1024;
8
9#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
10pub struct ContractSnapshot {
11 pub resolved_digest: String,
12 pub component_id: String,
13 pub operation_id: String,
14 pub validate_output: bool,
15 pub strict: bool,
16 pub describe_hash: Option<String>,
17 pub schema_hash: Option<String>,
18}
19
20impl ContractSnapshot {
21 pub fn new(
22 resolved_digest: String,
23 component_id: String,
24 operation_id: String,
25 validate_output: bool,
26 strict: bool,
27 ) -> Self {
28 Self {
29 resolved_digest,
30 component_id,
31 operation_id,
32 validate_output,
33 strict,
34 describe_hash: None,
35 schema_hash: None,
36 }
37 }
38
39 fn estimated_bytes(&self) -> u64 {
40 let mut bytes = 128_u64;
41 bytes = bytes.saturating_add(self.resolved_digest.len() as u64);
42 bytes = bytes.saturating_add(self.component_id.len() as u64);
43 bytes = bytes.saturating_add(self.operation_id.len() as u64);
44 if let Some(value) = &self.describe_hash {
45 bytes = bytes.saturating_add(value.len() as u64);
46 }
47 if let Some(value) = &self.schema_hash {
48 bytes = bytes.saturating_add(value.len() as u64);
49 }
50 bytes
51 }
52}
53
54#[derive(Debug, Clone, Default, PartialEq, Eq)]
55pub struct ContractCacheStats {
56 pub hits: u64,
57 pub misses: u64,
58 pub evictions: u64,
59 pub entries: u64,
60 pub total_bytes: u64,
61}
62
63#[derive(Debug, Clone)]
64pub struct ContractCache {
65 max_bytes: u64,
66 state: Arc<Mutex<ContractCacheState>>,
67}
68
69#[derive(Debug, Default)]
70struct ContractCacheState {
71 entries: HashMap<String, ContractCacheEntry>,
72 lru: VecDeque<String>,
73 total_bytes: u64,
74 hits: u64,
75 misses: u64,
76 evictions: u64,
77}
78
79#[derive(Debug)]
80struct ContractCacheEntry {
81 snapshot: Arc<ContractSnapshot>,
82 bytes_estimate: u64,
83}
84
85impl ContractCache {
86 pub fn new(max_bytes: u64) -> Self {
87 Self {
88 max_bytes,
89 state: Arc::new(Mutex::new(ContractCacheState::default())),
90 }
91 }
92
93 pub fn from_env() -> Self {
94 let max_bytes = std::env::var("GREENTIC_CONTRACT_CACHE_MAX_BYTES")
95 .ok()
96 .and_then(|raw| raw.trim().parse::<u64>().ok())
97 .unwrap_or(DEFAULT_CONTRACT_CACHE_MAX_BYTES);
98 Self::new(max_bytes)
99 }
100
101 pub fn get(&self, key: &str) -> Option<Arc<ContractSnapshot>> {
102 let mut state = self.state.lock();
103 if state.entries.contains_key(key) {
104 state.hits = state.hits.saturating_add(1);
105 let snapshot = state
106 .entries
107 .get(key)
108 .map(|entry| Arc::clone(&entry.snapshot));
109 touch_lru(&mut state.lru, key);
110 return snapshot;
111 }
112 state.misses = state.misses.saturating_add(1);
113 None
114 }
115
116 pub fn insert(&self, key: String, snapshot: Arc<ContractSnapshot>) {
117 let mut state = self.state.lock();
118 if let Some(existing) = state.entries.remove(&key) {
119 state.total_bytes = state.total_bytes.saturating_sub(existing.bytes_estimate);
120 remove_lru(&mut state.lru, &key);
121 }
122 let bytes_estimate = snapshot.estimated_bytes();
123 state.entries.insert(
124 key.clone(),
125 ContractCacheEntry {
126 snapshot,
127 bytes_estimate,
128 },
129 );
130 state.total_bytes = state.total_bytes.saturating_add(bytes_estimate);
131 state.lru.push_front(key);
132 self.evict_if_needed(&mut state);
133 }
134
135 pub fn stats(&self) -> ContractCacheStats {
136 let state = self.state.lock();
137 ContractCacheStats {
138 hits: state.hits,
139 misses: state.misses,
140 evictions: state.evictions,
141 entries: state.entries.len() as u64,
142 total_bytes: state.total_bytes,
143 }
144 }
145
146 fn evict_if_needed(&self, state: &mut ContractCacheState) {
147 if self.max_bytes == 0 {
148 return;
149 }
150 while state.total_bytes > self.max_bytes {
151 let Some(candidate) = state.lru.pop_back() else {
152 break;
153 };
154 if let Some(entry) = state.entries.remove(&candidate) {
155 state.total_bytes = state.total_bytes.saturating_sub(entry.bytes_estimate);
156 state.evictions = state.evictions.saturating_add(1);
157 }
158 }
159 }
160}
161
162fn touch_lru(lru: &mut VecDeque<String>, key: &str) {
163 if let Some(pos) = lru.iter().position(|item| item == key) {
164 lru.remove(pos);
165 lru.push_front(key.to_string());
166 }
167}
168
169fn remove_lru(lru: &mut VecDeque<String>, key: &str) {
170 if let Some(pos) = lru.iter().position(|item| item == key) {
171 lru.remove(pos);
172 }
173}
174
175#[cfg(test)]
176mod tests {
177 use super::*;
178
179 #[test]
180 fn cache_tracks_hits_and_lru_eviction() {
181 let cache = ContractCache::new(256);
182 let key_a = "sha256:a::component.alpha::run".to_string();
183 let key_b = "sha256:b::component.beta::run".to_string();
184
185 cache.insert(
186 key_a.clone(),
187 Arc::new(ContractSnapshot::new(
188 "sha256:a".to_string(),
189 "component.alpha".to_string(),
190 "run".to_string(),
191 true,
192 true,
193 )),
194 );
195 assert!(cache.get(&key_a).is_some());
196 cache.insert(
197 key_b.clone(),
198 Arc::new(ContractSnapshot::new(
199 "sha256:b".to_string(),
200 "component.beta".to_string(),
201 "run".to_string(),
202 true,
203 true,
204 )),
205 );
206 let stats = cache.stats();
207 assert!(stats.hits >= 1);
208 assert!(stats.entries >= 1);
209 assert!(cache.get(&key_b).is_some());
210 }
211}