1use std::collections::HashSet;
25use std::sync::Arc;
26use std::time::{Duration, Instant, SystemTime};
27
28use bee::manifest::{MantarayNode, unmarshal};
29use bee::swarm::Reference;
30
31use crate::api::ApiClient;
32
33const MAX_CHUNKS_PER_WALK: u64 = 10_000;
38
39#[derive(Debug, Clone)]
45pub struct DurabilityResult {
46 pub reference: Reference,
47 pub started_at: SystemTime,
48 pub duration_ms: u64,
49 pub chunks_total: u64,
50 pub chunks_lost: u64,
51 pub chunks_errors: u64,
52 pub root_is_manifest: bool,
56 pub truncated: bool,
58}
59
60impl DurabilityResult {
61 pub fn is_healthy(&self) -> bool {
63 self.chunks_lost == 0 && self.chunks_errors == 0
64 }
65 pub fn summary(&self) -> String {
67 let kind = if self.root_is_manifest {
68 "manifest"
69 } else {
70 "raw chunk"
71 };
72 let trunc = if self.truncated { " (truncated)" } else { "" };
73 if self.is_healthy() {
74 format!(
75 "durability-check OK in {}ms · {kind} · {} chunk{} retrievable{trunc}",
76 self.duration_ms,
77 self.chunks_total,
78 if self.chunks_total == 1 { "" } else { "s" },
79 )
80 } else {
81 format!(
82 "durability-check UNHEALTHY in {}ms · {kind} · total {} · lost {} · errors {}{trunc}",
83 self.duration_ms,
84 self.chunks_total,
85 self.chunks_lost,
86 self.chunks_errors,
87 )
88 }
89 }
90}
91
92pub async fn check(api: Arc<ApiClient>, reference: Reference) -> DurabilityResult {
97 let started = Instant::now();
98 let started_at = SystemTime::now();
99 let mut result = DurabilityResult {
100 reference: reference.clone(),
101 started_at,
102 duration_ms: 0,
103 chunks_total: 0,
104 chunks_lost: 0,
105 chunks_errors: 0,
106 root_is_manifest: false,
107 truncated: false,
108 };
109
110 let root_bytes = match api.bee().file().download_chunk(&reference, None).await {
112 Ok(b) => b,
113 Err(e) => {
114 let s = e.to_string();
119 if s.contains("404") {
120 result.chunks_lost = 1;
121 } else {
122 result.chunks_errors = 1;
123 }
124 result.chunks_total = 1;
125 result.duration_ms = elapsed_ms(started);
126 return result;
127 }
128 };
129 result.chunks_total = 1;
130
131 let root_node = match unmarshal(&root_bytes, reference.as_bytes()) {
134 Ok(n) => n,
135 Err(_) => {
136 result.duration_ms = elapsed_ms(started);
137 return result;
138 }
139 };
140 result.root_is_manifest = true;
141
142 let mut visited: HashSet<[u8; 32]> = HashSet::new();
145 let mut queue: Vec<MantarayNode> = vec![root_node];
146
147 while let Some(node) = queue.pop() {
148 for fork in node.forks.values() {
149 let Some(addr) = fork.node.self_address else {
150 continue;
151 };
152 if !visited.insert(addr) {
153 continue;
154 }
155 if result.chunks_total >= MAX_CHUNKS_PER_WALK {
156 result.truncated = true;
157 result.duration_ms = elapsed_ms(started);
158 return result;
159 }
160 result.chunks_total += 1;
161 let child_ref = match Reference::new(&addr) {
162 Ok(r) => r,
163 Err(_) => {
164 result.chunks_errors += 1;
165 continue;
166 }
167 };
168 match api.bee().file().download_chunk(&child_ref, None).await {
169 Ok(child_bytes) => {
170 if let Ok(child_node) = unmarshal(&child_bytes, child_ref.as_bytes()) {
173 queue.push(child_node);
174 }
175 }
176 Err(e) => {
177 if e.to_string().contains("404") {
178 result.chunks_lost += 1;
179 } else {
180 result.chunks_errors += 1;
181 }
182 }
183 }
184 }
185 }
186 result.duration_ms = elapsed_ms(started);
187 result
188}
189
190fn elapsed_ms(started: Instant) -> u64 {
191 let d: Duration = started.elapsed();
192 d.as_millis().min(u128::from(u64::MAX)) as u64
193}
194
195#[cfg(test)]
196mod tests {
197 use super::*;
198
199 fn fake_ref() -> Reference {
200 Reference::from_hex(&"a".repeat(64)).unwrap()
201 }
202
203 #[test]
204 fn summary_renders_healthy_message() {
205 let r = DurabilityResult {
206 reference: fake_ref(),
207 started_at: SystemTime::now(),
208 duration_ms: 123,
209 chunks_total: 4,
210 chunks_lost: 0,
211 chunks_errors: 0,
212 root_is_manifest: true,
213 truncated: false,
214 };
215 let s = r.summary();
216 assert!(s.contains("OK"), "{s}");
217 assert!(s.contains("4 chunks retrievable"), "{s}");
218 assert!(s.contains("manifest"), "{s}");
219 }
220
221 #[test]
222 fn summary_renders_unhealthy_breakdown() {
223 let r = DurabilityResult {
224 reference: fake_ref(),
225 started_at: SystemTime::now(),
226 duration_ms: 990,
227 chunks_total: 8,
228 chunks_lost: 2,
229 chunks_errors: 1,
230 root_is_manifest: true,
231 truncated: false,
232 };
233 let s = r.summary();
234 assert!(s.contains("UNHEALTHY"), "{s}");
235 assert!(s.contains("lost 2"), "{s}");
236 assert!(s.contains("errors 1"), "{s}");
237 }
238
239 #[test]
240 fn truncated_flag_surfaces_in_summary() {
241 let r = DurabilityResult {
242 reference: fake_ref(),
243 started_at: SystemTime::now(),
244 duration_ms: 1,
245 chunks_total: 10_000,
246 chunks_lost: 0,
247 chunks_errors: 0,
248 root_is_manifest: true,
249 truncated: true,
250 };
251 assert!(r.summary().contains("truncated"), "{}", r.summary());
252 }
253
254 #[test]
255 fn is_healthy_requires_zero_lost_and_zero_errors() {
256 let mut r = DurabilityResult {
257 reference: fake_ref(),
258 started_at: SystemTime::now(),
259 duration_ms: 1,
260 chunks_total: 5,
261 chunks_lost: 0,
262 chunks_errors: 0,
263 root_is_manifest: true,
264 truncated: false,
265 };
266 assert!(r.is_healthy());
267 r.chunks_lost = 1;
268 assert!(!r.is_healthy());
269 r.chunks_lost = 0;
270 r.chunks_errors = 1;
271 assert!(!r.is_healthy());
272 }
273}