Skip to main content

canic_core/storage/stable/
intent.rs

1//! Stable-memory intent store primitives.
2//!
3//! Data-only storage slots for cross-canister intent tracking. The ops layer
4//! enforces mechanical invariants (uniqueness, monotonic state transitions,
5//! aggregate consistency). Policy and capacity decisions live above this layer.
6
7use crate::cdk::structures::btreemap::BTreeMap as StableBtreeMap;
8use crate::{
9    cdk::structures::{
10        DefaultMemoryImpl, Storable, cell::Cell, memory::VirtualMemory, storable::Bound,
11    },
12    ids::{IntentId, IntentResourceKey},
13    role_contract::allocation::memory::intent::{
14        INTENT_META_ID, INTENT_PENDING_ID, INTENT_RECORDS_ID, INTENT_TOTALS_ID,
15    },
16    storage::prelude::*,
17};
18use std::{borrow::Cow, cell::RefCell};
19
20//
21// INTENT STORE
22//
23
24pub const INTENT_STORE_SCHEMA_VERSION: u32 = 1;
25
26eager_static! {
27    static INTENT_META: RefCell<Cell<IntentStoreMetaRecord, VirtualMemory<DefaultMemoryImpl>>> =
28        RefCell::new(Cell::init(
29            crate::ic_memory_key!(authority = CANIC_CORE_MEMORY_AUTHORITY, key = "canic.core.intent_meta.v1", ty = IntentStoreMetaRecord, id = INTENT_META_ID),
30            IntentStoreMetaRecord::default(),
31        ));
32}
33
34eager_static! {
35    static INTENT_RECORDS: RefCell<
36        StableBtreeMap<IntentId, IntentRecord, VirtualMemory<DefaultMemoryImpl>>
37    > = RefCell::new(
38        StableBtreeMap::init(crate::ic_memory_key!(authority = CANIC_CORE_MEMORY_AUTHORITY, key = "canic.core.intent_records.v1", ty = IntentRecord, id = INTENT_RECORDS_ID)),
39    );
40}
41
42eager_static! {
43    static INTENT_TOTALS: RefCell<
44        StableBtreeMap<IntentResourceKey, IntentResourceTotalsRecord, VirtualMemory<DefaultMemoryImpl>>
45    > = RefCell::new(
46        StableBtreeMap::init(crate::ic_memory_key!(authority = CANIC_CORE_MEMORY_AUTHORITY, key = "canic.core.intent_totals.v1", ty = IntentResourceTotalsRecord, id = INTENT_TOTALS_ID)),
47    );
48}
49
50eager_static! {
51    static INTENT_PENDING: RefCell<
52        StableBtreeMap<IntentId, IntentPendingEntryRecord, VirtualMemory<DefaultMemoryImpl>>
53    > = RefCell::new(
54        StableBtreeMap::init(crate::ic_memory_key!(authority = CANIC_CORE_MEMORY_AUTHORITY, key = "canic.core.intent_pending.v1", ty = IntentPendingEntryRecord, id = INTENT_PENDING_ID)),
55    );
56}
57
58impl Storable for IntentId {
59    const BOUND: Bound = Bound::Bounded {
60        max_size: 8,
61        is_fixed_size: true,
62    };
63
64    fn to_bytes(&self) -> Cow<'_, [u8]> {
65        Cow::Owned(self.0.to_be_bytes().to_vec())
66    }
67
68    fn into_bytes(self) -> Vec<u8> {
69        self.0.to_be_bytes().to_vec()
70    }
71
72    fn from_bytes(bytes: Cow<[u8]>) -> Self {
73        let b = bytes.as_ref();
74
75        if b.len() != 8 {
76            return Self::default();
77        }
78
79        let mut arr = [0u8; 8];
80        arr.copy_from_slice(b);
81
82        Self(u64::from_be_bytes(arr))
83    }
84}
85
86///
87/// IntentState
88///
89
90#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
91pub enum IntentState {
92    Pending,
93    Committed,
94    Aborted,
95}
96
97///
98/// IntentRecord
99///
100
101#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
102pub struct IntentRecord {
103    pub id: IntentId,
104    pub resource_key: IntentResourceKey,
105    pub quantity: u64,
106    pub state: IntentState,
107    pub created_at: u64,
108    // TTL is enforced logically at read time; cleanup is asynchronous.
109    pub ttl_secs: Option<u64>,
110}
111
112impl IntentRecord {
113    pub const STATE_CONTRACT_NAME: &'static str = "IntentRecord";
114    pub const STORABLE_MAX_SIZE: u32 = 256;
115}
116
117impl_storable_bounded!(IntentRecord, IntentRecord::STORABLE_MAX_SIZE, false);
118
119///
120/// IntentStoreMetaRecord
121///
122
123#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
124pub struct IntentStoreMetaRecord {
125    pub schema_version: u32,
126    pub next_intent_id: IntentId,
127    pub pending_total: u64,
128    pub committed_total: u64,
129    pub aborted_total: u64,
130}
131
132impl IntentStoreMetaRecord {
133    pub const STATE_CONTRACT_NAME: &'static str = "IntentStoreMetaRecord";
134    pub const STORABLE_MAX_SIZE: u32 = 96;
135}
136
137impl Default for IntentStoreMetaRecord {
138    fn default() -> Self {
139        Self {
140            schema_version: INTENT_STORE_SCHEMA_VERSION,
141            next_intent_id: IntentId(1),
142            pending_total: 0,
143            committed_total: 0,
144            aborted_total: 0,
145        }
146    }
147}
148
149impl_storable_bounded!(
150    IntentStoreMetaRecord,
151    IntentStoreMetaRecord::STORABLE_MAX_SIZE,
152    false
153);
154
155///
156/// IntentResourceTotalsRecord
157///
158
159#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
160pub struct IntentResourceTotalsRecord {
161    pub reserved_qty: u64,
162    pub committed_qty: u64,
163    pub pending_count: u64,
164}
165
166impl IntentResourceTotalsRecord {
167    pub const STATE_CONTRACT_NAME: &'static str = "IntentResourceTotalsRecord";
168    pub const STORABLE_MAX_SIZE: u32 = 64;
169}
170
171impl_storable_bounded!(
172    IntentResourceTotalsRecord,
173    IntentResourceTotalsRecord::STORABLE_MAX_SIZE,
174    false
175);
176
177///
178/// IntentPendingEntryRecord
179///
180
181#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
182pub struct IntentPendingEntryRecord {
183    pub resource_key: IntentResourceKey,
184    pub quantity: u64,
185    pub created_at: u64,
186    // TTL is enforced logically at read time; cleanup is asynchronous.
187    pub ttl_secs: Option<u64>,
188}
189
190impl IntentPendingEntryRecord {
191    pub const STATE_CONTRACT_NAME: &'static str = "IntentPendingEntryRecord";
192    pub const STORABLE_MAX_SIZE: u32 = 224;
193}
194
195impl_storable_bounded!(
196    IntentPendingEntryRecord,
197    IntentPendingEntryRecord::STORABLE_MAX_SIZE,
198    false
199);
200
201///
202/// IntentMetaData
203///
204/// Canonical intent-store metadata allocation snapshot.
205///
206
207#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
208pub struct IntentMetaData {
209    pub record: IntentStoreMetaRecord,
210}
211
212impl IntentMetaData {
213    pub const STATE_CONTRACT_NAME: &'static str = "IntentMetaData";
214}
215
216///
217/// IntentRecordEntryRecord
218///
219/// One logical intent-record snapshot row preserving its stable intent ID key.
220///
221
222#[derive(Clone, Debug, Eq, PartialEq)]
223pub struct IntentRecordEntryRecord {
224    pub intent_id: IntentId,
225    pub record: IntentRecord,
226}
227
228///
229/// IntentRecordsData
230///
231/// Canonical intent-records allocation snapshot.
232///
233
234#[derive(Clone, Debug, Default, Eq, PartialEq)]
235pub struct IntentRecordsData {
236    pub entries: Vec<IntentRecordEntryRecord>,
237}
238
239impl IntentRecordsData {
240    pub const STATE_CONTRACT_NAME: &'static str = "IntentRecordsData";
241}
242
243///
244/// IntentTotalsEntryRecord
245///
246/// One logical intent-total snapshot row preserving its stable resource key.
247///
248
249#[derive(Clone, Debug, Eq, PartialEq)]
250pub struct IntentTotalsEntryRecord {
251    pub resource_key: IntentResourceKey,
252    pub record: IntentResourceTotalsRecord,
253}
254
255///
256/// IntentTotalsData
257///
258/// Canonical intent-resource-totals allocation snapshot.
259///
260
261#[derive(Clone, Debug, Default, Eq, PartialEq)]
262pub struct IntentTotalsData {
263    pub entries: Vec<IntentTotalsEntryRecord>,
264}
265
266impl IntentTotalsData {
267    pub const STATE_CONTRACT_NAME: &'static str = "IntentTotalsData";
268}
269
270///
271/// IntentPendingIndexEntryRecord
272///
273/// One logical pending-intent snapshot row preserving its stable intent ID key.
274///
275
276#[derive(Clone, Debug, Eq, PartialEq)]
277pub struct IntentPendingIndexEntryRecord {
278    pub intent_id: IntentId,
279    pub record: IntentPendingEntryRecord,
280}
281
282///
283/// IntentPendingData
284///
285/// Canonical pending-intent allocation snapshot.
286///
287
288#[derive(Clone, Debug, Default, Eq, PartialEq)]
289pub struct IntentPendingData {
290    pub entries: Vec<IntentPendingIndexEntryRecord>,
291}
292
293impl IntentPendingData {
294    pub const STATE_CONTRACT_NAME: &'static str = "IntentPendingData";
295}
296
297///
298/// IntentStore
299///
300
301pub struct IntentStore;
302
303impl IntentStore {
304    // -------------------------------------------------------------
305    // Meta
306    // -------------------------------------------------------------
307
308    #[must_use]
309    pub(crate) fn meta() -> IntentStoreMetaRecord {
310        INTENT_META.with_borrow(|cell| *cell.get())
311    }
312
313    pub(crate) fn set_meta(meta: IntentStoreMetaRecord) {
314        INTENT_META.with_borrow_mut(|cell| cell.set(meta));
315    }
316
317    // -------------------------------------------------------------
318    // Records
319    // -------------------------------------------------------------
320
321    #[must_use]
322    pub(crate) fn get_record(id: IntentId) -> Option<IntentRecord> {
323        INTENT_RECORDS.with_borrow(|map| map.get(&id))
324    }
325
326    pub(crate) fn insert_record(record: IntentRecord) -> Option<IntentRecord> {
327        INTENT_RECORDS.with_borrow_mut(|map| map.insert(record.id, record))
328    }
329
330    // -------------------------------------------------------------
331    // Totals
332    // -------------------------------------------------------------
333
334    #[must_use]
335    pub(crate) fn get_totals(key: &IntentResourceKey) -> Option<IntentResourceTotalsRecord> {
336        INTENT_TOTALS.with_borrow(|map| map.get(key))
337    }
338
339    pub(crate) fn set_totals(
340        key: IntentResourceKey,
341        totals: IntentResourceTotalsRecord,
342    ) -> Option<IntentResourceTotalsRecord> {
343        INTENT_TOTALS.with_borrow_mut(|map| map.insert(key, totals))
344    }
345
346    // -------------------------------------------------------------
347    // Pending index
348    // -------------------------------------------------------------
349
350    #[must_use]
351    pub(crate) fn get_pending(id: IntentId) -> Option<IntentPendingEntryRecord> {
352        INTENT_PENDING.with_borrow(|map| map.get(&id))
353    }
354
355    pub(crate) fn insert_pending(
356        id: IntentId,
357        entry: IntentPendingEntryRecord,
358    ) -> Option<IntentPendingEntryRecord> {
359        INTENT_PENDING.with_borrow_mut(|map| map.insert(id, entry))
360    }
361
362    pub(crate) fn remove_pending(id: IntentId) -> Option<IntentPendingEntryRecord> {
363        INTENT_PENDING.with_borrow_mut(|map| map.remove(&id))
364    }
365
366    pub(crate) fn with_pending_entries<R>(
367        f: impl FnOnce(
368            &StableBtreeMap<IntentId, IntentPendingEntryRecord, VirtualMemory<DefaultMemoryImpl>>,
369        ) -> R,
370    ) -> R {
371        INTENT_PENDING.with_borrow(|map| f(map))
372    }
373}
374
375//
376// ─────────────────────────────────────────────────────────────
377// Test helpers
378// ─────────────────────────────────────────────────────────────
379//
380
381#[cfg(test)]
382impl IntentStore {
383    #[must_use]
384    pub(crate) fn export_meta() -> IntentMetaData {
385        IntentMetaData {
386            record: Self::meta(),
387        }
388    }
389
390    pub(crate) fn import_meta(data: IntentMetaData) {
391        Self::set_meta(data.record);
392    }
393
394    #[must_use]
395    pub(crate) fn export_records() -> IntentRecordsData {
396        IntentRecordsData {
397            entries: INTENT_RECORDS.with_borrow(|map| {
398                map.iter()
399                    .map(|entry| IntentRecordEntryRecord {
400                        intent_id: *entry.key(),
401                        record: entry.value(),
402                    })
403                    .collect()
404            }),
405        }
406    }
407
408    pub(crate) fn import_records(data: IntentRecordsData) {
409        INTENT_RECORDS.with_borrow_mut(|map| {
410            map.clear_new();
411            for entry in data.entries {
412                map.insert(entry.intent_id, entry.record);
413            }
414        });
415    }
416
417    #[must_use]
418    pub(crate) fn export_totals() -> IntentTotalsData {
419        IntentTotalsData {
420            entries: INTENT_TOTALS.with_borrow(|map| {
421                map.iter()
422                    .map(|entry| IntentTotalsEntryRecord {
423                        resource_key: entry.key().clone(),
424                        record: entry.value(),
425                    })
426                    .collect()
427            }),
428        }
429    }
430
431    pub(crate) fn import_totals(data: IntentTotalsData) {
432        INTENT_TOTALS.with_borrow_mut(|map| {
433            map.clear_new();
434            for entry in data.entries {
435                map.insert(entry.resource_key, entry.record);
436            }
437        });
438    }
439
440    #[must_use]
441    pub(crate) fn export_pending() -> IntentPendingData {
442        IntentPendingData {
443            entries: INTENT_PENDING.with_borrow(|map| {
444                map.iter()
445                    .map(|entry| IntentPendingIndexEntryRecord {
446                        intent_id: *entry.key(),
447                        record: entry.value(),
448                    })
449                    .collect()
450            }),
451        }
452    }
453
454    pub(crate) fn import_pending(data: IntentPendingData) {
455        INTENT_PENDING.with_borrow_mut(|map| {
456            map.clear_new();
457            for entry in data.entries {
458                map.insert(entry.intent_id, entry.record);
459            }
460        });
461    }
462
463    pub(crate) fn reset_for_tests() {
464        INTENT_RECORDS.with_borrow_mut(StableBtreeMap::clear_new);
465        INTENT_TOTALS.with_borrow_mut(StableBtreeMap::clear_new);
466        INTENT_PENDING.with_borrow_mut(StableBtreeMap::clear_new);
467        INTENT_META.with_borrow_mut(|cell| cell.set(IntentStoreMetaRecord::default()));
468    }
469}
470
471#[cfg(test)]
472mod tests {
473    use super::*;
474
475    #[test]
476    fn intent_allocations_round_trip_through_canonical_data_snapshots() {
477        IntentStore::reset_for_tests();
478        let intent_id = IntentId(7);
479        let resource_key = IntentResourceKey::new("storage:uploads");
480        let record = IntentRecord {
481            id: intent_id,
482            resource_key: resource_key.clone(),
483            quantity: 11,
484            state: IntentState::Pending,
485            created_at: 13,
486            ttl_secs: Some(17),
487        };
488        let totals = IntentResourceTotalsRecord {
489            reserved_qty: 11,
490            committed_qty: 19,
491            pending_count: 1,
492        };
493        let pending = IntentPendingEntryRecord {
494            resource_key: resource_key.clone(),
495            quantity: 11,
496            created_at: 13,
497            ttl_secs: Some(17),
498        };
499        let meta = IntentStoreMetaRecord {
500            schema_version: INTENT_STORE_SCHEMA_VERSION,
501            next_intent_id: IntentId(8),
502            pending_total: 1,
503            committed_total: 2,
504            aborted_total: 3,
505        };
506
507        IntentStore::set_meta(meta);
508        IntentStore::insert_record(record);
509        IntentStore::set_totals(resource_key, totals);
510        IntentStore::insert_pending(intent_id, pending);
511
512        let meta_data = IntentStore::export_meta();
513        let records_data = IntentStore::export_records();
514        let totals_data = IntentStore::export_totals();
515        let pending_data = IntentStore::export_pending();
516
517        IntentStore::reset_for_tests();
518        IntentStore::import_meta(meta_data);
519        IntentStore::import_records(records_data.clone());
520        IntentStore::import_totals(totals_data.clone());
521        IntentStore::import_pending(pending_data.clone());
522
523        assert_eq!(IntentStore::export_meta(), meta_data);
524        assert_eq!(IntentStore::export_records(), records_data);
525        assert_eq!(IntentStore::export_totals(), totals_data);
526        assert_eq!(IntentStore::export_pending(), pending_data);
527        IntentStore::reset_for_tests();
528    }
529}