Skip to main content

ic_memory/
bootstrap.rs

1use crate::{
2    declaration::AllocationDeclaration,
3    declaration::DeclarationSnapshot,
4    ledger::{
5        AllocationLedger, AllocationReservationError, AllocationRetirement,
6        AllocationRetirementError, AllocationStageError, LedgerCommitError, LedgerCommitStore,
7    },
8    policy::AllocationPolicy,
9    session::ValidatedAllocations,
10    validation::{AllocationValidationError, validate_allocations},
11};
12
13///
14/// AllocationBootstrap
15///
16/// Golden-path allocation ledger bootstrap pipeline.
17///
18/// This type owns allocation-governance sequencing only: recover the persisted
19/// ledger, apply the owner layer's policy, validate current declarations
20/// against ledger history, stage and commit the next generation, and return
21/// [`ValidatedAllocations`] only after the commit succeeds.
22///
23/// `AllocationBootstrap` is for whichever layer owns a given `ic-memory`
24/// ledger store. That owner may be a framework such as Canic, a library such as
25/// IcyDB using `ic-memory` directly, or a standalone application canister. The
26/// ownership model is not a fixed `ic-memory -> Canic -> IcyDB -> application`
27/// chain.
28///
29/// Exactly one owner should bootstrap a given ledger store. If multiple layers
30/// use `ic-memory` in the same canister, they must either compose their
31/// declarations into one bootstrap owner or use distinct ledger stores and
32/// allocation domains.
33///
34/// The owner still decides when bootstrap runs, how the ledger store is backed
35/// by stable memory, and when endpoint dispatch or stable-memory handle opening
36/// is allowed.
37#[derive(Debug)]
38pub struct AllocationBootstrap<'store> {
39    store: &'store mut LedgerCommitStore,
40}
41
42impl<'store> AllocationBootstrap<'store> {
43    /// Build a bootstrap pipeline over a protected ledger commit store.
44    pub const fn new(store: &'store mut LedgerCommitStore) -> Self {
45        Self { store }
46    }
47
48    /// Recover, validate, stage, commit, and publish one allocation generation.
49    pub fn validate_and_commit<P>(
50        &mut self,
51        snapshot: DeclarationSnapshot,
52        policy: &P,
53        committed_at: Option<u64>,
54    ) -> Result<BootstrapCommit, BootstrapError<P::Error>>
55    where
56        P: AllocationPolicy,
57    {
58        let prior = self.store.recover().map_err(BootstrapError::Ledger)?;
59        self.validate_against(prior, snapshot, policy, committed_at)
60    }
61
62    /// Initialize an empty ledger store explicitly, then validate and commit.
63    ///
64    /// This is the privileged genesis/import path. Normal default-runtime users
65    /// should use [`crate::bootstrap_default_memory_manager`], which supplies an
66    /// empty current-format genesis ledger. A non-empty `genesis` should only be
67    /// supplied by the layer that owns migration or import for this ledger store.
68    ///
69    /// The generic crate guarantees only that `genesis` is used when the
70    /// protected physical store is empty, never when recovery sees corrupt or
71    /// partially written state.
72    pub fn initialize_validate_and_commit<P>(
73        &mut self,
74        genesis: &AllocationLedger,
75        snapshot: DeclarationSnapshot,
76        policy: &P,
77        committed_at: Option<u64>,
78    ) -> Result<BootstrapCommit, BootstrapError<P::Error>>
79    where
80        P: AllocationPolicy,
81    {
82        let prior = self
83            .store
84            .recover_or_initialize(genesis)
85            .map_err(BootstrapError::Ledger)?;
86        self.validate_against(prior, snapshot, policy, committed_at)
87    }
88
89    /// Recover, policy-check, reserve, and commit one reservation generation.
90    pub fn reserve_and_commit<P>(
91        &mut self,
92        reservations: &[AllocationDeclaration],
93        policy: &P,
94        committed_at: Option<u64>,
95    ) -> Result<AllocationLedger, BootstrapReservationError<P::Error>>
96    where
97        P: AllocationPolicy,
98    {
99        let prior = self
100            .store
101            .recover()
102            .map_err(BootstrapReservationError::Ledger)?;
103        self.reserve_against(prior.into_ledger(), reservations, policy, committed_at)
104    }
105
106    /// Initialize an empty ledger store, then reserve and commit.
107    ///
108    /// This is the privileged genesis/import path for reservation staging. A
109    /// non-empty `genesis` should only be supplied by the owner of migration or
110    /// import for this ledger store.
111    pub fn initialize_reserve_and_commit<P>(
112        &mut self,
113        genesis: &AllocationLedger,
114        reservations: &[AllocationDeclaration],
115        policy: &P,
116        committed_at: Option<u64>,
117    ) -> Result<AllocationLedger, BootstrapReservationError<P::Error>>
118    where
119        P: AllocationPolicy,
120    {
121        let prior = self
122            .store
123            .recover_or_initialize(genesis)
124            .map_err(BootstrapReservationError::Ledger)?;
125        self.reserve_against(prior.into_ledger(), reservations, policy, committed_at)
126    }
127
128    /// Recover, retire, and commit one explicit retirement generation.
129    pub fn retire_and_commit(
130        &mut self,
131        retirement: &AllocationRetirement,
132        committed_at: Option<u64>,
133    ) -> Result<AllocationLedger, BootstrapRetirementError> {
134        let prior = self
135            .store
136            .recover()
137            .map_err(BootstrapRetirementError::Ledger)?;
138        self.retire_against(prior.into_ledger(), retirement, committed_at)
139    }
140
141    fn reserve_against<P>(
142        &mut self,
143        prior: AllocationLedger,
144        reservations: &[AllocationDeclaration],
145        policy: &P,
146        committed_at: Option<u64>,
147    ) -> Result<AllocationLedger, BootstrapReservationError<P::Error>>
148    where
149        P: AllocationPolicy,
150    {
151        for reservation in reservations {
152            policy
153                .validate_key(&reservation.stable_key)
154                .map_err(BootstrapReservationError::Policy)?;
155            policy
156                .validate_reserved_slot(&reservation.stable_key, &reservation.slot)
157                .map_err(BootstrapReservationError::Policy)?;
158        }
159
160        let staged = prior
161            .stage_reservation_generation(reservations, committed_at)
162            .map_err(BootstrapReservationError::Reservation)?;
163        self.store
164            .commit(&staged)
165            .map(crate::RecoveredLedger::into_ledger)
166            .map_err(BootstrapReservationError::Ledger)
167    }
168
169    fn retire_against(
170        &mut self,
171        prior: AllocationLedger,
172        retirement: &AllocationRetirement,
173        committed_at: Option<u64>,
174    ) -> Result<AllocationLedger, BootstrapRetirementError> {
175        let staged = prior
176            .stage_retirement_generation(retirement, committed_at)
177            .map_err(BootstrapRetirementError::Retirement)?;
178        self.store
179            .commit(&staged)
180            .map(crate::RecoveredLedger::into_ledger)
181            .map_err(BootstrapRetirementError::Ledger)
182    }
183
184    fn validate_against<P>(
185        &mut self,
186        prior: crate::RecoveredLedger,
187        snapshot: DeclarationSnapshot,
188        policy: &P,
189        committed_at: Option<u64>,
190    ) -> Result<BootstrapCommit, BootstrapError<P::Error>>
191    where
192        P: AllocationPolicy,
193    {
194        let validated =
195            validate_allocations(&prior, snapshot, policy).map_err(BootstrapError::Validation)?;
196        let prior_ledger = prior.into_ledger();
197        let staged = prior_ledger
198            .stage_validated_generation(&validated, committed_at)
199            .map_err(BootstrapError::Staging)?;
200        let committed = self.store.commit(&staged).map_err(BootstrapError::Ledger)?;
201
202        Ok(BootstrapCommit {
203            validated: validated.with_generation(committed.current_generation()),
204            ledger: committed.into_ledger(),
205        })
206    }
207}
208
209///
210/// BootstrapCommit
211///
212/// Result of a successful generic allocation bootstrap commit.
213#[derive(Clone, Debug, Eq, PartialEq)]
214pub struct BootstrapCommit {
215    /// Ledger recovered after the protected generation commit.
216    pub ledger: AllocationLedger,
217    /// Validated allocation declarations tied to the committed generation.
218    pub validated: ValidatedAllocations,
219}
220
221///
222/// BootstrapError
223///
224/// Failure to recover, validate, or commit an allocation generation.
225#[derive(Clone, Debug, Eq, thiserror::Error, PartialEq)]
226pub enum BootstrapError<P> {
227    /// Ledger recovery or protected commit failed.
228    #[error(transparent)]
229    Ledger(LedgerCommitError),
230    /// Policy or historical allocation validation failed.
231    #[error(transparent)]
232    Validation(AllocationValidationError<P>),
233    /// Validated declarations could not be staged against the recovered ledger.
234    #[error(transparent)]
235    Staging(AllocationStageError),
236}
237
238///
239/// BootstrapReservationError
240///
241/// Failure to policy-check, stage, or commit an allocation reservation.
242#[derive(Clone, Debug, Eq, thiserror::Error, PartialEq)]
243pub enum BootstrapReservationError<P> {
244    /// Ledger recovery or protected commit failed.
245    #[error(transparent)]
246    Ledger(LedgerCommitError),
247    /// Policy adapter rejected a reservation declaration.
248    #[error("allocation policy rejected a reservation")]
249    Policy(P),
250    /// Reservation conflicted with historical allocation facts.
251    #[error(transparent)]
252    Reservation(AllocationReservationError),
253}
254
255///
256/// BootstrapRetirementError
257///
258/// Failure to stage or commit an explicit allocation retirement.
259#[derive(Clone, Debug, Eq, thiserror::Error, PartialEq)]
260pub enum BootstrapRetirementError {
261    /// Ledger recovery or protected commit failed.
262    #[error(transparent)]
263    Ledger(LedgerCommitError),
264    /// Retirement conflicted with historical allocation facts.
265    #[error(transparent)]
266    Retirement(AllocationRetirementError),
267}
268
269#[cfg(test)]
270mod tests {
271    use super::*;
272    use crate::{
273        declaration::AllocationDeclaration,
274        ledger::{AllocationHistory, AllocationLedger, AllocationState},
275        schema::SchemaMetadata,
276        slot::AllocationSlotDescriptor,
277    };
278
279    #[derive(Debug, Eq, PartialEq)]
280    struct TestPolicy;
281
282    impl AllocationPolicy for TestPolicy {
283        type Error = &'static str;
284
285        fn validate_key(&self, _key: &crate::StableKey) -> Result<(), Self::Error> {
286            Ok(())
287        }
288
289        fn validate_slot(
290            &self,
291            _key: &crate::StableKey,
292            _slot: &AllocationSlotDescriptor,
293        ) -> Result<(), Self::Error> {
294            Ok(())
295        }
296
297        fn validate_reserved_slot(
298            &self,
299            _key: &crate::StableKey,
300            _slot: &AllocationSlotDescriptor,
301        ) -> Result<(), Self::Error> {
302            Ok(())
303        }
304    }
305
306    #[derive(Debug, Eq, PartialEq)]
307    struct RejectReservedPolicy;
308
309    impl AllocationPolicy for RejectReservedPolicy {
310        type Error = &'static str;
311
312        fn validate_key(&self, _key: &crate::StableKey) -> Result<(), Self::Error> {
313            Ok(())
314        }
315
316        fn validate_slot(
317            &self,
318            _key: &crate::StableKey,
319            _slot: &AllocationSlotDescriptor,
320        ) -> Result<(), Self::Error> {
321            Ok(())
322        }
323
324        fn validate_reserved_slot(
325            &self,
326            _key: &crate::StableKey,
327            _slot: &AllocationSlotDescriptor,
328        ) -> Result<(), Self::Error> {
329            Err("reserved slot rejected")
330        }
331    }
332
333    #[derive(Debug, Eq, PartialEq)]
334    struct RejectActivePolicy;
335
336    impl AllocationPolicy for RejectActivePolicy {
337        type Error = &'static str;
338
339        fn validate_key(&self, _key: &crate::StableKey) -> Result<(), Self::Error> {
340            Ok(())
341        }
342
343        fn validate_slot(
344            &self,
345            _key: &crate::StableKey,
346            _slot: &AllocationSlotDescriptor,
347        ) -> Result<(), Self::Error> {
348            Err("active slot rejected")
349        }
350
351        fn validate_reserved_slot(
352            &self,
353            _key: &crate::StableKey,
354            _slot: &AllocationSlotDescriptor,
355        ) -> Result<(), Self::Error> {
356            Ok(())
357        }
358    }
359
360    fn ledger() -> AllocationLedger {
361        AllocationLedger {
362            current_generation: 0,
363            allocation_history: AllocationHistory::default(),
364        }
365    }
366
367    fn declaration() -> AllocationDeclaration {
368        AllocationDeclaration::new(
369            "app.users.v1",
370            AllocationSlotDescriptor::memory_manager(100).expect("usable slot"),
371            None,
372            SchemaMetadata::default(),
373        )
374        .expect("declaration")
375    }
376
377    #[test]
378    fn validate_and_commit_publishes_committed_generation() {
379        let mut store = LedgerCommitStore::default();
380        store.commit(&ledger()).expect("initial ledger");
381        let snapshot = DeclarationSnapshot::new(vec![declaration()]).expect("snapshot");
382
383        let commit = AllocationBootstrap::new(&mut store)
384            .validate_and_commit(snapshot, &TestPolicy, Some(42))
385            .expect("bootstrap commit");
386
387        assert_eq!(commit.ledger.current_generation, 1);
388        assert_eq!(commit.validated.generation(), 1);
389        assert_eq!(commit.ledger.allocation_history.records().len(), 1);
390        assert_eq!(commit.ledger.allocation_history.generations().len(), 1);
391    }
392
393    #[test]
394    fn initialize_validate_and_commit_seeds_empty_ledger_store() {
395        let mut store = LedgerCommitStore::default();
396        let snapshot = DeclarationSnapshot::new(vec![declaration()]).expect("snapshot");
397
398        let commit = AllocationBootstrap::new(&mut store)
399            .initialize_validate_and_commit(&ledger(), snapshot, &TestPolicy, Some(42))
400            .expect("bootstrap commit");
401
402        assert_eq!(commit.ledger.current_generation, 1);
403        assert_eq!(commit.validated.generation(), 1);
404        assert_eq!(commit.ledger.allocation_history.records().len(), 1);
405    }
406
407    #[test]
408    fn initialize_validate_and_commit_fails_closed_on_corrupt_store() {
409        let mut store = LedgerCommitStore::default();
410        store
411            .write_corrupt_inactive_ledger(&ledger())
412            .expect("corrupt ledger");
413        let snapshot = DeclarationSnapshot::new(vec![declaration()]).expect("snapshot");
414
415        let err = AllocationBootstrap::new(&mut store)
416            .initialize_validate_and_commit(&ledger(), snapshot, &TestPolicy, Some(42))
417            .expect_err("corrupt state");
418
419        assert!(matches!(err, BootstrapError::Ledger(_)));
420    }
421
422    #[test]
423    fn reserve_and_commit_policy_checks_and_commits_reservation() {
424        let mut store = LedgerCommitStore::default();
425        store.commit(&ledger()).expect("initial ledger");
426        let reservation = declaration();
427
428        let committed = AllocationBootstrap::new(&mut store)
429            .reserve_and_commit(&[reservation], &TestPolicy, Some(42))
430            .expect("reservation commit");
431
432        assert_eq!(committed.current_generation, 1);
433        assert_eq!(committed.allocation_history.records().len(), 1);
434        assert_eq!(
435            committed.allocation_history.records()[0].state(),
436            AllocationState::Reserved
437        );
438    }
439
440    #[test]
441    fn initialize_reserve_and_commit_seeds_empty_store() {
442        let mut store = LedgerCommitStore::default();
443        let reservation = declaration();
444
445        let committed = AllocationBootstrap::new(&mut store)
446            .initialize_reserve_and_commit(&ledger(), &[reservation], &TestPolicy, Some(42))
447            .expect("reservation commit");
448
449        assert_eq!(committed.current_generation, 1);
450        assert_eq!(
451            committed.allocation_history.records()[0].state(),
452            AllocationState::Reserved
453        );
454    }
455
456    #[test]
457    fn reserve_and_commit_rejects_policy_failure_before_commit() {
458        let mut store = LedgerCommitStore::default();
459        store.commit(&ledger()).expect("initial ledger");
460        let reservation = declaration();
461
462        let err = AllocationBootstrap::new(&mut store)
463            .reserve_and_commit(&[reservation], &RejectReservedPolicy, Some(42))
464            .expect_err("policy failure");
465        let recovered = store.recover().expect("recovered");
466
467        assert!(matches!(err, BootstrapReservationError::Policy(_)));
468        assert_eq!(recovered.current_generation(), 0);
469        assert!(recovered.ledger().allocation_history().records().is_empty());
470    }
471
472    #[test]
473    fn reservation_policy_alone_does_not_activate_reserved_allocation() {
474        let mut store = LedgerCommitStore::default();
475        store.commit(&ledger()).expect("initial ledger");
476        let reservation = declaration();
477        AllocationBootstrap::new(&mut store)
478            .reserve_and_commit(&[reservation], &TestPolicy, Some(42))
479            .expect("reservation commit");
480        let snapshot = DeclarationSnapshot::new(vec![declaration()]).expect("snapshot");
481
482        let err = AllocationBootstrap::new(&mut store)
483            .validate_and_commit(snapshot, &RejectActivePolicy, Some(43))
484            .expect_err("active validation must run");
485        let recovered = store.recover().expect("recovered");
486
487        assert!(matches!(
488            err,
489            BootstrapError::Validation(AllocationValidationError::Policy("active slot rejected"))
490        ));
491        assert_eq!(
492            recovered.ledger().allocation_history().records()[0].state(),
493            AllocationState::Reserved
494        );
495    }
496
497    #[test]
498    fn retire_and_commit_tombstones_through_protected_commit() {
499        let mut store = LedgerCommitStore::default();
500        store.commit(&ledger()).expect("initial ledger");
501        let snapshot = DeclarationSnapshot::new(vec![declaration()]).expect("snapshot");
502        AllocationBootstrap::new(&mut store)
503            .validate_and_commit(snapshot, &TestPolicy, Some(42))
504            .expect("active commit");
505        let retirement = AllocationRetirement::new(
506            "app.users.v1",
507            AllocationSlotDescriptor::memory_manager(100).expect("usable slot"),
508        )
509        .expect("retirement");
510
511        let committed = AllocationBootstrap::new(&mut store)
512            .retire_and_commit(&retirement, Some(43))
513            .expect("retirement commit");
514
515        assert_eq!(committed.current_generation, 2);
516        assert_eq!(
517            committed.allocation_history.records()[0].state(),
518            AllocationState::Retired
519        );
520        assert_eq!(
521            committed.allocation_history.records()[0].retired_generation(),
522            Some(2)
523        );
524    }
525
526    #[test]
527    fn retire_and_commit_rejects_unknown_key_before_commit() {
528        let mut store = LedgerCommitStore::default();
529        store.commit(&ledger()).expect("initial ledger");
530        let retirement = AllocationRetirement::new(
531            "app.users.v1",
532            AllocationSlotDescriptor::memory_manager(100).expect("usable slot"),
533        )
534        .expect("retirement");
535
536        let err = AllocationBootstrap::new(&mut store)
537            .retire_and_commit(&retirement, Some(43))
538            .expect_err("unknown key");
539        let recovered = store.recover().expect("recovered");
540
541        assert!(matches!(err, BootstrapRetirementError::Retirement(_)));
542        assert_eq!(recovered.current_generation(), 0);
543        assert!(recovered.ledger().allocation_history().records().is_empty());
544    }
545}