Skip to main content

nodedb_mem/governor/
quotas.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Database and tenant budget quotas: set, clear, and scope teardown.
4//!
5//! ## Lock-poisoning policy
6//!
7//! The maps guarded by `RwLock` here (`database_budgets`, `tenant_budgets`)
8//! contain only `Arc<Budget>` handles — never partially-mutated invariants.
9//! `Budget` itself is built from atomics and is consistent at every byte
10//! boundary. A panic in another thread therefore cannot leave the *contents*
11//! of these maps in an inconsistent state; only the `RwLock`'s poison flag
12//! is set. The governor recovers via `unwrap_or_else(|p| p.into_inner())`
13//! so a one-off panic in a quota helper does not poison the entire memory
14//! subsystem and stall every future reservation. If a Budget's atomics ever
15//! grow into a multi-step protocol that can be partially updated, this
16//! policy must be revisited.
17
18use nodedb_types::{DatabaseId, TenantId};
19
20use super::core::MemoryGovernor;
21use crate::scoped_budget::{clear_scoped_limit, set_scoped_limit};
22
23impl MemoryGovernor {
24    // ── Database budget setters ───────────────────────────────────────────────
25
26    /// Install or replace the memory ceiling for a database.
27    ///
28    /// Called by the catalog apply path when `ALTER DATABASE … SET QUOTA` is
29    /// executed. Takes effect for all subsequent `try_reserve` calls; in-flight
30    /// tokens already issued are not recalled.
31    pub fn set_database_budget(&self, db: DatabaseId, max_bytes: usize) {
32        let mut map = self
33            .database_budgets
34            .write()
35            .unwrap_or_else(|p| p.into_inner());
36        set_scoped_limit(&mut map, db, max_bytes);
37    }
38
39    /// Remove the per-database budget ceiling, making that database uncapped.
40    pub fn clear_database_budget(&self, db: DatabaseId) {
41        let mut map = self
42            .database_budgets
43            .write()
44            .unwrap_or_else(|p| p.into_inner());
45        clear_scoped_limit(&mut map, &db);
46    }
47
48    // ── Tenant budget setters ─────────────────────────────────────────────────
49
50    /// Install or replace the memory ceiling for a tenant within a database.
51    pub fn set_tenant_budget(&self, db: DatabaseId, tenant: TenantId, max_bytes: usize) {
52        let mut map = self
53            .tenant_budgets
54            .write()
55            .unwrap_or_else(|p| p.into_inner());
56        set_scoped_limit(&mut map, (db, tenant), max_bytes);
57    }
58
59    /// Remove the per-tenant budget ceiling.
60    pub fn clear_tenant_budget(&self, db: DatabaseId, tenant: TenantId) {
61        let mut map = self
62            .tenant_budgets
63            .write()
64            .unwrap_or_else(|p| p.into_inner());
65        clear_scoped_limit(&mut map, &(db, tenant));
66    }
67
68    // ── Scope teardown ────────────────────────────────────────────────────────
69
70    /// Remove every memory budget of a dropped database, tenants included.
71    /// A dropped scope is never re-capped, so live tokens keep no entry alive.
72    pub fn clear_database_scope(&self, db: DatabaseId) {
73        {
74            let mut map = self
75                .database_budgets
76                .write()
77                .unwrap_or_else(|p| p.into_inner());
78            map.remove(&db);
79        }
80        let mut map = self
81            .tenant_budgets
82            .write()
83            .unwrap_or_else(|p| p.into_inner());
84        map.retain(|(entry_db, _), _| *entry_db != db);
85    }
86
87    /// Remove a dropped tenant's memory budgets in every database.
88    /// A dropped scope is never re-capped, so live tokens keep no entry alive.
89    pub fn clear_tenant_scope(&self, tenant: TenantId) {
90        let mut map = self
91            .tenant_budgets
92            .write()
93            .unwrap_or_else(|p| p.into_inner());
94        map.retain(|(_, entry_tenant), _| *entry_tenant != tenant);
95    }
96}
97
98#[cfg(test)]
99mod tests {
100    use super::*;
101    use crate::engine::EngineId;
102    use crate::governor::test_support::{db, tenant, test_config};
103
104    // ── Quota changes must not orphan a live counter ─────────────────────────
105
106    #[test]
107    fn raising_a_live_quota_keeps_the_allocated_bytes_tracked() {
108        let gov = MemoryGovernor::new(test_config()).unwrap();
109        gov.set_database_budget(db(), 500);
110
111        let _held = gov
112            .try_reserve(db(), tenant(), EngineId::Vector, 400)
113            .unwrap();
114        gov.set_database_budget(db(), 1000);
115
116        // 400 held + 700 requested exceeds the new 1000 cap.
117        let err = gov
118            .try_reserve(db(), tenant(), EngineId::Vector, 700)
119            .unwrap_err();
120        assert!(
121            matches!(err, crate::error::MemError::DatabaseBudgetExhausted { .. }),
122            "raising the quota must keep the held 400 bytes counted, got {err:?}"
123        );
124    }
125
126    #[test]
127    fn raising_a_live_tenant_quota_keeps_the_allocated_bytes_tracked() {
128        let gov = MemoryGovernor::new(test_config()).unwrap();
129        gov.set_tenant_budget(db(), tenant(), 500);
130
131        let _held = gov
132            .try_reserve(db(), tenant(), EngineId::Vector, 400)
133            .unwrap();
134        gov.set_tenant_budget(db(), tenant(), 1000);
135
136        let err = gov
137            .try_reserve(db(), tenant(), EngineId::Vector, 700)
138            .unwrap_err();
139        assert!(
140            matches!(err, crate::error::MemError::TenantBudgetExhausted { .. }),
141            "raising the quota must keep the held 400 bytes counted, got {err:?}"
142        );
143    }
144
145    #[test]
146    fn clearing_a_quota_with_live_tokens_keeps_the_counter() {
147        let gov = MemoryGovernor::new(test_config()).unwrap();
148        gov.set_database_budget(db(), 500);
149
150        let _held = gov
151            .try_reserve(db(), tenant(), EngineId::Vector, 400)
152            .unwrap();
153        gov.clear_database_budget(db());
154        gov.set_database_budget(db(), 1000);
155
156        let err = gov
157            .try_reserve(db(), tenant(), EngineId::Vector, 700)
158            .unwrap_err();
159        assert!(
160            matches!(err, crate::error::MemError::DatabaseBudgetExhausted { .. }),
161            "clear-then-set must keep the held 400 bytes counted, got {err:?}"
162        );
163    }
164
165    #[test]
166    fn clearing_a_quota_with_no_live_tokens_drops_the_entry() {
167        let gov = MemoryGovernor::new(test_config()).unwrap();
168        gov.set_database_budget(db(), 500);
169        gov.set_tenant_budget(db(), tenant(), 500);
170
171        {
172            let _tok = gov
173                .try_reserve(db(), tenant(), EngineId::Vector, 400)
174                .unwrap();
175        }
176        gov.clear_database_budget(db());
177        gov.clear_tenant_budget(db(), tenant());
178
179        assert!(
180            !gov.database_budgets.read().unwrap().contains_key(&db()),
181            "database entry must be removed once no token holds its counter"
182        );
183        assert!(
184            !gov.tenant_budgets
185                .read()
186                .unwrap()
187                .contains_key(&(db(), tenant())),
188            "tenant entry must be removed once no token holds its counter"
189        );
190    }
191
192    #[test]
193    fn released_bytes_do_not_count_against_a_later_quota() {
194        let gov = MemoryGovernor::new(test_config()).unwrap();
195        gov.set_database_budget(db(), 500);
196
197        {
198            let _tok = gov
199                .try_reserve(db(), tenant(), EngineId::Vector, 400)
200                .unwrap();
201        }
202        gov.set_database_budget(db(), 500);
203
204        // The released 400 bytes are gone, so the full 500 is available.
205        let _tok = gov
206            .try_reserve(db(), tenant(), EngineId::Vector, 500)
207            .expect("released bytes must not be double-counted");
208    }
209
210    #[test]
211    fn clear_database_scope_drops_the_database_and_its_tenants() {
212        let gov = MemoryGovernor::new(test_config()).unwrap();
213        let other = DatabaseId::new(9);
214        gov.set_database_budget(db(), 500);
215        gov.set_tenant_budget(db(), tenant(), 500);
216        gov.set_database_budget(other, 500);
217        gov.set_tenant_budget(other, tenant(), 500);
218        let held = gov
219            .try_reserve(db(), tenant(), EngineId::Vector, 400)
220            .unwrap();
221
222        gov.clear_database_scope(db());
223
224        assert!(!gov.database_budgets.read().unwrap().contains_key(&db()));
225        assert!(
226            !gov.tenant_budgets
227                .read()
228                .unwrap()
229                .contains_key(&(db(), tenant())),
230            "a live token must not keep a dropped scope's entry alive"
231        );
232        assert!(gov.database_budgets.read().unwrap().contains_key(&other));
233        assert!(
234            gov.tenant_budgets
235                .read()
236                .unwrap()
237                .contains_key(&(other, tenant()))
238        );
239        drop(held);
240    }
241
242    #[test]
243    fn clear_tenant_scope_drops_that_tenant_in_every_database() {
244        let gov = MemoryGovernor::new(test_config()).unwrap();
245        let other_db = DatabaseId::new(9);
246        let other_tenant = TenantId::new(2);
247        gov.set_tenant_budget(db(), tenant(), 500);
248        gov.set_tenant_budget(other_db, tenant(), 500);
249        gov.set_tenant_budget(db(), other_tenant, 500);
250
251        gov.clear_tenant_scope(tenant());
252
253        assert!(
254            !gov.tenant_budgets
255                .read()
256                .unwrap()
257                .contains_key(&(db(), tenant()))
258        );
259        assert!(
260            !gov.tenant_budgets
261                .read()
262                .unwrap()
263                .contains_key(&(other_db, tenant()))
264        );
265        assert!(
266            gov.tenant_budgets
267                .read()
268                .unwrap()
269                .contains_key(&(db(), other_tenant))
270        );
271    }
272}