1use std::collections::BTreeSet;
9
10use crate::{ReplayArtifact, StudioSource, ViewerError, ViewerResult};
11
12pub const EDGE_PARTITION_STATE_VERSION: u32 = 1;
14
15#[derive(Clone, Debug, PartialEq, Eq)]
17#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
18#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
19pub struct EdgePartition {
20 pub id: String,
22 pub placement: String,
24 pub node_ids: Vec<String>,
26}
27
28impl EdgePartition {
29 pub fn try_new(
31 id: impl Into<String>,
32 placement: impl Into<String>,
33 node_ids: Vec<String>,
34 ) -> ViewerResult<Self> {
35 let partition = Self { id: id.into(), placement: placement.into(), node_ids };
36 partition.validate()?;
37 Ok(partition)
38 }
39
40 pub fn validate(&self) -> ViewerResult<()> {
42 if self.id.trim().is_empty() || self.placement.trim().is_empty() || self.node_ids.is_empty()
43 {
44 return Err(ViewerError::InvalidState(
45 "edge partitions require an id, placement, and at least one node".into(),
46 ));
47 }
48 let mut nodes = BTreeSet::new();
49 for node_id in &self.node_ids {
50 if node_id.trim().is_empty() || !nodes.insert(node_id) {
51 return Err(ViewerError::InvalidState(
52 "edge partition node IDs must be non-empty and unique".into(),
53 ));
54 }
55 }
56 Ok(())
57 }
58}
59
60#[derive(Clone, Debug, PartialEq, Eq)]
62#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
63#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
64pub struct EdgePartitionTransfer {
65 pub sequence: u64,
67 pub source_topic: String,
69 pub from_node: String,
71 pub to_node: String,
73 pub payload_bytes: u64,
75 pub counted_copy_bytes: u64,
77 pub queue_signal: String,
79 pub completed: bool,
81}
82
83impl EdgePartitionTransfer {
84 #[allow(clippy::too_many_arguments)]
86 pub fn try_new(
87 sequence: u64,
88 source_topic: impl Into<String>,
89 from_node: impl Into<String>,
90 to_node: impl Into<String>,
91 payload_bytes: u64,
92 counted_copy_bytes: u64,
93 queue_signal: impl Into<String>,
94 completed: bool,
95 ) -> ViewerResult<Self> {
96 let transfer = Self {
97 sequence,
98 source_topic: source_topic.into(),
99 from_node: from_node.into(),
100 to_node: to_node.into(),
101 payload_bytes,
102 counted_copy_bytes,
103 queue_signal: queue_signal.into(),
104 completed,
105 };
106 transfer.validate()?;
107 Ok(transfer)
108 }
109
110 pub fn validate(&self) -> ViewerResult<()> {
112 if self.source_topic.trim().is_empty()
113 || self.from_node.trim().is_empty()
114 || self.to_node.trim().is_empty()
115 || self.from_node == self.to_node
116 || self.payload_bytes == 0
117 || self.counted_copy_bytes == 0
118 || self.counted_copy_bytes > self.payload_bytes
119 || self.queue_signal.trim().is_empty()
120 {
121 return Err(ViewerError::InvalidState(
122 "edge partition transfers require distinct nodes and consistent byte receipts"
123 .into(),
124 ));
125 }
126 Ok(())
127 }
128}
129
130#[derive(Clone, Debug, PartialEq, Eq)]
132#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
133#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
134pub struct EdgePartitionSummary {
135 pub source_packet_count: u64,
137 pub admitted_transfer_count: u64,
139 pub completed_transfer_count: u64,
141 pub payload_bytes: u64,
143 pub counted_copy_bytes: u64,
145 pub max_queue_depth: u64,
147 pub soft_limit_trips: u64,
149 pub hard_rejects: u64,
151 pub deterministic_order_verified: bool,
153 pub upstream_publish_ready: bool,
155 pub source_identity_match: bool,
157 pub frame_identity_match: bool,
159 pub calibration_registered: bool,
161 pub calibration_applied: bool,
163 pub time_basis: String,
165}
166
167impl EdgePartitionSummary {
168 #[allow(clippy::too_many_arguments)]
170 pub fn try_new(
171 source_packet_count: u64,
172 admitted_transfer_count: u64,
173 completed_transfer_count: u64,
174 payload_bytes: u64,
175 counted_copy_bytes: u64,
176 max_queue_depth: u64,
177 soft_limit_trips: u64,
178 hard_rejects: u64,
179 deterministic_order_verified: bool,
180 upstream_publish_ready: bool,
181 source_identity_match: bool,
182 frame_identity_match: bool,
183 calibration_registered: bool,
184 calibration_applied: bool,
185 time_basis: impl Into<String>,
186 ) -> ViewerResult<Self> {
187 let summary = Self {
188 source_packet_count,
189 admitted_transfer_count,
190 completed_transfer_count,
191 payload_bytes,
192 counted_copy_bytes,
193 max_queue_depth,
194 soft_limit_trips,
195 hard_rejects,
196 deterministic_order_verified,
197 upstream_publish_ready,
198 source_identity_match,
199 frame_identity_match,
200 calibration_registered,
201 calibration_applied,
202 time_basis: time_basis.into(),
203 };
204 summary.validate()?;
205 Ok(summary)
206 }
207
208 pub fn validate(&self) -> ViewerResult<()> {
210 if self.time_basis.trim().is_empty()
211 || self.admitted_transfer_count > self.source_packet_count
212 || self.completed_transfer_count > self.admitted_transfer_count
213 || (self.admitted_transfer_count > 0 && self.payload_bytes == 0)
214 || (self.completed_transfer_count > 0 && self.counted_copy_bytes == 0)
215 || self.counted_copy_bytes > self.payload_bytes
216 || (self.max_queue_depth > 0 && self.admitted_transfer_count == 0)
217 || (self.calibration_applied && !self.calibration_registered)
218 {
219 return Err(ViewerError::InvalidState(
220 "edge partition summary has invalid counters or calibration ordering".into(),
221 ));
222 }
223 Ok(())
224 }
225}
226
227#[derive(Clone, Debug, PartialEq, Eq)]
229#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
230#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
231pub struct EdgePartitionState {
232 pub version: u32,
234 pub title: String,
236 pub source: StudioSource,
238 pub upstream_live_publish_path: String,
240 pub calibration_readiness_path: String,
242 pub partitions: Vec<EdgePartition>,
244 pub transfers: Vec<EdgePartitionTransfer>,
246 pub summary: EdgePartitionSummary,
248 pub artifacts: Vec<ReplayArtifact>,
250 pub partition_ready: bool,
252 pub mapping_admitted: bool,
254 pub blockers: Vec<String>,
256}
257
258impl EdgePartitionState {
259 #[allow(clippy::too_many_arguments)]
261 pub fn try_new(
262 title: impl Into<String>,
263 source: StudioSource,
264 upstream_live_publish_path: impl Into<String>,
265 calibration_readiness_path: impl Into<String>,
266 partitions: Vec<EdgePartition>,
267 transfers: Vec<EdgePartitionTransfer>,
268 summary: EdgePartitionSummary,
269 artifacts: Vec<ReplayArtifact>,
270 blockers: Vec<String>,
271 ) -> ViewerResult<Self> {
272 let partition_ready = source.identity_matches
273 && summary.source_identity_match
274 && summary.upstream_publish_ready
275 && summary.frame_identity_match
276 && summary.deterministic_order_verified
277 && summary.source_packet_count > 0
278 && summary.admitted_transfer_count == summary.source_packet_count
279 && summary.completed_transfer_count == summary.admitted_transfer_count
280 && summary.hard_rejects == 0;
281 let mapping_admitted = partition_ready && summary.calibration_applied;
282 let state = Self {
283 version: EDGE_PARTITION_STATE_VERSION,
284 title: title.into(),
285 source,
286 upstream_live_publish_path: upstream_live_publish_path.into(),
287 calibration_readiness_path: calibration_readiness_path.into(),
288 partitions,
289 transfers,
290 summary,
291 artifacts,
292 partition_ready,
293 mapping_admitted,
294 blockers,
295 };
296 state.validate()?;
297 Ok(state)
298 }
299
300 pub fn validate(&self) -> ViewerResult<()> {
302 if self.version != EDGE_PARTITION_STATE_VERSION {
303 return Err(ViewerError::InvalidState(format!(
304 "unsupported edge partition state version {}",
305 self.version
306 )));
307 }
308 if self.title.trim().is_empty()
309 || self.upstream_live_publish_path.trim().is_empty()
310 || self.calibration_readiness_path.trim().is_empty()
311 || self.partitions.len() < 2
312 {
313 return Err(ViewerError::InvalidState(
314 "edge partition state requires title, input paths, and at least two partitions"
315 .into(),
316 ));
317 }
318 self.source.validate()?;
319 self.summary.validate()?;
320
321 let mut partition_ids = BTreeSet::new();
322 let mut node_ids = BTreeSet::new();
323 let mut node_partition = std::collections::BTreeMap::new();
324 for partition in &self.partitions {
325 partition.validate()?;
326 if !partition_ids.insert(&partition.id) {
327 return Err(ViewerError::InvalidState("edge partition IDs must be unique".into()));
328 }
329 for node_id in &partition.node_ids {
330 if !node_ids.insert(node_id) {
331 return Err(ViewerError::InvalidState(
332 "edge partition nodes must belong to exactly one partition".into(),
333 ));
334 }
335 node_partition.insert(node_id, &partition.id);
336 }
337 }
338
339 let mut transfer_payload_bytes = 0_u64;
340 let mut transfer_copy_bytes = 0_u64;
341 let mut completed_transfer_count = 0_u64;
342 for (expected_sequence, transfer) in self.transfers.iter().enumerate() {
343 transfer.validate()?;
344 if transfer.sequence != u64::try_from(expected_sequence).unwrap_or(u64::MAX)
345 || !node_partition.contains_key(&transfer.from_node)
346 || !node_partition.contains_key(&transfer.to_node)
347 || node_partition.get(&transfer.from_node) == node_partition.get(&transfer.to_node)
348 {
349 return Err(ViewerError::InvalidState(
350 "edge partition transfers have invalid order or graph membership".into(),
351 ));
352 }
353 transfer_payload_bytes =
354 transfer_payload_bytes.checked_add(transfer.payload_bytes).ok_or_else(|| {
355 ViewerError::InvalidState("edge partition payload count overflow".into())
356 })?;
357 transfer_copy_bytes =
358 transfer_copy_bytes.checked_add(transfer.counted_copy_bytes).ok_or_else(|| {
359 ViewerError::InvalidState("edge partition copy count overflow".into())
360 })?;
361 if transfer.completed {
362 completed_transfer_count =
363 completed_transfer_count.checked_add(1).ok_or_else(|| {
364 ViewerError::InvalidState("edge partition completion count overflow".into())
365 })?;
366 }
367 }
368 if transfer_payload_bytes != self.summary.payload_bytes
369 || transfer_copy_bytes != self.summary.counted_copy_bytes
370 || u64::try_from(self.transfers.len()).unwrap_or(u64::MAX)
371 != self.summary.admitted_transfer_count
372 || completed_transfer_count != self.summary.completed_transfer_count
373 {
374 return Err(ViewerError::InvalidState(
375 "edge partition transfer totals disagree with summary".into(),
376 ));
377 }
378 if (!self.source.identity_matches || !self.summary.upstream_publish_ready)
379 && !self.transfers.is_empty()
380 {
381 return Err(ViewerError::InvalidState(
382 "source- or upstream-mismatched edge state cannot contain transfers".into(),
383 ));
384 }
385
386 let calculated_partition_ready = self.source.identity_matches
387 && self.summary.source_identity_match
388 && self.summary.upstream_publish_ready
389 && self.summary.frame_identity_match
390 && self.summary.deterministic_order_verified
391 && self.summary.source_packet_count > 0
392 && self.summary.admitted_transfer_count == self.summary.source_packet_count
393 && self.summary.completed_transfer_count == self.summary.admitted_transfer_count
394 && self.summary.hard_rejects == 0;
395 if self.partition_ready != calculated_partition_ready {
396 return Err(ViewerError::InvalidState(
397 "partition_ready disagrees with source, upstream, graph, or queue gates".into(),
398 ));
399 }
400 let calculated_mapping = self.partition_ready && self.summary.calibration_applied;
401 if self.mapping_admitted != calculated_mapping {
402 return Err(ViewerError::InvalidState(
403 "mapping_admitted disagrees with partition and calibration gates".into(),
404 ));
405 }
406 if self.mapping_admitted && !self.blockers.is_empty() {
407 return Err(ViewerError::InvalidState(
408 "admitted edge partition mapping cannot contain blockers".into(),
409 ));
410 }
411 if !self.mapping_admitted && self.blockers.is_empty() {
412 return Err(ViewerError::InvalidState(
413 "blocked edge partition mapping must expose at least one blocker".into(),
414 ));
415 }
416 if self.blockers.iter().any(|blocker| blocker.trim().is_empty()) {
417 return Err(ViewerError::InvalidState(
418 "edge partition blockers must not contain empty messages".into(),
419 ));
420 }
421
422 let mut artifact_roles = BTreeSet::new();
423 let mut artifact_paths = BTreeSet::new();
424 for artifact in &self.artifacts {
425 artifact.validate()?;
426 if !artifact_roles.insert(&artifact.role) || !artifact_paths.insert(&artifact.path) {
427 return Err(ViewerError::InvalidState(
428 "edge partition artifacts must have unique roles and paths".into(),
429 ));
430 }
431 }
432 Ok(())
433 }
434}
435
436#[cfg(test)]
437mod tests {
438 use super::*;
439
440 const SHA: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
441
442 fn source(identity_matches: bool) -> StudioSource {
443 StudioSource::try_new(
444 "canonical bag",
445 "/media/input.db3",
446 SHA,
447 if identity_matches {
448 SHA
449 } else {
450 "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210"
451 },
452 identity_matches,
453 )
454 .unwrap()
455 }
456
457 fn partitions() -> Vec<EdgePartition> {
458 vec![
459 EdgePartition::try_new(
460 "edge",
461 "edge-host-0",
462 vec!["packet-gate".into(), "live-publish".into()],
463 )
464 .unwrap(),
465 EdgePartition::try_new(
466 "host",
467 "mapping-host-0",
468 vec!["host-receive".into(), "mapping".into()],
469 )
470 .unwrap(),
471 ]
472 }
473
474 fn summary(source_identity_match: bool) -> EdgePartitionSummary {
475 EdgePartitionSummary::try_new(
476 1,
477 if source_identity_match { 1 } else { 0 },
478 if source_identity_match { 1 } else { 0 },
479 if source_identity_match { 128 } else { 0 },
480 if source_identity_match { 128 } else { 0 },
481 if source_identity_match { 1 } else { 0 },
482 0,
483 0,
484 source_identity_match,
485 source_identity_match,
486 source_identity_match,
487 source_identity_match,
488 false,
489 false,
490 "PointCloud2 header stamp; no clock calibration applied",
491 )
492 .unwrap()
493 }
494
495 #[test]
496 fn healthy_partition_is_ready_while_mapping_stays_blocked() {
497 let transfer = EdgePartitionTransfer::try_new(
498 0,
499 "/lidar_front/points_raw",
500 "packet-gate",
501 "host-receive",
502 128,
503 128,
504 "soft-limit",
505 true,
506 )
507 .unwrap();
508 let state = EdgePartitionState::try_new(
509 "Edge Partition",
510 source(true),
511 "/media/live-publish.json",
512 "/media/readiness.json",
513 partitions(),
514 vec![transfer],
515 summary(true),
516 Vec::new(),
517 vec!["clock/frame calibration was not applied".into()],
518 )
519 .unwrap();
520 assert!(state.partition_ready);
521 assert!(!state.mapping_admitted);
522 state.validate().unwrap();
523 }
524
525 #[test]
526 fn source_mismatch_withholds_transfers() {
527 let state = EdgePartitionState::try_new(
528 "Edge Partition",
529 source(false),
530 "/media/live-publish.json",
531 "/media/readiness.json",
532 partitions(),
533 Vec::new(),
534 summary(false),
535 Vec::new(),
536 vec!["source SHA-256 mismatch".into()],
537 )
538 .unwrap();
539 assert!(!state.partition_ready);
540 assert!(!state.mapping_admitted);
541 assert!(state.transfers.is_empty());
542 }
543
544 #[test]
545 fn rejects_transfer_within_one_partition() {
546 let transfer = EdgePartitionTransfer::try_new(
547 0,
548 "/lidar_front/points_raw",
549 "packet-gate",
550 "live-publish",
551 128,
552 128,
553 "ok",
554 true,
555 )
556 .unwrap();
557 let result = EdgePartitionState::try_new(
558 "Edge Partition",
559 source(true),
560 "/media/live-publish.json",
561 "/media/readiness.json",
562 partitions(),
563 vec![transfer],
564 summary(true),
565 Vec::new(),
566 vec!["invalid graph edge".into()],
567 );
568 assert!(result.is_err());
569 }
570}