1use super::member::open_member_reader;
2use super::{
3 decode_manifest, delete_protection, map_heed, validate_controlled_manifest, LocationRecord,
4 PoolDeleteProtectionGuard, PoolDeleteProtectionStatus, PoolManifest, PoolMemberId,
5 PoolStoreConfig, MANIFEST_KEY,
6};
7use crate::{managed_env::ManagedEnv, LmdbBlobReader};
8use hashtree_core::store::StoreError;
9use hashtree_core::{sha256, types::Hash};
10use heed::types::Bytes;
11use heed::{Database, EnvFlags, EnvOpenOptions};
12use sha2::{Digest, Sha256};
13use std::collections::HashMap;
14use std::path::{Path, PathBuf};
15use std::time::{Duration, Instant};
16
17const TERMINAL_AUDIT_BATCH_ITEMS: usize = 4_096;
18const TERMINAL_AUDIT_BATCH_BYTES: u64 = 64 * 1024 * 1024;
19
20pub struct PoolStoreReader {
28 catalog_path: PathBuf,
29 env: ManagedEnv,
30 manifest: Database<Bytes, Bytes>,
31 opened_manifest_bytes: Vec<u8>,
32 locations: Database<Bytes, Bytes>,
33 by_member: Database<Bytes, heed::types::Unit>,
34 temperature_state: Database<Bytes, Bytes>,
35 manifest_identity: PoolManifestIdentity,
36 member_ids: Vec<PoolMemberId>,
37 members: HashMap<PoolMemberId, LmdbBlobReader>,
38 member_errors: HashMap<PoolMemberId, String>,
39}
40
41pub struct PoolReadBatchItem {
43 pub hash: Hash,
44 pub member_candidates: Vec<PoolMemberId>,
45 pub declared_size: Option<u64>,
46 pub data: Option<Vec<u8>>,
47 pub error: Option<String>,
48}
49
50#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct PoolTerminalAudit {
53 pub stored_locations: u64,
54 pub stored_bytes: u64,
55 pub catalog_sha256: Hash,
56 pub payload_sha256: Hash,
57 pub manifest_sha256: Hash,
58}
59
60#[derive(Debug, Clone, PartialEq, Eq)]
67pub struct PoolPhysicalAudit {
68 pub stored_locations: u64,
69 pub stored_bytes: u64,
70 pub catalog_sha256: Hash,
71 pub physical_sha256: Hash,
72 pub manifest_sha256: Hash,
73}
74
75struct TerminalStateAudit {
76 physical: PoolPhysicalAudit,
77 payload_sha256: Option<Hash>,
78}
79
80#[derive(Debug, Clone, Copy, PartialEq, Eq)]
86pub enum PoolCatalogLocation {
87 Missing,
88 Pending {
89 member: PoolMemberId,
90 size: u64,
91 },
92 Stored {
93 member: PoolMemberId,
94 size: u64,
95 },
96 Moving {
97 source: PoolMemberId,
98 target: PoolMemberId,
99 size: u64,
100 },
101}
102
103#[derive(Debug, Clone, PartialEq, Eq)]
108pub struct PoolManifestIdentity {
109 pub generation: u64,
110 pub sha256: Hash,
111 pub member_ids: Vec<PoolMemberId>,
112}
113
114impl PoolCatalogLocation {
115 pub(super) fn from_record(record: Option<LocationRecord>) -> Self {
116 match record {
117 None => Self::Missing,
118 Some(LocationRecord::Pending { member, size }) => Self::Pending { member, size },
119 Some(LocationRecord::Stored { member, size }) => Self::Stored { member, size },
120 Some(LocationRecord::Moving {
121 source,
122 target,
123 size,
124 }) => Self::Moving {
125 source,
126 target,
127 size,
128 },
129 }
130 }
131}
132
133impl PoolStoreReader {
134 pub fn open<P: AsRef<Path>>(path: P, config: PoolStoreConfig) -> Result<Self, StoreError> {
135 Self::open_inner(path.as_ref(), config, false, false, 1)
136 }
137
138 pub fn open_sequential<P: AsRef<Path>>(
142 path: P,
143 config: PoolStoreConfig,
144 ) -> Result<Self, StoreError> {
145 Self::open_inner(path.as_ref(), config, false, true, 1)
146 }
147
148 pub fn open_sequential_with_read_concurrency<P: AsRef<Path>>(
151 path: P,
152 config: PoolStoreConfig,
153 read_concurrency: usize,
154 ) -> Result<Self, StoreError> {
155 Self::open_inner(path.as_ref(), config, false, true, read_concurrency)
156 }
157
158 pub fn open_with_unavailable_members_for_audit<P: AsRef<Path>>(
165 path: P,
166 config: PoolStoreConfig,
167 ) -> Result<Self, StoreError> {
168 Self::open_inner(path.as_ref(), config, true, false, 1)
169 }
170
171 fn open_inner(
172 path: &Path,
173 config: PoolStoreConfig,
174 retain_unavailable_members: bool,
175 sequential_scan: bool,
176 read_concurrency: usize,
177 ) -> Result<Self, StoreError> {
178 if read_concurrency == 0 {
179 return Err(StoreError::Other(
180 "read-only Pool member read concurrency must be non-zero".into(),
181 ));
182 }
183 if config.temperature.enabled {
184 return Err(StoreError::Other(
185 "read-only Pool validation requires temperature tracking to be disabled".into(),
186 ));
187 }
188
189 let mut member_runtime_paths = config
190 .member_runtime_paths
191 .iter()
192 .map(|binding| (binding.id, binding.clone()))
193 .collect::<HashMap<_, _>>();
194 if member_runtime_paths.len() != config.member_runtime_paths.len() {
195 return Err(StoreError::Other(
196 "duplicate runtime path binding for controlled Pool reader".into(),
197 ));
198 }
199 let controlled = !member_runtime_paths.is_empty()
200 || config.catalog_lmdb_identity.is_some()
201 || config.expected_manifest_sha256.is_some();
202 if controlled
203 && (member_runtime_paths.is_empty()
204 || config.catalog_lmdb_identity.is_none()
205 || config.expected_manifest_sha256.is_none())
206 {
207 return Err(StoreError::Other(
208 "controlled Pool reader requires catalog identity, exact manifest SHA-256, and every member runtime binding".into(),
209 ));
210 }
211
212 let mut options = EnvOpenOptions::new();
213 options.max_dbs(super::CATALOG_DATABASES);
214 let mut flags = super::super::env_flags_from_env() | EnvFlags::READ_ONLY;
215 if sequential_scan {
216 flags.remove(EnvFlags::NO_READ_AHEAD);
217 } else {
218 flags |= EnvFlags::NO_READ_AHEAD;
219 }
220 unsafe {
221 options.flags(flags);
222 }
223 let env = unsafe {
224 match config.catalog_lmdb_identity {
225 Some(identity) => ManagedEnv::open_pinned(&options, path, identity),
226 None => ManagedEnv::open(&options, path),
227 }
228 }
229 .map_err(map_heed)?;
230 let rtxn = env.read_txn().map_err(map_heed)?;
231 let manifest_db: Database<Bytes, Bytes> = env
232 .open_database(&rtxn, Some("manifest"))
233 .map_err(map_heed)?
234 .ok_or_else(|| StoreError::Other("pool manifest database is missing".into()))?;
235 let locations = env
236 .open_database(&rtxn, Some("locations"))
237 .map_err(map_heed)?
238 .ok_or_else(|| StoreError::Other("pool locations database is missing".into()))?;
239 let by_member = env
240 .open_database(&rtxn, Some("by_member"))
241 .map_err(map_heed)?
242 .ok_or_else(|| StoreError::Other("pool member index database is missing".into()))?;
243 let temperature_state = env
244 .open_database(&rtxn, Some("temperature_state"))
245 .map_err(map_heed)?
246 .ok_or_else(|| {
247 StoreError::Other("pool temperature state database is missing".into())
248 })?;
249 let (manifest, manifest_sha256) = read_manifest(&manifest_db, &rtxn)?;
250 let opened_manifest_bytes = manifest_db
251 .get(&rtxn, MANIFEST_KEY)
252 .map_err(map_heed)?
253 .ok_or_else(|| StoreError::Other("pool manifest is missing".into()))?
254 .to_vec();
255 if let Some(expected) = config.expected_manifest_sha256 {
256 validate_controlled_manifest(&opened_manifest_bytes, expected, &member_runtime_paths)?;
257 }
258 rtxn.commit().map_err(map_heed)?;
259
260 let mut member_ids = manifest
261 .members
262 .iter()
263 .map(|member| member.id)
264 .collect::<Vec<_>>();
265 member_ids.sort_unstable();
266 let manifest_identity = PoolManifestIdentity {
267 generation: manifest.generation,
268 sha256: manifest_sha256,
269 member_ids: member_ids.clone(),
270 };
271 let mut members = HashMap::with_capacity(manifest.members.len());
272 let mut member_errors = HashMap::new();
273 for member in manifest.members {
274 let mut runtime_config = member.config.clone();
275 let pinned_identity = match member_runtime_paths.remove(&member.id) {
276 Some(binding) => {
277 runtime_config.path = binding.runtime_path;
278 runtime_config.external_blob_dir = binding.runtime_external_path;
279 Some(binding.lmdb_identity)
280 }
281 None if controlled => {
282 return Err(StoreError::Other(format!(
283 "controlled Pool reader has no runtime binding for member {}",
284 member.id
285 )))
286 }
287 None => None,
288 };
289 match open_member_reader(
290 member.id,
291 &runtime_config,
292 pinned_identity,
293 sequential_scan,
294 read_concurrency,
295 ) {
296 Ok(reader) => {
297 members.insert(member.id, reader);
298 }
299 Err(error) => {
300 if !retain_unavailable_members {
301 return Err(error);
302 }
303 member_errors.insert(member.id, error.to_string());
304 }
305 }
306 }
307 Ok(Self {
308 catalog_path: path.to_path_buf(),
309 env,
310 manifest: manifest_db,
311 opened_manifest_bytes,
312 locations,
313 by_member,
314 temperature_state,
315 manifest_identity,
316 member_ids,
317 members,
318 member_errors,
319 })
320 }
321
322 pub fn delete_protection_status(
323 &self,
324 ) -> Result<Option<PoolDeleteProtectionStatus>, StoreError> {
325 delete_protection::delete_protection_status(&self.env, self.manifest)
326 }
327
328 pub fn hold_delete_protection(
329 &self,
330 lease_id: Hash,
331 expected_record_sha256: Hash,
332 ) -> Result<PoolDeleteProtectionGuard, StoreError> {
333 delete_protection::hold_delete_protection(
334 &self.catalog_path,
335 &self.env,
336 self.manifest,
337 lease_id,
338 expected_record_sha256,
339 )
340 }
341
342 pub fn blob_location(&self, hash: &Hash) -> Result<Option<PoolMemberId>, StoreError> {
343 Ok(self
344 .read_location(hash)?
345 .map(LocationRecord::preferred_member))
346 }
347
348 pub fn member_ids(&self) -> Vec<PoolMemberId> {
353 self.member_ids.clone()
354 }
355
356 pub fn manifest_identity(&self) -> PoolManifestIdentity {
358 self.manifest_identity.clone()
359 }
360
361 pub fn validate_terminal_catalog_and_payloads(&self) -> Result<PoolTerminalAudit, StoreError> {
370 let audit = self.validate_terminal_state(true)?;
371 Ok(PoolTerminalAudit {
372 stored_locations: audit.physical.stored_locations,
373 stored_bytes: audit.physical.stored_bytes,
374 catalog_sha256: audit.physical.catalog_sha256,
375 payload_sha256: audit.payload_sha256.ok_or_else(|| {
376 StoreError::Other("terminal payload audit produced no payload digest".into())
377 })?,
378 manifest_sha256: audit.physical.manifest_sha256,
379 })
380 }
381
382 pub fn validate_terminal_catalog_and_physical_state(
385 &self,
386 ) -> Result<PoolPhysicalAudit, StoreError> {
387 Ok(self.validate_terminal_state(false)?.physical)
388 }
389
390 pub fn scan_terminal_catalog_entries_after(
396 &self,
397 after: Option<Hash>,
398 limit: usize,
399 ) -> Result<Vec<(Hash, u64)>, StoreError> {
400 if limit == 0 {
401 return Ok(Vec::new());
402 }
403 let rtxn = self.env.read_txn().map_err(map_heed)?;
404 let mut entries = Vec::with_capacity(limit);
405 let decode = |key: &[u8], encoded: &[u8]| -> Result<(Hash, u64), StoreError> {
406 let hash: Hash = key
407 .try_into()
408 .map_err(|_| StoreError::Other("invalid pool catalog hash key".into()))?;
409 let size = match LocationRecord::decode(encoded)? {
410 LocationRecord::Stored { size, .. } => size,
411 LocationRecord::Pending { .. } => {
412 return Err(StoreError::Other(format!(
413 "terminal Pool content proof rejected pending location {}",
414 hashtree_core::to_hex(&hash)
415 )))
416 }
417 LocationRecord::Moving { .. } => {
418 return Err(StoreError::Other(format!(
419 "terminal Pool content proof rejected moving location {}",
420 hashtree_core::to_hex(&hash)
421 )))
422 }
423 };
424 Ok((hash, size))
425 };
426 match after {
427 Some(after) => {
428 use std::ops::Bound;
429 let range = (Bound::Excluded(after.as_slice()), Bound::<&[u8]>::Unbounded);
430 for item in self.locations.range(&rtxn, &range).map_err(map_heed)? {
431 let (key, encoded) = item.map_err(map_heed)?;
432 entries.push(decode(key, encoded)?);
433 if entries.len() >= limit {
434 break;
435 }
436 }
437 }
438 None => {
439 for item in self.locations.iter(&rtxn).map_err(map_heed)? {
440 let (key, encoded) = item.map_err(map_heed)?;
441 entries.push(decode(key, encoded)?);
442 if entries.len() >= limit {
443 break;
444 }
445 }
446 }
447 }
448 Ok(entries)
449 }
450
451 fn validate_terminal_state(
452 &self,
453 verify_payloads: bool,
454 ) -> Result<TerminalStateAudit, StoreError> {
455 let rtxn = self.env.read_txn().map_err(map_heed)?;
456 let manifest = self
457 .manifest
458 .get(&rtxn, MANIFEST_KEY)
459 .map_err(map_heed)?
460 .ok_or_else(|| StoreError::Other("pool manifest is missing".into()))?;
461 if manifest != self.opened_manifest_bytes {
462 return Err(StoreError::Other(format!(
463 "pool manifest changed after terminal reader open: expected {}, found {}",
464 hashtree_core::to_hex(&self.manifest_identity.sha256),
465 hashtree_core::to_hex(&sha256(manifest))
466 )));
467 }
468 decode_manifest(manifest)?;
469
470 let mut catalog_digest = Sha256::new();
471 catalog_digest.update(b"hashtree-pool-terminal-catalog/v2\0");
472 catalog_digest.update((manifest.len() as u64).to_be_bytes());
473 catalog_digest.update(manifest);
474 let mut payload_digest = Sha256::new();
475 payload_digest.update(b"hashtree-pool-terminal-payloads/v1\0");
476 let mut expected_member_ownership = self
477 .member_ids
478 .iter()
479 .map(|member| {
480 let mut digest = Sha256::new();
481 digest.update(b"hashtree-pool-member-ownership/v1\0");
482 (*member, digest)
483 })
484 .collect::<HashMap<_, _>>();
485 let mut stored_locations = 0u64;
486 let mut stored_bytes = 0u64;
487 let mut member_counts = HashMap::<PoolMemberId, u64>::new();
488 let mut member_bytes = HashMap::<PoolMemberId, u64>::new();
489 let mut batch = Vec::<(Hash, PoolMemberId, u64)>::with_capacity(TERMINAL_AUDIT_BATCH_ITEMS);
490 let started = Instant::now();
491 let mut last_progress = started;
492
493 for item in self.locations.iter(&rtxn).map_err(map_heed)? {
494 let (hash, encoded) = item.map_err(map_heed)?;
495 let hash: Hash = hash
496 .try_into()
497 .map_err(|_| StoreError::Other("invalid pool catalog hash key".into()))?;
498 let location = LocationRecord::decode(encoded)?;
499 let (member, size) = match location {
500 LocationRecord::Stored { member, size } => (member, size),
501 LocationRecord::Pending { .. } => {
502 return Err(StoreError::Other(format!(
503 "terminal Pool audit rejected pending location {}",
504 hashtree_core::to_hex(&hash)
505 )))
506 }
507 LocationRecord::Moving { .. } => {
508 return Err(StoreError::Other(format!(
509 "terminal Pool audit rejected moving location {}",
510 hashtree_core::to_hex(&hash)
511 )))
512 }
513 };
514 if !self.members.contains_key(&member) {
515 return Err(self.member_unavailable_error(member));
516 }
517 if self
518 .by_member
519 .get(&rtxn, &super::member_hash_key(member, hash))
520 .map_err(map_heed)?
521 .is_none()
522 {
523 return Err(StoreError::Other(format!(
524 "terminal Pool audit found no member index for {} on {member}",
525 hashtree_core::to_hex(&hash)
526 )));
527 }
528 stored_locations = stored_locations
529 .checked_add(1)
530 .ok_or_else(|| StoreError::Other("terminal Pool location count overflow".into()))?;
531 stored_bytes = stored_bytes.checked_add(size).ok_or_else(|| {
532 StoreError::Other("terminal Pool declared byte total overflow".into())
533 })?;
534 let member_count = member_counts.entry(member).or_default();
535 *member_count = member_count
536 .checked_add(1)
537 .ok_or_else(|| StoreError::Other("terminal Pool member count overflow".into()))?;
538 let member_byte_total = member_bytes.entry(member).or_default();
539 *member_byte_total = member_byte_total.checked_add(size).ok_or_else(|| {
540 StoreError::Other("terminal Pool member byte total overflow".into())
541 })?;
542 let ownership = expected_member_ownership.get_mut(&member).ok_or_else(|| {
543 StoreError::Other(format!(
544 "terminal Pool catalog references unavailable member {member}"
545 ))
546 })?;
547 ownership.update(hash);
548 ownership.update(size.to_be_bytes());
549 catalog_digest.update(hash);
550 catalog_digest.update((encoded.len() as u64).to_be_bytes());
551 catalog_digest.update(encoded);
552 if verify_payloads {
553 batch.push((hash, member, size));
554 if batch.len() >= TERMINAL_AUDIT_BATCH_ITEMS {
555 self.validate_terminal_payload_batch(&batch, &mut payload_digest)?;
556 batch.clear();
557 }
558 }
559 if last_progress.elapsed() >= Duration::from_secs(10) {
560 eprintln!(
561 "Pool terminal audit: verified {stored_locations} catalogued blobs / {stored_bytes} declared bytes in {} s",
562 started.elapsed().as_secs()
563 );
564 last_progress = Instant::now();
565 }
566 }
567 if verify_payloads && !batch.is_empty() {
568 self.validate_terminal_payload_batch(&batch, &mut payload_digest)?;
569 }
570
571 let indexed = self.by_member.len(&rtxn).map_err(map_heed)?;
572 if indexed != stored_locations {
573 return Err(StoreError::Other(format!(
574 "terminal Pool member index count {indexed} differs from stored location count {stored_locations}"
575 )));
576 }
577 catalog_digest.update(b"\0exact-by-member-index\0");
578 catalog_digest.update(indexed.to_be_bytes());
579 for (prefix, label) in [(b"m".as_slice(), "move"), (b"c".as_slice(), "move-cleanup")] {
580 if self
581 .temperature_state
582 .prefix_iter(&rtxn, prefix)
583 .map_err(map_heed)?
584 .next()
585 .transpose()
586 .map_err(map_heed)?
587 .is_some()
588 {
589 return Err(StoreError::Other(format!(
590 "terminal Pool audit found active {label} ownership"
591 )));
592 }
593 }
594 catalog_digest.update(b"\0no-active-move-ownership\0");
595 rtxn.commit().map_err(map_heed)?;
596
597 let mut physical_digest = Sha256::new();
598 physical_digest.update(b"hashtree-pool-terminal-physical/v1\0");
599 physical_digest.update(self.manifest_identity.sha256);
600 for member in &self.member_ids {
601 let reader = self
602 .members
603 .get(member)
604 .ok_or_else(|| self.member_unavailable_error(*member))?;
605 let expected = member_counts.get(member).copied().unwrap_or(0);
606 let expected_bytes = member_bytes.get(member).copied().unwrap_or(0);
607 let keyset = reader.validate_terminal_member_keyset()?;
608 if keyset.blob_entries != expected
609 || keyset.metadata_entries != expected
610 || keyset.total_bytes != expected_bytes
611 {
612 return Err(StoreError::Other(format!(
613 "terminal Pool member {member} has {} blob and {} metadata records / {} exact metadata bytes, expected exactly {expected} catalog locations / {expected_bytes} catalog bytes",
614 keyset.blob_entries, keyset.metadata_entries, keyset.total_bytes
615 )));
616 }
617 let expected_ownership = expected_member_ownership
618 .remove(member)
619 .expect("every manifest member has an ownership digest")
620 .finalize();
621 if keyset.ownership_sha256.as_slice() != expected_ownership.as_slice() {
622 return Err(StoreError::Other(format!(
623 "terminal Pool member {member} physical hash/size ownership differs from its exact catalog ownership"
624 )));
625 }
626 catalog_digest.update(member.as_bytes());
627 catalog_digest.update(expected.to_be_bytes());
628 catalog_digest.update(expected_bytes.to_be_bytes());
629 catalog_digest.update(keyset.blob_entries.to_be_bytes());
630 catalog_digest.update(keyset.metadata_entries.to_be_bytes());
631 catalog_digest.update(keyset.total_bytes.to_be_bytes());
632 catalog_digest.update(keyset.pinned_count.to_be_bytes());
633 catalog_digest.update(keyset.pinned_bytes.to_be_bytes());
634 catalog_digest.update(keyset.sha256);
635 physical_digest.update(member.as_bytes());
636 physical_digest.update(keyset.ownership_sha256);
637 physical_digest.update(keyset.sha256);
638 }
639
640 let catalog_sha256 = catalog_digest.finalize().into();
641 physical_digest.update(catalog_sha256);
642 physical_digest.update(stored_locations.to_be_bytes());
643 physical_digest.update(stored_bytes.to_be_bytes());
644 Ok(TerminalStateAudit {
645 physical: PoolPhysicalAudit {
646 stored_locations,
647 stored_bytes,
648 catalog_sha256,
649 physical_sha256: physical_digest.finalize().into(),
650 manifest_sha256: self.manifest_identity.sha256,
651 },
652 payload_sha256: verify_payloads.then(|| payload_digest.finalize().into()),
653 })
654 }
655
656 fn validate_terminal_payload_batch(
657 &self,
658 batch: &[(Hash, PoolMemberId, u64)],
659 payload_digest: &mut Sha256,
660 ) -> Result<(), StoreError> {
661 let mut by_member = HashMap::<PoolMemberId, Vec<(Hash, u64)>>::new();
662 for (hash, member, size) in batch {
663 by_member.entry(*member).or_default().push((*hash, *size));
664 }
665 for (member, expected) in by_member {
666 let reader = self
667 .members
668 .get(&member)
669 .ok_or_else(|| self.member_unavailable_error(member))?;
670 let mut offset = 0usize;
671 while offset < expected.len() {
672 let hashes = expected[offset..]
673 .iter()
674 .map(|(hash, _)| *hash)
675 .collect::<Vec<_>>();
676 let found = reader.read_hashes_bounded(&hashes, TERMINAL_AUDIT_BATCH_BYTES)?;
677 if found.is_empty() {
678 return Err(StoreError::Other(format!(
679 "terminal Pool member {member} omitted a requested payload"
680 )));
681 }
682 for ((found_hash, data), (expected_hash, expected_size)) in
683 found.iter().zip(&expected[offset..])
684 {
685 if found_hash != expected_hash {
686 return Err(StoreError::Other(format!(
687 "terminal Pool member {member} returned payloads out of order"
688 )));
689 }
690 if data.len() as u64 != *expected_size || sha256(data) != *expected_hash {
691 return Err(StoreError::Other(format!(
692 "terminal Pool member {member} returned corrupt or size-mismatched bytes for {}",
693 hashtree_core::to_hex(expected_hash)
694 )));
695 }
696 }
697 offset = offset.checked_add(found.len()).ok_or_else(|| {
698 StoreError::Other("terminal Pool batch offset overflow".into())
699 })?;
700 }
701 }
702 for (hash, member, size) in batch {
703 payload_digest.update(hash);
704 payload_digest.update(member.as_bytes());
705 payload_digest.update(size.to_be_bytes());
706 }
707 Ok(())
708 }
709
710 pub fn member_existing_hashes_in_sorted_candidates(
713 &self,
714 member: PoolMemberId,
715 hashes: &[Hash],
716 ) -> Result<Vec<bool>, StoreError> {
717 self.members
718 .get(&member)
719 .ok_or_else(|| self.member_unavailable_error(member))?
720 .existing_hashes_in_sorted_candidates(hashes)
721 }
722
723 pub fn read_member_hashes_bounded_unverified(
730 &self,
731 member: PoolMemberId,
732 hashes: &[Hash],
733 byte_limit: u64,
734 ) -> Result<Vec<(Hash, Vec<u8>)>, StoreError> {
735 self.members
736 .get(&member)
737 .ok_or_else(|| self.member_unavailable_error(member))?
738 .read_hashes_bounded(hashes, byte_limit)
739 }
740
741 pub fn blob_member_candidates(
745 &self,
746 hashes: &[Hash],
747 ) -> Result<Vec<Vec<PoolMemberId>>, StoreError> {
748 let rtxn = self.env.read_txn().map_err(map_heed)?;
749 hashes
750 .iter()
751 .map(|hash| {
752 let location = self
753 .locations
754 .get(&rtxn, hash)
755 .map_err(map_heed)?
756 .map(LocationRecord::decode)
757 .transpose()?;
758 Ok(match location {
759 None => Vec::new(),
760 Some(LocationRecord::Pending { member, .. })
761 | Some(LocationRecord::Stored { member, .. }) => vec![member],
762 Some(LocationRecord::Moving { source, target, .. }) => {
763 vec![target, source]
764 }
765 })
766 })
767 .collect()
768 }
769
770 pub fn blob_catalog_locations(
776 &self,
777 hashes: &[Hash],
778 ) -> Result<Vec<PoolCatalogLocation>, StoreError> {
779 let rtxn = self.env.read_txn().map_err(map_heed)?;
780 hashes
781 .iter()
782 .map(|hash| {
783 let record = self
784 .locations
785 .get(&rtxn, hash)
786 .map_err(map_heed)?
787 .map(LocationRecord::decode)
788 .transpose()?;
789 Ok(PoolCatalogLocation::from_record(record))
790 })
791 .collect()
792 }
793
794 pub fn blob_size_sync(&self, hash: &Hash) -> Result<Option<u64>, StoreError> {
795 Ok(self.read_location(hash)?.map(LocationRecord::size))
796 }
797
798 pub fn get_sync(&self, hash: &Hash) -> Result<Option<Vec<u8>>, StoreError> {
801 let Some(location) = self.read_location(hash)? else {
802 return Ok(None);
803 };
804 let ids = match location {
805 LocationRecord::Pending { member, .. } | LocationRecord::Stored { member, .. } => {
806 ([member, member], 1)
807 }
808 LocationRecord::Moving { source, target, .. } => ([target, source], 2),
809 };
810
811 let mut first_error = None;
812 for id in ids.0.into_iter().take(ids.1) {
813 let Some(member) = self.members.get(&id) else {
814 first_error.get_or_insert_with(|| self.member_unavailable_error(id));
815 continue;
816 };
817 match member.get_sync(hash) {
818 Ok(Some(data)) if sha256(&data) == *hash => return Ok(Some(data)),
819 Ok(Some(_)) => {
820 first_error.get_or_insert_with(|| {
821 StoreError::Other(format!("pool member {id} returned corrupt bytes"))
822 });
823 }
824 Ok(None) => {}
825 Err(error) => {
826 first_error.get_or_insert(error);
827 }
828 }
829 }
830 match first_error {
831 Some(error) => Err(error),
832 None => Ok(None),
833 }
834 }
835
836 pub fn read_hashes_bounded(
840 &self,
841 hashes: &[Hash],
842 byte_limit: u64,
843 ) -> Result<Vec<PoolReadBatchItem>, StoreError> {
844 if hashes.is_empty() || byte_limit == 0 {
845 return Ok(Vec::new());
846 }
847
848 let rtxn = self.env.read_txn().map_err(map_heed)?;
849 let mut items = Vec::with_capacity(hashes.len());
850 let mut selected_bytes = 0u64;
851 for hash in hashes {
852 let location = self
853 .locations
854 .get(&rtxn, hash)
855 .map_err(map_heed)?
856 .map(LocationRecord::decode)
857 .transpose()?;
858 let declared_size = location.as_ref().map(|location| (*location).size());
859 let expected_size = declared_size.unwrap_or(0);
860 if !items.is_empty() && selected_bytes.saturating_add(expected_size) > byte_limit {
861 break;
862 }
863 let member_candidates = match location {
864 None => Vec::new(),
865 Some(LocationRecord::Pending { member, .. })
866 | Some(LocationRecord::Stored { member, .. }) => vec![member],
867 Some(LocationRecord::Moving { source, target, .. }) => vec![target, source],
868 };
869 items.push(PoolReadBatchItem {
870 hash: *hash,
871 member_candidates,
872 declared_size,
873 data: None,
874 error: None,
875 });
876 selected_bytes = selected_bytes.saturating_add(expected_size);
877 }
878 drop(rtxn);
879
880 for candidate_index in 0..2 {
881 let mut reads_by_member = HashMap::<PoolMemberId, Vec<(usize, Hash)>>::new();
882 for (item_index, item) in items.iter().enumerate() {
883 if item.data.is_none() {
884 if let Some(member) = item.member_candidates.get(candidate_index) {
885 reads_by_member
886 .entry(*member)
887 .or_default()
888 .push((item_index, item.hash));
889 }
890 }
891 }
892 for (member_id, requested) in reads_by_member {
893 let Some(member) = self.members.get(&member_id) else {
894 let error = self.member_unavailable_error(member_id).to_string();
895 for (item_index, _) in requested {
896 items[item_index].error.get_or_insert_with(|| error.clone());
897 }
898 continue;
899 };
900 let hashes = requested.iter().map(|(_, hash)| *hash).collect::<Vec<_>>();
901 let present = member.existing_hashes_in_sorted_candidates(&hashes)?;
902 let present_requests = requested
903 .into_iter()
904 .zip(present)
905 .filter_map(|(request, is_present)| is_present.then_some(request))
906 .collect::<Vec<_>>();
907 if present_requests.is_empty() {
908 continue;
909 }
910 let present_hashes = present_requests
911 .iter()
912 .map(|(_, hash)| *hash)
913 .collect::<Vec<_>>();
914 let bodies = member.read_hashes_bounded(&present_hashes, u64::MAX)?;
915 if bodies.len() != present_requests.len() {
916 return Err(StoreError::Other(format!(
917 "pool member {member_id} bounded reader returned {} of {} requested blobs",
918 bodies.len(),
919 present_requests.len()
920 )));
921 }
922 for ((item_index, expected_hash), (actual_hash, data)) in
923 present_requests.into_iter().zip(bodies)
924 {
925 if actual_hash != expected_hash {
926 return Err(StoreError::Other(format!(
927 "pool member {member_id} returned hashes out of order"
928 )));
929 }
930 if sha256(&data) == expected_hash {
931 items[item_index].data = Some(data);
932 } else {
933 items[item_index].error.get_or_insert_with(|| {
934 format!("pool member {member_id} returned corrupt bytes")
935 });
936 }
937 }
938 }
939 }
940 for item in &mut items {
941 if item.member_candidates.is_empty() {
942 item.error
943 .get_or_insert_with(|| "Pool catalog entry is missing".into());
944 } else if item.data.is_none() {
945 item.error
946 .get_or_insert_with(|| "Pool payload is missing from candidate members".into());
947 } else {
948 item.error = None;
949 }
950 }
951 Ok(items)
952 }
953
954 fn read_location(&self, hash: &Hash) -> Result<Option<LocationRecord>, StoreError> {
955 let rtxn = self.env.read_txn().map_err(map_heed)?;
956 self.locations
957 .get(&rtxn, hash)
958 .map_err(map_heed)?
959 .map(LocationRecord::decode)
960 .transpose()
961 }
962
963 fn member_unavailable_error(&self, member: PoolMemberId) -> StoreError {
964 match self.member_errors.get(&member) {
965 Some(error) => StoreError::Other(format!(
966 "pool member {member} is unavailable to reader: {error}"
967 )),
968 None if self.member_ids.contains(&member) => {
969 StoreError::Other(format!("pool member {member} is unavailable to reader"))
970 }
971 None => StoreError::Other(format!("unknown pool member {member}")),
972 }
973 }
974}
975
976fn read_manifest(
977 database: &Database<Bytes, Bytes>,
978 txn: &heed::RoTxn<'_>,
979) -> Result<(PoolManifest, Hash), StoreError> {
980 let bytes = database
981 .get(txn, MANIFEST_KEY)
982 .map_err(map_heed)?
983 .ok_or_else(|| StoreError::Other("pool manifest is missing".into()))?;
984 Ok((decode_manifest(bytes)?, sha256(bytes)))
985}
986
987#[cfg(test)]
988mod tests {
989 use super::*;
990 use crate::{PoolMemberConfig, PoolStore};
991 use std::fs;
992
993 #[test]
994 fn sequential_reader_enables_pool_read_ahead_and_bounded_member_reads() -> Result<(), StoreError>
995 {
996 let temp = tempfile::tempdir().map_err(StoreError::Io)?;
997 let catalog = temp.path().join("catalog");
998 let member_path = temp.path().join("member");
999 let mut config = PoolStoreConfig::default();
1000 config.temperature.enabled = false;
1001 let pool = PoolStore::open(&catalog, config.clone())?;
1002 let member = pool.add_member(PoolMemberConfig::new(member_path, 1024 * 1024))?;
1003 drop(pool);
1004
1005 let sparse = PoolStoreReader::open(&catalog, config.clone())?;
1006 let sparse_catalog_flags = sparse
1007 .env
1008 .flags()
1009 .map_err(map_heed)?
1010 .ok_or_else(|| StoreError::Other("sparse Pool catalog has no flags".into()))?;
1011 assert!(sparse_catalog_flags.contains(EnvFlags::NO_READ_AHEAD));
1012 let sparse_member_flags = sparse
1013 .members
1014 .get(&member)
1015 .expect("manifest member is open")
1016 .store
1017 .env
1018 .flags()
1019 .map_err(map_heed)?
1020 .ok_or_else(|| StoreError::Other("sparse Pool member has no flags".into()))?;
1021 assert!(sparse_member_flags.contains(EnvFlags::NO_READ_AHEAD));
1022 drop(sparse);
1023
1024 let sequential =
1025 PoolStoreReader::open_sequential_with_read_concurrency(&catalog, config.clone(), 16)?;
1026 let sequential_catalog_flags = sequential
1027 .env
1028 .flags()
1029 .map_err(map_heed)?
1030 .ok_or_else(|| StoreError::Other("sequential Pool catalog has no flags".into()))?;
1031 assert!(!sequential_catalog_flags.contains(EnvFlags::NO_READ_AHEAD));
1032 let sequential_member = sequential
1033 .members
1034 .get(&member)
1035 .expect("manifest member is open");
1036 let sequential_member_flags = sequential_member
1037 .store
1038 .env
1039 .flags()
1040 .map_err(map_heed)?
1041 .ok_or_else(|| StoreError::Other("sequential Pool member has no flags".into()))?;
1042 assert!(!sequential_member_flags.contains(EnvFlags::NO_READ_AHEAD));
1043 assert_eq!(sequential_member.external_read_concurrency, 16);
1044 drop(sequential);
1045
1046 let error =
1047 match PoolStoreReader::open_sequential_with_read_concurrency(&catalog, config, 65) {
1048 Ok(_) => {
1049 return Err(StoreError::Other(
1050 "reader accepted concurrency above the member limit".into(),
1051 ))
1052 }
1053 Err(error) => error,
1054 };
1055 assert!(error
1056 .to_string()
1057 .contains("outside its configured 1..=64 limit"));
1058 Ok(())
1059 }
1060
1061 #[test]
1062 fn reader_preserves_pending_and_moving_catalog_bytes() -> Result<(), StoreError> {
1063 let temp = tempfile::tempdir().map_err(StoreError::Io)?;
1064 let catalog = temp.path().join("catalog");
1065 let first_path = temp.path().join("first");
1066 let second_path = temp.path().join("second");
1067 let mut writable_config = PoolStoreConfig::default();
1068 writable_config.temperature.enabled = false;
1069 let pool = PoolStore::open(&catalog, writable_config)?;
1070 let first = pool.add_member(PoolMemberConfig::new(first_path, 1024 * 1024))?;
1071 let second = pool.add_member(PoolMemberConfig::new(second_path, 1024 * 1024))?;
1072
1073 let pending_data = b"pending validation payload";
1074 let pending_hash = sha256(pending_data);
1075 pool.get_member(first)?
1076 .put_sync(pending_hash, pending_data)?;
1077 let moving_data = b"moving validation payload";
1078 let moving_hash = sha256(moving_data);
1079 pool.get_member(first)?.put_sync(moving_hash, moving_data)?;
1080 pool.get_member(second)?
1081 .put_sync(moving_hash, moving_data)?;
1082 let mut wtxn = pool.env.write_txn().map_err(map_heed)?;
1083 pool.set_location_txn(
1084 &mut wtxn,
1085 pending_hash,
1086 Some(LocationRecord::Pending {
1087 member: first,
1088 size: pending_data.len() as u64,
1089 }),
1090 )?;
1091 pool.set_location_txn(
1092 &mut wtxn,
1093 moving_hash,
1094 Some(LocationRecord::Moving {
1095 source: first,
1096 target: second,
1097 size: moving_data.len() as u64,
1098 }),
1099 )?;
1100 wtxn.commit().map_err(map_heed)?;
1101 pool.force_sync()?;
1102 drop(pool);
1103
1104 let catalog_path = catalog.join("data.mdb");
1105 let before = fs::read(&catalog_path).map_err(StoreError::Io)?;
1106 let mut reader_config = PoolStoreConfig::default();
1107 reader_config.temperature.enabled = false;
1108 let reader = PoolStoreReader::open(&catalog, reader_config)?;
1109 assert_eq!(
1110 reader.blob_catalog_locations(&[pending_hash, moving_hash])?,
1111 vec![
1112 PoolCatalogLocation::Pending {
1113 member: first,
1114 size: pending_data.len() as u64,
1115 },
1116 PoolCatalogLocation::Moving {
1117 source: first,
1118 target: second,
1119 size: moving_data.len() as u64,
1120 },
1121 ]
1122 );
1123 assert_eq!(
1124 reader.get_sync(&pending_hash)?.as_deref(),
1125 Some(pending_data.as_slice())
1126 );
1127 assert_eq!(
1128 reader.get_sync(&moving_hash)?.as_deref(),
1129 Some(moving_data.as_slice())
1130 );
1131 let batch = reader.read_hashes_bounded(
1132 &[pending_hash, moving_hash],
1133 pending_data.len() as u64 + moving_data.len() as u64,
1134 )?;
1135 assert_eq!(batch.len(), 2);
1136 assert_eq!(batch[0].hash, pending_hash);
1137 assert_eq!(batch[0].data.as_deref(), Some(pending_data.as_slice()));
1138 assert!(batch[0].error.is_none());
1139 assert_eq!(batch[1].hash, moving_hash);
1140 assert_eq!(batch[1].data.as_deref(), Some(moving_data.as_slice()));
1141 assert!(batch[1].error.is_none());
1142 let terminal_error = reader
1143 .validate_terminal_catalog_and_payloads()
1144 .expect_err("terminal audit must reject Pending and Moving");
1145 assert!(terminal_error.to_string().contains("pending location"));
1146 drop(reader);
1147 let after = fs::read(&catalog_path).map_err(StoreError::Io)?;
1148 assert_eq!(after, before);
1149
1150 let reopened = PoolStore::open(&catalog, {
1151 let mut config = PoolStoreConfig::default();
1152 config.temperature.enabled = false;
1153 config
1154 })?;
1155 assert!(matches!(
1156 reopened.read_location(&pending_hash)?,
1157 Some(LocationRecord::Pending { .. })
1158 ));
1159 assert!(matches!(
1160 reopened.read_location(&moving_hash)?,
1161 Some(LocationRecord::Moving { .. })
1162 ));
1163 Ok(())
1164 }
1165
1166 #[test]
1167 fn reader_can_probe_one_exact_member_without_catalog_fallback() -> Result<(), StoreError> {
1168 let temp = tempfile::tempdir().map_err(StoreError::Io)?;
1169 let catalog = temp.path().join("catalog");
1170 let first_path = temp.path().join("first");
1171 let second_path = temp.path().join("second");
1172 let mut config = PoolStoreConfig::default();
1173 config.temperature.enabled = false;
1174 let pool = PoolStore::open(&catalog, config.clone())?;
1175 let first = pool.add_member(PoolMemberConfig::new(first_path, 1024 * 1024))?;
1176 let second = pool.add_member(PoolMemberConfig::new(second_path, 1024 * 1024))?;
1177
1178 let data = b"exact member validation payload";
1179 let hash = sha256(data);
1180 pool.get_member(first)?.put_sync(hash, data)?;
1181 let mut wtxn = pool.env.write_txn().map_err(map_heed)?;
1182 pool.set_location_txn(
1183 &mut wtxn,
1184 hash,
1185 Some(LocationRecord::Stored {
1186 member: first,
1187 size: data.len() as u64,
1188 }),
1189 )?;
1190 wtxn.commit().map_err(map_heed)?;
1191 pool.force_sync()?;
1192 drop(pool);
1193
1194 let reader = PoolStoreReader::open(&catalog, config)?;
1195 assert_eq!(
1196 reader.blob_catalog_locations(&[hash])?,
1197 vec![PoolCatalogLocation::Stored {
1198 member: first,
1199 size: data.len() as u64,
1200 }]
1201 );
1202 let mut expected_members = vec![first, second];
1203 expected_members.sort_unstable();
1204 assert_eq!(reader.member_ids(), expected_members);
1205 assert_eq!(
1206 reader.member_existing_hashes_in_sorted_candidates(first, &[hash])?,
1207 vec![true]
1208 );
1209 assert_eq!(
1210 reader.member_existing_hashes_in_sorted_candidates(second, &[hash])?,
1211 vec![false]
1212 );
1213 assert_eq!(
1214 reader.read_member_hashes_bounded_unverified(first, &[hash], data.len() as u64)?,
1215 vec![(hash, data.to_vec())]
1216 );
1217 let audit = reader.validate_terminal_catalog_and_payloads()?;
1218 assert_eq!(audit.stored_locations, 1);
1219 assert_eq!(audit.stored_bytes, data.len() as u64);
1220 assert_eq!(audit.manifest_sha256, reader.manifest_identity().sha256);
1221 let physical = reader.validate_terminal_catalog_and_physical_state()?;
1222 assert_eq!(physical.stored_locations, 1);
1223 assert_eq!(physical.stored_bytes, data.len() as u64);
1224 assert_eq!(physical.catalog_sha256, audit.catalog_sha256);
1225 assert_eq!(physical.manifest_sha256, audit.manifest_sha256);
1226 Ok(())
1227 }
1228
1229 #[test]
1230 fn physical_audit_rejects_equal_count_catalog_member_ownership_mismatch(
1231 ) -> Result<(), StoreError> {
1232 let temp = tempfile::tempdir().map_err(StoreError::Io)?;
1233 let catalog = temp.path().join("catalog");
1234 let member_path = temp.path().join("member");
1235 let mut config = PoolStoreConfig::default();
1236 config.temperature.enabled = false;
1237 let pool = PoolStore::open(&catalog, config.clone())?;
1238 let member = pool.add_member(PoolMemberConfig::new(member_path, 16 * 1024 * 1024))?;
1239 let physical_body = b"physical ownership A";
1240 let catalog_body = b"physical ownership B";
1241 assert_eq!(physical_body.len(), catalog_body.len());
1242 let physical_hash = sha256(physical_body);
1243 let catalog_hash = sha256(catalog_body);
1244 pool.put_sync(physical_hash, physical_body)?;
1245 let mut wtxn = pool.env.write_txn().map_err(map_heed)?;
1246 pool.set_location_txn(&mut wtxn, physical_hash, None)?;
1247 pool.set_location_txn(
1248 &mut wtxn,
1249 catalog_hash,
1250 Some(LocationRecord::Stored {
1251 member,
1252 size: catalog_body.len() as u64,
1253 }),
1254 )?;
1255 wtxn.commit().map_err(map_heed)?;
1256 pool.force_sync()?;
1257 drop(pool);
1258
1259 let reader = PoolStoreReader::open(&catalog, config)?;
1260 let error = reader
1261 .validate_terminal_catalog_and_physical_state()
1262 .expect_err("equal-count ownership mismatch must fail physical audit");
1263 assert!(error.to_string().contains("physical hash/size ownership"));
1264 Ok(())
1265 }
1266
1267 #[test]
1268 fn terminal_audit_rejects_missing_bodies_and_addressable_member_orphans(
1269 ) -> Result<(), StoreError> {
1270 let temp = tempfile::tempdir().map_err(StoreError::Io)?;
1271 let catalog = temp.path().join("catalog");
1272 let member_path = temp.path().join("member");
1273 let mut config = PoolStoreConfig::default();
1274 config.temperature.enabled = false;
1275 let pool = PoolStore::open(&catalog, config.clone())?;
1276 let member = pool.add_member(PoolMemberConfig::new(member_path, 16 * 1024 * 1024))?;
1277 let stored_data = b"terminal audit catalogued body";
1278 let stored_hash = sha256(stored_data);
1279 pool.put_sync(stored_hash, stored_data)?;
1280 pool.get_member(member)?.delete_sync(&stored_hash)?;
1281 pool.force_sync()?;
1282 drop(pool);
1283
1284 let reader = PoolStoreReader::open(&catalog, config.clone())?;
1285 let missing = reader
1286 .validate_terminal_catalog_and_payloads()
1287 .expect_err("missing catalogued body must fail terminal audit");
1288 assert!(
1289 missing.to_string().contains("lost blob")
1290 || missing.to_string().contains("omitted")
1291 || missing.to_string().contains("missing")
1292 );
1293 drop(reader);
1294
1295 let pool = PoolStore::open(&catalog, config.clone())?;
1296 pool.put_sync(stored_hash, stored_data)?;
1297 let orphan_data = b"terminal audit addressable member orphan";
1298 pool.get_member(member)?
1299 .put_sync(sha256(orphan_data), orphan_data)?;
1300 pool.force_sync()?;
1301 drop(pool);
1302
1303 let reader = PoolStoreReader::open(&catalog, config)?;
1304 let orphan = reader
1305 .validate_terminal_catalog_and_payloads()
1306 .expect_err("addressable member orphan must fail terminal audit");
1307 assert!(orphan.to_string().contains("expected exactly"));
1308 Ok(())
1309 }
1310
1311 #[test]
1312 fn terminal_audit_rejects_equal_count_member_keyset_mismatch() -> Result<(), StoreError> {
1313 let temp = tempfile::tempdir().map_err(StoreError::Io)?;
1314 let catalog = temp.path().join("catalog");
1315 let member_path = temp.path().join("member");
1316 let mut config = PoolStoreConfig::default();
1317 config.temperature.enabled = false;
1318 let pool = PoolStore::open(&catalog, config.clone())?;
1319 let member = pool.add_member(PoolMemberConfig::new(member_path, 16 * 1024 * 1024))?;
1320 let first_data = b"terminal member keyset first body";
1321 let second_data = b"terminal member keyset second body";
1322 let first_hash = sha256(first_data);
1323 let second_hash = sha256(second_data);
1324 let metadata_orphan_hash = sha256(b"metadata-only replacement key");
1325 pool.put_sync(first_hash, first_data)?;
1326 pool.put_sync(second_hash, second_data)?;
1327
1328 let member_store = pool.get_member(member)?;
1329 let mut wtxn = member_store.env.write_txn().map_err(map_heed)?;
1330 let second_metadata = member_store
1331 .metadata
1332 .get(&wtxn, &second_hash)
1333 .map_err(map_heed)?
1334 .ok_or_else(|| StoreError::Other("test metadata row is missing".into()))?
1335 .to_vec();
1336 member_store
1337 .metadata
1338 .delete(&mut wtxn, &second_hash)
1339 .map_err(map_heed)?;
1340 member_store
1341 .metadata
1342 .put(&mut wtxn, &metadata_orphan_hash, &second_metadata)
1343 .map_err(map_heed)?;
1344 wtxn.commit().map_err(map_heed)?;
1345 pool.force_sync()?;
1346 drop(member_store);
1347 drop(pool);
1348
1349 let reader = PoolStoreReader::open(&catalog, config)?;
1350 let error = reader
1351 .validate_terminal_catalog_and_payloads()
1352 .expect_err("equal blob/metadata counts with different keys must fail terminal audit");
1353 assert!(error.to_string().contains("blobs/metadata key sets differ"));
1354 Ok(())
1355 }
1356
1357 #[test]
1358 fn reader_preserves_manifest_identity_when_a_member_is_unavailable() -> Result<(), StoreError> {
1359 let temp = tempfile::tempdir().map_err(StoreError::Io)?;
1360 let catalog = temp.path().join("catalog");
1361 let member_path = temp.path().join("member");
1362 let unavailable_path = temp.path().join("member-unavailable");
1363 let mut config = PoolStoreConfig::default();
1364 config.temperature.enabled = false;
1365 let pool = PoolStore::open(&catalog, config.clone())?;
1366 let member = pool.add_member(PoolMemberConfig::new(member_path.clone(), 1024 * 1024))?;
1367 drop(pool);
1368 fs::rename(&member_path, &unavailable_path).map_err(StoreError::Io)?;
1369
1370 let default_error = match PoolStoreReader::open(&catalog, config.clone()) {
1371 Ok(_) => {
1372 return Err(StoreError::Other(
1373 "default reader accepted unavailable member".into(),
1374 ))
1375 }
1376 Err(error) => error,
1377 };
1378 assert!(default_error.to_string().contains("member"));
1379
1380 let reader = PoolStoreReader::open_with_unavailable_members_for_audit(&catalog, config)?;
1381 assert_eq!(reader.member_ids(), vec![member]);
1382 let error = reader
1383 .member_existing_hashes_in_sorted_candidates(member, &[sha256(b"missing")])
1384 .expect_err("unavailable member must remain an explicit read error");
1385 assert!(error.to_string().contains("unavailable to reader"));
1386 Ok(())
1387 }
1388}