Skip to main content

hashtree_lmdb/pool/
mod.rs

1mod adaptive;
2mod catalog;
3mod delete_protection;
4mod gate;
5mod maintenance;
6mod maintenance_batch;
7mod maintenance_move;
8#[cfg(test)]
9mod maintenance_tests;
10mod member;
11mod model;
12mod move_catalog;
13mod read_only;
14mod reader;
15mod temperature;
16mod temperature_balancer;
17mod temperature_catalog;
18mod temperature_worker;
19#[cfg(test)]
20mod tests;
21
22use self::adaptive::AdaptivePoolState;
23pub use self::delete_protection::{PoolDeleteProtectionGuard, POOL_DELETE_PROTECTED};
24use self::gate::ConcurrencyGate;
25use self::member::{open_member_store, prepare_member_paths, validate_member_config};
26use self::model::{LocationRecord, MemberRecord, PoolManifest, MIN_MEMBER_MAP_SIZE_BYTES};
27pub use self::model::{
28    PoolDeleteProtectionChange, PoolDeleteProtectionStatus, PoolMaintenanceReport,
29    PoolMemberConfig, PoolMemberId, PoolMemberRuntimePaths, PoolMemberState, PoolMemberStatus,
30    PoolStalePending, PoolStalePendingCleanupReport, PoolStoreConfig, PoolTemperatureConfig,
31    PoolTemperatureReport,
32};
33use self::move_catalog::{
34    move_cleanup_state_key, move_state_key, rebuild_move_cleanup_member_index_txn,
35    validate_move_cleanup_member_index,
36};
37pub use self::read_only::{
38    ReadOnlyPoolCatalogAudit, ReadOnlyPoolManifestMember, ReadOnlyPoolManifestSnapshot,
39    ReadOnlyPoolStore,
40};
41pub use self::reader::{
42    PoolCatalogLocation, PoolManifestIdentity, PoolPhysicalAudit, PoolReadBatchItem,
43    PoolStoreReader, PoolTerminalAudit,
44};
45use self::temperature::TemperatureRuntime;
46use self::temperature_worker::TemperatureWorker;
47use crate::{managed_env::ManagedEnv, pinned_lmdb_data_len, LmdbBlobStore};
48use async_trait::async_trait;
49use hashtree_core::store::{slice_blob_range, PutManyReport, Store, StoreError, StoreStats};
50use hashtree_core::{sha256, to_hex, types::Hash};
51use heed::types::{Bytes, Unit};
52use heed::{Database, EnvOpenOptions};
53use std::collections::{HashMap, HashSet};
54use std::fs;
55use std::ops::Deref;
56use std::path::{Path, PathBuf};
57use std::sync::atomic::{AtomicU64, Ordering};
58use std::sync::{Arc, Mutex, RwLock};
59use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
60
61const CATALOG_DATABASES: u32 = 6;
62const CATALOG_MAX_READERS: u32 = 1024;
63const MANIFEST_KEY: &[u8] = b"pool-manifest-v1";
64const MEMBER_MARKER_NAME: &str = ".hashtree-pool-member-v1";
65const EXTERNAL_MARKER_NAME: &str = ".hashtree-pool-external-v1";
66const MAX_STALE_PENDING_CLEANUP_ITEMS: usize = 4_096;
67
68fn align_catalog_map_size_bytes(bytes: u64) -> Result<u64, StoreError> {
69    let page_size = (page_size::get() as u64).max(4_096);
70    let remainder = bytes % page_size;
71    if remainder == 0 {
72        Ok(bytes)
73    } else {
74        bytes
75            .checked_add(page_size - remainder)
76            .ok_or_else(|| StoreError::Other("pool catalog map size overflows alignment".into()))
77    }
78}
79
80#[derive(Default)]
81struct RuntimeMembers {
82    generation: Option<u64>,
83    stores: HashMap<PoolMemberId, Arc<LmdbBlobStore>>,
84    read_gates: HashMap<PoolMemberId, Arc<ConcurrencyGate>>,
85    write_gates: HashMap<PoolMemberId, Arc<ConcurrencyGate>>,
86    errors: HashMap<PoolMemberId, String>,
87}
88
89#[derive(Clone)]
90pub struct PoolStore {
91    inner: Arc<PoolStoreInner>,
92}
93
94#[doc(hidden)]
95pub struct PoolStoreInner {
96    env: ManagedEnv,
97    catalog_path: PathBuf,
98    manifest_db: Database<Bytes, Bytes>,
99    locations: Database<Bytes, Bytes>,
100    by_member: Database<Bytes, Unit>,
101    pins: Database<Bytes, Bytes>,
102    last_accessed: Database<Bytes, Bytes>,
103    temperature_state: Database<Bytes, Bytes>,
104    runtime: RwLock<RuntimeMembers>,
105    adaptive: Mutex<AdaptivePoolState>,
106    temperature_config: PoolTemperatureConfig,
107    temperature: Mutex<TemperatureRuntime>,
108    temperature_access_counter: AtomicU64,
109    temperature_owner: PoolMemberId,
110    temperature_cycle: Mutex<()>,
111    temperature_worker: TemperatureWorker,
112    member_runtime_paths: HashMap<PoolMemberId, PoolMemberRuntimePaths>,
113    expected_manifest_sha256: Option<Hash>,
114    physical_space: Option<crate::PhysicalSpaceGuard>,
115}
116
117impl Deref for PoolStore {
118    type Target = PoolStoreInner;
119
120    fn deref(&self) -> &Self::Target {
121        &self.inner
122    }
123}
124
125fn validate_new_member_paths(
126    manifest: &PoolManifest,
127    config: &PoolMemberConfig,
128) -> Result<(), StoreError> {
129    if manifest
130        .members
131        .iter()
132        .any(|member| member.config.path == config.path)
133    {
134        return Err(StoreError::Other(format!(
135            "pool member path is already configured: {}",
136            config.path.display()
137        )));
138    }
139    if let Some(external) = config.external_blob_dir.as_ref() {
140        if manifest
141            .members
142            .iter()
143            .any(|member| member.config.external_blob_dir.as_ref() == Some(external))
144        {
145            return Err(StoreError::Other(format!(
146                "pool external blob path is already configured: {}",
147                external.display()
148            )));
149        }
150    }
151    Ok(())
152}
153
154fn open_required_catalog_database<K: 'static, D: 'static>(
155    env: &ManagedEnv,
156    txn: &heed::RoTxn<'_>,
157    name: &str,
158) -> Result<Database<K, D>, StoreError> {
159    env.open_database(txn, Some(name))
160        .map_err(map_heed)?
161        .ok_or_else(|| StoreError::Other(format!("pool {name} database is missing")))
162}
163
164fn validate_controlled_manifest(
165    bytes: &[u8],
166    expected_sha256: Hash,
167    bindings: &HashMap<PoolMemberId, PoolMemberRuntimePaths>,
168) -> Result<PoolManifest, StoreError> {
169    let actual_sha256 = sha256(bytes);
170    if actual_sha256 != expected_sha256 {
171        return Err(StoreError::Other(format!(
172            "live pool manifest SHA-256 differs from controlled authority: expected {}, found {}",
173            to_hex(&expected_sha256),
174            to_hex(&actual_sha256)
175        )));
176    }
177    let manifest = decode_manifest(bytes)?;
178    validate_controlled_manifest_members(&manifest, bindings)?;
179    Ok(manifest)
180}
181
182fn configured_paths_match(pinned: &Path, live: &Path) -> bool {
183    pinned == live
184        || fs::canonicalize(pinned)
185            .ok()
186            .zip(fs::canonicalize(live).ok())
187            .is_some_and(|(pinned, live)| pinned == live)
188}
189
190fn configured_optional_paths_match(pinned: Option<&Path>, live: Option<&Path>) -> bool {
191    match (pinned, live) {
192        (Some(pinned), Some(live)) => configured_paths_match(pinned, live),
193        (None, None) => true,
194        _ => false,
195    }
196}
197
198fn validate_controlled_manifest_members(
199    manifest: &PoolManifest,
200    bindings: &HashMap<PoolMemberId, PoolMemberRuntimePaths>,
201) -> Result<(), StoreError> {
202    if bindings.len() != manifest.members.len() {
203        return Err(StoreError::Other(format!(
204            "pinned pool topology has {} members, live manifest has {}",
205            bindings.len(),
206            manifest.members.len()
207        )));
208    }
209    for member in &manifest.members {
210        let binding = bindings.get(&member.id).ok_or_else(|| {
211            StoreError::Other(format!(
212                "live pool member {} is absent from pinned topology",
213                member.id
214            ))
215        })?;
216        if !configured_paths_match(&binding.configured_path, &member.config.path)
217            || !configured_optional_paths_match(
218                binding.configured_external_path.as_deref(),
219                member.config.external_blob_dir.as_deref(),
220            )
221        {
222            return Err(StoreError::Other(format!(
223                "live pool member {} paths differ from pinned topology",
224                member.id
225            )));
226        }
227        if !member.config.external_blob_sync {
228            return Err(StoreError::Other(format!(
229                "controlled migration requires external_blob_sync=true for pool member {}",
230                member.id
231            )));
232        }
233    }
234    Ok(())
235}
236
237impl PoolStore {
238    pub fn open<P: AsRef<Path>>(path: P, config: PoolStoreConfig) -> Result<Self, StoreError> {
239        config.temperature.validate()?;
240        let mut member_runtime_paths = HashMap::new();
241        for binding in &config.member_runtime_paths {
242            if member_runtime_paths
243                .insert(binding.id, binding.clone())
244                .is_some()
245            {
246                return Err(StoreError::Other(format!(
247                    "duplicate runtime path binding for pool member {}",
248                    binding.id
249                )));
250            }
251            if binding.configured_external_path.is_some() != binding.runtime_external_path.is_some()
252            {
253                return Err(StoreError::Other(format!(
254                    "incomplete external runtime path binding for pool member {}",
255                    binding.id
256                )));
257            }
258        }
259        let controlled = !member_runtime_paths.is_empty()
260            || config.catalog_lmdb_identity.is_some()
261            || config.expected_manifest_sha256.is_some();
262        if controlled
263            && (member_runtime_paths.is_empty()
264                || config.catalog_lmdb_identity.is_none()
265                || config.expected_manifest_sha256.is_none())
266        {
267            return Err(StoreError::Other(
268                "controlled Pool open requires catalog identity, exact manifest SHA-256, and every member runtime binding".into(),
269            ));
270        }
271        let path = path.as_ref();
272        if !controlled {
273            if let Some(guard) = &config.physical_space {
274                guard.create_dir_all(path)?;
275            } else {
276                fs::create_dir_all(path).map_err(StoreError::Io)?;
277            }
278        }
279        let existing_size = match config.catalog_lmdb_identity {
280            Some(identity) => pinned_lmdb_data_len(path, identity)?,
281            None => fs::metadata(path.join("data.mdb"))
282                .map(|metadata| metadata.len())
283                .unwrap_or(0),
284        };
285        let requested = if controlled {
286            config.catalog_map_size_bytes.max(MIN_MEMBER_MAP_SIZE_BYTES)
287        } else {
288            config
289                .catalog_map_size_bytes
290                .max(existing_size.saturating_add(existing_size / 10))
291                .max(MIN_MEMBER_MAP_SIZE_BYTES)
292        };
293        let requested = align_catalog_map_size_bytes(requested)?;
294        let map_size = usize::try_from(requested)
295            .map_err(|_| StoreError::Other("pool catalog map size exceeds usize".into()))?;
296
297        let mut options = EnvOpenOptions::new();
298        if let Some(guard) = &config.physical_space {
299            guard.install(&mut options)?;
300        }
301        if !controlled {
302            options.map_size(map_size);
303        }
304        options
305            .max_dbs(CATALOG_DATABASES)
306            .max_readers(CATALOG_MAX_READERS);
307        unsafe {
308            options.flags(super::env_flags_from_env());
309        }
310        let env = unsafe {
311            match config.catalog_lmdb_identity {
312                Some(identity) => ManagedEnv::open_pinned(&options, path, identity),
313                None => ManagedEnv::open(&options, path),
314            }
315        }
316        .map_err(|error| {
317            StoreError::Other(format!("open pool catalog {}: {error}", path.display()))
318        })?;
319        if controlled && env.info().map_size < map_size {
320            return Err(StoreError::Other(format!(
321                "controlled Pool catalog map is {} bytes, below its manifest-owned {} bytes; pre-size it before exact migration",
322                env.info().map_size,
323                map_size
324            )));
325        }
326        if !controlled {
327            let _ = env.clear_stale_readers();
328        }
329        let (manifest_db, locations, by_member, pins, last_accessed, temperature_state) =
330            if controlled {
331                let rtxn = env.read_txn().map_err(map_heed)?;
332                let manifest_db: Database<Bytes, Bytes> =
333                    open_required_catalog_database(&env, &rtxn, "manifest")?;
334                let locations: Database<Bytes, Bytes> =
335                    open_required_catalog_database(&env, &rtxn, "locations")?;
336                let by_member: Database<Bytes, Unit> =
337                    open_required_catalog_database(&env, &rtxn, "by_member")?;
338                let pins: Database<Bytes, Bytes> =
339                    open_required_catalog_database(&env, &rtxn, "pins")?;
340                let last_accessed: Database<Bytes, Bytes> =
341                    open_required_catalog_database(&env, &rtxn, "last_accessed")?;
342                let temperature_state: Database<Bytes, Bytes> =
343                    open_required_catalog_database(&env, &rtxn, "temperature_state")?;
344                let manifest_bytes = manifest_db
345                    .get(&rtxn, MANIFEST_KEY)
346                    .map_err(map_heed)?
347                    .ok_or_else(|| StoreError::Other("pool manifest is missing".into()))?;
348                validate_controlled_manifest(
349                    manifest_bytes,
350                    config
351                        .expected_manifest_sha256
352                        .expect("controlled manifest identity checked above"),
353                    &member_runtime_paths,
354                )?;
355                rtxn.commit().map_err(map_heed)?;
356                (
357                    manifest_db,
358                    locations,
359                    by_member,
360                    pins,
361                    last_accessed,
362                    temperature_state,
363                )
364            } else {
365                let mut wtxn = env.write_txn().map_err(map_heed)?;
366                let manifest_db = env
367                    .create_database(&mut wtxn, Some("manifest"))
368                    .map_err(map_heed)?;
369                let locations = env
370                    .create_database(&mut wtxn, Some("locations"))
371                    .map_err(map_heed)?;
372                let by_member = env
373                    .create_database(&mut wtxn, Some("by_member"))
374                    .map_err(map_heed)?;
375                let pins = env
376                    .create_database(&mut wtxn, Some("pins"))
377                    .map_err(map_heed)?;
378                let last_accessed = env
379                    .create_database(&mut wtxn, Some("last_accessed"))
380                    .map_err(map_heed)?;
381                let temperature_state = env
382                    .create_database(&mut wtxn, Some("temperature_state"))
383                    .map_err(map_heed)?;
384                if manifest_db
385                    .get(&wtxn, MANIFEST_KEY)
386                    .map_err(map_heed)?
387                    .is_none()
388                {
389                    let bytes = encode_manifest(&PoolManifest::default())?;
390                    manifest_db
391                        .put(&mut wtxn, MANIFEST_KEY, bytes.as_slice())
392                        .map_err(map_heed)?;
393                }
394                wtxn.commit().map_err(map_heed)?;
395                (
396                    manifest_db,
397                    locations,
398                    by_member,
399                    pins,
400                    last_accessed,
401                    temperature_state,
402                )
403            };
404        if !controlled && env.info().map_size < map_size {
405            unsafe { env.resize(map_size) }.map_err(map_heed)?;
406        }
407
408        if controlled {
409            validate_move_cleanup_member_index(&temperature_state, &by_member, &env)?;
410        } else {
411            let mut wtxn = env.write_txn().map_err(map_heed)?;
412            let cleanup_rebuild_started = Instant::now();
413            let cleanup_rebuild_count =
414                rebuild_move_cleanup_member_index_txn(&temperature_state, &by_member, &mut wtxn)?;
415            let cleanup_rebuild_elapsed = cleanup_rebuild_started.elapsed();
416            wtxn.commit().map_err(map_heed)?;
417            if cleanup_rebuild_count > 0 || cleanup_rebuild_elapsed >= Duration::from_millis(10) {
418                eprintln!(
419                    "Pool cleanup ownership index rebuild: entries {cleanup_rebuild_count}, elapsed {} us",
420                    cleanup_rebuild_elapsed.as_micros()
421                );
422            }
423        }
424
425        let mut temperature_config = config.temperature.clone();
426        // Guarded catch-up is append-only. Background relocation has separate
427        // publication/retention ownership and must not bypass this writer.
428        if config.physical_space.is_some() {
429            temperature_config.enabled = false;
430        }
431        let store = Self {
432            inner: Arc::new(PoolStoreInner {
433                env,
434                catalog_path: path.to_path_buf(),
435                manifest_db,
436                locations,
437                by_member,
438                pins,
439                last_accessed,
440                temperature_state,
441                runtime: RwLock::new(RuntimeMembers::default()),
442                adaptive: Mutex::new(AdaptivePoolState::new(config.member_failure_cooldown)),
443                temperature: Mutex::new(TemperatureRuntime::new(
444                    temperature_config.candidate_capacity,
445                )),
446                temperature_config,
447                temperature_access_counter: AtomicU64::new(0),
448                temperature_owner: PoolMemberId::new(),
449                temperature_cycle: Mutex::new(()),
450                temperature_worker: TemperatureWorker::default(),
451                member_runtime_paths,
452                expected_manifest_sha256: config.expected_manifest_sha256,
453                physical_space: config.physical_space,
454            }),
455        };
456        store.refresh_members()?;
457        store.start_temperature_worker()?;
458        Ok(store)
459    }
460
461    fn start_temperature_worker(&self) -> Result<(), StoreError> {
462        if !self.temperature_config.enabled {
463            return Ok(());
464        }
465        self.temperature_worker.start(
466            Arc::downgrade(&self.inner),
467            self.temperature_config.interval,
468        )
469    }
470
471    /// Stop this process's background temperature balancer.
472    ///
473    /// Exact maintenance and recovery commands use this before taking
474    /// catalog snapshots so the same Pool handle cannot relocate blobs behind
475    /// their authority checks.
476    pub fn stop_temperature_worker(&self) -> Result<(), StoreError> {
477        self.temperature_worker.stop()
478    }
479
480    pub fn add_member(&self, config: PoolMemberConfig) -> Result<PoolMemberId, StoreError> {
481        self.add_member_inner(config, false)?
482            .ok_or_else(|| StoreError::Other("pool member was not added".into()))
483    }
484
485    pub(crate) fn ensure_initial_member(
486        &self,
487        config: PoolMemberConfig,
488    ) -> Result<Option<PoolMemberId>, StoreError> {
489        if !self.read_manifest()?.members.is_empty() {
490            self.refresh_members()?;
491            return Ok(None);
492        }
493        self.add_member_inner(config, true)
494    }
495
496    fn add_member_inner(
497        &self,
498        config: PoolMemberConfig,
499        only_if_empty: bool,
500    ) -> Result<Option<PoolMemberId>, StoreError> {
501        if !self.member_runtime_paths.is_empty() {
502            return Err(StoreError::Other(
503                "cannot change pool membership while exact runtime paths are pinned".into(),
504            ));
505        }
506        validate_member_config(&config)?;
507        let wtxn = self.env.write_txn().map_err(map_heed)?;
508        let manifest = self.manifest_from_txn(&wtxn)?;
509        if only_if_empty && !manifest.members.is_empty() {
510            drop(wtxn);
511            self.refresh_members()?;
512            return Ok(None);
513        }
514        validate_new_member_paths(&manifest, &config)?;
515        let id = prepare_member_paths(&config, PoolMemberId::new(), self.physical_space.as_ref())?;
516        drop(wtxn);
517        let store = Arc::new(open_member_store(
518            id,
519            &config,
520            None,
521            self.physical_space.as_ref(),
522        )?);
523
524        let mut wtxn = self.env.write_txn().map_err(map_heed)?;
525        let mut manifest = self.manifest_from_txn(&wtxn)?;
526        if only_if_empty && !manifest.members.is_empty() {
527            drop(wtxn);
528            self.refresh_members()?;
529            return Ok(None);
530        }
531        validate_new_member_paths(&manifest, &config)?;
532        if manifest.members.iter().any(|member| member.id == id) {
533            return Err(StoreError::Other(format!(
534                "pool member identity is already configured: {id}"
535            )));
536        }
537        manifest.members.push(MemberRecord {
538            id,
539            state: PoolMemberState::Active,
540            config,
541        });
542        manifest.generation = manifest.generation.saturating_add(1);
543        self.put_manifest_txn(&mut wtxn, &manifest)?;
544        wtxn.commit().map_err(map_heed)?;
545
546        let mut runtime = self
547            .runtime
548            .write()
549            .map_err(|_| StoreError::Other("pool runtime lock poisoned".into()))?;
550        runtime.generation = Some(manifest.generation);
551        runtime.stores.insert(id, store);
552        let member = manifest
553            .members
554            .iter()
555            .find(|member| member.id == id)
556            .expect("new pool member is in committed manifest");
557        runtime.read_gates.insert(
558            id,
559            Arc::new(ConcurrencyGate::new(member.config.max_read_concurrency)),
560        );
561        runtime.write_gates.insert(
562            id,
563            Arc::new(ConcurrencyGate::new(member.config.max_write_concurrency)),
564        );
565        runtime.errors.remove(&id);
566        Ok(Some(id))
567    }
568
569    pub fn begin_drain(&self, id: PoolMemberId) -> Result<(), StoreError> {
570        let mut wtxn = self.env.write_txn().map_err(map_heed)?;
571        let mut manifest = self.manifest_from_txn(&wtxn)?;
572        let has_other_active = manifest
573            .members
574            .iter()
575            .any(|member| member.id != id && member.state == PoolMemberState::Active);
576        let member = manifest
577            .members
578            .iter_mut()
579            .find(|member| member.id == id)
580            .ok_or_else(|| StoreError::Other(format!("unknown pool member {id}")))?;
581        if member.state == PoolMemberState::Draining {
582            return Ok(());
583        }
584        if !has_other_active && self.member_has_locations_txn(&wtxn, id)? {
585            return Err(StoreError::Other(
586                "cannot drain the final member while it still owns blobs".into(),
587            ));
588        }
589        member.state = PoolMemberState::Draining;
590        manifest.generation = manifest.generation.saturating_add(1);
591        self.put_manifest_txn(&mut wtxn, &manifest)?;
592        wtxn.commit().map_err(map_heed)?;
593        self.refresh_members()?;
594        Ok(())
595    }
596
597    pub fn update_member_limits(
598        &self,
599        id: PoolMemberId,
600        capacity_bytes: u64,
601        max_read_concurrency: u32,
602        max_write_concurrency: u32,
603    ) -> Result<(), StoreError> {
604        if capacity_bytes == 0 || max_read_concurrency == 0 || max_write_concurrency == 0 {
605            return Err(StoreError::Other(
606                "pool member capacity and concurrency limits must be non-zero".into(),
607            ));
608        }
609        let mut wtxn = self.env.write_txn().map_err(map_heed)?;
610        let mut manifest = self.manifest_from_txn(&wtxn)?;
611        let member = manifest
612            .members
613            .iter_mut()
614            .find(|member| member.id == id)
615            .ok_or_else(|| StoreError::Other(format!("unknown pool member {id}")))?;
616        member.config.capacity_bytes = capacity_bytes;
617        member.config.max_read_concurrency = max_read_concurrency;
618        member.config.max_write_concurrency = max_write_concurrency;
619        manifest.generation = manifest.generation.saturating_add(1);
620        self.put_manifest_txn(&mut wtxn, &manifest)?;
621        wtxn.commit().map_err(map_heed)?;
622        self.refresh_members()
623    }
624
625    pub fn update_member_temperature_watermarks(
626        &self,
627        id: PoolMemberId,
628        low_percent: u8,
629        high_percent: u8,
630    ) -> Result<(), StoreError> {
631        if low_percent >= high_percent || high_percent > 100 {
632            return Err(StoreError::Other(
633                "pool temperature watermarks must satisfy 0 <= low < high <= 100".into(),
634            ));
635        }
636        let mut wtxn = self.env.write_txn().map_err(map_heed)?;
637        let mut manifest = self.manifest_from_txn(&wtxn)?;
638        let member = manifest
639            .members
640            .iter_mut()
641            .find(|member| member.id == id)
642            .ok_or_else(|| StoreError::Other(format!("unknown pool member {id}")))?;
643        member.config.temperature_low_watermark_percent = low_percent;
644        member.config.temperature_high_watermark_percent = high_percent;
645        manifest.generation = manifest.generation.saturating_add(1);
646        self.put_manifest_txn(&mut wtxn, &manifest)?;
647        wtxn.commit().map_err(map_heed)?;
648        self.refresh_members()
649    }
650
651    pub fn remove_member(&self, id: PoolMemberId) -> Result<(), StoreError> {
652        let mut wtxn = self.env.write_txn().map_err(map_heed)?;
653        let mut manifest = self.manifest_from_txn(&wtxn)?;
654        let index = manifest
655            .members
656            .iter()
657            .position(|member| member.id == id)
658            .ok_or_else(|| StoreError::Other(format!("unknown pool member {id}")))?;
659        if manifest.members[index].state != PoolMemberState::Draining {
660            return Err(StoreError::Other(
661                "pool member must be draining before removal".into(),
662            ));
663        }
664        if self.member_has_locations_txn(&wtxn, id)? {
665            return Err(StoreError::Other(format!(
666                "pool member {id} still owns blob(s)"
667            )));
668        }
669        manifest.members.remove(index);
670        manifest.generation = manifest.generation.saturating_add(1);
671        self.put_manifest_txn(&mut wtxn, &manifest)?;
672        wtxn.commit().map_err(map_heed)?;
673        self.refresh_members()?;
674        Ok(())
675    }
676
677    pub fn member(&self, id: PoolMemberId) -> Result<PoolMemberStatus, StoreError> {
678        self.refresh_members()?;
679        let manifest = self.read_manifest()?;
680        let member = manifest
681            .members
682            .iter()
683            .find(|member| member.id == id)
684            .ok_or_else(|| StoreError::Other(format!("unknown pool member {id}")))?;
685        let located_blobs = self.count_member_locations(id)?;
686        let runtime = self
687            .runtime
688            .read()
689            .map_err(|_| StoreError::Other("pool runtime lock poisoned".into()))?;
690        let (logical_bytes, available, last_error) = match runtime.stores.get(&id) {
691            Some(store) => match store.stats() {
692                Ok(stats) => (stats.total_bytes, true, None),
693                Err(error) => (0, false, Some(error.to_string())),
694            },
695            None => (0, false, runtime.errors.get(&id).cloned()),
696        };
697        Ok(PoolMemberStatus {
698            id,
699            state: member.state,
700            path: member.config.path.clone(),
701            capacity_bytes: member.config.capacity_bytes,
702            map_size_bytes: member.config.map_size_bytes,
703            external_blob_dir: member.config.external_blob_dir.clone(),
704            external_blob_min_bytes: member.config.external_blob_min_bytes,
705            external_blob_sync: member.config.external_blob_sync,
706            external_pack_target_bytes: member.config.external_pack_target_bytes,
707            max_read_concurrency: member.config.max_read_concurrency,
708            max_write_concurrency: member.config.max_write_concurrency,
709            temperature_low_watermark_percent: member.config.temperature_low_watermark_percent,
710            temperature_high_watermark_percent: member.config.temperature_high_watermark_percent,
711            logical_bytes,
712            located_blobs,
713            available,
714            last_error,
715        })
716    }
717
718    pub fn members(&self) -> Result<Vec<PoolMemberStatus>, StoreError> {
719        let manifest = self.read_manifest()?;
720        manifest
721            .members
722            .iter()
723            .map(|member| self.member(member.id))
724            .collect()
725    }
726
727    pub fn blob_location(&self, hash: &Hash) -> Result<Option<PoolMemberId>, StoreError> {
728        Ok(self
729            .read_location(hash)?
730            .map(LocationRecord::preferred_member))
731    }
732
733    pub fn put_sync(&self, hash: Hash, data: &[u8]) -> Result<bool, StoreError> {
734        if sha256(data) != hash {
735            return Err(StoreError::Other(
736                "pool rejected bytes that do not match their hash".into(),
737            ));
738        }
739
740        if let Some(location) = self.read_location(&hash)? {
741            match self.read_verified_location(&hash, location) {
742                Ok(Some(found)) => {
743                    if matches!(location, LocationRecord::Pending { .. }) {
744                        self.finalize_pending(hash, location)?;
745                    }
746                    debug_assert_eq!(sha256(&found), hash);
747                    return Ok(false);
748                }
749                Ok(None) | Err(_) => return self.repair_location(hash, data, location),
750            }
751        }
752
753        let target = self.choose_write_member(data.len() as u64, None)?;
754        let pending = LocationRecord::Pending {
755            member: target,
756            size: data.len() as u64,
757        };
758        let location = self.reserve_if_absent(hash, pending)?;
759        if location != pending {
760            match self.read_verified_location(&hash, location) {
761                Ok(Some(found)) => {
762                    if matches!(location, LocationRecord::Pending { .. }) {
763                        self.finalize_pending(hash, location)?;
764                    }
765                    debug_assert_eq!(sha256(&found), hash);
766                    return Ok(false);
767                }
768                Ok(None) | Err(_) => return self.repair_location(hash, data, location),
769            }
770        }
771
772        let target = location.preferred_member();
773        let store = self.get_member(target)?;
774        match self.write_verified_member(target, &store, hash, data) {
775            Ok(inserted) => {
776                self.finalize_pending(hash, location)?;
777                Ok(inserted)
778            }
779            Err(error)
780                if self
781                    .physical_space
782                    .as_ref()
783                    .is_some_and(|guard| guard.stop_after_write_error(&error)) =>
784            {
785                return Err(self.physical_space.as_ref().unwrap().refusal_error().into())
786            }
787            Err(_) => {
788                let mut excluded = HashSet::new();
789                excluded.insert(target);
790                self.repair_location_excluding(hash, data, location, excluded)
791            }
792        }
793    }
794
795    pub fn put_many_report_sync(
796        &self,
797        items: &[(Hash, Vec<u8>)],
798    ) -> Result<PutManyReport, StoreError> {
799        self.put_many_report_sync_with_existing_verification(items, true)
800    }
801
802    /// Insert a locally generated content-addressed batch while trusting
803    /// catalogued committed locations. Pending locations still take the
804    /// ordinary repair path; stored and moving locations need no payload read.
805    pub fn put_many_optimistic_report_sync(
806        &self,
807        items: &[(Hash, Vec<u8>)],
808    ) -> Result<PutManyReport, StoreError> {
809        self.put_many_report_sync_with_existing_verification(items, false)
810    }
811
812    fn put_many_report_sync_with_existing_verification(
813        &self,
814        items: &[(Hash, Vec<u8>)],
815        verify_existing: bool,
816    ) -> Result<PutManyReport, StoreError> {
817        let mut seen = HashSet::new();
818        let mut unique = Vec::with_capacity(items.len());
819        let mut ordered = Vec::with_capacity(items.len());
820        for (hash, data) in items {
821            if sha256(data) != *hash {
822                return Err(StoreError::Other(
823                    "pool rejected batch bytes that do not match their hash".into(),
824                ));
825            }
826            if seen.insert(*hash) {
827                unique.push((*hash, data));
828                ordered.push((*hash, data.len() as u64));
829            }
830        }
831
832        let mut inserted = HashSet::new();
833        let mut missing = Vec::new();
834        for (hash, data) in unique {
835            if let Some(location) = self.read_location(&hash)? {
836                if (verify_existing || matches!(location, LocationRecord::Pending { .. }))
837                    && self.put_sync(hash, data)?
838                {
839                    inserted.insert(hash);
840                }
841            } else {
842                missing.push((hash, data));
843            }
844        }
845        if missing.is_empty() {
846            return Ok(put_many_report(items.len(), &ordered, &inserted));
847        }
848
849        let mut reserved_bytes = HashMap::new();
850        let mut assignments = Vec::with_capacity(missing.len());
851        for (hash, data) in missing {
852            let target =
853                self.choose_write_member_with_reserved(data.len() as u64, None, &reserved_bytes)?;
854            let reserved = reserved_bytes.entry(target).or_insert(0u64);
855            *reserved = reserved.saturating_add(data.len() as u64);
856            assignments.push((hash, data, target));
857        }
858
859        let mut wtxn = self.env.write_txn().map_err(map_heed)?;
860        let mut plans = Vec::with_capacity(assignments.len());
861        let mut raced = Vec::new();
862        for (hash, data, target) in assignments {
863            if self
864                .locations
865                .get(&wtxn, &hash)
866                .map_err(map_heed)?
867                .is_some()
868            {
869                raced.push((hash, data));
870                continue;
871            }
872            let pending = LocationRecord::Pending {
873                member: target,
874                size: data.len() as u64,
875            };
876            self.set_location_txn(&mut wtxn, hash, Some(pending))?;
877            plans.push((hash, data, target, pending));
878        }
879        wtxn.commit().map_err(map_heed)?;
880
881        for (hash, data) in raced {
882            if self.put_sync(hash, data)? {
883                inserted.insert(hash);
884            }
885        }
886
887        let mut by_target: HashMap<PoolMemberId, Vec<(Hash, &[u8])>> = HashMap::new();
888        for (hash, data, target, _) in &plans {
889            by_target
890                .entry(*target)
891                .or_default()
892                .push((*hash, data.as_slice()));
893        }
894        for (target, batch) in by_target {
895            let store = self.get_member(target)?;
896            let gate = self.member_gate(target, true)?;
897            let permit = gate.acquire()?;
898            for (hash, _) in &batch {
899                if store
900                    .get_sync(hash)?
901                    .is_some_and(|existing| sha256(&existing) != *hash)
902                {
903                    store.delete_sync(hash)?;
904                }
905            }
906            let started = Instant::now();
907            let result = store.put_many_refs_report_sync(&batch);
908            let success = result.is_ok();
909            let bytes = batch.iter().map(|(_, data)| data.len()).sum::<usize>();
910            self.record_write(target, started.elapsed(), bytes, success);
911            let report = match result {
912                Ok(report) => report,
913                Err(error)
914                    if self
915                        .physical_space
916                        .as_ref()
917                        .is_some_and(|guard| guard.stop_after_write_error(&error)) =>
918                {
919                    return Err(self.physical_space.as_ref().unwrap().refusal_error().into())
920                }
921                Err(_) => {
922                    drop(permit);
923                    for (hash, data) in batch {
924                        if self.put_sync(hash, data)? {
925                            inserted.insert(hash);
926                        }
927                    }
928                    continue;
929                }
930            };
931            inserted.extend(report.inserted_hashes);
932            for (hash, _) in &batch {
933                self.read_verified_member(target, &store, hash)?
934                    .ok_or_else(|| {
935                        StoreError::Other(format!(
936                            "pool member {target} lost a committed batch write"
937                        ))
938                    })?;
939            }
940        }
941
942        let mut wtxn = self.env.write_txn().map_err(map_heed)?;
943        for (hash, _, target, pending) in plans {
944            let Some(current) = self.locations.get(&wtxn, &hash).map_err(map_heed)? else {
945                continue;
946            };
947            if LocationRecord::decode(current)? == pending {
948                self.set_location_txn(
949                    &mut wtxn,
950                    hash,
951                    Some(LocationRecord::Stored {
952                        member: target,
953                        size: pending.size(),
954                    }),
955                )?;
956            }
957        }
958        wtxn.commit().map_err(map_heed)?;
959        Ok(put_many_report(items.len(), &ordered, &inserted))
960    }
961
962    pub fn put_many_sync(&self, items: &[(Hash, Vec<u8>)]) -> Result<usize, StoreError> {
963        self.put_many_report_sync(items)
964            .map(|report| report.inserted)
965    }
966
967    pub fn put_many_optimistic_sync(&self, items: &[(Hash, Vec<u8>)]) -> Result<usize, StoreError> {
968        self.put_many_optimistic_report_sync(items)
969            .map(|report| report.inserted)
970    }
971
972    pub fn get_sync(&self, hash: &Hash) -> Result<Option<Vec<u8>>, StoreError> {
973        let Some(location) = self.read_location(hash)? else {
974            return Ok(None);
975        };
976        let data = self.read_verified_location(hash, location)?;
977        if data.is_some() && matches!(location, LocationRecord::Pending { .. }) {
978            self.finalize_pending(*hash, location)?;
979        }
980        if data.is_some() {
981            self.sample_temperature_access(*hash, location);
982        }
983        Ok(data)
984    }
985
986    pub fn get_range_sync(
987        &self,
988        hash: &Hash,
989        start: u64,
990        end_inclusive: u64,
991    ) -> Result<Option<Vec<u8>>, StoreError> {
992        let Some(data) = self.get_sync(hash)? else {
993            return Ok(None);
994        };
995        slice_blob_range(&data, start, end_inclusive).map(Some)
996    }
997
998    pub fn blob_size_sync(&self, hash: &Hash) -> Result<Option<u64>, StoreError> {
999        Ok(self.read_location(hash)?.map(LocationRecord::size))
1000    }
1001
1002    pub fn exists(&self, hash: &Hash) -> Result<bool, StoreError> {
1003        Ok(self.get_sync(hash)?.is_some())
1004    }
1005
1006    pub fn existing_hashes_in_sorted_candidates(
1007        &self,
1008        sorted_hashes: &[Hash],
1009    ) -> Result<Vec<bool>, StoreError> {
1010        let rtxn = self.env.read_txn().map_err(map_heed)?;
1011        sorted_hashes
1012            .iter()
1013            .map(|hash| self.locations.get(&rtxn, hash).map(|value| value.is_some()))
1014            .collect::<Result<Vec<_>, _>>()
1015            .map_err(map_heed)
1016    }
1017
1018    /// Mark hashes whose pool catalog entries represent committed data.
1019    ///
1020    /// Pending records deliberately return false so an interrupted migration
1021    /// supplies the source bytes again and lets the ordinary repair/finalize
1022    /// path complete them. Stored and moving records are already durable and
1023    /// can be skipped by an incremental migration without rereading payloads.
1024    pub fn committed_hashes_in_sorted_candidates(
1025        &self,
1026        sorted_hashes: &[Hash],
1027    ) -> Result<Vec<bool>, StoreError> {
1028        let rtxn = self.env.read_txn().map_err(map_heed)?;
1029        sorted_hashes
1030            .iter()
1031            .map(|hash| {
1032                self.locations
1033                    .get(&rtxn, hash)
1034                    .map_err(map_heed)?
1035                    .map(LocationRecord::decode)
1036                    .transpose()
1037                    .map(|location| {
1038                        location.is_some_and(|location| {
1039                            !matches!(location, LocationRecord::Pending { .. })
1040                        })
1041                    })
1042            })
1043            .collect()
1044    }
1045
1046    /// Return the exact catalog state for one sorted source page using a
1047    /// single read transaction.
1048    ///
1049    /// Migration reconciliation uses this to distinguish a terminal
1050    /// size-matched `Stored` record from `Missing`, crash-left `Pending`, and
1051    /// non-terminal `Moving` without loading target payload bytes.
1052    pub fn catalog_locations_in_sorted_candidates(
1053        &self,
1054        sorted_hashes: &[Hash],
1055    ) -> Result<Vec<PoolCatalogLocation>, StoreError> {
1056        let rtxn = self.env.read_txn().map_err(map_heed)?;
1057        sorted_hashes
1058            .iter()
1059            .map(|hash| {
1060                let record = self
1061                    .locations
1062                    .get(&rtxn, hash)
1063                    .map_err(map_heed)?
1064                    .map(LocationRecord::decode)
1065                    .transpose()?;
1066                Ok(PoolCatalogLocation::from_record(record))
1067            })
1068            .collect()
1069    }
1070
1071    /// Scan one bounded raw catalog page in hash order.
1072    ///
1073    /// Online migration content audits use the raw catalog key as their
1074    /// resumable cursor. Pending and moving rows are returned explicitly:
1075    /// they are not terminal content authorities, but their keys must still
1076    /// advance the scan so a page containing only transient rows makes
1077    /// progress.
1078    pub fn scan_catalog_locations_after(
1079        &self,
1080        after: Option<Hash>,
1081        limit: usize,
1082    ) -> Result<Vec<(Hash, PoolCatalogLocation)>, StoreError> {
1083        if limit == 0 {
1084            return Ok(Vec::new());
1085        }
1086        let rtxn = self.env.read_txn().map_err(map_heed)?;
1087        let mut entries = Vec::with_capacity(limit);
1088        let decode =
1089            |hash: &[u8], encoded: &[u8]| -> Result<(Hash, PoolCatalogLocation), StoreError> {
1090                let hash: Hash = hash
1091                    .try_into()
1092                    .map_err(|_| StoreError::Other("invalid pool catalog hash length".into()))?;
1093                let record = LocationRecord::decode(encoded)?;
1094                Ok((hash, PoolCatalogLocation::from_record(Some(record))))
1095            };
1096        match after {
1097            Some(after) => {
1098                use std::ops::Bound;
1099                let range = (Bound::Excluded(after.as_slice()), Bound::<&[u8]>::Unbounded);
1100                for item in self.locations.range(&rtxn, &range).map_err(map_heed)? {
1101                    let (hash, encoded) = item.map_err(map_heed)?;
1102                    entries.push(decode(hash, encoded)?);
1103                    if entries.len() >= limit {
1104                        break;
1105                    }
1106                }
1107            }
1108            None => {
1109                for item in self.locations.iter(&rtxn).map_err(map_heed)? {
1110                    let (hash, encoded) = item.map_err(map_heed)?;
1111                    entries.push(decode(hash, encoded)?);
1112                    if entries.len() >= limit {
1113                        break;
1114                    }
1115                }
1116            }
1117        }
1118        Ok(entries)
1119    }
1120
1121    /// Largest map size among members that are available in this process.
1122    ///
1123    /// The pool catalog is a separate LMDB environment and is intentionally
1124    /// not reported as blob capacity.
1125    pub fn largest_member_map_size_bytes(&self) -> Result<Option<usize>, StoreError> {
1126        self.refresh_members()?;
1127        let runtime = self
1128            .runtime
1129            .read()
1130            .map_err(|_| StoreError::Other("pool runtime lock poisoned".into()))?;
1131        Ok(runtime
1132            .stores
1133            .values()
1134            .map(|store| store.map_size_bytes())
1135            .max())
1136    }
1137
1138    pub fn delete_sync(&self, hash: &Hash) -> Result<bool, StoreError> {
1139        let _coordination = self.acquire_delete_coordination_lock(false)?;
1140        self.require_deletes_unprotected()?;
1141        let Some(location) = self.read_location(hash)? else {
1142            return Ok(false);
1143        };
1144        let (members, len) = location.members();
1145        let mut deleted = false;
1146        for member in members.into_iter().take(len) {
1147            if let Ok(store) = self.get_member(member) {
1148                let gate = self.member_gate(member, true)?;
1149                let _permit = gate.acquire()?;
1150                deleted |= store.delete_sync(hash)?;
1151            }
1152        }
1153        let mut wtxn = self.env.write_txn().map_err(map_heed)?;
1154        self.set_location_txn(&mut wtxn, *hash, None)?;
1155        self.pins.delete(&mut wtxn, hash).map_err(map_heed)?;
1156        wtxn.commit().map_err(map_heed)?;
1157        Ok(deleted)
1158    }
1159
1160    /// Delete a batch with one transaction per affected member and one pool
1161    /// catalog transaction.
1162    pub fn delete_many_sync(&self, hashes: &[Hash]) -> Result<usize, StoreError> {
1163        if hashes.is_empty() {
1164            return Ok(0);
1165        }
1166        let _coordination = self.acquire_delete_coordination_lock(false)?;
1167        self.require_deletes_unprotected()?;
1168        let rtxn = self.env.read_txn().map_err(map_heed)?;
1169        let mut seen = HashSet::with_capacity(hashes.len());
1170        let mut located = Vec::new();
1171        let mut by_member = HashMap::<PoolMemberId, Vec<Hash>>::new();
1172        for hash in hashes {
1173            if !seen.insert(*hash) {
1174                continue;
1175            }
1176            let Some(encoded) = self.locations.get(&rtxn, hash).map_err(map_heed)? else {
1177                continue;
1178            };
1179            let location = LocationRecord::decode(encoded)?;
1180            let (members, len) = location.members();
1181            for member in members.into_iter().take(len) {
1182                by_member.entry(member).or_default().push(*hash);
1183            }
1184            located.push(*hash);
1185        }
1186        drop(rtxn);
1187
1188        for (member, member_hashes) in by_member {
1189            if let Ok(store) = self.get_member(member) {
1190                let gate = self.member_gate(member, true)?;
1191                let _permit = gate.acquire()?;
1192                store.delete_many_sync(&member_hashes)?;
1193            }
1194        }
1195
1196        let mut wtxn = self.env.write_txn().map_err(map_heed)?;
1197        for hash in &located {
1198            self.set_location_txn(&mut wtxn, *hash, None)?;
1199            self.pins.delete(&mut wtxn, hash).map_err(map_heed)?;
1200        }
1201        wtxn.commit().map_err(map_heed)?;
1202        Ok(located.len())
1203    }
1204
1205    /// Clear one exact, bounded set of crash-abandoned `Pending` records.
1206    ///
1207    /// This is deliberately narrower than [`Self::delete_many_sync`]: every
1208    /// live location must still equal the authorized `(hash, member, size)`,
1209    /// no hash may be pinned or owned by a move, and no member may contain a
1210    /// physical record for the hash. The catalog and all secondary indexes
1211    /// change in one transaction and are force-synced before success.
1212    ///
1213    /// A caller must stop and fence **every** Pool writer before its strict
1214    /// read-only audit and keep that fence through this call. This includes
1215    /// clones and handles in this process: a writer can commit `Pending`, pause
1216    /// before entering its member gate, and otherwise resume after cleanup.
1217    /// Catalog and member LMDB environments do not provide one atomic
1218    /// cross-environment transaction, so this method intentionally makes no
1219    /// online-safety claim.
1220    pub fn cleanup_stale_pending_exact_offline_sync(
1221        &self,
1222        expected: &[PoolStalePending],
1223    ) -> Result<PoolStalePendingCleanupReport, StoreError> {
1224        if expected.is_empty() || expected.len() > MAX_STALE_PENDING_CLEANUP_ITEMS {
1225            return Err(StoreError::Other(format!(
1226                "stale Pending cleanup requires 1..={MAX_STALE_PENDING_CLEANUP_ITEMS} exact records"
1227            )));
1228        }
1229        if self.expected_manifest_sha256.is_none() || self.member_runtime_paths.is_empty() {
1230            return Err(StoreError::Other(
1231                "stale Pending cleanup requires a controlled Pool open".into(),
1232            ));
1233        }
1234        if self.temperature_config.enabled {
1235            return Err(StoreError::Other(
1236                "stale Pending cleanup requires temperature tracking to be disabled".into(),
1237            ));
1238        }
1239        if expected.windows(2).any(|pair| pair[0].hash >= pair[1].hash) {
1240            return Err(StoreError::Other(
1241                "stale Pending cleanup records must be strictly hash-sorted and unique".into(),
1242            ));
1243        }
1244        let declared_bytes = expected.iter().try_fold(0u64, |total, item| {
1245            total.checked_add(item.size).ok_or_else(|| {
1246                StoreError::Other("stale Pending declared byte total overflow".into())
1247            })
1248        })?;
1249
1250        self.validate_controlled_authority_and_sync()?;
1251
1252        let mut member_ids = expected.iter().map(|item| item.member).collect::<Vec<_>>();
1253        member_ids.sort_unstable();
1254        member_ids.dedup();
1255        let member_resources = member_ids
1256            .iter()
1257            .map(|member| {
1258                Ok((
1259                    *member,
1260                    self.get_member(*member)?,
1261                    self.member_gate(*member, true)?,
1262                ))
1263            })
1264            .collect::<Result<Vec<_>, StoreError>>()?;
1265        let _exclusive_permits = member_resources
1266            .iter()
1267            .map(|(_, _, gate)| gate.acquire_exclusive())
1268            .collect::<Result<Vec<_>, StoreError>>()?;
1269
1270        let ensure_physically_absent = || -> Result<(), StoreError> {
1271            for (member, store, _) in &member_resources {
1272                let hashes = expected
1273                    .iter()
1274                    .filter(|item| item.member == *member)
1275                    .map(|item| item.hash)
1276                    .collect::<Vec<_>>();
1277                let present = store.existing_hashes_in_sorted_candidates(&hashes)?;
1278                if let Some(hash) = hashes
1279                    .iter()
1280                    .zip(present)
1281                    .find_map(|(hash, present)| present.then_some(hash))
1282                {
1283                    return Err(StoreError::Other(format!(
1284                        "stale Pending cleanup rejected physically present member record {} on {member}",
1285                        to_hex(hash)
1286                    )));
1287                }
1288            }
1289            Ok(())
1290        };
1291        ensure_physically_absent()?;
1292
1293        let mut wtxn = self.env.write_txn().map_err(map_heed)?;
1294        let mut exact_pending = 0usize;
1295        let mut already_missing = 0usize;
1296        for item in expected {
1297            let current = self
1298                .locations
1299                .get(&wtxn, &item.hash)
1300                .map_err(map_heed)?
1301                .map(LocationRecord::decode)
1302                .transpose()?;
1303            let authorized = LocationRecord::Pending {
1304                member: item.member,
1305                size: item.size,
1306            };
1307            match current {
1308                Some(current) if current == authorized => {
1309                    exact_pending = exact_pending.saturating_add(1);
1310                    if self
1311                        .by_member
1312                        .get(&wtxn, &member_hash_key(item.member, item.hash))
1313                        .map_err(map_heed)?
1314                        .is_none()
1315                    {
1316                        return Err(StoreError::Other(format!(
1317                            "stale Pending cleanup found a missing member index for {}",
1318                            to_hex(&item.hash)
1319                        )));
1320                    }
1321                }
1322                None => {
1323                    already_missing = already_missing.saturating_add(1);
1324                    if self
1325                        .by_member
1326                        .get(&wtxn, &member_hash_key(item.member, item.hash))
1327                        .map_err(map_heed)?
1328                        .is_some()
1329                        || self
1330                            .last_accessed
1331                            .get(&wtxn, &item.hash)
1332                            .map_err(map_heed)?
1333                            .is_some()
1334                    {
1335                        return Err(StoreError::Other(format!(
1336                            "already-cleared stale Pending record {} retains catalog indexes",
1337                            to_hex(&item.hash)
1338                        )));
1339                    }
1340                }
1341                current => {
1342                    return Err(StoreError::Other(format!(
1343                        "stale Pending authority no longer matches {}: expected {authorized:?}, found {current:?}",
1344                        to_hex(&item.hash)
1345                    )));
1346                }
1347            }
1348            let pin_count = self
1349                .pins
1350                .get(&wtxn, &item.hash)
1351                .map_err(map_heed)?
1352                .map(decode_pin_count)
1353                .transpose()?
1354                .unwrap_or(0);
1355            if pin_count != 0 {
1356                return Err(StoreError::Other(format!(
1357                    "stale Pending cleanup rejected pinned hash {}",
1358                    to_hex(&item.hash)
1359                )));
1360            }
1361            if self
1362                .temperature_state
1363                .get(&wtxn, &move_state_key(item.hash))
1364                .map_err(map_heed)?
1365                .is_some()
1366                || self
1367                    .temperature_state
1368                    .get(&wtxn, &move_cleanup_state_key(item.hash))
1369                    .map_err(map_heed)?
1370                    .is_some()
1371            {
1372                return Err(StoreError::Other(format!(
1373                    "stale Pending cleanup rejected move-owned hash {}",
1374                    to_hex(&item.hash)
1375                )));
1376            }
1377        }
1378        if exact_pending != 0 && already_missing != 0 {
1379            return Err(StoreError::Other(
1380                "stale Pending cleanup refuses a partially changed authority set".into(),
1381            ));
1382        }
1383
1384        // Repeat the cross-environment absence proof as late as possible. The
1385        // operational writer fence remains mandatory for other Pool handles.
1386        ensure_physically_absent()?;
1387        if exact_pending != 0 {
1388            for item in expected {
1389                self.set_location_txn(&mut wtxn, item.hash, None)?;
1390                self.pins.delete(&mut wtxn, &item.hash).map_err(map_heed)?;
1391            }
1392        }
1393        wtxn.commit().map_err(map_heed)?;
1394        self.validate_controlled_authority_and_sync()?;
1395
1396        let rtxn = self.env.read_txn().map_err(map_heed)?;
1397        for item in expected {
1398            if self
1399                .locations
1400                .get(&rtxn, &item.hash)
1401                .map_err(map_heed)?
1402                .is_some()
1403                || self
1404                    .by_member
1405                    .get(&rtxn, &member_hash_key(item.member, item.hash))
1406                    .map_err(map_heed)?
1407                    .is_some()
1408                || self
1409                    .pins
1410                    .get(&rtxn, &item.hash)
1411                    .map_err(map_heed)?
1412                    .is_some()
1413                || self
1414                    .last_accessed
1415                    .get(&rtxn, &item.hash)
1416                    .map_err(map_heed)?
1417                    .is_some()
1418            {
1419                return Err(StoreError::Other(format!(
1420                    "stale Pending cleanup did not durably clear {}",
1421                    to_hex(&item.hash)
1422                )));
1423            }
1424        }
1425        rtxn.commit().map_err(map_heed)?;
1426        Ok(PoolStalePendingCleanupReport {
1427            requested: expected.len(),
1428            declared_bytes,
1429            already_cleaned: exact_pending == 0,
1430        })
1431    }
1432
1433    pub fn pin_sync(&self, hash: &Hash) -> Result<(), StoreError> {
1434        let mut wtxn = self.env.write_txn().map_err(map_heed)?;
1435        let previous = self
1436            .pins
1437            .get(&wtxn, hash)
1438            .map_err(map_heed)?
1439            .map(decode_pin_count)
1440            .transpose()?
1441            .unwrap_or(0);
1442        self.pins
1443            .put(&mut wtxn, hash, &previous.saturating_add(1).to_be_bytes())
1444            .map_err(map_heed)?;
1445        wtxn.commit().map_err(map_heed)
1446    }
1447
1448    pub fn unpin_sync(&self, hash: &Hash) -> Result<(), StoreError> {
1449        let mut wtxn = self.env.write_txn().map_err(map_heed)?;
1450        let count = self
1451            .pins
1452            .get(&wtxn, hash)
1453            .map_err(map_heed)?
1454            .map(decode_pin_count)
1455            .transpose()?
1456            .unwrap_or(0);
1457        if count <= 1 {
1458            self.pins.delete(&mut wtxn, hash).map_err(map_heed)?;
1459        } else {
1460            self.pins
1461                .put(&mut wtxn, hash, &(count - 1).to_be_bytes())
1462                .map_err(map_heed)?;
1463        }
1464        wtxn.commit().map_err(map_heed)
1465    }
1466
1467    pub fn pin_count_sync(&self, hash: &Hash) -> Result<u32, StoreError> {
1468        let rtxn = self.env.read_txn().map_err(map_heed)?;
1469        self.pins
1470            .get(&rtxn, hash)
1471            .map_err(map_heed)?
1472            .map(decode_pin_count)
1473            .transpose()
1474            .map(|count| count.unwrap_or(0))
1475    }
1476
1477    pub fn touch_accessed_sync(&self, hash: &Hash, now: u64) -> Result<bool, StoreError> {
1478        let mut wtxn = self.env.write_txn().map_err(map_heed)?;
1479        if self.locations.get(&wtxn, hash).map_err(map_heed)?.is_none() {
1480            return Ok(false);
1481        }
1482        let previous = self
1483            .last_accessed
1484            .get(&wtxn, hash)
1485            .map_err(map_heed)?
1486            .and_then(self::temperature::AccessRecord::decode)
1487            .unwrap_or_else(|| self::temperature::AccessRecord::new(now));
1488        let access = self::temperature::AccessRecord {
1489            last_accessed_at: now,
1490            ..previous
1491        }
1492        .encode();
1493        self.last_accessed
1494            .put(&mut wtxn, hash, &access)
1495            .map_err(map_heed)?;
1496        wtxn.commit().map_err(map_heed)?;
1497        Ok(true)
1498    }
1499
1500    pub fn touch_many_accessed_sync(&self, hashes: &[Hash], now: u64) -> Result<usize, StoreError> {
1501        let mut wtxn = self.env.write_txn().map_err(map_heed)?;
1502        let mut updated = 0usize;
1503        let mut seen = HashSet::new();
1504        for hash in hashes {
1505            if !seen.insert(*hash) || self.locations.get(&wtxn, hash).map_err(map_heed)?.is_none() {
1506                continue;
1507            }
1508            let previous = self
1509                .last_accessed
1510                .get(&wtxn, hash)
1511                .map_err(map_heed)?
1512                .and_then(self::temperature::AccessRecord::decode)
1513                .unwrap_or_else(|| self::temperature::AccessRecord::new(now));
1514            let access = self::temperature::AccessRecord {
1515                last_accessed_at: now,
1516                ..previous
1517            }
1518            .encode();
1519            self.last_accessed
1520                .put(&mut wtxn, hash, &access)
1521                .map_err(map_heed)?;
1522            updated += 1;
1523        }
1524        wtxn.commit().map_err(map_heed)?;
1525        Ok(updated)
1526    }
1527
1528    pub fn last_accessed_at_sync(&self, hash: &Hash) -> Result<Option<u64>, StoreError> {
1529        let rtxn = self.env.read_txn().map_err(map_heed)?;
1530        self.last_accessed
1531            .get(&rtxn, hash)
1532            .map_err(map_heed)?
1533            .map(|bytes| {
1534                self::temperature::AccessRecord::decode(bytes)
1535                    .map(|access| access.last_accessed_at)
1536                    .ok_or_else(|| StoreError::Other("invalid pool access record".into()))
1537            })
1538            .transpose()
1539    }
1540
1541    pub fn many_last_accessed_at_sync(
1542        &self,
1543        hashes: &[Hash],
1544    ) -> Result<Vec<(Hash, u64)>, StoreError> {
1545        let rtxn = self.env.read_txn().map_err(map_heed)?;
1546        let mut values = Vec::new();
1547        for hash in hashes {
1548            if let Some(value) = self.last_accessed.get(&rtxn, hash).map_err(map_heed)? {
1549                let access = self::temperature::AccessRecord::decode(value)
1550                    .ok_or_else(|| StoreError::Other("invalid pool access record".into()))?;
1551                values.push((*hash, access.last_accessed_at));
1552            }
1553        }
1554        Ok(values)
1555    }
1556
1557    pub fn list(&self) -> Result<Vec<Hash>, StoreError> {
1558        let rtxn = self.env.read_txn().map_err(map_heed)?;
1559        let mut hashes = Vec::new();
1560        for item in self.locations.iter(&rtxn).map_err(map_heed)? {
1561            let (hash, _) = item.map_err(map_heed)?;
1562            let hash: Hash = hash
1563                .try_into()
1564                .map_err(|_| StoreError::Other("invalid pool hash key".into()))?;
1565            hashes.push(hash);
1566        }
1567        Ok(hashes)
1568    }
1569
1570    pub fn stats(&self) -> Result<StoreStats, StoreError> {
1571        let rtxn = self.env.read_txn().map_err(map_heed)?;
1572        let mut stats = StoreStats::default();
1573        for item in self.locations.iter(&rtxn).map_err(map_heed)? {
1574            let (_, location) = item.map_err(map_heed)?;
1575            let location = LocationRecord::decode(location)?;
1576            stats.count = stats.count.saturating_add(1);
1577            stats.bytes = stats.bytes.saturating_add(location.size());
1578        }
1579        for item in self.pins.iter(&rtxn).map_err(map_heed)? {
1580            let (hash, count) = item.map_err(map_heed)?;
1581            if decode_pin_count(count)? == 0 {
1582                continue;
1583            }
1584            let Some(location) = self.locations.get(&rtxn, hash).map_err(map_heed)? else {
1585                continue;
1586            };
1587            stats.pinned_count = stats.pinned_count.saturating_add(1);
1588            stats.pinned_bytes = stats
1589                .pinned_bytes
1590                .saturating_add(LocationRecord::decode(location)?.size());
1591        }
1592        Ok(stats)
1593    }
1594
1595    /// Return conservative physical usage for quota admission without scanning
1596    /// the logical Pool catalog.
1597    ///
1598    /// Every member LMDB maintains its own totals transactionally, so summing
1599    /// those counters is constant in the member count and remains accurate
1600    /// across processes and mixed binary versions. Blobs temporarily duplicated
1601    /// during a move are counted in both members, which is intentionally
1602    /// conservative for writable-space admission. Missing member statistics
1603    /// fail closed rather than undercounting storage.
1604    pub fn writable_physical_stats(&self) -> Result<StoreStats, StoreError> {
1605        self.refresh_members()?;
1606        let manifest = self.read_manifest()?;
1607        let runtime = self
1608            .runtime
1609            .read()
1610            .map_err(|_| StoreError::Other("pool runtime lock poisoned".into()))?;
1611        let mut total = StoreStats::default();
1612        for member in manifest.members {
1613            let store = runtime.stores.get(&member.id).ok_or_else(|| {
1614                let detail = runtime
1615                    .errors
1616                    .get(&member.id)
1617                    .map(String::as_str)
1618                    .unwrap_or("member store is not open");
1619                StoreError::Other(format!(
1620                    "pool member {} unavailable for physical quota accounting: {detail}",
1621                    member.id
1622                ))
1623            })?;
1624            let stats = store.stats().map_err(|error| {
1625                StoreError::Other(format!(
1626                    "pool member {} unavailable for physical quota accounting: {error}",
1627                    member.id
1628                ))
1629            })?;
1630            let count = u64::try_from(stats.count)
1631                .map_err(|_| StoreError::Other("pool physical blob count exceeds u64".into()))?;
1632            let pinned_count = u64::try_from(stats.pinned_count)
1633                .map_err(|_| StoreError::Other("pool physical pin count exceeds u64".into()))?;
1634            total.count = total
1635                .count
1636                .checked_add(count)
1637                .ok_or_else(|| StoreError::Other("pool physical blob count overflow".into()))?;
1638            total.bytes = total
1639                .bytes
1640                .checked_add(stats.total_bytes)
1641                .ok_or_else(|| StoreError::Other("pool physical byte count overflow".into()))?;
1642            total.pinned_count = total
1643                .pinned_count
1644                .checked_add(pinned_count)
1645                .ok_or_else(|| StoreError::Other("pool physical pin count overflow".into()))?;
1646            total.pinned_bytes = total
1647                .pinned_bytes
1648                .checked_add(stats.pinned_bytes)
1649                .ok_or_else(|| StoreError::Other("pool physical pinned bytes overflow".into()))?;
1650        }
1651        Ok(total)
1652    }
1653
1654    pub fn force_sync(&self) -> Result<(), StoreError> {
1655        self.env.force_sync().map_err(map_heed)?;
1656        self.refresh_members()?;
1657        let runtime = self
1658            .runtime
1659            .read()
1660            .map_err(|_| StoreError::Other("pool runtime lock poisoned".into()))?;
1661        for store in runtime.stores.values() {
1662            store.force_sync()?;
1663        }
1664        Ok(())
1665    }
1666
1667    /// Current LMDB virtual map size for the Pool catalog.
1668    pub fn catalog_map_size_bytes(&self) -> u64 {
1669        u64::try_from(self.env.info().map_size).unwrap_or(u64::MAX)
1670    }
1671
1672    /// Exhaustively prove every controlled member's physical indexes and
1673    /// persisted aggregate counters before the first migration mutation.
1674    ///
1675    /// Controlled reopen performs only constant-time structural checks so
1676    /// bounded mapping epochs do not rescan the complete target. The caller
1677    /// must hold the external writer fence while this one full proof runs and
1678    /// must keep that fence held until migration completion.
1679    pub fn validate_controlled_member_state_exact(&self) -> Result<(), StoreError> {
1680        if self.expected_manifest_sha256.is_none() {
1681            return Err(StoreError::Other(
1682                "exact controlled member validation requires an exact manifest SHA-256".into(),
1683            ));
1684        }
1685        self.refresh_members()?;
1686        let manifest = self.read_manifest()?;
1687        let runtime = self
1688            .runtime
1689            .read()
1690            .map_err(|_| StoreError::Other("pool runtime lock poisoned".into()))?;
1691        if runtime.stores.len() != manifest.members.len() {
1692            return Err(StoreError::Other(
1693                "not every authority-pinned Pool member is open for exact validation".into(),
1694            ));
1695        }
1696        for member in manifest.members {
1697            let store = runtime.stores.get(&member.id).ok_or_else(|| {
1698                StoreError::Other(format!(
1699                    "controlled Pool member {} is unavailable for exact validation",
1700                    member.id
1701                ))
1702            })?;
1703            store.validate_exact_member_state().map_err(|error| {
1704                StoreError::Other(format!(
1705                    "controlled Pool member {} failed exact state validation: {error}",
1706                    member.id
1707                ))
1708            })?;
1709        }
1710        Ok(())
1711    }
1712
1713    /// Revalidate every controlled catalog/member authority without forcing
1714    /// unrelated writer commits to storage.
1715    ///
1716    /// This is sufficient before advancing a migration cursor when the
1717    /// migration observed exact `Stored` rows and performed no target writes.
1718    /// Any batch that attempted target writes must use
1719    /// [`Self::validate_controlled_authority_and_sync`] instead.
1720    pub fn validate_controlled_authority(&self) -> Result<(), StoreError> {
1721        if self.expected_manifest_sha256.is_none() {
1722            return Err(StoreError::Other(
1723                "controlled authority validation requires an exact manifest SHA-256".into(),
1724            ));
1725        }
1726        self.refresh_members()?;
1727        {
1728            let runtime = self
1729                .runtime
1730                .read()
1731                .map_err(|_| StoreError::Other("pool runtime lock poisoned".into()))?;
1732            if runtime.stores.len() != self.member_runtime_paths.len() {
1733                return Err(StoreError::Other(
1734                    "not every authority-pinned Pool member is open".into(),
1735                ));
1736            }
1737        }
1738        self.refresh_members()
1739    }
1740
1741    /// Revalidate every controlled authority and force the catalog/member
1742    /// commits durable before an external migration cursor may advance.
1743    pub fn validate_controlled_authority_and_sync(&self) -> Result<(), StoreError> {
1744        self.validate_controlled_authority()?;
1745        self.env.force_sync().map_err(map_heed)?;
1746        {
1747            let runtime = self
1748                .runtime
1749                .read()
1750                .map_err(|_| StoreError::Other("pool runtime lock poisoned".into()))?;
1751            for store in runtime.stores.values() {
1752                store.force_sync()?;
1753            }
1754        }
1755        self.validate_controlled_authority()
1756    }
1757
1758    fn refresh_members(&self) -> Result<(), StoreError> {
1759        let (manifest, manifest_sha256) = self.read_manifest_with_identity()?;
1760        if let Some(expected) = self.expected_manifest_sha256 {
1761            if manifest_sha256 != expected {
1762                return Err(StoreError::Other(format!(
1763                    "live pool manifest SHA-256 differs from controlled authority: expected {}, found {}",
1764                    to_hex(&expected),
1765                    to_hex(&manifest_sha256)
1766                )));
1767            }
1768            validate_controlled_manifest_members(&manifest, &self.member_runtime_paths)?;
1769        }
1770        let mut runtime = self
1771            .runtime
1772            .write()
1773            .map_err(|_| StoreError::Other("pool runtime lock poisoned".into()))?;
1774        if runtime.generation == Some(manifest.generation) {
1775            return Ok(());
1776        }
1777
1778        let configured = manifest
1779            .members
1780            .iter()
1781            .map(|member| member.id)
1782            .collect::<HashSet<_>>();
1783        runtime.stores.retain(|id, _| configured.contains(id));
1784        runtime.read_gates.retain(|id, _| configured.contains(id));
1785        runtime.write_gates.retain(|id, _| configured.contains(id));
1786        runtime.errors.retain(|id, _| configured.contains(id));
1787        for member in &manifest.members {
1788            let read_gate = runtime
1789                .read_gates
1790                .entry(member.id)
1791                .or_insert_with(|| {
1792                    Arc::new(ConcurrencyGate::new(member.config.max_read_concurrency))
1793                })
1794                .clone();
1795            read_gate.set_limit(member.config.max_read_concurrency)?;
1796            let write_gate = runtime
1797                .write_gates
1798                .entry(member.id)
1799                .or_insert_with(|| {
1800                    Arc::new(ConcurrencyGate::new(member.config.max_write_concurrency))
1801                })
1802                .clone();
1803            write_gate.set_limit(member.config.max_write_concurrency)?;
1804            if runtime.stores.contains_key(&member.id) {
1805                continue;
1806            }
1807            let config = self.runtime_member_config(member)?;
1808            let pinned_identity = self
1809                .member_runtime_paths
1810                .get(&member.id)
1811                .map(|binding| binding.lmdb_identity);
1812            match open_member_store(
1813                member.id,
1814                &config,
1815                pinned_identity,
1816                self.physical_space.as_ref(),
1817            ) {
1818                Ok(store) => {
1819                    runtime.stores.insert(member.id, Arc::new(store));
1820                    runtime.errors.remove(&member.id);
1821                }
1822                Err(error) => {
1823                    if self.expected_manifest_sha256.is_some() {
1824                        return Err(StoreError::Other(format!(
1825                            "controlled Pool member {} failed its exact open: {error}",
1826                            member.id
1827                        )));
1828                    }
1829                    runtime.errors.insert(member.id, error.to_string());
1830                }
1831            }
1832        }
1833        runtime.generation = Some(manifest.generation);
1834        drop(runtime);
1835        self.adaptive
1836            .lock()
1837            .map_err(|_| StoreError::Other("pool adaptive lock poisoned".into()))?
1838            .retain(&configured);
1839        Ok(())
1840    }
1841
1842    fn runtime_member_config(&self, member: &MemberRecord) -> Result<PoolMemberConfig, StoreError> {
1843        let Some(binding) = self.member_runtime_paths.get(&member.id) else {
1844            if self.member_runtime_paths.is_empty() {
1845                return Ok(member.config.clone());
1846            }
1847            return Err(StoreError::Other(format!(
1848                "pool member {} has no pinned runtime path",
1849                member.id
1850            )));
1851        };
1852        if !configured_paths_match(&binding.configured_path, &member.config.path)
1853            || !configured_optional_paths_match(
1854                binding.configured_external_path.as_deref(),
1855                member.config.external_blob_dir.as_deref(),
1856            )
1857        {
1858            return Err(StoreError::Other(format!(
1859                "pool member {} paths differ from pinned runtime authority",
1860                member.id
1861            )));
1862        }
1863        let mut config = member.config.clone();
1864        config.path = binding.runtime_path.clone();
1865        config.external_blob_dir = binding.runtime_external_path.clone();
1866        Ok(config)
1867    }
1868
1869    fn get_member(&self, id: PoolMemberId) -> Result<Arc<LmdbBlobStore>, StoreError> {
1870        self.refresh_members()?;
1871        if let Some(store) = self
1872            .runtime
1873            .read()
1874            .map_err(|_| StoreError::Other("pool runtime lock poisoned".into()))?
1875            .stores
1876            .get(&id)
1877            .cloned()
1878        {
1879            return Ok(store);
1880        }
1881
1882        let member = self
1883            .read_manifest()?
1884            .members
1885            .into_iter()
1886            .find(|member| member.id == id)
1887            .ok_or_else(|| StoreError::Other(format!("unknown pool member {id}")))?;
1888        let config = self.runtime_member_config(&member)?;
1889        let pinned_identity = self
1890            .member_runtime_paths
1891            .get(&id)
1892            .map(|binding| binding.lmdb_identity);
1893        match open_member_store(id, &config, pinned_identity, self.physical_space.as_ref()) {
1894            Ok(store) => {
1895                let store = Arc::new(store);
1896                let mut runtime = self
1897                    .runtime
1898                    .write()
1899                    .map_err(|_| StoreError::Other("pool runtime lock poisoned".into()))?;
1900                runtime.stores.insert(id, Arc::clone(&store));
1901                runtime.errors.remove(&id);
1902                Ok(store)
1903            }
1904            Err(error) => {
1905                self.record_member_failure(id, false);
1906                let mut runtime = self
1907                    .runtime
1908                    .write()
1909                    .map_err(|_| StoreError::Other("pool runtime lock poisoned".into()))?;
1910                runtime.errors.insert(id, error.to_string());
1911                Err(error)
1912            }
1913        }
1914    }
1915
1916    fn member_state(&self, id: PoolMemberId) -> Result<Option<PoolMemberState>, StoreError> {
1917        Ok(self
1918            .read_manifest()?
1919            .members
1920            .into_iter()
1921            .find(|member| member.id == id)
1922            .map(|member| member.state))
1923    }
1924
1925    fn member_gate(
1926        &self,
1927        id: PoolMemberId,
1928        write: bool,
1929    ) -> Result<Arc<ConcurrencyGate>, StoreError> {
1930        self.refresh_members()?;
1931        let runtime = self
1932            .runtime
1933            .read()
1934            .map_err(|_| StoreError::Other("pool runtime lock poisoned".into()))?;
1935        let gates = if write {
1936            &runtime.write_gates
1937        } else {
1938            &runtime.read_gates
1939        };
1940        gates
1941            .get(&id)
1942            .cloned()
1943            .ok_or_else(|| StoreError::Other(format!("unknown pool member {id}")))
1944    }
1945
1946    fn choose_write_member(
1947        &self,
1948        incoming_bytes: u64,
1949        exclude: Option<PoolMemberId>,
1950    ) -> Result<PoolMemberId, StoreError> {
1951        self.choose_write_member_with_reserved(incoming_bytes, exclude, &HashMap::new())
1952    }
1953
1954    fn choose_write_member_with_reserved(
1955        &self,
1956        incoming_bytes: u64,
1957        exclude: Option<PoolMemberId>,
1958        reserved_bytes: &HashMap<PoolMemberId, u64>,
1959    ) -> Result<PoolMemberId, StoreError> {
1960        let excluded = exclude.into_iter().collect::<HashSet<_>>();
1961        self.choose_write_member_excluding(incoming_bytes, &excluded, reserved_bytes)
1962    }
1963
1964    fn choose_write_member_excluding(
1965        &self,
1966        incoming_bytes: u64,
1967        excluded: &HashSet<PoolMemberId>,
1968        reserved_bytes: &HashMap<PoolMemberId, u64>,
1969    ) -> Result<PoolMemberId, StoreError> {
1970        self.refresh_members()?;
1971        let manifest = self.read_manifest()?;
1972        let runtime = self
1973            .runtime
1974            .read()
1975            .map_err(|_| StoreError::Other("pool runtime lock poisoned".into()))?;
1976        let mut candidates = Vec::new();
1977        let mut below_high_watermark = Vec::new();
1978        for member in manifest.members.iter().filter(|member| {
1979            member.state == PoolMemberState::Active && !excluded.contains(&member.id)
1980        }) {
1981            let Some(store) = runtime.stores.get(&member.id) else {
1982                continue;
1983            };
1984            let stats = match store.stats() {
1985                Ok(stats) => stats,
1986                Err(_) => continue,
1987            };
1988            let effective_bytes = stats
1989                .total_bytes
1990                .saturating_add(reserved_bytes.get(&member.id).copied().unwrap_or(0));
1991            if member.config.capacity_bytes > 0
1992                && effective_bytes.saturating_add(incoming_bytes) > member.config.capacity_bytes
1993            {
1994                continue;
1995            }
1996            candidates.push((member.id, effective_bytes, member.config.capacity_bytes));
1997            let projected_fill = effective_bytes
1998                .saturating_add(incoming_bytes)
1999                .saturating_mul(100)
2000                .saturating_div(member.config.capacity_bytes)
2001                .min(100);
2002            if projected_fill <= u64::from(member.config.temperature_high_watermark_percent) {
2003                below_high_watermark.push((
2004                    member.id,
2005                    effective_bytes,
2006                    member.config.capacity_bytes,
2007                ));
2008            }
2009        }
2010        drop(runtime);
2011        if !below_high_watermark.is_empty() {
2012            candidates = below_high_watermark;
2013        }
2014        self.adaptive
2015            .lock()
2016            .map_err(|_| StoreError::Other("pool adaptive lock poisoned".into()))?
2017            .choose_write(&candidates)
2018            .ok_or_else(|| StoreError::Other("no writable pool member has capacity".into()))
2019    }
2020
2021    fn repair_location(
2022        &self,
2023        hash: Hash,
2024        data: &[u8],
2025        expected: LocationRecord,
2026    ) -> Result<bool, StoreError> {
2027        self.repair_location_excluding(hash, data, expected, HashSet::new())
2028    }
2029
2030    fn repair_location_excluding(
2031        &self,
2032        hash: Hash,
2033        data: &[u8],
2034        expected: LocationRecord,
2035        mut excluded: HashSet<PoolMemberId>,
2036    ) -> Result<bool, StoreError> {
2037        let preferred = expected.preferred_member();
2038        let mut next = (!excluded.contains(&preferred)
2039            && self.member_state(preferred)? == Some(PoolMemberState::Active))
2040        .then_some(preferred);
2041        let mut last_error = None;
2042        let (target, inserted) = loop {
2043            let target = match next.take() {
2044                Some(target) => target,
2045                None => match self.choose_write_member_excluding(
2046                    data.len() as u64,
2047                    &excluded,
2048                    &HashMap::new(),
2049                ) {
2050                    Ok(target) => target,
2051                    Err(error) => {
2052                        return Err(last_error.unwrap_or(error));
2053                    }
2054                },
2055            };
2056            let result = self
2057                .get_member(target)
2058                .and_then(|store| self.write_verified_member(target, &store, hash, data));
2059            match result {
2060                Ok(inserted) => break (target, inserted),
2061                Err(error)
2062                    if self
2063                        .physical_space
2064                        .as_ref()
2065                        .is_some_and(|guard| guard.stop_after_write_error(&error)) =>
2066                {
2067                    return Err(self.physical_space.as_ref().unwrap().refusal_error().into())
2068                }
2069                Err(error) => {
2070                    excluded.insert(target);
2071                    last_error = Some(error);
2072                }
2073            }
2074        };
2075        let stored = LocationRecord::Stored {
2076            member: target,
2077            size: data.len() as u64,
2078        };
2079
2080        let mut wtxn = self.env.write_txn().map_err(map_heed)?;
2081        let current = self
2082            .locations
2083            .get(&wtxn, &hash)
2084            .map_err(map_heed)?
2085            .map(LocationRecord::decode)
2086            .transpose()?;
2087        if current == Some(expected) {
2088            self.set_location_txn(&mut wtxn, hash, Some(stored))?;
2089            wtxn.commit().map_err(map_heed)?;
2090            return Ok(inserted);
2091        }
2092        drop(wtxn);
2093
2094        if let Some(current) = current {
2095            if self.read_verified_location(&hash, current)?.is_some() {
2096                return Ok(false);
2097            }
2098        }
2099        Err(StoreError::Other(format!(
2100            "pool location changed while repairing {hash:?}"
2101        )))
2102    }
2103
2104    fn read_verified_location(
2105        &self,
2106        hash: &Hash,
2107        location: LocationRecord,
2108    ) -> Result<Option<Vec<u8>>, StoreError> {
2109        let mut ids = match location {
2110            LocationRecord::Pending { member, .. } | LocationRecord::Stored { member, .. } => {
2111                vec![member]
2112            }
2113            LocationRecord::Moving { source, target, .. } => vec![target, source],
2114        };
2115        self.adaptive
2116            .lock()
2117            .map_err(|_| StoreError::Other("pool adaptive lock poisoned".into()))?
2118            .order_reads(&mut ids);
2119
2120        let mut first_error = None;
2121        for id in ids {
2122            let store = match self.get_member(id) {
2123                Ok(store) => store,
2124                Err(error) => {
2125                    first_error.get_or_insert(error);
2126                    continue;
2127                }
2128            };
2129            match self.read_verified_member(id, &store, hash) {
2130                Ok(Some(data)) => return Ok(Some(data)),
2131                Ok(None) => {}
2132                Err(error) => {
2133                    first_error.get_or_insert(error);
2134                }
2135            }
2136        }
2137        match first_error {
2138            Some(error) => Err(error),
2139            None => Ok(None),
2140        }
2141    }
2142
2143    fn read_verified_member(
2144        &self,
2145        id: PoolMemberId,
2146        store: &LmdbBlobStore,
2147        hash: &Hash,
2148    ) -> Result<Option<Vec<u8>>, StoreError> {
2149        let gate = self.member_gate(id, false)?;
2150        let _permit = gate.acquire()?;
2151        let started = Instant::now();
2152        let result = store.get_sync(hash);
2153        match result {
2154            Ok(Some(data)) if sha256(&data) == *hash => {
2155                self.record_read(id, started.elapsed(), true);
2156                Ok(Some(data))
2157            }
2158            Ok(Some(_)) => {
2159                self.record_read(id, started.elapsed(), false);
2160                Err(StoreError::Other(format!(
2161                    "pool member {id} returned corrupt bytes"
2162                )))
2163            }
2164            Ok(None) => {
2165                self.record_read(id, started.elapsed(), true);
2166                Ok(None)
2167            }
2168            Err(error) => {
2169                self.record_read(id, started.elapsed(), false);
2170                Err(error)
2171            }
2172        }
2173    }
2174
2175    fn write_verified_member(
2176        &self,
2177        id: PoolMemberId,
2178        store: &LmdbBlobStore,
2179        hash: Hash,
2180        data: &[u8],
2181    ) -> Result<bool, StoreError> {
2182        let gate = self.member_gate(id, true)?;
2183        let _permit = gate.acquire()?;
2184        if let Some(existing) = store.get_sync(&hash)? {
2185            if sha256(&existing) == hash {
2186                return Ok(false);
2187            }
2188            store.delete_sync(&hash)?;
2189        }
2190        let started = Instant::now();
2191        let result = store.put_sync(hash, data);
2192        let success = result.is_ok();
2193        self.record_write(id, started.elapsed(), data.len(), success);
2194        let inserted = result?;
2195        let written = store
2196            .get_sync(&hash)?
2197            .ok_or_else(|| StoreError::Other(format!("pool member {id} lost a committed write")))?;
2198        if sha256(&written) != hash {
2199            self.record_member_failure(id, true);
2200            return Err(StoreError::Other(format!(
2201                "pool member {id} committed corrupt bytes"
2202            )));
2203        }
2204        Ok(inserted)
2205    }
2206
2207    fn delete_member_blob(
2208        &self,
2209        id: PoolMemberId,
2210        store: &LmdbBlobStore,
2211        hash: &Hash,
2212    ) -> Result<bool, StoreError> {
2213        let gate = self.member_gate(id, true)?;
2214        let _permit = gate.acquire()?;
2215        store.delete_sync(hash)
2216    }
2217
2218    fn record_read(&self, id: PoolMemberId, elapsed: Duration, success: bool) {
2219        if let Ok(mut adaptive) = self.adaptive.lock() {
2220            adaptive.record_read(id, elapsed, success);
2221        }
2222    }
2223
2224    fn record_write(&self, id: PoolMemberId, elapsed: Duration, bytes: usize, success: bool) {
2225        if let Ok(mut adaptive) = self.adaptive.lock() {
2226            adaptive.record_write(id, elapsed, bytes, success);
2227        }
2228    }
2229
2230    fn record_member_failure(&self, id: PoolMemberId, write: bool) {
2231        if write {
2232            self.record_write(id, Duration::ZERO, 0, false);
2233        } else {
2234            self.record_read(id, Duration::ZERO, false);
2235        }
2236    }
2237
2238    fn sample_temperature_access(&self, hash: Hash, location: LocationRecord) {
2239        if !self.temperature_config.enabled {
2240            return;
2241        }
2242        let sample_rate = u64::from(self.temperature_config.read_sample_rate.max(1));
2243        let access = self
2244            .temperature_access_counter
2245            .fetch_add(1, Ordering::Relaxed)
2246            .saturating_add(1);
2247        if !access.is_multiple_of(sample_rate) {
2248            return;
2249        }
2250        if let Ok(mut temperature) = self.temperature.lock() {
2251            temperature
2252                .samples
2253                .observe(hash, location, unix_timestamp_now());
2254        }
2255    }
2256}
2257
2258#[async_trait]
2259impl Store for PoolStore {
2260    async fn put(&self, hash: Hash, data: Vec<u8>) -> Result<bool, StoreError> {
2261        self.put_sync(hash, &data)
2262    }
2263
2264    async fn put_many(&self, items: Vec<(Hash, Vec<u8>)>) -> Result<usize, StoreError> {
2265        self.put_many_sync(&items)
2266    }
2267
2268    async fn put_many_optimistic(&self, items: Vec<(Hash, Vec<u8>)>) -> Result<usize, StoreError> {
2269        self.put_many_optimistic_sync(&items)
2270    }
2271
2272    async fn get(&self, hash: &Hash) -> Result<Option<Vec<u8>>, StoreError> {
2273        self.get_sync(hash)
2274    }
2275
2276    async fn get_range(
2277        &self,
2278        hash: &Hash,
2279        start: u64,
2280        end_inclusive: u64,
2281    ) -> Result<Option<Vec<u8>>, StoreError> {
2282        self.get_range_sync(hash, start, end_inclusive)
2283    }
2284
2285    async fn blob_size(&self, hash: &Hash) -> Result<Option<u64>, StoreError> {
2286        self.blob_size_sync(hash)
2287    }
2288
2289    async fn has(&self, hash: &Hash) -> Result<bool, StoreError> {
2290        self.exists(hash)
2291    }
2292
2293    async fn delete(&self, hash: &Hash) -> Result<bool, StoreError> {
2294        self.delete_sync(hash)
2295    }
2296
2297    async fn delete_many(&self, hashes: Vec<Hash>) -> Result<usize, StoreError> {
2298        self.delete_many_sync(&hashes)
2299    }
2300
2301    async fn stats(&self) -> StoreStats {
2302        PoolStore::stats(self).unwrap_or_default()
2303    }
2304
2305    async fn pin(&self, hash: &Hash) -> Result<(), StoreError> {
2306        self.pin_sync(hash)
2307    }
2308
2309    async fn unpin(&self, hash: &Hash) -> Result<(), StoreError> {
2310        self.unpin_sync(hash)
2311    }
2312
2313    fn pin_count(&self, hash: &Hash) -> u32 {
2314        self.pin_count_sync(hash).unwrap_or(0)
2315    }
2316}
2317
2318fn encode_manifest(manifest: &PoolManifest) -> Result<Vec<u8>, StoreError> {
2319    rmp_serde::to_vec_named(manifest)
2320        .map_err(|error| StoreError::Other(format!("encode pool manifest: {error}")))
2321}
2322
2323fn decode_manifest(bytes: &[u8]) -> Result<PoolManifest, StoreError> {
2324    let manifest: PoolManifest = rmp_serde::from_slice(bytes)
2325        .map_err(|error| StoreError::Other(format!("decode pool manifest: {error}")))?;
2326    if manifest.version != 1 {
2327        return Err(StoreError::Other(format!(
2328            "unsupported pool manifest version {}",
2329            manifest.version
2330        )));
2331    }
2332    Ok(manifest)
2333}
2334
2335fn member_hash_key(member: PoolMemberId, hash: Hash) -> [u8; 48] {
2336    let mut key = [0u8; 48];
2337    key[..16].copy_from_slice(member.as_bytes());
2338    key[16..].copy_from_slice(&hash);
2339    key
2340}
2341
2342fn decode_pin_count(bytes: &[u8]) -> Result<u32, StoreError> {
2343    Ok(u32::from_be_bytes(bytes.try_into().map_err(|_| {
2344        StoreError::Other("invalid pool pin count".into())
2345    })?))
2346}
2347
2348fn unix_timestamp_now() -> u64 {
2349    SystemTime::now()
2350        .duration_since(UNIX_EPOCH)
2351        .unwrap_or_default()
2352        .as_secs()
2353}
2354
2355fn put_many_report(
2356    total: usize,
2357    ordered: &[(Hash, u64)],
2358    inserted: &HashSet<Hash>,
2359) -> PutManyReport {
2360    let mut report = PutManyReport {
2361        total,
2362        ..PutManyReport::default()
2363    };
2364    for (hash, bytes) in ordered {
2365        if inserted.contains(hash) {
2366            report.inserted = report.inserted.saturating_add(1);
2367            report.inserted_bytes = report.inserted_bytes.saturating_add(*bytes);
2368            report.inserted_hashes.push(*hash);
2369        }
2370    }
2371    report
2372}
2373
2374fn map_heed(error: heed::Error) -> StoreError {
2375    super::map_heed_error(error)
2376}