Skip to main content

hashtree_lmdb/
lib.rs

1#![doc = include_str!("../README.md")]
2
3mod configured;
4mod external_file;
5mod managed_env;
6mod migration;
7mod migration_audit;
8mod parallel_key_scan;
9mod physical_space;
10pub use physical_space::{
11    PhysicalSpaceGuard, MAX_GUARDED_WRITE_BYTES, PHYSICAL_SPACE_METADATA_MARGIN,
12};
13mod pool;
14
15pub use configured::{
16    open_configured_lmdb_blob_store, open_shared_lmdb_blob_store,
17    open_shared_lmdb_blob_store_with_space_guard, pool_audit_read_only_enabled,
18    ConfiguredLmdbBlobStore, LOCAL_ADD_EXTERNAL_BLOB_DIR_NAME, POOL_AUDIT_READ_ONLY_ENV,
19    POOL_AUDIT_READ_ONLY_ERROR, SHARED_BLOB_MIN_MAP_SIZE_BYTES, SHARED_BLOB_POOL_DIR_NAME,
20};
21pub use heed::{PinnedLmdbFileIdentity, PinnedLmdbIdentity};
22pub use migration::{
23    audit_lmdb_source_batch_with_max_buffer_bytes, migrate_lmdb_batch,
24    migrate_lmdb_batch_with_max_buffer_bytes,
25    migrate_lmdb_batch_with_max_buffer_bytes_and_authorizer,
26    migrate_lmdb_hashes_with_max_buffer_bytes_and_authorizer,
27    reconcile_lmdb_source_batch_with_max_buffer_bytes_and_authorizer,
28    reconcile_lmdb_source_entries_with_max_buffer_bytes_and_authorizer,
29    reconcile_lmdb_source_hashes_with_max_buffer_bytes_and_authorizer,
30    reconcile_lmdb_source_union_page_with_max_buffer_bytes_and_authorizer, LmdbSourceAuditBatch,
31    PoolMigrationBatch, DEFAULT_POOL_MIGRATION_MAX_BUFFER_BYTES,
32};
33pub use migration_audit::{PoolMigrationAuditStore, PoolMigrationAuditSummary};
34pub use parallel_key_scan::{LmdbParallelRawKeyScanner, MAX_PARALLEL_RAW_KEY_SCAN_CONCURRENCY};
35pub use pool::{
36    PoolCatalogLocation, PoolDeleteProtectionChange, PoolDeleteProtectionGuard,
37    PoolDeleteProtectionStatus, PoolMaintenanceReport, PoolManifestIdentity, PoolMemberConfig,
38    PoolMemberId, PoolMemberRuntimePaths, PoolMemberState, PoolMemberStatus, PoolPhysicalAudit,
39    PoolReadBatchItem, PoolStalePending, PoolStalePendingCleanupReport, PoolStore, PoolStoreConfig,
40    PoolStoreReader, PoolTemperatureConfig, PoolTemperatureReport, PoolTerminalAudit,
41    ReadOnlyPoolCatalogAudit, ReadOnlyPoolManifestMember, ReadOnlyPoolManifestSnapshot,
42    ReadOnlyPoolStore, POOL_DELETE_PROTECTED,
43};
44
45use async_trait::async_trait;
46use hashtree_core::store::{PutManyReport, Store, StoreError, StoreStats};
47use hashtree_core::{to_hex, types::Hash};
48use heed::types::*;
49use heed::{Database, EnvFlags, EnvOpenOptions, Error as HeedError, MdbError, PutFlags};
50use managed_env::ManagedEnv;
51use sha2::{Digest, Sha256};
52use std::collections::{HashMap, HashSet};
53#[cfg(unix)]
54use std::ffi::{CString, OsStr};
55use std::fs::{self, File};
56use std::io::{Read, Seek, SeekFrom, Write};
57#[cfg(unix)]
58use std::os::fd::{AsRawFd, FromRawFd};
59#[cfg(unix)]
60use std::os::unix::ffi::OsStrExt;
61use std::path::{Path, PathBuf};
62use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
63use std::sync::mpsc;
64use std::sync::Arc;
65use std::thread;
66use std::time::{SystemTime, UNIX_EPOCH};
67
68// Re-export sha256 for convenience
69pub use hashtree_core::hash::sha256 as compute_sha256;
70
71#[cfg(target_pointer_width = "64")]
72const DEFAULT_MAP_SIZE: usize = 10 * 1024 * 1024 * 1024;
73#[cfg(target_pointer_width = "32")]
74const DEFAULT_MAP_SIZE: usize = 1024 * 1024 * 1024;
75const DEFAULT_MAX_READERS: u32 = 1024;
76const DATABASE_COUNT: u32 = 5;
77const REOPEN_HEADROOM_BYTES: u64 = 64 * 1024 * 1024;
78const EVICTION_BATCH_TARGET_BYTES: u64 = 256 * 1024 * 1024;
79const EVICTION_BATCH_MAX_ITEMS: usize = 4096;
80const LEGACY_BLOB_META_BYTES: usize = 16;
81const BLOB_META_BYTES: usize = 24;
82const ORDER_KEY_BYTES: usize = 40;
83const PIN_COUNT_BYTES: usize = 4;
84const STORE_TOTALS_BYTES: usize = 32;
85const STORE_TOTALS_KEY: &[u8] = b"totals";
86const NEXT_ORDER_BYTES: usize = 8;
87const NEXT_ORDER_KEY: &[u8] = b"next_order";
88const NEXT_ORDER_MIGRATION_FLOOR: u64 = 1 << 48;
89const EXTERNAL_BLOB_MARKER_PREFIX: &[u8] = b"\0hashtree-lmdb-external-blob-v1\0";
90const EXTERNAL_PACK_MARKER_PREFIX: &[u8] = b"\0hashtree-lmdb-external-pack-v1\0";
91const EXTERNAL_PACK_RESERVED_MARKER_PREFIX: &[u8] = b"\0hashtree-lmdb-external-pack-reserved-v1\0";
92const LMDB_NO_READ_AHEAD_ENV: &str = "HTREE_LMDB_NO_READ_AHEAD";
93const LMDB_NO_SYNC_ENV: &str = "HTREE_LMDB_NO_SYNC";
94const LMDB_NO_META_SYNC_ENV: &str = "HTREE_LMDB_NO_META_SYNC";
95const LMDB_EXTERNAL_BLOB_MIN_BYTES_ENV: &str = "HTREE_LMDB_EXTERNAL_BLOB_MIN_BYTES";
96const LMDB_EXTERNAL_BLOB_DIR_ENV: &str = "HTREE_LMDB_EXTERNAL_BLOB_DIR";
97const LMDB_EXTERNAL_BLOB_SYNC_ENV: &str = "HTREE_LMDB_EXTERNAL_BLOB_SYNC";
98const LMDB_EXTERNAL_BLOB_PACK_TARGET_BYTES_ENV: &str = "HTREE_LMDB_EXTERNAL_BLOB_PACK_TARGET_BYTES";
99static EXTERNAL_PACK_COUNTER: AtomicU64 = AtomicU64::new(0);
100
101#[derive(Debug, Clone, Copy)]
102struct BlobMeta {
103    order: u64,
104    size: u64,
105    last_accessed_at: u64,
106}
107
108#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
109struct StoreTotals {
110    count: u64,
111    total_bytes: u64,
112    pinned_count: u64,
113    pinned_bytes: u64,
114}
115
116#[derive(Debug, Clone)]
117struct ExternalBlobConfig {
118    base_path: PathBuf,
119    min_bytes: usize,
120    sync: bool,
121    pack_target_bytes: Option<usize>,
122    #[cfg(unix)]
123    pinned_root: Option<Arc<PinnedExternalRoot>>,
124}
125
126#[cfg(unix)]
127#[derive(Debug)]
128struct PinnedExternalRoot {
129    directory: File,
130}
131
132/// Options for storing larger LMDB blobs in normal files.
133#[derive(Debug, Clone)]
134pub struct ExternalBlobOptions {
135    pub base_path: PathBuf,
136    pub min_bytes: usize,
137    pub sync: bool,
138    pub pack_target_bytes: Option<usize>,
139}
140
141impl ExternalBlobOptions {
142    /// Build external-blob options from the standard hashtree LMDB environment.
143    pub fn from_env(env_path: &Path) -> Option<Self> {
144        ExternalBlobConfig::from_env(env_path).map(Into::into)
145    }
146
147    /// Override only the base directory, keeping the remaining env-derived knobs.
148    pub fn with_base_path(mut self, base_path: PathBuf) -> Self {
149        self.base_path = base_path;
150        self
151    }
152}
153
154impl ExternalBlobConfig {
155    fn from_env(env_path: &Path) -> Option<Self> {
156        let min_bytes = std::env::var(LMDB_EXTERNAL_BLOB_MIN_BYTES_ENV)
157            .ok()
158            .and_then(|value| value.parse::<usize>().ok())
159            .filter(|value| *value > 0)?;
160        let base_path = std::env::var(LMDB_EXTERNAL_BLOB_DIR_ENV)
161            .ok()
162            .filter(|value| !value.trim().is_empty())
163            .map(PathBuf::from)
164            .unwrap_or_else(|| env_path.join("external-blobs"));
165        let sync = env_bool(LMDB_EXTERNAL_BLOB_SYNC_ENV).unwrap_or(true);
166        let pack_target_bytes = std::env::var(LMDB_EXTERNAL_BLOB_PACK_TARGET_BYTES_ENV)
167            .ok()
168            .and_then(|value| value.parse::<usize>().ok())
169            .filter(|value| *value > 0);
170        Some(Self {
171            base_path,
172            min_bytes,
173            sync,
174            pack_target_bytes,
175            #[cfg(unix)]
176            pinned_root: None,
177        })
178    }
179}
180
181impl From<ExternalBlobConfig> for ExternalBlobOptions {
182    fn from(config: ExternalBlobConfig) -> Self {
183        Self {
184            base_path: config.base_path,
185            min_bytes: config.min_bytes,
186            sync: config.sync,
187            pack_target_bytes: config.pack_target_bytes,
188        }
189    }
190}
191
192impl From<ExternalBlobOptions> for ExternalBlobConfig {
193    fn from(options: ExternalBlobOptions) -> Self {
194        Self {
195            base_path: options.base_path,
196            min_bytes: options.min_bytes,
197            sync: options.sync,
198            pack_target_bytes: options.pack_target_bytes,
199            #[cfg(unix)]
200            pinned_root: None,
201        }
202    }
203}
204
205impl ExternalBlobConfig {
206    fn prepare_runtime(self) -> Result<Self, StoreError> {
207        #[cfg(target_os = "linux")]
208        {
209            let mut prepared = self;
210            if is_linux_proc_fd_directory(&prepared.base_path) {
211                prepared.pinned_root =
212                    Some(Arc::new(PinnedExternalRoot::open(&prepared.base_path)?));
213            }
214            return Ok(prepared);
215        }
216        #[cfg(not(target_os = "linux"))]
217        Ok(self)
218    }
219
220    fn relative_blob_path(hash: &Hash) -> PathBuf {
221        let hex = to_hex(hash);
222        PathBuf::from(&hex[..2]).join(&hex[2..4]).join(&hex[4..])
223    }
224
225    fn relative_pack_path(pack_name: &str) -> PathBuf {
226        PathBuf::from("packs").join(&pack_name[..2]).join(pack_name)
227    }
228
229    fn open_relative(&self, relative: &Path) -> Result<File, StoreError> {
230        #[cfg(unix)]
231        if let Some(root) = &self.pinned_root {
232            return root.open_regular(relative);
233        }
234        File::open(self.base_path.join(relative)).map_err(StoreError::Io)
235    }
236}
237
238#[cfg(target_os = "linux")]
239fn is_linux_proc_fd_directory(path: &Path) -> bool {
240    let mut components = path.components();
241    components.next() == Some(std::path::Component::RootDir)
242        && ["proc", "self", "fd"].into_iter().all(|expected| {
243            components
244                .next()
245                .and_then(|component| component.as_os_str().to_str())
246                == Some(expected)
247        })
248        && components
249            .next()
250            .and_then(|component| component.as_os_str().to_str())
251            .is_some_and(|descriptor| {
252                !descriptor.is_empty() && descriptor.bytes().all(|byte| byte.is_ascii_digit())
253            })
254        && components.next().is_none()
255}
256
257#[cfg(target_os = "linux")]
258fn pinned_lmdb_data_len(path: &Path, identity: PinnedLmdbIdentity) -> Result<u64, StoreError> {
259    if !is_linux_proc_fd_directory(path) {
260        return Err(StoreError::Other(
261            "pinned LMDB sizing requires an exact /proc/self/fd/N directory".into(),
262        ));
263    }
264    let directory = File::open(path).map_err(StoreError::Io)?;
265    if !directory.metadata().map_err(StoreError::Io)?.is_dir() {
266        return Err(StoreError::Other(
267            "pinned LMDB sizing root is not a directory".into(),
268        ));
269    }
270    let name = CString::new("data.mdb").expect("LMDB file name has no NUL");
271    let raw = unsafe {
272        libc::openat(
273            directory.as_raw_fd(),
274            name.as_ptr(),
275            libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW | libc::O_NONBLOCK,
276        )
277    };
278    if raw < 0 {
279        return Err(StoreError::Io(std::io::Error::last_os_error()));
280    }
281    let data = unsafe { File::from_raw_fd(raw) };
282    let metadata = data.metadata().map_err(StoreError::Io)?;
283    use std::os::unix::fs::MetadataExt;
284    if !metadata.is_file()
285        || metadata.dev() != identity.data.device
286        || metadata.ino() != identity.data.inode
287    {
288        return Err(StoreError::Other(
289            "pinned LMDB data.mdb identity changed before map sizing".into(),
290        ));
291    }
292    Ok(metadata.len())
293}
294
295#[cfg(not(target_os = "linux"))]
296fn pinned_lmdb_data_len(_path: &Path, _identity: PinnedLmdbIdentity) -> Result<u64, StoreError> {
297    Err(StoreError::Other(
298        "pinned LMDB sizing is supported only on Linux".into(),
299    ))
300}
301
302#[cfg(unix)]
303impl PinnedExternalRoot {
304    #[cfg(target_os = "linux")]
305    fn open(path: &Path) -> Result<Self, StoreError> {
306        let directory = File::open(path).map_err(StoreError::Io)?;
307        if !directory.metadata().map_err(StoreError::Io)?.is_dir() {
308            return Err(StoreError::Other(
309                "pinned external blob root is not a directory".into(),
310            ));
311        }
312        Ok(Self { directory })
313    }
314
315    fn c_name(name: &OsStr) -> Result<CString, StoreError> {
316        CString::new(name.as_bytes())
317            .map_err(|_| StoreError::Other("external blob path component contains NUL".into()))
318    }
319
320    fn components(relative: &Path) -> Result<Vec<&OsStr>, StoreError> {
321        if relative.is_absolute() {
322            return Err(StoreError::Other(
323                "external blob path must be relative to its pinned root".into(),
324            ));
325        }
326        let mut names = Vec::new();
327        for component in relative.components() {
328            match component {
329                std::path::Component::Normal(name) => names.push(name),
330                _ => {
331                    return Err(StoreError::Other(
332                        "external blob path contains an unsafe component".into(),
333                    ))
334                }
335            }
336        }
337        if names.is_empty() {
338            return Err(StoreError::Other("external blob path is empty".into()));
339        }
340        Ok(names)
341    }
342
343    fn open_parent(
344        &self,
345        relative: &Path,
346        create: bool,
347        durable: bool,
348        guard: Option<&PhysicalSpaceGuard>,
349    ) -> Result<(File, CString), StoreError> {
350        let components = Self::components(relative)?;
351        let (leaf, directories) = components
352            .split_last()
353            .expect("non-empty external path checked above");
354        let mut current = self.directory.try_clone().map_err(StoreError::Io)?;
355        for component in directories {
356            let name = Self::c_name(component)?;
357            let open = || unsafe {
358                libc::openat(
359                    current.as_raw_fd(),
360                    name.as_ptr(),
361                    libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
362                )
363            };
364            let mut raw = open();
365            if raw < 0
366                && create
367                && std::io::Error::last_os_error().kind() == std::io::ErrorKind::NotFound
368            {
369                if let Some(guard) = guard {
370                    guard.admit_file(&current, 0, 1)?;
371                }
372                let status = unsafe { libc::mkdirat(current.as_raw_fd(), name.as_ptr(), 0o700) };
373                if status != 0
374                    && std::io::Error::last_os_error().kind() != std::io::ErrorKind::AlreadyExists
375                {
376                    return Err(StoreError::Io(std::io::Error::last_os_error()));
377                }
378                if durable {
379                    current.sync_all().map_err(StoreError::Io)?;
380                }
381                raw = open();
382            }
383            if raw < 0 {
384                return Err(StoreError::Io(std::io::Error::last_os_error()));
385            }
386            let next = unsafe { File::from_raw_fd(raw) };
387            if !next.metadata().map_err(StoreError::Io)?.is_dir() {
388                return Err(StoreError::Other(
389                    "external blob path traverses a non-directory".into(),
390                ));
391            }
392            if durable {
393                current.sync_all().map_err(StoreError::Io)?;
394            }
395            current = next;
396        }
397        Ok((current, Self::c_name(leaf)?))
398    }
399
400    fn open_regular(&self, relative: &Path) -> Result<File, StoreError> {
401        let (parent, leaf) = self.open_parent(relative, false, false, None)?;
402        let raw = unsafe {
403            libc::openat(
404                parent.as_raw_fd(),
405                leaf.as_ptr(),
406                libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW | libc::O_NONBLOCK,
407            )
408        };
409        if raw < 0 {
410            return Err(StoreError::Io(std::io::Error::last_os_error()));
411        }
412        let file = unsafe { File::from_raw_fd(raw) };
413        if !file.metadata().map_err(StoreError::Io)?.is_file() {
414            return Err(StoreError::Other(
415                "external blob entry is not a regular file".into(),
416            ));
417        }
418        Ok(file)
419    }
420
421    fn open_regular_optional(parent: &File, leaf: &CString) -> Result<Option<File>, StoreError> {
422        let raw = unsafe {
423            libc::openat(
424                parent.as_raw_fd(),
425                leaf.as_ptr(),
426                libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW | libc::O_NONBLOCK,
427            )
428        };
429        if raw < 0 {
430            let error = std::io::Error::last_os_error();
431            if error.kind() == std::io::ErrorKind::NotFound {
432                return Ok(None);
433            }
434            return Err(StoreError::Io(error));
435        }
436        let file = unsafe { File::from_raw_fd(raw) };
437        if !file.metadata().map_err(StoreError::Io)?.is_file() {
438            return Err(StoreError::Other(
439                "external blob entry is not a regular file".into(),
440            ));
441        }
442        Ok(Some(file))
443    }
444
445    fn verify_existing_blob(
446        parent: &File,
447        leaf: &CString,
448        expected_hash: &Hash,
449        expected_len: usize,
450        sync: bool,
451    ) -> Result<bool, StoreError> {
452        let Some(mut file) = Self::open_regular_optional(parent, leaf)? else {
453            return Ok(false);
454        };
455        if file.metadata().map_err(StoreError::Io)?.len() != expected_len as u64 {
456            return Err(StoreError::Other(
457                "existing pinned external blob has the wrong length".into(),
458            ));
459        }
460        let mut hasher = Sha256::new();
461        let mut buffer = vec![0u8; 1024 * 1024];
462        let read_limit = u64::try_from(expected_len)
463            .unwrap_or(u64::MAX)
464            .saturating_add(1);
465        let mut limited = Read::by_ref(&mut file).take(read_limit);
466        let mut total = 0usize;
467        loop {
468            let read = limited.read(&mut buffer).map_err(StoreError::Io)?;
469            if read == 0 {
470                break;
471            }
472            total = total.saturating_add(read);
473            hasher.update(&buffer[..read]);
474        }
475        if total != expected_len
476            || file.metadata().map_err(StoreError::Io)?.len() != expected_len as u64
477            || hasher.finalize().as_slice() != expected_hash
478        {
479            return Err(StoreError::Other(
480                "existing pinned external blob has the wrong hash".into(),
481            ));
482        }
483        if sync {
484            file.sync_all().map_err(StoreError::Io)?;
485            parent.sync_all().map_err(StoreError::Io)?;
486        }
487        Ok(true)
488    }
489
490    #[cfg(test)]
491    fn write_blob(
492        &self,
493        relative: &Path,
494        data: &[u8],
495        expected_hash: &Hash,
496        sync: bool,
497    ) -> Result<(), StoreError> {
498        self.write_blob_guarded(relative, data, expected_hash, sync, None)
499    }
500
501    fn write_blob_guarded(
502        &self,
503        relative: &Path,
504        data: &[u8],
505        expected_hash: &Hash,
506        sync: bool,
507        guard: Option<&PhysicalSpaceGuard>,
508    ) -> Result<(), StoreError> {
509        let (parent, leaf) = self.open_parent(relative, true, sync, guard)?;
510        if Self::verify_existing_blob(&parent, &leaf, expected_hash, data.len(), sync)? {
511            return Ok(());
512        }
513        if let Some(guard) = guard {
514            guard.admit_file(&parent, 0, 1)?;
515        }
516        let mut file = Self::create_unnamed_file(&parent)?;
517        if let Some(guard) = guard {
518            guard.write_all(&mut file, data)?;
519        } else {
520            file.write_all(data).map_err(StoreError::Io)?;
521        }
522        if sync {
523            file.sync_all().map_err(StoreError::Io)?;
524        }
525        if let Some(guard) = guard {
526            guard.admit_file(&parent, 0, 1)?;
527        }
528        let linked = match Self::link_open_file(&file, &parent, &leaf) {
529            Ok(()) => true,
530            Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => false,
531            Err(error) => return Err(StoreError::Io(error)),
532        };
533        let _ = linked;
534        Self::verify_existing_blob(&parent, &leaf, expected_hash, data.len(), sync)?;
535        if sync {
536            parent.sync_all().map_err(StoreError::Io)?;
537        }
538        Ok(())
539    }
540
541    fn create_file<T>(
542        &self,
543        relative: &Path,
544        sync: bool,
545        guard: Option<&PhysicalSpaceGuard>,
546        write: impl FnOnce(&mut File) -> Result<T, StoreError>,
547    ) -> Result<T, StoreError> {
548        let (parent, leaf) = self.open_parent(relative, true, sync, guard)?;
549        if Self::open_regular_optional(&parent, &leaf)?.is_some() {
550            return Err(StoreError::Other(
551                "generated external pack already exists".into(),
552            ));
553        }
554        if let Some(guard) = guard {
555            guard.admit_file(&parent, 0, 1)?;
556        }
557        let mut file = Self::create_unnamed_file(&parent)?;
558        let value = write(&mut file)?;
559        if sync {
560            file.sync_all().map_err(StoreError::Io)?;
561        }
562        if let Some(guard) = guard {
563            guard.admit_file(&parent, 0, 1)?;
564        }
565        Self::link_open_file(&file, &parent, &leaf).map_err(StoreError::Io)?;
566        if sync {
567            parent.sync_all().map_err(StoreError::Io)?;
568        }
569        Ok(value)
570    }
571
572    fn remove_file(&self, relative: &Path, sync: bool) -> Result<(), StoreError> {
573        let (parent, leaf) = self.open_parent(relative, false, false, None)?;
574        if unsafe { libc::unlinkat(parent.as_raw_fd(), leaf.as_ptr(), 0) } != 0 {
575            let error = std::io::Error::last_os_error();
576            if error.kind() != std::io::ErrorKind::NotFound {
577                return Err(StoreError::Io(error));
578            }
579        }
580        if sync {
581            parent.sync_all().map_err(StoreError::Io)?;
582        }
583        Ok(())
584    }
585
586    fn link_open_file(file: &File, parent: &File, leaf: &CString) -> Result<(), std::io::Error> {
587        let proc_fd = CString::new(format!("/proc/self/fd/{}", file.as_raw_fd()))
588            .expect("generated procfd path has no NUL");
589        let status = unsafe {
590            libc::linkat(
591                libc::AT_FDCWD,
592                proc_fd.as_ptr(),
593                parent.as_raw_fd(),
594                leaf.as_ptr(),
595                libc::AT_SYMLINK_FOLLOW,
596            )
597        };
598        if status != 0 {
599            return Err(std::io::Error::last_os_error());
600        }
601        let linked = Self::open_regular_optional(parent, leaf)
602            .map_err(|error| std::io::Error::other(error.to_string()))?
603            .ok_or_else(|| std::io::Error::other("linked external file disappeared"))?;
604        let source = file.metadata()?;
605        let target = linked.metadata()?;
606        use std::os::unix::fs::MetadataExt;
607        if source.dev() != target.dev() || source.ino() != target.ino() {
608            return Err(std::io::Error::other(
609                "linked external file identity differs from open writer",
610            ));
611        }
612        Ok(())
613    }
614
615    #[cfg(target_os = "linux")]
616    fn create_unnamed_file(parent: &File) -> Result<File, StoreError> {
617        let dot = CString::new(".").expect("dot has no NUL");
618        let raw = unsafe {
619            libc::openat(
620                parent.as_raw_fd(),
621                dot.as_ptr(),
622                libc::O_RDWR | libc::O_TMPFILE | libc::O_CLOEXEC,
623                0o600,
624            )
625        };
626        if raw < 0 {
627            return Err(StoreError::Io(std::io::Error::last_os_error()));
628        }
629        Ok(unsafe { File::from_raw_fd(raw) })
630    }
631
632    #[cfg(not(target_os = "linux"))]
633    fn create_unnamed_file(_parent: &File) -> Result<File, StoreError> {
634        Err(StoreError::Other(
635            "pinned external publication is supported only on Linux".into(),
636        ))
637    }
638}
639
640#[derive(Debug, Clone)]
641struct ExternalPackRef {
642    name: String,
643    offset: u64,
644    len: u64,
645}
646
647#[derive(Debug)]
648enum SourceBlobRead {
649    ExternalBlob {
650        file: ExternalSourceFile,
651        expected_size: u64,
652    },
653    ExternalPack {
654        file: ExternalSourceFile,
655        offset: u64,
656        len: u64,
657        expected_size: u64,
658    },
659}
660
661impl SourceBlobRead {
662    fn physical_path(&self) -> &Path {
663        match self {
664            Self::ExternalBlob { file, .. } | Self::ExternalPack { file, .. } => &file.path,
665        }
666    }
667
668    fn physical_offset(&self) -> u64 {
669        match self {
670            Self::ExternalBlob { .. } => 0,
671            Self::ExternalPack { offset, .. } => *offset,
672        }
673    }
674}
675
676#[derive(Debug, Clone)]
677struct ExternalSourceFile {
678    path: PathBuf,
679    #[cfg(unix)]
680    relative: PathBuf,
681    #[cfg(unix)]
682    pinned_root: Option<Arc<PinnedExternalRoot>>,
683}
684
685impl ExternalSourceFile {
686    fn new(config: &ExternalBlobConfig, relative: PathBuf) -> Self {
687        Self {
688            path: config.base_path.join(&relative),
689            #[cfg(unix)]
690            relative,
691            #[cfg(unix)]
692            pinned_root: config.pinned_root.clone(),
693        }
694    }
695
696    fn open(&self) -> Result<File, StoreError> {
697        #[cfg(unix)]
698        if let Some(root) = &self.pinned_root {
699            return root.open_regular(&self.relative);
700        }
701        File::open(&self.path).map_err(StoreError::Io)
702    }
703}
704
705#[derive(Debug)]
706struct SourceBlobReadPlan {
707    index: usize,
708    hash: Hash,
709    read: SourceBlobRead,
710}
711
712/// LMDB-backed blob store implementing hashtree's Store trait.
713pub struct LmdbBlobStore {
714    env: ManagedEnv,
715    /// Maps SHA256 hash (32 bytes) → blob data
716    blobs: Database<Bytes, Bytes>,
717    /// Maps SHA256 hash (32 bytes) → [order: u64][size: u64]
718    metadata: Database<Bytes, Bytes>,
719    /// Maps [order: u64][hash: 32 bytes] → ()
720    eviction_order: Database<Bytes, Unit>,
721    /// Maps SHA256 hash (32 bytes) → pin count (u32)
722    pins: Database<Bytes, Bytes>,
723    /// Small aggregate counters used by quota checks and stats.
724    stats: Database<Bytes, Bytes>,
725    max_bytes: AtomicU64,
726    next_order: AtomicU64,
727    external_blobs: Option<ExternalBlobConfig>,
728    physical_space: Option<PhysicalSpaceGuard>,
729}
730
731/// Read-only view of an existing LMDB blob store for online migration and verification.
732///
733/// It adopts the map size published in the LMDB environment instead of requesting
734/// a resize, and intentionally exposes no mutation methods.
735pub struct LmdbBlobReader {
736    store: LmdbBlobStore,
737    external_read_concurrency: usize,
738}
739
740/// Exhaustive key-set proof for a terminal LMDB migration source.
741#[derive(Debug, Clone, PartialEq, Eq)]
742pub struct LmdbSourceKeysetAudit {
743    pub blob_entries: u64,
744    pub metadata_entries: u64,
745    pub blob_only_entries: u64,
746    pub legacy_blob_only: bool,
747    pub inline_entries: u64,
748    pub loose_external_entries: u64,
749    pub packed_external_entries: u64,
750    pub sha256: Hash,
751    pub catalog_location_sha256: Hash,
752}
753
754/// Exhaustive proof of the ordered raw blob keys in a frozen migration source.
755///
756/// This deliberately excludes blob values, metadata, and storage locations.
757/// Once the same content-addressed keys have exact `Stored` Pool catalog rows
758/// and the Pool bodies are independently SHA-256 audited, touching legacy
759/// source values adds I/O but no preservation evidence.
760#[derive(Debug, Clone, PartialEq, Eq)]
761pub struct LmdbSourceKeyAudit {
762    pub blob_entries: u64,
763    pub sha256: Hash,
764}
765
766/// Streaming builder for [`LmdbSourceKeyAudit`].
767pub struct LmdbSourceKeyAuditBuilder {
768    hasher: Sha256,
769    blob_entries: u64,
770    previous: Option<Hash>,
771}
772
773impl LmdbSourceKeyAuditBuilder {
774    pub fn new() -> Self {
775        let mut hasher = Sha256::new();
776        hasher.update(b"hashtree-lmdb-terminal-source-keys/v1\0");
777        Self {
778            hasher,
779            blob_entries: 0,
780            previous: None,
781        }
782    }
783
784    pub fn append_sorted(&mut self, hashes: &[Hash]) -> Result<(), StoreError> {
785        if hashes.windows(2).any(|pair| pair[0] >= pair[1])
786            || self
787                .previous
788                .zip(hashes.first().copied())
789                .is_some_and(|(previous, first)| previous >= first)
790        {
791            return Err(StoreError::Other(
792                "terminal source keys must be globally unique and strictly sorted".into(),
793            ));
794        }
795        for hash in hashes {
796            self.hasher.update(hash);
797        }
798        self.blob_entries = self
799            .blob_entries
800            .checked_add(hashes.len() as u64)
801            .ok_or_else(|| StoreError::Other("terminal source key count overflow".into()))?;
802        self.previous = hashes.last().copied().or(self.previous);
803        Ok(())
804    }
805
806    pub fn blob_entries(&self) -> u64 {
807        self.blob_entries
808    }
809
810    pub fn finish(mut self) -> LmdbSourceKeyAudit {
811        self.hasher.update(self.blob_entries.to_be_bytes());
812        LmdbSourceKeyAudit {
813            blob_entries: self.blob_entries,
814            sha256: self.hasher.finalize().into(),
815        }
816    }
817}
818
819impl Default for LmdbSourceKeyAuditBuilder {
820    fn default() -> Self {
821        Self::new()
822    }
823}
824
825/// Stable LMDB generation fields used to bind a source-terminal receipt.
826#[derive(Debug, Clone, Copy, PartialEq, Eq)]
827pub struct LmdbEnvironmentGeneration {
828    pub map_size: u64,
829    pub last_page_number: u64,
830    pub last_txn_id: u64,
831}
832
833#[derive(Debug, Clone, PartialEq, Eq)]
834pub(crate) struct LmdbTerminalMemberKeysetAudit {
835    pub blob_entries: u64,
836    pub metadata_entries: u64,
837    pub total_bytes: u64,
838    pub pinned_count: u64,
839    pub pinned_bytes: u64,
840    pub ownership_sha256: Hash,
841    pub sha256: Hash,
842}
843
844impl LmdbBlobReader {
845    pub fn open<P: AsRef<Path>>(
846        path: P,
847        external_blobs: Option<ExternalBlobOptions>,
848    ) -> Result<Self, StoreError> {
849        Self::open_with_external_read_concurrency(path, external_blobs, 1)
850    }
851
852    /// Open a read-only source with bounded concurrency for distinct unpacked
853    /// external blob files.
854    ///
855    /// External pack ranges remain physically ordered and are read
856    /// sequentially through one open pack at a time.
857    pub fn open_with_external_read_concurrency<P: AsRef<Path>>(
858        path: P,
859        external_blobs: Option<ExternalBlobOptions>,
860        external_read_concurrency: usize,
861    ) -> Result<Self, StoreError> {
862        Self::open_with_external_read_concurrency_inner(
863            path.as_ref(),
864            external_blobs,
865            external_read_concurrency,
866            None,
867            false,
868        )
869    }
870
871    /// Open an unpinned source for an exhaustive, key-ordered scan with
872    /// kernel read-ahead enabled.
873    pub fn open_sequential_with_external_read_concurrency<P: AsRef<Path>>(
874        path: P,
875        external_blobs: Option<ExternalBlobOptions>,
876        external_read_concurrency: usize,
877    ) -> Result<Self, StoreError> {
878        Self::open_with_external_read_concurrency_inner(
879            path.as_ref(),
880            external_blobs,
881            external_read_concurrency,
882            None,
883            true,
884        )
885    }
886
887    /// Open a migration source through a retained directory descriptor and
888    /// require the actual LMDB data/lock descriptors to match identities
889    /// captured before the launch acknowledgement.
890    pub fn open_with_external_read_concurrency_and_pinned_identity<P: AsRef<Path>>(
891        path: P,
892        external_blobs: Option<ExternalBlobOptions>,
893        external_read_concurrency: usize,
894        identity: PinnedLmdbIdentity,
895    ) -> Result<Self, StoreError> {
896        Self::open_with_external_read_concurrency_inner(
897            path.as_ref(),
898            external_blobs,
899            external_read_concurrency,
900            Some(identity),
901            false,
902        )
903    }
904
905    /// Open a pinned migration source for an exhaustive, key-ordered scan.
906    ///
907    /// Unlike the ordinary sparse reader, this leaves kernel read-ahead
908    /// enabled. Large inline LMDB values otherwise degrade into one blocking
909    /// major page fault per page during a complete migration or audit.
910    pub fn open_sequential_with_external_read_concurrency_and_pinned_identity<P: AsRef<Path>>(
911        path: P,
912        external_blobs: Option<ExternalBlobOptions>,
913        external_read_concurrency: usize,
914        identity: PinnedLmdbIdentity,
915    ) -> Result<Self, StoreError> {
916        Self::open_with_external_read_concurrency_inner(
917            path.as_ref(),
918            external_blobs,
919            external_read_concurrency,
920            Some(identity),
921            true,
922        )
923    }
924
925    fn open_with_external_read_concurrency_inner(
926        path: &Path,
927        external_blobs: Option<ExternalBlobOptions>,
928        external_read_concurrency: usize,
929        pinned_identity: Option<PinnedLmdbIdentity>,
930        sequential_scan: bool,
931    ) -> Result<Self, StoreError> {
932        if external_read_concurrency == 0 {
933            return Err(StoreError::Other(
934                "external read concurrency must be non-zero".into(),
935            ));
936        }
937        let mut options = ManagedEnv::read_only_options(path).map_err(map_heed_error)?;
938        options
939            .max_dbs(DATABASE_COUNT)
940            .max_readers(DEFAULT_MAX_READERS);
941        let mut flags = env_flags_from_env();
942        if sequential_scan {
943            flags.remove(EnvFlags::NO_READ_AHEAD);
944        } else {
945            // Ordinary verification performs sparse hash lookups across a
946            // potentially very large source. Disable kernel read-ahead so
947            // unrelated LMDB pages do not inflate reclaimable file cache.
948            flags |= EnvFlags::NO_READ_AHEAD;
949        }
950        unsafe {
951            options.flags(flags);
952        }
953        let env = unsafe {
954            match pinned_identity {
955                Some(identity) => ManagedEnv::open_pinned(&options, path, identity),
956                None => ManagedEnv::open(&options, path),
957            }
958        }
959        .map_err(map_heed_error)?;
960        let rtxn = env.read_txn().map_err(map_heed_error)?;
961        let open_bytes = |name| -> Result<Database<Bytes, Bytes>, StoreError> {
962            env.open_database(&rtxn, Some(name))
963                .map_err(map_heed_error)?
964                .ok_or_else(|| StoreError::Other(format!("missing LMDB database {name}")))
965        };
966        let open_unit = |name| -> Result<Database<Bytes, Unit>, StoreError> {
967            env.open_database(&rtxn, Some(name))
968                .map_err(map_heed_error)?
969                .ok_or_else(|| StoreError::Other(format!("missing LMDB database {name}")))
970        };
971        let blobs = open_bytes("blobs")?;
972        let metadata = open_bytes("metadata")?;
973        let eviction_order = open_unit("eviction_order")?;
974        let pins = open_bytes("pins")?;
975        let stats = open_bytes("stats")?;
976        rtxn.commit().map_err(map_heed_error)?;
977        let external_blobs = external_blobs
978            .map(ExternalBlobConfig::from)
979            .map(ExternalBlobConfig::prepare_runtime)
980            .transpose()?;
981        Ok(Self {
982            store: LmdbBlobStore {
983                env,
984                blobs,
985                metadata,
986                eviction_order,
987                pins,
988                stats,
989                max_bytes: AtomicU64::new(0),
990                next_order: AtomicU64::new(0),
991                external_blobs,
992                physical_space: None,
993            },
994            external_read_concurrency,
995        })
996    }
997
998    pub fn get_sync(&self, hash: &Hash) -> Result<Option<Vec<u8>>, StoreError> {
999        self.store.get_sync(hash)
1000    }
1001
1002    pub fn blob_size_sync(&self, hash: &Hash) -> Result<Option<u64>, StoreError> {
1003        self.store.blob_size_sync(hash)
1004    }
1005
1006    pub fn scan_hashes_after(
1007        &self,
1008        after: Option<Hash>,
1009        limit: usize,
1010    ) -> Result<Vec<Hash>, StoreError> {
1011        self.store.scan_hashes_after(after, limit)
1012    }
1013
1014    /// Scan raw blob keys from `start` inclusively without requesting LMDB
1015    /// values.
1016    pub fn scan_hashes_from(&self, start: Hash, limit: usize) -> Result<Vec<Hash>, StoreError> {
1017        self.store.scan_hashes_from(start, limit)
1018    }
1019
1020    /// Resolve exact logical sizes without loading blob payloads.
1021    ///
1022    /// Legacy loose external rows have no metadata size, so this stats the
1023    /// content-addressed external file; pack rows use their encoded interval.
1024    pub fn sizes_for_sorted_hashes(&self, hashes: &[Hash]) -> Result<Vec<u64>, StoreError> {
1025        let rtxn = self.store.env.read_txn().map_err(map_heed_error)?;
1026        hashes
1027            .iter()
1028            .map(|hash| {
1029                self.store
1030                    .blob_size_in_txn(&rtxn, hash)?
1031                    .ok_or_else(|| StoreError::Other(format!("source lost blob {hash:?}")))
1032            })
1033            .collect()
1034    }
1035
1036    pub fn environment_generation(&self) -> LmdbEnvironmentGeneration {
1037        let info = self.store.env.info();
1038        LmdbEnvironmentGeneration {
1039            map_size: info.map_size as u64,
1040            last_page_number: info.last_page_number as u64,
1041            last_txn_id: info.last_txn_id as u64,
1042        }
1043    }
1044
1045    /// Read a present set of hashes with one LMDB transaction and physically
1046    /// coalesced external-file reads, stopping before `byte_limit` after at
1047    /// least one result.
1048    ///
1049    /// Callers must supply only hashes that are present in this reader. The
1050    /// returned prefix length tells the caller where to resume.
1051    pub fn read_hashes_bounded(
1052        &self,
1053        hashes: &[Hash],
1054        byte_limit: u64,
1055    ) -> Result<Vec<(Hash, Vec<u8>)>, StoreError> {
1056        if hashes.is_empty() || byte_limit == 0 {
1057            return Ok(Vec::new());
1058        }
1059
1060        // Resolve every LMDB value in one read transaction. The old migration
1061        // path opened one transaction per hash, amplifying cursor and mmap
1062        // overhead on multi-terabyte sources.
1063        let rtxn = self.store.env.read_txn().map_err(map_heed_error)?;
1064        let mut values = Vec::with_capacity(hashes.len());
1065        let mut reads = Vec::new();
1066        let mut selected_bytes = 0u64;
1067
1068        for hash in hashes {
1069            let expected_size = self.store.blob_size_in_txn(&rtxn, hash)?.ok_or_else(|| {
1070                StoreError::Other(format!("source lost blob {hash:?} during scan"))
1071            })?;
1072            if !values.is_empty() && selected_bytes.saturating_add(expected_size) > byte_limit {
1073                break;
1074            }
1075            let value = self
1076                .store
1077                .blobs
1078                .get(&rtxn, hash)
1079                .map_err(map_heed_error)?
1080                .ok_or_else(|| {
1081                    StoreError::Other(format!("source lost blob {hash:?} during scan"))
1082                })?;
1083            let index = values.len();
1084            if self.store.is_external_blob_ref(hash, value) {
1085                let config = self.store.external_blobs.as_ref().ok_or_else(|| {
1086                    StoreError::Other(
1087                        "external LMDB blob marker found but external blobs are disabled".into(),
1088                    )
1089                })?;
1090                values.push((*hash, None));
1091                reads.push(SourceBlobReadPlan {
1092                    index,
1093                    hash: *hash,
1094                    read: SourceBlobRead::ExternalBlob {
1095                        file: ExternalSourceFile::new(
1096                            config,
1097                            ExternalBlobConfig::relative_blob_path(hash),
1098                        ),
1099                        expected_size,
1100                    },
1101                });
1102            } else if let Some(pack_ref) = LmdbBlobStore::decode_external_pack_ref(value)? {
1103                let config = self.store.external_blobs.as_ref().ok_or_else(|| {
1104                    StoreError::Other(
1105                        "external LMDB pack marker found but external blobs are disabled".into(),
1106                    )
1107                })?;
1108                values.push((*hash, None));
1109                reads.push(SourceBlobReadPlan {
1110                    index,
1111                    hash: *hash,
1112                    read: SourceBlobRead::ExternalPack {
1113                        file: ExternalSourceFile::new(
1114                            config,
1115                            ExternalBlobConfig::relative_pack_path(&pack_ref.name),
1116                        ),
1117                        offset: pack_ref.offset,
1118                        len: pack_ref.len,
1119                        expected_size,
1120                    },
1121                });
1122            } else {
1123                let data = value.to_vec();
1124                if data.len() as u64 != expected_size {
1125                    return Err(StoreError::Other(format!(
1126                        "source size changed for {hash:?}: metadata says {expected_size}, value has {}",
1127                        data.len()
1128                    )));
1129                }
1130                values.push((*hash, Some(data)));
1131            }
1132            selected_bytes = selected_bytes.saturating_add(expected_size);
1133        }
1134        drop(rtxn);
1135
1136        let (mut external_reads, mut pack_reads): (Vec<_>, Vec<_>) = reads
1137            .into_iter()
1138            .partition(|plan| matches!(plan.read, SourceBlobRead::ExternalBlob { .. }));
1139
1140        // Distinct legacy files can otherwise turn a migration into one
1141        // blocking disk seek per hash. Keep only a bounded number in flight so
1142        // RAID/device scheduling can reorder them without opening the whole
1143        // page at once.
1144        external_reads.sort_unstable_by(|left, right| {
1145            left.read
1146                .physical_path()
1147                .cmp(right.read.physical_path())
1148                .then_with(|| left.index.cmp(&right.index))
1149        });
1150        for (index, data) in
1151            read_external_blob_files(&external_reads, self.external_read_concurrency)?
1152        {
1153            values[index].1 = Some(data);
1154        }
1155
1156        // Hash order is the durable cursor order, but external pack layout is
1157        // physical. Read pack payloads by path and offset through one open pack,
1158        // then restore the original hash order before verification/insertion.
1159        pack_reads.sort_unstable_by(|left, right| {
1160            left.read
1161                .physical_path()
1162                .cmp(right.read.physical_path())
1163                .then_with(|| {
1164                    left.read
1165                        .physical_offset()
1166                        .cmp(&right.read.physical_offset())
1167                })
1168                .then_with(|| left.index.cmp(&right.index))
1169        });
1170        let mut open_pack: Option<(PathBuf, File)> = None;
1171        for plan in pack_reads {
1172            let data = match plan.read {
1173                SourceBlobRead::ExternalPack {
1174                    file,
1175                    offset,
1176                    len,
1177                    expected_size,
1178                } => {
1179                    if len != expected_size {
1180                        return Err(StoreError::Other(format!(
1181                            "source size changed for {:?}: metadata says {expected_size}, pack marker says {len}",
1182                            plan.hash
1183                        )));
1184                    }
1185                    let must_open = open_pack
1186                        .as_ref()
1187                        .is_none_or(|(open_path, _)| open_path != &file.path);
1188                    if must_open {
1189                        open_pack = Some((file.path.clone(), file.open()?));
1190                    }
1191                    let (_, file) = open_pack.as_mut().expect("pack was opened");
1192                    file.seek(SeekFrom::Start(offset))?;
1193                    let read_len = usize::try_from(len).map_err(|_| {
1194                        StoreError::Other("external pack blob is too large to read".to_string())
1195                    })?;
1196                    let mut data = vec![0; read_len];
1197                    file.read_exact(&mut data)?;
1198                    data
1199                }
1200                SourceBlobRead::ExternalBlob { .. } => {
1201                    return Err(StoreError::Other(
1202                        "external blob read entered the pack read lane".to_string(),
1203                    ));
1204                }
1205            };
1206            values[plan.index].1 = Some(data);
1207        }
1208
1209        values
1210            .into_iter()
1211            .map(|(hash, data)| {
1212                data.map(|data| (hash, data)).ok_or_else(|| {
1213                    StoreError::Other(format!("source read plan omitted blob {hash:?}"))
1214                })
1215            })
1216            .collect()
1217    }
1218
1219    /// Mark candidate hashes present using one read transaction.
1220    pub fn existing_hashes_in_sorted_candidates(
1221        &self,
1222        sorted_hashes: &[Hash],
1223    ) -> Result<Vec<bool>, StoreError> {
1224        self.store
1225            .existing_hashes_in_sorted_candidates(sorted_hashes)
1226    }
1227
1228    /// Mark exact raw `blobs` keys present without consulting metadata or
1229    /// requesting LMDB values.
1230    pub fn existing_blob_keys_in_sorted_candidates(
1231        &self,
1232        sorted_hashes: &[Hash],
1233    ) -> Result<Vec<bool>, StoreError> {
1234        self.store
1235            .existing_blob_keys_in_sorted_candidates(sorted_hashes)
1236    }
1237
1238    /// Prove exact raw `blobs` keys with one bounded forward cursor scan.
1239    pub fn blob_keys_match_bounded_raw_range(
1240        &self,
1241        sorted_hashes: &[Hash],
1242        inclusive_end: Hash,
1243        max_scanned_keys: usize,
1244    ) -> Result<bool, StoreError> {
1245        self.store
1246            .blob_keys_match_bounded_raw_range(sorted_hashes, inclusive_end, max_scanned_keys)
1247    }
1248
1249    /// Read the aggregate counters without opening the environment writable.
1250    pub fn stats(&self) -> Result<LmdbStats, StoreError> {
1251        self.store.stats()
1252    }
1253
1254    /// Read exact LMDB entry counts from the blob and metadata database roots.
1255    pub fn database_entry_counts(&self) -> Result<(u64, u64), StoreError> {
1256        let rtxn = self.store.env.read_txn().map_err(map_heed_error)?;
1257        let blob_entries = self.store.blobs.len(&rtxn).map_err(map_heed_error)?;
1258        let metadata_entries = self.store.metadata.len(&rtxn).map_err(map_heed_error)?;
1259        Ok((blob_entries, metadata_entries))
1260    }
1261
1262    /// Prove the exact terminal physical indexes and aggregate state of one
1263    /// Pool member.
1264    ///
1265    /// Pool members are never accepted as legacy blob-only stores. Metadata
1266    /// sizes must match exact inline/loose/packed body lengths, every metadata
1267    /// row must have its exact eviction key, every nonzero pin must reference
1268    /// metadata, and the recomputed totals must equal persisted counters. The
1269    /// digest binds all of that state, not only entry counts.
1270    pub(crate) fn validate_terminal_member_keyset(
1271        &self,
1272    ) -> Result<LmdbTerminalMemberKeysetAudit, StoreError> {
1273        self.store.validate_exact_member_state()
1274    }
1275
1276    /// Prove the complete raw source catalog and every encoded storage
1277    /// location used by the final migration scan.
1278    ///
1279    /// `blobs` is the membership authority. Missing metadata is valid for
1280    /// legacy rows, including in a partially upgraded database; metadata with
1281    /// no corresponding blob row is invalid. The keyset digest binds every raw
1282    /// catalog key, while the location digest binds whether each value is
1283    /// inline, a loose external marker, or an exact external pack marker.
1284    /// Callers must keep every source writer fenced throughout this scan.
1285    pub fn validate_terminal_migration_keyset(&self) -> Result<LmdbSourceKeysetAudit, StoreError> {
1286        let rtxn = self.store.env.read_txn().map_err(map_heed_error)?;
1287        let blob_entries = self.store.blobs.len(&rtxn).map_err(map_heed_error)?;
1288        let metadata_entries = self.store.metadata.len(&rtxn).map_err(map_heed_error)?;
1289        let legacy_blob_only = metadata_entries == 0;
1290        let mut keyset_digest = Sha256::new();
1291        keyset_digest.update(b"hashtree-lmdb-terminal-source-keyset/v2\0");
1292        keyset_digest.update(blob_entries.to_be_bytes());
1293        keyset_digest.update(metadata_entries.to_be_bytes());
1294        let mut location_digest = Sha256::new();
1295        location_digest.update(b"hashtree-lmdb-terminal-source-locations/v1\0");
1296        location_digest.update(blob_entries.to_be_bytes());
1297        location_digest.update(metadata_entries.to_be_bytes());
1298        let mut blob_only_entries = 0u64;
1299        let mut inline_entries = 0u64;
1300        let mut loose_external_entries = 0u64;
1301        let mut packed_external_entries = 0u64;
1302
1303        let blobs = self.store.blobs.iter(&rtxn).map_err(map_heed_error)?;
1304        let mut metadata = self.store.metadata.iter(&rtxn).map_err(map_heed_error)?;
1305        let mut next_metadata = metadata.next().transpose().map_err(map_heed_error)?;
1306        for item in blobs {
1307            let (blob_key, blob_value) = item.map_err(map_heed_error)?;
1308            let blob_hash: Hash = blob_key.try_into().map_err(|_| {
1309                StoreError::Other("terminal source has an invalid blob hash key".into())
1310            })?;
1311            while next_metadata
1312                .as_ref()
1313                .is_some_and(|(metadata_key, _)| *metadata_key < blob_key)
1314            {
1315                let metadata_key = next_metadata
1316                    .as_ref()
1317                    .expect("checked present metadata key")
1318                    .0;
1319                let metadata_hash: Hash = metadata_key.try_into().map_err(|_| {
1320                    StoreError::Other("terminal source has an invalid metadata hash key".into())
1321                })?;
1322                return Err(StoreError::Other(format!(
1323                    "terminal source metadata {} has no raw blob row",
1324                    to_hex(&metadata_hash)
1325                )));
1326            }
1327            if next_metadata
1328                .as_ref()
1329                .is_some_and(|(metadata_key, _)| *metadata_key == blob_key)
1330            {
1331                next_metadata = metadata.next().transpose().map_err(map_heed_error)?;
1332            } else {
1333                blob_only_entries = blob_only_entries
1334                    .checked_add(1)
1335                    .ok_or_else(|| StoreError::Other("source blob-only count overflow".into()))?;
1336            }
1337
1338            keyset_digest.update(blob_hash);
1339            location_digest.update(blob_hash);
1340            if self.store.is_external_blob_ref(&blob_hash, blob_value) {
1341                loose_external_entries =
1342                    loose_external_entries.checked_add(1).ok_or_else(|| {
1343                        StoreError::Other("source loose-external count overflow".into())
1344                    })?;
1345                location_digest.update(b"L");
1346                location_digest.update((blob_value.len() as u64).to_be_bytes());
1347                location_digest.update(blob_value);
1348            } else if LmdbBlobStore::decode_external_pack_ref(blob_value)?.is_some() {
1349                packed_external_entries =
1350                    packed_external_entries.checked_add(1).ok_or_else(|| {
1351                        StoreError::Other("source packed-external count overflow".into())
1352                    })?;
1353                location_digest.update(b"P");
1354                location_digest.update((blob_value.len() as u64).to_be_bytes());
1355                location_digest.update(blob_value);
1356            } else {
1357                inline_entries = inline_entries
1358                    .checked_add(1)
1359                    .ok_or_else(|| StoreError::Other("source inline count overflow".into()))?;
1360                location_digest.update(b"I");
1361                location_digest.update((blob_value.len() as u64).to_be_bytes());
1362            }
1363        }
1364        if let Some((metadata_key, _)) = next_metadata.as_ref() {
1365            let metadata_hash: Hash = (*metadata_key).try_into().map_err(|_| {
1366                StoreError::Other("terminal source has an invalid metadata hash key".into())
1367            })?;
1368            return Err(StoreError::Other(format!(
1369                "terminal source metadata {} has no raw blob row",
1370                to_hex(&metadata_hash)
1371            )));
1372        }
1373        drop(metadata);
1374        rtxn.commit().map_err(map_heed_error)?;
1375
1376        Ok(LmdbSourceKeysetAudit {
1377            blob_entries,
1378            metadata_entries,
1379            blob_only_entries,
1380            legacy_blob_only,
1381            inline_entries,
1382            loose_external_entries,
1383            packed_external_entries,
1384            sha256: keyset_digest.finalize().into(),
1385            catalog_location_sha256: location_digest.finalize().into(),
1386        })
1387    }
1388
1389    pub fn map_size_bytes(&self) -> usize {
1390        self.store.map_size_bytes()
1391    }
1392}
1393
1394fn read_external_blob_files(
1395    plans: &[SourceBlobReadPlan],
1396    concurrency: usize,
1397) -> Result<Vec<(usize, Vec<u8>)>, StoreError> {
1398    if plans.is_empty() {
1399        return Ok(Vec::new());
1400    }
1401    let worker_count = concurrency.min(plans.len());
1402    let next = AtomicUsize::new(0);
1403    let cancelled = AtomicBool::new(false);
1404    let (sender, receiver) = mpsc::channel();
1405    let mut results = (0..plans.len())
1406        .map(|_| None)
1407        .collect::<Vec<Option<Result<Vec<u8>, StoreError>>>>();
1408
1409    thread::scope(|scope| -> Result<(), StoreError> {
1410        for _ in 0..worker_count {
1411            let sender = sender.clone();
1412            let next = &next;
1413            let cancelled = &cancelled;
1414            scope.spawn(move || loop {
1415                if cancelled.load(Ordering::Acquire) {
1416                    break;
1417                }
1418                let plan_index = next.fetch_add(1, Ordering::Relaxed);
1419                let Some(plan) = plans.get(plan_index) else {
1420                    break;
1421                };
1422                if cancelled.load(Ordering::Acquire) {
1423                    break;
1424                }
1425                let result = read_external_blob_file(plan);
1426                let failed = result.is_err();
1427                if failed {
1428                    cancelled.store(true, Ordering::Release);
1429                }
1430                if sender.send((plan_index, result)).is_err() {
1431                    break;
1432                }
1433                if failed {
1434                    break;
1435                }
1436            });
1437        }
1438        drop(sender);
1439        for (plan_index, result) in receiver {
1440            results[plan_index] = Some(result);
1441        }
1442        Ok(())
1443    })?;
1444
1445    for result in &mut results {
1446        if result.as_ref().is_some_and(Result::is_err) {
1447            return Err(result
1448                .take()
1449                .expect("checked external source read result")
1450                .expect_err("checked external source read error"));
1451        }
1452    }
1453    results
1454        .into_iter()
1455        .enumerate()
1456        .map(|(plan_index, result)| {
1457            let data = result
1458                .ok_or_else(|| StoreError::Other("external source read omitted a file".into()))??;
1459            Ok((plans[plan_index].index, data))
1460        })
1461        .collect()
1462}
1463
1464fn read_external_blob_file(plan: &SourceBlobReadPlan) -> Result<Vec<u8>, StoreError> {
1465    let SourceBlobRead::ExternalBlob {
1466        file,
1467        expected_size,
1468    } = &plan.read
1469    else {
1470        return Err(StoreError::Other(
1471            "pack range entered the external-file read lane".into(),
1472        ));
1473    };
1474    let mut file = file.open()?;
1475    let file_size = file.metadata()?.len();
1476    if file_size != *expected_size {
1477        return Err(StoreError::Other(format!(
1478            "source size changed for {:?}: metadata says {expected_size}, file has {}",
1479            plan.hash, file_size
1480        )));
1481    }
1482    let expected_len = usize::try_from(*expected_size)
1483        .map_err(|_| StoreError::Other("external source blob is too large to read".into()))?;
1484    let read_limit = expected_size
1485        .checked_add(1)
1486        .ok_or_else(|| StoreError::Other("external source blob is too large to read".into()))?;
1487    let mut data = Vec::with_capacity(expected_len.min(1024 * 1024));
1488    Read::by_ref(&mut file)
1489        .take(read_limit)
1490        .read_to_end(&mut data)
1491        .map_err(StoreError::Io)?;
1492    if data.len() != expected_len {
1493        return Err(StoreError::Other(format!(
1494            "source size changed for {:?}: metadata says {expected_size}, read {}",
1495            plan.hash,
1496            data.len()
1497        )));
1498    }
1499    Ok(data)
1500}
1501
1502impl LmdbBlobStore {
1503    /// Open or create an LMDB blob store at the given path.
1504    pub fn new<P: AsRef<Path>>(path: P) -> Result<Self, StoreError> {
1505        Self::with_map_size(path, DEFAULT_MAP_SIZE)
1506    }
1507
1508    /// Open or create with a maximum logical storage size.
1509    pub fn with_max_bytes<P: AsRef<Path>>(path: P, max_bytes: u64) -> Result<Self, StoreError> {
1510        Self::with_max_bytes_and_external_blob_options(
1511            path,
1512            max_bytes,
1513            ExternalBlobOptions::from_env,
1514        )
1515    }
1516
1517    /// Open or create with a maximum logical storage size and custom external-blob options.
1518    pub fn with_max_bytes_and_external_blob_options<P, F>(
1519        path: P,
1520        max_bytes: u64,
1521        external_blobs: F,
1522    ) -> Result<Self, StoreError>
1523    where
1524        P: AsRef<Path>,
1525        F: FnOnce(&Path) -> Option<ExternalBlobOptions>,
1526    {
1527        let path_ref = path.as_ref();
1528        let existing_map_size = std::fs::metadata(path_ref.join("data.mdb"))
1529            .map(|metadata| metadata.len())
1530            .unwrap_or(0);
1531        let existing_headroom = if existing_map_size == 0 {
1532            0
1533        } else {
1534            existing_map_size
1535                .saturating_div(10)
1536                .max(REOPEN_HEADROOM_BYTES)
1537        };
1538        let requested_map_size = usize::try_from(Self::align_map_size_bytes(
1539            existing_map_size
1540                .saturating_add(existing_headroom)
1541                .max(max_bytes),
1542        ))
1543        .unwrap_or(usize::MAX)
1544        .max(DEFAULT_MAP_SIZE);
1545        let external_blobs = external_blobs(path_ref);
1546        let store = Self::with_map_size_and_external_blob_options(
1547            path_ref,
1548            requested_map_size,
1549            external_blobs,
1550        )?;
1551        store.max_bytes.store(max_bytes, Ordering::Relaxed);
1552        let current = store.total_bytes()?;
1553        if max_bytes > 0 && current > max_bytes {
1554            let target = max_bytes.saturating_mul(9) / 10;
1555            store.evict_to_target(current, target)?;
1556        }
1557        Ok(store)
1558    }
1559
1560    /// Open or create with the default map size and explicit external-blob options.
1561    pub fn with_external_blob_options<P: AsRef<Path>>(
1562        path: P,
1563        external_blobs: Option<ExternalBlobOptions>,
1564    ) -> Result<Self, StoreError> {
1565        Self::with_map_size_and_external_blob_options(path, DEFAULT_MAP_SIZE, external_blobs)
1566    }
1567
1568    fn align_map_size_bytes(bytes: u64) -> u64 {
1569        if bytes == 0 {
1570            return 0;
1571        }
1572        let page_size = (page_size::get() as u64).max(4096);
1573        let remainder = bytes % page_size;
1574        if remainder == 0 {
1575            bytes
1576        } else {
1577            bytes.saturating_add(page_size - remainder)
1578        }
1579    }
1580
1581    /// Open or create with custom map size.
1582    pub fn with_map_size<P: AsRef<Path>>(path: P, map_size: usize) -> Result<Self, StoreError> {
1583        Self::with_map_size_and_settings(
1584            path,
1585            map_size,
1586            env_flags_from_env(),
1587            ExternalBlobConfig::from_env,
1588        )
1589    }
1590
1591    /// Open or create with custom map size and explicit external-blob options.
1592    pub fn with_map_size_and_external_blob_options<P: AsRef<Path>>(
1593        path: P,
1594        map_size: usize,
1595        external_blobs: Option<ExternalBlobOptions>,
1596    ) -> Result<Self, StoreError> {
1597        Self::with_map_size_and_settings(path, map_size, env_flags_from_env(), |_| {
1598            external_blobs.map(Into::into)
1599        })
1600    }
1601
1602    /// Open or create with one exact, manifest-owned map size.
1603    ///
1604    /// Dynamic pools persist this value so every same-host process opens a member
1605    /// with identical LMDB options. Unlike the general constructor, reopening does
1606    /// not opportunistically add headroom; resizing is an explicit pool operation.
1607    pub fn with_exact_map_size_and_external_blob_options<P: AsRef<Path>>(
1608        path: P,
1609        map_size: usize,
1610        external_blobs: Option<ExternalBlobOptions>,
1611    ) -> Result<Self, StoreError> {
1612        Self::with_map_size_and_settings_mode(
1613            path,
1614            map_size,
1615            env_flags_from_env(),
1616            |_| external_blobs.map(Into::into),
1617            false,
1618            None,
1619            None,
1620        )
1621    }
1622
1623    pub(crate) fn with_exact_map_size_external_options_and_pinned_identity<P: AsRef<Path>>(
1624        path: P,
1625        map_size: usize,
1626        external_blobs: Option<ExternalBlobOptions>,
1627        identity: PinnedLmdbIdentity,
1628    ) -> Result<Self, StoreError> {
1629        Self::with_map_size_and_settings_mode(
1630            path,
1631            map_size,
1632            env_flags_from_env(),
1633            |_| external_blobs.map(Into::into),
1634            false,
1635            Some(identity),
1636            None,
1637        )
1638    }
1639
1640    /// Open an archive member with admission on its actual LMDB/external writes.
1641    pub fn with_physical_space_guard<P: AsRef<Path>>(
1642        path: P,
1643        map_size: usize,
1644        external: Option<ExternalBlobOptions>,
1645        pinned_identity: Option<PinnedLmdbIdentity>,
1646        guard: PhysicalSpaceGuard,
1647    ) -> Result<Self, StoreError> {
1648        Self::with_map_size_and_settings_mode(
1649            path,
1650            map_size,
1651            env_flags_from_env(),
1652            |_| external.map(Into::into),
1653            false,
1654            pinned_identity,
1655            Some(guard),
1656        )
1657    }
1658
1659    fn with_map_size_and_settings<P, F>(
1660        path: P,
1661        map_size: usize,
1662        flags: EnvFlags,
1663        external_blobs: F,
1664    ) -> Result<Self, StoreError>
1665    where
1666        P: AsRef<Path>,
1667        F: FnOnce(&Path) -> Option<ExternalBlobConfig>,
1668    {
1669        Self::with_map_size_and_settings_mode(
1670            path,
1671            map_size,
1672            flags,
1673            external_blobs,
1674            true,
1675            None,
1676            None,
1677        )
1678    }
1679
1680    fn with_map_size_and_settings_mode<P, F>(
1681        path: P,
1682        map_size: usize,
1683        flags: EnvFlags,
1684        external_blobs: F,
1685        add_reopen_headroom: bool,
1686        pinned_identity: Option<PinnedLmdbIdentity>,
1687        physical_space: Option<PhysicalSpaceGuard>,
1688    ) -> Result<Self, StoreError>
1689    where
1690        P: AsRef<Path>,
1691        F: FnOnce(&Path) -> Option<ExternalBlobConfig>,
1692    {
1693        let path_ref = path.as_ref();
1694        let controlled = pinned_identity.is_some();
1695        if !controlled {
1696            if let Some(guard) = &physical_space {
1697                guard.create_dir_all(path_ref)?;
1698            } else {
1699                std::fs::create_dir_all(path_ref).map_err(StoreError::Io)?;
1700            }
1701        }
1702        let existing_map_size = match pinned_identity {
1703            Some(identity) => pinned_lmdb_data_len(path_ref, identity)?,
1704            None => std::fs::metadata(path_ref.join("data.mdb"))
1705                .map(|metadata| metadata.len())
1706                .unwrap_or(0),
1707        };
1708        let existing_headroom = if controlled || existing_map_size == 0 || !add_reopen_headroom {
1709            0
1710        } else {
1711            existing_map_size
1712                .saturating_div(10)
1713                .max(REOPEN_HEADROOM_BYTES)
1714        };
1715        let requested_map_size = u64::try_from(map_size).unwrap_or(u64::MAX);
1716        let map_size = usize::try_from(Self::align_map_size_bytes(
1717            requested_map_size.max(existing_map_size.saturating_add(existing_headroom)),
1718        ))
1719        .unwrap_or(usize::MAX);
1720
1721        let mut env_options = EnvOpenOptions::new();
1722        if let Some(guard) = &physical_space {
1723            guard.install(&mut env_options)?;
1724        }
1725        if !controlled {
1726            env_options.map_size(map_size);
1727        }
1728        env_options
1729            .max_dbs(DATABASE_COUNT)
1730            .max_readers(DEFAULT_MAX_READERS);
1731        unsafe {
1732            env_options.flags(flags);
1733        }
1734        let env = unsafe {
1735            match pinned_identity {
1736                Some(identity) => ManagedEnv::open_pinned(&env_options, path_ref, identity),
1737                None => ManagedEnv::open(&env_options, path_ref),
1738            }
1739            .map_err(map_heed_error)?
1740        };
1741        if controlled && env.info().map_size < map_size {
1742            return Err(StoreError::Other(format!(
1743                "controlled LMDB member map is {} bytes, below its manifest-owned {} bytes; pre-size it before exact migration",
1744                env.info().map_size,
1745                map_size
1746            )));
1747        }
1748        if !controlled {
1749            let _ = env.clear_stale_readers();
1750        }
1751        if !controlled && env.info().map_size < map_size {
1752            unsafe { env.resize(map_size) }.map_err(map_heed_error)?;
1753        }
1754
1755        let (blobs, metadata, eviction_order, pins, stats, next_order) = if controlled {
1756            let rtxn = env.read_txn().map_err(map_heed_error)?;
1757            let open_bytes = |name| -> Result<Database<Bytes, Bytes>, StoreError> {
1758                env.open_database(&rtxn, Some(name))
1759                    .map_err(map_heed_error)?
1760                    .ok_or_else(|| {
1761                        StoreError::Other(format!(
1762                            "controlled LMDB member is missing required database {name}"
1763                        ))
1764                    })
1765            };
1766            let open_unit = |name| -> Result<Database<Bytes, Unit>, StoreError> {
1767                env.open_database(&rtxn, Some(name))
1768                    .map_err(map_heed_error)?
1769                    .ok_or_else(|| {
1770                        StoreError::Other(format!(
1771                            "controlled LMDB member is missing required database {name}"
1772                        ))
1773                    })
1774            };
1775            let blobs = open_bytes("blobs")?;
1776            let metadata = open_bytes("metadata")?;
1777            let eviction_order = open_unit("eviction_order")?;
1778            let pins = open_bytes("pins")?;
1779            let stats = open_bytes("stats")?;
1780            let next_order = Self::validate_controlled_member_state(
1781                &rtxn,
1782                blobs,
1783                metadata,
1784                eviction_order,
1785                pins,
1786                stats,
1787            )?;
1788            rtxn.commit().map_err(map_heed_error)?;
1789            (blobs, metadata, eviction_order, pins, stats, next_order)
1790        } else {
1791            let mut wtxn = env.write_txn().map_err(map_heed_error)?;
1792            let blobs = env
1793                .create_database(&mut wtxn, Some("blobs"))
1794                .map_err(map_heed_error)?;
1795            let metadata = env
1796                .create_database(&mut wtxn, Some("metadata"))
1797                .map_err(map_heed_error)?;
1798            let eviction_order = env
1799                .create_database(&mut wtxn, Some("eviction_order"))
1800                .map_err(map_heed_error)?;
1801            let pins = env
1802                .create_database(&mut wtxn, Some("pins"))
1803                .map_err(map_heed_error)?;
1804            let stats = env
1805                .create_database(&mut wtxn, Some("stats"))
1806                .map_err(map_heed_error)?;
1807            wtxn.commit().map_err(map_heed_error)?;
1808            let totals = Self::load_or_rebuild_totals(&env, metadata, pins, stats)?;
1809            let next_order = Self::load_or_initialize_next_order(&env, stats, totals.count > 0)?;
1810            (blobs, metadata, eviction_order, pins, stats, next_order)
1811        };
1812
1813        let external_blobs = external_blobs(path_ref)
1814            .map(ExternalBlobConfig::prepare_runtime)
1815            .transpose()?;
1816        Ok(Self {
1817            env,
1818            blobs,
1819            metadata,
1820            eviction_order,
1821            pins,
1822            stats,
1823            max_bytes: AtomicU64::new(0),
1824            next_order: AtomicU64::new(next_order),
1825            external_blobs,
1826            physical_space,
1827        })
1828    }
1829
1830    fn validate_controlled_member_state(
1831        txn: &heed::RoTxn<'_>,
1832        blobs: Database<Bytes, Bytes>,
1833        metadata: Database<Bytes, Bytes>,
1834        eviction_order: Database<Bytes, Unit>,
1835        pins: Database<Bytes, Bytes>,
1836        stats: Database<Bytes, Bytes>,
1837    ) -> Result<u64, StoreError> {
1838        let totals = Self::read_store_totals(stats, txn)?.ok_or_else(|| {
1839            StoreError::Other("controlled LMDB member is missing persisted aggregate totals".into())
1840        })?;
1841        let next_order = Self::read_next_order(stats, txn)?.ok_or_else(|| {
1842            StoreError::Other(
1843                "controlled LMDB member is missing persisted next-order authority".into(),
1844            )
1845        })?;
1846        let blob_entries = blobs.len(txn).map_err(map_heed_error)?;
1847        let metadata_entries = metadata.len(txn).map_err(map_heed_error)?;
1848        let eviction_entries = eviction_order.len(txn).map_err(map_heed_error)?;
1849        let pin_entries = pins.len(txn).map_err(map_heed_error)?;
1850        if blob_entries != totals.count
1851            || metadata_entries != totals.count
1852            || eviction_entries != totals.count
1853            || pin_entries != totals.pinned_count
1854            || totals.pinned_count > totals.count
1855            || totals.pinned_bytes > totals.total_bytes
1856            || next_order < totals.count
1857        {
1858            return Err(StoreError::Other(format!(
1859                "controlled LMDB member secondary/stat counts are stale: blobs {blob_entries}, metadata {metadata_entries}, eviction {eviction_entries}, pins {pin_entries}, totals {totals:?}, next order {next_order}"
1860            )));
1861        }
1862        Ok(next_order)
1863    }
1864
1865    fn validate_exact_member_state(&self) -> Result<LmdbTerminalMemberKeysetAudit, StoreError> {
1866        let rtxn = self.env.read_txn().map_err(map_heed_error)?;
1867        let persisted = Self::read_store_totals(self.stats, &rtxn)?.ok_or_else(|| {
1868            StoreError::Other("LMDB member is missing persisted aggregate totals".into())
1869        })?;
1870        let next_order = Self::read_next_order(self.stats, &rtxn)?.ok_or_else(|| {
1871            StoreError::Other("LMDB member is missing persisted next-order authority".into())
1872        })?;
1873        let blob_entries = self.blobs.len(&rtxn).map_err(map_heed_error)?;
1874        let metadata_entries = self.metadata.len(&rtxn).map_err(map_heed_error)?;
1875        let eviction_entries = self.eviction_order.len(&rtxn).map_err(map_heed_error)?;
1876        let pin_entries = self.pins.len(&rtxn).map_err(map_heed_error)?;
1877        if blob_entries != metadata_entries {
1878            return Err(StoreError::Other(format!(
1879                "terminal Pool member blobs/metadata key sets differ: {blob_entries} blob rows, {metadata_entries} metadata rows"
1880            )));
1881        }
1882        if eviction_entries != metadata_entries {
1883            return Err(StoreError::Other(format!(
1884                "terminal Pool member eviction/metadata key sets differ: {eviction_entries} eviction rows, {metadata_entries} metadata rows"
1885            )));
1886        }
1887
1888        let mut computed = StoreTotals::default();
1889        let mut digest = Sha256::new();
1890        digest.update(b"hashtree-lmdb-terminal-member-state/v2\0");
1891        digest.update(blob_entries.to_be_bytes());
1892        digest.update(metadata_entries.to_be_bytes());
1893        digest.update(eviction_entries.to_be_bytes());
1894        digest.update(pin_entries.to_be_bytes());
1895        digest.update(next_order.to_be_bytes());
1896        let mut ownership_digest = Sha256::new();
1897        ownership_digest.update(b"hashtree-pool-member-ownership/v1\0");
1898        let mut external_pack_lengths = HashMap::<String, u64>::new();
1899
1900        for item in self.metadata.iter(&rtxn).map_err(map_heed_error)? {
1901            let (metadata_key, metadata_bytes) = item.map_err(map_heed_error)?;
1902            let hash: Hash = metadata_key.try_into().map_err(|_| {
1903                StoreError::Other("terminal Pool member has an invalid metadata hash key".into())
1904            })?;
1905            let meta = Self::decode_blob_meta(metadata_bytes)?;
1906            let blob_value = self
1907                .blobs
1908                .get(&rtxn, &hash)
1909                .map_err(map_heed_error)?
1910                .ok_or_else(|| {
1911                    StoreError::Other(format!(
1912                        "terminal Pool member blobs/metadata key sets differ: metadata {} has no blob row",
1913                        to_hex(&hash)
1914                    ))
1915                })?;
1916            let actual_size = if self.is_external_blob_ref(&hash, blob_value) {
1917                let config = self.external_blobs.as_ref().ok_or_else(|| {
1918                    StoreError::Other(
1919                        "terminal Pool member has an external blob marker but no external directory"
1920                            .into(),
1921                    )
1922                })?;
1923                let size = config
1924                    .open_relative(&ExternalBlobConfig::relative_blob_path(&hash))?
1925                    .metadata()
1926                    .map_err(StoreError::Io)?
1927                    .len();
1928                digest.update(b"L");
1929                digest.update((blob_value.len() as u64).to_be_bytes());
1930                digest.update(blob_value);
1931                size
1932            } else if let Some(pack_ref) = Self::decode_external_pack_ref(blob_value)? {
1933                let config = self.external_blobs.as_ref().ok_or_else(|| {
1934                    StoreError::Other(
1935                        "terminal Pool member has an external pack marker but no external directory"
1936                            .into(),
1937                    )
1938                })?;
1939                let end = pack_ref.offset.checked_add(pack_ref.len).ok_or_else(|| {
1940                    StoreError::Other("terminal Pool member external pack range overflow".into())
1941                })?;
1942                let pack_size = match external_pack_lengths.get(&pack_ref.name) {
1943                    Some(size) => *size,
1944                    None => {
1945                        let size = config
1946                            .open_relative(&ExternalBlobConfig::relative_pack_path(&pack_ref.name))?
1947                            .metadata()
1948                            .map_err(StoreError::Io)?
1949                            .len();
1950                        external_pack_lengths.insert(pack_ref.name.clone(), size);
1951                        size
1952                    }
1953                };
1954                if end > pack_size {
1955                    return Err(StoreError::Other(format!(
1956                        "terminal Pool member external pack range {}..{end} exceeds physical pack size {pack_size}",
1957                        pack_ref.offset
1958                    )));
1959                }
1960                digest.update(b"P");
1961                digest.update((blob_value.len() as u64).to_be_bytes());
1962                digest.update(blob_value);
1963                pack_ref.len
1964            } else {
1965                digest.update(b"I");
1966                digest.update((blob_value.len() as u64).to_be_bytes());
1967                blob_value.len() as u64
1968            };
1969            if meta.size != actual_size {
1970                return Err(StoreError::Other(format!(
1971                    "terminal Pool member metadata size {} for {} differs from exact body size {actual_size}",
1972                    meta.size,
1973                    to_hex(&hash)
1974                )));
1975            }
1976            let order_key = Self::encode_order_key(meta.order, &hash);
1977            if self
1978                .eviction_order
1979                .get(&rtxn, &order_key)
1980                .map_err(map_heed_error)?
1981                .is_none()
1982            {
1983                return Err(StoreError::Other(format!(
1984                    "terminal Pool member eviction/metadata key sets differ: metadata {} has no exact eviction row",
1985                    to_hex(&hash)
1986                )));
1987            }
1988            if meta.order >= next_order {
1989                return Err(StoreError::Other(format!(
1990                    "terminal Pool member metadata order {} for {} is not below next order {next_order}",
1991                    meta.order,
1992                    to_hex(&hash)
1993                )));
1994            }
1995            computed.count = computed
1996                .count
1997                .checked_add(1)
1998                .ok_or_else(|| StoreError::Other("LMDB member blob count overflow".into()))?;
1999            computed.total_bytes = computed
2000                .total_bytes
2001                .checked_add(meta.size)
2002                .ok_or_else(|| StoreError::Other("LMDB member byte total overflow".into()))?;
2003            ownership_digest.update(hash);
2004            ownership_digest.update(meta.size.to_be_bytes());
2005            digest.update(hash);
2006            digest.update((metadata_bytes.len() as u64).to_be_bytes());
2007            digest.update(metadata_bytes);
2008        }
2009
2010        for item in self.pins.iter(&rtxn).map_err(map_heed_error)? {
2011            let (pin_key, pin_bytes) = item.map_err(map_heed_error)?;
2012            let hash: Hash = pin_key.try_into().map_err(|_| {
2013                StoreError::Other("terminal Pool member has an invalid pin hash key".into())
2014            })?;
2015            let pin_count = Self::decode_pin_count(pin_bytes)?;
2016            if pin_count == 0 {
2017                return Err(StoreError::Other(format!(
2018                    "terminal Pool member has a zero-count pin row for {}",
2019                    to_hex(&hash)
2020                )));
2021            }
2022            let metadata_bytes = self
2023                .metadata
2024                .get(&rtxn, &hash)
2025                .map_err(map_heed_error)?
2026                .ok_or_else(|| {
2027                    StoreError::Other(format!(
2028                        "terminal Pool member pin {} has no metadata row",
2029                        to_hex(&hash)
2030                    ))
2031                })?;
2032            let meta = Self::decode_blob_meta(metadata_bytes)?;
2033            computed.pinned_count = computed
2034                .pinned_count
2035                .checked_add(1)
2036                .ok_or_else(|| StoreError::Other("LMDB member pinned count overflow".into()))?;
2037            computed.pinned_bytes =
2038                computed
2039                    .pinned_bytes
2040                    .checked_add(meta.size)
2041                    .ok_or_else(|| {
2042                        StoreError::Other("LMDB member pinned byte total overflow".into())
2043                    })?;
2044            digest.update(hash);
2045            digest.update(pin_count.to_be_bytes());
2046        }
2047
2048        if computed != persisted {
2049            return Err(StoreError::Other(format!(
2050                "LMDB member persisted aggregate totals are stale: persisted {persisted:?}, exact {computed:?}"
2051            )));
2052        }
2053        digest.update(Self::encode_store_totals(computed));
2054        rtxn.commit().map_err(map_heed_error)?;
2055
2056        Ok(LmdbTerminalMemberKeysetAudit {
2057            blob_entries,
2058            metadata_entries,
2059            total_bytes: computed.total_bytes,
2060            pinned_count: computed.pinned_count,
2061            pinned_bytes: computed.pinned_bytes,
2062            ownership_sha256: ownership_digest.finalize().into(),
2063            sha256: digest.finalize().into(),
2064        })
2065    }
2066
2067    fn load_or_rebuild_totals(
2068        env: &heed::Env,
2069        metadata: Database<Bytes, Bytes>,
2070        pins: Database<Bytes, Bytes>,
2071        stats: Database<Bytes, Bytes>,
2072    ) -> Result<StoreTotals, StoreError> {
2073        {
2074            let rtxn = env.read_txn().map_err(map_heed_error)?;
2075            if let Some(totals) = Self::read_store_totals(stats, &rtxn)? {
2076                return Ok(totals);
2077            }
2078        }
2079
2080        let mut totals = StoreTotals::default();
2081        {
2082            let rtxn = env.read_txn().map_err(map_heed_error)?;
2083            for item in metadata.iter(&rtxn).map_err(map_heed_error)? {
2084                let (_, bytes) = item.map_err(map_heed_error)?;
2085                let meta = Self::decode_blob_meta(bytes)?;
2086                totals.count = totals.count.saturating_add(1);
2087                totals.total_bytes = totals.total_bytes.saturating_add(meta.size);
2088            }
2089            for item in pins.iter(&rtxn).map_err(map_heed_error)? {
2090                let (hash, pin_bytes) = item.map_err(map_heed_error)?;
2091                let count = Self::decode_pin_count(pin_bytes)?;
2092                if count == 0 {
2093                    continue;
2094                }
2095                let Some(meta_bytes) = metadata.get(&rtxn, hash).map_err(map_heed_error)? else {
2096                    continue;
2097                };
2098                let meta = Self::decode_blob_meta(meta_bytes)?;
2099                totals.pinned_count = totals.pinned_count.saturating_add(1);
2100                totals.pinned_bytes = totals.pinned_bytes.saturating_add(meta.size);
2101            }
2102        }
2103
2104        let mut wtxn = env.write_txn().map_err(map_heed_error)?;
2105        Self::write_store_totals(stats, &mut wtxn, totals).map_err(map_heed_error)?;
2106        wtxn.commit().map_err(map_heed_error)?;
2107        Ok(totals)
2108    }
2109
2110    fn load_or_initialize_next_order(
2111        env: &heed::Env,
2112        stats: Database<Bytes, Bytes>,
2113        has_existing_blobs: bool,
2114    ) -> Result<u64, StoreError> {
2115        {
2116            let rtxn = env.read_txn().map_err(map_heed_error)?;
2117            if let Some(next_order) = Self::read_next_order(stats, &rtxn)? {
2118                return Ok(next_order);
2119            }
2120        }
2121
2122        let next_order = if has_existing_blobs {
2123            Self::legacy_next_order_floor()
2124        } else {
2125            0
2126        };
2127        let mut wtxn = env.write_txn().map_err(map_heed_error)?;
2128        let next_order = Self::read_next_order_lossy(stats, &wtxn)
2129            .map_err(map_heed_error)?
2130            .unwrap_or(next_order);
2131        Self::write_next_order(stats, &mut wtxn, next_order).map_err(map_heed_error)?;
2132        wtxn.commit().map_err(map_heed_error)?;
2133        Ok(next_order)
2134    }
2135
2136    fn legacy_next_order_floor() -> u64 {
2137        let micros = SystemTime::now()
2138            .duration_since(UNIX_EPOCH)
2139            .ok()
2140            .and_then(|duration| u64::try_from(duration.as_micros()).ok())
2141            .unwrap_or(NEXT_ORDER_MIGRATION_FLOOR);
2142        micros.max(NEXT_ORDER_MIGRATION_FLOOR)
2143    }
2144
2145    fn read_store_totals(
2146        stats: Database<Bytes, Bytes>,
2147        txn: &heed::RoTxn,
2148    ) -> Result<Option<StoreTotals>, StoreError> {
2149        stats
2150            .get(txn, STORE_TOTALS_KEY)
2151            .map_err(map_heed_error)?
2152            .map(Self::decode_store_totals)
2153            .transpose()
2154    }
2155
2156    fn read_store_totals_lossy(
2157        stats: Database<Bytes, Bytes>,
2158        txn: &heed::RoTxn,
2159    ) -> std::result::Result<StoreTotals, HeedError> {
2160        Ok(stats
2161            .get(txn, STORE_TOTALS_KEY)?
2162            .and_then(Self::decode_store_totals_lossy)
2163            .unwrap_or_default())
2164    }
2165
2166    fn write_store_totals(
2167        stats: Database<Bytes, Bytes>,
2168        txn: &mut heed::RwTxn,
2169        totals: StoreTotals,
2170    ) -> std::result::Result<(), HeedError> {
2171        let encoded = Self::encode_store_totals(totals);
2172        stats.put(txn, STORE_TOTALS_KEY, &encoded)
2173    }
2174
2175    fn read_next_order(
2176        stats: Database<Bytes, Bytes>,
2177        txn: &heed::RoTxn,
2178    ) -> Result<Option<u64>, StoreError> {
2179        stats
2180            .get(txn, NEXT_ORDER_KEY)
2181            .map_err(map_heed_error)?
2182            .map(Self::decode_next_order)
2183            .transpose()
2184    }
2185
2186    fn read_next_order_lossy(
2187        stats: Database<Bytes, Bytes>,
2188        txn: &heed::RoTxn,
2189    ) -> std::result::Result<Option<u64>, HeedError> {
2190        Ok(stats
2191            .get(txn, NEXT_ORDER_KEY)?
2192            .and_then(Self::decode_next_order_lossy))
2193    }
2194
2195    fn write_next_order(
2196        stats: Database<Bytes, Bytes>,
2197        txn: &mut heed::RwTxn,
2198        next_order: u64,
2199    ) -> std::result::Result<(), HeedError> {
2200        let encoded = Self::encode_next_order(next_order);
2201        stats.put(txn, NEXT_ORDER_KEY, &encoded)
2202    }
2203
2204    fn allocate_order_range_in_txn(
2205        &self,
2206        txn: &mut heed::RwTxn,
2207        count: usize,
2208    ) -> std::result::Result<u64, HeedError> {
2209        let start = Self::read_next_order_lossy(self.stats, txn)?
2210            .unwrap_or_else(|| self.next_order.load(Ordering::Relaxed));
2211        let end = start.saturating_add(count as u64);
2212        Self::write_next_order(self.stats, txn, end)?;
2213        self.next_order.store(end, Ordering::Relaxed);
2214        Ok(start)
2215    }
2216
2217    fn increment_totals_in_txn(
2218        &self,
2219        txn: &mut heed::RwTxn,
2220        count: u64,
2221        total_bytes: u64,
2222        pinned_count: u64,
2223        pinned_bytes: u64,
2224    ) -> std::result::Result<(), HeedError> {
2225        let mut totals = Self::read_store_totals_lossy(self.stats, txn)?;
2226        totals.count = totals.count.saturating_add(count);
2227        totals.total_bytes = totals.total_bytes.saturating_add(total_bytes);
2228        totals.pinned_count = totals.pinned_count.saturating_add(pinned_count);
2229        totals.pinned_bytes = totals.pinned_bytes.saturating_add(pinned_bytes);
2230        Self::write_store_totals(self.stats, txn, totals)
2231    }
2232
2233    fn decrement_totals_in_txn(
2234        &self,
2235        txn: &mut heed::RwTxn,
2236        count: u64,
2237        total_bytes: u64,
2238        pinned_count: u64,
2239        pinned_bytes: u64,
2240    ) -> std::result::Result<(), HeedError> {
2241        let mut totals = Self::read_store_totals_lossy(self.stats, txn)?;
2242        totals.count = totals.count.saturating_sub(count);
2243        totals.total_bytes = totals.total_bytes.saturating_sub(total_bytes);
2244        totals.pinned_count = totals.pinned_count.saturating_sub(pinned_count);
2245        totals.pinned_bytes = totals.pinned_bytes.saturating_sub(pinned_bytes);
2246        Self::write_store_totals(self.stats, txn, totals)
2247    }
2248
2249    /// Check if a hash exists (sync version for internal use).
2250    pub fn exists(&self, hash: &Hash) -> Result<bool, StoreError> {
2251        let rtxn = self
2252            .env
2253            .read_txn()
2254            .map_err(|e| StoreError::Other(e.to_string()))?;
2255
2256        if self
2257            .metadata
2258            .get(&rtxn, hash)
2259            .map_err(|e| StoreError::Other(e.to_string()))?
2260            .is_some()
2261        {
2262            return Ok(true);
2263        }
2264
2265        Ok(self
2266            .blobs
2267            .get(&rtxn, hash)
2268            .map_err(|e| StoreError::Other(e.to_string()))?
2269            .is_some())
2270    }
2271
2272    fn mark_existing_hashes_in_db(
2273        db: Database<Bytes, Bytes>,
2274        rtxn: &heed::RoTxn,
2275        sorted_hashes: &[Hash],
2276        existing: &mut [bool],
2277    ) -> Result<(), StoreError> {
2278        debug_assert_eq!(sorted_hashes.len(), existing.len());
2279        debug_assert!(sorted_hashes.windows(2).all(|pair| pair[0] <= pair[1]));
2280
2281        if sorted_hashes.is_empty() {
2282            return Ok(());
2283        }
2284
2285        for (index, hash) in sorted_hashes.iter().enumerate() {
2286            if existing[index] {
2287                continue;
2288            }
2289            if db.get(rtxn, hash).map_err(map_heed_error)?.is_some() {
2290                existing[index] = true;
2291            }
2292        }
2293
2294        Ok(())
2295    }
2296
2297    /// Mark which sorted hashes already exist using bounded LMDB range scans.
2298    pub fn existing_hashes_in_sorted_candidates(
2299        &self,
2300        sorted_hashes: &[Hash],
2301    ) -> Result<Vec<bool>, StoreError> {
2302        let mut existing = vec![false; sorted_hashes.len()];
2303        if sorted_hashes.is_empty() {
2304            return Ok(existing);
2305        }
2306
2307        let rtxn = self.env.read_txn().map_err(map_heed_error)?;
2308        Self::mark_existing_hashes_in_db(self.metadata, &rtxn, sorted_hashes, &mut existing)?;
2309        if existing.iter().all(|exists| *exists) {
2310            return Ok(existing);
2311        }
2312        Self::mark_existing_hashes_in_db(self.blobs, &rtxn, sorted_hashes, &mut existing)?;
2313        Ok(existing)
2314    }
2315
2316    /// Mark exact raw `blobs` keys present without consulting metadata or
2317    /// requesting LMDB values.
2318    pub fn existing_blob_keys_in_sorted_candidates(
2319        &self,
2320        sorted_hashes: &[Hash],
2321    ) -> Result<Vec<bool>, StoreError> {
2322        if sorted_hashes.windows(2).any(|pair| pair[0] >= pair[1]) {
2323            return Err(StoreError::Other(
2324                "raw blob key candidates must be unique and strictly sorted".into(),
2325            ));
2326        }
2327        if sorted_hashes.is_empty() {
2328            return Ok(Vec::new());
2329        }
2330        let rtxn = self.env.read_txn().map_err(map_heed_error)?;
2331        let keys = sorted_hashes
2332            .iter()
2333            .map(|hash| hash.as_slice())
2334            .collect::<Vec<_>>();
2335        self.blobs
2336            .contains_raw_keys_without_data(&rtxn, &keys)
2337            .map_err(map_heed_error)
2338    }
2339
2340    /// Prove exact raw `blobs` keys using one bounded, forward-only cursor.
2341    ///
2342    /// Keys already covered by an earlier audit may appear between candidates.
2343    /// The scan must still reach `inclusive_end` within `max_scanned_keys`
2344    /// distinct raw keys. LMDB values are never requested.
2345    pub fn blob_keys_match_bounded_raw_range(
2346        &self,
2347        sorted_hashes: &[Hash],
2348        inclusive_end: Hash,
2349        max_scanned_keys: usize,
2350    ) -> Result<bool, StoreError> {
2351        if sorted_hashes.windows(2).any(|pair| pair[0] >= pair[1]) {
2352            return Err(StoreError::Other(
2353                "bounded raw blob key candidates must be unique and strictly sorted".into(),
2354            ));
2355        }
2356        let Some(last) = sorted_hashes.last() else {
2357            return Err(StoreError::Other(
2358                "bounded raw blob key proof requires at least one candidate".into(),
2359            ));
2360        };
2361        if max_scanned_keys == 0 || sorted_hashes.len() > max_scanned_keys {
2362            return Err(StoreError::Other(
2363                "bounded raw blob key proof has an invalid scan limit".into(),
2364            ));
2365        }
2366        if *last > inclusive_end {
2367            return Err(StoreError::Other(
2368                "bounded raw blob key candidate exceeds the terminal cursor".into(),
2369            ));
2370        }
2371
2372        let rtxn = self.env.read_txn().map_err(map_heed_error)?;
2373        let keys = sorted_hashes
2374            .iter()
2375            .map(|hash| hash.as_slice())
2376            .collect::<Vec<_>>();
2377        self.blobs
2378            .contains_raw_keys_in_bounded_range_without_data(
2379                &rtxn,
2380                &keys,
2381                inclusive_end.as_slice(),
2382                max_scanned_keys,
2383            )
2384            .map_err(map_heed_error)
2385    }
2386
2387    pub fn blob_size_sync(&self, hash: &Hash) -> Result<Option<u64>, StoreError> {
2388        let rtxn = self
2389            .env
2390            .read_txn()
2391            .map_err(|e| StoreError::Other(e.to_string()))?;
2392        self.blob_size_in_txn(&rtxn, hash)
2393    }
2394
2395    pub fn map_size_bytes(&self) -> usize {
2396        self.env.info().map_size
2397    }
2398
2399    pub fn force_sync(&self) -> Result<(), StoreError> {
2400        self.env.force_sync().map_err(map_heed_error)
2401    }
2402
2403    fn total_bytes(&self) -> Result<u64, StoreError> {
2404        let rtxn = self.env.read_txn().map_err(map_heed_error)?;
2405        Ok(Self::read_store_totals(self.stats, &rtxn)?
2406            .unwrap_or_default()
2407            .total_bytes)
2408    }
2409
2410    fn evict_for_write_pressure(&self, incoming_bytes: u64) -> Result<u64, StoreError> {
2411        // Raw archive and Pool member stores leave max_bytes unset: their
2412        // owning layer controls retention. A full map must fail, not silently
2413        // delete history outside that layer's catalog or root policy.
2414        if self.max_bytes.load(Ordering::Relaxed) == 0 {
2415            return Ok(0);
2416        }
2417        let current = self.total_bytes()?;
2418        if current == 0 {
2419            return Ok(0);
2420        }
2421
2422        let headroom = incoming_bytes.max(current / 10).max(1);
2423        let target = current.saturating_sub(headroom);
2424        self.evict_to_target(current, target)
2425    }
2426
2427    fn enforce_max_bytes_after_insert(&self, inserted_bytes: u64) -> Result<u64, StoreError> {
2428        let max = self.max_bytes.load(Ordering::Relaxed);
2429        if max == 0 || inserted_bytes == 0 {
2430            return Ok(0);
2431        }
2432
2433        let current = self.total_bytes()?;
2434        if current <= max {
2435            return Ok(0);
2436        }
2437
2438        let target = if inserted_bytes >= max {
2439            inserted_bytes
2440        } else {
2441            max.saturating_mul(9)
2442                .saturating_div(10)
2443                .saturating_add(inserted_bytes)
2444                .min(max)
2445        };
2446        self.evict_to_target(current, target)
2447    }
2448
2449    fn write_metadata_for_inserted_blobs(
2450        &self,
2451        wtxn: &mut heed::RwTxn,
2452        inserted_entries: &[(Hash, u64)],
2453    ) -> Result<(), StoreError> {
2454        if inserted_entries.is_empty() {
2455            return Ok(());
2456        }
2457
2458        let now = unix_timestamp_now();
2459        let mut inserted_bytes = 0u64;
2460        let mut inserted_pinned = 0u64;
2461        let mut inserted_pinned_bytes = 0u64;
2462        let mut order = self
2463            .allocate_order_range_in_txn(wtxn, inserted_entries.len())
2464            .map_err(map_heed_error)?;
2465
2466        for (hash, data_len) in inserted_entries {
2467            let meta = Self::encode_blob_meta(BlobMeta {
2468                order,
2469                size: *data_len,
2470                last_accessed_at: now,
2471            });
2472            let order_key = Self::encode_order_key(order, hash);
2473            order = order.saturating_add(1);
2474            self.metadata
2475                .put(wtxn, hash, &meta)
2476                .map_err(map_heed_error)?;
2477            self.eviction_order
2478                .put(wtxn, &order_key, &())
2479                .map_err(map_heed_error)?;
2480            inserted_bytes = inserted_bytes.saturating_add(*data_len);
2481            if self
2482                .read_pin_count_lossy(wtxn, hash)
2483                .map_err(map_heed_error)?
2484                > 0
2485            {
2486                inserted_pinned = inserted_pinned.saturating_add(1);
2487                inserted_pinned_bytes = inserted_pinned_bytes.saturating_add(*data_len);
2488            }
2489        }
2490
2491        self.increment_totals_in_txn(
2492            wtxn,
2493            inserted_entries.len() as u64,
2494            inserted_bytes,
2495            inserted_pinned,
2496            inserted_pinned_bytes,
2497        )
2498        .map_err(map_heed_error)?;
2499        Ok(())
2500    }
2501
2502    fn put_sync_attempt(&self, hash: Hash, data: &[u8]) -> Result<bool, StoreError> {
2503        let mut wtxn = self.env.write_txn().map_err(map_heed_error)?;
2504        let external_config = self
2505            .external_blobs
2506            .as_ref()
2507            .filter(|config| data.len() >= config.min_bytes);
2508        let external_marker = external_config.map(|_| Self::external_blob_ref(&hash));
2509        let value = external_marker.as_deref().unwrap_or(data);
2510
2511        match self
2512            .blobs
2513            .put_with_flags(&mut wtxn, PutFlags::NO_OVERWRITE, &hash, value)
2514        {
2515            Ok(()) => {}
2516            Err(HeedError::Mdb(MdbError::KeyExist)) => return Ok(false),
2517            Err(err) => return Err(map_heed_error(err)),
2518        }
2519
2520        if let Some(config) = external_config {
2521            self.write_external_blob(&hash, data, config)?;
2522        }
2523        self.write_metadata_for_inserted_blobs(&mut wtxn, &[(hash, data.len() as u64)])?;
2524        wtxn.commit().map_err(map_heed_error)?;
2525        Ok(true)
2526    }
2527
2528    fn put_many_sync_attempt(
2529        &self,
2530        total: usize,
2531        items: &[(Hash, &[u8])],
2532    ) -> Result<PutManyReport, StoreError> {
2533        let mut wtxn = self.env.write_txn().map_err(map_heed_error)?;
2534        let mut report = PutManyReport {
2535            total,
2536            ..PutManyReport::default()
2537        };
2538        let mut inserted_entries: Vec<(Hash, u64)> = Vec::new();
2539        let mut external_blobs: Vec<(Hash, &[u8])> = Vec::new();
2540        let mut external_pack_entries: Vec<(usize, Hash, &[u8])> = Vec::new();
2541        let external_config = self.external_blobs.as_ref();
2542
2543        for (hash, data) in items {
2544            let external = external_config.filter(|config| data.len() >= config.min_bytes);
2545            let pack_external = external.is_some_and(|config| config.pack_target_bytes.is_some());
2546            let reserved_marker;
2547            let external_marker;
2548            let value = if pack_external {
2549                reserved_marker = Self::external_pack_reserved_ref(hash);
2550                reserved_marker.as_slice()
2551            } else if external.is_some() {
2552                external_marker = Self::external_blob_ref(hash);
2553                external_marker.as_slice()
2554            } else {
2555                *data
2556            };
2557
2558            match self
2559                .blobs
2560                .put_with_flags(&mut wtxn, PutFlags::NO_OVERWRITE, hash, value)
2561            {
2562                Ok(()) => {}
2563                Err(HeedError::Mdb(MdbError::KeyExist)) => continue,
2564                Err(err) => return Err(map_heed_error(err)),
2565            }
2566
2567            let inserted_index = inserted_entries.len();
2568            let data_len = data.len() as u64;
2569            inserted_entries.push((*hash, data_len));
2570            report.inserted = report.inserted.saturating_add(1);
2571            report.inserted_bytes = report.inserted_bytes.saturating_add(data_len);
2572            report.inserted_hashes.push(*hash);
2573
2574            if pack_external {
2575                external_pack_entries.push((inserted_index, *hash, *data));
2576            } else if external.is_some() {
2577                external_blobs.push((*hash, *data));
2578            }
2579        }
2580
2581        if inserted_entries.is_empty() {
2582            return Ok(report);
2583        }
2584
2585        if let Some(config) = external_config {
2586            for (hash, data) in external_blobs {
2587                self.write_external_blob(&hash, data, config)?;
2588            }
2589            if let Some(pack_target_bytes) = config.pack_target_bytes {
2590                for (inserted_index, marker) in self.write_external_blob_packs(
2591                    &external_pack_entries,
2592                    config,
2593                    pack_target_bytes,
2594                )? {
2595                    let hash = inserted_entries[inserted_index].0;
2596                    self.blobs
2597                        .put(&mut wtxn, &hash, &marker)
2598                        .map_err(map_heed_error)?;
2599                }
2600            }
2601        }
2602
2603        self.write_metadata_for_inserted_blobs(&mut wtxn, &inserted_entries)?;
2604
2605        wtxn.commit().map_err(map_heed_error)?;
2606        Ok(report)
2607    }
2608
2609    /// Get storage statistics.
2610    pub fn stats(&self) -> Result<LmdbStats, StoreError> {
2611        let rtxn = self
2612            .env
2613            .read_txn()
2614            .map_err(|e| StoreError::Other(e.to_string()))?;
2615        let totals = Self::read_store_totals(self.stats, &rtxn)?.unwrap_or_default();
2616
2617        Ok(LmdbStats {
2618            count: totals.count as usize,
2619            total_bytes: totals.total_bytes,
2620            pinned_count: totals.pinned_count as usize,
2621            pinned_bytes: totals.pinned_bytes,
2622        })
2623    }
2624
2625    /// List all hashes in the store.
2626    pub fn list(&self) -> Result<Vec<Hash>, StoreError> {
2627        let rtxn = self
2628            .env
2629            .read_txn()
2630            .map_err(|e| StoreError::Other(e.to_string()))?;
2631
2632        let mut hashes = Vec::new();
2633        for item in self.blobs.iter(&rtxn).map_err(map_heed_error)? {
2634            let (hash, _) = item.map_err(|e| StoreError::Other(e.to_string()))?;
2635            let hash_arr: Hash = hash
2636                .try_into()
2637                .map_err(|_| StoreError::Other("invalid hash length".into()))?;
2638            hashes.push(hash_arr);
2639        }
2640
2641        Ok(hashes)
2642    }
2643
2644    /// Scan every raw blob-catalog hash in lexicographic order without
2645    /// materializing the whole store.
2646    ///
2647    /// `after` is exclusive, so callers can persist the final returned hash as a
2648    /// resumable cursor. The `blobs` database is authoritative for membership:
2649    /// a partially upgraded legacy store may contain metadata for only a subset
2650    /// of its blob rows, and selecting `metadata` would silently omit the rest.
2651    /// LMDB receives a null data pointer, so large inline values' overflow pages
2652    /// are not resolved or faulted merely to enumerate keys.
2653    pub fn scan_hashes_after(
2654        &self,
2655        after: Option<Hash>,
2656        limit: usize,
2657    ) -> Result<Vec<Hash>, StoreError> {
2658        use std::ops::Bound;
2659        match after.as_ref() {
2660            Some(after) => self.scan_hashes_from_bound(Bound::Excluded(after.as_slice()), limit),
2661            None => self.scan_hashes_from_bound(Bound::Unbounded, limit),
2662        }
2663    }
2664
2665    /// Scan raw blob keys from `start` inclusively without requesting LMDB
2666    /// values.
2667    pub fn scan_hashes_from(&self, start: Hash, limit: usize) -> Result<Vec<Hash>, StoreError> {
2668        self.scan_hashes_from_bound(std::ops::Bound::Included(start.as_slice()), limit)
2669    }
2670
2671    fn scan_hashes_from_bound(
2672        &self,
2673        start: std::ops::Bound<&[u8]>,
2674        limit: usize,
2675    ) -> Result<Vec<Hash>, StoreError> {
2676        if limit == 0 {
2677            return Ok(Vec::new());
2678        }
2679        let rtxn = self.env.read_txn().map_err(map_heed_error)?;
2680        self.blobs
2681            .raw_keys_from(&rtxn, start, limit)
2682            .map_err(map_heed_error)?
2683            .into_iter()
2684            .map(|hash| {
2685                hash.as_slice()
2686                    .try_into()
2687                    .map_err(|_| StoreError::Other("invalid hash length".into()))
2688            })
2689            .collect()
2690    }
2691
2692    /// Sync put operation (for use in sync contexts).
2693    pub fn put_sync(&self, hash: Hash, data: &[u8]) -> Result<bool, StoreError> {
2694        let incoming_bytes = data.len() as u64;
2695
2696        let mut retried_after_eviction = false;
2697        loop {
2698            match self.put_sync_attempt(hash, data) {
2699                Ok(inserted) => {
2700                    if inserted {
2701                        self.enforce_max_bytes_after_insert(incoming_bytes)?;
2702                    }
2703                    return Ok(inserted);
2704                }
2705                Err(err) if is_map_full_store_error(&err) && !retried_after_eviction => {
2706                    let freed = self.evict_for_write_pressure(incoming_bytes)?;
2707                    if freed == 0 {
2708                        return Err(err);
2709                    }
2710                    retried_after_eviction = true;
2711                }
2712                Err(err) => return Err(err),
2713            }
2714        }
2715    }
2716
2717    /// Sync batch put operation with exact insert accounting.
2718    pub fn put_many_report_sync(
2719        &self,
2720        items: &[(Hash, Vec<u8>)],
2721    ) -> Result<PutManyReport, StoreError> {
2722        let borrowed = items
2723            .iter()
2724            .map(|(hash, data)| (*hash, data.as_slice()))
2725            .collect::<Vec<_>>();
2726        self.put_many_refs_report_sync(&borrowed)
2727    }
2728
2729    /// Sync batch put without requiring callers to clone owned blob buffers.
2730    pub fn put_many_refs_report_sync(
2731        &self,
2732        items: &[(Hash, &[u8])],
2733    ) -> Result<PutManyReport, StoreError> {
2734        let total = items.len();
2735        if items.is_empty() {
2736            return Ok(PutManyReport::default());
2737        }
2738
2739        let mut seen_missing = HashSet::new();
2740        let write_items = items
2741            .iter()
2742            .filter_map(|(hash, data)| {
2743                if !seen_missing.insert(*hash) {
2744                    None
2745                } else {
2746                    Some((*hash, *data))
2747                }
2748            })
2749            .collect::<Vec<_>>();
2750        let incoming_bytes = write_items
2751            .iter()
2752            .map(|(_, data)| data.len() as u64)
2753            .fold(0u64, |total, size| total.saturating_add(size));
2754
2755        let mut retried_after_eviction = false;
2756        loop {
2757            match self.put_many_sync_attempt(total, &write_items) {
2758                Ok(report) => {
2759                    if report.inserted_bytes > 0 {
2760                        self.enforce_max_bytes_after_insert(report.inserted_bytes)?;
2761                    }
2762                    return Ok(report);
2763                }
2764                Err(err) if is_map_full_store_error(&err) && !retried_after_eviction => {
2765                    let freed = self.evict_for_write_pressure(incoming_bytes)?;
2766                    if freed == 0 {
2767                        return Err(err);
2768                    }
2769                    retried_after_eviction = true;
2770                }
2771                Err(err) => return Err(err),
2772            }
2773        }
2774    }
2775
2776    /// Sync batch put operation (for use in sync contexts).
2777    pub fn put_many_sync(&self, items: &[(Hash, Vec<u8>)]) -> Result<usize, StoreError> {
2778        self.put_many_report_sync(items)
2779            .map(|report| report.inserted)
2780    }
2781
2782    /// Sync get operation (for use in sync contexts).
2783    pub fn get_sync(&self, hash: &Hash) -> Result<Option<Vec<u8>>, StoreError> {
2784        let rtxn = self
2785            .env
2786            .read_txn()
2787            .map_err(|e| StoreError::Other(e.to_string()))?;
2788
2789        let Some(blob) = self
2790            .blobs
2791            .get(&rtxn, hash)
2792            .map_err(|e| StoreError::Other(e.to_string()))?
2793        else {
2794            return Ok(None);
2795        };
2796
2797        let expected_size = self
2798            .metadata
2799            .get(&rtxn, hash)
2800            .map_err(|e| StoreError::Other(e.to_string()))?
2801            .map(Self::decode_blob_meta)
2802            .transpose()?
2803            .map(|meta| meta.size);
2804        self.decode_blob_value(hash, blob, expected_size).map(Some)
2805    }
2806
2807    pub fn get_range_sync(
2808        &self,
2809        hash: &Hash,
2810        start: u64,
2811        end_inclusive: u64,
2812    ) -> Result<Option<Vec<u8>>, StoreError> {
2813        let rtxn = self
2814            .env
2815            .read_txn()
2816            .map_err(|e| StoreError::Other(e.to_string()))?;
2817        let Some(blob) = self
2818            .blobs
2819            .get(&rtxn, hash)
2820            .map_err(|e| StoreError::Other(e.to_string()))?
2821        else {
2822            return Ok(None);
2823        };
2824
2825        if self.is_external_blob_ref(hash, blob) {
2826            return self.read_external_blob_range(hash, start, end_inclusive);
2827        }
2828        if let Some(pack_ref) = Self::decode_external_pack_ref(blob)? {
2829            return self.read_external_pack_range(&pack_ref, start, end_inclusive);
2830        }
2831
2832        if blob.is_empty() || end_inclusive < start {
2833            return Ok(Some(Vec::new()));
2834        }
2835        let len = blob.len() as u64;
2836        if start >= len {
2837            return Ok(Some(Vec::new()));
2838        }
2839
2840        let actual_end = end_inclusive.min(len - 1);
2841        let start = usize::try_from(start)
2842            .map_err(|_| StoreError::Other("blob range start is too large".to_string()))?;
2843        let end_exclusive = usize::try_from(actual_end.saturating_add(1))
2844            .map_err(|_| StoreError::Other("blob range end is too large".to_string()))?;
2845        Ok(Some(blob[start..end_exclusive].to_vec()))
2846    }
2847
2848    pub(crate) fn copy_blob_to_sync(
2849        &self,
2850        target: &LmdbBlobStore,
2851        hash: &Hash,
2852        expected_size: u64,
2853        chunk_bytes: usize,
2854    ) -> Result<bool, StoreError> {
2855        let actual_size = self
2856            .blob_size_sync(hash)?
2857            .ok_or_else(|| StoreError::Other("source blob disappeared during move".into()))?;
2858        if actual_size != expected_size {
2859            return Err(StoreError::Other(format!(
2860                "source blob size changed during move: expected {expected_size}, found {actual_size}"
2861            )));
2862        }
2863        if target.blob_size_sync(hash)?.is_some() {
2864            match target.verify_blob_streaming(hash, expected_size, chunk_bytes) {
2865                Ok(()) => return Ok(false),
2866                Err(_) => {
2867                    target.delete_sync(hash)?;
2868                }
2869            }
2870        }
2871
2872        let external = target
2873            .external_blobs
2874            .as_ref()
2875            .filter(|config| expected_size >= config.min_bytes as u64);
2876        if let Some(config) = external {
2877            return self.copy_blob_to_external_target(
2878                target,
2879                hash,
2880                expected_size,
2881                chunk_bytes,
2882                config,
2883            );
2884        }
2885
2886        let capacity = usize::try_from(expected_size)
2887            .map_err(|_| StoreError::Other("inline move exceeds addressable memory".into()))?;
2888        let mut data = Vec::new();
2889        data.try_reserve_exact(capacity)
2890            .map_err(|error| StoreError::Other(format!("reserve inline move buffer: {error}")))?;
2891        let actual_hash = self.stream_blob_chunks(hash, expected_size, chunk_bytes, |chunk| {
2892            data.extend_from_slice(chunk);
2893            Ok(())
2894        })?;
2895        if actual_hash != *hash {
2896            return Err(StoreError::Other(
2897                "source returned corrupt bytes during move".into(),
2898            ));
2899        }
2900        let inserted = target.put_sync(*hash, &data)?;
2901        target.verify_blob_streaming(hash, expected_size, chunk_bytes)?;
2902        Ok(inserted)
2903    }
2904
2905    fn copy_blob_to_external_target(
2906        &self,
2907        target: &LmdbBlobStore,
2908        hash: &Hash,
2909        expected_size: u64,
2910        chunk_bytes: usize,
2911        config: &ExternalBlobConfig,
2912    ) -> Result<bool, StoreError> {
2913        #[cfg(unix)]
2914        if config.pinned_root.is_some() {
2915            return Err(StoreError::Other(
2916                "streaming member relocation is disabled while external paths are authority-pinned"
2917                    .into(),
2918            ));
2919        }
2920        let path = Self::external_blob_path_for_config(config, hash);
2921        let parent = path
2922            .parent()
2923            .ok_or_else(|| StoreError::Other("external blob path has no parent".into()))?;
2924        fs::create_dir_all(parent)?;
2925        let temp_path = unique_temp_path(&path);
2926        let mut file = File::options()
2927            .write(true)
2928            .create_new(true)
2929            .open(&temp_path)?;
2930        let actual_hash = self.stream_blob_chunks(hash, expected_size, chunk_bytes, |chunk| {
2931            file.write_all(chunk).map_err(StoreError::Io)
2932        });
2933        let actual_hash = match actual_hash {
2934            Ok(actual_hash) => actual_hash,
2935            Err(error) => {
2936                drop(file);
2937                let _ = fs::remove_file(&temp_path);
2938                return Err(error);
2939            }
2940        };
2941        if actual_hash != *hash {
2942            drop(file);
2943            let _ = fs::remove_file(&temp_path);
2944            return Err(StoreError::Other(
2945                "source returned corrupt bytes during move".into(),
2946            ));
2947        }
2948        file.flush()?;
2949        if config.sync {
2950            file.sync_all()?;
2951        }
2952        drop(file);
2953
2954        let mut wtxn = target.env.write_txn().map_err(map_heed_error)?;
2955        if target
2956            .blobs
2957            .get(&wtxn, hash)
2958            .map_err(map_heed_error)?
2959            .is_some()
2960        {
2961            drop(wtxn);
2962            let _ = fs::remove_file(&temp_path);
2963            target.verify_blob_streaming(hash, expected_size, chunk_bytes)?;
2964            return Ok(false);
2965        }
2966        if path.exists() {
2967            fs::remove_file(&path)?;
2968        }
2969        if let Err(error) = external_file::publish(&temp_path, &path, config.sync) {
2970            let _ = fs::remove_file(&temp_path);
2971            return Err(error.into());
2972        }
2973        let marker = Self::external_blob_ref(hash);
2974        target
2975            .blobs
2976            .put_with_flags(&mut wtxn, PutFlags::NO_OVERWRITE, hash, &marker)
2977            .map_err(map_heed_error)?;
2978        target.write_metadata_for_inserted_blobs(&mut wtxn, &[(*hash, expected_size)])?;
2979        wtxn.commit().map_err(map_heed_error)?;
2980        if let Err(error) = target.verify_blob_streaming(hash, expected_size, chunk_bytes) {
2981            let _ = target.delete_sync(hash);
2982            return Err(error);
2983        }
2984        Ok(true)
2985    }
2986
2987    pub(crate) fn verify_blob_streaming(
2988        &self,
2989        hash: &Hash,
2990        expected_size: u64,
2991        chunk_bytes: usize,
2992    ) -> Result<(), StoreError> {
2993        let size = self
2994            .blob_size_sync(hash)?
2995            .ok_or_else(|| StoreError::Other("target blob disappeared during move".into()))?;
2996        if size != expected_size {
2997            return Err(StoreError::Other(format!(
2998                "target blob size mismatch: expected {expected_size}, found {size}"
2999            )));
3000        }
3001        if self.stream_blob_chunks(hash, size, chunk_bytes, |_| Ok(()))? != *hash {
3002            return Err(StoreError::Other(
3003                "target returned corrupt bytes during move".into(),
3004            ));
3005        }
3006        Ok(())
3007    }
3008
3009    fn stream_blob_chunks(
3010        &self,
3011        hash: &Hash,
3012        expected_size: u64,
3013        chunk_bytes: usize,
3014        mut consume: impl FnMut(&[u8]) -> Result<(), StoreError>,
3015    ) -> Result<Hash, StoreError> {
3016        let chunk_bytes = u64::try_from(chunk_bytes.max(1)).unwrap_or(u64::MAX);
3017        let mut offset = 0u64;
3018        let mut hasher = Sha256::new();
3019        while offset < expected_size {
3020            let end = offset
3021                .saturating_add(chunk_bytes)
3022                .min(expected_size)
3023                .saturating_sub(1);
3024            let chunk = self
3025                .get_range_sync(hash, offset, end)?
3026                .ok_or_else(|| StoreError::Other("blob disappeared during streamed move".into()))?;
3027            let expected_chunk = end.saturating_sub(offset).saturating_add(1);
3028            if chunk.len() as u64 != expected_chunk {
3029                return Err(StoreError::Other(format!(
3030                    "short streamed blob read: expected {expected_chunk}, found {}",
3031                    chunk.len()
3032                )));
3033            }
3034            hasher.update(&chunk);
3035            consume(&chunk)?;
3036            offset = end.saturating_add(1);
3037        }
3038        Ok(hasher.finalize().into())
3039    }
3040
3041    pub fn touch_accessed_sync(&self, hash: &Hash, now: u64) -> Result<bool, StoreError> {
3042        self.touch_many_accessed_sync(std::slice::from_ref(hash), now)
3043            .map(|updated| updated > 0)
3044    }
3045
3046    pub fn touch_many_accessed_sync(&self, hashes: &[Hash], now: u64) -> Result<usize, StoreError> {
3047        if hashes.is_empty() {
3048            return Ok(0);
3049        }
3050
3051        let mut wtxn = self
3052            .env
3053            .write_txn()
3054            .map_err(|e| StoreError::Other(e.to_string()))?;
3055        let mut to_touch = Vec::new();
3056
3057        for hash in hashes {
3058            let meta = self
3059                .metadata
3060                .get(&wtxn, hash)
3061                .map_err(|e| StoreError::Other(e.to_string()))?
3062                .map(Self::decode_blob_meta)
3063                .transpose()?;
3064            let Some(meta) = meta else {
3065                continue;
3066            };
3067
3068            if meta.last_accessed_at >= now {
3069                continue;
3070            }
3071            to_touch.push((*hash, meta));
3072        }
3073
3074        let updated = to_touch.len();
3075        if updated == 0 {
3076            wtxn.commit()
3077                .map_err(|e| StoreError::Other(e.to_string()))?;
3078            return Ok(0);
3079        }
3080
3081        let mut order = self
3082            .allocate_order_range_in_txn(&mut wtxn, updated)
3083            .map_err(|e| StoreError::Other(e.to_string()))?;
3084
3085        for (hash, mut meta) in to_touch {
3086            let old_order_key = Self::encode_order_key(meta.order, &hash);
3087            let _ = self
3088                .eviction_order
3089                .delete(&mut wtxn, &old_order_key)
3090                .map_err(|e| StoreError::Other(e.to_string()))?;
3091
3092            meta.order = order;
3093            order = order.saturating_add(1);
3094            meta.last_accessed_at = now;
3095            let meta_bytes = Self::encode_blob_meta(meta);
3096            let new_order_key = Self::encode_order_key(meta.order, &hash);
3097            self.metadata
3098                .put(&mut wtxn, &hash, &meta_bytes)
3099                .map_err(|e| StoreError::Other(e.to_string()))?;
3100            self.eviction_order
3101                .put(&mut wtxn, &new_order_key, &())
3102                .map_err(|e| StoreError::Other(e.to_string()))?;
3103        }
3104
3105        wtxn.commit()
3106            .map_err(|e| StoreError::Other(e.to_string()))?;
3107        Ok(updated)
3108    }
3109
3110    pub fn last_accessed_at_sync(&self, hash: &Hash) -> Result<Option<u64>, StoreError> {
3111        let rtxn = self
3112            .env
3113            .read_txn()
3114            .map_err(|e| StoreError::Other(e.to_string()))?;
3115        self.metadata
3116            .get(&rtxn, hash)
3117            .map_err(|e| StoreError::Other(e.to_string()))?
3118            .map(Self::decode_blob_meta)
3119            .transpose()
3120            .map(|meta| meta.map(|meta| meta.last_accessed_at))
3121    }
3122
3123    pub fn many_last_accessed_at_sync(
3124        &self,
3125        hashes: &[Hash],
3126    ) -> Result<Vec<(Hash, u64)>, StoreError> {
3127        let rtxn = self
3128            .env
3129            .read_txn()
3130            .map_err(|e| StoreError::Other(e.to_string()))?;
3131        let mut results = Vec::new();
3132        for hash in hashes {
3133            let Some(meta) = self
3134                .metadata
3135                .get(&rtxn, hash)
3136                .map_err(|e| StoreError::Other(e.to_string()))?
3137                .map(Self::decode_blob_meta)
3138                .transpose()?
3139            else {
3140                continue;
3141            };
3142            results.push((*hash, meta.last_accessed_at));
3143        }
3144        Ok(results)
3145    }
3146
3147    /// Sync delete operation (for use in sync contexts).
3148    pub fn delete_sync(&self, hash: &Hash) -> Result<bool, StoreError> {
3149        let mut wtxn = self
3150            .env
3151            .write_txn()
3152            .map_err(|e| StoreError::Other(e.to_string()))?;
3153        let external_path = self.external_blob_path_in_txn(&wtxn, hash)?;
3154        let (existed, _) = self.delete_blob_in_txn(&mut wtxn, hash)?;
3155
3156        wtxn.commit()
3157            .map_err(|e| StoreError::Other(e.to_string()))?;
3158        if existed {
3159            self.remove_external_blob_file(external_path);
3160        }
3161
3162        Ok(existed)
3163    }
3164
3165    /// Delete a batch in one LMDB transaction.
3166    pub fn delete_many_sync(&self, hashes: &[Hash]) -> Result<usize, StoreError> {
3167        if hashes.is_empty() {
3168            return Ok(0);
3169        }
3170        let mut wtxn = self
3171            .env
3172            .write_txn()
3173            .map_err(|e| StoreError::Other(e.to_string()))?;
3174        let mut seen = HashSet::with_capacity(hashes.len());
3175        let mut external_paths = Vec::new();
3176        let mut deleted = 0usize;
3177        for hash in hashes {
3178            if !seen.insert(*hash) {
3179                continue;
3180            }
3181            let external_path = self.external_blob_path_in_txn(&wtxn, hash)?;
3182            let (existed, _) = self.delete_blob_in_txn(&mut wtxn, hash)?;
3183            if existed {
3184                deleted += 1;
3185                if let Some(path) = external_path {
3186                    external_paths.push(path);
3187                }
3188            }
3189        }
3190        wtxn.commit()
3191            .map_err(|e| StoreError::Other(e.to_string()))?;
3192        for path in external_paths {
3193            self.remove_external_blob_file(Some(path));
3194        }
3195        Ok(deleted)
3196    }
3197
3198    fn pin_sync(&self, hash: &Hash) -> Result<(), StoreError> {
3199        let mut wtxn = self
3200            .env
3201            .write_txn()
3202            .map_err(|e| StoreError::Other(e.to_string()))?;
3203        let previous = self.read_pin_count(&wtxn, hash)?;
3204        let count = previous.saturating_add(1);
3205        let encoded = count.to_be_bytes();
3206        self.pins
3207            .put(&mut wtxn, hash, &encoded)
3208            .map_err(|e| StoreError::Other(e.to_string()))?;
3209        if previous == 0 {
3210            if let Some(size) = self.blob_size_in_txn(&wtxn, hash)? {
3211                self.increment_totals_in_txn(&mut wtxn, 0, 0, 1, size)
3212                    .map_err(|e| StoreError::Other(e.to_string()))?;
3213            }
3214        }
3215        wtxn.commit()
3216            .map_err(|e| StoreError::Other(e.to_string()))?;
3217        Ok(())
3218    }
3219
3220    fn unpin_sync(&self, hash: &Hash) -> Result<(), StoreError> {
3221        let mut wtxn = self
3222            .env
3223            .write_txn()
3224            .map_err(|e| StoreError::Other(e.to_string()))?;
3225        let count = self.read_pin_count(&wtxn, hash)?;
3226        if count == 1 {
3227            if let Some(size) = self.blob_size_in_txn(&wtxn, hash)? {
3228                self.decrement_totals_in_txn(&mut wtxn, 0, 0, 1, size)
3229                    .map_err(|e| StoreError::Other(e.to_string()))?;
3230            }
3231        }
3232        if count <= 1 {
3233            let _ = self
3234                .pins
3235                .delete(&mut wtxn, hash)
3236                .map_err(|e| StoreError::Other(e.to_string()))?;
3237        } else {
3238            let encoded = (count - 1).to_be_bytes();
3239            self.pins
3240                .put(&mut wtxn, hash, &encoded)
3241                .map_err(|e| StoreError::Other(e.to_string()))?;
3242        }
3243        wtxn.commit()
3244            .map_err(|e| StoreError::Other(e.to_string()))?;
3245        Ok(())
3246    }
3247
3248    fn evict_to_target(&self, current_bytes: u64, target_bytes: u64) -> Result<u64, StoreError> {
3249        if current_bytes <= target_bytes {
3250            return Ok(0);
3251        }
3252
3253        let rtxn = self
3254            .env
3255            .read_txn()
3256            .map_err(|e| StoreError::Other(e.to_string()))?;
3257        let order_keys: Vec<Vec<u8>> = self
3258            .eviction_order
3259            .iter(&rtxn)
3260            .map_err(|e| StoreError::Other(e.to_string()))?
3261            .map(|item| {
3262                item.map(|(key, _)| key.to_vec())
3263                    .map_err(|e| StoreError::Other(e.to_string()))
3264            })
3265            .collect::<Result<_, _>>()?;
3266        drop(rtxn);
3267
3268        let mut freed_total = 0u64;
3269        let to_free = current_bytes - target_bytes;
3270        let mut index = 0usize;
3271
3272        while freed_total < to_free && index < order_keys.len() {
3273            let mut wtxn = self
3274                .env
3275                .write_txn()
3276                .map_err(|e| StoreError::Other(e.to_string()))?;
3277            let mut batch_freed = 0u64;
3278            let mut batch_items = 0usize;
3279            let mut batch_external_paths = Vec::new();
3280
3281            while freed_total + batch_freed < to_free && index < order_keys.len() {
3282                let order_key = &order_keys[index];
3283                index += 1;
3284
3285                let hash = Self::decode_hash_from_order_key(order_key)?;
3286                if self.read_pin_count(&wtxn, &hash)? > 0 {
3287                    continue;
3288                }
3289
3290                let external_path = self.external_blob_path_in_txn(&wtxn, &hash)?;
3291                let (_, bytes_freed) = self.delete_blob_in_txn(&mut wtxn, &hash)?;
3292                if bytes_freed == 0 {
3293                    let _ = self
3294                        .eviction_order
3295                        .delete(&mut wtxn, order_key)
3296                        .map_err(|e| StoreError::Other(e.to_string()))?;
3297                    continue;
3298                }
3299
3300                batch_freed = batch_freed.saturating_add(bytes_freed);
3301                batch_items += 1;
3302                if let Some(path) = external_path {
3303                    batch_external_paths.push(path);
3304                }
3305
3306                if batch_freed >= EVICTION_BATCH_TARGET_BYTES
3307                    || batch_items >= EVICTION_BATCH_MAX_ITEMS
3308                {
3309                    break;
3310                }
3311            }
3312
3313            wtxn.commit()
3314                .map_err(|e| StoreError::Other(e.to_string()))?;
3315            for path in batch_external_paths {
3316                let _ = fs::remove_file(path);
3317            }
3318            if batch_freed > 0 {
3319                freed_total = freed_total.saturating_add(batch_freed);
3320            }
3321        }
3322
3323        Ok(freed_total)
3324    }
3325
3326    fn delete_blob_in_txn(
3327        &self,
3328        wtxn: &mut heed::RwTxn,
3329        hash: &Hash,
3330    ) -> Result<(bool, u64), StoreError> {
3331        let pin_count = self.read_pin_count(wtxn, hash)?;
3332        let data_len = self
3333            .blobs
3334            .get(wtxn, hash)
3335            .map_err(|e| StoreError::Other(e.to_string()))?
3336            .map(|data| data.len() as u64);
3337        let meta = self
3338            .metadata
3339            .get(wtxn, hash)
3340            .map_err(|e| StoreError::Other(e.to_string()))?
3341            .map(Self::decode_blob_meta)
3342            .transpose()?;
3343
3344        let existed = self
3345            .blobs
3346            .delete(wtxn, hash)
3347            .map_err(|e| StoreError::Other(e.to_string()))?;
3348        let _ = self
3349            .metadata
3350            .delete(wtxn, hash)
3351            .map_err(|e| StoreError::Other(e.to_string()))?;
3352        let _ = self
3353            .pins
3354            .delete(wtxn, hash)
3355            .map_err(|e| StoreError::Other(e.to_string()))?;
3356        if let Some(meta) = meta {
3357            let order_key = Self::encode_order_key(meta.order, hash);
3358            let _ = self
3359                .eviction_order
3360                .delete(wtxn, &order_key)
3361                .map_err(|e| StoreError::Other(e.to_string()))?;
3362        }
3363        let bytes_freed = meta.map(|m| m.size).or(data_len).unwrap_or(0);
3364        if existed || meta.is_some() {
3365            self.decrement_totals_in_txn(
3366                wtxn,
3367                1,
3368                bytes_freed,
3369                u64::from(pin_count > 0),
3370                if pin_count > 0 { bytes_freed } else { 0 },
3371            )
3372            .map_err(|e| StoreError::Other(e.to_string()))?;
3373        }
3374
3375        Ok((existed || meta.is_some(), bytes_freed))
3376    }
3377
3378    fn blob_size_in_txn(&self, txn: &heed::RoTxn, hash: &Hash) -> Result<Option<u64>, StoreError> {
3379        if let Some(meta) = self
3380            .metadata
3381            .get(txn, hash)
3382            .map_err(|e| StoreError::Other(e.to_string()))?
3383            .map(Self::decode_blob_meta)
3384            .transpose()?
3385        {
3386            return Ok(Some(meta.size));
3387        }
3388
3389        let Some(value) = self
3390            .blobs
3391            .get(txn, hash)
3392            .map_err(|e| StoreError::Other(e.to_string()))?
3393        else {
3394            return Ok(None);
3395        };
3396        if self.is_external_blob_ref(hash, value) {
3397            let config = self.external_blobs.as_ref().ok_or_else(|| {
3398                StoreError::Other(
3399                    "external LMDB blob marker found but external blobs are disabled".into(),
3400                )
3401            })?;
3402            let path = Self::external_blob_path_for_config(config, hash);
3403            return path
3404                .metadata()
3405                .map(|metadata| Some(metadata.len()))
3406                .map_err(StoreError::Io);
3407        }
3408        if let Some(pack_ref) = Self::decode_external_pack_ref(value)? {
3409            return Ok(Some(pack_ref.len));
3410        }
3411        Ok(Some(value.len() as u64))
3412    }
3413
3414    fn read_pin_count(&self, txn: &heed::RoTxn, hash: &[u8]) -> Result<u32, StoreError> {
3415        self.pins
3416            .get(txn, hash)
3417            .map_err(|e| StoreError::Other(e.to_string()))?
3418            .map(Self::decode_pin_count)
3419            .transpose()?
3420            .map_or(Ok(0), Ok)
3421    }
3422
3423    fn read_pin_count_lossy(
3424        &self,
3425        txn: &heed::RoTxn,
3426        hash: &[u8],
3427    ) -> std::result::Result<u32, HeedError> {
3428        Ok(self
3429            .pins
3430            .get(txn, hash)?
3431            .and_then(Self::decode_pin_count_lossy)
3432            .unwrap_or(0))
3433    }
3434
3435    fn encode_blob_meta(meta: BlobMeta) -> [u8; BLOB_META_BYTES] {
3436        let mut encoded = [0u8; BLOB_META_BYTES];
3437        encoded[..8].copy_from_slice(&meta.order.to_be_bytes());
3438        encoded[8..16].copy_from_slice(&meta.size.to_be_bytes());
3439        encoded[16..].copy_from_slice(&meta.last_accessed_at.to_be_bytes());
3440        encoded
3441    }
3442
3443    fn decode_blob_meta(bytes: &[u8]) -> Result<BlobMeta, StoreError> {
3444        if bytes.len() != LEGACY_BLOB_META_BYTES && bytes.len() != BLOB_META_BYTES {
3445            return Err(StoreError::Other(format!(
3446                "invalid blob metadata length: {}",
3447                bytes.len()
3448            )));
3449        }
3450        Ok(BlobMeta {
3451            order: Self::decode_order(&bytes[..8])?,
3452            size: u64::from_be_bytes(
3453                bytes[8..16]
3454                    .try_into()
3455                    .map_err(|_| StoreError::Other("invalid blob size bytes".into()))?,
3456            ),
3457            last_accessed_at: if bytes.len() >= BLOB_META_BYTES {
3458                u64::from_be_bytes(
3459                    bytes[16..24]
3460                        .try_into()
3461                        .map_err(|_| StoreError::Other("invalid blob access time bytes".into()))?,
3462                )
3463            } else {
3464                0
3465            },
3466        })
3467    }
3468
3469    fn encode_store_totals(totals: StoreTotals) -> [u8; STORE_TOTALS_BYTES] {
3470        let mut encoded = [0u8; STORE_TOTALS_BYTES];
3471        encoded[0..8].copy_from_slice(&totals.count.to_be_bytes());
3472        encoded[8..16].copy_from_slice(&totals.total_bytes.to_be_bytes());
3473        encoded[16..24].copy_from_slice(&totals.pinned_count.to_be_bytes());
3474        encoded[24..32].copy_from_slice(&totals.pinned_bytes.to_be_bytes());
3475        encoded
3476    }
3477
3478    fn decode_store_totals(bytes: &[u8]) -> Result<StoreTotals, StoreError> {
3479        Self::decode_store_totals_lossy(bytes).ok_or_else(|| {
3480            StoreError::Other(format!("invalid store totals length: {}", bytes.len()))
3481        })
3482    }
3483
3484    fn decode_store_totals_lossy(bytes: &[u8]) -> Option<StoreTotals> {
3485        if bytes.len() != STORE_TOTALS_BYTES {
3486            return None;
3487        }
3488        Some(StoreTotals {
3489            count: u64::from_be_bytes(bytes[0..8].try_into().ok()?),
3490            total_bytes: u64::from_be_bytes(bytes[8..16].try_into().ok()?),
3491            pinned_count: u64::from_be_bytes(bytes[16..24].try_into().ok()?),
3492            pinned_bytes: u64::from_be_bytes(bytes[24..32].try_into().ok()?),
3493        })
3494    }
3495
3496    fn encode_next_order(next_order: u64) -> [u8; NEXT_ORDER_BYTES] {
3497        next_order.to_be_bytes()
3498    }
3499
3500    fn decode_next_order(bytes: &[u8]) -> Result<u64, StoreError> {
3501        Self::decode_next_order_lossy(bytes)
3502            .ok_or_else(|| StoreError::Other(format!("invalid next order length: {}", bytes.len())))
3503    }
3504
3505    fn decode_next_order_lossy(bytes: &[u8]) -> Option<u64> {
3506        if bytes.len() != NEXT_ORDER_BYTES {
3507            return None;
3508        }
3509        Some(u64::from_be_bytes(bytes.try_into().ok()?))
3510    }
3511
3512    fn encode_order_key(order: u64, hash: &Hash) -> [u8; ORDER_KEY_BYTES] {
3513        let mut key = [0u8; ORDER_KEY_BYTES];
3514        key[..8].copy_from_slice(&order.to_be_bytes());
3515        key[8..].copy_from_slice(hash);
3516        key
3517    }
3518
3519    fn decode_order(bytes: &[u8]) -> Result<u64, StoreError> {
3520        if bytes.len() != 8 {
3521            return Err(StoreError::Other(format!(
3522                "invalid order length: {}",
3523                bytes.len()
3524            )));
3525        }
3526        Ok(u64::from_be_bytes(bytes.try_into().map_err(|_| {
3527            StoreError::Other("invalid order bytes".into())
3528        })?))
3529    }
3530
3531    fn decode_hash_from_order_key(bytes: &[u8]) -> Result<Hash, StoreError> {
3532        if bytes.len() != ORDER_KEY_BYTES {
3533            return Err(StoreError::Other(format!(
3534                "invalid order key length: {}",
3535                bytes.len()
3536            )));
3537        }
3538        let mut hash = [0u8; 32];
3539        hash.copy_from_slice(&bytes[8..]);
3540        Ok(hash)
3541    }
3542
3543    fn decode_pin_count(bytes: &[u8]) -> Result<u32, StoreError> {
3544        Self::decode_pin_count_lossy(bytes)
3545            .ok_or_else(|| StoreError::Other(format!("invalid pin count length: {}", bytes.len())))
3546    }
3547
3548    fn decode_pin_count_lossy(bytes: &[u8]) -> Option<u32> {
3549        if bytes.len() != PIN_COUNT_BYTES {
3550            return None;
3551        }
3552        Some(u32::from_be_bytes(bytes.try_into().ok()?))
3553    }
3554
3555    fn external_blob_ref(hash: &Hash) -> Vec<u8> {
3556        let mut marker = Vec::with_capacity(EXTERNAL_BLOB_MARKER_PREFIX.len() + hash.len());
3557        marker.extend_from_slice(EXTERNAL_BLOB_MARKER_PREFIX);
3558        marker.extend_from_slice(hash);
3559        marker
3560    }
3561
3562    fn external_pack_reserved_ref(hash: &Hash) -> Vec<u8> {
3563        let mut marker =
3564            Vec::with_capacity(EXTERNAL_PACK_RESERVED_MARKER_PREFIX.len() + hash.len());
3565        marker.extend_from_slice(EXTERNAL_PACK_RESERVED_MARKER_PREFIX);
3566        marker.extend_from_slice(hash);
3567        marker
3568    }
3569
3570    fn external_pack_blob_ref(
3571        pack_name: &str,
3572        offset: u64,
3573        len: u64,
3574    ) -> Result<Vec<u8>, StoreError> {
3575        let name_len = u16::try_from(pack_name.len())
3576            .map_err(|_| StoreError::Other("external pack file name is too long".to_string()))?;
3577        let mut marker =
3578            Vec::with_capacity(EXTERNAL_PACK_MARKER_PREFIX.len() + 2 + pack_name.len() + 16);
3579        marker.extend_from_slice(EXTERNAL_PACK_MARKER_PREFIX);
3580        marker.extend_from_slice(&name_len.to_be_bytes());
3581        marker.extend_from_slice(pack_name.as_bytes());
3582        marker.extend_from_slice(&offset.to_be_bytes());
3583        marker.extend_from_slice(&len.to_be_bytes());
3584        Ok(marker)
3585    }
3586
3587    fn is_external_blob_ref(&self, hash: &Hash, value: &[u8]) -> bool {
3588        value.len() == EXTERNAL_BLOB_MARKER_PREFIX.len() + hash.len()
3589            && value.starts_with(EXTERNAL_BLOB_MARKER_PREFIX)
3590            && &value[EXTERNAL_BLOB_MARKER_PREFIX.len()..] == hash
3591    }
3592
3593    fn decode_external_pack_ref(value: &[u8]) -> Result<Option<ExternalPackRef>, StoreError> {
3594        if !value.starts_with(EXTERNAL_PACK_MARKER_PREFIX) {
3595            return Ok(None);
3596        }
3597
3598        let rest = &value[EXTERNAL_PACK_MARKER_PREFIX.len()..];
3599        if rest.len() < 2 + 8 + 8 {
3600            return Err(StoreError::Other(
3601                "invalid external LMDB pack marker length".to_string(),
3602            ));
3603        }
3604        let name_len = u16::from_be_bytes(
3605            rest[..2]
3606                .try_into()
3607                .map_err(|_| StoreError::Other("invalid external pack name length".into()))?,
3608        ) as usize;
3609        let expected_len = 2usize.saturating_add(name_len).saturating_add(16);
3610        if rest.len() != expected_len {
3611            return Err(StoreError::Other(format!(
3612                "invalid external LMDB pack marker length: {}",
3613                value.len()
3614            )));
3615        }
3616        let name_bytes = &rest[2..2 + name_len];
3617        let name = std::str::from_utf8(name_bytes)
3618            .map_err(|_| StoreError::Other("external pack name is not UTF-8".into()))?;
3619        if name.len() < 2
3620            || name.contains('/')
3621            || name.contains('\\')
3622            || name.starts_with('.')
3623            || name.contains("..")
3624        {
3625            return Err(StoreError::Other(
3626                "external pack name is not a safe relative file name".into(),
3627            ));
3628        }
3629        let offset_start = 2 + name_len;
3630        let offset = u64::from_be_bytes(
3631            rest[offset_start..offset_start + 8]
3632                .try_into()
3633                .map_err(|_| StoreError::Other("invalid external pack offset".into()))?,
3634        );
3635        let len = u64::from_be_bytes(
3636            rest[offset_start + 8..offset_start + 16]
3637                .try_into()
3638                .map_err(|_| StoreError::Other("invalid external pack length".into()))?,
3639        );
3640        Ok(Some(ExternalPackRef {
3641            name: name.to_string(),
3642            offset,
3643            len,
3644        }))
3645    }
3646
3647    fn external_blob_path_for_config(config: &ExternalBlobConfig, hash: &Hash) -> PathBuf {
3648        let hex = to_hex(hash);
3649        config
3650            .base_path
3651            .join(&hex[..2])
3652            .join(&hex[2..4])
3653            .join(&hex[4..])
3654    }
3655
3656    fn external_blob_path(&self, hash: &Hash) -> Option<PathBuf> {
3657        self.external_blobs
3658            .as_ref()
3659            .map(|config| Self::external_blob_path_for_config(config, hash))
3660    }
3661
3662    fn external_pack_path_for_config(config: &ExternalBlobConfig, pack_name: &str) -> PathBuf {
3663        config
3664            .base_path
3665            .join("packs")
3666            .join(&pack_name[..2])
3667            .join(pack_name)
3668    }
3669
3670    #[cfg(test)]
3671    fn external_pack_path(&self, pack_ref: &ExternalPackRef) -> Option<PathBuf> {
3672        self.external_blobs
3673            .as_ref()
3674            .map(|config| Self::external_pack_path_for_config(config, &pack_ref.name))
3675    }
3676
3677    fn external_pack_name(first_hash: &Hash) -> String {
3678        let hash_hex = to_hex(first_hash);
3679        let nanos = SystemTime::now()
3680            .duration_since(UNIX_EPOCH)
3681            .unwrap_or_default()
3682            .as_nanos();
3683        let counter = EXTERNAL_PACK_COUNTER.fetch_add(1, Ordering::Relaxed);
3684        format!(
3685            "{}-{:032x}-{}-{:016x}.pack",
3686            &hash_hex[..12],
3687            nanos,
3688            std::process::id(),
3689            counter
3690        )
3691    }
3692
3693    fn write_external_blob(
3694        &self,
3695        hash: &Hash,
3696        data: &[u8],
3697        config: &ExternalBlobConfig,
3698    ) -> Result<(), StoreError> {
3699        #[cfg(unix)]
3700        if let Some(root) = &config.pinned_root {
3701            return root.write_blob_guarded(
3702                &ExternalBlobConfig::relative_blob_path(hash),
3703                data,
3704                hash,
3705                config.sync,
3706                self.physical_space.as_ref(),
3707            );
3708        }
3709        let path = Self::external_blob_path_for_config(config, hash);
3710        if path.exists() {
3711            return Ok(());
3712        }
3713
3714        let parent = path
3715            .parent()
3716            .ok_or_else(|| StoreError::Other("external blob path has no parent".to_string()))?;
3717        if let Some(guard) = &self.physical_space {
3718            guard.create_dir_all(parent)?;
3719            guard.admit_file(&File::open(parent)?, 0, 1)?;
3720        } else {
3721            fs::create_dir_all(parent)?;
3722        }
3723        let temp_path = unique_temp_path(&path);
3724        let written = (|| -> Result<(), StoreError> {
3725            let mut file = File::options()
3726                .write(true)
3727                .create_new(true)
3728                .open(&temp_path)?;
3729            if let Some(guard) = &self.physical_space {
3730                guard.write_all(&mut file, data)?;
3731            } else {
3732                file.write_all(data)?;
3733            }
3734            if config.sync {
3735                file.sync_all()?;
3736            }
3737            Ok(())
3738        })();
3739        if let Err(error) = written {
3740            let _ = fs::remove_file(&temp_path);
3741            return Err(error);
3742        }
3743
3744        if let Some(guard) = &self.physical_space {
3745            if let Err(error) = guard.admit_file(&File::open(parent)?, 0, 1) {
3746                let _ = fs::remove_file(&temp_path);
3747                return Err(error.into());
3748            }
3749        }
3750        if let Err(error) = external_file::publish(&temp_path, &path, config.sync) {
3751            let _ = fs::remove_file(&temp_path);
3752            return Err(error.into());
3753        }
3754        Ok(())
3755    }
3756
3757    fn write_external_blob_packs(
3758        &self,
3759        entries: &[(usize, Hash, &[u8])],
3760        config: &ExternalBlobConfig,
3761        pack_target_bytes: usize,
3762    ) -> Result<Vec<(usize, Vec<u8>)>, StoreError> {
3763        let mut markers = Vec::with_capacity(entries.len());
3764        let mut pack_entries = Vec::new();
3765        let mut pack_bytes = 0usize;
3766
3767        for entry in entries {
3768            let data_len = entry.2.len();
3769            let would_exceed_target =
3770                !pack_entries.is_empty() && pack_bytes.saturating_add(data_len) > pack_target_bytes;
3771            if would_exceed_target {
3772                markers.extend(self.write_external_blob_pack(&pack_entries, config)?);
3773                pack_entries.clear();
3774                pack_bytes = 0;
3775            }
3776
3777            pack_entries.push(*entry);
3778            pack_bytes = pack_bytes.saturating_add(data_len);
3779        }
3780
3781        if !pack_entries.is_empty() {
3782            markers.extend(self.write_external_blob_pack(&pack_entries, config)?);
3783        }
3784
3785        Ok(markers)
3786    }
3787
3788    fn write_external_blob_pack(
3789        &self,
3790        entries: &[(usize, Hash, &[u8])],
3791        config: &ExternalBlobConfig,
3792    ) -> Result<Vec<(usize, Vec<u8>)>, StoreError> {
3793        if entries.is_empty() {
3794            return Ok(Vec::new());
3795        }
3796
3797        let pack_name = Self::external_pack_name(&entries[0].1);
3798        #[cfg(unix)]
3799        if let Some(root) = &config.pinned_root {
3800            let relative = ExternalBlobConfig::relative_pack_path(&pack_name);
3801            return root.create_file(
3802                &relative,
3803                config.sync,
3804                self.physical_space.as_ref(),
3805                |file| {
3806                    let mut markers = Vec::with_capacity(entries.len());
3807                    let mut offset = 0u64;
3808                    for (index, _, data) in entries {
3809                        let len = data.len() as u64;
3810                        if let Some(guard) = &self.physical_space {
3811                            guard.write_all(file, data)?;
3812                        } else {
3813                            file.write_all(data).map_err(StoreError::Io)?;
3814                        }
3815                        markers.push((
3816                            *index,
3817                            Self::external_pack_blob_ref(&pack_name, offset, len)?,
3818                        ));
3819                        offset = offset.saturating_add(len);
3820                    }
3821                    Ok(markers)
3822                },
3823            );
3824        }
3825        let path = Self::external_pack_path_for_config(config, &pack_name);
3826        let parent = path
3827            .parent()
3828            .ok_or_else(|| StoreError::Other("external pack path has no parent".to_string()))?;
3829        if let Some(guard) = &self.physical_space {
3830            guard.create_dir_all(parent)?;
3831            guard.admit_file(&File::open(parent)?, 0, 1)?;
3832        } else {
3833            fs::create_dir_all(parent)?;
3834        }
3835        let temp_path = unique_temp_path(&path);
3836        let mut markers = Vec::with_capacity(entries.len());
3837        let write_result = (|| -> Result<(), StoreError> {
3838            let mut file = File::options()
3839                .write(true)
3840                .create_new(true)
3841                .open(&temp_path)?;
3842            let mut offset = 0u64;
3843            for (index, _, data) in entries {
3844                let len = data.len() as u64;
3845                if let Some(guard) = &self.physical_space {
3846                    guard.write_all(&mut file, data)?;
3847                } else {
3848                    file.write_all(data)?;
3849                }
3850                markers.push((
3851                    *index,
3852                    Self::external_pack_blob_ref(&pack_name, offset, len)?,
3853                ));
3854                offset = offset.saturating_add(len);
3855            }
3856            if config.sync {
3857                file.sync_all()?;
3858            }
3859            Ok(())
3860        })();
3861
3862        if let Err(error) = write_result {
3863            let _ = fs::remove_file(&temp_path);
3864            return Err(error);
3865        }
3866        if let Some(guard) = &self.physical_space {
3867            if let Err(error) = guard.admit_file(&File::open(parent)?, 0, 1) {
3868                let _ = fs::remove_file(&temp_path);
3869                return Err(error.into());
3870            }
3871        }
3872        if let Err(error) = external_file::publish(&temp_path, &path, config.sync) {
3873            let _ = fs::remove_file(&temp_path);
3874            return Err(error.into());
3875        }
3876        Ok(markers)
3877    }
3878
3879    fn decode_blob_value(
3880        &self,
3881        hash: &Hash,
3882        value: &[u8],
3883        expected_size: Option<u64>,
3884    ) -> Result<Vec<u8>, StoreError> {
3885        if self.is_external_blob_ref(hash, value) {
3886            let config = self.external_blobs.as_ref().ok_or_else(|| {
3887                StoreError::Other(
3888                    "external LMDB blob marker found but external blobs are disabled".into(),
3889                )
3890            })?;
3891            let mut file = config.open_relative(&ExternalBlobConfig::relative_blob_path(hash))?;
3892            let expected_size = expected_size.ok_or_else(|| {
3893                StoreError::Other("external LMDB blob is missing size metadata".into())
3894            })?;
3895            let capacity = usize::try_from(expected_size)
3896                .map_err(|_| StoreError::Other("external LMDB blob size exceeds usize".into()))?;
3897            let mut data = Vec::with_capacity(capacity.min(1024 * 1024));
3898            Read::by_ref(&mut file)
3899                .take(expected_size.saturating_add(1))
3900                .read_to_end(&mut data)
3901                .map_err(StoreError::Io)?;
3902            if data.len() as u64 != expected_size
3903                || file.metadata().map_err(StoreError::Io)?.len() != expected_size
3904            {
3905                return Err(StoreError::Other(
3906                    "external LMDB blob length differs from committed metadata".into(),
3907                ));
3908            }
3909            return Ok(data);
3910        }
3911        if let Some(pack_ref) = Self::decode_external_pack_ref(value)? {
3912            return self.read_external_pack_blob(&pack_ref);
3913        }
3914        Ok(value.to_vec())
3915    }
3916
3917    fn read_external_blob_range(
3918        &self,
3919        hash: &Hash,
3920        start: u64,
3921        end_inclusive: u64,
3922    ) -> Result<Option<Vec<u8>>, StoreError> {
3923        let config = self.external_blobs.as_ref().ok_or_else(|| {
3924            StoreError::Other(
3925                "external LMDB blob marker found but external blobs are disabled".into(),
3926            )
3927        })?;
3928        let mut file = config.open_relative(&ExternalBlobConfig::relative_blob_path(hash))?;
3929        let len = file.metadata()?.len();
3930        if len == 0 || start >= len || end_inclusive < start {
3931            return Ok(Some(Vec::new()));
3932        }
3933
3934        let actual_end = end_inclusive.min(len - 1);
3935        let read_len = actual_end.saturating_sub(start).saturating_add(1);
3936        let read_len = usize::try_from(read_len)
3937            .map_err(|_| StoreError::Other("blob range is too large to read".to_string()))?;
3938        let mut data = vec![0; read_len];
3939        file.seek(SeekFrom::Start(start))?;
3940        file.read_exact(&mut data)?;
3941        Ok(Some(data))
3942    }
3943
3944    fn read_external_pack_blob(&self, pack_ref: &ExternalPackRef) -> Result<Vec<u8>, StoreError> {
3945        let read_len = usize::try_from(pack_ref.len).map_err(|_| {
3946            StoreError::Other("external pack blob is too large to read".to_string())
3947        })?;
3948        let config = self.external_blobs.as_ref().ok_or_else(|| {
3949            StoreError::Other(
3950                "external LMDB pack marker found but external blobs are disabled".into(),
3951            )
3952        })?;
3953        let mut file =
3954            config.open_relative(&ExternalBlobConfig::relative_pack_path(&pack_ref.name))?;
3955        file.seek(SeekFrom::Start(pack_ref.offset))?;
3956        let mut data = vec![0; read_len];
3957        file.read_exact(&mut data)?;
3958        Ok(data)
3959    }
3960
3961    fn read_external_pack_range(
3962        &self,
3963        pack_ref: &ExternalPackRef,
3964        start: u64,
3965        end_inclusive: u64,
3966    ) -> Result<Option<Vec<u8>>, StoreError> {
3967        if pack_ref.len == 0 || start >= pack_ref.len || end_inclusive < start {
3968            return Ok(Some(Vec::new()));
3969        }
3970
3971        let actual_end = end_inclusive.min(pack_ref.len - 1);
3972        let read_len = actual_end.saturating_sub(start).saturating_add(1);
3973        let read_len = usize::try_from(read_len).map_err(|_| {
3974            StoreError::Other("external pack blob range is too large to read".to_string())
3975        })?;
3976        let config = self.external_blobs.as_ref().ok_or_else(|| {
3977            StoreError::Other(
3978                "external LMDB pack marker found but external blobs are disabled".into(),
3979            )
3980        })?;
3981        let mut file =
3982            config.open_relative(&ExternalBlobConfig::relative_pack_path(&pack_ref.name))?;
3983        file.seek(SeekFrom::Start(pack_ref.offset.saturating_add(start)))?;
3984        let mut data = vec![0; read_len];
3985        file.read_exact(&mut data)?;
3986        Ok(Some(data))
3987    }
3988
3989    fn external_blob_path_for_value(
3990        &self,
3991        hash: &Hash,
3992        value: &[u8],
3993    ) -> Result<Option<PathBuf>, StoreError> {
3994        if self.is_external_blob_ref(hash, value) {
3995            return Ok(self.external_blob_path(hash));
3996        }
3997        if Self::decode_external_pack_ref(value)?.is_some() {
3998            return Ok(None);
3999        }
4000        Ok(None)
4001    }
4002
4003    fn external_blob_path_in_txn(
4004        &self,
4005        txn: &heed::RoTxn,
4006        hash: &Hash,
4007    ) -> Result<Option<PathBuf>, StoreError> {
4008        let Some(value) = self
4009            .blobs
4010            .get(txn, hash)
4011            .map_err(|e| StoreError::Other(e.to_string()))?
4012        else {
4013            return Ok(None);
4014        };
4015        self.external_blob_path_for_value(hash, value)
4016    }
4017
4018    fn remove_external_blob_file(&self, path: Option<PathBuf>) {
4019        if let Some(path) = path {
4020            #[cfg(unix)]
4021            if let Some(config) = &self.external_blobs {
4022                if let Some(root) = &config.pinned_root {
4023                    if let Ok(relative) = path.strip_prefix(&config.base_path) {
4024                        let _ = root.remove_file(relative, config.sync);
4025                        return;
4026                    }
4027                }
4028            }
4029            let _ = fs::remove_file(path);
4030        }
4031    }
4032}
4033
4034fn map_heed_error(error: HeedError) -> StoreError {
4035    match error {
4036        HeedError::Io(io_error) => StoreError::Io(io_error),
4037        other => StoreError::Other(other.to_string()),
4038    }
4039}
4040
4041fn is_map_full_store_error(err: &StoreError) -> bool {
4042    let message = err.to_string();
4043    message.contains("MDB_MAP_FULL") || message.contains("MapFull")
4044}
4045
4046fn env_bool(name: &str) -> Option<bool> {
4047    std::env::var(name).ok().and_then(|value| {
4048        let value = value.trim();
4049        if value == "1" || value.eq_ignore_ascii_case("true") || value.eq_ignore_ascii_case("yes") {
4050            Some(true)
4051        } else if value == "0"
4052            || value.eq_ignore_ascii_case("false")
4053            || value.eq_ignore_ascii_case("no")
4054        {
4055            Some(false)
4056        } else {
4057            None
4058        }
4059    })
4060}
4061
4062fn env_flags_from_env() -> EnvFlags {
4063    env_flags_from_bools(
4064        env_bool(LMDB_NO_READ_AHEAD_ENV).unwrap_or(false),
4065        env_bool(LMDB_NO_SYNC_ENV).unwrap_or(false),
4066        env_bool(LMDB_NO_META_SYNC_ENV).unwrap_or(false),
4067    )
4068}
4069
4070fn env_flags_from_bools(no_read_ahead: bool, no_sync: bool, no_meta_sync: bool) -> EnvFlags {
4071    let mut flags = EnvFlags::empty();
4072    if no_read_ahead {
4073        flags |= EnvFlags::NO_READ_AHEAD;
4074    }
4075    if no_sync {
4076        flags |= EnvFlags::NO_SYNC;
4077    }
4078    if no_meta_sync {
4079        flags |= EnvFlags::NO_META_SYNC;
4080    }
4081    flags
4082}
4083
4084fn unique_temp_path(path: &Path) -> PathBuf {
4085    let file_name = path
4086        .file_name()
4087        .and_then(|name| name.to_str())
4088        .unwrap_or("blob");
4089    let nanos = SystemTime::now()
4090        .duration_since(UNIX_EPOCH)
4091        .unwrap_or_default()
4092        .as_nanos();
4093    path.with_file_name(format!(".{file_name}.tmp.{}.{}", std::process::id(), nanos))
4094}
4095
4096fn unix_timestamp_now() -> u64 {
4097    SystemTime::now()
4098        .duration_since(UNIX_EPOCH)
4099        .unwrap_or_default()
4100        .as_secs()
4101}
4102
4103#[derive(Debug, Clone)]
4104pub struct LmdbStats {
4105    pub count: usize,
4106    pub total_bytes: u64,
4107    pub pinned_count: usize,
4108    pub pinned_bytes: u64,
4109}
4110
4111#[async_trait]
4112impl Store for LmdbBlobStore {
4113    async fn put(&self, hash: Hash, data: Vec<u8>) -> Result<bool, StoreError> {
4114        self.put_sync(hash, &data)
4115    }
4116
4117    async fn put_many(&self, items: Vec<(Hash, Vec<u8>)>) -> Result<usize, StoreError> {
4118        self.put_many_sync(&items)
4119    }
4120
4121    async fn get(&self, hash: &Hash) -> Result<Option<Vec<u8>>, StoreError> {
4122        self.get_sync(hash)
4123    }
4124
4125    async fn get_range(
4126        &self,
4127        hash: &Hash,
4128        start: u64,
4129        end_inclusive: u64,
4130    ) -> Result<Option<Vec<u8>>, StoreError> {
4131        self.get_range_sync(hash, start, end_inclusive)
4132    }
4133
4134    async fn blob_size(&self, hash: &Hash) -> Result<Option<u64>, StoreError> {
4135        self.blob_size_sync(hash)
4136    }
4137
4138    async fn has(&self, hash: &Hash) -> Result<bool, StoreError> {
4139        self.exists(hash)
4140    }
4141
4142    async fn delete(&self, hash: &Hash) -> Result<bool, StoreError> {
4143        self.delete_sync(hash)
4144    }
4145
4146    async fn delete_many(&self, hashes: Vec<Hash>) -> Result<usize, StoreError> {
4147        self.delete_many_sync(&hashes)
4148    }
4149
4150    fn set_max_bytes(&self, max: u64) {
4151        self.max_bytes.store(max, Ordering::Relaxed);
4152    }
4153
4154    fn max_bytes(&self) -> Option<u64> {
4155        let max = self.max_bytes.load(Ordering::Relaxed);
4156        if max > 0 {
4157            Some(max)
4158        } else {
4159            None
4160        }
4161    }
4162
4163    async fn stats(&self) -> StoreStats {
4164        match self.stats() {
4165            Ok(stats) => StoreStats {
4166                count: stats.count as u64,
4167                bytes: stats.total_bytes,
4168                pinned_count: stats.pinned_count as u64,
4169                pinned_bytes: stats.pinned_bytes,
4170            },
4171            Err(_) => StoreStats::default(),
4172        }
4173    }
4174
4175    async fn evict_if_needed(&self) -> Result<u64, StoreError> {
4176        let max = self.max_bytes.load(Ordering::Relaxed);
4177        if max == 0 {
4178            return Ok(0);
4179        }
4180
4181        let current = self.total_bytes()?;
4182        if current <= max {
4183            return Ok(0);
4184        }
4185
4186        let target = max * 9 / 10;
4187        self.evict_to_target(current, target)
4188    }
4189
4190    async fn pin(&self, hash: &Hash) -> Result<(), StoreError> {
4191        self.pin_sync(hash)
4192    }
4193
4194    async fn unpin(&self, hash: &Hash) -> Result<(), StoreError> {
4195        self.unpin_sync(hash)
4196    }
4197
4198    fn pin_count(&self, hash: &Hash) -> u32 {
4199        let Ok(rtxn) = self.env.read_txn() else {
4200            return 0;
4201        };
4202        self.read_pin_count(&rtxn, hash).unwrap_or(0)
4203    }
4204}
4205
4206#[cfg(test)]
4207mod tests {
4208    use super::*;
4209    use hashtree_core::sha256;
4210    use heed::EnvOpenOptions;
4211    #[cfg(unix)]
4212    use std::path::{Path, PathBuf};
4213    #[cfg(unix)]
4214    use std::process::Command;
4215    #[cfg(target_os = "macos")]
4216    use std::sync::atomic::AtomicUsize;
4217    use std::sync::{
4218        atomic::{AtomicBool, Ordering},
4219        Arc, Barrier,
4220    };
4221    use std::time::Duration;
4222    use tempfile::TempDir;
4223
4224    #[cfg(unix)]
4225    const STALE_READER_HELPER_ENV: &str = "HASHTREE_LMDB_STALE_READER_HELPER";
4226    #[cfg(unix)]
4227    const STALE_READER_HELPER_MODE_ENV: &str = "HASHTREE_LMDB_STALE_READER_HELPER_MODE";
4228    #[cfg(unix)]
4229    const STALE_READER_DB_PATH_ENV: &str = "HASHTREE_LMDB_STALE_READER_DB_PATH";
4230    #[cfg(unix)]
4231    const STALE_READER_MARKER_PATH_ENV: &str = "HASHTREE_LMDB_STALE_READER_MARKER_PATH";
4232    #[cfg(unix)]
4233    const TEST_MAX_READERS: u32 = 4;
4234
4235    #[cfg(target_os = "linux")]
4236    fn generated_pinned_external_root(
4237        path: &Path,
4238    ) -> Result<(File, Arc<PinnedExternalRoot>), StoreError> {
4239        std::fs::create_dir(path).map_err(StoreError::Io)?;
4240        let retained = File::open(path).map_err(StoreError::Io)?;
4241        let config = ExternalBlobConfig {
4242            base_path: PathBuf::from(format!("/proc/self/fd/{}", retained.as_raw_fd())),
4243            min_bytes: 1,
4244            sync: true,
4245            pack_target_bytes: None,
4246            pinned_root: None,
4247        }
4248        .prepare_runtime()?;
4249        Ok((
4250            retained,
4251            config
4252                .pinned_root
4253                .expect("Linux procfd external root is pinned"),
4254        ))
4255    }
4256
4257    #[cfg(target_os = "linux")]
4258    #[test]
4259    fn pinned_external_publication_uses_retained_unnamed_file() -> Result<(), StoreError> {
4260        let temp = TempDir::new().map_err(StoreError::Io)?;
4261        let root_path = temp.path().join("external");
4262        let (_retained, root) = generated_pinned_external_root(&root_path)?;
4263        let data = b"generated pinned O_TMPFILE publication";
4264        let hash = sha256(data);
4265        let relative = Path::new("aa").join("bb").join("blob");
4266
4267        root.write_blob(&relative, data, &hash, true)?;
4268        root.write_blob(&relative, data, &hash, true)?;
4269
4270        assert_eq!(
4271            std::fs::read(root_path.join(&relative)).map_err(StoreError::Io)?,
4272            data
4273        );
4274        assert!(
4275            std::fs::read_dir(root_path.join("aa").join("bb"))
4276                .map_err(StoreError::Io)?
4277                .all(|entry| {
4278                    !entry
4279                        .expect("generated external directory entry")
4280                        .file_name()
4281                        .to_string_lossy()
4282                        .starts_with(".hashtree-external.tmp.")
4283                }),
4284            "pinned publication must never expose a mutable named temporary"
4285        );
4286        Ok(())
4287    }
4288
4289    #[cfg(target_os = "linux")]
4290    #[test]
4291    fn pinned_external_intermediate_symlink_cannot_touch_victim() -> Result<(), StoreError> {
4292        let temp = TempDir::new().map_err(StoreError::Io)?;
4293        let root_path = temp.path().join("external");
4294        let victim = temp.path().join("victim");
4295        std::fs::create_dir(&victim).map_err(StoreError::Io)?;
4296        let (_retained, root) = generated_pinned_external_root(&root_path)?;
4297        std::os::unix::fs::symlink(&victim, root_path.join("aa")).map_err(StoreError::Io)?;
4298        let data = b"generated symlink rejection";
4299        let hash = sha256(data);
4300
4301        let error = root
4302            .write_blob(Path::new("aa/bb/blob"), data, &hash, true)
4303            .expect_err("pinned external traversal followed an intermediate symlink");
4304        assert!(
4305            error.to_string().contains("Too many levels")
4306                || error.to_string().contains("Not a directory"),
4307            "unexpected pinned symlink error: {error}"
4308        );
4309        assert_eq!(
4310            std::fs::read_dir(&victim).map_err(StoreError::Io)?.count(),
4311            0,
4312            "failed pinned write touched the symlink victim"
4313        );
4314        Ok(())
4315    }
4316
4317    fn count_files_under(path: &std::path::Path) -> std::io::Result<usize> {
4318        if !path.exists() {
4319            return Ok(0);
4320        }
4321        let mut count = 0usize;
4322        for entry in std::fs::read_dir(path)? {
4323            let entry = entry?;
4324            let metadata = entry.metadata()?;
4325            if metadata.is_dir() {
4326                count += count_files_under(&entry.path())?;
4327            } else if metadata.is_file() {
4328                count += 1;
4329            }
4330        }
4331        Ok(count)
4332    }
4333
4334    fn persisted_next_order(store: &LmdbBlobStore) -> Result<u64, StoreError> {
4335        let rtxn = store.env.read_txn().map_err(map_heed_error)?;
4336        LmdbBlobStore::read_next_order(store.stats, &rtxn)?
4337            .ok_or_else(|| StoreError::Other("missing persisted next_order".to_string()))
4338    }
4339
4340    fn metadata_order(store: &LmdbBlobStore, hash: &Hash) -> Result<u64, StoreError> {
4341        let rtxn = store.env.read_txn().map_err(map_heed_error)?;
4342        let meta = store
4343            .metadata
4344            .get(&rtxn, hash)
4345            .map_err(map_heed_error)?
4346            .ok_or_else(|| StoreError::Other("missing blob metadata".to_string()))?;
4347        LmdbBlobStore::decode_blob_meta(meta).map(|meta| meta.order)
4348    }
4349
4350    fn delete_persisted_next_order(store: &LmdbBlobStore) -> Result<(), StoreError> {
4351        let mut wtxn = store.env.write_txn().map_err(map_heed_error)?;
4352        store
4353            .stats
4354            .delete(&mut wtxn, NEXT_ORDER_KEY)
4355            .map_err(map_heed_error)?;
4356        wtxn.commit().map_err(map_heed_error)
4357    }
4358
4359    #[cfg(unix)]
4360    fn run_helper(mode: &str, path: &Path, marker: &Path) {
4361        let output = Command::new(std::env::current_exe().expect("test binary path"))
4362            .arg("--ignored")
4363            .arg("--exact")
4364            .arg("tests::lmdb_stale_reader_helper")
4365            .env(STALE_READER_HELPER_ENV, "1")
4366            .env(STALE_READER_HELPER_MODE_ENV, mode)
4367            .env(STALE_READER_DB_PATH_ENV, path)
4368            .env(STALE_READER_MARKER_PATH_ENV, marker)
4369            .env("RUST_TEST_THREADS", "1")
4370            .output()
4371            .expect("spawn stale-reader helper");
4372
4373        assert!(
4374            output.status.success(),
4375            "stale-reader helper failed: stdout={} stderr={}",
4376            String::from_utf8_lossy(&output.stdout),
4377            String::from_utf8_lossy(&output.stderr)
4378        );
4379        assert!(
4380            marker.exists(),
4381            "stale-reader helper did not run: stdout={} stderr={}",
4382            String::from_utf8_lossy(&output.stdout),
4383            String::from_utf8_lossy(&output.stderr)
4384        );
4385    }
4386
4387    #[test]
4388    fn env_flags_from_bools_enables_bulk_ingest_flags() {
4389        let flags = env_flags_from_bools(true, true, true);
4390
4391        assert!(flags.contains(EnvFlags::NO_READ_AHEAD));
4392        assert!(flags.contains(EnvFlags::NO_SYNC));
4393        assert!(flags.contains(EnvFlags::NO_META_SYNC));
4394    }
4395
4396    #[test]
4397    fn sequential_migration_reader_enables_kernel_read_ahead() -> Result<(), StoreError> {
4398        let temp = TempDir::new().map_err(StoreError::Io)?;
4399        let sparse_path = temp.path().join("sparse");
4400        let sequential_path = temp.path().join("sequential");
4401        drop(LmdbBlobStore::new(&sparse_path)?);
4402        drop(LmdbBlobStore::new(&sequential_path)?);
4403
4404        let sparse = LmdbBlobReader::open(&sparse_path, None)?;
4405        let sparse_flags = sparse
4406            .store
4407            .env
4408            .flags()
4409            .map_err(map_heed_error)?
4410            .ok_or_else(|| {
4411                StoreError::Other("sparse reader has no LMDB environment flags".into())
4412            })?;
4413        assert!(sparse_flags.contains(EnvFlags::NO_READ_AHEAD));
4414
4415        let sequential = LmdbBlobReader::open_with_external_read_concurrency_inner(
4416            &sequential_path,
4417            None,
4418            1,
4419            None,
4420            true,
4421        )?;
4422        let sequential_flags = sequential
4423            .store
4424            .env
4425            .flags()
4426            .map_err(map_heed_error)?
4427            .ok_or_else(|| {
4428                StoreError::Other("sequential reader has no LMDB environment flags".into())
4429            })?;
4430        assert!(!sequential_flags.contains(EnvFlags::NO_READ_AHEAD));
4431        Ok(())
4432    }
4433
4434    #[tokio::test]
4435    async fn test_put_get() -> Result<(), StoreError> {
4436        let temp = TempDir::new().unwrap();
4437        let store = LmdbBlobStore::new(temp.path().join("blobs"))?;
4438
4439        let data = b"hello lmdb";
4440        let hash = sha256(data);
4441        store.put(hash, data.to_vec()).await?;
4442
4443        assert!(store.has(&hash).await?);
4444        assert_eq!(store.get(&hash).await?, Some(data.to_vec()));
4445
4446        Ok(())
4447    }
4448
4449    #[test]
4450    fn external_blob_spill_keeps_large_values_out_of_lmdb() -> Result<(), StoreError> {
4451        for sync in [false, true] {
4452            let temp = TempDir::new().unwrap();
4453            let external_dir = temp.path().join("external");
4454            let store = LmdbBlobStore::with_map_size_and_settings(
4455                temp.path().join("blobs"),
4456                16 * 1024 * 1024,
4457                EnvFlags::empty(),
4458                |_| {
4459                    Some(ExternalBlobConfig {
4460                        base_path: external_dir.clone(),
4461                        min_bytes: 8,
4462                        sync,
4463                        pack_target_bytes: None,
4464                        #[cfg(unix)]
4465                        pinned_root: None,
4466                    })
4467                },
4468            )?;
4469
4470            let small = b"tiny";
4471            let small_hash = sha256(small);
4472            assert!(store.put_sync(small_hash, small)?);
4473
4474            let large = b"large external blob payload";
4475            let large_hash = sha256(large);
4476            assert!(store.put_sync(large_hash, large)?);
4477
4478            assert_eq!(store.get_sync(&small_hash)?, Some(small.to_vec()));
4479            assert_eq!(store.get_sync(&large_hash)?, Some(large.to_vec()));
4480            assert_eq!(
4481                store.get_range_sync(&large_hash, 6, 13)?,
4482                Some(b"external".to_vec())
4483            );
4484
4485            let rtxn = store.env.read_txn().map_err(map_heed_error)?;
4486            let inline_value = store
4487                .blobs
4488                .get(&rtxn, &small_hash)
4489                .map_err(map_heed_error)?
4490                .expect("small inline value");
4491            assert_eq!(inline_value, small);
4492            let external_value = store
4493                .blobs
4494                .get(&rtxn, &large_hash)
4495                .map_err(map_heed_error)?
4496                .expect("large marker value");
4497            assert!(store.is_external_blob_ref(&large_hash, external_value));
4498            drop(rtxn);
4499
4500            let external_path = store.external_blob_path(&large_hash).unwrap();
4501            assert_eq!(std::fs::read(&external_path)?, large);
4502            let stats = store.stats()?;
4503            assert_eq!(stats.count, 2);
4504            assert_eq!(stats.total_bytes, (small.len() + large.len()) as u64);
4505
4506            assert!(store.delete_sync(&large_hash)?);
4507            assert!(!external_path.exists());
4508        }
4509        Ok(())
4510    }
4511
4512    #[test]
4513    fn external_blob_pack_batches_large_values() -> Result<(), StoreError> {
4514        let temp = TempDir::new().unwrap();
4515        let external_dir = temp.path().join("external");
4516        let store = LmdbBlobStore::with_map_size_and_settings(
4517            temp.path().join("blobs"),
4518            16 * 1024 * 1024,
4519            EnvFlags::empty(),
4520            |_| {
4521                Some(ExternalBlobConfig {
4522                    base_path: external_dir.clone(),
4523                    min_bytes: 8,
4524                    sync: true,
4525                    pack_target_bytes: Some(1024 * 1024),
4526                    #[cfg(unix)]
4527                    pinned_root: None,
4528                })
4529            },
4530        )?;
4531
4532        let first = b"first packed blob payload".to_vec();
4533        let second = b"second packed blob payload".to_vec();
4534        let tiny = b"tiny".to_vec();
4535        let first_hash = sha256(&first);
4536        let second_hash = sha256(&second);
4537        let tiny_hash = sha256(&tiny);
4538        let items = vec![
4539            (first_hash, first.clone()),
4540            (tiny_hash, tiny.clone()),
4541            (second_hash, second.clone()),
4542        ];
4543
4544        assert_eq!(store.put_many_sync(&items)?, 3);
4545        assert_eq!(store.get_sync(&first_hash)?, Some(first.clone()));
4546        assert_eq!(store.get_sync(&second_hash)?, Some(second.clone()));
4547        assert_eq!(store.get_sync(&tiny_hash)?, Some(tiny.clone()));
4548        assert_eq!(
4549            store.get_range_sync(&second_hash, 7, 12)?,
4550            Some(b"packed".to_vec())
4551        );
4552
4553        let rtxn = store.env.read_txn().map_err(map_heed_error)?;
4554        let first_value = store
4555            .blobs
4556            .get(&rtxn, &first_hash)
4557            .map_err(map_heed_error)?
4558            .expect("first pack marker");
4559        let second_value = store
4560            .blobs
4561            .get(&rtxn, &second_hash)
4562            .map_err(map_heed_error)?
4563            .expect("second pack marker");
4564        let tiny_value = store
4565            .blobs
4566            .get(&rtxn, &tiny_hash)
4567            .map_err(map_heed_error)?
4568            .expect("tiny inline value");
4569        let first_pack =
4570            LmdbBlobStore::decode_external_pack_ref(first_value)?.expect("first external pack ref");
4571        let second_pack = LmdbBlobStore::decode_external_pack_ref(second_value)?
4572            .expect("second external pack ref");
4573        assert_eq!(first_pack.name, second_pack.name);
4574        assert_eq!(tiny_value, tiny.as_slice());
4575        drop(rtxn);
4576
4577        let pack_path = store.external_pack_path(&first_pack).unwrap();
4578        assert!(pack_path.exists());
4579        assert_eq!(
4580            std::fs::read(&pack_path)?,
4581            [first.as_slice(), second.as_slice()].concat()
4582        );
4583        let pack_count_after_first_write = count_files_under(&external_dir.join("packs"))?;
4584
4585        assert_eq!(store.put_many_sync(&items)?, 0);
4586        assert_eq!(
4587            count_files_under(&external_dir.join("packs"))?,
4588            pack_count_after_first_write,
4589            "rewriting an already-present batch must not create orphan external pack files"
4590        );
4591
4592        assert!(store.delete_sync(&first_hash)?);
4593        assert!(pack_path.exists());
4594        assert_eq!(store.get_sync(&second_hash)?, Some(second));
4595        Ok(())
4596    }
4597
4598    #[test]
4599    fn terminal_member_audit_rejects_truncated_external_pack() -> Result<(), StoreError> {
4600        let temp = TempDir::new().unwrap();
4601        let external_dir = temp.path().join("external");
4602        let store = LmdbBlobStore::with_map_size_and_settings(
4603            temp.path().join("blobs"),
4604            16 * 1024 * 1024,
4605            EnvFlags::empty(),
4606            |_| {
4607                Some(ExternalBlobConfig {
4608                    base_path: external_dir,
4609                    min_bytes: 8,
4610                    sync: true,
4611                    pack_target_bytes: Some(1024 * 1024),
4612                    #[cfg(unix)]
4613                    pinned_root: None,
4614                })
4615            },
4616        )?;
4617        let first = b"first packed audit body".to_vec();
4618        let second = b"second packed audit body".to_vec();
4619        let items = vec![(sha256(&first), first), (sha256(&second), second)];
4620        assert_eq!(store.put_many_sync(&items)?, 2);
4621        store.validate_exact_member_state()?;
4622
4623        let rtxn = store.env.read_txn().map_err(map_heed_error)?;
4624        let marker = store
4625            .blobs
4626            .get(&rtxn, &items[0].0)
4627            .map_err(map_heed_error)?
4628            .expect("external pack marker");
4629        let pack =
4630            LmdbBlobStore::decode_external_pack_ref(marker)?.expect("decoded external pack marker");
4631        drop(rtxn);
4632        let pack_path = store.external_pack_path(&pack).expect("external pack path");
4633        let pack_len = std::fs::metadata(&pack_path)?.len();
4634        std::fs::OpenOptions::new()
4635            .write(true)
4636            .open(pack_path)?
4637            .set_len(pack_len - 1)?;
4638
4639        let error = store
4640            .validate_exact_member_state()
4641            .expect_err("truncated external pack must fail physical audit");
4642        assert!(error.to_string().contains("exceeds physical pack size"));
4643        Ok(())
4644    }
4645
4646    #[tokio::test]
4647    async fn test_delete() -> Result<(), StoreError> {
4648        let temp = TempDir::new().unwrap();
4649        let store = LmdbBlobStore::new(temp.path().join("blobs"))?;
4650
4651        let data = b"delete me";
4652        let hash = sha256(data);
4653        store.put(hash, data.to_vec()).await?;
4654        assert!(store.has(&hash).await?);
4655
4656        assert!(store.delete(&hash).await?);
4657        assert!(!store.has(&hash).await?);
4658        assert!(!store.delete(&hash).await?);
4659
4660        Ok(())
4661    }
4662
4663    #[test]
4664    fn batch_delete_removes_unique_hashes_in_one_operation() -> Result<(), StoreError> {
4665        let temp = TempDir::new().unwrap();
4666        let store = LmdbBlobStore::new(temp.path().join("blobs"))?;
4667        let first = sha256(b"batch-delete-first");
4668        let second = sha256(b"batch-delete-second");
4669        let retained = sha256(b"batch-delete-retained");
4670        store.put_sync(first, b"batch-delete-first")?;
4671        store.put_sync(second, b"batch-delete-second")?;
4672        store.put_sync(retained, b"batch-delete-retained")?;
4673
4674        assert_eq!(store.delete_many_sync(&[first, second, first])?, 2);
4675        assert!(!store.exists(&first)?);
4676        assert!(!store.exists(&second)?);
4677        assert!(store.exists(&retained)?);
4678        assert_eq!(store.stats()?.count, 1);
4679        Ok(())
4680    }
4681
4682    #[tokio::test]
4683    async fn test_list() -> Result<(), StoreError> {
4684        let temp = TempDir::new().unwrap();
4685        let store = LmdbBlobStore::new(temp.path().join("blobs"))?;
4686
4687        let d1 = b"one";
4688        let d2 = b"two";
4689        let d3 = b"three";
4690        let h1 = sha256(d1);
4691        let h2 = sha256(d2);
4692        let h3 = sha256(d3);
4693
4694        store.put(h1, d1.to_vec()).await?;
4695        store.put(h2, d2.to_vec()).await?;
4696        store.put(h3, d3.to_vec()).await?;
4697
4698        let hashes = store.list()?;
4699        assert_eq!(hashes.len(), 3);
4700        assert!(hashes.contains(&h1));
4701        assert!(hashes.contains(&h2));
4702        assert!(hashes.contains(&h3));
4703
4704        Ok(())
4705    }
4706
4707    #[test]
4708    fn scan_hashes_after_is_bounded_ordered_and_resumable() -> Result<(), StoreError> {
4709        let temp = TempDir::new().unwrap();
4710        let store = LmdbBlobStore::new(temp.path().join("blobs"))?;
4711        for index in 0..11 {
4712            let data = format!("cursor blob {index:02}").into_bytes();
4713            store.put_sync(sha256(&data), &data)?;
4714        }
4715        let mut expected = store.list()?;
4716        expected.sort_unstable();
4717
4718        let mut actual = Vec::new();
4719        let mut cursor = None;
4720        loop {
4721            let page = store.scan_hashes_after(cursor, 3)?;
4722            assert!(page.len() <= 3);
4723            if page.is_empty() {
4724                break;
4725            }
4726            cursor = page.last().copied();
4727            actual.extend(page);
4728        }
4729        assert_eq!(actual, expected);
4730        assert!(store.scan_hashes_after(cursor, 0)?.is_empty());
4731        Ok(())
4732    }
4733
4734    #[test]
4735    fn existing_hashes_in_sorted_candidates_marks_present_hashes() -> Result<(), StoreError> {
4736        let temp = TempDir::new().unwrap();
4737        let store = LmdbBlobStore::new(temp.path().join("blobs"))?;
4738
4739        let h1 = sha256(b"range present one");
4740        let h2 = sha256(b"range present two");
4741        let missing = sha256(b"range missing");
4742        store.put_sync(h1, b"range present one")?;
4743        store.put_sync(h2, b"range present two")?;
4744
4745        let mut candidates = vec![missing, h2, h1, h1];
4746        candidates.sort_unstable();
4747        let existing = store.existing_hashes_in_sorted_candidates(&candidates)?;
4748
4749        assert_eq!(existing.len(), candidates.len());
4750        for (hash, exists) in candidates.iter().zip(existing) {
4751            assert_eq!(exists, *hash == h1 || *hash == h2);
4752        }
4753
4754        Ok(())
4755    }
4756
4757    #[test]
4758    fn exact_blob_key_candidates_allow_sparse_overlap_and_require_strict_order(
4759    ) -> Result<(), StoreError> {
4760        let temp = TempDir::new().unwrap();
4761        let store = LmdbBlobStore::new(temp.path().join("blobs"))?;
4762        let mut entries = [
4763            b"raw key one".as_slice(),
4764            b"raw key two".as_slice(),
4765            b"raw key three".as_slice(),
4766        ]
4767        .map(|data| (sha256(data), data));
4768        entries.sort_unstable_by_key(|(hash, _)| *hash);
4769        for (hash, data) in entries {
4770            store.put_sync(hash, data)?;
4771        }
4772        let hashes = entries.map(|(hash, _)| hash);
4773
4774        assert_eq!(
4775            store.existing_blob_keys_in_sorted_candidates(&[hashes[0], hashes[2]])?,
4776            vec![true, true],
4777            "already-covered raw keys may occur between new audit candidates"
4778        );
4779        assert!(store.blob_keys_match_bounded_raw_range(&[hashes[0], hashes[2]], hashes[2], 3,)?);
4780        assert!(
4781            !store.blob_keys_match_bounded_raw_range(&[hashes[0], hashes[2]], hashes[2], 2,)?,
4782            "the proof must not scan beyond the checkpoint key count"
4783        );
4784        let error = store
4785            .existing_blob_keys_in_sorted_candidates(&[hashes[0], hashes[0]])
4786            .expect_err("duplicate candidates must fail closed");
4787        assert!(error.to_string().contains("unique and strictly sorted"));
4788        Ok(())
4789    }
4790
4791    #[test]
4792    fn generated_partial_metadata_source_scans_and_audits_every_raw_blob_row(
4793    ) -> Result<(), StoreError> {
4794        let temp = TempDir::new().unwrap();
4795        let path = temp.path().join("blobs");
4796        let external_dir = temp.path().join("external");
4797        let legacy_data = b"generated legacy loose external blob without metadata";
4798        let legacy_hash = sha256(legacy_data);
4799        let new_data = b"new inline";
4800        let new_hash = sha256(new_data);
4801
4802        let store = LmdbBlobStore::with_map_size_and_settings(
4803            &path,
4804            16 * 1024 * 1024,
4805            EnvFlags::empty(),
4806            |_| {
4807                Some(ExternalBlobConfig {
4808                    base_path: external_dir.clone(),
4809                    min_bytes: 16,
4810                    sync: true,
4811                    pack_target_bytes: None,
4812                    #[cfg(unix)]
4813                    pinned_root: None,
4814                })
4815            },
4816        )?;
4817        store.put_sync(legacy_hash, legacy_data)?;
4818        let mut wtxn = store.env.write_txn().map_err(map_heed_error)?;
4819        store
4820            .metadata
4821            .delete(&mut wtxn, &legacy_hash)
4822            .map_err(map_heed_error)?;
4823        wtxn.commit().map_err(map_heed_error)?;
4824        store.put_sync(new_hash, new_data)?;
4825        store.force_sync()?;
4826        drop(store);
4827
4828        let mixed_reader = LmdbBlobReader::open(
4829            &path,
4830            Some(ExternalBlobOptions {
4831                base_path: external_dir,
4832                min_bytes: 1,
4833                sync: true,
4834                pack_target_bytes: None,
4835            }),
4836        )?;
4837        let mut expected = vec![legacy_hash, new_hash];
4838        expected.sort_unstable();
4839        assert_eq!(mixed_reader.scan_hashes_after(None, 8)?, expected);
4840        let audit = mixed_reader.validate_terminal_migration_keyset()?;
4841        assert_eq!(audit.blob_entries, 2);
4842        assert_eq!(audit.metadata_entries, 1);
4843        assert_eq!(audit.blob_only_entries, 1);
4844        assert!(!audit.legacy_blob_only);
4845        assert_eq!(audit.inline_entries, 1);
4846        assert_eq!(audit.loose_external_entries, 1);
4847        assert_eq!(audit.packed_external_entries, 0);
4848        let mut cursor = None;
4849        let mut evidence = Vec::new();
4850        loop {
4851            let source = crate::migration::audit_lmdb_source_batch_with_max_buffer_bytes(
4852                &mixed_reader,
4853                cursor,
4854                1,
4855                1024 * 1024,
4856            )?;
4857            if source.source_exhausted {
4858                break;
4859            }
4860            assert_eq!(source.scanned, 1);
4861            assert_eq!(source.verified, 1);
4862            cursor = source.last_hash;
4863            evidence.extend(source.verified_source_entries);
4864        }
4865        assert_eq!(
4866            evidence.iter().map(|(hash, _)| *hash).collect::<Vec<_>>(),
4867            expected
4868        );
4869
4870        let pool = PoolStore::open(temp.path().join("pool"), PoolStoreConfig::default())?;
4871        pool.add_member(PoolMemberConfig::new(
4872            temp.path().join("pool-member"),
4873            16 * 1024 * 1024,
4874        ))?;
4875        let union = evidence
4876            .iter()
4877            .map(|(hash, size)| (*hash, *size, 0usize))
4878            .collect::<Vec<_>>();
4879        let migrated =
4880            crate::migration::reconcile_lmdb_source_union_page_with_max_buffer_bytes_and_authorizer(
4881                &[&mixed_reader],
4882                &pool,
4883                &union,
4884                1024 * 1024,
4885                &mut |_, _| Ok(()),
4886            )?;
4887        assert_eq!(migrated.scanned, 2);
4888        assert_eq!(migrated.verified, 2);
4889        assert_eq!(migrated.inserted, 2);
4890        assert_eq!(pool.get_sync(&legacy_hash)?, Some(legacy_data.to_vec()));
4891        assert_eq!(pool.get_sync(&new_hash)?, Some(new_data.to_vec()));
4892        Ok(())
4893    }
4894
4895    #[test]
4896    fn generated_metadata_without_raw_blob_row_is_rejected() -> Result<(), StoreError> {
4897        let temp = TempDir::new().unwrap();
4898        let path = temp.path().join("blobs");
4899        let data = b"generated metadata-only row";
4900        let hash = sha256(data);
4901        let store = LmdbBlobStore::new(&path)?;
4902        store.put_sync(hash, data)?;
4903        let mut wtxn = store.env.write_txn().map_err(map_heed_error)?;
4904        store
4905            .blobs
4906            .delete(&mut wtxn, &hash)
4907            .map_err(map_heed_error)?;
4908        wtxn.commit().map_err(map_heed_error)?;
4909        store.force_sync()?;
4910        drop(store);
4911
4912        let reader = LmdbBlobReader::open(&path, None)?;
4913        assert_eq!(
4914            reader.existing_hashes_in_sorted_candidates(&[hash])?,
4915            vec![true],
4916            "the legacy metadata-or-blob helper demonstrates why it cannot prove raw keys"
4917        );
4918        assert_eq!(
4919            reader.existing_blob_keys_in_sorted_candidates(&[hash])?,
4920            vec![false],
4921            "metadata-only rows must not satisfy exact raw source membership"
4922        );
4923        assert!(reader.scan_hashes_from(hash, 1)?.is_empty());
4924        let error = reader
4925            .validate_terminal_migration_keyset()
4926            .expect_err("metadata without a raw blob row must withhold completion");
4927        assert!(error.to_string().contains("has no raw blob row"));
4928        Ok(())
4929    }
4930
4931    #[test]
4932    fn key_only_reconciliation_never_reads_an_already_stored_source_value() -> Result<(), StoreError>
4933    {
4934        let temp = TempDir::new().unwrap();
4935        let source_path = temp.path().join("source");
4936        let valid = vec![0x42; 128 * 1024];
4937        let hash = sha256(&valid);
4938        let source = LmdbBlobStore::new(&source_path)?;
4939        source.put_sync(hash, &valid)?;
4940
4941        // Keep the authoritative content-addressed source key but corrupt its
4942        // value at equal length. A reconciliation that touches the source
4943        // value must fail; the key-only Stored path must not.
4944        let corrupt = vec![0xa5; valid.len()];
4945        let mut wtxn = source.env.write_txn().map_err(map_heed_error)?;
4946        source
4947            .blobs
4948            .put(&mut wtxn, &hash, &corrupt)
4949            .map_err(map_heed_error)?;
4950        wtxn.commit().map_err(map_heed_error)?;
4951        source.force_sync()?;
4952        drop(source);
4953        let source = LmdbBlobReader::open(&source_path, None)?;
4954
4955        let pool = PoolStore::open(temp.path().join("pool"), PoolStoreConfig::default())?;
4956        pool.add_member(PoolMemberConfig::new(
4957            temp.path().join("pool-member"),
4958            16 * 1024 * 1024,
4959        ))?;
4960        assert!(pool.put_sync(hash, &valid)?);
4961        pool.force_sync()?;
4962
4963        let batch =
4964            crate::migration::reconcile_lmdb_source_batch_with_max_buffer_bytes_and_authorizer(
4965                &source,
4966                &pool,
4967                None,
4968                1,
4969                1024 * 1024,
4970                &mut |_, _| Ok(()),
4971            )?;
4972        assert_eq!(batch.scanned, 1);
4973        assert_eq!(batch.already_present, 1);
4974        assert_eq!(batch.verified, 0);
4975        assert_eq!(batch.source_read_groups, 0);
4976        assert_eq!(batch.source_entries, vec![(hash, valid.len() as u64)]);
4977        assert_eq!(pool.get_sync(&hash)?, Some(valid));
4978        Ok(())
4979    }
4980
4981    #[test]
4982    fn key_only_reconciliation_still_hashes_a_missing_target_source_body() -> Result<(), StoreError>
4983    {
4984        let temp = TempDir::new().unwrap();
4985        let source_path = temp.path().join("source");
4986        let valid = b"a missing target must be recovered from verified source bytes";
4987        let hash = sha256(valid);
4988        let source = LmdbBlobStore::new(&source_path)?;
4989        source.put_sync(hash, valid)?;
4990        let corrupt = vec![0x3c; valid.len()];
4991        let mut wtxn = source.env.write_txn().map_err(map_heed_error)?;
4992        source
4993            .blobs
4994            .put(&mut wtxn, &hash, &corrupt)
4995            .map_err(map_heed_error)?;
4996        wtxn.commit().map_err(map_heed_error)?;
4997        source.force_sync()?;
4998        drop(source);
4999        let source = LmdbBlobReader::open(&source_path, None)?;
5000
5001        let pool = PoolStore::open(temp.path().join("pool"), PoolStoreConfig::default())?;
5002        pool.add_member(PoolMemberConfig::new(
5003            temp.path().join("pool-member"),
5004            16 * 1024 * 1024,
5005        ))?;
5006        let error =
5007            crate::migration::reconcile_lmdb_source_batch_with_max_buffer_bytes_and_authorizer(
5008                &source,
5009                &pool,
5010                None,
5011                1,
5012                1024 * 1024,
5013                &mut |_, _| Ok(()),
5014            )
5015            .expect_err("missing target must not accept corrupt source bytes");
5016        assert!(error
5017            .to_string()
5018            .contains("differs from its content-addressed key"));
5019        assert_eq!(pool.get_sync(&hash)?, None);
5020        Ok(())
5021    }
5022
5023    #[test]
5024    fn terminal_source_key_audit_is_streaming_and_order_bound() -> Result<(), StoreError> {
5025        let mut hashes = vec![
5026            sha256(b"terminal source key one"),
5027            sha256(b"terminal source key two"),
5028            sha256(b"terminal source key three"),
5029        ];
5030        hashes.sort_unstable();
5031        let mut paged = LmdbSourceKeyAuditBuilder::new();
5032        paged.append_sorted(&hashes[..1])?;
5033        paged.append_sorted(&hashes[1..])?;
5034        let paged = paged.finish();
5035
5036        let mut single = LmdbSourceKeyAuditBuilder::new();
5037        single.append_sorted(&hashes)?;
5038        assert_eq!(paged, single.finish());
5039        assert_eq!(paged.blob_entries, 3);
5040
5041        let mut invalid = LmdbSourceKeyAuditBuilder::new();
5042        invalid.append_sorted(&hashes[1..])?;
5043        assert!(invalid
5044            .append_sorted(&hashes[..1])
5045            .expect_err("global key order must be strict")
5046            .to_string()
5047            .contains("globally unique"));
5048        Ok(())
5049    }
5050
5051    #[tokio::test]
5052    async fn test_blob_last_accessed_persists_and_updates_eviction_order() -> Result<(), StoreError>
5053    {
5054        let temp = TempDir::new().unwrap();
5055        let store = LmdbBlobStore::new(temp.path().join("blobs"))?;
5056
5057        let first = sha256(b"first");
5058        let second = sha256(b"second");
5059        store.put(first, b"first".to_vec()).await?;
5060        store.put(second, b"second".to_vec()).await?;
5061
5062        assert!(store.last_accessed_at_sync(&first)?.unwrap_or(0) > 0);
5063        let access_time = unix_timestamp_now().saturating_add(1000);
5064        store.touch_accessed_sync(&first, access_time)?;
5065
5066        assert_eq!(store.last_accessed_at_sync(&first)?, Some(access_time));
5067        assert!(store.delete_sync(&first)?);
5068        assert!(store.exists(&second)?);
5069
5070        Ok(())
5071    }
5072
5073    #[test]
5074    fn duplicate_put_is_noop_and_preserves_blob_last_accessed() -> Result<(), StoreError> {
5075        let temp = TempDir::new().unwrap();
5076        let store = LmdbBlobStore::new(temp.path().join("blobs"))?;
5077
5078        let data = b"already here";
5079        let hash = sha256(data);
5080        assert!(store.put_sync(hash, data)?);
5081        let accessed = store.last_accessed_at_sync(&hash)?;
5082        let next_order = persisted_next_order(&store)?;
5083
5084        assert!(!store.put_sync(hash, data)?);
5085        assert_eq!(store.last_accessed_at_sync(&hash)?, accessed);
5086        assert_eq!(persisted_next_order(&store)?, next_order);
5087
5088        let stats = store.stats()?;
5089        assert_eq!(stats.count, 1);
5090        assert_eq!(stats.total_bytes, data.len() as u64);
5091
5092        Ok(())
5093    }
5094
5095    #[test]
5096    fn next_order_persists_across_reopen_and_counts_only_new_blobs() -> Result<(), StoreError> {
5097        let temp = TempDir::new().unwrap();
5098        let path = temp.path().join("blobs");
5099        let first = sha256(b"order first");
5100        let second = sha256(b"order second");
5101        let third = sha256(b"order third");
5102
5103        {
5104            let store = LmdbBlobStore::new(&path)?;
5105            assert!(store.put_sync(first, b"order first")?);
5106            assert!(store.put_sync(second, b"order second")?);
5107            assert_eq!(metadata_order(&store, &first)?, 0);
5108            assert_eq!(metadata_order(&store, &second)?, 1);
5109            assert_eq!(persisted_next_order(&store)?, 2);
5110
5111            assert!(!store.put_sync(first, b"order first")?);
5112            assert_eq!(
5113                store.put_many_sync(&[(second, b"order second".to_vec())])?,
5114                0
5115            );
5116            assert_eq!(persisted_next_order(&store)?, 2);
5117        }
5118
5119        let reopened = LmdbBlobStore::new(&path)?;
5120        assert_eq!(persisted_next_order(&reopened)?, 2);
5121        assert!(reopened.put_sync(third, b"order third")?);
5122        assert_eq!(metadata_order(&reopened, &third)?, 2);
5123        assert_eq!(persisted_next_order(&reopened)?, 3);
5124
5125        Ok(())
5126    }
5127
5128    #[test]
5129    fn legacy_store_without_next_order_seeds_high_counter_without_tail_scan(
5130    ) -> Result<(), StoreError> {
5131        let temp = TempDir::new().unwrap();
5132        let path = temp.path().join("blobs");
5133        let existing = sha256(b"legacy existing");
5134        let new = sha256(b"legacy new");
5135
5136        {
5137            let store = LmdbBlobStore::new(&path)?;
5138            assert!(store.put_sync(existing, b"legacy existing")?);
5139            delete_persisted_next_order(&store)?;
5140        }
5141
5142        let reopened = LmdbBlobStore::new(&path)?;
5143        let migrated_next_order = persisted_next_order(&reopened)?;
5144        assert!(migrated_next_order >= NEXT_ORDER_MIGRATION_FLOOR);
5145        assert!(reopened.put_sync(new, b"legacy new")?);
5146        assert_eq!(metadata_order(&reopened, &new)?, migrated_next_order);
5147        assert_eq!(
5148            persisted_next_order(&reopened)?,
5149            migrated_next_order.saturating_add(1)
5150        );
5151
5152        Ok(())
5153    }
5154
5155    #[test]
5156    fn put_many_report_counts_only_new_hashes_and_bytes() -> Result<(), StoreError> {
5157        let temp = TempDir::new().unwrap();
5158        let store = LmdbBlobStore::new(temp.path().join("blobs"))?;
5159
5160        let existing = b"existing";
5161        let existing_hash = sha256(existing);
5162        assert!(store.put_sync(existing_hash, existing)?);
5163
5164        let new_one = b"new one";
5165        let new_two = b"new two";
5166        let new_one_hash = sha256(new_one);
5167        let new_two_hash = sha256(new_two);
5168        let items = vec![
5169            (existing_hash, existing.to_vec()),
5170            (new_one_hash, new_one.to_vec()),
5171            (new_one_hash, new_one.to_vec()),
5172            (new_two_hash, new_two.to_vec()),
5173        ];
5174
5175        let report = store.put_many_report_sync(&items)?;
5176
5177        assert_eq!(report.total, 4);
5178        assert_eq!(report.inserted, 2);
5179        assert_eq!(
5180            report.inserted_bytes,
5181            (new_one.len() + new_two.len()) as u64
5182        );
5183        assert_eq!(report.inserted_hashes, vec![new_one_hash, new_two_hash]);
5184        assert_eq!(store.put_many_sync(&items)?, 0);
5185        assert_eq!(
5186            store.stats()?.total_bytes,
5187            (existing.len() + new_one.len() + new_two.len()) as u64
5188        );
5189
5190        Ok(())
5191    }
5192
5193    fn assert_unbounded_map_full_preserves_history(batch_write: bool) -> Result<(), StoreError> {
5194        let temp = TempDir::new().unwrap();
5195        let store = LmdbBlobStore::with_exact_map_size_and_external_blob_options(
5196            temp.path().join("blobs"),
5197            512 * 1024,
5198            None,
5199        )?;
5200        assert!(store.max_bytes().is_none());
5201        let mut retained = Vec::new();
5202        let mut exhausted = false;
5203        for batch in 0..128u64 {
5204            let items = (0..1usize)
5205                .map(|offset| {
5206                    let mut bytes = vec![0x5a; 16 * 1024];
5207                    let sequence = batch + offset as u64;
5208                    bytes[..8].copy_from_slice(&sequence.to_le_bytes());
5209                    (sha256(&bytes), bytes)
5210                })
5211                .collect::<Vec<_>>();
5212            let result = if !batch_write {
5213                store.put_sync(items[0].0, &items[0].1).map(usize::from)
5214            } else {
5215                store.put_many_sync(&items)
5216            };
5217            match result {
5218                Ok(_) => retained.extend(items),
5219                Err(error) => {
5220                    assert!(is_map_full_store_error(&error), "unexpected error: {error}");
5221                    exhausted = true;
5222                    break;
5223                }
5224            }
5225        }
5226        assert!(!retained.is_empty());
5227        for (hash, bytes) in &retained {
5228            assert!(
5229                store.get_sync(hash)?.as_deref() == Some(bytes.as_slice()),
5230                "map pressure deleted previously committed history"
5231            );
5232        }
5233        assert!(
5234            exhausted,
5235            "bounded map must reject a write instead of evicting archive history"
5236        );
5237        assert_eq!(store.stats()?.count, retained.len());
5238        Ok(())
5239    }
5240
5241    #[test]
5242    fn unbounded_single_write_map_full_preserves_history() -> Result<(), StoreError> {
5243        assert_unbounded_map_full_preserves_history(false)
5244    }
5245
5246    #[test]
5247    fn unbounded_batch_write_map_full_preserves_history() -> Result<(), StoreError> {
5248        assert_unbounded_map_full_preserves_history(true)
5249    }
5250
5251    #[test]
5252    fn duplicate_heavy_batch_does_not_evict_by_candidate_bytes() -> Result<(), StoreError> {
5253        let temp = TempDir::new().unwrap();
5254        let store = LmdbBlobStore::new(temp.path().join("blobs"))?;
5255        store.set_max_bytes(35);
5256
5257        let first = [1u8; 10];
5258        let second = [2u8; 10];
5259        let third = [3u8; 10];
5260        let new = [4u8; 5];
5261        let first_hash = sha256(&first);
5262        let second_hash = sha256(&second);
5263        let third_hash = sha256(&third);
5264        let new_hash = sha256(&new);
5265        assert!(store.put_sync(first_hash, &first)?);
5266        assert!(store.put_sync(second_hash, &second)?);
5267        assert!(store.put_sync(third_hash, &third)?);
5268        assert_eq!(store.stats()?.total_bytes, 30);
5269
5270        let report = store.put_many_report_sync(&[
5271            (first_hash, first.to_vec()),
5272            (second_hash, second.to_vec()),
5273            (new_hash, new.to_vec()),
5274        ])?;
5275
5276        assert_eq!(report.inserted, 1);
5277        assert_eq!(report.inserted_bytes, 5);
5278        assert_eq!(store.stats()?.total_bytes, 35);
5279        assert!(store.exists(&first_hash)?);
5280        assert!(store.exists(&second_hash)?);
5281        assert!(store.exists(&third_hash)?);
5282        assert!(store.exists(&new_hash)?);
5283
5284        Ok(())
5285    }
5286
5287    #[test]
5288    fn test_decodes_legacy_blob_metadata_without_access_time() -> Result<(), StoreError> {
5289        let mut encoded = [0u8; LEGACY_BLOB_META_BYTES];
5290        encoded[..8].copy_from_slice(&7u64.to_be_bytes());
5291        encoded[8..].copy_from_slice(&42u64.to_be_bytes());
5292
5293        let meta = LmdbBlobStore::decode_blob_meta(&encoded)?;
5294
5295        assert_eq!(meta.order, 7);
5296        assert_eq!(meta.size, 42);
5297        assert_eq!(meta.last_accessed_at, 0);
5298        Ok(())
5299    }
5300
5301    #[tokio::test]
5302    async fn test_stats() -> Result<(), StoreError> {
5303        let temp = TempDir::new().unwrap();
5304        let store = LmdbBlobStore::new(temp.path().join("blobs"))?;
5305
5306        let d1 = b"hello";
5307        let d2 = b"world";
5308        store.put(sha256(d1), d1.to_vec()).await?;
5309        store.put(sha256(d2), d2.to_vec()).await?;
5310
5311        let stats = store.stats()?;
5312        assert_eq!(stats.count, 2);
5313        assert_eq!(stats.total_bytes, 10);
5314
5315        Ok(())
5316    }
5317
5318    #[tokio::test]
5319    async fn test_stats_persist_across_reopen_and_mutations() -> Result<(), StoreError> {
5320        let temp = TempDir::new().unwrap();
5321        let path = temp.path().join("blobs");
5322        let h1 = sha256(b"hello");
5323        let h2 = sha256(b"world!");
5324        let h3 = sha256(b"prepin");
5325
5326        {
5327            let store = LmdbBlobStore::new(&path)?;
5328            store.put(h1, b"hello".to_vec()).await?;
5329            store.put(h2, b"world!".to_vec()).await?;
5330            store.pin(&h1).await?;
5331            store.pin(&h3).await?;
5332            store.put(h3, b"prepin".to_vec()).await?;
5333
5334            let stats = store.stats()?;
5335            assert_eq!(stats.count, 3);
5336            assert_eq!(stats.total_bytes, 17);
5337            assert_eq!(stats.pinned_count, 2);
5338            assert_eq!(stats.pinned_bytes, 11);
5339        }
5340
5341        {
5342            let reopened = LmdbBlobStore::new(&path)?;
5343            let stats = reopened.stats()?;
5344            assert_eq!(stats.count, 3);
5345            assert_eq!(stats.total_bytes, 17);
5346            assert_eq!(stats.pinned_count, 2);
5347            assert_eq!(stats.pinned_bytes, 11);
5348
5349            assert!(reopened.delete(&h1).await?);
5350            let stats = reopened.stats()?;
5351            assert_eq!(stats.count, 2);
5352            assert_eq!(stats.total_bytes, 12);
5353            assert_eq!(stats.pinned_count, 1);
5354            assert_eq!(stats.pinned_bytes, 6);
5355        }
5356
5357        let reopened = LmdbBlobStore::new(&path)?;
5358        let stats = reopened.stats()?;
5359        assert_eq!(stats.count, 2);
5360        assert_eq!(stats.total_bytes, 12);
5361        assert_eq!(stats.pinned_count, 1);
5362        assert_eq!(stats.pinned_bytes, 6);
5363
5364        Ok(())
5365    }
5366
5367    #[tokio::test]
5368    async fn test_deduplication() -> Result<(), StoreError> {
5369        let temp = TempDir::new().unwrap();
5370        let store = LmdbBlobStore::new(temp.path().join("blobs"))?;
5371
5372        let data = b"same";
5373        let hash = sha256(data);
5374        assert!(store.put(hash, data.to_vec()).await?); // Returns true (newly stored)
5375        assert!(!store.put(hash, data.to_vec()).await?); // Returns false (already existed)
5376
5377        assert_eq!(store.list()?.len(), 1);
5378
5379        Ok(())
5380    }
5381
5382    #[tokio::test]
5383    async fn test_max_bytes() -> Result<(), StoreError> {
5384        let temp = TempDir::new().unwrap();
5385        let store = LmdbBlobStore::new(temp.path().join("blobs"))?;
5386
5387        assert!(store.max_bytes().is_none());
5388
5389        store.set_max_bytes(1000);
5390        assert_eq!(store.max_bytes(), Some(1000));
5391
5392        store.set_max_bytes(0);
5393        assert!(store.max_bytes().is_none());
5394
5395        Ok(())
5396    }
5397
5398    #[test]
5399    fn test_with_max_bytes_expands_lmdb_map_size() -> Result<(), StoreError> {
5400        let temp = TempDir::new().unwrap();
5401        let requested = (DEFAULT_MAP_SIZE as u64) + 64 * 1024 * 1024;
5402        let store = LmdbBlobStore::with_max_bytes(temp.path().join("blobs"), requested)?;
5403
5404        assert!(
5405            store.map_size_bytes() as u64 >= requested,
5406            "expected LMDB map to grow to at least {requested} bytes, got {}",
5407            store.map_size_bytes()
5408        );
5409        assert_eq!(store.max_bytes(), Some(requested));
5410
5411        Ok(())
5412    }
5413
5414    #[tokio::test]
5415    async fn test_eviction_over_limit() -> Result<(), StoreError> {
5416        let temp = TempDir::new().unwrap();
5417        let store = LmdbBlobStore::new(temp.path().join("blobs"))?;
5418        store.set_max_bytes(25);
5419
5420        let h1 = sha256(b"aaaaaaaaaa");
5421        let h2 = sha256(b"bbbbbbbbbb");
5422        let h3 = sha256(b"cccccccccc");
5423
5424        store.put(h1, b"aaaaaaaaaa".to_vec()).await?;
5425        store.put(h2, b"bbbbbbbbbb".to_vec()).await?;
5426        store.put(h3, b"cccccccccc".to_vec()).await?;
5427
5428        let freed = store.evict_if_needed().await?;
5429        assert_eq!(
5430            freed, 0,
5431            "write path should have already evicted stale blobs"
5432        );
5433
5434        assert!(
5435            !store.has(&h1).await?,
5436            "oldest blob should be evicted before the third write"
5437        );
5438        assert!(store.has(&h2).await?);
5439        assert!(store.has(&h3).await?);
5440
5441        let stats = store.stats()?;
5442        assert!(
5443            stats.total_bytes <= 22,
5444            "store should be reduced to 90% target"
5445        );
5446
5447        Ok(())
5448    }
5449
5450    #[tokio::test]
5451    async fn test_eviction_respects_pins() -> Result<(), StoreError> {
5452        let temp = TempDir::new().unwrap();
5453        let store = LmdbBlobStore::new(temp.path().join("blobs"))?;
5454        store.set_max_bytes(25);
5455
5456        let h1 = sha256(b"aaaaaaaaaa");
5457        let h2 = sha256(b"bbbbbbbbbb");
5458        let h3 = sha256(b"cccccccccc");
5459
5460        store.put(h1, b"aaaaaaaaaa".to_vec()).await?;
5461        store.put(h2, b"bbbbbbbbbb".to_vec()).await?;
5462        store.pin(&h1).await?;
5463        store.put(h3, b"cccccccccc".to_vec()).await?;
5464
5465        let freed = store.evict_if_needed().await?;
5466        assert_eq!(
5467            freed, 0,
5468            "write path should have already evicted stale blobs"
5469        );
5470
5471        assert!(store.has(&h1).await?, "pinned blob must not be evicted");
5472        assert!(
5473            !store.has(&h2).await?,
5474            "oldest unpinned blob should be evicted before the third write"
5475        );
5476        assert!(store.has(&h3).await?);
5477
5478        Ok(())
5479    }
5480
5481    #[tokio::test]
5482    async fn test_reopen_with_existing_eviction_order() -> Result<(), StoreError> {
5483        let temp = TempDir::new().unwrap();
5484        let path = temp.path().join("blobs");
5485
5486        {
5487            let store = LmdbBlobStore::new(&path)?;
5488            let h1 = sha256(b"aaaaaaaaaa");
5489            let h2 = sha256(b"bbbbbbbbbb");
5490            store.put(h1, b"aaaaaaaaaa".to_vec()).await?;
5491            store.put(h2, b"bbbbbbbbbb".to_vec()).await?;
5492        }
5493
5494        let reopened = LmdbBlobStore::new(&path)?;
5495        let h3 = sha256(b"cccccccccc");
5496        assert!(reopened.put(h3, b"cccccccccc".to_vec()).await?);
5497        assert!(reopened.has(&h3).await?);
5498
5499        Ok(())
5500    }
5501
5502    #[tokio::test]
5503    async fn test_reopen_with_lower_max_bytes_evicts_existing_blobs() -> Result<(), StoreError> {
5504        let temp = TempDir::new().unwrap();
5505        let path = temp.path().join("blobs");
5506
5507        {
5508            let store = LmdbBlobStore::new(&path)?;
5509            let h1 = sha256(b"aaaaaaaaaa");
5510            let h2 = sha256(b"bbbbbbbbbb");
5511            let h3 = sha256(b"cccccccccc");
5512            store.put(h1, b"aaaaaaaaaa".to_vec()).await?;
5513            store.put(h2, b"bbbbbbbbbb".to_vec()).await?;
5514            store.put(h3, b"cccccccccc".to_vec()).await?;
5515        }
5516
5517        let reopened = LmdbBlobStore::with_max_bytes(&path, 25)?;
5518        let h1 = sha256(b"aaaaaaaaaa");
5519        let h2 = sha256(b"bbbbbbbbbb");
5520        let h3 = sha256(b"cccccccccc");
5521
5522        assert!(
5523            !reopened.has(&h1).await?,
5524            "oldest blob should be evicted when reopening over the new cap"
5525        );
5526        assert!(reopened.has(&h2).await?);
5527        assert!(reopened.has(&h3).await?);
5528
5529        let stats = reopened.stats()?;
5530        assert!(
5531            stats.total_bytes <= 22,
5532            "reopened store should be reduced to the 90% target"
5533        );
5534
5535        Ok(())
5536    }
5537
5538    #[test]
5539    fn test_supports_many_concurrent_readers() -> Result<(), Box<dyn std::error::Error>> {
5540        const READER_THREADS: usize = 160;
5541
5542        let temp = TempDir::new()?;
5543        let store = Arc::new(LmdbBlobStore::new(temp.path().join("blobs"))?);
5544        let hash = sha256(b"many readers");
5545        store.put_sync(hash, b"many readers")?;
5546
5547        let start = Arc::new(Barrier::new(READER_THREADS + 1));
5548        let release = Arc::new(AtomicBool::new(false));
5549        let mut handles = Vec::with_capacity(READER_THREADS);
5550
5551        for _ in 0..READER_THREADS {
5552            let env = heed::Env::clone(&store.env);
5553            let start = Arc::clone(&start);
5554            let release = Arc::clone(&release);
5555            handles.push(std::thread::spawn(move || -> Result<(), String> {
5556                start.wait();
5557                let _rtxn = env.read_txn().map_err(|err| err.to_string())?;
5558                while !release.load(Ordering::Relaxed) {
5559                    std::thread::sleep(Duration::from_millis(1));
5560                }
5561                Ok(())
5562            }));
5563        }
5564
5565        start.wait();
5566        std::thread::sleep(Duration::from_millis(50));
5567        release.store(true, Ordering::Relaxed);
5568
5569        let results: Vec<Result<(), String>> = handles
5570            .into_iter()
5571            .map(|handle| handle.join().expect("reader thread panicked"))
5572            .collect();
5573
5574        let failures: Vec<String> = results.into_iter().filter_map(Result::err).collect();
5575        assert!(
5576            failures.is_empty(),
5577            "concurrent reader failures: {}",
5578            failures.join(" | ")
5579        );
5580        assert!(store.exists(&hash)?);
5581
5582        Ok(())
5583    }
5584
5585    #[test]
5586    fn managed_environment_closes_after_last_store_handle() -> Result<(), Box<dyn std::error::Error>>
5587    {
5588        let temp = TempDir::new()?;
5589        let path = temp.path().join("blobs");
5590        let first = LmdbBlobStore::new(&path)?;
5591        let hash = sha256(b"shared environment lifecycle");
5592        first.put_sync(hash, b"shared environment lifecycle")?;
5593        let second = LmdbBlobStore::new(&path)?;
5594        // Heed normalizes Windows verbatim paths before using them as cache keys.
5595        let environment_path = second.env.path().to_path_buf();
5596
5597        drop(first);
5598        assert!(
5599            heed::env_closing_event(&environment_path).is_some(),
5600            "the environment must remain open while another managed store owns it"
5601        );
5602        assert_eq!(
5603            second.get_sync(&hash)?,
5604            Some(b"shared environment lifecycle".to_vec())
5605        );
5606
5607        drop(second);
5608        assert!(
5609            heed::env_closing_event(&environment_path).is_none(),
5610            "the last managed store must remove Heed's cached environment"
5611        );
5612        Ok(())
5613    }
5614
5615    #[cfg(target_os = "macos")]
5616    #[test]
5617    fn managed_writers_do_not_exhaust_darwin_sem_undo_slots(
5618    ) -> Result<(), Box<dyn std::error::Error>> {
5619        const WRITERS: usize = 16;
5620
5621        let temp = TempDir::new()?;
5622        let stores = (0..WRITERS)
5623            .map(|index| LmdbBlobStore::new(temp.path().join(format!("member-{index}"))))
5624            .collect::<Result<Vec<_>, _>>()?;
5625        let start = Arc::new(Barrier::new(WRITERS));
5626        let active = Arc::new(AtomicUsize::new(0));
5627        let peak = Arc::new(AtomicUsize::new(0));
5628
5629        let handles = stores
5630            .into_iter()
5631            .map(|store| {
5632                let start = Arc::clone(&start);
5633                let active = Arc::clone(&active);
5634                let peak = Arc::clone(&peak);
5635                std::thread::spawn(move || -> Result<(), String> {
5636                    start.wait();
5637                    let txn = store.env.write_txn().map_err(|error| error.to_string())?;
5638                    let now = active.fetch_add(1, Ordering::SeqCst) + 1;
5639                    peak.fetch_max(now, Ordering::SeqCst);
5640                    std::thread::sleep(Duration::from_millis(75));
5641                    active.fetch_sub(1, Ordering::SeqCst);
5642                    drop(txn);
5643                    Ok(())
5644                })
5645            })
5646            .collect::<Vec<_>>();
5647
5648        let failures = handles
5649            .into_iter()
5650            .filter_map(|handle| handle.join().expect("writer thread panicked").err())
5651            .collect::<Vec<_>>();
5652        assert!(
5653            failures.is_empty(),
5654            "Darwin SEM_UNDO exhaustion rejected managed writers: {}",
5655            failures.join(" | ")
5656        );
5657        assert!(peak.load(Ordering::SeqCst) > 1);
5658        Ok(())
5659    }
5660
5661    #[cfg(unix)]
5662    #[test]
5663    fn test_reclaims_stale_reader_slots() -> Result<(), Box<dyn std::error::Error>> {
5664        let temp = TempDir::new()?;
5665        let path = temp.path().join("blobs");
5666        let data = b"hello stale readers";
5667        let hash = sha256(data);
5668
5669        run_helper("setup", &path, &temp.path().join("setup.marker"));
5670
5671        for index in 0..TEST_MAX_READERS {
5672            let marker = temp.path().join(format!("helper-{index}.marker"));
5673            run_helper("stale", &path, &marker);
5674        }
5675
5676        let store = LmdbBlobStore::with_map_size(&path, 1024 * 1024)?;
5677        assert!(store.exists(&hash)?);
5678
5679        Ok(())
5680    }
5681
5682    #[cfg(unix)]
5683    #[test]
5684    fn test_reopens_existing_env_with_larger_map_size() -> Result<(), Box<dyn std::error::Error>> {
5685        let temp = TempDir::new()?;
5686        let path = temp.path().join("blobs");
5687        run_helper("small-map", &path, &temp.path().join("small-map.marker"));
5688
5689        let reopened = LmdbBlobStore::with_map_size(&path, 8 * 1024 * 1024)?;
5690        assert!(reopened.map_size_bytes() >= 8 * 1024 * 1024);
5691
5692        Ok(())
5693    }
5694
5695    #[cfg(unix)]
5696    #[test]
5697    fn test_reopens_existing_env_with_smaller_requested_map_size(
5698    ) -> Result<(), Box<dyn std::error::Error>> {
5699        let temp = TempDir::new()?;
5700        let path = temp.path().join("blobs");
5701        let hash = sha256(b"large existing blob");
5702        run_helper("large-map", &path, &temp.path().join("large-map.marker"));
5703
5704        let existing_size = std::fs::metadata(path.join("data.mdb"))?.len();
5705        assert!(
5706            existing_size > 1024 * 1024,
5707            "test setup should create an environment larger than the reopen request"
5708        );
5709
5710        let reopened = LmdbBlobStore::with_map_size(&path, 1024 * 1024)?;
5711        assert!(
5712            reopened.map_size_bytes() as u64 >= existing_size,
5713            "expected map size to cover existing data.mdb size {existing_size}, got {}",
5714            reopened.map_size_bytes()
5715        );
5716        assert!(reopened.exists(&hash)?);
5717
5718        Ok(())
5719    }
5720
5721    #[cfg(unix)]
5722    #[test]
5723    #[ignore = "used as a subprocess helper by test_reclaims_stale_reader_slots"]
5724    fn lmdb_stale_reader_helper() {
5725        let Some(db_path) = std::env::var_os(STALE_READER_DB_PATH_ENV) else {
5726            return;
5727        };
5728        let marker_path =
5729            PathBuf::from(std::env::var_os(STALE_READER_MARKER_PATH_ENV).expect("marker path"));
5730        std::fs::write(&marker_path, b"started").expect("write helper marker");
5731
5732        let _env_flag = std::env::var_os(STALE_READER_HELPER_ENV).expect("helper mode enabled");
5733        let mode = std::env::var(STALE_READER_HELPER_MODE_ENV).expect("helper mode");
5734        let db_path = PathBuf::from(db_path);
5735        std::fs::create_dir_all(&db_path).expect("create helper db dir");
5736        let helper_map_size = if mode == "large-map" {
5737            8 * 1024 * 1024
5738        } else {
5739            1024 * 1024
5740        };
5741        let env = unsafe {
5742            EnvOpenOptions::new()
5743                .map_size(helper_map_size)
5744                .max_dbs(DATABASE_COUNT)
5745                .max_readers(TEST_MAX_READERS)
5746                .open(&db_path)
5747                .expect("open lmdb env")
5748        };
5749        match mode.as_str() {
5750            "setup" => {
5751                let mut wtxn = env.write_txn().expect("open write txn");
5752                let blobs: Database<Bytes, Bytes> = env
5753                    .create_database(&mut wtxn, Some("blobs"))
5754                    .expect("create blobs database");
5755                let data = b"hello stale readers";
5756                let hash = sha256(data);
5757                blobs.put(&mut wtxn, &hash, data).expect("seed blob");
5758                wtxn.commit().expect("commit setup txn");
5759                std::process::exit(0);
5760            }
5761            "stale" => {
5762                let _rtxn = env.read_txn().expect("open read txn");
5763                std::process::exit(0);
5764            }
5765            "small-map" => {
5766                let mut wtxn = env.write_txn().expect("open write txn");
5767                let _blobs: Database<Bytes, Bytes> = env
5768                    .create_database(&mut wtxn, Some("blobs"))
5769                    .expect("create blobs database");
5770                let _metadata: Database<Bytes, Bytes> = env
5771                    .create_database(&mut wtxn, Some("metadata"))
5772                    .expect("create metadata database");
5773                let _eviction_order: Database<Bytes, Unit> = env
5774                    .create_database(&mut wtxn, Some("eviction_order"))
5775                    .expect("create eviction_order database");
5776                let _pins: Database<Bytes, Bytes> = env
5777                    .create_database(&mut wtxn, Some("pins"))
5778                    .expect("create pins database");
5779                wtxn.commit().expect("commit small-map setup txn");
5780                std::process::exit(0);
5781            }
5782            "large-map" => {
5783                let mut wtxn = env.write_txn().expect("open write txn");
5784                let blobs: Database<Bytes, Bytes> = env
5785                    .create_database(&mut wtxn, Some("blobs"))
5786                    .expect("create blobs database");
5787                let _metadata: Database<Bytes, Bytes> = env
5788                    .create_database(&mut wtxn, Some("metadata"))
5789                    .expect("create metadata database");
5790                let _eviction_order: Database<Bytes, Unit> = env
5791                    .create_database(&mut wtxn, Some("eviction_order"))
5792                    .expect("create eviction_order database");
5793                let _pins: Database<Bytes, Bytes> = env
5794                    .create_database(&mut wtxn, Some("pins"))
5795                    .expect("create pins database");
5796                let data = vec![42u8; 3 * 1024 * 1024];
5797                let hash = sha256(b"large existing blob");
5798                blobs.put(&mut wtxn, &hash, &data).expect("seed blob");
5799                wtxn.commit().expect("commit large-map setup txn");
5800                std::process::exit(0);
5801            }
5802            other => panic!("unknown helper mode: {other}"),
5803        }
5804    }
5805}