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