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::{
8    cdk::structures::{
9        DefaultMemoryImpl, Storable, cell::Cell, memory::VirtualMemory, storable::Bound,
10    },
11    ids::{IntentId, IntentResourceKey},
12    storage::{
13        prelude::*,
14        stable::memory::intent::{
15            INTENT_META_ID, INTENT_PENDING_ID, INTENT_RECORDS_ID, INTENT_TOTALS_ID,
16        },
17    },
18};
19use ic_memory::stable_structures::btreemap::BTreeMap as StableBtreeMap;
20use std::{borrow::Cow, cell::RefCell};
21
22//
23// INTENT STORE
24//
25
26pub const INTENT_STORE_SCHEMA_VERSION: u32 = 1;
27
28eager_static! {
29    static INTENT_META: RefCell<Cell<IntentStoreMetaRecord, VirtualMemory<DefaultMemoryImpl>>> =
30        RefCell::new(Cell::init(
31            crate::ic_memory_key!("canic.core.intent_meta.v1", IntentStoreMetaRecord, INTENT_META_ID),
32            IntentStoreMetaRecord::default(),
33        ));
34}
35
36eager_static! {
37    static INTENT_RECORDS: RefCell<
38        StableBtreeMap<IntentId, IntentRecord, VirtualMemory<DefaultMemoryImpl>>
39    > = RefCell::new(
40        StableBtreeMap::init(crate::ic_memory_key!("canic.core.intent_records.v1", IntentRecord, INTENT_RECORDS_ID)),
41    );
42}
43
44eager_static! {
45    static INTENT_TOTALS: RefCell<
46        StableBtreeMap<IntentResourceKey, IntentResourceTotalsRecord, VirtualMemory<DefaultMemoryImpl>>
47    > = RefCell::new(
48        StableBtreeMap::init(crate::ic_memory_key!("canic.core.intent_totals.v1", IntentResourceTotalsRecord, INTENT_TOTALS_ID)),
49    );
50}
51
52eager_static! {
53    static INTENT_PENDING: RefCell<
54        StableBtreeMap<IntentId, IntentPendingEntryRecord, VirtualMemory<DefaultMemoryImpl>>
55    > = RefCell::new(
56        StableBtreeMap::init(crate::ic_memory_key!("canic.core.intent_pending.v1", IntentPendingEntryRecord, INTENT_PENDING_ID)),
57    );
58}
59
60impl Storable for IntentId {
61    const BOUND: Bound = Bound::Bounded {
62        max_size: 8,
63        is_fixed_size: true,
64    };
65
66    fn to_bytes(&self) -> Cow<'_, [u8]> {
67        Cow::Owned(self.0.to_be_bytes().to_vec())
68    }
69
70    fn into_bytes(self) -> Vec<u8> {
71        self.0.to_be_bytes().to_vec()
72    }
73
74    fn from_bytes(bytes: Cow<[u8]>) -> Self {
75        let b = bytes.as_ref();
76
77        if b.len() != 8 {
78            return Self::default();
79        }
80
81        let mut arr = [0u8; 8];
82        arr.copy_from_slice(b);
83
84        Self(u64::from_be_bytes(arr))
85    }
86}
87
88///
89/// IntentState
90///
91
92#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
93pub enum IntentState {
94    Pending,
95    Committed,
96    Aborted,
97}
98
99///
100/// IntentRecord
101///
102
103#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
104pub struct IntentRecord {
105    pub id: IntentId,
106    pub resource_key: IntentResourceKey,
107    pub quantity: u64,
108    pub state: IntentState,
109    pub created_at: u64,
110    // TTL is enforced logically at read time; cleanup is asynchronous.
111    pub ttl_secs: Option<u64>,
112}
113
114impl IntentRecord {
115    pub const STORABLE_MAX_SIZE: u32 = 256;
116}
117
118impl_storable_bounded!(IntentRecord, IntentRecord::STORABLE_MAX_SIZE, false);
119
120///
121/// IntentStoreMetaRecord
122///
123
124#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
125pub struct IntentStoreMetaRecord {
126    pub schema_version: u32,
127    pub next_intent_id: IntentId,
128    pub pending_total: u64,
129    pub committed_total: u64,
130    pub aborted_total: u64,
131}
132
133impl 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 STORABLE_MAX_SIZE: u32 = 64;
168}
169
170impl_storable_bounded!(
171    IntentResourceTotalsRecord,
172    IntentResourceTotalsRecord::STORABLE_MAX_SIZE,
173    false
174);
175
176///
177/// IntentPendingEntryRecord
178///
179
180#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
181pub struct IntentPendingEntryRecord {
182    pub resource_key: IntentResourceKey,
183    pub quantity: u64,
184    pub created_at: u64,
185    // TTL is enforced logically at read time; cleanup is asynchronous.
186    pub ttl_secs: Option<u64>,
187}
188
189impl IntentPendingEntryRecord {
190    pub const STORABLE_MAX_SIZE: u32 = 224;
191}
192
193impl_storable_bounded!(
194    IntentPendingEntryRecord,
195    IntentPendingEntryRecord::STORABLE_MAX_SIZE,
196    false
197);
198
199///
200/// IntentStore
201///
202
203pub struct IntentStore;
204
205impl IntentStore {
206    // -------------------------------------------------------------
207    // Meta
208    // -------------------------------------------------------------
209
210    #[must_use]
211    pub(crate) fn meta() -> IntentStoreMetaRecord {
212        INTENT_META.with_borrow(|cell| *cell.get())
213    }
214
215    pub(crate) fn set_meta(meta: IntentStoreMetaRecord) {
216        INTENT_META.with_borrow_mut(|cell| cell.set(meta));
217    }
218
219    // -------------------------------------------------------------
220    // Records
221    // -------------------------------------------------------------
222
223    #[must_use]
224    pub(crate) fn get_record(id: IntentId) -> Option<IntentRecord> {
225        INTENT_RECORDS.with_borrow(|map| map.get(&id))
226    }
227
228    pub(crate) fn insert_record(record: IntentRecord) -> Option<IntentRecord> {
229        INTENT_RECORDS.with_borrow_mut(|map| map.insert(record.id, record))
230    }
231
232    // -------------------------------------------------------------
233    // Totals
234    // -------------------------------------------------------------
235
236    #[must_use]
237    pub(crate) fn get_totals(key: &IntentResourceKey) -> Option<IntentResourceTotalsRecord> {
238        INTENT_TOTALS.with_borrow(|map| map.get(key))
239    }
240
241    pub(crate) fn set_totals(
242        key: IntentResourceKey,
243        totals: IntentResourceTotalsRecord,
244    ) -> Option<IntentResourceTotalsRecord> {
245        INTENT_TOTALS.with_borrow_mut(|map| map.insert(key, totals))
246    }
247
248    // -------------------------------------------------------------
249    // Pending index
250    // -------------------------------------------------------------
251
252    #[must_use]
253    pub(crate) fn get_pending(id: IntentId) -> Option<IntentPendingEntryRecord> {
254        INTENT_PENDING.with_borrow(|map| map.get(&id))
255    }
256
257    pub(crate) fn insert_pending(
258        id: IntentId,
259        entry: IntentPendingEntryRecord,
260    ) -> Option<IntentPendingEntryRecord> {
261        INTENT_PENDING.with_borrow_mut(|map| map.insert(id, entry))
262    }
263
264    pub(crate) fn remove_pending(id: IntentId) -> Option<IntentPendingEntryRecord> {
265        INTENT_PENDING.with_borrow_mut(|map| map.remove(&id))
266    }
267
268    pub(crate) fn with_pending_entries<R>(
269        f: impl FnOnce(
270            &StableBtreeMap<IntentId, IntentPendingEntryRecord, VirtualMemory<DefaultMemoryImpl>>,
271        ) -> R,
272    ) -> R {
273        INTENT_PENDING.with_borrow(|map| f(map))
274    }
275}
276
277//
278// ─────────────────────────────────────────────────────────────
279// Test helpers
280// ─────────────────────────────────────────────────────────────
281//
282
283#[cfg(test)]
284impl IntentStore {
285    pub(crate) fn reset_for_tests() {
286        INTENT_RECORDS.with_borrow_mut(StableBtreeMap::clear_new);
287        INTENT_TOTALS.with_borrow_mut(StableBtreeMap::clear_new);
288        INTENT_PENDING.with_borrow_mut(StableBtreeMap::clear_new);
289        INTENT_META.with_borrow_mut(|cell| cell.set(IntentStoreMetaRecord::default()));
290    }
291}