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    file_descriptors: Option<hawdb_storage::file_descriptors::FileOpenContext>,
65}
66
67#[derive(Debug)]
68struct QueryMemoryLedgerInner {
69    budget_bytes: usize,
70    state: Mutex<QueryMemoryLedgerState>,
71}
72
73#[derive(Debug, Default)]
74struct QueryMemoryLedgerState {
75    used_bytes: usize,
76    peak_bytes: usize,
77    next_account_id: u64,
78    accounts: BTreeMap<u64, QueryMemoryAccountState>,
79    classes: BTreeMap<QueryMemoryClass, QueryMemoryClassState>,
80}
81
82#[derive(Debug)]
83struct QueryMemoryAccountState {
84    class: QueryMemoryClass,
85    owner: Arc<str>,
86    budget_bytes: usize,
87    used_bytes: usize,
88    peak_bytes: usize,
89}
90
91#[derive(Debug, Default)]
92struct QueryMemoryClassState {
93    used_bytes: usize,
94    peak_bytes: usize,
95}
96
97impl QueryMemoryLedger {
98    pub fn new(budget_bytes: NonZeroUsize) -> Self {
99        Self {
100            inner: Arc::new(QueryMemoryLedgerInner {
101                budget_bytes: budget_bytes.get(),
102                state: Mutex::new(QueryMemoryLedgerState::default()),
103            }),
104            file_descriptors: None,
105        }
106    }
107
108    /// Bind query IO to its store while retaining the existing memory ledger.
109    #[doc(hidden)]
110    pub fn with_file_descriptors(
111        mut self,
112        context: Option<hawdb_storage::file_descriptors::FileOpenContext>,
113    ) -> Self {
114        self.file_descriptors = context.or(self.file_descriptors);
115        self
116    }
117
118    pub(crate) fn file_descriptor_context(
119        &self,
120    ) -> Option<&hawdb_storage::file_descriptors::FileOpenContext> {
121        self.file_descriptors.as_ref()
122    }
123
124    pub fn account(
125        &self,
126        class: QueryMemoryClass,
127        owner: impl Into<Arc<str>>,
128        budget_bytes: NonZeroUsize,
129    ) -> QueryMemoryAccount {
130        // Caller code must run before locking: conversion can panic or re-enter the ledger.
131        let owner = owner.into();
132        let mut state = lock_recover(&self.inner.state);
133        let account_id = state.next_account_id;
134        state.next_account_id = state.next_account_id.saturating_add(1);
135        state.accounts.insert(
136            account_id,
137            QueryMemoryAccountState {
138                class,
139                owner,
140                budget_bytes: budget_bytes.get(),
141                used_bytes: 0,
142                peak_bytes: 0,
143            },
144        );
145        QueryMemoryAccount {
146            ledger: self.clone(),
147            account_id,
148            backing: None,
149        }
150    }
151
152    pub fn snapshot(&self) -> QueryMemoryLedgerSnapshot {
153        let state = lock_recover(&self.inner.state);
154        QueryMemoryLedgerSnapshot {
155            budget_bytes: self.inner.budget_bytes,
156            used_bytes: state.used_bytes,
157            peak_bytes: state.peak_bytes,
158            account_count: state.accounts.len(),
159            classes: state
160                .classes
161                .iter()
162                .map(|(class, state)| QueryMemoryClassSnapshot {
163                    class: *class,
164                    used_bytes: state.used_bytes,
165                    peak_bytes: state.peak_bytes,
166                })
167                .collect(),
168        }
169    }
170
171    fn reserve(&self, account_id: u64, bytes: usize) -> Result<()> {
172        if bytes == 0 {
173            return Ok(());
174        }
175        let mut state = lock_recover(&self.inner.state);
176        let (class, owner, account_budget, account_next) = {
177            let account = state.accounts.get(&account_id).ok_or_else(|| {
178                HawDBError::Execution("query memory account is no longer registered".to_string())
179            })?;
180            let account_next = account.used_bytes.checked_add(bytes).ok_or_else(|| {
181                HawDBError::Execution(format!(
182                    "query memory account {} ({}) byte accounting overflow",
183                    account.owner,
184                    account.class.as_str()
185                ))
186            })?;
187            (
188                account.class,
189                Arc::clone(&account.owner),
190                account.budget_bytes,
191                account_next,
192            )
193        };
194        if account_next > account_budget {
195            return Err(HawDBError::Execution(format!(
196                "query memory account {owner} ({}) would use {account_next} bytes, exceeding its {account_budget}-byte budget",
197                class.as_str()
198            )));
199        }
200        let root_next = state.used_bytes.checked_add(bytes).ok_or_else(|| {
201            HawDBError::Execution(format!(
202                "query memory ledger byte accounting overflow while charging {owner} ({})",
203                class.as_str()
204            ))
205        })?;
206        if root_next > self.inner.budget_bytes {
207            return Err(HawDBError::Execution(format!(
208                "query memory ledger would use {root_next} bytes while charging {owner} ({}), exceeding query_memory_bytes {}",
209                class.as_str(),
210                self.inner.budget_bytes
211            )));
212        }
213
214        // Complete allocation before changing any of the hierarchical counters.
215        state.classes.entry(class).or_default();
216        let account = state
217            .accounts
218            .get_mut(&account_id)
219            .expect("validated query memory account remains registered");
220        account.used_bytes = account_next;
221        account.peak_bytes = account.peak_bytes.max(account_next);
222        state.used_bytes = root_next;
223        state.peak_bytes = state.peak_bytes.max(root_next);
224        let class_state = state
225            .classes
226            .get_mut(&class)
227            .expect("prepared query memory class remains registered");
228        class_state.used_bytes = class_state.used_bytes.saturating_add(bytes);
229        class_state.peak_bytes = class_state.peak_bytes.max(class_state.used_bytes);
230        Ok(())
231    }
232
233    fn can_reserve(&self, account_id: u64, bytes: usize) -> bool {
234        let state = lock_recover(&self.inner.state);
235        let Some(account) = state.accounts.get(&account_id) else {
236            return false;
237        };
238        let Some(account_next) = account.used_bytes.checked_add(bytes) else {
239            return false;
240        };
241        let Some(root_next) = state.used_bytes.checked_add(bytes) else {
242            return false;
243        };
244        account_next <= account.budget_bytes && root_next <= self.inner.budget_bytes
245    }
246
247    fn release(&self, account_id: u64, bytes: usize) {
248        if bytes == 0 {
249            return;
250        }
251        let mut state = lock_recover(&self.inner.state);
252        let Some(account) = state.accounts.get_mut(&account_id) else {
253            return;
254        };
255        let released = bytes.min(account.used_bytes);
256        let class = account.class;
257        account.used_bytes -= released;
258        state.used_bytes = state.used_bytes.saturating_sub(released);
259        if let Some(class_state) = state.classes.get_mut(&class) {
260            class_state.used_bytes = class_state.used_bytes.saturating_sub(released);
261        }
262    }
263
264    fn transfer(
265        &self,
266        source_account_id: u64,
267        source_bytes: usize,
268        target_account_id: u64,
269        target_bytes: usize,
270    ) -> Result<()> {
271        if source_account_id == target_account_id {
272            return Err(HawDBError::Execution(
273                "query memory transfer requires distinct accounts".to_string(),
274            ));
275        }
276        let mut state = lock_recover(&self.inner.state);
277        let (source_class, source_used) = state
278            .accounts
279            .get(&source_account_id)
280            .map(|account| (account.class, account.used_bytes))
281            .ok_or_else(|| {
282                HawDBError::Execution(
283                    "source query memory account is no longer registered".to_string(),
284                )
285            })?;
286        if source_bytes > source_used {
287            return Err(HawDBError::Execution(format!(
288                "query memory transfer tried to release {source_bytes} bytes from a source account using {source_used} bytes"
289            )));
290        }
291        let (target_class, target_owner, target_budget, target_used) = state
292            .accounts
293            .get(&target_account_id)
294            .map(|account| {
295                (
296                    account.class,
297                    Arc::clone(&account.owner),
298                    account.budget_bytes,
299                    account.used_bytes,
300                )
301            })
302            .ok_or_else(|| {
303                HawDBError::Execution(
304                    "target query memory account is no longer registered".to_string(),
305                )
306            })?;
307        let target_next = target_used.checked_add(target_bytes).ok_or_else(|| {
308            HawDBError::Execution(format!(
309                "query memory account {target_owner} ({}) byte accounting overflow",
310                target_class.as_str()
311            ))
312        })?;
313        if target_next > target_budget {
314            return Err(HawDBError::Execution(format!(
315                "query memory account {target_owner} ({}) would use {target_next} bytes, exceeding its {target_budget}-byte budget",
316                target_class.as_str()
317            )));
318        }
319        let root_after_release = state.used_bytes.checked_sub(source_bytes).ok_or_else(|| {
320            HawDBError::Execution(
321                "query memory ledger underflow during ownership transfer".to_string(),
322            )
323        })?;
324        let root_next = root_after_release.checked_add(target_bytes).ok_or_else(|| {
325            HawDBError::Execution(format!(
326                "query memory ledger byte accounting overflow while transferring ownership to {target_owner} ({})",
327                target_class.as_str()
328            ))
329        })?;
330        if root_next > self.inner.budget_bytes {
331            return Err(HawDBError::Execution(format!(
332                "query memory ledger would use {root_next} bytes while transferring ownership to {target_owner} ({}), exceeding query_memory_bytes {}",
333                target_class.as_str(),
334                self.inner.budget_bytes
335            )));
336        }
337
338        state.classes.entry(target_class).or_default();
339        let source = state
340            .accounts
341            .get_mut(&source_account_id)
342            .expect("validated source query memory account remains registered");
343        source.used_bytes -= source_bytes;
344        let target = state
345            .accounts
346            .get_mut(&target_account_id)
347            .expect("validated target query memory account remains registered");
348        target.used_bytes = target_next;
349        target.peak_bytes = target.peak_bytes.max(target_next);
350        state.used_bytes = root_next;
351        state.peak_bytes = state.peak_bytes.max(root_next);
352        if let Some(class_state) = state.classes.get_mut(&source_class) {
353            class_state.used_bytes = class_state.used_bytes.saturating_sub(source_bytes);
354        }
355        let class_state = state
356            .classes
357            .get_mut(&target_class)
358            .expect("prepared target query memory class remains registered");
359        class_state.used_bytes = class_state.used_bytes.saturating_add(target_bytes);
360        class_state.peak_bytes = class_state.peak_bytes.max(class_state.used_bytes);
361        Ok(())
362    }
363}
364
365#[derive(Debug, Clone)]
366pub struct QueryMemoryAccount {
367    ledger: QueryMemoryLedger,
368    account_id: u64,
369    // A child ledger owns an up-front reservation in its parent. Every account
370    // and outstanding lease keeps that reservation alive.
371    backing: Option<Arc<QueryMemoryLease>>,
372}
373
374impl QueryMemoryAccount {
375    /// Reserve the entire child allowance before dispatch, without charging
376    /// individual child allocations to the query root a second time.
377    pub(crate) fn sub_account(&self, budget: NonZeroUsize) -> Result<Self> {
378        let backing = Arc::new(self.reserve(budget.get())?);
379        let (class, owner) = {
380            let state = lock_recover(&self.ledger.inner.state);
381            let account = &state.accounts[&self.account_id];
382            (account.class, Arc::clone(&account.owner))
383        };
384        let mut child = QueryMemoryLedger::new(budget).account(class, owner, budget);
385        child.backing = Some(backing);
386        Ok(child)
387    }
388
389    pub(crate) fn budget_bytes(&self) -> NonZeroUsize {
390        NonZeroUsize::new(
391            lock_recover(&self.ledger.inner.state).accounts[&self.account_id].budget_bytes,
392        )
393        .unwrap()
394    }
395
396    pub(crate) fn available_bytes(&self) -> usize {
397        let state = lock_recover(&self.ledger.inner.state);
398        let account = &state.accounts[&self.account_id];
399        account.budget_bytes.saturating_sub(account.used_bytes).min(
400            self.ledger
401                .inner
402                .budget_bytes
403                .saturating_sub(state.used_bytes),
404        )
405    }
406
407    pub(crate) fn peak_bytes(&self) -> usize {
408        lock_recover(&self.ledger.inner.state).accounts[&self.account_id].peak_bytes
409    }
410
411    pub(crate) fn sibling(
412        &self,
413        class: QueryMemoryClass,
414        owner: impl Into<Arc<str>>,
415        budget_bytes: NonZeroUsize,
416    ) -> Self {
417        let mut account = self.ledger.account(class, owner, budget_bytes);
418        account.backing = self.backing.clone();
419        account
420    }
421
422    pub(crate) fn can_reserve(&self, bytes: usize) -> bool {
423        self.ledger.can_reserve(self.account_id, bytes)
424    }
425
426    pub fn reserve(&self, bytes: usize) -> Result<QueryMemoryLease> {
427        self.ledger.reserve(self.account_id, bytes)?;
428        Ok(QueryMemoryLease {
429            account: self.clone(),
430            bytes,
431        })
432    }
433}
434
435#[derive(Debug)]
436pub struct QueryMemoryLease {
437    account: QueryMemoryAccount,
438    bytes: usize,
439}
440
441impl QueryMemoryLease {
442    pub fn bytes(&self) -> usize {
443        self.bytes
444    }
445
446    pub fn grow(&mut self, bytes: usize) -> Result<()> {
447        self.account
448            .ledger
449            .reserve(self.account.account_id, bytes)?;
450        self.bytes = self.bytes.saturating_add(bytes);
451        Ok(())
452    }
453
454    pub fn shrink(&mut self, bytes: usize) {
455        let released = bytes.min(self.bytes);
456        self.account
457            .ledger
458            .release(self.account.account_id, released);
459        self.bytes -= released;
460    }
461
462    pub fn reset(&mut self) {
463        self.shrink(self.bytes);
464    }
465
466    pub(crate) fn transfer_to(
467        &mut self,
468        source_bytes: usize,
469        target: &mut Self,
470        target_bytes: usize,
471    ) -> Result<()> {
472        if !Arc::ptr_eq(&self.account.ledger.inner, &target.account.ledger.inner) {
473            return Err(HawDBError::Execution(
474                "query memory transfer requires accounts from the same ledger".to_string(),
475            ));
476        }
477        if source_bytes > self.bytes {
478            return Err(HawDBError::Execution(format!(
479                "query memory transfer tried to release {source_bytes} bytes from a {}-byte lease",
480                self.bytes,
481            )));
482        }
483        self.account.ledger.transfer(
484            self.account.account_id,
485            source_bytes,
486            target.account.account_id,
487            target_bytes,
488        )?;
489        self.bytes -= source_bytes;
490        target.bytes = target.bytes.saturating_add(target_bytes);
491        Ok(())
492    }
493}
494
495impl Drop for QueryMemoryLease {
496    fn drop(&mut self) {
497        self.account
498            .ledger
499            .release(self.account.account_id, self.bytes);
500        self.bytes = 0;
501    }
502}
503
504fn lock_recover<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
505    // State never escapes the ledger. Checks and allocation precede counter updates,
506    // and no caller code runs under this lock. Recovering poisoning preserves that
507    // valid state; it is not a repair mechanism for arbitrary accounting corruption.
508    mutex
509        .lock()
510        .unwrap_or_else(|poisoned| poisoned.into_inner())
511}
512
513#[cfg(test)]
514#[path = "memory_ledger_hardening_tests.rs"]
515mod hardening_tests;
516
517#[cfg(test)]
518mod tests {
519    use super::*;
520
521    #[test]
522    fn sub_accounts_reserve_parent_capacity_without_double_charging() {
523        let budget = NonZeroUsize::new(100).unwrap();
524        let ledger = QueryMemoryLedger::new(budget);
525        let parent = ledger.account(QueryMemoryClass::BlockingState, "merge", budget);
526        let retained = parent.reserve(10).unwrap();
527        let left = parent.sub_account(NonZeroUsize::new(60).unwrap()).unwrap();
528        let right = parent.sub_account(NonZeroUsize::new(30).unwrap()).unwrap();
529        assert_eq!(parent.available_bytes(), 0);
530        assert!(parent.sub_account(NonZeroUsize::MIN).is_err());
531        let mut left_lease = left.reserve(60).unwrap();
532        let right_lease = right.reserve(30).unwrap();
533        assert!(left_lease.grow(1).is_err());
534        assert_eq!(ledger.snapshot().used_bytes, 100);
535        drop((left, right, retained));
536        assert_eq!(ledger.snapshot().used_bytes, 90);
537        drop(left_lease);
538        assert_eq!(ledger.snapshot().used_bytes, 30);
539        drop(right_lease);
540        assert_eq!(ledger.snapshot().used_bytes, 0);
541        assert_eq!(ledger.snapshot().peak_bytes, 100);
542    }
543
544    #[test]
545    fn nested_accounts_and_siblings_keep_backing_until_last_owner_drops() {
546        let budget = NonZeroUsize::new(8).unwrap();
547        let ledger = QueryMemoryLedger::new(budget);
548        let parent = ledger.account(QueryMemoryClass::BlockingState, "merge", budget);
549        let child = parent.sub_account(budget).unwrap();
550        let sibling = child.sibling(QueryMemoryClass::SpillStaging, "staging", budget);
551        let nested = child.sub_account(NonZeroUsize::new(4).unwrap()).unwrap();
552        assert!(sibling.reserve(5).is_err());
553        let lease = nested.reserve(4).unwrap();
554        drop((parent, child, nested));
555        assert_eq!(ledger.snapshot().used_bytes, 8);
556        drop(lease);
557        assert_eq!(ledger.snapshot().used_bytes, 8);
558        let sibling_lease = sibling.reserve(8).unwrap();
559        drop(sibling);
560        assert_eq!(ledger.snapshot().used_bytes, 8);
561        drop(sibling_lease);
562        assert_eq!(ledger.snapshot().used_bytes, 0);
563    }
564
565    #[test]
566    fn failed_sub_account_admission_does_not_leak_root_capacity() {
567        let budget = NonZeroUsize::new(100).unwrap();
568        let ledger = QueryMemoryLedger::new(budget);
569        let parent = ledger.account(QueryMemoryClass::BlockingState, "merge", budget);
570        let other = ledger.account(QueryMemoryClass::PipelineBatch, "input", budget);
571        let retained = other.reserve(70).unwrap();
572        assert!(parent.sub_account(NonZeroUsize::new(31).unwrap()).is_err());
573        assert_eq!(ledger.snapshot().used_bytes, 70);
574        let child = parent.sub_account(NonZeroUsize::new(30).unwrap()).unwrap();
575        assert!(child.reserve(31).is_err());
576        drop((child, retained));
577        assert_eq!(ledger.snapshot().used_bytes, 0);
578    }
579
580    #[test]
581    fn sibling_accounts_share_one_root_budget() {
582        let ledger = QueryMemoryLedger::new(NonZeroUsize::new(10).unwrap());
583        let left = ledger.account(
584            QueryMemoryClass::BlockingState,
585            "left",
586            NonZeroUsize::new(10).unwrap(),
587        );
588        let right = ledger.account(
589            QueryMemoryClass::PipelineBatch,
590            "right",
591            NonZeroUsize::new(10).unwrap(),
592        );
593        let left_lease = left.reserve(6).unwrap();
594        let error = right.reserve(5).unwrap_err();
595
596        assert!(error.to_string().contains("query_memory_bytes 10"));
597        assert_eq!(ledger.snapshot().used_bytes, 6);
598        drop(left_lease);
599        assert_eq!(ledger.snapshot().used_bytes, 0);
600    }
601
602    #[test]
603    fn capacity_preflight_checks_account_and_root_without_charging() {
604        let ledger = QueryMemoryLedger::new(NonZeroUsize::new(10).unwrap());
605        let left = ledger.account(
606            QueryMemoryClass::BlockingState,
607            "left",
608            NonZeroUsize::new(8).unwrap(),
609        );
610        let right = ledger.account(
611            QueryMemoryClass::PipelineBatch,
612            "right",
613            NonZeroUsize::new(10).unwrap(),
614        );
615
616        assert!(left.can_reserve(8));
617        assert_eq!(ledger.snapshot().used_bytes, 0);
618        let left_lease = left.reserve(6).unwrap();
619        assert!(!left.can_reserve(3));
620        assert!(right.can_reserve(4));
621        assert!(!right.can_reserve(5));
622        assert_eq!(ledger.snapshot().used_bytes, 6);
623
624        drop(left_lease);
625        assert_eq!(ledger.snapshot().used_bytes, 0);
626    }
627
628    #[test]
629    fn derived_sibling_account_keeps_the_original_query_root() {
630        let budget = NonZeroUsize::new(8).unwrap();
631        let ledger = QueryMemoryLedger::new(budget);
632        let state = ledger.account(QueryMemoryClass::BlockingState, "merge", budget);
633        let output = state.sibling(QueryMemoryClass::PipelineBatch, "output", budget);
634        let state_lease = state.reserve(6).unwrap();
635        assert!(output
636            .reserve(3)
637            .unwrap_err()
638            .to_string()
639            .contains("query_memory_bytes 8"));
640        let output_lease = output.reserve(2).unwrap();
641        assert_eq!(ledger.snapshot().used_bytes, 8);
642        drop((state_lease, output_lease));
643        assert_eq!(ledger.snapshot().used_bytes, 0);
644    }
645
646    #[test]
647    fn ownership_transfer_does_not_require_double_root_capacity() {
648        let budget = NonZeroUsize::new(8).unwrap();
649        let ledger = QueryMemoryLedger::new(budget);
650        let source = ledger.account(QueryMemoryClass::BlockingState, "source", budget);
651        let target = ledger.account(QueryMemoryClass::PipelineBatch, "target", budget);
652        let mut source_lease = source.reserve(8).unwrap();
653        let mut target_lease = target.reserve(0).unwrap();
654
655        source_lease.transfer_to(8, &mut target_lease, 8).unwrap();
656
657        assert_eq!(source_lease.bytes(), 0);
658        assert_eq!(target_lease.bytes(), 8);
659        let snapshot = ledger.snapshot();
660        assert_eq!(snapshot.used_bytes, 8);
661        assert_eq!(snapshot.peak_bytes, 8);
662        drop((source_lease, target_lease));
663        assert_eq!(ledger.snapshot().used_bytes, 0);
664    }
665
666    #[test]
667    fn failed_ownership_transfer_preserves_source_charge() {
668        let budget = NonZeroUsize::new(8).unwrap();
669        let ledger = QueryMemoryLedger::new(budget);
670        let source = ledger.account(QueryMemoryClass::BlockingState, "source", budget);
671        let target = ledger.account(QueryMemoryClass::PipelineBatch, "target", budget);
672        let mut source_lease = source.reserve(8).unwrap();
673        let mut target_lease = target.reserve(0).unwrap();
674
675        let error = source_lease
676            .transfer_to(8, &mut target_lease, 9)
677            .unwrap_err();
678
679        assert!(error.to_string().contains("9 bytes"));
680        assert_eq!(source_lease.bytes(), 8);
681        assert_eq!(target_lease.bytes(), 0);
682        assert_eq!(ledger.snapshot().used_bytes, 8);
683    }
684
685    #[test]
686    fn account_budget_is_enforced_before_root_budget() {
687        let ledger = QueryMemoryLedger::new(NonZeroUsize::new(100).unwrap());
688        let account = ledger.account(
689            QueryMemoryClass::BlockingState,
690            "sort",
691            NonZeroUsize::new(4).unwrap(),
692        );
693
694        let error = account.reserve(5).unwrap_err();
695
696        assert!(error.to_string().contains("sort"));
697        assert!(error.to_string().contains("4-byte budget"));
698        assert_eq!(ledger.snapshot().used_bytes, 0);
699    }
700
701    #[test]
702    fn lease_growth_shrink_and_drop_update_hierarchy() {
703        let ledger = QueryMemoryLedger::new(NonZeroUsize::new(100).unwrap());
704        let account = ledger.account(
705            QueryMemoryClass::MorselOutput,
706            "reorder",
707            NonZeroUsize::new(80).unwrap(),
708        );
709        let mut lease = account.reserve(20).unwrap();
710        lease.grow(30).unwrap();
711        lease.shrink(10);
712        assert_eq!(lease.bytes(), 40);
713        assert_eq!(ledger.snapshot().used_bytes, 40);
714
715        drop(lease);
716        let snapshot = ledger.snapshot();
717        assert_eq!(snapshot.used_bytes, 0);
718        assert_eq!(snapshot.peak_bytes, 50);
719        assert_eq!(snapshot.classes[0].peak_bytes, 50);
720    }
721
722    #[test]
723    fn unwind_releases_query_memory_lease() {
724        let ledger = QueryMemoryLedger::new(NonZeroUsize::new(100).unwrap());
725        let account = ledger.account(
726            QueryMemoryClass::SpillStaging,
727            "spill",
728            NonZeroUsize::new(100).unwrap(),
729        );
730        let result = std::panic::catch_unwind(|| {
731            let _lease = account.reserve(80).unwrap();
732            panic!("injected panic");
733        });
734
735        assert!(result.is_err());
736        assert_eq!(ledger.snapshot().used_bytes, 0);
737    }
738}