1use aequora_protocol::SnapshotEntity;
8use aequora_scope::{ScopeGeneration, ScopeVersion};
9pub use aequora_types::{AuthorityEpoch, AuthorityId};
10use aequora_types::{OperationId, Sequence, SnapshotId, SyncScopeId};
11use async_trait::async_trait;
12use serde::{Deserialize, Serialize};
13use std::collections::{BTreeMap, BTreeSet};
14use thiserror::Error;
15use uuid::Uuid;
16
17pub const MANIFEST_FORMAT_VERSION: u32 = 1;
19
20#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
22#[serde(transparent)]
23pub struct BootstrapJobId(Uuid);
24
25impl BootstrapJobId {
26 #[must_use]
28 pub fn new() -> Self {
29 Self(Uuid::now_v7())
30 }
31}
32
33impl Default for BootstrapJobId {
34 fn default() -> Self {
35 Self::new()
36 }
37}
38
39macro_rules! nonzero_u64 {
40 ($(#[$meta:meta])* $name:ident) => {
41 $(#[$meta])*
42 #[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
43 #[serde(transparent)]
44 pub struct $name(u64);
45
46 impl $name {
47 pub const fn new(value: u64) -> Result<Self, BootstrapError> {
53 if value == 0 {
54 Err(BootstrapError::ZeroValue(stringify!($name)))
55 } else {
56 Ok(Self(value))
57 }
58 }
59
60 #[must_use]
62 pub const fn get(self) -> u64 {
63 self.0
64 }
65 }
66 };
67}
68
69nonzero_u64!(
70 ProjectionSchemaVersion
72);
73nonzero_u64!(
74 ReplicaGeneration
76);
77#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
79pub struct SnapshotBoundary {
80 pub authority_id: AuthorityId,
81 pub scope_id: SyncScopeId,
82 pub scope_version: ScopeVersion,
83 pub scope_generation: ScopeGeneration,
84 pub sequence: Sequence,
85 pub authority_epoch: AuthorityEpoch,
86}
87
88#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
90pub enum CompressionKind {
91 None,
92 Zstd,
93}
94
95#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
97#[serde(transparent)]
98pub struct ChunkId(pub [u8; 32]);
99
100#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
102pub struct EntityRange {
103 pub first: aequora_types::EntityRef,
104 pub last: aequora_types::EntityRef,
105}
106
107#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
109pub enum ChunkLocation {
110 Service,
111 HttpRange { object_ref: String },
112 ObjectStore { object_ref: String, via_cdn: bool },
113}
114
115#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
117pub struct ChunkDescriptor {
118 pub chunk_id: ChunkId,
119 pub ordinal: u32,
120 pub entity_range: EntityRange,
121 pub record_count: u64,
122 pub compressed_bytes: u64,
123 pub uncompressed_bytes: u64,
124 pub digest: [u8; 32],
125 pub compression: CompressionKind,
126 pub location: ChunkLocation,
127}
128
129#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
131pub struct SnapshotManifest {
132 pub format_version: u32,
133 pub snapshot_id: SnapshotId,
134 pub boundary: SnapshotBoundary,
135 pub schema_version: ProjectionSchemaVersion,
136 pub record_count: u64,
137 pub total_uncompressed_bytes: u64,
138 pub total_compressed_bytes: u64,
139 pub root_digest: [u8; 32],
140 pub chunks: Vec<ChunkDescriptor>,
141}
142
143#[derive(Clone, Debug, Eq, PartialEq)]
145pub struct SnapshotChunk {
146 pub descriptor: ChunkDescriptor,
147 pub bytes: Vec<u8>,
148}
149
150#[derive(Clone, Debug, Eq, PartialEq)]
152pub struct BuiltSnapshot {
153 pub manifest: SnapshotManifest,
154 pub chunks: Vec<SnapshotChunk>,
155}
156
157#[derive(Clone, Copy, Debug, Eq, PartialEq)]
159pub struct ChunkingConfig {
160 pub target_records: usize,
161 pub target_uncompressed_bytes: usize,
162 pub max_chunk_uncompressed_bytes: usize,
163 pub max_chunks: usize,
164 pub max_records: usize,
165 pub max_total_uncompressed_bytes: usize,
166}
167
168impl Default for ChunkingConfig {
169 fn default() -> Self {
170 Self {
171 target_records: 1_000,
172 target_uncompressed_bytes: 8 * 1024 * 1024,
173 max_chunk_uncompressed_bytes: 32 * 1024 * 1024,
174 max_chunks: 65_536,
175 max_records: 10_000_000,
176 max_total_uncompressed_bytes: 64 * 1024 * 1024 * 1024,
177 }
178 }
179}
180
181impl SnapshotManifest {
182 pub fn build(
188 snapshot_id: SnapshotId,
189 boundary: SnapshotBoundary,
190 schema_version: ProjectionSchemaVersion,
191 records: &[SnapshotEntity],
192 config: ChunkingConfig,
193 ) -> Result<BuiltSnapshot, BootstrapError> {
194 config.validate()?;
195 if records.len() > config.max_records {
196 return Err(BootstrapError::RecordLimit);
197 }
198 let mut ordered = records.to_vec();
199 ordered.sort_by_key(|record| record.entity);
200 let mut chunks = Vec::new();
201 let manifest = Self::build_streaming(
202 snapshot_id,
203 boundary,
204 schema_version,
205 ordered,
206 config,
207 |chunk| {
208 chunks.push(chunk);
209 Ok(())
210 },
211 )?;
212 Ok(BuiltSnapshot { manifest, chunks })
213 }
214
215 pub fn build_streaming<I, F>(
224 snapshot_id: SnapshotId,
225 boundary: SnapshotBoundary,
226 schema_version: ProjectionSchemaVersion,
227 records: I,
228 config: ChunkingConfig,
229 mut sink: F,
230 ) -> Result<Self, BootstrapError>
231 where
232 I: IntoIterator<Item = SnapshotEntity>,
233 F: FnMut(SnapshotChunk) -> Result<(), BootstrapError>,
234 {
235 config.validate()?;
236 let mut descriptors = Vec::with_capacity(config.max_chunks.min(1_024));
237 let mut current = Vec::with_capacity(config.target_records.min(1_024));
238 let mut previous_entity = None;
239 let mut record_count = 0_usize;
240 let mut total_uncompressed_bytes = 0_u64;
241
242 for record in records {
243 if previous_entity.is_some_and(|previous| previous >= record.entity) {
244 return Err(if previous_entity == Some(record.entity) {
245 BootstrapError::DuplicateEntity
246 } else {
247 BootstrapError::EntityOrder
248 });
249 }
250 previous_entity = Some(record.entity);
251 record_count = record_count
252 .checked_add(1)
253 .ok_or(BootstrapError::Overflow)?;
254 if record_count > config.max_records {
255 return Err(BootstrapError::RecordLimit);
256 }
257 current.push(record);
258 let encoded_len = postcard::to_stdvec(¤t)?.len();
259 let reached_target = current.len() >= config.target_records
260 || encoded_len >= config.target_uncompressed_bytes;
261 if encoded_len > config.max_chunk_uncompressed_bytes {
262 if current.len() == 1 {
263 return Err(BootstrapError::ChunkByteLimit);
264 }
265 let last = current.pop().ok_or(BootstrapError::InvalidState)?;
266 emit_stream_chunk(
267 snapshot_id,
268 ¤t,
269 config,
270 &mut descriptors,
271 &mut total_uncompressed_bytes,
272 &mut sink,
273 )?;
274 current.clear();
275 current.push(last);
276 if postcard::to_stdvec(¤t)?.len() > config.max_chunk_uncompressed_bytes {
277 return Err(BootstrapError::ChunkByteLimit);
278 }
279 } else if reached_target {
280 emit_stream_chunk(
281 snapshot_id,
282 ¤t,
283 config,
284 &mut descriptors,
285 &mut total_uncompressed_bytes,
286 &mut sink,
287 )?;
288 current.clear();
289 }
290 }
291 if !current.is_empty() {
292 emit_stream_chunk(
293 snapshot_id,
294 ¤t,
295 config,
296 &mut descriptors,
297 &mut total_uncompressed_bytes,
298 &mut sink,
299 )?;
300 }
301
302 let mut manifest = Self {
303 format_version: MANIFEST_FORMAT_VERSION,
304 snapshot_id,
305 boundary,
306 schema_version,
307 record_count: u64::try_from(record_count).map_err(|_| BootstrapError::Overflow)?,
308 total_uncompressed_bytes,
309 total_compressed_bytes: total_uncompressed_bytes,
310 root_digest: [0; 32],
311 chunks: descriptors,
312 };
313 manifest.root_digest = manifest.calculate_root()?;
314 Ok(manifest)
315 }
316
317 pub fn verify(&self) -> Result<(), BootstrapError> {
323 if self.format_version != MANIFEST_FORMAT_VERSION {
324 return Err(BootstrapError::UnsupportedManifestVersion);
325 }
326 let mut records = 0_u64;
327 let mut compressed = 0_u64;
328 let mut uncompressed = 0_u64;
329 let mut previous_last = None;
330 for (index, chunk) in self.chunks.iter().enumerate() {
331 let ordinal = u32::try_from(index).map_err(|_| BootstrapError::Overflow)?;
332 if chunk.ordinal != ordinal || chunk.record_count == 0 {
333 return Err(BootstrapError::ChunkOrder);
334 }
335 if chunk.entity_range.first > chunk.entity_range.last
336 || previous_last.is_some_and(|last| last >= chunk.entity_range.first)
337 {
338 return Err(BootstrapError::EntityOrder);
339 }
340 validate_location(&chunk.location)?;
341 records = records
342 .checked_add(chunk.record_count)
343 .ok_or(BootstrapError::Overflow)?;
344 compressed = compressed
345 .checked_add(chunk.compressed_bytes)
346 .ok_or(BootstrapError::Overflow)?;
347 uncompressed = uncompressed
348 .checked_add(chunk.uncompressed_bytes)
349 .ok_or(BootstrapError::Overflow)?;
350 previous_last = Some(chunk.entity_range.last);
351 }
352 if records != self.record_count
353 || compressed != self.total_compressed_bytes
354 || uncompressed != self.total_uncompressed_bytes
355 {
356 return Err(BootstrapError::ManifestTotals);
357 }
358 if self.calculate_root()? != self.root_digest {
359 return Err(BootstrapError::RootMismatch);
360 }
361 Ok(())
362 }
363
364 fn calculate_root(&self) -> Result<[u8; 32], BootstrapError> {
365 let encoded = postcard::to_stdvec(&(
366 self.format_version,
367 self.snapshot_id,
368 self.boundary,
369 self.schema_version,
370 self.record_count,
371 self.total_uncompressed_bytes,
372 self.total_compressed_bytes,
373 &self.chunks,
374 ))?;
375 Ok(*blake3::hash(&encoded).as_bytes())
376 }
377}
378
379impl SnapshotChunk {
380 pub fn decode_verified(
386 &self,
387 expected: &ChunkDescriptor,
388 max_uncompressed_bytes: usize,
389 ) -> Result<Vec<SnapshotEntity>, BootstrapError> {
390 if &self.descriptor != expected {
391 return Err(BootstrapError::ChunkIdentityMismatch);
392 }
393 let expected_bytes =
394 usize::try_from(expected.uncompressed_bytes).map_err(|_| BootstrapError::Overflow)?;
395 if self.bytes.len() != expected_bytes || self.bytes.len() > max_uncompressed_bytes {
396 return Err(BootstrapError::ChunkByteLimit);
397 }
398 if *blake3::hash(&self.bytes).as_bytes() != expected.digest {
399 return Err(BootstrapError::ChunkDigestMismatch);
400 }
401 let records: Vec<SnapshotEntity> = postcard::from_bytes(&self.bytes)?;
402 let count = u64::try_from(records.len()).map_err(|_| BootstrapError::Overflow)?;
403 if count != expected.record_count {
404 return Err(BootstrapError::ChunkRecordCount);
405 }
406 let first = records.first().ok_or(BootstrapError::ChunkRecordCount)?;
407 let last = records.last().ok_or(BootstrapError::ChunkRecordCount)?;
408 if first.entity != expected.entity_range.first || last.entity != expected.entity_range.last
409 {
410 return Err(BootstrapError::EntityOrder);
411 }
412 if records
413 .windows(2)
414 .any(|pair| pair[0].entity >= pair[1].entity)
415 {
416 return Err(BootstrapError::EntityOrder);
417 }
418 Ok(records)
419 }
420}
421
422fn build_chunk(
423 snapshot_id: SnapshotId,
424 ordinal: usize,
425 records: &[SnapshotEntity],
426) -> Result<SnapshotChunk, BootstrapError> {
427 let bytes = postcard::to_stdvec(records)?;
428 let digest = *blake3::hash(&bytes).as_bytes();
429 let ordinal = u32::try_from(ordinal).map_err(|_| BootstrapError::Overflow)?;
430 let first = records.first().ok_or(BootstrapError::ChunkRecordCount)?;
431 let last = records.last().ok_or(BootstrapError::ChunkRecordCount)?;
432 let mut identity = blake3::Hasher::new();
433 identity.update(snapshot_id.as_uuid().as_bytes());
434 identity.update(&ordinal.to_le_bytes());
435 identity.update(&digest);
436 let byte_count = u64::try_from(bytes.len()).map_err(|_| BootstrapError::Overflow)?;
437 let record_count = u64::try_from(records.len()).map_err(|_| BootstrapError::Overflow)?;
438 let descriptor = ChunkDescriptor {
439 chunk_id: ChunkId(*identity.finalize().as_bytes()),
440 ordinal,
441 entity_range: EntityRange {
442 first: first.entity,
443 last: last.entity,
444 },
445 record_count,
446 compressed_bytes: byte_count,
447 uncompressed_bytes: byte_count,
448 digest,
449 compression: CompressionKind::None,
450 location: ChunkLocation::Service,
451 };
452 Ok(SnapshotChunk { descriptor, bytes })
453}
454
455fn emit_stream_chunk<F>(
456 snapshot_id: SnapshotId,
457 records: &[SnapshotEntity],
458 config: ChunkingConfig,
459 descriptors: &mut Vec<ChunkDescriptor>,
460 total_uncompressed_bytes: &mut u64,
461 sink: &mut F,
462) -> Result<(), BootstrapError>
463where
464 F: FnMut(SnapshotChunk) -> Result<(), BootstrapError>,
465{
466 if descriptors.len() == config.max_chunks {
467 return Err(BootstrapError::ChunkLimit);
468 }
469 let chunk = build_chunk(snapshot_id, descriptors.len(), records)?;
470 *total_uncompressed_bytes = total_uncompressed_bytes
471 .checked_add(chunk.descriptor.uncompressed_bytes)
472 .ok_or(BootstrapError::Overflow)?;
473 let max_total =
474 u64::try_from(config.max_total_uncompressed_bytes).map_err(|_| BootstrapError::Overflow)?;
475 if *total_uncompressed_bytes > max_total {
476 return Err(BootstrapError::TotalByteLimit);
477 }
478 descriptors.push(chunk.descriptor.clone());
479 sink(chunk)
480}
481
482impl ChunkingConfig {
483 fn validate(self) -> Result<(), BootstrapError> {
484 if self.target_records == 0
485 || self.target_uncompressed_bytes == 0
486 || self.max_chunk_uncompressed_bytes == 0
487 || self.max_chunks == 0
488 || self.max_records == 0
489 || self.max_total_uncompressed_bytes == 0
490 || self.target_uncompressed_bytes > self.max_chunk_uncompressed_bytes
491 {
492 return Err(BootstrapError::InvalidLimits);
493 }
494 Ok(())
495 }
496}
497
498fn validate_location(location: &ChunkLocation) -> Result<(), BootstrapError> {
499 match location {
500 ChunkLocation::Service => Ok(()),
501 ChunkLocation::HttpRange { object_ref } | ChunkLocation::ObjectStore { object_ref, .. }
502 if !object_ref.trim().is_empty() =>
503 {
504 Ok(())
505 }
506 ChunkLocation::HttpRange { .. } | ChunkLocation::ObjectStore { .. } => {
507 Err(BootstrapError::BlankObjectReference)
508 }
509 }
510}
511
512#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
514pub enum BootstrapState {
515 Requested,
516 Planning,
517 Downloading,
518 Installing,
519 Verifying,
520 ReadyToActivate,
521 Activating,
522 CatchingUp,
523 Complete,
524 Failed,
525 Quarantined,
526 Cancelled,
527}
528
529impl BootstrapState {
530 fn may_transition(self, next: Self) -> bool {
531 matches!(
532 (self, next),
533 (
534 Self::Requested | Self::Failed | Self::Cancelled,
535 Self::Planning
536 ) | (Self::Planning, Self::Downloading)
537 | (Self::Downloading, Self::Installing | Self::Cancelled)
538 | (
539 Self::Installing,
540 Self::Downloading | Self::Verifying | Self::Cancelled
541 )
542 | (Self::Verifying, Self::ReadyToActivate | Self::Quarantined)
543 | (Self::ReadyToActivate, Self::Activating | Self::Cancelled)
544 | (Self::Activating, Self::CatchingUp)
545 | (Self::CatchingUp, Self::Complete)
546 | (
547 Self::Requested
548 | Self::Planning
549 | Self::Downloading
550 | Self::Installing
551 | Self::Verifying
552 | Self::ReadyToActivate
553 | Self::Activating
554 | Self::CatchingUp,
555 Self::Failed
556 )
557 )
558 }
559}
560
561#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
563pub enum ChunkState {
564 NotStarted,
565 Downloading,
566 Downloaded,
567 Verified,
568 Installed,
569}
570
571#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
573pub struct ChunkProgress {
574 pub chunk_id: ChunkId,
575 pub ordinal: u32,
576 pub state: ChunkState,
577 pub downloaded_bytes: u64,
578 pub object_identity: Option<String>,
579 pub attempts: u32,
580}
581
582#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
584pub enum BootstrapMutationPolicy {
585 AllowQueue,
586 ReadOnly,
587 Custom,
588}
589
590#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
592pub struct PendingIntentPlan {
593 pub operation_ids: Vec<OperationId>,
594 pub immutable_sent: BTreeSet<OperationId>,
595 pub digest: [u8; 32],
596}
597
598impl PendingIntentPlan {
599 pub fn build(
605 mut operation_ids: Vec<OperationId>,
606 immutable_sent: BTreeSet<OperationId>,
607 ) -> Result<Self, BootstrapError> {
608 operation_ids.sort_unstable();
609 if operation_ids.windows(2).any(|pair| pair[0] == pair[1]) {
610 return Err(BootstrapError::DuplicatePendingIntent);
611 }
612 if !immutable_sent
613 .iter()
614 .all(|operation| operation_ids.binary_search(operation).is_ok())
615 {
616 return Err(BootstrapError::UnknownSentIntent);
617 }
618 let encoded = postcard::to_stdvec(&(&operation_ids, &immutable_sent))?;
619 Ok(Self {
620 operation_ids,
621 immutable_sent,
622 digest: *blake3::hash(&encoded).as_bytes(),
623 })
624 }
625}
626
627#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
629pub struct BootstrapJob {
630 pub job_id: BootstrapJobId,
631 pub snapshot_id: SnapshotId,
632 pub manifest_root: [u8; 32],
633 pub boundary: SnapshotBoundary,
634 pub schema_version: ProjectionSchemaVersion,
635 pub staging_generation: ReplicaGeneration,
636 pub mutation_policy: BootstrapMutationPolicy,
637 pub pending_intent_digest: [u8; 32],
638 pub state: BootstrapState,
639 pub chunks: BTreeMap<u32, ChunkProgress>,
640}
641
642impl BootstrapJob {
643 pub fn new(
649 manifest: &SnapshotManifest,
650 staging_generation: ReplicaGeneration,
651 mutation_policy: BootstrapMutationPolicy,
652 pending: &PendingIntentPlan,
653 ) -> Result<Self, BootstrapError> {
654 manifest.verify()?;
655 let chunks = manifest
656 .chunks
657 .iter()
658 .map(|chunk| {
659 (
660 chunk.ordinal,
661 ChunkProgress {
662 chunk_id: chunk.chunk_id,
663 ordinal: chunk.ordinal,
664 state: ChunkState::NotStarted,
665 downloaded_bytes: 0,
666 object_identity: None,
667 attempts: 0,
668 },
669 )
670 })
671 .collect();
672 Ok(Self {
673 job_id: BootstrapJobId::new(),
674 snapshot_id: manifest.snapshot_id,
675 manifest_root: manifest.root_digest,
676 boundary: manifest.boundary,
677 schema_version: manifest.schema_version,
678 staging_generation,
679 mutation_policy,
680 pending_intent_digest: pending.digest,
681 state: BootstrapState::Requested,
682 chunks,
683 })
684 }
685
686 pub fn transition(&mut self, next: BootstrapState) -> Result<(), BootstrapError> {
692 if !self.state.may_transition(next) {
693 return Err(BootstrapError::IllegalTransition {
694 from: self.state,
695 to: next,
696 });
697 }
698 self.state = next;
699 Ok(())
700 }
701
702 pub fn validate_resume(&self, manifest: &SnapshotManifest) -> Result<(), BootstrapError> {
708 manifest.verify()?;
709 if self.snapshot_id != manifest.snapshot_id
710 || self.manifest_root != manifest.root_digest
711 || self.boundary != manifest.boundary
712 || self.schema_version != manifest.schema_version
713 || self.chunks.len() != manifest.chunks.len()
714 || manifest.chunks.iter().any(|chunk| {
715 self.chunks
716 .get(&chunk.ordinal)
717 .is_none_or(|progress| progress.chunk_id != chunk.chunk_id)
718 })
719 {
720 return Err(BootstrapError::ResumeIdentityMismatch);
721 }
722 Ok(())
723 }
724
725 pub fn record_chunk_read(
734 &mut self,
735 descriptor: &ChunkDescriptor,
736 read: &ChunkRead,
737 ) -> Result<ChunkProgress, BootstrapError> {
738 if read.object_identity.trim().is_empty() {
739 return Err(BootstrapError::BlankObjectIdentity);
740 }
741 let progress = self
742 .chunks
743 .get_mut(&descriptor.ordinal)
744 .ok_or(BootstrapError::UnknownChunk)?;
745 if progress.chunk_id != descriptor.chunk_id
746 || progress
747 .object_identity
748 .as_ref()
749 .is_some_and(|identity| identity != &read.object_identity)
750 {
751 return Err(BootstrapError::RangeResumeMismatch);
752 }
753 if !matches!(
754 progress.state,
755 ChunkState::NotStarted | ChunkState::Downloading
756 ) {
757 return Err(BootstrapError::InvalidChunkState);
758 }
759 let bytes = u64::try_from(read.bytes.len()).map_err(|_| BootstrapError::Overflow)?;
760 let expected_next = progress
761 .downloaded_bytes
762 .checked_add(bytes)
763 .ok_or(BootstrapError::Overflow)?;
764 if read.next_offset != expected_next || read.next_offset > descriptor.compressed_bytes {
765 return Err(BootstrapError::RangeResumeMismatch);
766 }
767 if read.complete != (read.next_offset == descriptor.compressed_bytes) {
768 return Err(BootstrapError::RangeCompletionMismatch);
769 }
770 progress.downloaded_bytes = read.next_offset;
771 progress.object_identity = Some(read.object_identity.clone());
772 progress.attempts = progress.attempts.saturating_add(1);
773 progress.state = if read.complete {
774 ChunkState::Downloaded
775 } else {
776 ChunkState::Downloading
777 };
778 Ok(progress.clone())
779 }
780
781 pub fn mark_chunk_verified(
787 &mut self,
788 descriptor: &ChunkDescriptor,
789 ) -> Result<(), BootstrapError> {
790 self.advance_chunk(descriptor, ChunkState::Downloaded, ChunkState::Verified)
791 }
792
793 pub fn mark_chunk_installed(
799 &mut self,
800 descriptor: &ChunkDescriptor,
801 ) -> Result<(), BootstrapError> {
802 self.advance_chunk(descriptor, ChunkState::Verified, ChunkState::Installed)
803 }
804
805 fn advance_chunk(
806 &mut self,
807 descriptor: &ChunkDescriptor,
808 expected: ChunkState,
809 next: ChunkState,
810 ) -> Result<(), BootstrapError> {
811 let progress = self
812 .chunks
813 .get_mut(&descriptor.ordinal)
814 .ok_or(BootstrapError::UnknownChunk)?;
815 if progress.chunk_id != descriptor.chunk_id || progress.state != expected {
816 return Err(BootstrapError::InvalidChunkState);
817 }
818 progress.state = next;
819 Ok(())
820 }
821
822 #[must_use]
824 pub fn status(&self) -> BootstrapStatus {
825 let chunks_complete = self
826 .chunks
827 .values()
828 .filter(|progress| progress.state == ChunkState::Installed)
829 .count();
830 BootstrapStatus {
831 state: self.state,
832 scope_id: self.boundary.scope_id,
833 bytes_downloaded: self
834 .chunks
835 .values()
836 .map(|progress| progress.downloaded_bytes)
837 .sum(),
838 chunks_complete,
839 chunks_total: self.chunks.len(),
840 }
841 }
842}
843
844#[derive(Clone, Copy, Debug, Eq, PartialEq)]
846pub struct BootstrapStatus {
847 pub state: BootstrapState,
848 pub scope_id: SyncScopeId,
849 pub bytes_downloaded: u64,
850 pub chunks_complete: usize,
851 pub chunks_total: usize,
852}
853
854#[derive(Clone, Debug, Eq, PartialEq)]
856pub struct RangeResumeGuard {
857 pub snapshot_id: SnapshotId,
858 pub chunk_id: ChunkId,
859 pub object_identity: String,
860 pub downloaded_bytes: u64,
861}
862
863impl RangeResumeGuard {
864 pub fn verify(
870 &self,
871 snapshot_id: SnapshotId,
872 descriptor: &ChunkDescriptor,
873 object_identity: &str,
874 ) -> Result<(), BootstrapError> {
875 if object_identity.trim().is_empty() {
876 return Err(BootstrapError::BlankObjectIdentity);
877 }
878 if self.snapshot_id != snapshot_id
879 || self.chunk_id != descriptor.chunk_id
880 || self.object_identity != object_identity
881 || self.downloaded_bytes > descriptor.compressed_bytes
882 {
883 return Err(BootstrapError::RangeResumeMismatch);
884 }
885 Ok(())
886 }
887}
888
889#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
891pub struct SnapshotLease {
892 pub snapshot_id: SnapshotId,
893 pub boundary_sequence: Sequence,
894 pub retained_from: Sequence,
895 pub expires_at_unix_ms: u64,
896}
897
898impl SnapshotLease {
899 pub fn verify(
905 self,
906 snapshot_id: SnapshotId,
907 boundary: SnapshotBoundary,
908 now_unix_ms: u64,
909 ) -> Result<(), BootstrapError> {
910 let first_delta = boundary
911 .sequence
912 .0
913 .checked_add(1)
914 .ok_or(BootstrapError::Overflow)?;
915 if self.snapshot_id != snapshot_id || self.boundary_sequence != boundary.sequence {
916 return Err(BootstrapError::LeaseIdentityMismatch);
917 }
918 if now_unix_ms >= self.expires_at_unix_ms {
919 return Err(BootstrapError::LeaseExpired);
920 }
921 if self.retained_from.0 > first_delta {
922 return Err(BootstrapError::JournalGap);
923 }
924 Ok(())
925 }
926}
927
928#[derive(Clone, Copy, Debug, Eq, PartialEq)]
930pub struct BootstrapPreflight {
931 pub available_disk_bytes: u64,
932 pub reserved_disk_bytes: u64,
933 pub max_memory_bytes: u64,
934 pub ready_queue_bytes: u64,
935 pub max_chunk_uncompressed_bytes: u64,
936}
937
938impl BootstrapPreflight {
939 pub fn verify(self, manifest: &SnapshotManifest) -> Result<(), BootstrapError> {
945 let required_disk = manifest
946 .total_compressed_bytes
947 .checked_add(manifest.total_uncompressed_bytes)
948 .and_then(|value| value.checked_add(self.reserved_disk_bytes))
949 .ok_or(BootstrapError::Overflow)?;
950 if required_disk > self.available_disk_bytes {
951 return Err(BootstrapError::InsufficientDisk);
952 }
953 let required_memory = self
954 .ready_queue_bytes
955 .checked_add(self.max_chunk_uncompressed_bytes)
956 .ok_or(BootstrapError::Overflow)?;
957 if required_memory > self.max_memory_bytes {
958 return Err(BootstrapError::InsufficientMemory);
959 }
960 if manifest
961 .chunks
962 .iter()
963 .any(|chunk| chunk.uncompressed_bytes > self.max_chunk_uncompressed_bytes)
964 {
965 return Err(BootstrapError::ChunkByteLimit);
966 }
967 Ok(())
968 }
969}
970
971#[derive(Clone, Debug, Eq, PartialEq)]
973pub struct ActivationEvidence {
974 pub manifest_root: [u8; 32],
975 pub installed_chunks: BTreeSet<ChunkId>,
976 pub current_scope_version: ScopeVersion,
977 pub current_scope_generation: ScopeGeneration,
978 pub current_authority_epoch: AuthorityEpoch,
979 pub authorization_current: bool,
980 pub staging_verified: bool,
981 pub pending_intent_digest: [u8; 32],
982 pub lease: SnapshotLease,
983}
984
985#[derive(Clone, Copy, Debug, Eq, PartialEq)]
987pub struct VerifiedActivation {
988 pub job_id: BootstrapJobId,
989 pub snapshot_id: SnapshotId,
990 pub staging_generation: ReplicaGeneration,
991 pub cursor_sequence: Sequence,
992 pub pending_intent_digest: [u8; 32],
993}
994
995pub fn verify_activation(
1002 job: &BootstrapJob,
1003 manifest: &SnapshotManifest,
1004 evidence: &ActivationEvidence,
1005 now_unix_ms: u64,
1006) -> Result<VerifiedActivation, BootstrapError> {
1007 job.validate_resume(manifest)?;
1008 if job.state != BootstrapState::ReadyToActivate {
1009 return Err(BootstrapError::NotReadyToActivate);
1010 }
1011 evidence
1012 .lease
1013 .verify(job.snapshot_id, job.boundary, now_unix_ms)?;
1014 let expected_chunks = manifest
1015 .chunks
1016 .iter()
1017 .map(|chunk| chunk.chunk_id)
1018 .collect::<BTreeSet<_>>();
1019 if evidence.manifest_root != job.manifest_root {
1020 return Err(BootstrapError::RootMismatch);
1021 }
1022 if evidence.installed_chunks != expected_chunks
1023 || job
1024 .chunks
1025 .values()
1026 .any(|progress| progress.state != ChunkState::Installed)
1027 {
1028 return Err(BootstrapError::IncompleteStaging);
1029 }
1030 if !evidence.authorization_current {
1031 return Err(BootstrapError::AuthorizationRevoked);
1032 }
1033 if evidence.current_scope_version != job.boundary.scope_version
1034 || evidence.current_scope_generation != job.boundary.scope_generation
1035 {
1036 return Err(BootstrapError::ScopeChanged);
1037 }
1038 if evidence.current_authority_epoch != job.boundary.authority_epoch {
1039 return Err(BootstrapError::AuthorityEpochChanged);
1040 }
1041 if !evidence.staging_verified {
1042 return Err(BootstrapError::StagingNotVerified);
1043 }
1044 if evidence.pending_intent_digest != job.pending_intent_digest {
1045 return Err(BootstrapError::PendingIntentChanged);
1046 }
1047 Ok(VerifiedActivation {
1048 job_id: job.job_id,
1049 snapshot_id: job.snapshot_id,
1050 staging_generation: job.staging_generation,
1051 cursor_sequence: job.boundary.sequence,
1052 pending_intent_digest: job.pending_intent_digest,
1053 })
1054}
1055
1056#[derive(Clone, Debug, Eq, PartialEq)]
1058pub struct ChunkRead {
1059 pub bytes: Vec<u8>,
1060 pub next_offset: u64,
1061 pub complete: bool,
1062 pub object_identity: String,
1063}
1064
1065#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1067pub struct SnapshotRequest {
1068 pub scope_id: SyncScopeId,
1069 pub scope_version: ScopeVersion,
1070 pub scope_generation: ScopeGeneration,
1071 pub schema_version: ProjectionSchemaVersion,
1072}
1073
1074#[async_trait]
1076pub trait SnapshotReadView: Send {
1077 fn snapshot_id(&self) -> SnapshotId;
1078 fn boundary(&self) -> SnapshotBoundary;
1079 async fn next_records(
1080 &mut self,
1081 max_records: usize,
1082 max_uncompressed_bytes: usize,
1083 ) -> Result<Vec<SnapshotEntity>, BootstrapStoreError>;
1084}
1085
1086#[async_trait]
1088pub trait SnapshotSource: Send + Sync {
1089 async fn open_snapshot(
1090 &self,
1091 request: SnapshotRequest,
1092 ) -> Result<Box<dyn SnapshotReadView>, BootstrapStoreError>;
1093}
1094
1095#[async_trait]
1097pub trait SnapshotChunkSource: Send + Sync {
1098 async fn manifest(
1099 &self,
1100 snapshot_id: SnapshotId,
1101 ) -> Result<SnapshotManifest, BootstrapStoreError>;
1102
1103 async fn read_chunk(
1104 &self,
1105 snapshot_id: SnapshotId,
1106 chunk: &ChunkDescriptor,
1107 offset: u64,
1108 max_bytes: usize,
1109 ) -> Result<ChunkRead, BootstrapStoreError>;
1110}
1111
1112#[async_trait]
1114pub trait SnapshotSink: Send + Sync {
1115 async fn begin_staging(
1116 &self,
1117 job: &BootstrapJob,
1118 manifest: &SnapshotManifest,
1119 pending: &PendingIntentPlan,
1120 ) -> Result<(), BootstrapStoreError>;
1121
1122 async fn install_chunk(
1124 &self,
1125 job: &BootstrapJob,
1126 descriptor: &ChunkDescriptor,
1127 records: &[SnapshotEntity],
1128 ) -> Result<(), BootstrapStoreError>;
1129
1130 async fn verify_staging(
1131 &self,
1132 job: &BootstrapJob,
1133 manifest: &SnapshotManifest,
1134 ) -> Result<(), BootstrapStoreError>;
1135
1136 async fn activate(
1138 &self,
1139 activation: VerifiedActivation,
1140 ) -> Result<ActivationOutcome, BootstrapStoreError>;
1141
1142 async fn quarantine_revoked(&self, job: &BootstrapJob) -> Result<(), BootstrapStoreError>;
1144}
1145
1146#[async_trait]
1148pub trait SnapshotLeaseStore: Send + Sync {
1149 async fn acquire(
1150 &self,
1151 snapshot_id: SnapshotId,
1152 boundary: SnapshotBoundary,
1153 ) -> Result<SnapshotLease, BootstrapStoreError>;
1154
1155 async fn renew(&self, lease: SnapshotLease) -> Result<SnapshotLease, BootstrapStoreError>;
1156}
1157
1158#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1160pub struct ActivationOutcome {
1161 pub active_generation: ReplicaGeneration,
1162 pub cursor_sequence: Sequence,
1163 pub preserved_pending_operations: usize,
1164}
1165
1166#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
1168pub enum SnapshotInstallFeature {
1169 GenerationSwap,
1170 ResumableChunkInstall,
1171 ConsistentRead,
1172 PreservesPendingIntent,
1173}
1174
1175#[derive(Clone, Debug, Default, Eq, PartialEq)]
1177pub struct SnapshotInstallCapability {
1178 pub features: BTreeSet<SnapshotInstallFeature>,
1179}
1180
1181impl SnapshotInstallCapability {
1182 #[must_use]
1184 pub fn is_tier_a(&self) -> bool {
1185 [
1186 SnapshotInstallFeature::GenerationSwap,
1187 SnapshotInstallFeature::ResumableChunkInstall,
1188 SnapshotInstallFeature::ConsistentRead,
1189 SnapshotInstallFeature::PreservesPendingIntent,
1190 ]
1191 .iter()
1192 .all(|feature| self.features.contains(feature))
1193 }
1194}
1195
1196#[derive(Clone, Debug, Error, Eq, PartialEq)]
1198pub enum BootstrapError {
1199 #[error("{0} must be non-zero")]
1200 ZeroValue(&'static str),
1201 #[error("bootstrap limits are invalid")]
1202 InvalidLimits,
1203 #[error("snapshot record limit exceeded")]
1204 RecordLimit,
1205 #[error("snapshot chunk limit exceeded")]
1206 ChunkLimit,
1207 #[error("snapshot total-byte limit exceeded")]
1208 TotalByteLimit,
1209 #[error("snapshot chunk byte limit exceeded")]
1210 ChunkByteLimit,
1211 #[error("snapshot contains a duplicate entity")]
1212 DuplicateEntity,
1213 #[error("snapshot manifest version is unsupported")]
1214 UnsupportedManifestVersion,
1215 #[error("snapshot chunk ordering is invalid")]
1216 ChunkOrder,
1217 #[error("snapshot entity ordering is invalid")]
1218 EntityOrder,
1219 #[error("snapshot manifest totals do not match descriptors")]
1220 ManifestTotals,
1221 #[error("snapshot root digest does not match")]
1222 RootMismatch,
1223 #[error("snapshot chunk identity changed")]
1224 ChunkIdentityMismatch,
1225 #[error("snapshot chunk digest does not match")]
1226 ChunkDigestMismatch,
1227 #[error("snapshot chunk record count does not match")]
1228 ChunkRecordCount,
1229 #[error("bootstrap state is invalid")]
1230 InvalidState,
1231 #[error("bootstrap chunk is unknown")]
1232 UnknownChunk,
1233 #[error("bootstrap chunk phase is invalid")]
1234 InvalidChunkState,
1235 #[error("illegal bootstrap transition from {from:?} to {to:?}")]
1236 IllegalTransition {
1237 from: BootstrapState,
1238 to: BootstrapState,
1239 },
1240 #[error("bootstrap resume identity changed")]
1241 ResumeIdentityMismatch,
1242 #[error("range resume object identity changed")]
1243 RangeResumeMismatch,
1244 #[error("range response completion marker does not match its offset")]
1245 RangeCompletionMismatch,
1246 #[error("range resume object identity is blank")]
1247 BlankObjectIdentity,
1248 #[error("chunk object reference is blank")]
1249 BlankObjectReference,
1250 #[error("pending operation identity is duplicated")]
1251 DuplicatePendingIntent,
1252 #[error("sent operation is absent from the pending plan")]
1253 UnknownSentIntent,
1254 #[error("snapshot lease identity changed")]
1255 LeaseIdentityMismatch,
1256 #[error("snapshot lease expired")]
1257 LeaseExpired,
1258 #[error("required post-snapshot journal history is unavailable")]
1259 JournalGap,
1260 #[error("insufficient disk for bounded bootstrap")]
1261 InsufficientDisk,
1262 #[error("insufficient memory for bounded bootstrap")]
1263 InsufficientMemory,
1264 #[error("bootstrap is not ready to activate")]
1265 NotReadyToActivate,
1266 #[error("staging does not contain every required chunk")]
1267 IncompleteStaging,
1268 #[error("scope authorization was revoked")]
1269 AuthorizationRevoked,
1270 #[error("scope version or generation changed")]
1271 ScopeChanged,
1272 #[error("authority epoch changed")]
1273 AuthorityEpochChanged,
1274 #[error("staging verification is incomplete")]
1275 StagingNotVerified,
1276 #[error("pending intent changed during bootstrap")]
1277 PendingIntentChanged,
1278 #[error("integer or size overflow")]
1279 Overflow,
1280 #[error("snapshot serialization failed")]
1281 Codec,
1282}
1283
1284impl From<postcard::Error> for BootstrapError {
1285 fn from(_: postcard::Error) -> Self {
1286 Self::Codec
1287 }
1288}
1289
1290#[derive(Clone, Debug, Error, Eq, PartialEq)]
1292#[error("bootstrap store {kind:?}: {message}")]
1293pub struct BootstrapStoreError {
1294 pub kind: BootstrapStoreErrorKind,
1295 pub message: String,
1296}
1297
1298#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1300pub enum BootstrapStoreErrorKind {
1301 Transient,
1302 Permanent,
1303 Conflict,
1304 Unauthorized,
1305 Capacity,
1306}
1307
1308#[cfg(test)]
1309mod tests {
1310 use super::*;
1311 use aequora_types::{EntityId, EntityRef, EntityType, EntityVersion};
1312 use proptest::prelude::*;
1313
1314 fn boundary() -> SnapshotBoundary {
1315 SnapshotBoundary {
1316 authority_id: AuthorityId::from_uuid(Uuid::from_u128(1)),
1317 scope_id: SyncScopeId::from_uuid(Uuid::from_u128(11)),
1318 scope_version: ScopeVersion::INITIAL,
1319 scope_generation: ScopeGeneration::INITIAL,
1320 sequence: Sequence(41),
1321 authority_epoch: AuthorityEpoch::new(1).unwrap_or_else(|error| panic!("{error}")),
1322 }
1323 }
1324
1325 fn record(id: u128, payload_size: usize) -> SnapshotEntity {
1326 SnapshotEntity {
1327 entity: EntityRef {
1328 entity_type: EntityType::new(1).unwrap_or_else(|error| panic!("{error}")),
1329 entity_id: EntityId::from_uuid(Uuid::from_u128(id)),
1330 },
1331 version: EntityVersion::INITIAL,
1332 payload: vec![7; payload_size],
1333 tombstone: false,
1334 }
1335 }
1336
1337 fn built(records: &[SnapshotEntity]) -> BuiltSnapshot {
1338 SnapshotManifest::build(
1339 SnapshotId::from_uuid(Uuid::from_u128(77)),
1340 boundary(),
1341 ProjectionSchemaVersion::new(1).unwrap_or_else(|error| panic!("{error}")),
1342 records,
1343 ChunkingConfig {
1344 target_records: 2,
1345 target_uncompressed_bytes: 1_024,
1346 max_chunk_uncompressed_bytes: 4_096,
1347 max_chunks: 32,
1348 max_records: 100,
1349 max_total_uncompressed_bytes: 65_536,
1350 },
1351 )
1352 .unwrap_or_else(|error| panic!("{error}"))
1353 }
1354
1355 #[test]
1356 fn deterministic_manifest_is_order_independent_and_chunks_verify() {
1357 let one = built(&[record(3, 8), record(1, 8), record(2, 8)]);
1358 let two = built(&[record(2, 8), record(3, 8), record(1, 8)]);
1359 assert_eq!(one.manifest, two.manifest);
1360 assert_eq!(one.chunks, two.chunks);
1361 one.manifest
1362 .verify()
1363 .unwrap_or_else(|error| panic!("{error}"));
1364 for chunk in &one.chunks {
1365 let decoded = chunk
1366 .decode_verified(&chunk.descriptor, 4_096)
1367 .unwrap_or_else(|error| panic!("{error}"));
1368 assert!(!decoded.is_empty());
1369 }
1370 }
1371
1372 #[test]
1373 fn streaming_builder_emits_one_bounded_chunk_at_a_time() {
1374 let records = vec![record(1, 8), record(2, 8), record(3, 8)];
1375 let config = ChunkingConfig {
1376 target_records: 2,
1377 target_uncompressed_bytes: 1_024,
1378 max_chunk_uncompressed_bytes: 4_096,
1379 max_chunks: 32,
1380 max_records: 100,
1381 max_total_uncompressed_bytes: 65_536,
1382 };
1383 let mut emitted = Vec::new();
1384 let manifest = SnapshotManifest::build_streaming(
1385 SnapshotId::from_uuid(Uuid::from_u128(77)),
1386 boundary(),
1387 ProjectionSchemaVersion::new(1).unwrap_or_else(|error| panic!("{error}")),
1388 records,
1389 config,
1390 |chunk| {
1391 assert!(chunk.bytes.len() <= config.max_chunk_uncompressed_bytes);
1392 emitted.push(chunk.descriptor.clone());
1393 Ok(())
1394 },
1395 )
1396 .unwrap_or_else(|error| panic!("{error}"));
1397 assert_eq!(manifest.chunks, emitted);
1398 assert_eq!(manifest.record_count, 3);
1399 manifest.verify().unwrap_or_else(|error| panic!("{error}"));
1400 }
1401
1402 #[test]
1403 fn manifest_and_chunk_tampering_fail_closed() {
1404 let mut snapshot = built(&[record(1, 8), record(2, 8)]);
1405 snapshot.manifest.record_count += 1;
1406 assert_eq!(
1407 snapshot.manifest.verify(),
1408 Err(BootstrapError::ManifestTotals)
1409 );
1410 let mut chunk = snapshot.chunks.remove(0);
1411 chunk.bytes[0] ^= 1;
1412 assert_eq!(
1413 chunk.decode_verified(&chunk.descriptor, 4_096),
1414 Err(BootstrapError::ChunkDigestMismatch)
1415 );
1416 }
1417
1418 #[test]
1419 fn state_machine_and_resume_identity_are_fail_closed() {
1420 let snapshot = built(&[record(1, 8)]);
1421 let pending = PendingIntentPlan::build(Vec::new(), BTreeSet::new())
1422 .unwrap_or_else(|error| panic!("{error}"));
1423 let mut job = BootstrapJob::new(
1424 &snapshot.manifest,
1425 ReplicaGeneration::new(2).unwrap_or_else(|error| panic!("{error}")),
1426 BootstrapMutationPolicy::AllowQueue,
1427 &pending,
1428 )
1429 .unwrap_or_else(|error| panic!("{error}"));
1430 assert!(job.transition(BootstrapState::Activating).is_err());
1431 job.transition(BootstrapState::Planning)
1432 .unwrap_or_else(|error| panic!("{error}"));
1433 job.validate_resume(&snapshot.manifest)
1434 .unwrap_or_else(|error| panic!("{error}"));
1435 let mut changed = snapshot.manifest.clone();
1436 changed.root_digest[0] ^= 1;
1437 assert!(job.validate_resume(&changed).is_err());
1438 }
1439
1440 #[test]
1441 fn range_resume_requires_the_same_immutable_object() {
1442 let snapshot = built(&[record(1, 8)]);
1443 let descriptor = &snapshot.manifest.chunks[0];
1444 let guard = RangeResumeGuard {
1445 snapshot_id: snapshot.manifest.snapshot_id,
1446 chunk_id: descriptor.chunk_id,
1447 object_identity: "etag-v1".to_owned(),
1448 downloaded_bytes: 3,
1449 };
1450 guard
1451 .verify(snapshot.manifest.snapshot_id, descriptor, "etag-v1")
1452 .unwrap_or_else(|error| panic!("{error}"));
1453 assert_eq!(
1454 guard.verify(snapshot.manifest.snapshot_id, descriptor, "etag-v2"),
1455 Err(BootstrapError::RangeResumeMismatch)
1456 );
1457
1458 let pending = PendingIntentPlan::build(Vec::new(), BTreeSet::new())
1459 .unwrap_or_else(|error| panic!("{error}"));
1460 let mut job = BootstrapJob::new(
1461 &snapshot.manifest,
1462 ReplicaGeneration::new(2).unwrap_or_else(|error| panic!("{error}")),
1463 BootstrapMutationPolicy::AllowQueue,
1464 &pending,
1465 )
1466 .unwrap_or_else(|error| panic!("{error}"));
1467 let first = ChunkRead {
1468 bytes: snapshot.chunks[0].bytes[..3].to_vec(),
1469 next_offset: 3,
1470 complete: false,
1471 object_identity: "etag-v1".to_owned(),
1472 };
1473 assert_eq!(
1474 job.record_chunk_read(descriptor, &first)
1475 .unwrap_or_else(|error| panic!("{error}"))
1476 .state,
1477 ChunkState::Downloading
1478 );
1479 let rest = ChunkRead {
1480 bytes: snapshot.chunks[0].bytes[3..].to_vec(),
1481 next_offset: descriptor.compressed_bytes,
1482 complete: true,
1483 object_identity: "etag-v1".to_owned(),
1484 };
1485 assert_eq!(
1486 job.record_chunk_read(descriptor, &rest)
1487 .unwrap_or_else(|error| panic!("{error}"))
1488 .state,
1489 ChunkState::Downloaded
1490 );
1491 job.mark_chunk_verified(descriptor)
1492 .unwrap_or_else(|error| panic!("{error}"));
1493 job.mark_chunk_installed(descriptor)
1494 .unwrap_or_else(|error| panic!("{error}"));
1495 }
1496
1497 #[test]
1498 fn lease_preflight_and_activation_require_complete_current_evidence() {
1499 let snapshot = built(&[record(1, 8), record(2, 8)]);
1500 BootstrapPreflight {
1501 available_disk_bytes: 1_000_000,
1502 reserved_disk_bytes: 1_000,
1503 max_memory_bytes: 16_384,
1504 ready_queue_bytes: 4_096,
1505 max_chunk_uncompressed_bytes: 4_096,
1506 }
1507 .verify(&snapshot.manifest)
1508 .unwrap_or_else(|error| panic!("{error}"));
1509 let pending = PendingIntentPlan::build(Vec::new(), BTreeSet::new())
1510 .unwrap_or_else(|error| panic!("{error}"));
1511 let mut job = BootstrapJob::new(
1512 &snapshot.manifest,
1513 ReplicaGeneration::new(2).unwrap_or_else(|error| panic!("{error}")),
1514 BootstrapMutationPolicy::AllowQueue,
1515 &pending,
1516 )
1517 .unwrap_or_else(|error| panic!("{error}"));
1518 for progress in job.chunks.values_mut() {
1519 progress.state = ChunkState::Installed;
1520 }
1521 job.state = BootstrapState::ReadyToActivate;
1522 let installed_chunks = snapshot
1523 .manifest
1524 .chunks
1525 .iter()
1526 .map(|chunk| chunk.chunk_id)
1527 .collect();
1528 let evidence = ActivationEvidence {
1529 manifest_root: snapshot.manifest.root_digest,
1530 installed_chunks,
1531 current_scope_version: job.boundary.scope_version,
1532 current_scope_generation: job.boundary.scope_generation,
1533 current_authority_epoch: job.boundary.authority_epoch,
1534 authorization_current: true,
1535 staging_verified: true,
1536 pending_intent_digest: pending.digest,
1537 lease: SnapshotLease {
1538 snapshot_id: job.snapshot_id,
1539 boundary_sequence: job.boundary.sequence,
1540 retained_from: Sequence(0),
1541 expires_at_unix_ms: 10_000,
1542 },
1543 };
1544 let activation = verify_activation(&job, &snapshot.manifest, &evidence, 5_000)
1545 .unwrap_or_else(|error| panic!("{error}"));
1546 assert_eq!(activation.cursor_sequence, Sequence(41));
1547 let mut revoked = evidence;
1548 revoked.authorization_current = false;
1549 assert_eq!(
1550 verify_activation(&job, &snapshot.manifest, &revoked, 5_000),
1551 Err(BootstrapError::AuthorizationRevoked)
1552 );
1553 }
1554
1555 proptest! {
1556 #[test]
1557 fn generated_input_order_never_changes_manifest(mut ids in prop::collection::btree_set(1_u128..10_000, 1..40).prop_map(|values| values.into_iter().collect::<Vec<_>>())) {
1558 let records = ids.iter().map(|id| record(*id, 4)).collect::<Vec<_>>();
1559 let expected = built(&records);
1560 ids.reverse();
1561 let reversed = ids.iter().map(|id| record(*id, 4)).collect::<Vec<_>>();
1562 prop_assert_eq!(built(&reversed), expected);
1563 }
1564 }
1565}