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