Skip to main content

hawdb_executor/
memory_ledger.rs

1// Copyright 2026 Nowledge
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Query-owned hierarchical memory accounting.
16
17use hawdb_core::{HawDBError, Result};
18use std::collections::BTreeMap;
19use std::num::NonZeroUsize;
20use std::sync::{Arc, Mutex, MutexGuard};
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
23pub enum QueryMemoryClass {
24    PipelineBatch,
25    BlockingState,
26    ExternalRead,
27    SpillStaging,
28    MorselOutput,
29    ResultMaterialization,
30}
31
32impl QueryMemoryClass {
33    pub const fn as_str(self) -> &'static str {
34        match self {
35            Self::PipelineBatch => "pipeline_batch",
36            Self::BlockingState => "blocking_state",
37            Self::ExternalRead => "external_read",
38            Self::SpillStaging => "spill_staging",
39            Self::MorselOutput => "morsel_output",
40            Self::ResultMaterialization => "result_materialization",
41        }
42    }
43}
44
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub struct QueryMemoryClassSnapshot {
47    pub class: QueryMemoryClass,
48    pub used_bytes: usize,
49    pub peak_bytes: usize,
50}
51
52#[derive(Debug, Clone, PartialEq, Eq)]
53pub struct QueryMemoryLedgerSnapshot {
54    pub budget_bytes: usize,
55    pub used_bytes: usize,
56    pub peak_bytes: usize,
57    pub account_count: usize,
58    pub classes: Vec<QueryMemoryClassSnapshot>,
59}
60
61#[derive(Debug, Clone)]
62pub struct QueryMemoryLedger {
63    inner: Arc<QueryMemoryLedgerInner>,
64}
65
66#[derive(Debug)]
67struct QueryMemoryLedgerInner {
68    budget_bytes: usize,
69    state: Mutex<QueryMemoryLedgerState>,
70}
71
72#[derive(Debug, Default)]
73struct QueryMemoryLedgerState {
74    used_bytes: usize,
75    peak_bytes: usize,
76    next_account_id: u64,
77    accounts: BTreeMap<u64, QueryMemoryAccountState>,
78    classes: BTreeMap<QueryMemoryClass, QueryMemoryClassState>,
79}
80
81#[derive(Debug)]
82struct QueryMemoryAccountState {
83    class: QueryMemoryClass,
84    owner: Arc<str>,
85    budget_bytes: usize,
86    used_bytes: usize,
87    peak_bytes: usize,
88}
89
90#[derive(Debug, Default)]
91struct QueryMemoryClassState {
92    used_bytes: usize,
93    peak_bytes: usize,
94}
95
96impl QueryMemoryLedger {
97    pub fn new(budget_bytes: NonZeroUsize) -> Self {
98        Self {
99            inner: Arc::new(QueryMemoryLedgerInner {
100                budget_bytes: budget_bytes.get(),
101                state: Mutex::new(QueryMemoryLedgerState::default()),
102            }),
103        }
104    }
105
106    pub fn account(
107        &self,
108        class: QueryMemoryClass,
109        owner: impl Into<Arc<str>>,
110        budget_bytes: NonZeroUsize,
111    ) -> QueryMemoryAccount {
112        // Caller code must run before locking: conversion can panic or re-enter the ledger.
113        let owner = owner.into();
114        let mut state = lock_recover(&self.inner.state);
115        let account_id = state.next_account_id;
116        state.next_account_id = state.next_account_id.saturating_add(1);
117        state.accounts.insert(
118            account_id,
119            QueryMemoryAccountState {
120                class,
121                owner,
122                budget_bytes: budget_bytes.get(),
123                used_bytes: 0,
124                peak_bytes: 0,
125            },
126        );
127        QueryMemoryAccount {
128            ledger: self.clone(),
129            account_id,
130        }
131    }
132
133    pub fn snapshot(&self) -> QueryMemoryLedgerSnapshot {
134        let state = lock_recover(&self.inner.state);
135        QueryMemoryLedgerSnapshot {
136            budget_bytes: self.inner.budget_bytes,
137            used_bytes: state.used_bytes,
138            peak_bytes: state.peak_bytes,
139            account_count: state.accounts.len(),
140            classes: state
141                .classes
142                .iter()
143                .map(|(class, state)| QueryMemoryClassSnapshot {
144                    class: *class,
145                    used_bytes: state.used_bytes,
146                    peak_bytes: state.peak_bytes,
147                })
148                .collect(),
149        }
150    }
151
152    fn reserve(&self, account_id: u64, bytes: usize) -> Result<()> {
153        if bytes == 0 {
154            return Ok(());
155        }
156        let mut state = lock_recover(&self.inner.state);
157        let (class, owner, account_budget, account_next) = {
158            let account = state.accounts.get(&account_id).ok_or_else(|| {
159                HawDBError::Execution("query memory account is no longer registered".to_string())
160            })?;
161            let account_next = account.used_bytes.checked_add(bytes).ok_or_else(|| {
162                HawDBError::Execution(format!(
163                    "query memory account {} ({}) byte accounting overflow",
164                    account.owner,
165                    account.class.as_str()
166                ))
167            })?;
168            (
169                account.class,
170                Arc::clone(&account.owner),
171                account.budget_bytes,
172                account_next,
173            )
174        };
175        if account_next > account_budget {
176            return Err(HawDBError::Execution(format!(
177                "query memory account {owner} ({}) would use {account_next} bytes, exceeding its {account_budget}-byte budget",
178                class.as_str()
179            )));
180        }
181        let root_next = state.used_bytes.checked_add(bytes).ok_or_else(|| {
182            HawDBError::Execution(format!(
183                "query memory ledger byte accounting overflow while charging {owner} ({})",
184                class.as_str()
185            ))
186        })?;
187        if root_next > self.inner.budget_bytes {
188            return Err(HawDBError::Execution(format!(
189                "query memory ledger would use {root_next} bytes while charging {owner} ({}), exceeding query_memory_bytes {}",
190                class.as_str(),
191                self.inner.budget_bytes
192            )));
193        }
194
195        // Complete allocation before changing any of the hierarchical counters.
196        state.classes.entry(class).or_default();
197        let account = state
198            .accounts
199            .get_mut(&account_id)
200            .expect("validated query memory account remains registered");
201        account.used_bytes = account_next;
202        account.peak_bytes = account.peak_bytes.max(account_next);
203        state.used_bytes = root_next;
204        state.peak_bytes = state.peak_bytes.max(root_next);
205        let class_state = state
206            .classes
207            .get_mut(&class)
208            .expect("prepared query memory class remains registered");
209        class_state.used_bytes = class_state.used_bytes.saturating_add(bytes);
210        class_state.peak_bytes = class_state.peak_bytes.max(class_state.used_bytes);
211        Ok(())
212    }
213
214    fn can_reserve(&self, account_id: u64, bytes: usize) -> bool {
215        let state = lock_recover(&self.inner.state);
216        let Some(account) = state.accounts.get(&account_id) else {
217            return false;
218        };
219        let Some(account_next) = account.used_bytes.checked_add(bytes) else {
220            return false;
221        };
222        let Some(root_next) = state.used_bytes.checked_add(bytes) else {
223            return false;
224        };
225        account_next <= account.budget_bytes && root_next <= self.inner.budget_bytes
226    }
227
228    fn release(&self, account_id: u64, bytes: usize) {
229        if bytes == 0 {
230            return;
231        }
232        let mut state = lock_recover(&self.inner.state);
233        let Some(account) = state.accounts.get_mut(&account_id) else {
234            return;
235        };
236        let released = bytes.min(account.used_bytes);
237        let class = account.class;
238        account.used_bytes -= released;
239        state.used_bytes = state.used_bytes.saturating_sub(released);
240        if let Some(class_state) = state.classes.get_mut(&class) {
241            class_state.used_bytes = class_state.used_bytes.saturating_sub(released);
242        }
243    }
244
245    fn transfer(
246        &self,
247        source_account_id: u64,
248        source_bytes: usize,
249        target_account_id: u64,
250        target_bytes: usize,
251    ) -> Result<()> {
252        if source_account_id == target_account_id {
253            return Err(HawDBError::Execution(
254                "query memory transfer requires distinct accounts".to_string(),
255            ));
256        }
257        let mut state = lock_recover(&self.inner.state);
258        let (source_class, source_used) = state
259            .accounts
260            .get(&source_account_id)
261            .map(|account| (account.class, account.used_bytes))
262            .ok_or_else(|| {
263                HawDBError::Execution(
264                    "source query memory account is no longer registered".to_string(),
265                )
266            })?;
267        if source_bytes > source_used {
268            return Err(HawDBError::Execution(format!(
269                "query memory transfer tried to release {source_bytes} bytes from a source account using {source_used} bytes"
270            )));
271        }
272        let (target_class, target_owner, target_budget, target_used) = state
273            .accounts
274            .get(&target_account_id)
275            .map(|account| {
276                (
277                    account.class,
278                    Arc::clone(&account.owner),
279                    account.budget_bytes,
280                    account.used_bytes,
281                )
282            })
283            .ok_or_else(|| {
284                HawDBError::Execution(
285                    "target query memory account is no longer registered".to_string(),
286                )
287            })?;
288        let target_next = target_used.checked_add(target_bytes).ok_or_else(|| {
289            HawDBError::Execution(format!(
290                "query memory account {target_owner} ({}) byte accounting overflow",
291                target_class.as_str()
292            ))
293        })?;
294        if target_next > target_budget {
295            return Err(HawDBError::Execution(format!(
296                "query memory account {target_owner} ({}) would use {target_next} bytes, exceeding its {target_budget}-byte budget",
297                target_class.as_str()
298            )));
299        }
300        let root_after_release = state.used_bytes.checked_sub(source_bytes).ok_or_else(|| {
301            HawDBError::Execution(
302                "query memory ledger underflow during ownership transfer".to_string(),
303            )
304        })?;
305        let root_next = root_after_release.checked_add(target_bytes).ok_or_else(|| {
306            HawDBError::Execution(format!(
307                "query memory ledger byte accounting overflow while transferring ownership to {target_owner} ({})",
308                target_class.as_str()
309            ))
310        })?;
311        if root_next > self.inner.budget_bytes {
312            return Err(HawDBError::Execution(format!(
313                "query memory ledger would use {root_next} bytes while transferring ownership to {target_owner} ({}), exceeding query_memory_bytes {}",
314                target_class.as_str(),
315                self.inner.budget_bytes
316            )));
317        }
318
319        state.classes.entry(target_class).or_default();
320        let source = state
321            .accounts
322            .get_mut(&source_account_id)
323            .expect("validated source query memory account remains registered");
324        source.used_bytes -= source_bytes;
325        let target = state
326            .accounts
327            .get_mut(&target_account_id)
328            .expect("validated target query memory account remains registered");
329        target.used_bytes = target_next;
330        target.peak_bytes = target.peak_bytes.max(target_next);
331        state.used_bytes = root_next;
332        state.peak_bytes = state.peak_bytes.max(root_next);
333        if let Some(class_state) = state.classes.get_mut(&source_class) {
334            class_state.used_bytes = class_state.used_bytes.saturating_sub(source_bytes);
335        }
336        let class_state = state
337            .classes
338            .get_mut(&target_class)
339            .expect("prepared target query memory class remains registered");
340        class_state.used_bytes = class_state.used_bytes.saturating_add(target_bytes);
341        class_state.peak_bytes = class_state.peak_bytes.max(class_state.used_bytes);
342        Ok(())
343    }
344}
345
346#[derive(Debug, Clone)]
347pub struct QueryMemoryAccount {
348    ledger: QueryMemoryLedger,
349    account_id: u64,
350}
351
352impl QueryMemoryAccount {
353    pub(crate) fn peak_bytes(&self) -> usize {
354        lock_recover(&self.ledger.inner.state).accounts[&self.account_id].peak_bytes
355    }
356
357    pub(crate) fn sibling(
358        &self,
359        class: QueryMemoryClass,
360        owner: impl Into<Arc<str>>,
361        budget_bytes: NonZeroUsize,
362    ) -> Self {
363        self.ledger.account(class, owner, budget_bytes)
364    }
365
366    pub(crate) fn can_reserve(&self, bytes: usize) -> bool {
367        self.ledger.can_reserve(self.account_id, bytes)
368    }
369
370    pub fn reserve(&self, bytes: usize) -> Result<QueryMemoryLease> {
371        self.ledger.reserve(self.account_id, bytes)?;
372        Ok(QueryMemoryLease {
373            account: self.clone(),
374            bytes,
375        })
376    }
377}
378
379#[derive(Debug)]
380pub struct QueryMemoryLease {
381    account: QueryMemoryAccount,
382    bytes: usize,
383}
384
385impl QueryMemoryLease {
386    pub fn bytes(&self) -> usize {
387        self.bytes
388    }
389
390    pub fn grow(&mut self, bytes: usize) -> Result<()> {
391        self.account
392            .ledger
393            .reserve(self.account.account_id, bytes)?;
394        self.bytes = self.bytes.saturating_add(bytes);
395        Ok(())
396    }
397
398    pub fn shrink(&mut self, bytes: usize) {
399        let released = bytes.min(self.bytes);
400        self.account
401            .ledger
402            .release(self.account.account_id, released);
403        self.bytes -= released;
404    }
405
406    pub fn reset(&mut self) {
407        self.shrink(self.bytes);
408    }
409
410    pub(crate) fn transfer_to(
411        &mut self,
412        source_bytes: usize,
413        target: &mut Self,
414        target_bytes: usize,
415    ) -> Result<()> {
416        if !Arc::ptr_eq(&self.account.ledger.inner, &target.account.ledger.inner) {
417            return Err(HawDBError::Execution(
418                "query memory transfer requires accounts from the same ledger".to_string(),
419            ));
420        }
421        if source_bytes > self.bytes {
422            return Err(HawDBError::Execution(format!(
423                "query memory transfer tried to release {source_bytes} bytes from a {}-byte lease",
424                self.bytes,
425            )));
426        }
427        self.account.ledger.transfer(
428            self.account.account_id,
429            source_bytes,
430            target.account.account_id,
431            target_bytes,
432        )?;
433        self.bytes -= source_bytes;
434        target.bytes = target.bytes.saturating_add(target_bytes);
435        Ok(())
436    }
437}
438
439impl Drop for QueryMemoryLease {
440    fn drop(&mut self) {
441        self.account
442            .ledger
443            .release(self.account.account_id, self.bytes);
444        self.bytes = 0;
445    }
446}
447
448fn lock_recover<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
449    // State never escapes the ledger. Checks and allocation precede counter updates,
450    // and no caller code runs under this lock. Recovering poisoning preserves that
451    // valid state; it is not a repair mechanism for arbitrary accounting corruption.
452    mutex
453        .lock()
454        .unwrap_or_else(|poisoned| poisoned.into_inner())
455}
456
457#[cfg(test)]
458#[path = "memory_ledger_hardening_tests.rs"]
459mod hardening_tests;
460
461#[cfg(test)]
462mod tests {
463    use super::*;
464
465    #[test]
466    fn sibling_accounts_share_one_root_budget() {
467        let ledger = QueryMemoryLedger::new(NonZeroUsize::new(10).unwrap());
468        let left = ledger.account(
469            QueryMemoryClass::BlockingState,
470            "left",
471            NonZeroUsize::new(10).unwrap(),
472        );
473        let right = ledger.account(
474            QueryMemoryClass::PipelineBatch,
475            "right",
476            NonZeroUsize::new(10).unwrap(),
477        );
478        let left_lease = left.reserve(6).unwrap();
479        let error = right.reserve(5).unwrap_err();
480
481        assert!(error.to_string().contains("query_memory_bytes 10"));
482        assert_eq!(ledger.snapshot().used_bytes, 6);
483        drop(left_lease);
484        assert_eq!(ledger.snapshot().used_bytes, 0);
485    }
486
487    #[test]
488    fn capacity_preflight_checks_account_and_root_without_charging() {
489        let ledger = QueryMemoryLedger::new(NonZeroUsize::new(10).unwrap());
490        let left = ledger.account(
491            QueryMemoryClass::BlockingState,
492            "left",
493            NonZeroUsize::new(8).unwrap(),
494        );
495        let right = ledger.account(
496            QueryMemoryClass::PipelineBatch,
497            "right",
498            NonZeroUsize::new(10).unwrap(),
499        );
500
501        assert!(left.can_reserve(8));
502        assert_eq!(ledger.snapshot().used_bytes, 0);
503        let left_lease = left.reserve(6).unwrap();
504        assert!(!left.can_reserve(3));
505        assert!(right.can_reserve(4));
506        assert!(!right.can_reserve(5));
507        assert_eq!(ledger.snapshot().used_bytes, 6);
508
509        drop(left_lease);
510        assert_eq!(ledger.snapshot().used_bytes, 0);
511    }
512
513    #[test]
514    fn derived_sibling_account_keeps_the_original_query_root() {
515        let budget = NonZeroUsize::new(8).unwrap();
516        let ledger = QueryMemoryLedger::new(budget);
517        let state = ledger.account(QueryMemoryClass::BlockingState, "merge", budget);
518        let output = state.sibling(QueryMemoryClass::PipelineBatch, "output", budget);
519        let state_lease = state.reserve(6).unwrap();
520        assert!(output
521            .reserve(3)
522            .unwrap_err()
523            .to_string()
524            .contains("query_memory_bytes 8"));
525        let output_lease = output.reserve(2).unwrap();
526        assert_eq!(ledger.snapshot().used_bytes, 8);
527        drop((state_lease, output_lease));
528        assert_eq!(ledger.snapshot().used_bytes, 0);
529    }
530
531    #[test]
532    fn ownership_transfer_does_not_require_double_root_capacity() {
533        let budget = NonZeroUsize::new(8).unwrap();
534        let ledger = QueryMemoryLedger::new(budget);
535        let source = ledger.account(QueryMemoryClass::BlockingState, "source", budget);
536        let target = ledger.account(QueryMemoryClass::PipelineBatch, "target", budget);
537        let mut source_lease = source.reserve(8).unwrap();
538        let mut target_lease = target.reserve(0).unwrap();
539
540        source_lease.transfer_to(8, &mut target_lease, 8).unwrap();
541
542        assert_eq!(source_lease.bytes(), 0);
543        assert_eq!(target_lease.bytes(), 8);
544        let snapshot = ledger.snapshot();
545        assert_eq!(snapshot.used_bytes, 8);
546        assert_eq!(snapshot.peak_bytes, 8);
547        drop((source_lease, target_lease));
548        assert_eq!(ledger.snapshot().used_bytes, 0);
549    }
550
551    #[test]
552    fn failed_ownership_transfer_preserves_source_charge() {
553        let budget = NonZeroUsize::new(8).unwrap();
554        let ledger = QueryMemoryLedger::new(budget);
555        let source = ledger.account(QueryMemoryClass::BlockingState, "source", budget);
556        let target = ledger.account(QueryMemoryClass::PipelineBatch, "target", budget);
557        let mut source_lease = source.reserve(8).unwrap();
558        let mut target_lease = target.reserve(0).unwrap();
559
560        let error = source_lease
561            .transfer_to(8, &mut target_lease, 9)
562            .unwrap_err();
563
564        assert!(error.to_string().contains("9 bytes"));
565        assert_eq!(source_lease.bytes(), 8);
566        assert_eq!(target_lease.bytes(), 0);
567        assert_eq!(ledger.snapshot().used_bytes, 8);
568    }
569
570    #[test]
571    fn account_budget_is_enforced_before_root_budget() {
572        let ledger = QueryMemoryLedger::new(NonZeroUsize::new(100).unwrap());
573        let account = ledger.account(
574            QueryMemoryClass::BlockingState,
575            "sort",
576            NonZeroUsize::new(4).unwrap(),
577        );
578
579        let error = account.reserve(5).unwrap_err();
580
581        assert!(error.to_string().contains("sort"));
582        assert!(error.to_string().contains("4-byte budget"));
583        assert_eq!(ledger.snapshot().used_bytes, 0);
584    }
585
586    #[test]
587    fn lease_growth_shrink_and_drop_update_hierarchy() {
588        let ledger = QueryMemoryLedger::new(NonZeroUsize::new(100).unwrap());
589        let account = ledger.account(
590            QueryMemoryClass::MorselOutput,
591            "reorder",
592            NonZeroUsize::new(80).unwrap(),
593        );
594        let mut lease = account.reserve(20).unwrap();
595        lease.grow(30).unwrap();
596        lease.shrink(10);
597        assert_eq!(lease.bytes(), 40);
598        assert_eq!(ledger.snapshot().used_bytes, 40);
599
600        drop(lease);
601        let snapshot = ledger.snapshot();
602        assert_eq!(snapshot.used_bytes, 0);
603        assert_eq!(snapshot.peak_bytes, 50);
604        assert_eq!(snapshot.classes[0].peak_bytes, 50);
605    }
606
607    #[test]
608    fn unwind_releases_query_memory_lease() {
609        let ledger = QueryMemoryLedger::new(NonZeroUsize::new(100).unwrap());
610        let account = ledger.account(
611            QueryMemoryClass::SpillStaging,
612            "spill",
613            NonZeroUsize::new(100).unwrap(),
614        );
615        let result = std::panic::catch_unwind(|| {
616            let _lease = account.reserve(80).unwrap();
617            panic!("injected panic");
618        });
619
620        assert!(result.is_err());
621        assert_eq!(ledger.snapshot().used_bytes, 0);
622    }
623}