1use crate::{managed_env::ManagedEnv, map_heed_error};
2use hashtree_core::store::StoreError;
3use hashtree_core::types::Hash;
4use heed::types::Bytes;
5use heed::{Database, EnvFlags, EnvOpenOptions};
6use sha2::{Digest, Sha256};
7use std::path::{Path, PathBuf};
8
9const AUDIT_MAP_SIZE: usize = 32 * 1024 * 1024 * 1024;
14const AUDIT_DATABASE_COUNT: u32 = 5;
18const BINDING_KEY: &[u8] = b"authority-binding-v1";
19const TARGET_CURSOR_KEY: &[u8] = b"target-catalog-cursor-v1";
20const TARGET_FENCE_BINDING_KEY: &[u8] = b"target-fence-binding-v1";
21const TARGET_IMPORT_STAGING_AUTHORITY_KEY: &[u8] = b"target-import-staging-authority-v1";
22const TARGET_IMPORT_AUTHENTICATED_KEY: &[u8] = b"target-import-authenticated-v1";
23const TARGET_IMPORT_PROMOTION_CURSOR_KEY: &[u8] = b"target-import-promotion-cursor-v1";
24const TARGET_IMPORT_PROMOTED_KEY: &[u8] = b"target-import-promoted-v1";
25
26#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct PoolMigrationAuditSummary {
29 pub entries: u64,
30 pub bytes: u64,
31 pub content_sha256: Hash,
32}
33
34pub struct PoolMigrationAuditStore {
42 path: PathBuf,
43 env: ManagedEnv,
44 source_reconciled: Database<Bytes, Bytes>,
45 target_verified: Database<Bytes, Bytes>,
46 target_import_staging: Database<Bytes, Bytes>,
47 metadata: Database<Bytes, Bytes>,
48 binding: Hash,
49 writable: bool,
50}
51
52impl PoolMigrationAuditStore {
53 pub fn open(path: &Path, binding: Hash) -> Result<Self, StoreError> {
54 std::fs::create_dir_all(path).map_err(StoreError::Io)?;
55 let mut options = EnvOpenOptions::new();
56 options
57 .map_size(AUDIT_MAP_SIZE)
58 .max_dbs(AUDIT_DATABASE_COUNT);
59 let env = unsafe { ManagedEnv::open(&options, path) }.map_err(map_heed_error)?;
60 let mut wtxn = env.write_txn().map_err(map_heed_error)?;
61 let source_reconciled = env
62 .create_database(&mut wtxn, Some("source_reconciled"))
63 .map_err(map_heed_error)?;
64 let target_verified = env
65 .create_database(&mut wtxn, Some("target_verified"))
66 .map_err(map_heed_error)?;
67 let target_import_staging = env
68 .create_database(&mut wtxn, Some("target_import_staging"))
69 .map_err(map_heed_error)?;
70 let metadata = env
71 .create_database(&mut wtxn, Some("metadata"))
72 .map_err(map_heed_error)?;
73 match metadata.get(&wtxn, BINDING_KEY).map_err(map_heed_error)? {
74 Some(actual) if actual != binding => {
75 return Err(StoreError::Other(
76 "online migration audit store belongs to a different authority binding".into(),
77 ))
78 }
79 Some(_) => {}
80 None => metadata
81 .put(&mut wtxn, BINDING_KEY, binding.as_slice())
82 .map_err(map_heed_error)?,
83 }
84 wtxn.commit().map_err(map_heed_error)?;
85 env.force_sync().map_err(map_heed_error)?;
86 Ok(Self {
87 path: path.to_path_buf(),
88 env,
89 source_reconciled,
90 target_verified,
91 target_import_staging,
92 metadata,
93 binding,
94 writable: true,
95 })
96 }
97
98 pub fn open_read_only(path: &Path, binding: Hash) -> Result<Self, StoreError> {
99 let mut options = EnvOpenOptions::new();
100 options.max_dbs(AUDIT_DATABASE_COUNT);
101 unsafe {
102 options.flags(EnvFlags::READ_ONLY | EnvFlags::NO_READ_AHEAD);
103 }
104 let env = unsafe { ManagedEnv::open(&options, path) }.map_err(map_heed_error)?;
105 let rtxn = env.read_txn().map_err(map_heed_error)?;
106 let source_reconciled = env
107 .open_database(&rtxn, Some("source_reconciled"))
108 .map_err(map_heed_error)?
109 .ok_or_else(|| {
110 StoreError::Other(
111 "online migration source reconciliation database is missing".into(),
112 )
113 })?;
114 let target_verified = env
115 .open_database(&rtxn, Some("target_verified"))
116 .map_err(map_heed_error)?
117 .ok_or_else(|| {
118 StoreError::Other("online migration target audit database is missing".into())
119 })?;
120 let target_import_staging = env
121 .open_database(&rtxn, Some("target_import_staging"))
122 .map_err(map_heed_error)?
123 .ok_or_else(|| {
124 StoreError::Other(
125 "online migration target import staging database is missing".into(),
126 )
127 })?;
128 let metadata = env
129 .open_database(&rtxn, Some("metadata"))
130 .map_err(map_heed_error)?
131 .ok_or_else(|| {
132 StoreError::Other("online migration audit metadata database is missing".into())
133 })?;
134 let actual = metadata
135 .get(&rtxn, BINDING_KEY)
136 .map_err(map_heed_error)?
137 .ok_or_else(|| {
138 StoreError::Other("online migration audit lost its authority binding".into())
139 })?;
140 if actual != binding {
141 return Err(StoreError::Other(
142 "online migration audit store belongs to a different authority binding".into(),
143 ));
144 }
145 rtxn.commit().map_err(map_heed_error)?;
146 Ok(Self {
147 path: path.to_path_buf(),
148 env,
149 source_reconciled,
150 target_verified,
151 target_import_staging,
152 metadata,
153 binding,
154 writable: false,
155 })
156 }
157
158 pub fn path(&self) -> &Path {
159 &self.path
160 }
161
162 pub fn binding(&self) -> Hash {
163 self.binding
164 }
165
166 pub fn contains_source_reconciled_exact_sorted(
170 &self,
171 candidates: &[(Hash, u64)],
172 ) -> Result<Vec<bool>, StoreError> {
173 self.contains_exact_sorted_in(self.source_reconciled, candidates, "source reconciliation")
174 }
175
176 pub fn contains_target_exact_sorted(
177 &self,
178 candidates: &[(Hash, u64)],
179 ) -> Result<Vec<bool>, StoreError> {
180 self.contains_exact_sorted_in(self.target_verified, candidates, "target")
181 }
182
183 pub fn record_reconciled_source(&self, entries: &[(Hash, u64)]) -> Result<(), StoreError> {
188 self.require_writable()?;
189 require_sorted_candidates(entries)?;
190 if entries.is_empty() {
191 return Ok(());
192 }
193 let mut wtxn = self.env.write_txn().map_err(map_heed_error)?;
194 self.put_verified_txn(
195 &mut wtxn,
196 self.source_reconciled,
197 entries,
198 "source reconciliation",
199 )?;
200 wtxn.commit().map_err(map_heed_error)?;
201 self.env.force_sync().map_err(map_heed_error)
202 }
203
204 pub fn begin_target_fenced_epoch(&self, fence_binding: Hash) -> Result<(), StoreError> {
210 self.require_writable()?;
211 let mut wtxn = self.env.write_txn().map_err(map_heed_error)?;
212 match self
213 .metadata
214 .get(&wtxn, TARGET_FENCE_BINDING_KEY)
215 .map_err(map_heed_error)?
216 {
217 Some(actual) if actual == fence_binding => {}
218 Some(_) => {
219 return Err(StoreError::Other(
220 "online migration target fence authority changed; use a new rollout".into(),
221 ))
222 }
223 None => {
224 self.metadata
225 .delete(&mut wtxn, TARGET_CURSOR_KEY)
226 .map_err(map_heed_error)?;
227 self.metadata
228 .put(
229 &mut wtxn,
230 TARGET_FENCE_BINDING_KEY,
231 fence_binding.as_slice(),
232 )
233 .map_err(map_heed_error)?;
234 }
235 }
236 wtxn.commit().map_err(map_heed_error)?;
237 self.env.force_sync().map_err(map_heed_error)
238 }
239
240 pub fn target_fence_binding(&self) -> Result<Option<Hash>, StoreError> {
241 let rtxn = self.env.read_txn().map_err(map_heed_error)?;
242 self.metadata
243 .get(&rtxn, TARGET_FENCE_BINDING_KEY)
244 .map_err(map_heed_error)?
245 .map(|bytes| {
246 bytes.try_into().map_err(|_| {
247 StoreError::Other(
248 "online migration audit target fence binding has an invalid length".into(),
249 )
250 })
251 })
252 .transpose()
253 }
254
255 pub fn record_verified_target_page(
259 &self,
260 entries: &[(Hash, u64)],
261 cursor: Hash,
262 ) -> Result<(), StoreError> {
263 self.require_writable()?;
264 require_sorted_candidates(entries)?;
265 let mut wtxn = self.env.write_txn().map_err(map_heed_error)?;
266 self.put_verified_txn(&mut wtxn, self.target_verified, entries, "target")?;
267 self.metadata
268 .put(&mut wtxn, TARGET_CURSOR_KEY, cursor.as_slice())
269 .map_err(map_heed_error)?;
270 wtxn.commit().map_err(map_heed_error)?;
271 self.env.force_sync().map_err(map_heed_error)
272 }
273
274 pub fn prepare_target_import(&self, authority: Hash) -> Result<bool, StoreError> {
283 self.require_writable()?;
284 let mut wtxn = self.env.write_txn().map_err(map_heed_error)?;
285 if let Some(actual) = self
286 .metadata
287 .get(&wtxn, TARGET_IMPORT_PROMOTED_KEY)
288 .map_err(map_heed_error)?
289 {
290 require_exact_import_authority(actual, authority)?;
291 wtxn.commit().map_err(map_heed_error)?;
292 return Ok(false);
293 }
294 if let Some(actual) = self
295 .metadata
296 .get(&wtxn, TARGET_IMPORT_AUTHENTICATED_KEY)
297 .map_err(map_heed_error)?
298 {
299 require_exact_import_authority(actual, authority)?;
300 wtxn.commit().map_err(map_heed_error)?;
301 return Ok(false);
302 }
303 self.target_import_staging
304 .clear(&mut wtxn)
305 .map_err(map_heed_error)?;
306 self.metadata
307 .delete(&mut wtxn, TARGET_IMPORT_PROMOTION_CURSOR_KEY)
308 .map_err(map_heed_error)?;
309 self.metadata
310 .put(
311 &mut wtxn,
312 TARGET_IMPORT_STAGING_AUTHORITY_KEY,
313 authority.as_slice(),
314 )
315 .map_err(map_heed_error)?;
316 wtxn.commit().map_err(map_heed_error)?;
317 self.env.force_sync().map_err(map_heed_error)?;
318 Ok(true)
319 }
320
321 pub fn stage_target_import_entries(
324 &self,
325 authority: Hash,
326 entries: &[(Hash, u64)],
327 ) -> Result<(), StoreError> {
328 self.require_writable()?;
329 require_sorted_candidates(entries)?;
330 if entries.is_empty() {
331 return Ok(());
332 }
333 let mut wtxn = self.env.write_txn().map_err(map_heed_error)?;
334 self.require_staging_authority(&wtxn, authority)?;
335 if self
336 .metadata
337 .get(&wtxn, TARGET_IMPORT_AUTHENTICATED_KEY)
338 .map_err(map_heed_error)?
339 .is_some()
340 {
341 return Err(StoreError::Other(
342 "authenticated target import staging is immutable".into(),
343 ));
344 }
345 self.put_verified_txn(
346 &mut wtxn,
347 self.target_import_staging,
348 entries,
349 "staged target import",
350 )?;
351 wtxn.commit().map_err(map_heed_error)?;
352 self.env.force_sync().map_err(map_heed_error)
353 }
354
355 pub fn authenticate_target_import(&self, authority: Hash) -> Result<(), StoreError> {
360 self.require_writable()?;
361 let mut wtxn = self.env.write_txn().map_err(map_heed_error)?;
362 self.require_staging_authority(&wtxn, authority)?;
363 match self
364 .metadata
365 .get(&wtxn, TARGET_IMPORT_AUTHENTICATED_KEY)
366 .map_err(map_heed_error)?
367 {
368 Some(actual) => require_exact_import_authority(actual, authority)?,
369 None => self
370 .metadata
371 .put(
372 &mut wtxn,
373 TARGET_IMPORT_AUTHENTICATED_KEY,
374 authority.as_slice(),
375 )
376 .map_err(map_heed_error)?,
377 }
378 wtxn.commit().map_err(map_heed_error)?;
379 self.env.force_sync().map_err(map_heed_error)
380 }
381
382 pub fn promote_authenticated_target_import_batch(
392 &self,
393 authority: Hash,
394 batch_size: usize,
395 ) -> Result<bool, StoreError> {
396 self.require_writable()?;
397 if batch_size == 0 {
398 return Err(StoreError::Other(
399 "target import promotion batch size must be non-zero".into(),
400 ));
401 }
402 {
403 let rtxn = self.env.read_txn().map_err(map_heed_error)?;
404 if let Some(actual) = self
405 .metadata
406 .get(&rtxn, TARGET_IMPORT_PROMOTED_KEY)
407 .map_err(map_heed_error)?
408 {
409 require_exact_import_authority(actual, authority)?;
410 rtxn.commit().map_err(map_heed_error)?;
411 return Ok(true);
412 }
413 require_exact_import_authority(
414 self.metadata
415 .get(&rtxn, TARGET_IMPORT_AUTHENTICATED_KEY)
416 .map_err(map_heed_error)?
417 .ok_or_else(|| {
418 StoreError::Other(
419 "target import cannot promote before authenticated EOF".into(),
420 )
421 })?,
422 authority,
423 )?;
424 rtxn.commit().map_err(map_heed_error)?;
425 }
426
427 let cursor = {
428 let rtxn = self.env.read_txn().map_err(map_heed_error)?;
429 let cursor: Option<Hash> = self
430 .metadata
431 .get(&rtxn, TARGET_IMPORT_PROMOTION_CURSOR_KEY)
432 .map_err(map_heed_error)?
433 .map(|bytes| {
434 bytes.try_into().map_err(|_| {
435 StoreError::Other(
436 "target import promotion cursor has an invalid length".into(),
437 )
438 })
439 })
440 .transpose()?;
441 rtxn.commit().map_err(map_heed_error)?;
442 cursor
443 };
444 let rtxn = self.env.read_txn().map_err(map_heed_error)?;
445 let mut entries = Vec::with_capacity(batch_size);
446 if let Some(cursor) = cursor {
447 use std::ops::Bound::{Excluded, Unbounded};
448 let range = (Excluded(cursor.as_slice()), Unbounded);
449 for item in self
450 .target_import_staging
451 .range(&rtxn, &range)
452 .map_err(map_heed_error)?
453 .take(batch_size)
454 {
455 let (hash, encoded) = item.map_err(map_heed_error)?;
456 entries.push((
457 hash.try_into().map_err(|_| {
458 StoreError::Other(
459 "target import staging contains an invalid hash key".into(),
460 )
461 })?,
462 decode_size(encoded)?,
463 ));
464 }
465 } else {
466 for item in self
467 .target_import_staging
468 .iter(&rtxn)
469 .map_err(map_heed_error)?
470 .take(batch_size)
471 {
472 let (hash, encoded) = item.map_err(map_heed_error)?;
473 entries.push((
474 hash.try_into().map_err(|_| {
475 StoreError::Other(
476 "target import staging contains an invalid hash key".into(),
477 )
478 })?,
479 decode_size(encoded)?,
480 ));
481 }
482 }
483 rtxn.commit().map_err(map_heed_error)?;
484
485 let mut wtxn = self.env.write_txn().map_err(map_heed_error)?;
486 self.require_staging_authority(&wtxn, authority)?;
487 require_exact_import_authority(
488 self.metadata
489 .get(&wtxn, TARGET_IMPORT_AUTHENTICATED_KEY)
490 .map_err(map_heed_error)?
491 .ok_or_else(|| {
492 StoreError::Other(
493 "target import authentication disappeared during promotion".into(),
494 )
495 })?,
496 authority,
497 )?;
498 if entries.is_empty() {
499 self.target_import_staging
500 .clear(&mut wtxn)
501 .map_err(map_heed_error)?;
502 for key in [
503 TARGET_IMPORT_STAGING_AUTHORITY_KEY,
504 TARGET_IMPORT_AUTHENTICATED_KEY,
505 TARGET_IMPORT_PROMOTION_CURSOR_KEY,
506 ] {
507 self.metadata
508 .delete(&mut wtxn, key)
509 .map_err(map_heed_error)?;
510 }
511 self.metadata
512 .put(&mut wtxn, TARGET_IMPORT_PROMOTED_KEY, authority.as_slice())
513 .map_err(map_heed_error)?;
514 } else {
515 self.put_verified_txn(
516 &mut wtxn,
517 self.target_verified,
518 &entries,
519 "promoted target import",
520 )?;
521 for (hash, _) in &entries {
522 self.target_import_staging
523 .delete(&mut wtxn, hash.as_slice())
524 .map_err(map_heed_error)?;
525 }
526 self.metadata
527 .put(
528 &mut wtxn,
529 TARGET_IMPORT_PROMOTION_CURSOR_KEY,
530 entries
531 .last()
532 .expect("nonempty target import batch")
533 .0
534 .as_slice(),
535 )
536 .map_err(map_heed_error)?;
537 }
538 wtxn.commit().map_err(map_heed_error)?;
539 self.env.force_sync().map_err(map_heed_error)?;
540 Ok(entries.is_empty())
541 }
542
543 pub fn promoted_target_import_authority(&self) -> Result<Option<Hash>, StoreError> {
544 let rtxn = self.env.read_txn().map_err(map_heed_error)?;
545 self.metadata
546 .get(&rtxn, TARGET_IMPORT_PROMOTED_KEY)
547 .map_err(map_heed_error)?
548 .map(|bytes| {
549 bytes.try_into().map_err(|_| {
550 StoreError::Other(
551 "promoted target import authority has an invalid length".into(),
552 )
553 })
554 })
555 .transpose()
556 }
557
558 pub fn target_cursor(&self) -> Result<Option<Hash>, StoreError> {
559 let rtxn = self.env.read_txn().map_err(map_heed_error)?;
560 self.metadata
561 .get(&rtxn, TARGET_CURSOR_KEY)
562 .map_err(map_heed_error)?
563 .map(|bytes| {
564 bytes.try_into().map_err(|_| {
565 StoreError::Other(
566 "online migration audit target cursor has an invalid length".into(),
567 )
568 })
569 })
570 .transpose()
571 }
572
573 pub fn reset_target_cursor(&self) -> Result<(), StoreError> {
574 self.require_writable()?;
575 let mut wtxn = self.env.write_txn().map_err(map_heed_error)?;
576 self.metadata
577 .delete(&mut wtxn, TARGET_CURSOR_KEY)
578 .map_err(map_heed_error)?;
579 wtxn.commit().map_err(map_heed_error)?;
580 self.env.force_sync().map_err(map_heed_error)
581 }
582
583 pub fn for_each_source_reconciled_batch(
585 &self,
586 batch_size: usize,
587 visit: impl FnMut(&[(Hash, u64)]) -> Result<(), StoreError>,
588 ) -> Result<PoolMigrationAuditSummary, StoreError> {
589 self.for_each_verified_batch_in(self.source_reconciled, batch_size, visit)
590 }
591
592 pub fn for_each_target_verified_batch(
593 &self,
594 batch_size: usize,
595 visit: impl FnMut(&[(Hash, u64)]) -> Result<(), StoreError>,
596 ) -> Result<PoolMigrationAuditSummary, StoreError> {
597 self.for_each_verified_batch_in(self.target_verified, batch_size, visit)
598 }
599
600 fn for_each_verified_batch_in(
601 &self,
602 database: Database<Bytes, Bytes>,
603 batch_size: usize,
604 mut visit: impl FnMut(&[(Hash, u64)]) -> Result<(), StoreError>,
605 ) -> Result<PoolMigrationAuditSummary, StoreError> {
606 if batch_size == 0 {
607 return Err(StoreError::Other(
608 "online migration audit export batch size must be non-zero".into(),
609 ));
610 }
611 let rtxn = self.env.read_txn().map_err(map_heed_error)?;
612 let mut hasher = Sha256::new();
613 hasher.update(b"hashtree-pool-migration-source-content/v3\0");
614 let mut entries = 0u64;
615 let mut bytes = 0u64;
616 let mut batch = Vec::with_capacity(batch_size);
617 for item in database.iter(&rtxn).map_err(map_heed_error)? {
618 let (key, encoded) = item.map_err(map_heed_error)?;
619 let hash: Hash = key.try_into().map_err(|_| {
620 StoreError::Other("online migration audit contains an invalid hash key".into())
621 })?;
622 let size = decode_size(encoded)?;
623 batch.push((hash, size));
624 if batch.len() == batch_size {
625 visit(&batch)?;
626 batch.clear();
627 }
628 hasher.update(hash);
629 hasher.update(size.to_be_bytes());
630 entries = entries.checked_add(1).ok_or_else(|| {
631 StoreError::Other("online migration audit entry count overflow".into())
632 })?;
633 bytes = bytes.checked_add(size).ok_or_else(|| {
634 StoreError::Other("online migration audit byte count overflow".into())
635 })?;
636 }
637 if !batch.is_empty() {
638 visit(&batch)?;
639 }
640 rtxn.commit().map_err(map_heed_error)?;
641 Ok(PoolMigrationAuditSummary {
642 entries,
643 bytes,
644 content_sha256: hasher.finalize().into(),
645 })
646 }
647
648 pub fn first_unreconciled_source_key(
653 &self,
654 source: &crate::LmdbBlobReader,
655 page_size: usize,
656 ) -> Result<Option<Hash>, StoreError> {
657 self.first_unreconciled_source_key_parallel(source, page_size, 1)
658 }
659
660 pub fn first_unreconciled_source_key_parallel(
666 &self,
667 source: &crate::LmdbBlobReader,
668 page_size: usize,
669 concurrency: usize,
670 ) -> Result<Option<Hash>, StoreError> {
671 if page_size == 0 {
672 return Err(StoreError::Other(
673 "online migration audit coverage page size must be non-zero".into(),
674 ));
675 }
676 let mut scanner = source.parallel_raw_key_scanner(None, concurrency)?;
677 loop {
678 let hashes = scanner.next_page(page_size)?;
679 if hashes.is_empty() {
680 return Ok(None);
681 }
682 let rtxn = self.env.read_txn().map_err(map_heed_error)?;
683 for hash in &hashes {
684 if self
685 .source_reconciled
686 .get(&rtxn, hash)
687 .map_err(map_heed_error)?
688 .is_none()
689 {
690 return Ok(Some(*hash));
691 }
692 }
693 }
694 }
695
696 pub fn validate_binding(&self) -> Result<(), StoreError> {
697 let rtxn = self.env.read_txn().map_err(map_heed_error)?;
698 let actual = self
699 .metadata
700 .get(&rtxn, BINDING_KEY)
701 .map_err(map_heed_error)?
702 .ok_or_else(|| {
703 StoreError::Other("online migration audit lost its authority binding".into())
704 })?;
705 if actual != self.binding {
706 return Err(StoreError::Other(
707 "online migration audit authority binding changed".into(),
708 ));
709 }
710 rtxn.commit().map_err(map_heed_error)
711 }
712
713 fn contains_exact_sorted_in(
714 &self,
715 database: Database<Bytes, Bytes>,
716 candidates: &[(Hash, u64)],
717 proof_kind: &str,
718 ) -> Result<Vec<bool>, StoreError> {
719 require_sorted_candidates(candidates)?;
720 let rtxn = self.env.read_txn().map_err(map_heed_error)?;
721 candidates
722 .iter()
723 .map(|(hash, size)| {
724 let Some(encoded) = database.get(&rtxn, hash).map_err(map_heed_error)? else {
725 return Ok(false);
726 };
727 let actual = decode_size(encoded)?;
728 if actual != *size {
729 return Err(StoreError::Other(format!(
730 "online migration {proof_kind} audit size for {} changed from {actual} to {size}",
731 hashtree_core::to_hex(hash)
732 )));
733 }
734 Ok(true)
735 })
736 .collect()
737 }
738
739 fn put_verified_txn(
740 &self,
741 wtxn: &mut heed::RwTxn<'_>,
742 database: Database<Bytes, Bytes>,
743 entries: &[(Hash, u64)],
744 proof_kind: &str,
745 ) -> Result<(), StoreError> {
746 for (hash, size) in entries {
747 if let Some(encoded) = database.get(wtxn, hash).map_err(map_heed_error)? {
748 let actual = decode_size(encoded)?;
749 if actual != *size {
750 return Err(StoreError::Other(format!(
751 "online migration {proof_kind} audit size for {} changed from {actual} to {size}",
752 hashtree_core::to_hex(hash)
753 )));
754 }
755 continue;
756 }
757 database
758 .put(wtxn, hash, &size.to_be_bytes())
759 .map_err(map_heed_error)?;
760 }
761 Ok(())
762 }
763
764 fn require_writable(&self) -> Result<(), StoreError> {
765 if !self.writable {
766 return Err(StoreError::Other(
767 "online migration audit store is read-only".into(),
768 ));
769 }
770 Ok(())
771 }
772
773 fn require_staging_authority(
774 &self,
775 txn: &heed::RoTxn<'_>,
776 authority: Hash,
777 ) -> Result<(), StoreError> {
778 require_exact_import_authority(
779 self.metadata
780 .get(txn, TARGET_IMPORT_STAGING_AUTHORITY_KEY)
781 .map_err(map_heed_error)?
782 .ok_or_else(|| {
783 StoreError::Other("target import staging has no authority".into())
784 })?,
785 authority,
786 )
787 }
788}
789
790fn require_exact_import_authority(actual: &[u8], expected: Hash) -> Result<(), StoreError> {
791 if actual != expected {
792 return Err(StoreError::Other(
793 "target import authority changed; use a new rollout".into(),
794 ));
795 }
796 Ok(())
797}
798
799fn require_sorted_candidates(candidates: &[(Hash, u64)]) -> Result<(), StoreError> {
800 if candidates.windows(2).any(|pair| pair[0].0 >= pair[1].0) {
801 return Err(StoreError::Other(
802 "online migration audit candidates must be unique and strictly sorted".into(),
803 ));
804 }
805 Ok(())
806}
807
808fn decode_size(encoded: &[u8]) -> Result<u64, StoreError> {
809 let bytes: [u8; 8] = encoded.try_into().map_err(|_| {
810 StoreError::Other("online migration audit contains an invalid size value".into())
811 })?;
812 Ok(u64::from_be_bytes(bytes))
813}
814
815#[cfg(test)]
816mod tests {
817 use super::*;
818 use crate::{
819 migrate_lmdb_hashes_with_max_buffer_bytes_and_authorizer, LmdbBlobReader, LmdbBlobStore,
820 PoolMemberConfig, PoolStore, PoolStoreConfig,
821 };
822 use hashtree_core::sha256;
823
824 #[test]
825 fn verified_set_is_durable_exact_and_authority_bound() {
826 let temp = tempfile::tempdir().expect("temporary audit root");
827 let path = temp.path().join("online-audit");
828 let binding = sha256(b"rollout/source/pool authority");
829 let first = sha256(b"first verified body");
830 let second = sha256(b"second verified body");
831 let mut entries = vec![(first, 19), (second, 20)];
832 entries.sort_unstable_by_key(|(hash, _)| *hash);
833 {
834 let audit = PoolMigrationAuditStore::open(&path, binding).expect("create audit");
835 audit
836 .record_reconciled_source(&entries)
837 .expect("record verified entries");
838 }
839 let audit = PoolMigrationAuditStore::open(&path, binding).expect("reopen audit");
840 assert_eq!(
841 audit
842 .contains_source_reconciled_exact_sorted(&entries)
843 .expect("query exact entries"),
844 vec![true, true]
845 );
846 assert_eq!(
847 audit
848 .contains_target_exact_sorted(&entries)
849 .expect("query target provenance"),
850 vec![false, false]
851 );
852 audit
853 .record_verified_target_page(&entries, entries.last().expect("last entry").0)
854 .expect("record independent target-body proof");
855 assert_eq!(
856 audit
857 .contains_target_exact_sorted(&entries)
858 .expect("query independent target provenance"),
859 vec![true, true]
860 );
861 assert!(audit
862 .contains_source_reconciled_exact_sorted(&[(first, 18)])
863 .expect_err("conflicting size must fail")
864 .to_string()
865 .contains("changed"));
866 drop(audit);
867 let error = match PoolMigrationAuditStore::open(&path, sha256(b"different authority")) {
868 Ok(_) => panic!("different authority must fail"),
869 Err(error) => error,
870 };
871 assert!(error.to_string().contains("different authority"));
872 }
873
874 #[test]
875 fn coverage_scan_reads_only_keys_and_finds_only_unreconciled_source() {
876 let temp = tempfile::tempdir().expect("temporary migration root");
877 let source_path = temp.path().join("source");
878 let source = LmdbBlobStore::new(&source_path).expect("create source");
879 let first_body = b"first source body";
880 let second_body = b"second source body";
881 let first = sha256(first_body);
882 let second = sha256(second_body);
883 source
884 .put_sync(first, first_body)
885 .expect("put first source");
886 source
887 .put_sync(second, second_body)
888 .expect("put second source");
889 drop(source);
890 let reader = LmdbBlobReader::open(&source_path, None).expect("open source reader");
891
892 let audit = PoolMigrationAuditStore::open(
893 &temp.path().join("online-audit"),
894 sha256(b"coverage authority"),
895 )
896 .expect("create online audit");
897 audit
898 .record_reconciled_source(&[(first, first_body.len() as u64)])
899 .expect("record first source");
900 assert_eq!(
901 audit
902 .first_unreconciled_source_key_parallel(&reader, 1, 4)
903 .expect("scan coverage"),
904 Some(second)
905 );
906 audit
907 .record_reconciled_source(&[(second, second_body.len() as u64)])
908 .expect("record second source");
909 assert_eq!(
910 audit
911 .first_unreconciled_source_key_parallel(&reader, 1, 4)
912 .expect("scan complete coverage"),
913 None
914 );
915 }
916
917 #[test]
918 fn durable_verified_set_resumes_real_explicit_pool_migration() {
919 let temp = tempfile::tempdir().expect("temporary migration root");
920 let source_path = temp.path().join("source");
921 let source = LmdbBlobStore::new(&source_path).expect("create source");
922 let bodies = [
923 b"online migration body zero".as_slice(),
924 b"online migration body one".as_slice(),
925 b"online migration body two".as_slice(),
926 ];
927 for body in bodies {
928 source
929 .put_sync(sha256(body), body)
930 .expect("write source body");
931 }
932 source.force_sync().expect("sync source");
933 drop(source);
934 let source = LmdbBlobReader::open(&source_path, None).expect("open source reader");
935 let hashes = source.scan_hashes_after(None, 16).expect("scan source");
936 let sizes = source
937 .sizes_for_sorted_hashes(&hashes)
938 .expect("read source sizes");
939
940 let pool = PoolStore::open(temp.path().join("catalog"), PoolStoreConfig::default())
941 .expect("open target Pool");
942 pool.add_member(PoolMemberConfig::new(
943 temp.path().join("member"),
944 64 * 1024 * 1024,
945 ))
946 .expect("add target member");
947 let mut authorize = |_: Option<Hash>, _: usize| Ok(());
948 let first = migrate_lmdb_hashes_with_max_buffer_bytes_and_authorizer(
949 &source,
950 &pool,
951 &hashes[..2],
952 None,
953 usize::MAX,
954 &mut authorize,
955 )
956 .expect("migrate first explicit page");
957 pool.force_sync().expect("sync target");
958
959 let audit_path = temp.path().join("online-audit");
960 let binding = sha256(b"resumable real migration authority");
961 let audit = PoolMigrationAuditStore::open(&audit_path, binding).expect("create audit");
962 audit
963 .record_reconciled_source(&first.verified_source_entries)
964 .expect("record first verified page");
965 drop(audit);
966
967 let audit = PoolMigrationAuditStore::open(&audit_path, binding).expect("reopen audit");
968 let entries = hashes
969 .iter()
970 .copied()
971 .zip(sizes.iter().copied())
972 .collect::<Vec<_>>();
973 assert_eq!(
974 audit
975 .contains_source_reconciled_exact_sorted(&entries)
976 .expect("query resumed coverage"),
977 vec![true, true, false]
978 );
979 let resumed = migrate_lmdb_hashes_with_max_buffer_bytes_and_authorizer(
980 &source,
981 &pool,
982 &hashes[2..],
983 hashes.get(1).copied(),
984 usize::MAX,
985 &mut authorize,
986 )
987 .expect("resume only missing explicit body");
988 pool.force_sync().expect("sync resumed target");
989 audit
990 .record_reconciled_source(&resumed.verified_source_entries)
991 .expect("record resumed page");
992 assert_eq!(
993 audit
994 .contains_source_reconciled_exact_sorted(&entries)
995 .expect("query complete coverage"),
996 vec![true, true, true]
997 );
998 assert_eq!(pool.stats().expect("target stats").count, 3);
999 }
1000
1001 #[test]
1002 fn target_fence_rescans_catalog_but_retains_root_verified_content_proofs() {
1003 let temp = tempfile::tempdir().expect("temporary audit root");
1004 let path = temp.path().join("online-audit");
1005 let binding = sha256(b"root ledger authority");
1006 let pre_fence = sha256(b"pre-fence target");
1007 let fenced = sha256(b"fenced target");
1008 let root = PoolMigrationAuditStore::open(&path, binding).expect("create root ledger");
1009 root.record_reconciled_source(&[(pre_fence, 16)])
1010 .expect("record source reconciliation before target fence");
1011 assert_eq!(
1012 root.contains_target_exact_sorted(&[(pre_fence, 16)])
1013 .expect("query target proof before target audit"),
1014 vec![false]
1015 );
1016 root.record_verified_target_page(&[(pre_fence, 16)], pre_fence)
1017 .expect("record pre-fence target page");
1018 assert_eq!(
1019 root.contains_target_exact_sorted(&[(pre_fence, 16)])
1020 .expect("query pre-fence target proof"),
1021 vec![true]
1022 );
1023 assert_eq!(
1024 root.target_cursor().expect("read pre-fence cursor"),
1025 Some(pre_fence)
1026 );
1027
1028 let fence = sha256(b"exact persistent mask authorities");
1029 root.begin_target_fenced_epoch(fence)
1030 .expect("begin target proof epoch");
1031 assert_eq!(
1032 root.target_cursor().expect("read reset target cursor"),
1033 None
1034 );
1035 root.record_verified_target_page(&[(fenced, 13)], fenced)
1036 .expect("record root target proof");
1037 drop(root);
1038 let worker =
1039 PoolMigrationAuditStore::open_read_only(&path, binding).expect("open worker ledger");
1040 assert_eq!(
1041 worker.target_fence_binding().expect("read fence"),
1042 Some(fence)
1043 );
1044 let mut target_entries = vec![(pre_fence, 16), (fenced, 13)];
1045 target_entries.sort_unstable_by_key(|(hash, _)| *hash);
1046 assert_eq!(
1047 worker
1048 .contains_target_exact_sorted(&target_entries)
1049 .expect("read exact target provenance"),
1050 vec![true, true]
1051 );
1052 assert!(worker
1053 .record_reconciled_source(&[(pre_fence, 16)])
1054 .expect_err("worker ledger writes must fail")
1055 .to_string()
1056 .contains("read-only"));
1057 drop(worker);
1058 let root = PoolMigrationAuditStore::open(&path, binding).expect("reopen root ledger");
1059 assert!(root
1060 .begin_target_fenced_epoch(sha256(b"changed masks"))
1061 .expect_err("fence authority change must fail closed")
1062 .to_string()
1063 .contains("changed"));
1064 }
1065
1066 #[test]
1067 fn certified_target_import_is_quarantined_resumable_and_authority_bound() {
1068 let temp = tempfile::tempdir().expect("temporary audit root");
1069 let mut entries = (0_u64..17)
1070 .map(|index| {
1071 let body = format!("prior target body {index}");
1072 (sha256(body.as_bytes()), body.len() as u64)
1073 })
1074 .collect::<Vec<_>>();
1075 entries.sort_unstable_by_key(|(hash, _)| *hash);
1076 let promotion_batch_size = 4;
1077 let imported_path = temp.path().join("imported-audit");
1078 let binding = sha256(b"new source, binary, and parent-cert authority");
1079 let import_authority = sha256(b"exact certification plus evidence authority");
1080 let changed_authority = sha256(b"different certification authority");
1081
1082 {
1083 let imported =
1084 PoolMigrationAuditStore::open(&imported_path, binding).expect("create new audit");
1085 assert_eq!(
1086 imported.env.info().map_size,
1087 AUDIT_MAP_SIZE,
1088 "fresh audit must reserve capacity for both full proof trees"
1089 );
1090 assert!(imported
1091 .prepare_target_import(import_authority)
1092 .expect("prepare quarantine"));
1093 imported
1094 .stage_target_import_entries(import_authority, &entries[..3])
1095 .expect("stage unauthenticated prefix");
1096 assert_eq!(
1097 imported
1098 .contains_target_exact_sorted(&entries)
1099 .expect("query proof set before authenticated EOF"),
1100 vec![false; entries.len()],
1101 "unmarked staging must remain invisible"
1102 );
1103 }
1104
1105 {
1106 let imported = PoolMigrationAuditStore::open(&imported_path, binding)
1107 .expect("reopen after pre-marker crash");
1108 assert!(imported
1109 .prepare_target_import(import_authority)
1110 .expect("discard untrusted prefix"));
1111 imported
1112 .stage_target_import_entries(import_authority, &entries)
1113 .expect("stage complete authenticated input");
1114 imported
1115 .authenticate_target_import(import_authority)
1116 .expect("publish authenticated marker");
1117 }
1118
1119 {
1120 let imported = PoolMigrationAuditStore::open(&imported_path, binding)
1121 .expect("reopen after post-marker crash");
1122 assert!(!imported
1123 .prepare_target_import(import_authority)
1124 .expect("resume authenticated staging"));
1125 assert!(!imported
1126 .promote_authenticated_target_import_batch(import_authority, promotion_batch_size,)
1127 .expect("promote one production batch"));
1128 let rtxn = imported.env.read_txn().expect("read staged row count");
1129 assert_eq!(
1130 imported
1131 .target_import_staging
1132 .len(&rtxn)
1133 .expect("count remaining staged rows"),
1134 (entries.len() - promotion_batch_size) as u64,
1135 "each promoted row must drain from staging in the same transaction"
1136 );
1137 assert_eq!(
1138 imported
1139 .contains_target_exact_sorted(&entries)
1140 .expect("query partially promoted proofs"),
1141 [
1142 vec![true; promotion_batch_size],
1143 vec![false; entries.len() - promotion_batch_size]
1144 ]
1145 .concat(),
1146 "only an authenticated prefix may become visible"
1147 );
1148 }
1149
1150 let imported = PoolMigrationAuditStore::open(&imported_path, binding)
1151 .expect("reopen during promotion");
1152 assert_eq!(
1153 imported.env.info().map_size,
1154 AUDIT_MAP_SIZE,
1155 "reopened audit must retain the full proof-tree capacity"
1156 );
1157 let rtxn = imported
1158 .env
1159 .read_txn()
1160 .expect("read staged rows after reopen");
1161 assert_eq!(
1162 imported
1163 .target_import_staging
1164 .len(&rtxn)
1165 .expect("count staged rows after reopen"),
1166 (entries.len() - promotion_batch_size) as u64,
1167 "a crash/reopen must preserve the exact drained prefix"
1168 );
1169 drop(rtxn);
1170 while !imported
1171 .promote_authenticated_target_import_batch(import_authority, promotion_batch_size)
1172 .expect("resume exact idempotent promotion")
1173 {}
1174 let rtxn = imported
1175 .env
1176 .read_txn()
1177 .expect("read final staged row count");
1178 assert_eq!(
1179 imported
1180 .target_import_staging
1181 .len(&rtxn)
1182 .expect("count final staged rows"),
1183 0,
1184 "completed promotion must leave no duplicate staging tree"
1185 );
1186 drop(rtxn);
1187 assert_eq!(
1188 imported
1189 .promoted_target_import_authority()
1190 .expect("read durable import provenance"),
1191 Some(import_authority)
1192 );
1193 assert_eq!(
1194 imported
1195 .contains_target_exact_sorted(&entries)
1196 .expect("query promoted body proofs"),
1197 vec![true; entries.len()]
1198 );
1199 assert_eq!(
1200 imported.target_cursor().expect("query imported cursor"),
1201 None,
1202 "imported body evidence must not skip the new rollout's catalog scan"
1203 );
1204 assert_eq!(
1205 imported
1206 .contains_source_reconciled_exact_sorted(&entries)
1207 .expect("query source provenance"),
1208 vec![false; entries.len()],
1209 "target evidence must not become source reconciliation evidence"
1210 );
1211 assert!(imported
1212 .prepare_target_import(changed_authority)
1213 .expect_err("changed parent authority must fail closed")
1214 .to_string()
1215 .contains("authority changed"));
1216 }
1217}