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
68pub 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#[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 pub fn from_env(env_path: &Path) -> Option<Self> {
144 ExternalBlobConfig::from_env(env_path).map(Into::into)
145 }
146
147 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(¤t, 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
712pub struct LmdbBlobStore {
714 env: ManagedEnv,
715 blobs: Database<Bytes, Bytes>,
717 metadata: Database<Bytes, Bytes>,
719 eviction_order: Database<Bytes, Unit>,
721 pins: Database<Bytes, Bytes>,
723 stats: Database<Bytes, Bytes>,
725 max_bytes: AtomicU64,
726 next_order: AtomicU64,
727 external_blobs: Option<ExternalBlobConfig>,
728 physical_space: Option<PhysicalSpaceGuard>,
729}
730
731pub struct LmdbBlobReader {
736 store: LmdbBlobStore,
737 external_read_concurrency: usize,
738}
739
740#[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#[derive(Debug, Clone, PartialEq, Eq)]
761pub struct LmdbSourceKeyAudit {
762 pub blob_entries: u64,
763 pub sha256: Hash,
764}
765
766pub 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#[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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 pub fn stats(&self) -> Result<LmdbStats, StoreError> {
1251 self.store.stats()
1252 }
1253
1254 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 pub(crate) fn validate_terminal_member_keyset(
1271 &self,
1272 ) -> Result<LmdbTerminalMemberKeysetAudit, StoreError> {
1273 self.store.validate_exact_member_state()
1274 }
1275
1276 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 pub fn new<P: AsRef<Path>>(path: P) -> Result<Self, StoreError> {
1505 Self::with_map_size(path, DEFAULT_MAP_SIZE)
1506 }
1507
1508 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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?); assert!(!store.put(hash, data.to_vec()).await?); 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 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}