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::{
18    HawDBError, ResourceBudgetError, ResourceBudgetKind, ResourceBudgetScope, Result,
19};
20use std::num::NonZeroUsize;
21use std::sync::{Arc, Mutex, MutexGuard};
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
24pub enum QueryMemoryClass {
25    PipelineBatch,
26    BlockingState,
27    ExternalRead,
28    SpillStaging,
29    MorselOutput,
30    ResultMaterialization,
31}
32
33impl QueryMemoryClass {
34    /// Every class in declaration (and therefore `Ord`) order.
35    const ALL: [Self; 6] = [
36        Self::PipelineBatch,
37        Self::BlockingState,
38        Self::ExternalRead,
39        Self::SpillStaging,
40        Self::MorselOutput,
41        Self::ResultMaterialization,
42    ];
43
44    const fn index(self) -> usize {
45        self as usize
46    }
47
48    pub const fn as_str(self) -> &'static str {
49        match self {
50            Self::PipelineBatch => "pipeline_batch",
51            Self::BlockingState => "blocking_state",
52            Self::ExternalRead => "external_read",
53            Self::SpillStaging => "spill_staging",
54            Self::MorselOutput => "morsel_output",
55            Self::ResultMaterialization => "result_materialization",
56        }
57    }
58}
59
60#[derive(Debug, Clone, PartialEq, Eq)]
61pub struct QueryMemoryClassSnapshot {
62    pub class: QueryMemoryClass,
63    pub used_bytes: usize,
64    pub peak_bytes: usize,
65}
66
67#[derive(Debug, Clone, PartialEq, Eq)]
68pub struct QueryMemoryLedgerSnapshot {
69    pub budget_bytes: usize,
70    pub used_bytes: usize,
71    pub peak_bytes: usize,
72    pub account_count: usize,
73    pub classes: Vec<QueryMemoryClassSnapshot>,
74}
75
76#[derive(Debug, Clone)]
77pub struct QueryMemoryLedger {
78    inner: Arc<QueryMemoryLedgerInner>,
79    file_descriptors: Option<hawdb_storage::file_descriptors::FileOpenContext>,
80}
81
82#[derive(Debug)]
83struct QueryMemoryLedgerInner {
84    budget_bytes: usize,
85    budget_option: &'static str,
86    state: Mutex<QueryMemoryLedgerState>,
87}
88
89/// Operators register their accounts while the plan is set up. Reserving room
90/// for a typical plan when the query starts keeps that registration from
91/// reallocating the table under the ledger lock.
92const INITIAL_ACCOUNT_CAPACITY: usize = 16;
93
94// Accounts are never deregistered, so an account id is its dense index.
95// Classes are a closed enum indexed by discriminant; a class appears in
96// snapshots once it has been charged, exactly as an ordered map would show it.
97// Every charge and release is per-row hot path work under one lock (#1615).
98#[derive(Debug, Default)]
99struct QueryMemoryLedgerState {
100    used_bytes: usize,
101    peak_bytes: usize,
102    accounts: Vec<QueryMemoryAccountState>,
103    classes: [Option<QueryMemoryClassState>; QueryMemoryClass::ALL.len()],
104}
105
106impl QueryMemoryLedgerState {
107    fn account(&self, account_id: u64) -> Option<&QueryMemoryAccountState> {
108        usize::try_from(account_id)
109            .ok()
110            .and_then(|index| self.accounts.get(index))
111    }
112
113    fn account_mut(&mut self, account_id: u64) -> Option<&mut QueryMemoryAccountState> {
114        usize::try_from(account_id)
115            .ok()
116            .and_then(|index| self.accounts.get_mut(index))
117    }
118
119    fn registered_account(&self, account_id: u64) -> &QueryMemoryAccountState {
120        self.account(account_id)
121            .expect("query memory account handles always name a registered account")
122    }
123
124    fn class_mut(&mut self, class: QueryMemoryClass) -> &mut QueryMemoryClassState {
125        self.classes[class.index()].get_or_insert_with(QueryMemoryClassState::default)
126    }
127
128    #[cfg(test)]
129    fn charged_classes(&self) -> impl Iterator<Item = (QueryMemoryClass, &QueryMemoryClassState)> {
130        QueryMemoryClass::ALL
131            .into_iter()
132            .zip(&self.classes)
133            .filter_map(|(class, state)| state.as_ref().map(|state| (class, state)))
134    }
135}
136
137#[derive(Debug)]
138struct QueryMemoryAccountState {
139    class: QueryMemoryClass,
140    owner: Arc<str>,
141    budget_bytes: usize,
142    budget_option: &'static str,
143    used_bytes: usize,
144    peak_bytes: usize,
145}
146
147#[derive(Debug, Default)]
148struct QueryMemoryClassState {
149    used_bytes: usize,
150    peak_bytes: usize,
151}
152
153impl QueryMemoryAccountState {
154    fn budget_error(
155        &self,
156        scope: ResourceBudgetScope,
157        requested: usize,
158        used: usize,
159        budget: usize,
160        budget_option: &'static str,
161        message: String,
162    ) -> HawDBError {
163        HawDBError::from(ResourceBudgetError {
164            resource: ResourceBudgetKind::MemoryBytes,
165            scope,
166            owner: self.owner.to_string(),
167            memory_class: Some(self.class.as_str()),
168            requested: requested as u64,
169            used: used as u64,
170            budget: budget as u64,
171            budget_option,
172            message,
173        })
174    }
175
176    fn account_budget_error(&self, requested: usize, message: String) -> HawDBError {
177        self.budget_error(
178            ResourceBudgetScope::Account,
179            requested,
180            self.used_bytes,
181            self.budget_bytes,
182            self.budget_option,
183            message,
184        )
185    }
186}
187
188impl QueryMemoryLedger {
189    /// Creates a query root with the effective host or runtime-granted limit.
190    #[doc(hidden)]
191    pub fn for_query(
192        memory: &crate::ExecutionMemoryConfig,
193        task_context: Option<&hawdb_core::RuntimeTaskContext>,
194    ) -> Result<Self> {
195        Ok(Self::new_with_budget_option(
196            crate::memory::enforced_query_memory_budget(memory, task_context)?,
197            if task_context
198                .and_then(hawdb_core::RuntimeTaskContext::memory_reservation)
199                .is_some()
200            {
201                "RuntimeTaskContext.memory_reservation.memory_bytes"
202            } else {
203                "ExecutionMemoryConfig.query_memory_bytes"
204            },
205        ))
206    }
207
208    pub(crate) fn budget_bytes(&self) -> NonZeroUsize {
209        NonZeroUsize::new(self.inner.budget_bytes).unwrap()
210    }
211
212    pub(crate) fn source_account(
213        &self,
214        owner: &'static str,
215        source_budget: NonZeroUsize,
216        retained_budget: NonZeroUsize,
217    ) -> QueryMemoryAccount {
218        self.source_account_retaining(
219            owner,
220            source_budget,
221            self.account(QueryMemoryClass::BlockingState, owner, retained_budget),
222        )
223    }
224
225    pub(crate) fn source_account_retaining(
226        &self,
227        owner: &'static str,
228        source_budget: NonZeroUsize,
229        retained: QueryMemoryAccount,
230    ) -> QueryMemoryAccount {
231        self.account(QueryMemoryClass::ExternalRead, owner, source_budget)
232            .with_retained_state(retained)
233    }
234
235    pub fn new(budget_bytes: NonZeroUsize) -> Self {
236        Self::new_with_budget_option(budget_bytes, "QueryMemoryLedger::new(budget_bytes)")
237    }
238
239    /// Names the configuration option or runtime grant supplying this limit.
240    pub fn new_with_budget_option(budget_bytes: NonZeroUsize, budget_option: &'static str) -> Self {
241        Self {
242            inner: Arc::new(QueryMemoryLedgerInner {
243                budget_bytes: budget_bytes.get(),
244                budget_option,
245                state: Mutex::new(QueryMemoryLedgerState {
246                    accounts: Vec::with_capacity(INITIAL_ACCOUNT_CAPACITY),
247                    ..QueryMemoryLedgerState::default()
248                }),
249            }),
250            file_descriptors: None,
251        }
252    }
253
254    /// Bind query IO to its store while retaining the existing memory ledger.
255    #[doc(hidden)]
256    pub fn with_file_descriptors(
257        mut self,
258        context: Option<hawdb_storage::file_descriptors::FileOpenContext>,
259    ) -> Self {
260        self.file_descriptors = context.or(self.file_descriptors);
261        self
262    }
263
264    pub(crate) fn file_descriptor_context(
265        &self,
266    ) -> Option<&hawdb_storage::file_descriptors::FileOpenContext> {
267        self.file_descriptors.as_ref()
268    }
269
270    pub fn account(
271        &self,
272        class: QueryMemoryClass,
273        owner: impl Into<Arc<str>>,
274        budget_bytes: NonZeroUsize,
275    ) -> QueryMemoryAccount {
276        self.account_with_budget_option(
277            class,
278            owner,
279            budget_bytes,
280            "QueryMemoryLedger::account(budget_bytes)",
281        )
282    }
283
284    /// Creates an account with an explicit origin for its effective byte limit.
285    pub fn account_with_budget_option(
286        &self,
287        class: QueryMemoryClass,
288        owner: impl Into<Arc<str>>,
289        budget_bytes: NonZeroUsize,
290        budget_option: &'static str,
291    ) -> QueryMemoryAccount {
292        // Caller code must run before locking: conversion can panic or re-enter the ledger.
293        let owner = owner.into();
294        let mut state = lock_recover(&self.inner.state);
295        let account_id = state.accounts.len() as u64;
296        state.accounts.push(QueryMemoryAccountState {
297            class,
298            owner,
299            budget_bytes: budget_bytes.get(),
300            budget_option,
301            used_bytes: 0,
302            peak_bytes: 0,
303        });
304        QueryMemoryAccount {
305            ledger: self.clone(),
306            account_id,
307            backing: None,
308            retained_state: None,
309        }
310    }
311
312    pub fn snapshot(&self) -> QueryMemoryLedgerSnapshot {
313        let state = lock_recover(&self.inner.state);
314        QueryMemoryLedgerSnapshot {
315            budget_bytes: self.inner.budget_bytes,
316            used_bytes: state.used_bytes,
317            peak_bytes: state.peak_bytes,
318            account_count: state.accounts.len(),
319            classes: QueryMemoryClass::ALL
320                .into_iter()
321                .zip(&state.classes)
322                .filter_map(|(class, state)| {
323                    state.as_ref().map(|state| QueryMemoryClassSnapshot {
324                        class,
325                        used_bytes: state.used_bytes,
326                        peak_bytes: state.peak_bytes,
327                    })
328                })
329                .collect(),
330        }
331    }
332
333    /// Test observation of the actual owner, rather than unrelated root charges.
334    #[cfg(test)]
335    pub(crate) fn test_owner_used_bytes(&self, class: QueryMemoryClass, owner: &str) -> usize {
336        lock_recover(&self.inner.state)
337            .accounts
338            .iter()
339            .filter(|account| account.class == class && account.owner.as_ref() == owner)
340            .map(|account| account.used_bytes)
341            .sum()
342    }
343}
344
345impl QueryMemoryLedgerInner {
346    fn reserve(&self, account_id: u64, bytes: usize) -> Result<()> {
347        if bytes == 0 {
348            return Ok(());
349        }
350        let mut state = lock_recover(&self.state);
351        let account = state.account(account_id).ok_or_else(|| {
352            HawDBError::Execution("query memory account is no longer registered".to_string())
353        })?;
354        let class = account.class;
355        let owner = &account.owner;
356        let account_budget = account.budget_bytes;
357        let account_next = account.used_bytes.checked_add(bytes).ok_or_else(|| {
358            account.account_budget_error(
359                bytes,
360                format!(
361                    "query memory account {owner} ({}) byte accounting overflow",
362                    class.as_str()
363                ),
364            )
365        })?;
366        if account_next > account_budget {
367            return Err(account.account_budget_error(bytes, format!(
368                "query memory account {owner} ({}) would use {account_next} bytes, exceeding its {account_budget}-byte budget",
369                class.as_str()
370            )));
371        }
372        let query_error = |message| {
373            account.budget_error(
374                ResourceBudgetScope::Query,
375                bytes,
376                state.used_bytes,
377                self.budget_bytes,
378                self.budget_option,
379                message,
380            )
381        };
382        let root_next = state.used_bytes.checked_add(bytes).ok_or_else(|| {
383            query_error(format!(
384                "query memory ledger byte accounting overflow while charging {owner} ({})",
385                class.as_str()
386            ))
387        })?;
388        if root_next > self.budget_bytes {
389            return Err(query_error(format!(
390                "query memory ledger would use {root_next} bytes while charging {owner} ({}), exceeding query_memory_bytes {}",
391                class.as_str(),
392                self.budget_bytes
393            )));
394        }
395
396        // All checks precede the hierarchical counter updates.
397        let account = state
398            .account_mut(account_id)
399            .expect("validated query memory account remains registered");
400        account.used_bytes = account_next;
401        account.peak_bytes = account.peak_bytes.max(account_next);
402        state.used_bytes = root_next;
403        state.peak_bytes = state.peak_bytes.max(root_next);
404        let class_state = state.class_mut(class);
405        class_state.used_bytes = class_state.used_bytes.saturating_add(bytes);
406        class_state.peak_bytes = class_state.peak_bytes.max(class_state.used_bytes);
407        Ok(())
408    }
409
410    fn can_reserve(&self, account_id: u64, bytes: usize) -> bool {
411        let state = lock_recover(&self.state);
412        let Some(account) = state.account(account_id) else {
413            return false;
414        };
415        let Some(account_next) = account.used_bytes.checked_add(bytes) else {
416            return false;
417        };
418        let Some(root_next) = state.used_bytes.checked_add(bytes) else {
419            return false;
420        };
421        account_next <= account.budget_bytes && root_next <= self.budget_bytes
422    }
423
424    fn release(&self, account_id: u64, bytes: usize) {
425        if bytes == 0 {
426            return;
427        }
428        let mut state = lock_recover(&self.state);
429        let Some(account) = state.account_mut(account_id) else {
430            return;
431        };
432        let released = bytes.min(account.used_bytes);
433        let class = account.class;
434        account.used_bytes -= released;
435        state.used_bytes = state.used_bytes.saturating_sub(released);
436        if let Some(class_state) = &mut state.classes[class.index()] {
437            class_state.used_bytes = class_state.used_bytes.saturating_sub(released);
438        }
439    }
440
441    fn transfer(
442        &self,
443        source_account_id: u64,
444        source_bytes: usize,
445        target_account_id: u64,
446        target_bytes: usize,
447    ) -> Result<()> {
448        if source_account_id == target_account_id {
449            return Err(HawDBError::Execution(
450                "query memory transfer requires distinct accounts".to_string(),
451            ));
452        }
453        let mut state = lock_recover(&self.state);
454        let (source_class, source_used) = state
455            .account(source_account_id)
456            .map(|account| (account.class, account.used_bytes))
457            .ok_or_else(|| {
458                HawDBError::Execution(
459                    "source query memory account is no longer registered".to_string(),
460                )
461            })?;
462        if source_bytes > source_used {
463            return Err(HawDBError::Execution(format!(
464                "query memory transfer tried to release {source_bytes} bytes from a source account using {source_used} bytes"
465            )));
466        }
467        let target_account = state.account(target_account_id).ok_or_else(|| {
468            HawDBError::Execution("target query memory account is no longer registered".to_string())
469        })?;
470        let target_class = target_account.class;
471        let target_owner = &target_account.owner;
472        let target_budget = target_account.budget_bytes;
473        let target_used = target_account.used_bytes;
474        let target_next = target_used.checked_add(target_bytes).ok_or_else(|| {
475            target_account.account_budget_error(
476                target_bytes,
477                format!(
478                    "query memory account {target_owner} ({}) byte accounting overflow",
479                    target_class.as_str()
480                ),
481            )
482        })?;
483        if target_next > target_budget {
484            return Err(target_account.account_budget_error(target_bytes, format!(
485                "query memory account {target_owner} ({}) would use {target_next} bytes, exceeding its {target_budget}-byte budget",
486                target_class.as_str()
487            )));
488        }
489        let root_after_release = state.used_bytes.checked_sub(source_bytes).ok_or_else(|| {
490            HawDBError::Execution(
491                "query memory ledger underflow during ownership transfer".to_string(),
492            )
493        })?;
494        let query_error = |message| {
495            target_account.budget_error(
496                ResourceBudgetScope::Query,
497                target_bytes.saturating_sub(source_bytes),
498                state.used_bytes,
499                self.budget_bytes,
500                self.budget_option,
501                message,
502            )
503        };
504        let root_next = root_after_release.checked_add(target_bytes).ok_or_else(|| {
505            query_error(format!(
506                "query memory ledger byte accounting overflow while transferring ownership to {target_owner} ({})",
507                target_class.as_str()
508            ))
509        })?;
510        if root_next > self.budget_bytes {
511            return Err(query_error(format!(
512                "query memory ledger would use {root_next} bytes while transferring ownership to {target_owner} ({}), exceeding query_memory_bytes {}",
513                target_class.as_str(),
514                self.budget_bytes
515            )));
516        }
517
518        let source = state
519            .account_mut(source_account_id)
520            .expect("validated source query memory account remains registered");
521        source.used_bytes -= source_bytes;
522        let target = state
523            .account_mut(target_account_id)
524            .expect("validated target query memory account remains registered");
525        target.used_bytes = target_next;
526        target.peak_bytes = target.peak_bytes.max(target_next);
527        state.used_bytes = root_next;
528        state.peak_bytes = state.peak_bytes.max(root_next);
529        if let Some(class_state) = &mut state.classes[source_class.index()] {
530            class_state.used_bytes = class_state.used_bytes.saturating_sub(source_bytes);
531        }
532        let class_state = state.class_mut(target_class);
533        class_state.used_bytes = class_state.used_bytes.saturating_add(target_bytes);
534        class_state.peak_bytes = class_state.peak_bytes.max(class_state.used_bytes);
535        Ok(())
536    }
537}
538
539#[derive(Debug, Clone)]
540pub struct QueryMemoryAccount {
541    ledger: QueryMemoryLedger,
542    account_id: u64,
543    // A child ledger owns an up-front reservation in its parent. Every account
544    // and outstanding lease keeps that reservation alive.
545    backing: Option<Arc<QueryMemoryLease>>,
546    retained_state: Option<Arc<QueryMemoryAccount>>,
547}
548
549impl QueryMemoryAccount {
550    pub(crate) fn query_budget_bytes(&self) -> NonZeroUsize {
551        self.ledger.budget_bytes()
552    }
553
554    pub(crate) fn with_retained_state(mut self, account: Self) -> Self {
555        self.retained_state = Some(Arc::new(account));
556        self
557    }
558
559    pub(crate) fn retained_state(&self) -> Option<&Self> {
560        self.retained_state.as_deref()
561    }
562
563    /// Shares the retained-state account without cloning its handles; graph
564    /// reads keep it once per admitted record (#1615).
565    pub(crate) fn retained_state_shared(&self) -> Option<Arc<Self>> {
566        self.retained_state.clone()
567    }
568
569    /// Reserve the entire child allowance before dispatch, without charging
570    /// individual child allocations to the query root a second time.
571    pub(crate) fn sub_account(&self, budget: NonZeroUsize) -> Result<Self> {
572        let backing = Arc::new(self.reserve(budget.get())?);
573        let (class, owner) = {
574            let state = lock_recover(&self.ledger.inner.state);
575            let account = state.registered_account(self.account_id);
576            (account.class, Arc::clone(&account.owner))
577        };
578        let origin = "QueryMemoryAccount::sub_account(budget)";
579        let mut child = QueryMemoryLedger::new_with_budget_option(budget, origin)
580            .account_with_budget_option(class, owner, budget, origin);
581        child.backing = Some(backing);
582        Ok(child)
583    }
584
585    pub(crate) fn budget_bytes(&self) -> NonZeroUsize {
586        NonZeroUsize::new(
587            lock_recover(&self.ledger.inner.state)
588                .registered_account(self.account_id)
589                .budget_bytes,
590        )
591        .unwrap()
592    }
593
594    /// Remaining capacity in both this account and its shared query root.
595    /// This is a sizing observation; the following reservation still arbitrates
596    /// concurrent owners and can fail without advancing the producer.
597    #[doc(hidden)]
598    pub fn available_bytes(&self) -> usize {
599        let state = lock_recover(&self.ledger.inner.state);
600        let account = state.registered_account(self.account_id);
601        account.budget_bytes.saturating_sub(account.used_bytes).min(
602            self.ledger
603                .inner
604                .budget_bytes
605                .saturating_sub(state.used_bytes),
606        )
607    }
608
609    pub(crate) fn peak_bytes(&self) -> usize {
610        lock_recover(&self.ledger.inner.state)
611            .registered_account(self.account_id)
612            .peak_bytes
613    }
614
615    pub(crate) fn query_available_bytes(&self) -> usize {
616        let state = lock_recover(&self.ledger.inner.state);
617        self.ledger
618            .inner
619            .budget_bytes
620            .saturating_sub(state.used_bytes)
621    }
622
623    pub(crate) fn sibling(
624        &self,
625        class: QueryMemoryClass,
626        owner: impl Into<Arc<str>>,
627        budget_bytes: NonZeroUsize,
628    ) -> Self {
629        self.sibling_with_budget_option(
630            class,
631            owner,
632            budget_bytes,
633            "QueryMemoryAccount::sibling(budget_bytes)",
634        )
635    }
636
637    pub(crate) fn sibling_with_budget_option(
638        &self,
639        class: QueryMemoryClass,
640        owner: impl Into<Arc<str>>,
641        budget_bytes: NonZeroUsize,
642        budget_option: &'static str,
643    ) -> Self {
644        let mut account =
645            self.ledger
646                .account_with_budget_option(class, owner, budget_bytes, budget_option);
647        account.backing = self.backing.clone();
648        account
649    }
650
651    pub(crate) fn can_reserve(&self, bytes: usize) -> bool {
652        self.ledger.inner.can_reserve(self.account_id, bytes)
653    }
654
655    pub fn reserve(&self, bytes: usize) -> Result<QueryMemoryLease> {
656        self.ledger.inner.reserve(self.account_id, bytes)?;
657        Ok(QueryMemoryLease {
658            owner: LeaseOwner {
659                ledger: Arc::clone(&self.ledger.inner),
660                account_id: self.account_id,
661                _backing: self.backing.clone(),
662            },
663            bytes,
664        })
665    }
666}
667
668/// The parts of an account a charge needs to settle itself. Leases are created
669/// and dropped per admitted row, so they hold only the ledger they charge and
670/// any parent reservation backing it, not every handle of the account (#1615).
671#[derive(Debug, Clone)]
672struct LeaseOwner {
673    ledger: Arc<QueryMemoryLedgerInner>,
674    account_id: u64,
675    // Held only for ownership: a child ledger's up-front reservation in its
676    // parent stays charged while any account or lease of that child exists.
677    _backing: Option<Arc<QueryMemoryLease>>,
678}
679
680impl LeaseOwner {
681    fn is(&self, ledger: &Arc<QueryMemoryLedgerInner>, account_id: u64) -> bool {
682        self.account_id == account_id && Arc::ptr_eq(&self.ledger, ledger)
683    }
684}
685
686#[derive(Debug)]
687pub struct QueryMemoryLease {
688    owner: LeaseOwner,
689    bytes: usize,
690}
691
692impl QueryMemoryLease {
693    pub(crate) fn operator_budget_error(
694        &self,
695        requested: usize,
696        used: usize,
697        budget: usize,
698        budget_option: &'static str,
699        message: String,
700    ) -> HawDBError {
701        let state = lock_recover(&self.owner.ledger.state);
702        state
703            .registered_account(self.owner.account_id)
704            .budget_error(
705                ResourceBudgetScope::Operator,
706                requested,
707                used,
708                budget,
709                budget_option,
710                message,
711            )
712    }
713
714    pub(crate) fn belongs_to(&self, account: &QueryMemoryAccount) -> bool {
715        self.owner.is(&account.ledger.inner, account.account_id)
716    }
717
718    pub fn bytes(&self) -> usize {
719        self.bytes
720    }
721
722    /// Transfers part of this already admitted charge to an independent owner.
723    /// The ledger charge never disappears or grows during the handoff.
724    pub(crate) fn split_off(&mut self, bytes: usize) -> Result<Self> {
725        if bytes > self.bytes {
726            return Err(HawDBError::Execution(
727                "query memory lease split exceeds its admitted charge".into(),
728            ));
729        }
730        self.bytes -= bytes;
731        Ok(Self {
732            owner: self.owner.clone(),
733            bytes,
734        })
735    }
736
737    /// Takes over another lease's charge on the same account. The ledger
738    /// charge is unchanged; a lease from another account is returned intact.
739    pub(crate) fn absorb(&mut self, mut other: Self) -> std::result::Result<(), Self> {
740        if !self.owner.is(&other.owner.ledger, other.owner.account_id) {
741            return Err(other);
742        }
743        self.bytes = self.bytes.saturating_add(other.bytes);
744        other.bytes = 0;
745        Ok(())
746    }
747
748    pub fn grow(&mut self, bytes: usize) -> Result<()> {
749        self.owner.ledger.reserve(self.owner.account_id, bytes)?;
750        self.bytes = self.bytes.saturating_add(bytes);
751        Ok(())
752    }
753
754    pub fn shrink(&mut self, bytes: usize) {
755        let released = bytes.min(self.bytes);
756        self.owner.ledger.release(self.owner.account_id, released);
757        self.bytes -= released;
758    }
759
760    pub fn reset(&mut self) {
761        self.shrink(self.bytes);
762    }
763
764    pub(crate) fn transfer_to(
765        &mut self,
766        source_bytes: usize,
767        target: &mut Self,
768        target_bytes: usize,
769    ) -> Result<()> {
770        if !Arc::ptr_eq(&self.owner.ledger, &target.owner.ledger) {
771            return Err(HawDBError::Execution(
772                "query memory transfer requires accounts from the same ledger".to_string(),
773            ));
774        }
775        if source_bytes > self.bytes {
776            return Err(HawDBError::Execution(format!(
777                "query memory transfer tried to release {source_bytes} bytes from a {}-byte lease",
778                self.bytes,
779            )));
780        }
781        self.owner.ledger.transfer(
782            self.owner.account_id,
783            source_bytes,
784            target.owner.account_id,
785            target_bytes,
786        )?;
787        self.bytes -= source_bytes;
788        target.bytes = target.bytes.saturating_add(target_bytes);
789        Ok(())
790    }
791}
792
793impl Drop for QueryMemoryLease {
794    fn drop(&mut self) {
795        self.owner.ledger.release(self.owner.account_id, self.bytes);
796        self.bytes = 0;
797    }
798}
799
800fn lock_recover<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
801    // State never escapes the ledger. Checks and allocation precede counter updates,
802    // and no caller code runs under this lock. Recovering poisoning preserves that
803    // valid state; it is not a repair mechanism for arbitrary accounting corruption.
804    mutex
805        .lock()
806        .unwrap_or_else(|poisoned| poisoned.into_inner())
807}
808
809#[cfg(test)]
810#[path = "memory_ledger_hardening_tests.rs"]
811mod hardening_tests;
812
813#[cfg(test)]
814#[path = "memory_ledger_budget_tests.rs"]
815mod budget_tests;
816
817#[cfg(test)]
818#[path = "memory_ledger_reference_tests.rs"]
819mod reference_tests;
820
821#[cfg(test)]
822mod tests {
823    use super::*;
824
825    #[test]
826    fn splitting_a_full_lease_preserves_charge_and_independent_drop() {
827        let budget = NonZeroUsize::new(8).unwrap();
828        let ledger = QueryMemoryLedger::new(budget);
829        let account = ledger.account(QueryMemoryClass::ResultMaterialization, "retained", budget);
830        let mut owner = account.reserve(8).unwrap();
831        assert!(owner.split_off(9).is_err());
832        assert_eq!(owner.bytes(), 8);
833        let view = owner.split_off(3).unwrap();
834        assert_eq!(owner.bytes(), 5);
835        assert_eq!(view.bytes(), 3);
836        assert_eq!(ledger.snapshot().used_bytes, 8);
837        assert_eq!(ledger.snapshot().peak_bytes, 8);
838        drop(owner);
839        assert_eq!(ledger.snapshot().used_bytes, 3);
840        drop(view);
841        assert_eq!(ledger.snapshot().used_bytes, 0);
842    }
843
844    #[test]
845    fn source_account_reuses_retained_cap_without_registering_an_unused_account() {
846        let ledger = QueryMemoryLedger::new(NonZeroUsize::new(1024).unwrap());
847        let retained = ledger.account(
848            QueryMemoryClass::BlockingState,
849            "shared state",
850            NonZeroUsize::new(512).unwrap(),
851        );
852        let source =
853            ledger.source_account_retaining("source", ledger.budget_bytes(), retained.clone());
854        assert_eq!(ledger.snapshot().account_count, 2);
855        let mut grant = crate::store::admit_graph_read(&source, None, 512).unwrap();
856        grant.retain_state().unwrap();
857        assert!(retained.reserve(1).is_err());
858        assert_eq!(ledger.snapshot().used_bytes, 512);
859        drop(grant);
860        let reused = retained.reserve(512).unwrap();
861        assert_eq!(ledger.snapshot().account_count, 2);
862        drop(reused);
863        assert_eq!(ledger.snapshot().used_bytes, 0);
864    }
865
866    #[test]
867    fn sub_accounts_reserve_parent_capacity_without_double_charging() {
868        let budget = NonZeroUsize::new(100).unwrap();
869        let ledger = QueryMemoryLedger::new(budget);
870        let parent = ledger.account(QueryMemoryClass::BlockingState, "merge", budget);
871        let retained = parent.reserve(10).unwrap();
872        let left = parent.sub_account(NonZeroUsize::new(60).unwrap()).unwrap();
873        let right = parent.sub_account(NonZeroUsize::new(30).unwrap()).unwrap();
874        assert_eq!(parent.available_bytes(), 0);
875        assert!(parent.sub_account(NonZeroUsize::MIN).is_err());
876        let mut left_lease = left.reserve(60).unwrap();
877        let right_lease = right.reserve(30).unwrap();
878        assert!(left_lease.grow(1).is_err());
879        assert_eq!(ledger.snapshot().used_bytes, 100);
880        drop((left, right, retained));
881        assert_eq!(ledger.snapshot().used_bytes, 90);
882        drop(left_lease);
883        assert_eq!(ledger.snapshot().used_bytes, 30);
884        drop(right_lease);
885        assert_eq!(ledger.snapshot().used_bytes, 0);
886        assert_eq!(ledger.snapshot().peak_bytes, 100);
887    }
888
889    #[test]
890    fn nested_accounts_and_siblings_keep_backing_until_last_owner_drops() {
891        let budget = NonZeroUsize::new(8).unwrap();
892        let ledger = QueryMemoryLedger::new(budget);
893        let parent = ledger.account(QueryMemoryClass::BlockingState, "merge", budget);
894        let child = parent.sub_account(budget).unwrap();
895        let sibling = child.sibling(QueryMemoryClass::SpillStaging, "staging", budget);
896        let nested = child.sub_account(NonZeroUsize::new(4).unwrap()).unwrap();
897        assert!(sibling.reserve(5).is_err());
898        let lease = nested.reserve(4).unwrap();
899        drop((parent, child, nested));
900        assert_eq!(ledger.snapshot().used_bytes, 8);
901        drop(lease);
902        assert_eq!(ledger.snapshot().used_bytes, 8);
903        let sibling_lease = sibling.reserve(8).unwrap();
904        drop(sibling);
905        assert_eq!(ledger.snapshot().used_bytes, 8);
906        drop(sibling_lease);
907        assert_eq!(ledger.snapshot().used_bytes, 0);
908    }
909
910    #[test]
911    fn failed_sub_account_admission_does_not_leak_root_capacity() {
912        let budget = NonZeroUsize::new(100).unwrap();
913        let ledger = QueryMemoryLedger::new(budget);
914        let parent = ledger.account(QueryMemoryClass::BlockingState, "merge", budget);
915        let other = ledger.account(QueryMemoryClass::PipelineBatch, "input", budget);
916        let retained = other.reserve(70).unwrap();
917        assert!(parent.sub_account(NonZeroUsize::new(31).unwrap()).is_err());
918        assert_eq!(ledger.snapshot().used_bytes, 70);
919        let child = parent.sub_account(NonZeroUsize::new(30).unwrap()).unwrap();
920        assert!(child.reserve(31).is_err());
921        drop((child, retained));
922        assert_eq!(ledger.snapshot().used_bytes, 0);
923    }
924
925    #[test]
926    fn sibling_accounts_share_one_root_budget() {
927        let ledger = QueryMemoryLedger::new(NonZeroUsize::new(10).unwrap());
928        let left = ledger.account(
929            QueryMemoryClass::BlockingState,
930            "left",
931            NonZeroUsize::new(10).unwrap(),
932        );
933        let right = ledger.account(
934            QueryMemoryClass::PipelineBatch,
935            "right",
936            NonZeroUsize::new(10).unwrap(),
937        );
938        let left_lease = left.reserve(6).unwrap();
939        let error = right.reserve(5).unwrap_err();
940
941        assert!(error.to_string().contains("query_memory_bytes 10"));
942        assert_eq!(ledger.snapshot().used_bytes, 6);
943        drop(left_lease);
944        assert_eq!(ledger.snapshot().used_bytes, 0);
945    }
946
947    #[test]
948    fn capacity_preflight_checks_account_and_root_without_charging() {
949        let ledger = QueryMemoryLedger::new(NonZeroUsize::new(10).unwrap());
950        let left = ledger.account(
951            QueryMemoryClass::BlockingState,
952            "left",
953            NonZeroUsize::new(8).unwrap(),
954        );
955        let right = ledger.account(
956            QueryMemoryClass::PipelineBatch,
957            "right",
958            NonZeroUsize::new(10).unwrap(),
959        );
960
961        assert!(left.can_reserve(8));
962        assert_eq!(ledger.snapshot().used_bytes, 0);
963        let left_lease = left.reserve(6).unwrap();
964        assert!(!left.can_reserve(3));
965        assert!(right.can_reserve(4));
966        assert!(!right.can_reserve(5));
967        assert_eq!(ledger.snapshot().used_bytes, 6);
968
969        drop(left_lease);
970        assert_eq!(ledger.snapshot().used_bytes, 0);
971    }
972
973    #[test]
974    fn derived_sibling_account_keeps_the_original_query_root() {
975        let budget = NonZeroUsize::new(8).unwrap();
976        let ledger = QueryMemoryLedger::new(budget);
977        let state = ledger.account(QueryMemoryClass::BlockingState, "merge", budget);
978        let output = state.sibling(QueryMemoryClass::PipelineBatch, "output", budget);
979        let state_lease = state.reserve(6).unwrap();
980        assert!(output
981            .reserve(3)
982            .unwrap_err()
983            .to_string()
984            .contains("query_memory_bytes 8"));
985        let output_lease = output.reserve(2).unwrap();
986        assert_eq!(ledger.snapshot().used_bytes, 8);
987        drop((state_lease, output_lease));
988        assert_eq!(ledger.snapshot().used_bytes, 0);
989    }
990
991    #[test]
992    fn ownership_transfer_does_not_require_double_root_capacity() {
993        let budget = NonZeroUsize::new(8).unwrap();
994        let ledger = QueryMemoryLedger::new(budget);
995        let source = ledger.account(QueryMemoryClass::BlockingState, "source", budget);
996        let target = ledger.account(QueryMemoryClass::PipelineBatch, "target", budget);
997        let mut source_lease = source.reserve(8).unwrap();
998        let mut target_lease = target.reserve(0).unwrap();
999
1000        source_lease.transfer_to(8, &mut target_lease, 8).unwrap();
1001
1002        assert_eq!(source_lease.bytes(), 0);
1003        assert_eq!(target_lease.bytes(), 8);
1004        let snapshot = ledger.snapshot();
1005        assert_eq!(snapshot.used_bytes, 8);
1006        assert_eq!(snapshot.peak_bytes, 8);
1007        drop((source_lease, target_lease));
1008        assert_eq!(ledger.snapshot().used_bytes, 0);
1009    }
1010
1011    #[test]
1012    fn failed_ownership_transfer_preserves_source_charge() {
1013        let budget = NonZeroUsize::new(8).unwrap();
1014        let ledger = QueryMemoryLedger::new(budget);
1015        let source = ledger.account(QueryMemoryClass::BlockingState, "source", budget);
1016        let target = ledger.account(QueryMemoryClass::PipelineBatch, "target", budget);
1017        let mut source_lease = source.reserve(8).unwrap();
1018        let mut target_lease = target.reserve(0).unwrap();
1019
1020        let error = source_lease
1021            .transfer_to(8, &mut target_lease, 9)
1022            .unwrap_err();
1023
1024        assert!(error.to_string().contains("9 bytes"));
1025        assert_eq!(source_lease.bytes(), 8);
1026        assert_eq!(target_lease.bytes(), 0);
1027        assert_eq!(ledger.snapshot().used_bytes, 8);
1028    }
1029
1030    #[test]
1031    fn account_budget_is_enforced_before_root_budget() {
1032        let ledger = QueryMemoryLedger::new(NonZeroUsize::new(100).unwrap());
1033        let account = ledger.account(
1034            QueryMemoryClass::BlockingState,
1035            "sort",
1036            NonZeroUsize::new(4).unwrap(),
1037        );
1038
1039        let error = account.reserve(5).unwrap_err();
1040
1041        assert!(error.to_string().contains("sort"));
1042        assert!(error.to_string().contains("4-byte budget"));
1043        assert_eq!(ledger.snapshot().used_bytes, 0);
1044    }
1045
1046    #[test]
1047    fn lease_growth_shrink_and_drop_update_hierarchy() {
1048        let ledger = QueryMemoryLedger::new(NonZeroUsize::new(100).unwrap());
1049        let account = ledger.account(
1050            QueryMemoryClass::MorselOutput,
1051            "reorder",
1052            NonZeroUsize::new(80).unwrap(),
1053        );
1054        let mut lease = account.reserve(20).unwrap();
1055        lease.grow(30).unwrap();
1056        lease.shrink(10);
1057        assert_eq!(lease.bytes(), 40);
1058        assert_eq!(ledger.snapshot().used_bytes, 40);
1059
1060        drop(lease);
1061        let snapshot = ledger.snapshot();
1062        assert_eq!(snapshot.used_bytes, 0);
1063        assert_eq!(snapshot.peak_bytes, 50);
1064        assert_eq!(snapshot.classes[0].peak_bytes, 50);
1065    }
1066
1067    #[test]
1068    fn unwind_releases_query_memory_lease() {
1069        let ledger = QueryMemoryLedger::new(NonZeroUsize::new(100).unwrap());
1070        let account = ledger.account(
1071            QueryMemoryClass::SpillStaging,
1072            "spill",
1073            NonZeroUsize::new(100).unwrap(),
1074        );
1075        let result = std::panic::catch_unwind(|| {
1076            let _lease = account.reserve(80).unwrap();
1077            panic!("injected panic");
1078        });
1079
1080        assert!(result.is_err());
1081        assert_eq!(ledger.snapshot().used_bytes, 0);
1082    }
1083}