Skip to main content

hashtree_lmdb/
migration.rs

1use crate::{LmdbBlobReader, PoolCatalogLocation, PoolStore};
2use hashtree_core::store::StoreError;
3use hashtree_core::{sha256, types::Hash};
4use std::time::{Duration, Instant};
5
6/// Default upper bound for complete blob payloads retained by a migration.
7///
8/// A blob larger than this limit is migrated alone, so the actual peak is
9/// bounded by the larger of this value and the largest individual blob.
10pub const DEFAULT_POOL_MIGRATION_MAX_BUFFER_BYTES: usize = 64 * 1024 * 1024;
11
12#[derive(Debug, Clone, Default, PartialEq, Eq)]
13pub struct PoolMigrationBatch {
14    /// Source hashes scanned while advancing the durable cursor.
15    pub scanned: usize,
16    /// Scanned hashes already committed in the target catalog.
17    pub already_present: usize,
18    /// Source payloads loaded and hash-verified in this pass.
19    pub verified: usize,
20    /// Ordered content-address/length pairs for every source payload verified
21    /// in this page. Final migration receipts stream these compact records
22    /// into one source-content digest without rereading multi-terabyte bodies.
23    pub verified_source_entries: Vec<(Hash, u64)>,
24    /// Ordered hash/declared-size pairs for the complete scanned source page,
25    /// including entries whose exact-size Stored target record let the
26    /// reconciliation path skip loading source bytes.
27    pub source_entries: Vec<(Hash, u64)>,
28    pub inserted: usize,
29    pub inserted_bytes: u64,
30    /// Number of byte-bounded writes used to commit this cursor page.
31    pub write_batches: usize,
32    /// Maximum bytes of complete blob payloads owned at once.
33    pub peak_buffered_bytes: u64,
34    /// Time spent scanning source hashes for this cursor page.
35    pub scan_micros: u64,
36    /// Time spent probing the Pool catalog for already committed hashes.
37    pub catalog_probe_micros: u64,
38    /// Time spent loading source payloads.
39    pub source_read_micros: u64,
40    /// Number of source-group reads used for this page.
41    pub source_read_groups: usize,
42    /// Time spent hash-verifying source payloads.
43    pub source_verify_micros: u64,
44    /// Time spent committing payloads and locations to the Pool.
45    pub target_write_micros: u64,
46    pub last_hash: Option<Hash>,
47    pub source_exhausted: bool,
48}
49
50#[derive(Debug, Clone, Default, PartialEq, Eq)]
51pub struct LmdbSourceAuditBatch {
52    pub scanned: usize,
53    pub verified: usize,
54    pub verified_bytes: u64,
55    pub verified_source_entries: Vec<(Hash, u64)>,
56    pub peak_buffered_bytes: u64,
57    pub scan_micros: u64,
58    pub source_read_micros: u64,
59    pub source_verify_micros: u64,
60    pub last_hash: Option<Hash>,
61    pub source_exhausted: bool,
62}
63
64/// Read and content-hash one bounded source page without opening or mutating a
65/// target. This is the expensive source proof used before final downtime.
66pub fn audit_lmdb_source_batch_with_max_buffer_bytes(
67    source: &LmdbBlobReader,
68    after: Option<Hash>,
69    limit: usize,
70    max_buffer_bytes: usize,
71) -> Result<LmdbSourceAuditBatch, StoreError> {
72    if limit == 0 {
73        return Ok(LmdbSourceAuditBatch::default());
74    }
75    if max_buffer_bytes == 0 {
76        return Err(StoreError::Other(
77            "source audit max buffer bytes must be non-zero".into(),
78        ));
79    }
80    let scan_started = Instant::now();
81    let hashes = source.scan_hashes_after(after, limit)?;
82    let scan_micros = elapsed_micros(scan_started.elapsed());
83    if hashes.is_empty() {
84        return Ok(LmdbSourceAuditBatch {
85            source_exhausted: true,
86            scan_micros,
87            ..LmdbSourceAuditBatch::default()
88        });
89    }
90    let mut batch = LmdbSourceAuditBatch {
91        scanned: hashes.len(),
92        scan_micros,
93        last_hash: hashes.last().copied(),
94        ..LmdbSourceAuditBatch::default()
95    };
96    let mut next = 0usize;
97    while next < hashes.len() {
98        let source_read_started = Instant::now();
99        let items = source.read_hashes_bounded(&hashes[next..], max_buffer_bytes as u64)?;
100        batch.source_read_micros = batch
101            .source_read_micros
102            .saturating_add(elapsed_micros(source_read_started.elapsed()));
103        if items.is_empty() {
104            return Err(StoreError::Other(
105                "bounded source audit read made no progress".into(),
106            ));
107        }
108        let source_verify_started = Instant::now();
109        let mut buffered_bytes = 0u64;
110        for (offset, (hash, data)) in items.iter().enumerate() {
111            let expected_hash = hashes.get(next + offset).ok_or_else(|| {
112                StoreError::Other("bounded source read exceeded the requested hash set".into())
113            })?;
114            if hash != expected_hash {
115                return Err(StoreError::Other(format!(
116                    "bounded source read returned {:?}, expected {:?}",
117                    hash, expected_hash
118                )));
119            }
120            if sha256(data) != *hash {
121                return Err(StoreError::Other(format!(
122                    "source returned corrupt bytes for {hash:?}"
123                )));
124            }
125            let len = data.len() as u64;
126            buffered_bytes = buffered_bytes.saturating_add(len);
127            batch.verified_bytes = batch.verified_bytes.saturating_add(len);
128            batch.verified_source_entries.push((*hash, len));
129        }
130        batch.source_verify_micros = batch
131            .source_verify_micros
132            .saturating_add(elapsed_micros(source_verify_started.elapsed()));
133        batch.verified = batch.verified.saturating_add(items.len());
134        batch.peak_buffered_bytes = batch.peak_buffered_bytes.max(buffered_bytes);
135        next = next.saturating_add(items.len());
136    }
137    Ok(batch)
138}
139
140/// Copy and verify one bounded lexicographic page from an existing LMDB store.
141///
142/// The caller persists `last_hash` only after this returns successfully. Replaying
143/// a page after process death is safe because pool writes are hash-verified and
144/// idempotent.
145pub fn migrate_lmdb_batch(
146    source: &LmdbBlobReader,
147    target: &PoolStore,
148    after: Option<Hash>,
149    limit: usize,
150) -> Result<PoolMigrationBatch, StoreError> {
151    migrate_lmdb_batch_with_max_buffer_bytes(
152        source,
153        target,
154        after,
155        limit,
156        DEFAULT_POOL_MIGRATION_MAX_BUFFER_BYTES,
157    )
158}
159
160/// Copy and verify one lexicographic cursor page using byte-bounded writes.
161///
162/// The item limit controls durable cursor frequency. `max_buffer_bytes`
163/// independently limits how many complete blob payloads are retained for one
164/// target write, preventing a page of large blobs from consuming memory in
165/// proportion to `limit`. A single oversized blob is still migrated atomically
166/// and therefore defines the unavoidable upper bound.
167pub fn migrate_lmdb_batch_with_max_buffer_bytes(
168    source: &LmdbBlobReader,
169    target: &PoolStore,
170    after: Option<Hash>,
171    limit: usize,
172    max_buffer_bytes: usize,
173) -> Result<PoolMigrationBatch, StoreError> {
174    migrate_lmdb_batch_with_max_buffer_bytes_and_authorizer(
175        source,
176        target,
177        after,
178        limit,
179        max_buffer_bytes,
180        &mut |_, _| Ok(()),
181    )
182}
183
184/// Copy and verify one lexicographic cursor page, authorizing each bounded
185/// target-write group immediately before it can mutate the Pool.
186///
187/// The callback is deliberately invoked after source reads and verification,
188/// and only when the group contains at least one blob not already committed in
189/// the target. This keeps a root-owned external fence fresh at the mutation
190/// boundary without holding that authorization across potentially slow source
191/// I/O.
192pub fn migrate_lmdb_batch_with_max_buffer_bytes_and_authorizer(
193    source: &LmdbBlobReader,
194    target: &PoolStore,
195    after: Option<Hash>,
196    limit: usize,
197    max_buffer_bytes: usize,
198    authorize_target_write: &mut dyn FnMut(Option<Hash>, usize) -> Result<(), StoreError>,
199) -> Result<PoolMigrationBatch, StoreError> {
200    if limit == 0 {
201        return Ok(PoolMigrationBatch::default());
202    }
203    if max_buffer_bytes == 0 {
204        return Err(StoreError::Other(
205            "migration max buffer bytes must be non-zero".into(),
206        ));
207    }
208    let scan_started = Instant::now();
209    let hashes = source.scan_hashes_after(after, limit)?;
210    let scan_micros = elapsed_micros(scan_started.elapsed());
211    if hashes.is_empty() {
212        return Ok(PoolMigrationBatch {
213            source_exhausted: true,
214            scan_micros,
215            ..PoolMigrationBatch::default()
216        });
217    }
218
219    let mut batch = migrate_lmdb_hashes_with_max_buffer_bytes_and_authorizer(
220        source,
221        target,
222        &hashes,
223        after,
224        max_buffer_bytes,
225        authorize_target_write,
226    )?;
227    batch.scan_micros = scan_micros;
228    Ok(batch)
229}
230
231/// Verify and migrate an explicit sorted source hash set.
232///
233/// Online audit recovery uses this after consulting its durable verified set,
234/// so a resumed or catch-up pass rereads only source bodies that have not
235/// already been proven against the exact target authority.
236pub fn migrate_lmdb_hashes_with_max_buffer_bytes_and_authorizer(
237    source: &LmdbBlobReader,
238    target: &PoolStore,
239    hashes: &[Hash],
240    checkpoint_cursor: Option<Hash>,
241    max_buffer_bytes: usize,
242    authorize_target_write: &mut dyn FnMut(Option<Hash>, usize) -> Result<(), StoreError>,
243) -> Result<PoolMigrationBatch, StoreError> {
244    if hashes.is_empty() {
245        return Ok(PoolMigrationBatch::default());
246    }
247    if max_buffer_bytes == 0 {
248        return Err(StoreError::Other(
249            "migration max buffer bytes must be non-zero".into(),
250        ));
251    }
252    if hashes.windows(2).any(|pair| pair[0] >= pair[1]) {
253        return Err(StoreError::Other(
254            "explicit migration hashes must be unique and strictly sorted".into(),
255        ));
256    }
257    let mut batch = PoolMigrationBatch {
258        scanned: hashes.len(),
259        last_hash: hashes.last().copied(),
260        ..PoolMigrationBatch::default()
261    };
262    let catalog_probe_started = Instant::now();
263    let committed = target.committed_hashes_in_sorted_candidates(hashes)?;
264    batch.catalog_probe_micros = elapsed_micros(catalog_probe_started.elapsed());
265    let max_buffer_bytes = max_buffer_bytes as u64;
266    let mut next = 0usize;
267    while next < hashes.len() {
268        let source_read_started = Instant::now();
269        let items = source.read_hashes_bounded(&hashes[next..], max_buffer_bytes)?;
270        batch.source_read_micros = batch
271            .source_read_micros
272            .saturating_add(elapsed_micros(source_read_started.elapsed()));
273        if items.is_empty() {
274            return Err(StoreError::Other(
275                "bounded source read made no migration progress".into(),
276            ));
277        }
278        let source_verify_started = Instant::now();
279        let mut buffered_bytes = 0u64;
280        for (hash, data) in &items {
281            if sha256(data) != *hash {
282                return Err(StoreError::Other(format!(
283                    "source returned corrupt bytes for {hash:?}"
284                )));
285            }
286            buffered_bytes = buffered_bytes.saturating_add(data.len() as u64);
287            batch
288                .verified_source_entries
289                .push((*hash, data.len() as u64));
290        }
291        batch.source_verify_micros = batch
292            .source_verify_micros
293            .saturating_add(elapsed_micros(source_verify_started.elapsed()));
294        batch.verified = batch.verified.saturating_add(items.len());
295        batch.peak_buffered_bytes = batch.peak_buffered_bytes.max(buffered_bytes);
296        let item_count = items.len();
297        let mut writes = Vec::new();
298        for (offset, (hash, data)) in items.into_iter().enumerate() {
299            if committed[next + offset] {
300                let target_data = target.get_sync(&hash)?.ok_or_else(|| {
301                    StoreError::Other(format!(
302                        "target catalog claims committed blob {hash:?}, but its bytes are unavailable"
303                    ))
304                })?;
305                if target_data != data {
306                    return Err(StoreError::Other(format!(
307                        "target catalog claims committed blob {hash:?}, but its bytes differ from source"
308                    )));
309                }
310                batch.already_present = batch.already_present.saturating_add(1);
311            } else {
312                writes.push((hash, data));
313            }
314        }
315        let target_write_started = Instant::now();
316        if !writes.is_empty() {
317            authorize_target_write(checkpoint_cursor, writes.len())?;
318        }
319        let report = target.put_many_report_sync(&writes)?;
320        batch.target_write_micros = batch
321            .target_write_micros
322            .saturating_add(elapsed_micros(target_write_started.elapsed()));
323        batch.inserted = batch.inserted.saturating_add(report.inserted);
324        batch.inserted_bytes = batch.inserted_bytes.saturating_add(report.inserted_bytes);
325        if !writes.is_empty() {
326            batch.write_batches = batch.write_batches.saturating_add(1);
327        }
328        next = next.saturating_add(item_count);
329    }
330    Ok(batch)
331}
332
333/// Reconcile one sorted source page using source keys and target metadata.
334///
335/// `Stored` target records supply their exact size without touching the source
336/// value. `Missing` and `Pending` records load and hash-check only the needed
337/// source bodies. `Moving` and every declared-size mismatch fail closed. The
338/// release path separately proves every target body and the complete frozen
339/// source key set.
340pub fn reconcile_lmdb_source_batch_with_max_buffer_bytes_and_authorizer(
341    source: &LmdbBlobReader,
342    target: &PoolStore,
343    after: Option<Hash>,
344    limit: usize,
345    max_buffer_bytes: usize,
346    authorize_target_write: &mut dyn FnMut(Option<Hash>, usize) -> Result<(), StoreError>,
347) -> Result<PoolMigrationBatch, StoreError> {
348    if limit == 0 {
349        return Ok(PoolMigrationBatch::default());
350    }
351    if max_buffer_bytes == 0 {
352        return Err(StoreError::Other(
353            "migration max buffer bytes must be non-zero".into(),
354        ));
355    }
356    let scan_started = Instant::now();
357    let hashes = source.scan_hashes_after(after, limit)?;
358    let scan_micros = elapsed_micros(scan_started.elapsed());
359    if hashes.is_empty() {
360        return Ok(PoolMigrationBatch {
361            source_exhausted: true,
362            scan_micros,
363            ..PoolMigrationBatch::default()
364        });
365    }
366    let mut batch = reconcile_lmdb_source_hashes_with_max_buffer_bytes_and_authorizer(
367        source,
368        target,
369        &hashes,
370        max_buffer_bytes,
371        authorize_target_write,
372    )?;
373    batch.scan_micros = scan_micros;
374    Ok(batch)
375}
376
377/// Reconcile exact ordered source keys, deriving sizes from the target when
378/// already `Stored` and reading source bodies only for `Missing`/`Pending`.
379pub fn reconcile_lmdb_source_hashes_with_max_buffer_bytes_and_authorizer(
380    source: &LmdbBlobReader,
381    target: &PoolStore,
382    hashes: &[Hash],
383    max_buffer_bytes: usize,
384    authorize_target_write: &mut dyn FnMut(Option<Hash>, usize) -> Result<(), StoreError>,
385) -> Result<PoolMigrationBatch, StoreError> {
386    if hashes.is_empty() {
387        return Ok(PoolMigrationBatch::default());
388    }
389    if max_buffer_bytes == 0 {
390        return Err(StoreError::Other(
391            "migration max buffer bytes must be non-zero".into(),
392        ));
393    }
394    if hashes.windows(2).any(|pair| pair[0] >= pair[1]) {
395        return Err(StoreError::Other(
396            "source reconciliation hashes must be unique and strictly sorted".into(),
397        ));
398    }
399
400    let catalog_probe_started = Instant::now();
401    let locations = target.catalog_locations_in_sorted_candidates(hashes)?;
402    let catalog_probe_micros = elapsed_micros(catalog_probe_started.elapsed());
403    if locations.len() != hashes.len() {
404        return Err(StoreError::Other(
405            "target catalog lookup returned the wrong result count".into(),
406        ));
407    }
408
409    let mut source_entries = Vec::with_capacity(hashes.len());
410    let mut needed = Vec::new();
411    let mut already_present = 0usize;
412    for (hash, location) in hashes.iter().copied().zip(locations) {
413        match location {
414            PoolCatalogLocation::Stored { size, .. } => {
415                source_entries.push((hash, size));
416                already_present = already_present.saturating_add(1);
417            }
418            PoolCatalogLocation::Pending { size, .. } => {
419                needed.push((hash, Some(size)));
420            }
421            PoolCatalogLocation::Missing => needed.push((hash, None)),
422            PoolCatalogLocation::Moving { size, .. } => {
423                return Err(StoreError::Other(format!(
424                    "target Moving location is non-terminal for {hash:?} (declared size {size})"
425                )));
426            }
427        }
428    }
429
430    let mut batch = PoolMigrationBatch {
431        scanned: hashes.len(),
432        already_present,
433        catalog_probe_micros,
434        last_hash: hashes.last().copied(),
435        ..PoolMigrationBatch::default()
436    };
437    let max_buffer_bytes = max_buffer_bytes as u64;
438    let mut next = 0usize;
439    while next < needed.len() {
440        let source_read_started = Instant::now();
441        let needed_hashes = needed[next..]
442            .iter()
443            .map(|(hash, _)| *hash)
444            .collect::<Vec<_>>();
445        let items = source.read_hashes_bounded(&needed_hashes, max_buffer_bytes)?;
446        batch.source_read_micros = batch
447            .source_read_micros
448            .saturating_add(elapsed_micros(source_read_started.elapsed()));
449        if items.is_empty() {
450            return Err(StoreError::Other(
451                "bounded key-only source reconciliation read made no progress".into(),
452            ));
453        }
454        batch.source_read_groups = batch.source_read_groups.saturating_add(1);
455
456        let source_verify_started = Instant::now();
457        let mut buffered_bytes = 0u64;
458        let mut writes = Vec::with_capacity(items.len());
459        for (offset, (hash, data)) in items.into_iter().enumerate() {
460            let (expected_hash, pending_size) = needed.get(next + offset).ok_or_else(|| {
461                StoreError::Other(
462                    "bounded key-only source read exceeded the requested hash set".into(),
463                )
464            })?;
465            let size = data.len() as u64;
466            if hash != *expected_hash || sha256(&data) != *expected_hash {
467                return Err(StoreError::Other(format!(
468                    "source payload differs from its content-addressed key for {expected_hash:?}"
469                )));
470            }
471            if pending_size.is_some_and(|expected_size| expected_size != size) {
472                return Err(StoreError::Other(format!(
473                    "target Pending size {} differs from source size {size} for {expected_hash:?}",
474                    pending_size.expect("checked pending size")
475                )));
476            }
477            buffered_bytes = buffered_bytes.saturating_add(size);
478            source_entries.push((*expected_hash, size));
479            batch.verified_source_entries.push((*expected_hash, size));
480            writes.push((*expected_hash, data));
481        }
482        batch.source_verify_micros = batch
483            .source_verify_micros
484            .saturating_add(elapsed_micros(source_verify_started.elapsed()));
485        batch.verified = batch.verified.saturating_add(writes.len());
486        batch.peak_buffered_bytes = batch.peak_buffered_bytes.max(buffered_bytes);
487        authorize_target_write(writes.last().map(|(hash, _)| *hash), writes.len())?;
488        let target_write_started = Instant::now();
489        let report = target.put_many_optimistic_report_sync(&writes)?;
490        batch.target_write_micros = batch
491            .target_write_micros
492            .saturating_add(elapsed_micros(target_write_started.elapsed()));
493        batch.inserted = batch.inserted.saturating_add(report.inserted);
494        batch.inserted_bytes = batch.inserted_bytes.saturating_add(report.inserted_bytes);
495        batch.write_batches = batch.write_batches.saturating_add(1);
496        next = next.saturating_add(writes.len());
497    }
498
499    source_entries.sort_unstable_by_key(|(hash, _)| *hash);
500    if source_entries.len() != hashes.len()
501        || source_entries
502            .iter()
503            .zip(hashes)
504            .any(|((entry_hash, _), expected_hash)| entry_hash != expected_hash)
505    {
506        return Err(StoreError::Other(
507            "key-only source reconciliation did not cover the exact source page".into(),
508        ));
509    }
510    batch.source_entries = source_entries;
511    Ok(batch)
512}
513
514/// Reconcile an exact, receipt-proven sorted hash/size page.
515///
516/// This is the final-union counterpart to the source-cursor API above. It
517/// never scans source keys or stats source blobs: the caller supplies the
518/// immutable evidence records and this function loads only bodies absent from
519/// an exact-size terminal `Stored` target location.
520pub fn reconcile_lmdb_source_entries_with_max_buffer_bytes_and_authorizer(
521    source: &LmdbBlobReader,
522    target: &PoolStore,
523    source_entries: &[(Hash, u64)],
524    max_buffer_bytes: usize,
525    authorize_target_write: &mut dyn FnMut(Option<Hash>, usize) -> Result<(), StoreError>,
526) -> Result<PoolMigrationBatch, StoreError> {
527    let union_entries = source_entries
528        .iter()
529        .map(|(hash, size)| (*hash, *size, 0usize))
530        .collect::<Vec<_>>();
531    reconcile_lmdb_source_union_page_with_max_buffer_bytes_and_authorizer(
532        &[source],
533        target,
534        &union_entries,
535        max_buffer_bytes,
536        authorize_target_write,
537    )
538}
539
540/// Reconcile one globally sorted, receipt-proven union page.
541///
542/// The target catalog is probed exactly once for the whole page. Missing and
543/// exact-size Pending bodies are then read in globally byte-bounded chunks,
544/// grouped by source within each chunk, and committed in global hash order.
545/// Thus interleaved manifests cost at most one read per participating source
546/// per byte-bounded chunk rather than one read transaction per alternating
547/// hash.
548pub fn reconcile_lmdb_source_union_page_with_max_buffer_bytes_and_authorizer(
549    sources: &[&LmdbBlobReader],
550    target: &PoolStore,
551    source_entries: &[(Hash, u64, usize)],
552    max_buffer_bytes: usize,
553    authorize_target_write: &mut dyn FnMut(Option<Hash>, usize) -> Result<(), StoreError>,
554) -> Result<PoolMigrationBatch, StoreError> {
555    if source_entries.is_empty() {
556        return Ok(PoolMigrationBatch::default());
557    }
558    if sources.is_empty() {
559        return Err(StoreError::Other(
560            "receipt-proven source union has no body sources".into(),
561        ));
562    }
563    if max_buffer_bytes == 0 {
564        return Err(StoreError::Other(
565            "migration max buffer bytes must be non-zero".into(),
566        ));
567    }
568    if source_entries
569        .windows(2)
570        .any(|entries| entries[0].0 >= entries[1].0)
571    {
572        return Err(StoreError::Other(
573            "receipt-proven source entries must be strictly sorted".into(),
574        ));
575    }
576    if source_entries
577        .iter()
578        .any(|(_, _, source_index)| *source_index >= sources.len())
579    {
580        return Err(StoreError::Other(
581            "receipt-proven source union references an unknown body source".into(),
582        ));
583    }
584    let hashes = source_entries
585        .iter()
586        .map(|(hash, _, _)| *hash)
587        .collect::<Vec<_>>();
588    let catalog_probe_started = Instant::now();
589    let locations = target.catalog_locations_in_sorted_candidates(&hashes)?;
590    let catalog_probe_micros = elapsed_micros(catalog_probe_started.elapsed());
591    if locations.len() != hashes.len() {
592        return Err(StoreError::Other(
593            "target catalog lookup returned the wrong result count".into(),
594        ));
595    }
596
597    let mut needed = Vec::new();
598    let mut already_present = 0usize;
599    for ((hash, expected_size, source_index), location) in
600        source_entries.iter().copied().zip(locations)
601    {
602        match location {
603            PoolCatalogLocation::Stored { size, .. } if size == expected_size => {
604                already_present = already_present.saturating_add(1);
605            }
606            PoolCatalogLocation::Stored { size, .. } => {
607                return Err(StoreError::Other(format!(
608                    "target Stored size {size} differs from source size {expected_size} for {hash:?}"
609                )));
610            }
611            PoolCatalogLocation::Pending { size, .. } if size == expected_size => {
612                needed.push((hash, expected_size, source_index));
613            }
614            PoolCatalogLocation::Missing => needed.push((hash, expected_size, source_index)),
615            PoolCatalogLocation::Pending { size, .. } => {
616                return Err(StoreError::Other(format!(
617                    "target Pending size {size} differs from source size {expected_size} for {hash:?}"
618                )));
619            }
620            PoolCatalogLocation::Moving { size, .. } => {
621                return Err(StoreError::Other(format!(
622                    "target Moving location is non-terminal for {hash:?} (declared size {size})"
623                )));
624            }
625        }
626    }
627
628    let mut batch = PoolMigrationBatch {
629        scanned: hashes.len(),
630        already_present,
631        source_entries: source_entries
632            .iter()
633            .map(|(hash, size, _)| (*hash, *size))
634            .collect(),
635        catalog_probe_micros,
636        last_hash: hashes.last().copied(),
637        ..PoolMigrationBatch::default()
638    };
639    let max_buffer_bytes = max_buffer_bytes as u64;
640    let mut next = 0usize;
641    while next < needed.len() {
642        let mut end = next;
643        let mut chunk_bytes = 0u64;
644        while end < needed.len() {
645            let size = needed[end].1;
646            if end > next && chunk_bytes.saturating_add(size) > max_buffer_bytes {
647                break;
648            }
649            chunk_bytes = chunk_bytes.saturating_add(size);
650            end += 1;
651            if chunk_bytes >= max_buffer_bytes {
652                break;
653            }
654        }
655        let mut bodies = std::collections::HashMap::with_capacity(end - next);
656        let source_read_started = Instant::now();
657        for (source_index, source) in sources.iter().enumerate() {
658            let source_hashes = needed[next..end]
659                .iter()
660                .filter_map(|(hash, _, candidate_source)| {
661                    (*candidate_source == source_index).then_some(*hash)
662                })
663                .collect::<Vec<_>>();
664            if source_hashes.is_empty() {
665                continue;
666            }
667            let items = source.read_hashes_bounded(&source_hashes, max_buffer_bytes)?;
668            if items.len() != source_hashes.len() {
669                return Err(StoreError::Other(
670                    "bounded source-union read returned an incomplete body group".into(),
671                ));
672            }
673            batch.source_read_groups = batch.source_read_groups.saturating_add(1);
674            for (hash, data) in items {
675                if bodies.insert(hash, data).is_some() {
676                    return Err(StoreError::Other(
677                        "source-union body group returned a duplicate hash".into(),
678                    ));
679                }
680            }
681        }
682        batch.source_read_micros = batch
683            .source_read_micros
684            .saturating_add(elapsed_micros(source_read_started.elapsed()));
685        if bodies.len() != end - next {
686            return Err(StoreError::Other(
687                "source-union body reads did not cover the exact needed chunk".into(),
688            ));
689        }
690        let source_verify_started = Instant::now();
691        let mut items = Vec::with_capacity(end - next);
692        for (expected_hash, expected_size, _) in &needed[next..end] {
693            let data = bodies.remove(expected_hash).ok_or_else(|| {
694                StoreError::Other(format!("source-union body group omitted {expected_hash:?}"))
695            })?;
696            if data.len() as u64 != *expected_size || sha256(&data) != *expected_hash {
697                return Err(StoreError::Other(format!(
698                    "source payload differs from its receipt-bound hash/size for {expected_hash:?}"
699                )));
700            }
701            batch
702                .verified_source_entries
703                .push((*expected_hash, *expected_size));
704            items.push((*expected_hash, data));
705        }
706        batch.source_verify_micros = batch
707            .source_verify_micros
708            .saturating_add(elapsed_micros(source_verify_started.elapsed()));
709        batch.verified = batch.verified.saturating_add(items.len());
710        batch.peak_buffered_bytes = batch.peak_buffered_bytes.max(chunk_bytes);
711        authorize_target_write(items.last().map(|(hash, _)| *hash), items.len())?;
712        let target_write_started = Instant::now();
713        let report = target.put_many_optimistic_report_sync(&items)?;
714        batch.target_write_micros = batch
715            .target_write_micros
716            .saturating_add(elapsed_micros(target_write_started.elapsed()));
717        batch.inserted = batch.inserted.saturating_add(report.inserted);
718        batch.inserted_bytes = batch.inserted_bytes.saturating_add(report.inserted_bytes);
719        batch.write_batches = batch.write_batches.saturating_add(1);
720        next = end;
721    }
722    Ok(batch)
723}
724
725fn elapsed_micros(elapsed: Duration) -> u64 {
726    u64::try_from(elapsed.as_micros()).unwrap_or(u64::MAX)
727}