Skip to main content

velesdb_core/
alloc_guard.rs

1//! RAII guards for safe manual memory management.
2//!
3//! # PERF-002: Allocation Guard
4//!
5//! Provides panic-safe allocation patterns for code that must use
6//! manual memory management (e.g., cache-aligned buffers).
7//!
8//! # Usage
9//!
10//! ```rust,ignore
11//! use velesdb_core::alloc_guard::AllocGuard;
12//! use std::alloc::Layout;
13//!
14//! let layout = Layout::from_size_align(1024, 64).unwrap();
15//! let guard = AllocGuard::new(layout)?;
16//!
17//! // Use guard.as_ptr() for operations...
18//! // If panic occurs, memory is automatically freed
19//!
20//! // Transfer ownership when done
21//! let ptr = guard.into_raw();
22//! ```
23
24use std::alloc::{alloc, alloc_zeroed, dealloc, Layout};
25use std::ptr::NonNull;
26use std::sync::atomic::{AtomicUsize, Ordering};
27
28/// Default ceiling for a single raw allocation, in bytes: **1 TiB**.
29///
30/// # Rationale (#899 + follow-up)
31///
32/// This is purely a *backstop against pathological / attacker-controlled /
33/// overflow-class sizes*, **not** a workload limit. It must NEVER reject a
34/// legitimate large index.
35///
36/// `ContiguousVectors` is a **single monolithic buffer holding ALL vectors of an
37/// HNSW graph** — it is not sharded. The previous 16 GiB ceiling therefore
38/// falsely rejected legitimate ingests: a collection only needs ~5.6M vectors at
39/// 768D to exceed 16 GiB in one buffer, and the geometric doubling in
40/// `ensure_capacity`/`resize` tripped even earlier (~2.8M @768D, when the next
41/// doubling crosses 16 GiB). Worse, an index built+persisted under the old code
42/// with > 16 GiB of vectors became **un-loadable** after upgrade.
43///
44/// 1 TiB is chosen because:
45/// - It is far above any single in-RAM vector buffer a real deployment builds
46///   (~358M vectors at 768D, ~715M at 384D), so legitimate workloads never trip
47///   it; the OS allocator / OOM killer rejects genuinely-impossible sizes first.
48/// - It still sits *vastly* below overflow-class requests: a wrapped `usize`
49///   lands near `usize::MAX` (~16 EiB on 64-bit), four-plus orders of magnitude
50///   above 1 TiB, so wrapped/absurd sizes are still cut off before they reach the
51///   system allocator.
52/// - The real overflow guard is the `checked_mul`/`checked_add` arithmetic in
53///   [`ContiguousVectors::byte_size`](crate::perf_optimizations::ContiguousVectors)
54///   and the insert/resize paths; this ceiling is a coarse secondary net.
55///
56/// Configurable at runtime via [`set_alloc_byte_limit`] if an operator
57/// legitimately needs an even larger single allocation, or to harden it down.
58///
59/// On 32-bit / WASM targets `usize` tops out at ~4 GiB, so the 1 TiB literal
60/// would overflow at compile time and the ceiling is meaningless anyway (the
61/// allocator caps allocations well below it). There the backstop is disabled
62/// (`usize::MAX`) and the OS allocator is the effective limit.
63#[cfg(target_pointer_width = "64")]
64pub const DEFAULT_ALLOC_BYTE_LIMIT: usize = 1024 * 1024 * 1024 * 1024;
65/// See the 64-bit variant above: disabled on 32-bit / WASM where 1 TiB would
66/// overflow `usize` and the allocator is the effective ceiling.
67#[cfg(not(target_pointer_width = "64"))]
68pub const DEFAULT_ALLOC_BYTE_LIMIT: usize = usize::MAX;
69
70/// Process-wide per-allocation byte ceiling, initialized to
71/// [`DEFAULT_ALLOC_BYTE_LIMIT`]. See [`set_alloc_byte_limit`].
72static ALLOC_BYTE_LIMIT: AtomicUsize = AtomicUsize::new(DEFAULT_ALLOC_BYTE_LIMIT);
73
74/// Overrides the per-allocation byte ceiling enforced by [`AllocGuard`].
75///
76/// Use to raise the limit for genuinely huge single-buffer workloads, or lower
77/// it to harden against pathological sizes. Affects all subsequent allocations
78/// through [`AllocGuard::new`] / [`AllocGuard::new_zeroed`] and
79/// [`check_alloc_bound`]. Passing `0` is treated as "no override" and restores
80/// the default ceiling.
81pub fn set_alloc_byte_limit(limit_bytes: usize) {
82    let effective = if limit_bytes == 0 {
83        DEFAULT_ALLOC_BYTE_LIMIT
84    } else {
85        limit_bytes
86    };
87    ALLOC_BYTE_LIMIT.store(effective, Ordering::Relaxed);
88}
89
90/// Returns the current per-allocation byte ceiling.
91#[must_use]
92pub fn alloc_byte_limit() -> usize {
93    ALLOC_BYTE_LIMIT.load(Ordering::Relaxed)
94}
95
96/// Runs `f` with the per-allocation ceiling raised to **at least** `min_bytes`,
97/// restoring the previous ceiling afterward (even on panic).
98///
99/// Used by the persisted-index **load** path: the on-disk vector payload has
100/// already been validated to fit within the actual file length (see
101/// `validate_vectors_file_len`), so it is a *real, legitimately-built* size — it
102/// must reload regardless of the process-wide backstop. Bounding the temporary
103/// raise by the file-derived `min_bytes` (rather than removing the ceiling) keeps
104/// the backstop meaningful: a corrupt oversized header is still rejected earlier
105/// by the file-length check, and unrelated allocations during `f` are still
106/// bounded by `max(previous_limit, min_bytes)`.
107///
108/// If the current ceiling already covers `min_bytes`, this is a transparent
109/// pass-through with no mutation.
110pub fn with_min_alloc_byte_limit<T>(min_bytes: usize, f: impl FnOnce() -> T) -> T {
111    let previous = alloc_byte_limit();
112    if min_bytes <= previous {
113        return f();
114    }
115    // RAII restore so a panic inside `f` cannot leave the ceiling raised.
116    let _restore = LimitRestore(previous);
117    ALLOC_BYTE_LIMIT.store(min_bytes, Ordering::Relaxed);
118    f()
119}
120
121/// RAII helper that restores [`ALLOC_BYTE_LIMIT`] to a saved value on drop,
122/// including during unwinding. Used by [`with_min_alloc_byte_limit`].
123struct LimitRestore(usize);
124
125impl Drop for LimitRestore {
126    fn drop(&mut self) {
127        ALLOC_BYTE_LIMIT.store(self.0, Ordering::Relaxed);
128    }
129}
130
131/// Validates that a requested allocation of `bytes` is within the configured
132/// ceiling, *without* allocating.
133///
134/// Lets callers reject pathological sizes before building a [`Layout`] or
135/// reserving a `Vec`. Thread this in front of resize / gather / reorder paths.
136///
137/// # Errors
138///
139/// Returns [`Error::AllocationFailed`](crate::error::Error::AllocationFailed) if
140/// `bytes` exceeds [`alloc_byte_limit`].
141pub fn check_alloc_bound(bytes: usize) -> crate::error::Result<()> {
142    let limit = alloc_byte_limit();
143    if bytes > limit {
144        return Err(crate::error::Error::AllocationFailed(format!(
145            "requested allocation of {bytes} bytes exceeds ceiling of {limit} bytes \
146             (raise via set_alloc_byte_limit if intentional)"
147        )));
148    }
149    Ok(())
150}
151
152/// RAII guard for raw allocations.
153///
154/// Ensures memory is deallocated if dropped, preventing leaks on panic.
155/// Use `into_raw()` to take ownership and prevent deallocation.
156#[derive(Debug)]
157pub struct AllocGuard {
158    ptr: NonNull<u8>,
159    layout: Layout,
160    /// If true, memory will be deallocated on drop
161    owns_memory: bool,
162}
163
164impl AllocGuard {
165    /// Allocates memory with the given layout.
166    ///
167    /// # Returns
168    ///
169    /// - `Some(guard)` if allocation succeeded
170    /// - `None` if allocation failed (OOM), layout size is zero, or the request
171    ///   exceeds the configured [`alloc_byte_limit`] (#899 backstop)
172    ///
173    /// # Panics
174    ///
175    /// This method does not panic. However, callers typically use
176    /// `unwrap_or_else(|| panic!(...))` which will panic on OOM.
177    ///
178    /// # Safety
179    ///
180    /// The returned guard manages raw memory. The caller must ensure
181    /// proper initialization before use.
182    #[must_use]
183    pub fn new(layout: Layout) -> Option<Self> {
184        if layout.size() == 0 || layout.size() > alloc_byte_limit() {
185            return None;
186        }
187
188        // SAFETY: `alloc` requires a valid non-zero layout.
189        // - Condition 1: `layout.size() > 0` is checked above.
190        // - Condition 2: `Layout` comes from std APIs and is therefore well-formed.
191        // SAFETY: Raw allocation is required to build a panic-safe RAII guard.
192        let ptr = unsafe { alloc(layout) };
193
194        NonNull::new(ptr).map(|ptr| Self {
195            ptr,
196            layout,
197            owns_memory: true,
198        })
199    }
200
201    /// Allocates zero-initialized memory with the given layout.
202    ///
203    /// Same as [`new`](Self::new) but guarantees all bytes are zero.
204    /// Use for buffers where sparse writes (e.g., `insert_at`) may leave gaps.
205    ///
206    /// Returns `None` when the layout size is zero or exceeds the configured
207    /// [`alloc_byte_limit`] (#899 backstop).
208    #[must_use]
209    pub fn new_zeroed(layout: Layout) -> Option<Self> {
210        if layout.size() == 0 || layout.size() > alloc_byte_limit() {
211            return None;
212        }
213
214        // SAFETY: `alloc_zeroed` requires a valid non-zero layout.
215        // - Condition 1: `layout.size() > 0` is checked above.
216        // - Condition 2: `Layout` comes from std APIs and is therefore well-formed.
217        // SAFETY: Zero-initialized allocation prevents UB from reading unwritten slots.
218        let ptr = unsafe { alloc_zeroed(layout) };
219
220        NonNull::new(ptr).map(|ptr| Self {
221            ptr,
222            layout,
223            owns_memory: true,
224        })
225    }
226
227    /// Returns the raw pointer to the allocated memory.
228    #[inline]
229    #[must_use]
230    pub fn as_ptr(&self) -> *mut u8 {
231        self.ptr.as_ptr()
232    }
233
234    /// Returns the layout used for this allocation.
235    #[inline]
236    #[must_use]
237    pub fn layout(&self) -> Layout {
238        self.layout
239    }
240
241    /// Transfers ownership of the memory, preventing deallocation on drop.
242    ///
243    /// # Returns
244    ///
245    /// The raw pointer to the allocated memory. The caller is now
246    /// responsible for deallocating it with the same layout.
247    #[inline]
248    #[must_use]
249    pub fn into_raw(mut self) -> *mut u8 {
250        self.owns_memory = false;
251        self.ptr.as_ptr()
252    }
253
254    /// Casts the pointer to a specific type.
255    ///
256    /// # Safety
257    ///
258    /// The caller must ensure the layout is compatible with type T.
259    #[inline]
260    #[must_use]
261    pub fn cast<T>(&self) -> *mut T {
262        self.ptr.as_ptr().cast()
263    }
264}
265
266impl Drop for AllocGuard {
267    fn drop(&mut self) {
268        if self.owns_memory {
269            // SAFETY: `dealloc` requires the original pointer/layout pair.
270            // - Condition 1: `self.ptr` was produced by `alloc(self.layout)` in `new`.
271            // - Condition 2: `owns_memory` guarantees this path runs at most once.
272            // SAFETY: Manual deallocation is needed for raw-memory RAII.
273            unsafe {
274                dealloc(self.ptr.as_ptr(), self.layout);
275            }
276        }
277    }
278}
279
280// SAFETY: `AllocGuard` is `Send` because it owns an allocation handle only.
281// - Condition 1: No aliasing references are stored, only pointer + layout metadata.
282// - Condition 2: Mutation requires `&mut self`, preventing cross-thread races on the type.
283// SAFETY: Heap allocations are not thread-affine; ownership transfer across threads is sound.
284unsafe impl Send for AllocGuard {}
285
286// AllocGuard is NOT Sync - concurrent access to raw memory is unsafe
287// (intentionally not implementing Sync)