ferrum_types/
resource_trace.rs1use 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}