1use std::sync::Arc;
9
10use nodedb_types::{DatabaseId, TenantId};
11
12use crate::engine::EngineId;
13use crate::error::Result;
14use crate::governor::MemoryGovernor;
15use crate::reservation_token::ReservationToken;
16
17#[derive(Clone, Debug)]
19pub struct ScopedMemory {
20 governor: Arc<MemoryGovernor>,
21 database_id: DatabaseId,
22 tenant_id: TenantId,
23 engine: EngineId,
24}
25
26impl ScopedMemory {
27 pub fn new(
28 governor: Arc<MemoryGovernor>,
29 database_id: DatabaseId,
30 tenant_id: TenantId,
31 engine: EngineId,
32 ) -> Self {
33 Self {
34 governor,
35 database_id,
36 tenant_id,
37 engine,
38 }
39 }
40
41 pub fn reserve(&self, bytes: usize) -> Result<ReservationToken> {
47 self.governor
48 .try_reserve(self.database_id, self.tenant_id, self.engine, bytes)
49 }
50
51 pub fn charge(&self, bytes: usize) -> ReservationToken {
55 self.governor
56 .charge(self.database_id, self.tenant_id, self.engine, bytes)
57 }
58
59 pub fn engine(&self) -> EngineId {
60 self.engine
61 }
62
63 pub fn database_id(&self) -> DatabaseId {
64 self.database_id
65 }
66
67 pub fn tenant_id(&self) -> TenantId {
68 self.tenant_id
69 }
70
71 pub fn governor(&self) -> &Arc<MemoryGovernor> {
72 &self.governor
73 }
74}
75
76#[cfg(test)]
77mod tests {
78 use std::sync::Arc;
79 use std::sync::atomic::Ordering;
80
81 use nodedb_types::{DatabaseId, TenantId};
82
83 use super::*;
84 use crate::engine_limits::EngineLimits;
85 use crate::error::MemError;
86 use crate::governor::GovernorConfig;
87
88 fn make_governor(limits: &[(EngineId, usize)], ceiling: usize) -> Arc<MemoryGovernor> {
89 let engine_limits: EngineLimits = limits.iter().copied().collect();
90 Arc::new(
91 MemoryGovernor::new(GovernorConfig {
92 global_ceiling: ceiling,
93 engine_limits,
94 })
95 .expect("valid config"),
96 )
97 }
98
99 fn scoped(gov: &Arc<MemoryGovernor>, engine: EngineId) -> ScopedMemory {
100 ScopedMemory::new(
101 Arc::clone(gov),
102 DatabaseId::DEFAULT,
103 TenantId::new(1),
104 engine,
105 )
106 }
107
108 #[test]
109 fn reservation_moves_the_global_counter_symmetrically() {
110 let gov = make_governor(&[(EngineId::Vector, 4096)], 8192);
111 let mem = scoped(&gov, EngineId::Vector);
112
113 {
114 let _token = mem.reserve(1000).expect("within budget");
115 assert_eq!(
116 gov.global_counter.allocated.load(Ordering::Relaxed),
117 1000,
118 "a reservation must increment the global counter it releases on drop"
119 );
120 }
121
122 assert_eq!(
123 gov.global_counter.allocated.load(Ordering::Relaxed),
124 0,
125 "dropping the token must return the global counter to zero"
126 );
127 }
128
129 #[test]
130 fn reserve_over_engine_budget_returns_err_and_charges_nothing() {
131 let gov = make_governor(&[(EngineId::Fts, 512)], 1024);
132 let mem = scoped(&gov, EngineId::Fts);
133
134 let err = mem.reserve(1000).unwrap_err();
135 assert!(
136 matches!(err, MemError::BudgetExhausted { .. }),
137 "expected BudgetExhausted, got {err:?}"
138 );
139 assert_eq!(gov.budget(EngineId::Fts).allocated(), 0);
140 }
141
142 #[test]
143 fn independent_engines_accumulate_and_release_independently() {
144 let gov = make_governor(
145 &[
146 (EngineId::Vector, 4096),
147 (EngineId::Columnar, 4096),
148 (EngineId::Graph, 4096),
149 ],
150 16384,
151 );
152 let vector = scoped(&gov, EngineId::Vector);
153 let columnar = scoped(&gov, EngineId::Columnar);
154 let graph = scoped(&gov, EngineId::Graph);
155
156 let t1 = vector.reserve(1000).unwrap();
157 let t2 = columnar.reserve(2000).unwrap();
158 let t3 = graph.reserve(3000).unwrap();
159
160 assert_eq!(gov.budget(EngineId::Vector).allocated(), 1000);
161 assert_eq!(gov.budget(EngineId::Columnar).allocated(), 2000);
162 assert_eq!(gov.budget(EngineId::Graph).allocated(), 3000);
163
164 drop(t2);
165 assert_eq!(gov.budget(EngineId::Vector).allocated(), 1000);
166 assert_eq!(gov.budget(EngineId::Columnar).allocated(), 0);
167 assert_eq!(gov.budget(EngineId::Graph).allocated(), 3000);
168
169 drop(t1);
170 drop(t3);
171 assert_eq!(gov.budget(EngineId::Vector).allocated(), 0);
172 assert_eq!(gov.budget(EngineId::Graph).allocated(), 0);
173 }
174
175 #[test]
176 fn freed_reservation_can_be_reserved_again() {
177 let gov = make_governor(&[(EngineId::Timeseries, 1024)], 2048);
178 let mem = scoped(&gov, EngineId::Timeseries);
179
180 {
181 let _t = mem.reserve(1024).unwrap();
182 assert!(mem.reserve(1).is_err(), "budget fully consumed");
183 } let _t2 = mem
186 .reserve(1024)
187 .expect("budget freed by previous token drop");
188 }
189
190 #[test]
193 fn mem_forget_does_not_release() {
194 let gov = make_governor(&[(EngineId::Kv, 4096)], 8192);
195 let mem = scoped(&gov, EngineId::Kv);
196
197 let token = mem.reserve(500).unwrap();
198 assert_eq!(gov.budget(EngineId::Kv).allocated(), 500);
199
200 std::mem::forget(token);
201
202 assert_eq!(
203 gov.budget(EngineId::Kv).allocated(),
204 500,
205 "mem::forget intentionally skips drop; bytes remain charged"
206 );
207 }
208
209 #[test]
210 fn reserve_zero_bytes_is_allowed() {
211 let gov = make_governor(&[(EngineId::Query, 1024)], 2048);
212 let mem = scoped(&gov, EngineId::Query);
213
214 let token = mem.reserve(0).expect("zero bytes always fits");
215 assert_eq!(token.size(), 0);
216 drop(token);
217 assert_eq!(gov.budget(EngineId::Query).allocated(), 0);
218 }
219
220 #[test]
223 fn charge_past_the_engine_limit_still_accounts_the_bytes() {
224 let gov = make_governor(&[(EngineId::Timeseries, 100)], 1000);
225 let mem = scoped(&gov, EngineId::Timeseries);
226
227 let token = mem.charge(500);
228 assert_eq!(token.size(), 500);
229 assert_eq!(
230 gov.budget(EngineId::Timeseries).allocated(),
231 500,
232 "a charge past the engine limit must still be visible to the governor"
233 );
234 }
235
236 #[test]
237 fn charge_past_the_global_ceiling_still_accounts_and_clamps_utilization() {
238 let gov = make_governor(&[(EngineId::Timeseries, 50)], 100);
239 let mem = scoped(&gov, EngineId::Timeseries);
240
241 let token = mem.charge(500);
242 assert_eq!(token.size(), 500);
243 assert_eq!(
244 gov.global_utilization_percent(),
245 100,
246 "utilization must clamp at 100%, not wrap, when a charge exceeds the ceiling"
247 );
248 }
249
250 #[test]
251 fn dropping_a_charged_token_releases_every_layer() {
252 let gov = make_governor(&[(EngineId::Kv, 1024)], 2048);
253 let mem = scoped(&gov, EngineId::Kv);
254
255 {
256 let _token = mem.charge(2000);
257 assert_eq!(gov.budget(EngineId::Kv).allocated(), 2000);
258 assert_eq!(gov.global_counter.allocated.load(Ordering::Relaxed), 2000);
259 }
260
261 assert_eq!(
262 gov.budget(EngineId::Kv).allocated(),
263 0,
264 "dropping a charged token must return the engine counter to baseline"
265 );
266 assert_eq!(
267 gov.global_counter.allocated.load(Ordering::Relaxed),
268 0,
269 "dropping a charged token must return the global counter to baseline"
270 );
271 }
272
273 #[test]
274 fn charge_does_not_loosen_reserve() {
275 let gov = make_governor(&[(EngineId::Graph, 100)], 1000);
276 let mem = scoped(&gov, EngineId::Graph);
277
278 let _held = mem.charge(500);
279 let err = mem
280 .reserve(1)
281 .expect_err("reserve must still refuse past the limit after an unrelated charge");
282 assert!(matches!(err, MemError::BudgetExhausted { .. }));
283 }
284}