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/// DiagnosticCode
98///
99/// Stable machine-readable category for an operator diagnostic failure.
100///
101
102#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
103pub enum DiagnosticCode {
104    /// Deferred static initialization could not run.
105    #[serde(rename = "eager_init")]
106    EagerInit,
107    /// Static allocation declarations could not be read.
108    #[serde(rename = "declaration_registry")]
109    DeclarationRegistry,
110    /// Static range declarations could not be read.
111    #[serde(rename = "range_registry")]
112    RangeRegistry,
113    /// Effective range authority could not be constructed or applied.
114    #[serde(rename = "range_authority")]
115    RangeAuthority,
116    /// Registered declarations could not form a valid snapshot.
117    #[serde(rename = "declaration_snapshot")]
118    DeclarationSnapshot,
119    /// Stable-cell storage could not be decoded.
120    #[serde(rename = "stable_cell")]
121    StableCell,
122    /// Persisted bytes use a recognized but unsupported durable format.
123    #[serde(rename = "unsupported_format")]
124    UnsupportedFormat,
125    /// Protected ledger recovery failed.
126    #[serde(rename = "ledger_recovery")]
127    LedgerRecovery,
128    /// An empty current-format genesis ledger could not be constructed.
129    #[serde(rename = "genesis_ledger")]
130    GenesisLedger,
131    /// Current declarations failed allocation validation.
132    #[serde(rename = "allocation_validation")]
133    AllocationValidation,
134}
135
136///
137/// DiagnosticFailure
138///
139/// Machine-readable diagnostic code paired with an operator-facing message.
140///
141
142#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
143#[serde(deny_unknown_fields)]
144pub struct DiagnosticFailure {
145    /// Stable diagnostic category.
146    pub code: DiagnosticCode,
147    /// Human-readable failure detail.
148    pub message: String,
149}
150
151impl DiagnosticFailure {
152    /// Build a coded diagnostic failure.
153    #[must_use]
154    pub fn new(code: DiagnosticCode, message: impl Into<String>) -> Self {
155        Self {
156            code,
157            message: message.into(),
158        }
159    }
160}
161
162///
163/// DiagnosticRangeAuthority
164///
165/// Read-only diagnostic view of registered and effective range authority.
166///
167
168#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
169#[serde(deny_unknown_fields)]
170pub struct DiagnosticRangeAuthority {
171    /// Range records registered directly by linked crates.
172    pub registered_records: Vec<MemoryManagerAuthorityRecord>,
173    /// Effective range authority table or its validation error.
174    pub effective_authority: Result<MemoryManagerRangeAuthority, DiagnosticFailure>,
175}
176
177impl DiagnosticRangeAuthority {
178    /// Build a range-authority diagnostic.
179    #[must_use]
180    pub const fn new(
181        registered_records: Vec<MemoryManagerAuthorityRecord>,
182        effective_authority: Result<MemoryManagerRangeAuthority, DiagnosticFailure>,
183    ) -> Self {
184        Self {
185            registered_records,
186            effective_authority,
187        }
188    }
189}
190
191///
192/// DiagnosticStableCell
193///
194/// Read-only diagnostic view of the stable-cell ledger storage envelope.
195///
196
197#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
198#[serde(deny_unknown_fields)]
199pub struct DiagnosticStableCell {
200    /// Stable-cell status.
201    pub status: DiagnosticStableCellStatus,
202    /// Backing memory size for the ledger cell.
203    pub memory_size: DiagnosticMemorySize,
204}
205
206impl DiagnosticStableCell {
207    /// Build a stable-cell diagnostic.
208    #[must_use]
209    pub const fn new(
210        status: DiagnosticStableCellStatus,
211        memory_size: DiagnosticMemorySize,
212    ) -> Self {
213        Self {
214            status,
215            memory_size,
216        }
217    }
218}
219
220///
221/// DiagnosticStableCellStatus
222///
223/// Stable-cell ledger storage status.
224///
225
226#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
227#[serde(deny_unknown_fields)]
228pub enum DiagnosticStableCellStatus {
229    /// The ledger memory is empty and can be initialized.
230    Empty,
231    /// The stable-cell envelope and ledger record decoded successfully.
232    Readable,
233    /// The ledger memory is present but could not be decoded as the expected
234    /// stable-cell ledger record.
235    Corrupt {
236        /// Stable-cell envelope or ledger-record decode failure.
237        failure: DiagnosticFailure,
238    },
239}
240
241///
242/// DiagnosticCheck
243///
244/// Read-only diagnostic status for a preflight check.
245///
246
247#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
248#[serde(deny_unknown_fields)]
249pub enum DiagnosticCheck {
250    /// The check could not run because prerequisite state was unavailable.
251    NotRun {
252        /// Stable diagnostic category.
253        code: DiagnosticCode,
254        /// Reason the check could not run.
255        message: String,
256    },
257    /// The check completed successfully.
258    Passed,
259    /// The check ran and found a problem.
260    Failed {
261        /// Stable diagnostic category.
262        code: DiagnosticCode,
263        /// Validation failure.
264        message: String,
265    },
266}
267
268impl DiagnosticCheck {
269    /// Build a passed diagnostic check.
270    #[must_use]
271    pub const fn passed() -> Self {
272        Self::Passed
273    }
274
275    /// Build a failed diagnostic check.
276    #[must_use]
277    pub fn failed(code: DiagnosticCode, message: impl Into<String>) -> Self {
278        Self::Failed {
279            code,
280            message: message.into(),
281        }
282    }
283
284    /// Build a skipped diagnostic check.
285    #[must_use]
286    pub fn not_run(code: DiagnosticCode, message: impl Into<String>) -> Self {
287        Self::NotRun {
288            code,
289            message: message.into(),
290        }
291    }
292}
293
294impl DiagnosticExport {
295    /// Build a read-only diagnostic export from an allocation ledger.
296    #[must_use]
297    pub fn from_ledger(ledger: &AllocationLedger, ledger_anchor: AllocationSlotDescriptor) -> Self {
298        Self::from_ledger_with_commit_recovery(ledger, ledger_anchor, None)
299    }
300
301    /// Build a read-only diagnostic export with protected commit recovery state.
302    #[must_use]
303    pub fn from_ledger_with_commit_recovery(
304        ledger: &AllocationLedger,
305        ledger_anchor: AllocationSlotDescriptor,
306        commit_recovery: Option<CommitStoreDiagnostic>,
307    ) -> Self {
308        Self::from_ledger_with_commit_recovery_and_memory_sizes(
309            ledger,
310            ledger_anchor,
311            commit_recovery,
312            std::iter::empty(),
313        )
314    }
315
316    /// Build a read-only diagnostic export with live memory sizes.
317    #[must_use]
318    pub fn from_ledger_with_memory_sizes(
319        ledger: &AllocationLedger,
320        ledger_anchor: AllocationSlotDescriptor,
321        memory_sizes: impl IntoIterator<Item = (AllocationSlotDescriptor, DiagnosticMemorySize)>,
322    ) -> Self {
323        Self::from_ledger_with_commit_recovery_and_memory_sizes(
324            ledger,
325            ledger_anchor,
326            None,
327            memory_sizes,
328        )
329    }
330
331    /// Build a read-only diagnostic export with protected recovery state and live memory sizes.
332    #[must_use]
333    pub fn from_ledger_with_commit_recovery_and_memory_sizes(
334        ledger: &AllocationLedger,
335        ledger_anchor: AllocationSlotDescriptor,
336        commit_recovery: Option<CommitStoreDiagnostic>,
337        memory_sizes: impl IntoIterator<Item = (AllocationSlotDescriptor, DiagnosticMemorySize)>,
338    ) -> Self {
339        let memory_sizes: BTreeMap<_, _> = memory_sizes.into_iter().collect();
340        Self {
341            current_generation: ledger.current_generation,
342            ledger_anchor,
343            records: ledger
344                .allocation_history()
345                .records()
346                .iter()
347                .cloned()
348                .map(|allocation| {
349                    let memory_size = memory_sizes.get(allocation.slot()).copied();
350                    DiagnosticRecord {
351                        allocation,
352                        memory_size,
353                    }
354                })
355                .collect(),
356            generations: ledger
357                .allocation_history()
358                .generations()
359                .iter()
360                .cloned()
361                .map(|generation| DiagnosticGeneration { generation })
362                .collect(),
363            commit_recovery,
364        }
365    }
366}
367
368///
369/// DiagnosticRecord
370///
371/// Read-only diagnostic allocation record.
372#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
373#[serde(deny_unknown_fields)]
374pub struct DiagnosticRecord {
375    /// Allocation record.
376    pub allocation: AllocationRecord,
377    /// Live backing memory size, when the exporter measured one.
378    ///
379    /// This is allocation size reported by the backing memory, not logical user
380    /// payload size inside the stable structure.
381    #[serde(skip_serializing_if = "Option::is_none")]
382    pub memory_size: Option<DiagnosticMemorySize>,
383}
384
385///
386/// DiagnosticMemorySize
387///
388/// Live size reported by a backing stable memory.
389///
390
391#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
392#[serde(deny_unknown_fields)]
393pub struct DiagnosticMemorySize {
394    /// WebAssembly pages reported by the memory.
395    pub wasm_pages: u64,
396    /// Bytes represented by the page count.
397    pub bytes: u64,
398}
399
400impl DiagnosticMemorySize {
401    /// Build a size from a WebAssembly page count.
402    #[must_use]
403    pub const fn from_wasm_pages(wasm_pages: u64) -> Self {
404        Self {
405            wasm_pages,
406            bytes: wasm_pages.saturating_mul(WASM_PAGE_SIZE_BYTES),
407        }
408    }
409}
410
411///
412/// DiagnosticGeneration
413///
414/// Read-only diagnostic generation record.
415#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
416#[serde(deny_unknown_fields)]
417pub struct DiagnosticGeneration {
418    /// Generation record.
419    pub generation: GenerationRecord,
420}
421
422#[cfg(test)]
423mod tests {
424    use super::*;
425    use crate::{
426        declaration::AllocationDeclaration,
427        ledger::{AllocationHistory, AllocationRecord},
428        physical::{CommitRecoveryError, CommitSlotDiagnostic, CommitStoreDiagnostic},
429        schema::SchemaMetadata,
430    };
431
432    #[test]
433    fn diagnostic_export_copies_ledger_records() {
434        let declaration = AllocationDeclaration::new(
435            "app.users.v1",
436            AllocationSlotDescriptor::memory_manager(100).expect("usable slot"),
437            None,
438            SchemaMetadata::default(),
439        )
440        .expect("declaration");
441        let ledger = AllocationLedger {
442            current_generation: 3,
443            allocation_history: AllocationHistory::from_parts(
444                vec![AllocationRecord::active(3, declaration).expect("valid schema metadata")],
445                vec![GenerationRecord {
446                    generation: 3,
447                    parent_generation: 2,
448                    runtime_fingerprint: Some("wasm:abc123".to_string()),
449                    declaration_count: 1,
450                    committed_at: None,
451                }],
452            ),
453        };
454
455        let export = DiagnosticExport::from_ledger(
456            &ledger,
457            AllocationSlotDescriptor::memory_manager(0).expect("usable slot"),
458        );
459
460        assert_eq!(export.current_generation, 3);
461        assert_eq!(export.records.len(), 1);
462        assert_eq!(export.records[0].memory_size, None);
463        assert_eq!(export.generations.len(), 1);
464        assert_eq!(
465            export.ledger_anchor,
466            AllocationSlotDescriptor::memory_manager(0).expect("usable slot")
467        );
468        assert_eq!(export.commit_recovery, None);
469    }
470
471    #[test]
472    fn diagnostic_export_rejects_unknown_top_level_fields() {
473        use crate::test_cbor::Value;
474
475        let export = DiagnosticExport {
476            current_generation: 0,
477            ledger_anchor: AllocationSlotDescriptor::memory_manager(0).expect("usable slot"),
478            records: Vec::new(),
479            generations: Vec::new(),
480            commit_recovery: None,
481        };
482        let Value::Map(mut map) = crate::test_cbor::to_value(export).expect("diagnostic value")
483        else {
484            panic!("diagnostic export encodes as a map");
485        };
486        crate::test_cbor::map_insert(
487            &mut map,
488            Value::Text("future_field".to_string()),
489            Value::Bool(true),
490        );
491        let bytes = crate::test_cbor::to_vec(&Value::Map(map)).expect("diagnostic bytes");
492
493        let err = crate::test_cbor::from_slice::<DiagnosticExport>(&bytes)
494            .expect_err("unknown diagnostic field must fail closed");
495
496        assert!(err.to_string().contains("future_field"));
497    }
498
499    #[test]
500    fn diagnostic_outcome_states_round_trip() {
501        let stable_cell = DiagnosticStableCell::new(
502            DiagnosticStableCellStatus::Corrupt {
503                failure: DiagnosticFailure::new(
504                    DiagnosticCode::StableCell,
505                    "bad stable-cell record",
506                ),
507            },
508            DiagnosticMemorySize::from_wasm_pages(1),
509        );
510        let range_authority = DiagnosticRangeAuthority::new(
511            Vec::new(),
512            Err(DiagnosticFailure::new(
513                DiagnosticCode::RangeAuthority,
514                "overlapping authority ranges",
515            )),
516        );
517        let check = DiagnosticCheck::failed(
518            DiagnosticCode::AllocationValidation,
519            "duplicate declaration",
520        );
521
522        for value in [DiagnosticCheck::passed(), check] {
523            let bytes = crate::test_cbor::to_vec(&value).expect("check bytes");
524            let decoded: DiagnosticCheck =
525                crate::test_cbor::from_slice(&bytes).expect("check round trip");
526            assert_eq!(decoded, value);
527        }
528
529        let bytes = crate::test_cbor::to_vec(&stable_cell).expect("stable-cell diagnostic bytes");
530        let decoded: DiagnosticStableCell =
531            crate::test_cbor::from_slice(&bytes).expect("stable-cell round trip");
532        assert_eq!(decoded, stable_cell);
533
534        let bytes = crate::test_cbor::to_vec(&range_authority).expect("range diagnostic bytes");
535        let decoded: DiagnosticRangeAuthority =
536            crate::test_cbor::from_slice(&bytes).expect("range round trip");
537        assert_eq!(decoded, range_authority);
538    }
539
540    #[test]
541    fn diagnostic_codes_have_stable_wire_names() {
542        let cases = [
543            (DiagnosticCode::EagerInit, "eager_init"),
544            (DiagnosticCode::DeclarationRegistry, "declaration_registry"),
545            (DiagnosticCode::RangeRegistry, "range_registry"),
546            (DiagnosticCode::RangeAuthority, "range_authority"),
547            (DiagnosticCode::DeclarationSnapshot, "declaration_snapshot"),
548            (DiagnosticCode::StableCell, "stable_cell"),
549            (DiagnosticCode::UnsupportedFormat, "unsupported_format"),
550            (DiagnosticCode::LedgerRecovery, "ledger_recovery"),
551            (DiagnosticCode::GenesisLedger, "genesis_ledger"),
552            (
553                DiagnosticCode::AllocationValidation,
554                "allocation_validation",
555            ),
556        ];
557
558        for (code, expected) in cases {
559            assert_eq!(
560                crate::test_cbor::to_value(code).expect("diagnostic code value"),
561                crate::test_cbor::Value::Text(expected.to_string())
562            );
563        }
564    }
565
566    #[test]
567    fn diagnostic_export_can_include_commit_recovery_state() {
568        let ledger = AllocationLedger {
569            current_generation: 3,
570            allocation_history: AllocationHistory::default(),
571        };
572        let commit_recovery = CommitStoreDiagnostic {
573            slot0: CommitSlotDiagnostic::Valid { generation: 3 },
574            slot1: CommitSlotDiagnostic::Empty,
575            recovery: Ok(3),
576        };
577
578        let export = DiagnosticExport::from_ledger_with_commit_recovery(
579            &ledger,
580            AllocationSlotDescriptor::memory_manager(0).expect("usable slot"),
581            Some(commit_recovery),
582        );
583
584        assert_eq!(export.commit_recovery, Some(commit_recovery));
585    }
586
587    #[test]
588    fn diagnostic_export_can_include_memory_sizes() {
589        let declaration = AllocationDeclaration::new(
590            "app.users.v1",
591            AllocationSlotDescriptor::memory_manager(100).expect("usable slot"),
592            None,
593            SchemaMetadata::default(),
594        )
595        .expect("declaration");
596        let ledger = AllocationLedger {
597            current_generation: 3,
598            allocation_history: AllocationHistory::from_parts(
599                vec![AllocationRecord::active(3, declaration).expect("valid schema metadata")],
600                Vec::new(),
601            ),
602        };
603
604        let export = DiagnosticExport::from_ledger_with_memory_sizes(
605            &ledger,
606            AllocationSlotDescriptor::memory_manager(0).expect("usable slot"),
607            [(
608                AllocationSlotDescriptor::memory_manager(100).expect("usable slot"),
609                DiagnosticMemorySize::from_wasm_pages(2),
610            )],
611        );
612
613        assert_eq!(
614            export.records[0].memory_size,
615            Some(DiagnosticMemorySize {
616                wasm_pages: 2,
617                bytes: 131_072,
618            })
619        );
620    }
621
622    #[test]
623    fn diagnostic_export_can_report_recovery_failure() {
624        let ledger = AllocationLedger {
625            current_generation: 0,
626            allocation_history: AllocationHistory::default(),
627        };
628        let commit_recovery = CommitStoreDiagnostic {
629            slot0: CommitSlotDiagnostic::Empty,
630            slot1: CommitSlotDiagnostic::Empty,
631            recovery: Err(CommitRecoveryError::NoValidGeneration),
632        };
633
634        let export = DiagnosticExport::from_ledger_with_commit_recovery(
635            &ledger,
636            AllocationSlotDescriptor::memory_manager(0).expect("usable slot"),
637            Some(commit_recovery),
638        );
639
640        assert_eq!(
641            export.commit_recovery.expect("commit recovery").recovery,
642            Err(CommitRecoveryError::NoValidGeneration)
643        );
644    }
645}