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