Skip to main content

ferrum_types/
resource_trace.rs

1//! Shared resource lifecycle event envelope for offline invariant checks.
2
3use serde::{Deserialize, Serialize};
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
6#[serde(rename_all = "snake_case")]
7pub enum ResourceAction {
8    RequestOpen,
9    Reserve,
10    Commit,
11    Defer,
12    Reject,
13    Release,
14    Rollback,
15    RequestClose,
16    CapacitySnapshot,
17}
18
19#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
20pub struct ResourceTraceEvent {
21    pub owner_kind: String,
22    pub owner_id: String,
23    pub resource_kind: String,
24    pub action: ResourceAction,
25    #[serde(default, skip_serializing_if = "Option::is_none")]
26    pub amount: Option<i64>,
27    #[serde(default, skip_serializing_if = "Option::is_none")]
28    pub before: Option<i64>,
29    #[serde(default, skip_serializing_if = "Option::is_none")]
30    pub after: Option<i64>,
31    #[serde(default, skip_serializing_if = "Option::is_none")]
32    pub capacity: Option<i64>,
33    #[serde(default, skip_serializing_if = "Option::is_none")]
34    pub underflow_amount: Option<i64>,
35    #[serde(default, skip_serializing_if = "Option::is_none")]
36    pub reason: Option<String>,
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    pub error_kind: Option<String>,
39    #[serde(default, skip_serializing_if = "Option::is_none")]
40    pub message: Option<String>,
41    #[serde(default, skip_serializing_if = "Option::is_none")]
42    pub resource_error_kind: Option<String>,
43}
44
45impl ResourceTraceEvent {
46    pub fn validate(&self) -> std::result::Result<(), String> {
47        if self.owner_kind.trim().is_empty() {
48            return Err("resource owner_kind must be non-empty".to_string());
49        }
50        if self.owner_id.trim().is_empty() {
51            return Err("resource owner_id must be non-empty".to_string());
52        }
53        if self.resource_kind.trim().is_empty() {
54            return Err("resource_kind must be non-empty".to_string());
55        }
56        if self.error_kind.as_deref().is_some_and(str::is_empty) {
57            return Err("resource error_kind must be non-empty when set".to_string());
58        }
59        if self.message.as_deref().is_some_and(str::is_empty) {
60            return Err("resource message must be non-empty when set".to_string());
61        }
62        if self
63            .resource_error_kind
64            .as_deref()
65            .is_some_and(str::is_empty)
66        {
67            return Err("resource resource_error_kind must be non-empty when set".to_string());
68        }
69        if self.underflow_amount.is_some_and(|amount| amount <= 0) {
70            return Err("resource underflow_amount must be positive when set".to_string());
71        }
72        match self.action {
73            ResourceAction::Reserve
74            | ResourceAction::Commit
75            | ResourceAction::Release
76            | ResourceAction::Rollback => {
77                if self.amount.is_none() {
78                    return Err("resource amount is required for lifecycle action".to_string());
79                }
80                if self.before.is_none() || self.after.is_none() {
81                    return Err(
82                        "resource before/after are required for lifecycle action".to_string()
83                    );
84                }
85            }
86            ResourceAction::Defer | ResourceAction::Reject => {
87                if self.reason.as_deref().unwrap_or("").trim().is_empty() {
88                    return Err("resource defer/reject reason must be non-empty".to_string());
89                }
90            }
91            ResourceAction::CapacitySnapshot => {
92                if self.capacity.is_none() {
93                    return Err("resource capacity is required for capacity_snapshot".to_string());
94                }
95            }
96            ResourceAction::RequestOpen | ResourceAction::RequestClose => {}
97        }
98        Ok(())
99    }
100}
101
102#[cfg(test)]
103mod tests {
104    use super::*;
105
106    #[test]
107    fn lifecycle_event_requires_owner_and_before_after() {
108        let event = ResourceTraceEvent {
109            owner_kind: "request".to_string(),
110            owner_id: "req-1".to_string(),
111            resource_kind: "kv_block".to_string(),
112            action: ResourceAction::Reserve,
113            amount: Some(1),
114            before: Some(4),
115            after: Some(3),
116            capacity: Some(4),
117            underflow_amount: None,
118            reason: None,
119            error_kind: None,
120            message: None,
121            resource_error_kind: None,
122        };
123        event.validate().unwrap();
124
125        let mut missing_owner = event.clone();
126        missing_owner.owner_id.clear();
127        assert!(missing_owner.validate().is_err());
128
129        let mut missing_after = event;
130        missing_after.after = None;
131        assert!(missing_after.validate().is_err());
132    }
133
134    #[test]
135    fn lifecycle_event_allows_failure_diagnostics_when_non_empty() {
136        let mut event = ResourceTraceEvent {
137            owner_kind: "request".to_string(),
138            owner_id: "req-oom".to_string(),
139            resource_kind: "backend_workspace".to_string(),
140            action: ResourceAction::RequestClose,
141            amount: None,
142            before: None,
143            after: None,
144            capacity: None,
145            underflow_amount: None,
146            reason: None,
147            error_kind: Some("cuda_oom".to_string()),
148            message: Some("CUDA out of memory".to_string()),
149            resource_error_kind: Some("kv_capacity_exhausted".to_string()),
150        };
151        event.validate().unwrap();
152
153        event.error_kind = Some(String::new());
154        assert!(event.validate().is_err());
155    }
156
157    #[test]
158    fn lifecycle_event_allows_positive_underflow_amount_only() {
159        let mut event = ResourceTraceEvent {
160            owner_kind: "request".to_string(),
161            owner_id: "req-underflow".to_string(),
162            resource_kind: "kv_block".to_string(),
163            action: ResourceAction::Release,
164            amount: Some(2),
165            before: Some(1),
166            after: Some(-1),
167            capacity: Some(4),
168            underflow_amount: Some(1),
169            reason: None,
170            error_kind: None,
171            message: None,
172            resource_error_kind: None,
173        };
174        event.validate().unwrap();
175
176        event.underflow_amount = Some(0);
177        assert!(event.validate().is_err());
178    }
179}