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