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#[non_exhaustive]
226#[derive(Clone, Debug, Eq, thiserror::Error, PartialEq)]
227pub enum BootstrapError<P> {
228    /// Ledger recovery or protected commit failed.
229    #[error(transparent)]
230    Ledger(LedgerCommitError),
231    /// Policy or historical allocation validation failed.
232    #[error(transparent)]
233    Validation(AllocationValidationError<P>),
234    /// Validated declarations could not be staged against the recovered ledger.
235    #[error(transparent)]
236    Staging(AllocationStageError),
237}
238
239///
240/// BootstrapReservationError
241///
242/// Failure to policy-check, stage, or commit an allocation reservation.
243#[non_exhaustive]
244#[derive(Clone, Debug, Eq, thiserror::Error, PartialEq)]
245pub enum BootstrapReservationError<P> {
246    /// Ledger recovery or protected commit failed.
247    #[error(transparent)]
248    Ledger(LedgerCommitError),
249    /// Policy adapter rejected a reservation declaration.
250    #[error("allocation policy rejected a reservation")]
251    Policy(P),
252    /// Reservation conflicted with historical allocation facts.
253    #[error(transparent)]
254    Reservation(AllocationReservationError),
255}
256
257///
258/// BootstrapRetirementError
259///
260/// Failure to stage or commit an explicit allocation retirement.
261#[non_exhaustive]
262#[derive(Clone, Debug, Eq, thiserror::Error, PartialEq)]
263pub enum BootstrapRetirementError {
264    /// Ledger recovery or protected commit failed.
265    #[error(transparent)]
266    Ledger(LedgerCommitError),
267    /// Retirement conflicted with historical allocation facts.
268    #[error(transparent)]
269    Retirement(AllocationRetirementError),
270}
271
272#[cfg(test)]
273mod tests {
274    use super::*;
275    use crate::{
276        declaration::AllocationDeclaration,
277        ledger::{AllocationHistory, AllocationLedger, AllocationState},
278        schema::SchemaMetadata,
279        slot::AllocationSlotDescriptor,
280    };
281
282    #[derive(Debug, Eq, PartialEq)]
283    struct TestPolicy;
284
285    impl AllocationPolicy for TestPolicy {
286        type Error = &'static str;
287
288        fn validate_key(&self, _key: &crate::StableKey) -> Result<(), Self::Error> {
289            Ok(())
290        }
291
292        fn validate_slot(
293            &self,
294            _key: &crate::StableKey,
295            _slot: &AllocationSlotDescriptor,
296        ) -> Result<(), Self::Error> {
297            Ok(())
298        }
299
300        fn validate_reserved_slot(
301            &self,
302            _key: &crate::StableKey,
303            _slot: &AllocationSlotDescriptor,
304        ) -> Result<(), Self::Error> {
305            Ok(())
306        }
307    }
308
309    #[derive(Debug, Eq, PartialEq)]
310    struct RejectReservedPolicy;
311
312    impl AllocationPolicy for RejectReservedPolicy {
313        type Error = &'static str;
314
315        fn validate_key(&self, _key: &crate::StableKey) -> Result<(), Self::Error> {
316            Ok(())
317        }
318
319        fn validate_slot(
320            &self,
321            _key: &crate::StableKey,
322            _slot: &AllocationSlotDescriptor,
323        ) -> Result<(), Self::Error> {
324            Ok(())
325        }
326
327        fn validate_reserved_slot(
328            &self,
329            _key: &crate::StableKey,
330            _slot: &AllocationSlotDescriptor,
331        ) -> Result<(), Self::Error> {
332            Err("reserved slot rejected")
333        }
334    }
335
336    #[derive(Debug, Eq, PartialEq)]
337    struct RejectActivePolicy;
338
339    impl AllocationPolicy for RejectActivePolicy {
340        type Error = &'static str;
341
342        fn validate_key(&self, _key: &crate::StableKey) -> Result<(), Self::Error> {
343            Ok(())
344        }
345
346        fn validate_slot(
347            &self,
348            _key: &crate::StableKey,
349            _slot: &AllocationSlotDescriptor,
350        ) -> Result<(), Self::Error> {
351            Err("active slot rejected")
352        }
353
354        fn validate_reserved_slot(
355            &self,
356            _key: &crate::StableKey,
357            _slot: &AllocationSlotDescriptor,
358        ) -> Result<(), Self::Error> {
359            Ok(())
360        }
361    }
362
363    fn ledger() -> AllocationLedger {
364        AllocationLedger {
365            current_generation: 0,
366            allocation_history: AllocationHistory::default(),
367        }
368    }
369
370    fn declaration() -> AllocationDeclaration {
371        AllocationDeclaration::new(
372            "app.users.v1",
373            AllocationSlotDescriptor::memory_manager(100).expect("usable slot"),
374            None,
375            SchemaMetadata::default(),
376        )
377        .expect("declaration")
378    }
379
380    #[test]
381    fn validate_and_commit_publishes_committed_generation() {
382        let mut store = LedgerCommitStore::default();
383        store.commit(&ledger()).expect("initial ledger");
384        let snapshot = DeclarationSnapshot::new(vec![declaration()]).expect("snapshot");
385
386        let commit = AllocationBootstrap::new(&mut store)
387            .validate_and_commit(snapshot, &TestPolicy, Some(42))
388            .expect("bootstrap commit");
389
390        assert_eq!(commit.ledger.current_generation, 1);
391        assert_eq!(commit.validated.generation(), 1);
392        assert_eq!(commit.ledger.allocation_history.records().len(), 1);
393        assert_eq!(commit.ledger.allocation_history.generations().len(), 1);
394    }
395
396    #[test]
397    fn initialize_validate_and_commit_seeds_empty_ledger_store() {
398        let mut store = LedgerCommitStore::default();
399        let snapshot = DeclarationSnapshot::new(vec![declaration()]).expect("snapshot");
400
401        let commit = AllocationBootstrap::new(&mut store)
402            .initialize_validate_and_commit(&ledger(), snapshot, &TestPolicy, Some(42))
403            .expect("bootstrap commit");
404
405        assert_eq!(commit.ledger.current_generation, 1);
406        assert_eq!(commit.validated.generation(), 1);
407        assert_eq!(commit.ledger.allocation_history.records().len(), 1);
408    }
409
410    #[test]
411    fn initialize_validate_and_commit_fails_closed_on_corrupt_store() {
412        let mut store = LedgerCommitStore::default();
413        store
414            .write_corrupt_inactive_ledger(&ledger())
415            .expect("corrupt ledger");
416        let snapshot = DeclarationSnapshot::new(vec![declaration()]).expect("snapshot");
417
418        let err = AllocationBootstrap::new(&mut store)
419            .initialize_validate_and_commit(&ledger(), snapshot, &TestPolicy, Some(42))
420            .expect_err("corrupt state");
421
422        assert!(matches!(err, BootstrapError::Ledger(_)));
423    }
424
425    #[test]
426    fn reserve_and_commit_policy_checks_and_commits_reservation() {
427        let mut store = LedgerCommitStore::default();
428        store.commit(&ledger()).expect("initial ledger");
429        let reservation = declaration();
430
431        let committed = AllocationBootstrap::new(&mut store)
432            .reserve_and_commit(&[reservation], &TestPolicy, Some(42))
433            .expect("reservation commit");
434
435        assert_eq!(committed.current_generation, 1);
436        assert_eq!(committed.allocation_history.records().len(), 1);
437        assert_eq!(
438            committed.allocation_history.records()[0].state(),
439            AllocationState::Reserved
440        );
441    }
442
443    #[test]
444    fn initialize_reserve_and_commit_seeds_empty_store() {
445        let mut store = LedgerCommitStore::default();
446        let reservation = declaration();
447
448        let committed = AllocationBootstrap::new(&mut store)
449            .initialize_reserve_and_commit(&ledger(), &[reservation], &TestPolicy, Some(42))
450            .expect("reservation commit");
451
452        assert_eq!(committed.current_generation, 1);
453        assert_eq!(
454            committed.allocation_history.records()[0].state(),
455            AllocationState::Reserved
456        );
457    }
458
459    #[test]
460    fn reserve_and_commit_rejects_policy_failure_before_commit() {
461        let mut store = LedgerCommitStore::default();
462        store.commit(&ledger()).expect("initial ledger");
463        let reservation = declaration();
464
465        let err = AllocationBootstrap::new(&mut store)
466            .reserve_and_commit(&[reservation], &RejectReservedPolicy, Some(42))
467            .expect_err("policy failure");
468        let recovered = store.recover().expect("recovered");
469
470        assert!(matches!(err, BootstrapReservationError::Policy(_)));
471        assert_eq!(recovered.current_generation(), 0);
472        assert!(recovered.ledger().allocation_history().records().is_empty());
473    }
474
475    #[test]
476    fn reservation_policy_alone_does_not_activate_reserved_allocation() {
477        let mut store = LedgerCommitStore::default();
478        store.commit(&ledger()).expect("initial ledger");
479        let reservation = declaration();
480        AllocationBootstrap::new(&mut store)
481            .reserve_and_commit(&[reservation], &TestPolicy, Some(42))
482            .expect("reservation commit");
483        let snapshot = DeclarationSnapshot::new(vec![declaration()]).expect("snapshot");
484
485        let err = AllocationBootstrap::new(&mut store)
486            .validate_and_commit(snapshot, &RejectActivePolicy, Some(43))
487            .expect_err("active validation must run");
488        let recovered = store.recover().expect("recovered");
489
490        assert!(matches!(
491            err,
492            BootstrapError::Validation(AllocationValidationError::Policy("active slot rejected"))
493        ));
494        assert_eq!(
495            recovered.ledger().allocation_history().records()[0].state(),
496            AllocationState::Reserved
497        );
498    }
499
500    #[test]
501    fn retire_and_commit_tombstones_through_protected_commit() {
502        let mut store = LedgerCommitStore::default();
503        store.commit(&ledger()).expect("initial ledger");
504        let snapshot = DeclarationSnapshot::new(vec![declaration()]).expect("snapshot");
505        AllocationBootstrap::new(&mut store)
506            .validate_and_commit(snapshot, &TestPolicy, Some(42))
507            .expect("active commit");
508        let retirement = AllocationRetirement::new(
509            "app.users.v1",
510            AllocationSlotDescriptor::memory_manager(100).expect("usable slot"),
511        )
512        .expect("retirement");
513
514        let committed = AllocationBootstrap::new(&mut store)
515            .retire_and_commit(&retirement, Some(43))
516            .expect("retirement commit");
517
518        assert_eq!(committed.current_generation, 2);
519        assert_eq!(
520            committed.allocation_history.records()[0].state(),
521            AllocationState::Retired
522        );
523        assert_eq!(
524            committed.allocation_history.records()[0].retired_generation(),
525            Some(2)
526        );
527    }
528
529    #[test]
530    fn retire_and_commit_rejects_unknown_key_before_commit() {
531        let mut store = LedgerCommitStore::default();
532        store.commit(&ledger()).expect("initial ledger");
533        let retirement = AllocationRetirement::new(
534            "app.users.v1",
535            AllocationSlotDescriptor::memory_manager(100).expect("usable slot"),
536        )
537        .expect("retirement");
538
539        let err = AllocationBootstrap::new(&mut store)
540            .retire_and_commit(&retirement, Some(43))
541            .expect_err("unknown key");
542        let recovered = store.recover().expect("recovered");
543
544        assert!(matches!(err, BootstrapRetirementError::Retirement(_)));
545        assert_eq!(recovered.current_generation(), 0);
546        assert!(recovered.ledger().allocation_history().records().is_empty());
547    }
548}