Skip to main content

ic_memory/
diagnostics.rs

1use crate::{
2    constants::WASM_PAGE_SIZE_BYTES,
3    declaration::AllocationDeclaration,
4    ledger::{AllocationLedger, AllocationRecord, GenerationRecord},
5    physical::CommitStoreDiagnostic,
6    slot::{AllocationSlotDescriptor, MemoryManagerAuthorityRecord, MemoryManagerRangeAuthority},
7};
8use serde::{Deserialize, Serialize};
9use std::collections::BTreeMap;
10
11///
12/// DiagnosticExport
13///
14/// Read-only machine-readable allocation ledger export.
15#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
16#[serde(deny_unknown_fields)]
17pub struct DiagnosticExport {
18    /// Current committed generation.
19    pub current_generation: u64,
20    /// Ledger anchor descriptor.
21    pub ledger_anchor: AllocationSlotDescriptor,
22    /// Allocation records.
23    pub records: Vec<DiagnosticRecord>,
24    /// Generation records.
25    pub generations: Vec<DiagnosticGeneration>,
26    /// Optional protected commit recovery diagnostic.
27    #[serde(deserialize_with = "crate::cbor::deserialize_present_option")]
28    pub commit_recovery: Option<CommitStoreDiagnostic>,
29}
30
31///
32/// DefaultMemoryManagerDoctorReport
33///
34/// Preflight and runtime diagnostic report for the default `MemoryManager`
35/// integration.
36///
37/// This report is intended for operator-facing diagnostics. Recoverable
38/// runtime problems, such as corrupt stable-cell bytes or commit recovery
39/// failure, are represented as fields instead of aborting report construction.
40///
41
42#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
43#[serde(deny_unknown_fields)]
44pub struct DefaultMemoryManagerDoctorReport {
45    /// Whether the default runtime has completed bootstrap validation.
46    pub bootstrapped: bool,
47    /// Ledger anchor descriptor used by the default runtime.
48    pub ledger_anchor: AllocationSlotDescriptor,
49    /// Stable-cell ledger storage status.
50    pub stable_cell: DiagnosticStableCell,
51    /// Protected commit recovery status when a ledger record was readable.
52    #[serde(deserialize_with = "crate::cbor::deserialize_present_option")]
53    pub commit_recovery: Option<CommitStoreDiagnostic>,
54    /// Recovered allocation ledger export when protected recovery succeeded.
55    #[serde(deserialize_with = "crate::cbor::deserialize_present_option")]
56    pub ledger: Option<DiagnosticExport>,
57    /// Static declarations registered by linked crates.
58    pub registered_declarations: Vec<DiagnosticDeclaration>,
59    /// Static range authority registered by linked crates and the effective
60    /// authority table used by the default runtime.
61    pub range_authority: DiagnosticRangeAuthority,
62    /// Current generic default-runtime declaration validation preflight result.
63    ///
64    /// Caller-supplied policies passed to
65    /// [`crate::bootstrap_default_memory_manager_with_policy`] are not
66    /// represented in this check.
67    pub validation: DiagnosticCheck,
68}
69
70///
71/// DiagnosticDeclaration
72///
73/// Read-only diagnostic view of one static allocation declaration.
74///
75
76#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
77#[serde(deny_unknown_fields)]
78pub struct DiagnosticDeclaration {
79    /// Crate or integration authority that registered the declaration.
80    pub authority: String,
81    /// Allocation declaration registered by that authority.
82    pub declaration: AllocationDeclaration,
83}
84
85impl DiagnosticDeclaration {
86    /// Build a diagnostic declaration record.
87    #[must_use]
88    pub fn new(authority: impl Into<String>, declaration: AllocationDeclaration) -> Self {
89        Self {
90            authority: authority.into(),
91            declaration,
92        }
93    }
94}
95
96///
97/// DiagnosticRangeAuthority
98///
99/// Read-only diagnostic view of registered and effective range authority.
100///
101
102#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
103#[serde(deny_unknown_fields)]
104pub struct DiagnosticRangeAuthority {
105    /// Range records registered directly by linked crates.
106    pub registered_records: Vec<MemoryManagerAuthorityRecord>,
107    /// Effective range authority table or its validation error.
108    pub effective_authority: Result<MemoryManagerRangeAuthority, String>,
109}
110
111impl DiagnosticRangeAuthority {
112    /// Build a range-authority diagnostic.
113    #[must_use]
114    pub const fn new(
115        registered_records: Vec<MemoryManagerAuthorityRecord>,
116        effective_authority: Result<MemoryManagerRangeAuthority, String>,
117    ) -> Self {
118        Self {
119            registered_records,
120            effective_authority,
121        }
122    }
123}
124
125///
126/// DiagnosticStableCell
127///
128/// Read-only diagnostic view of the stable-cell ledger storage envelope.
129///
130
131#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
132#[serde(deny_unknown_fields)]
133pub struct DiagnosticStableCell {
134    /// Stable-cell status.
135    pub status: DiagnosticStableCellStatus,
136    /// Backing memory size for the ledger cell.
137    pub memory_size: DiagnosticMemorySize,
138}
139
140impl DiagnosticStableCell {
141    /// Build a stable-cell diagnostic.
142    #[must_use]
143    pub const fn new(
144        status: DiagnosticStableCellStatus,
145        memory_size: DiagnosticMemorySize,
146    ) -> Self {
147        Self {
148            status,
149            memory_size,
150        }
151    }
152}
153
154///
155/// DiagnosticStableCellStatus
156///
157/// Stable-cell ledger storage status.
158///
159
160#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
161#[serde(deny_unknown_fields)]
162pub enum DiagnosticStableCellStatus {
163    /// The ledger memory is empty and can be initialized.
164    Empty,
165    /// The stable-cell envelope and ledger record decoded successfully.
166    Readable,
167    /// The ledger memory is present but could not be decoded as the expected
168    /// stable-cell ledger record.
169    Corrupt {
170        /// Stable-cell envelope or ledger-record decode error.
171        error: String,
172    },
173}
174
175///
176/// DiagnosticCheck
177///
178/// Read-only diagnostic status for a preflight check.
179///
180
181#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
182#[serde(deny_unknown_fields)]
183pub enum DiagnosticCheck {
184    /// The check could not run because prerequisite state was unavailable.
185    NotRun {
186        /// Reason the check could not run.
187        message: String,
188    },
189    /// The check completed successfully.
190    Passed,
191    /// The check ran and found a problem.
192    Failed {
193        /// Validation failure.
194        message: String,
195    },
196}
197
198impl DiagnosticCheck {
199    /// Build a passed diagnostic check.
200    #[must_use]
201    pub const fn passed() -> Self {
202        Self::Passed
203    }
204
205    /// Build a failed diagnostic check.
206    #[must_use]
207    pub fn failed(message: impl Into<String>) -> Self {
208        Self::Failed {
209            message: message.into(),
210        }
211    }
212
213    /// Build a skipped diagnostic check.
214    #[must_use]
215    pub fn not_run(message: impl Into<String>) -> Self {
216        Self::NotRun {
217            message: message.into(),
218        }
219    }
220}
221
222impl DiagnosticExport {
223    /// Build a read-only diagnostic export from an allocation ledger.
224    #[must_use]
225    pub fn from_ledger(ledger: &AllocationLedger, ledger_anchor: AllocationSlotDescriptor) -> Self {
226        Self::from_ledger_with_commit_recovery(ledger, ledger_anchor, None)
227    }
228
229    /// Build a read-only diagnostic export with protected commit recovery state.
230    #[must_use]
231    pub fn from_ledger_with_commit_recovery(
232        ledger: &AllocationLedger,
233        ledger_anchor: AllocationSlotDescriptor,
234        commit_recovery: Option<CommitStoreDiagnostic>,
235    ) -> Self {
236        Self::from_ledger_with_commit_recovery_and_memory_sizes(
237            ledger,
238            ledger_anchor,
239            commit_recovery,
240            std::iter::empty(),
241        )
242    }
243
244    /// Build a read-only diagnostic export with live memory sizes.
245    #[must_use]
246    pub fn from_ledger_with_memory_sizes(
247        ledger: &AllocationLedger,
248        ledger_anchor: AllocationSlotDescriptor,
249        memory_sizes: impl IntoIterator<Item = (AllocationSlotDescriptor, DiagnosticMemorySize)>,
250    ) -> Self {
251        Self::from_ledger_with_commit_recovery_and_memory_sizes(
252            ledger,
253            ledger_anchor,
254            None,
255            memory_sizes,
256        )
257    }
258
259    /// Build a read-only diagnostic export with protected recovery state and live memory sizes.
260    #[must_use]
261    pub fn from_ledger_with_commit_recovery_and_memory_sizes(
262        ledger: &AllocationLedger,
263        ledger_anchor: AllocationSlotDescriptor,
264        commit_recovery: Option<CommitStoreDiagnostic>,
265        memory_sizes: impl IntoIterator<Item = (AllocationSlotDescriptor, DiagnosticMemorySize)>,
266    ) -> Self {
267        let memory_sizes: BTreeMap<_, _> = memory_sizes.into_iter().collect();
268        Self {
269            current_generation: ledger.current_generation,
270            ledger_anchor,
271            records: ledger
272                .allocation_history()
273                .records()
274                .iter()
275                .cloned()
276                .map(|allocation| {
277                    let memory_size = memory_sizes.get(allocation.slot()).copied();
278                    DiagnosticRecord {
279                        allocation,
280                        memory_size,
281                    }
282                })
283                .collect(),
284            generations: ledger
285                .allocation_history()
286                .generations()
287                .iter()
288                .cloned()
289                .map(|generation| DiagnosticGeneration { generation })
290                .collect(),
291            commit_recovery,
292        }
293    }
294}
295
296///
297/// DiagnosticRecord
298///
299/// Read-only diagnostic allocation record.
300#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
301#[serde(deny_unknown_fields)]
302pub struct DiagnosticRecord {
303    /// Allocation record.
304    pub allocation: AllocationRecord,
305    /// Live backing memory size, when the exporter measured one.
306    ///
307    /// This is allocation size reported by the backing memory, not logical user
308    /// payload size inside the stable structure.
309    #[serde(skip_serializing_if = "Option::is_none")]
310    pub memory_size: Option<DiagnosticMemorySize>,
311}
312
313///
314/// DiagnosticMemorySize
315///
316/// Live size reported by a backing stable memory.
317///
318
319#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
320#[serde(deny_unknown_fields)]
321pub struct DiagnosticMemorySize {
322    /// WebAssembly pages reported by the memory.
323    pub wasm_pages: u64,
324    /// Bytes represented by the page count.
325    pub bytes: u64,
326}
327
328impl DiagnosticMemorySize {
329    /// Build a size from a WebAssembly page count.
330    #[must_use]
331    pub const fn from_wasm_pages(wasm_pages: u64) -> Self {
332        Self {
333            wasm_pages,
334            bytes: wasm_pages.saturating_mul(WASM_PAGE_SIZE_BYTES),
335        }
336    }
337}
338
339///
340/// DiagnosticGeneration
341///
342/// Read-only diagnostic generation record.
343#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
344#[serde(deny_unknown_fields)]
345pub struct DiagnosticGeneration {
346    /// Generation record.
347    pub generation: GenerationRecord,
348}
349
350#[cfg(test)]
351mod tests {
352    use super::*;
353    use crate::{
354        declaration::AllocationDeclaration,
355        ledger::{AllocationHistory, AllocationRecord},
356        physical::{CommitRecoveryError, CommitSlotDiagnostic, CommitStoreDiagnostic},
357        schema::SchemaMetadata,
358    };
359
360    #[test]
361    fn diagnostic_export_copies_ledger_records() {
362        let declaration = AllocationDeclaration::new(
363            "app.users.v1",
364            AllocationSlotDescriptor::memory_manager(100).expect("usable slot"),
365            None,
366            SchemaMetadata::default(),
367        )
368        .expect("declaration");
369        let ledger = AllocationLedger {
370            current_generation: 3,
371            allocation_history: AllocationHistory::from_parts(
372                vec![AllocationRecord::active(3, declaration).expect("valid schema metadata")],
373                vec![GenerationRecord {
374                    generation: 3,
375                    parent_generation: 2,
376                    runtime_fingerprint: Some("wasm:abc123".to_string()),
377                    declaration_count: 1,
378                    committed_at: None,
379                }],
380            ),
381        };
382
383        let export = DiagnosticExport::from_ledger(
384            &ledger,
385            AllocationSlotDescriptor::memory_manager(0).expect("usable slot"),
386        );
387
388        assert_eq!(export.current_generation, 3);
389        assert_eq!(export.records.len(), 1);
390        assert_eq!(export.records[0].memory_size, None);
391        assert_eq!(export.generations.len(), 1);
392        assert_eq!(
393            export.ledger_anchor,
394            AllocationSlotDescriptor::memory_manager(0).expect("usable slot")
395        );
396        assert_eq!(export.commit_recovery, None);
397    }
398
399    #[test]
400    fn diagnostic_export_rejects_unknown_top_level_fields() {
401        use crate::test_cbor::Value;
402
403        let export = DiagnosticExport {
404            current_generation: 0,
405            ledger_anchor: AllocationSlotDescriptor::memory_manager(0).expect("usable slot"),
406            records: Vec::new(),
407            generations: Vec::new(),
408            commit_recovery: None,
409        };
410        let Value::Map(mut map) = crate::test_cbor::to_value(export).expect("diagnostic value")
411        else {
412            panic!("diagnostic export encodes as a map");
413        };
414        crate::test_cbor::map_insert(
415            &mut map,
416            Value::Text("future_field".to_string()),
417            Value::Bool(true),
418        );
419        let bytes = crate::test_cbor::to_vec(&Value::Map(map)).expect("diagnostic bytes");
420
421        let err = crate::test_cbor::from_slice::<DiagnosticExport>(&bytes)
422            .expect_err("unknown diagnostic field must fail closed");
423
424        assert!(err.to_string().contains("future_field"));
425    }
426
427    #[test]
428    fn diagnostic_outcome_states_round_trip() {
429        let stable_cell = DiagnosticStableCell::new(
430            DiagnosticStableCellStatus::Corrupt {
431                error: "bad stable-cell record".to_string(),
432            },
433            DiagnosticMemorySize::from_wasm_pages(1),
434        );
435        let range_authority = DiagnosticRangeAuthority::new(
436            Vec::new(),
437            Err("overlapping authority ranges".to_string()),
438        );
439        let check = DiagnosticCheck::failed("duplicate declaration");
440
441        for value in [DiagnosticCheck::passed(), check] {
442            let bytes = crate::test_cbor::to_vec(&value).expect("check bytes");
443            let decoded: DiagnosticCheck =
444                crate::test_cbor::from_slice(&bytes).expect("check round trip");
445            assert_eq!(decoded, value);
446        }
447
448        let bytes = crate::test_cbor::to_vec(&stable_cell).expect("stable-cell diagnostic bytes");
449        let decoded: DiagnosticStableCell =
450            crate::test_cbor::from_slice(&bytes).expect("stable-cell round trip");
451        assert_eq!(decoded, stable_cell);
452
453        let bytes = crate::test_cbor::to_vec(&range_authority).expect("range diagnostic bytes");
454        let decoded: DiagnosticRangeAuthority =
455            crate::test_cbor::from_slice(&bytes).expect("range round trip");
456        assert_eq!(decoded, range_authority);
457    }
458
459    #[test]
460    fn diagnostic_export_can_include_commit_recovery_state() {
461        let ledger = AllocationLedger {
462            current_generation: 3,
463            allocation_history: AllocationHistory::default(),
464        };
465        let commit_recovery = CommitStoreDiagnostic {
466            slot0: CommitSlotDiagnostic::Valid { generation: 3 },
467            slot1: CommitSlotDiagnostic::Empty,
468            recovery: Ok(3),
469        };
470
471        let export = DiagnosticExport::from_ledger_with_commit_recovery(
472            &ledger,
473            AllocationSlotDescriptor::memory_manager(0).expect("usable slot"),
474            Some(commit_recovery),
475        );
476
477        assert_eq!(export.commit_recovery, Some(commit_recovery));
478    }
479
480    #[test]
481    fn diagnostic_export_can_include_memory_sizes() {
482        let declaration = AllocationDeclaration::new(
483            "app.users.v1",
484            AllocationSlotDescriptor::memory_manager(100).expect("usable slot"),
485            None,
486            SchemaMetadata::default(),
487        )
488        .expect("declaration");
489        let ledger = AllocationLedger {
490            current_generation: 3,
491            allocation_history: AllocationHistory::from_parts(
492                vec![AllocationRecord::active(3, declaration).expect("valid schema metadata")],
493                Vec::new(),
494            ),
495        };
496
497        let export = DiagnosticExport::from_ledger_with_memory_sizes(
498            &ledger,
499            AllocationSlotDescriptor::memory_manager(0).expect("usable slot"),
500            [(
501                AllocationSlotDescriptor::memory_manager(100).expect("usable slot"),
502                DiagnosticMemorySize::from_wasm_pages(2),
503            )],
504        );
505
506        assert_eq!(
507            export.records[0].memory_size,
508            Some(DiagnosticMemorySize {
509                wasm_pages: 2,
510                bytes: 131_072,
511            })
512        );
513    }
514
515    #[test]
516    fn diagnostic_export_can_report_recovery_failure() {
517        let ledger = AllocationLedger {
518            current_generation: 0,
519            allocation_history: AllocationHistory::default(),
520        };
521        let commit_recovery = CommitStoreDiagnostic {
522            slot0: CommitSlotDiagnostic::Empty,
523            slot1: CommitSlotDiagnostic::Empty,
524            recovery: Err(CommitRecoveryError::NoValidGeneration),
525        };
526
527        let export = DiagnosticExport::from_ledger_with_commit_recovery(
528            &ledger,
529            AllocationSlotDescriptor::memory_manager(0).expect("usable slot"),
530            Some(commit_recovery),
531        );
532
533        assert_eq!(
534            export.commit_recovery.expect("commit recovery").recovery,
535            Err(CommitRecoveryError::NoValidGeneration)
536        );
537    }
538}