mod configured;
pub use configured::{
open_configured_lmdb_blob_store, open_shared_lmdb_blob_store, ConfiguredLmdbBlobStore,
LOCAL_ADD_EXTERNAL_BLOB_DIR_NAME, SHARED_BLOB_MIN_MAP_SIZE_BYTES,
};
use async_trait::async_trait;
use hashtree_core::store::{PutManyReport, Store, StoreError, StoreStats};
use hashtree_core::{to_hex, types::Hash};
use heed::types::*;
use heed::{Database, EnvFlags, EnvOpenOptions, Error as HeedError, MdbError, PutFlags};
use std::collections::HashSet;
use std::fs::{self, File};
use std::io::{Read, Seek, SeekFrom, Write};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
pub use hashtree_core::hash::sha256 as compute_sha256;
#[cfg(target_pointer_width = "64")]
const DEFAULT_MAP_SIZE: usize = 10 * 1024 * 1024 * 1024;
#[cfg(target_pointer_width = "32")]
const DEFAULT_MAP_SIZE: usize = 1024 * 1024 * 1024;
const DEFAULT_MAX_READERS: u32 = 1024;
const DATABASE_COUNT: u32 = 5;
const REOPEN_HEADROOM_BYTES: u64 = 64 * 1024 * 1024;
const EVICTION_BATCH_TARGET_BYTES: u64 = 256 * 1024 * 1024;
const EVICTION_BATCH_MAX_ITEMS: usize = 4096;
const LEGACY_BLOB_META_BYTES: usize = 16;
const BLOB_META_BYTES: usize = 24;
const ORDER_KEY_BYTES: usize = 40;
const PIN_COUNT_BYTES: usize = 4;
const STORE_TOTALS_BYTES: usize = 32;
const STORE_TOTALS_KEY: &[u8] = b"totals";
const NEXT_ORDER_BYTES: usize = 8;
const NEXT_ORDER_KEY: &[u8] = b"next_order";
const NEXT_ORDER_MIGRATION_FLOOR: u64 = 1 << 48;
const EXTERNAL_BLOB_MARKER_PREFIX: &[u8] = b"\0hashtree-lmdb-external-blob-v1\0";
const EXTERNAL_PACK_MARKER_PREFIX: &[u8] = b"\0hashtree-lmdb-external-pack-v1\0";
const EXTERNAL_PACK_RESERVED_MARKER_PREFIX: &[u8] = b"\0hashtree-lmdb-external-pack-reserved-v1\0";
const LMDB_NO_READ_AHEAD_ENV: &str = "HTREE_LMDB_NO_READ_AHEAD";
const LMDB_NO_SYNC_ENV: &str = "HTREE_LMDB_NO_SYNC";
const LMDB_NO_META_SYNC_ENV: &str = "HTREE_LMDB_NO_META_SYNC";
const LMDB_EXTERNAL_BLOB_MIN_BYTES_ENV: &str = "HTREE_LMDB_EXTERNAL_BLOB_MIN_BYTES";
const LMDB_EXTERNAL_BLOB_DIR_ENV: &str = "HTREE_LMDB_EXTERNAL_BLOB_DIR";
const LMDB_EXTERNAL_BLOB_SYNC_ENV: &str = "HTREE_LMDB_EXTERNAL_BLOB_SYNC";
const LMDB_EXTERNAL_BLOB_PACK_TARGET_BYTES_ENV: &str = "HTREE_LMDB_EXTERNAL_BLOB_PACK_TARGET_BYTES";
static EXTERNAL_PACK_COUNTER: AtomicU64 = AtomicU64::new(0);
#[derive(Debug, Clone, Copy)]
struct BlobMeta {
order: u64,
size: u64,
last_accessed_at: u64,
}
#[derive(Debug, Default, Clone, Copy)]
struct StoreTotals {
count: u64,
total_bytes: u64,
pinned_count: u64,
pinned_bytes: u64,
}
#[derive(Debug, Clone)]
struct ExternalBlobConfig {
base_path: PathBuf,
min_bytes: usize,
sync: bool,
pack_target_bytes: Option<usize>,
}
#[derive(Debug, Clone)]
pub struct ExternalBlobOptions {
pub base_path: PathBuf,
pub min_bytes: usize,
pub sync: bool,
pub pack_target_bytes: Option<usize>,
}
impl ExternalBlobOptions {
pub fn from_env(env_path: &Path) -> Option<Self> {
ExternalBlobConfig::from_env(env_path).map(Into::into)
}
pub fn with_base_path(mut self, base_path: PathBuf) -> Self {
self.base_path = base_path;
self
}
}
impl ExternalBlobConfig {
fn from_env(env_path: &Path) -> Option<Self> {
let min_bytes = std::env::var(LMDB_EXTERNAL_BLOB_MIN_BYTES_ENV)
.ok()
.and_then(|value| value.parse::<usize>().ok())
.filter(|value| *value > 0)?;
let base_path = std::env::var(LMDB_EXTERNAL_BLOB_DIR_ENV)
.ok()
.filter(|value| !value.trim().is_empty())
.map(PathBuf::from)
.unwrap_or_else(|| env_path.join("external-blobs"));
let sync = env_bool(LMDB_EXTERNAL_BLOB_SYNC_ENV).unwrap_or(true);
let pack_target_bytes = std::env::var(LMDB_EXTERNAL_BLOB_PACK_TARGET_BYTES_ENV)
.ok()
.and_then(|value| value.parse::<usize>().ok())
.filter(|value| *value > 0);
Some(Self {
base_path,
min_bytes,
sync,
pack_target_bytes,
})
}
}
impl From<ExternalBlobConfig> for ExternalBlobOptions {
fn from(config: ExternalBlobConfig) -> Self {
Self {
base_path: config.base_path,
min_bytes: config.min_bytes,
sync: config.sync,
pack_target_bytes: config.pack_target_bytes,
}
}
}
impl From<ExternalBlobOptions> for ExternalBlobConfig {
fn from(options: ExternalBlobOptions) -> Self {
Self {
base_path: options.base_path,
min_bytes: options.min_bytes,
sync: options.sync,
pack_target_bytes: options.pack_target_bytes,
}
}
}
#[derive(Debug, Clone)]
struct ExternalPackRef {
name: String,
offset: u64,
len: u64,
}
pub struct LmdbBlobStore {
env: heed::Env,
blobs: Database<Bytes, Bytes>,
metadata: Database<Bytes, Bytes>,
eviction_order: Database<Bytes, Unit>,
pins: Database<Bytes, Bytes>,
stats: Database<Bytes, Bytes>,
max_bytes: AtomicU64,
next_order: AtomicU64,
external_blobs: Option<ExternalBlobConfig>,
}
impl LmdbBlobStore {
pub fn new<P: AsRef<Path>>(path: P) -> Result<Self, StoreError> {
Self::with_map_size(path, DEFAULT_MAP_SIZE)
}
pub fn with_max_bytes<P: AsRef<Path>>(path: P, max_bytes: u64) -> Result<Self, StoreError> {
Self::with_max_bytes_and_external_blob_options(
path,
max_bytes,
ExternalBlobOptions::from_env,
)
}
pub fn with_max_bytes_and_external_blob_options<P, F>(
path: P,
max_bytes: u64,
external_blobs: F,
) -> Result<Self, StoreError>
where
P: AsRef<Path>,
F: FnOnce(&Path) -> Option<ExternalBlobOptions>,
{
let path_ref = path.as_ref();
let existing_map_size = std::fs::metadata(path_ref.join("data.mdb"))
.map(|metadata| metadata.len())
.unwrap_or(0);
let existing_headroom = if existing_map_size == 0 {
0
} else {
existing_map_size
.saturating_div(10)
.max(REOPEN_HEADROOM_BYTES)
};
let requested_map_size = usize::try_from(Self::align_map_size_bytes(
existing_map_size
.saturating_add(existing_headroom)
.max(max_bytes),
))
.unwrap_or(usize::MAX)
.max(DEFAULT_MAP_SIZE);
let external_blobs = external_blobs(path_ref);
let store = Self::with_map_size_and_external_blob_options(
path_ref,
requested_map_size,
external_blobs,
)?;
store.max_bytes.store(max_bytes, Ordering::Relaxed);
let current = store.total_bytes()?;
if max_bytes > 0 && current > max_bytes {
let target = max_bytes.saturating_mul(9) / 10;
store.evict_to_target(current, target)?;
}
Ok(store)
}
pub fn with_external_blob_options<P: AsRef<Path>>(
path: P,
external_blobs: Option<ExternalBlobOptions>,
) -> Result<Self, StoreError> {
Self::with_map_size_and_external_blob_options(path, DEFAULT_MAP_SIZE, external_blobs)
}
fn align_map_size_bytes(bytes: u64) -> u64 {
if bytes == 0 {
return 0;
}
let page_size = (page_size::get() as u64).max(4096);
let remainder = bytes % page_size;
if remainder == 0 {
bytes
} else {
bytes.saturating_add(page_size - remainder)
}
}
pub fn with_map_size<P: AsRef<Path>>(path: P, map_size: usize) -> Result<Self, StoreError> {
Self::with_map_size_and_settings(
path,
map_size,
env_flags_from_env(),
ExternalBlobConfig::from_env,
)
}
pub fn with_map_size_and_external_blob_options<P: AsRef<Path>>(
path: P,
map_size: usize,
external_blobs: Option<ExternalBlobOptions>,
) -> Result<Self, StoreError> {
Self::with_map_size_and_settings(path, map_size, env_flags_from_env(), |_| {
external_blobs.map(Into::into)
})
}
fn with_map_size_and_settings<P, F>(
path: P,
map_size: usize,
flags: EnvFlags,
external_blobs: F,
) -> Result<Self, StoreError>
where
P: AsRef<Path>,
F: FnOnce(&Path) -> Option<ExternalBlobConfig>,
{
let path_ref = path.as_ref();
std::fs::create_dir_all(path_ref).map_err(StoreError::Io)?;
let existing_map_size = std::fs::metadata(path_ref.join("data.mdb"))
.map(|metadata| metadata.len())
.unwrap_or(0);
let existing_headroom = if existing_map_size == 0 {
0
} else {
existing_map_size
.saturating_div(10)
.max(REOPEN_HEADROOM_BYTES)
};
let requested_map_size = u64::try_from(map_size).unwrap_or(u64::MAX);
let map_size = usize::try_from(Self::align_map_size_bytes(
requested_map_size.max(existing_map_size.saturating_add(existing_headroom)),
))
.unwrap_or(usize::MAX);
let mut env_options = EnvOpenOptions::new();
env_options
.map_size(map_size)
.max_dbs(DATABASE_COUNT)
.max_readers(DEFAULT_MAX_READERS);
unsafe {
env_options.flags(flags);
}
let env = unsafe { env_options.open(path_ref).map_err(map_heed_error)? };
let _ = env.clear_stale_readers();
if env.info().map_size < map_size {
unsafe { env.resize(map_size) }.map_err(map_heed_error)?;
}
let mut wtxn = env.write_txn().map_err(map_heed_error)?;
let blobs = env
.create_database(&mut wtxn, Some("blobs"))
.map_err(map_heed_error)?;
let metadata = env
.create_database(&mut wtxn, Some("metadata"))
.map_err(map_heed_error)?;
let eviction_order = env
.create_database(&mut wtxn, Some("eviction_order"))
.map_err(map_heed_error)?;
let pins = env
.create_database(&mut wtxn, Some("pins"))
.map_err(map_heed_error)?;
let stats = env
.create_database(&mut wtxn, Some("stats"))
.map_err(map_heed_error)?;
wtxn.commit().map_err(map_heed_error)?;
let totals = Self::load_or_rebuild_totals(&env, metadata, pins, stats)?;
let next_order = Self::load_or_initialize_next_order(&env, stats, totals.count > 0)?;
Ok(Self {
env,
blobs,
metadata,
eviction_order,
pins,
stats,
max_bytes: AtomicU64::new(0),
next_order: AtomicU64::new(next_order),
external_blobs: external_blobs(path_ref),
})
}
fn load_or_rebuild_totals(
env: &heed::Env,
metadata: Database<Bytes, Bytes>,
pins: Database<Bytes, Bytes>,
stats: Database<Bytes, Bytes>,
) -> Result<StoreTotals, StoreError> {
{
let rtxn = env.read_txn().map_err(map_heed_error)?;
if let Some(totals) = Self::read_store_totals(stats, &rtxn)? {
return Ok(totals);
}
}
let mut totals = StoreTotals::default();
{
let rtxn = env.read_txn().map_err(map_heed_error)?;
for item in metadata.iter(&rtxn).map_err(map_heed_error)? {
let (_, bytes) = item.map_err(map_heed_error)?;
let meta = Self::decode_blob_meta(bytes)?;
totals.count = totals.count.saturating_add(1);
totals.total_bytes = totals.total_bytes.saturating_add(meta.size);
}
for item in pins.iter(&rtxn).map_err(map_heed_error)? {
let (hash, pin_bytes) = item.map_err(map_heed_error)?;
let count = Self::decode_pin_count(pin_bytes)?;
if count == 0 {
continue;
}
let Some(meta_bytes) = metadata.get(&rtxn, hash).map_err(map_heed_error)? else {
continue;
};
let meta = Self::decode_blob_meta(meta_bytes)?;
totals.pinned_count = totals.pinned_count.saturating_add(1);
totals.pinned_bytes = totals.pinned_bytes.saturating_add(meta.size);
}
}
let mut wtxn = env.write_txn().map_err(map_heed_error)?;
Self::write_store_totals(stats, &mut wtxn, totals).map_err(map_heed_error)?;
wtxn.commit().map_err(map_heed_error)?;
Ok(totals)
}
fn load_or_initialize_next_order(
env: &heed::Env,
stats: Database<Bytes, Bytes>,
has_existing_blobs: bool,
) -> Result<u64, StoreError> {
{
let rtxn = env.read_txn().map_err(map_heed_error)?;
if let Some(next_order) = Self::read_next_order(stats, &rtxn)? {
return Ok(next_order);
}
}
let next_order = if has_existing_blobs {
Self::legacy_next_order_floor()
} else {
0
};
let mut wtxn = env.write_txn().map_err(map_heed_error)?;
let next_order = Self::read_next_order_lossy(stats, &wtxn)
.map_err(map_heed_error)?
.unwrap_or(next_order);
Self::write_next_order(stats, &mut wtxn, next_order).map_err(map_heed_error)?;
wtxn.commit().map_err(map_heed_error)?;
Ok(next_order)
}
fn legacy_next_order_floor() -> u64 {
let micros = SystemTime::now()
.duration_since(UNIX_EPOCH)
.ok()
.and_then(|duration| u64::try_from(duration.as_micros()).ok())
.unwrap_or(NEXT_ORDER_MIGRATION_FLOOR);
micros.max(NEXT_ORDER_MIGRATION_FLOOR)
}
fn read_store_totals(
stats: Database<Bytes, Bytes>,
txn: &heed::RoTxn,
) -> Result<Option<StoreTotals>, StoreError> {
stats
.get(txn, STORE_TOTALS_KEY)
.map_err(map_heed_error)?
.map(Self::decode_store_totals)
.transpose()
}
fn read_store_totals_lossy(
stats: Database<Bytes, Bytes>,
txn: &heed::RoTxn,
) -> std::result::Result<StoreTotals, HeedError> {
Ok(stats
.get(txn, STORE_TOTALS_KEY)?
.and_then(Self::decode_store_totals_lossy)
.unwrap_or_default())
}
fn write_store_totals(
stats: Database<Bytes, Bytes>,
txn: &mut heed::RwTxn,
totals: StoreTotals,
) -> std::result::Result<(), HeedError> {
let encoded = Self::encode_store_totals(totals);
stats.put(txn, STORE_TOTALS_KEY, &encoded)
}
fn read_next_order(
stats: Database<Bytes, Bytes>,
txn: &heed::RoTxn,
) -> Result<Option<u64>, StoreError> {
stats
.get(txn, NEXT_ORDER_KEY)
.map_err(map_heed_error)?
.map(Self::decode_next_order)
.transpose()
}
fn read_next_order_lossy(
stats: Database<Bytes, Bytes>,
txn: &heed::RoTxn,
) -> std::result::Result<Option<u64>, HeedError> {
Ok(stats
.get(txn, NEXT_ORDER_KEY)?
.and_then(Self::decode_next_order_lossy))
}
fn write_next_order(
stats: Database<Bytes, Bytes>,
txn: &mut heed::RwTxn,
next_order: u64,
) -> std::result::Result<(), HeedError> {
let encoded = Self::encode_next_order(next_order);
stats.put(txn, NEXT_ORDER_KEY, &encoded)
}
fn allocate_order_range_in_txn(
&self,
txn: &mut heed::RwTxn,
count: usize,
) -> std::result::Result<u64, HeedError> {
let start = Self::read_next_order_lossy(self.stats, txn)?
.unwrap_or_else(|| self.next_order.load(Ordering::Relaxed));
let end = start.saturating_add(count as u64);
Self::write_next_order(self.stats, txn, end)?;
self.next_order.store(end, Ordering::Relaxed);
Ok(start)
}
fn increment_totals_in_txn(
&self,
txn: &mut heed::RwTxn,
count: u64,
total_bytes: u64,
pinned_count: u64,
pinned_bytes: u64,
) -> std::result::Result<(), HeedError> {
let mut totals = Self::read_store_totals_lossy(self.stats, txn)?;
totals.count = totals.count.saturating_add(count);
totals.total_bytes = totals.total_bytes.saturating_add(total_bytes);
totals.pinned_count = totals.pinned_count.saturating_add(pinned_count);
totals.pinned_bytes = totals.pinned_bytes.saturating_add(pinned_bytes);
Self::write_store_totals(self.stats, txn, totals)
}
fn decrement_totals_in_txn(
&self,
txn: &mut heed::RwTxn,
count: u64,
total_bytes: u64,
pinned_count: u64,
pinned_bytes: u64,
) -> std::result::Result<(), HeedError> {
let mut totals = Self::read_store_totals_lossy(self.stats, txn)?;
totals.count = totals.count.saturating_sub(count);
totals.total_bytes = totals.total_bytes.saturating_sub(total_bytes);
totals.pinned_count = totals.pinned_count.saturating_sub(pinned_count);
totals.pinned_bytes = totals.pinned_bytes.saturating_sub(pinned_bytes);
Self::write_store_totals(self.stats, txn, totals)
}
pub fn exists(&self, hash: &Hash) -> Result<bool, StoreError> {
let rtxn = self
.env
.read_txn()
.map_err(|e| StoreError::Other(e.to_string()))?;
if self
.metadata
.get(&rtxn, hash)
.map_err(|e| StoreError::Other(e.to_string()))?
.is_some()
{
return Ok(true);
}
Ok(self
.blobs
.get(&rtxn, hash)
.map_err(|e| StoreError::Other(e.to_string()))?
.is_some())
}
fn mark_existing_hashes_in_db(
db: Database<Bytes, Bytes>,
rtxn: &heed::RoTxn,
sorted_hashes: &[Hash],
existing: &mut [bool],
) -> Result<(), StoreError> {
debug_assert_eq!(sorted_hashes.len(), existing.len());
debug_assert!(sorted_hashes.windows(2).all(|pair| pair[0] <= pair[1]));
if sorted_hashes.is_empty() {
return Ok(());
}
for (index, hash) in sorted_hashes.iter().enumerate() {
if existing[index] {
continue;
}
if db.get(rtxn, hash).map_err(map_heed_error)?.is_some() {
existing[index] = true;
}
}
Ok(())
}
pub fn existing_hashes_in_sorted_candidates(
&self,
sorted_hashes: &[Hash],
) -> Result<Vec<bool>, StoreError> {
let mut existing = vec![false; sorted_hashes.len()];
if sorted_hashes.is_empty() {
return Ok(existing);
}
let rtxn = self.env.read_txn().map_err(map_heed_error)?;
Self::mark_existing_hashes_in_db(self.metadata, &rtxn, sorted_hashes, &mut existing)?;
if existing.iter().all(|exists| *exists) {
return Ok(existing);
}
Self::mark_existing_hashes_in_db(self.blobs, &rtxn, sorted_hashes, &mut existing)?;
Ok(existing)
}
pub fn blob_size_sync(&self, hash: &Hash) -> Result<Option<u64>, StoreError> {
let rtxn = self
.env
.read_txn()
.map_err(|e| StoreError::Other(e.to_string()))?;
self.blob_size_in_txn(&rtxn, hash)
}
pub fn map_size_bytes(&self) -> usize {
self.env.info().map_size
}
pub fn force_sync(&self) -> Result<(), StoreError> {
self.env.force_sync().map_err(map_heed_error)
}
fn total_bytes(&self) -> Result<u64, StoreError> {
let rtxn = self.env.read_txn().map_err(map_heed_error)?;
Ok(Self::read_store_totals(self.stats, &rtxn)?
.unwrap_or_default()
.total_bytes)
}
fn evict_for_write_pressure(&self, incoming_bytes: u64) -> Result<u64, StoreError> {
let current = self.total_bytes()?;
if current == 0 {
return Ok(0);
}
let headroom = incoming_bytes.max(current / 10).max(1);
let target = current.saturating_sub(headroom);
self.evict_to_target(current, target)
}
fn enforce_max_bytes_after_insert(&self, inserted_bytes: u64) -> Result<u64, StoreError> {
let max = self.max_bytes.load(Ordering::Relaxed);
if max == 0 || inserted_bytes == 0 {
return Ok(0);
}
let current = self.total_bytes()?;
if current <= max {
return Ok(0);
}
let target = if inserted_bytes >= max {
inserted_bytes
} else {
max.saturating_mul(9)
.saturating_div(10)
.saturating_add(inserted_bytes)
.min(max)
};
self.evict_to_target(current, target)
}
fn write_metadata_for_inserted_blobs(
&self,
wtxn: &mut heed::RwTxn,
inserted_entries: &[(Hash, u64)],
) -> Result<(), StoreError> {
if inserted_entries.is_empty() {
return Ok(());
}
let now = unix_timestamp_now();
let mut inserted_bytes = 0u64;
let mut inserted_pinned = 0u64;
let mut inserted_pinned_bytes = 0u64;
let mut order = self
.allocate_order_range_in_txn(wtxn, inserted_entries.len())
.map_err(map_heed_error)?;
for (hash, data_len) in inserted_entries {
let meta = Self::encode_blob_meta(BlobMeta {
order,
size: *data_len,
last_accessed_at: now,
});
let order_key = Self::encode_order_key(order, hash);
order = order.saturating_add(1);
self.metadata
.put(wtxn, hash, &meta)
.map_err(map_heed_error)?;
self.eviction_order
.put(wtxn, &order_key, &())
.map_err(map_heed_error)?;
inserted_bytes = inserted_bytes.saturating_add(*data_len);
if self
.read_pin_count_lossy(wtxn, hash)
.map_err(map_heed_error)?
> 0
{
inserted_pinned = inserted_pinned.saturating_add(1);
inserted_pinned_bytes = inserted_pinned_bytes.saturating_add(*data_len);
}
}
self.increment_totals_in_txn(
wtxn,
inserted_entries.len() as u64,
inserted_bytes,
inserted_pinned,
inserted_pinned_bytes,
)
.map_err(map_heed_error)?;
Ok(())
}
fn put_sync_attempt(&self, hash: Hash, data: &[u8]) -> Result<bool, StoreError> {
let mut wtxn = self.env.write_txn().map_err(map_heed_error)?;
let external_config = self
.external_blobs
.as_ref()
.filter(|config| data.len() >= config.min_bytes);
let external_marker = external_config.map(|_| Self::external_blob_ref(&hash));
let value = external_marker.as_deref().unwrap_or(data);
match self
.blobs
.put_with_flags(&mut wtxn, PutFlags::NO_OVERWRITE, &hash, value)
{
Ok(()) => {}
Err(HeedError::Mdb(MdbError::KeyExist)) => return Ok(false),
Err(err) => return Err(map_heed_error(err)),
}
if let Some(config) = external_config {
self.write_external_blob(&hash, data, config)?;
}
self.write_metadata_for_inserted_blobs(&mut wtxn, &[(hash, data.len() as u64)])?;
wtxn.commit().map_err(map_heed_error)?;
Ok(true)
}
fn put_many_sync_attempt(
&self,
total: usize,
items: &[(Hash, &[u8])],
) -> Result<PutManyReport, StoreError> {
let mut wtxn = self.env.write_txn().map_err(map_heed_error)?;
let mut report = PutManyReport {
total,
..PutManyReport::default()
};
let mut inserted_entries: Vec<(Hash, u64)> = Vec::new();
let mut external_blobs: Vec<(Hash, &[u8])> = Vec::new();
let mut external_pack_entries: Vec<(usize, Hash, &[u8])> = Vec::new();
let external_config = self.external_blobs.as_ref();
for (hash, data) in items {
let external = external_config.filter(|config| data.len() >= config.min_bytes);
let pack_external = external.is_some_and(|config| config.pack_target_bytes.is_some());
let reserved_marker;
let external_marker;
let value = if pack_external {
reserved_marker = Self::external_pack_reserved_ref(hash);
reserved_marker.as_slice()
} else if external.is_some() {
external_marker = Self::external_blob_ref(hash);
external_marker.as_slice()
} else {
*data
};
match self
.blobs
.put_with_flags(&mut wtxn, PutFlags::NO_OVERWRITE, hash, value)
{
Ok(()) => {}
Err(HeedError::Mdb(MdbError::KeyExist)) => continue,
Err(err) => return Err(map_heed_error(err)),
}
let inserted_index = inserted_entries.len();
let data_len = data.len() as u64;
inserted_entries.push((*hash, data_len));
report.inserted = report.inserted.saturating_add(1);
report.inserted_bytes = report.inserted_bytes.saturating_add(data_len);
report.inserted_hashes.push(*hash);
if pack_external {
external_pack_entries.push((inserted_index, *hash, *data));
} else if external.is_some() {
external_blobs.push((*hash, *data));
}
}
if inserted_entries.is_empty() {
return Ok(report);
}
if let Some(config) = external_config {
for (hash, data) in external_blobs {
self.write_external_blob(&hash, data, config)?;
}
if let Some(pack_target_bytes) = config.pack_target_bytes {
for (inserted_index, marker) in self.write_external_blob_packs(
&external_pack_entries,
config,
pack_target_bytes,
)? {
let hash = inserted_entries[inserted_index].0;
self.blobs
.put(&mut wtxn, &hash, &marker)
.map_err(map_heed_error)?;
}
}
}
self.write_metadata_for_inserted_blobs(&mut wtxn, &inserted_entries)?;
wtxn.commit().map_err(map_heed_error)?;
Ok(report)
}
pub fn stats(&self) -> Result<LmdbStats, StoreError> {
let rtxn = self
.env
.read_txn()
.map_err(|e| StoreError::Other(e.to_string()))?;
let totals = Self::read_store_totals(self.stats, &rtxn)?.unwrap_or_default();
Ok(LmdbStats {
count: totals.count as usize,
total_bytes: totals.total_bytes,
pinned_count: totals.pinned_count as usize,
pinned_bytes: totals.pinned_bytes,
})
}
pub fn list(&self) -> Result<Vec<Hash>, StoreError> {
let rtxn = self
.env
.read_txn()
.map_err(|e| StoreError::Other(e.to_string()))?;
let mut hashes = Vec::new();
for item in self.metadata.iter(&rtxn).map_err(map_heed_error)? {
let (hash, _) = item.map_err(|e| StoreError::Other(e.to_string()))?;
let hash_arr: Hash = hash
.try_into()
.map_err(|_| StoreError::Other("invalid hash length".into()))?;
hashes.push(hash_arr);
}
if hashes.is_empty() {
for item in self
.blobs
.iter(&rtxn)
.map_err(|e| StoreError::Other(e.to_string()))?
{
let (hash, _) = item.map_err(|e| StoreError::Other(e.to_string()))?;
let hash_arr: Hash = hash
.try_into()
.map_err(|_| StoreError::Other("invalid hash length".into()))?;
hashes.push(hash_arr);
}
}
Ok(hashes)
}
pub fn put_sync(&self, hash: Hash, data: &[u8]) -> Result<bool, StoreError> {
let incoming_bytes = data.len() as u64;
let mut retried_after_eviction = false;
loop {
match self.put_sync_attempt(hash, data) {
Ok(inserted) => {
if inserted {
self.enforce_max_bytes_after_insert(incoming_bytes)?;
}
return Ok(inserted);
}
Err(err) if is_map_full_store_error(&err) && !retried_after_eviction => {
let freed = self.evict_for_write_pressure(incoming_bytes)?;
if freed == 0 {
return Err(err);
}
retried_after_eviction = true;
}
Err(err) => return Err(err),
}
}
}
pub fn put_many_report_sync(
&self,
items: &[(Hash, Vec<u8>)],
) -> Result<PutManyReport, StoreError> {
let total = items.len();
if items.is_empty() {
return Ok(PutManyReport::default());
}
let mut seen_missing = HashSet::new();
let write_items = items
.iter()
.filter_map(|(hash, data)| {
if !seen_missing.insert(*hash) {
None
} else {
Some((*hash, data.as_slice()))
}
})
.collect::<Vec<_>>();
let incoming_bytes = write_items
.iter()
.map(|(_, data)| data.len() as u64)
.fold(0u64, |total, size| total.saturating_add(size));
let mut retried_after_eviction = false;
loop {
match self.put_many_sync_attempt(total, &write_items) {
Ok(report) => {
if report.inserted_bytes > 0 {
self.enforce_max_bytes_after_insert(report.inserted_bytes)?;
}
return Ok(report);
}
Err(err) if is_map_full_store_error(&err) && !retried_after_eviction => {
let freed = self.evict_for_write_pressure(incoming_bytes)?;
if freed == 0 {
return Err(err);
}
retried_after_eviction = true;
}
Err(err) => return Err(err),
}
}
}
pub fn put_many_sync(&self, items: &[(Hash, Vec<u8>)]) -> Result<usize, StoreError> {
self.put_many_report_sync(items)
.map(|report| report.inserted)
}
pub fn get_sync(&self, hash: &Hash) -> Result<Option<Vec<u8>>, StoreError> {
let rtxn = self
.env
.read_txn()
.map_err(|e| StoreError::Other(e.to_string()))?;
let Some(blob) = self
.blobs
.get(&rtxn, hash)
.map_err(|e| StoreError::Other(e.to_string()))?
else {
return Ok(None);
};
self.decode_blob_value(hash, blob).map(Some)
}
pub fn get_range_sync(
&self,
hash: &Hash,
start: u64,
end_inclusive: u64,
) -> Result<Option<Vec<u8>>, StoreError> {
let rtxn = self
.env
.read_txn()
.map_err(|e| StoreError::Other(e.to_string()))?;
let Some(blob) = self
.blobs
.get(&rtxn, hash)
.map_err(|e| StoreError::Other(e.to_string()))?
else {
return Ok(None);
};
if self.is_external_blob_ref(hash, blob) {
return self.read_external_blob_range(hash, start, end_inclusive);
}
if let Some(pack_ref) = Self::decode_external_pack_ref(blob)? {
return self.read_external_pack_range(&pack_ref, start, end_inclusive);
}
if blob.is_empty() || end_inclusive < start {
return Ok(Some(Vec::new()));
}
let len = blob.len() as u64;
if start >= len {
return Ok(Some(Vec::new()));
}
let actual_end = end_inclusive.min(len - 1);
let start = usize::try_from(start)
.map_err(|_| StoreError::Other("blob range start is too large".to_string()))?;
let end_exclusive = usize::try_from(actual_end.saturating_add(1))
.map_err(|_| StoreError::Other("blob range end is too large".to_string()))?;
Ok(Some(blob[start..end_exclusive].to_vec()))
}
pub fn touch_accessed_sync(&self, hash: &Hash, now: u64) -> Result<bool, StoreError> {
self.touch_many_accessed_sync(std::slice::from_ref(hash), now)
.map(|updated| updated > 0)
}
pub fn touch_many_accessed_sync(&self, hashes: &[Hash], now: u64) -> Result<usize, StoreError> {
if hashes.is_empty() {
return Ok(0);
}
let mut wtxn = self
.env
.write_txn()
.map_err(|e| StoreError::Other(e.to_string()))?;
let mut to_touch = Vec::new();
for hash in hashes {
let meta = self
.metadata
.get(&wtxn, hash)
.map_err(|e| StoreError::Other(e.to_string()))?
.map(Self::decode_blob_meta)
.transpose()?;
let Some(meta) = meta else {
continue;
};
if meta.last_accessed_at >= now {
continue;
}
to_touch.push((*hash, meta));
}
let updated = to_touch.len();
if updated == 0 {
wtxn.commit()
.map_err(|e| StoreError::Other(e.to_string()))?;
return Ok(0);
}
let mut order = self
.allocate_order_range_in_txn(&mut wtxn, updated)
.map_err(|e| StoreError::Other(e.to_string()))?;
for (hash, mut meta) in to_touch {
let old_order_key = Self::encode_order_key(meta.order, &hash);
let _ = self
.eviction_order
.delete(&mut wtxn, &old_order_key)
.map_err(|e| StoreError::Other(e.to_string()))?;
meta.order = order;
order = order.saturating_add(1);
meta.last_accessed_at = now;
let meta_bytes = Self::encode_blob_meta(meta);
let new_order_key = Self::encode_order_key(meta.order, &hash);
self.metadata
.put(&mut wtxn, &hash, &meta_bytes)
.map_err(|e| StoreError::Other(e.to_string()))?;
self.eviction_order
.put(&mut wtxn, &new_order_key, &())
.map_err(|e| StoreError::Other(e.to_string()))?;
}
wtxn.commit()
.map_err(|e| StoreError::Other(e.to_string()))?;
Ok(updated)
}
pub fn last_accessed_at_sync(&self, hash: &Hash) -> Result<Option<u64>, StoreError> {
let rtxn = self
.env
.read_txn()
.map_err(|e| StoreError::Other(e.to_string()))?;
self.metadata
.get(&rtxn, hash)
.map_err(|e| StoreError::Other(e.to_string()))?
.map(Self::decode_blob_meta)
.transpose()
.map(|meta| meta.map(|meta| meta.last_accessed_at))
}
pub fn many_last_accessed_at_sync(
&self,
hashes: &[Hash],
) -> Result<Vec<(Hash, u64)>, StoreError> {
let rtxn = self
.env
.read_txn()
.map_err(|e| StoreError::Other(e.to_string()))?;
let mut results = Vec::new();
for hash in hashes {
let Some(meta) = self
.metadata
.get(&rtxn, hash)
.map_err(|e| StoreError::Other(e.to_string()))?
.map(Self::decode_blob_meta)
.transpose()?
else {
continue;
};
results.push((*hash, meta.last_accessed_at));
}
Ok(results)
}
pub fn delete_sync(&self, hash: &Hash) -> Result<bool, StoreError> {
let mut wtxn = self
.env
.write_txn()
.map_err(|e| StoreError::Other(e.to_string()))?;
let external_path = self.external_blob_path_in_txn(&wtxn, hash)?;
let (existed, _) = self.delete_blob_in_txn(&mut wtxn, hash)?;
wtxn.commit()
.map_err(|e| StoreError::Other(e.to_string()))?;
if existed {
self.remove_external_blob_file(external_path);
}
Ok(existed)
}
fn pin_sync(&self, hash: &Hash) -> Result<(), StoreError> {
let mut wtxn = self
.env
.write_txn()
.map_err(|e| StoreError::Other(e.to_string()))?;
let previous = self.read_pin_count(&wtxn, hash)?;
let count = previous.saturating_add(1);
let encoded = count.to_be_bytes();
self.pins
.put(&mut wtxn, hash, &encoded)
.map_err(|e| StoreError::Other(e.to_string()))?;
if previous == 0 {
if let Some(size) = self.blob_size_in_txn(&wtxn, hash)? {
self.increment_totals_in_txn(&mut wtxn, 0, 0, 1, size)
.map_err(|e| StoreError::Other(e.to_string()))?;
}
}
wtxn.commit()
.map_err(|e| StoreError::Other(e.to_string()))?;
Ok(())
}
fn unpin_sync(&self, hash: &Hash) -> Result<(), StoreError> {
let mut wtxn = self
.env
.write_txn()
.map_err(|e| StoreError::Other(e.to_string()))?;
let count = self.read_pin_count(&wtxn, hash)?;
if count == 1 {
if let Some(size) = self.blob_size_in_txn(&wtxn, hash)? {
self.decrement_totals_in_txn(&mut wtxn, 0, 0, 1, size)
.map_err(|e| StoreError::Other(e.to_string()))?;
}
}
if count <= 1 {
let _ = self
.pins
.delete(&mut wtxn, hash)
.map_err(|e| StoreError::Other(e.to_string()))?;
} else {
let encoded = (count - 1).to_be_bytes();
self.pins
.put(&mut wtxn, hash, &encoded)
.map_err(|e| StoreError::Other(e.to_string()))?;
}
wtxn.commit()
.map_err(|e| StoreError::Other(e.to_string()))?;
Ok(())
}
fn evict_to_target(&self, current_bytes: u64, target_bytes: u64) -> Result<u64, StoreError> {
if current_bytes <= target_bytes {
return Ok(0);
}
let rtxn = self
.env
.read_txn()
.map_err(|e| StoreError::Other(e.to_string()))?;
let order_keys: Vec<Vec<u8>> = self
.eviction_order
.iter(&rtxn)
.map_err(|e| StoreError::Other(e.to_string()))?
.map(|item| {
item.map(|(key, _)| key.to_vec())
.map_err(|e| StoreError::Other(e.to_string()))
})
.collect::<Result<_, _>>()?;
drop(rtxn);
let mut freed_total = 0u64;
let to_free = current_bytes - target_bytes;
let mut index = 0usize;
while freed_total < to_free && index < order_keys.len() {
let mut wtxn = self
.env
.write_txn()
.map_err(|e| StoreError::Other(e.to_string()))?;
let mut batch_freed = 0u64;
let mut batch_items = 0usize;
let mut batch_external_paths = Vec::new();
while freed_total + batch_freed < to_free && index < order_keys.len() {
let order_key = &order_keys[index];
index += 1;
let hash = Self::decode_hash_from_order_key(order_key)?;
if self.read_pin_count(&wtxn, &hash)? > 0 {
continue;
}
let external_path = self.external_blob_path_in_txn(&wtxn, &hash)?;
let (_, bytes_freed) = self.delete_blob_in_txn(&mut wtxn, &hash)?;
if bytes_freed == 0 {
let _ = self
.eviction_order
.delete(&mut wtxn, order_key)
.map_err(|e| StoreError::Other(e.to_string()))?;
continue;
}
batch_freed = batch_freed.saturating_add(bytes_freed);
batch_items += 1;
if let Some(path) = external_path {
batch_external_paths.push(path);
}
if batch_freed >= EVICTION_BATCH_TARGET_BYTES
|| batch_items >= EVICTION_BATCH_MAX_ITEMS
{
break;
}
}
wtxn.commit()
.map_err(|e| StoreError::Other(e.to_string()))?;
for path in batch_external_paths {
let _ = fs::remove_file(path);
}
if batch_freed > 0 {
freed_total = freed_total.saturating_add(batch_freed);
}
}
Ok(freed_total)
}
fn delete_blob_in_txn(
&self,
wtxn: &mut heed::RwTxn,
hash: &Hash,
) -> Result<(bool, u64), StoreError> {
let pin_count = self.read_pin_count(wtxn, hash)?;
let data_len = self
.blobs
.get(wtxn, hash)
.map_err(|e| StoreError::Other(e.to_string()))?
.map(|data| data.len() as u64);
let meta = self
.metadata
.get(wtxn, hash)
.map_err(|e| StoreError::Other(e.to_string()))?
.map(Self::decode_blob_meta)
.transpose()?;
let existed = self
.blobs
.delete(wtxn, hash)
.map_err(|e| StoreError::Other(e.to_string()))?;
let _ = self
.metadata
.delete(wtxn, hash)
.map_err(|e| StoreError::Other(e.to_string()))?;
let _ = self
.pins
.delete(wtxn, hash)
.map_err(|e| StoreError::Other(e.to_string()))?;
if let Some(meta) = meta {
let order_key = Self::encode_order_key(meta.order, hash);
let _ = self
.eviction_order
.delete(wtxn, &order_key)
.map_err(|e| StoreError::Other(e.to_string()))?;
}
let bytes_freed = meta.map(|m| m.size).or(data_len).unwrap_or(0);
if existed || meta.is_some() {
self.decrement_totals_in_txn(
wtxn,
1,
bytes_freed,
u64::from(pin_count > 0),
if pin_count > 0 { bytes_freed } else { 0 },
)
.map_err(|e| StoreError::Other(e.to_string()))?;
}
Ok((existed || meta.is_some(), bytes_freed))
}
fn blob_size_in_txn(&self, txn: &heed::RoTxn, hash: &Hash) -> Result<Option<u64>, StoreError> {
if let Some(meta) = self
.metadata
.get(txn, hash)
.map_err(|e| StoreError::Other(e.to_string()))?
.map(Self::decode_blob_meta)
.transpose()?
{
return Ok(Some(meta.size));
}
self.blobs
.get(txn, hash)
.map_err(|e| StoreError::Other(e.to_string()))
.map(|data| data.map(|bytes| bytes.len() as u64))
}
fn read_pin_count(&self, txn: &heed::RoTxn, hash: &[u8]) -> Result<u32, StoreError> {
self.pins
.get(txn, hash)
.map_err(|e| StoreError::Other(e.to_string()))?
.map(Self::decode_pin_count)
.transpose()?
.map_or(Ok(0), Ok)
}
fn read_pin_count_lossy(
&self,
txn: &heed::RoTxn,
hash: &[u8],
) -> std::result::Result<u32, HeedError> {
Ok(self
.pins
.get(txn, hash)?
.and_then(Self::decode_pin_count_lossy)
.unwrap_or(0))
}
fn encode_blob_meta(meta: BlobMeta) -> [u8; BLOB_META_BYTES] {
let mut encoded = [0u8; BLOB_META_BYTES];
encoded[..8].copy_from_slice(&meta.order.to_be_bytes());
encoded[8..16].copy_from_slice(&meta.size.to_be_bytes());
encoded[16..].copy_from_slice(&meta.last_accessed_at.to_be_bytes());
encoded
}
fn decode_blob_meta(bytes: &[u8]) -> Result<BlobMeta, StoreError> {
if bytes.len() != LEGACY_BLOB_META_BYTES && bytes.len() != BLOB_META_BYTES {
return Err(StoreError::Other(format!(
"invalid blob metadata length: {}",
bytes.len()
)));
}
Ok(BlobMeta {
order: Self::decode_order(&bytes[..8])?,
size: u64::from_be_bytes(
bytes[8..16]
.try_into()
.map_err(|_| StoreError::Other("invalid blob size bytes".into()))?,
),
last_accessed_at: if bytes.len() >= BLOB_META_BYTES {
u64::from_be_bytes(
bytes[16..24]
.try_into()
.map_err(|_| StoreError::Other("invalid blob access time bytes".into()))?,
)
} else {
0
},
})
}
fn encode_store_totals(totals: StoreTotals) -> [u8; STORE_TOTALS_BYTES] {
let mut encoded = [0u8; STORE_TOTALS_BYTES];
encoded[0..8].copy_from_slice(&totals.count.to_be_bytes());
encoded[8..16].copy_from_slice(&totals.total_bytes.to_be_bytes());
encoded[16..24].copy_from_slice(&totals.pinned_count.to_be_bytes());
encoded[24..32].copy_from_slice(&totals.pinned_bytes.to_be_bytes());
encoded
}
fn decode_store_totals(bytes: &[u8]) -> Result<StoreTotals, StoreError> {
Self::decode_store_totals_lossy(bytes).ok_or_else(|| {
StoreError::Other(format!("invalid store totals length: {}", bytes.len()))
})
}
fn decode_store_totals_lossy(bytes: &[u8]) -> Option<StoreTotals> {
if bytes.len() != STORE_TOTALS_BYTES {
return None;
}
Some(StoreTotals {
count: u64::from_be_bytes(bytes[0..8].try_into().ok()?),
total_bytes: u64::from_be_bytes(bytes[8..16].try_into().ok()?),
pinned_count: u64::from_be_bytes(bytes[16..24].try_into().ok()?),
pinned_bytes: u64::from_be_bytes(bytes[24..32].try_into().ok()?),
})
}
fn encode_next_order(next_order: u64) -> [u8; NEXT_ORDER_BYTES] {
next_order.to_be_bytes()
}
fn decode_next_order(bytes: &[u8]) -> Result<u64, StoreError> {
Self::decode_next_order_lossy(bytes)
.ok_or_else(|| StoreError::Other(format!("invalid next order length: {}", bytes.len())))
}
fn decode_next_order_lossy(bytes: &[u8]) -> Option<u64> {
if bytes.len() != NEXT_ORDER_BYTES {
return None;
}
Some(u64::from_be_bytes(bytes.try_into().ok()?))
}
fn encode_order_key(order: u64, hash: &Hash) -> [u8; ORDER_KEY_BYTES] {
let mut key = [0u8; ORDER_KEY_BYTES];
key[..8].copy_from_slice(&order.to_be_bytes());
key[8..].copy_from_slice(hash);
key
}
fn decode_order(bytes: &[u8]) -> Result<u64, StoreError> {
if bytes.len() != 8 {
return Err(StoreError::Other(format!(
"invalid order length: {}",
bytes.len()
)));
}
Ok(u64::from_be_bytes(bytes.try_into().map_err(|_| {
StoreError::Other("invalid order bytes".into())
})?))
}
fn decode_hash_from_order_key(bytes: &[u8]) -> Result<Hash, StoreError> {
if bytes.len() != ORDER_KEY_BYTES {
return Err(StoreError::Other(format!(
"invalid order key length: {}",
bytes.len()
)));
}
let mut hash = [0u8; 32];
hash.copy_from_slice(&bytes[8..]);
Ok(hash)
}
fn decode_pin_count(bytes: &[u8]) -> Result<u32, StoreError> {
Self::decode_pin_count_lossy(bytes)
.ok_or_else(|| StoreError::Other(format!("invalid pin count length: {}", bytes.len())))
}
fn decode_pin_count_lossy(bytes: &[u8]) -> Option<u32> {
if bytes.len() != PIN_COUNT_BYTES {
return None;
}
Some(u32::from_be_bytes(bytes.try_into().ok()?))
}
fn external_blob_ref(hash: &Hash) -> Vec<u8> {
let mut marker = Vec::with_capacity(EXTERNAL_BLOB_MARKER_PREFIX.len() + hash.len());
marker.extend_from_slice(EXTERNAL_BLOB_MARKER_PREFIX);
marker.extend_from_slice(hash);
marker
}
fn external_pack_reserved_ref(hash: &Hash) -> Vec<u8> {
let mut marker =
Vec::with_capacity(EXTERNAL_PACK_RESERVED_MARKER_PREFIX.len() + hash.len());
marker.extend_from_slice(EXTERNAL_PACK_RESERVED_MARKER_PREFIX);
marker.extend_from_slice(hash);
marker
}
fn external_pack_blob_ref(
pack_name: &str,
offset: u64,
len: u64,
) -> Result<Vec<u8>, StoreError> {
let name_len = u16::try_from(pack_name.len())
.map_err(|_| StoreError::Other("external pack file name is too long".to_string()))?;
let mut marker =
Vec::with_capacity(EXTERNAL_PACK_MARKER_PREFIX.len() + 2 + pack_name.len() + 16);
marker.extend_from_slice(EXTERNAL_PACK_MARKER_PREFIX);
marker.extend_from_slice(&name_len.to_be_bytes());
marker.extend_from_slice(pack_name.as_bytes());
marker.extend_from_slice(&offset.to_be_bytes());
marker.extend_from_slice(&len.to_be_bytes());
Ok(marker)
}
fn is_external_blob_ref(&self, hash: &Hash, value: &[u8]) -> bool {
value.len() == EXTERNAL_BLOB_MARKER_PREFIX.len() + hash.len()
&& value.starts_with(EXTERNAL_BLOB_MARKER_PREFIX)
&& &value[EXTERNAL_BLOB_MARKER_PREFIX.len()..] == hash
}
fn decode_external_pack_ref(value: &[u8]) -> Result<Option<ExternalPackRef>, StoreError> {
if !value.starts_with(EXTERNAL_PACK_MARKER_PREFIX) {
return Ok(None);
}
let rest = &value[EXTERNAL_PACK_MARKER_PREFIX.len()..];
if rest.len() < 2 + 8 + 8 {
return Err(StoreError::Other(
"invalid external LMDB pack marker length".to_string(),
));
}
let name_len = u16::from_be_bytes(
rest[..2]
.try_into()
.map_err(|_| StoreError::Other("invalid external pack name length".into()))?,
) as usize;
let expected_len = 2usize.saturating_add(name_len).saturating_add(16);
if rest.len() != expected_len {
return Err(StoreError::Other(format!(
"invalid external LMDB pack marker length: {}",
value.len()
)));
}
let name_bytes = &rest[2..2 + name_len];
let name = std::str::from_utf8(name_bytes)
.map_err(|_| StoreError::Other("external pack name is not UTF-8".into()))?;
if name.len() < 2
|| name.contains('/')
|| name.contains('\\')
|| name.starts_with('.')
|| name.contains("..")
{
return Err(StoreError::Other(
"external pack name is not a safe relative file name".into(),
));
}
let offset_start = 2 + name_len;
let offset = u64::from_be_bytes(
rest[offset_start..offset_start + 8]
.try_into()
.map_err(|_| StoreError::Other("invalid external pack offset".into()))?,
);
let len = u64::from_be_bytes(
rest[offset_start + 8..offset_start + 16]
.try_into()
.map_err(|_| StoreError::Other("invalid external pack length".into()))?,
);
Ok(Some(ExternalPackRef {
name: name.to_string(),
offset,
len,
}))
}
fn external_blob_path_for_config(config: &ExternalBlobConfig, hash: &Hash) -> PathBuf {
let hex = to_hex(hash);
config
.base_path
.join(&hex[..2])
.join(&hex[2..4])
.join(&hex[4..])
}
fn external_blob_path(&self, hash: &Hash) -> Option<PathBuf> {
self.external_blobs
.as_ref()
.map(|config| Self::external_blob_path_for_config(config, hash))
}
fn external_pack_path_for_config(config: &ExternalBlobConfig, pack_name: &str) -> PathBuf {
config
.base_path
.join("packs")
.join(&pack_name[..2])
.join(pack_name)
}
fn external_pack_path(&self, pack_ref: &ExternalPackRef) -> Option<PathBuf> {
self.external_blobs
.as_ref()
.map(|config| Self::external_pack_path_for_config(config, &pack_ref.name))
}
fn external_pack_name(first_hash: &Hash) -> String {
let hash_hex = to_hex(first_hash);
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
let counter = EXTERNAL_PACK_COUNTER.fetch_add(1, Ordering::Relaxed);
format!(
"{}-{:032x}-{}-{:016x}.pack",
&hash_hex[..12],
nanos,
std::process::id(),
counter
)
}
fn write_external_blob(
&self,
hash: &Hash,
data: &[u8],
config: &ExternalBlobConfig,
) -> Result<(), StoreError> {
let path = Self::external_blob_path_for_config(config, hash);
if path.exists() {
return Ok(());
}
let parent = path
.parent()
.ok_or_else(|| StoreError::Other("external blob path has no parent".to_string()))?;
fs::create_dir_all(parent)?;
let temp_path = unique_temp_path(&path);
{
let mut file = File::options()
.write(true)
.create_new(true)
.open(&temp_path)?;
file.write_all(data)?;
if config.sync {
file.sync_all()?;
}
}
if let Err(error) = fs::rename(&temp_path, &path) {
let _ = fs::remove_file(&temp_path);
return Err(error.into());
}
if config.sync {
File::open(parent)?.sync_all()?;
}
Ok(())
}
fn write_external_blob_packs(
&self,
entries: &[(usize, Hash, &[u8])],
config: &ExternalBlobConfig,
pack_target_bytes: usize,
) -> Result<Vec<(usize, Vec<u8>)>, StoreError> {
let mut markers = Vec::with_capacity(entries.len());
let mut pack_entries = Vec::new();
let mut pack_bytes = 0usize;
for entry in entries {
let data_len = entry.2.len();
let would_exceed_target =
!pack_entries.is_empty() && pack_bytes.saturating_add(data_len) > pack_target_bytes;
if would_exceed_target {
markers.extend(self.write_external_blob_pack(&pack_entries, config)?);
pack_entries.clear();
pack_bytes = 0;
}
pack_entries.push(*entry);
pack_bytes = pack_bytes.saturating_add(data_len);
}
if !pack_entries.is_empty() {
markers.extend(self.write_external_blob_pack(&pack_entries, config)?);
}
Ok(markers)
}
fn write_external_blob_pack(
&self,
entries: &[(usize, Hash, &[u8])],
config: &ExternalBlobConfig,
) -> Result<Vec<(usize, Vec<u8>)>, StoreError> {
if entries.is_empty() {
return Ok(Vec::new());
}
let pack_name = Self::external_pack_name(&entries[0].1);
let path = Self::external_pack_path_for_config(config, &pack_name);
let parent = path
.parent()
.ok_or_else(|| StoreError::Other("external pack path has no parent".to_string()))?;
fs::create_dir_all(parent)?;
let temp_path = unique_temp_path(&path);
let mut markers = Vec::with_capacity(entries.len());
let write_result = (|| -> Result<(), StoreError> {
let mut file = File::options()
.write(true)
.create_new(true)
.open(&temp_path)?;
let mut offset = 0u64;
for (index, _, data) in entries {
let len = data.len() as u64;
file.write_all(data)?;
markers.push((
*index,
Self::external_pack_blob_ref(&pack_name, offset, len)?,
));
offset = offset.saturating_add(len);
}
if config.sync {
file.sync_all()?;
}
Ok(())
})();
if let Err(error) = write_result {
let _ = fs::remove_file(&temp_path);
return Err(error);
}
if let Err(error) = fs::rename(&temp_path, &path) {
let _ = fs::remove_file(&temp_path);
return Err(error.into());
}
if config.sync {
File::open(parent)?.sync_all()?;
}
Ok(markers)
}
fn decode_blob_value(&self, hash: &Hash, value: &[u8]) -> Result<Vec<u8>, StoreError> {
if self.is_external_blob_ref(hash, value) {
let path = self.external_blob_path(hash).ok_or_else(|| {
StoreError::Other(
"external LMDB blob marker found but external blobs are disabled".into(),
)
})?;
return fs::read(path).map_err(StoreError::Io);
}
if let Some(pack_ref) = Self::decode_external_pack_ref(value)? {
return self.read_external_pack_blob(&pack_ref);
}
Ok(value.to_vec())
}
fn read_external_blob_range(
&self,
hash: &Hash,
start: u64,
end_inclusive: u64,
) -> Result<Option<Vec<u8>>, StoreError> {
let path = self.external_blob_path(hash).ok_or_else(|| {
StoreError::Other(
"external LMDB blob marker found but external blobs are disabled".into(),
)
})?;
let mut file = File::open(path)?;
let len = file.metadata()?.len();
if len == 0 || start >= len || end_inclusive < start {
return Ok(Some(Vec::new()));
}
let actual_end = end_inclusive.min(len - 1);
let read_len = actual_end.saturating_sub(start).saturating_add(1);
let read_len = usize::try_from(read_len)
.map_err(|_| StoreError::Other("blob range is too large to read".to_string()))?;
let mut data = vec![0; read_len];
file.seek(SeekFrom::Start(start))?;
file.read_exact(&mut data)?;
Ok(Some(data))
}
fn read_external_pack_blob(&self, pack_ref: &ExternalPackRef) -> Result<Vec<u8>, StoreError> {
let read_len = usize::try_from(pack_ref.len).map_err(|_| {
StoreError::Other("external pack blob is too large to read".to_string())
})?;
let path = self.external_pack_path(pack_ref).ok_or_else(|| {
StoreError::Other(
"external LMDB pack marker found but external blobs are disabled".into(),
)
})?;
let mut file = File::open(path)?;
file.seek(SeekFrom::Start(pack_ref.offset))?;
let mut data = vec![0; read_len];
file.read_exact(&mut data)?;
Ok(data)
}
fn read_external_pack_range(
&self,
pack_ref: &ExternalPackRef,
start: u64,
end_inclusive: u64,
) -> Result<Option<Vec<u8>>, StoreError> {
if pack_ref.len == 0 || start >= pack_ref.len || end_inclusive < start {
return Ok(Some(Vec::new()));
}
let actual_end = end_inclusive.min(pack_ref.len - 1);
let read_len = actual_end.saturating_sub(start).saturating_add(1);
let read_len = usize::try_from(read_len).map_err(|_| {
StoreError::Other("external pack blob range is too large to read".to_string())
})?;
let path = self.external_pack_path(pack_ref).ok_or_else(|| {
StoreError::Other(
"external LMDB pack marker found but external blobs are disabled".into(),
)
})?;
let mut file = File::open(path)?;
file.seek(SeekFrom::Start(pack_ref.offset.saturating_add(start)))?;
let mut data = vec![0; read_len];
file.read_exact(&mut data)?;
Ok(Some(data))
}
fn external_blob_path_for_value(
&self,
hash: &Hash,
value: &[u8],
) -> Result<Option<PathBuf>, StoreError> {
if self.is_external_blob_ref(hash, value) {
return Ok(self.external_blob_path(hash));
}
if Self::decode_external_pack_ref(value)?.is_some() {
return Ok(None);
}
Ok(None)
}
fn external_blob_path_in_txn(
&self,
txn: &heed::RoTxn,
hash: &Hash,
) -> Result<Option<PathBuf>, StoreError> {
let Some(value) = self
.blobs
.get(txn, hash)
.map_err(|e| StoreError::Other(e.to_string()))?
else {
return Ok(None);
};
self.external_blob_path_for_value(hash, value)
}
fn remove_external_blob_file(&self, path: Option<PathBuf>) {
if let Some(path) = path {
let _ = fs::remove_file(path);
}
}
}
fn map_heed_error(error: HeedError) -> StoreError {
match error {
HeedError::Io(io_error) => StoreError::Io(io_error),
other => StoreError::Other(other.to_string()),
}
}
fn is_map_full_store_error(err: &StoreError) -> bool {
let message = err.to_string();
message.contains("MDB_MAP_FULL") || message.contains("MapFull")
}
fn env_bool(name: &str) -> Option<bool> {
std::env::var(name).ok().and_then(|value| {
let value = value.trim();
if value == "1" || value.eq_ignore_ascii_case("true") || value.eq_ignore_ascii_case("yes") {
Some(true)
} else if value == "0"
|| value.eq_ignore_ascii_case("false")
|| value.eq_ignore_ascii_case("no")
{
Some(false)
} else {
None
}
})
}
fn env_flags_from_env() -> EnvFlags {
env_flags_from_bools(
env_bool(LMDB_NO_READ_AHEAD_ENV).unwrap_or(false),
env_bool(LMDB_NO_SYNC_ENV).unwrap_or(false),
env_bool(LMDB_NO_META_SYNC_ENV).unwrap_or(false),
)
}
fn env_flags_from_bools(no_read_ahead: bool, no_sync: bool, no_meta_sync: bool) -> EnvFlags {
let mut flags = EnvFlags::empty();
if no_read_ahead {
flags |= EnvFlags::NO_READ_AHEAD;
}
if no_sync {
flags |= EnvFlags::NO_SYNC;
}
if no_meta_sync {
flags |= EnvFlags::NO_META_SYNC;
}
flags
}
fn unique_temp_path(path: &Path) -> PathBuf {
let file_name = path
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("blob");
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
path.with_file_name(format!(".{file_name}.tmp.{}.{}", std::process::id(), nanos))
}
fn unix_timestamp_now() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
#[derive(Debug, Clone)]
pub struct LmdbStats {
pub count: usize,
pub total_bytes: u64,
pub pinned_count: usize,
pub pinned_bytes: u64,
}
#[async_trait]
impl Store for LmdbBlobStore {
async fn put(&self, hash: Hash, data: Vec<u8>) -> Result<bool, StoreError> {
self.put_sync(hash, &data)
}
async fn put_many(&self, items: Vec<(Hash, Vec<u8>)>) -> Result<usize, StoreError> {
self.put_many_sync(&items)
}
async fn get(&self, hash: &Hash) -> Result<Option<Vec<u8>>, StoreError> {
self.get_sync(hash)
}
async fn get_range(
&self,
hash: &Hash,
start: u64,
end_inclusive: u64,
) -> Result<Option<Vec<u8>>, StoreError> {
self.get_range_sync(hash, start, end_inclusive)
}
async fn blob_size(&self, hash: &Hash) -> Result<Option<u64>, StoreError> {
self.blob_size_sync(hash)
}
async fn has(&self, hash: &Hash) -> Result<bool, StoreError> {
self.exists(hash)
}
async fn delete(&self, hash: &Hash) -> Result<bool, StoreError> {
self.delete_sync(hash)
}
fn set_max_bytes(&self, max: u64) {
self.max_bytes.store(max, Ordering::Relaxed);
}
fn max_bytes(&self) -> Option<u64> {
let max = self.max_bytes.load(Ordering::Relaxed);
if max > 0 {
Some(max)
} else {
None
}
}
async fn stats(&self) -> StoreStats {
match self.stats() {
Ok(stats) => StoreStats {
count: stats.count as u64,
bytes: stats.total_bytes,
pinned_count: stats.pinned_count as u64,
pinned_bytes: stats.pinned_bytes,
},
Err(_) => StoreStats::default(),
}
}
async fn evict_if_needed(&self) -> Result<u64, StoreError> {
let max = self.max_bytes.load(Ordering::Relaxed);
if max == 0 {
return Ok(0);
}
let current = self.total_bytes()?;
if current <= max {
return Ok(0);
}
let target = max * 9 / 10;
self.evict_to_target(current, target)
}
async fn pin(&self, hash: &Hash) -> Result<(), StoreError> {
self.pin_sync(hash)
}
async fn unpin(&self, hash: &Hash) -> Result<(), StoreError> {
self.unpin_sync(hash)
}
fn pin_count(&self, hash: &Hash) -> u32 {
let Ok(rtxn) = self.env.read_txn() else {
return 0;
};
self.read_pin_count(&rtxn, hash).unwrap_or(0)
}
}
#[cfg(test)]
mod tests {
use super::*;
use hashtree_core::sha256;
use heed::EnvOpenOptions;
#[cfg(unix)]
use std::path::{Path, PathBuf};
#[cfg(unix)]
use std::process::Command;
use std::sync::{
atomic::{AtomicBool, Ordering},
Arc, Barrier,
};
use std::time::Duration;
use tempfile::TempDir;
#[cfg(unix)]
const STALE_READER_HELPER_ENV: &str = "HASHTREE_LMDB_STALE_READER_HELPER";
#[cfg(unix)]
const STALE_READER_HELPER_MODE_ENV: &str = "HASHTREE_LMDB_STALE_READER_HELPER_MODE";
#[cfg(unix)]
const STALE_READER_DB_PATH_ENV: &str = "HASHTREE_LMDB_STALE_READER_DB_PATH";
#[cfg(unix)]
const STALE_READER_MARKER_PATH_ENV: &str = "HASHTREE_LMDB_STALE_READER_MARKER_PATH";
#[cfg(unix)]
const TEST_MAX_READERS: u32 = 4;
fn count_files_under(path: &std::path::Path) -> std::io::Result<usize> {
if !path.exists() {
return Ok(0);
}
let mut count = 0usize;
for entry in std::fs::read_dir(path)? {
let entry = entry?;
let metadata = entry.metadata()?;
if metadata.is_dir() {
count += count_files_under(&entry.path())?;
} else if metadata.is_file() {
count += 1;
}
}
Ok(count)
}
fn persisted_next_order(store: &LmdbBlobStore) -> Result<u64, StoreError> {
let rtxn = store.env.read_txn().map_err(map_heed_error)?;
LmdbBlobStore::read_next_order(store.stats, &rtxn)?
.ok_or_else(|| StoreError::Other("missing persisted next_order".to_string()))
}
fn metadata_order(store: &LmdbBlobStore, hash: &Hash) -> Result<u64, StoreError> {
let rtxn = store.env.read_txn().map_err(map_heed_error)?;
let meta = store
.metadata
.get(&rtxn, hash)
.map_err(map_heed_error)?
.ok_or_else(|| StoreError::Other("missing blob metadata".to_string()))?;
LmdbBlobStore::decode_blob_meta(meta).map(|meta| meta.order)
}
fn delete_persisted_next_order(store: &LmdbBlobStore) -> Result<(), StoreError> {
let mut wtxn = store.env.write_txn().map_err(map_heed_error)?;
store
.stats
.delete(&mut wtxn, NEXT_ORDER_KEY)
.map_err(map_heed_error)?;
wtxn.commit().map_err(map_heed_error)
}
#[cfg(unix)]
fn run_helper(mode: &str, path: &Path, marker: &Path) {
let output = Command::new(std::env::current_exe().expect("test binary path"))
.arg("--ignored")
.arg("--exact")
.arg("tests::lmdb_stale_reader_helper")
.env(STALE_READER_HELPER_ENV, "1")
.env(STALE_READER_HELPER_MODE_ENV, mode)
.env(STALE_READER_DB_PATH_ENV, path)
.env(STALE_READER_MARKER_PATH_ENV, marker)
.env("RUST_TEST_THREADS", "1")
.output()
.expect("spawn stale-reader helper");
assert!(
output.status.success(),
"stale-reader helper failed: stdout={} stderr={}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
assert!(
marker.exists(),
"stale-reader helper did not run: stdout={} stderr={}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}
#[test]
fn env_flags_from_bools_enables_bulk_ingest_flags() {
let flags = env_flags_from_bools(true, true, true);
assert!(flags.contains(EnvFlags::NO_READ_AHEAD));
assert!(flags.contains(EnvFlags::NO_SYNC));
assert!(flags.contains(EnvFlags::NO_META_SYNC));
}
#[tokio::test]
async fn test_put_get() -> Result<(), StoreError> {
let temp = TempDir::new().unwrap();
let store = LmdbBlobStore::new(temp.path().join("blobs"))?;
let data = b"hello lmdb";
let hash = sha256(data);
store.put(hash, data.to_vec()).await?;
assert!(store.has(&hash).await?);
assert_eq!(store.get(&hash).await?, Some(data.to_vec()));
Ok(())
}
#[test]
fn external_blob_spill_keeps_large_values_out_of_lmdb() -> Result<(), StoreError> {
let temp = TempDir::new().unwrap();
let external_dir = temp.path().join("external");
let store = LmdbBlobStore::with_map_size_and_settings(
temp.path().join("blobs"),
16 * 1024 * 1024,
EnvFlags::empty(),
|_| {
Some(ExternalBlobConfig {
base_path: external_dir.clone(),
min_bytes: 8,
sync: false,
pack_target_bytes: None,
})
},
)?;
let small = b"tiny";
let small_hash = sha256(small);
assert!(store.put_sync(small_hash, small)?);
let large = b"large external blob payload";
let large_hash = sha256(large);
assert!(store.put_sync(large_hash, large)?);
assert_eq!(store.get_sync(&small_hash)?, Some(small.to_vec()));
assert_eq!(store.get_sync(&large_hash)?, Some(large.to_vec()));
assert_eq!(
store.get_range_sync(&large_hash, 6, 13)?,
Some(b"external".to_vec())
);
let rtxn = store.env.read_txn().map_err(map_heed_error)?;
let inline_value = store
.blobs
.get(&rtxn, &small_hash)
.map_err(map_heed_error)?
.expect("small inline value");
assert_eq!(inline_value, small);
let external_value = store
.blobs
.get(&rtxn, &large_hash)
.map_err(map_heed_error)?
.expect("large marker value");
assert!(store.is_external_blob_ref(&large_hash, external_value));
drop(rtxn);
let external_path = store.external_blob_path(&large_hash).unwrap();
assert_eq!(std::fs::read(&external_path)?, large);
let stats = store.stats()?;
assert_eq!(stats.count, 2);
assert_eq!(stats.total_bytes, (small.len() + large.len()) as u64);
assert!(store.delete_sync(&large_hash)?);
assert!(!external_path.exists());
Ok(())
}
#[test]
fn external_blob_pack_batches_large_values() -> Result<(), StoreError> {
let temp = TempDir::new().unwrap();
let external_dir = temp.path().join("external");
let store = LmdbBlobStore::with_map_size_and_settings(
temp.path().join("blobs"),
16 * 1024 * 1024,
EnvFlags::empty(),
|_| {
Some(ExternalBlobConfig {
base_path: external_dir.clone(),
min_bytes: 8,
sync: true,
pack_target_bytes: Some(1024 * 1024),
})
},
)?;
let first = b"first packed blob payload".to_vec();
let second = b"second packed blob payload".to_vec();
let tiny = b"tiny".to_vec();
let first_hash = sha256(&first);
let second_hash = sha256(&second);
let tiny_hash = sha256(&tiny);
let items = vec![
(first_hash, first.clone()),
(tiny_hash, tiny.clone()),
(second_hash, second.clone()),
];
assert_eq!(store.put_many_sync(&items)?, 3);
assert_eq!(store.get_sync(&first_hash)?, Some(first.clone()));
assert_eq!(store.get_sync(&second_hash)?, Some(second.clone()));
assert_eq!(store.get_sync(&tiny_hash)?, Some(tiny.clone()));
assert_eq!(
store.get_range_sync(&second_hash, 7, 12)?,
Some(b"packed".to_vec())
);
let rtxn = store.env.read_txn().map_err(map_heed_error)?;
let first_value = store
.blobs
.get(&rtxn, &first_hash)
.map_err(map_heed_error)?
.expect("first pack marker");
let second_value = store
.blobs
.get(&rtxn, &second_hash)
.map_err(map_heed_error)?
.expect("second pack marker");
let tiny_value = store
.blobs
.get(&rtxn, &tiny_hash)
.map_err(map_heed_error)?
.expect("tiny inline value");
let first_pack =
LmdbBlobStore::decode_external_pack_ref(first_value)?.expect("first external pack ref");
let second_pack = LmdbBlobStore::decode_external_pack_ref(second_value)?
.expect("second external pack ref");
assert_eq!(first_pack.name, second_pack.name);
assert_eq!(tiny_value, tiny.as_slice());
drop(rtxn);
let pack_path = store.external_pack_path(&first_pack).unwrap();
assert!(pack_path.exists());
assert_eq!(
std::fs::read(&pack_path)?,
[first.as_slice(), second.as_slice()].concat()
);
let pack_count_after_first_write = count_files_under(&external_dir.join("packs"))?;
assert_eq!(store.put_many_sync(&items)?, 0);
assert_eq!(
count_files_under(&external_dir.join("packs"))?,
pack_count_after_first_write,
"rewriting an already-present batch must not create orphan external pack files"
);
assert!(store.delete_sync(&first_hash)?);
assert!(pack_path.exists());
assert_eq!(store.get_sync(&second_hash)?, Some(second));
Ok(())
}
#[tokio::test]
async fn test_delete() -> Result<(), StoreError> {
let temp = TempDir::new().unwrap();
let store = LmdbBlobStore::new(temp.path().join("blobs"))?;
let data = b"delete me";
let hash = sha256(data);
store.put(hash, data.to_vec()).await?;
assert!(store.has(&hash).await?);
assert!(store.delete(&hash).await?);
assert!(!store.has(&hash).await?);
assert!(!store.delete(&hash).await?);
Ok(())
}
#[tokio::test]
async fn test_list() -> Result<(), StoreError> {
let temp = TempDir::new().unwrap();
let store = LmdbBlobStore::new(temp.path().join("blobs"))?;
let d1 = b"one";
let d2 = b"two";
let d3 = b"three";
let h1 = sha256(d1);
let h2 = sha256(d2);
let h3 = sha256(d3);
store.put(h1, d1.to_vec()).await?;
store.put(h2, d2.to_vec()).await?;
store.put(h3, d3.to_vec()).await?;
let hashes = store.list()?;
assert_eq!(hashes.len(), 3);
assert!(hashes.contains(&h1));
assert!(hashes.contains(&h2));
assert!(hashes.contains(&h3));
Ok(())
}
#[test]
fn existing_hashes_in_sorted_candidates_marks_present_hashes() -> Result<(), StoreError> {
let temp = TempDir::new().unwrap();
let store = LmdbBlobStore::new(temp.path().join("blobs"))?;
let h1 = sha256(b"range present one");
let h2 = sha256(b"range present two");
let missing = sha256(b"range missing");
store.put_sync(h1, b"range present one")?;
store.put_sync(h2, b"range present two")?;
let mut candidates = vec![missing, h2, h1, h1];
candidates.sort_unstable();
let existing = store.existing_hashes_in_sorted_candidates(&candidates)?;
assert_eq!(existing.len(), candidates.len());
for (hash, exists) in candidates.iter().zip(existing) {
assert_eq!(exists, *hash == h1 || *hash == h2);
}
Ok(())
}
#[tokio::test]
async fn test_blob_last_accessed_persists_and_updates_eviction_order() -> Result<(), StoreError>
{
let temp = TempDir::new().unwrap();
let store = LmdbBlobStore::new(temp.path().join("blobs"))?;
let first = sha256(b"first");
let second = sha256(b"second");
store.put(first, b"first".to_vec()).await?;
store.put(second, b"second".to_vec()).await?;
assert!(store.last_accessed_at_sync(&first)?.unwrap_or(0) > 0);
let access_time = unix_timestamp_now().saturating_add(1000);
store.touch_accessed_sync(&first, access_time)?;
assert_eq!(store.last_accessed_at_sync(&first)?, Some(access_time));
assert!(store.delete_sync(&first)?);
assert!(store.exists(&second)?);
Ok(())
}
#[test]
fn duplicate_put_is_noop_and_preserves_blob_last_accessed() -> Result<(), StoreError> {
let temp = TempDir::new().unwrap();
let store = LmdbBlobStore::new(temp.path().join("blobs"))?;
let data = b"already here";
let hash = sha256(data);
assert!(store.put_sync(hash, data)?);
let accessed = store.last_accessed_at_sync(&hash)?;
let next_order = persisted_next_order(&store)?;
assert!(!store.put_sync(hash, data)?);
assert_eq!(store.last_accessed_at_sync(&hash)?, accessed);
assert_eq!(persisted_next_order(&store)?, next_order);
let stats = store.stats()?;
assert_eq!(stats.count, 1);
assert_eq!(stats.total_bytes, data.len() as u64);
Ok(())
}
#[test]
fn next_order_persists_across_reopen_and_counts_only_new_blobs() -> Result<(), StoreError> {
let temp = TempDir::new().unwrap();
let path = temp.path().join("blobs");
let first = sha256(b"order first");
let second = sha256(b"order second");
let third = sha256(b"order third");
{
let store = LmdbBlobStore::new(&path)?;
assert!(store.put_sync(first, b"order first")?);
assert!(store.put_sync(second, b"order second")?);
assert_eq!(metadata_order(&store, &first)?, 0);
assert_eq!(metadata_order(&store, &second)?, 1);
assert_eq!(persisted_next_order(&store)?, 2);
assert!(!store.put_sync(first, b"order first")?);
assert_eq!(
store.put_many_sync(&[(second, b"order second".to_vec())])?,
0
);
assert_eq!(persisted_next_order(&store)?, 2);
}
let reopened = LmdbBlobStore::new(&path)?;
assert_eq!(persisted_next_order(&reopened)?, 2);
assert!(reopened.put_sync(third, b"order third")?);
assert_eq!(metadata_order(&reopened, &third)?, 2);
assert_eq!(persisted_next_order(&reopened)?, 3);
Ok(())
}
#[test]
fn legacy_store_without_next_order_seeds_high_counter_without_tail_scan(
) -> Result<(), StoreError> {
let temp = TempDir::new().unwrap();
let path = temp.path().join("blobs");
let existing = sha256(b"legacy existing");
let new = sha256(b"legacy new");
{
let store = LmdbBlobStore::new(&path)?;
assert!(store.put_sync(existing, b"legacy existing")?);
delete_persisted_next_order(&store)?;
}
let reopened = LmdbBlobStore::new(&path)?;
let migrated_next_order = persisted_next_order(&reopened)?;
assert!(migrated_next_order >= NEXT_ORDER_MIGRATION_FLOOR);
assert!(reopened.put_sync(new, b"legacy new")?);
assert_eq!(metadata_order(&reopened, &new)?, migrated_next_order);
assert_eq!(
persisted_next_order(&reopened)?,
migrated_next_order.saturating_add(1)
);
Ok(())
}
#[test]
fn put_many_report_counts_only_new_hashes_and_bytes() -> Result<(), StoreError> {
let temp = TempDir::new().unwrap();
let store = LmdbBlobStore::new(temp.path().join("blobs"))?;
let existing = b"existing";
let existing_hash = sha256(existing);
assert!(store.put_sync(existing_hash, existing)?);
let new_one = b"new one";
let new_two = b"new two";
let new_one_hash = sha256(new_one);
let new_two_hash = sha256(new_two);
let items = vec![
(existing_hash, existing.to_vec()),
(new_one_hash, new_one.to_vec()),
(new_one_hash, new_one.to_vec()),
(new_two_hash, new_two.to_vec()),
];
let report = store.put_many_report_sync(&items)?;
assert_eq!(report.total, 4);
assert_eq!(report.inserted, 2);
assert_eq!(
report.inserted_bytes,
(new_one.len() + new_two.len()) as u64
);
assert_eq!(report.inserted_hashes, vec![new_one_hash, new_two_hash]);
assert_eq!(store.put_many_sync(&items)?, 0);
assert_eq!(
store.stats()?.total_bytes,
(existing.len() + new_one.len() + new_two.len()) as u64
);
Ok(())
}
#[test]
fn duplicate_heavy_batch_does_not_evict_by_candidate_bytes() -> Result<(), StoreError> {
let temp = TempDir::new().unwrap();
let store = LmdbBlobStore::new(temp.path().join("blobs"))?;
store.set_max_bytes(35);
let first = [1u8; 10];
let second = [2u8; 10];
let third = [3u8; 10];
let new = [4u8; 5];
let first_hash = sha256(&first);
let second_hash = sha256(&second);
let third_hash = sha256(&third);
let new_hash = sha256(&new);
assert!(store.put_sync(first_hash, &first)?);
assert!(store.put_sync(second_hash, &second)?);
assert!(store.put_sync(third_hash, &third)?);
assert_eq!(store.stats()?.total_bytes, 30);
let report = store.put_many_report_sync(&[
(first_hash, first.to_vec()),
(second_hash, second.to_vec()),
(new_hash, new.to_vec()),
])?;
assert_eq!(report.inserted, 1);
assert_eq!(report.inserted_bytes, 5);
assert_eq!(store.stats()?.total_bytes, 35);
assert!(store.exists(&first_hash)?);
assert!(store.exists(&second_hash)?);
assert!(store.exists(&third_hash)?);
assert!(store.exists(&new_hash)?);
Ok(())
}
#[test]
fn test_decodes_legacy_blob_metadata_without_access_time() -> Result<(), StoreError> {
let mut encoded = [0u8; LEGACY_BLOB_META_BYTES];
encoded[..8].copy_from_slice(&7u64.to_be_bytes());
encoded[8..].copy_from_slice(&42u64.to_be_bytes());
let meta = LmdbBlobStore::decode_blob_meta(&encoded)?;
assert_eq!(meta.order, 7);
assert_eq!(meta.size, 42);
assert_eq!(meta.last_accessed_at, 0);
Ok(())
}
#[tokio::test]
async fn test_stats() -> Result<(), StoreError> {
let temp = TempDir::new().unwrap();
let store = LmdbBlobStore::new(temp.path().join("blobs"))?;
let d1 = b"hello";
let d2 = b"world";
store.put(sha256(d1), d1.to_vec()).await?;
store.put(sha256(d2), d2.to_vec()).await?;
let stats = store.stats()?;
assert_eq!(stats.count, 2);
assert_eq!(stats.total_bytes, 10);
Ok(())
}
#[tokio::test]
async fn test_stats_persist_across_reopen_and_mutations() -> Result<(), StoreError> {
let temp = TempDir::new().unwrap();
let path = temp.path().join("blobs");
let h1 = sha256(b"hello");
let h2 = sha256(b"world!");
let h3 = sha256(b"prepin");
{
let store = LmdbBlobStore::new(&path)?;
store.put(h1, b"hello".to_vec()).await?;
store.put(h2, b"world!".to_vec()).await?;
store.pin(&h1).await?;
store.pin(&h3).await?;
store.put(h3, b"prepin".to_vec()).await?;
let stats = store.stats()?;
assert_eq!(stats.count, 3);
assert_eq!(stats.total_bytes, 17);
assert_eq!(stats.pinned_count, 2);
assert_eq!(stats.pinned_bytes, 11);
}
{
let reopened = LmdbBlobStore::new(&path)?;
let stats = reopened.stats()?;
assert_eq!(stats.count, 3);
assert_eq!(stats.total_bytes, 17);
assert_eq!(stats.pinned_count, 2);
assert_eq!(stats.pinned_bytes, 11);
assert!(reopened.delete(&h1).await?);
let stats = reopened.stats()?;
assert_eq!(stats.count, 2);
assert_eq!(stats.total_bytes, 12);
assert_eq!(stats.pinned_count, 1);
assert_eq!(stats.pinned_bytes, 6);
}
let reopened = LmdbBlobStore::new(&path)?;
let stats = reopened.stats()?;
assert_eq!(stats.count, 2);
assert_eq!(stats.total_bytes, 12);
assert_eq!(stats.pinned_count, 1);
assert_eq!(stats.pinned_bytes, 6);
Ok(())
}
#[tokio::test]
async fn test_deduplication() -> Result<(), StoreError> {
let temp = TempDir::new().unwrap();
let store = LmdbBlobStore::new(temp.path().join("blobs"))?;
let data = b"same";
let hash = sha256(data);
assert!(store.put(hash, data.to_vec()).await?); assert!(!store.put(hash, data.to_vec()).await?);
assert_eq!(store.list()?.len(), 1);
Ok(())
}
#[tokio::test]
async fn test_max_bytes() -> Result<(), StoreError> {
let temp = TempDir::new().unwrap();
let store = LmdbBlobStore::new(temp.path().join("blobs"))?;
assert!(store.max_bytes().is_none());
store.set_max_bytes(1000);
assert_eq!(store.max_bytes(), Some(1000));
store.set_max_bytes(0);
assert!(store.max_bytes().is_none());
Ok(())
}
#[test]
fn test_with_max_bytes_expands_lmdb_map_size() -> Result<(), StoreError> {
let temp = TempDir::new().unwrap();
let requested = (DEFAULT_MAP_SIZE as u64) + 64 * 1024 * 1024;
let store = LmdbBlobStore::with_max_bytes(temp.path().join("blobs"), requested)?;
assert!(
store.map_size_bytes() as u64 >= requested,
"expected LMDB map to grow to at least {requested} bytes, got {}",
store.map_size_bytes()
);
assert_eq!(store.max_bytes(), Some(requested));
Ok(())
}
#[tokio::test]
async fn test_eviction_over_limit() -> Result<(), StoreError> {
let temp = TempDir::new().unwrap();
let store = LmdbBlobStore::new(temp.path().join("blobs"))?;
store.set_max_bytes(25);
let h1 = sha256(b"aaaaaaaaaa");
let h2 = sha256(b"bbbbbbbbbb");
let h3 = sha256(b"cccccccccc");
store.put(h1, b"aaaaaaaaaa".to_vec()).await?;
store.put(h2, b"bbbbbbbbbb".to_vec()).await?;
store.put(h3, b"cccccccccc".to_vec()).await?;
let freed = store.evict_if_needed().await?;
assert_eq!(
freed, 0,
"write path should have already evicted stale blobs"
);
assert!(
!store.has(&h1).await?,
"oldest blob should be evicted before the third write"
);
assert!(store.has(&h2).await?);
assert!(store.has(&h3).await?);
let stats = store.stats()?;
assert!(
stats.total_bytes <= 22,
"store should be reduced to 90% target"
);
Ok(())
}
#[tokio::test]
async fn test_eviction_respects_pins() -> Result<(), StoreError> {
let temp = TempDir::new().unwrap();
let store = LmdbBlobStore::new(temp.path().join("blobs"))?;
store.set_max_bytes(25);
let h1 = sha256(b"aaaaaaaaaa");
let h2 = sha256(b"bbbbbbbbbb");
let h3 = sha256(b"cccccccccc");
store.put(h1, b"aaaaaaaaaa".to_vec()).await?;
store.put(h2, b"bbbbbbbbbb".to_vec()).await?;
store.pin(&h1).await?;
store.put(h3, b"cccccccccc".to_vec()).await?;
let freed = store.evict_if_needed().await?;
assert_eq!(
freed, 0,
"write path should have already evicted stale blobs"
);
assert!(store.has(&h1).await?, "pinned blob must not be evicted");
assert!(
!store.has(&h2).await?,
"oldest unpinned blob should be evicted before the third write"
);
assert!(store.has(&h3).await?);
Ok(())
}
#[tokio::test]
async fn test_reopen_with_existing_eviction_order() -> Result<(), StoreError> {
let temp = TempDir::new().unwrap();
let path = temp.path().join("blobs");
{
let store = LmdbBlobStore::new(&path)?;
let h1 = sha256(b"aaaaaaaaaa");
let h2 = sha256(b"bbbbbbbbbb");
store.put(h1, b"aaaaaaaaaa".to_vec()).await?;
store.put(h2, b"bbbbbbbbbb".to_vec()).await?;
}
let reopened = LmdbBlobStore::new(&path)?;
let h3 = sha256(b"cccccccccc");
assert!(reopened.put(h3, b"cccccccccc".to_vec()).await?);
assert!(reopened.has(&h3).await?);
Ok(())
}
#[tokio::test]
async fn test_reopen_with_lower_max_bytes_evicts_existing_blobs() -> Result<(), StoreError> {
let temp = TempDir::new().unwrap();
let path = temp.path().join("blobs");
{
let store = LmdbBlobStore::new(&path)?;
let h1 = sha256(b"aaaaaaaaaa");
let h2 = sha256(b"bbbbbbbbbb");
let h3 = sha256(b"cccccccccc");
store.put(h1, b"aaaaaaaaaa".to_vec()).await?;
store.put(h2, b"bbbbbbbbbb".to_vec()).await?;
store.put(h3, b"cccccccccc".to_vec()).await?;
}
let reopened = LmdbBlobStore::with_max_bytes(&path, 25)?;
let h1 = sha256(b"aaaaaaaaaa");
let h2 = sha256(b"bbbbbbbbbb");
let h3 = sha256(b"cccccccccc");
assert!(
!reopened.has(&h1).await?,
"oldest blob should be evicted when reopening over the new cap"
);
assert!(reopened.has(&h2).await?);
assert!(reopened.has(&h3).await?);
let stats = reopened.stats()?;
assert!(
stats.total_bytes <= 22,
"reopened store should be reduced to the 90% target"
);
Ok(())
}
#[test]
fn test_supports_many_concurrent_readers() -> Result<(), Box<dyn std::error::Error>> {
const READER_THREADS: usize = 160;
let temp = TempDir::new()?;
let store = Arc::new(LmdbBlobStore::new(temp.path().join("blobs"))?);
let hash = sha256(b"many readers");
store.put_sync(hash, b"many readers")?;
let start = Arc::new(Barrier::new(READER_THREADS + 1));
let release = Arc::new(AtomicBool::new(false));
let mut handles = Vec::with_capacity(READER_THREADS);
for _ in 0..READER_THREADS {
let env = store.env.clone();
let start = Arc::clone(&start);
let release = Arc::clone(&release);
handles.push(std::thread::spawn(move || -> Result<(), String> {
start.wait();
let _rtxn = env.read_txn().map_err(|err| err.to_string())?;
while !release.load(Ordering::Relaxed) {
std::thread::sleep(Duration::from_millis(1));
}
Ok(())
}));
}
start.wait();
std::thread::sleep(Duration::from_millis(50));
release.store(true, Ordering::Relaxed);
let results: Vec<Result<(), String>> = handles
.into_iter()
.map(|handle| handle.join().expect("reader thread panicked"))
.collect();
let failures: Vec<String> = results.into_iter().filter_map(Result::err).collect();
assert!(
failures.is_empty(),
"concurrent reader failures: {}",
failures.join(" | ")
);
assert!(store.exists(&hash)?);
Ok(())
}
#[cfg(unix)]
#[test]
fn test_reclaims_stale_reader_slots() -> Result<(), Box<dyn std::error::Error>> {
let temp = TempDir::new()?;
let path = temp.path().join("blobs");
let data = b"hello stale readers";
let hash = sha256(data);
run_helper("setup", &path, &temp.path().join("setup.marker"));
for index in 0..TEST_MAX_READERS {
let marker = temp.path().join(format!("helper-{index}.marker"));
run_helper("stale", &path, &marker);
}
let store = LmdbBlobStore::with_map_size(&path, 1024 * 1024)?;
assert!(store.exists(&hash)?);
Ok(())
}
#[cfg(unix)]
#[test]
fn test_reopens_existing_env_with_larger_map_size() -> Result<(), Box<dyn std::error::Error>> {
let temp = TempDir::new()?;
let path = temp.path().join("blobs");
run_helper("small-map", &path, &temp.path().join("small-map.marker"));
let reopened = LmdbBlobStore::with_map_size(&path, 8 * 1024 * 1024)?;
assert!(reopened.map_size_bytes() >= 8 * 1024 * 1024);
Ok(())
}
#[cfg(unix)]
#[test]
fn test_reopens_existing_env_with_smaller_requested_map_size(
) -> Result<(), Box<dyn std::error::Error>> {
let temp = TempDir::new()?;
let path = temp.path().join("blobs");
let hash = sha256(b"large existing blob");
run_helper("large-map", &path, &temp.path().join("large-map.marker"));
let existing_size = std::fs::metadata(path.join("data.mdb"))?.len();
assert!(
existing_size > 1024 * 1024,
"test setup should create an environment larger than the reopen request"
);
let reopened = LmdbBlobStore::with_map_size(&path, 1024 * 1024)?;
assert!(
reopened.map_size_bytes() as u64 >= existing_size,
"expected map size to cover existing data.mdb size {existing_size}, got {}",
reopened.map_size_bytes()
);
assert!(reopened.exists(&hash)?);
Ok(())
}
#[cfg(unix)]
#[test]
#[ignore = "used as a subprocess helper by test_reclaims_stale_reader_slots"]
fn lmdb_stale_reader_helper() {
let Some(db_path) = std::env::var_os(STALE_READER_DB_PATH_ENV) else {
return;
};
let marker_path =
PathBuf::from(std::env::var_os(STALE_READER_MARKER_PATH_ENV).expect("marker path"));
std::fs::write(&marker_path, b"started").expect("write helper marker");
let _env_flag = std::env::var_os(STALE_READER_HELPER_ENV).expect("helper mode enabled");
let mode = std::env::var(STALE_READER_HELPER_MODE_ENV).expect("helper mode");
let db_path = PathBuf::from(db_path);
std::fs::create_dir_all(&db_path).expect("create helper db dir");
let helper_map_size = if mode == "large-map" {
8 * 1024 * 1024
} else {
1024 * 1024
};
let env = unsafe {
EnvOpenOptions::new()
.map_size(helper_map_size)
.max_dbs(DATABASE_COUNT)
.max_readers(TEST_MAX_READERS)
.open(&db_path)
.expect("open lmdb env")
};
match mode.as_str() {
"setup" => {
let mut wtxn = env.write_txn().expect("open write txn");
let blobs: Database<Bytes, Bytes> = env
.create_database(&mut wtxn, Some("blobs"))
.expect("create blobs database");
let data = b"hello stale readers";
let hash = sha256(data);
blobs.put(&mut wtxn, &hash, data).expect("seed blob");
wtxn.commit().expect("commit setup txn");
std::process::exit(0);
}
"stale" => {
let _rtxn = env.read_txn().expect("open read txn");
std::process::exit(0);
}
"small-map" => {
let mut wtxn = env.write_txn().expect("open write txn");
let _blobs: Database<Bytes, Bytes> = env
.create_database(&mut wtxn, Some("blobs"))
.expect("create blobs database");
let _metadata: Database<Bytes, Bytes> = env
.create_database(&mut wtxn, Some("metadata"))
.expect("create metadata database");
let _eviction_order: Database<Bytes, Unit> = env
.create_database(&mut wtxn, Some("eviction_order"))
.expect("create eviction_order database");
let _pins: Database<Bytes, Bytes> = env
.create_database(&mut wtxn, Some("pins"))
.expect("create pins database");
wtxn.commit().expect("commit small-map setup txn");
std::process::exit(0);
}
"large-map" => {
let mut wtxn = env.write_txn().expect("open write txn");
let blobs: Database<Bytes, Bytes> = env
.create_database(&mut wtxn, Some("blobs"))
.expect("create blobs database");
let _metadata: Database<Bytes, Bytes> = env
.create_database(&mut wtxn, Some("metadata"))
.expect("create metadata database");
let _eviction_order: Database<Bytes, Unit> = env
.create_database(&mut wtxn, Some("eviction_order"))
.expect("create eviction_order database");
let _pins: Database<Bytes, Bytes> = env
.create_database(&mut wtxn, Some("pins"))
.expect("create pins database");
let data = vec![42u8; 3 * 1024 * 1024];
let hash = sha256(b"large existing blob");
blobs.put(&mut wtxn, &hash, &data).expect("seed blob");
wtxn.commit().expect("commit large-map setup txn");
std::process::exit(0);
}
other => panic!("unknown helper mode: {other}"),
}
}
}