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)