From 844fdff56b3779b1ac6478e74f79b1d3b0582559 Mon Sep 17 00:00:00 2001 From: synicix Date: Mon, 4 Aug 2025 23:38:37 +0000 Subject: [PATCH 01/65] Move to dev --- .devcontainer/gpu/devcontainer.json | 2 +- src/core/operator.rs | 87 ++++++++++++++++------------- tests/operator.rs | 6 +- 3 files changed, 51 insertions(+), 44 deletions(-) diff --git a/.devcontainer/gpu/devcontainer.json b/.devcontainer/gpu/devcontainer.json index ebbaba51..7d701a97 100644 --- a/.devcontainer/gpu/devcontainer.json +++ b/.devcontainer/gpu/devcontainer.json @@ -21,7 +21,7 @@ }, "runArgs": [ "--name=${localWorkspaceFolderBasename}_devcontainer", - "--gpus=all", + // "--gpus=all", "--privileged", "--cgroupns=host" ], diff --git a/src/core/operator.rs b/src/core/operator.rs index 948fca28..6ea4ffde 100644 --- a/src/core/operator.rs +++ b/src/core/operator.rs @@ -1,61 +1,68 @@ -use crate::uniffi::{error::Result, model::packet::Packet}; -use itertools::Itertools as _; -use std::{ - clone::Clone, - collections::HashMap, - iter::IntoIterator, - sync::{Arc, Mutex}, +use crate::{ + core::util::get, + uniffi::{error::Result, model::packet::Packet}, }; +use itertools::Itertools as _; +use std::{clone::Clone as _, collections::HashMap, iter::IntoIterator as _}; pub trait Operator { - fn next(&self, packets: Vec<(String, Packet)>) -> Result>; + fn next(&mut self, packets: Vec<(String, Packet)>) -> Result>; } pub struct JoinOperator { parent_count: usize, - received_streams: Arc>>>, + packet_cache: HashMap>, } impl JoinOperator { pub fn new(parent_count: usize) -> Self { Self { parent_count, - received_streams: Arc::new(Mutex::new(HashMap::new())), + packet_cache: HashMap::new(), } } } #[expect(clippy::excessive_nesting, reason = "Nesting manageable.")] impl Operator for JoinOperator { - fn next(&self, packets: Vec<(String, Packet)>) -> Result> { - let mut next_packets = vec![]; - for (stream, packet) in &packets { - let mut received_streams = self.received_streams.lock()?; - if self.parent_count - usize::from(!received_streams.contains_key(stream)) - == received_streams.len() - { - let packets_to_multiplex = received_streams + fn next(&mut self, packets: Vec<(String, Packet)>) -> Result> { + let temp: Vec = packets + .iter() + .flat_map(|(parent_id, packet)| { + self.packet_cache + .entry(parent_id.clone()) + .or_insert_with(|| vec![packet.clone()]) + .push(packet.clone()); + + // If we still don't have at least 1 packet for each parent, skip computation + if self.packet_cache.len() < self.parent_count { + return vec![]; + } + + // Build the factors for the cartesian product, silently missing key since it shouldn't be possible + let factors = self + .packet_cache .iter() - .filter_map(|(parent_stream, parent_packets)| { - (parent_stream != stream).then_some(parent_packets.clone()) + .filter_map(|(id, parent_packets)| { + (id != parent_id).then_some(parent_packets.clone()) }) - .chain(vec![vec![packet.clone()]].into_iter()); - let current_packets = packets_to_multiplex.multi_cartesian_product().map( - |packet_combinations_to_merge| { - packet_combinations_to_merge - .into_iter() - .flat_map(IntoIterator::into_iter) - .collect::>() - }, - ); - next_packets.extend(current_packets); - } - received_streams - .entry(stream.clone()) - .or_default() - .push(packet.clone()); - } - Ok(next_packets) + .chain(vec![vec![packet.clone()]]); + + factors + .multi_cartesian_product() + .map(|packets_to_combined| { + packets_to_combined.into_iter().fold( + HashMap::new(), + |mut acc, new_packet| { + acc.extend(new_packet); + acc + }, + ) + }) + .collect::>() + }) + .collect(); + Ok(temp) } } @@ -64,13 +71,13 @@ pub struct MapOperator { } impl MapOperator { - pub fn new(map: &HashMap) -> Self { - Self { map: map.clone() } + pub const fn new(map: HashMap) -> Self { + Self { map } } } impl Operator for MapOperator { - fn next(&self, packets: Vec<(String, Packet)>) -> Result> { + fn next(&mut self, packets: Vec<(String, Packet)>) -> Result> { Ok(packets .iter() .map(|(_, packet)| { diff --git a/tests/operator.rs b/tests/operator.rs index 11b25034..4bb1c18a 100644 --- a/tests/operator.rs +++ b/tests/operator.rs @@ -25,7 +25,7 @@ fn make_packet_key(key_name: String, filepath: String) -> (String, PathSet) { #[test] fn join_once() -> Result<()> { - let operator = JoinOperator::new(2); + let mut operator = JoinOperator::new(2); let left_stream = (0..3) .map(|i| { @@ -90,7 +90,7 @@ fn join_once() -> Result<()> { #[test] fn join_spotty() -> Result<()> { - let operator = JoinOperator::new(2); + let mut operator = JoinOperator::new(2); assert_eq!( operator.next(vec![( @@ -171,7 +171,7 @@ fn join_spotty() -> Result<()> { #[test] fn map_once() -> Result<()> { - let operator = MapOperator::new(&HashMap::from([("key_old".into(), "key_new".into())])); + let mut operator = MapOperator::new(HashMap::from([("key_old".into(), "key_new".into())])); assert_eq!( operator.next(vec![( From 764387d4f02101923c370b0374633929bb25b2b9 Mon Sep 17 00:00:00 2001 From: Synicix Date: Tue, 5 Aug 2025 00:11:49 +0000 Subject: [PATCH 02/65] Remove uneeded results and change to return iterator --- src/core/operator.rs | 103 +++++++++++++----------------- tests/operator.rs | 149 ++++++++++++++++++++++--------------------- 2 files changed, 124 insertions(+), 128 deletions(-) diff --git a/src/core/operator.rs b/src/core/operator.rs index 6ea4ffde..838ee726 100644 --- a/src/core/operator.rs +++ b/src/core/operator.rs @@ -1,12 +1,9 @@ -use crate::{ - core::util::get, - uniffi::{error::Result, model::packet::Packet}, -}; +use crate::uniffi::model::packet::Packet; use itertools::Itertools as _; use std::{clone::Clone as _, collections::HashMap, iter::IntoIterator as _}; pub trait Operator { - fn next(&mut self, packets: Vec<(String, Packet)>) -> Result>; + fn next(&mut self, packets: Vec<(String, Packet)>) -> impl Iterator; } pub struct JoinOperator { @@ -25,44 +22,39 @@ impl JoinOperator { #[expect(clippy::excessive_nesting, reason = "Nesting manageable.")] impl Operator for JoinOperator { - fn next(&mut self, packets: Vec<(String, Packet)>) -> Result> { - let temp: Vec = packets - .iter() - .flat_map(|(parent_id, packet)| { - self.packet_cache - .entry(parent_id.clone()) - .or_insert_with(|| vec![packet.clone()]) - .push(packet.clone()); + fn next(&mut self, packets: Vec<(String, Packet)>) -> impl Iterator { + packets.into_iter().flat_map(|(parent_id, packet)| { + self.packet_cache + .entry(parent_id.clone()) + .or_insert_with(|| vec![packet.clone()]) + .push(packet.clone()); - // If we still don't have at least 1 packet for each parent, skip computation - if self.packet_cache.len() < self.parent_count { - return vec![]; - } + // If we still don't have at least 1 packet for each parent, skip computation + if self.packet_cache.len() < self.parent_count { + return vec![]; + } - // Build the factors for the cartesian product, silently missing key since it shouldn't be possible - let factors = self - .packet_cache - .iter() - .filter_map(|(id, parent_packets)| { - (id != parent_id).then_some(parent_packets.clone()) - }) - .chain(vec![vec![packet.clone()]]); + // Build the factors for the cartesian product, silently missing key since it shouldn't be possible + let factors = self + .packet_cache + .iter() + .filter_map(|(id, parent_packets)| { + (*id != parent_id).then_some(parent_packets.clone()) + }) + .chain(vec![vec![packet]]); - factors - .multi_cartesian_product() - .map(|packets_to_combined| { - packets_to_combined.into_iter().fold( - HashMap::new(), - |mut acc, new_packet| { - acc.extend(new_packet); - acc - }, - ) - }) - .collect::>() - }) - .collect(); - Ok(temp) + factors + .multi_cartesian_product() + .map(|packets_to_combined| { + packets_to_combined + .into_iter() + .fold(HashMap::new(), |mut acc, new_packet| { + acc.extend(new_packet); + acc + }) + }) + .collect::>() + }) } } @@ -77,22 +69,19 @@ impl MapOperator { } impl Operator for MapOperator { - fn next(&mut self, packets: Vec<(String, Packet)>) -> Result> { - Ok(packets - .iter() - .map(|(_, packet)| { - packet - .iter() - .map(|(packet_key, path_set)| { - ( - self.map - .get(packet_key) - .map_or_else(|| packet_key.clone(), Clone::clone), - path_set.clone(), - ) - }) - .collect() - }) - .collect()) + fn next(&mut self, packets: Vec<(String, Packet)>) -> impl Iterator { + packets.into_iter().map(|(_, packet)| { + packet + .iter() + .map(|(packet_key, path_set)| { + ( + self.map + .get(packet_key) + .map_or_else(|| packet_key.clone(), Clone::clone), + path_set.clone(), + ) + }) + .collect() + }) } } diff --git a/tests/operator.rs b/tests/operator.rs index 4bb1c18a..8b24a60b 100644 --- a/tests/operator.rs +++ b/tests/operator.rs @@ -1,11 +1,8 @@ -#![expect(missing_docs, clippy::panic_in_result_fn, reason = "OK in tests.")] +#![expect(missing_docs, reason = "OK in tests.")] use orcapod::{ core::operator::{JoinOperator, MapOperator, Operator as _}, - uniffi::{ - error::Result, - model::packet::{Blob, BlobKind, Packet, PathSet, URI}, - }, + uniffi::model::packet::{Blob, BlobKind, Packet, PathSet, URI}, }; use std::{collections::HashMap, path::PathBuf}; @@ -23,8 +20,18 @@ fn make_packet_key(key_name: String, filepath: String) -> (String, PathSet) { ) } +fn assert_contains_packet(packets_to_check: &Vec, vec_to_check: &[Packet]) { + for packet in packets_to_check { + assert!( + vec_to_check.contains(packet), + "{}", + format!("Expected packet {packet:?} not found in the vector.") + ); + } +} + #[test] -fn join_once() -> Result<()> { +fn join_once() { let mut operator = JoinOperator::new(2); let left_stream = (0..3) @@ -54,13 +61,16 @@ fn join_once() -> Result<()> { let mut input_streams = left_stream; input_streams.extend(right_stream); - assert_eq!( - operator.next(input_streams)?, - vec![ + assert_contains_packet( + &vec![ Packet::from([ make_packet_key("subject".into(), "left/subject0.png".into()), make_packet_key("style".into(), "right/style0.t7".into()), ]), + Packet::from([ + make_packet_key("subject".into(), "left/subject0.png".into()), + make_packet_key("style".into(), "right/style1.t7".into()), + ]), Packet::from([ make_packet_key("subject".into(), "left/subject1.png".into()), make_packet_key("style".into(), "right/style0.t7".into()), @@ -69,10 +79,6 @@ fn join_once() -> Result<()> { make_packet_key("subject".into(), "left/subject2.png".into()), make_packet_key("style".into(), "right/style0.t7".into()), ]), - Packet::from([ - make_packet_key("subject".into(), "left/subject0.png".into()), - make_packet_key("style".into(), "right/style1.t7".into()), - ]), Packet::from([ make_packet_key("subject".into(), "left/subject1.png".into()), make_packet_key("style".into(), "right/style1.t7".into()), @@ -82,43 +88,38 @@ fn join_once() -> Result<()> { make_packet_key("style".into(), "right/style1.t7".into()), ]), ], - "Unexpected streams." + &operator.next(input_streams).collect::>(), ); - - Ok(()) } #[test] -fn join_spotty() -> Result<()> { +fn join_spotty() { let mut operator = JoinOperator::new(2); - assert_eq!( - operator.next(vec![( - "right".into(), - Packet::from([make_packet_key("style".into(), "right/style0.t7".into(),)]), - )])?, - vec![], + assert!( + operator + .next(vec![( + "right".into(), + Packet::from([make_packet_key("style".into(), "right/style0.t7".into(),)]), + )]) + .next() + .is_none(), "Unexpected streams." ); - assert_eq!( - operator.next(vec![( - "right".into(), - Packet::from([make_packet_key("style".into(), "right/style1.t7".into(),)]), - )])?, - vec![], + assert!( + operator + .next(vec![( + "right".into(), + Packet::from([make_packet_key("style".into(), "right/style1.t7".into(),)]), + )]) + .next() + .is_none(), "Unexpected streams." ); - assert_eq!( - operator.next(vec![( - "left".into(), - Packet::from([make_packet_key( - "subject".into(), - "left/subject0.png".into(), - )]), - )])?, - vec![ + assert_contains_packet( + &vec![ Packet::from([ make_packet_key("subject".into(), "left/subject0.png".into()), make_packet_key("style".into(), "right/style0.t7".into()), @@ -128,24 +129,19 @@ fn join_spotty() -> Result<()> { make_packet_key("style".into(), "right/style1.t7".into()), ]), ], - "Unexpected streams." + &operator + .next(vec![( + "left".into(), + Packet::from([make_packet_key( + "subject".into(), + "left/subject0.png".into(), + )]), + )]) + .collect::>(), ); - assert_eq!( - operator.next( - (1..3) - .map(|i| { - ( - "left".into(), - Packet::from([make_packet_key( - "subject".into(), - format!("left/subject{i}.png"), - )]), - ) - }) - .collect::>() - )?, - vec![ + assert_contains_packet( + &vec![ Packet::from([ make_packet_key("subject".into(), "left/subject1.png".into()), make_packet_key("style".into(), "right/style0.t7".into()), @@ -163,30 +159,41 @@ fn join_spotty() -> Result<()> { make_packet_key("style".into(), "right/style1.t7".into()), ]), ], - "Unexpected streams." + &operator + .next( + (1..3) + .map(|i| { + ( + "left".into(), + Packet::from([make_packet_key( + "subject".into(), + format!("left/subject{i}.png"), + )]), + ) + }) + .collect::>(), + ) + .collect::>(), ); - - Ok(()) } #[test] -fn map_once() -> Result<()> { +fn map_once() { let mut operator = MapOperator::new(HashMap::from([("key_old".into(), "key_new".into())])); - assert_eq!( - operator.next(vec![( - "parent".into(), - Packet::from([ - make_packet_key("key_old".into(), "some/key.txt".into()), - make_packet_key("subject".into(), "some/subject.txt".into()), - ]), - )])?, - vec![Packet::from([ + assert_contains_packet( + &vec![Packet::from([ make_packet_key("key_new".into(), "some/key.txt".into()), make_packet_key("subject".into(), "some/subject.txt".into()), - ]),], - "Unexpected packet." + ])], + &operator + .next(vec![( + "parent".into(), + Packet::from([ + make_packet_key("key_old".into(), "some/key.txt".into()), + make_packet_key("subject".into(), "some/subject.txt".into()), + ]), + )]) + .collect::>(), ); - - Ok(()) } From a03db374bb8b840a5ef74a5ab315eb61a4fb992c Mon Sep 17 00:00:00 2001 From: Synicix Date: Tue, 5 Aug 2025 00:55:29 +0000 Subject: [PATCH 03/65] Remove accident comment --- .devcontainer/gpu/devcontainer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.devcontainer/gpu/devcontainer.json b/.devcontainer/gpu/devcontainer.json index 7d701a97..ebbaba51 100644 --- a/.devcontainer/gpu/devcontainer.json +++ b/.devcontainer/gpu/devcontainer.json @@ -21,7 +21,7 @@ }, "runArgs": [ "--name=${localWorkspaceFolderBasename}_devcontainer", - // "--gpus=all", + "--gpus=all", "--privileged", "--cgroupns=host" ], From abbb5ce78a67e9e3c41b7e88198c1a7583fa9b21 Mon Sep 17 00:00:00 2001 From: Synicix Date: Tue, 5 Aug 2025 01:39:21 +0000 Subject: [PATCH 04/65] Add ground work for pod operator --- src/core/operator.rs | 79 ++++++++++++++++++++++++++------------------ tests/operator.rs | 44 +++++++++++++----------- 2 files changed, 70 insertions(+), 53 deletions(-) diff --git a/src/core/operator.rs b/src/core/operator.rs index 838ee726..3a0c8b84 100644 --- a/src/core/operator.rs +++ b/src/core/operator.rs @@ -1,60 +1,70 @@ -use crate::uniffi::model::packet::Packet; +use crate::uniffi::{error::Result, model::packet::Packet}; use itertools::Itertools as _; -use std::{clone::Clone as _, collections::HashMap, iter::IntoIterator as _}; +use std::{clone::Clone as _, collections::HashMap, iter::IntoIterator as _, sync::Arc}; +use tokio::sync::Mutex; +#[allow(async_fn_in_trait, reason = "We only use this internally")] pub trait Operator { - fn next(&mut self, packets: Vec<(String, Packet)>) -> impl Iterator; + async fn process_packets(&mut self, packets: Vec<(String, Packet)>) -> Result>; } pub struct JoinOperator { parent_count: usize, - packet_cache: HashMap>, + packet_cache: Arc>>>, } impl JoinOperator { pub fn new(parent_count: usize) -> Self { Self { parent_count, - packet_cache: HashMap::new(), + packet_cache: Arc::new(Mutex::new(HashMap::new())), } } } #[expect(clippy::excessive_nesting, reason = "Nesting manageable.")] impl Operator for JoinOperator { - fn next(&mut self, packets: Vec<(String, Packet)>) -> impl Iterator { - packets.into_iter().flat_map(|(parent_id, packet)| { - self.packet_cache + async fn process_packets(&mut self, packets: Vec<(String, Packet)>) -> Result> { + let mut new_packets = vec![]; + + for (parent_id, packet) in packets { + let mut packet_cache_lock = self.packet_cache.lock().await; + packet_cache_lock .entry(parent_id.clone()) .or_insert_with(|| vec![packet.clone()]) .push(packet.clone()); // If we still don't have at least 1 packet for each parent, skip computation - if self.packet_cache.len() < self.parent_count { - return vec![]; + if packet_cache_lock.len() < self.parent_count { + new_packets.extend(vec![]); + continue; } // Build the factors for the cartesian product, silently missing key since it shouldn't be possible - let factors = self - .packet_cache + let factors = packet_cache_lock .iter() .filter_map(|(id, parent_packets)| { (*id != parent_id).then_some(parent_packets.clone()) }) - .chain(vec![vec![packet]]); + .chain(vec![vec![packet]]) + .collect::>(); + + // We don't need the lock after this point + drop(packet_cache_lock); - factors - .multi_cartesian_product() - .map(|packets_to_combined| { + // Compute the cartesian product of the factors, this might take a while + new_packets.extend(factors.into_iter().multi_cartesian_product().map( + |packets_to_combined| { packets_to_combined .into_iter() .fold(HashMap::new(), |mut acc, new_packet| { acc.extend(new_packet); acc }) - }) - .collect::>() - }) + }, + )); + } + Ok(new_packets) } } @@ -69,19 +79,22 @@ impl MapOperator { } impl Operator for MapOperator { - fn next(&mut self, packets: Vec<(String, Packet)>) -> impl Iterator { - packets.into_iter().map(|(_, packet)| { - packet - .iter() - .map(|(packet_key, path_set)| { - ( - self.map - .get(packet_key) - .map_or_else(|| packet_key.clone(), Clone::clone), - path_set.clone(), - ) - }) - .collect() - }) + async fn process_packets(&mut self, packets: Vec<(String, Packet)>) -> Result> { + Ok(packets + .into_iter() + .map(|(_, packet)| { + packet + .iter() + .map(|(packet_key, path_set)| { + ( + self.map + .get(packet_key) + .map_or_else(|| packet_key.clone(), Clone::clone), + path_set.clone(), + ) + }) + .collect() + }) + .collect::>()) } } diff --git a/tests/operator.rs b/tests/operator.rs index 8b24a60b..17a1cdf9 100644 --- a/tests/operator.rs +++ b/tests/operator.rs @@ -1,7 +1,8 @@ -#![expect(missing_docs, reason = "OK in tests.")] +#![expect(missing_docs, clippy::panic_in_result_fn, reason = "OK in tests.")] use orcapod::{ core::operator::{JoinOperator, MapOperator, Operator as _}, + uniffi::error::Result, uniffi::model::packet::{Blob, BlobKind, Packet, PathSet, URI}, }; use std::{collections::HashMap, path::PathBuf}; @@ -30,8 +31,8 @@ fn assert_contains_packet(packets_to_check: &Vec, vec_to_check: &[Packet } } -#[test] -fn join_once() { +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn join_once() -> Result<()> { let mut operator = JoinOperator::new(2); let left_stream = (0..3) @@ -88,33 +89,34 @@ fn join_once() { make_packet_key("style".into(), "right/style1.t7".into()), ]), ], - &operator.next(input_streams).collect::>(), + &operator.process_packets(input_streams).await?, ); + Ok(()) } -#[test] -fn join_spotty() { +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn join_spotty() -> Result<()> { let mut operator = JoinOperator::new(2); assert!( operator - .next(vec![( + .process_packets(vec![( "right".into(), Packet::from([make_packet_key("style".into(), "right/style0.t7".into(),)]), )]) - .next() - .is_none(), + .await? + .is_empty(), "Unexpected streams." ); assert!( operator - .next(vec![( + .process_packets(vec![( "right".into(), Packet::from([make_packet_key("style".into(), "right/style1.t7".into(),)]), )]) - .next() - .is_none(), + .await? + .is_empty(), "Unexpected streams." ); @@ -130,14 +132,14 @@ fn join_spotty() { ]), ], &operator - .next(vec![( + .process_packets(vec![( "left".into(), Packet::from([make_packet_key( "subject".into(), "left/subject0.png".into(), )]), )]) - .collect::>(), + .await?, ); assert_contains_packet( @@ -160,7 +162,7 @@ fn join_spotty() { ]), ], &operator - .next( + .process_packets( (1..3) .map(|i| { ( @@ -173,12 +175,13 @@ fn join_spotty() { }) .collect::>(), ) - .collect::>(), + .await?, ); + Ok(()) } -#[test] -fn map_once() { +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn map_once() -> Result<()> { let mut operator = MapOperator::new(HashMap::from([("key_old".into(), "key_new".into())])); assert_contains_packet( @@ -187,13 +190,14 @@ fn map_once() { make_packet_key("subject".into(), "some/subject.txt".into()), ])], &operator - .next(vec![( + .process_packets(vec![( "parent".into(), Packet::from([ make_packet_key("key_old".into(), "some/key.txt".into()), make_packet_key("subject".into(), "some/subject.txt".into()), ]), )]) - .collect::>(), + .await?, ); + Ok(()) } From 450a90fb3b134ff67ac1cb8d8eecda58adbb7897 Mon Sep 17 00:00:00 2001 From: Synicix Date: Tue, 5 Aug 2025 04:51:54 +0000 Subject: [PATCH 05/65] Add async operation for JoinOperator --- src/core/operator.rs | 96 +++++++++++++++++++++++++++----------------- 1 file changed, 60 insertions(+), 36 deletions(-) diff --git a/src/core/operator.rs b/src/core/operator.rs index 3a0c8b84..7a360fb8 100644 --- a/src/core/operator.rs +++ b/src/core/operator.rs @@ -1,7 +1,7 @@ use crate::uniffi::{error::Result, model::packet::Packet}; use itertools::Itertools as _; use std::{clone::Clone as _, collections::HashMap, iter::IntoIterator as _, sync::Arc}; -use tokio::sync::Mutex; +use tokio::{sync::Mutex, task::JoinSet}; #[allow(async_fn_in_trait, reason = "We only use this internally")] pub trait Operator { @@ -22,48 +22,72 @@ impl JoinOperator { } } -#[expect(clippy::excessive_nesting, reason = "Nesting manageable.")] -impl Operator for JoinOperator { - async fn process_packets(&mut self, packets: Vec<(String, Packet)>) -> Result> { - let mut new_packets = vec![]; +impl JoinOperator { + async fn process_packet( + parent_count: usize, + packet_cache: Arc>>>, + parent_id: String, + packet: Packet, + ) -> Result> { + let mut packet_cache_lock = packet_cache.lock().await; + packet_cache_lock + .entry(parent_id.clone()) + .or_insert_with(|| vec![packet.clone()]) + .push(packet.clone()); - for (parent_id, packet) in packets { - let mut packet_cache_lock = self.packet_cache.lock().await; - packet_cache_lock - .entry(parent_id.clone()) - .or_insert_with(|| vec![packet.clone()]) - .push(packet.clone()); + // If we still don't have at least 1 packet for each parent, skip computation + if packet_cache_lock.len() < parent_count { + return Ok(vec![]); + } - // If we still don't have at least 1 packet for each parent, skip computation - if packet_cache_lock.len() < self.parent_count { - new_packets.extend(vec![]); - continue; - } + // Build the factors for the cartesian product, silently missing key since it shouldn't be possible + let factors = packet_cache_lock + .iter() + .filter_map(|(id, parent_packets)| (*id != parent_id).then_some(parent_packets.clone())) + .chain(vec![vec![packet]]) + .collect::>(); + + // We don't need the lock after this point + drop(packet_cache_lock); - // Build the factors for the cartesian product, silently missing key since it shouldn't be possible - let factors = packet_cache_lock - .iter() - .filter_map(|(id, parent_packets)| { - (*id != parent_id).then_some(parent_packets.clone()) - }) - .chain(vec![vec![packet]]) - .collect::>(); + // Compute the cartesian product of the factors, this might take a while + Ok(factors + .into_iter() + .multi_cartesian_product() + .map(|packets_to_combined| { + packets_to_combined + .into_iter() + .fold(HashMap::new(), |mut acc, new_packet| { + acc.extend(new_packet); + acc + }) + }) + .collect::>()) + } +} - // We don't need the lock after this point - drop(packet_cache_lock); +impl Operator for JoinOperator { + async fn process_packets(&mut self, packets: Vec<(String, Packet)>) -> Result> { + let mut processing_task = JoinSet::new(); - // Compute the cartesian product of the factors, this might take a while - new_packets.extend(factors.into_iter().multi_cartesian_product().map( - |packets_to_combined| { - packets_to_combined - .into_iter() - .fold(HashMap::new(), |mut acc, new_packet| { - acc.extend(new_packet); - acc - }) - }, + for (parent_id, packet) in packets { + processing_task.spawn(Self::process_packet( + self.parent_count, + Arc::clone(&self.packet_cache), + parent_id, + packet, )); } + + let mut new_packets = vec![]; + while let Some(result) = processing_task.join_next().await { + match result { + Ok(Ok(products)) => new_packets.extend(products), + Ok(Err(err)) => return Err(err), + Err(err) => return Err(err.into()), + } + } + Ok(new_packets) } } From e4b3a5464ada3e7f692d1611ab8242b32518adc5 Mon Sep 17 00:00:00 2001 From: Synicix Date: Tue, 5 Aug 2025 11:26:30 +0000 Subject: [PATCH 06/65] Add pod operator + better error handling for pod_result:::new() --- src/core/error.rs | 5 + src/core/operator.rs | 186 ++++++++++++++++++++++++- src/uniffi/error.rs | 35 +++++ src/uniffi/model/packet.rs | 49 +++++++ src/uniffi/model/pod.rs | 109 ++++++++++----- src/uniffi/orchestrator/agent.rs | 17 ++- src/uniffi/orchestrator/docker.rs | 2 +- tests/extra/data/input_txt/Where.txt | 1 + tests/extra/data/input_txt/black.txt | 1 + tests/extra/data/input_txt/cat.txt | 1 + tests/extra/data/input_txt/hiding.txt | 1 + tests/extra/data/input_txt/is_the.txt | 1 + tests/extra/data/input_txt/playing.txt | 1 + tests/extra/data/input_txt/tabby.txt | 1 + tests/fixture/mod.rs | 44 +++++- tests/operator.rs | 98 +++++++++++-- tests/orchestrator.rs | 9 +- 17 files changed, 493 insertions(+), 68 deletions(-) create mode 100644 tests/extra/data/input_txt/Where.txt create mode 100644 tests/extra/data/input_txt/black.txt create mode 100644 tests/extra/data/input_txt/cat.txt create mode 100644 tests/extra/data/input_txt/hiding.txt create mode 100644 tests/extra/data/input_txt/is_the.txt create mode 100644 tests/extra/data/input_txt/playing.txt create mode 100644 tests/extra/data/input_txt/tabby.txt diff --git a/src/core/error.rs b/src/core/error.rs index c4b68a50..574f29d8 100644 --- a/src/core/error.rs +++ b/src/core/error.rs @@ -137,6 +137,7 @@ impl fmt::Debug for OrcaError { | Kind::GeneratedNamesOverflow { backtrace, .. } | Kind::IncompletePacket { backtrace, .. } | Kind::InvalidFilepath { backtrace, .. } + | Kind::InvalidIndex { backtrace, .. } | Kind::InvalidPodResultTerminatedDatetime { backtrace, .. } | Kind::KeyMissing { backtrace, .. } | Kind::NoAnnotationFound { backtrace, .. } @@ -145,6 +146,10 @@ impl fmt::Debug for OrcaError { | Kind::NoMatchingPodRun { backtrace, .. } | Kind::NoRemainingServices { backtrace, .. } | Kind::NoTagFoundInContainerAltImage { backtrace, .. } + | Kind::PodJobProcessingError { backtrace, .. } + | Kind::PodJobOutputNotFound { backtrace, .. } + | Kind::StatusConversionFailure { backtrace, .. } + | Kind::UnexpectedPathType { backtrace, .. } | Kind::BollardError { backtrace, .. } | Kind::ChronoParseError { backtrace, .. } | Kind::DOTError { backtrace, .. } diff --git a/src/core/operator.rs b/src/core/operator.rs index 7a360fb8..226500d9 100644 --- a/src/core/operator.rs +++ b/src/core/operator.rs @@ -1,11 +1,185 @@ -use crate::uniffi::{error::Result, model::packet::Packet}; +use crate::{ + core::{crypto::hash_buffer, model::serialize_hashmap}, + uniffi::{ + error::{Kind, OrcaError, Result, selector}, + model::{ + packet::{Packet, URI}, + pod::{Pod, PodJob, PodResult, PodResultStatus}, + }, + orchestrator::agent::{AgentClient, Response}, + }, +}; use itertools::Itertools as _; -use std::{clone::Clone as _, collections::HashMap, iter::IntoIterator as _, sync::Arc}; +use serde_yaml::Serializer; +use snafu::{OptionExt as _, ResultExt as _}; +use std::{ + clone::Clone as _, collections::HashMap, iter::IntoIterator as _, path::PathBuf, sync::Arc, +}; use tokio::{sync::Mutex, task::JoinSet}; #[allow(async_fn_in_trait, reason = "We only use this internally")] pub trait Operator { - async fn process_packets(&mut self, packets: Vec<(String, Packet)>) -> Result>; + async fn process_packets(&self, packets: Vec<(String, Packet)>) -> Result>; +} + +pub struct PodOperator { + node_id: String, + pod: Arc, + namespace: String, + namespace_lookup: Arc>, + client: Arc, +} + +impl PodOperator { + pub const fn new( + node_id: String, + pod: Arc, + namespace: String, + namespace_lookup: Arc>, + client: Arc, + ) -> Self { + Self { + node_id, + pod, + namespace, + namespace_lookup, + client, + } + } + + async fn process_packet( + pod_job_hash: String, + node_id: String, + packet: Packet, + pod: Arc, + namespace: String, + namespace_lookup: Arc>, + client: Arc, + ) -> Result> { + let input_packet_hash = { + let mut buf = Vec::new(); + let mut serializer = Serializer::new(&mut buf); + serialize_hashmap(&packet, &mut serializer)?; + hash_buffer(buf) + }; + + // Create the pod job + let pod_job = PodJob::new( + None, + Arc::clone(&pod), + packet, + URI::new( + namespace, + format!("pod_runs/{pod_job_hash}/{node_id}/{input_packet_hash}").into(), + ), + pod.recommended_cpus, + pod.recommended_memory, + None, + &namespace_lookup, + )?; + + // Create listener for pod_job + let target_key_exp = format!("group/{}/pod_job/{}/**", client.group, pod_job.hash); + // Create the subscriber + let pod_job_subscriber = client + .session + .declare_subscriber(target_key_exp) + .await + .context(selector::AgentCommunicationFailure {})?; + + // Create the async task to listen for the pod job completion + let pod_job_listener_task = tokio::spawn(async move { + // Wait for the pod job to complete and extract the result + let sample = pod_job_subscriber + .recv_async() + .await + .context(selector::AgentCommunicationFailure {})?; + // Extract the pod_result from the payload + let pod_result: PodResult = serde_json::from_slice(&sample.payload().to_bytes())?; + Ok::<_, OrcaError>(pod_result) + }); + + // Submit it to the client and get the response to make sure it was successful + let responses = client.start_pod_jobs(vec![pod_job.clone().into()]).await; + let response = responses + .first() + .context(selector::InvalidIndex { idx: 0_usize })?; + + match response { + Response::Ok => (), + Response::Err(err) => { + return Err(OrcaError { + kind: Kind::PodJobProcessingError { + hash: pod_job.hash, + reason: err.clone(), + backtrace: Some(snafu::Backtrace::capture()), + }, + }); + } + } + + // Get the pod result from the listener task + let pod_result = pod_job_listener_task.await??; + + // Get the output packet for the pod result + let output_packet = match pod_result.status { + PodResultStatus::Completed => { + // Get the output packet + pod_result.output_packet + } + PodResultStatus::Failed(exit_code) => { + // Processing failed, thus return the error + return Err(OrcaError { + kind: Kind::PodJobProcessingError { + hash: pod_result.pod_job.hash.clone(), + reason: format!("Pod processing failed with exit code {exit_code}"), + backtrace: Some(snafu::Backtrace::capture()), + }, + }); + } + PodResultStatus::Unset => { + // This should not happen, but if it does, we will return an error + return Err(OrcaError { + kind: Kind::PodJobProcessingError { + hash: pod_result.pod_job.hash.clone(), + reason: "Pod processing status is unset".to_owned(), + backtrace: Some(snafu::Backtrace::capture()), + }, + }); + } + }; + + Ok(vec![output_packet]) + } +} + +impl Operator for PodOperator { + async fn process_packets(&self, packets: Vec<(String, Packet)>) -> Result> { + let mut processing_tasks = JoinSet::new(); + + for (pod_job_hash, packet) in packets { + processing_tasks.spawn(Self::process_packet( + pod_job_hash, + self.node_id.clone(), + packet, + Arc::clone(&self.pod), + self.namespace.clone(), + Arc::clone(&self.namespace_lookup), + Arc::clone(&self.client), + )); + } + + let mut new_packets = vec![]; + while let Some(result) = processing_tasks.join_next().await { + match result { + Ok(Ok(products)) => new_packets.extend(products), + Ok(Err(err)) => return Err(err), + Err(err) => return Err(err.into()), + } + } + + Ok(new_packets) + } } pub struct JoinOperator { @@ -20,9 +194,7 @@ impl JoinOperator { packet_cache: Arc::new(Mutex::new(HashMap::new())), } } -} -impl JoinOperator { async fn process_packet( parent_count: usize, packet_cache: Arc>>>, @@ -67,7 +239,7 @@ impl JoinOperator { } impl Operator for JoinOperator { - async fn process_packets(&mut self, packets: Vec<(String, Packet)>) -> Result> { + async fn process_packets(&self, packets: Vec<(String, Packet)>) -> Result> { let mut processing_task = JoinSet::new(); for (parent_id, packet) in packets { @@ -103,7 +275,7 @@ impl MapOperator { } impl Operator for MapOperator { - async fn process_packets(&mut self, packets: Vec<(String, Packet)>) -> Result> { + async fn process_packets(&self, packets: Vec<(String, Packet)>) -> Result> { Ok(packets .into_iter() .map(|(_, packet)| { diff --git a/src/uniffi/error.rs b/src/uniffi/error.rs index 0fe43abe..d0efa508 100644 --- a/src/uniffi/error.rs +++ b/src/uniffi/error.rs @@ -18,6 +18,8 @@ use std::{ }; use tokio::task; use uniffi; + +use crate::uniffi::orchestrator::PodStatus; /// Shorthand for a Result that returns an [`OrcaError`]. pub type Result = result::Result; /// Possible errors you may encounter. @@ -51,6 +53,11 @@ pub(crate) enum Kind { source: io::Error, backtrace: Option, }, + #[snafu(display("Failed to get items at idx {idx}."))] + InvalidIndex { + idx: usize, + backtrace: Option, + }, #[snafu(display( "An invalid datetime was set for pod result for pod job (hash: {pod_job_hash})." ))] @@ -89,6 +96,34 @@ pub(crate) enum Kind { path: PathBuf, backtrace: Option, }, + #[snafu(display("Pod job {hash} failed to process with reason: {reason}."))] + PodJobProcessingError { + hash: String, + reason: String, + backtrace: Option, + }, + #[snafu(display( + "Missing expected output file or dir with key {packet_key} at path {path:?} for pod job (hash: {pod_job_hash})." + ))] + PodJobOutputNotFound { + pod_job_hash: String, + packet_key: String, + path: Box, + backtrace: Option, + }, + #[snafu(display( + "Failed to convert status {status:?} to PodResultStatus with reason: {reason}." + ))] + StatusConversionFailure { + status: PodStatus, + reason: String, + backtrace: Option, + }, + #[snafu(display("Unexpected path type: {path:?}. Only support files and directories."))] + UnexpectedPathType { + path: PathBuf, + backtrace: Option, + }, #[snafu(transparent)] BollardError { source: Box, diff --git a/src/uniffi/model/packet.rs b/src/uniffi/model/packet.rs index 595a3f92..396a6109 100644 --- a/src/uniffi/model/packet.rs +++ b/src/uniffi/model/packet.rs @@ -1,3 +1,4 @@ +use crate::{core::crypto::hash_blob, uniffi::error::Result}; use serde::{Deserialize, Serialize}; use std::{collections::HashMap, path::PathBuf}; use uniffi; @@ -12,6 +13,18 @@ pub struct PathInfo { pub match_pattern: String, } +#[uniffi::export] +impl PathInfo { + #[uniffi::constructor] + /// Create a new `PathInfo` with the given path and match pattern. + pub const fn new(path: PathBuf, match_pattern: String) -> Self { + Self { + path, + match_pattern, + } + } +} + /// File or directory options for BLOBs. #[derive(uniffi::Enum, Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default)] pub enum BlobKind { @@ -31,6 +44,15 @@ pub struct URI { pub path: PathBuf, } +#[uniffi::export] +impl URI { + #[uniffi::constructor] + /// Create a new URI with the given namespace and path. + pub const fn new(namespace: String, path: PathBuf) -> Self { + Self { namespace, path } + } +} + /// BLOB with metadata. #[derive(uniffi::Record, Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default)] pub struct Blob { @@ -42,6 +64,19 @@ pub struct Blob { pub checksum: String, } +#[uniffi::export] +impl Blob { + /// Create a new BLOB with the given kind, location, and checksum. + #[uniffi::constructor] + pub const fn new(kind: BlobKind, location: URI) -> Self { + Self { + kind, + location, + checksum: String::new(), + } + } +} + /// A single BLOB or a collection of BLOBs. #[derive(uniffi::Enum, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] #[serde(untagged)] @@ -52,5 +87,19 @@ pub enum PathSet { Collection(Vec), } +impl PathSet { + pub(crate) fn hash_content(&self, namespace_lookup: &HashMap) -> Result { + match self { + Self::Unary(blob) => Ok(Self::Unary(hash_blob(namespace_lookup, blob)?)), + Self::Collection(blobs) => Ok(Self::Collection( + blobs + .iter() + .map(|blob| hash_blob(namespace_lookup, blob)) + .collect::>()?, + )), + } + } +} + /// A complete set of inputs to be provided to a computational unit. pub type Packet = HashMap; diff --git a/src/uniffi/model/pod.rs b/src/uniffi/model/pod.rs index 59f352d7..2bc5c174 100644 --- a/src/uniffi/model/pod.rs +++ b/src/uniffi/model/pod.rs @@ -9,7 +9,7 @@ use crate::{ validation::validate_packet, }, uniffi::{ - error::{OrcaError, Result}, + error::{Kind, OrcaError, Result}, model::{ Annotation, packet::{Blob, BlobKind, Packet, PathInfo, PathSet, URI}, @@ -183,6 +183,36 @@ impl PodJob { } } +#[derive(uniffi::Enum, Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default)] +/// Status of a pod result. +pub enum PodResultStatus { + /// Pod Job completed successfully. + Completed, + /// Pod Job failed with an exit code. + Failed(i16), + /// Mainly used for default values, not a valid status. + #[default] + Unset, +} + +impl TryFrom for PodResultStatus { + type Error = OrcaError; + + fn try_from(status: PodStatus) -> Result { + match status { + PodStatus::Completed => Ok(Self::Completed), + PodStatus::Failed(code) => Ok(Self::Failed(code)), + PodStatus::Running | PodStatus::Unset => Err(OrcaError { + kind: Kind::StatusConversionFailure { + status, + reason: "Cannot convert Running or Unset status to PodResultStatus".to_owned(), + backtrace: Some(snafu::Backtrace::capture()), + }, + }), + } + } +} + /// Result from a compute job run. #[derive(uniffi::Record, Serialize, Deserialize, Debug, Clone, PartialEq, Default)] pub struct PodResult { @@ -200,7 +230,7 @@ pub struct PodResult { /// Name given by orchestrator. pub assigned_name: String, /// Status of compute run when terminated. - pub status: PodStatus, + pub status: PodResultStatus, /// Time in epoch when created in seconds. pub created: u64, /// Time in epoch when terminated in seconds. @@ -217,7 +247,7 @@ impl PodResult { annotation: Option, pod_job: Arc, assigned_name: String, - status: PodStatus, + status: PodResultStatus, created: u64, terminated: u64, namespace_lookup: &HashMap, @@ -226,44 +256,51 @@ impl PodResult { .pod .output_spec .iter() - .filter_map(|(packet_key, path_info)| { - let location = URI { - namespace: pod_job.output_dir.namespace.clone(), - path: pod_job.output_dir.path.join(&path_info.path), - }; + .map(|(packet_key, path_info)| { + let full_path = get(namespace_lookup, &pod_job.output_dir.namespace)? + .join(&pod_job.output_dir.path) + .join(&path_info.path); - let local_location = match get(namespace_lookup, &location.namespace) { - Ok(root_path) => root_path.join(&location.path), - Err(error) => return Some(Err(error)), - }; - - match local_location.try_exists() { - Ok(false) => None, - Err(error) => Some(Err(OrcaError::from(error))), - Ok(true) => Some(Ok(( - packet_key, - Blob { - kind: if local_location.is_file() { - BlobKind::File - } else { - BlobKind::Directory - }, - location, - checksum: String::new(), + // Check if it exists + if !full_path.exists() { + return Err(OrcaError { + kind: Kind::PodJobOutputNotFound { + pod_job_hash: pod_job.hash.clone(), + packet_key: packet_key.clone(), + path: full_path.into(), + backtrace: Some(snafu::Backtrace::capture()), }, - ))), + }); } + + // Check the type + let path_set = if full_path.is_file() { + PathSet::Unary(Blob::new( + BlobKind::File, + URI::new(pod_job.output_dir.namespace.clone(), full_path), + )) + } else if full_path.is_dir() { + PathSet::Unary(Blob::new( + BlobKind::Directory, + URI::new(pod_job.output_dir.namespace.clone(), full_path), + )) + } else { + return Err(OrcaError { + kind: Kind::UnexpectedPathType { + path: full_path, + backtrace: Some(snafu::Backtrace::capture()), + }, + }); + }; + + let hashed_path_set = path_set.hash_content(namespace_lookup)?; + + // Return the key and pathset + Ok((packet_key.clone(), hashed_path_set)) }) - .map(|result| { - let (packet_key, blob) = result?; - Ok(( - packet_key.clone(), - PathSet::Unary(hash_blob(namespace_lookup, &blob)?), - )) - }) - .collect::>()?; + .collect::>>()?; - if matches!(status, PodStatus::Completed) { + if matches!(status, PodResultStatus::Completed) { validate_packet("output".into(), &pod_job.pod.output_spec, &output_packet)?; } diff --git a/src/uniffi/orchestrator/agent.rs b/src/uniffi/orchestrator/agent.rs index f07a811d..47d317a9 100644 --- a/src/uniffi/orchestrator/agent.rs +++ b/src/uniffi/orchestrator/agent.rs @@ -2,8 +2,8 @@ use crate::{ core::orchestrator::agent::start_service, uniffi::{ error::{OrcaError, Result, selector}, - model::pod::PodJob, - orchestrator::{Orchestrator, PodStatus, docker::LocalDockerOrchestrator}, + model::pod::{PodJob, PodResultStatus}, + orchestrator::{Orchestrator, docker::LocalDockerOrchestrator}, store::{Store as _, filestore::LocalFileStore}, }, }; @@ -168,13 +168,12 @@ impl Agent { client .publish( &format!( - "{}/pod_job/{}", + "pod_job/{}/{}", + pod_result.pod_job.hash, match &pod_result.status { - PodStatus::Completed => "success", - PodStatus::Running | PodStatus::Failed(_) | PodStatus::Unset => - "failure", + PodResultStatus::Completed => "success", + PodResultStatus::Failed(_) | PodResultStatus::Unset => "failure", }, - pod_result.pod_job.hash ), &pod_result, ) @@ -184,7 +183,7 @@ impl Agent { if let Some(store) = available_store { services.spawn(start_service( Arc::new(self.clone()), - "success/pod_job/**".to_owned(), + "pod_job/success/**".to_owned(), namespace_lookup.clone(), { let inner_store = Arc::clone(&store); @@ -197,7 +196,7 @@ impl Agent { )); services.spawn(start_service( Arc::new(self.clone()), - "failure/pod_job/**".to_owned(), + "pod_job/failure/**".to_owned(), namespace_lookup.clone(), async move |_, _, _, pod_result| { store.save_pod_result(&pod_result)?; diff --git a/src/uniffi/orchestrator/docker.rs b/src/uniffi/orchestrator/docker.rs index 6ed00d78..8fa61db5 100644 --- a/src/uniffi/orchestrator/docker.rs +++ b/src/uniffi/orchestrator/docker.rs @@ -231,7 +231,7 @@ impl Orchestrator for LocalDockerOrchestrator { None, Arc::clone(&pod_run.pod_job), pod_run.assigned_name.clone(), - result_info.status, + result_info.status.try_into()?, result_info.created, result_info .terminated diff --git a/tests/extra/data/input_txt/Where.txt b/tests/extra/data/input_txt/Where.txt new file mode 100644 index 00000000..2891a132 --- /dev/null +++ b/tests/extra/data/input_txt/Where.txt @@ -0,0 +1 @@ +Where diff --git a/tests/extra/data/input_txt/black.txt b/tests/extra/data/input_txt/black.txt new file mode 100644 index 00000000..7e66a17d --- /dev/null +++ b/tests/extra/data/input_txt/black.txt @@ -0,0 +1 @@ +black diff --git a/tests/extra/data/input_txt/cat.txt b/tests/extra/data/input_txt/cat.txt new file mode 100644 index 00000000..ef07ddcd --- /dev/null +++ b/tests/extra/data/input_txt/cat.txt @@ -0,0 +1 @@ +cat diff --git a/tests/extra/data/input_txt/hiding.txt b/tests/extra/data/input_txt/hiding.txt new file mode 100644 index 00000000..56e64f05 --- /dev/null +++ b/tests/extra/data/input_txt/hiding.txt @@ -0,0 +1 @@ +hiding diff --git a/tests/extra/data/input_txt/is_the.txt b/tests/extra/data/input_txt/is_the.txt new file mode 100644 index 00000000..863d01a3 --- /dev/null +++ b/tests/extra/data/input_txt/is_the.txt @@ -0,0 +1 @@ +is the diff --git a/tests/extra/data/input_txt/playing.txt b/tests/extra/data/input_txt/playing.txt new file mode 100644 index 00000000..0395b790 --- /dev/null +++ b/tests/extra/data/input_txt/playing.txt @@ -0,0 +1 @@ +playing diff --git a/tests/extra/data/input_txt/tabby.txt b/tests/extra/data/input_txt/tabby.txt new file mode 100644 index 00000000..3de6015d --- /dev/null +++ b/tests/extra/data/input_txt/tabby.txt @@ -0,0 +1 @@ +tabby diff --git a/tests/fixture/mod.rs b/tests/fixture/mod.rs index fb4e9541..d5589fa2 100644 --- a/tests/fixture/mod.rs +++ b/tests/fixture/mod.rs @@ -13,9 +13,8 @@ use orcapod::uniffi::{ model::{ Annotation, packet::{Blob, BlobKind, Packet, PathInfo, PathSet, URI}, - pod::{Pod, PodJob, PodResult}, + pod::{Pod, PodJob, PodResult, PodResultStatus}, }, - orchestrator::PodStatus, store::{ModelID, ModelInfo, Store}, }; use std::{ @@ -149,7 +148,7 @@ pub fn pod_result_style( }), pod_job_style(namespace_lookup)?.into(), "simple-endeavour".to_owned(), - PodStatus::Completed, + PodResultStatus::Completed, 1_737_922_307, 1_737_925_907, namespace_lookup, @@ -280,6 +279,45 @@ pub fn pull_image(reference: &str) -> Result<()> { Ok(()) } +// Pipeline Fixture +pub fn combine_txt_pod(pod_name: &str) -> Result { + Pod::new( + Some(Annotation { + name: pod_name.to_owned(), + description: "Pod append it's own name to the end of the file.".to_owned(), + version: "1.0.0".to_owned(), + }), + "alpine:3.14".to_owned(), + vec![ + "sh".into(), + "-c".into(), + format!("cat input/input_1.txt input/input_2.txt > /output/output.txt"), + ], + HashMap::from([ + ( + "input_1".to_owned(), + PathInfo::new("/input/input_1.txt".into(), r".*\.txt".into()), + ), + ( + "input_2".into(), + PathInfo::new("/input/input_2.txt".into(), r".*\.txt".into()), + ), + ]), + PathBuf::from("/output"), + HashMap::from([( + "output".to_owned(), + PathInfo { + path: PathBuf::from("output.txt"), + match_pattern: r".*\.txt".to_owned(), + }, + )]), + "N/A".to_owned(), + 0.25, // 250 millicores as frac cores + 128_u64 << 20, // 128MB in bytes + None, + ) +} + // --- util --- pub fn str_to_vec(v: &str) -> Vec { diff --git a/tests/operator.rs b/tests/operator.rs index 17a1cdf9..51b1000f 100644 --- a/tests/operator.rs +++ b/tests/operator.rs @@ -1,11 +1,23 @@ -#![expect(missing_docs, clippy::panic_in_result_fn, reason = "OK in tests.")] - +#![expect( + missing_docs, + clippy::panic_in_result_fn, + clippy::unwrap_used, + clippy::panic, + reason = "OK in tests." +)] +pub mod fixture; +use fixture::{TestDirs, combine_txt_pod}; use orcapod::{ - core::operator::{JoinOperator, MapOperator, Operator as _}, - uniffi::error::Result, - uniffi::model::packet::{Blob, BlobKind, Packet, PathSet, URI}, + core::operator::{JoinOperator, MapOperator, Operator as _, PodOperator}, + uniffi::{ + error::Result, + model::packet::{Blob, BlobKind, Packet, PathSet, URI}, + orchestrator::{agent::Agent, docker::LocalDockerOrchestrator}, + }, }; -use std::{collections::HashMap, path::PathBuf}; +use pretty_assertions::assert_eq as pretty_assert_eq; +use std::{collections::HashMap, path::PathBuf, sync::Arc}; +use tokio::fs; fn make_packet_key(key_name: String, filepath: String) -> (String, PathSet) { ( @@ -33,7 +45,7 @@ fn assert_contains_packet(packets_to_check: &Vec, vec_to_check: &[Packet #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn join_once() -> Result<()> { - let mut operator = JoinOperator::new(2); + let operator = JoinOperator::new(2); let left_stream = (0..3) .map(|i| { @@ -96,7 +108,7 @@ async fn join_once() -> Result<()> { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn join_spotty() -> Result<()> { - let mut operator = JoinOperator::new(2); + let operator = JoinOperator::new(2); assert!( operator @@ -182,7 +194,7 @@ async fn join_spotty() -> Result<()> { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn map_once() -> Result<()> { - let mut operator = MapOperator::new(HashMap::from([("key_old".into(), "key_new".into())])); + let operator = MapOperator::new(HashMap::from([("key_old".into(), "key_new".into())])); assert_contains_packet( &vec![Packet::from([ @@ -201,3 +213,71 @@ async fn map_once() -> Result<()> { ); Ok(()) } + +#[tokio::test(flavor = "multi_thread", worker_threads = 3)] +async fn combine_txt_pod_job() -> Result<()> { + // Create the test_dir and get the namespace lookup + let test_dirs = TestDirs::new(&HashMap::from([( + "default".to_owned(), + Some("./tests/extra/data/"), + )]))?; + let namespace_lookup = test_dirs.namespace_lookup(); + + // Start an agent to process the orchestrator + let (group, host) = ("combine_txt_pod_job", "host"); + let agent = Agent::new( + group.to_owned(), + host.to_owned(), + LocalDockerOrchestrator::new()?.into(), + )?; + let agent_client_clone = Arc::clone(&agent.client); + let namespace_lookup_clone = namespace_lookup.clone(); + let agent_join_handle = + tokio::spawn(async move { agent.start(&namespace_lookup_clone, None).await }); + + // Create a pod operator with some fake info for test + let pod_operator = PodOperator::new( + "test_node".into(), + combine_txt_pod("test")?.into(), + "default".into(), + namespace_lookup.into(), + agent_client_clone, + ); + + // Create input packet + let packet = Packet::from([ + ( + "input_1".into(), + PathSet::Unary(Blob::new( + BlobKind::File, + URI::new("default".into(), "input_txt/black.txt".into()), + )), + ), + ( + "input_2".into(), + PathSet::Unary(Blob::new( + BlobKind::File, + URI::new("default".into(), "input_txt/cat.txt".into()), + )), + ), + ]); + + // Process the packet + let output_packets = pod_operator + .process_packets(vec![("pod_operator_test".into(), packet)]) + .await?; + + // Verify that the output file has been created and contains the expected content + match output_packets.first().unwrap().get("output") { + Some(PathSet::Unary(Blob { location, .. })) => { + let file_content = fs::read_to_string(&location.path).await?; + pretty_assert_eq!(file_content, "black\ncat\n"); + } + _ => panic!("Output packet does not contain the expected output blob."), + } + + // Stop the agent + agent_join_handle.abort(); + + Ok(()) +} diff --git a/tests/orchestrator.rs b/tests/orchestrator.rs index 8055b7fd..1a862ad2 100644 --- a/tests/orchestrator.rs +++ b/tests/orchestrator.rs @@ -8,7 +8,10 @@ use fixture::{ use futures_util::future::join_all; use orcapod::uniffi::{ error::{OrcaError, Result}, - model::packet::{Packet, URI}, + model::{ + packet::{Packet, URI}, + pod::PodResultStatus, + }, orchestrator::{ ImageKind, Orchestrator as _, PodRun, PodStatus, docker::LocalDockerOrchestrator, }, @@ -151,7 +154,7 @@ async fn remote_container_image_failed() -> Result<()> { orch.delete(&pod_run).await?; assert!( - matches!(pod_result.status, PodStatus::Failed(1)), + matches!(pod_result.status, PodResultStatus::Failed(1)), "Expected to fail but did not." ); Ok(()) @@ -183,7 +186,7 @@ async fn verify_pod_result_not_running() -> Result<()> { let statuses = results .into_iter() .map(|result| Ok(result?.status)) - .filter(|status| !matches!(status, Ok(PodStatus::Completed))) + .filter(|status| !matches!(status, Ok(PodResultStatus::Completed))) .collect::>>()?; println!("statuses: {statuses:?}"); From fee8d9ce04ca167bf19e2c35d14757249f2ad91f Mon Sep 17 00:00:00 2001 From: Synicix Date: Tue, 5 Aug 2025 12:42:51 +0000 Subject: [PATCH 07/65] Improve error handling for file io in localfilestore --- src/core/store/filestore.rs | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/src/core/store/filestore.rs b/src/core/store/filestore.rs index 2dcd4057..884168e9 100644 --- a/src/core/store/filestore.rs +++ b/src/core/store/filestore.rs @@ -16,7 +16,7 @@ use heck::ToSnakeCase as _; use regex::Regex; use serde::{Serialize, de::DeserializeOwned}; use serde_yaml; -use snafu::OptionExt as _; +use snafu::{OptionExt as _, ResultExt}; use std::{ fmt, fs, path::{Path, PathBuf}, @@ -188,15 +188,17 @@ impl LocalFileStore { model_id: &ModelID, ) -> Result<(T, Option, String)> { match model_id { - ModelID::Hash(hash) => Ok(( - serde_yaml::from_str(&fs::read_to_string(self.make_path( - &T::default(), - hash, - Self::SPEC_RELPATH, - ))?)?, - None, - hash.to_owned(), - )), + ModelID::Hash(hash) => { + let path = self.make_path(&T::default(), hash, Self::SPEC_RELPATH); + Ok(( + serde_yaml::from_str( + &fs::read_to_string(path.clone()) + .context(selector::InvalidFilepath { path })?, + )?, + None, + hash.to_owned(), + )) + } ModelID::Annotation(name, version) => { let hash = self.lookup_hash(&T::default(), name, version)?; Ok(( From c04335f73eab944fa608f41401143631301bfee5 Mon Sep 17 00:00:00 2001 From: Synicix Date: Tue, 5 Aug 2025 21:36:11 +0000 Subject: [PATCH 08/65] Update operator to be in operation again --- cspell.json | 3 ++- src/core/operator.rs | 2 +- src/core/store/filestore.rs | 2 +- src/uniffi/orchestrator/agent.rs | 18 ++---------------- 4 files changed, 6 insertions(+), 19 deletions(-) diff --git a/cspell.json b/cspell.json index 4f00f0b8..99f8c978 100644 --- a/cspell.json +++ b/cspell.json @@ -79,7 +79,8 @@ "getrandom", "wasi", "patchelf", - "itertools" + "itertools", + "colinianking" ], "useGitignore": false, "ignorePaths": [ diff --git a/src/core/operator.rs b/src/core/operator.rs index 226500d9..a126c3f1 100644 --- a/src/core/operator.rs +++ b/src/core/operator.rs @@ -79,7 +79,7 @@ impl PodOperator { )?; // Create listener for pod_job - let target_key_exp = format!("group/{}/pod_job/{}/**", client.group, pod_job.hash); + let target_key_exp = format!("group/{}/pod_job/{}/status/**", client.group, pod_job.hash); // Create the subscriber let pod_job_subscriber = client .session diff --git a/src/core/store/filestore.rs b/src/core/store/filestore.rs index 884168e9..efa91936 100644 --- a/src/core/store/filestore.rs +++ b/src/core/store/filestore.rs @@ -16,7 +16,7 @@ use heck::ToSnakeCase as _; use regex::Regex; use serde::{Serialize, de::DeserializeOwned}; use serde_yaml; -use snafu::{OptionExt as _, ResultExt}; +use snafu::{OptionExt as _, ResultExt as _}; use std::{ fmt, fs, path::{Path, PathBuf}, diff --git a/src/uniffi/orchestrator/agent.rs b/src/uniffi/orchestrator/agent.rs index 47d317a9..0a163575 100644 --- a/src/uniffi/orchestrator/agent.rs +++ b/src/uniffi/orchestrator/agent.rs @@ -141,7 +141,6 @@ impl Agent { /// # Errors /// /// Will stop and return an error if encounters an error while processing any pod job request. - #[expect(clippy::excessive_nesting, reason = "Nesting manageable.")] pub async fn start( &self, namespace_lookup: &HashMap, @@ -168,7 +167,7 @@ impl Agent { client .publish( &format!( - "pod_job/{}/{}", + "pod_job/{}/status/{}", pod_result.pod_job.hash, match &pod_result.status { PodResultStatus::Completed => "success", @@ -183,20 +182,7 @@ impl Agent { if let Some(store) = available_store { services.spawn(start_service( Arc::new(self.clone()), - "pod_job/success/**".to_owned(), - namespace_lookup.clone(), - { - let inner_store = Arc::clone(&store); - async move |_, _, _, pod_result| { - inner_store.save_pod_result(&pod_result)?; - Ok(()) - } - }, - async |_, ()| Ok(()), - )); - services.spawn(start_service( - Arc::new(self.clone()), - "pod_job/failure/**".to_owned(), + "pod_job/*/status/**".to_owned(), namespace_lookup.clone(), async move |_, _, _, pod_result| { store.save_pod_result(&pod_result)?; From ebe04d1de7a2daf5593f5314f9465e2956a150ef Mon Sep 17 00:00:00 2001 From: Synicix Date: Tue, 5 Aug 2025 22:00:19 +0000 Subject: [PATCH 09/65] Fix pod result hashing bug --- src/uniffi/model/pod.rs | 10 +++++----- tests/operator.rs | 7 +++++-- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/src/uniffi/model/pod.rs b/src/uniffi/model/pod.rs index 2bc5c174..53eb3042 100644 --- a/src/uniffi/model/pod.rs +++ b/src/uniffi/model/pod.rs @@ -257,9 +257,9 @@ impl PodResult { .output_spec .iter() .map(|(packet_key, path_info)| { - let full_path = get(namespace_lookup, &pod_job.output_dir.namespace)? - .join(&pod_job.output_dir.path) - .join(&path_info.path); + let rel_path = &pod_job.output_dir.path.join(&path_info.path); + let full_path = + get(namespace_lookup, &pod_job.output_dir.namespace)?.join(rel_path); // Check if it exists if !full_path.exists() { @@ -277,12 +277,12 @@ impl PodResult { let path_set = if full_path.is_file() { PathSet::Unary(Blob::new( BlobKind::File, - URI::new(pod_job.output_dir.namespace.clone(), full_path), + URI::new(pod_job.output_dir.namespace.clone(), rel_path.into()), )) } else if full_path.is_dir() { PathSet::Unary(Blob::new( BlobKind::Directory, - URI::new(pod_job.output_dir.namespace.clone(), full_path), + URI::new(pod_job.output_dir.namespace.clone(), rel_path.into()), )) } else { return Err(OrcaError { diff --git a/tests/operator.rs b/tests/operator.rs index 51b1000f..53a6d235 100644 --- a/tests/operator.rs +++ b/tests/operator.rs @@ -3,6 +3,7 @@ clippy::panic_in_result_fn, clippy::unwrap_used, clippy::panic, + clippy::indexing_slicing, reason = "OK in tests." )] pub mod fixture; @@ -240,7 +241,7 @@ async fn combine_txt_pod_job() -> Result<()> { "test_node".into(), combine_txt_pod("test")?.into(), "default".into(), - namespace_lookup.into(), + namespace_lookup.clone().into(), agent_client_clone, ); @@ -270,7 +271,9 @@ async fn combine_txt_pod_job() -> Result<()> { // Verify that the output file has been created and contains the expected content match output_packets.first().unwrap().get("output") { Some(PathSet::Unary(Blob { location, .. })) => { - let file_content = fs::read_to_string(&location.path).await?; + let file_content = + fs::read_to_string(namespace_lookup[&location.namespace].join(&location.path)) + .await?; pretty_assert_eq!(file_content, "black\ncat\n"); } _ => panic!("Output packet does not contain the expected output blob."), From 20f234581a6696fbb65ae7f49948c4d15dd56a30 Mon Sep 17 00:00:00 2001 From: Synicix Date: Wed, 6 Aug 2025 09:42:20 +0000 Subject: [PATCH 10/65] Apply feedback from review --- Cargo.toml | 1 + src/core/error.rs | 2 +- src/core/operator.rs | 220 +++--------------------------- src/uniffi/error.rs | 3 +- src/uniffi/model/packet.rs | 46 +------ src/uniffi/model/pod.rs | 107 +++++++-------- src/uniffi/orchestrator/agent.rs | 8 +- src/uniffi/orchestrator/docker.rs | 2 +- tests/fixture/mod.rs | 15 +- tests/operator.rs | 133 ++++-------------- tests/orchestrator.rs | 9 +- 11 files changed, 119 insertions(+), 427 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 9e40996f..a62fe3fa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -159,6 +159,7 @@ std_instead_of_alloc = { level = "allow", priority = 127 } # we shou std_instead_of_core = { level = "allow", priority = 127 } # we should use std when possible string_add = { level = "allow", priority = 127 } # simple concat ok string_lit_chars_any = { level = "allow", priority = 127 } # favor readability until a perf case comes up +wildcard_enum_match_arm = { level = "allow", priority = 127 } # allow wildcard match arm in enums use_debug = { level = "warn", priority = 127 } # debug print # temporary diff --git a/src/core/error.rs b/src/core/error.rs index 574f29d8..8a6b2be7 100644 --- a/src/core/error.rs +++ b/src/core/error.rs @@ -147,7 +147,7 @@ impl fmt::Debug for OrcaError { | Kind::NoRemainingServices { backtrace, .. } | Kind::NoTagFoundInContainerAltImage { backtrace, .. } | Kind::PodJobProcessingError { backtrace, .. } - | Kind::PodJobOutputNotFound { backtrace, .. } + | Kind::FailedToGetPodJobOutput { backtrace, .. } | Kind::StatusConversionFailure { backtrace, .. } | Kind::UnexpectedPathType { backtrace, .. } | Kind::BollardError { backtrace, .. } diff --git a/src/core/operator.rs b/src/core/operator.rs index a126c3f1..667c6478 100644 --- a/src/core/operator.rs +++ b/src/core/operator.rs @@ -1,20 +1,6 @@ -use crate::{ - core::{crypto::hash_buffer, model::serialize_hashmap}, - uniffi::{ - error::{Kind, OrcaError, Result, selector}, - model::{ - packet::{Packet, URI}, - pod::{Pod, PodJob, PodResult, PodResultStatus}, - }, - orchestrator::agent::{AgentClient, Response}, - }, -}; +use crate::uniffi::{error::Result, model::packet::Packet}; use itertools::Itertools as _; -use serde_yaml::Serializer; -use snafu::{OptionExt as _, ResultExt as _}; -use std::{ - clone::Clone as _, collections::HashMap, iter::IntoIterator as _, path::PathBuf, sync::Arc, -}; +use std::{clone::Clone as _, collections::HashMap, iter::IntoIterator as _, sync::Arc}; use tokio::{sync::Mutex, task::JoinSet}; #[allow(async_fn_in_trait, reason = "We only use this internally")] @@ -22,219 +8,57 @@ pub trait Operator { async fn process_packets(&self, packets: Vec<(String, Packet)>) -> Result>; } -pub struct PodOperator { - node_id: String, - pod: Arc, - namespace: String, - namespace_lookup: Arc>, - client: Arc, -} - -impl PodOperator { - pub const fn new( - node_id: String, - pod: Arc, - namespace: String, - namespace_lookup: Arc>, - client: Arc, - ) -> Self { - Self { - node_id, - pod, - namespace, - namespace_lookup, - client, - } - } - - async fn process_packet( - pod_job_hash: String, - node_id: String, - packet: Packet, - pod: Arc, - namespace: String, - namespace_lookup: Arc>, - client: Arc, - ) -> Result> { - let input_packet_hash = { - let mut buf = Vec::new(); - let mut serializer = Serializer::new(&mut buf); - serialize_hashmap(&packet, &mut serializer)?; - hash_buffer(buf) - }; - - // Create the pod job - let pod_job = PodJob::new( - None, - Arc::clone(&pod), - packet, - URI::new( - namespace, - format!("pod_runs/{pod_job_hash}/{node_id}/{input_packet_hash}").into(), - ), - pod.recommended_cpus, - pod.recommended_memory, - None, - &namespace_lookup, - )?; - - // Create listener for pod_job - let target_key_exp = format!("group/{}/pod_job/{}/status/**", client.group, pod_job.hash); - // Create the subscriber - let pod_job_subscriber = client - .session - .declare_subscriber(target_key_exp) - .await - .context(selector::AgentCommunicationFailure {})?; - - // Create the async task to listen for the pod job completion - let pod_job_listener_task = tokio::spawn(async move { - // Wait for the pod job to complete and extract the result - let sample = pod_job_subscriber - .recv_async() - .await - .context(selector::AgentCommunicationFailure {})?; - // Extract the pod_result from the payload - let pod_result: PodResult = serde_json::from_slice(&sample.payload().to_bytes())?; - Ok::<_, OrcaError>(pod_result) - }); - - // Submit it to the client and get the response to make sure it was successful - let responses = client.start_pod_jobs(vec![pod_job.clone().into()]).await; - let response = responses - .first() - .context(selector::InvalidIndex { idx: 0_usize })?; - - match response { - Response::Ok => (), - Response::Err(err) => { - return Err(OrcaError { - kind: Kind::PodJobProcessingError { - hash: pod_job.hash, - reason: err.clone(), - backtrace: Some(snafu::Backtrace::capture()), - }, - }); - } - } - - // Get the pod result from the listener task - let pod_result = pod_job_listener_task.await??; - - // Get the output packet for the pod result - let output_packet = match pod_result.status { - PodResultStatus::Completed => { - // Get the output packet - pod_result.output_packet - } - PodResultStatus::Failed(exit_code) => { - // Processing failed, thus return the error - return Err(OrcaError { - kind: Kind::PodJobProcessingError { - hash: pod_result.pod_job.hash.clone(), - reason: format!("Pod processing failed with exit code {exit_code}"), - backtrace: Some(snafu::Backtrace::capture()), - }, - }); - } - PodResultStatus::Unset => { - // This should not happen, but if it does, we will return an error - return Err(OrcaError { - kind: Kind::PodJobProcessingError { - hash: pod_result.pod_job.hash.clone(), - reason: "Pod processing status is unset".to_owned(), - backtrace: Some(snafu::Backtrace::capture()), - }, - }); - } - }; - - Ok(vec![output_packet]) - } -} - -impl Operator for PodOperator { - async fn process_packets(&self, packets: Vec<(String, Packet)>) -> Result> { - let mut processing_tasks = JoinSet::new(); - - for (pod_job_hash, packet) in packets { - processing_tasks.spawn(Self::process_packet( - pod_job_hash, - self.node_id.clone(), - packet, - Arc::clone(&self.pod), - self.namespace.clone(), - Arc::clone(&self.namespace_lookup), - Arc::clone(&self.client), - )); - } - - let mut new_packets = vec![]; - while let Some(result) = processing_tasks.join_next().await { - match result { - Ok(Ok(products)) => new_packets.extend(products), - Ok(Err(err)) => return Err(err), - Err(err) => return Err(err.into()), - } - } - - Ok(new_packets) - } -} - pub struct JoinOperator { parent_count: usize, - packet_cache: Arc>>>, + received_packets: Arc>>>, } impl JoinOperator { pub fn new(parent_count: usize) -> Self { Self { parent_count, - packet_cache: Arc::new(Mutex::new(HashMap::new())), + received_packets: Arc::new(Mutex::new(HashMap::new())), } } async fn process_packet( parent_count: usize, - packet_cache: Arc>>>, + received_packets: Arc>>>, parent_id: String, packet: Packet, - ) -> Result> { - let mut packet_cache_lock = packet_cache.lock().await; - packet_cache_lock + ) -> Vec { + let mut received_packet_lock = received_packets.lock().await; + received_packet_lock .entry(parent_id.clone()) .or_insert_with(|| vec![packet.clone()]) .push(packet.clone()); // If we still don't have at least 1 packet for each parent, skip computation - if packet_cache_lock.len() < parent_count { - return Ok(vec![]); + if received_packet_lock.len() < parent_count { + return vec![]; } // Build the factors for the cartesian product, silently missing key since it shouldn't be possible - let factors = packet_cache_lock + let factors = received_packet_lock .iter() .filter_map(|(id, parent_packets)| (*id != parent_id).then_some(parent_packets.clone())) .chain(vec![vec![packet]]) .collect::>(); - // We don't need the lock after this point - drop(packet_cache_lock); + // We don't need the lock after this point, thus release for other tasks + drop(received_packet_lock); // Compute the cartesian product of the factors, this might take a while - Ok(factors + factors .into_iter() .multi_cartesian_product() .map(|packets_to_combined| { packets_to_combined .into_iter() - .fold(HashMap::new(), |mut acc, new_packet| { - acc.extend(new_packet); - acc - }) + .flat_map(IntoIterator::into_iter) + .collect::>() }) - .collect::>()) + .collect::>() } } @@ -245,19 +69,15 @@ impl Operator for JoinOperator { for (parent_id, packet) in packets { processing_task.spawn(Self::process_packet( self.parent_count, - Arc::clone(&self.packet_cache), + Arc::clone(&self.received_packets), parent_id, packet, )); } let mut new_packets = vec![]; - while let Some(result) = processing_task.join_next().await { - match result { - Ok(Ok(products)) => new_packets.extend(products), - Ok(Err(err)) => return Err(err), - Err(err) => return Err(err.into()), - } + while let Some(product_packets) = processing_task.join_next().await { + new_packets.extend(product_packets?); } Ok(new_packets) diff --git a/src/uniffi/error.rs b/src/uniffi/error.rs index d0efa508..467008be 100644 --- a/src/uniffi/error.rs +++ b/src/uniffi/error.rs @@ -105,10 +105,11 @@ pub(crate) enum Kind { #[snafu(display( "Missing expected output file or dir with key {packet_key} at path {path:?} for pod job (hash: {pod_job_hash})." ))] - PodJobOutputNotFound { + FailedToGetPodJobOutput { pod_job_hash: String, packet_key: String, path: Box, + io_error: Box, backtrace: Option, }, #[snafu(display( diff --git a/src/uniffi/model/packet.rs b/src/uniffi/model/packet.rs index 396a6109..c1514533 100644 --- a/src/uniffi/model/packet.rs +++ b/src/uniffi/model/packet.rs @@ -1,4 +1,3 @@ -use crate::{core::crypto::hash_blob, uniffi::error::Result}; use serde::{Deserialize, Serialize}; use std::{collections::HashMap, path::PathBuf}; use uniffi; @@ -14,16 +13,7 @@ pub struct PathInfo { } #[uniffi::export] -impl PathInfo { - #[uniffi::constructor] - /// Create a new `PathInfo` with the given path and match pattern. - pub const fn new(path: PathBuf, match_pattern: String) -> Self { - Self { - path, - match_pattern, - } - } -} +impl PathInfo {} /// File or directory options for BLOBs. #[derive(uniffi::Enum, Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default)] @@ -45,13 +35,7 @@ pub struct URI { } #[uniffi::export] -impl URI { - #[uniffi::constructor] - /// Create a new URI with the given namespace and path. - pub const fn new(namespace: String, path: PathBuf) -> Self { - Self { namespace, path } - } -} +impl URI {} /// BLOB with metadata. #[derive(uniffi::Record, Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default)] @@ -65,17 +49,7 @@ pub struct Blob { } #[uniffi::export] -impl Blob { - /// Create a new BLOB with the given kind, location, and checksum. - #[uniffi::constructor] - pub const fn new(kind: BlobKind, location: URI) -> Self { - Self { - kind, - location, - checksum: String::new(), - } - } -} +impl Blob {} /// A single BLOB or a collection of BLOBs. #[derive(uniffi::Enum, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] @@ -87,19 +61,5 @@ pub enum PathSet { Collection(Vec), } -impl PathSet { - pub(crate) fn hash_content(&self, namespace_lookup: &HashMap) -> Result { - match self { - Self::Unary(blob) => Ok(Self::Unary(hash_blob(namespace_lookup, blob)?)), - Self::Collection(blobs) => Ok(Self::Collection( - blobs - .iter() - .map(|blob| hash_blob(namespace_lookup, blob)) - .collect::>()?, - )), - } - } -} - /// A complete set of inputs to be provided to a computational unit. pub type Packet = HashMap; diff --git a/src/uniffi/model/pod.rs b/src/uniffi/model/pod.rs index 53eb3042..752a3c8f 100644 --- a/src/uniffi/model/pod.rs +++ b/src/uniffi/model/pod.rs @@ -20,7 +20,7 @@ use crate::{ use derive_more::Display; use getset::CloneGetters; use serde::{Deserialize, Serialize}; -use std::{collections::HashMap, path::PathBuf, sync::Arc}; +use std::{collections::HashMap, io, path::PathBuf, sync::Arc}; use uniffi; /// A reusable, containerized computational unit. @@ -182,37 +182,6 @@ impl PodJob { }) } } - -#[derive(uniffi::Enum, Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default)] -/// Status of a pod result. -pub enum PodResultStatus { - /// Pod Job completed successfully. - Completed, - /// Pod Job failed with an exit code. - Failed(i16), - /// Mainly used for default values, not a valid status. - #[default] - Unset, -} - -impl TryFrom for PodResultStatus { - type Error = OrcaError; - - fn try_from(status: PodStatus) -> Result { - match status { - PodStatus::Completed => Ok(Self::Completed), - PodStatus::Failed(code) => Ok(Self::Failed(code)), - PodStatus::Running | PodStatus::Unset => Err(OrcaError { - kind: Kind::StatusConversionFailure { - status, - reason: "Cannot convert Running or Unset status to PodResultStatus".to_owned(), - backtrace: Some(snafu::Backtrace::capture()), - }, - }), - } - } -} - /// Result from a compute job run. #[derive(uniffi::Record, Serialize, Deserialize, Debug, Clone, PartialEq, Default)] pub struct PodResult { @@ -230,7 +199,7 @@ pub struct PodResult { /// Name given by orchestrator. pub assigned_name: String, /// Status of compute run when terminated. - pub status: PodResultStatus, + pub status: PodStatus, /// Time in epoch when created in seconds. pub created: u64, /// Time in epoch when terminated in seconds. @@ -247,7 +216,7 @@ impl PodResult { annotation: Option, pod_job: Arc, assigned_name: String, - status: PodResultStatus, + status: PodStatus, created: u64, terminated: u64, namespace_lookup: &HashMap, @@ -261,30 +230,40 @@ impl PodResult { let full_path = get(namespace_lookup, &pod_job.output_dir.namespace)?.join(rel_path); - // Check if it exists - if !full_path.exists() { - return Err(OrcaError { - kind: Kind::PodJobOutputNotFound { - pod_job_hash: pod_job.hash.clone(), - packet_key: packet_key.clone(), - path: full_path.into(), - backtrace: Some(snafu::Backtrace::capture()), - }, - }); + // Check if exists and any permissions issues + match full_path.try_exists() { + Ok(true) => (), + Ok(false) => { + return Err(OrcaError { + kind: Kind::FailedToGetPodJobOutput { + pod_job_hash: pod_job.hash.clone(), + packet_key: packet_key.clone(), + path: full_path.into(), + io_error: Box::new(io::ErrorKind::NotFound.into()), + backtrace: Some(snafu::Backtrace::capture()), + }, + }); + } + Err(err) => { + return Err(OrcaError { + kind: Kind::FailedToGetPodJobOutput { + pod_job_hash: pod_job.hash.clone(), + packet_key: packet_key.clone(), + path: full_path.into(), + io_error: Box::new(err), + backtrace: Some(snafu::Backtrace::capture()), + }, + }); + } } - // Check the type - let path_set = if full_path.is_file() { - PathSet::Unary(Blob::new( - BlobKind::File, - URI::new(pod_job.output_dir.namespace.clone(), rel_path.into()), - )) + // Determine if file or directory and handle other cases such as socket, named_pipe, etc. + let blob_kind = if full_path.is_file() { + BlobKind::File } else if full_path.is_dir() { - PathSet::Unary(Blob::new( - BlobKind::Directory, - URI::new(pod_job.output_dir.namespace.clone(), rel_path.into()), - )) + BlobKind::Directory } else { + // Will fail on socket, named pipe, etc. return Err(OrcaError { kind: Kind::UnexpectedPathType { path: full_path, @@ -293,14 +272,24 @@ impl PodResult { }); }; - let hashed_path_set = path_set.hash_content(namespace_lookup)?; - - // Return the key and pathset - Ok((packet_key.clone(), hashed_path_set)) + Ok(( + packet_key.clone(), + PathSet::Unary(hash_blob( + namespace_lookup, + &Blob { + kind: blob_kind, + location: URI { + namespace: pod_job.output_dir.namespace.clone(), + path: rel_path.into(), + }, + ..Default::default() + }, + )?), + )) }) .collect::>>()?; - if matches!(status, PodResultStatus::Completed) { + if matches!(status, PodStatus::Completed) { validate_packet("output".into(), &pod_job.pod.output_spec, &output_packet)?; } diff --git a/src/uniffi/orchestrator/agent.rs b/src/uniffi/orchestrator/agent.rs index 0a163575..e9a63722 100644 --- a/src/uniffi/orchestrator/agent.rs +++ b/src/uniffi/orchestrator/agent.rs @@ -2,8 +2,8 @@ use crate::{ core::orchestrator::agent::start_service, uniffi::{ error::{OrcaError, Result, selector}, - model::pod::{PodJob, PodResultStatus}, - orchestrator::{Orchestrator, docker::LocalDockerOrchestrator}, + model::pod::PodJob, + orchestrator::{Orchestrator, PodStatus, docker::LocalDockerOrchestrator}, store::{Store as _, filestore::LocalFileStore}, }, }; @@ -170,8 +170,8 @@ impl Agent { "pod_job/{}/status/{}", pod_result.pod_job.hash, match &pod_result.status { - PodResultStatus::Completed => "success", - PodResultStatus::Failed(_) | PodResultStatus::Unset => "failure", + PodStatus::Completed => "success", + _ => "failure", }, ), &pod_result, diff --git a/src/uniffi/orchestrator/docker.rs b/src/uniffi/orchestrator/docker.rs index 8fa61db5..6ed00d78 100644 --- a/src/uniffi/orchestrator/docker.rs +++ b/src/uniffi/orchestrator/docker.rs @@ -231,7 +231,7 @@ impl Orchestrator for LocalDockerOrchestrator { None, Arc::clone(&pod_run.pod_job), pod_run.assigned_name.clone(), - result_info.status.try_into()?, + result_info.status, result_info.created, result_info .terminated diff --git a/tests/fixture/mod.rs b/tests/fixture/mod.rs index d5589fa2..ed6ff2fa 100644 --- a/tests/fixture/mod.rs +++ b/tests/fixture/mod.rs @@ -13,8 +13,9 @@ use orcapod::uniffi::{ model::{ Annotation, packet::{Blob, BlobKind, Packet, PathInfo, PathSet, URI}, - pod::{Pod, PodJob, PodResult, PodResultStatus}, + pod::{Pod, PodJob, PodResult}, }, + orchestrator::PodStatus, store::{ModelID, ModelInfo, Store}, }; use std::{ @@ -148,7 +149,7 @@ pub fn pod_result_style( }), pod_job_style(namespace_lookup)?.into(), "simple-endeavour".to_owned(), - PodResultStatus::Completed, + PodStatus::Completed, 1_737_922_307, 1_737_925_907, namespace_lookup, @@ -296,11 +297,17 @@ pub fn combine_txt_pod(pod_name: &str) -> Result { HashMap::from([ ( "input_1".to_owned(), - PathInfo::new("/input/input_1.txt".into(), r".*\.txt".into()), + PathInfo { + path: PathBuf::from("/input/input_1.txt"), + match_pattern: r".*\.txt".to_owned(), + }, ), ( "input_2".into(), - PathInfo::new("/input/input_2.txt".into(), r".*\.txt".into()), + PathInfo { + path: PathBuf::from("/input/input_2.txt"), + match_pattern: r".*\.txt".to_owned(), + }, ), ]), PathBuf::from("/output"), diff --git a/tests/operator.rs b/tests/operator.rs index 53a6d235..4079b311 100644 --- a/tests/operator.rs +++ b/tests/operator.rs @@ -1,24 +1,13 @@ -#![expect( - missing_docs, - clippy::panic_in_result_fn, - clippy::unwrap_used, - clippy::panic, - clippy::indexing_slicing, - reason = "OK in tests." -)] +#![expect(missing_docs, clippy::panic_in_result_fn, reason = "OK in tests.")] pub mod fixture; -use fixture::{TestDirs, combine_txt_pod}; use orcapod::{ - core::operator::{JoinOperator, MapOperator, Operator as _, PodOperator}, + core::operator::{JoinOperator, MapOperator, Operator as _}, uniffi::{ error::Result, model::packet::{Blob, BlobKind, Packet, PathSet, URI}, - orchestrator::{agent::Agent, docker::LocalDockerOrchestrator}, }, }; -use pretty_assertions::assert_eq as pretty_assert_eq; -use std::{collections::HashMap, path::PathBuf, sync::Arc}; -use tokio::fs; +use std::{collections::HashMap, path::PathBuf}; fn make_packet_key(key_name: String, filepath: String) -> (String, PathSet) { ( @@ -38,8 +27,7 @@ fn assert_contains_packet(packets_to_check: &Vec, vec_to_check: &[Packet for packet in packets_to_check { assert!( vec_to_check.contains(packet), - "{}", - format!("Expected packet {packet:?} not found in the vector.") + "Expected packet {packet:?} not found in the vector." ); } } @@ -76,6 +64,7 @@ async fn join_once() -> Result<()> { input_streams.extend(right_stream); assert_contains_packet( + &operator.process_packets(input_streams).await?, &vec![ Packet::from([ make_packet_key("subject".into(), "left/subject0.png".into()), @@ -102,7 +91,6 @@ async fn join_once() -> Result<()> { make_packet_key("style".into(), "right/style1.t7".into()), ]), ], - &operator.process_packets(input_streams).await?, ); Ok(()) } @@ -156,7 +144,22 @@ async fn join_spotty() -> Result<()> { ); assert_contains_packet( - &vec![ + &operator + .process_packets( + (1..3) + .map(|i| { + ( + "left".into(), + Packet::from([make_packet_key( + "subject".into(), + format!("left/subject{i}.png"), + )]), + ) + }) + .collect::>(), + ) + .await?, + &[ Packet::from([ make_packet_key("subject".into(), "left/subject1.png".into()), make_packet_key("style".into(), "right/style0.t7".into()), @@ -174,21 +177,6 @@ async fn join_spotty() -> Result<()> { make_packet_key("style".into(), "right/style1.t7".into()), ]), ], - &operator - .process_packets( - (1..3) - .map(|i| { - ( - "left".into(), - Packet::from([make_packet_key( - "subject".into(), - format!("left/subject{i}.png"), - )]), - ) - }) - .collect::>(), - ) - .await?, ); Ok(()) } @@ -196,12 +184,7 @@ async fn join_spotty() -> Result<()> { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn map_once() -> Result<()> { let operator = MapOperator::new(HashMap::from([("key_old".into(), "key_new".into())])); - assert_contains_packet( - &vec![Packet::from([ - make_packet_key("key_new".into(), "some/key.txt".into()), - make_packet_key("subject".into(), "some/subject.txt".into()), - ])], &operator .process_packets(vec![( "parent".into(), @@ -211,76 +194,10 @@ async fn map_once() -> Result<()> { ]), )]) .await?, + &[Packet::from([ + make_packet_key("key_new".into(), "some/key.txt".into()), + make_packet_key("subject".into(), "some/subject.txt".into()), + ])], ); Ok(()) } - -#[tokio::test(flavor = "multi_thread", worker_threads = 3)] -async fn combine_txt_pod_job() -> Result<()> { - // Create the test_dir and get the namespace lookup - let test_dirs = TestDirs::new(&HashMap::from([( - "default".to_owned(), - Some("./tests/extra/data/"), - )]))?; - let namespace_lookup = test_dirs.namespace_lookup(); - - // Start an agent to process the orchestrator - let (group, host) = ("combine_txt_pod_job", "host"); - let agent = Agent::new( - group.to_owned(), - host.to_owned(), - LocalDockerOrchestrator::new()?.into(), - )?; - let agent_client_clone = Arc::clone(&agent.client); - let namespace_lookup_clone = namespace_lookup.clone(); - let agent_join_handle = - tokio::spawn(async move { agent.start(&namespace_lookup_clone, None).await }); - - // Create a pod operator with some fake info for test - let pod_operator = PodOperator::new( - "test_node".into(), - combine_txt_pod("test")?.into(), - "default".into(), - namespace_lookup.clone().into(), - agent_client_clone, - ); - - // Create input packet - let packet = Packet::from([ - ( - "input_1".into(), - PathSet::Unary(Blob::new( - BlobKind::File, - URI::new("default".into(), "input_txt/black.txt".into()), - )), - ), - ( - "input_2".into(), - PathSet::Unary(Blob::new( - BlobKind::File, - URI::new("default".into(), "input_txt/cat.txt".into()), - )), - ), - ]); - - // Process the packet - let output_packets = pod_operator - .process_packets(vec![("pod_operator_test".into(), packet)]) - .await?; - - // Verify that the output file has been created and contains the expected content - match output_packets.first().unwrap().get("output") { - Some(PathSet::Unary(Blob { location, .. })) => { - let file_content = - fs::read_to_string(namespace_lookup[&location.namespace].join(&location.path)) - .await?; - pretty_assert_eq!(file_content, "black\ncat\n"); - } - _ => panic!("Output packet does not contain the expected output blob."), - } - - // Stop the agent - agent_join_handle.abort(); - - Ok(()) -} diff --git a/tests/orchestrator.rs b/tests/orchestrator.rs index 1a862ad2..8055b7fd 100644 --- a/tests/orchestrator.rs +++ b/tests/orchestrator.rs @@ -8,10 +8,7 @@ use fixture::{ use futures_util::future::join_all; use orcapod::uniffi::{ error::{OrcaError, Result}, - model::{ - packet::{Packet, URI}, - pod::PodResultStatus, - }, + model::packet::{Packet, URI}, orchestrator::{ ImageKind, Orchestrator as _, PodRun, PodStatus, docker::LocalDockerOrchestrator, }, @@ -154,7 +151,7 @@ async fn remote_container_image_failed() -> Result<()> { orch.delete(&pod_run).await?; assert!( - matches!(pod_result.status, PodResultStatus::Failed(1)), + matches!(pod_result.status, PodStatus::Failed(1)), "Expected to fail but did not." ); Ok(()) @@ -186,7 +183,7 @@ async fn verify_pod_result_not_running() -> Result<()> { let statuses = results .into_iter() .map(|result| Ok(result?.status)) - .filter(|status| !matches!(status, Ok(PodResultStatus::Completed))) + .filter(|status| !matches!(status, Ok(PodStatus::Completed))) .collect::>>()?; println!("statuses: {statuses:?}"); From cb728298a87b693975235dfc7185686f4ab75c5e Mon Sep 17 00:00:00 2001 From: Synicix Date: Fri, 8 Aug 2025 23:01:40 +0000 Subject: [PATCH 11/65] Remove stale error --- src/core/error.rs | 14 -------------- src/uniffi/error.rs | 11 ----------- tests/error.rs | 38 +------------------------------------- 3 files changed, 1 insertion(+), 62 deletions(-) diff --git a/src/core/error.rs b/src/core/error.rs index 8a6b2be7..5b606ee6 100644 --- a/src/core/error.rs +++ b/src/core/error.rs @@ -6,10 +6,8 @@ use serde_json; use serde_yaml; use std::{ backtrace::{Backtrace, BacktraceStatus}, - error::Error, fmt::{self, Formatter}, io, path, - sync::PoisonError, }; use tokio::task; @@ -73,16 +71,6 @@ impl From for OrcaError { } } } -impl From> for OrcaError { - fn from(_: PoisonError) -> Self { - Self { - kind: Kind::PoisonError { - source: Box::::from("PoisonError"), - backtrace: Some(Backtrace::capture()), - }, - } - } -} impl From for OrcaError { fn from(error: serde_json::Error) -> Self { Self { @@ -146,7 +134,6 @@ impl fmt::Debug for OrcaError { | Kind::NoMatchingPodRun { backtrace, .. } | Kind::NoRemainingServices { backtrace, .. } | Kind::NoTagFoundInContainerAltImage { backtrace, .. } - | Kind::PodJobProcessingError { backtrace, .. } | Kind::FailedToGetPodJobOutput { backtrace, .. } | Kind::StatusConversionFailure { backtrace, .. } | Kind::UnexpectedPathType { backtrace, .. } @@ -156,7 +143,6 @@ impl fmt::Debug for OrcaError { | Kind::GlobPatternError { backtrace, .. } | Kind::IoError { backtrace, .. } | Kind::PathPrefixError { backtrace, .. } - | Kind::PoisonError { backtrace, .. } | Kind::SerdeJsonError { backtrace, .. } | Kind::SerdeYamlError { backtrace, .. } | Kind::TokioTaskJoinError { backtrace, .. } => { diff --git a/src/uniffi/error.rs b/src/uniffi/error.rs index 467008be..6bc4ecd6 100644 --- a/src/uniffi/error.rs +++ b/src/uniffi/error.rs @@ -96,12 +96,6 @@ pub(crate) enum Kind { path: PathBuf, backtrace: Option, }, - #[snafu(display("Pod job {hash} failed to process with reason: {reason}."))] - PodJobProcessingError { - hash: String, - reason: String, - backtrace: Option, - }, #[snafu(display( "Missing expected output file or dir with key {packet_key} at path {path:?} for pod job (hash: {pod_job_hash})." ))] @@ -156,11 +150,6 @@ pub(crate) enum Kind { backtrace: Option, }, #[snafu(transparent)] - PoisonError { - source: Box, - backtrace: Option, - }, - #[snafu(transparent)] SerdeJsonError { source: Box, backtrace: Option, diff --git a/tests/error.rs b/tests/error.rs index bd3a2e0b..7a9e20e0 100644 --- a/tests/error.rs +++ b/tests/error.rs @@ -1,6 +1,5 @@ #![expect( missing_docs, - clippy::panic, clippy::panic_in_result_fn, clippy::indexing_slicing, reason = "OK in tests." @@ -25,15 +24,7 @@ use orcapod::{ }; use serde_json; use serde_yaml; -use std::{ - collections::HashMap, - fs, - ops::Deref as _, - path::PathBuf, - sync::{Arc, Mutex}, - thread, - time::Duration, -}; +use std::{collections::HashMap, fs, ops::Deref as _, path::PathBuf, sync::Arc, time::Duration}; use tokio::{self, time::sleep as async_sleep}; fn contains_debug(error: impl Into) -> bool { @@ -97,33 +88,6 @@ fn external_path_prefix() { ); } -#[expect( - clippy::let_underscore_must_use, - clippy::let_underscore_untyped, - clippy::significant_drop_tightening, - unreachable_code, - reason = "debug" -)] -#[test] -fn external_poison() { - let name = Arc::new(Mutex::new("jack")); - let _ = thread::spawn({ - let inner_name = Arc::clone(&name); - move || { - let mut new_name = inner_name.lock()?; - *new_name = "jill"; - panic!(); - Ok::<_, OrcaError>(()) - } - }) - .join(); - - assert!( - name.lock().is_err_and(contains_debug), - "Did not raise a poison error." - ); -} - #[test] fn external_json() { assert!( From 2986e0d242e688a7f9381397379677a4d957abce Mon Sep 17 00:00:00 2001 From: Synicix Date: Sun, 10 Aug 2025 13:02:34 +0000 Subject: [PATCH 12/65] Save changes --- Cargo.toml | 1 + src/core/mod.rs | 1 + src/core/pipeline_runner.rs | 1134 +++++++++++++++++++++++++++++++++ src/uniffi/mod.rs | 2 + src/uniffi/pipeline_runner.rs | 1 + 5 files changed, 1139 insertions(+) create mode 100644 src/core/pipeline_runner.rs create mode 100644 src/uniffi/pipeline_runner.rs diff --git a/Cargo.toml b/Cargo.toml index a62fe3fa..c7f4926b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -55,6 +55,7 @@ glob = "0.3.1" heck = "0.5.0" # convert bytes to hex strings hex = "0.4.3" +hostname = "0.4.1" # hashmaps that preserve insertion order indexmap = { version = "2.9.0", features = ["serde"] } # support for cartesian products diff --git a/src/core/mod.rs b/src/core/mod.rs index 19bc8212..14744c75 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -11,6 +11,7 @@ pub(crate) mod error; pub(crate) mod graph; pub(crate) mod orchestrator; pub(crate) mod pipeline; +pub(crate) mod pipeline_runner; pub(crate) mod store; pub(crate) mod util; pub(crate) mod validation; diff --git a/src/core/pipeline_runner.rs b/src/core/pipeline_runner.rs new file mode 100644 index 00000000..2199a883 --- /dev/null +++ b/src/core/pipeline_runner.rs @@ -0,0 +1,1134 @@ +use crate::{ + core::{crypto::hash_buffer, model::serialize_hashmap, util::get}, + uniffi::{ + error::{Kind, OrcaError, Result, selector}, + model::{ + packet::{PathSet, URI}, + pipeline::{Kernel, Pipeline, PipelineJob}, + pod::{Pod, PodJob, PodResult}, + }, + orchestrator::{ + agent::{Agent, AgentClient, Response}, + docker::LocalDockerOrchestrator, + }, + }, +}; +use async_trait::async_trait; +use itertools::Itertools as _; +use serde::{Deserialize, Serialize}; +use serde_yaml::Serializer; +use snafu::{OptionExt as _, ResultExt as _}; +use std::{ + collections::HashMap, + fmt::{Display, Formatter, Result as FmtResult}, + hash::{Hash, Hasher}, + path::PathBuf, + sync::Arc, +}; +use tokio::{ + sync::{Mutex, RwLock}, + task::JoinSet, +}; +use zenoh::{handlers::FifoChannelHandler, pubsub::Subscriber, sample::Sample}; + +static SUCCESS_KEY_EXP: &str = "success"; +static FAILURE_KEY_EXP: &str = "failure"; +static INPUT_KEY_EXP: &str = "input_node/outputs"; + +#[derive(Serialize, Deserialize, Clone, Debug)] +enum NodeOutput { + Packet(String, HashMap), + ProcessingCompleted(String), +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +struct ProcessingFailure { + node_id: String, + error: String, +} + +/// Internal representation of a pipeline run, this should not be made public due to the fact that it contains +/// internal states and tasks +#[derive(Debug)] +struct PipelineRun { + /// `PipelineJob` that this run is associated with + pipeline_job: Arc, // The pipeline job that this run is associated with + node_tasks: JoinSet>, // JoinSet of tasks for each node in the pipeline + outputs: Arc>>>, // String is the node key, while hash + orchestrator_agent: Arc, // This is placed in pipeline due to the current design requiring a namespace to operate on + orchestrator_agent_task: JoinSet>, // JoinSet of tasks for the orchestrator agent + failure_logs: Arc>>, // Logs of processing failures + failure_logging_task: JoinSet>, // JoinSet of tasks for logging failures +} + +impl PartialEq for PipelineRun { + fn eq(&self, other: &Self) -> bool { + self.pipeline_job.hash == other.pipeline_job.hash + } +} + +impl Eq for PipelineRun {} + +impl Hash for PipelineRun { + fn hash(&self, state: &mut H) { + self.pipeline_job.hash.hash(state); + } +} + +impl Display for PipelineRun { + fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult { + write!(f, "PipelineRun({})", self.pipeline_job.hash) + } +} + +/// Runner that uses a docker agent to run pipelines +pub struct DockerPipelineRunner { + /// User label on which group of agents this runner is associated with + pub group: String, + /// The host name of the runner + pub host: String, + agent: Arc, + pipeline_runs: HashMap, +} + +/// This is an implementation of a pipeline runner that uses Zenoh to communicate between the tasks +/// The runtime is tokio +/// +/// These are the key expressions of the components of the pipeline: +/// Input Node: `pipeline_job_hash/input_node/outputs` (This is where the `pipeline_job` packets get fed to) +/// Nodes: `pipeline_job_hash/node_id/outputs/(success|failure)` (This is where the node outputs are sent to) +/// +impl DockerPipelineRunner { + /// Create a new Docker pipeline runner + /// # Errors + /// Will error out if the environment variable `HOSTNAME` is not set + pub fn new(group: String, host: String, agent: Arc) -> Result { + Ok(Self { + group, + host, + agent, + pipeline_runs: HashMap::new(), + }) + } + + /// Will start a new pipeline run with the given `PipelineJob` + /// This will start the async tasks for each node in the pipeline + /// including the one that captures the outputs from the leaf nodes + /// + /// Upon receiving the ready message from all the nodes, it will send the input packets to the input node + /// + /// # Errors + /// Will error out if the pipeline job fails to start + pub async fn start( + &mut self, + pipeline_job: PipelineJob, + namespace: &str, // Name space to save pod_results to + namespace_lookup: &HashMap, + ) -> Result { + // Create the orchestrator + let orchestrator_agent = Agent::new( + self.group.clone(), + self.host.clone(), + LocalDockerOrchestrator::new()?.into(), + )?; + + // Create a new pipeline run + let mut pipeline_run = PipelineRun { + pipeline_job: pipeline_job.into(), + outputs: Arc::new(RwLock::new(HashMap::new())), + node_tasks: JoinSet::new(), + orchestrator_agent: orchestrator_agent.into(), + orchestrator_agent_task: JoinSet::new(), + failure_logs: Arc::new(RwLock::new(Vec::new())), + failure_logging_task: JoinSet::new(), + }; + + // Get the preexisting zenoh session from agent + let session = &self.agent.client.session.into(); + + // Spawn task for each of the processing node + let orchestrator_agent_clone = Arc::clone(&pipeline_run.orchestrator_agent); + let namespace_lookup_clone = namespace_lookup.clone(); + // Start the orchestrator agent service + pipeline_run.orchestrator_agent_task.spawn(async move { + orchestrator_agent_clone + .start(&namespace_lookup_clone, None) + .await + }); + + // Create failure logging task + pipeline_run + .failure_logging_task + .spawn(Self::failure_capture_task( + Arc::clone(&session), + Arc::clone(&pipeline_run.failure_logs), + )); + + // Create the processor task for each node + // The id for the pipeline_run is the pipeline_job hash + let pipeline_run_id = pipeline_run.pipeline_job.hash.clone(); + + let graph = &pipeline_run.pipeline_job.pipeline.graph; + + // Create the subscriber that listen for ready messages + let subscriber = session + .declare_subscriber(self.get_base_key_exp(&pipeline_run_id) + "/*/status/ready") + .await + .context(selector::AgentCommunicationFailure {})?; + + // Get the set of input_nodes + let input_nodes = pipeline_run.pipeline_job.pipeline.get_input_nodes(); + + // Iterate through each node in the graph and spawn a task for each + for node_idx in graph.node_indices() { + let node = &graph[node_idx]; + + // Spawn the task + pipeline_run + .node_tasks + .spawn(Self::spawn_node_processing_task( + node.clone(), + Arc::clone(&pipeline_run.pipeline_job.pipeline), + input_nodes.contains(&node.name), + self.get_base_key_exp(&pipeline_run_id), + namespace.to_owned(), + namespace_lookup.clone(), + Arc::clone(&session), + Arc::clone(&pipeline_run.orchestrator_agent.client), + )); + } + + // Spawn the task that captures the outputs based on the output_spec + let mut node_output_spec = HashMap::new(); + // Group the output spec by node + for (output_key, node_uri) in &pipeline_run.pipeline_job.pipeline.output_spec { + node_output_spec + .entry(node_uri.node_name.clone()) + .or_insert_with(HashMap::new) + .insert(output_key.clone(), node_uri.key.clone()); + } + + for (node_id, key_mapping) in node_output_spec { + // Create the key expression to subscribe to + let key_exp_to_sub = format!( + "{}/{}/outputs/{}", + self.get_base_key_exp(&pipeline_run_id), + node_id, + SUCCESS_KEY_EXP, + ); + + // Spawn the task that captures the outputs + pipeline_run + .node_tasks + .spawn(Self::create_output_capture_task_for_node( + key_mapping, + Arc::clone(&pipeline_run.outputs), + Arc::clone(&session), + key_exp_to_sub, + )); + } + + // Wait for all nodes to be ready before sending inputs + let num_of_nodes = graph.node_count(); + let mut ready_nodes = 0; + + while (subscriber.recv_async().await).is_ok() { + // Message is empty, just increment the counter + ready_nodes += 1; + + if ready_nodes == num_of_nodes { + break; // All nodes are ready, we can start sending inputs + } + } + + // Submit the input_packets to the correct key_exp + let base_input_node_key_exp = format!( + "{}/{}", + self.get_base_key_exp(&pipeline_run_id), + INPUT_KEY_EXP, + ); + + // For each node send all the packets associate with it + for (node_name, input_packets) in pipeline_run.pipeline_job.get_input_packet_per_node()? { + for packet in input_packets { + // Send the packet to the input node key_exp + let output_key_exp = format!("{base_input_node_key_exp}/{node_name}"); + session + .put( + &output_key_exp, + serde_json::to_string(&NodeOutput::Packet( + "input_node".to_owned(), + packet.clone(), + ))?, + ) + .await + .context(selector::AgentCommunicationFailure {})?; + + // All packets associate with node are sent, we can send processing complete msg now + session + .put( + &output_key_exp, + serde_json::to_string(&NodeOutput::ProcessingCompleted( + "input_node".to_owned(), + ))?, + ) + .await + .context(selector::AgentCommunicationFailure {})?; + } + } + + // Insert into the list of pipeline runs + self.pipeline_runs + .insert(pipeline_run_id.clone(), pipeline_run); + + // Return the pipeline run id + Ok(pipeline_run_id) + } + + /// Given a pipeline run, wait for all its tasks to complete and return the `PipelineResult` + /// + /// # Errors + /// Will error out if any of the pipeline tasks failed to join + pub async fn get_result(&mut self, pipeline_run_id: &str) -> Result { + // To get the result, the pipeline execution must be complete, so we need to await on the tasks + + let pipeline_run = + self.pipeline_runs + .get_mut(pipeline_run_id) + .context(selector::KeyMissing { + key: pipeline_run_id.to_owned(), + })?; + + // Wait for all the tasks to complete + while let Some(result) = pipeline_run.node_tasks.join_next().await { + match result { + Ok(Ok(())) => {} // Task completed successfully + Ok(Err(err)) => { + eprintln!("Task failed with err: {err}"); + return Err(err); + } + Err(err) => { + eprintln!("Join set error: {err}"); + return Err(err.into()); + } + } + } + + // Figure out how to do this later + Ok(PipelineResult { + pipeline_job: Arc::clone(&pipeline_run.pipeline_job), + output_packets: pipeline_run.outputs.read().await.clone(), + }) + } + + /// Stop the pipeline run and all its tasks + /// This will send a stop message to a channel that all node manager task are subscribed to. + /// Upon receiving the stop message, each node manager will force abort all of its task and exit. + /// + /// # Errors + /// Will error out if the pipeline run is not found or if any of the tasks fail to stop correctly + pub async fn stop(&mut self, pipeline_run_id: &str) -> Result<()> { + let stop_key_exp = format!( + "{}/{}/stop", + self.get_base_key_exp(pipeline_run_id), + pipeline_run_id + ); + // To stop the pipeline run, we need to send a stop message to all the tasks + // Get the pipeline run first + let pipeline_run = + self.pipeline_runs + .get_mut(pipeline_run_id) + .context(selector::KeyMissing { + key: pipeline_run_id.to_owned(), + })?; + + let session = zenoh::open(zenoh::Config::default()) + .await + .context(selector::AgentCommunicationFailure {})?; + + // Send the stop message into the stop key_exp, the msg is just an empty vector + session + .put(stop_key_exp, Vec::new()) + .await + .context(selector::AgentCommunicationFailure {})?; + + while pipeline_run.node_tasks.join_next().await.is_some() {} + Ok(()) + } + + /// This will capture the outputs of the given nodes and store it in the `outputs` map + async fn create_output_capture_task_for_node( + // + key_mapping: HashMap, + outputs: Arc>>>, + session: Arc, + key_exp_to_sub: String, + ) -> Result<()> { + // Determine which keys we are interested in for the given node_id + + // Create a zenoh session + let subscriber = session + .declare_subscriber(key_exp_to_sub) + .await + .context(selector::AgentCommunicationFailure {})?; + + while let Ok(payload) = subscriber.recv_async().await { + // Extract the message from the payload + let msg: NodeOutput = serde_json::from_slice(&payload.payload().to_bytes())?; + + match msg { + NodeOutput::Packet(_, packet) => { + // Figure out which keys + // Store the output packet in the outputs map + let mut outputs_lock = outputs.write().await; + for (output_key, node_key) in &key_mapping { + outputs_lock + .entry(output_key.to_owned()) + .or_default() + .push(get(&packet, node_key.as_str())?.clone()); + } + } + NodeOutput::ProcessingCompleted(_) => { + // Processing is completed, thus we can exit this task + break; + } + } + } + Ok(()) + } + + async fn failure_capture_task( + session: Arc, + failure_logs: Arc>>, + ) -> Result<()> { + let sub = session + .declare_subscriber(format!("**/outputs/{FAILURE_KEY_EXP}")) + .await + .context(selector::AgentCommunicationFailure {})?; + + // Listen to any failure messages and write it the logs + while let Ok(payload) = sub.recv_async().await { + // Extract the message from the payload + let process_failure: ProcessingFailure = + serde_json::from_slice(&payload.payload().to_bytes())?; + // Store the failure message in the logs + failure_logs.write().await.push(process_failure.clone()); + if let Some(first_line) = process_failure.error.lines().next() { + println!( + "Node {} processing failed with error: {}", + process_failure.node_id, first_line + ); + } + } + + Ok(()) + } + + /// Function to start tasks associated with the node + /// Steps: + /// - Create the node processor based on the kernel type + /// - Create the zenoh session + /// - Create a join set to spawn and handle incoming messages tasks + /// - Create a subscriber for each of the parent nodes (Should only be 1, unless it is a joiner node) + /// - Create an abort listener task that will listen for stop requests + /// - For each subscriber, handle the incoming message appropriately + /// + /// # Errors + /// Will error out if the kernel for the node is not found or if the + async fn spawn_node_processing_task( + node: PipelineNode, + pipeline: Arc, + is_input_node: bool, + base_key_exp: String, + namespace: String, + namespace_lookup: HashMap, + session: Arc, + client: Arc, + ) -> Result<()> { + // Create the correct processor for the node based on the kernel type + let node_processor: Arc>> = + Arc::new(Mutex::new(match &node.kernel { + Kernel::Pod { pod } => Box::new(PodProcessor::new(Arc::clone(pod), client)), + Kernel::Mapper { mapper } => Box::new(MapperProcessor::new(Arc::clone(mapper))), + Kernel::Joiner => { + // Need to get the parent node id for this joiner node + let mut parent_nodes = pipeline + .get_node_parents(&node) + .map(|parent_node| parent_node.name.clone()) + .collect::>(); + + // Check if it this node takes input from input_nodes, if so we need ot add it to parent_node + if is_input_node { + parent_nodes.push("input_node".to_owned()); + } + + Box::new(JoinerProcessor::new(parent_nodes)) + } + })); + + // Create a join set to spawn and handle incoming messages tasks + let mut listener_tasks = JoinSet::new(); + + // Create the list of key_expressions to subscribe to + let mut key_exps_to_subscribe_to = pipeline + .get_node_parents(&node) + .map(|parent_node| { + format!( + "{base_key_exp}/{}/outputs/{SUCCESS_KEY_EXP}", + parent_node.name + ) + }) + .collect::>(); + + // Check if node is an input_node, if so we need to add the input node key expression + if is_input_node { + key_exps_to_subscribe_to + .push(format!("{base_key_exp}/input_node/outputs/{}", node.name)); + } + + // Create a subscriber for each of the parent nodes (Should only be 1, unless it is a joiner node) + for key_exp in &key_exps_to_subscribe_to { + let subscriber = session + .declare_subscriber(key_exp) + .await + .context(selector::AgentCommunicationFailure {})?; + + listener_tasks.spawn(Self::start_async_processor_task( + subscriber, + Arc::clone(&node_processor), + node.name.clone(), + base_key_exp.clone(), + namespace.clone(), + namespace_lookup.clone(), + Arc::clone(&session), + )); + } + + // Create the listener task for the stop request + let mut stop_listener_task = JoinSet::new(); + + stop_listener_task.spawn(Self::start_stop_request_task( + Arc::clone(&node_processor), + format!("{base_key_exp}/{}/stop", node.name), + Arc::clone(&session), + )); + + // Wait for all tasks to be spawned and reply with ready message + // This is to ensure that the pipeline run knows when all tasks are ready to receive inputs + + let mut num_of_ready_subscribers: usize = 0; + // Build the subscriber + let status_subscriber = session + .declare_subscriber(format!( + "{base_key_exp}/{}/subscriber/status/ready", + node.name + )) + .await + .context(selector::AgentCommunicationFailure {})?; + + while status_subscriber.recv_async().await.is_ok() { + num_of_ready_subscribers += 1; + if num_of_ready_subscribers == key_exps_to_subscribe_to.len() { + // +1 for the stop request task + break; // All tasks are ready, we can start sending inputs + } + } + + // Send a ready message so the pipeline knows when to start sending inputs + session + .put( + format!("{base_key_exp}/{}/status/ready", node.name), + &node.name, + ) + .await + .context(selector::AgentCommunicationFailure {})?; + + // Wait for all task to complete + listener_tasks.join_all().await; + + // Abort the stop listener task since we don't need it anymore + stop_listener_task.abort_all(); + + Ok(()) + } + + /// This is the actual handler for incoming messages for the node + async fn start_async_processor_task( + subscriber: Subscriber>, + node_processor: Arc>>, + node_name: String, + base_key_exp: String, + namespace: String, + namespace_lookup: HashMap, + session: Arc, + ) -> Result<()> { + // We do not know when tokio will start executing this task, therefore we need to send a ready message + // back to our spawner task + session + .put( + format!("{base_key_exp}/{node_name}/subscriber/status/ready"), + &node_name, + ) + .await + .context(selector::AgentCommunicationFailure {})?; + + let node_base_output_key_exp = format!("{base_key_exp}/{node_name}/outputs"); + while let Ok(payload) = subscriber.recv_async().await { + // Extract the message from the payload + match serde_json::from_slice(&payload.payload().to_bytes())? { + NodeOutput::Packet(sender_id, hash_map) => { + // Process the packet using the node processor + let result = node_processor.lock().await.process_packet( + &sender_id, + &node_name, + &hash_map, + Arc::clone(&session), + &node_base_output_key_exp, + &namespace, + &namespace_lookup, + ); + + if let Err(err) = result { + try_to_forward_err_msg( + Arc::clone(&session), + err, + &node_base_output_key_exp, + &node_name, + ) + .await; + } + } + NodeOutput::ProcessingCompleted(sender_id) => { + // Notify the processor that the parent node has completed processing + if node_processor + .lock() + .await + .mark_parent_as_complete(&sender_id) + .await + { + // This was the last parent, thus we need to send the processing complete message + let output_key_exp = + format!("{base_key_exp}/{node_name}/outputs/{SUCCESS_KEY_EXP}"); + session + .put( + output_key_exp, + serde_json::to_string(&NodeOutput::ProcessingCompleted( + node_name.clone(), + ))?, + ) + .await + .context(selector::AgentCommunicationFailure {})?; + } + break; + } + } + } + + Ok::<(), OrcaError>(()) + } + + /// This task will listen for stop requests on the given key expression + async fn start_stop_request_task( + node_processor: Arc>>, + base_key_exp: String, + session: Arc, + ) -> Result<()> { + let subscriber = session + .declare_subscriber(format!("{base_key_exp}/stop")) + .await + .context(selector::AgentCommunicationFailure {})?; + while subscriber.recv_async().await.is_ok() { + // Received a request to stop, therefore we need to tell the node_processor to shutdown + node_processor.lock().await.stop(); + } + Ok::<(), OrcaError>(()) + } + + fn get_base_key_exp(&self, pipeline_run_id: &str) -> String { + format!("{}/{}/{}", self.group, self.host, pipeline_run_id) + } +} + +/// Unify the interface for node processors and provide a common way to handle processing of incoming messages +/// This trait defines the methods that all node processors should implement +/// +/// Main purpose was to reduce the amount of code duplication between different node processors +/// As a result, each processor only needs to worry about writing their own function to process the msg +#[async_trait] +trait NodeProcessor: Send + Sync { + fn process_packet( + &mut self, + sender_node_id: &str, + node_id: &str, + packet: &HashMap, + session: Arc, + base_output_key_exp: &str, + namespace: &str, + namespace_lookup: &HashMap, + ) -> Result<()>; + + /// Notifies the processor that the parent node has completed processing + /// If the parent node was the last one to complete, this function will wait till all task are done + /// and send the node processing complete message then return. + /// + /// Otherwise it will return immediately + /// + /// # Returns + /// true if the parent node was the last one to complete processing, user send + /// the processing completion message to the output + /// + /// false if there are still other parent nodes that need to complete processing + async fn mark_parent_as_complete(&mut self, parent_node_id: &str) -> bool; + + fn stop(&mut self); +} + +/// Util function to handle forwarding error messages to the failure channel +async fn try_to_forward_err_msg( + session: Arc, + err: OrcaError, + node_base_output_key_exp: &str, + node_id: &str, +) { + match async { + session + .put( + format!("{node_base_output_key_exp}/{FAILURE_KEY_EXP}"), + serde_json::to_string(&ProcessingFailure { + node_id: node_id.to_owned(), + error: err.to_string(), + })?, + ) + .await + .context(selector::AgentCommunicationFailure {})?; + Ok::<(), OrcaError>(()) + } + .await + { + Ok(()) => {} + Err(send_err) => { + eprintln!("Failed to send failure message: {send_err}"); + } + } +} + +/// Processor for Pods +/// Currently missing implementation to call agents for actual pod processing +struct PodProcessor { + pod: Arc, + processing_tasks: JoinSet<()>, + client: Arc, +} + +impl PodProcessor { + fn new(pod: Arc, client: Arc) -> Self { + Self { + pod, + processing_tasks: JoinSet::new(), + client, + } + } + + /// Will handle the creation of the pod job, submission to the agent, listening for completion, and extracting the `output_packet` if successful + async fn start_pod_job_task( + node_id: String, + pod: Arc, + packet: HashMap, + client: Arc, + session: Arc, + base_output_key_exp: String, + namespace: String, + namespace_lookup: &HashMap, + ) -> Result<()> { + // For now we will just send the input_packet to the success channel + let node_id_bytes = node_id.as_bytes().to_vec(); + let input_packet_hash = { + let mut buf = node_id_bytes; + let mut serializer = Serializer::new(&mut buf); + serialize_hashmap(&packet, &mut serializer)?; + hash_buffer(buf) + }; + let output_dir = URI { + namespace: namespace.clone(), + path: PathBuf::from(format!("pod_runs/{node_id}/{input_packet_hash}")), + }; + + let cpu_limit = pod.recommended_cpus; + let memory_limit = pod.recommended_memory; + + // Create the pod job + let pod_job = PodJob::new( + None, + Arc::clone(&pod), + packet, + output_dir, + cpu_limit, + memory_limit, + None, + namespace_lookup, + )?; + + // Create listener for pod_job + let target_key_exp = format!("group/{}/success/pod_job/{}/**", client.group, pod_job.hash); + + // Create the subscriber + let pod_job_subscriber = session + .declare_subscriber(target_key_exp) + .await + .context(selector::AgentCommunicationFailure {})?; + + // Create the async task to listen for the pod job completion + let pod_job_listener_task = tokio::spawn(async move { + // Wait for the pod job to complete and extract the result + let sample = pod_job_subscriber + .recv_async() + .await + .context(selector::AgentCommunicationFailure {})?; + // Extract the pod_result from the payload + let pod_result: PodResult = serde_json::from_slice(&sample.payload().to_bytes())?; + Ok::<_, OrcaError>(pod_result) + }); + + // Submit it to the client and get the response to make sure it was successful + let responses = client.start_pod_jobs(vec![pod_job.into()]).await; + let response = responses + .first() + .context(selector::InvalidIndex { idx: 0_usize })?; + + match response { + Response::Ok => (), + Response::Err(err) => { + return Err(OrcaError { + kind: Kind::PodJobSubmissionFailed { + reason: err.clone(), + backtrace: Some(snafu::Backtrace::capture()), + }, + }); + } + } + + // Get the pod result from the listener task + let temp = pod_job_listener_task.await?; + + let pod_result = temp?; + + // Get the output packet for the pod result + let output_packet = match pod_result.status { + PodResultStatus::Completed => { + // Get the output packet + pod_result.pod_job.get_output_packet(namespace_lookup)? + } + PodResultStatus::Failed(exit_code) => { + // Processing failed, thus return the error + return Err(OrcaError { + kind: Kind::PodJobProcessingError { + hash: pod_result.pod_job.hash.clone(), + reason: format!("Pod processing failed with exit code {exit_code}"), + backtrace: Some(snafu::Backtrace::capture()), + }, + }); + } + PodResultStatus::Unset => { + // This should not happen, but if it does, we will return an error + return Err(OrcaError { + kind: Kind::PodJobProcessingError { + hash: pod_result.pod_job.hash.clone(), + reason: "Pod processing status is unset".to_owned(), + backtrace: Some(snafu::Backtrace::capture()), + }, + }); + } + }; + + session + .put( + base_output_key_exp.clone() + "/" + SUCCESS_KEY_EXP, + serde_json::to_string(&NodeOutput::Packet(node_id.clone(), output_packet))?, + ) + .await + .context(selector::AgentCommunicationFailure {})?; + Ok::<(), OrcaError>(()) + } +} + +#[async_trait] +impl NodeProcessor for PodProcessor { + fn process_packet( + &mut self, + _sender_node_id: &str, + node_id: &str, + packet: &HashMap, + session: Arc, + base_output_key_exp: &str, + namespace: &str, + namespace_lookup: &HashMap, + ) -> Result<()> { + // We need a unique hash for this given input packet process by the node + // therefore we need to generate a hash that has the pod_id + input_packet + let pod_clone = Arc::clone(&self.pod); + let client_clone = Arc::clone(&self.client); + let node_id_owned = node_id.to_owned(); + let packet_owned = packet.clone(); + let base_output_key_exp_owned = base_output_key_exp.to_owned(); + let namespace_owned = namespace.to_owned(); + let namespace_lookup_owned = namespace_lookup.clone(); + + self.processing_tasks.spawn(async move { + let results = Self::start_pod_job_task( + node_id_owned.clone(), + pod_clone, + packet_owned, + client_clone, + Arc::clone(&session), + base_output_key_exp_owned.clone(), + namespace_owned.clone(), + &namespace_lookup_owned, + ) + .await; + + match results { + Ok(()) => {} + Err(err) => { + try_to_forward_err_msg( + session, + err, + &base_output_key_exp_owned, + &node_id_owned, + ) + .await; + } + } + }); + Ok(()) + } + + async fn mark_parent_as_complete(&mut self, _parent_node_id: &str) -> bool { + // For pod we only have one parent, thus execute the exit case + while self.processing_tasks.join_next().await.is_some() {} + true + } + + fn stop(&mut self) { + self.processing_tasks.abort_all(); + } +} + +/// Processor for Mapper nodes +/// This processor renames the `input_keys` from the input packet to the `output_keys` defined by the map +struct MapperProcessor { + mapper: Arc, + processing_tasks: JoinSet<()>, +} + +impl MapperProcessor { + fn new(mapper: Arc) -> Self { + Self { + mapper, + processing_tasks: JoinSet::new(), + } + } +} + +#[async_trait] +impl NodeProcessor for MapperProcessor { + fn process_packet( + &mut self, + _sender_node_id: &str, + node_id: &str, + packet: &HashMap, + session: Arc, + base_output_key_exp: &str, + _namespace: &str, + _namespace_lookup: &HashMap, + ) -> Result<()> { + let mapping = self.mapper.mapping.clone(); + let packet_clone = packet.clone(); + let node_id_clone = node_id.to_owned(); + let output_key_exp_clone = base_output_key_exp.to_owned(); + + self.processing_tasks.spawn(async move { + let result = async { + // Apply the mapping to the input packet + let output_map = mapping + .iter() + .map(|(input_key, output_key)| { + let input = get(&packet_clone, input_key)?.clone(); + Ok((output_key.to_owned(), input)) + }) + .collect::>>()?; + + // Send the packet outwards + session + .put( + format!("{output_key_exp_clone}/{SUCCESS_KEY_EXP}"), + &serde_json::to_string(&NodeOutput::Packet( + node_id_clone.clone(), + output_map, + ))?, + ) + .await + .context(selector::AgentCommunicationFailure {})?; + Ok::<(), OrcaError>(()) + } + .await; + + if let Err(err) = result { + try_to_forward_err_msg(session, err, &output_key_exp_clone, &node_id_clone).await; + } + }); + Ok(()) + } + + async fn mark_parent_as_complete(&mut self, _parent_node_id: &str) -> bool { + // For mapper we only have one parent, thus execute the exit case + while (self.processing_tasks.join_next().await).is_some() { + // The only error that should be forwarded here is the failure to send the output packet + } + true + } + + fn stop(&mut self) { + self.processing_tasks.abort_all(); + } +} + +/// Processor for Joiner nodes +/// This processor combines packets from multiple parent nodes into a single output packet +/// It uses a cartesian product to combine packets from different parents +#[derive(Debug)] +struct JoinerProcessor { + /// Cache for all packets received by the node + input_packet_cache: HashMap>>, + completed_parents: Vec, + processing_tasks: JoinSet<()>, +} + +impl JoinerProcessor { + fn new(parents_node_id: Vec) -> Self { + let input_packet_cache = parents_node_id + .into_iter() + .map(|id| (id, Vec::new())) + .collect(); + Self { + input_packet_cache, + completed_parents: Vec::new(), + processing_tasks: JoinSet::new(), + } + } + + fn compute_cartesian_product( + factors: &[Vec>], + ) -> Vec> { + factors + .iter() + .multi_cartesian_product() + .map(|packets_to_combined| { + packets_to_combined + .into_iter() + .fold(HashMap::new(), |mut acc, packet| { + acc.extend(packet.clone()); + acc + }) + }) + .collect::>() + } +} + +#[async_trait] +impl NodeProcessor for JoinerProcessor { + fn process_packet( + &mut self, + sender_node_id: &str, + node_id: &str, + packet: &HashMap, + session: Arc, + base_output_key_exp: &str, + _namespace: &str, + _namespace_lookup: &HashMap, + ) -> Result<()> { + self.input_packet_cache + .get_mut(sender_node_id) + .context(selector::KeyMissing { + key: sender_node_id.to_owned(), + })? + .push(packet.clone()); + + // Check if we have all the other parents needed to compute the cartesian product + if self.input_packet_cache.values().all(|v| !v.is_empty()) { + // Get all the cached packets from other parents + let other_parent_ids = self + .input_packet_cache + .keys() + .filter(|key| *key != sender_node_id); + + // Build the factors of the product as owned values to avoid lifetime issues + let mut factors = other_parent_ids + .map(|id| get(&self.input_packet_cache, id).cloned()) + .collect::>>()?; + + // Add the new packet as a factor + factors.push(vec![packet.clone()]); + + // Compute the cartesian product of the factors + let node_id_clone = node_id.to_owned(); + let output_key_exp_clone = base_output_key_exp.to_owned(); + + self.processing_tasks.spawn(async move { + // Convert Vec>> to Vec<&Vec>> for compute_cartesian_product + let cartesian_product = Self::compute_cartesian_product(&factors); + // Post all products to the output channel + let session_clone = Arc::clone(&session); + for output_packet in cartesian_product { + let result = async { + session_clone + .put( + format!("{output_key_exp_clone}/{SUCCESS_KEY_EXP}"), + serde_json::to_string(&NodeOutput::Packet( + node_id_clone.clone(), + output_packet, + ))?, + ) + .await + .context(selector::AgentCommunicationFailure {})?; + Ok::<(), OrcaError>(()) + } + .await; + + // If the result is an error, we will just send it to the error channel + if let Err(err) = result { + try_to_forward_err_msg( + Arc::clone(&session_clone), + err, + &output_key_exp_clone, + &node_id_clone, + ) + .await; + } + } + }); + } + Ok(()) + } + + async fn mark_parent_as_complete(&mut self, _parent_node_id: &str) -> bool { + // For Joiner, we need to determine if all parents are complete, if so then wait for task to complete + // before returning true + self.completed_parents.push(_parent_node_id.to_owned()); + + // If we have all parents completed, we can wait for the tasks to complete + if self.completed_parents.len() == self.input_packet_cache.len() { + while (self.processing_tasks.join_next().await).is_some() { + // Wait for all tasks to complete + } + return true; + } + + // If not all parents are completed, we return false + false + } + + fn stop(&mut self) { + // We want to abort any computation + self.processing_tasks.abort_all(); + } +} diff --git a/src/uniffi/mod.rs b/src/uniffi/mod.rs index e02fd6c9..a4315b48 100644 --- a/src/uniffi/mod.rs +++ b/src/uniffi/mod.rs @@ -6,3 +6,5 @@ pub mod model; pub mod orchestrator; /// Data persistence provided by a store backend. pub mod store; + +pub mod pipeline_runner; diff --git a/src/uniffi/pipeline_runner.rs b/src/uniffi/pipeline_runner.rs new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/src/uniffi/pipeline_runner.rs @@ -0,0 +1 @@ + From 595a12e91e09da05f76d1491ff1e92a8e5ffbdec Mon Sep 17 00:00:00 2001 From: Synicix Date: Sun, 10 Aug 2025 13:15:46 +0000 Subject: [PATCH 13/65] Split model in core to match uniffi --- src/core/graph.rs | 2 +- src/core/mod.rs | 1 - src/core/{model.rs => model/mod.rs} | 65 +++-------------------------- src/core/{ => model}/pipeline.rs | 2 +- src/core/model/pod.rs | 56 +++++++++++++++++++++++++ src/uniffi/model/pipeline.rs | 4 +- src/uniffi/model/pod.rs | 4 +- 7 files changed, 67 insertions(+), 67 deletions(-) rename src/core/{model.rs => model/mod.rs} (53%) rename src/core/{ => model}/pipeline.rs (72%) create mode 100644 src/core/model/pod.rs diff --git a/src/core/graph.rs b/src/core/graph.rs index 8772dcc6..78e709db 100644 --- a/src/core/graph.rs +++ b/src/core/graph.rs @@ -1,5 +1,5 @@ use crate::{ - core::{pipeline::PipelineNode, util::get}, + core::{model::pipeline::PipelineNode, util::get}, uniffi::{error::Result, model::pipeline::Kernel}, }; use dot_parser::ast::Graph as DOTGraph; diff --git a/src/core/mod.rs b/src/core/mod.rs index 19bc8212..4f15d482 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -10,7 +10,6 @@ macro_rules! inner_attr_to_each { pub(crate) mod error; pub(crate) mod graph; pub(crate) mod orchestrator; -pub(crate) mod pipeline; pub(crate) mod store; pub(crate) mod util; pub(crate) mod validation; diff --git a/src/core/model.rs b/src/core/model/mod.rs similarity index 53% rename from src/core/model.rs rename to src/core/model/mod.rs index 8d87d921..efef247c 100644 --- a/src/core/model.rs +++ b/src/core/model/mod.rs @@ -1,20 +1,14 @@ -use crate::{ - core::util::get_type_name, - uniffi::{ - error::Result, - model::pod::{Pod, PodJob}, - }, -}; +use crate::{core::util::get_type_name, uniffi::error::Result}; use heck::ToSnakeCase as _; use indexmap::IndexMap; -use serde::{Deserialize as _, Deserializer, Serialize, Serializer}; +use serde::{Serialize, Serializer}; use serde_yaml::{self, Value}; use std::{ collections::{BTreeMap, HashMap}, hash::BuildHasher, result, - sync::Arc, }; + /// Converts a model instance into a consistent yaml. /// /// # Errors @@ -67,54 +61,5 @@ where sorted.serialize(serializer) } -#[expect( - clippy::expect_used, - reason = "Function signature required by serde API." -)] -pub fn deserialize_pod<'de, D>(deserializer: D) -> result::Result, D::Error> -where - D: Deserializer<'de>, -{ - let value = Value::deserialize(deserializer)?; - (value).as_str().map_or_else( - || { - Ok(serde_yaml::from_value(value.clone()) - .expect("Failed to convert from serde value to specific type.")) - }, - |hash| { - Ok({ - Pod { - hash: hash.to_owned(), - ..Pod::default() - } - .into() - }) - }, - ) -} - -#[expect( - clippy::expect_used, - reason = "Function signature required by serde API." -)] -pub fn deserialize_pod_job<'de, D>(deserializer: D) -> result::Result, D::Error> -where - D: Deserializer<'de>, -{ - let value = Value::deserialize(deserializer)?; - (value).as_str().map_or_else( - || { - Ok(serde_yaml::from_value(value.clone()) - .expect("Failed to convert from serde value to specific type.")) - }, - |hash| { - Ok({ - PodJob { - hash: hash.to_owned(), - ..PodJob::default() - } - .into() - }) - }, - ) -} +pub mod pipeline; +pub mod pod; diff --git a/src/core/pipeline.rs b/src/core/model/pipeline.rs similarity index 72% rename from src/core/pipeline.rs rename to src/core/model/pipeline.rs index 494e4919..41f7b28d 100644 --- a/src/core/pipeline.rs +++ b/src/core/model/pipeline.rs @@ -1,7 +1,7 @@ use crate::uniffi::model::pipeline::Kernel; use serde::{Deserialize, Serialize}; -#[derive(Debug, Clone, Deserialize, Serialize)] +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] pub struct PipelineNode { pub name: String, pub kernel: Kernel, diff --git a/src/core/model/pod.rs b/src/core/model/pod.rs new file mode 100644 index 00000000..eb97de5d --- /dev/null +++ b/src/core/model/pod.rs @@ -0,0 +1,56 @@ +use crate::uniffi::model::pod::{Pod, PodJob}; +use serde::{Deserialize as _, Deserializer}; +use serde_yaml::{self, Value}; +use std::{result, sync::Arc}; + +#[expect( + clippy::expect_used, + reason = "Function signature required by serde API." +)] +pub fn deserialize_pod<'de, D>(deserializer: D) -> result::Result, D::Error> +where + D: Deserializer<'de>, +{ + let value = Value::deserialize(deserializer)?; + (value).as_str().map_or_else( + || { + Ok(serde_yaml::from_value(value.clone()) + .expect("Failed to convert from serde value to specific type.")) + }, + |hash| { + Ok({ + Pod { + hash: hash.to_owned(), + ..Pod::default() + } + .into() + }) + }, + ) +} + +#[expect( + clippy::expect_used, + reason = "Function signature required by serde API." +)] +pub fn deserialize_pod_job<'de, D>(deserializer: D) -> result::Result, D::Error> +where + D: Deserializer<'de>, +{ + let value = Value::deserialize(deserializer)?; + (value).as_str().map_or_else( + || { + Ok(serde_yaml::from_value(value.clone()) + .expect("Failed to convert from serde value to specific type.")) + }, + |hash| { + Ok({ + PodJob { + hash: hash.to_owned(), + ..PodJob::default() + } + .into() + }) + }, + ) +} diff --git a/src/uniffi/model/pipeline.rs b/src/uniffi/model/pipeline.rs index 223f73cf..ea74364a 100644 --- a/src/uniffi/model/pipeline.rs +++ b/src/uniffi/model/pipeline.rs @@ -2,7 +2,7 @@ use crate::{ core::{ crypto::{hash_blob, make_random_hash}, graph::make_graph, - pipeline::PipelineNode, + model::pipeline::PipelineNode, validation::validate_packet, }, uniffi::{ @@ -130,7 +130,7 @@ impl PipelineJob { } /// A node in a computational pipeline. -#[derive(uniffi::Enum, Debug, Clone, Deserialize, Serialize)] +#[derive(uniffi::Enum, Debug, Clone, Deserialize, Serialize, PartialEq)] pub enum Kernel { /// Pod reference. Pod { diff --git a/src/uniffi/model/pod.rs b/src/uniffi/model/pod.rs index 752a3c8f..60e41962 100644 --- a/src/uniffi/model/pod.rs +++ b/src/uniffi/model/pod.rs @@ -2,8 +2,8 @@ use crate::{ core::{ crypto::{hash_blob, hash_buffer}, model::{ - deserialize_pod, deserialize_pod_job, serialize_hashmap, serialize_hashmap_option, - to_yaml, + pod::{deserialize_pod, deserialize_pod_job}, + serialize_hashmap, serialize_hashmap_option, to_yaml, }, util::get, validation::validate_packet, From 86d5f70056200e314ad5c219823b3ba5b077e075 Mon Sep 17 00:00:00 2001 From: Synicix Date: Mon, 11 Aug 2025 04:49:23 +0000 Subject: [PATCH 14/65] Save progress --- .clippy.toml | 2 +- src/core/error.rs | 3 +- src/core/model/pipeline.rs | 110 ++++++++++++++++++++++++++++++- src/core/operator.rs | 8 +-- src/core/pipeline_runner.rs | 56 +++++++++------- src/core/util.rs | 23 ++++++- src/uniffi/error.rs | 29 ++++---- src/uniffi/model/pipeline.rs | 25 ++++--- src/uniffi/orchestrator/agent.rs | 5 +- tests/pipeline.rs | 6 +- 10 files changed, 206 insertions(+), 61 deletions(-) diff --git a/.clippy.toml b/.clippy.toml index 8987fce2..5821063e 100644 --- a/.clippy.toml +++ b/.clippy.toml @@ -1,3 +1,3 @@ -excessive-nesting-threshold = 4 +excessive-nesting-threshold = 5 too-many-arguments-threshold = 10 allowed-idents-below-min-chars = ["..", "k", "v", "f", "re", "id", "Ok", "'_"] diff --git a/src/core/error.rs b/src/core/error.rs index 5b606ee6..a27d4aaa 100644 --- a/src/core/error.rs +++ b/src/core/error.rs @@ -134,8 +134,9 @@ impl fmt::Debug for OrcaError { | Kind::NoMatchingPodRun { backtrace, .. } | Kind::NoRemainingServices { backtrace, .. } | Kind::NoTagFoundInContainerAltImage { backtrace, .. } + | Kind::PodJobSubmissionFailed { backtrace, .. } + | Kind::PodJobProcessingError { backtrace, .. } | Kind::FailedToGetPodJobOutput { backtrace, .. } - | Kind::StatusConversionFailure { backtrace, .. } | Kind::UnexpectedPathType { backtrace, .. } | Kind::BollardError { backtrace, .. } | Kind::ChronoParseError { backtrace, .. } diff --git a/src/core/model/pipeline.rs b/src/core/model/pipeline.rs index 41f7b28d..dfcccb1a 100644 --- a/src/core/model/pipeline.rs +++ b/src/core/model/pipeline.rs @@ -1,4 +1,17 @@ -use crate::uniffi::model::pipeline::Kernel; +use std::{ + backtrace::Backtrace, + collections::{HashMap, HashSet}, +}; + +use crate::uniffi::{ + error::{Kind, OrcaError, Result}, + model::{ + packet::PathSet, + pipeline::{Kernel, Pipeline, PipelineJob}, + }, +}; +use itertools::Itertools as _; +use petgraph::Direction::Incoming; use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] @@ -6,3 +19,98 @@ pub struct PipelineNode { pub name: String, pub kernel: Kernel, } + +impl Pipeline { + /// Function to get the parents of a node + pub(crate) fn get_node_parents( + &self, + node: &PipelineNode, + ) -> impl Iterator { + // Find the NodeIndex for the given node_key + let node_index = self + .graph + .node_indices() + .find(|&idx| self.graph[idx] == *node); + node_index.into_iter().flat_map(move |idx| { + self.graph + .neighbors_directed(idx, Incoming) + .map(move |parent_idx| &self.graph[parent_idx]) + }) + } + + /// Return a vec of `node_names` that takes in inputs based on the `input_spec` + pub(crate) fn get_input_nodes(&self) -> HashSet<&String> { + let mut input_nodes = HashSet::new(); + + self.input_spec.iter().for_each(|(_, node_uris)| { + for node_uri in node_uris { + input_nodes.insert(&node_uri.node_id); + } + }); + + input_nodes + } +} + +impl PipelineJob { + /// Helpful function to get the input packet for input nodes of the pipeline based on the `pipeline_job` an`pipeline_spec`ec + /// # Errors + /// Will return `Err` if there is an issue getting the input packet per node. + /// # Returns + /// A `HashMap` where the key is the node name and the value is a vector of `HashMap` representing the input packets for that node. + #[expect(clippy::excessive_nesting, reason = "Nesting manageable")] + pub fn get_input_packet_per_node( + &self, + ) -> Result>>> { + // For each node in the input specification, we will iterate over its mapping and + let mut node_input_spec = HashMap::new(); + for (input_key, node_uris) in &self.pipeline.input_spec { + for node_uri in node_uris { + let input_path_sets = self.input_packet.get(input_key).ok_or(OrcaError { + kind: Kind::KeyMissing { + key: input_key.clone(), + backtrace: Some(Backtrace::capture()), + }, + })?; + // There shouldn't be a duplicate key in the input packet + let node_input_path_sets_ref = node_input_spec + .entry(&node_uri.node_id) + .or_insert_with(HashMap::new); + + // Check if the node_uri.key already exists, if it does this is an error as there can't be two input_packet that map to the same key + if node_input_path_sets_ref.contains_key(&node_uri.key) { + todo!() + } else { + // Insert all the input_path_sets that map to this specific key for the node + node_input_path_sets_ref.insert(&node_uri.key, input_path_sets); + } + } + } + + // For each node, compute the cartesian product of the path_sets for each unique combination of keys + let node_input_packets = node_input_spec + .into_iter() + .map(|(node_id, input_node_keys)| { + // We need to pull them out at the same time to ensure the key order is preserve to match the cartesian product + let (keys, values): (Vec<_>, Vec<_>) = input_node_keys.into_iter().unzip(); + + // Covert each combo into a packet + let packets = values + .into_iter() + .multi_cartesian_product() + .map(|combo| { + keys.iter() + .copied() + .zip(combo) + .map(|(key, pathset)| (key.to_owned(), pathset.to_owned())) + .collect::>() + }) + .collect::>>(); + + (node_id.to_owned(), packets) + }) + .collect::>(); + + Ok(node_input_packets) + } +} diff --git a/src/core/operator.rs b/src/core/operator.rs index 667c6478..296f2b2b 100644 --- a/src/core/operator.rs +++ b/src/core/operator.rs @@ -1,5 +1,6 @@ use crate::uniffi::{error::Result, model::packet::Packet}; use itertools::Itertools as _; +use serde::{Deserialize, Serialize}; use std::{clone::Clone as _, collections::HashMap, iter::IntoIterator as _, sync::Arc}; use tokio::{sync::Mutex, task::JoinSet}; @@ -84,16 +85,11 @@ impl Operator for JoinOperator { } } +#[derive(uniffi::Object, Debug, Clone, Deserialize, Serialize, PartialEq)] pub struct MapOperator { map: HashMap, } -impl MapOperator { - pub const fn new(map: HashMap) -> Self { - Self { map } - } -} - impl Operator for MapOperator { async fn process_packets(&self, packets: Vec<(String, Packet)>) -> Result> { Ok(packets diff --git a/src/core/pipeline_runner.rs b/src/core/pipeline_runner.rs index 2199a883..dbf98dc0 100644 --- a/src/core/pipeline_runner.rs +++ b/src/core/pipeline_runner.rs @@ -1,13 +1,19 @@ use crate::{ - core::{crypto::hash_buffer, model::serialize_hashmap, util::get}, + core::{ + crypto::hash_buffer, + model::{pipeline::PipelineNode, serialize_hashmap}, + operator::{MapOperator, Operator}, + util::get, + }, uniffi::{ error::{Kind, OrcaError, Result, selector}, model::{ packet::{PathSet, URI}, - pipeline::{Kernel, Pipeline, PipelineJob}, + pipeline::{Kernel, Pipeline, PipelineJob, PipelineResult}, pod::{Pod, PodJob, PodResult}, }, orchestrator::{ + PodStatus, agent::{Agent, AgentClient, Response}, docker::LocalDockerOrchestrator, }, @@ -144,7 +150,7 @@ impl DockerPipelineRunner { }; // Get the preexisting zenoh session from agent - let session = &self.agent.client.session.into(); + let session = Arc::clone(&self.agent.client.session); // Spawn task for each of the processing node let orchestrator_agent_clone = Arc::clone(&pipeline_run.orchestrator_agent); @@ -203,7 +209,7 @@ impl DockerPipelineRunner { // Group the output spec by node for (output_key, node_uri) in &pipeline_run.pipeline_job.pipeline.output_spec { node_output_spec - .entry(node_uri.node_name.clone()) + .entry(node_uri.node_id.clone()) .or_insert_with(HashMap::new) .insert(output_key.clone(), node_uri.key.clone()); } @@ -449,8 +455,10 @@ impl DockerPipelineRunner { let node_processor: Arc>> = Arc::new(Mutex::new(match &node.kernel { Kernel::Pod { pod } => Box::new(PodProcessor::new(Arc::clone(pod), client)), - Kernel::Mapper { mapper } => Box::new(MapperProcessor::new(Arc::clone(mapper))), - Kernel::Joiner => { + Kernel::MapOperator { mapper } => { + Box::new(MapOperatorProcessor::new(Arc::clone(mapper))) + } + Kernel::JoinOperator => { // Need to get the parent node id for this joiner node let mut parent_nodes = pipeline .get_node_parents(&node) @@ -814,11 +822,11 @@ impl PodProcessor { // Get the output packet for the pod result let output_packet = match pod_result.status { - PodResultStatus::Completed => { + PodStatus::Completed => { // Get the output packet - pod_result.pod_job.get_output_packet(namespace_lookup)? + pod_result.output_packet } - PodResultStatus::Failed(exit_code) => { + PodStatus::Failed(exit_code) => { // Processing failed, thus return the error return Err(OrcaError { kind: Kind::PodJobProcessingError { @@ -828,7 +836,7 @@ impl PodProcessor { }, }); } - PodResultStatus::Unset => { + PodStatus::Unset | PodStatus::Running => { // This should not happen, but if it does, we will return an error return Err(OrcaError { kind: Kind::PodJobProcessingError { @@ -913,15 +921,20 @@ impl NodeProcessor for PodProcessor { } } +struct OperatorProcessor { + operator: Arc, + processing_tasks: JoinSet<()>, +} + /// Processor for Mapper nodes /// This processor renames the `input_keys` from the input packet to the `output_keys` defined by the map -struct MapperProcessor { - mapper: Arc, +struct MapOperatorProcessor { + mapper: Arc, processing_tasks: JoinSet<()>, } -impl MapperProcessor { - fn new(mapper: Arc) -> Self { +impl MapOperatorProcessor { + fn new(mapper: Arc) -> Self { Self { mapper, processing_tasks: JoinSet::new(), @@ -930,7 +943,7 @@ impl MapperProcessor { } #[async_trait] -impl NodeProcessor for MapperProcessor { +impl NodeProcessor for MapOperatorProcessor { fn process_packet( &mut self, _sender_node_id: &str, @@ -941,22 +954,15 @@ impl NodeProcessor for MapperProcessor { _namespace: &str, _namespace_lookup: &HashMap, ) -> Result<()> { - let mapping = self.mapper.mapping.clone(); + let mapping = self.mapper; let packet_clone = packet.clone(); let node_id_clone = node_id.to_owned(); let output_key_exp_clone = base_output_key_exp.to_owned(); - + let mapper = Arc::clone(&self.mapper); self.processing_tasks.spawn(async move { let result = async { // Apply the mapping to the input packet - let output_map = mapping - .iter() - .map(|(input_key, output_key)| { - let input = get(&packet_clone, input_key)?.clone(); - Ok((output_key.to_owned(), input)) - }) - .collect::>>()?; - + let output_map = mapper.process_packets(packet_clone).await?; // Send the packet outwards session .put( diff --git a/src/core/util.rs b/src/core/util.rs index 8721cb90..c8957104 100644 --- a/src/core/util.rs +++ b/src/core/util.rs @@ -1,6 +1,11 @@ use crate::uniffi::error::{Result, selector}; use snafu::OptionExt as _; -use std::{any::type_name, borrow::Borrow, collections::HashMap, fmt, hash}; +use std::{ + any::type_name, + borrow::Borrow, + collections::{BTreeMap, HashMap}, + fmt, hash, +}; #[expect( clippy::unwrap_used, @@ -35,3 +40,19 @@ where key: format!("{key:?}"), })?) } + +pub fn create_key_expr( + group: &str, + host: &str, + topic: &str, + content: &BTreeMap, +) -> String { + // For each key-value pair in the content, we format it as "key/value" and join them with "/". + // The final format will be "group/host/topic/key1/value1/key2/value + let content_converted = content + .into_iter() + .map(|(k, v)| format!("{}/{}", k, v)) + .collect::>() + .join("/"); + format!("{}/{}/{}/{}", group, host, topic, content_converted) +} diff --git a/src/uniffi/error.rs b/src/uniffi/error.rs index 6bc4ecd6..99c5516f 100644 --- a/src/uniffi/error.rs +++ b/src/uniffi/error.rs @@ -41,6 +41,16 @@ pub(crate) enum Kind { }, #[snafu(display("Out of generated random names."))] GeneratedNamesOverflow { backtrace: Option }, + #[snafu(display( + "Missing expected output file or dir with key {packet_key} at path {path:?} for pod job (hash: {pod_job_hash})." + ))] + FailedToGetPodJobOutput { + pod_job_hash: String, + packet_key: String, + path: Box, + io_error: Box, + backtrace: Option, + }, #[snafu(display("Incomplete {kind} packet. Missing `{missing_keys:?}` keys."))] IncompletePacket { kind: String, @@ -96,21 +106,14 @@ pub(crate) enum Kind { path: PathBuf, backtrace: Option, }, - #[snafu(display( - "Missing expected output file or dir with key {packet_key} at path {path:?} for pod job (hash: {pod_job_hash})." - ))] - FailedToGetPodJobOutput { - pod_job_hash: String, - packet_key: String, - path: Box, - io_error: Box, + #[snafu(display("Pod job submission failed with reason: {reason}."))] + PodJobSubmissionFailed { + reason: String, backtrace: Option, }, - #[snafu(display( - "Failed to convert status {status:?} to PodResultStatus with reason: {reason}." - ))] - StatusConversionFailure { - status: PodStatus, + #[snafu(display("Pod job {hash} failed to process with reason: {reason}."))] + PodJobProcessingError { + hash: String, reason: String, backtrace: Option, }, diff --git a/src/uniffi/model/pipeline.rs b/src/uniffi/model/pipeline.rs index ea74364a..b008acef 100644 --- a/src/uniffi/model/pipeline.rs +++ b/src/uniffi/model/pipeline.rs @@ -3,6 +3,7 @@ use crate::{ crypto::{hash_blob, make_random_hash}, graph::make_graph, model::pipeline::PipelineNode, + operator::MapOperator, validation::validate_packet, }, uniffi::{ @@ -30,9 +31,9 @@ pub struct Pipeline { #[getset(skip)] pub graph: DiGraph, /// Exposed, internal input specification. Each input may be fed into more than one node/key if desired. - pub input_spec: HashMap>, + pub input_spec: HashMap>, /// Exposed, internal output specification. Each output is associated with only one node/key. - pub output_spec: HashMap, + pub output_spec: HashMap, } #[uniffi::export] @@ -46,8 +47,8 @@ impl Pipeline { pub fn new( graph_dot: &str, metadata: HashMap, - input_spec: &HashMap>, - output_spec: &HashMap, + input_spec: &HashMap>, + output_spec: &HashMap, ) -> Result { let graph = make_graph(graph_dot, metadata)?; Ok(Self { @@ -129,28 +130,36 @@ impl PipelineJob { } } +/// Struct to hold the result of a pipeline execution. +pub struct PipelineResult { + /// The pipeline job that was executed. + pub pipeline_job: Arc, + /// The result of the pipeline execution. + pub output_packets: HashMap>, +} + /// A node in a computational pipeline. #[derive(uniffi::Enum, Debug, Clone, Deserialize, Serialize, PartialEq)] pub enum Kernel { /// Pod reference. Pod { /// See [`Pod`](crate::uniffi::model::pod::Pod). - r#ref: Arc, + pod: Arc, }, /// Cartesian product operation. See [`JoinOperator`](crate::core::operator::JoinOperator). JoinOperator, /// Rename a path set key operation. MapOperator { /// See [`MapOperator`](crate::core::operator::MapOperator). - map: HashMap, + mapper: Arc, }, } /// Index from pipeline node into pod specification. #[derive(uniffi::Record, Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] -pub struct SpecURI { +pub struct NodeURI { /// Node reference name in pipeline. - pub node: String, + pub node_id: String, /// Specification key. pub key: String, } diff --git a/src/uniffi/orchestrator/agent.rs b/src/uniffi/orchestrator/agent.rs index e9a63722..d3cba551 100644 --- a/src/uniffi/orchestrator/agent.rs +++ b/src/uniffi/orchestrator/agent.rs @@ -47,7 +47,7 @@ pub struct AgentClient { /// Connecting agent's assigned name used for reference. pub host: String, #[getset(skip)] - pub(crate) session: zenoh::Session, + pub(crate) session: Arc, } #[uniffi::export] @@ -68,7 +68,8 @@ impl AgentClient { .await .context(selector::AgentCommunicationFailure {})?, ) - })?, + })? + .into(), }) } /// Start many pod jobs to be processed in parallel. diff --git a/tests/pipeline.rs b/tests/pipeline.rs index afcf9d5e..78705347 100644 --- a/tests/pipeline.rs +++ b/tests/pipeline.rs @@ -13,7 +13,7 @@ use orcapod::uniffi::{ error::Result, model::{ packet::{Blob, BlobKind, PathInfo, PathSet, URI}, - pipeline::{Kernel, Pipeline, PipelineJob, SpecURI}, + pipeline::{Kernel, NodeURI, Pipeline, PipelineJob}, }, }; use std::collections::HashMap; @@ -45,8 +45,8 @@ fn input_packet_checksum() -> Result<()> { )]), &HashMap::from([( "pipeline_key_1".into(), - vec![SpecURI { - node: "A".into(), + vec![NodeURI { + node_id: "A".into(), key: "node_key_1".into(), }], )]), From 58ba1986536663576db806199992c65a74e84938 Mon Sep 17 00:00:00 2001 From: Synicix Date: Mon, 11 Aug 2025 05:46:05 +0000 Subject: [PATCH 15/65] Fix missing stuff --- Cargo.toml | 4 ---- src/core/mod.rs | 12 ++---------- src/uniffi/orchestrator/agent.rs | 1 + 3 files changed, 3 insertions(+), 14 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 4c156d17..e2774aaf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,10 +27,6 @@ edition = "2024" default = [] test = [] -[features] -default = [] -test = [] - [lib] crate-type = ["rlib", "cdylib"] diff --git a/src/core/mod.rs b/src/core/mod.rs index 469fd1a5..97264f55 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -7,18 +7,8 @@ macro_rules! inner_attr_to_each { } } -macro_rules! inner_attr_to_each { - { #!$attr:tt $($it:item)* } => { - $( - #$attr - $it - )* - } -} - pub(crate) mod error; pub(crate) mod graph; -pub(crate) mod pipeline; pub(crate) mod store; pub(crate) mod util; pub(crate) mod validation; @@ -28,6 +18,7 @@ inner_attr_to_each! { pub(crate) mod crypto; pub(crate) mod model; pub(crate) mod orchestrator; + pub(crate) mod operator; } #[cfg(feature = "test")] @@ -44,4 +35,5 @@ inner_attr_to_each! { pub mod crypto; pub mod model; pub mod orchestrator; + pub mod operator; } diff --git a/src/uniffi/orchestrator/agent.rs b/src/uniffi/orchestrator/agent.rs index 8a1273a7..c2d8d00e 100644 --- a/src/uniffi/orchestrator/agent.rs +++ b/src/uniffi/orchestrator/agent.rs @@ -155,6 +155,7 @@ impl Agent { /// # Errors /// /// Will stop and return an error if encounters an error while processing any pod job request. + #[expect(clippy::excessive_nesting, reason = "Nesting manageable.")] pub async fn start( &self, namespace_lookup: &HashMap, From 7ee5251c5a4c82d6227ed99b1e8a59c7b8a4e00b Mon Sep 17 00:00:00 2001 From: Synicix Date: Mon, 11 Aug 2025 07:09:01 +0000 Subject: [PATCH 16/65] save change --- .vscode/settings.json | 1 - cspell.json | 3 ++- src/core/error.rs | 3 +++ src/core/pipeline_runner.rs | 2 +- src/uniffi/error.rs | 34 +++++++++++++++++++++------------- 5 files changed, 27 insertions(+), 16 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 695f87c2..15fa34bb 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -9,7 +9,6 @@ ], "files.autoSave": "off", "files.insertFinalNewline": true, - "gitlens.showWelcomeOnInstall": false, "gitlens.showWhatsNewAfterUpgrades": false, "lldb.consoleMode": "evaluate", "rust-analyzer.cargo.features": [ diff --git a/cspell.json b/cspell.json index 51f80b58..29f81373 100644 --- a/cspell.json +++ b/cspell.json @@ -81,7 +81,8 @@ "patchelf", "itertools", "colinianking", - "itertools" + "itertools", + "pathset" ], "useGitignore": false, "ignorePaths": [ diff --git a/src/core/error.rs b/src/core/error.rs index bdc1d46e..ad4c8550 100644 --- a/src/core/error.rs +++ b/src/core/error.rs @@ -124,8 +124,11 @@ impl fmt::Debug for OrcaError { | Kind::IncompletePacket { backtrace, .. } | Kind::InvalidFilepath { backtrace, .. } | Kind::InvalidIndex { backtrace, .. } + | Kind::KeyMissing { backtrace, .. } | Kind::MissingInfo { backtrace, .. } | Kind::FailedToGetPodJobOutput { backtrace, .. } + | Kind::PodJobProcessingError { backtrace, .. } + | Kind::PodJobSubmissionFailed { backtrace, .. } | Kind::UnexpectedPathType { backtrace, .. } | Kind::BollardError { backtrace, .. } | Kind::ChronoParseError { backtrace, .. } diff --git a/src/core/pipeline_runner.rs b/src/core/pipeline_runner.rs index dbf98dc0..a6a53d98 100644 --- a/src/core/pipeline_runner.rs +++ b/src/core/pipeline_runner.rs @@ -962,7 +962,7 @@ impl NodeProcessor for MapOperatorProcessor { self.processing_tasks.spawn(async move { let result = async { // Apply the mapping to the input packet - let output_map = mapper.process_packets(packet_clone).await?; + let output_map = mapper.process_packets(vec![packet_clone]).await?; // Send the packet outwards session .put( diff --git a/src/uniffi/error.rs b/src/uniffi/error.rs index 5e9892cd..3e2f09d1 100644 --- a/src/uniffi/error.rs +++ b/src/uniffi/error.rs @@ -32,6 +32,16 @@ pub(crate) enum Kind { source: Box, backtrace: Option, }, + #[snafu(display( + "Missing expected output file or dir with key {packet_key} at path {path:?} for pod job (hash: {pod_job_hash})." + ))] + FailedToGetPodJobOutput { + pod_job_hash: String, + packet_key: String, + path: Box, + io_error: Box, + backtrace: Option, + }, #[snafu(display("Incomplete {kind} packet. Missing `{missing_keys:?}` keys."))] IncompletePacket { kind: String, @@ -49,26 +59,24 @@ pub(crate) enum Kind { idx: usize, backtrace: Option, }, + #[snafu(display("Key '{key}' was not found in map."))] + KeyMissing { + key: String, + backtrace: Option, + }, #[snafu(display("Missing info. Details: {details}."))] MissingInfo { details: String, backtrace: Option, }, - #[snafu(display( - "Missing expected output file or dir with key {packet_key} at path {path:?} for pod job (hash: {pod_job_hash})." - ))] - FailedToGetPodJobOutput { - pod_job_hash: String, - packet_key: String, - path: Box, - io_error: Box, + #[snafu(display("Pod job submission failed with reason: {reason}."))] + PodJobSubmissionFailed { + reason: String, backtrace: Option, }, - #[snafu(display( - "Failed to convert status {status:?} to PodResultStatus with reason: {reason}." - ))] - StatusConversionFailure { - status: PodStatus, + #[snafu(display("Pod job {hash} failed to process with reason: {reason}."))] + PodJobProcessingError { + hash: String, reason: String, backtrace: Option, }, From fda43aa091bc4e996af3a5e989ce1a5dc73b4776 Mon Sep 17 00:00:00 2001 From: Synicix Date: Mon, 11 Aug 2025 20:52:10 +0000 Subject: [PATCH 17/65] Update pipeline_runner to use new operator + improvments --- src/core/graph.rs | 2 +- src/core/model/pipeline.rs | 3 +- src/core/operator.rs | 10 +- src/core/pipeline_runner.rs | 944 ++++++++++++++-------------------- src/core/util.rs | 8 +- src/uniffi/error.rs | 1 - src/uniffi/mod.rs | 2 - src/uniffi/pipeline_runner.rs | 1 - tests/operator.rs | 4 +- tests/pipeline.rs | 2 +- 10 files changed, 416 insertions(+), 561 deletions(-) delete mode 100644 src/uniffi/pipeline_runner.rs diff --git a/src/core/graph.rs b/src/core/graph.rs index 78e709db..73bb87cb 100644 --- a/src/core/graph.rs +++ b/src/core/graph.rs @@ -25,7 +25,7 @@ pub fn make_graph( let graph = DiGraph::::from_dot_graph(DOTGraph::try_from(input_dot)?).map( |_, node| PipelineNode { - name: node.id.clone(), + id: node.id.clone(), kernel: get(&metadata, &node.id) .unwrap_or_else(|error| panic!("{error}")) .clone(), diff --git a/src/core/model/pipeline.rs b/src/core/model/pipeline.rs index dfcccb1a..137c9534 100644 --- a/src/core/model/pipeline.rs +++ b/src/core/model/pipeline.rs @@ -16,7 +16,7 @@ use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] pub struct PipelineNode { - pub name: String, + pub id: String, pub kernel: Kernel, } @@ -58,7 +58,6 @@ impl PipelineJob { /// Will return `Err` if there is an issue getting the input packet per node. /// # Returns /// A `HashMap` where the key is the node name and the value is a vector of `HashMap` representing the input packets for that node. - #[expect(clippy::excessive_nesting, reason = "Nesting manageable")] pub fn get_input_packet_per_node( &self, ) -> Result>>> { diff --git a/src/core/operator.rs b/src/core/operator.rs index 296f2b2b..24ff1472 100644 --- a/src/core/operator.rs +++ b/src/core/operator.rs @@ -1,11 +1,13 @@ use crate::uniffi::{error::Result, model::packet::Packet}; +use async_trait::async_trait; use itertools::Itertools as _; use serde::{Deserialize, Serialize}; use std::{clone::Clone as _, collections::HashMap, iter::IntoIterator as _, sync::Arc}; use tokio::{sync::Mutex, task::JoinSet}; #[allow(async_fn_in_trait, reason = "We only use this internally")] -pub trait Operator { +#[async_trait] +pub trait Operator: Send + Sync { async fn process_packets(&self, packets: Vec<(String, Packet)>) -> Result>; } @@ -63,6 +65,7 @@ impl JoinOperator { } } +#[async_trait] impl Operator for JoinOperator { async fn process_packets(&self, packets: Vec<(String, Packet)>) -> Result> { let mut processing_task = JoinSet::new(); @@ -85,11 +88,12 @@ impl Operator for JoinOperator { } } -#[derive(uniffi::Object, Debug, Clone, Deserialize, Serialize, PartialEq)] +#[derive(uniffi::Object, Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] pub struct MapOperator { - map: HashMap, + pub map: HashMap, } +#[async_trait] impl Operator for MapOperator { async fn process_packets(&self, packets: Vec<(String, Packet)>) -> Result> { Ok(packets diff --git a/src/core/pipeline_runner.rs b/src/core/pipeline_runner.rs index a6a53d98..1ef79aa0 100644 --- a/src/core/pipeline_runner.rs +++ b/src/core/pipeline_runner.rs @@ -2,14 +2,14 @@ use crate::{ core::{ crypto::hash_buffer, model::{pipeline::PipelineNode, serialize_hashmap}, - operator::{MapOperator, Operator}, - util::get, + operator::{JoinOperator, Operator}, + util::{get, make_key_expr}, }, uniffi::{ error::{Kind, OrcaError, Result, selector}, model::{ - packet::{PathSet, URI}, - pipeline::{Kernel, Pipeline, PipelineJob, PipelineResult}, + packet::{Packet, PathSet, URI}, + pipeline::{Kernel, PipelineJob, PipelineResult}, pod::{Pod, PodJob, PodResult}, }, orchestrator::{ @@ -20,12 +20,12 @@ use crate::{ }, }; use async_trait::async_trait; -use itertools::Itertools as _; +use names::{Generator, Name}; use serde::{Deserialize, Serialize}; use serde_yaml::Serializer; use snafu::{OptionExt as _, ResultExt as _}; use std::{ - collections::HashMap, + collections::{BTreeMap, HashMap}, fmt::{Display, Formatter, Result as FmtResult}, hash::{Hash, Hasher}, path::PathBuf, @@ -58,13 +58,77 @@ struct ProcessingFailure { #[derive(Debug)] struct PipelineRun { /// `PipelineJob` that this run is associated with + group: String, + host: String, + assigned_name: String, + session: Arc, // Zenoh session for communication + agent_client: Arc, // Zenoh agent client for communication with docker orchestrators pipeline_job: Arc, // The pipeline job that this run is associated with - node_tasks: JoinSet>, // JoinSet of tasks for each node in the pipeline + node_tasks: Arc>>>, // JoinSet of tasks for each node in the pipeline outputs: Arc>>>, // String is the node key, while hash orchestrator_agent: Arc, // This is placed in pipeline due to the current design requiring a namespace to operate on orchestrator_agent_task: JoinSet>, // JoinSet of tasks for the orchestrator agent failure_logs: Arc>>, // Logs of processing failures - failure_logging_task: JoinSet>, // JoinSet of tasks for logging failures + failure_logging_task: Arc>>>, // JoinSet of tasks for logging failures + namespace: String, + namespace_lookup: HashMap, +} + +impl PipelineRun { + fn make_key_expr(&self, node_id: &str, event: &str) -> String { + make_key_expr( + &self.group, + &self.host, + "pipeline_run", + &BTreeMap::from([ + ("event".to_owned(), event.to_owned()), + ("node_id".to_owned(), node_id.to_owned()), + ("pipeline_run_id".to_owned(), self.assigned_name.clone()), + ]), + ) + } + + fn make_abort_request_key_exp(&self) -> String { + make_key_expr( + &self.group, + &self.host, + "pipeline_run", + &BTreeMap::from([ + ("event".to_owned(), "abort".to_owned()), + ("pipeline_run_id".to_owned(), self.assigned_name.clone()), + ]), + ) + } + + // Utils functions + async fn send_packets(&self, node_id: &str, output_packets: &Vec) -> Result<()> { + Ok(self + .session + .put( + self.make_key_expr(node_id, "output"), + serde_json::to_string(&output_packets)?, + ) + .await + .context(selector::AgentCommunicationFailure {})?) + } + + async fn send_err(&self, node_id: &str, err: OrcaError) { + try_to_forward_err_msg( + Arc::clone(&self.session), + err, + &self.make_key_expr(node_id, "error"), + node_id, + ) + .await; + } + + async fn send_abort_request(&self) -> Result<()> { + Ok(self + .session + .put(&self.make_abort_request_key_exp(), vec![]) + .await + .context(selector::AgentCommunicationFailure {})?) + } } impl PartialEq for PipelineRun { @@ -94,7 +158,7 @@ pub struct DockerPipelineRunner { /// The host name of the runner pub host: String, agent: Arc, - pipeline_runs: HashMap, + pipeline_runs: HashMap>, } /// This is an implementation of a pipeline runner that uses Zenoh to communicate between the tasks @@ -108,13 +172,13 @@ impl DockerPipelineRunner { /// Create a new Docker pipeline runner /// # Errors /// Will error out if the environment variable `HOSTNAME` is not set - pub fn new(group: String, host: String, agent: Arc) -> Result { - Ok(Self { + pub fn new(group: String, host: String, agent: Arc) -> Self { + Self { group, host, agent, pipeline_runs: HashMap::new(), - }) + } } /// Will start a new pipeline run with the given `PipelineJob` @@ -139,36 +203,33 @@ impl DockerPipelineRunner { )?; // Create a new pipeline run - let mut pipeline_run = PipelineRun { + let pipeline_run = Arc::new(PipelineRun { pipeline_job: pipeline_job.into(), outputs: Arc::new(RwLock::new(HashMap::new())), - node_tasks: JoinSet::new(), + node_tasks: Arc::new(Mutex::new(JoinSet::new())), orchestrator_agent: orchestrator_agent.into(), orchestrator_agent_task: JoinSet::new(), failure_logs: Arc::new(RwLock::new(Vec::new())), - failure_logging_task: JoinSet::new(), - }; - - // Get the preexisting zenoh session from agent - let session = Arc::clone(&self.agent.client.session); - - // Spawn task for each of the processing node - let orchestrator_agent_clone = Arc::clone(&pipeline_run.orchestrator_agent); - let namespace_lookup_clone = namespace_lookup.clone(); - // Start the orchestrator agent service - pipeline_run.orchestrator_agent_task.spawn(async move { - orchestrator_agent_clone - .start(&namespace_lookup_clone, None) - .await + failure_logging_task: Arc::new(Mutex::new(JoinSet::new())), + group: self.group.clone(), + host: self.host.clone(), + assigned_name: Generator::with_naming(Name::Plain).next().context( + selector::MissingInfo { + details: "unable to generate a random name", + }, + )?, + session: Arc::clone(&self.agent.client.session), + agent_client: Arc::clone(&self.agent.client), + namespace: namespace.to_owned(), + namespace_lookup: namespace_lookup.clone(), }); // Create failure logging task pipeline_run .failure_logging_task - .spawn(Self::failure_capture_task( - Arc::clone(&session), - Arc::clone(&pipeline_run.failure_logs), - )); + .lock() + .await + .spawn(Self::failure_capture_task(Arc::clone(&pipeline_run))); // Create the processor task for each node // The id for the pipeline_run is the pipeline_job hash @@ -177,8 +238,9 @@ impl DockerPipelineRunner { let graph = &pipeline_run.pipeline_job.pipeline.graph; // Create the subscriber that listen for ready messages - let subscriber = session - .declare_subscriber(self.get_base_key_exp(&pipeline_run_id) + "/*/status/ready") + let subscriber = pipeline_run + .session + .declare_subscriber(pipeline_run.make_key_expr("*", "node_ready")) .await .context(selector::AgentCommunicationFailure {})?; @@ -192,15 +254,12 @@ impl DockerPipelineRunner { // Spawn the task pipeline_run .node_tasks + .lock() + .await .spawn(Self::spawn_node_processing_task( - node.clone(), - Arc::clone(&pipeline_run.pipeline_job.pipeline), - input_nodes.contains(&node.name), - self.get_base_key_exp(&pipeline_run_id), - namespace.to_owned(), - namespace_lookup.clone(), - Arc::clone(&session), - Arc::clone(&pipeline_run.orchestrator_agent.client), + graph[node_idx].clone(), + Arc::clone(&pipeline_run), + input_nodes.contains(&node.id), )); } @@ -215,22 +274,15 @@ impl DockerPipelineRunner { } for (node_id, key_mapping) in node_output_spec { - // Create the key expression to subscribe to - let key_exp_to_sub = format!( - "{}/{}/outputs/{}", - self.get_base_key_exp(&pipeline_run_id), - node_id, - SUCCESS_KEY_EXP, - ); - // Spawn the task that captures the outputs pipeline_run .node_tasks + .lock() + .await .spawn(Self::create_output_capture_task_for_node( key_mapping, - Arc::clone(&pipeline_run.outputs), - Arc::clone(&session), - key_exp_to_sub, + Arc::clone(&pipeline_run), + node_id.clone(), )); } @@ -247,40 +299,27 @@ impl DockerPipelineRunner { } } - // Submit the input_packets to the correct key_exp - let base_input_node_key_exp = format!( - "{}/{}", - self.get_base_key_exp(&pipeline_run_id), - INPUT_KEY_EXP, - ); - // For each node send all the packets associate with it - for (node_name, input_packets) in pipeline_run.pipeline_job.get_input_packet_per_node()? { - for packet in input_packets { - // Send the packet to the input node key_exp - let output_key_exp = format!("{base_input_node_key_exp}/{node_name}"); - session - .put( - &output_key_exp, - serde_json::to_string(&NodeOutput::Packet( - "input_node".to_owned(), - packet.clone(), - ))?, - ) - .await - .context(selector::AgentCommunicationFailure {})?; - - // All packets associate with node are sent, we can send processing complete msg now - session - .put( - &output_key_exp, - serde_json::to_string(&NodeOutput::ProcessingCompleted( - "input_node".to_owned(), - ))?, - ) - .await - .context(selector::AgentCommunicationFailure {})?; - } + for (node_id, input_packets) in pipeline_run.pipeline_job.get_input_packet_per_node()? { + // Send the packet to the input node key_exp + pipeline_run + .session + .put( + pipeline_run.make_key_expr(&node_id, INPUT_KEY_EXP), + serde_json::to_string(&input_packets)?, + ) + .await + .context(selector::AgentCommunicationFailure {})?; + + // All packets associate with node are sent, we can send processing complete msg now + pipeline_run + .session + .put( + pipeline_run.make_key_expr(&node_id, INPUT_KEY_EXP), + serde_json::to_string(&Vec::::new())?, + ) + .await + .context(selector::AgentCommunicationFailure {})?; } // Insert into the list of pipeline runs @@ -306,7 +345,7 @@ impl DockerPipelineRunner { })?; // Wait for all the tasks to complete - while let Some(result) = pipeline_run.node_tasks.join_next().await { + while let Some(result) = pipeline_run.node_tasks.lock().await.join_next().await { match result { Ok(Ok(())) => {} // Task completed successfully Ok(Err(err)) => { @@ -334,13 +373,7 @@ impl DockerPipelineRunner { /// # Errors /// Will error out if the pipeline run is not found or if any of the tasks fail to stop correctly pub async fn stop(&mut self, pipeline_run_id: &str) -> Result<()> { - let stop_key_exp = format!( - "{}/{}/stop", - self.get_base_key_exp(pipeline_run_id), - pipeline_run_id - ); - // To stop the pipeline run, we need to send a stop message to all the tasks - // Get the pipeline run first + // Get the pipeline run first then broadcast the abort request signal let pipeline_run = self.pipeline_runs .get_mut(pipeline_run_id) @@ -348,17 +381,17 @@ impl DockerPipelineRunner { key: pipeline_run_id.to_owned(), })?; - let session = zenoh::open(zenoh::Config::default()) - .await - .context(selector::AgentCommunicationFailure {})?; + // Send the abort request signal + pipeline_run.send_abort_request().await?; - // Send the stop message into the stop key_exp, the msg is just an empty vector - session - .put(stop_key_exp, Vec::new()) + while pipeline_run + .node_tasks + .lock() .await - .context(selector::AgentCommunicationFailure {})?; - - while pipeline_run.node_tasks.join_next().await.is_some() {} + .join_next() + .await + .is_some() + {} Ok(()) } @@ -366,15 +399,15 @@ impl DockerPipelineRunner { async fn create_output_capture_task_for_node( // key_mapping: HashMap, - outputs: Arc>>>, - session: Arc, - key_exp_to_sub: String, + pipeline_run: Arc, + node_id: String, ) -> Result<()> { // Determine which keys we are interested in for the given node_id // Create a zenoh session - let subscriber = session - .declare_subscriber(key_exp_to_sub) + let subscriber = pipeline_run + .session + .declare_subscriber(pipeline_run.make_key_expr(&node_id, SUCCESS_KEY_EXP)) .await .context(selector::AgentCommunicationFailure {})?; @@ -386,7 +419,7 @@ impl DockerPipelineRunner { NodeOutput::Packet(_, packet) => { // Figure out which keys // Store the output packet in the outputs map - let mut outputs_lock = outputs.write().await; + let mut outputs_lock = pipeline_run.outputs.write().await; for (output_key, node_key) in &key_mapping { outputs_lock .entry(output_key.to_owned()) @@ -403,12 +436,10 @@ impl DockerPipelineRunner { Ok(()) } - async fn failure_capture_task( - session: Arc, - failure_logs: Arc>>, - ) -> Result<()> { - let sub = session - .declare_subscriber(format!("**/outputs/{FAILURE_KEY_EXP}")) + async fn failure_capture_task(pipeline_run: Arc) -> Result<()> { + let sub = pipeline_run + .session + .declare_subscriber(pipeline_run.make_key_expr("*", FAILURE_KEY_EXP)) .await .context(selector::AgentCommunicationFailure {})?; @@ -418,7 +449,11 @@ impl DockerPipelineRunner { let process_failure: ProcessingFailure = serde_json::from_slice(&payload.payload().to_bytes())?; // Store the failure message in the logs - failure_logs.write().await.push(process_failure.clone()); + pipeline_run + .failure_logs + .write() + .await + .push(process_failure.clone()); if let Some(first_line) = process_failure.error.lines().next() { println!( "Node {} processing failed with error: {}", @@ -443,94 +478,82 @@ impl DockerPipelineRunner { /// Will error out if the kernel for the node is not found or if the async fn spawn_node_processing_task( node: PipelineNode, - pipeline: Arc, + pipeline_run: Arc, is_input_node: bool, - base_key_exp: String, - namespace: String, - namespace_lookup: HashMap, - session: Arc, - client: Arc, ) -> Result<()> { + // Get the node parents + let parent_nodes = pipeline_run + .pipeline_job + .pipeline + .get_node_parents(&node) + .collect::>(); + // Create the correct processor for the node based on the kernel type let node_processor: Arc>> = Arc::new(Mutex::new(match &node.kernel { - Kernel::Pod { pod } => Box::new(PodProcessor::new(Arc::clone(pod), client)), - Kernel::MapOperator { mapper } => { - Box::new(MapOperatorProcessor::new(Arc::clone(mapper))) - } - Kernel::JoinOperator => { - // Need to get the parent node id for this joiner node - let mut parent_nodes = pipeline - .get_node_parents(&node) - .map(|parent_node| parent_node.name.clone()) - .collect::>(); - - // Check if it this node takes input from input_nodes, if so we need ot add it to parent_node - if is_input_node { - parent_nodes.push("input_node".to_owned()); - } - - Box::new(JoinerProcessor::new(parent_nodes)) - } + Kernel::Pod { pod } => Box::new(PodProcessor::new( + Arc::clone(&pipeline_run), + node.id.clone(), + Arc::clone(pod), + )), + Kernel::MapOperator { mapper } => Box::new(OperatorProcessor::new( + Arc::clone(&pipeline_run), + node.id.clone(), + Arc::clone(mapper), + parent_nodes.len(), + )), + Kernel::JoinOperator => Box::new(OperatorProcessor::new( + Arc::clone(&pipeline_run), + node.id.clone(), + JoinOperator::new(parent_nodes.len()).into(), + parent_nodes.len(), + )), })); // Create a join set to spawn and handle incoming messages tasks let mut listener_tasks = JoinSet::new(); // Create the list of key_expressions to subscribe to - let mut key_exps_to_subscribe_to = pipeline - .get_node_parents(&node) + let mut key_exps_to_subscribe_to = parent_nodes + .into_iter() .map(|parent_node| { - format!( - "{base_key_exp}/{}/outputs/{SUCCESS_KEY_EXP}", - parent_node.name - ) + // Make the parent key to listen to + pipeline_run.make_key_expr(&parent_node.id, SUCCESS_KEY_EXP) }) .collect::>(); // Check if node is an input_node, if so we need to add the input node key expression if is_input_node { - key_exps_to_subscribe_to - .push(format!("{base_key_exp}/input_node/outputs/{}", node.name)); + key_exps_to_subscribe_to.push(pipeline_run.make_key_expr("input", SUCCESS_KEY_EXP)); } // Create a subscriber for each of the parent nodes (Should only be 1, unless it is a joiner node) for key_exp in &key_exps_to_subscribe_to { - let subscriber = session - .declare_subscriber(key_exp) - .await - .context(selector::AgentCommunicationFailure {})?; - - listener_tasks.spawn(Self::start_async_processor_task( - subscriber, + listener_tasks.spawn(Self::event_handler( + Arc::clone(&pipeline_run), + node.id.clone(), + pipeline_run + .session + .declare_subscriber(key_exp) + .await + .context(selector::AgentCommunicationFailure {})?, Arc::clone(&node_processor), - node.name.clone(), - base_key_exp.clone(), - namespace.clone(), - namespace_lookup.clone(), - Arc::clone(&session), )); } // Create the listener task for the stop request - let mut stop_listener_task = JoinSet::new(); - - stop_listener_task.spawn(Self::start_stop_request_task( - Arc::clone(&node_processor), - format!("{base_key_exp}/{}/stop", node.name), - Arc::clone(&session), + let abort_request_handler_task = tokio::spawn(Self::abort_request_event_handler( + node_processor, + Arc::clone(&pipeline_run), )); // Wait for all tasks to be spawned and reply with ready message // This is to ensure that the pipeline run knows when all tasks are ready to receive inputs - let mut num_of_ready_subscribers: usize = 0; // Build the subscriber - let status_subscriber = session - .declare_subscriber(format!( - "{base_key_exp}/{}/subscriber/status/ready", - node.name - )) + let status_subscriber = pipeline_run + .session + .declare_subscriber(pipeline_run.make_key_expr(&node.id, "event_handler_ready")) .await .context(selector::AgentCommunicationFailure {})?; @@ -543,11 +566,9 @@ impl DockerPipelineRunner { } // Send a ready message so the pipeline knows when to start sending inputs - session - .put( - format!("{base_key_exp}/{}/status/ready", node.name), - &node.name, - ) + pipeline_run + .session + .put(pipeline_run.make_key_expr(&node.id, "node_ready"), vec![]) .await .context(selector::AgentCommunicationFailure {})?; @@ -555,80 +576,51 @@ impl DockerPipelineRunner { listener_tasks.join_all().await; // Abort the stop listener task since we don't need it anymore - stop_listener_task.abort_all(); + abort_request_handler_task.abort(); Ok(()) } /// This is the actual handler for incoming messages for the node - async fn start_async_processor_task( + async fn event_handler( + pipeline_run: Arc, + node_id: String, subscriber: Subscriber>, - node_processor: Arc>>, - node_name: String, - base_key_exp: String, - namespace: String, - namespace_lookup: HashMap, - session: Arc, + processor: Arc>>, ) -> Result<()> { // We do not know when tokio will start executing this task, therefore we need to send a ready message // back to our spawner task - session + + pipeline_run + .session .put( - format!("{base_key_exp}/{node_name}/subscriber/status/ready"), - &node_name, + pipeline_run.make_key_expr(&node_id, "event_handler_ready"), + vec![], ) .await .context(selector::AgentCommunicationFailure {})?; - let node_base_output_key_exp = format!("{base_key_exp}/{node_name}/outputs"); - while let Ok(payload) = subscriber.recv_async().await { - // Extract the message from the payload - match serde_json::from_slice(&payload.payload().to_bytes())? { - NodeOutput::Packet(sender_id, hash_map) => { - // Process the packet using the node processor - let result = node_processor.lock().await.process_packet( - &sender_id, - &node_name, - &hash_map, - Arc::clone(&session), - &node_base_output_key_exp, - &namespace, - &namespace_lookup, - ); - - if let Err(err) = result { - try_to_forward_err_msg( - Arc::clone(&session), - err, - &node_base_output_key_exp, - &node_name, - ) - .await; - } - } - NodeOutput::ProcessingCompleted(sender_id) => { - // Notify the processor that the parent node has completed processing - if node_processor - .lock() - .await - .mark_parent_as_complete(&sender_id) - .await - { - // This was the last parent, thus we need to send the processing complete message - let output_key_exp = - format!("{base_key_exp}/{node_name}/outputs/{SUCCESS_KEY_EXP}"); - session - .put( - output_key_exp, - serde_json::to_string(&NodeOutput::ProcessingCompleted( - node_name.clone(), - ))?, - ) - .await - .context(selector::AgentCommunicationFailure {})?; - } - break; - } + while let Ok(sample) = subscriber.recv_async().await { + // Extract out the packets + let packets: Vec = serde_json::from_slice(&sample.payload().to_bytes())?; + + // Check if the packets are empty, if so that means the node is finished processing + if packets.is_empty() { + processor + .lock() + .await + .mark_parent_as_complete(&node_id) + .await; + break; + } + + // For each packet, we need to process it + for packet in packets { + processor + .lock() + .await + .process_incoming_packet(&node_id, &packet) + .await; } } @@ -636,13 +628,13 @@ impl DockerPipelineRunner { } /// This task will listen for stop requests on the given key expression - async fn start_stop_request_task( + async fn abort_request_event_handler( node_processor: Arc>>, - base_key_exp: String, - session: Arc, + pipeline_run: Arc, ) -> Result<()> { - let subscriber = session - .declare_subscriber(format!("{base_key_exp}/stop")) + let subscriber = pipeline_run + .session + .declare_subscriber(pipeline_run.make_abort_request_key_exp()) .await .context(selector::AgentCommunicationFailure {})?; while subscriber.recv_async().await.is_ok() { @@ -651,10 +643,6 @@ impl DockerPipelineRunner { } Ok::<(), OrcaError>(()) } - - fn get_base_key_exp(&self, pipeline_run_id: &str) -> String { - format!("{}/{}/{}", self.group, self.host, pipeline_run_id) - } } /// Unify the interface for node processors and provide a common way to handle processing of incoming messages @@ -664,29 +652,12 @@ impl DockerPipelineRunner { /// As a result, each processor only needs to worry about writing their own function to process the msg #[async_trait] trait NodeProcessor: Send + Sync { - fn process_packet( - &mut self, - sender_node_id: &str, - node_id: &str, - packet: &HashMap, - session: Arc, - base_output_key_exp: &str, - namespace: &str, - namespace_lookup: &HashMap, - ) -> Result<()>; + async fn process_incoming_packet(&mut self, sender_node_id: &str, incoming_packet: &Packet); /// Notifies the processor that the parent node has completed processing - /// If the parent node was the last one to complete, this function will wait till all task are done - /// and send the node processing complete message then return. - /// - /// Otherwise it will return immediately - /// - /// # Returns - /// true if the parent node was the last one to complete processing, user send - /// the processing completion message to the output - /// - /// false if there are still other parent nodes that need to complete processing - async fn mark_parent_as_complete(&mut self, parent_node_id: &str) -> bool; + /// If it is the last parent to complete, it will wait for all processing task to finish + /// Then send a completion signal + async fn mark_parent_as_complete(&mut self, parent_node_id: &str); fn stop(&mut self); } @@ -695,13 +666,13 @@ trait NodeProcessor: Send + Sync { async fn try_to_forward_err_msg( session: Arc, err: OrcaError, - node_base_output_key_exp: &str, + key_expression: &str, node_id: &str, ) { match async { session .put( - format!("{node_base_output_key_exp}/{FAILURE_KEY_EXP}"), + format!("{key_expression}/{FAILURE_KEY_EXP}"), serde_json::to_string(&ProcessingFailure { node_id: node_id.to_owned(), error: err.to_string(), @@ -723,65 +694,71 @@ async fn try_to_forward_err_msg( /// Processor for Pods /// Currently missing implementation to call agents for actual pod processing struct PodProcessor { + pipeline_run: Arc, + node_id: String, pod: Arc, processing_tasks: JoinSet<()>, - client: Arc, } impl PodProcessor { - fn new(pod: Arc, client: Arc) -> Self { + fn new(pipeline_run: Arc, node_id: String, pod: Arc) -> Self { Self { + pipeline_run, + node_id, pod, processing_tasks: JoinSet::new(), - client, } } +} +impl PodProcessor { /// Will handle the creation of the pod job, submission to the agent, listening for completion, and extracting the `output_packet` if successful - async fn start_pod_job_task( + async fn process_packet( + pipeline_run: Arc, node_id: String, pod: Arc, - packet: HashMap, - client: Arc, - session: Arc, - base_output_key_exp: String, - namespace: String, - namespace_lookup: &HashMap, - ) -> Result<()> { - // For now we will just send the input_packet to the success channel - let node_id_bytes = node_id.as_bytes().to_vec(); + incoming_packet: HashMap, + ) -> Result { + // Hash the input_packet to create a unique identifier for the pod job let input_packet_hash = { - let mut buf = node_id_bytes; + let mut buf = Vec::new(); let mut serializer = Serializer::new(&mut buf); - serialize_hashmap(&packet, &mut serializer)?; + serialize_hashmap(&incoming_packet, &mut serializer)?; hash_buffer(buf) }; - let output_dir = URI { - namespace: namespace.clone(), - path: PathBuf::from(format!("pod_runs/{node_id}/{input_packet_hash}")), - }; - - let cpu_limit = pod.recommended_cpus; - let memory_limit = pod.recommended_memory; // Create the pod job let pod_job = PodJob::new( None, Arc::clone(&pod), - packet, - output_dir, - cpu_limit, - memory_limit, + incoming_packet, + URI { + namespace: pipeline_run.namespace.clone(), + path: format!( + "pipeline_outputs/{}/{node_id}/{input_packet_hash}", + pipeline_run.assigned_name + ) + .into(), + }, + pod.recommended_cpus, + pod.recommended_memory, None, - namespace_lookup, + &pipeline_run.namespace_lookup, )?; // Create listener for pod_job - let target_key_exp = format!("group/{}/success/pod_job/{}/**", client.group, pod_job.hash); - // Create the subscriber - let pod_job_subscriber = session - .declare_subscriber(target_key_exp) + let pod_job_subscriber = pipeline_run + .session + .declare_subscriber(make_key_expr( + &pipeline_run.group, + "*", + "pod_job", + &BTreeMap::from([ + ("id".to_owned(), pod_job.hash.clone()), + ("action".to_owned(), "success".to_owned()), + ]), + )) .await .context(selector::AgentCommunicationFailure {})?; @@ -798,7 +775,10 @@ impl PodProcessor { }); // Submit it to the client and get the response to make sure it was successful - let responses = client.start_pod_jobs(vec![pod_job.into()]).await; + let responses = pipeline_run + .agent_client + .start_pod_jobs(vec![pod_job.clone().into()]) + .await; let response = responses .first() .context(selector::InvalidIndex { idx: 0_usize })?; @@ -807,7 +787,8 @@ impl PodProcessor { Response::Ok => (), Response::Err(err) => { return Err(OrcaError { - kind: Kind::PodJobSubmissionFailed { + kind: Kind::PodJobProcessingError { + hash: pod_job.hash, reason: err.clone(), backtrace: Some(snafu::Backtrace::capture()), }, @@ -816,12 +797,10 @@ impl PodProcessor { } // Get the pod result from the listener task - let temp = pod_job_listener_task.await?; - - let pod_result = temp?; + let pod_result = pod_job_listener_task.await??; // Get the output packet for the pod result - let output_packet = match pod_result.status { + Ok(match pod_result.status { PodStatus::Completed => { // Get the output packet pod_result.output_packet @@ -836,161 +815,81 @@ impl PodProcessor { }, }); } - PodStatus::Unset | PodStatus::Running => { + PodStatus::Running | PodStatus::Unset => { // This should not happen, but if it does, we will return an error return Err(OrcaError { kind: Kind::PodJobProcessingError { hash: pod_result.pod_job.hash.clone(), - reason: "Pod processing status is unset".to_owned(), + reason: "Pod result status is running or unset".to_owned(), backtrace: Some(snafu::Backtrace::capture()), }, }); } - }; - - session - .put( - base_output_key_exp.clone() + "/" + SUCCESS_KEY_EXP, - serde_json::to_string(&NodeOutput::Packet(node_id.clone(), output_packet))?, - ) - .await - .context(selector::AgentCommunicationFailure {})?; - Ok::<(), OrcaError>(()) + }) } } +#[expect(clippy::excessive_nesting, reason = "Nesting is manageable")] #[async_trait] impl NodeProcessor for PodProcessor { - fn process_packet( + async fn process_incoming_packet( &mut self, _sender_node_id: &str, - node_id: &str, - packet: &HashMap, - session: Arc, - base_output_key_exp: &str, - namespace: &str, - namespace_lookup: &HashMap, - ) -> Result<()> { - // We need a unique hash for this given input packet process by the node - // therefore we need to generate a hash that has the pod_id + input_packet - let pod_clone = Arc::clone(&self.pod); - let client_clone = Arc::clone(&self.client); - let node_id_owned = node_id.to_owned(); - let packet_owned = packet.clone(); - let base_output_key_exp_owned = base_output_key_exp.to_owned(); - let namespace_owned = namespace.to_owned(); - let namespace_lookup_owned = namespace_lookup.clone(); + incoming_packet: &HashMap, + ) { + // Clone all necessary fields from self to move into the async block + let pipeline_run = Arc::clone(&self.pipeline_run); + let node_id = self.node_id.clone(); + let pod = Arc::clone(&self.pod); + + let incoming_packet_inner = incoming_packet.clone(); self.processing_tasks.spawn(async move { - let results = Self::start_pod_job_task( - node_id_owned.clone(), - pod_clone, - packet_owned, - client_clone, - Arc::clone(&session), - base_output_key_exp_owned.clone(), - namespace_owned.clone(), - &namespace_lookup_owned, + let result = match Self::process_packet( + Arc::clone(&pipeline_run), + node_id.clone(), + Arc::clone(&pod), + incoming_packet_inner.clone(), ) - .await; + .await + { + Ok(output_packet) => { + match pipeline_run + .send_packets(&node_id, &vec![output_packet]) + .await + { + Ok(()) => Ok(()), + Err(err) => Err(err), + } + } + Err(err) => Err(err), + }; - match results { - Ok(()) => {} + match result { + Ok(()) => { + // Successfully processed the packet, nothing to do + } Err(err) => { - try_to_forward_err_msg( - session, - err, - &base_output_key_exp_owned, - &node_id_owned, - ) - .await; + pipeline_run.send_err(&node_id, err).await; } } }); - Ok(()) } - async fn mark_parent_as_complete(&mut self, _parent_node_id: &str) -> bool { + async fn mark_parent_as_complete(&mut self, _parent_node_id: &str) { // For pod we only have one parent, thus execute the exit case while self.processing_tasks.join_next().await.is_some() {} - true - } - - fn stop(&mut self) { - self.processing_tasks.abort_all(); - } -} - -struct OperatorProcessor { - operator: Arc, - processing_tasks: JoinSet<()>, -} - -/// Processor for Mapper nodes -/// This processor renames the `input_keys` from the input packet to the `output_keys` defined by the map -struct MapOperatorProcessor { - mapper: Arc, - processing_tasks: JoinSet<()>, -} - -impl MapOperatorProcessor { - fn new(mapper: Arc) -> Self { - Self { - mapper, - processing_tasks: JoinSet::new(), - } - } -} - -#[async_trait] -impl NodeProcessor for MapOperatorProcessor { - fn process_packet( - &mut self, - _sender_node_id: &str, - node_id: &str, - packet: &HashMap, - session: Arc, - base_output_key_exp: &str, - _namespace: &str, - _namespace_lookup: &HashMap, - ) -> Result<()> { - let mapping = self.mapper; - let packet_clone = packet.clone(); - let node_id_clone = node_id.to_owned(); - let output_key_exp_clone = base_output_key_exp.to_owned(); - let mapper = Arc::clone(&self.mapper); - self.processing_tasks.spawn(async move { - let result = async { - // Apply the mapping to the input packet - let output_map = mapper.process_packets(vec![packet_clone]).await?; - // Send the packet outwards - session - .put( - format!("{output_key_exp_clone}/{SUCCESS_KEY_EXP}"), - &serde_json::to_string(&NodeOutput::Packet( - node_id_clone.clone(), - output_map, - ))?, - ) - .await - .context(selector::AgentCommunicationFailure {})?; - Ok::<(), OrcaError>(()) - } - .await; - - if let Err(err) = result { - try_to_forward_err_msg(session, err, &output_key_exp_clone, &node_id_clone).await; + // Send out completion signal + match self + .pipeline_run + .send_packets(&self.node_id, &Vec::new()) + .await + { + Ok(()) => {} + Err(err) => { + self.pipeline_run.send_err(&self.node_id, err).await; } - }); - Ok(()) - } - - async fn mark_parent_as_complete(&mut self, _parent_node_id: &str) -> bool { - // For mapper we only have one parent, thus execute the exit case - while (self.processing_tasks.join_next().await).is_some() { - // The only error that should be forwarded here is the failure to send the output packet } - true } fn stop(&mut self) { @@ -998,143 +897,98 @@ impl NodeProcessor for MapOperatorProcessor { } } -/// Processor for Joiner nodes -/// This processor combines packets from multiple parent nodes into a single output packet -/// It uses a cartesian product to combine packets from different parents -#[derive(Debug)] -struct JoinerProcessor { - /// Cache for all packets received by the node - input_packet_cache: HashMap>>, - completed_parents: Vec, +struct OperatorProcessor { + pipeline_run: Arc, + node_id: String, + operator: Arc, + num_of_parents: usize, + num_of_completed_parents: usize, processing_tasks: JoinSet<()>, } -impl JoinerProcessor { - fn new(parents_node_id: Vec) -> Self { - let input_packet_cache = parents_node_id - .into_iter() - .map(|id| (id, Vec::new())) - .collect(); +impl OperatorProcessor { + /// Create a new operator processor + pub fn new( + pipeline_run: Arc, + node_id: String, + operator: Arc, + num_of_parents: usize, + ) -> Self { Self { - input_packet_cache, - completed_parents: Vec::new(), + pipeline_run, + node_id, + operator, + num_of_parents, + num_of_completed_parents: 0, processing_tasks: JoinSet::new(), } } - - fn compute_cartesian_product( - factors: &[Vec>], - ) -> Vec> { - factors - .iter() - .multi_cartesian_product() - .map(|packets_to_combined| { - packets_to_combined - .into_iter() - .fold(HashMap::new(), |mut acc, packet| { - acc.extend(packet.clone()); - acc - }) - }) - .collect::>() - } } +#[expect(clippy::excessive_nesting, reason = "Nesting is manageable")] #[async_trait] -impl NodeProcessor for JoinerProcessor { - fn process_packet( +impl NodeProcessor for OperatorProcessor { + async fn process_incoming_packet( &mut self, sender_node_id: &str, - node_id: &str, - packet: &HashMap, - session: Arc, - base_output_key_exp: &str, - _namespace: &str, - _namespace_lookup: &HashMap, - ) -> Result<()> { - self.input_packet_cache - .get_mut(sender_node_id) - .context(selector::KeyMissing { - key: sender_node_id.to_owned(), - })? - .push(packet.clone()); - - // Check if we have all the other parents needed to compute the cartesian product - if self.input_packet_cache.values().all(|v| !v.is_empty()) { - // Get all the cached packets from other parents - let other_parent_ids = self - .input_packet_cache - .keys() - .filter(|key| *key != sender_node_id); - - // Build the factors of the product as owned values to avoid lifetime issues - let mut factors = other_parent_ids - .map(|id| get(&self.input_packet_cache, id).cloned()) - .collect::>>()?; - - // Add the new packet as a factor - factors.push(vec![packet.clone()]); - - // Compute the cartesian product of the factors - let node_id_clone = node_id.to_owned(); - let output_key_exp_clone = base_output_key_exp.to_owned(); - - self.processing_tasks.spawn(async move { - // Convert Vec>> to Vec<&Vec>> for compute_cartesian_product - let cartesian_product = Self::compute_cartesian_product(&factors); - // Post all products to the output channel - let session_clone = Arc::clone(&session); - for output_packet in cartesian_product { - let result = async { - session_clone - .put( - format!("{output_key_exp_clone}/{SUCCESS_KEY_EXP}"), - serde_json::to_string(&NodeOutput::Packet( - node_id_clone.clone(), - output_packet, - ))?, - ) - .await - .context(selector::AgentCommunicationFailure {})?; - Ok::<(), OrcaError>(()) - } - .await; + incoming_packet: &HashMap, + ) { + // Clone all necessary fields from self to move into the async block + let operator = Arc::clone(&self.operator); + let pipeline_run = Arc::clone(&self.pipeline_run); + let node_id = self.node_id.clone(); - // If the result is an error, we will just send it to the error channel - if let Err(err) = result { - try_to_forward_err_msg( - Arc::clone(&session_clone), - err, - &output_key_exp_clone, - &node_id_clone, - ) - .await; + let sender_node_id_inner = sender_node_id.to_owned(); + let incoming_packet_inner = incoming_packet.clone(); + + self.processing_tasks.spawn(async move { + let processing_result = operator + .process_packets(vec![(sender_node_id_inner, incoming_packet_inner)]) + .await; + + match processing_result { + Ok(output_packets) => { + if !output_packets.is_empty() { + // Send out all the packets + match pipeline_run.send_packets(&node_id, &output_packets).await { + Ok(()) => {} + Err(err) => { + pipeline_run.send_err(&node_id, err).await; + } + } } } - }); - } - Ok(()) + Err(err) => { + pipeline_run.send_err(&node_id, err).await; + } + } + }); } - async fn mark_parent_as_complete(&mut self, _parent_node_id: &str) -> bool { - // For Joiner, we need to determine if all parents are complete, if so then wait for task to complete - // before returning true - self.completed_parents.push(_parent_node_id.to_owned()); + async fn mark_parent_as_complete(&mut self, _parent_node_id: &str) { + // Figure out if this is the last parent or not + self.num_of_completed_parents += 1; - // If we have all parents completed, we can wait for the tasks to complete - if self.completed_parents.len() == self.input_packet_cache.len() { + if self.num_of_completed_parents == self.num_of_parents { + // All parents are complete, thus we need to wait on all processing tasks then exit while (self.processing_tasks.join_next().await).is_some() { // Wait for all tasks to complete } - return true; + // Send out completion signal which is same as success but it is an empty vec of packets + match self + .pipeline_run + .send_packets(&self.node_id, &Vec::new()) + .await + { + Ok(()) => {} + Err(err) => { + self.pipeline_run.send_err(&self.node_id, err).await; + } + } } - - // If not all parents are completed, we return false - false } fn stop(&mut self) { - // We want to abort any computation - self.processing_tasks.abort_all(); + todo!() } } diff --git a/src/core/util.rs b/src/core/util.rs index 7a2a2da2..4f0f72fd 100644 --- a/src/core/util.rs +++ b/src/core/util.rs @@ -41,7 +41,7 @@ where })?) } -pub fn create_key_expr( +pub fn make_key_expr( group: &str, host: &str, topic: &str, @@ -50,9 +50,9 @@ pub fn create_key_expr( // For each key-value pair in the content, we format it as "key/value" and join them with "/". // The final format will be "group/host/topic/key1/value1/key2/value let content_converted = content - .into_iter() - .map(|(k, v)| format!("{}/{}", k, v)) + .iter() + .map(|(k, v)| format!("{k}/{v}")) .collect::>() .join("/"); - format!("{}/{}/{}/{}", group, host, topic, content_converted) + format!("{group}/{host}/{topic}/{content_converted}") } diff --git a/src/uniffi/error.rs b/src/uniffi/error.rs index 3e2f09d1..5cd9f355 100644 --- a/src/uniffi/error.rs +++ b/src/uniffi/error.rs @@ -19,7 +19,6 @@ use std::{ use tokio::task; use uniffi; -use crate::uniffi::orchestrator::PodStatus; /// Shorthand for a Result that returns an [`OrcaError`]. pub type Result = result::Result; /// Possible errors you may encounter. diff --git a/src/uniffi/mod.rs b/src/uniffi/mod.rs index a4315b48..e02fd6c9 100644 --- a/src/uniffi/mod.rs +++ b/src/uniffi/mod.rs @@ -6,5 +6,3 @@ pub mod model; pub mod orchestrator; /// Data persistence provided by a store backend. pub mod store; - -pub mod pipeline_runner; diff --git a/src/uniffi/pipeline_runner.rs b/src/uniffi/pipeline_runner.rs deleted file mode 100644 index 8b137891..00000000 --- a/src/uniffi/pipeline_runner.rs +++ /dev/null @@ -1 +0,0 @@ - diff --git a/tests/operator.rs b/tests/operator.rs index 4079b311..c80697df 100644 --- a/tests/operator.rs +++ b/tests/operator.rs @@ -183,7 +183,9 @@ async fn join_spotty() -> Result<()> { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn map_once() -> Result<()> { - let operator = MapOperator::new(HashMap::from([("key_old".into(), "key_new".into())])); + let operator = MapOperator { + map: HashMap::from([("key_old".into(), "key_new".into())]), + }; assert_contains_packet( &operator .process_packets(vec![( diff --git a/tests/pipeline.rs b/tests/pipeline.rs index 78705347..87b40c08 100644 --- a/tests/pipeline.rs +++ b/tests/pipeline.rs @@ -29,7 +29,7 @@ fn input_packet_checksum() -> Result<()> { HashMap::from([( "A".into(), Kernel::Pod { - r#ref: pod_custom( + pod: pod_custom( "alpine:3.14", &["echo".into()], HashMap::from([( From 1e9a19f0834b0b3a511979d5a9cd6c6dfd83d7c4 Mon Sep 17 00:00:00 2001 From: Synicix Date: Mon, 11 Aug 2025 22:48:34 +0000 Subject: [PATCH 18/65] Readded tests --- src/core/mod.rs | 4 +- src/core/pipeline_runner.rs | 109 +++++++--------- src/uniffi/model/pipeline.rs | 27 +++- src/uniffi/orchestrator/agent.rs | 17 ++- tests/fixture/mod.rs | 212 +++++++++++++++++++++++++++++-- tests/pipeline.rs | 6 +- tests/pipeline_runner.rs | 122 ++++++++++++++++++ 7 files changed, 414 insertions(+), 83 deletions(-) create mode 100644 tests/pipeline_runner.rs diff --git a/src/core/mod.rs b/src/core/mod.rs index c74b7595..2f5d1b33 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -9,7 +9,7 @@ macro_rules! inner_attr_to_each { pub(crate) mod error; pub(crate) mod graph; -pub(crate) mod pipeline_runner; + pub(crate) mod store; pub(crate) mod util; pub(crate) mod validation; @@ -19,6 +19,7 @@ inner_attr_to_each! { pub(crate) mod crypto; pub(crate) mod model; pub(crate) mod orchestrator; + pub(crate) mod pipeline_runner; pub(crate) mod operator; } @@ -36,5 +37,6 @@ inner_attr_to_each! { pub mod crypto; pub mod model; pub mod orchestrator; + pub mod pipeline_runner; pub mod operator; } diff --git a/src/core/pipeline_runner.rs b/src/core/pipeline_runner.rs index 1ef79aa0..90fc8847 100644 --- a/src/core/pipeline_runner.rs +++ b/src/core/pipeline_runner.rs @@ -15,7 +15,6 @@ use crate::{ orchestrator::{ PodStatus, agent::{Agent, AgentClient, Response}, - docker::LocalDockerOrchestrator, }, }, }; @@ -37,9 +36,8 @@ use tokio::{ }; use zenoh::{handlers::FifoChannelHandler, pubsub::Subscriber, sample::Sample}; -static SUCCESS_KEY_EXP: &str = "success"; +static NODE_OUTPUT_KEY_EXPR: &str = "output"; static FAILURE_KEY_EXP: &str = "failure"; -static INPUT_KEY_EXP: &str = "input_node/outputs"; #[derive(Serialize, Deserialize, Clone, Debug)] enum NodeOutput { @@ -58,16 +56,12 @@ struct ProcessingFailure { #[derive(Debug)] struct PipelineRun { /// `PipelineJob` that this run is associated with - group: String, - host: String, assigned_name: String, session: Arc, // Zenoh session for communication agent_client: Arc, // Zenoh agent client for communication with docker orchestrators pipeline_job: Arc, // The pipeline job that this run is associated with node_tasks: Arc>>>, // JoinSet of tasks for each node in the pipeline outputs: Arc>>>, // String is the node key, while hash - orchestrator_agent: Arc, // This is placed in pipeline due to the current design requiring a namespace to operate on - orchestrator_agent_task: JoinSet>, // JoinSet of tasks for the orchestrator agent failure_logs: Arc>>, // Logs of processing failures failure_logging_task: Arc>>>, // JoinSet of tasks for logging failures namespace: String, @@ -77,8 +71,8 @@ struct PipelineRun { impl PipelineRun { fn make_key_expr(&self, node_id: &str, event: &str) -> String { make_key_expr( - &self.group, - &self.host, + &self.agent_client.group, + &self.agent_client.host, "pipeline_run", &BTreeMap::from([ ("event".to_owned(), event.to_owned()), @@ -90,8 +84,8 @@ impl PipelineRun { fn make_abort_request_key_exp(&self) -> String { make_key_expr( - &self.group, - &self.host, + &self.agent_client.group, + &self.agent_client.host, "pipeline_run", &BTreeMap::from([ ("event".to_owned(), "abort".to_owned()), @@ -105,7 +99,7 @@ impl PipelineRun { Ok(self .session .put( - self.make_key_expr(node_id, "output"), + self.make_key_expr(node_id, NODE_OUTPUT_KEY_EXPR), serde_json::to_string(&output_packets)?, ) .await @@ -152,11 +146,8 @@ impl Display for PipelineRun { } /// Runner that uses a docker agent to run pipelines +#[derive(Debug, Clone)] pub struct DockerPipelineRunner { - /// User label on which group of agents this runner is associated with - pub group: String, - /// The host name of the runner - pub host: String, agent: Arc, pipeline_runs: HashMap>, } @@ -172,10 +163,8 @@ impl DockerPipelineRunner { /// Create a new Docker pipeline runner /// # Errors /// Will error out if the environment variable `HOSTNAME` is not set - pub fn new(group: String, host: String, agent: Arc) -> Self { + pub fn new(agent: Arc) -> Self { Self { - group, - host, agent, pipeline_runs: HashMap::new(), } @@ -195,24 +184,13 @@ impl DockerPipelineRunner { namespace: &str, // Name space to save pod_results to namespace_lookup: &HashMap, ) -> Result { - // Create the orchestrator - let orchestrator_agent = Agent::new( - self.group.clone(), - self.host.clone(), - LocalDockerOrchestrator::new()?.into(), - )?; - // Create a new pipeline run let pipeline_run = Arc::new(PipelineRun { pipeline_job: pipeline_job.into(), outputs: Arc::new(RwLock::new(HashMap::new())), node_tasks: Arc::new(Mutex::new(JoinSet::new())), - orchestrator_agent: orchestrator_agent.into(), - orchestrator_agent_task: JoinSet::new(), failure_logs: Arc::new(RwLock::new(Vec::new())), failure_logging_task: Arc::new(Mutex::new(JoinSet::new())), - group: self.group.clone(), - host: self.host.clone(), assigned_name: Generator::with_naming(Name::Plain).next().context( selector::MissingInfo { details: "unable to generate a random name", @@ -287,6 +265,7 @@ impl DockerPipelineRunner { } // Wait for all nodes to be ready before sending inputs + let num_of_nodes = graph.node_count(); let mut ready_nodes = 0; @@ -305,7 +284,8 @@ impl DockerPipelineRunner { pipeline_run .session .put( - pipeline_run.make_key_expr(&node_id, INPUT_KEY_EXP), + pipeline_run + .make_key_expr(&format!("input_node_{node_id}"), NODE_OUTPUT_KEY_EXPR), serde_json::to_string(&input_packets)?, ) .await @@ -315,7 +295,7 @@ impl DockerPipelineRunner { pipeline_run .session .put( - pipeline_run.make_key_expr(&node_id, INPUT_KEY_EXP), + pipeline_run.make_key_expr(&node_id, NODE_OUTPUT_KEY_EXPR), serde_json::to_string(&Vec::::new())?, ) .await @@ -407,29 +387,26 @@ impl DockerPipelineRunner { // Create a zenoh session let subscriber = pipeline_run .session - .declare_subscriber(pipeline_run.make_key_expr(&node_id, SUCCESS_KEY_EXP)) + .declare_subscriber(pipeline_run.make_key_expr(&node_id, NODE_OUTPUT_KEY_EXPR)) .await .context(selector::AgentCommunicationFailure {})?; while let Ok(payload) = subscriber.recv_async().await { // Extract the message from the payload - let msg: NodeOutput = serde_json::from_slice(&payload.payload().to_bytes())?; - - match msg { - NodeOutput::Packet(_, packet) => { - // Figure out which keys - // Store the output packet in the outputs map - let mut outputs_lock = pipeline_run.outputs.write().await; - for (output_key, node_key) in &key_mapping { - outputs_lock - .entry(output_key.to_owned()) - .or_default() - .push(get(&packet, node_key.as_str())?.clone()); - } - } - NodeOutput::ProcessingCompleted(_) => { - // Processing is completed, thus we can exit this task - break; + let packets: Vec = serde_json::from_slice(&payload.payload().to_bytes())?; + + if packets.is_empty() { + // Output node exited, thus we can exit the capture task too + break; + } + let mut outputs_lock = pipeline_run.outputs.write().await; + + for packet in packets { + for (output_key, node_key) in &key_mapping { + outputs_lock + .entry(output_key.to_owned()) + .or_default() + .push(get(&packet, node_key)?.clone()); } } } @@ -518,13 +495,21 @@ impl DockerPipelineRunner { .into_iter() .map(|parent_node| { // Make the parent key to listen to - pipeline_run.make_key_expr(&parent_node.id, SUCCESS_KEY_EXP) + pipeline_run.make_key_expr(&parent_node.id, NODE_OUTPUT_KEY_EXPR) }) .collect::>(); + println!( + "Spawning node processing task for node: {}, with {:?} parents", + node.id, key_exps_to_subscribe_to + ); + // Check if node is an input_node, if so we need to add the input node key expression if is_input_node { - key_exps_to_subscribe_to.push(pipeline_run.make_key_expr("input", SUCCESS_KEY_EXP)); + key_exps_to_subscribe_to.push( + pipeline_run + .make_key_expr(&format!("input_node_{}", node.id), NODE_OUTPUT_KEY_EXPR), + ); } // Create a subscriber for each of the parent nodes (Should only be 1, unless it is a joiner node) @@ -601,6 +586,7 @@ impl DockerPipelineRunner { .context(selector::AgentCommunicationFailure {})?; while let Ok(sample) = subscriber.recv_async().await { + println!("Received sample for node: {node_id}"); // Extract out the packets let packets: Vec = serde_json::from_slice(&sample.payload().to_bytes())?; @@ -750,14 +736,17 @@ impl PodProcessor { // Create the subscriber let pod_job_subscriber = pipeline_run .session - .declare_subscriber(make_key_expr( - &pipeline_run.group, - "*", - "pod_job", - &BTreeMap::from([ - ("id".to_owned(), pod_job.hash.clone()), - ("action".to_owned(), "success".to_owned()), - ]), + .declare_subscriber(format!( + "{}/*", + make_key_expr( + &pipeline_run.agent_client.group, + "*", + "pod_job", + &BTreeMap::from([ + ("id".to_owned(), pod_job.hash.clone()), + ("action".to_owned(), "success".to_owned()), + ]), + ) )) .await .context(selector::AgentCommunicationFailure {})?; diff --git a/src/uniffi/model/pipeline.rs b/src/uniffi/model/pipeline.rs index b008acef..6d2bd13b 100644 --- a/src/uniffi/model/pipeline.rs +++ b/src/uniffi/model/pipeline.rs @@ -47,14 +47,14 @@ impl Pipeline { pub fn new( graph_dot: &str, metadata: HashMap, - input_spec: &HashMap>, - output_spec: &HashMap, + input_spec: HashMap>, + output_spec: HashMap, ) -> Result { let graph = make_graph(graph_dot, metadata)?; Ok(Self { graph, - input_spec: input_spec.clone(), - output_spec: output_spec.clone(), + input_spec, + output_spec, }) } } @@ -80,7 +80,6 @@ pub struct PipelineJob { pub output_dir: URI, } -#[expect(clippy::excessive_nesting, reason = "Nesting manageable.")] #[uniffi::export] impl PipelineJob { /// Construct a new pipeline job instance. @@ -92,7 +91,7 @@ impl PipelineJob { pub fn new( pipeline: Arc, input_packet: &HashMap>, - output_dir: &URI, + output_dir: URI, namespace_lookup: &HashMap, ) -> Result { validate_packet("input".into(), &pipeline.input_spec, input_packet)?; @@ -125,7 +124,7 @@ impl PipelineJob { hash: make_random_hash(), pipeline, input_packet: input_packet_with_checksum, - output_dir: output_dir.clone(), + output_dir, }) } } @@ -155,6 +154,20 @@ pub enum Kernel { }, } +impl From for Kernel { + fn from(mapper: MapOperator) -> Self { + Self::MapOperator { + mapper: Arc::new(mapper), + } + } +} + +impl From for Kernel { + fn from(pod: Pod) -> Self { + Self::Pod { pod: Arc::new(pod) } + } +} + /// Index from pipeline node into pod specification. #[derive(uniffi::Record, Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] pub struct NodeURI { diff --git a/src/uniffi/orchestrator/agent.rs b/src/uniffi/orchestrator/agent.rs index e319cbbd..47a7cf21 100644 --- a/src/uniffi/orchestrator/agent.rs +++ b/src/uniffi/orchestrator/agent.rs @@ -156,15 +156,15 @@ impl Agent { /// # Errors /// /// Will stop and return an error if encounters an error while processing any pod job request. - #[expect(clippy::excessive_nesting, reason = "Nesting manageable.")] pub async fn start( &self, namespace_lookup: &HashMap, available_store: Option>, ) -> Result<()> { let mut services = JoinSet::new(); + let self_ref = Arc::new(self.clone()); services.spawn(start_service( - Arc::new(self.clone()), + Arc::clone(&self_ref), "pod_job", BTreeMap::from([("action", "request".to_owned())]), namespace_lookup.clone(), @@ -204,7 +204,7 @@ impl Agent { )); if let Some(store) = available_store { services.spawn(start_service( - Arc::new(self.clone()), + Arc::clone(&self_ref), "pod_job", BTreeMap::from([("action", "success".to_owned())]), namespace_lookup.clone(), @@ -218,7 +218,7 @@ impl Agent { async |_, ()| Ok(()), )); services.spawn(start_service( - Arc::new(self.clone()), + Arc::clone(&self_ref), "pod_job", BTreeMap::from([("action", "failure".to_owned())]), namespace_lookup.clone(), @@ -229,6 +229,15 @@ impl Agent { async |_, ()| Ok(()), )); } + // // Spawn PipelineRunner service + // services.spawn(start_service( + // self_ref, + // "pipeline_job", + // BTreeMap::from([("action", "request".to_owned())]), + // namespace_lookup.clone(), + // async move |agent, inner_namespace_lookup, _, pipeline_job| Ok(()), + // async |_, ()| Ok(()), + // )); services.join_next().await.context(selector::MissingInfo { details: "no available services".to_owned(), })?? diff --git a/tests/fixture/mod.rs b/tests/fixture/mod.rs index ed6ff2fa..469c190b 100644 --- a/tests/fixture/mod.rs +++ b/tests/fixture/mod.rs @@ -8,15 +8,19 @@ )] use names::{Generator, Name}; -use orcapod::uniffi::{ - error::Result, - model::{ - Annotation, - packet::{Blob, BlobKind, Packet, PathInfo, PathSet, URI}, - pod::{Pod, PodJob, PodResult}, +use orcapod::{ + core::operator::MapOperator, + uniffi::{ + error::Result, + model::{ + Annotation, + packet::{Blob, BlobKind, Packet, PathInfo, PathSet, URI}, + pipeline::{Kernel, NodeURI, Pipeline, PipelineJob}, + pod::{Pod, PodJob, PodResult}, + }, + orchestrator::PodStatus, + store::{ModelID, ModelInfo, Store}, }, - orchestrator::PodStatus, - store::{ModelID, ModelInfo, Store}, }; use std::{ collections::HashMap, @@ -325,6 +329,198 @@ pub fn combine_txt_pod(pod_name: &str) -> Result { ) } +pub fn pipeline() -> Result { + // Create a simple pipeline where the functions job is to add append their name into the input file + // Structure: A -> Mapper -> Joiner -> B -> Mapper -> C, D -> Mapper -> Joiner + + // Create the kernel map + let mut kernel_map = HashMap::new(); + + // Insert the pod into the kernel map + for pod_name in ["A", "B", "C", "D"] { + kernel_map.insert(pod_name.into(), combine_txt_pod(pod_name)?.into()); + } + + let output_to_input_1 = Arc::new(MapOperator { + map: HashMap::from([("output".to_owned(), "input_1".to_owned())]), + }); + + // Create a mapper for A, B, and C + kernel_map.insert( + "pod_a_mapper".into(), + Kernel::MapOperator { + mapper: Arc::clone(&output_to_input_1), + }, + ); + kernel_map.insert( + "pod_b_mapper".into(), + MapOperator { + map: HashMap::from([("output".to_owned(), "input_2".to_owned())]), + } + .into(), + ); + kernel_map.insert( + "pod_c_mapper".into(), + Kernel::MapOperator { + mapper: Arc::clone(&output_to_input_1), + }, + ); + + // Add the joiner node + kernel_map.insert("pod_c_joiner".into(), Kernel::JoinOperator); + + // Add joiner node for D + kernel_map.insert("pod_d_joiner".into(), Kernel::JoinOperator); + + // Write all the edges in DOT format + let dot = " + digraph { + A -> pod_a_mapper -> pod_c_joiner; + B -> pod_b_mapper -> pod_c_joiner; + pod_c_joiner -> C -> pod_d_joiner -> D; + } + "; + + Pipeline::new( + dot, + kernel_map, + HashMap::from([ + ( + "where".into(), + vec![NodeURI { + node_id: "A".into(), + key: "input_1".into(), + }], + ), + ( + "is_the".into(), + vec![NodeURI { + node_id: "A".into(), + key: "input_2".into(), + }], + ), + ( + "cat_color".into(), + vec![NodeURI { + node_id: "B".into(), + key: "input_1".into(), + }], + ), + ( + "cat".into(), + vec![NodeURI { + node_id: "B".into(), + key: "input_2".into(), + }], + ), + ( + "action".into(), + vec![NodeURI { + node_id: "pod_d_joiner".into(), + key: "input_2".into(), + }], + ), + ]), + HashMap::from([( + "output".to_owned(), + NodeURI { + node_id: "D".into(), + key: "output".into(), + }, + )]), + ) +} + +#[expect(clippy::implicit_hasher, reason = "Could be a false positive?")] +pub fn pipeline_job(namespace_lookup: &HashMap) -> Result { + // Create a simple pipeline_job + PipelineJob::new( + pipeline()?.into(), + &HashMap::from([ + ( + "where".into(), + vec![PathSet::Unary(Blob { + kind: BlobKind::File, + location: URI { + namespace: "default".into(), + path: "input_txt/Where.txt".into(), + }, + checksum: String::new(), + })], + ), + ( + "is_the".into(), + vec![PathSet::Unary(Blob { + kind: BlobKind::File, + location: URI { + namespace: "default".into(), + path: "input_txt/is_the.txt".into(), + }, + checksum: String::new(), + })], + ), + ( + "cat_color".into(), + vec![ + PathSet::Unary(Blob { + kind: BlobKind::File, + location: URI { + namespace: "default".into(), + path: "input_txt/black.txt".into(), + }, + checksum: String::new(), + }), + PathSet::Unary(Blob { + kind: BlobKind::File, + location: URI { + namespace: "default".into(), + path: "input_txt/tabby.txt".into(), + }, + checksum: String::new(), + }), + ], + ), + ( + "cat".into(), + vec![PathSet::Unary(Blob { + kind: BlobKind::File, + location: URI { + namespace: "default".into(), + path: "input_txt/cat.txt".into(), + }, + checksum: String::new(), + })], + ), + ( + "action".into(), + vec![ + PathSet::Unary(Blob { + kind: BlobKind::File, + location: URI { + namespace: "default".into(), + path: "input_txt/hiding.txt".into(), + }, + checksum: String::new(), + }), + PathSet::Unary(Blob { + kind: BlobKind::File, + location: URI { + namespace: "default".into(), + path: "input_txt/playing.txt".into(), + }, + checksum: String::new(), + }), + ], + ), + ]), + URI { + namespace: "default".to_owned(), + path: PathBuf::from("pipeline_output"), + }, + namespace_lookup, + ) +} + // --- util --- pub fn str_to_vec(v: &str) -> Vec { diff --git a/tests/pipeline.rs b/tests/pipeline.rs index 87b40c08..56e10870 100644 --- a/tests/pipeline.rs +++ b/tests/pipeline.rs @@ -43,14 +43,14 @@ fn input_packet_checksum() -> Result<()> { .into(), }, )]), - &HashMap::from([( + HashMap::from([( "pipeline_key_1".into(), vec![NodeURI { node_id: "A".into(), key: "node_key_1".into(), }], )]), - &HashMap::new(), + HashMap::new(), )?; let pipeline_job = PipelineJob::new( @@ -66,7 +66,7 @@ fn input_packet_checksum() -> Result<()> { checksum: String::new(), }])], )]), - &URI { + URI { namespace: "default".into(), path: "output/pipeline".into(), }, diff --git a/tests/pipeline_runner.rs b/tests/pipeline_runner.rs new file mode 100644 index 00000000..923ad843 --- /dev/null +++ b/tests/pipeline_runner.rs @@ -0,0 +1,122 @@ +#![expect( + missing_docs, + clippy::panic_in_result_fn, + clippy::expect_used, + clippy::unwrap_used, + reason = "OK in tests." +)] + +// If 'fixture' is a local module, ensure there is a 'mod fixture;' statement or a 'fixture.rs' file in the same directory or in 'tests/'. +// If 'fixture' is an external crate, add it to Cargo.toml and import as shown below. +// use fixture::pipeline_job; +pub mod fixture; + +// Example for a local module: +use std::{collections::HashMap, sync::Arc}; + +use orcapod::{ + core::pipeline_runner::DockerPipelineRunner, + uniffi::{ + error::Result, + orchestrator::{agent::Agent, docker::LocalDockerOrchestrator}, + }, +}; + +use crate::fixture::TestDirs; +use fixture::pipeline_job; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn basic_run() -> Result<()> { + // Create the test_dir and get the namespace lookup + let test_dirs = TestDirs::new(&HashMap::from([( + "default".to_owned(), + Some("./tests/extra/data/"), + )]))?; + let namespace_lookup = test_dirs.namespace_lookup(); + + // create a zenoh session to print out all communication message + let session = zenoh::open(zenoh::Config::default()) + .await + .expect("Failed to open zenoh session"); + + tokio::spawn(async move { + // Subscribe to all messages in the 'test' group + let sub = session + .declare_subscriber("**") + .await + .expect("Failed to declare subscriber"); + + while let Ok(sample) = sub.recv_async().await { + // Print the key expression and payload of each message + println!("Received message: {}:", sample.key_expr().as_str(),); + } + }); + + // Create and agent and start it (temporary for now, will be merge later) + let agent = Arc::new(Agent::new( + "test/basic_run".to_owned(), + "localhost".to_owned(), + Arc::new(LocalDockerOrchestrator::new().unwrap()), + )?); + + let agent_inner = Arc::clone(&agent); + let namespace_lookup_inner = namespace_lookup.clone(); + tokio::spawn(async move { + agent_inner + .start(&namespace_lookup_inner, None) + .await + .unwrap(); + }); + + let pipeline_job = pipeline_job(&namespace_lookup)?; + + // Create the runner + let mut runner = DockerPipelineRunner::new(agent); + + let pipeline_run = runner + .start(pipeline_job, "default", &namespace_lookup) + .await?; + + // Wait for the pipeline run to complete + let pipeline_result = runner.get_result(&pipeline_run).await?; + + println!( + "Pipeline run completed: {:?}", + pipeline_result.output_packets + ); + + assert!( + pipeline_result.output_packets.len() == 1, + "Expected exactly one output packet." + ); + + Ok(()) +} + +// #[tokio::test(flavor = "multi_thread", worker_threads = 2)] +// async fn stop() -> Result<()> { +// // Create the test_dir and get the namespace lookup +// let test_dirs = TestDirs::new(&HashMap::from([( +// "default".to_owned(), +// Some( +// "./tests/extra +// /data/", +// ), +// )]))?; + +// let namespace_lookup = test_dirs.namespace_lookup(); + +// let pipeline_job = pipeline_job(&namespace_lookup)?; + +// // Create the runner +// let mut runner = DockerPipelineRunner::new("test".to_owned())?; + +// let pipeline_run = runner +// .start(pipeline_job, "default", &namespace_lookup) +// .await?; + +// // Abort the pipeline run +// runner.stop(&pipeline_run).await?; + +// Ok(()) +// } From 32299f8b4603b2c392b21ffce3d36e1730fb7173 Mon Sep 17 00:00:00 2001 From: Synicix Date: Tue, 12 Aug 2025 01:32:43 +0000 Subject: [PATCH 19/65] save progress --- src/core/orchestrator/agent.rs | 1 + src/core/pipeline_runner.rs | 38 +++++++++++++++++++++++--------- src/uniffi/orchestrator/agent.rs | 6 ++--- tests/pipeline_runner.rs | 2 +- 4 files changed, 32 insertions(+), 15 deletions(-) diff --git a/src/core/orchestrator/agent.rs b/src/core/orchestrator/agent.rs index 9cbc7904..9ec47a2b 100644 --- a/src/core/orchestrator/agent.rs +++ b/src/core/orchestrator/agent.rs @@ -157,6 +157,7 @@ where let input = serde_json::from_slice::(&sample.payload().to_bytes())?; let inner_response_tx = response_tx.clone(); let mut event_metadata = extract_metadata(sample.key_expr().as_str()); + println!("asdhfjkashdfkj{:?}", event_metadata); let timestamp = event_metadata .remove("timestamp") diff --git a/src/core/pipeline_runner.rs b/src/core/pipeline_runner.rs index 90fc8847..623da6d4 100644 --- a/src/core/pipeline_runner.rs +++ b/src/core/pipeline_runner.rs @@ -562,6 +562,10 @@ impl DockerPipelineRunner { // Abort the stop listener task since we don't need it anymore abort_request_handler_task.abort(); + println!( + "Node {} processing completed, exiting capture task", + node.id + ); Ok(()) } @@ -736,17 +740,13 @@ impl PodProcessor { // Create the subscriber let pod_job_subscriber = pipeline_run .session - .declare_subscriber(format!( - "{}/*", - make_key_expr( - &pipeline_run.agent_client.group, - "*", - "pod_job", - &BTreeMap::from([ - ("id".to_owned(), pod_job.hash.clone()), - ("action".to_owned(), "success".to_owned()), - ]), - ) + .declare_subscriber(pipeline_run.agent_client.make_key_expr( + true, + "pod_job", + BTreeMap::from([ + ("hash", pod_job.hash.clone()), + ("event", "success".to_owned()), + ]), )) .await .context(selector::AgentCommunicationFailure {})?; @@ -754,15 +754,26 @@ impl PodProcessor { // Create the async task to listen for the pod job completion let pod_job_listener_task = tokio::spawn(async move { // Wait for the pod job to complete and extract the result + println!("Listening on {}", pod_job_subscriber.key_expr()); let sample = pod_job_subscriber .recv_async() .await .context(selector::AgentCommunicationFailure {})?; // Extract the pod_result from the payload + println!("Received pod job completion message: {:?}", sample); let pod_result: PodResult = serde_json::from_slice(&sample.payload().to_bytes())?; + println!( + "Pod job {} completed with status: {:?}", + pod_result.pod_job.hash, pod_result.status + ); Ok::<_, OrcaError>(pod_result) }); + println!( + "Submitting pod job {} for node {} with input packet hash: {}", + pod_job.hash, node_id, input_packet_hash + ); + // Submit it to the client and get the response to make sure it was successful let responses = pipeline_run .agent_client @@ -788,6 +799,11 @@ impl PodProcessor { // Get the pod result from the listener task let pod_result = pod_job_listener_task.await??; + println!( + "Pod job {} completed with status: {:?}", + pod_result.pod_job.hash, pod_result.status + ); + // Get the output packet for the pod result Ok(match pod_result.status { PodStatus::Completed => { diff --git a/src/uniffi/orchestrator/agent.rs b/src/uniffi/orchestrator/agent.rs index 47a7cf21..e49ad319 100644 --- a/src/uniffi/orchestrator/agent.rs +++ b/src/uniffi/orchestrator/agent.rs @@ -186,7 +186,7 @@ impl Agent { "pod_job", BTreeMap::from([ ( - "action", + "event", match &pod_result.status { PodStatus::Completed => "success", PodStatus::Running @@ -206,7 +206,7 @@ impl Agent { services.spawn(start_service( Arc::clone(&self_ref), "pod_job", - BTreeMap::from([("action", "success".to_owned())]), + BTreeMap::from([("event", "success".to_owned())]), namespace_lookup.clone(), { let inner_store = Arc::clone(&store); @@ -220,7 +220,7 @@ impl Agent { services.spawn(start_service( Arc::clone(&self_ref), "pod_job", - BTreeMap::from([("action", "failure".to_owned())]), + BTreeMap::from([("event", "failure".to_owned())]), namespace_lookup.clone(), async move |_, _, _, pod_result| { store.save_pod_result(&pod_result)?; diff --git a/tests/pipeline_runner.rs b/tests/pipeline_runner.rs index 923ad843..ddb2bc7d 100644 --- a/tests/pipeline_runner.rs +++ b/tests/pipeline_runner.rs @@ -54,7 +54,7 @@ async fn basic_run() -> Result<()> { // Create and agent and start it (temporary for now, will be merge later) let agent = Arc::new(Agent::new( - "test/basic_run".to_owned(), + "test:basic_run".to_owned(), "localhost".to_owned(), Arc::new(LocalDockerOrchestrator::new().unwrap()), )?); From ab900b625d0ef073bf24d19a5088ddabca1b5678 Mon Sep 17 00:00:00 2001 From: Synicix Date: Wed, 13 Aug 2025 12:07:49 +0000 Subject: [PATCH 20/65] Fix logic bug --- Cargo.toml | 1 - src/core/mod.rs | 2 - src/core/operator.rs | 7 +- src/core/pipeline_runner.rs | 162 +++++++++++++------------- tests/extra/data/input_txt/is.txt | 1 + tests/extra/data/input_txt/is_the.txt | 1 - tests/extra/data/input_txt/the.txt | 1 + tests/fixture/mod.rs | 76 ++++++++---- tests/operator.rs | 10 +- 9 files changed, 146 insertions(+), 115 deletions(-) create mode 100644 tests/extra/data/input_txt/is.txt delete mode 100644 tests/extra/data/input_txt/is_the.txt create mode 100644 tests/extra/data/input_txt/the.txt diff --git a/Cargo.toml b/Cargo.toml index 77997a43..c9991c08 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -160,7 +160,6 @@ std_instead_of_alloc = { level = "allow", priority = 127 } # we shou std_instead_of_core = { level = "allow", priority = 127 } # we should use std when possible string_add = { level = "allow", priority = 127 } # simple concat ok string_lit_chars_any = { level = "allow", priority = 127 } # favor readability until a perf case comes up -wildcard_enum_match_arm = { level = "allow", priority = 127 } # allow wildcard match arm in enums use_debug = { level = "warn", priority = 127 } # debug print wildcard_enum_match_arm = { level = "allow", priority = 127 } # allow wildcard match arm in enums diff --git a/src/core/mod.rs b/src/core/mod.rs index c41290a3..d5f14d51 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -9,7 +9,6 @@ macro_rules! inner_attr_to_each { pub(crate) mod error; pub(crate) mod graph; -pub(crate) mod pipeline; pub(crate) mod store; pub(crate) mod util; pub(crate) mod validation; @@ -21,7 +20,6 @@ inner_attr_to_each! { pub(crate) mod operator; pub(crate) mod orchestrator; pub(crate) mod pipeline_runner; - pub(crate) mod operator; } #[cfg(feature = "test")] diff --git a/src/core/operator.rs b/src/core/operator.rs index 7421ec92..72bdf1fb 100644 --- a/src/core/operator.rs +++ b/src/core/operator.rs @@ -1,12 +1,13 @@ use crate::uniffi::{error::Result, model::packet::Packet}; use async_trait; use itertools::Itertools as _; +use serde::{Deserialize, Serialize}; use std::{clone::Clone, collections::HashMap, iter::IntoIterator, sync::Arc}; use tokio::sync::Mutex; #[async_trait::async_trait] pub trait Operator { - async fn next(&self, stream_name: String, packet: Packet) -> Result>; + async fn process_packet(&self, stream_name: String, packet: Packet) -> Result>; } pub struct JoinOperator { @@ -25,7 +26,7 @@ impl JoinOperator { #[async_trait::async_trait] impl Operator for JoinOperator { - async fn next(&self, stream_name: String, packet: Packet) -> Result> { + async fn process_packet(&self, stream_name: String, packet: Packet) -> Result> { let mut received_packets = self.received_packets.lock().await; received_packets .entry(stream_name.clone()) @@ -74,7 +75,7 @@ impl MapOperator { #[async_trait::async_trait] impl Operator for MapOperator { - async fn next(&self, _: String, packet: Packet) -> Result> { + async fn process_packet(&self, _: String, packet: Packet) -> Result> { Ok(vec![ packet .iter() diff --git a/src/core/pipeline_runner.rs b/src/core/pipeline_runner.rs index 623da6d4..6712160b 100644 --- a/src/core/pipeline_runner.rs +++ b/src/core/pipeline_runner.rs @@ -3,10 +3,14 @@ use crate::{ crypto::hash_buffer, model::{pipeline::PipelineNode, serialize_hashmap}, operator::{JoinOperator, Operator}, + orchestrator::agent::extract_metadata, util::{get, make_key_expr}, }, uniffi::{ - error::{Kind, OrcaError, Result, selector}, + error::{ + Kind, OrcaError, Result, + selector::{self}, + }, model::{ packet::{Packet, PathSet, URI}, pipeline::{Kernel, PipelineJob, PipelineResult}, @@ -100,13 +104,14 @@ impl PipelineRun { .session .put( self.make_key_expr(node_id, NODE_OUTPUT_KEY_EXPR), - serde_json::to_string(&output_packets)?, + serde_json::to_string(output_packets)?, ) .await .context(selector::AgentCommunicationFailure {})?) } async fn send_err(&self, node_id: &str, err: OrcaError) { + println!("{}", err); try_to_forward_err_msg( Arc::clone(&self.session), err, @@ -282,24 +287,13 @@ impl DockerPipelineRunner { for (node_id, input_packets) in pipeline_run.pipeline_job.get_input_packet_per_node()? { // Send the packet to the input node key_exp pipeline_run - .session - .put( - pipeline_run - .make_key_expr(&format!("input_node_{node_id}"), NODE_OUTPUT_KEY_EXPR), - serde_json::to_string(&input_packets)?, - ) - .await - .context(selector::AgentCommunicationFailure {})?; + .send_packets(&format!("input_node_{node_id}"), &input_packets) + .await?; - // All packets associate with node are sent, we can send processing complete msg now + // Packets are sent, thus we can send the empty vec which signify processing is done pipeline_run - .session - .put( - pipeline_run.make_key_expr(&node_id, NODE_OUTPUT_KEY_EXPR), - serde_json::to_string(&Vec::::new())?, - ) - .await - .context(selector::AgentCommunicationFailure {})?; + .send_packets(&format!("input_node_{node_id}"), &Vec::new()) + .await?; } // Insert into the list of pipeline runs @@ -490,38 +484,23 @@ impl DockerPipelineRunner { // Create a join set to spawn and handle incoming messages tasks let mut listener_tasks = JoinSet::new(); - // Create the list of key_expressions to subscribe to - let mut key_exps_to_subscribe_to = parent_nodes - .into_iter() - .map(|parent_node| { - // Make the parent key to listen to - pipeline_run.make_key_expr(&parent_node.id, NODE_OUTPUT_KEY_EXPR) - }) + // Create a list of node_ids that this node should listen to + let mut nodes_to_sub_to = parent_nodes + .iter() + .map(|parent_node| parent_node.id.clone()) .collect::>(); - println!( - "Spawning node processing task for node: {}, with {:?} parents", - node.id, key_exps_to_subscribe_to - ); - - // Check if node is an input_node, if so we need to add the input node key expression if is_input_node { - key_exps_to_subscribe_to.push( - pipeline_run - .make_key_expr(&format!("input_node_{}", node.id), NODE_OUTPUT_KEY_EXPR), - ); + // If the node is an input node, we need to add the input node key expression + nodes_to_sub_to.push(format!("input_node_{}", node.id)); } - // Create a subscriber for each of the parent nodes (Should only be 1, unless it is a joiner node) - for key_exp in &key_exps_to_subscribe_to { + // For each node in nodes_to_subscribe_to, call the event handler func + for node_to_sub in &nodes_to_sub_to { listener_tasks.spawn(Self::event_handler( Arc::clone(&pipeline_run), node.id.clone(), - pipeline_run - .session - .declare_subscriber(key_exp) - .await - .context(selector::AgentCommunicationFailure {})?, + node_to_sub.to_owned(), Arc::clone(&node_processor), )); } @@ -534,7 +513,7 @@ impl DockerPipelineRunner { // Wait for all tasks to be spawned and reply with ready message // This is to ensure that the pipeline run knows when all tasks are ready to receive inputs - let mut num_of_ready_subscribers: usize = 0; + let mut num_of_ready_event_handler: usize = 0; // Build the subscriber let status_subscriber = pipeline_run .session @@ -543,8 +522,8 @@ impl DockerPipelineRunner { .context(selector::AgentCommunicationFailure {})?; while status_subscriber.recv_async().await.is_ok() { - num_of_ready_subscribers += 1; - if num_of_ready_subscribers == key_exps_to_subscribe_to.len() { + num_of_ready_event_handler += 1; + if num_of_ready_event_handler == nodes_to_sub_to.len() { // +1 for the stop request task break; // All tasks are ready, we can start sending inputs } @@ -558,12 +537,22 @@ impl DockerPipelineRunner { .context(selector::AgentCommunicationFailure {})?; // Wait for all task to complete - listener_tasks.join_all().await; + while let Some(result) = listener_tasks.join_next().await { + match result { + Ok(Ok(())) => {} // Task completed successfully + Ok(Err(err)) => { + pipeline_run.send_err(&node.id, err).await; + } + Err(err) => { + pipeline_run.send_err(&node.id, OrcaError::from(err)).await; + } + } + } // Abort the stop listener task since we don't need it anymore abort_request_handler_task.abort(); println!( - "Node {} processing completed, exiting capture task", + "Node {} processing completed, exiting node processing task", node.id ); @@ -574,12 +563,17 @@ impl DockerPipelineRunner { async fn event_handler( pipeline_run: Arc, node_id: String, - subscriber: Subscriber>, + node_to_sub_to: String, processor: Arc>>, ) -> Result<()> { - // We do not know when tokio will start executing this task, therefore we need to send a ready message - // back to our spawner task + // Create the subscriber + let subscriber = pipeline_run + .session + .declare_subscriber(pipeline_run.make_key_expr(&node_to_sub_to, NODE_OUTPUT_KEY_EXPR)) + .await + .context(selector::AgentCommunicationFailure {})?; + // Send out ready signal pipeline_run .session .put( @@ -589,18 +583,30 @@ impl DockerPipelineRunner { .await .context(selector::AgentCommunicationFailure {})?; - while let Ok(sample) = subscriber.recv_async().await { - println!("Received sample for node: {node_id}"); + // Listen to the key + loop { + let sample = subscriber + .recv_async() + .await + .context(selector::AgentCommunicationFailure)?; + + println!( + "Received sample from node: {node_to_sub_to} for node {node_id} and key expr {}", + subscriber.key_expr().as_str() + ); // Extract out the packets let packets: Vec = serde_json::from_slice(&sample.payload().to_bytes())?; + println!("Packet len: {}", packets.len()); // Check if the packets are empty, if so that means the node is finished processing if packets.is_empty() { processor .lock() .await - .mark_parent_as_complete(&node_id) + .mark_parent_as_complete(&node_to_sub_to) .await; + + println!("Node {node_id} processing completed"); break; } @@ -609,11 +615,11 @@ impl DockerPipelineRunner { processor .lock() .await - .process_incoming_packet(&node_id, &packet) + .process_incoming_packet(&node_to_sub_to, &packet) .await; } } - + println!("Node {node_id} event handler exiting"); Ok::<(), OrcaError>(()) } @@ -743,10 +749,7 @@ impl PodProcessor { .declare_subscriber(pipeline_run.agent_client.make_key_expr( true, "pod_job", - BTreeMap::from([ - ("hash", pod_job.hash.clone()), - ("event", "success".to_owned()), - ]), + BTreeMap::from([("hash", pod_job.hash.clone()), ("event", "*".to_owned())]), )) .await .context(selector::AgentCommunicationFailure {})?; @@ -754,26 +757,15 @@ impl PodProcessor { // Create the async task to listen for the pod job completion let pod_job_listener_task = tokio::spawn(async move { // Wait for the pod job to complete and extract the result - println!("Listening on {}", pod_job_subscriber.key_expr()); let sample = pod_job_subscriber .recv_async() .await .context(selector::AgentCommunicationFailure {})?; // Extract the pod_result from the payload - println!("Received pod job completion message: {:?}", sample); let pod_result: PodResult = serde_json::from_slice(&sample.payload().to_bytes())?; - println!( - "Pod job {} completed with status: {:?}", - pod_result.pod_job.hash, pod_result.status - ); Ok::<_, OrcaError>(pod_result) }); - println!( - "Submitting pod job {} for node {} with input packet hash: {}", - pod_job.hash, node_id, input_packet_hash - ); - // Submit it to the client and get the response to make sure it was successful let responses = pipeline_run .agent_client @@ -798,12 +790,6 @@ impl PodProcessor { // Get the pod result from the listener task let pod_result = pod_job_listener_task.await??; - - println!( - "Pod job {} completed with status: {:?}", - pod_result.pod_job.hash, pod_result.status - ); - // Get the output packet for the pod result Ok(match pod_result.status { PodStatus::Completed => { @@ -863,7 +849,7 @@ impl NodeProcessor for PodProcessor { .send_packets(&node_id, &vec![output_packet]) .await { - Ok(()) => Ok(()), + Ok(()) => Ok(println!("Node {}: Sending packet ", node_id)), Err(err) => Err(err), } } @@ -882,8 +868,18 @@ impl NodeProcessor for PodProcessor { } async fn mark_parent_as_complete(&mut self, _parent_node_id: &str) { + println!( + "Number of task waiting to be join {} for node {}", + self.processing_tasks.len(), + self.node_id + ); // For pod we only have one parent, thus execute the exit case - while self.processing_tasks.join_next().await.is_some() {} + while self.processing_tasks.join_next().await.is_some() { + println!( + "Waiting for pod node {} processing tasks to complete", + self.node_id + ); + } // Send out completion signal match self .pipeline_run @@ -895,6 +891,10 @@ impl NodeProcessor for PodProcessor { self.pipeline_run.send_err(&self.node_id, err).await; } } + println!( + "Pod node {} processing completed, exiting pod processing task", + self.node_id + ); } fn stop(&mut self) { @@ -938,6 +938,10 @@ impl NodeProcessor for OperatorProcessor sender_node_id: &str, incoming_packet: &HashMap, ) { + println!( + "Processing incoming packet from node: {} for operator: {}", + sender_node_id, self.node_id + ); // Clone all necessary fields from self to move into the async block let operator = Arc::clone(&self.operator); let pipeline_run = Arc::clone(&self.pipeline_run); @@ -948,7 +952,7 @@ impl NodeProcessor for OperatorProcessor self.processing_tasks.spawn(async move { let processing_result = operator - .process_packets(vec![(sender_node_id_inner, incoming_packet_inner)]) + .process_packet(sender_node_id_inner, incoming_packet_inner) .await; match processing_result { diff --git a/tests/extra/data/input_txt/is.txt b/tests/extra/data/input_txt/is.txt new file mode 100644 index 00000000..f5cb1322 --- /dev/null +++ b/tests/extra/data/input_txt/is.txt @@ -0,0 +1 @@ +is diff --git a/tests/extra/data/input_txt/is_the.txt b/tests/extra/data/input_txt/is_the.txt deleted file mode 100644 index 863d01a3..00000000 --- a/tests/extra/data/input_txt/is_the.txt +++ /dev/null @@ -1 +0,0 @@ -is the diff --git a/tests/extra/data/input_txt/the.txt b/tests/extra/data/input_txt/the.txt new file mode 100644 index 00000000..41d25f51 --- /dev/null +++ b/tests/extra/data/input_txt/the.txt @@ -0,0 +1 @@ +the diff --git a/tests/fixture/mod.rs b/tests/fixture/mod.rs index 469c190b..7f92febc 100644 --- a/tests/fixture/mod.rs +++ b/tests/fixture/mod.rs @@ -337,7 +337,7 @@ pub fn pipeline() -> Result { let mut kernel_map = HashMap::new(); // Insert the pod into the kernel map - for pod_name in ["A", "B", "C", "D"] { + for pod_name in ["A", "B", "C", "D", "E"] { kernel_map.insert(pod_name.into(), combine_txt_pod(pod_name)?.into()); } @@ -345,6 +345,10 @@ pub fn pipeline() -> Result { map: HashMap::from([("output".to_owned(), "input_1".to_owned())]), }); + let output_to_input_2 = Arc::new(MapOperator { + map: HashMap::from([("output".to_owned(), "input_2".to_owned())]), + }); + // Create a mapper for A, B, and C kernel_map.insert( "pod_a_mapper".into(), @@ -354,10 +358,9 @@ pub fn pipeline() -> Result { ); kernel_map.insert( "pod_b_mapper".into(), - MapOperator { - map: HashMap::from([("output".to_owned(), "input_2".to_owned())]), - } - .into(), + Kernel::MapOperator { + mapper: Arc::clone(&output_to_input_2), + }, ); kernel_map.insert( "pod_c_mapper".into(), @@ -365,19 +368,25 @@ pub fn pipeline() -> Result { mapper: Arc::clone(&output_to_input_1), }, ); + kernel_map.insert( + "pod_d_mapper".into(), + Kernel::MapOperator { + mapper: Arc::clone(&output_to_input_2), + }, + ); - // Add the joiner node - kernel_map.insert("pod_c_joiner".into(), Kernel::JoinOperator); - - // Add joiner node for D - kernel_map.insert("pod_d_joiner".into(), Kernel::JoinOperator); + for joiner_name in ['c', 'd', 'e'] { + kernel_map.insert(format!("pod_{}_joiner", joiner_name), Kernel::JoinOperator); + } // Write all the edges in DOT format let dot = " digraph { A -> pod_a_mapper -> pod_c_joiner; B -> pod_b_mapper -> pod_c_joiner; - pod_c_joiner -> C -> pod_d_joiner -> D; + pod_c_joiner -> C -> pod_c_mapper-> pod_e_joiner; + D -> pod_d_mapper -> pod_e_joiner; + pod_e_joiner -> E; } "; @@ -393,30 +402,37 @@ pub fn pipeline() -> Result { }], ), ( - "is_the".into(), + "is".into(), vec![NodeURI { node_id: "A".into(), key: "input_2".into(), }], ), ( - "cat_color".into(), + "the".into(), vec![NodeURI { node_id: "B".into(), key: "input_1".into(), }], ), ( - "cat".into(), + "cat_color".into(), vec![NodeURI { node_id: "B".into(), key: "input_2".into(), }], ), + ( + "cat".into(), + vec![NodeURI { + node_id: "D".into(), + key: "input_1".into(), + }], + ), ( "action".into(), vec![NodeURI { - node_id: "pod_d_joiner".into(), + node_id: "D".into(), key: "input_2".into(), }], ), @@ -434,6 +450,7 @@ pub fn pipeline() -> Result { #[expect(clippy::implicit_hasher, reason = "Could be a false positive?")] pub fn pipeline_job(namespace_lookup: &HashMap) -> Result { // Create a simple pipeline_job + let namespace: String = "default".into(); PipelineJob::new( pipeline()?.into(), &HashMap::from([ @@ -442,19 +459,30 @@ pub fn pipeline_job(namespace_lookup: &HashMap) -> Result) -> Result) -> Result) -> Result) -> Result) -> Result Result> { let mut next_packets = vec![]; for (stream_name, packet) in packets { - next_packets.extend(operator.next(stream_name, packet).await?); + next_packets.extend(operator.process_packet(stream_name, packet).await?); } Ok(next_packets) } @@ -103,7 +103,7 @@ async fn join_spotty() -> Result<()> { assert_eq!( operator - .next( + .process_packet( "right".into(), Packet::from([make_packet_key("style".into(), "right/style0.t7".into())]) ) @@ -114,7 +114,7 @@ async fn join_spotty() -> Result<()> { assert_eq!( operator - .next( + .process_packet( "right".into(), Packet::from([make_packet_key("style".into(), "right/style1.t7".into())]) ) @@ -125,7 +125,7 @@ async fn join_spotty() -> Result<()> { assert_eq!( operator - .next( + .process_packet( "left".into(), Packet::from([make_packet_key( "subject".into(), @@ -198,7 +198,7 @@ async fn map_once() -> Result<()> { assert_eq!( operator - .next( + .process_packet( "parent".into(), Packet::from([ make_packet_key("key_old".into(), "some/key.txt".into()), From 4f511ce13d427ca926bd2b20ebd5a1c3ff4ecc6a Mon Sep 17 00:00:00 2001 From: Synicix Date: Wed, 13 Aug 2025 15:45:47 +0000 Subject: [PATCH 21/65] Update tests and test fixture to merge sentence correctly --- src/core/orchestrator/agent.rs | 1 - src/core/pipeline_runner.rs | 109 ++++++--------------------------- src/uniffi/model/packet.rs | 26 ++++++++ tests/fixture/mod.rs | 14 +++-- tests/operator.rs | 28 ++++----- tests/pipeline_runner.rs | 54 ++++++++-------- 6 files changed, 94 insertions(+), 138 deletions(-) diff --git a/src/core/orchestrator/agent.rs b/src/core/orchestrator/agent.rs index 9ec47a2b..9cbc7904 100644 --- a/src/core/orchestrator/agent.rs +++ b/src/core/orchestrator/agent.rs @@ -157,7 +157,6 @@ where let input = serde_json::from_slice::(&sample.payload().to_bytes())?; let inner_response_tx = response_tx.clone(); let mut event_metadata = extract_metadata(sample.key_expr().as_str()); - println!("asdhfjkashdfkj{:?}", event_metadata); let timestamp = event_metadata .remove("timestamp") diff --git a/src/core/pipeline_runner.rs b/src/core/pipeline_runner.rs index 6712160b..ad100f6c 100644 --- a/src/core/pipeline_runner.rs +++ b/src/core/pipeline_runner.rs @@ -3,7 +3,6 @@ use crate::{ crypto::hash_buffer, model::{pipeline::PipelineNode, serialize_hashmap}, operator::{JoinOperator, Operator}, - orchestrator::agent::extract_metadata, util::{get, make_key_expr}, }, uniffi::{ @@ -38,7 +37,6 @@ use tokio::{ sync::{Mutex, RwLock}, task::JoinSet, }; -use zenoh::{handlers::FifoChannelHandler, pubsub::Subscriber, sample::Sample}; static NODE_OUTPUT_KEY_EXPR: &str = "output"; static FAILURE_KEY_EXP: &str = "failure"; @@ -111,14 +109,18 @@ impl PipelineRun { } async fn send_err(&self, node_id: &str, err: OrcaError) { - println!("{}", err); - try_to_forward_err_msg( - Arc::clone(&self.session), - err, - &self.make_key_expr(node_id, "error"), - node_id, - ) - .await; + let payload = match serde_json::to_string(&err.to_string()) { + Ok(json) => json, + Err(serialize_err) => serialize_err.to_string(), + }; + + self.session + .put(&self.make_key_expr(node_id, FAILURE_KEY_EXP), payload) + .await + .context(selector::AgentCommunicationFailure {}) + .unwrap_or_else(|send_err| { + eprintln!("Failed to send error message for node {node_id}: {send_err}"); + }); } async fn send_abort_request(&self) -> Result<()> { @@ -320,20 +322,9 @@ impl DockerPipelineRunner { // Wait for all the tasks to complete while let Some(result) = pipeline_run.node_tasks.lock().await.join_next().await { - match result { - Ok(Ok(())) => {} // Task completed successfully - Ok(Err(err)) => { - eprintln!("Task failed with err: {err}"); - return Err(err); - } - Err(err) => { - eprintln!("Join set error: {err}"); - return Err(err.into()); - } - } + result??; } - // Figure out how to do this later Ok(PipelineResult { pipeline_job: Arc::clone(&pipeline_run.pipeline_job), output_packets: pipeline_run.outputs.read().await.clone(), @@ -386,6 +377,11 @@ impl DockerPipelineRunner { .context(selector::AgentCommunicationFailure {})?; while let Ok(payload) = subscriber.recv_async().await { + println!( + "Received output from node {}: {}", + node_id, + String::from_utf8_lossy(&payload.payload().to_bytes()) + ); // Extract the message from the payload let packets: Vec = serde_json::from_slice(&payload.payload().to_bytes())?; @@ -425,12 +421,6 @@ impl DockerPipelineRunner { .write() .await .push(process_failure.clone()); - if let Some(first_line) = process_failure.error.lines().next() { - println!( - "Node {} processing failed with error: {}", - process_failure.node_id, first_line - ); - } } Ok(()) @@ -551,10 +541,6 @@ impl DockerPipelineRunner { // Abort the stop listener task since we don't need it anymore abort_request_handler_task.abort(); - println!( - "Node {} processing completed, exiting node processing task", - node.id - ); Ok(()) } @@ -590,13 +576,8 @@ impl DockerPipelineRunner { .await .context(selector::AgentCommunicationFailure)?; - println!( - "Received sample from node: {node_to_sub_to} for node {node_id} and key expr {}", - subscriber.key_expr().as_str() - ); // Extract out the packets let packets: Vec = serde_json::from_slice(&sample.payload().to_bytes())?; - println!("Packet len: {}", packets.len()); // Check if the packets are empty, if so that means the node is finished processing if packets.is_empty() { @@ -605,8 +586,6 @@ impl DockerPipelineRunner { .await .mark_parent_as_complete(&node_to_sub_to) .await; - - println!("Node {node_id} processing completed"); break; } @@ -619,7 +598,6 @@ impl DockerPipelineRunner { .await; } } - println!("Node {node_id} event handler exiting"); Ok::<(), OrcaError>(()) } @@ -658,35 +636,6 @@ trait NodeProcessor: Send + Sync { fn stop(&mut self); } -/// Util function to handle forwarding error messages to the failure channel -async fn try_to_forward_err_msg( - session: Arc, - err: OrcaError, - key_expression: &str, - node_id: &str, -) { - match async { - session - .put( - format!("{key_expression}/{FAILURE_KEY_EXP}"), - serde_json::to_string(&ProcessingFailure { - node_id: node_id.to_owned(), - error: err.to_string(), - })?, - ) - .await - .context(selector::AgentCommunicationFailure {})?; - Ok::<(), OrcaError>(()) - } - .await - { - Ok(()) => {} - Err(send_err) => { - eprintln!("Failed to send failure message: {send_err}"); - } - } -} - /// Processor for Pods /// Currently missing implementation to call agents for actual pod processing struct PodProcessor { @@ -849,7 +798,7 @@ impl NodeProcessor for PodProcessor { .send_packets(&node_id, &vec![output_packet]) .await { - Ok(()) => Ok(println!("Node {}: Sending packet ", node_id)), + Ok(()) => Ok(()), Err(err) => Err(err), } } @@ -868,18 +817,8 @@ impl NodeProcessor for PodProcessor { } async fn mark_parent_as_complete(&mut self, _parent_node_id: &str) { - println!( - "Number of task waiting to be join {} for node {}", - self.processing_tasks.len(), - self.node_id - ); // For pod we only have one parent, thus execute the exit case - while self.processing_tasks.join_next().await.is_some() { - println!( - "Waiting for pod node {} processing tasks to complete", - self.node_id - ); - } + while self.processing_tasks.join_next().await.is_some() {} // Send out completion signal match self .pipeline_run @@ -891,10 +830,6 @@ impl NodeProcessor for PodProcessor { self.pipeline_run.send_err(&self.node_id, err).await; } } - println!( - "Pod node {} processing completed, exiting pod processing task", - self.node_id - ); } fn stop(&mut self) { @@ -938,10 +873,6 @@ impl NodeProcessor for OperatorProcessor sender_node_id: &str, incoming_packet: &HashMap, ) { - println!( - "Processing incoming packet from node: {} for operator: {}", - sender_node_id, self.node_id - ); // Clone all necessary fields from self to move into the async block let operator = Arc::clone(&self.operator); let pipeline_run = Arc::clone(&self.pipeline_run); diff --git a/src/uniffi/model/packet.rs b/src/uniffi/model/packet.rs index c1514533..e8f68304 100644 --- a/src/uniffi/model/packet.rs +++ b/src/uniffi/model/packet.rs @@ -2,6 +2,9 @@ use serde::{Deserialize, Serialize}; use std::{collections::HashMap, path::PathBuf}; use uniffi; +use crate::core::util::get; +use crate::uniffi::error::Result; + /// Path sets are named and represent an abstraction for the file(s) that represent some particular /// data within a compute environment. #[derive(uniffi::Record, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] @@ -61,5 +64,28 @@ pub enum PathSet { Collection(Vec), } +impl PathSet { + /// Util function to convert ``PathSet`` to ``PathBuf`` given a namespace lookup table + /// + /// # Errors + /// Will error out if namespace is missing in namespace lookup + pub fn to_path_buf(&self, namespace_lookup: &HashMap) -> Result> { + match self { + Self::Unary(blob) => { + let base_path = get(namespace_lookup, &blob.location.namespace)?; + Ok(vec![base_path.join(&blob.location.path)]) + } + Self::Collection(blobs) => { + let mut paths = Vec::with_capacity(blobs.len()); + for blob in blobs { + let base_path = get(namespace_lookup, &blob.location.namespace)?; + paths.push(base_path.join(&blob.location.path)); + } + Ok(paths) + } + } + } +} + /// A complete set of inputs to be provided to a computational unit. pub type Packet = HashMap; diff --git a/tests/fixture/mod.rs b/tests/fixture/mod.rs index 7f92febc..6cd8a420 100644 --- a/tests/fixture/mod.rs +++ b/tests/fixture/mod.rs @@ -289,14 +289,17 @@ pub fn combine_txt_pod(pod_name: &str) -> Result { Pod::new( Some(Annotation { name: pod_name.to_owned(), - description: "Pod append it's own name to the end of the file.".to_owned(), + description: "Takes two input files, remove the final next line and combine them" + .to_owned(), version: "1.0.0".to_owned(), }), "alpine:3.14".to_owned(), vec![ "sh".into(), "-c".into(), - format!("cat input/input_1.txt input/input_2.txt > /output/output.txt"), + format!( + "printf '%s %s\\n' \"$(cat input/input_1.txt | head -c -1)\" \"$(cat input/input_2.txt | head -c -1)\" > /output/output.txt" + ), ], HashMap::from([ ( @@ -329,6 +332,7 @@ pub fn combine_txt_pod(pod_name: &str) -> Result { ) } +#[expect(clippy::too_many_lines, reason = "OK in tests.")] pub fn pipeline() -> Result { // Create a simple pipeline where the functions job is to add append their name into the input file // Structure: A -> Mapper -> Joiner -> B -> Mapper -> C, D -> Mapper -> Joiner @@ -376,7 +380,7 @@ pub fn pipeline() -> Result { ); for joiner_name in ['c', 'd', 'e'] { - kernel_map.insert(format!("pod_{}_joiner", joiner_name), Kernel::JoinOperator); + kernel_map.insert(format!("pod_{joiner_name}_joiner"), Kernel::JoinOperator); } // Write all the edges in DOT format @@ -440,7 +444,7 @@ pub fn pipeline() -> Result { HashMap::from([( "output".to_owned(), NodeURI { - node_id: "D".into(), + node_id: "E".into(), key: "output".into(), }, )]), @@ -533,7 +537,7 @@ pub fn pipeline_job(namespace_lookup: &HashMap) -> Result Result<()> { make_packet_key("subject".into(), "left/subject0.png".into()), make_packet_key("style".into(), "right/style0.t7".into()), ]), - Packet::from([ - make_packet_key("subject".into(), "left/subject0.png".into()), - make_packet_key("style".into(), "right/style1.t7".into()), - ]), Packet::from([ make_packet_key("subject".into(), "left/subject1.png".into()), make_packet_key("style".into(), "right/style0.t7".into()), @@ -84,6 +80,10 @@ async fn join_once() -> Result<()> { make_packet_key("subject".into(), "left/subject2.png".into()), make_packet_key("style".into(), "right/style0.t7".into()), ]), + Packet::from([ + make_packet_key("subject".into(), "left/subject0.png".into()), + make_packet_key("style".into(), "right/style1.t7".into()), + ]), Packet::from([ make_packet_key("subject".into(), "left/subject1.png".into()), make_packet_key("style".into(), "right/style1.t7".into()), @@ -93,7 +93,9 @@ async fn join_once() -> Result<()> { make_packet_key("style".into(), "right/style1.t7".into()), ]), ], + "Unexpected streams." ); + Ok(()) } @@ -143,15 +145,7 @@ async fn join_spotty() -> Result<()> { make_packet_key("style".into(), "right/style1.t7".into()), ]), ], - &operator - .process_packets(vec![( - "left".into(), - Packet::from([make_packet_key( - "subject".into(), - "left/subject0.png".into(), - )]), - )]) - .await?, + "Unexpected streams." ); assert_eq!( @@ -188,7 +182,9 @@ async fn join_spotty() -> Result<()> { make_packet_key("style".into(), "right/style1.t7".into()), ]), ], + "Unexpected streams." ); + Ok(()) } @@ -209,7 +205,9 @@ async fn map_once() -> Result<()> { vec![Packet::from([ make_packet_key("key_new".into(), "some/key.txt".into()), make_packet_key("subject".into(), "some/subject.txt".into()), - ])], + ]),], + "Unexpected packet." ); + Ok(()) } diff --git a/tests/pipeline_runner.rs b/tests/pipeline_runner.rs index ddb2bc7d..07a079d3 100644 --- a/tests/pipeline_runner.rs +++ b/tests/pipeline_runner.rs @@ -1,7 +1,7 @@ #![expect( missing_docs, clippy::panic_in_result_fn, - clippy::expect_used, + clippy::indexing_slicing, clippy::unwrap_used, reason = "OK in tests." )] @@ -12,7 +12,10 @@ pub mod fixture; // Example for a local module: -use std::{collections::HashMap, sync::Arc}; +use std::{ + collections::{HashMap, HashSet}, + sync::Arc, +}; use orcapod::{ core::pipeline_runner::DockerPipelineRunner, @@ -21,6 +24,7 @@ use orcapod::{ orchestrator::{agent::Agent, docker::LocalDockerOrchestrator}, }, }; +use tokio::fs::read_to_string; use crate::fixture::TestDirs; use fixture::pipeline_job; @@ -34,24 +38,6 @@ async fn basic_run() -> Result<()> { )]))?; let namespace_lookup = test_dirs.namespace_lookup(); - // create a zenoh session to print out all communication message - let session = zenoh::open(zenoh::Config::default()) - .await - .expect("Failed to open zenoh session"); - - tokio::spawn(async move { - // Subscribe to all messages in the 'test' group - let sub = session - .declare_subscriber("**") - .await - .expect("Failed to declare subscriber"); - - while let Ok(sample) = sub.recv_async().await { - // Print the key expression and payload of each message - println!("Received message: {}:", sample.key_expr().as_str(),); - } - }); - // Create and agent and start it (temporary for now, will be merge later) let agent = Arc::new(Agent::new( "test:basic_run".to_owned(), @@ -80,14 +66,26 @@ async fn basic_run() -> Result<()> { // Wait for the pipeline run to complete let pipeline_result = runner.get_result(&pipeline_run).await?; - println!( - "Pipeline run completed: {:?}", - pipeline_result.output_packets - ); - - assert!( - pipeline_result.output_packets.len() == 1, - "Expected exactly one output packet." + // Check the output packet content + assert_eq!(pipeline_result.output_packets["output"].len(), 4); + + // Get all the output file content and read them in + let mut output_content = HashSet::new(); + + for output_packet in &pipeline_result.output_packets["output"] { + output_content + .insert(read_to_string(&output_packet.to_path_buf(&namespace_lookup)?[0]).await?); + } + + // Check if the output_content matches + assert_eq!( + output_content, + HashSet::from([ + "Where is the black cat playing\n".to_owned(), + "Where is the black cat hiding\n".to_owned(), + "Where is the tabby cat playing\n".to_owned(), + "Where is the tabby cat hiding\n".to_owned(), + ]) ); Ok(()) From 2a4f1f763a40e11543239420083c419ba6992482 Mon Sep 17 00:00:00 2001 From: Synicix Date: Wed, 13 Aug 2025 15:57:52 +0000 Subject: [PATCH 22/65] Fix agent event to make it more efficient --- src/uniffi/orchestrator/agent.rs | 16 +--------------- tests/agent.rs | 2 +- 2 files changed, 2 insertions(+), 16 deletions(-) diff --git a/src/uniffi/orchestrator/agent.rs b/src/uniffi/orchestrator/agent.rs index e49ad319..a060c271 100644 --- a/src/uniffi/orchestrator/agent.rs +++ b/src/uniffi/orchestrator/agent.rs @@ -206,21 +206,7 @@ impl Agent { services.spawn(start_service( Arc::clone(&self_ref), "pod_job", - BTreeMap::from([("event", "success".to_owned())]), - namespace_lookup.clone(), - { - let inner_store = Arc::clone(&store); - async move |_, _, _, pod_result| { - inner_store.save_pod_result(&pod_result)?; - Ok(()) - } - }, - async |_, ()| Ok(()), - )); - services.spawn(start_service( - Arc::clone(&self_ref), - "pod_job", - BTreeMap::from([("event", "failure".to_owned())]), + BTreeMap::from([("event", "*".to_owned())]), namespace_lookup.clone(), async move |_, _, _, pod_result| { store.save_pod_result(&pod_result)?; diff --git a/tests/agent.rs b/tests/agent.rs index 0fa82a0d..89e42c87 100644 --- a/tests/agent.rs +++ b/tests/agent.rs @@ -102,7 +102,7 @@ async fn parallel_four_cores() -> Result<()> { .await .expect("All senders have dropped."); let metadata = extract_metadata(sample.key_expr().as_str()); - let topic_kind = metadata["action"].as_str(); + let topic_kind = metadata["event"].as_str(); if ["success", "failure"].contains(&topic_kind) { let pod_result = serde_json::from_slice::(&sample.payload().to_bytes())?; assert!( From c89c7000168c72a3d0af1f6477a8a044d27de5da Mon Sep 17 00:00:00 2001 From: Synicix Date: Wed, 13 Aug 2025 16:00:37 +0000 Subject: [PATCH 23/65] Remove empty impl --- src/uniffi/model/packet.rs | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/uniffi/model/packet.rs b/src/uniffi/model/packet.rs index e8f68304..df9f4a91 100644 --- a/src/uniffi/model/packet.rs +++ b/src/uniffi/model/packet.rs @@ -37,9 +37,6 @@ pub struct URI { pub path: PathBuf, } -#[uniffi::export] -impl URI {} - /// BLOB with metadata. #[derive(uniffi::Record, Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default)] pub struct Blob { @@ -51,9 +48,6 @@ pub struct Blob { pub checksum: String, } -#[uniffi::export] -impl Blob {} - /// A single BLOB or a collection of BLOBs. #[derive(uniffi::Enum, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] #[serde(untagged)] From a834ed915648dfd1978ab57f861082f46dc0ab52 Mon Sep 17 00:00:00 2001 From: Synicix Date: Thu, 14 Aug 2025 06:35:06 +0000 Subject: [PATCH 24/65] Fix remaining test to deal with issue --- src/uniffi/model/pod.rs | 90 ++++++++++----------------- src/uniffi/orchestrator/mod.rs | 2 +- tests/extra/data/output/result2.jpeg | Bin 152661 -> 152666 bytes tests/model.rs | 6 +- tests/orchestrator.rs | 12 ++-- 5 files changed, 42 insertions(+), 68 deletions(-) diff --git a/src/uniffi/model/pod.rs b/src/uniffi/model/pod.rs index 60e41962..f06706e6 100644 --- a/src/uniffi/model/pod.rs +++ b/src/uniffi/model/pod.rs @@ -9,7 +9,7 @@ use crate::{ validation::validate_packet, }, uniffi::{ - error::{Kind, OrcaError, Result}, + error::{OrcaError, Result}, model::{ Annotation, packet::{Blob, BlobKind, Packet, PathInfo, PathSet, URI}, @@ -20,7 +20,7 @@ use crate::{ use derive_more::Display; use getset::CloneGetters; use serde::{Deserialize, Serialize}; -use std::{collections::HashMap, io, path::PathBuf, sync::Arc}; +use std::{collections::HashMap, path::PathBuf, sync::Arc}; use uniffi; /// A reusable, containerized computational unit. @@ -225,70 +225,44 @@ impl PodResult { .pod .output_spec .iter() - .map(|(packet_key, path_info)| { - let rel_path = &pod_job.output_dir.path.join(&path_info.path); - let full_path = - get(namespace_lookup, &pod_job.output_dir.namespace)?.join(rel_path); - - // Check if exists and any permissions issues - match full_path.try_exists() { - Ok(true) => (), - Ok(false) => { - return Err(OrcaError { - kind: Kind::FailedToGetPodJobOutput { - pod_job_hash: pod_job.hash.clone(), - packet_key: packet_key.clone(), - path: full_path.into(), - io_error: Box::new(io::ErrorKind::NotFound.into()), - backtrace: Some(snafu::Backtrace::capture()), - }, - }); - } - Err(err) => { - return Err(OrcaError { - kind: Kind::FailedToGetPodJobOutput { - pod_job_hash: pod_job.hash.clone(), - packet_key: packet_key.clone(), - path: full_path.into(), - io_error: Box::new(err), - backtrace: Some(snafu::Backtrace::capture()), - }, - }); - } - } + .filter_map(|(packet_key, path_info)| { + let location = URI { + namespace: pod_job.output_dir.namespace.clone(), + path: pod_job.output_dir.path.join(&path_info.path), + }; - // Determine if file or directory and handle other cases such as socket, named_pipe, etc. - let blob_kind = if full_path.is_file() { - BlobKind::File - } else if full_path.is_dir() { - BlobKind::Directory - } else { - // Will fail on socket, named pipe, etc. - return Err(OrcaError { - kind: Kind::UnexpectedPathType { - path: full_path, - backtrace: Some(snafu::Backtrace::capture()), - }, - }); + let local_location = match get(namespace_lookup, &location.namespace) { + Ok(root_path) => root_path.join(&location.path), + Err(error) => return Some(Err(error)), }; - Ok(( - packet_key.clone(), - PathSet::Unary(hash_blob( - namespace_lookup, - &Blob { - kind: blob_kind, - location: URI { - namespace: pod_job.output_dir.namespace.clone(), - path: rel_path.into(), + match local_location.try_exists() { + Ok(false) => None, + Err(error) => Some(Err(OrcaError::from(error))), + Ok(true) => Some(Ok(( + packet_key, + Blob { + kind: if local_location.is_file() { + BlobKind::File + } else { + BlobKind::Directory }, - ..Default::default() + location, + checksum: String::new(), }, - )?), + ))), + } + }) + .map(|result| { + let (packet_key, blob) = result?; + Ok(( + packet_key.clone(), + PathSet::Unary(hash_blob(namespace_lookup, &blob)?), )) }) - .collect::>>()?; + .collect::>()?; + // If packet is completed, the output packet must meet the output spec if matches!(status, PodStatus::Completed) { validate_packet("output".into(), &pod_job.pod.output_spec, &output_packet)?; } diff --git a/src/uniffi/orchestrator/mod.rs b/src/uniffi/orchestrator/mod.rs index f692546e..e85345f7 100644 --- a/src/uniffi/orchestrator/mod.rs +++ b/src/uniffi/orchestrator/mod.rs @@ -56,7 +56,7 @@ pub struct PodRunInfo { pub memory_limit: u64, } /// Current computation managed by orchestrator. -#[derive(uniffi::Record, Debug)] +#[derive(uniffi::Record, Debug, PartialEq, Clone)] pub struct PodRun { /// Original compute request. pub pod_job: Arc, diff --git a/tests/extra/data/output/result2.jpeg b/tests/extra/data/output/result2.jpeg index 15e2e4b3adbabb291543ae123394c31ed2fe758f..ddfd76c2e21fa6bdcb78a04d536be85440ec5fe9 100644 GIT binary patch delta 122657 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

Bx+0%NQ<(ezf)v*# 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 z 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%0Ap>v}d0lKL#f>H|kd_ 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 F|Jl^r3;6&5 delta 122652 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

tOy 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 diff --git a/tests/model.rs b/tests/model.rs index a2b3e133..13d7a722 100644 --- a/tests/model.rs +++ b/tests/model.rs @@ -102,9 +102,9 @@ fn pod_job_to_yaml() -> Result<()> { #[test] fn hash_pod_result() -> Result<()> { - assert_eq!( + pretty_assert_eq!( pod_result_style(&NAMESPACE_LOOKUP_READ_ONLY)?.hash, - "35abb8180349bed1f3ea8c0d84e98000ec3ace904624e94e678783891a7e710e", + "e752d86d4fc5435bfa4564ba951851530f5cf2228586c2f488c2ca9e7bcc7ed1", "Hash didn't match." ); Ok(()) @@ -129,7 +129,7 @@ fn pod_result_to_yaml() -> Result<()> { location: namespace: default path: output/result2.jpeg - checksum: da71a1b5f8ca6ebd1edfd11df4c83078fc50d0e6a4c9b3d642ba397d81d8e883 + checksum: a1458fc7d7d9d23a66feae88b5a89f1756055bdbb6be02fdf672f7d31ed92735 assigned_name: simple-endeavour status: Completed created: 1737922307 diff --git a/tests/orchestrator.rs b/tests/orchestrator.rs index 8055b7fd..ae3bb2e1 100644 --- a/tests/orchestrator.rs +++ b/tests/orchestrator.rs @@ -38,9 +38,9 @@ where assert_eq!( orchestrator .list_blocking()? - .iter() - .filter(|container| container.pod_job == pod_run.pod_job) - .map(|run| Ok(orchestrator.get_info_blocking(run)?.command)) + .into_iter() + .filter(|pod_run_from_list| *pod_run_from_list == pod_run) + .map(|run| Ok(orchestrator.get_info_blocking(&run)?.command)) .collect::>>()?, vec![expected_command.clone()], "Unexpected list." @@ -59,9 +59,9 @@ where assert_eq!( orchestrator .list_blocking()? - .iter() - .filter(|container| container.pod_job == pod_run.pod_job) - .map(|run| Ok(orchestrator.get_info_blocking(run)?.command)) + .into_iter() + .filter(|pod_run_from_list| *pod_run_from_list == pod_run) + .map(|run| Ok(orchestrator.get_info_blocking(&run)?.command)) .collect::>>()?, vec![expected_command], "Unexpected list." From 7863cadc13b6d72652beb3a17766f57c900cc91c Mon Sep 17 00:00:00 2001 From: Synicix Date: Fri, 15 Aug 2025 04:48:13 +0000 Subject: [PATCH 25/65] Fix memory bug --- test.ipynb | 301 +++++++++++++++++++++++++++++++ tests/extra/python/agent_test.py | 8 +- 2 files changed, 305 insertions(+), 4 deletions(-) create mode 100644 test.ipynb diff --git a/test.ipynb b/test.ipynb new file mode 100644 index 00000000..57443778 --- /dev/null +++ b/test.ipynb @@ -0,0 +1,301 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "id": "d85bc53e", + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "usage: ipykernel_launcher.py [-h] test_dir\n", + "ipykernel_launcher.py: error: the following arguments are required: test_dir\n" + ] + }, + { + "ename": "SystemExit", + "evalue": "2", + "output_type": "error", + "traceback": [ + "An exception has occurred, use %tb to see the full traceback.\n", + "\u001b[0;31mSystemExit\u001b[0m\u001b[0;31m:\u001b[0m 2\n" + ] + } + ], + "source": [ + "#!/usr/bin/env python3\n", + "\n", + "# build: maturin develop --uv\n", + "# debugger: select \"Python: Debug agent test\" + F5\n", + "# script: /path/to/this/test/file.py /path/to/directory (must exist, will create subdirectory)\n", + "import shutil\n", + "from pathlib import Path\n", + "import argparse\n", + "import asyncio\n", + "import zenoh\n", + "from orcapod import (\n", + " Agent,\n", + " AgentClient,\n", + " LocalDockerOrchestrator,\n", + " LocalFileStore,\n", + " PodJob,\n", + " Uri,\n", + " Pod,\n", + " Annotation,\n", + ")\n", + "\n", + "\n", + "async def verify(group, pod_job_count):\n", + " counter = 0\n", + "\n", + " def count(sample):\n", + " nonlocal counter\n", + " counter += 1\n", + "\n", + " with zenoh.open(zenoh.Config()) as session:\n", + " with session.declare_subscriber(f\"**\", count) as subscriber:\n", + " await asyncio.sleep(30) # wait for results\n", + "\n", + " if counter != pod_job_count:\n", + " raise Exception(f\"Unexpected successful pod job count: {counter}.\")\n", + "\n", + "\n", + "async def main(client, agent, test_dir, namespace_lookup, pod_jobs):\n", + " watcher = asyncio.create_task(client.watch(key_expr=\"**\"))\n", + " worker = asyncio.create_task(\n", + " agent.start(\n", + " namespace_lookup=namespace_lookup,\n", + " available_store=LocalFileStore(directory=f\"{test_dir}/store\"),\n", + " ),\n", + " )\n", + " await asyncio.sleep(1) # ensure service ready\n", + "\n", + " try:\n", + " await client.start_pod_jobs(pod_jobs=pod_jobs)\n", + " await verify(client.group(), len(pod_jobs))\n", + " finally:\n", + " shutil.rmtree(test_dir)\n", + "\n", + "\n", + "if __name__ == \"__main__\":\n", + " parser = argparse.ArgumentParser(\n", + " description=\"Basic tests for orcapod's Python API.\"\n", + " )\n", + " parser.add_argument(\"test_dir\", help=\"Root directory for tests.\")\n", + " args = parser.parse_args()\n", + "\n", + " test_dir = f\"{args.test_dir}/{Path(__file__).stem}\"\n", + "\n", + " group = \"test\"\n", + " host = \"alpha\"\n", + "\n", + " client = AgentClient(group=group, host=host)\n", + " agent = Agent(group=group, host=host, orchestrator=LocalDockerOrchestrator())\n", + "\n", + " namespace_lookup = {\n", + " \"default\": f\"{test_dir}/default\",\n", + " }\n", + " pod_jobs = [\n", + " PodJob(\n", + " annotation=Annotation(\n", + " name=\"simple\",\n", + " description=\"This is an example pod job.\",\n", + " version=f\"0.{i}.0\",\n", + " ),\n", + " pod=Pod(\n", + " annotation=None,\n", + " image=\"ghcr.io/colinianking/stress-ng:e2f96874f951a72c1c83ff49098661f0e013ac40\",\n", + " command=\"stress-ng --cpu 1 --cpu-load 100 --timeout 5 --metrics-brief\".split(\n", + " \" \"\n", + " ),\n", + " input_spec={},\n", + " output_dir=\"/tmp/output\",\n", + " output_spec={},\n", + " source_commit_url=\"https://github.com/user/simple\",\n", + " recommended_cpus=0.1,\n", + " recommended_memory=10 << 20,\n", + " required_gpu=None,\n", + " ),\n", + " input_packet={},\n", + " output_dir=Uri(\n", + " namespace=\"default\",\n", + " path=\".\",\n", + " ),\n", + " cpu_limit=1,\n", + " memory_limit=10 << 20,\n", + " env_vars=None,\n", + " namespace_lookup=namespace_lookup,\n", + " )\n", + " for i in range(1, 5)\n", + " ]\n", + "\n", + " asyncio.run(main(client, agent, test_dir, namespace_lookup, pod_jobs))" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b06f2835", + "metadata": {}, + "outputs": [ + { + "ename": "NameError", + "evalue": "name 'count' is not defined", + "output_type": "error", + "traceback": [ + "\u001b[0;31m---------------------------------------------------------------------------\u001b[0m", + "\u001b[0;31mNameError\u001b[0m Traceback (most recent call last)", + "Cell \u001b[0;32mIn[3], line 2\u001b[0m\n\u001b[1;32m 1\u001b[0m \u001b[38;5;28;01mwith\u001b[39;00m zenoh\u001b[38;5;241m.\u001b[39mopen(zenoh\u001b[38;5;241m.\u001b[39mConfig()) \u001b[38;5;28;01mas\u001b[39;00m session:\n\u001b[0;32m----> 2\u001b[0m \u001b[38;5;28;01mwith\u001b[39;00m session\u001b[38;5;241m.\u001b[39mdeclare_subscriber(\u001b[38;5;124mf\u001b[39m\u001b[38;5;124m\"\u001b[39m\u001b[38;5;124m**\u001b[39m\u001b[38;5;124m\"\u001b[39m, count) \u001b[38;5;28;01mas\u001b[39;00m subscriber:\n\u001b[1;32m 3\u001b[0m \u001b[38;5;28;01mawait\u001b[39;00m asyncio\u001b[38;5;241m.\u001b[39msleep(\u001b[38;5;241m20\u001b[39m) \u001b[38;5;66;03m# wait for results\u001b[39;00m\n", + "\u001b[0;31mNameError\u001b[0m: name 'count' is not defined" + ] + } + ], + "source": [ + "with zenoh.open(zenoh.Config()) as session:\n", + " with session.declare_subscriber(\n", + " f\"**\",\n", + " ) as subscriber:\n", + " await asyncio.sleep(20) # wait for results" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1588e7de", + "metadata": {}, + "outputs": [], + "source": [ + "session = zenoh.open(zenoh.Config())" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "6c179488", + "metadata": {}, + "outputs": [], + "source": [ + "def process_msg(sample):\n", + " print(f\"Received sample: {sample.key_expr} -> {sample.payload.decode('utf-8')}\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6100146d", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "Subscriber { inner: SubscriberInner { session: Session { id: b60e73cc95cd5c0928a92854862a3b8d }, id: 3, key_expr: ke`**`, kind: Subscriber, undeclare_on_drop: false }, handler: Py(0x61fc06ea4c10) }" + ] + }, + "execution_count": 7, + "metadata": {}, + "output_type": "execute_result" + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "callback error\n", + "Traceback (most recent call last):\n", + " File \"/tmp/ipykernel_214267/187340876.py\", line 2, in process_msg\n", + " print(f\"Received sample: {sample.key_expr} -> {sample.payload.decode('utf-8')}\")\n", + "AttributeError: 'builtins.ZBytes' object has no attribute 'decode'\n", + "callback error\n", + "Traceback (most recent call last):\n", + " File \"/tmp/ipykernel_214267/187340876.py\", line 2, in process_msg\n", + " print(f\"Received sample: {sample.key_expr} -> {sample.payload.decode('utf-8')}\")\n", + "AttributeError: 'builtins.ZBytes' object has no attribute 'decode'\n", + "callback error\n", + "Traceback (most recent call last):\n", + " File \"/tmp/ipykernel_214267/187340876.py\", line 2, in process_msg\n", + " print(f\"Received sample: {sample.key_expr} -> {sample.payload.decode('utf-8')}\")\n", + "AttributeError: 'builtins.ZBytes' object has no attribute 'decode'\n", + "callback error\n", + "Traceback (most recent call last):\n", + " File \"/tmp/ipykernel_214267/187340876.py\", line 2, in process_msg\n", + " print(f\"Received sample: {sample.key_expr} -> {sample.payload.decode('utf-8')}\")\n", + "AttributeError: 'builtins.ZBytes' object has no attribute 'decode'\n", + "callback error\n", + "Traceback (most recent call last):\n", + " File \"/tmp/ipykernel_214267/187340876.py\", line 2, in process_msg\n", + " print(f\"Received sample: {sample.key_expr} -> {sample.payload.decode('utf-8')}\")\n", + "AttributeError: 'builtins.ZBytes' object has no attribute 'decode'\n", + "callback error\n", + "Traceback (most recent call last):\n", + " File \"/tmp/ipykernel_214267/187340876.py\", line 2, in process_msg\n", + " print(f\"Received sample: {sample.key_expr} -> {sample.payload.decode('utf-8')}\")\n", + "AttributeError: 'builtins.ZBytes' object has no attribute 'decode'\n", + "callback error\n", + "Traceback (most recent call last):\n", + " File \"/tmp/ipykernel_214267/187340876.py\", line 2, in process_msg\n", + " print(f\"Received sample: {sample.key_expr} -> {sample.payload.decode('utf-8')}\")\n", + "AttributeError: 'builtins.ZBytes' object has no attribute 'decode'\n", + "callback error\n", + "Traceback (most recent call last):\n", + " File \"/tmp/ipykernel_214267/187340876.py\", line 2, in process_msg\n", + " print(f\"Received sample: {sample.key_expr} -> {sample.payload.decode('utf-8')}\")\n", + "AttributeError: 'builtins.ZBytes' object has no attribute 'decode'\n", + "callback error\n", + "Traceback (most recent call last):\n", + " File \"/tmp/ipykernel_214267/187340876.py\", line 2, in process_msg\n", + " print(f\"Received sample: {sample.key_expr} -> {sample.payload.decode('utf-8')}\")\n", + "AttributeError: 'builtins.ZBytes' object has no attribute 'decode'\n", + "callback error\n", + "Traceback (most recent call last):\n", + " File \"/tmp/ipykernel_214267/187340876.py\", line 2, in process_msg\n", + " print(f\"Received sample: {sample.key_expr} -> {sample.payload.decode('utf-8')}\")\n", + "AttributeError: 'builtins.ZBytes' object has no attribute 'decode'\n", + "callback error\n", + "Traceback (most recent call last):\n", + " File \"/tmp/ipykernel_214267/187340876.py\", line 2, in process_msg\n", + " print(f\"Received sample: {sample.key_expr} -> {sample.payload.decode('utf-8')}\")\n", + "AttributeError: 'builtins.ZBytes' object has no attribute 'decode'\n", + "callback error\n", + "Traceback (most recent call last):\n", + " File \"/tmp/ipykernel_214267/187340876.py\", line 2, in process_msg\n", + " print(f\"Received sample: {sample.key_expr} -> {sample.payload.decode('utf-8')}\")\n", + "AttributeError: 'builtins.ZBytes' object has no attribute 'decode'\n" + ] + } + ], + "source": [ + "session.declare_subscriber(\"**\", process_msg)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b984b267", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "base", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.18" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/tests/extra/python/agent_test.py b/tests/extra/python/agent_test.py index 7e35ea79..305dd2f8 100644 --- a/tests/extra/python/agent_test.py +++ b/tests/extra/python/agent_test.py @@ -29,7 +29,7 @@ def count(sample): with zenoh.open(zenoh.Config()) as session: with session.declare_subscriber( - f"**/action/success/**/group/{group}/**/topic/pod_job/**", count + f"**/event/success/**/group/{group}/**/topic/pod_job/**", count ) as subscriber: await asyncio.sleep(20) # wait for results @@ -45,7 +45,7 @@ async def main(client, agent, test_dir, namespace_lookup, pod_jobs): available_store=LocalFileStore(directory=f"{test_dir}/store"), ), ) - await asyncio.sleep(5) # ensure service ready + await asyncio.sleep(1) # ensure service ready try: await client.start_pod_jobs(pod_jobs=pod_jobs) @@ -90,7 +90,7 @@ async def main(client, agent, test_dir, namespace_lookup, pod_jobs): output_spec={}, source_commit_url="https://github.com/user/simple", recommended_cpus=0.1, - recommended_memory=10 << 20, + recommended_memory=128 << 20, required_gpu=None, ), input_packet={}, @@ -99,7 +99,7 @@ async def main(client, agent, test_dir, namespace_lookup, pod_jobs): path=".", ), cpu_limit=1, - memory_limit=10 << 20, + memory_limit=128 << 20, env_vars=None, namespace_lookup=namespace_lookup, ) From b8c2de2ad4f55e09029ebf315fd803eb11b798ef Mon Sep 17 00:00:00 2001 From: Synicix Date: Fri, 15 Aug 2025 06:38:20 +0000 Subject: [PATCH 26/65] Update rust version --- .github/workflows/tests.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index fc9c207a..5e60118e 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -17,7 +17,7 @@ jobs: - name: Install Rust + components uses: actions-rust-lang/setup-rust-toolchain@v1 with: - toolchain: 1.87 + toolchain: 1.89 components: rustfmt,clippy - name: Install code coverage uses: taiki-e/install-action@cargo-llvm-cov From acbd9679743228f53bbcf602e6c30a5c9ad7a93f Mon Sep 17 00:00:00 2001 From: Synicix Date: Fri, 15 Aug 2025 08:40:44 +0000 Subject: [PATCH 27/65] Remove into iter() --- src/core/operator.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/operator.rs b/src/core/operator.rs index 72bdf1fb..c10bcbc7 100644 --- a/src/core/operator.rs +++ b/src/core/operator.rs @@ -41,7 +41,7 @@ impl Operator for JoinOperator { .filter_map(|(parent_stream, parent_packets)| { (parent_stream != &stream_name).then_some(parent_packets.clone()) }) - .chain(vec![vec![packet.clone()]].into_iter()) + .chain(vec![vec![packet.clone()]]) .collect::>(); drop(received_packets); From ae137edcf9952377ae34fd5bfbbee55abb9ae5ec Mon Sep 17 00:00:00 2001 From: Synicix Date: Fri, 15 Aug 2025 08:43:23 +0000 Subject: [PATCH 28/65] Fix old lint that doesn't apply anymore, was hidden by rust analyzer --- src/core/pipeline_runner.rs | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/core/pipeline_runner.rs b/src/core/pipeline_runner.rs index ad100f6c..b21ab697 100644 --- a/src/core/pipeline_runner.rs +++ b/src/core/pipeline_runner.rs @@ -769,7 +769,6 @@ impl PodProcessor { } } -#[expect(clippy::excessive_nesting, reason = "Nesting is manageable")] #[async_trait] impl NodeProcessor for PodProcessor { async fn process_incoming_packet( @@ -865,7 +864,6 @@ impl OperatorProcessor { } } -#[expect(clippy::excessive_nesting, reason = "Nesting is manageable")] #[async_trait] impl NodeProcessor for OperatorProcessor { async fn process_incoming_packet( From f5fefb0f271a25c47cb812a94c3103c2652434e7 Mon Sep 17 00:00:00 2001 From: Synicix Date: Fri, 15 Aug 2025 10:51:21 +0000 Subject: [PATCH 29/65] Add PipelineStatus and failure logs --- src/core/pipeline_runner.rs | 91 ++++++++++++++++++++---------------- src/uniffi/model/pipeline.rs | 20 ++++++++ tests/pipeline_runner.rs | 32 ++----------- 3 files changed, 74 insertions(+), 69 deletions(-) diff --git a/src/core/pipeline_runner.rs b/src/core/pipeline_runner.rs index b21ab697..2ea1807f 100644 --- a/src/core/pipeline_runner.rs +++ b/src/core/pipeline_runner.rs @@ -12,7 +12,7 @@ use crate::{ }, model::{ packet::{Packet, PathSet, URI}, - pipeline::{Kernel, PipelineJob, PipelineResult}, + pipeline::{Kernel, PipelineJob, PipelineResult, PipelineStatus}, pod::{Pod, PodJob, PodResult}, }, orchestrator::{ @@ -56,7 +56,7 @@ struct ProcessingFailure { /// Internal representation of a pipeline run, this should not be made public due to the fact that it contains /// internal states and tasks #[derive(Debug)] -struct PipelineRun { +struct PipelineRunInternal { /// `PipelineJob` that this run is associated with assigned_name: String, session: Arc, // Zenoh session for communication @@ -64,13 +64,13 @@ struct PipelineRun { pipeline_job: Arc, // The pipeline job that this run is associated with node_tasks: Arc>>>, // JoinSet of tasks for each node in the pipeline outputs: Arc>>>, // String is the node key, while hash - failure_logs: Arc>>, // Logs of processing failures + failure_logs: Arc>>, // Logs of processing failures failure_logging_task: Arc>>>, // JoinSet of tasks for logging failures namespace: String, namespace_lookup: HashMap, } -impl PipelineRun { +impl PipelineRunInternal { fn make_key_expr(&self, node_id: &str, event: &str) -> String { make_key_expr( &self.agent_client.group, @@ -108,14 +108,12 @@ impl PipelineRun { .context(selector::AgentCommunicationFailure {})?) } - async fn send_err(&self, node_id: &str, err: OrcaError) { - let payload = match serde_json::to_string(&err.to_string()) { - Ok(json) => json, - Err(serialize_err) => serialize_err.to_string(), - }; - + async fn send_err_msg(&self, node_id: &str, err: OrcaError) { self.session - .put(&self.make_key_expr(node_id, FAILURE_KEY_EXP), payload) + .put( + &self.make_key_expr(node_id, FAILURE_KEY_EXP), + format!("Node {node_id}: {err}"), + ) .await .context(selector::AgentCommunicationFailure {}) .unwrap_or_else(|send_err| { @@ -130,23 +128,35 @@ impl PipelineRun { .await .context(selector::AgentCommunicationFailure {})?) } + + async fn get_status(&self) -> PipelineStatus { + if !self.node_tasks.lock().await.is_empty() { + PipelineStatus::Running + } else if self.outputs.read().await.is_empty() { + PipelineStatus::Failed + } else if self.failure_logs.read().await.is_empty() { + PipelineStatus::Succeeded + } else { + PipelineStatus::PartiallySucceeded + } + } } -impl PartialEq for PipelineRun { +impl PartialEq for PipelineRunInternal { fn eq(&self, other: &Self) -> bool { self.pipeline_job.hash == other.pipeline_job.hash } } -impl Eq for PipelineRun {} +impl Eq for PipelineRunInternal {} -impl Hash for PipelineRun { +impl Hash for PipelineRunInternal { fn hash(&self, state: &mut H) { self.pipeline_job.hash.hash(state); } } -impl Display for PipelineRun { +impl Display for PipelineRunInternal { fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult { write!(f, "PipelineRun({})", self.pipeline_job.hash) } @@ -156,7 +166,7 @@ impl Display for PipelineRun { #[derive(Debug, Clone)] pub struct DockerPipelineRunner { agent: Arc, - pipeline_runs: HashMap>, + pipeline_runs: HashMap>, } /// This is an implementation of a pipeline runner that uses Zenoh to communicate between the tasks @@ -192,7 +202,7 @@ impl DockerPipelineRunner { namespace_lookup: &HashMap, ) -> Result { // Create a new pipeline run - let pipeline_run = Arc::new(PipelineRun { + let pipeline_run = Arc::new(PipelineRunInternal { pipeline_job: pipeline_job.into(), outputs: Arc::new(RwLock::new(HashMap::new())), node_tasks: Arc::new(Mutex::new(JoinSet::new())), @@ -327,6 +337,8 @@ impl DockerPipelineRunner { Ok(PipelineResult { pipeline_job: Arc::clone(&pipeline_run.pipeline_job), + failure_logs: pipeline_run.failure_logs.read().await.clone(), + status: pipeline_run.get_status().await, output_packets: pipeline_run.outputs.read().await.clone(), }) } @@ -364,7 +376,7 @@ impl DockerPipelineRunner { async fn create_output_capture_task_for_node( // key_mapping: HashMap, - pipeline_run: Arc, + pipeline_run: Arc, node_id: String, ) -> Result<()> { // Determine which keys we are interested in for the given node_id @@ -403,7 +415,7 @@ impl DockerPipelineRunner { Ok(()) } - async fn failure_capture_task(pipeline_run: Arc) -> Result<()> { + async fn failure_capture_task(pipeline_run: Arc) -> Result<()> { let sub = pipeline_run .session .declare_subscriber(pipeline_run.make_key_expr("*", FAILURE_KEY_EXP)) @@ -413,14 +425,9 @@ impl DockerPipelineRunner { // Listen to any failure messages and write it the logs while let Ok(payload) = sub.recv_async().await { // Extract the message from the payload - let process_failure: ProcessingFailure = - serde_json::from_slice(&payload.payload().to_bytes())?; + let failure_msg: String = serde_json::from_slice(&payload.payload().to_bytes())?; // Store the failure message in the logs - pipeline_run - .failure_logs - .write() - .await - .push(process_failure.clone()); + pipeline_run.failure_logs.write().await.push(failure_msg); } Ok(()) @@ -439,7 +446,7 @@ impl DockerPipelineRunner { /// Will error out if the kernel for the node is not found or if the async fn spawn_node_processing_task( node: PipelineNode, - pipeline_run: Arc, + pipeline_run: Arc, is_input_node: bool, ) -> Result<()> { // Get the node parents @@ -531,10 +538,12 @@ impl DockerPipelineRunner { match result { Ok(Ok(())) => {} // Task completed successfully Ok(Err(err)) => { - pipeline_run.send_err(&node.id, err).await; + pipeline_run.send_err_msg(&node.id, err).await; } Err(err) => { - pipeline_run.send_err(&node.id, OrcaError::from(err)).await; + pipeline_run + .send_err_msg(&node.id, OrcaError::from(err)) + .await; } } } @@ -547,7 +556,7 @@ impl DockerPipelineRunner { /// This is the actual handler for incoming messages for the node async fn event_handler( - pipeline_run: Arc, + pipeline_run: Arc, node_id: String, node_to_sub_to: String, processor: Arc>>, @@ -604,7 +613,7 @@ impl DockerPipelineRunner { /// This task will listen for stop requests on the given key expression async fn abort_request_event_handler( node_processor: Arc>>, - pipeline_run: Arc, + pipeline_run: Arc, ) -> Result<()> { let subscriber = pipeline_run .session @@ -639,14 +648,14 @@ trait NodeProcessor: Send + Sync { /// Processor for Pods /// Currently missing implementation to call agents for actual pod processing struct PodProcessor { - pipeline_run: Arc, + pipeline_run: Arc, node_id: String, pod: Arc, processing_tasks: JoinSet<()>, } impl PodProcessor { - fn new(pipeline_run: Arc, node_id: String, pod: Arc) -> Self { + fn new(pipeline_run: Arc, node_id: String, pod: Arc) -> Self { Self { pipeline_run, node_id, @@ -659,7 +668,7 @@ impl PodProcessor { impl PodProcessor { /// Will handle the creation of the pod job, submission to the agent, listening for completion, and extracting the `output_packet` if successful async fn process_packet( - pipeline_run: Arc, + pipeline_run: Arc, node_id: String, pod: Arc, incoming_packet: HashMap, @@ -809,7 +818,7 @@ impl NodeProcessor for PodProcessor { // Successfully processed the packet, nothing to do } Err(err) => { - pipeline_run.send_err(&node_id, err).await; + pipeline_run.send_err_msg(&node_id, err).await; } } }); @@ -826,7 +835,7 @@ impl NodeProcessor for PodProcessor { { Ok(()) => {} Err(err) => { - self.pipeline_run.send_err(&self.node_id, err).await; + self.pipeline_run.send_err_msg(&self.node_id, err).await; } } } @@ -837,7 +846,7 @@ impl NodeProcessor for PodProcessor { } struct OperatorProcessor { - pipeline_run: Arc, + pipeline_run: Arc, node_id: String, operator: Arc, num_of_parents: usize, @@ -848,7 +857,7 @@ struct OperatorProcessor { impl OperatorProcessor { /// Create a new operator processor pub fn new( - pipeline_run: Arc, + pipeline_run: Arc, node_id: String, operator: Arc, num_of_parents: usize, @@ -891,13 +900,13 @@ impl NodeProcessor for OperatorProcessor match pipeline_run.send_packets(&node_id, &output_packets).await { Ok(()) => {} Err(err) => { - pipeline_run.send_err(&node_id, err).await; + pipeline_run.send_err_msg(&node_id, err).await; } } } } Err(err) => { - pipeline_run.send_err(&node_id, err).await; + pipeline_run.send_err_msg(&node_id, err).await; } } }); @@ -920,7 +929,7 @@ impl NodeProcessor for OperatorProcessor { Ok(()) => {} Err(err) => { - self.pipeline_run.send_err(&self.node_id, err).await; + self.pipeline_run.send_err_msg(&self.node_id, err).await; } } } diff --git a/src/uniffi/model/pipeline.rs b/src/uniffi/model/pipeline.rs index 6d2bd13b..c360454d 100644 --- a/src/uniffi/model/pipeline.rs +++ b/src/uniffi/model/pipeline.rs @@ -130,13 +130,33 @@ impl PipelineJob { } /// Struct to hold the result of a pipeline execution. +#[derive(uniffi::Object, Debug, Clone, Deserialize, Serialize, Display, CloneGetters)] +#[getset(get_clone, impl_attrs = "#[uniffi::export]")] +#[display("{self:#?}")] +#[uniffi::export(Display)] pub struct PipelineResult { /// The pipeline job that was executed. pub pipeline_job: Arc, /// The result of the pipeline execution. pub output_packets: HashMap>, + /// Logs of any failures that occurred during the pipeline execution. + pub failure_logs: Vec, + /// The status of the pipeline execution. + pub status: PipelineStatus, } +/// The status of a pipeline execution. +#[derive(uniffi::Enum, Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +pub enum PipelineStatus { + /// The pipeline is currently running. + Running, + /// The pipeline has completed successfully. + Succeeded, + /// The pipeline has failed. + Failed, + /// The pipeline has partially succeeded. There should be some failure logs + PartiallySucceeded, +} /// A node in a computational pipeline. #[derive(uniffi::Enum, Debug, Clone, Deserialize, Serialize, PartialEq)] pub enum Kernel { diff --git a/tests/pipeline_runner.rs b/tests/pipeline_runner.rs index 07a079d3..76490bc1 100644 --- a/tests/pipeline_runner.rs +++ b/tests/pipeline_runner.rs @@ -21,6 +21,7 @@ use orcapod::{ core::pipeline_runner::DockerPipelineRunner, uniffi::{ error::Result, + model::pipeline::PipelineStatus, orchestrator::{agent::Agent, docker::LocalDockerOrchestrator}, }, }; @@ -69,6 +70,9 @@ async fn basic_run() -> Result<()> { // Check the output packet content assert_eq!(pipeline_result.output_packets["output"].len(), 4); + // Check the status + assert_eq!(pipeline_result.status, PipelineStatus::Succeeded); + // Get all the output file content and read them in let mut output_content = HashSet::new(); @@ -90,31 +94,3 @@ async fn basic_run() -> Result<()> { Ok(()) } - -// #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -// async fn stop() -> Result<()> { -// // Create the test_dir and get the namespace lookup -// let test_dirs = TestDirs::new(&HashMap::from([( -// "default".to_owned(), -// Some( -// "./tests/extra -// /data/", -// ), -// )]))?; - -// let namespace_lookup = test_dirs.namespace_lookup(); - -// let pipeline_job = pipeline_job(&namespace_lookup)?; - -// // Create the runner -// let mut runner = DockerPipelineRunner::new("test".to_owned())?; - -// let pipeline_run = runner -// .start(pipeline_job, "default", &namespace_lookup) -// .await?; - -// // Abort the pipeline run -// runner.stop(&pipeline_run).await?; - -// Ok(()) -// } From 75336deea70dfb659389699a05b65e7ec759ad93 Mon Sep 17 00:00:00 2001 From: Synicix Date: Fri, 15 Aug 2025 10:52:25 +0000 Subject: [PATCH 30/65] Remove notebook --- test.ipynb | 301 ----------------------------------------------------- 1 file changed, 301 deletions(-) delete mode 100644 test.ipynb diff --git a/test.ipynb b/test.ipynb deleted file mode 100644 index 57443778..00000000 --- a/test.ipynb +++ /dev/null @@ -1,301 +0,0 @@ -{ - "cells": [ - { - "cell_type": "code", - "execution_count": null, - "id": "d85bc53e", - "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "usage: ipykernel_launcher.py [-h] test_dir\n", - "ipykernel_launcher.py: error: the following arguments are required: test_dir\n" - ] - }, - { - "ename": "SystemExit", - "evalue": "2", - "output_type": "error", - "traceback": [ - "An exception has occurred, use %tb to see the full traceback.\n", - "\u001b[0;31mSystemExit\u001b[0m\u001b[0;31m:\u001b[0m 2\n" - ] - } - ], - "source": [ - "#!/usr/bin/env python3\n", - "\n", - "# build: maturin develop --uv\n", - "# debugger: select \"Python: Debug agent test\" + F5\n", - "# script: /path/to/this/test/file.py /path/to/directory (must exist, will create subdirectory)\n", - "import shutil\n", - "from pathlib import Path\n", - "import argparse\n", - "import asyncio\n", - "import zenoh\n", - "from orcapod import (\n", - " Agent,\n", - " AgentClient,\n", - " LocalDockerOrchestrator,\n", - " LocalFileStore,\n", - " PodJob,\n", - " Uri,\n", - " Pod,\n", - " Annotation,\n", - ")\n", - "\n", - "\n", - "async def verify(group, pod_job_count):\n", - " counter = 0\n", - "\n", - " def count(sample):\n", - " nonlocal counter\n", - " counter += 1\n", - "\n", - " with zenoh.open(zenoh.Config()) as session:\n", - " with session.declare_subscriber(f\"**\", count) as subscriber:\n", - " await asyncio.sleep(30) # wait for results\n", - "\n", - " if counter != pod_job_count:\n", - " raise Exception(f\"Unexpected successful pod job count: {counter}.\")\n", - "\n", - "\n", - "async def main(client, agent, test_dir, namespace_lookup, pod_jobs):\n", - " watcher = asyncio.create_task(client.watch(key_expr=\"**\"))\n", - " worker = asyncio.create_task(\n", - " agent.start(\n", - " namespace_lookup=namespace_lookup,\n", - " available_store=LocalFileStore(directory=f\"{test_dir}/store\"),\n", - " ),\n", - " )\n", - " await asyncio.sleep(1) # ensure service ready\n", - "\n", - " try:\n", - " await client.start_pod_jobs(pod_jobs=pod_jobs)\n", - " await verify(client.group(), len(pod_jobs))\n", - " finally:\n", - " shutil.rmtree(test_dir)\n", - "\n", - "\n", - "if __name__ == \"__main__\":\n", - " parser = argparse.ArgumentParser(\n", - " description=\"Basic tests for orcapod's Python API.\"\n", - " )\n", - " parser.add_argument(\"test_dir\", help=\"Root directory for tests.\")\n", - " args = parser.parse_args()\n", - "\n", - " test_dir = f\"{args.test_dir}/{Path(__file__).stem}\"\n", - "\n", - " group = \"test\"\n", - " host = \"alpha\"\n", - "\n", - " client = AgentClient(group=group, host=host)\n", - " agent = Agent(group=group, host=host, orchestrator=LocalDockerOrchestrator())\n", - "\n", - " namespace_lookup = {\n", - " \"default\": f\"{test_dir}/default\",\n", - " }\n", - " pod_jobs = [\n", - " PodJob(\n", - " annotation=Annotation(\n", - " name=\"simple\",\n", - " description=\"This is an example pod job.\",\n", - " version=f\"0.{i}.0\",\n", - " ),\n", - " pod=Pod(\n", - " annotation=None,\n", - " image=\"ghcr.io/colinianking/stress-ng:e2f96874f951a72c1c83ff49098661f0e013ac40\",\n", - " command=\"stress-ng --cpu 1 --cpu-load 100 --timeout 5 --metrics-brief\".split(\n", - " \" \"\n", - " ),\n", - " input_spec={},\n", - " output_dir=\"/tmp/output\",\n", - " output_spec={},\n", - " source_commit_url=\"https://github.com/user/simple\",\n", - " recommended_cpus=0.1,\n", - " recommended_memory=10 << 20,\n", - " required_gpu=None,\n", - " ),\n", - " input_packet={},\n", - " output_dir=Uri(\n", - " namespace=\"default\",\n", - " path=\".\",\n", - " ),\n", - " cpu_limit=1,\n", - " memory_limit=10 << 20,\n", - " env_vars=None,\n", - " namespace_lookup=namespace_lookup,\n", - " )\n", - " for i in range(1, 5)\n", - " ]\n", - "\n", - " asyncio.run(main(client, agent, test_dir, namespace_lookup, pod_jobs))" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "b06f2835", - "metadata": {}, - "outputs": [ - { - "ename": "NameError", - "evalue": "name 'count' is not defined", - "output_type": "error", - "traceback": [ - "\u001b[0;31m---------------------------------------------------------------------------\u001b[0m", - "\u001b[0;31mNameError\u001b[0m Traceback (most recent call last)", - "Cell \u001b[0;32mIn[3], line 2\u001b[0m\n\u001b[1;32m 1\u001b[0m \u001b[38;5;28;01mwith\u001b[39;00m zenoh\u001b[38;5;241m.\u001b[39mopen(zenoh\u001b[38;5;241m.\u001b[39mConfig()) \u001b[38;5;28;01mas\u001b[39;00m session:\n\u001b[0;32m----> 2\u001b[0m \u001b[38;5;28;01mwith\u001b[39;00m session\u001b[38;5;241m.\u001b[39mdeclare_subscriber(\u001b[38;5;124mf\u001b[39m\u001b[38;5;124m\"\u001b[39m\u001b[38;5;124m**\u001b[39m\u001b[38;5;124m\"\u001b[39m, count) \u001b[38;5;28;01mas\u001b[39;00m subscriber:\n\u001b[1;32m 3\u001b[0m \u001b[38;5;28;01mawait\u001b[39;00m asyncio\u001b[38;5;241m.\u001b[39msleep(\u001b[38;5;241m20\u001b[39m) \u001b[38;5;66;03m# wait for results\u001b[39;00m\n", - "\u001b[0;31mNameError\u001b[0m: name 'count' is not defined" - ] - } - ], - "source": [ - "with zenoh.open(zenoh.Config()) as session:\n", - " with session.declare_subscriber(\n", - " f\"**\",\n", - " ) as subscriber:\n", - " await asyncio.sleep(20) # wait for results" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "1588e7de", - "metadata": {}, - "outputs": [], - "source": [ - "session = zenoh.open(zenoh.Config())" - ] - }, - { - "cell_type": "code", - "execution_count": 6, - "id": "6c179488", - "metadata": {}, - "outputs": [], - "source": [ - "def process_msg(sample):\n", - " print(f\"Received sample: {sample.key_expr} -> {sample.payload.decode('utf-8')}\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "6100146d", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "Subscriber { inner: SubscriberInner { session: Session { id: b60e73cc95cd5c0928a92854862a3b8d }, id: 3, key_expr: ke`**`, kind: Subscriber, undeclare_on_drop: false }, handler: Py(0x61fc06ea4c10) }" - ] - }, - "execution_count": 7, - "metadata": {}, - "output_type": "execute_result" - }, - { - "name": "stderr", - "output_type": "stream", - "text": [ - "callback error\n", - "Traceback (most recent call last):\n", - " File \"/tmp/ipykernel_214267/187340876.py\", line 2, in process_msg\n", - " print(f\"Received sample: {sample.key_expr} -> {sample.payload.decode('utf-8')}\")\n", - "AttributeError: 'builtins.ZBytes' object has no attribute 'decode'\n", - "callback error\n", - "Traceback (most recent call last):\n", - " File \"/tmp/ipykernel_214267/187340876.py\", line 2, in process_msg\n", - " print(f\"Received sample: {sample.key_expr} -> {sample.payload.decode('utf-8')}\")\n", - "AttributeError: 'builtins.ZBytes' object has no attribute 'decode'\n", - "callback error\n", - "Traceback (most recent call last):\n", - " File \"/tmp/ipykernel_214267/187340876.py\", line 2, in process_msg\n", - " print(f\"Received sample: {sample.key_expr} -> {sample.payload.decode('utf-8')}\")\n", - "AttributeError: 'builtins.ZBytes' object has no attribute 'decode'\n", - "callback error\n", - "Traceback (most recent call last):\n", - " File \"/tmp/ipykernel_214267/187340876.py\", line 2, in process_msg\n", - " print(f\"Received sample: {sample.key_expr} -> {sample.payload.decode('utf-8')}\")\n", - "AttributeError: 'builtins.ZBytes' object has no attribute 'decode'\n", - "callback error\n", - "Traceback (most recent call last):\n", - " File \"/tmp/ipykernel_214267/187340876.py\", line 2, in process_msg\n", - " print(f\"Received sample: {sample.key_expr} -> {sample.payload.decode('utf-8')}\")\n", - "AttributeError: 'builtins.ZBytes' object has no attribute 'decode'\n", - "callback error\n", - "Traceback (most recent call last):\n", - " File \"/tmp/ipykernel_214267/187340876.py\", line 2, in process_msg\n", - " print(f\"Received sample: {sample.key_expr} -> {sample.payload.decode('utf-8')}\")\n", - "AttributeError: 'builtins.ZBytes' object has no attribute 'decode'\n", - "callback error\n", - "Traceback (most recent call last):\n", - " File \"/tmp/ipykernel_214267/187340876.py\", line 2, in process_msg\n", - " print(f\"Received sample: {sample.key_expr} -> {sample.payload.decode('utf-8')}\")\n", - "AttributeError: 'builtins.ZBytes' object has no attribute 'decode'\n", - "callback error\n", - "Traceback (most recent call last):\n", - " File \"/tmp/ipykernel_214267/187340876.py\", line 2, in process_msg\n", - " print(f\"Received sample: {sample.key_expr} -> {sample.payload.decode('utf-8')}\")\n", - "AttributeError: 'builtins.ZBytes' object has no attribute 'decode'\n", - "callback error\n", - "Traceback (most recent call last):\n", - " File \"/tmp/ipykernel_214267/187340876.py\", line 2, in process_msg\n", - " print(f\"Received sample: {sample.key_expr} -> {sample.payload.decode('utf-8')}\")\n", - "AttributeError: 'builtins.ZBytes' object has no attribute 'decode'\n", - "callback error\n", - "Traceback (most recent call last):\n", - " File \"/tmp/ipykernel_214267/187340876.py\", line 2, in process_msg\n", - " print(f\"Received sample: {sample.key_expr} -> {sample.payload.decode('utf-8')}\")\n", - "AttributeError: 'builtins.ZBytes' object has no attribute 'decode'\n", - "callback error\n", - "Traceback (most recent call last):\n", - " File \"/tmp/ipykernel_214267/187340876.py\", line 2, in process_msg\n", - " print(f\"Received sample: {sample.key_expr} -> {sample.payload.decode('utf-8')}\")\n", - "AttributeError: 'builtins.ZBytes' object has no attribute 'decode'\n", - "callback error\n", - "Traceback (most recent call last):\n", - " File \"/tmp/ipykernel_214267/187340876.py\", line 2, in process_msg\n", - " print(f\"Received sample: {sample.key_expr} -> {sample.payload.decode('utf-8')}\")\n", - "AttributeError: 'builtins.ZBytes' object has no attribute 'decode'\n" - ] - } - ], - "source": [ - "session.declare_subscriber(\"**\", process_msg)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "b984b267", - "metadata": {}, - "outputs": [], - "source": [] - } - ], - "metadata": { - "kernelspec": { - "display_name": "base", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.10.18" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} From f9adf0a3afd82ddadbadc521155f966c37d1c581 Mon Sep 17 00:00:00 2001 From: Synicix Date: Fri, 15 Aug 2025 11:03:15 +0000 Subject: [PATCH 31/65] Remove stale functions --- src/core/pipeline_runner.rs | 20 -------------------- 1 file changed, 20 deletions(-) diff --git a/src/core/pipeline_runner.rs b/src/core/pipeline_runner.rs index 2ea1807f..a93fff8f 100644 --- a/src/core/pipeline_runner.rs +++ b/src/core/pipeline_runner.rs @@ -142,26 +142,6 @@ impl PipelineRunInternal { } } -impl PartialEq for PipelineRunInternal { - fn eq(&self, other: &Self) -> bool { - self.pipeline_job.hash == other.pipeline_job.hash - } -} - -impl Eq for PipelineRunInternal {} - -impl Hash for PipelineRunInternal { - fn hash(&self, state: &mut H) { - self.pipeline_job.hash.hash(state); - } -} - -impl Display for PipelineRunInternal { - fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult { - write!(f, "PipelineRun({})", self.pipeline_job.hash) - } -} - /// Runner that uses a docker agent to run pipelines #[derive(Debug, Clone)] pub struct DockerPipelineRunner { From 9795458762617732fe1b9268b390217b370ba4d9 Mon Sep 17 00:00:00 2001 From: Synicix Date: Fri, 15 Aug 2025 11:11:59 +0000 Subject: [PATCH 32/65] Remove unused imports --- src/core/pipeline_runner.rs | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/core/pipeline_runner.rs b/src/core/pipeline_runner.rs index a93fff8f..b555a7f4 100644 --- a/src/core/pipeline_runner.rs +++ b/src/core/pipeline_runner.rs @@ -28,8 +28,6 @@ use serde_yaml::Serializer; use snafu::{OptionExt as _, ResultExt as _}; use std::{ collections::{BTreeMap, HashMap}, - fmt::{Display, Formatter, Result as FmtResult}, - hash::{Hash, Hasher}, path::PathBuf, sync::Arc, }; From 4fae330335bb9517114c7bdf2865ad84dd890e52 Mon Sep 17 00:00:00 2001 From: Synicix Date: Thu, 28 Aug 2025 06:11:35 +0000 Subject: [PATCH 33/65] Merge remote-tracking branch 'upstream/dev' into logging --- src/core/error.rs | 1 + src/core/operator.rs | 2 +- src/uniffi/error.rs | 7 +++ src/uniffi/model/pod.rs | 4 ++ src/uniffi/orchestrator/docker.rs | 61 ++++++++++++++++++++++++++- src/uniffi/orchestrator/mod.rs | 3 ++ tests/extra/data/output/result2.jpeg | Bin 152661 -> 152666 bytes tests/fixture/mod.rs | 1 + tests/model.rs | 5 ++- 9 files changed, 80 insertions(+), 4 deletions(-) diff --git a/src/core/error.rs b/src/core/error.rs index c7b0e3e8..3de3cb2f 100644 --- a/src/core/error.rs +++ b/src/core/error.rs @@ -121,6 +121,7 @@ impl fmt::Debug for OrcaError { fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { match &self.kind { Kind::AgentCommunicationFailure { backtrace, .. } + | Kind::FailedToExtractRunInfo { backtrace, .. } | Kind::IncompletePacket { backtrace, .. } | Kind::InvalidFilepath { backtrace, .. } | Kind::MissingInfo { backtrace, .. } diff --git a/src/core/operator.rs b/src/core/operator.rs index 6944001a..64db6206 100644 --- a/src/core/operator.rs +++ b/src/core/operator.rs @@ -40,7 +40,7 @@ impl Operator for JoinOperator { .filter_map(|(parent_stream, parent_packets)| { (parent_stream != &stream_name).then_some(parent_packets.clone()) }) - .chain(vec![vec![packet.clone()]].into_iter()) + .chain(vec![vec![packet.clone()]]) .collect::>(); drop(received_packets); diff --git a/src/uniffi/error.rs b/src/uniffi/error.rs index f6e63a17..5ce5c832 100644 --- a/src/uniffi/error.rs +++ b/src/uniffi/error.rs @@ -30,6 +30,13 @@ pub(crate) enum Kind { source: Box, backtrace: Option, }, + #[snafu(display( + "Failed to extract run info from the container image file: {container_name}." + ))] + FailedToExtractRunInfo { + container_name: String, + backtrace: Option, + }, #[snafu(display("Incomplete {kind} packet. Missing `{missing_keys:?}` keys."))] IncompletePacket { kind: String, diff --git a/src/uniffi/model/pod.rs b/src/uniffi/model/pod.rs index 62cd006f..affc4dbf 100644 --- a/src/uniffi/model/pod.rs +++ b/src/uniffi/model/pod.rs @@ -205,6 +205,8 @@ pub struct PodResult { pub created: u64, /// Time in epoch when terminated in seconds. pub terminated: u64, + /// Logs about stdout and stderr, where stderr is append at the end + pub logs: String, } impl PodResult { @@ -221,6 +223,7 @@ impl PodResult { created: u64, terminated: u64, namespace_lookup: &HashMap, + logs: String, ) -> Result { let output_packet = pod_job .pod @@ -276,6 +279,7 @@ impl PodResult { status, created, terminated, + logs, }; Ok(Self { hash: hash_buffer(to_yaml(&pod_result_no_hash)?), diff --git a/src/uniffi/orchestrator/docker.rs b/src/uniffi/orchestrator/docker.rs index a08137b3..9baa301e 100644 --- a/src/uniffi/orchestrator/docker.rs +++ b/src/uniffi/orchestrator/docker.rs @@ -12,7 +12,9 @@ use crate::{ use async_trait; use bollard::{ Docker, - container::{RemoveContainerOptions, StartContainerOptions, WaitContainerOptions}, + container::{ + LogOutput, LogsOptions, RemoveContainerOptions, StartContainerOptions, WaitContainerOptions, + }, errors::Error::DockerContainerWaitError, image::{CreateImageOptions, ImportImageOptions}, }; @@ -241,8 +243,65 @@ impl Orchestrator for LocalDockerOrchestrator { ), })?, namespace_lookup, + self.get_logs(pod_run).await?, ) } + + async fn get_logs(&self, pod_run: &PodRun) -> Result { + let mut std_out = Vec::new(); + let mut std_err = Vec::new(); + + self.api + .logs::( + &pod_run.assigned_name, + Some(LogsOptions { + stdout: true, + stderr: true, + ..Default::default() + }), + ) + .try_collect::>() + .await? + .iter() + .for_each(|log_output| match log_output { + LogOutput::StdOut { message } => { + std_out.extend(message.to_vec()); + } + LogOutput::StdErr { message } => { + std_err.extend(message.to_vec()); + } + LogOutput::StdIn { .. } | LogOutput::Console { .. } => { + // Ignore stdin logs, as they are not relevant for our use case + } + }); + + let mut logs = String::from_utf8_lossy(&std_out).to_string(); + if !std_err.is_empty() { + logs.push_str("\nSTDERR:\n"); + logs.push_str(&String::from_utf8_lossy(&std_err)); + } + + // Check for errors in the docker state, if exist, attach it to logs + // This is for when the container exits immediately due to a bad command or similar + let error = self + .api + .inspect_container(&pod_run.assigned_name, None) + .await? + .state + .context(selector::FailedToExtractRunInfo { + container_name: &pod_run.assigned_name, + })? + .error + .context(selector::FailedToExtractRunInfo { + container_name: &pod_run.assigned_name, + })?; + + if !error.is_empty() { + logs.push_str(&error); + } + + Ok(logs) + } } #[uniffi::export] diff --git a/src/uniffi/orchestrator/mod.rs b/src/uniffi/orchestrator/mod.rs index f692546e..c00d707f 100644 --- a/src/uniffi/orchestrator/mod.rs +++ b/src/uniffi/orchestrator/mod.rs @@ -168,6 +168,9 @@ pub trait Orchestrator: Send + Sync + fmt::Debug { namespace_lookup: &HashMap, pod_run: &PodRun, ) -> Result; + + /// Get the logs for a specific pod run. + async fn get_logs(&self, pod_run: &PodRun) -> Result; } /// Orchestration execution agent daemon and client. pub mod agent; diff --git a/tests/extra/data/output/result2.jpeg b/tests/extra/data/output/result2.jpeg index 15e2e4b3adbabb291543ae123394c31ed2fe758f..ddfd76c2e21fa6bdcb78a04d536be85440ec5fe9 100644 GIT binary patch delta 122657 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

Bx+0%NQ<(ezf)v*# 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 z z_Ey5h7Q&cB8UY`sV0pCjE#!n)#knB{7c$bXJ3u-mH6^f*Yxc_-+X2Jl72D6^N%(Mo zhcG}5fUxAe^_8F{xu3pn4VtUBw=D~OFzmJfSW{q9Dc^JMMT(sL;=Q6lc)WQ$@ExRT zktfsD6bh#8L)R?*8GAgOq$f>{*nWLwSPht3zxvx)rl;rYfpHMp>Leho=r&S^bGK&d z-~1Zx4C-BKcb53Lh9FIc^a*#BoEbIH14QiE_x}nL9;!(VQPUf@O&p%$Ct{U4OS#Qnyn9r$w>7+le+>)I5-UGR z$p;ULK4E$^)EbgI1Ac^`v#WErsWqg@^|15b-_>&he;UJMti@|claa;EIG)N|9{wD) z%SH2;^y5gi&M(xKfk1-}5IEdBLsJ|pt^RBh-=i79ES2jf+^YpI#Gb8G!Z4t34CEtm zH^(}xMH&<~`KsC1B=~=v5^WA<`^=M3v^juQ?)n;GFDaa54nt?OY;5j{i;f(^6aT%m zH&y7oEx{~dsDC8va!>~M2@rp~Q-p^~T~^E^TYU-VQEWGQh}aME06y|iDvn+Ff=nq5&DFBfcdhoM67#{=6VYy$miSqs!s zA>IY!Ka{vzgJGr#3`6I^`giSm+S^g?c6A8iK5@462wU82L;l%D=bt#lvsvV=F5Z}^ zE`o?6m)a1GYk=L0-jDH7JVVmcr}0)x+f}@**CK>D^PZ+4`WLw9Itj+|ajv>y6M2=J zJ{27(%Sjusf~p5fEIq=mIij!B{{O4&R+=F?u=d$92HE|34^&v`bi-p7MN+M*!F!~} zRx5DGI;7yu*sUgi+jFy{+#Rw}*Fg2+aVUs(`!TA`L2V}2f|7<{Py|Fw2J7{LNz}c*JJElu%}Q`_F+%Q zE+)Dax$OO_V($ibdiom%EyZx3Fwbj8?i~22q}*4izNS#8#^z=1Mp9nhNo|9LTlj^Q zJ#Cq&K6S)9ZadHbq?z{Q6SV>a<1RW&)^Qs~FtAC=_Fl{S{1>Mnc<`fNW?SozA3!X6 z`dQ<#`_T21oSK*dvc{)_&)lWEu{Y1ve%cy6*7!D^!72sKs`EuK zxQ2L^=8<6tx4L-o`LPoIub`E#mX=130BOAkF4)X@fwWk<94FEF4}tklH&cVUJ+;A{ z5gLD5?s!OX00OjJmHdAi>W7!m{Db!!NwDHO2y|N*Sp6*Us^&G=C)#aqHYq~&0hqPGdxDG#gL07b2zc%h8_;6> zjQW(tp&chlidBhE7r^BeI5)lv)PAMr_2}nkga@1pJxdn>BS{=fO(;c@&rWC7E%JC0 z#CaL%5-wMGl_0emdu{viN@8Wer3vf|u)^9vI_*o6iM4P-@k;bc7WBOxnf@KGgEV-j z;$>yprfUE?v(4(fIH}kLu3PCsY}fw~tTeT{6sfJ^KML|e*yk@9cNyAFt>d4W_LHX0 zzNxwvbcvJqU?j}bpm;*Vv}|g6bMU4~FDKcw*~e&dhce>)!vkKEml_E;>`hxfghtW) zH+yL`JB z2Q}oX@xR^$IE>;xfeBuDjzDdG5eqII@|v7Dw5FIMBwfOzb^U)o;ALufX$+d~Os8Fr z{p&JQxPe3Af%wR;$+n_KSJ&^li0l=l*J%=kVqle!ht!XRro*{GM#_gtHRId|ZvWxN z)O3COJg$nO_XjSrOb$P%GQ^fAnxDI*T5!{*r?+BWpUp?B>1TxU>7((JC?h z?8nNo2iPh2Y-AUXa|FkX?A~{}QI|yMP)=ZsN{#}~NR_`9^h9DihuFaQpG(I(_ef1d zpVFTen_0rc+to7MPuh!{QaU4rZKn>HQGav@-ThteU6lWAm$Czq zv+=;vS8I+jn4RU0>9Y8TH=&C{SDXzOozk*nZ@j8FbN&?tw$QhR!-GKqD1hn+W!h@{ z>LB1qh){*GL}85Vke z!zJM)5v_{f-Yh}xL9djPN!lPVg20utAtPK7m`YVI8S+qUI6fFo)zb2~Yn*P~p7g z@M=)PYo$-BLg<6H)K&ASkFsft0{1XpjBRI9)F@?pl=tMC_+;umkkHMQF7pLxK?$_X z%TT%Jcun}ULW4h4HiVhwrvwXC;A!krt=x-z4jMA#p;j2{hQ@{3;J22gZ~g&orK@%f zK^q;_P-NDDcdeO5jD1IrF<3ti_)0o*n(AmYv>@#NA4O*#4%PdHaaoc*30bEiDqHs5 zq=gV#?M%qNW#7h}B4nMA6fu>Mb+V3q?2;sVb~Co@GnR2=X1>4k``6`ix#rAy-sgRu z`~KX9v0;HdjfY#h#}-I^s4Goa<2LLxpWGIKokXf9s&+l}%!+t(e|EOxqvO5sDn|ck zF~#2#o2soj0co#(o_toJpRDc>*iQA@gWG|&MW`OgYHMTyOgft@0=i=@zv6-;|J(z< zcq(~PM(loeMJ;n1(**GM+}6}r)@x(ute?Dk<@fNvfxnkPjpZJhBy@wKK8Uu+Xe`01pProYb7 zxU^r6Pr-}UVB`EJfxmT)k9(?#v!3P(ope!%5oxUn+Z}%Zj9qW&*IeY^6Wr=2nA+Y4 zFA#Rv!Bso@1Q~{=UkU?^$Cey(9>;7|{uqCKe&I%`P@ib4l4Z9;a zFl=E-6mMDv@gUYx&Jv;#C`z@mp7!XgfDVjv30knIn$bWHFS_^|gMpxbWi!_7X(2~DBOn!yi-=udK!71LP-jQ9|RBb_L4;i{Fs+Py^RyT2WuZk$wS1~)7213wW6{H^#m#tlukri>R@%kM0L%QMjqv2ykIWvR^iUi-mE`Lj}FD>m7{oC z&~G5+$sk57DA?ZiVN(=h6Hw+T)ipPlm6yGAzwfE^gS`CQ=5gP0C`G3zUiDO6xhYGLCovLr@6+K%6YD69guAE+~R$Npi zA^B(pxkL*dKBFctp3>HoSB5Yvkza@y4UPqvZ`cYnirFSM((G8cIfu}R3Ryw zjm+9X^Rp$htOH*o2ievr9O9qcdo_8^7MwiE^Hb?cHPwh#0eD*B31Xo00subJJ6nH2 zo@$P@H}o&JyMa2KJpZcdmf1B)=biu-;Fe<`r@WBXW8G<3-e|`-UfV`_1nv%>7C-LL zlc@zoNPHb`P|4vxcxPRM=CGML;a6D5a2!%}`FmX*a6`ZS=-mQ?QAHC)g_cHuqRG}# z@{sNEDcQWsPoTbuyqe;>`xK8UnZAiRu(c>f`Xyys;3|6MK5#Kx26`RJh3l*`YTQpQ3RyYN&+&U=P5RXiF*~U0 z&S6cA$N4iFOPDsxpKzWs9mQiu)G!;_%c4>c&#iY3VFK9&jZRwHPAJ#e_K0!biyAxd z&Kp~Fy3(!RA!88ic=eYlU>1Q?sT8;MHVH;Mj;h2Zz9#9)6Rd_rRNE_8&kzO13JF4J8jEtlylktBnq$ zuGGAKU2XeA|9s2`QK#1ZyoHjM@uG|R-p(Gr@=I{Z%Oxg?`v3zw^)s!qRooV6H=+7V zZ(UiyFkGcx1B3Q%7Ipo2u-BU|`_=c7(=}AD_{fW2+x`=&c6z!5-RUWrat>o_peEMW=2L&*N8du(++- zF8btr-3JVlj!{064OCokBk%N>pVO9(X=ZiP8%qcrpdF*Z!)kQ;uRpWVtN}WMlr>u8 z5~xe$VqHLNZhewYYE=gDLE;>w*N%0Ap>v}d0lKL#f>H|kd_ z0Ba#{H+PXTGda9(XmaxPN?pqW?f^ z?T4nTV4B=rU=q??2?Njf=IP`38dypOQMxDh#$+KGks`k^P!Z$#T-n;=T4i@jnO8$7 zgZRz2zV$F;P|zyAo7}k=8JTt|cDV%TE6|AQvHIXPAWf@Kbnqu*dtjtIFnW4HA@Drb(EIJeBjx+s^-;mS2 zv#hnN6@I2E(d4NtT-VsQzzjbz6Zr|_`2u&9)zeB?2%&d>|9I9*Yw%I^xGdl}DvP~P zUss>tg3Ft`-goud_h83Sm`%E-wa7^*E!6e%LtBZ$+_~gYkB%$%>VGUU&}7uDQngFn zxT&$hp(Mgt5))NY-3!RQ78!2*gz1hLlcg#k9{Z?DVMUcWKU#W40@~6YJql{bXgziMPjXa9%x;Fu!g9!7b&U?4UtHCI0rDia15m zo98>J{CSn7lW%^i(VTA$qBT%mNOVNvJP#FMrKF~RKEBoZ+qZYhQ3s}YGQe3^ERu6# z7{;lp4e-u~^+RK`1i8<9gvL58pFFZmjs=b(-&geGlK^uYaFtR{dtHNw#_(C*13gOq zhNO921$^<9t}SXAYrFq3hz`ZF2FZ=RUU0(md{9&nG!ctCEbW+R#ri@;x8N<@udvS$$5ngeC_Q=**eLyx;;(UhhGoy!j@7l0;!N5`^idIo`rw9xrr4P1c+Ya*Cf#~kQnh8Os zXBXtke=GE*^=Ev?nh2S^s+_C|)cR9&)qi>DFL5)YwXt-o;b{K!Gj5NpiBHe_X4e7% zhX-C(8$Yr0_JX!B0IE#ag2zVSt_s{p@g^LvXX}|^=i1RT=R*cQr0F;}sznB=Zr}w0 z_rX}iiDtM|C6utFXvN5YtGnMyT(0jhJbjdl9&Wu-O?%xOO6e@0qmMYKMg_|XoPsdx zgW?}vpFgMAD}3b#(_{knGL*jz&3_Vss-nakh{a**Yz*f4*#T}q$NjJU0+r&YV2!Sq zo)XX+3{I_!u^JPS75VW!gXv``smzgy!g1Zr?L5EChYyPkr;PT^`=cT$dMVKMIG>VA zD|7JU0xjlt2)NGaHyn|n{))omMdIO)Px&)mTdWAjN|14zYaiF}=loOv;Du#=r=Y{> z;j)v^FyMAJM`6mlqe;y)(I`@Yndd|d;0@)&OE=V4juz(VkCorI@nN-keOr->>b`wF zElM%_CkfLl#|}@5n@dLDyiV zhDhV#>XDKMDgQArk;n1?vQ%L9*B|m2iAG#ArjaJL9U=YV{|gExuPx~|Na2-n;~NQ+ zP%9GXONpqT&)@igdhpx$KL+{nU&uPm2cB?F{jc=SIqA4x8xKZR5iwKk*Di;f{%k$- zenut0V5YLl$vEx%{lpQnS^@!{kSB!h$de_-reX^muDEYi$lU@ee{!Ev{NQ4KGgIQ+ zhJ!woaR-|`?o>zcKmqCAII_X}U>CV&IP`kGhzxg=cy=rBx$(4oK>vkBQOi|pd9Rx) z?LBo@#ec~dX_uCJ=h)}W(0C*VO3C)d@6D?NN|_r9`0KJv_YdoS|Et@L)V*mrrgy

d{L1!=Z!75F7Dnk~uLNsShd=S|Ypxc}I1$U7igI8FXMzbs7#{ z^#?5sdwe1|;Ow5+FS8#hwY#~=Hp$b2oz8BZ2<#m=sIa7*nkD-E$!XiTa=s|u(bih) z$os2-;W3~oCSyxHn*qiRo1gN^=KO}>-}99lQ#TAh>s_Vty7Ju9DLudKi9JKUWzD9+ zT)S2Qv=Y92xccC4)B$o@DOo~TrN+aChOnjVYM4Vso)}ks%!%ru^MVhH!kplzskgwk zbkA1NVd{>&tw~Oel+pI!g^%WM7N>XD_ltuDfk|@$B1Olicq1LmC$j9ksJKKHO!3=R zH`T5&6sXIGHP%`(FlZ6$F)`W6^L#q?5TixVx~6VaCGr_kabZn%nOin!!~Ct$MbW3q z>E~Yj84um+U2IXBUML@NH$OWZmShm!AC?h9n(+U4I&~vXb00fLZb-2^Y0uW;cD7s` z*s96PfU2mrhNyKg2C(1ql>f+vn0-UAh^J*Jg04R2g<*br4#oP z3NEg!c<4Xn5Ng{!``UzaS7tlvn%#WkWO+lNVjH{W!!$&mo&bm-`kexEZjce38)G%& z)GIEGQ=T3j-*0TC&)p3meOrjhreJ_+b;{u|N*Jm74UAk!3;jfWNDj$kDa2f1lv9%; z?A(2K!BOZ^V6?d><=Y^S16r5G%_Vr8($ajWr@-++_TPgKe5=RCvytBZ_}Yn-TEw*B z;w@;3f$dZfyo2Y`VGA(t%C%tNv{@;%H}*(ui*x@hq4YY+(9^FSCIoI!5&&AsK0F2? zBv+;~jg1i(6|VLWP;xdO+qIb+Um4>*m8jBVsBM)v4xU(;XF&>pI^(EwEhgpKW;N=K z6=hZ1TCp5pigcvX72u?uG);N(LP5l<8_AP zITI&Sd`h4p%<%N`4HL6sV25V6*5;;K;w*Ma z-=FlUwdb=O{@hfJVZ7s4?wYLiO;^Y`n-o9(l$Lo=ZbB$ZtoQ=t+`tXX2m3MEi;LSn zJ<4mt>_}XjrVqGq;{J5bA_VBTk)>Zpi=*Uny~YSzzvl1b3YI+xF{B?6e4i&{(j<3r zUT2RzzZJe<`tQCO2qPR%o5x0kloFN7ziwptlzNIK>UK4Cpg3yoJ9NuOsJyvbq8XTX zn+Tt-CKOhyt^)B$*s(Npks1)KNcXhFsg08rS<_7zmm-A8*i=|eTyix`APm!133_VW zlbN;mI!*f^55y+0^y)l$0J>4wXw^sr^mFQC5y7-_7c+JLuSUodpG zNK!SyFFF{L06Cs9J*|=>PN{@o?3vz`R|VE5fD0ESdj$Q&xMc=<^_?y$l$l;$D`??q zZub{QWIQzC3Y7_ll$~Lt_}cSe%STVW(;yEC-Ib@-TK}SaD(d23Urln=Ym*sc#uL$i z&2Lcs7`j=sTQu`7*gM|@Z&!da1id(klcw_Kr;WZ2ylp?aVAp%jCqG0}Q}rK9QP@g5 zKs6kxUrHfD&Q_VH>>Z8JHmxG zXg^I#KPMjD>eoUxh+lwD<>-UAOFvtMS(TrDhTX;}=03>7{+X3yedW;?bXYITIXlYJnrNm)e{Lj}m zkj2J+-l_DJJRzHscVc0s^->irI|y+GsaW4QT=X`I(l3|0f4hIJD%wV!-IkN-M4px_ z-dD%@);T8jw!B4FGv)_G#oUv>M9?yUNwYvreWW+uByZ=~ zI1F0&EDUZQ5O-LSSHPS?&wt$5&#h2BlByP+5RP2(Q#-bz|4l84c-VSj+8thl?S{m_ zT5iz?Z=mz~SOdvSTB1;XwC6WWti0&!37b4?3z(>DTr8-ky@fjs@`dp|9NkC6If&Tn zap`%uj#AYn!>t}04ZeFxEfS@9y`jaU#KR&NV4Ks6J3`dkBNc=ki&6Wc*aDWFGGw~k z`Q(fj+{2;QHa=J?yb4d1($u~3plUV5$7>uOJT%s>qw4Krtg^l3Rg;%c1hoDWA0oD} z0{3Wz&9(#|iyx%hr0NdgmG_}U+cm7oO``Tr&8JrQ%p!$I+gQ@!t-AQTV>L3izo&w~-C5qd$dgtR zpkx{rej6$JtduK$NRA$&rTi1e-&q%XYkF-B*m#%>B1jx|BySu_adhVrSv~on05CeqBx#=7vvbZ?uSlseyB6WOY{iB*5> z>qz4K;IJ3idS@gU zuIKu5K(^%Tqie4KwII71%k|d5PS)UiBss`%TmMP}4FFsCXdzqN1=KUJ` z|1m@Lhw5+pgJpp?6{A z_QWfQ-hzAfoYBy~pi`oA(6q}9XH4D2y?Ru#Dmq$h!xdTHS}aLFhLtYOi8){U!++zU znQcSR9K~j*m?E7Dc+SVopH?Vn0urt&fnU6)0NKhu`JBtk_F^I{4-AAfS+l{=UI%ts!ofS28uMsM`VaP!evhJ4 zq2DB)J2NdWe()c!=NN=;DBP(<{=tL+?Z+gL?cLKM)zYNk7uO|8BInw69L%JKCNv7=93!%dstVV_(=+b&w zacC(VUMIt0(=#`up zz%c4@g7a+vrwi?~>P|`)oo6^W!C6b7;OoX2xq)@AMfSnmp##Z9{=ZOf2J7Z}NI`== zDDE&U5pbEqLSLIZ&0{*&r8g_g&*JKyT^T4*I?u+YeHWc)d0zNPXG1E^#m2foA$C&T zr93?a8##Y7My~hSA@@-6sdkgRt)0soIdFY zHId=!SyA2aP5qd5N@5k6ypJt~KPmUSGlaASi1-XY(Ec|3WaZ$!%|n ztl9X{{l;sZD`NW22u3Q@R7SUZNfRxaST}W#b;1lyNG|y|zj)Ye4P>CzPNk0TzEw(D zI(nII)X!~HE|Y6W>7f%zm4M|93%rxQi{M4-kv>qe>MHN@9ZH4E+XCKC zKcuCGFHEf2F=cf|lY&UK!U5$W8KDvIoQ;35jMXEG<@*&LnT|0UvH3%=kG-@PUHWf) z_YNrK*5;S+Z+7C9uWVLF-ql3Mz)#>{rv(Sg3MN47ZF_f@VYBb4c8E>MwTOTW@(r|@ z=D<%#1==^%S^Y&CylZu{m7Tl_MEeR1)N;J*AaW#mpzc_|LBCU*S!EjGY3ro3my$~F zno1afv;|C(F!IDX+prD8hLK=qQL)lMpBAe?>P>0>VsboXeJpqiPt;qb<=kx8)y2k3)0o40<%YS5}^o;j6XKT%X0Uzv9VP5Y0 zSeBH0(?FwLIviD0(n+4Dvn!K_`G--g^xkZR&eHJIF|uDbOwcgk+oEQ=jEHQ_OXrmB zQhUQq8njYeC#iSh#XHF(iG_SLBMt@gLCaQwjGB8flpBX z6b9y0MX7fom{6x}j2ldJ=ELG>hNVA*FZV5e>s8%TRhGp{)#})dUK-NFPo%Y{b{pK(ro z-))`g7}}rF5804ewFgt#yR}DVt_1)8FQ`q_GG{&;&*K|8ndXN(PFhZ1+-OqGgqCrC zvPTw9ew)(-a~5XRiNWa~_YSzsZJQfLeX`)Qx$iWD{?@PAS*0rW`EyK;lw{5SL!amz zDUkPo_-m;6kJNB*k_;y*v=8!!6%htr<_SP?)amVRrJjQ93I_@EOKgl+^zK^w+U-Vi zA8bLP!yR(!3q0)rH&vrDRuXM8L`b0+#(7wsOskxiHNuLM1rz8@;Dp&w@YgzP(4zNY zT^>aw{KVdr`bllXWznykOxGwCNsmF?A`A_b`bYQ`LBRD&tiqeE4x#bqZ>6ejD$@~>B3aqzZA+oMd$iMrkwl@Ai>zUR>&uTc9k}9Qf zSCxoCG#yaU($dwXv5 ziT+`Ef@T^#eusO1CkbCS(T$&G>N$fNgmQ+??GHE*=T!6*!ND>rU7WPuxFq-O0oTFA zXU>ujZml{+-3rJ0K)c~)Yk!%?KU~NB^F4!`Ym8B?7s(&I83sBfc&eE=T=7D5#3f?#|weo{(Kyw|9SB;cn~e)Sha+N{SzMfV-&rT5|5_6d^WwFeDq&Xyd8 z>#3o|M}R$=N5~*qvdB~@?*uK0E=2EZTz#&0E=;}4vQ-j{CA)QRqokm?8Fi!5jS#g; zk~Pkj^-8>%k|?lZtEXwQrF0;wBsvC#GxxE{`L|b7zr->)t_$ z*8;bnX-^F5T?Ho#!rbWWLZ4^B9to+eg`H^O3aop=o=ISoeN~5G3@HpH2 zyL`trcggPn_@w!d^I?q9e+&+{euz#f8{OAumykXmTFf6?KcB84M7#Pv7x^<*8od+z znYeezDWtgjk0w*#dpb5PUxjP}X`IFJ#*0Y*#X%mV_OPFhEivV`oXV`)?6!h$!Lwpf z&jzQief|d3egEqcJa2>TKL%@dBh$zkbmh>+xqK2gAa>&>X|Rn)G*3&7D%DbQMp#$l z+fugx)14Db1TW)EX9Gdul-c*>_#XU}xdWI%>V=|IIOgN!RUqfWW$1YO|HB@o4@!>2kr-t)nlW5a0MgDCfy-6d||!|)8*s&Wu5GH9Y24uOMLT7 z1I*Zj_$h8wel!IJj*%iEI~5K>c%oE(t77Bp08Ujl`5KYf>|Z-KFY;Wm7|RGS;q`VA z@mMTJ-h|G*d$e4<(+(SIFrR1$>68#m;Zu}aAH7V$q~(-~9oePnZ(XVW{B=V znJ(Dgm|9&1nqH`igq@!LdBz~HGT}iRgYxTVVb~<7@Hv2?i|)S7kI67%E#_5lQnU?a zOSN4#)%=O!tzHqsbNv5wBMCfz5D#gMNK;bv0uF+oe1;@;Q-Yl)Vt_?ARhqyd1h1|2 zkbb69 zq&E=ni1mTz`#p{My)+P1OB<=|2)e?-!R3)f-ECCIE;9J2v=REIiVUGQ7#L0Ms z9aT$gyVNm-5bIenHjpuPbMe@jYA}pNI!2&)K&^3GRp4d;7c2& ze4<1>e&mwgWv0|Qnv`y9+F}<9xj7ob9%nm}hLXRmPn(4{*(-S1tQOLL<7)>KgV4$2 zh$_}ik6^E0qEWJ2m-tmY{*1np-E*htYeiR^cS7_!T0ek+$8e-LME$6P?8&_;sEID& zCAA@F<>VVS*o`}NAtU8y-RB2qE(;C;HlXDK_Bk{^@O#1b)g6kScT;#i(nl2Pv=6J; z=)$GlBB%0){AhK1U6jt`QaY-DcT|{SBH|Nk&6Dt=dU}7wrO3W-mT)`EI@Jj?MauJI%;&UPN0>4*eU_hX(E%t zLqKJSe~JqDB>wNm`Qj_O`~^KB+^aeha>x2qmhp^xjbrqy)W^+aw^vIiY@9K{~caibzq#Dwi(T-$!pIg$G#J9pmq ze-jG(!}Kkh=|y*x7*w9vL65ItkrYRWI6oWMD`l@6)Q{NW;xP*J#a&o7J$~9Jr6X>j zDTH-niXZT>c=T;#SZ!i_0AY(&{xx`QsO#aXPoUZiB7ehW6xf^p<45@pnvmQm^1o}$ z6U3P&%2fNc8G{CdXBRV$9Yd-TTvvUE3aO78+P0>l+}7xg+W z^nN4Z;#HE~cB7{%cN4C0uP`(9>P*B?qY%7(WuQ!t!`acTpr%=EvswiAoCtqV)or<| z^Y!23SyvnYn%D3FIt5LR?-CcL<v`8&!Y6Cq2ld}DvFiYj&R8&< zj&N;_;e9(?F8H87r)0+qW%z3taSshWcYJWs!(v+8r7_e=!vZLQfNwu3GRGcaBAoS7 zDvVJ@IV~sW1`l)YbUe|kh*k@@9=}Jb^K=1m|9}U*>T%l{k1)3%So>Zy;$60yU=?M& zBl^^`j{X~9P~l0M6MH*joo*Q7otHW9SO-~9o?7E}ddhfNIP z*w}fA5UsIyLzw;(o^9z#D4ew`Q?y#=1m-VxJtbf$g^+3e!uIphW7Q6*liPqV#DWCU&BSsN_}b|Y7hPlCVc zV^E?LI=T5~X`lOqmVBCvpNEGBDvPZ;T-NE?$pg-Y?+4fhz#Y0A&O?zhA_VC40g~t^ zP1a<&x1??BLUH&JTY3bbuf_0vCaCY>n!{TW*ID4Byr`XdWRZ>g?goP0uaH&x?w-GHf)lWu;4-Cr zX#NfBNvmz;nxeYW@`7i%;j`DW7LGf+TPlYSg{~fdPFC&;;5O12RHgh8Re38gCObuM5)VwFX}-;-mkGm#W1UnCiXt4FYYfzDzyxep5m2=X~zf;&|;(d_A)%PcrB;QpkiM@Q6MOa>*~$5ucO-WgwR0HG-@3;-{*A6g zAA?n+lKhJeL&-{KGo9|GtyMM0g!RrmBA`X{qVc+rkANnBO!yGtTYPNLnh$@0H2zvf zUJvDP`(mmwE0}XSs_@JmR-I660GPP|9D-b*Hu98)_4usT^~92ly%KEA>XL%k&%Q~Q zYCm&hmJ~J+N+fGEb3D2%(~35i&uxS`W;$N5$tiz;3fb9}0HJj#3nO@G0sX2`p+{S0 zK!4)K$j-uB1zacd#OGTlBpwHtAm?V+Fl^15kN5M-kC{qCp*sS{!Va$;winHY8iMhwG;nVwb)yg={~+%)toacCpVapYvJ~lxBQ1|wBxpPdP*YN5SglhWZq=aG zx55lb%8Lis7uHpzX_dWTlD;$V#uLqD*I17JkXCICVeMTOuXItX>z!^ zOEtIIGb317lo8gQ_6F`^YPeX4lfzA^^vlU~lFNI_a9_0Qbr!COJm5{P7ADV6;ySd{ zS{CJ;yORnG#?vGL%#&IR!nx?Xi7n$Tz=CCTEjj@u2BrZXMCr<*?H#$A0fcE)W6+20 z=m*}Hj~3)Q8NBwWmoy*$auz=0zifmeHdp{fBss-t0y7^k|MHYv6$au{_ zjqY^3HOTvwde5Ad{#`isAT}=Pgef%Uems%sR_w5gawPI#H6E%#=s$3)5xasg4g#V^Xh`Ph$T}kQ3C_BQ_^+XmSSXQ zI@?y1XWB+1lf6TUQbBivomlI~)ciasOiEdFs^W@4?$zcJZG`<+!^ZM^e$I-RXa1Cm ziKeRk2*5i`p@%1Nj)$C>1n2zYIc2ve(Jh~%`{UI3!}e1qXC!K*Gb$pR~W(DSn~; z8?J^y{9=Mt$wx6I&858BT|Tn5EMe2dI4oOEb85hPw$#UU6P@`J^iv|9YQBBzPXPt)-|O;{0Shoh|7YS~l5%Rr6J>FJDI3nM*?9BTibGW1}yi%RVAvC$4XmHSq|@ zgH1hzKD}dyx&BXKf^@dhkDh#p6S?6#vO&KGsph=s!&a;IzH|J$MO}WU>A&fk);f`k z2!UCu2N?WGTRx3|c_o~j~0;eZEEuF4Roo6gl`&Pm*1aQ@#7eBFD2N?WNw#!lP?B_MZc9Nx|L?%`9@uq1< z;}RUHKLXEPsA3ZSHYoM5SVn7M=j==;QeX=WVQGs{LdNziZL73;-z@7QimanPj z9UoNRD|_v8>she)SobyoHXYLi?LBrn06w5Y-^)kz%vz6_%Tfxm`_edIt=uzwZH9t{ zzzhrda9mLWk~IqQu`^H8-sxC-I0GNUB4iK=dGO)u(IQr@>uZgk=UJ>?#3Pq7l1Zk> z{$nQ4{O&O1FESU6x7s2MH!E-s&s_Lf_TJSAp((kS9_S#*Yw<~3yx4X!l5Es7X#ceK z82!6SI<++6@dkWW8mO)H{WTtyH2xn0BLp0DBho*S5N$R<9MY}yua+*<1SCkcPDocL z4r>NhU7pr9PEIwH`UJKYc;<=WG6>kzR^Hp6y#95i+1TFnBZdZNx;*2M3ERiX@nP>@(C7y@YWdgTN?_s2MvYTtG&CQiF_`8~K zzUmq+xa18t*qWR#7=rA3Tlycv<{#6(2E$m8c6g)xc@_^FSMk!}h^oggIvdY*z|GCdzSG~mu6M#+MH8Tel|cu3*^#4o zgqFVbr6HSHxEk^#6Z==^qJsp@l3f0>@};Ijw}E2m7$0ck=4T9Q6aFe-sm68R{%SqF zYj89f?JW&^c1I+%D1xR*dyaQ*F{fC8cK_TGoY2mp5ZB+C=!M~f!Whaw+h2V4xw9R{O*N>rsnOY*zL*($L$PgDb0AXZ3R|ym zv9si|OvA%~$vx{@6q!1h8K<94LI5hyUe_XT`kL+;^+^|2tZPNU9#f5JpW!Z;7XmU- z@l)fsLw+))3>6!ylKm3K_)N(mA0}&2M6|D|W^Eg233NXpAnpRv!H7o+1iEpb6 z$_0~jE)*oWe>QkwN{=}DX-j`5~=m|Rqtd0d<#cC7}nmt%~ zIa1r4RWLl^Oi2zsN!VjIdn5VDLdHbF1Okz~r)Sn(e_kman|qz0B_KK`^W zhas?5(zFO>v+50B_`GUM=0AK{gP5G}Ru}x|e5zuO03k1w?wrfo&=nSF5mDP_TgJH zE=Es|dW#&AL)Io7cG4s|vtvIHZF4Wu-B;}IIj^L;Bm+JM5^#;afp8w`Sp_;f%fj0J zceo31(#GD9INno6;k#%3%tS7;**y9ox!5g}08>RLhr^4EXW)RF^tv8~J~A_}S2} zeI_IhkYJZt-$lVXwT7%##uNH z9~#5osvc%aXNGvxkwqY)lYZv650MNcmu-Kq!99O|rlXy4S)y0zu9>;?@Ox6yLRKe! zDu$9ciu8jn7l~96(vRCCH3@$9b7GbjOaRZ)Cx~*u&Dw;M9LIHnQnWOvyuF>1#Eov> z&6*1B43|GR`f|@E>c;kO7582+?(i|wR3m26ixL6_dRbBeN$5_9FJd`mkQFx0c--}` zd)v;)UvEZ4hz#RzGl?5t4{Y)5VO5G=*lDE%V!C79irSi_>5DgdxrW9qQ7V;Y_D;c3 z4d)h;L;hLDipndqzPnoA>v05lL)U2KA1RuFyW_ZYTU%UlJgIfM9aB?8Jsa2s=SBYC zb@4QegW#Itq57rXXDVfL#ExI$TMA>e=F&txq*fKE(7EkJ!Vt34i1r42a@2LhtOA>i z812=3-rUmB?4u-#1oTdHVx}Z0=qB9AmmDpUjj$2ZJ1l2?)!=TCUt+D-cUX>TF}>e!cI?iMi3#cdyA% zbIsu)+16sD^*r_Q@76QZ{i{>%$y2KdH+GxyHEMCv=YH)qqba)U^Id3Os_|&e{3eXW zz+0SpBoitmb&GZ9ti~eK2TFy)b@E|5I@V?r!L6fPp$`;;aSJ~6Bl|__p*SU;7Rl}a zL+SLc6Ama<<6K>>yc8C;zY#NM5)9T(2KECsZ%;q%@?Z6Fb+?!*EIJqEY-a1EDbN~1 zK^zX=J!b#wT~nXM#NfZNUtz4_nHH~los#AUk*L))ofPrsQw^6NSw5PeN?}!JOA%|( z*yPds1puahm~~K*s?E=|e3zt!;+NHOY1fR_ajP)(Tr0ItUsr9&Dgd#DWU=R?DyBDN zUcES~Q@l+E@Tu0aP`eV+Mr zl%eV(oR_YF`U9|TO4fsE#{ymslF_Pzl!=ZMz)8%=IzZ3a%YW$yx3>KD!K39W*WVwc z%J#Le5R&rq5OmUEU*hIZZ?iRwt^CJui`4oy7uve{B(T<*EpEyr(lYliw=z@t>V{Oq z@BV9sK6MZ1Jv7Ok3XR*W&{@rGZ#$7KkQRQ_UHtH{;n=cMPZ+#Z z;1OyeE({$9O#@P4w{e! z)XjX_q*F_7Xi~zulgvIIX(37{JIaSqL=%r?Q||ZgcC#l^=T|T*VSh3O9kwmJo|dn! zgu89!L!Dz4EtzV4d>fSMMsSw-ww{>>t882JBC!ni_BQW)U$YuXSQXv6?ZPd@nA&Wi zo(x6s&5Q599ztG$_Ak*D$QyBeSxiRcJhWb^JN3z@lbffD{zh7w9y_k#YikGFO)ZI` zkfW2pMOLC}HQ~ryh1kzv@%zO%QI!mDE7ISgZHw!H-H|6_$T5^JWWm@?9gi>WbEn39 zcD6n?R!8d^6Iq^S@{dZK``$Z{8YR=j(s^VND78NH-~b1_&4v&S)jGn)$>lKfF|UR_ zSTua$1DtdZ9}}_qsu{6>5Tg(3T^axfQvOg)m$0Yje!B@%?>%f50~O+57!|y`Qh=>+$3`9+|J~X)Sb9jWFO@PEtQb zyf#tuBhU1l?AI^Pt{zi$PAMM1q5%)7P_eFIfF?!_qnUlBaXf8!RabBM`l$GEi$rm{ z-{%_=Q2(%X`}sn<-=Qg(JvY-k{*Yh&Z-qHme`o-LeYmqP)6EL?(bb`80jtZ0!mmjl z|1pUb4;b!_Z8jb5+Mzj-S83&R4b-STv*(?K^_72vXgAjsTNbJPcQ0jcT~z+ae~6I& z45UhZOk1emgpyTvjt{7T2z8S@JDnw5xT2k@jns@p$6~p&ir&Sr`-v3$Zn_oN*LT(Ty3Fn04J)Qw)$UY*%7b24r z_iS2$j(Smx!aPKpMf2^3)5$dtZM^__r4yvPm}O0Ia`Rn6g5BdGskD;&j3>dhA+ngV zP>uy4`NMa6w5c^V6ymIJl?P-c=`#6}G~*5Q;9Zn$=8i?c5GR7Zd86@r&p zdzt3xqXxz8$Z6+~@*}_hRPy^*8dBYz6PWD9 zbcYA%%5ROxu^EgJ?#?>M-w<`vHo;igqjjjBo&huC0mYzDJr$^*O0psc#>|^HC|`V( z=N3E^i(KWk>r_KI#;zq&PdXC@4mUT@HWTkx2A7%4a@!^BBXiZ@s6&Bl+PX= zbAi^|1?94L+kRd-{IJZwxT>+W*|mMg(ZBE8sW{=m~S$Eu)3BMI|1w2Ic@XbsX6^`akn{B z>?}7=P-Y0raX8>Pj2m&PS2=Cv_-^&^LVa3iJ$Le6n1GM3*PY#mZ7=_03TO$ZW|6lN zOZ(Klp&cbMwQBu5sR9a#N@`Z78+Tq8Ds5|_KYU869aw>IsGlc!M@^?=V*SONvGAK` zj_9`H)mpM0`Xw?AU;eSPvxQz~;WIw*;ptzu_MMgbr0gl6c`I~IGr~v3Tv+Mq7i#Wq z(`;}>%G`6wRpLnR#|!He2<1>8(V4(u8p^73>9uZY9$nTQT%#hvlvT zKY1e4qoZVG+SsvnE&2!S;Ca)poF0AJA9Dp9iO@z!n1L=QWAY5~y^-SlIaj_XtJ9y7 z1;r8zuK*voUYjim>~#n8wI|2rO5kxWn?eM+4IXkG{@&OYQpM4Yh)%GVH}Q57UgxJ9 zSU+I?=+|=(K+MZ6#Ql3b#-ja9(qBG3{l*+6v#{BF%=iukb+v1We~J9jj!$*C+KXzk zL04PP+rX;Uk|o+i}y9(-Pff-gM(odm@>os=o{di zJngkL)B)vyIuH2T(Be|`o6MIZww&Uo&)-tEee_zzMt&lmHQ2J!6{uR?z>%3dK(7&Q z+H{=pctU1JyP0U}C1jG5WTEC-U3;&z7yX84zLhkga7 z+yYpH9u>Wk`S!WZwp)Bj)7ayFZo9*LJnqa9?0LA z^G8*HBg;!#-?7|LhM%3_l8ojxH1LcT`fc|1Z#9AlHS$@Q5&PoGd5qIhEA|{Mum@LX zy;{j%V^V>5-T1{m$Z|I*5)%lh%- z`YaU3zo{3?NSFKCRPA?B$ChV{7G=)F(*>NgbuninnV6oxb3kW`N-xOeB=M z21m0EY&z)Nj?RQ}psvi!Pxtf2!^;X2GFCnc#OYlqAlIwda)-l$U19Z*&3ujToO9^}EV`s0ulbr-ROz{ec;ms?Qu;&03<)HwX>!7Y zjW=QR+ip;D%pcc4E`zVMK%0=lw@!)KgXbtE&uC2hm+@)GLjbIHAJ;bY+okIt6~kK? zMD*rCIEzTM;YWv-#EpGET&|CJZJ{{0V=Te*H%qz45Kc{^1!9oKlPy3fF{C;1d2CXToUVz0~7s$(nYYP2tXDbgM=*jygyU@t3 z-hJ`%@~7HhVBgpJH`4D`YUFmav8Tdg(!eYC4BqqZg=O%(89llvBA!qjsj!L_o$dT) z^XCz(>VHfrl5f406Y&@B3|hsAmGe+}Kzmt>a&fn23EYkPBCS`v3cMxlzI~STas83g zh7T{OA(nG|C@nGjI&F!?jDt0rk*M_1M@6al8Gr_4Bg{;iQnoe%JsQTAxj2`)a|Jm3 zD^5|{Xqj$A$eXzvl;Rj`?NHn4sTEa^;uqZ~(xf*y`abU%hT2i)sYcD9#@`XN4?RU& zy1TEGv3Ru>d+6}GI&}2j-9af5G!AyY);ksu*+2?Wd^=6G6*fgXgdpq+1??e{-?gri zP5{IH5}5K3e(T@%fyy$DV@t`hMO+nfkaG?bjrD=nHvPXkT8}EgB3$E6hfX`{@+4{U zs_JVSJ2$1es0~}x2HWS$m&1UVJq@Y08uW}r?=f>OeF4*+DES{#wnckcU8J+Jdz_Fyf`Y3yN> z>jJOY1YFM*e~vh$P@&fCuVz6`Zof3UPKkIi zTR!D}Oh>0F&sT46vQQwM)6Ei{8d9CXL8E%QJ>T%!>LpHy$}meGW|6WH6f@8cn4_+{ z;aKKH1oeBd-p-&E1ICn;2;AFg)e(^4VY0yq6a^Y+r)GyEr~DYf9@V5?WJU zD-@6BWGGB}{>L=pKjUifhvwfeyWqn=xbE9KsOpc&0X#7g@8;L7CBRjnc&)C?x0GhB zHWZ_(ouk@h?AbcuEq0kfW61y@K|X17t*n(=0%qw$)26{i^){~i_;JcBsC9oOcf_Kr zJp~?!N*%@99T`8Qfx6e3A88$LmzJPYzFn&RZQ%!EPbio4e;mD!FiQoj56x`Iz@|9` z+NF$Dq8vo{p;>Gh_iO0}y`lc%c#74pL%q{Lrx4R^U1sLm;CTi&khxIbj!Qt@90xpM zVA+!b0U0A-%ZowL@vD955RvVXrSBh}fD?JKy5fWcLV)7f5XAu;SAcBaei0rn7AMMGH=wvnU_Nv^32D?lv2Fu@NDgmO%Ni>u>^ zt!W;*xMD9xW>3C^HMQ&Z^;Z~8oRWN&?lK0=?IId#a@VSU zTEwx5t{10t>h$e}Hx5I=dqPm5XUG#r--|-^hgY$zG$5AMl5cnlC>`Ct|Eju+nNj)b z?BZtHiqzt+a8VJrSlNe#)v0c&|Cp543#l4N35c^CM9)!3iWJc+vDA=ceCcQYwY)e1 zq;mc&P-Aw>`Gf)2=a?OBq6U1`7!)_WVTq=+nWv*Yh52s5o-MpSa@&G^O3p4#@z5at zcR~qap=ASDDO9iKnWHkS1mJ+MaZU^R;yMsgwx01W?XThW`Kq2af31VKf84MSz0kHk z`Dc*`gH24;-bmpAtXZ?4rAMHqT9-g*{U!;xqMtR}*{v5dtzHWvIzsKVnV9}P9*f{dv(?snf&5*eXr{do`@!T-;nfWD0Z)kN|3#y{eU|Q z>Erk=l*0H+XS5-CH$HyTX#b7(O`iKZMB}DWWu{YcjChXsOK^>{I1ICjwn3_<*W3Zk z)D82L`uHdyV&@>L_?-Uu#GkzN*&u9ZOXwFwJ`rppY0xE+yv=cJjy+17#ovZYZWgYV zDjRAtU&zz)?J)|%W9)m@4Tv2PX_x}Q%(aa3*(H2h%cRPkW z_GA?4P}=VKd7x;n4=B81_2%r;_gtc^URVB|n8P;FK&tuacSjAN=`sHMeFBDdGcZcQ zn5LKdqz;rr#Z#v(jX&n=7JLZ1=kfL&NpXs{dwgDCK?(-eyx@>dDbyV*guEBEHYz35 zf8&awXrJ+iL)TTWnU;iH?bpV^2R@CWo>6A)=B35VUOMHYfT2bR?qdbt-I92H6J*uq zSk?5u<52y7nVWp{o5w%!K48=XrW?oJ&J4o0iesI|ycg07KJ;|BcJw@Be7m#0YboXP z|8KdDHuQCXAoqKS8aUo!TsqtHq6)7L!`v@&tE^{GcMdZi*}4c@Rw0VDK*7Na)pjfz z!LT~YZULC{r`-ec_6%&~Sla`?&h~wde5i9)XZ_#=b6Y8Pc4~T7k-;jaU*Ql#eVsN| zTAK}M2<$Fz2GKc>C+shM2V0Hc_G#9oDL@IG1#&Ps{+F_92E1MPFW-1$rJp{;#B^VA z4MsMa55r2(0*maAhhZ0`H)QsQxTj(iav*y3S2j0>q?lgBhw1!k^GT}xK@0B`{Dg+6 zGn3^=BGrapgnlaK1Z&)}ZE6g+N;)mW>hnms|Fqh7Sn64}5X(fUo2Qn^*uKpW6GL*s z38-zT3VDV!2o(=da-5WU-FCe3JpA%{l2r2To~4gH7gF=sgF|KmJZ%gLHYVonT85x& zzoqMW)V;3Cn#Nh^Da?BZR{YC>ZUmCHS!vPTXl_jb>Co0qBJz-rWvS$#JEXtUcvSUQ0ozVbt?q5+{}T zPy8g+NlgJSFjG}<4rYmNSS+T<-MXlR_efI6bX2FfqhLe#XRp9vwn*su3Y%pQD3|Rn z7(=We z*{qtJxn||*l|xG+Wa9)yh=33GhjO6?&~^B(?X&GgR(FcBzpW2^b9;1&>*;x{Q!?7@ z6TbggfXuP!4GzNznNxZi$#h-HV$KG7+PbHBp?^w7@uf_v_zq;z??jWt-6d=s1(JxB zWUfC-LOv>)T?-IILfosHY5|!gMAR>pXQ_7*#7zi633>#}`xMpPUi*tQAI+8{0?R)7A?h@QGz6CcWn1SL|P(=R;aiw%d>SXxv{AlEfL3xaBRH zDyj2w4v3b4-le|;Uj7x5h`%krQV{HG`;1lFrfXb+721@hZ-T9yfTO122f;_((rXIt zJw?zzIXRz#X8@xC|CLZPaFZGx+mlPiicv7V5Dx3^vKhM;LD@(_rt3-kw?}{c`12)g zJol_B{to@c@dP;PXPJP3x`6p6{FcU*mF(Et6v4TSI5)s9j91A+O^$-~8NWcSJ^Bbn zQQbxL0^~jjn~cH`J0kq)uv66}R?M1{ZD8+EGgzDB*D$X%%gYwEzE-$F-xyiJacrSP88YN1YucTMQCrX(Qh)B19jE;M zwAoMnog&ZVAH@dDhGxrj*@0Pw$$IED z!rVDbI^|`xw4J?c(c#nbfN8f)*?K$nA!-+`8FU#~CwBsGb5D|M2UGrIVv*`O76raj zJm04VWmx_MjT6_t_R1%gmP;@ZSOw|eHEC>9dTHYQ?`WCJDP7)tbdl-(r9khhIH%9lSMz?6*ZlO$M zWPYCgB2a^4TT4`^XTtoeOAiQ(1|-5xs9+?b5z%sasdHueUr-FoslXkm>#y4z4}G4z zn%ZAq35wo)LpKA5qJWEA*oz=^1K}MryGqzX zcmIo{`F7k!n6>dH_r4P)ndi!{6+L1JgI=J+EofrCa&cQV=%~4iWal@4qp-XJ=<2&; zj1#V|x+~X=CE4rH@1uUr*XDfnG;6cwi`ch)uskp-SNJ`XSwKj94h;0fydOYR%XbqG zZ@n^<$*u=;K9I@ROFvV*#*V)sVWgob2!ue({OTS~?V^}3$9Vb8xcZ-;{ySx(cTQzz zQpkgoUs7d#vI!(NO#-MA#=lp_jnjq1fmUvp6%svWXmiild|EFoF~xoh`)a8acc}4idn}yn)^Z5@m2V< zy{EOiNkCqsX1{{VyS{-P9x-OcoqoirPKeN#I@ydCvVGq zb3C$(d9MDG{nYDAff%XF6i5srR=>uTo)Ulu*0nNhg`y%zH^%if%+u#zmjBrk(fa=w zZwM(j6?YM7LpU;9z;&F@`SurNc*KV`KWW!=1hhxOuGXD)C={mS^B59GRbU%N?roEE z2@w{FEPcicD z==*86K$DrHjLCiO#i`S_Cp;Q@h??${RG*XDYqdG}r3wQP)aC_v*mx95_ZTzz{t8 zhP{A9j_AZ{vLPQ-;T^XbAaBl&w? z=cWauBOooj+dkYRqHbhd_?9Ar2x3Uh3EnZ9XPaT?5H2>fP>OM9V1F?pt5bJ!OWI#U zc3)VGJG$%p_cIxf9Y1ly5U95KIuM#lm!*Mu*{B)xr9BWrPdmFdF{2AoHcRij1QoVu zT^CAiJAW~}GwoB-AqiM}MN@6~+Gv`ZUhZE_&(`!7#TF1tw`EeB>3hRR23ec;=HK0! z-}%DH)0y~nRQ?Im6XPAs5G)Z#_K{$4gWpYHrXZ|Sj+2&5JXe(>)_@~dq+x&Q{yID2 zbNS(A0KvuKXgkzBPrHfhO^&ZW4;s0nRY8M2H|@3Gqj7&YfZ<==U4Py4r}B+?T!+t_ zv>yWx-Ab^QFByaGDLIm;QERbH$h0p>t_{P)&)^`n^3He9NlZA1>Cym)!uwy>_()0C ze5O|;{N2PmSp5f(BuDUMrXHa+ue;~>!1$oghr<8aR)dm3HFQC^ChLh$8z9SbKVdgv zGJ3T$(=TVp4{td;~-olYn5NOlMj05xW?25?-XKC>5oL+%bOS`3rxUm!U z=>%crLUVnN%ya-I#;PeZ)ll!aC^wZ%aA|`@z-lwQ(QiCKLNn*d88`c@aUKz zm$wE4x6C4~E|TvaCYOic;&Gr1I(4dNBpU#Dj{D}>6`4OFF&-TpNGolN^Mkm@aR3y z7l5vN4VPVF@T6bT4q8!Hlt`S%OjbAYh>ua={ob z#-Y0O6C33DQGd&cp8siC;K&tik~*9nA9(fYm67JR=fw7TcF`MbtWZ{{2YMP#Q2SMv zvTlE6w8gRDQP|V=K)1y|swE5WfnV$d`0qj;&hXM@BkL)Y`w<;87GPwO^%TRWAEGf& zOp0Id?oN&XYL+|CF|_j5vUK7YH%qm7WXoq=>N97SmXyH<6!!d)TzfOfvt_4rFeJuX z?knZxtv%6$M#xt1XL9yv)3r5A*`NNie2*G~kPSIvai_3X)_~eT1{aXE)!QK|@i7Y~ zfP^&pf{^U_g!?MqvP#eE((2W$0xF11H&Y*j?os?^Cb^TkLIcEyf4gh)QM|+V<-Li< ztguu?$a(kvMiD0qrWW=mr9Z=j&e*`EZcO`6iWwGwe2@Q_q^AFg<~-(6Hc(_V4xs@y^{|FZ{Yx<8TLTgit@ zKP3M!*0MY3Of&)kK5C(s=L2q4Z9t{a+j1Hl#qN6pT$2}-4EUWdyNJc@Qp~Sdvvcc& zNB6;> z)7MgiEuo%7ZxMHir!-8UH2Jcaeto?&+)&j6wPh?5yL;(kBXc;cY@YlSL%XIKPxnN; zo*{a7&Kk|%qZKcF<)?XDJ!>=6mVWu$?m1(m{t<{y`iEaCZcjV~Dy<2%p*jmdmt0if zKkxWGsWiY1W z4w?FWxVJvxeSr2mZvQ{X`vlOgb$sG}A)=q^N=B8FAQz;qVTnjJ&1iVcb@S;=y;fyu zuF@#}1tT4I*ffNC8C+BPNJY9)HjyKd6DbN%SSJV9T^1nmsn%y-F`f9v==h?(e?mJ5 z^K&~9fNZz0QKmrL&E56-xl74U#s-r~hJg0O-`#zK7YnIVFLek~vZI8*%RHb1at{*) zIf3M#yG7yX%$E96*Rw?3dd3U?cuqii)S&T$Xf)dTkd-cA99^lORHeS&l(*E@c0Z zie^zm^7-R&dm)+w*}9)t++8jY?9l=KXFo$0;??dd+q&l&v9%UFWO`vzQ~i!lA_4yq zF;ATDg-$zatHNRw1(0XRo8QIBJ@F;HAB`ae))v2`&TmzGklCMk%DghcG1t* z(>I!xc{#yIwiH0Wnr9_kOX_`US|Xl?EU)(#&qtFjwO5}{!nuE`rvOn$d{PU(rJSAh=fhakp<|1tcQ?&EwH;)lVh+cqY2zpJHNNc0PjC4tFS+wn|KC_jQwWII-QRC!T9$ z7|KGom_=%h;oKl>E5JP4w{rhGgzYn+HQXOwI+Gb$l;Nyooo!#A|n10l_sey#m)(W!p~7f%lvT^4xhxh03|REG$=2tsB)< z>$Y>R6K=oLR*l<+I)U>Oq=ow4nG!5NLn{k%4yhG=5k}MLN|k1NUH8Y@1{CCN#Z2|T}A z%&+$I+Bf0`kkhPCJN}PVHuM5Q>HGNjc3E72ml_tPj4elDZ!H{(`&QxYZ)c$$eUH5V`Q-Xj z-*L&RcURjF|0JBqI)qU}C|XFKqZUFK!r9IOB8mWR5-Pt}zLy+Oty}r4BXLsr=ARH& zirpAOf;iNLo{=K57h@3NkLxED>^O|-z9SsqAN=;;@p9gqedt1qq5r}EUR0M48}JQQ z$-R)DE{+9`CYSe+G~O6E4Rm0nv|oeL?O!46EVW%|CMgcirOoFG+FAdm2uX})>( zYhQusbaZq@F``9Iyx4o1)Io&%qUXApkx3IPAUg&t+do{fu?GLesCiozc@6>1}us!Q;o#WpHs;iuo1s_%E@wA@W@i6 z^BJouf*~TF@5z`pu(Lmo{%gf2=vepxtkFpYqq);|fpsy5mrz5>qS7^ZWC%H=SFF=g z5cFd#Ep0z)bmaZK&Uw38+eq-r4@bQEi{|;?64Wj-i0udg({k(h!nQp8;(_M+2Jj(w z$+6EFdh>kS>fMg{v6Ehh`Mw7KepG`JSVdRy#WDhUmNLxlQ;Z|GD_U+-e^xt<_tzP` zy4R+aGZU(68O@NRu!k>fpY6^cVzRaQ{7&~x{tL^kGwQef_`$R&ja*TV>4HP7&}xY5 z#6NVUBbnb;Tkv+C>rbimY5+U4I{)iPv=OXyLG<(aZ}sttIS-}{i{{NmWpZ6#CXiS! z{Hz>LHkpn)G-yM!0j|c(G~O9vZwKbA?K7GrP1~Q1!)`nQ=F*^Yc4?4_-9cmg--zw4 z=;m*S{=u5)*d6~@qv<;SE_kgK?+A1qrfUV%NI2M6qI%Y*=bzpN3hT;Ml3$5$nJT5` zYqXVtncT-}_VBF1XiB^(P2+lY8SUEf)T`&?jvvKqX6cefNTWnub(fo zTNz!g+xhTqSjtX6U&vDYWDrv&;YFjJ(D4NyLgHf${9C|@gSX#Bi4}0h@97GJaAep- zI--La2ecOJLx?`mNzCNUZfd|wnqGLmkE%CUT<7ztSXeQN_ zfH7aN4YvjGgYPyzR60-5iM#DwUVO0SvQE#sG2ij{u^dIB3kNnA+CoFxQKu=qG5?%#L=Il?2-F?j z4)@|Rbo0E)cF7~q>5W+Gt;X(9H#%q@Y!^lXgg^2P`T2 zPqp%%Y1bPOb}osrtGN^g+RM;_%7*AB@Ofsa^H&836SNBwf*;LYWH=FJ~EG z@v9SzjYl4Rk*e~Yeafk$M<{QgWLGB%>a=->B$B?^+r}~V0&>DFRZCdsDaAYPQHzg5 z`xytjUzTF(S*s|pzG#;{zP%>OoHRsq*}Oy0^MAG%;-(;gf_s0Dnd(#0JzC>>Wt=w< zA{Ps|p(8-(w>md*J}S}EKW_~$3Tbf}Q~clOEKk6R{+>eESdas|M9zlD34jw>0TT>)CukNvEb3{?_jhlWt@?Cb>;qonzcVfj zGydP3JL7#3v2(M+AHZ?VDo*rS710wdHruA)Ud3u1H(B1@QYix^9h?BF%NiP8GY~>@ ziFu2BNM3*^p9R}YJf-(}i-r4#Qh$qQTdb^+m|TSM)j<30W7c_w*RTxWHJ{8Uc)hlq z7BzB}9F^mYq-&h)x&8DA9eC2>^0vpRlt~=xV2f&DLa#QE%l3M>L~3Z!v$p9_!AH^=kzyo7du7kE@H@c;PW!9?J*wNUboV z6U(ikg>(SPP#Z;ENA*H^)Y%lj86K4o*mT=A+DY7aLc#5(`FFL zpK4$rge+;e_aBpQ2=`n0Ic(1nTG3Fg&Hm~HlDT}wqZ-#tw|^gqtlQ{3NJ$MoiSi0{ zpYH&xdreU?B*Jh0^eXRKMwZ5Vi%S0gn3`hvl1ok>E3O{%kmVvbFZ&#QWT|P`EqLby zWETtUVJ2_?lI&c#@*rcxy1sG8{0{4*;MDg`+o6ndPj)kEBH635f)-*(fxa;jvLqkT zaHH(nQz9sa)sWDb)S(>aD`M?-3{)&l)dJV<=#{}8qh`~Z=JoK9gRNssREt}dl zP_llCJzG~353FWPQgW_^=-USr*txC|Nu!cfd_0d_=xFJNsEQ{p~m zBe1XQm^Br)l^6S$;3&HPhj!}tTy`F<*906cNuvj%hO?HtJhh+RYZ6WLQOwBnIIEYa z8@iE?ml}k(WG)ypI035WaA9^g8szhJ`wJ5LwQXKb)Y|+E;k~VpbT&An?IccY=&qRD z&~7x0Gq&|cVZTU|)cUU`prRL-?t-Y*Q#8&}K3t=_9iq=24*+$18%N|!VsfIUI*o2I z*5XiP>pI!=SWr@vUT;dDRx^=>j{JbM++F^R$#408By`5n+vCd&CA_*+1yYLgm=`bj zxQ>byNo!vfHVqees(bD9=Tj=D2^72cKwa;KUtPWOq+x-ZwiUo5D8<1(-H{U3@a)Cl ztuPmvv2sUT@6ePKvD00Yqk+;J`O?xjcXIbht?A!{x1V!^gi}UatSDH=&YFb`r{7lQ z34Y#LsxoT5K$Zq_=xXOOVuc7d)HW!rTREOw{)Dik&V$SnlLZ;&P7ud(-c6K$Od;p0 zkb&Mln{L0qFCYw$lom5AYg=yh!OsgoJ)|%RO<;puIS)pQx3AAM8u5_c*URG*|0>Qa zf4s$WIun%>^cAJlo{v2HOi}I}LvLhXO6Y4kfxMa9D$rW@jKWnPLtqum|2e)*jCtqoPYwn_Y;ukAzlgJMMq z)XU?c!GGY$rDjG@<^?Sozwe47`mt8qIQtCH<1GdLe#npKXYi!tNQI%807#8XyCJhR z`3N(0)$oMZ7PhBBZ8KpRdUNPkbI?yr2F`t+b{)({BK7e4{zg8aZGWa^wSRf~w%3?) zDFc}7XA)!mUUl`Gh4nj?_ljnhU2bO+ z_v*QdxZEJ{7r&?pw(-tDX`4i2-fWi8Q{7Nb|M-%}K0_R_g>kdlcV|vYdg@l=!_#1& z9b5!Q5+ADv6*RTl)vWDT9?~Nui9wa#K=$7mhUiQX#D;N|2GD&OQ1Wl`c7i4w!q_%i zf9$GFS-D)#Dz9e;dOqyOC5aM=$_nOA>&2}^iWXS4zm22WEGUf5(D*vN3!lwg|IGUD zZ+F~)uW;W-Udm0$m#3ufGM5LSS%4S7G&3==5Z!~Bf%OE7v?jP#xuKaiTXX{+ zB#!tNFHGs$+q>N9sI7;C^rc2V! zeU_&`ettqYl-3!5@MEz5F@XZ?R5X=yLS2YC?wAc^L0-?V@T%tH0#5Sq~Cl{@E;RmcGPYDOF$rAi^!usbjrR+w9KriVr+kNBAylD(Rc{PgM z27Mta`S`pF5zTY1M(t^|<(PV)!}EIG${SV#Ex6lW!ke46E{Xp!c?{8R*1sWPQ-_Yv zb5iaYG^I`rLm3ip0h{N)3iZ6NoqM@l39&wks(}YRbeibOHg^(t^gS^nnqD9at-g|K z@|yfG7VBmg^U&l^>Mdp-Dw&#ER56j9QkeZ9>8k$CS=`otOgj?0SBPM)Z4a6qv_*BW z(VtDN+4D*j%v7ydYKnbmXEpV9WYxOz;H36zJ&9I1(4jO_2jHo8l=nDpoYVX?v(ER7 zkqiS2yB%EAE2s2PyX*9I0ZabNJj+=}VYCDAALc)b#fqb5HJND=g)~g3=v528CNQbZ ze)Zk#OzrwrbzZXC&DkhZ7WO+~DyuW7;Yr+gNC$>P#DzE?j^%fqPWvqQI4k!}v6k=B z7XLeMliVOca|)AFmcID3GPOk2urFk7Vx!p=>oQ_2lF;*jN24%n?k7BOz5d5xHDR8? zJG3xZ_8BaIEGgbpJ6iR^dXE0w90ILJ-lmp+Y9CviQh(@R^jeuUfmHi?P6AvX2P;|i zUD?}bkk^jl8Ft@(dZhF&uQzR|`er!fI!bn1t^q*|_$Ei>d_BiTJdsUxtn(jVFZ#mu z&y+9ya2NjzoziitSNKiu?pK6RKZC@Xi>L|escum|@?`GZjZqnE18=UL=W$$@O3c&8 z!>>}3S}eOel3)?t`mc)nRgki)Q-=ki;_0@JQJOlQ*WgooO&dr(3g-BN4c&arp0g?H z060EBROBY;{KZ18989jqnuS{HrdwIgi}4H~)sMbGJ=Kgw*Dl&2&yh2*$5JC-Mv&0F zbxjTB$~l`~1^f#a8#z20XT?+fma`5#jj7b5ci`PTqLCIvk?OHFj$;_OFj+A~2sZaC zhC;9KD=oaxNn}T8I=)ifgL(X%F*%&SlF4_E?STSR`bG+ctt!0l#E=Sg0DPY5j5}n$E}SqTgw)$< zsB{7zQgBc4hIDy&kWZ9@$S(&J?Q&TE49_oyIvBG86n+dD%mB){~!c&)%TJR%4l?#J&h8jt$Uue+0 zjeJhaTL4W)(1|NaA)kpkdBQ97(eN?i0JE&R(` zrfO0~%8j@kCBc?EtTKgF%6)ZZL)ll>UQ2=q5JGQ<8`2yER#%fA#jRIz$XSpdo7wke zT%6YBKzm6oLNf*hOb&)2K`O`FAPVm3VqQg&bPt{wfBZ*>s|NG;Z(1|| z(6ap^xcu6!0YZ+wT%9wRvKg~kJjYpH>v=uIP0Vp{KIh)ut+tdn+P^UangE%lOZ3*L z{{VLfif6bchgU68%B-cXCP}&RuG0mZj~5Sm3>^Rv9!X868@o0orIVwnA!KNR_)Q_( zHO)I2J^W8IZdkMU$NVw$es{Z&eVU!W_9qXybqoldXf<4SpzuqkBthmvgHaya z;M{UCmU{&1_8*e~4c?3U{$|Xkw|@E|1(DXP6u7!*SeRN`Xrf2g((b$O!Ij^Nto5g# z253eS&GDqV{89woPPf{(6kgpzk^XQu^+qhq_h8}4%*{r+I78_uQJs6iJ9hp;EtQN< zPz0wMkGW-F?W5V}JPY3wu0=Ks&fHJsf9^GCBn54slRV1XhnRpvrw)fH5Dbkhgs(Mi)6h<-4!8eRrd!us`kQIzQ3oFH zDT~Kq_(ihI+m=W4yI=>cs&1m7UW&EJsi~7y$^F!|-{x&IpZvBALx<=Pq%qBf0w;LR zcEMQGWsk3r(NiFO9$wBXP#6^PTzux^`DsGH0cNlj-Ybaip0(^nizpfjlmo|j7xv72 z9q;Q8Ey(FY_t);v{Ot9$tKaxZsqeKvb5tD1P$%zCVyH*gRghK->r!3!Z=HVc?6}j# z4ZTx;A8ozw`oyg-2O3$n3v@D%{*%1@1lhR9-q^Z2{ zhfeoXgUP?@+1!@hm^&;=GlBXYCMKJJhdPOGpz;!~m=ECjO}l~H1m_NuJFRGEh#{>k zoo3&R;pE+(%-*MU@Bsc!+grPwNxnN=yseJZO`7>aBe>_yYLQM)SEQ9A3OzSu%M}eJ zO*9H446h6nHvf%!!Y#n9&mQQpylDt)WAp{} zW%Xel;_~zr-ft>@@wWT+9~1ZQ9|)K%)zH|k9*ff$vOoF)5c1?{%iZ8o0j=mDn2Qe5 zmB;~&4*vX^Pj)vJwO;cQZGzes1(n3y1llp!V~OKoG`d!_ zS%27gQfmFS9;795aO0$W!%K5H+70b^UvhRQj_p|8?bZkM|Dll(ORPL_=Kby=x8v`! zFgQTnc%iYx{zgTa_F;>pI#Bs69u`b~Zr|go~@~2}S zN+3wGIzI145$l0+T|R082_B)Tb`E)0TiwvHi3==TXYYJuejvIFXWqtvtqIXpY<8 zYefaCleGv#Aor4S>Hk@IwF!dD5&rY6bBH@cK_X!QONdT4%;Ox96Y?h*lJl%(EK{#q+8**^0 z`ZWrNai8qxxB@mcQw5ZzPYcu{JwL!}M6rGwua||IQC)r<& zXeSw;?3eBO^jT$G;m=_ z;-qslJ#VjevVA7~eYbp?l=ou~9X?;HnX=%`sY6EhWYsn{H68G7q>N1`^=oQ8f zPgyY_beK=24Ve|UE7 zTtQIEcOi=h=KSw+f_5VdM*IyO!>m7|xd67zL=j0YC) z{N3$jKQVx;^%5#hAjVncN~-te>}^bg*kmP)cXO#mpH+_Z%w>eNpgD|X*X!RFc<0>| zqRYC@3vWu$0}4EA4bCJ%*w>VNPKYK7vw&1)*WE}U?D<)f#6M{R$liry5!#U6NX55W zw%enjHu_Dec&n-ljyui|?jvYWDORPquqA9e&5TrT&^;&GOpZ?4Bt>_tKH*%8J+*lK zF=}h;%6NW)i^zFJBBT}?zOBiPw5+7HE*#x3aKeCxBE{S-qxA7e%un<2izjw(tdJP+ zgFisiU5}^ZUqezx^!x3&>i?jwd{C`fzWYMvrfhQm4vrYak$}CT!x2TK;mJ!Z*>V?= z8j~{qvEM}PM$bi*I{Dr0*M9OmY2sfd*=ux$bOy|ML3cEzf2^iVW*5v^)qLl>`r8`$ zF52|gn~zl*zOWoLR|_*(9f`W*xYSjO9%%rHlVMx+yuU8gyiSZ9PsT7^{)43i7@_jW9g9P zwZ)%26JQ9v_kx#?j(njyn|Z%z9KAjOlxam^UE5*vEXV5WO+#-{Y7DuN{jx+h0&r39 zx@m}$-x;U69G|OlBFW4NmM4hGQ)1L2vUj)~N)&{t)>hhZ{KMzr8#;1e0Tem@bc$w6!6|Ds6tGp zpvK6$yDq2PbL)uXYvF}P6kJ_a(CA`fL00p8bMQ31qPHDVE79c%bk6fKZkD-c39ba9 z1S{*RTatAeUFu`Q8-hH*%WOu*KuSt#5XP0}N%sL$6wgq6do7w^opu1U$0G1x`ejzl zushVHL0aZxis#>HE$vgG^Cr*cYYUg(sQZ$ghjeu>U$pKLdfYVj_U_n6y$hBOD#`q; z#TfgEUCm72Z-^)I6mht>bVXW9&KJZdku;sOU2Pq&iGSWK1UDTaX;GOQeKKlA@9ndD zZEW}Nu#$vg`qmyu&BbGZxSxmR9HZW+!Lrg4>g3JUI#J&x$3n~QG=ni^Mdo5Ym;br* zN_!HyDarg=DwZ}+9Qy5U$lKpLU+Ep=U6ahaV*S>gD%b(DnSLfS>geC1cT*^RoX2}% zkhvD=OPJ@Kre$|Gxr8y~3E6gmR+HYun(sB(rbqP|R<@Gog_i;Mm%BsdsJBRNaFe|W z0t{S)kUB@D46v8O=e~KXC;7{7{+b^z1%A2zIh%+$)4==*KSA_WFT38Uemk=!0XvAV zZ7M5vMn+ktEFtw05?|(sTKY>BEzTRt{08fiGt5W%*pr|~QEjN(LQEH!BoJGGq zCAP-nBtu0r@u5TbN|j?+%0j+=88s~YRUi zC||g4Io|kvPN)8o<$F8EwWCCaC!vs{+$s0QEnhrL)g1-F$7$zl*qzRP=6|S5jN4sZ zaDNI!D;|uKf+FA{lMG!D=xR6O$1)53A4TWl&*cC9aix+&Njc=Wa!BQr^Lgv!l+elf zuq4DnLQb>0DCe_K#OmWzIm~I!HfJH^IOaU39L6SQZ1(-#zdyisKjyyg_jSFl*Y$jL zgt(Metr7D~{rAsb?@>9xtDJ(>wv9qG-Gs%oRo}E?gK0v|`4nx4P+i^`7G=9vZV}L4-xa#N(8Bz{}#fOR1IMi$-i*57$7nB%wnkq0eo#8~t6PJ>jw2CQvTL%@L;LP$i$%%IsR_tQ_ zWO%v+6r=!veQietJ&o3RRIqo1xHqZ-CF0bY!wvnW?-s2j6&lZ58AUfKfc3@n&{G|B zoKhz%H`+#up13~maJB=rFAp>&!gLOEh5B9{NZ5RLPR$Gs@TtYz$t|kxmPxXd>Om9$ z!3WYAt8)_CbTq}Yk2KTWnRFcal2V;}iIVe0a6B66^3-iKxmUWrDuxIB>SugK&)_gF zYYZyS6rnmrs!#c_4P*p9h>Pos%Xw_BYUP%B~|1-)KtNp~~rSbch(U7J|+3DPCcn~2spN;2J^ujhfP2HyulAcpC$`5^_ z^?1Rj;6t|B+w*6d;;ug8`tmkRZtc5^*pJseJAuuI#JF>VVTAWFWsxCIYmS!%WeLqw4`{{T5?z%SR-QL(` zbQNK9l?0~0%+nGUK_44lBp*9u5Nk7q)S)B%e2Y9ZOne&DZLg@PPS+@>-T(z2t!Z%8 zdY<>)WS@hAOl?z=wMRN<7VOO6=*wIe8-XmqcNGQ|>s(T)?gF~IX;gpweg`%#KF z762kcW(bpJ)swmfBU`~cecme?7k_O(0(aBO05|dLy*B8@+DPKUD=(MYaVhyNWA<+? z!s{j^Fjp(%8|Y9B@;52i$U@Z=3V67UY)gH9ZKQio`Im6(w!NEg+v|L+$L?Ci>a)v+ zUzW%?8mr_rta_U%2rOtO0?(CVbwrtJ05$48@>%XFT(!R2-`4AO8E_XOP4+eG(>riTUHg`N$Cn#5N5p{UwM6OQ8=NcN+dZuNa$ z@?(l$8;l>tL!Vl}juqgEGL(SLlyj%(!jg*b1G{ei!YCZYK_PS{+J8qM{-Xf39cRwX zuGKVnu31}cX zT5kT{yK(hg8q2SOd3awLUE2TQ9WYrvMjWh_i29Cs4>&`J_FZ#Tn8cUBQ#8?{GX^D9 zt3*laB9tzJgWQR0-8BErX>Qw*=HgwN646P=myrq;Ms(qG&Zg3G71Y zy{mEkAQ6GlDHqPkbIj`Ds0jYYhQi7A_xPZQGEUpGyf|1Xa)IH53o(St1!CLzN{~kXZAhG?%EVNSRx=J1rAP8# z59Ry0#wTAGmJGqTI3+je(N0(_G#jq>8c$TK*gM>?lnv*Kh%f;9z zxrFM!yLNw3BN>z#E}N5&1kT&f1hd?iVTpJTpNK4dAq12u3ooZSZHm`aY>Wc*1(KNiMpts^q6ulbTxU+?)JrkGUe3dDZLm-ryw) z_P@N_f}Wm)Y){(u^AL0tQ<;LM$Q_Kf#C^a9x9{+a>cvjxrxmc%sZYa6!{0+{vexpSQF^T3j zw#SW+f>*C3g;Ho+av(Il&!!?3;jZPpVn6-$`dY4^17=fn5yFEEqMJ}h=CkRM3J;MA z`2}5>fY`g#LoFA0Rh^dbgO>BUgoKWZ$bXy{NfgxPQ=zy zT5nHTO0A{9Q0V{y9EnswI)JDwFf2gt+QO8Vi^+%a_5Fn$hU)F)aGTTO*~#^g%NyHS zhgGywwRBBzyqQ4&%N!r}$rnURT$;!C{;z#SlKKgZWyi;fQj0Q1Y*|Z41%}&T@g$Kh zvE3@I<+$m7%ocv;tEvOSB~93Ah#7sI#> z7*@4z>sI}ulvG{xM^9Fw;!25HN5<19;X{!;-S|uKNd=ZvbO*S7CDiPt+g%B)Dc{lasF`{g zE3DUI4DrpBMqR}8K%(IkYxI=GVpp7E(L78dNNHi~-K)2Kh=m_k`?fDBL#hMco)LrO zi?gT+?j+SS=Bpq!Ne@x7Zr9u^XH>FIjLBU-`?}z#k-VdK$$YgDM!irs2U#Z>S6#I<&jb^T6^{;LKBJ?>L zPIsMpIJuQhHa*@kz@`c}FwZO5M_b5puPvbCD zn*MXpf}p3Z_R66z@JdI0Lf#ea$5L-^E}e}`qF>|a;0;eZ)&noH$uEcg~_GV*qN}35nxd}S{-(5!@@0Zrj z4X?JsIlz;)3{A1}IT0k;yV{P%qB2W70*@J#NL(6D4NldY=hbyvaSBPx1-T&PlVD!} z1>22q#OZlj=mGrXOhI)ZA5Tt+5aOkttKXnDCIbgWUZ5{BWF4l# z5ix#&I{~uElW};Z`fHr{L-u#8MoKfn?79|G>gb}Lc?i$C^2Qt=R&D$jQnCk``q@zD zx;Ly3i(*)ueEY-ps>$o-f;ofc*~C>+D(1Rbm^0v%=iLJ?-lPoCS4oj+)JGf?LaPs_ z;|p5(r{&7=C2moBhmO~&chC9AN{NT2!2?l!FfP_{);NwMZP6kDDdQVVI=VhPPUxT_ z4nGn~WqNPk={euq`RIxiifx_>S41jOd_cLB2-WtVdkZ_Z+pIV|)fXH97etUIV=ImU zw+#e2@9*hRoj~yhG+07lZg`@q)ln++Tu^2UOOdLSz|?m4EAh<8`WSJ4NO4pAKEE?gX=EWu=54d(*=4c}G<`7wZc8+a+1Lp}^~mf@8++Qi*j^1z`Y1mUfBGG0)41z@4E&YdK={9+J4P zzy;_umfFA1zqADziO*`B-tykhI_R7CA?Hyv7mV6w{5s)+O?MQrB>WjOz<1$D?#__x zKg9t4nBKcjzUw}7Z4Ktd$^Ghk&E+q-Zmu_yIk%qiYpO$UN-M%tAyeLoX3}VDw+05L z8Py===?0Wcn$4fY7z%y?{h>)S{}+!3kar?6wEIMi5b}MGWLa=>#dg2%Tr~&T60BwV zDO>UT5UkRBrtac3)bBOmY>G#)hi@qKX4jxXE#JsC-v|+>MDJe23$w-{Q}_$etMnQ$ z%8|T#!0KLTmbD7XQjd4Lifm#RV^3J(spt=L20zvRC>e0Z$HOa+@ExV@^V{#R+tJrR zT!Di0Fq1IyQT4Rzt;;hNf7p|CR+>Zfv_V&OoHhk>2lm!I zf3o^u+$@pL*dIzl9U>*ZUS9ByWQ}j~5h)+6Gd-{HUSfyCQA6OE3muz#VmzM;-wdOs zUL^+A_D}#u$2dkNZ4oAeZ4)0~e{K9oW3?IS6-b4(p)?5UgCAHL^e|w-NBG4nHR6Y~ z-Y%Qoq^!F$Z-*A~-!Q3*7gs)I{TTm#AT{LS8h~h4s$gWYKdpPCWq$9;=2U~%-po{} zgx9=MH2y40Y$If_Es4lIzaJH#bjDGuX^8o-@C87U{nHBTU{h3x;HzWr@^i{wP?{_@ zqV6Z`qN8<;qN_h4mGeB$CD;)m0@HeJZSLH(M!3QbZIZFBEua|*W{V8o9@;`F1Vy!0 zO}%)-y3kzNVER|kHeVBEW1FrTS<9Z5975=m(4ya3#6-X(nvCYbP&#=LQ@iO~p+!@j zfbPLVqgM%+c5EPfX020$1wjwLWvLa&P`_Cc<5=xdQm*)8$K1t-iJD!3o#GvdKhM5CB{vAn ze?^zOzh%kLC1J5n?0>*94^L248x@ElV;xm`b(Q706NYpZ*{`U6zlSLuPwY{${tFK=s{|N$=*ejzq7{e*p#Cj+$jpM5B`hfS6|XX;FD) z7JfOIY3m1ASnimEXBkwer<8t$(ayR&B0I(;>(l1CgDSC&CuL4Q0><3alJ2!1rI$LF z({E8#6zr~zSPtt6d@pFOEWEouKDTi}{7Fg#L5y&QWHI61jpw|=8m3!P4TrZ{(+hk! z=mlHDvj$t|UqD{(>4^YTAMnD@p+xCAN}KptSfzeAn+gK6;0JJDJw z@cK3THFB;&AnsImTUK6lmTv+vESFJK{#4JV)vbE_y{Ed&@FB3KQ;ax7-wJ|sLXXio z8QgR`BBNEutzvOI`i45@{chN!Vf__b+kJ=g?aPr+PM3F}|85sP=}y`PHw`Y^{GB@< zaK_${fbOb|fpjEE-0`_kQX9X(>Oak8F_8(ib<@)O;u5=)Gzq62Z=mc)m9W&ArZMWP zO_j#gbn_zGUj_27mz@Yj;~mN?i&6K|Fu4+@CRp0TqV{p3*~WkT_3zrtXUR>M=1Yox zdK>idc>8N0RU;*0ln@hJbWgBADBpgh*h>zi@Id1tD^EXFSx-k;KFTp%e#6{K$BejO z=v5S89_Y%3pQp3aE*z!q#WgltwUYPF_!uRxvBa^BXEk~w@6_C9d(G$jh1B5l0Y(AU zd1CL*IH`RR-ranm_0aX>X^hZ&lE{ap)~?ekzkzoT{05F~z@MW!A>vJPV0?W)OO&3p zz%7b6|I%v2cDRIg-FCK}QUBmX3(-bU;wz!TQ$jq{OJscpyoIaPf1l)ey6}lSD10b# za9DwTvQo2sgp591&;Rn297bM3$+Ko46jKZ*>R0aNAw?7q@>z3pJ^h_O(I7Xy*yE=i zV86%^aIx>x$1`k}MRt0ME& z)T`PX$7`+TT|#OvNt_9dB72bLrZfg=PpN_J_}DovHH&Bw!EX!3uH{rmYBogpv1L`NgRwXB!dhDOr3fItghAEnKBDYKyHFR=3(|nhT z1DZ=V38rsubW)()F;W0mRl*mgn#>WhIvoP4w5>4MOCSQ=?^R+3Mo^_#8AIrB=f-cuT;$69~qrT2q2!(KRq1!WBZzH_RelM2c^wwq~#-99_$ zB14`wP+_yY8Kuf_+MbtH1j1%zluqZEUj{2eViA0p00Fa{+@Z^Nh0`tJ4z9tL*WpK<%$ ze&x%ZcTpw~j=6YitX|5PL(dBa(5fd)<)tC9V@ewp2lfou#yDII6ot;B{>+|lrU-Q< zozP4p-9pH5dU@ydmK8r$;Umi&GwrIk)V_W%1%P{?y5Tj0@l5eW{-Yeg)4oxQX`phM zG8p5W=2g~wu8o>|-nq*f7gPdFEz%4}QA)EOKsBtjrgj88OqAmr?lz*#oJP_Vb6x>4 zj(*j-QHgsbqDJXFgxJ0y&`rxg>&Mqr>1hgfJp0#v81DXN;KRN0fmzLCDexq!p%>7h zCxC7%yB2!?j8*z>lZa_xV^dSo=uVa}Ok^oBN9g5kJ0JIDxdVm4F+qHFePv?;b>)ux zO1;klM?Iz=PnVb71m7%CI!rj_k;PjHf!OT_l;t?EIul~T#MlztZ@p3uEDt+S_|^0B zxgw|5y^$j{FkjdZwYf?eSY5y~0shB|E=|~(=t;;%3UL)z9CQ76T*aLK z-*B_k|Jz+3j8LVF>ni9z4BeTY`56m?dMuu*6ptC^Z>r7VaWgJ;;n_Ymje2I}t&M5y zaQnn_()j1UG5!pdx^Gd1;>CRhGSqJvY4>40Uz?0KLo%7}oQePjJ)Po#P7P8Xl|Faz#va zr<%CDr1VB`g<21Qhi`2wI*BDjKpHTV5mokmn(b!Jn=W$7r$XhTg1x+-Ev~#RyQyEP z8oQ7F0O!J@C;LceYVZJwtkbpK&Qc5*3GkEj_JS@wZ@nJ5kP>r!<^w7_-A%c0A?CgQ zD0a+t<_&}O@86bmtEqVOk6Qqr!uXG(pOg@R3g;G37P$(=IT z9Vn+*ut2M<7LpcJdd`>7i1{g?b-0I-i>VTm@QHk#uH;LcvD$coGYUKo3g$F#9mV`@ zg~uaCg)E?E;}CrxsdSVJ%xA+PP$6(ody#pDq5Rj{4%Jv_5Xes~Aa{=aPD0O0W2B=( zIZoPGVLE3(0|bO2|6-0fCx~}filSxO#a{P-3Y1eigscxcxfe7IE@p(r|9r~) z9@lSByq2XDk9VIzbVxuN|CMa*PI-{8BveYrr+c;R4;agCI8}UQb-=2s;UdJ@vpsrCfivMGcfOX#2>hh6ls(kJRO5!(pD{l96fa?k6d6JfVu=G;a z(M$Sb{v1<o zBn^-b3kkdRId-N333jHmV*#5Z!P&dU3Wlh5@SnUo{pHQTRm>>*9gLkNYQ&JHYg*!m zcI`g_N@}8}_1I7Yo=l#_I$f$|IPNY594yN}2~|8+rpe+` zr-rt}D~sC95Tw%8gyo^`=$FJ{LVL!$XIVQN*V$^{z#4BCDb;@M4;4nixa}`&F8dP} z117Mh6A$-geo)Y42>A3oNc|t19ng^74#D1_N01OfZJvR%GN-Ost?obQC`jiIbfCh; zf`n+WW;jvS0#w2MY)8}_^_5fs?NOn=Ty>bQ4J3t^s@x(*SmW3X8@pY~QGg z${V>E5E>V{P-Cn3kO*kj?X)aj;z|4uw@B}whn+?BH}nPN&GUkFT__o9lgY4aHyOHA zgR5+3lYKYXdu0BYjqwkDoiza$TKL1#MQYPGK=jDW71J=Nqf*Di!R7qnsp<5c|FMlR z^W|Rqj;Pz*x8lN`tT&(Xn0pddqNF^)HY zd2Axt+2juuBP2P4pwd!<6%es=4SYIH}b zsbxh>OH_LZ;QwrvXvJE16?n1Rugli~tNQz_k;&k>t+DFKQm?|JK_$I5QdPlbIc_nu zm)IHjkJt_Rz$q4($-uTbY!cb>TabnE(B?A5`a?FurR#k2*B$Q@?tDbqi!yVf%~kOH zSsAbrwq^A;ERXF+GsP*ie{Gs0P^l)$n=19UPKJQ-U0vOfK=m{#`=adTHsd0~mnlF$ zL%BUus?enC18qP`y}bMYxtVXiX|DM^BRSRA0q6vcje}j73v}}!93PT5?zZhEHcw)A zv{b;V_POqh+QKYJKQi1a)m`hpxmB?VJbSm|pvEs-roVRtZM2>ouuZ&KVyrYN*&mg_URsE4n0^HruzUTbB_%(ujfi?&H$ST! zG24KN%uHjl9hK~f3wXKKMRnmgYR1l)0Iy6<0=&24rxN{=4H4PUQjyR1ilVGSJ%-7_ zXn5O%derY3&*7Cx)LA0L0vSw)fDoGZ^w3bs2B|RBOO1JyXFj&MBnM6L=jaLaxpM0^ z@55P~;ZZh7@x{}x%EqFu@lM>{=!Tvc3z*RiD2rHx2uhPR^1LnxSU>u3x4#-V{yI=B z5c316`wfAUYG`Pp=DLNioO?7pGca-N#1eZvgU|z(YP$2VScovwnTmKg3P!K{BNjd8 zk32c1)Ju0yRM&cp>_43s`1{%yE}lBnx$x!37+Pg`F1vou!Q?{=>Du*Fhbpil=Wtn1 z0BB+0?NHw>Wlsl4nARgFU@>9*)r;DUfw1`{D?)#XGxgvL?d=sar`Qbnsp@qA*OWA~uO#rTM?8>!@q* zj4c}~enH&db*W5ONH50j!`YB0=w}$39+fssG5TZYlgw`h1NA_#ry`cS&BisqF3msQ zLR+|O{gb@9r+3baS(Ui}aDd;A=KYWt-Pu1}EZ1IZ(ftpmP(1}B=5^W8q853h(B-vp zYPKcIrv3z^3F)uegqw-^*jcWOfSDx{rOHL zamF06J{N130~E#TNtMbblF7SE;lE8K3WEKAW{@SsUHg*xPqAxbzF4!kZc!K{#bVxe z5Ruqkx|k%Q8!X|K?KNDw%>LQ6?yt+m9&SPb(h$*#)OYeMxu$(jW2}Z;yH~OQ+1oj$ zmy+8z)tBNWNE#jiX)~(egw0dw-5fuXo#ex1&msVDv0XVb#1`kSBl!86eU6*fTR$F; z>X)?xJe3md$D0=yfFwOHhAlk|4W`bewdM-e#DXEeZmZV`)JsIkk$KU(Z+@QA%(eSQ z8Eg?hAKYZII2aeQhe>Gid{$da)xi6C+s*Rc2v^%U;Wl-$@(Md&-=6x~>=k;RKvwqx zJPLrFl^yrVi@#Ge>0z!w_PU*r=l`kg()K&JZR(ORn2wgx{xC@{gSLm84TM??<{l+St<{Xy8rX-b)N)yrA6!9WLhUqfL_7SLY|=z zB41Y9ip+H;zi9BjqM=$gsHXj_`%HNI5AhALwzGvM@KfJ@7p5}9LdEDvbulZz**rfd z({cv*+=1!@j#bat?xBfyx|G*=NoUM(okbpRT>P_S!dI0AFA2EoRfrpeniqId@A;73 zxSqJFT;vHo^rGdDqB#Dt(Dl-+)ro`ja2#c|MbvfHLBwT7s(cM6;gj{L#HPb@-^eS$7*p$|jZzKRmns z{KMev%^=y>{lRzW6To~5i>EHt;=|l&ocM(2IHg!IyL8gz=-V;9rEVZ7aOwGu7Unxr z+oy2WylEer2%_{wCOm4A)KCgzB68M4 z(U1+)aOWEg`=wOk3=CUbX7Lu>axk9O6KK6*$fYnU2cVtV{w^3su+*27Hx zNbd9DK&?lJV^y~f@Y6T)k+Hx5nfeuJOaDy$$Qovyfjl4eFG}>`yd%@_@vVK0r4YNP z+zO4Dn7i3)Uc?2iM7(J=LpoV z_yV8I=woqRW4H#TDxF{rKu-|Vd&VtqeeWaLUMerY=^Fz`KPz62f32F9sOqSuf;8YP zxjQqB2Pc#~K99BfTtfmbd_*Qq0+F-2bX#z2DRPuU-m^Ft^o+R0{i`WSi0)#AwKYf> zK4P8{17jtOyItViOoyW=JtYP|({9|~;uPvP?iAIvxi<`ddsJO}rM5nD@-X>B?62<~ z;nvA=8^GYCD(f`BFl5S86bs6jU|xNAG(FRBY)@-3HH>>fF#Alx`}0pDFIc=1-EhG3`F9Yrt$=Dp62TO_~*nv@suXY4fkEJT8eO`EI7kvaoL+ja?ieA?{UQgK@gi@1hYcVN4SSjDtW&ma~3~Y;UHxFKk%wO!gNgNj?1I zRnvzEiP?esoY0H6IWwufl$cHec!2rOKrPV9?Mww|aC#$na%yBM6y&3UfYf~fD58R+Es_dxS8B`R)DN!%08&ANgJ zDaef0gT0pD3x33UR%=|xH}D1cCl(j+Znjy8rUt?t!CQTGaX$ts1p03e?QH3lx+eZJ zrVlHOLJ=R`&MY_be0UWsoKKbQ^oapQXGqeXQZ$PF=o3@FuqRNUf;BSQbWbM7?dI*@ zh3B4h+&q>h-2V!IpafYHQTE_1RVMM8ne%*4d_T9 zHnfyreK%|vqzVPIZBEaxyTn0+KTM(jwfq$CmnU71zOOq&Cet&eP=Sr3hj1E}E;{dp zXcHQ82(s!_DpLLr-g&(Es^Zt0Nd)jQq=KYc_MK#?0_`pal~keV%Og&G-kj&n zUK!HgF8$1z5PO5EQ|F~iZi7uYZ~IU?6+vSRGcP@l+s8rZE(>@3E8<_DA$R=RfxKed zh&^%_&Us$vnfNGE&CXG64U7Kk`)mh^^5eRfkau;@L?2L^{NxMD&OUm96dx}%*_|Qj znFQ#b#xKZHpg+@WoCGdTC*Nc5(q`1FCxGcPm<2dfAa4O+D#0g;=gFgx^C*Ga0pcjL zXU3_AJ44nAFP?GiC)bP&aQH*9jQISv z6F>zxXs_t4U^jM&o&f1m;sGJa+i@W7F>1R>o6}1rRNXX&$2&^WT2JgeXV)=-gDn*s z9tW9wUxMqaJ!aOHYe``^Texzc+?|XqxlnLTFqM_E7uW(xfSmyw;PCxi&Ec|3O*K^` zS^BR#c=`(m%ZuBkUDCM>IWAsuYr-$$0p^A9)ova4D4Y%oEbN7w!08X?c)|jLA5UR@ zU-)lS>cLXcr}ve}BDR!PaRX!oX)b0?tVxxc!Aq1{;D}V?h6^f=QOnZ55={|BL1n`h zU#33AC2Nqci?RJS4@m?}pu7lB{n}^p-&ycPdL89dbHjM(|Jo`41)$%o<4TM{o60dXUgj7XLVBAxk|h+4|B5@hhF}BA?snFlJl#sPcGwBZOcr4 zgJxllNQgg(&Z6gTx5IdGnzoKXcSuHW|EnSRXM!0R-I40Dr$}S?SR2UY&u5RFZPp$8 z>w{889viMf>W{inG4H*~y#b_h*}eA1Bi()oEl4v6dgQhS?fR{^Sb{+>^*?k-To|b? zEC(riW9mmDnwlC_r--R{Z~m%znI`##R1cq=T7Yyai5yUZj7Y2)|M7P+X(j~B^GRuM zt-A21Mg9siA+bkES4i~8f z8J9Z?7M*i!$h(?gQh5c&XSdM|>Vfg^d3V9?;)v49me|!xONZ7ycz%?m2O0}YC_@6J zFV3w&FD5Fx=6n3r1<%_+I>r^0qL~+5 zNp#kD4Mc~;WT?S9qXSm-vRaCZd2yf>-VN`wQjxVmcF79G~ z$<6CO&uO_yl?7w0Z}h7)DN3kUT+FsF&9*?uy5ovko;zH~o>zVye{I%!-_`%XI*YjN zviv_bG5ZOm96f%Pi{OpTvA>5nk!?JZ)}cCe*IE1ES)xV@@WAiD`dg6sKN|no(w9 z#n;%>-cwEhb~6%8?G-jhpcvKuD)}mLG4Z=a3YNR~Xm~G3${L(<2bP#Cag+rT1o$ZK zx2dl0S;y$#+~qYWHi(A~-G~zVx0HT~ap{-`mC3LClI|UGf(Ph-%rJWTH@Z(7q*7^< z{QV;ID)V_psG|l+4?;KEeSWcF?8@S@fmy4CunW*FB|z`@W$3=pac{La#~StmpO(`( zd*4O6BNhFPKUC8+Q_SN$sV7_dDn#qnMzRZf~O{J+4P zfGh@ic=kQITB71Bst>1;$>Q9nxWI?~(Y&DAdbV2BmU+ilDW&EbPm0rhn{f8uef2$3 zrD0MG16we#>*zDNIw{Ig-Q^o{HGmjZe)a#VEt_Nkyj z7MCtbtI1=5ppyxuVZR5g8gS0W{uTukh?dn@aJf|7uLDo-w2c4aSsirJ0C4iw$zd~9y zrO3uU?$lX2*G+q&w%?!k`+sbK{`tqM11>!$a4;@|KSenqzDd}VDBGT+YCv61_}NYp z=kAgxgoIBk_3qr=zZ>-~dYa4sR3opb(q`DAg4;%$gf1oX!?Q%8$G(c+e4Tw(@0~J^ zOTHJ+lPkAlH_h?3IBvCA*Bsb4b5x{x(wIqq!kPo$KmKN&a$sAu5$cH?q8cq`{NRn< zMH5eLttnzLyiVO$e&~O`ZV*>@LE4iOdM|P20JLaSC$;)S zi}B(1B1hRnNc3jGBNFGA8@(xQI2B&ui~art9O&){xA-f#zeZ9z2Oza7{>kT?KnF=O zSwX;?TgP1d(zRRJ$D0D@=g1uMU`4V>3M+cMJnmgYw|zw$$4OA{m$^n6*JUZGKUF!W zc`IVyZ)XLEmhUTG{`6}_JH)fvKQ&bSzYyJ0Y+c1ppYo?`-RJ6`q~|wIm1YbaP9Iv+ zOu+AtphFfNtf9vNs$!f{BvjG^(nuA07vRnv;JuMN+#-4^NH@|U`E-A%>sGyYqW9A; z!HKWi11&uNDG}XSoIMmDTJ>;=Hxp6n@K-`^WYIo}2YHjyc4D-8=hdUT$v6IyF7c)$ zDt~!O7|fNT{*R5xFG{-@4i>i_+fgR}pJtuLh8JK-*#82uuZxjX+IyIfsB7Tk_@GI1ehy+6MNc0O!6txtpZLe z8!|X=EHS%rS{Tx1y6;(8Sgz(tp~oqcaD;tk@eX`!a3g*t^5KVH8QlECt9Fz6-Xm+6 zA2Up6x-!EZX+cFoss%Fe3$RGLzO{42CYFF{_EdJZ`oM^M<&F66h#uk%EwYpPB0Ls; z5qTvG7egKAM0({(N%kzFc|8fhHNlh{%gr@r%A)EWyjTZ!8_&i>me<%#GfGqQK}NHmr@i39!U0s z!t}I-&qHeZ@b(P5z-S+cuJLfo|(%@oUL@4zFO?xOq z?&Mn#b^A?kN@2S_tsdBnq8cUNarp*KUq$KB$-yLeOhXNZ#LF}1ao9AT(HFYVZ*CcD z^Phq1l+dT!J!d(k|8=4g^tl7G9z&@$bv_V3jlLnTM6PDEoGie(vU4)atkb{mX^Z6f<59mJSqQfZ>~BZG=BLpULT1iK74!X} z9f9ZSCt=Y|z%Ab!$>D}8364+P8+I`%16A_!UkjYA&-^VqtL4yp?4_2x`Nb?eg8HxhLbq=o z(&!Jtfa>+3aWF97MDZ`?BYRw&k)Fwgvv2mC(s#;9TtR(z)(2E9EU#pRdimfV|8=$J z(*e#0C~+%Dtg%9ly4&FVP#&Z;J%;9i(nk7Ig*uW196Cm(ilQ+yCEq4G1G_y-mw)<> zCeBcAG~bt?*c^hk&8VIERt?j%^Xsq8jSd+y z(dk!1yYj`pFNwh6KVrHC7Z-+cGf8ROL!e1Hn7Fgz+`(k{?u$!Vv)i zABa{?Cn!i|G&_=u(v?K}b@|q^Bf{*$f$Q6iWkvnqQSM4m6-L0w>B+{~SGdG1IxJ-e3O=;q0}80-cFq2%jRP^CF77!| zRy#|k;^-6f97qpNr5o;>RMn5~ro8t1w(iyVTdoIM z-~@x3?!V~{Rs0rMWCGPm{`p>saFNt_#)8FJ#4zzVrSHCwNmc|A1@hE2)D=p6>`&F4 zVl{2Arais!95k@G$O!Rx_@BcRQ`_Z#Y_L-!kLId@(H6Wr%{Q!YCl>p2Lunh1LOVw_ez#xe*0z*G5S*lGab@vaN)EY+ez7d`p7 z)!G`!l}yM|5YduKG!4SH?l5T>P~D|6~7Nq9@0@7u{QDjM|;VYh(4@x?nXgM)(<3hOKQt!_>W$ zZ71I!tYN6eAEq(xDuu8~rCu{@CdCRBm3osZ82bwp}`j!rMA03V7Om zx|le;+MYUfz2{GG{Nk}N<6Vp-j$1qmbFoWdWJjB-evM)DYj^udDwVEHscL;i51Ar8 zY4GQB@}x-V50H1x-uM%aT8|tQSkK%KF-+ms+m}1j^y;&stXqq53b*p%Q;~!1g}Kdw zMIVuMVnziO(GimH6Yiu|!x9~O3gpV^8o1PFUcRNbTHwWmAH}QZ2+eQR7lCn!7|aXA z_epGhP2EUgLv(RMJk&3JJ~y)2VesmY&#ksT-bZ7g7jvj6d*SZ_`C;+Y;a?wC7fQSt z4C@fLAZ zS@P$Vp|7{}zrKELD+Sb<^vut{`3XCXci)~GX`2-oV(Koy<0beqjYj2=a*=dZHe$yWsi2-YgzqQd}MYN}Xww`j%RHO}hE|Wh zdMB@qKg|Brq|VqU|k^Lw2IE+YL@|s3rU1|ZVP!@?P#{aBXF0S>J{}G$$mz4SZwqc=P&%z zb6c^yPrnZr)vo#t4*2m*K^z~y_b2W%2f<+mPLp|+t}t)vXixg^q5by<7s_&E>~SZv zt1ZUlKd*(7mH%)Z+yUdM$dCV@N{|j_FhO&QkU9Vb^|eh~-Ag*>&co{H^2dX}j*-sJeRhYBOT4+JkR&b_T*rYrMWl7;yY@!KOQ33`7>P1Ss8Oy=&d-%AaLtKJmICXdZ~MDA!sbZi5i^>czY>zP@vZz6?D+6H|&xv~^mpB(%5E_&e9DLMQId2f>N+u}6hq%XLL06d$X z2bOgHmOfH3>D~b%qe?~mzSch*YX+oT+V@}4ea!0#O3ta4kvG0q>a)!7K^6^J^-`$qH!&vn30B8{ncZ?u96RoLarDXu49DxTWsxRg?b z^?f{vn#w&3==61DK;>%Od5GSnK)%B*Wha#raT2O~Wj6f<0SB-h-SXs>tdIGbzx6mt z{g3B;;$AZ4sMmum-ecn;u|qy>0*=>T`-z>uz&!KicqpL~k;Nl|&MEX1(V4Lyh`c75 zqEb?X@&0rq*C|7^ zZ2g|x1D0Ys1B$)yzxFopm+hl#WAT5p_Tc!Xq=Odm=3b>S^dhlMnS2P&?sgL^Mc z#!fSyF<%z^9q@hinYi&rt2*lbG4T;6TGr&ZX$S=5TR>Zp5`lt$ixuu)3%(TUUJTUN zSomS$E6)h(GLN2p3KmG2`2?5aqL z$$k@S8a9pLZxi@Jc&t2C;b>hg);Se^ONf-?eY}7MR~ZE4b~WOXXAUF8VK(+&k}u$?4}STit2bux?_KpWpjp8=xh1C z)IVp>2Wyf^Fz`OD4b*H}V{NM3!zH-|fdB{UFmqm?`zCld_Dj>FzR`bbzk~iX@d&qV zk!luRD!#f^gp;+}5a7Cc<0N`lHYbK9Q7Zn?emCjn7(8_8S9GqM^?&4j%lkt3zxG-E zg#Ik)pA&U)r>KOt(G>)1WOo z5Ce%~P3kk$j%xm+8ByNP%Kk#c;wGyv_&?@V{ic85m|ivbNu{6Kv-U1A-`HuI7s zH(_yriGnwG&P9KGU*PZ9i}u6#8>w1rUj_a;c>e(5Exp{IHie~XQ{8EiESx%+BweLQ zCxO>B?jNz&hd*xbgMK;j55V0w_P*7%Ej1&G<4;{n!s36k#z`_q2JHE9<-XwbuO~9d z>D8}Jcm3k>_>)O%rn+}KF!p zIBcH1E1d90?RjPJlf~0RqkJ^+OK3Wr!egY}X!3tYEsUSUW8m*o{oc8+P5q;8ejfhQ z9|h#p`~m%~ZQ>0ie`x6D{{TX`w|H(NJNA<4(SnsY^{;BKCcL34QI9OTKRpPHFsgTx zvS-1+8h>Q(ivIw!-lMJf+eOox#(oS8CA#|(1=KW~bUYs@oKF&i$X-AhHO>4!_#yD? z;}?I42gRR--?SE+@b|~sD~Hj1Rif(272lPQxpNs{E@5DB(YGKBbjYu(?lljJzqAK| zZoDz@!Z`G=?Hdm(R@CoSWs=aG2~q@D^4Y-|J&kylulpE$C-__O*TB+vr{V6McW-gx z2;tP)T@ar>J*O@~PnZ$_KU$2hFwFTSR~LV*Bqn*Psz_kjB z{O<4GR_L#9oxI3=WAVrK*7!{(&)P@!SnwytJs7~sJW24|#hyj%k&%MU1N^S1x&e&l zytm=!h`(eXi+>T#kA%PAnZFSdXHpKGf33R34284unB1x%?m7(D>t>bvBYYEv$~%Am z00;aQ(<}^#2#(Xkn@3#m7CZsZYPoOlZ%@*0Wd6+X*tU?z^Z7bsCjfLfL0wd{FJ~@Q z2WRTi``7Awam0I7@5=V>*ZCh8_^aU;!Ve92e@yr-@R#;6(0(5Hr^hkF7PGC-siow? z)gOGZBVe6{R}8!Y8=Cstz?}g>?9ltvG9NP%kl1;tH-8G@RnhOc$4`g!%S?BHb@blh&N*Usn1{{XbF#gBgx_}liR z__y%~SNM10Te&rTLdw&_amAxZ@^sm*;9^U)nExLG(JT0lKI)GA5HelHu0XsRxc?a$B z@ay6K0Ehk&YJU-YPvG^BT+@H;7-=^!+C9C?%6L`Ys0m#49)iB-F5+Cu7#L;LDe_Za zb9Ya%@VT!AU@;h)`IKs}Z#1oaQfXW9UPs5@9PKPVCwy$s{t?EaT`R)AR1=^!hk+efJm=t{f)JINIp7zPtx@j5o$WWgpycW7!oTnQ;&S(>0Ezzz(0Z7zLDU` zF8I>{pQxaZDrEuxcqYPAB(iVikd&g zj{rco9w6{{hjit-Lv^tan;vyj#Dr=lM)!$YJt&HF&@6 z2k_@#(0pm7{7mp>k#!Z;pLGPjB-0wgREJZE92GJRpX(PqH(G!2syQfL68F5*vuClI z<*~)kl;||7HKw{;>OL!e%Krcg{x5hA$Kg-KzYkm9-CAie#}2NMs@>VX(Uyuq^99K0 zNfq){=j^Mee#qLB+I(yHHKqJV@H~>{)5bPhLP>X}u5fjFWyjXO<FnS4>;tp@(X!#*bc&cC6H36cmd8HO$)U6@_n3X1w4_FL6;>4%Hv)BH84 zUM#*PH%SyW6J#sIqdD1(dsoocttirqYR}0(Bkpm|t}`xLtG<`~4;=U>CX6SEQ-f(e^m=TU$jbv#s-)vRzc2U)&p);A{1g8G;Wxy89!v3q;eF-L!@n4` zjdA|~2|t6O7VC5Z?%gyo;UruRP66cSHQ>JmJRSRHFN<&fJZZi=@W+j{?K&mVJTG~w z!90IaphjsWF@@-aaP9^UP7Ql!?K|;{S=4+xKf=!f!Kuxplm}G2x3`sFRSlAsR_<5# zaAyWb>TpGRSM2ZbkH=mb_&;N9qj+BC`&F7Zy40Q>E@Kiiw-Cp*4%pRB03Ul9HSN;I zRjY<{WbYNDTH0Mx(^H=>SnLFY9{taL0 zb`V>L??2%Zy_)k)n1(JJj4+IxWe$7Skn3yV{{R+vc0}-<<*n54$CxzRTt;I#$W(}s zLhSd2h!**ssB#7j*X4JZtc3`Wb(r zT-)j&*^=4Z#K3*wDj+9;-y*xPxHw^{H-`P&cDj1sV_FochnljyY(RSNY>#+PsJXQ`$o4vGsFMf#nL&QG|+r}RY?sN?nO9Opnr(1vA_+j+P z14ayKyAiPAqJQEZ_2ZVG1AY|#+_&B;*St}sMQdZC!D*)We^7$iURd=6Ml-y7zWgfu zG9C!caGHmQe`#;obHR7e{1ow2a_E=V(%Y|vd_^4S+H8`7rduiYu~D3#Eb(5u@XJ}h z@Tb6?E5l>qFNtl2k8K-6ccp*n?nLwQ?&d%M%aU`N_^f^swpmq55p(8uR=wkE9!#+w z+I-X2&qvwqZ;=ME@!$Rm1Mr{4{t^9+JVoI@hCU*L#oC6L-wS+47oVl1HyUwQDDWzk z+qH7RNEtk5IIp*~FCTxwNi^`E+W!Czz70LW1*W;R*KgSoi9CfdqBfWfA;y>80 z_L=>#{6DIG(f%dz9DW$obnQL~J|1cCH1l0(^23%}jDfXxpS*w^gI?wPKmP#1M?6pA z{{RSDcxU#z_-U%?+WY~nsz{m3qJ+#$z+|c8Ij^Wth9;8sm%q3L^{#RwqA& zd|&%T_@CmgnW0(yCH}HG44l8EeYfLJ+TZqv z_``oE{4>YGtB(h0JGQ@y{9mWL-Q43DFj}hoqqbVS3-*D!)-`_%J8u(wSBifZYkDNG zrj2*4Y34+Zv6fifTXc~Vh?{WE;g0p`{u1%e$6X9Qned-Y{gQNRh-QSNC7z*qFmQ3zWVQf%=ZaX071LA0 zLhr5DLp;Mh%wa`Qqwg)6dt0xcrl-r_5B>_j?K!4=GyQ*=z7P1TO4szeJts%L_=k6; z>PTaQPl>T2$DD2sRaBm}^q1{F;_n3O-?es?@R0b9(mxISG19H38f>zxQC(`%tBA)u zd2q#y4^v)g@Q?N~_@$@*(He)xe;IrO)3oTd3s~T}()2q^t030uK@skP6&rE}R~++S zMffxJeei!4nc^GI7wG>0v$c+!E~9bwnLMpJ=HZNoAd>ROl6mi4j+HDIB%vL;S*vKT z{{RH!%b|qB<=(>&6$onVpH}bRbCU5d#2*F2rW=hP#FkUFyphQw+s>?T8#}l>(Oq}O zkAR;Gd`FYJ2{EU@4Nm(l& F|Jl^r3;6&5 delta 122652 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

tOy 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 diff --git a/tests/fixture/mod.rs b/tests/fixture/mod.rs index fb4e9541..5b9b8859 100644 --- a/tests/fixture/mod.rs +++ b/tests/fixture/mod.rs @@ -153,6 +153,7 @@ pub fn pod_result_style( 1_737_922_307, 1_737_925_907, namespace_lookup, + "Example logs".to_owned(), ) } diff --git a/tests/model.rs b/tests/model.rs index a2b3e133..b2040381 100644 --- a/tests/model.rs +++ b/tests/model.rs @@ -104,7 +104,7 @@ fn pod_job_to_yaml() -> Result<()> { fn hash_pod_result() -> Result<()> { assert_eq!( pod_result_style(&NAMESPACE_LOOKUP_READ_ONLY)?.hash, - "35abb8180349bed1f3ea8c0d84e98000ec3ace904624e94e678783891a7e710e", + "0bc0f17230cf78dbf3054c6887f42abad8b48382efee2ffe1adc79f0ae02fd1a", "Hash didn't match." ); Ok(()) @@ -129,11 +129,12 @@ fn pod_result_to_yaml() -> Result<()> { location: namespace: default path: output/result2.jpeg - checksum: da71a1b5f8ca6ebd1edfd11df4c83078fc50d0e6a4c9b3d642ba397d81d8e883 + checksum: a1458fc7d7d9d23a66feae88b5a89f1756055bdbb6be02fdf672f7d31ed92735 assigned_name: simple-endeavour status: Completed created: 1737922307 terminated: 1737925907 + logs: Example logs "}, "YAML serialization didn't match." ); From 9de08e1916f03b4bb440ceb9566687d6610dad8b Mon Sep 17 00:00:00 2001 From: Synicix Date: Thu, 28 Aug 2025 09:50:34 +0000 Subject: [PATCH 34/65] reintergrate logging into podresult --- src/uniffi/orchestrator/docker.rs | 3 +++ src/uniffi/orchestrator/mod.rs | 4 +++ tests/orchestrator.rs | 41 +++++++++++++++++++++++++++++++ 3 files changed, 48 insertions(+) diff --git a/src/uniffi/orchestrator/docker.rs b/src/uniffi/orchestrator/docker.rs index f99bedab..3125280d 100644 --- a/src/uniffi/orchestrator/docker.rs +++ b/src/uniffi/orchestrator/docker.rs @@ -72,6 +72,9 @@ impl Orchestrator for LocalDockerOrchestrator { ) -> Result { ASYNC_RUNTIME.block_on(self.get_result(pod_run, namespace_lookup)) } + fn get_logs_blocking(&self, pod_run: &PodRun) -> Result { + ASYNC_RUNTIME.block_on(self.get_logs(pod_run)) + } #[expect( clippy::try_err, reason = r#" diff --git a/src/uniffi/orchestrator/mod.rs b/src/uniffi/orchestrator/mod.rs index b1aa83ba..067d458e 100644 --- a/src/uniffi/orchestrator/mod.rs +++ b/src/uniffi/orchestrator/mod.rs @@ -121,6 +121,10 @@ pub trait Orchestrator: Send + Sync + fmt::Debug { pod_run: &PodRun, namespace_lookup: &HashMap, ) -> Result; + /// Get the logs for a specific pod run. + /// # Errors + /// Will return `Err` if there is an issue getting logs. + fn get_logs_blocking(&self, pod_run: &PodRun) -> Result; /// How to asynchronously start containers with an alternate image. /// /// # Errors diff --git a/tests/orchestrator.rs b/tests/orchestrator.rs index d129928b..fd18afca 100644 --- a/tests/orchestrator.rs +++ b/tests/orchestrator.rs @@ -296,3 +296,44 @@ async fn verify_pod_result_not_running() -> Result<()> { ); Ok(()) } + +#[test] +fn logs() -> Result<()> { + execute_wrapper(|orchestrator, namespace_lookup| { + let pod_job = pod_job_custom( + pod_custom( + "alpine:3.14", + vec!["bin/sh".into(), "-c".into(), "echo \"hi\"".into()], + HashMap::new(), + )?, + HashMap::new(), + namespace_lookup, + )?; + + let pod_run = orchestrator.start_blocking(&pod_job, namespace_lookup)?; + let pod_result = orchestrator.get_result_blocking(&pod_run, namespace_lookup)?; + + assert_eq!( + pod_result.status, + PodStatus::Completed, + "Pod status is not completed" + ); + + assert_eq!(orchestrator.get_logs_blocking(&pod_run)?, "hi\n"); + assert_eq!( + orchestrator + .get_result_blocking(&pod_run, namespace_lookup)? + .logs, + "hi\n" + ); + + orchestrator.delete_blocking(&pod_run)?; + + assert!( + !orchestrator.list_blocking()?.contains(&pod_run), + "Unexpected container remains." + ); + + Ok(()) + }) +} From 3ace99d70d01d42014c0937c6f2fcd502871c3ed Mon Sep 17 00:00:00 2001 From: Synicix Date: Fri, 29 Aug 2025 06:18:12 +0000 Subject: [PATCH 35/65] Fix code according to copilot suggestions --- src/core/model/pipeline.rs | 20 +++++++------------- src/core/pipeline_runner.rs | 5 ++--- src/uniffi/model/pipeline.rs | 1 + 3 files changed, 10 insertions(+), 16 deletions(-) diff --git a/src/core/model/pipeline.rs b/src/core/model/pipeline.rs index 137c9534..ffb0e319 100644 --- a/src/core/model/pipeline.rs +++ b/src/core/model/pipeline.rs @@ -61,8 +61,9 @@ impl PipelineJob { pub fn get_input_packet_per_node( &self, ) -> Result>>> { - // For each node in the input specification, we will iterate over its mapping and - let mut node_input_spec = HashMap::new(); + // For each node in the input specification, we will iterate over its mapping + // nodes_input_spec contains > + let mut nodes_input_spec = HashMap::new(); for (input_key, node_uris) in &self.pipeline.input_spec { for node_uri in node_uris { let input_path_sets = self.input_packet.get(input_key).ok_or(OrcaError { @@ -71,23 +72,16 @@ impl PipelineJob { backtrace: Some(Backtrace::capture()), }, })?; - // There shouldn't be a duplicate key in the input packet - let node_input_path_sets_ref = node_input_spec + // There shouldn't be a duplicate key in the input packet as this will be handle by pipeline verify + let input_spec = nodes_input_spec .entry(&node_uri.node_id) .or_insert_with(HashMap::new); - - // Check if the node_uri.key already exists, if it does this is an error as there can't be two input_packet that map to the same key - if node_input_path_sets_ref.contains_key(&node_uri.key) { - todo!() - } else { - // Insert all the input_path_sets that map to this specific key for the node - node_input_path_sets_ref.insert(&node_uri.key, input_path_sets); - } + input_spec.insert(&node_uri.key, input_path_sets); } } // For each node, compute the cartesian product of the path_sets for each unique combination of keys - let node_input_packets = node_input_spec + let node_input_packets = nodes_input_spec .into_iter() .map(|(node_id, input_node_keys)| { // We need to pull them out at the same time to ensure the key order is preserve to match the cartesian product diff --git a/src/core/pipeline_runner.rs b/src/core/pipeline_runner.rs index b555a7f4..5494923c 100644 --- a/src/core/pipeline_runner.rs +++ b/src/core/pipeline_runner.rs @@ -51,8 +51,7 @@ struct ProcessingFailure { error: String, } -/// Internal representation of a pipeline run, this should not be made public due to the fact that it contains -/// internal states and tasks +/// Internal representation of a pipeline run, which should not be made public due to the fact that it contains #[derive(Debug)] struct PipelineRunInternal { /// `PipelineJob` that this run is associated with @@ -914,6 +913,6 @@ impl NodeProcessor for OperatorProcessor } fn stop(&mut self) { - todo!() + self.processing_tasks.abort_all(); } } diff --git a/src/uniffi/model/pipeline.rs b/src/uniffi/model/pipeline.rs index c360454d..250fa742 100644 --- a/src/uniffi/model/pipeline.rs +++ b/src/uniffi/model/pipeline.rs @@ -51,6 +51,7 @@ impl Pipeline { output_spec: HashMap, ) -> Result { let graph = make_graph(graph_dot, metadata)?; + Ok(Self { graph, input_spec, From cd0e6754be935bd6d4255b0243f8e4be37ee1a0f Mon Sep 17 00:00:00 2001 From: Synicix Date: Fri, 29 Aug 2025 09:11:18 +0000 Subject: [PATCH 36/65] Revert test feature to be more inline with rust testing standards --- .vscode/settings.json | 4 - notebook.ipynb | 65 ++++++++++ src/core/crypto.rs | 40 ++++++ src/core/mod.rs | 41 ++---- src/core/model/mod.rs | 8 +- src/core/operator.rs | 233 +++++++++++++++++++++++++++++++++-- src/uniffi/mod.rs | 2 + src/uniffi/model/pipeline.rs | 2 +- src/uniffi/operator.rs | 11 ++ tests/agent.rs | 26 ++-- tests/crypto.rs | 29 ----- tests/error.rs | 25 ++-- tests/fixture/mod.rs | 2 +- tests/operator.rs | 213 -------------------------------- tests/store.rs | 139 +++++++++++++++------ 15 files changed, 474 insertions(+), 366 deletions(-) create mode 100644 notebook.ipynb create mode 100644 src/uniffi/operator.rs delete mode 100644 tests/crypto.rs delete mode 100644 tests/operator.rs diff --git a/.vscode/settings.json b/.vscode/settings.json index 15fa34bb..a752f26f 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -11,10 +11,6 @@ "files.insertFinalNewline": true, "gitlens.showWhatsNewAfterUpgrades": false, "lldb.consoleMode": "evaluate", - "rust-analyzer.cargo.features": [ - "test" - ], - "rust-analyzer.cargo.noDefaultFeatures": true, "rust-analyzer.check.command": "clippy", "rust-analyzer.runnables.extraTestBinaryArgs": [ "--nocapture" diff --git a/notebook.ipynb b/notebook.ipynb new file mode 100644 index 00000000..f045ec7c --- /dev/null +++ b/notebook.ipynb @@ -0,0 +1,65 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 1, + "id": "3ba2aae6", + "metadata": {}, + "outputs": [], + "source": [ + "import orcapod as op" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4d867643", + "metadata": {}, + "outputs": [ + { + "ename": "TypeError", + "evalue": "Pipeline.__init__() missing 4 required positional arguments: 'graph_dot', 'metadata', 'input_spec', and 'output_spec'", + "output_type": "error", + "traceback": [ + "\u001b[0;31m---------------------------------------------------------------------------\u001b[0m", + "\u001b[0;31mTypeError\u001b[0m Traceback (most recent call last)", + "Cell \u001b[0;32mIn[3], line 1\u001b[0m\n\u001b[0;32m----> 1\u001b[0m \u001b[43mop\u001b[49m\u001b[38;5;241;43m.\u001b[39;49m\u001b[43mPipeline\u001b[49m\u001b[43m(\u001b[49m\u001b[43m)\u001b[49m\n", + "\u001b[0;31mTypeError\u001b[0m: Pipeline.__init__() missing 4 required positional arguments: 'graph_dot', 'metadata', 'input_spec', and 'output_spec'" + ] + } + ], + "source": [ + "op.Pipeline()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "7626744f", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "orcapod", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.18" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/src/core/crypto.rs b/src/core/crypto.rs index 31933463..698b6de3 100644 --- a/src/core/crypto.rs +++ b/src/core/crypto.rs @@ -107,3 +107,43 @@ pub fn make_random_hash() -> String { rand::rng().fill_bytes(&mut bytes); hex::encode(bytes) } + +#[cfg(test)] +mod tests { + #![expect(clippy::panic_in_result_fn, reason = "OK in tests.")] + use crate::{ + core::crypto::{hash_buffer, hash_dir, hash_file}, + uniffi::error::Result, + }; + use std::fs::read; + + #[test] + fn consistent_hash() -> Result<()> { + let filepath = "./tests/extra/data/images/subject.jpeg"; + assert_eq!( + hash_file(filepath)?, + hash_buffer(&read(filepath)?), + "Checksum not consistent." + ); + Ok(()) + } + + #[test] + fn complex_hash() -> Result<()> { + let dirpath = "./tests/extra/data/images"; + assert_eq!( + hash_dir(dirpath)?, + "6c96a478ea25e34fab045bc82858a2980b2cfb22db32e83c01349a8e7ed3b42c".to_owned(), + "Directory checksum didn't match." + ); + Ok(()) + } + + #[test] + fn internal_invalid_filepath() { + assert!( + hash_file("nonexistent_file.txt").is_err(), + "Did not raise an invalid filepath error." + ); + } +} diff --git a/src/core/mod.rs b/src/core/mod.rs index d5f14d51..fe107cf0 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -1,41 +1,14 @@ -macro_rules! inner_attr_to_each { - { #!$attr:tt $($it:item)* } => { - $( - #$attr - $it - )* - } -} - pub(crate) mod error; pub(crate) mod graph; pub(crate) mod store; pub(crate) mod util; pub(crate) mod validation; -inner_attr_to_each! { - #![cfg(feature = "default")] - pub(crate) mod crypto; - pub(crate) mod model; - pub(crate) mod operator; - pub(crate) mod orchestrator; - pub(crate) mod pipeline_runner; -} +pub(crate) mod crypto; +/// Model definition for orcapod +pub mod model; +pub(crate) mod operator; +pub(crate) mod orchestrator; -#[cfg(feature = "test")] -inner_attr_to_each! { - #![cfg_attr( - feature = "test", - allow( - missing_docs, - clippy::missing_errors_doc, - clippy::missing_panics_doc, - reason = "Documentation not necessary since private API.", - ), - )] - pub mod crypto; - pub mod model; - pub mod orchestrator; - pub mod pipeline_runner; - pub mod operator; -} +/// Pipeline runner module +pub mod pipeline_runner; diff --git a/src/core/model/mod.rs b/src/core/model/mod.rs index 4006415b..707d69f2 100644 --- a/src/core/model/mod.rs +++ b/src/core/model/mod.rs @@ -32,7 +32,7 @@ pub fn to_yaml(instance: &T) -> Result { Ok(yaml) } -pub fn serialize_hashmap( +pub(crate) fn serialize_hashmap( map: &HashMap, serializer: S, ) -> result::Result @@ -44,7 +44,7 @@ where } #[allow(clippy::ref_option, reason = "Serde requires this signature.")] -pub fn serialize_hashmap_option( +pub(crate) fn serialize_hashmap_option( map_option: &Option>, serializer: S, ) -> result::Result @@ -57,5 +57,5 @@ where sorted.serialize(serializer) } -pub mod pipeline; -pub mod pod; +pub(crate) mod pipeline; +pub(crate) mod pod; diff --git a/src/core/operator.rs b/src/core/operator.rs index c10bcbc7..b5487d3f 100644 --- a/src/core/operator.rs +++ b/src/core/operator.rs @@ -1,7 +1,6 @@ -use crate::uniffi::{error::Result, model::packet::Packet}; +use crate::uniffi::{error::Result, model::packet::Packet, operator::MapOperator}; use async_trait; use itertools::Itertools as _; -use serde::{Deserialize, Serialize}; use std::{clone::Clone, collections::HashMap, iter::IntoIterator, sync::Arc}; use tokio::sync::Mutex; @@ -62,17 +61,6 @@ impl Operator for JoinOperator { } } -#[derive(uniffi::Object, Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] -pub struct MapOperator { - pub map: HashMap, -} - -impl MapOperator { - pub fn new(map: &HashMap) -> Self { - Self { map: map.clone() } - } -} - #[async_trait::async_trait] impl Operator for MapOperator { async fn process_packet(&self, _: String, packet: Packet) -> Result> { @@ -91,3 +79,222 @@ impl Operator for MapOperator { ]) } } + +#[cfg(test)] +mod tests { + #![expect(clippy::panic_in_result_fn, reason = "OK in tests.")] + + use crate::{ + core::operator::{JoinOperator, MapOperator, Operator}, + uniffi::{ + error::Result, + model::packet::{Blob, BlobKind, Packet, PathSet, URI}, + }, + }; + use std::{collections::HashMap, path::PathBuf}; + + fn make_packet_key(key_name: String, filepath: String) -> (String, PathSet) { + ( + key_name, + PathSet::Unary(Blob { + kind: BlobKind::File, + location: URI { + namespace: "default".into(), + path: PathBuf::from(filepath), + }, + checksum: String::new(), + }), + ) + } + + async fn next_batch( + operator: impl Operator, + packets: Vec<(String, Packet)>, + ) -> Result> { + let mut next_packets = vec![]; + for (stream_name, packet) in packets { + next_packets.extend(operator.process_packet(stream_name, packet).await?); + } + Ok(next_packets) + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn join_once() -> Result<()> { + let operator = JoinOperator::new(2); + + let left_stream = (0..3) + .map(|i| { + ( + "left".into(), + Packet::from([make_packet_key( + "subject".into(), + format!("left/subject{i}.png"), + )]), + ) + }) + .collect::>(); + + let right_stream = (0..2) + .map(|i| { + ( + "right".into(), + Packet::from([make_packet_key( + "style".into(), + format!("right/style{i}.t7"), + )]), + ) + }) + .collect::>(); + + let mut input_streams = left_stream; + input_streams.extend(right_stream); + + assert_eq!( + next_batch(operator, input_streams).await?, + vec![ + Packet::from([ + make_packet_key("subject".into(), "left/subject0.png".into()), + make_packet_key("style".into(), "right/style0.t7".into()), + ]), + Packet::from([ + make_packet_key("subject".into(), "left/subject1.png".into()), + make_packet_key("style".into(), "right/style0.t7".into()), + ]), + Packet::from([ + make_packet_key("subject".into(), "left/subject2.png".into()), + make_packet_key("style".into(), "right/style0.t7".into()), + ]), + Packet::from([ + make_packet_key("subject".into(), "left/subject0.png".into()), + make_packet_key("style".into(), "right/style1.t7".into()), + ]), + Packet::from([ + make_packet_key("subject".into(), "left/subject1.png".into()), + make_packet_key("style".into(), "right/style1.t7".into()), + ]), + Packet::from([ + make_packet_key("subject".into(), "left/subject2.png".into()), + make_packet_key("style".into(), "right/style1.t7".into()), + ]), + ], + "Unexpected streams." + ); + + Ok(()) + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn join_spotty() -> Result<()> { + let operator = JoinOperator::new(2); + + assert_eq!( + operator + .process_packet( + "right".into(), + Packet::from([make_packet_key("style".into(), "right/style0.t7".into())]) + ) + .await?, + vec![], + "Unexpected streams." + ); + + assert_eq!( + operator + .process_packet( + "right".into(), + Packet::from([make_packet_key("style".into(), "right/style1.t7".into())]) + ) + .await?, + vec![], + "Unexpected streams." + ); + + assert_eq!( + operator + .process_packet( + "left".into(), + Packet::from([make_packet_key( + "subject".into(), + "left/subject0.png".into() + )]) + ) + .await?, + vec![ + Packet::from([ + make_packet_key("subject".into(), "left/subject0.png".into()), + make_packet_key("style".into(), "right/style0.t7".into()), + ]), + Packet::from([ + make_packet_key("subject".into(), "left/subject0.png".into()), + make_packet_key("style".into(), "right/style1.t7".into()), + ]), + ], + "Unexpected streams." + ); + + assert_eq!( + next_batch( + operator, + (1..3) + .map(|i| { + ( + "left".into(), + Packet::from([make_packet_key( + "subject".into(), + format!("left/subject{i}.png"), + )]), + ) + }) + .collect::>() + ) + .await?, + vec![ + Packet::from([ + make_packet_key("subject".into(), "left/subject1.png".into()), + make_packet_key("style".into(), "right/style0.t7".into()), + ]), + Packet::from([ + make_packet_key("subject".into(), "left/subject1.png".into()), + make_packet_key("style".into(), "right/style1.t7".into()), + ]), + Packet::from([ + make_packet_key("subject".into(), "left/subject2.png".into()), + make_packet_key("style".into(), "right/style0.t7".into()), + ]), + Packet::from([ + make_packet_key("subject".into(), "left/subject2.png".into()), + make_packet_key("style".into(), "right/style1.t7".into()), + ]), + ], + "Unexpected streams." + ); + + Ok(()) + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn map_once() -> Result<()> { + let operator = MapOperator { + map: HashMap::from([("key_old".into(), "key_new".into())]), + }; + + assert_eq!( + operator + .process_packet( + "parent".into(), + Packet::from([ + make_packet_key("key_old".into(), "some/key.txt".into()), + make_packet_key("subject".into(), "some/subject.txt".into()), + ]), + ) + .await?, + vec![Packet::from([ + make_packet_key("key_new".into(), "some/key.txt".into()), + make_packet_key("subject".into(), "some/subject.txt".into()), + ]),], + "Unexpected packet." + ); + + Ok(()) + } +} diff --git a/src/uniffi/mod.rs b/src/uniffi/mod.rs index e02fd6c9..2443ce00 100644 --- a/src/uniffi/mod.rs +++ b/src/uniffi/mod.rs @@ -2,6 +2,8 @@ pub mod error; /// Components of the data model. pub mod model; +/// Operators for pipeline +pub mod operator; /// Interface into container orchestration engine. pub mod orchestrator; /// Data persistence provided by a store backend. diff --git a/src/uniffi/model/pipeline.rs b/src/uniffi/model/pipeline.rs index 250fa742..af22d5e9 100644 --- a/src/uniffi/model/pipeline.rs +++ b/src/uniffi/model/pipeline.rs @@ -3,7 +3,6 @@ use crate::{ crypto::{hash_blob, make_random_hash}, graph::make_graph, model::pipeline::PipelineNode, - operator::MapOperator, validation::validate_packet, }, uniffi::{ @@ -12,6 +11,7 @@ use crate::{ packet::{PathSet, URI}, pod::Pod, }, + operator::MapOperator, }, }; use derive_more::Display; diff --git a/src/uniffi/operator.rs b/src/uniffi/operator.rs new file mode 100644 index 00000000..a7f666be --- /dev/null +++ b/src/uniffi/operator.rs @@ -0,0 +1,11 @@ +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; + +/// Operator class that map `input_keys` to `output_key`, effectively renaming it +/// For use in pipelines +#[derive(uniffi::Object, Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +pub struct MapOperator { + /// Mapping of input keys to output keys + pub map: HashMap, +} diff --git a/tests/agent.rs b/tests/agent.rs index 89e42c87..e10736ab 100644 --- a/tests/agent.rs +++ b/tests/agent.rs @@ -9,17 +9,15 @@ pub mod fixture; use fixture::{NAMESPACE_LOOKUP_READ_ONLY, TestDirs, pod_jobs_stresser, pull_image}; -use orcapod::{ - core::orchestrator::agent::extract_metadata, - uniffi::{ - error::Result, - model::pod::PodResult, - orchestrator::{ - agent::{Agent, AgentClient}, - docker::LocalDockerOrchestrator, - }, - store::{ModelID, Store as _, filestore::LocalFileStore}, +use itertools::Itertools as _; +use orcapod::uniffi::{ + error::Result, + model::pod::PodResult, + orchestrator::{ + agent::{Agent, AgentClient}, + docker::LocalDockerOrchestrator, }, + store::{ModelID, Store as _, filestore::LocalFileStore}, }; use std::{ collections::HashMap, @@ -101,7 +99,13 @@ async fn parallel_four_cores() -> Result<()> { .recv_async() .await .expect("All senders have dropped."); - let metadata = extract_metadata(sample.key_expr().as_str()); + let metadata: HashMap = sample + .key_expr() + .as_str() + .split('/') + .map(ToOwned::to_owned) + .tuples() + .collect(); let topic_kind = metadata["event"].as_str(); if ["success", "failure"].contains(&topic_kind) { let pod_result = serde_json::from_slice::(&sample.payload().to_bytes())?; diff --git a/tests/crypto.rs b/tests/crypto.rs deleted file mode 100644 index cbb1f54f..00000000 --- a/tests/crypto.rs +++ /dev/null @@ -1,29 +0,0 @@ -#![expect(missing_docs, clippy::panic_in_result_fn, reason = "OK in tests.")] - -use orcapod::{ - core::crypto::{hash_buffer, hash_dir, hash_file}, - uniffi::error::Result, -}; -use std::fs::read; - -#[test] -fn consistent_hash() -> Result<()> { - let filepath = "./tests/extra/data/images/subject.jpeg"; - assert_eq!( - hash_file(filepath)?, - hash_buffer(&read(filepath)?), - "Checksum not consistent." - ); - Ok(()) -} - -#[test] -fn complex_hash() -> Result<()> { - let dirpath = "./tests/extra/data/images"; - assert_eq!( - hash_dir(dirpath)?, - "6c96a478ea25e34fab045bc82858a2980b2cfb22db32e83c01349a8e7ed3b42c".to_owned(), - "Directory checksum didn't match." - ); - Ok(()) -} diff --git a/tests/error.rs b/tests/error.rs index 7a9e20e0..838e121e 100644 --- a/tests/error.rs +++ b/tests/error.rs @@ -10,16 +10,13 @@ use chrono::DateTime; use dot_parser::ast::Graph as DOTGraph; use fixture::{NAMESPACE_LOOKUP_READ_ONLY, pod_custom, pod_job_custom, pod_job_style, str_to_vec}; use glob::glob; -use orcapod::{ - core::crypto::hash_file, - uniffi::{ - error::{OrcaError, Result}, - model::packet::PathInfo, - orchestrator::{ - Orchestrator as _, - agent::{AgentClient, Response}, - docker::LocalDockerOrchestrator, - }, +use orcapod::uniffi::{ + error::{OrcaError, Result}, + model::packet::PathInfo, + orchestrator::{ + Orchestrator as _, + agent::{AgentClient, Response}, + docker::LocalDockerOrchestrator, }, }; use serde_json; @@ -154,14 +151,6 @@ fn internal_incomplete_packet() -> Result<()> { Ok(()) } -#[test] -fn internal_invalid_filepath() { - assert!( - hash_file("nonexistent_file.txt").is_err_and(contains_debug), - "Did not raise an invalid filepath error." - ); -} - #[test] fn internal_key_missing() { assert!( diff --git a/tests/fixture/mod.rs b/tests/fixture/mod.rs index 6cd8a420..7a971433 100644 --- a/tests/fixture/mod.rs +++ b/tests/fixture/mod.rs @@ -9,7 +9,7 @@ use names::{Generator, Name}; use orcapod::{ - core::operator::MapOperator, + uniffi::operator::MapOperator, uniffi::{ error::Result, model::{ diff --git a/tests/operator.rs b/tests/operator.rs deleted file mode 100644 index 546c7a7f..00000000 --- a/tests/operator.rs +++ /dev/null @@ -1,213 +0,0 @@ -#![expect(missing_docs, clippy::panic_in_result_fn, reason = "OK in tests.")] - -use orcapod::{ - core::operator::{JoinOperator, MapOperator, Operator}, - uniffi::{ - error::Result, - model::packet::{Blob, BlobKind, Packet, PathSet, URI}, - }, -}; -use std::{collections::HashMap, path::PathBuf}; - -fn make_packet_key(key_name: String, filepath: String) -> (String, PathSet) { - ( - key_name, - PathSet::Unary(Blob { - kind: BlobKind::File, - location: URI { - namespace: "default".into(), - path: PathBuf::from(filepath), - }, - checksum: String::new(), - }), - ) -} - -async fn next_batch( - operator: impl Operator, - packets: Vec<(String, Packet)>, -) -> Result> { - let mut next_packets = vec![]; - for (stream_name, packet) in packets { - next_packets.extend(operator.process_packet(stream_name, packet).await?); - } - Ok(next_packets) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 1)] -async fn join_once() -> Result<()> { - let operator = JoinOperator::new(2); - - let left_stream = (0..3) - .map(|i| { - ( - "left".into(), - Packet::from([make_packet_key( - "subject".into(), - format!("left/subject{i}.png"), - )]), - ) - }) - .collect::>(); - - let right_stream = (0..2) - .map(|i| { - ( - "right".into(), - Packet::from([make_packet_key( - "style".into(), - format!("right/style{i}.t7"), - )]), - ) - }) - .collect::>(); - - let mut input_streams = left_stream; - input_streams.extend(right_stream); - - assert_eq!( - next_batch(operator, input_streams).await?, - vec![ - Packet::from([ - make_packet_key("subject".into(), "left/subject0.png".into()), - make_packet_key("style".into(), "right/style0.t7".into()), - ]), - Packet::from([ - make_packet_key("subject".into(), "left/subject1.png".into()), - make_packet_key("style".into(), "right/style0.t7".into()), - ]), - Packet::from([ - make_packet_key("subject".into(), "left/subject2.png".into()), - make_packet_key("style".into(), "right/style0.t7".into()), - ]), - Packet::from([ - make_packet_key("subject".into(), "left/subject0.png".into()), - make_packet_key("style".into(), "right/style1.t7".into()), - ]), - Packet::from([ - make_packet_key("subject".into(), "left/subject1.png".into()), - make_packet_key("style".into(), "right/style1.t7".into()), - ]), - Packet::from([ - make_packet_key("subject".into(), "left/subject2.png".into()), - make_packet_key("style".into(), "right/style1.t7".into()), - ]), - ], - "Unexpected streams." - ); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 1)] -async fn join_spotty() -> Result<()> { - let operator = JoinOperator::new(2); - - assert_eq!( - operator - .process_packet( - "right".into(), - Packet::from([make_packet_key("style".into(), "right/style0.t7".into())]) - ) - .await?, - vec![], - "Unexpected streams." - ); - - assert_eq!( - operator - .process_packet( - "right".into(), - Packet::from([make_packet_key("style".into(), "right/style1.t7".into())]) - ) - .await?, - vec![], - "Unexpected streams." - ); - - assert_eq!( - operator - .process_packet( - "left".into(), - Packet::from([make_packet_key( - "subject".into(), - "left/subject0.png".into() - )]) - ) - .await?, - vec![ - Packet::from([ - make_packet_key("subject".into(), "left/subject0.png".into()), - make_packet_key("style".into(), "right/style0.t7".into()), - ]), - Packet::from([ - make_packet_key("subject".into(), "left/subject0.png".into()), - make_packet_key("style".into(), "right/style1.t7".into()), - ]), - ], - "Unexpected streams." - ); - - assert_eq!( - next_batch( - operator, - (1..3) - .map(|i| { - ( - "left".into(), - Packet::from([make_packet_key( - "subject".into(), - format!("left/subject{i}.png"), - )]), - ) - }) - .collect::>() - ) - .await?, - vec![ - Packet::from([ - make_packet_key("subject".into(), "left/subject1.png".into()), - make_packet_key("style".into(), "right/style0.t7".into()), - ]), - Packet::from([ - make_packet_key("subject".into(), "left/subject1.png".into()), - make_packet_key("style".into(), "right/style1.t7".into()), - ]), - Packet::from([ - make_packet_key("subject".into(), "left/subject2.png".into()), - make_packet_key("style".into(), "right/style0.t7".into()), - ]), - Packet::from([ - make_packet_key("subject".into(), "left/subject2.png".into()), - make_packet_key("style".into(), "right/style1.t7".into()), - ]), - ], - "Unexpected streams." - ); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 1)] -async fn map_once() -> Result<()> { - let operator = MapOperator::new(&HashMap::from([("key_old".into(), "key_new".into())])); - - assert_eq!( - operator - .process_packet( - "parent".into(), - Packet::from([ - make_packet_key("key_old".into(), "some/key.txt".into()), - make_packet_key("subject".into(), "some/subject.txt".into()), - ]), - ) - .await?, - vec![Packet::from([ - make_packet_key("key_new".into(), "some/key.txt".into()), - make_packet_key("subject".into(), "some/subject.txt".into()), - ]),], - "Unexpected packet." - ); - - Ok(()) -} diff --git a/tests/store.rs b/tests/store.rs index 3a6c66f8..0efb1d8f 100644 --- a/tests/store.rs +++ b/tests/store.rs @@ -3,6 +3,7 @@ missing_docs, clippy::panic_in_result_fn, clippy::indexing_slicing, + clippy::unwrap_used, reason = "OK in tests." )] @@ -10,16 +11,22 @@ pub mod fixture; use fixture::{ NAMESPACE_LOOKUP_READ_ONLY, TestDirs, TestSetup, pod_job_style, pod_result_style, pod_style, }; -use orcapod::{ - core::{crypto::hash_buffer, model::to_yaml}, - uniffi::{ - error::Result, - model::{Annotation, ModelType}, - store::{ModelID, ModelInfo, Store as _, filestore::LocalFileStore}, - }, +use orcapod::uniffi::{ + error::Result, + model::{Annotation, ModelType, packet::PathInfo, pod::Pod}, + store::{ModelID, ModelInfo, Store as _, filestore::LocalFileStore}, }; use pretty_assertions::assert_eq as pretty_assert_eq; -use std::{collections::HashMap, fmt::Debug, ops::Deref as _, path::Path, sync::Arc}; +use std::{ + collections::HashMap, + fmt::Debug, + ops::Deref as _, + path::{Path, PathBuf}, + sync::Arc, + vec, +}; + +use crate::fixture::str_to_vec; fn is_dir_empty(file: &Path, levels_up: usize) -> Option { Some( @@ -241,37 +248,84 @@ fn pod_annotation_delete() -> Result<()> { Ok(()) } +#[expect(clippy::too_many_lines, reason = "Okay because of creating pods")] #[test] fn pod_annotation_unique() -> Result<()> { let test_dirs = TestDirs::new(&HashMap::from([("default".to_owned(), None::)]))?; let store = LocalFileStore::new(test_dirs.0["default"].path().to_path_buf()); - let original_annotation = Annotation { - name: "example".to_owned(), + + // Pod values + let annotation = Annotation { + name: "style-transfer".to_owned(), + description: "This is an example pod.".to_owned(), version: "1.0.0".to_owned(), - description: "original".to_owned(), }; - let mut pod = pod_style()?; - pod.annotation = Some(original_annotation.clone()); + let image = "example.server.com/user/style-transfer:1.0.0".to_owned(); + let command = str_to_vec("python /run.py"); + let input_spec = HashMap::from([( + "input_key_1".to_owned(), + PathInfo { + path: PathBuf::from("/input"), + match_pattern: "input/.*".to_owned(), + }, + )]); + let output_spec = HashMap::from([( + "output_key_1".to_owned(), + PathInfo { + path: PathBuf::from("/output"), + match_pattern: "output/.*".to_owned(), + }, + )]); + let output_dir: PathBuf = "/output".into(); + let source_commit_url = "https://github.com/user/style-transfer/tree/1.0.0".to_owned(); + let recommended_cpus = 0.25; // 250 millicores as frac cores + let recommended_memory = 1_u64 << 30; // 1GiB in + + let pod = Pod::new( + Some(annotation.clone()), + image.clone(), + command.clone(), + input_spec.clone(), + output_dir.clone(), + output_spec.clone(), + source_commit_url.clone(), + recommended_cpus, + recommended_memory, + None, + )?; + + // Save pod above store.save_pod(&pod)?; - let original_hash = pod.hash.clone(); - // case 1: Only change description, should skip saving model and annotation - pod.annotation = Some(Annotation { - description: "new".to_owned(), - ..original_annotation.clone() - }); + // case 1: Only change description, should skip saving model and annotation since overriding annotation is not allowed + let pod_with_new_annotation = Pod::new( + Some(Annotation { + description: "new description".into(), + ..pod.annotation.as_ref().unwrap().clone() + }), + "example.server.com/user/style-transfer:1.0.0".to_owned(), + command, + input_spec.clone(), + "/output".into(), + output_spec.clone(), + source_commit_url.clone(), + recommended_cpus, + recommended_memory, + None, + )?; + store.save_pod(&pod)?; pretty_assert_eq!( store.list_pod()?, vec![ ModelInfo { - name: Some(original_annotation.name.clone()), - version: Some(original_annotation.version.clone()), - hash: original_hash.clone(), + name: Some(annotation.name.clone()), + version: Some(annotation.version.clone()), + hash: pod_with_new_annotation.hash, }, ModelInfo { name: None, version: None, - hash: original_hash.clone(), + hash: pod.hash.clone(), }, ], "Pod list didn't return 2 expected entries." @@ -279,35 +333,44 @@ fn pod_annotation_unique() -> Result<()> { pretty_assert_eq!( store .load_pod(&ModelID::Annotation( - original_annotation.name.clone(), - original_annotation.version.clone() + annotation.name.clone(), + annotation.version.clone() ))? .annotation, - Some(original_annotation.clone()), + Some(annotation.clone()), "Pod annotation unexpected." ); // case 2: Change description + model, should save model but skip annotation - pod.output_dir = "/output_2".into(); - pod.hash = hash_buffer(to_yaml(&pod)?); - let new_hash = pod.hash.clone(); - store.save_pod(&pod)?; + let pod_with_updated_command = Pod::new( + Some(annotation.clone()), + image, + str_to_vec("python new_run.py"), + input_spec, + output_dir, + output_spec, + source_commit_url, + recommended_cpus, + recommended_memory, + None, + )?; + store.save_pod(&pod_with_updated_command)?; pretty_assert_eq!( store.list_pod()?, vec![ ModelInfo { - name: Some(original_annotation.name.clone()), - version: Some(original_annotation.version.clone()), - hash: original_hash.clone(), + name: Some(annotation.name.clone()), + version: Some(annotation.version.clone()), + hash: pod.hash.clone(), }, ModelInfo { name: None, version: None, - hash: original_hash, + hash: pod.hash, }, ModelInfo { name: None, version: None, - hash: new_hash, + hash: pod_with_updated_command.hash, }, ], "Pod list didn't return 3 expected entries." @@ -315,11 +378,11 @@ fn pod_annotation_unique() -> Result<()> { pretty_assert_eq!( store .load_pod(&ModelID::Annotation( - original_annotation.name.clone(), - original_annotation.version.clone() + annotation.name.clone(), + annotation.version.clone() ))? .annotation, - Some(original_annotation), + Some(annotation), "Pod annotation unexpected." ); Ok(()) From a7bf03ce59e8eaa8eefe4565e234c207515596e9 Mon Sep 17 00:00:00 2001 From: Synicix Date: Fri, 29 Aug 2025 09:21:46 +0000 Subject: [PATCH 37/65] fix copilot suggestions --- src/uniffi/orchestrator/agent.rs | 9 --------- tests/pipeline_runner.rs | 4 ---- tests/store.rs | 2 +- 3 files changed, 1 insertion(+), 14 deletions(-) diff --git a/src/uniffi/orchestrator/agent.rs b/src/uniffi/orchestrator/agent.rs index a060c271..0e7a1d03 100644 --- a/src/uniffi/orchestrator/agent.rs +++ b/src/uniffi/orchestrator/agent.rs @@ -215,15 +215,6 @@ impl Agent { async |_, ()| Ok(()), )); } - // // Spawn PipelineRunner service - // services.spawn(start_service( - // self_ref, - // "pipeline_job", - // BTreeMap::from([("action", "request".to_owned())]), - // namespace_lookup.clone(), - // async move |agent, inner_namespace_lookup, _, pipeline_job| Ok(()), - // async |_, ()| Ok(()), - // )); services.join_next().await.context(selector::MissingInfo { details: "no available services".to_owned(), })?? diff --git a/tests/pipeline_runner.rs b/tests/pipeline_runner.rs index 76490bc1..fa3c3ac2 100644 --- a/tests/pipeline_runner.rs +++ b/tests/pipeline_runner.rs @@ -5,10 +5,6 @@ clippy::unwrap_used, reason = "OK in tests." )] - -// If 'fixture' is a local module, ensure there is a 'mod fixture;' statement or a 'fixture.rs' file in the same directory or in 'tests/'. -// If 'fixture' is an external crate, add it to Cargo.toml and import as shown below. -// use fixture::pipeline_job; pub mod fixture; // Example for a local module: diff --git a/tests/store.rs b/tests/store.rs index 0efb1d8f..bd6873b4 100644 --- a/tests/store.rs +++ b/tests/store.rs @@ -313,7 +313,7 @@ fn pod_annotation_unique() -> Result<()> { None, )?; - store.save_pod(&pod)?; + store.save_pod(&pod_with_new_annotation)?; pretty_assert_eq!( store.list_pod()?, vec![ From 781ffcc80f0619331f518a48e06f1da8d8ae0235 Mon Sep 17 00:00:00 2001 From: Synicix Date: Fri, 29 Aug 2025 09:23:07 +0000 Subject: [PATCH 38/65] Update cspell --- cspell.json | 1 - 1 file changed, 1 deletion(-) diff --git a/cspell.json b/cspell.json index 9a69a2e5..42697206 100644 --- a/cspell.json +++ b/cspell.json @@ -83,7 +83,6 @@ "colinianking", "itertools", "pathset", - "colinianking" ], "useGitignore": false, "ignorePaths": [ From d16da30fbd675be4f823a568ef976423d2c00203 Mon Sep 17 00:00:00 2001 From: Synicix Date: Fri, 29 Aug 2025 09:24:33 +0000 Subject: [PATCH 39/65] Fix copilot suggestions --- src/core/pipeline_runner.rs | 5 ----- src/uniffi/orchestrator/agent.rs | 9 --------- 2 files changed, 14 deletions(-) diff --git a/src/core/pipeline_runner.rs b/src/core/pipeline_runner.rs index 5494923c..283dbde1 100644 --- a/src/core/pipeline_runner.rs +++ b/src/core/pipeline_runner.rs @@ -366,11 +366,6 @@ impl DockerPipelineRunner { .context(selector::AgentCommunicationFailure {})?; while let Ok(payload) = subscriber.recv_async().await { - println!( - "Received output from node {}: {}", - node_id, - String::from_utf8_lossy(&payload.payload().to_bytes()) - ); // Extract the message from the payload let packets: Vec = serde_json::from_slice(&payload.payload().to_bytes())?; diff --git a/src/uniffi/orchestrator/agent.rs b/src/uniffi/orchestrator/agent.rs index a060c271..0e7a1d03 100644 --- a/src/uniffi/orchestrator/agent.rs +++ b/src/uniffi/orchestrator/agent.rs @@ -215,15 +215,6 @@ impl Agent { async |_, ()| Ok(()), )); } - // // Spawn PipelineRunner service - // services.spawn(start_service( - // self_ref, - // "pipeline_job", - // BTreeMap::from([("action", "request".to_owned())]), - // namespace_lookup.clone(), - // async move |agent, inner_namespace_lookup, _, pipeline_job| Ok(()), - // async |_, ()| Ok(()), - // )); services.join_next().await.context(selector::MissingInfo { details: "no available services".to_owned(), })?? From 9a3807616bb67b206560761f7a1cdb3b9b6bf4d7 Mon Sep 17 00:00:00 2001 From: Synicix Date: Fri, 29 Aug 2025 09:24:53 +0000 Subject: [PATCH 40/65] Fix copilot suggestions --- cspell.json | 1 - 1 file changed, 1 deletion(-) diff --git a/cspell.json b/cspell.json index 9a69a2e5..42697206 100644 --- a/cspell.json +++ b/cspell.json @@ -83,7 +83,6 @@ "colinianking", "itertools", "pathset", - "colinianking" ], "useGitignore": false, "ignorePaths": [ From a1a6d8391234fb59b6fd12c666a473f0d676b5f5 Mon Sep 17 00:00:00 2001 From: Synicix Date: Tue, 2 Sep 2025 07:11:05 +0000 Subject: [PATCH 41/65] Covert to_yaml into an actual trait and add ExecRequirements, and remove pod source --- src/core/model/mod.rs | 28 ++++++++++ src/core/pipeline_runner.rs | 4 +- src/uniffi/model/mod.rs | 32 ++++++++++++ src/uniffi/model/pod.rs | 90 ++++++++++++++++++-------------- tests/agent.rs | 2 +- tests/extra/python/agent_test.py | 10 ++-- tests/extra/python/smoke_test.py | 8 +-- tests/fixture/mod.rs | 49 ++++++++--------- tests/model.rs | 28 +++++----- tests/store.rs | 25 ++++----- 10 files changed, 170 insertions(+), 106 deletions(-) diff --git a/src/core/model/mod.rs b/src/core/model/mod.rs index 707d69f2..0dbd6adb 100644 --- a/src/core/model/mod.rs +++ b/src/core/model/mod.rs @@ -32,6 +32,34 @@ pub fn to_yaml(instance: &T) -> Result { Ok(yaml) } +/// Trait to handle serialization to yaml for `OrcaPod` models +pub trait ToYaml: Serialize + Sized { + /// Serializes the instance to a YAML string. + /// # Errors + /// Will return `Err` if it fail to serialize instance to string + fn to_yaml(&self) -> Result { + let mapping: IndexMap = serde_yaml::from_str(&serde_yaml::to_string(self)?)?; // cast to map + let mut yaml = serde_yaml::to_string( + &mapping + .iter() + .filter_map(|(k, v)| Self::process_field(k, v)) + .collect::>(), + )?; // skip fields and convert refs to hash pointers + yaml.insert_str( + 0, + &format!("class: {}\n", get_type_name::().to_snake_case()), + ); // replace class at top + Ok(yaml) + } + + /// Filter out which field to serialize and which to omit + /// + /// # Returns + /// (`field_name`, `field_value`): to be pass to `to_yaml` for serialization + /// None: to skip + fn process_field(field_name: &str, field_value: &Value) -> Option<(String, Value)>; +} + pub(crate) fn serialize_hashmap( map: &HashMap, serializer: S, diff --git a/src/core/pipeline_runner.rs b/src/core/pipeline_runner.rs index 283dbde1..086718cf 100644 --- a/src/core/pipeline_runner.rs +++ b/src/core/pipeline_runner.rs @@ -666,8 +666,8 @@ impl PodProcessor { ) .into(), }, - pod.recommended_cpus, - pod.recommended_memory, + pod.exec_requirements.recommended_cpus, + pod.exec_requirements.recommended_memory, None, &pipeline_run.namespace_lookup, )?; diff --git a/src/uniffi/model/mod.rs b/src/uniffi/model/mod.rs index a266fd99..94b10640 100644 --- a/src/uniffi/model/mod.rs +++ b/src/uniffi/model/mod.rs @@ -23,6 +23,38 @@ pub struct Annotation { pub description: String, } +/// Execution requirements for a pod/pipeline, since it doesn't impact the actual reproducibility except for GPU requirement or OOM, +/// it shouldn't be +#[derive(uniffi::Record, Serialize, Deserialize, Debug, PartialEq, Default, Clone)] +pub struct ExecRequirements { + /// Optimal number of CPU cores needed to run the pod/pipeline provided by the user + pub recommended_cpus: f32, + /// Optimal amount of memory needed to run the pod/pipeline provided by the user, code can probably run with less but may hit OOM + pub recommended_memory: u64, + /// Optional GPU requirements for the pod/pipeline. If set, then the system should at least meet the architecture requirements. + pub gpu_requirements: Option, +} + +/// Specification for GPU requirements in computation. +#[derive(uniffi::Record, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] +pub struct GPURequirement { + /// GPU model specification. + pub model: GPUModel, + /// Manufacturer recommended memory. + pub recommended_memory: u64, + /// Number of GPU cards required. + pub count: u16, +} + +/// GPU model specification. +#[derive(uniffi::Enum, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] +pub enum GPUModel { + /// NVIDIA-manufactured card where `String` is the specific CUDA version + NVIDIA(String), + /// Any GPU architecture, code is generic enough + Any, +} + uniffi::custom_type!(PathBuf, String, { remote, try_lift: |val| Ok(PathBuf::from(&val)), diff --git a/src/uniffi/model/pod.rs b/src/uniffi/model/pod.rs index f06706e6..92fdc447 100644 --- a/src/uniffi/model/pod.rs +++ b/src/uniffi/model/pod.rs @@ -2,8 +2,9 @@ use crate::{ core::{ crypto::{hash_blob, hash_buffer}, model::{ + ToYaml, pod::{deserialize_pod, deserialize_pod_job}, - serialize_hashmap, serialize_hashmap_option, to_yaml, + serialize_hashmap, serialize_hashmap_option, }, util::get, validation::validate_packet, @@ -11,7 +12,7 @@ use crate::{ uniffi::{ error::{OrcaError, Result}, model::{ - Annotation, + Annotation, ExecRequirements, packet::{Blob, BlobKind, Packet, PathInfo, PathSet, URI}, }, orchestrator::PodStatus, @@ -49,14 +50,8 @@ pub struct Pod { /// Exposed, internal output specification. #[serde(serialize_with = "serialize_hashmap")] pub output_spec: HashMap, - /// Link to source associated with image binary. - pub source_commit_url: String, - /// Recommendation for CPU in fractional cores. - pub recommended_cpus: f32, - /// Recommendation for memory in bytes. - pub recommended_memory: u64, - /// If applicable, recommendation for GPU configuration. - pub required_gpu: Option, + /// Execution requirements for the pod. + pub exec_requirements: ExecRequirements, } #[uniffi::export] @@ -74,10 +69,7 @@ impl Pod { input_spec: HashMap, output_dir: PathBuf, output_spec: HashMap, - source_commit_url: String, - recommended_cpus: f32, - recommended_memory: u64, - required_gpu: Option, + exec_requirements: ExecRequirements, ) -> Result { let pod_no_hash = Self { annotation, @@ -87,19 +79,32 @@ impl Pod { input_spec, output_dir, output_spec, - source_commit_url, - recommended_cpus, - recommended_memory, - required_gpu, + exec_requirements, }; Ok(Self { - hash: hash_buffer(to_yaml(&pod_no_hash)?), + hash: hash_buffer(pod_no_hash.to_yaml()?), ..pod_no_hash }) } } +impl ToYaml for Pod { + fn process_field( + field_name: &str, + field_value: &serde_yaml::Value, + ) -> Option<(String, serde_yaml::Value)> { + match field_name { + "annotation" | "hash" | "exec_requirements" => None, + _ => Some((field_name.to_owned(), field_value.clone())), + } + } +} + /// A compute job that specifies resource requests and input/output targets. +/// +/// `PodJob` represents a specific execution instance of a [`Pod`] with concrete +/// input data, resource limits, and output specifications. It includes all the +/// information needed to run a containerized computation job. #[derive( uniffi::Object, Serialize, Deserialize, Debug, PartialEq, Clone, Default, Display, CloneGetters, )] @@ -177,11 +182,25 @@ impl PodJob { env_vars, }; Ok(Self { - hash: hash_buffer(to_yaml(&pod_job_no_hash)?), + hash: hash_buffer(pod_job_no_hash.to_yaml()?), ..pod_job_no_hash }) } } + +impl ToYaml for PodJob { + fn process_field( + field_name: &str, + field_value: &serde_yaml::Value, + ) -> Option<(String, serde_yaml::Value)> { + match field_name { + "annotation" | "hash" => None, + "pod" => Some((field_name.to_owned(), field_value["hash"].clone())), + _ => Some((field_name.to_owned(), field_value.clone())), + } + } +} + /// Result from a compute job run. #[derive(uniffi::Record, Serialize, Deserialize, Debug, Clone, PartialEq, Default)] pub struct PodResult { @@ -278,28 +297,21 @@ impl PodResult { terminated, }; Ok(Self { - hash: hash_buffer(to_yaml(&pod_result_no_hash)?), + hash: hash_buffer(pod_result_no_hash.to_yaml()?), ..pod_result_no_hash }) } } -/// Specification for GPU requirements in computation. -#[derive(uniffi::Record, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] -pub struct GPURequirement { - /// GPU model specification. - pub model: GPUModel, - /// Manufacturer recommended memory. - pub recommended_memory: u64, - /// Number of GPU cards required. - pub count: u16, -} - -/// GPU model specification. -#[derive(uniffi::Enum, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] -pub enum GPUModel { - /// NVIDIA-manufactured card where `String` is the specific model e.g. ??? - NVIDIA(String), - /// AMD-manufactured card where `String` is the specific model e.g. ??? - AMD(String), +impl ToYaml for PodResult { + fn process_field( + field_name: &str, + field_value: &serde_yaml::Value, + ) -> Option<(String, serde_yaml::Value)> { + match field_name { + "annotation" | "hash" => None, + "pod_job" => Some((field_name.to_owned(), field_value["hash"].clone())), + _ => Some((field_name.to_owned(), field_value.clone())), + } + } } diff --git a/tests/agent.rs b/tests/agent.rs index e10736ab..a3f0b543 100644 --- a/tests/agent.rs +++ b/tests/agent.rs @@ -48,7 +48,7 @@ async fn parallel_four_cores() -> Result<()> { // config let image_reference = "ghcr.io/colinianking/stress-ng:e2f96874f951a72c1c83ff49098661f0e013ac40"; pull_image(image_reference)?; - let margin_millis = 2000; + let margin_millis = 3000; let run_duration_secs = 5; let (group, host) = ("agent_parallel-four-cores", "host"); // api diff --git a/tests/extra/python/agent_test.py b/tests/extra/python/agent_test.py index 305dd2f8..b0a35e8d 100644 --- a/tests/extra/python/agent_test.py +++ b/tests/extra/python/agent_test.py @@ -17,6 +17,7 @@ Uri, Pod, Annotation, + ExecRequirements, ) @@ -88,10 +89,11 @@ async def main(client, agent, test_dir, namespace_lookup, pod_jobs): input_spec={}, output_dir="/tmp/output", output_spec={}, - source_commit_url="https://github.com/user/simple", - recommended_cpus=0.1, - recommended_memory=128 << 20, - required_gpu=None, + exec_requirements=ExecRequirements( + recommended_cpus=0.1, + recommended_memory=128 << 20, + gpu_requirements=None, + ), ), input_packet={}, output_dir=Uri( diff --git a/tests/extra/python/smoke_test.py b/tests/extra/python/smoke_test.py index 4e111023..896d4321 100755 --- a/tests/extra/python/smoke_test.py +++ b/tests/extra/python/smoke_test.py @@ -16,6 +16,7 @@ LocalFileStore, ModelId, ModelType, + ExecRequirements, OrcaError, ) @@ -32,10 +33,9 @@ def create_pod(data, _): input_spec={}, output_dir="/tmp/output", output_spec={}, - source_commit_url="https://github.com/user/simple", - recommended_cpus=0.1, - recommended_memory=10 << 20, - required_gpu=None, + exec_requirements=ExecRequirements( + recommended_cpus=0.1, recommended_memory=10 << 20, gpu_requirements=None + ), ) return data["pod"], data diff --git a/tests/fixture/mod.rs b/tests/fixture/mod.rs index 7a971433..3a90091b 100644 --- a/tests/fixture/mod.rs +++ b/tests/fixture/mod.rs @@ -8,19 +8,17 @@ )] use names::{Generator, Name}; -use orcapod::{ - uniffi::operator::MapOperator, - uniffi::{ - error::Result, - model::{ - Annotation, - packet::{Blob, BlobKind, Packet, PathInfo, PathSet, URI}, - pipeline::{Kernel, NodeURI, Pipeline, PipelineJob}, - pod::{Pod, PodJob, PodResult}, - }, - orchestrator::PodStatus, - store::{ModelID, ModelInfo, Store}, +use orcapod::uniffi::{ + error::Result, + model::{ + Annotation, ExecRequirements, + packet::{Blob, BlobKind, Packet, PathInfo, PathSet, URI}, + pipeline::{Kernel, NodeURI, Pipeline, PipelineJob}, + pod::{Pod, PodJob, PodResult}, }, + operator::MapOperator, + orchestrator::PodStatus, + store::{ModelID, ModelInfo, Store}, }; use std::{ collections::HashMap, @@ -79,10 +77,11 @@ pub fn pod_style() -> Result { }, ), ]), - "https://github.com/user/style-transfer/tree/1.0.0".to_owned(), - 0.25, // 250 millicores as frac cores - 1_u64 << 30, // 1GiB in bytes - None, + ExecRequirements { + recommended_cpus: 0.25, + recommended_memory: 1_u64 << 30, + gpu_requirements: None, + }, ) } @@ -172,10 +171,11 @@ pub fn pod_custom( input_spec, PathBuf::from("/tmp/output"), HashMap::new(), - "https://github.com/place/holder".to_owned(), - 0.1, // 100 millicores as frac cores - 50_u64 << 20, // 10 MiB in bytes - None, + ExecRequirements { + recommended_cpus: 0.1, + recommended_memory: 50_u64 << 20, + gpu_requirements: None, + }, ) } @@ -325,10 +325,11 @@ pub fn combine_txt_pod(pod_name: &str) -> Result { match_pattern: r".*\.txt".to_owned(), }, )]), - "N/A".to_owned(), - 0.25, // 250 millicores as frac cores - 128_u64 << 20, // 128MB in bytes - None, + ExecRequirements { + recommended_cpus: 0.25, + recommended_memory: 128_u64 << 20, + gpu_requirements: None, + }, ) } diff --git a/tests/model.rs b/tests/model.rs index 13d7a722..b8c1bbf1 100644 --- a/tests/model.rs +++ b/tests/model.rs @@ -1,16 +1,16 @@ -#![expect(missing_docs, clippy::panic_in_result_fn, reason = "OK in tests.")] +#![expect(missing_docs, reason = "OK in tests.")] pub mod fixture; use fixture::{NAMESPACE_LOOKUP_READ_ONLY, pod_job_style, pod_result_style, pod_style}; use indoc::indoc; -use orcapod::{core::model::to_yaml, uniffi::error::Result}; +use orcapod::{core::model::ToYaml as _, uniffi::error::Result}; use pretty_assertions::assert_eq as pretty_assert_eq; #[test] fn hash_pod() -> Result<()> { - assert_eq!( + pretty_assert_eq!( pod_style()?.hash, - "0e993f645fbb36f0635e2c9140975997cf4ca723d0b49cf4ee4963b76e6424d7", + "3ee06e06cc1c821712a2a8fd98a6aa3e72ab0e78d5a2668d39b3400d5ece5d52", "Hash didn't match." ); Ok(()) @@ -19,7 +19,7 @@ fn hash_pod() -> Result<()> { #[test] fn pod_to_yaml() -> Result<()> { pretty_assert_eq!( - to_yaml(&pod_style()?)?, + pod_style()?.to_yaml()?, indoc! {r" class: pod image: example.server.com/user/style-transfer:1.0.0 @@ -41,10 +41,6 @@ fn pod_to_yaml() -> Result<()> { result2: path: result2.jpeg match_pattern: .*\.jpeg - source_commit_url: https://github.com/user/style-transfer/tree/1.0.0 - recommended_cpus: 0.25 - recommended_memory: 1073741824 - required_gpu: null "}, "YAML serialization didn't match." ); @@ -53,9 +49,9 @@ fn pod_to_yaml() -> Result<()> { #[test] fn hash_pod_job() -> Result<()> { - assert_eq!( + pretty_assert_eq!( pod_job_style(&NAMESPACE_LOOKUP_READ_ONLY)?.hash, - "ba1c4693f9186ccb1b6e63625085d8fd95552b28b7a60fe9b1b47f68a9ba8880", + "0bc729c0d718f01d35d42856f5303353b95b280e441410d623372e0d29e9f1c9", "Hash didn't match." ); Ok(()) @@ -64,10 +60,10 @@ fn hash_pod_job() -> Result<()> { #[test] fn pod_job_to_yaml() -> Result<()> { pretty_assert_eq!( - to_yaml(&pod_job_style(&NAMESPACE_LOOKUP_READ_ONLY)?)?, + pod_job_style(&NAMESPACE_LOOKUP_READ_ONLY)?.to_yaml()?, indoc! {" class: pod_job - pod: 0e993f645fbb36f0635e2c9140975997cf4ca723d0b49cf4ee4963b76e6424d7 + pod: 3ee06e06cc1c821712a2a8fd98a6aa3e72ab0e78d5a2668d39b3400d5ece5d52 input_packet: base-input: - kind: File @@ -104,7 +100,7 @@ fn pod_job_to_yaml() -> Result<()> { fn hash_pod_result() -> Result<()> { pretty_assert_eq!( pod_result_style(&NAMESPACE_LOOKUP_READ_ONLY)?.hash, - "e752d86d4fc5435bfa4564ba951851530f5cf2228586c2f488c2ca9e7bcc7ed1", + "72a6586ccb1af6892c14c608757dbd43b7f8092ffeaee57ceb21d6bbffe6a92f", "Hash didn't match." ); Ok(()) @@ -113,10 +109,10 @@ fn hash_pod_result() -> Result<()> { #[test] fn pod_result_to_yaml() -> Result<()> { pretty_assert_eq!( - to_yaml(&pod_result_style(&NAMESPACE_LOOKUP_READ_ONLY)?)?, + pod_result_style(&NAMESPACE_LOOKUP_READ_ONLY)?.to_yaml()?, indoc! {" class: pod_result - pod_job: ba1c4693f9186ccb1b6e63625085d8fd95552b28b7a60fe9b1b47f68a9ba8880 + pod_job: 0bc729c0d718f01d35d42856f5303353b95b280e441410d623372e0d29e9f1c9 output_packet: result1: kind: File diff --git a/tests/store.rs b/tests/store.rs index bd6873b4..adbe8e2b 100644 --- a/tests/store.rs +++ b/tests/store.rs @@ -13,7 +13,7 @@ use fixture::{ }; use orcapod::uniffi::{ error::Result, - model::{Annotation, ModelType, packet::PathInfo, pod::Pod}, + model::{Annotation, ExecRequirements, ModelType, packet::PathInfo, pod::Pod}, store::{ModelID, ModelInfo, Store as _, filestore::LocalFileStore}, }; use pretty_assertions::assert_eq as pretty_assert_eq; @@ -277,9 +277,11 @@ fn pod_annotation_unique() -> Result<()> { }, )]); let output_dir: PathBuf = "/output".into(); - let source_commit_url = "https://github.com/user/style-transfer/tree/1.0.0".to_owned(); - let recommended_cpus = 0.25; // 250 millicores as frac cores - let recommended_memory = 1_u64 << 30; // 1GiB in + let exec_requirements = ExecRequirements { + recommended_cpus: 0.25, + recommended_memory: 1_u64 << 30, + gpu_requirements: None, + }; let pod = Pod::new( Some(annotation.clone()), @@ -288,10 +290,7 @@ fn pod_annotation_unique() -> Result<()> { input_spec.clone(), output_dir.clone(), output_spec.clone(), - source_commit_url.clone(), - recommended_cpus, - recommended_memory, - None, + exec_requirements.clone(), )?; // Save pod above @@ -307,10 +306,7 @@ fn pod_annotation_unique() -> Result<()> { input_spec.clone(), "/output".into(), output_spec.clone(), - source_commit_url.clone(), - recommended_cpus, - recommended_memory, - None, + exec_requirements.clone(), )?; store.save_pod(&pod_with_new_annotation)?; @@ -348,10 +344,7 @@ fn pod_annotation_unique() -> Result<()> { input_spec, output_dir, output_spec, - source_commit_url, - recommended_cpus, - recommended_memory, - None, + exec_requirements, )?; store.save_pod(&pod_with_updated_command)?; pretty_assert_eq!( From 03099b5a37ce02c069a6a3d892db16c09791c949 Mon Sep 17 00:00:00 2001 From: Synicix Date: Tue, 2 Sep 2025 15:35:44 +0000 Subject: [PATCH 42/65] Move gpu_requirements to be included in hash, and fix missing implementation for recommendSpecs --- notebook.ipynb | 65 ----------------------------------- src/core/error.rs | 1 + src/core/model/mod.rs | 23 ------------- src/core/pipeline_runner.rs | 4 +-- src/core/store/filestore.rs | 8 ++--- src/uniffi/error.rs | 5 +++ src/uniffi/model/mod.rs | 32 ----------------- src/uniffi/model/pod.rs | 54 ++++++++++++++++++++++++++--- src/uniffi/store/filestore.rs | 60 +++++++++++++++++++++++++++----- src/uniffi/store/mod.rs | 2 +- tests/fixture/mod.rs | 28 +++++++-------- tests/model.rs | 11 +++--- tests/store.rs | 33 +++++++++++------- 13 files changed, 155 insertions(+), 171 deletions(-) delete mode 100644 notebook.ipynb diff --git a/notebook.ipynb b/notebook.ipynb deleted file mode 100644 index f045ec7c..00000000 --- a/notebook.ipynb +++ /dev/null @@ -1,65 +0,0 @@ -{ - "cells": [ - { - "cell_type": "code", - "execution_count": 1, - "id": "3ba2aae6", - "metadata": {}, - "outputs": [], - "source": [ - "import orcapod as op" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "4d867643", - "metadata": {}, - "outputs": [ - { - "ename": "TypeError", - "evalue": "Pipeline.__init__() missing 4 required positional arguments: 'graph_dot', 'metadata', 'input_spec', and 'output_spec'", - "output_type": "error", - "traceback": [ - "\u001b[0;31m---------------------------------------------------------------------------\u001b[0m", - "\u001b[0;31mTypeError\u001b[0m Traceback (most recent call last)", - "Cell \u001b[0;32mIn[3], line 1\u001b[0m\n\u001b[0;32m----> 1\u001b[0m \u001b[43mop\u001b[49m\u001b[38;5;241;43m.\u001b[39;49m\u001b[43mPipeline\u001b[49m\u001b[43m(\u001b[49m\u001b[43m)\u001b[49m\n", - "\u001b[0;31mTypeError\u001b[0m: Pipeline.__init__() missing 4 required positional arguments: 'graph_dot', 'metadata', 'input_spec', and 'output_spec'" - ] - } - ], - "source": [ - "op.Pipeline()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "7626744f", - "metadata": {}, - "outputs": [], - "source": [] - } - ], - "metadata": { - "kernelspec": { - "display_name": "orcapod", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.10.18" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/src/core/error.rs b/src/core/error.rs index ad4c8550..a9e31225 100644 --- a/src/core/error.rs +++ b/src/core/error.rs @@ -121,6 +121,7 @@ impl fmt::Debug for OrcaError { fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { match &self.kind { Kind::AgentCommunicationFailure { backtrace, .. } + | Kind::EmptyDir { backtrace, .. } | Kind::IncompletePacket { backtrace, .. } | Kind::InvalidFilepath { backtrace, .. } | Kind::InvalidIndex { backtrace, .. } diff --git a/src/core/model/mod.rs b/src/core/model/mod.rs index 0dbd6adb..242748ef 100644 --- a/src/core/model/mod.rs +++ b/src/core/model/mod.rs @@ -8,29 +8,6 @@ use std::{ hash::BuildHasher, result, }; -/// Converts a model instance into a consistent yaml. -/// -/// # Errors -/// -/// Will return `Err` if there is an issue converting an `instance` into YAML (w/o annotation). -pub fn to_yaml(instance: &T) -> Result { - let mapping: IndexMap = serde_yaml::from_str(&serde_yaml::to_string(instance)?)?; // cast to map - let mut yaml = serde_yaml::to_string( - &mapping - .iter() - .filter_map(|(k, v)| match &**k { - "annotation" | "hash" => None, - "pod" | "pod_job" => Some((k, v["hash"].clone())), - _ => Some((k, v.clone())), - }) - .collect::>(), - )?; // skip fields and convert refs to hash pointers - yaml.insert_str( - 0, - &format!("class: {}\n", get_type_name::().to_snake_case()), - ); // replace class at top - Ok(yaml) -} /// Trait to handle serialization to yaml for `OrcaPod` models pub trait ToYaml: Serialize + Sized { diff --git a/src/core/pipeline_runner.rs b/src/core/pipeline_runner.rs index 086718cf..e7665058 100644 --- a/src/core/pipeline_runner.rs +++ b/src/core/pipeline_runner.rs @@ -666,8 +666,8 @@ impl PodProcessor { ) .into(), }, - pod.exec_requirements.recommended_cpus, - pod.exec_requirements.recommended_memory, + pod.recommend_specs.cpus, + pod.recommend_specs.memory, None, &pipeline_run.namespace_lookup, )?; diff --git a/src/core/store/filestore.rs b/src/core/store/filestore.rs index 843486c3..3329efcb 100644 --- a/src/core/store/filestore.rs +++ b/src/core/store/filestore.rs @@ -1,6 +1,6 @@ use crate::{ core::{ - model::to_yaml, + model::ToYaml, store::MODEL_NAMESPACE, util::{get_type_name, parse_debug_name}, }, @@ -111,7 +111,7 @@ impl LocalFileStore { Ok(model_info.hash) } - fn save_file(file: impl AsRef, content: impl AsRef<[u8]>) -> Result<()> { + pub(crate) fn save_file(file: impl AsRef, content: impl AsRef<[u8]>) -> Result<()> { if let Some(parent) = file.as_ref().parent() { fs::create_dir_all(parent)?; } @@ -123,7 +123,7 @@ impl LocalFileStore { /// # Errors /// /// Will return `Err` if there is an issue storing the model. - pub(crate) fn save_model( + pub(crate) fn save_model( &self, model: &T, hash: &str, @@ -174,7 +174,7 @@ impl LocalFileStore { .yellow(), ); } else { - Self::save_file(spec_file, to_yaml(model)?)?; + Self::save_file(spec_file, model.to_yaml()?)?; } Ok(()) } diff --git a/src/uniffi/error.rs b/src/uniffi/error.rs index 5cd9f355..df9358b1 100644 --- a/src/uniffi/error.rs +++ b/src/uniffi/error.rs @@ -31,6 +31,11 @@ pub(crate) enum Kind { source: Box, backtrace: Option, }, + #[snafu(display("Empty directory: {dir:?}, where they should be files"))] + EmptyDir { + dir: PathBuf, + backtrace: Option, + }, #[snafu(display( "Missing expected output file or dir with key {packet_key} at path {path:?} for pod job (hash: {pod_job_hash})." ))] diff --git a/src/uniffi/model/mod.rs b/src/uniffi/model/mod.rs index 94b10640..a266fd99 100644 --- a/src/uniffi/model/mod.rs +++ b/src/uniffi/model/mod.rs @@ -23,38 +23,6 @@ pub struct Annotation { pub description: String, } -/// Execution requirements for a pod/pipeline, since it doesn't impact the actual reproducibility except for GPU requirement or OOM, -/// it shouldn't be -#[derive(uniffi::Record, Serialize, Deserialize, Debug, PartialEq, Default, Clone)] -pub struct ExecRequirements { - /// Optimal number of CPU cores needed to run the pod/pipeline provided by the user - pub recommended_cpus: f32, - /// Optimal amount of memory needed to run the pod/pipeline provided by the user, code can probably run with less but may hit OOM - pub recommended_memory: u64, - /// Optional GPU requirements for the pod/pipeline. If set, then the system should at least meet the architecture requirements. - pub gpu_requirements: Option, -} - -/// Specification for GPU requirements in computation. -#[derive(uniffi::Record, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] -pub struct GPURequirement { - /// GPU model specification. - pub model: GPUModel, - /// Manufacturer recommended memory. - pub recommended_memory: u64, - /// Number of GPU cards required. - pub count: u16, -} - -/// GPU model specification. -#[derive(uniffi::Enum, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] -pub enum GPUModel { - /// NVIDIA-manufactured card where `String` is the specific CUDA version - NVIDIA(String), - /// Any GPU architecture, code is generic enough - Any, -} - uniffi::custom_type!(PathBuf, String, { remote, try_lift: |val| Ok(PathBuf::from(&val)), diff --git a/src/uniffi/model/pod.rs b/src/uniffi/model/pod.rs index 92fdc447..d681432f 100644 --- a/src/uniffi/model/pod.rs +++ b/src/uniffi/model/pod.rs @@ -12,7 +12,7 @@ use crate::{ uniffi::{ error::{OrcaError, Result}, model::{ - Annotation, ExecRequirements, + Annotation, packet::{Blob, BlobKind, Packet, PathInfo, PathSet, URI}, }, orchestrator::PodStatus, @@ -51,7 +51,10 @@ pub struct Pod { #[serde(serialize_with = "serialize_hashmap")] pub output_spec: HashMap, /// Execution requirements for the pod. - pub exec_requirements: ExecRequirements, + #[serde(default)] + pub recommend_specs: RecommendSpecs, + /// Optional GPU requirements for the pod. If set, then the running system needs a GPU that meets the requirements. + pub gpu_requirements: Option, } #[uniffi::export] @@ -69,7 +72,8 @@ impl Pod { input_spec: HashMap, output_dir: PathBuf, output_spec: HashMap, - exec_requirements: ExecRequirements, + recommend_specs: RecommendSpecs, + gpu_requirements: Option, ) -> Result { let pod_no_hash = Self { annotation, @@ -79,7 +83,8 @@ impl Pod { input_spec, output_dir, output_spec, - exec_requirements, + recommend_specs, + gpu_requirements, }; Ok(Self { hash: hash_buffer(pod_no_hash.to_yaml()?), @@ -94,12 +99,51 @@ impl ToYaml for Pod { field_value: &serde_yaml::Value, ) -> Option<(String, serde_yaml::Value)> { match field_name { - "annotation" | "hash" | "exec_requirements" => None, + "annotation" | "hash" | "recommend_specs" => None, _ => Some((field_name.to_owned(), field_value.clone())), } } } +/// Execution recommendations for a pod, since it doesn't impact the actual reproducibility +/// it shouldn't be hashed along with the pod +#[derive(uniffi::Record, Serialize, Deserialize, Debug, PartialEq, Default, Clone)] +pub struct RecommendSpecs { + /// Optimal number of CPU cores needed to run the pod provided by the user + pub cpus: f32, + /// Optimal amount of memory needed to run the pod provided by the user, code can probably run with less but may hit OOM + pub memory: u64, +} + +impl ToYaml for RecommendSpecs { + fn process_field( + field_name: &str, + field_value: &serde_yaml::Value, + ) -> Option<(String, serde_yaml::Value)> { + Some((field_name.to_owned(), field_value.clone())) + } +} + +/// Specification for GPU requirements in computation. +#[derive(uniffi::Record, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] +pub struct GPURequirement { + /// GPU model specification. + pub model: GPUModel, + /// Manufacturer recommended memory. + pub recommended_memory: u64, + /// Number of GPU cards required. + pub count: u16, +} + +/// GPU model specification. +#[derive(uniffi::Enum, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] +pub enum GPUModel { + /// NVIDIA-manufactured card where `String` is the specific CUDA version + NVIDIA(String), + /// Any GPU architecture, code is generic enough + Any, +} + /// A compute job that specifies resource requests and input/output targets. /// /// `PodJob` represents a specific execution instance of a [`Pod`] with concrete diff --git a/src/uniffi/store/filestore.rs b/src/uniffi/store/filestore.rs index db089dfa..c940e87e 100644 --- a/src/uniffi/store/filestore.rs +++ b/src/uniffi/store/filestore.rs @@ -1,14 +1,18 @@ -use crate::uniffi::{ - error::Result, - model::{ - ModelType, - pod::{Pod, PodJob, PodResult}, +use crate::{ + core::model::ToYaml as _, + uniffi::{ + error::{Kind, OrcaError, Result}, + model::{ + ModelType, + pod::{Pod, PodJob, PodResult}, + }, + store::{ModelID, ModelInfo, Store}, }, - store::{ModelID, ModelInfo, Store}, }; +use chrono::Utc; use derive_more::Display; use getset::CloneGetters; -use std::{fs, path::PathBuf}; +use std::{backtrace::Backtrace, fs, path::PathBuf}; use uniffi; /// Support for a storage backend on a local filesystem directory. #[derive(uniffi::Object, Debug, Display, CloneGetters, Clone)] @@ -23,12 +27,52 @@ pub struct LocalFileStore { #[uniffi::export] impl Store for LocalFileStore { fn save_pod(&self, pod: &Pod) -> Result<()> { - self.save_model(pod, &pod.hash, pod.annotation.as_ref()) + self.save_model(pod, &pod.hash, pod.annotation.as_ref())?; + // Deal with saving the recommended_specs + // Since we are going with a no modify scheme for saving, we will save the latest version as year-month-day-hour-min-second UTC + Self::save_file( + self.make_path( + pod, + &pod.hash, + format!( + "recommended_specs/{}", + Utc::now().format("%Y-%m-%d-%H-%M-%S") + ), + ), + &pod.recommend_specs.to_yaml()?, + ) } fn load_pod(&self, model_id: &ModelID) -> Result { let (mut pod, annotation, hash) = self.load_model::(model_id)?; pod.annotation = annotation; pod.hash = hash; + // Deal with the recommended_specs by selecting the last saved spec + // List all files in the dir + let folder_path = self.make_path(&pod, &pod.hash, "recommended_specs"); + let mut recommended_specs = fs::read_dir(&folder_path)?; + + let mut latest_spec_file_name = recommended_specs + .next() + .ok_or(OrcaError { + kind: Kind::EmptyDir { + dir: folder_path.clone(), + backtrace: Some(Backtrace::capture()), + }, + })?? + .file_name(); + + for entry in recommended_specs { + let file_name = entry?.file_name(); + if file_name > latest_spec_file_name { + latest_spec_file_name = file_name; + } + } + + // Read the latest_spec and loaded back in + pod.recommend_specs = serde_yaml::from_str(&fs::read_to_string( + folder_path.join(latest_spec_file_name), + )?)?; + Ok(pod) } fn list_pod(&self) -> Result> { diff --git a/src/uniffi/store/mod.rs b/src/uniffi/store/mod.rs index 449da225..c07ac84a 100644 --- a/src/uniffi/store/mod.rs +++ b/src/uniffi/store/mod.rs @@ -16,7 +16,7 @@ pub enum ModelID { } /// Metadata for a model. -#[derive(uniffi::Record, Debug, PartialEq, Eq)] +#[derive(uniffi::Record, Debug, PartialEq, Eq, Hash)] pub struct ModelInfo { /// A model's name. pub name: Option, diff --git a/tests/fixture/mod.rs b/tests/fixture/mod.rs index 3a90091b..bd895622 100644 --- a/tests/fixture/mod.rs +++ b/tests/fixture/mod.rs @@ -11,10 +11,10 @@ use names::{Generator, Name}; use orcapod::uniffi::{ error::Result, model::{ - Annotation, ExecRequirements, + Annotation, packet::{Blob, BlobKind, Packet, PathInfo, PathSet, URI}, pipeline::{Kernel, NodeURI, Pipeline, PipelineJob}, - pod::{Pod, PodJob, PodResult}, + pod::{Pod, PodJob, PodResult, RecommendSpecs}, }, operator::MapOperator, orchestrator::PodStatus, @@ -77,11 +77,11 @@ pub fn pod_style() -> Result { }, ), ]), - ExecRequirements { - recommended_cpus: 0.25, - recommended_memory: 1_u64 << 30, - gpu_requirements: None, + RecommendSpecs { + cpus: 0.25, + memory: 1_u64 << 30, }, + None, ) } @@ -171,11 +171,11 @@ pub fn pod_custom( input_spec, PathBuf::from("/tmp/output"), HashMap::new(), - ExecRequirements { - recommended_cpus: 0.1, - recommended_memory: 50_u64 << 20, - gpu_requirements: None, + RecommendSpecs { + cpus: 0.1, + memory: 50_u64 << 20, }, + None, ) } @@ -325,11 +325,11 @@ pub fn combine_txt_pod(pod_name: &str) -> Result { match_pattern: r".*\.txt".to_owned(), }, )]), - ExecRequirements { - recommended_cpus: 0.25, - recommended_memory: 128_u64 << 20, - gpu_requirements: None, + RecommendSpecs { + cpus: 0.25, + memory: 128_u64 << 20, }, + None, ) } diff --git a/tests/model.rs b/tests/model.rs index b8c1bbf1..fb48fc47 100644 --- a/tests/model.rs +++ b/tests/model.rs @@ -10,7 +10,7 @@ use pretty_assertions::assert_eq as pretty_assert_eq; fn hash_pod() -> Result<()> { pretty_assert_eq!( pod_style()?.hash, - "3ee06e06cc1c821712a2a8fd98a6aa3e72ab0e78d5a2668d39b3400d5ece5d52", + "2104a9b471bec3a54f1f5437d887e16626bed3e818241410ce1ec7a63f0361fb", "Hash didn't match." ); Ok(()) @@ -41,6 +41,7 @@ fn pod_to_yaml() -> Result<()> { result2: path: result2.jpeg match_pattern: .*\.jpeg + gpu_requirements: null "}, "YAML serialization didn't match." ); @@ -51,7 +52,7 @@ fn pod_to_yaml() -> Result<()> { fn hash_pod_job() -> Result<()> { pretty_assert_eq!( pod_job_style(&NAMESPACE_LOOKUP_READ_ONLY)?.hash, - "0bc729c0d718f01d35d42856f5303353b95b280e441410d623372e0d29e9f1c9", + "009588059284ab7e62c6af040eca44dbd8b32964ebb1406a34b174dafcf4520d", "Hash didn't match." ); Ok(()) @@ -63,7 +64,7 @@ fn pod_job_to_yaml() -> Result<()> { pod_job_style(&NAMESPACE_LOOKUP_READ_ONLY)?.to_yaml()?, indoc! {" class: pod_job - pod: 3ee06e06cc1c821712a2a8fd98a6aa3e72ab0e78d5a2668d39b3400d5ece5d52 + pod: 2104a9b471bec3a54f1f5437d887e16626bed3e818241410ce1ec7a63f0361fb input_packet: base-input: - kind: File @@ -100,7 +101,7 @@ fn pod_job_to_yaml() -> Result<()> { fn hash_pod_result() -> Result<()> { pretty_assert_eq!( pod_result_style(&NAMESPACE_LOOKUP_READ_ONLY)?.hash, - "72a6586ccb1af6892c14c608757dbd43b7f8092ffeaee57ceb21d6bbffe6a92f", + "56d750bae1e9f70be2bca38c121cdbb7bd2e67a3c40d28df9bc5b5133eaedf70", "Hash didn't match." ); Ok(()) @@ -112,7 +113,7 @@ fn pod_result_to_yaml() -> Result<()> { pod_result_style(&NAMESPACE_LOOKUP_READ_ONLY)?.to_yaml()?, indoc! {" class: pod_result - pod_job: 0bc729c0d718f01d35d42856f5303353b95b280e441410d623372e0d29e9f1c9 + pod_job: 009588059284ab7e62c6af040eca44dbd8b32964ebb1406a34b174dafcf4520d output_packet: result1: kind: File diff --git a/tests/store.rs b/tests/store.rs index adbe8e2b..64abfd7b 100644 --- a/tests/store.rs +++ b/tests/store.rs @@ -13,12 +13,16 @@ use fixture::{ }; use orcapod::uniffi::{ error::Result, - model::{Annotation, ExecRequirements, ModelType, packet::PathInfo, pod::Pod}, + model::{ + Annotation, ModelType, + packet::PathInfo, + pod::{Pod, RecommendSpecs}, + }, store::{ModelID, ModelInfo, Store as _, filestore::LocalFileStore}, }; use pretty_assertions::assert_eq as pretty_assert_eq; use std::{ - collections::HashMap, + collections::{HashMap, HashSet}, fmt::Debug, ops::Deref as _, path::{Path, PathBuf}, @@ -81,6 +85,7 @@ fn pod_basic() -> Result<()> { fn pod_job_basic() -> Result<()> { let mut expected_model = pod_job_style(&NAMESPACE_LOOKUP_READ_ONLY)?; let mut pod = expected_model.pod.deref().clone(); + pod.annotation = None; expected_model.pod = Arc::new(pod); basic_test( @@ -277,12 +282,13 @@ fn pod_annotation_unique() -> Result<()> { }, )]); let output_dir: PathBuf = "/output".into(); - let exec_requirements = ExecRequirements { - recommended_cpus: 0.25, - recommended_memory: 1_u64 << 30, - gpu_requirements: None, + let exec_requirements = RecommendSpecs { + cpus: 0.25, + memory: 1_u64 << 30, }; + let gpu_requirements = None; + let pod = Pod::new( Some(annotation.clone()), image.clone(), @@ -291,6 +297,7 @@ fn pod_annotation_unique() -> Result<()> { output_dir.clone(), output_spec.clone(), exec_requirements.clone(), + gpu_requirements.clone(), )?; // Save pod above @@ -307,12 +314,13 @@ fn pod_annotation_unique() -> Result<()> { "/output".into(), output_spec.clone(), exec_requirements.clone(), + gpu_requirements.clone(), )?; store.save_pod(&pod_with_new_annotation)?; pretty_assert_eq!( - store.list_pod()?, - vec![ + HashSet::from_iter(store.list_pod()?), + HashSet::from([ ModelInfo { name: Some(annotation.name.clone()), version: Some(annotation.version.clone()), @@ -323,7 +331,7 @@ fn pod_annotation_unique() -> Result<()> { version: None, hash: pod.hash.clone(), }, - ], + ]), "Pod list didn't return 2 expected entries." ); pretty_assert_eq!( @@ -345,11 +353,12 @@ fn pod_annotation_unique() -> Result<()> { output_dir, output_spec, exec_requirements, + gpu_requirements, )?; store.save_pod(&pod_with_updated_command)?; pretty_assert_eq!( - store.list_pod()?, - vec![ + HashSet::from_iter(store.list_pod()?), + HashSet::from([ ModelInfo { name: Some(annotation.name.clone()), version: Some(annotation.version.clone()), @@ -365,7 +374,7 @@ fn pod_annotation_unique() -> Result<()> { version: None, hash: pod_with_updated_command.hash, }, - ], + ]), "Pod list didn't return 3 expected entries." ); pretty_assert_eq!( From 7fa14bbe61d4dd510c521b46b1d272c45f8261fa Mon Sep 17 00:00:00 2001 From: Synicix Date: Tue, 2 Sep 2025 15:58:03 +0000 Subject: [PATCH 43/65] Update python scripts --- .github/workflows/tests.yaml | 4 ++-- tests/extra/python/{smoke_test.py => model.py} | 8 +++++--- 2 files changed, 7 insertions(+), 5 deletions(-) rename tests/extra/python/{smoke_test.py => model.py} (96%) diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 5e60118e..836c4d7c 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -22,7 +22,7 @@ jobs: - name: Install code coverage uses: taiki-e/install-action@cargo-llvm-cov - name: Run syntax and style tests - run: cargo clippy --no-default-features --features=test --all-targets -- -D warnings + run: cargo clippy --all-targets -- -D warnings - name: Run format test run: cargo fmt --check - name: Run integration tests w/ coverage report @@ -62,7 +62,7 @@ jobs: RUST_BACKTRACE: full run: | . ~/.local/share/base/bin/activate - python tests/extra/python/smoke_test.py -- tests/.tmp + python tests/extra/python/model.py -- tests/.tmp - name: Run agent test env: RUST_BACKTRACE: full diff --git a/tests/extra/python/smoke_test.py b/tests/extra/python/model.py similarity index 96% rename from tests/extra/python/smoke_test.py rename to tests/extra/python/model.py index 896d4321..03c76d8c 100755 --- a/tests/extra/python/smoke_test.py +++ b/tests/extra/python/model.py @@ -16,7 +16,7 @@ LocalFileStore, ModelId, ModelType, - ExecRequirements, + RecommendSpecs, OrcaError, ) @@ -33,9 +33,11 @@ def create_pod(data, _): input_spec={}, output_dir="/tmp/output", output_spec={}, - exec_requirements=ExecRequirements( - recommended_cpus=0.1, recommended_memory=10 << 20, gpu_requirements=None + recommend_specs=RecommendSpecs( + cpus=0.1, + memory=10 << 20, ), + gpu_requirements=None, ) return data["pod"], data From 6ec5fa07397a194ae9faf6528d7f11dcf318418e Mon Sep 17 00:00:00 2001 From: Synicix Date: Tue, 2 Sep 2025 16:04:01 +0000 Subject: [PATCH 44/65] Add microseconds in case the case of mutiple save in a second --- src/uniffi/store/filestore.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/uniffi/store/filestore.rs b/src/uniffi/store/filestore.rs index c940e87e..afbe7a43 100644 --- a/src/uniffi/store/filestore.rs +++ b/src/uniffi/store/filestore.rs @@ -36,7 +36,7 @@ impl Store for LocalFileStore { &pod.hash, format!( "recommended_specs/{}", - Utc::now().format("%Y-%m-%d-%H-%M-%S") + Utc::now().format("%Y-%m-%d-%H-%M-%S-%f") ), ), &pod.recommend_specs.to_yaml()?, From 2b0d33bbf9846c8e87307d5d0278b68755db7025 Mon Sep 17 00:00:00 2001 From: Synicix Date: Tue, 2 Sep 2025 16:30:19 +0000 Subject: [PATCH 45/65] Change threshold --- tests/agent.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/agent.rs b/tests/agent.rs index a3f0b543..f31efce2 100644 --- a/tests/agent.rs +++ b/tests/agent.rs @@ -48,7 +48,7 @@ async fn parallel_four_cores() -> Result<()> { // config let image_reference = "ghcr.io/colinianking/stress-ng:e2f96874f951a72c1c83ff49098661f0e013ac40"; pull_image(image_reference)?; - let margin_millis = 3000; + let margin_millis = 5000; let run_duration_secs = 5; let (group, host) = ("agent_parallel-four-cores", "host"); // api From 1f66f20b9642e80113c6909566115984d82817c5 Mon Sep 17 00:00:00 2001 From: Synicix Date: Tue, 2 Sep 2025 16:30:31 +0000 Subject: [PATCH 46/65] Update agent python test --- src/uniffi/model/pod.rs | 2 +- tests/extra/python/{agent_test.py => agent.py} | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) rename tests/extra/python/{agent_test.py => agent.py} (93%) diff --git a/src/uniffi/model/pod.rs b/src/uniffi/model/pod.rs index d681432f..539009ce 100644 --- a/src/uniffi/model/pod.rs +++ b/src/uniffi/model/pod.rs @@ -138,7 +138,7 @@ pub struct GPURequirement { /// GPU model specification. #[derive(uniffi::Enum, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] pub enum GPUModel { - /// NVIDIA-manufactured card where `String` is the specific CUDA version + /// NVIDIA-manufactured card where `String` is the specific minimum CUDA version in X.XX NVIDIA(String), /// Any GPU architecture, code is generic enough Any, diff --git a/tests/extra/python/agent_test.py b/tests/extra/python/agent.py similarity index 93% rename from tests/extra/python/agent_test.py rename to tests/extra/python/agent.py index b0a35e8d..8405d8d7 100644 --- a/tests/extra/python/agent_test.py +++ b/tests/extra/python/agent.py @@ -17,7 +17,7 @@ Uri, Pod, Annotation, - ExecRequirements, + RecommendSpecs, ) @@ -89,11 +89,11 @@ async def main(client, agent, test_dir, namespace_lookup, pod_jobs): input_spec={}, output_dir="/tmp/output", output_spec={}, - exec_requirements=ExecRequirements( - recommended_cpus=0.1, - recommended_memory=128 << 20, - gpu_requirements=None, + recommend_specs=RecommendSpecs( + cpus=0.1, + memory=128 << 20, ), + gpu_requirements=None, ), input_packet={}, output_dir=Uri( From dacb3128b415d2d53b1cb8564d0a05f841c9e5ba Mon Sep 17 00:00:00 2001 From: Synicix Date: Tue, 2 Sep 2025 16:38:53 +0000 Subject: [PATCH 47/65] Update python test.yaml --- .github/workflows/tests.yaml | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 836c4d7c..9a579c6c 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -57,15 +57,11 @@ jobs: uv pip install eclipse-zenoh -p ~/.local/share/base . ~/.local/share/base/bin/activate maturin develop --uv - - name: Run smoke test + - name: Run Python test env: RUST_BACKTRACE: full run: | . ~/.local/share/base/bin/activate - python tests/extra/python/model.py -- tests/.tmp - - name: Run agent test - env: - RUST_BACKTRACE: full - run: | - . ~/.local/share/base/bin/activate - python tests/extra/python/agent_test.py -- tests/.tmp + for py_file in tests/extra/python/*.py; do + python "$py_file" -- tests/.tmp + done From f3115da0d81adc9f189698baf41a4e0564b52c7e Mon Sep 17 00:00:00 2001 From: Synicix Date: Tue, 2 Sep 2025 16:39:45 +0000 Subject: [PATCH 48/65] Update name --- .github/workflows/tests.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 9a579c6c..33299131 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -57,7 +57,7 @@ jobs: uv pip install eclipse-zenoh -p ~/.local/share/base . ~/.local/share/base/bin/activate maturin develop --uv - - name: Run Python test + - name: Run Python tests env: RUST_BACKTRACE: full run: | From 39b3996010a218b217378e861ab86bc8e5f9dca9 Mon Sep 17 00:00:00 2001 From: synicix Date: Fri, 5 Sep 2025 03:04:03 +0000 Subject: [PATCH 49/65] Update excessive nesting exception for clippy --- src/core/pipeline_runner.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/core/pipeline_runner.rs b/src/core/pipeline_runner.rs index e7665058..e7530164 100644 --- a/src/core/pipeline_runner.rs +++ b/src/core/pipeline_runner.rs @@ -750,6 +750,7 @@ impl PodProcessor { } } +#[expect(clippy::excessive_nesting, reason = "Nesting manageable")] #[async_trait] impl NodeProcessor for PodProcessor { async fn process_incoming_packet( @@ -845,6 +846,7 @@ impl OperatorProcessor { } } +#[expect(clippy::excessive_nesting, reason = "Nesting manageable")] #[async_trait] impl NodeProcessor for OperatorProcessor { async fn process_incoming_packet( From 8e956b936a7d2e587c705407e714d32569b821be Mon Sep 17 00:00:00 2001 From: synicix Date: Fri, 5 Sep 2025 08:07:04 +0000 Subject: [PATCH 50/65] Add hash to kernels --- src/core/graph.rs | 2 +- src/core/model/pipeline.rs | 5 ++++- src/core/operator.rs | 21 +++++++++++++++++---- src/core/pipeline_runner.rs | 22 +++++++++++----------- src/uniffi/model/pipeline.rs | 17 ++++++++++++++++- src/uniffi/operator.rs | 26 ++++++++++++++++++++++++++ tests/fixture/mod.rs | 16 +++++++++------- 7 files changed, 84 insertions(+), 25 deletions(-) diff --git a/src/core/graph.rs b/src/core/graph.rs index 73bb87cb..a0ff2f25 100644 --- a/src/core/graph.rs +++ b/src/core/graph.rs @@ -25,7 +25,7 @@ pub fn make_graph( let graph = DiGraph::::from_dot_graph(DOTGraph::try_from(input_dot)?).map( |_, node| PipelineNode { - id: node.id.clone(), + hash: node.id.clone(), kernel: get(&metadata, &node.id) .unwrap_or_else(|error| panic!("{error}")) .clone(), diff --git a/src/core/model/pipeline.rs b/src/core/model/pipeline.rs index ffb0e319..f5bbdf4d 100644 --- a/src/core/model/pipeline.rs +++ b/src/core/model/pipeline.rs @@ -16,8 +16,11 @@ use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] pub struct PipelineNode { - pub id: String, + // Hash that represent the node + pub hash: String, pub kernel: Kernel, + /// User provided label for the node + pub label: String, } impl Pipeline { diff --git a/src/core/operator.rs b/src/core/operator.rs index b5487d3f..55f72bed 100644 --- a/src/core/operator.rs +++ b/src/core/operator.rs @@ -1,4 +1,7 @@ -use crate::uniffi::{error::Result, model::packet::Packet, operator::MapOperator}; +use crate::{ + core::model::ToYaml, + uniffi::{error::Result, model::packet::Packet, operator::MapOperator}, +}; use async_trait; use itertools::Itertools as _; use std::{clone::Clone, collections::HashMap, iter::IntoIterator, sync::Arc}; @@ -80,6 +83,18 @@ impl Operator for MapOperator { } } +impl ToYaml for MapOperator { + fn process_field( + field_name: &str, + field_value: &serde_yaml::Value, + ) -> Option<(String, serde_yaml::Value)> { + match field_name { + "hash" => None, + _ => Some((field_name.to_owned(), field_value.clone())), + } + } +} + #[cfg(test)] mod tests { #![expect(clippy::panic_in_result_fn, reason = "OK in tests.")] @@ -274,9 +289,7 @@ mod tests { #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn map_once() -> Result<()> { - let operator = MapOperator { - map: HashMap::from([("key_old".into(), "key_new".into())]), - }; + let operator = MapOperator::new(HashMap::from([("key_old".into(), "key_new".into())]))?; assert_eq!( operator diff --git a/src/core/pipeline_runner.rs b/src/core/pipeline_runner.rs index e7530164..a8c75a88 100644 --- a/src/core/pipeline_runner.rs +++ b/src/core/pipeline_runner.rs @@ -231,7 +231,7 @@ impl DockerPipelineRunner { .spawn(Self::spawn_node_processing_task( graph[node_idx].clone(), Arc::clone(&pipeline_run), - input_nodes.contains(&node.id), + input_nodes.contains(&node.hash), )); } @@ -433,18 +433,18 @@ impl DockerPipelineRunner { Arc::new(Mutex::new(match &node.kernel { Kernel::Pod { pod } => Box::new(PodProcessor::new( Arc::clone(&pipeline_run), - node.id.clone(), + node.hash.clone(), Arc::clone(pod), )), Kernel::MapOperator { mapper } => Box::new(OperatorProcessor::new( Arc::clone(&pipeline_run), - node.id.clone(), + node.hash.clone(), Arc::clone(mapper), parent_nodes.len(), )), Kernel::JoinOperator => Box::new(OperatorProcessor::new( Arc::clone(&pipeline_run), - node.id.clone(), + node.hash.clone(), JoinOperator::new(parent_nodes.len()).into(), parent_nodes.len(), )), @@ -456,19 +456,19 @@ impl DockerPipelineRunner { // Create a list of node_ids that this node should listen to let mut nodes_to_sub_to = parent_nodes .iter() - .map(|parent_node| parent_node.id.clone()) + .map(|parent_node| parent_node.hash.clone()) .collect::>(); if is_input_node { // If the node is an input node, we need to add the input node key expression - nodes_to_sub_to.push(format!("input_node_{}", node.id)); + nodes_to_sub_to.push(format!("input_node_{}", node.hash)); } // For each node in nodes_to_subscribe_to, call the event handler func for node_to_sub in &nodes_to_sub_to { listener_tasks.spawn(Self::event_handler( Arc::clone(&pipeline_run), - node.id.clone(), + node.hash.clone(), node_to_sub.to_owned(), Arc::clone(&node_processor), )); @@ -486,7 +486,7 @@ impl DockerPipelineRunner { // Build the subscriber let status_subscriber = pipeline_run .session - .declare_subscriber(pipeline_run.make_key_expr(&node.id, "event_handler_ready")) + .declare_subscriber(pipeline_run.make_key_expr(&node.hash, "event_handler_ready")) .await .context(selector::AgentCommunicationFailure {})?; @@ -501,7 +501,7 @@ impl DockerPipelineRunner { // Send a ready message so the pipeline knows when to start sending inputs pipeline_run .session - .put(pipeline_run.make_key_expr(&node.id, "node_ready"), vec![]) + .put(pipeline_run.make_key_expr(&node.hash, "node_ready"), vec![]) .await .context(selector::AgentCommunicationFailure {})?; @@ -510,11 +510,11 @@ impl DockerPipelineRunner { match result { Ok(Ok(())) => {} // Task completed successfully Ok(Err(err)) => { - pipeline_run.send_err_msg(&node.id, err).await; + pipeline_run.send_err_msg(&node.hash, err).await; } Err(err) => { pipeline_run - .send_err_msg(&node.id, OrcaError::from(err)) + .send_err_msg(&node.hash, OrcaError::from(err)) .await; } } diff --git a/src/uniffi/model/pipeline.rs b/src/uniffi/model/pipeline.rs index af22d5e9..b3e1ca5c 100644 --- a/src/uniffi/model/pipeline.rs +++ b/src/uniffi/model/pipeline.rs @@ -1,6 +1,6 @@ use crate::{ core::{ - crypto::{hash_blob, make_random_hash}, + crypto::{hash_blob, hash_buffer, make_random_hash}, graph::make_graph, model::pipeline::PipelineNode, validation::validate_packet, @@ -18,9 +18,12 @@ use derive_more::Display; use getset::CloneGetters; use petgraph::graph::DiGraph; use serde::{Deserialize, Serialize}; +use std::sync::LazyLock; use std::{collections::HashMap, path::PathBuf, sync::Arc}; use uniffi; +static JOIN_OPERATOR_HASH: LazyLock = LazyLock::new(|| hash_buffer(b"join_operator")); + /// Computational dependencies as a [DAG](https://en.wikipedia.org/wiki/Directed_acyclic_graph). #[derive(uniffi::Object, Debug, Display, CloneGetters, Clone, Deserialize, Serialize)] #[getset(get_clone, impl_attrs = "#[uniffi::export]")] @@ -189,6 +192,18 @@ impl From for Kernel { } } +impl Kernel { + /// Get a unique hash that represents the kernel. + /// The exception here is the `JoinOperator` doesn't have any pre execution configuration, since it's logic is completely dependent on what is fed to it during execution. + pub fn get_hash(&self) -> &str { + match self { + Self::Pod { pod } => &pod.hash, + Self::JoinOperator => &JOIN_OPERATOR_HASH, + Self::MapOperator { mapper } => &mapper.hash, + } + } +} + /// Index from pipeline node into pod specification. #[derive(uniffi::Record, Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] pub struct NodeURI { diff --git a/src/uniffi/operator.rs b/src/uniffi/operator.rs index a7f666be..15320dc1 100644 --- a/src/uniffi/operator.rs +++ b/src/uniffi/operator.rs @@ -2,10 +2,36 @@ use std::collections::HashMap; use serde::{Deserialize, Serialize}; +use crate::core::{crypto::hash_buffer, model::ToYaml as _, model::serialize_hashmap}; +use crate::uniffi::error::Result; + /// Operator class that map `input_keys` to `output_key`, effectively renaming it /// For use in pipelines #[derive(uniffi::Object, Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] pub struct MapOperator { + /// Unique hash of the map operator + pub hash: String, /// Mapping of input keys to output keys + #[serde(serialize_with = "serialize_hashmap")] pub map: HashMap, } + +#[uniffi::export] +impl MapOperator { + #[uniffi::constructor] + /// Create a new `MapOperator` + /// + /// # Errors + /// Will error if there are issues converting the map to yaml for hashing + pub fn new(map: HashMap) -> Result { + let no_hash = Self { + map, + hash: String::new(), + }; + + Ok(Self { + hash: hash_buffer(no_hash.to_yaml()?), + ..no_hash + }) + } +} diff --git a/tests/fixture/mod.rs b/tests/fixture/mod.rs index bd895622..7c0e7ce4 100644 --- a/tests/fixture/mod.rs +++ b/tests/fixture/mod.rs @@ -346,13 +346,15 @@ pub fn pipeline() -> Result { kernel_map.insert(pod_name.into(), combine_txt_pod(pod_name)?.into()); } - let output_to_input_1 = Arc::new(MapOperator { - map: HashMap::from([("output".to_owned(), "input_1".to_owned())]), - }); - - let output_to_input_2 = Arc::new(MapOperator { - map: HashMap::from([("output".to_owned(), "input_2".to_owned())]), - }); + let output_to_input_1 = Arc::new(MapOperator::new(HashMap::from([( + "output".to_owned(), + "input_1".to_owned(), + )]))?); + + let output_to_input_2 = Arc::new(MapOperator::new(HashMap::from([( + "output".to_owned(), + "input_2".to_owned(), + )]))?); // Create a mapper for A, B, and C kernel_map.insert( From dbab5507cfca49e3929d5338cab2c428fabf0265 Mon Sep 17 00:00:00 2001 From: synicix Date: Sun, 7 Sep 2025 09:07:58 +0000 Subject: [PATCH 51/65] Move pod test into unit test and add some basic framework for pipeline hashing --- src/core/crypto.rs | 8 +- src/core/error.rs | 3 +- src/core/graph.rs | 6 +- src/core/mod.rs | 2 +- src/core/model/mod.rs | 8 +- src/core/model/pipeline.rs | 196 ++++++++++++++++++++++++-- src/core/model/pod.rs | 213 +++++++++++++++++++++++++++++ src/core/pipeline_runner.rs | 2 +- src/core/store/filestore.rs | 2 +- src/uniffi/error.rs | 9 +- src/uniffi/model/packet.rs | 13 ++ src/uniffi/model/pipeline.rs | 9 +- src/uniffi/model/pod.rs | 12 +- src/uniffi/operator.rs | 3 +- src/uniffi/orchestrator/docker.rs | 2 +- tests/extra/data/output/output.txt | 1 + tests/model.rs | 138 ------------------- tests/pipeline.rs | 3 + 18 files changed, 455 insertions(+), 175 deletions(-) create mode 100644 tests/extra/data/output/output.txt delete mode 100644 tests/model.rs diff --git a/src/core/crypto.rs b/src/core/crypto.rs index 698b6de3..8f4619f3 100644 --- a/src/core/crypto.rs +++ b/src/core/crypto.rs @@ -50,11 +50,9 @@ pub fn hash_buffer(buffer: impl AsRef<[u8]>) -> String { /// /// Will return error if unable to access file. pub fn hash_file(filepath: impl AsRef) -> Result { - hash_stream( - &mut File::open(&filepath).context(selector::InvalidFilepath { - path: filepath.as_ref(), - })?, - ) + hash_stream(&mut File::open(&filepath).context(selector::InvalidPath { + path: filepath.as_ref(), + })?) } /// Evaluate checksum hash of a directory. /// diff --git a/src/core/error.rs b/src/core/error.rs index a9e31225..88399184 100644 --- a/src/core/error.rs +++ b/src/core/error.rs @@ -123,11 +123,12 @@ impl fmt::Debug for OrcaError { Kind::AgentCommunicationFailure { backtrace, .. } | Kind::EmptyDir { backtrace, .. } | Kind::IncompletePacket { backtrace, .. } - | Kind::InvalidFilepath { backtrace, .. } + | Kind::InvalidPath { backtrace, .. } | Kind::InvalidIndex { backtrace, .. } | Kind::KeyMissing { backtrace, .. } | Kind::MissingInfo { backtrace, .. } | Kind::FailedToGetPodJobOutput { backtrace, .. } + | Kind::PipelineValidationErrorMissingKeys { backtrace, .. } | Kind::PodJobProcessingError { backtrace, .. } | Kind::PodJobSubmissionFailed { backtrace, .. } | Kind::UnexpectedPathType { backtrace, .. } diff --git a/src/core/graph.rs b/src/core/graph.rs index a0ff2f25..729b185e 100644 --- a/src/core/graph.rs +++ b/src/core/graph.rs @@ -24,11 +24,13 @@ pub fn make_graph( ) -> Result> { let graph = DiGraph::::from_dot_graph(DOTGraph::try_from(input_dot)?).map( - |_, node| PipelineNode { - hash: node.id.clone(), + |node_idx, node| PipelineNode { + hash: String::new(), kernel: get(&metadata, &node.id) .unwrap_or_else(|error| panic!("{error}")) .clone(), + label: node.id.clone(), + node_idx, }, |_, _| (), ); diff --git a/src/core/mod.rs b/src/core/mod.rs index fe107cf0..9faec897 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -6,7 +6,7 @@ pub(crate) mod validation; pub(crate) mod crypto; /// Model definition for orcapod -pub mod model; +pub(crate) mod model; pub(crate) mod operator; pub(crate) mod orchestrator; diff --git a/src/core/model/mod.rs b/src/core/model/mod.rs index 242748ef..c195ad96 100644 --- a/src/core/model/mod.rs +++ b/src/core/model/mod.rs @@ -37,7 +37,7 @@ pub trait ToYaml: Serialize + Sized { fn process_field(field_name: &str, field_value: &Value) -> Option<(String, Value)>; } -pub(crate) fn serialize_hashmap( +pub fn serialize_hashmap( map: &HashMap, serializer: S, ) -> result::Result @@ -49,7 +49,7 @@ where } #[allow(clippy::ref_option, reason = "Serde requires this signature.")] -pub(crate) fn serialize_hashmap_option( +pub fn serialize_hashmap_option( map_option: &Option>, serializer: S, ) -> result::Result @@ -62,5 +62,5 @@ where sorted.serialize(serializer) } -pub(crate) mod pipeline; -pub(crate) mod pod; +pub mod pipeline; +pub mod pod; diff --git a/src/core/model/pipeline.rs b/src/core/model/pipeline.rs index f5bbdf4d..2150fccb 100644 --- a/src/core/model/pipeline.rs +++ b/src/core/model/pipeline.rs @@ -3,42 +3,143 @@ use std::{ collections::{HashMap, HashSet}, }; -use crate::uniffi::{ - error::{Kind, OrcaError, Result}, - model::{ - packet::PathSet, - pipeline::{Kernel, Pipeline, PipelineJob}, +use crate::{ + core::{crypto::hash_buffer, util::get}, + uniffi::{ + error::{Kind, OrcaError, Result}, + model::{ + packet::PathSet, + pipeline::{Kernel, NodeURI, Pipeline, PipelineJob}, + }, }, }; use itertools::Itertools as _; -use petgraph::Direction::Incoming; +use petgraph::{ + Direction::Incoming, + graph::{DiGraph, NodeIndex}, +}; use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] pub struct PipelineNode { // Hash that represent the node pub hash: String, + /// Kernel associated with the node pub kernel: Kernel, /// User provided label for the node pub label: String, + /// This is meant for internal use only to track the node index in the graph + pub node_idx: NodeIndex, } impl Pipeline { + pub(crate) fn validate(&self) -> Result<()> { + // For verification we check that each node has it's input_spec covered by either it's parent or input_spec + // Build a map from input_spec where HashMap, + let mut input_nodes_key_lut = HashMap::<&String, HashSet<&String>>::new(); + for (input_key, node_uris) in &self.input_spec { + for node_uri in node_uris { + input_nodes_key_lut + .entry(&node_uri.node_id) + .or_default() + .insert(input_key); + } + } + + // Iterate over each node in the graph and verify that its input spec is met + for node_idx in self.graph.node_indices() { + self.validate_valid_input_spec( + node_idx, + get(&input_nodes_key_lut, &self.graph[node_idx].label)?, + )?; + } + + Ok(()) + } + + fn validate_valid_input_spec( + &self, + node_idx: NodeIndex, + input_keys_for_node: &HashSet<&String>, + ) -> Result<()> { + // We need to get the input spec of the current node and build the packet based on the + // parent nodes to verify that the input_spec if met + + // Get the parent nodes input specs and combine them into + let incoming_packet_keys = self + .get_parent_node_indices(node_idx) + .flat_map(|parent_idx| self.get_output_spec_for_node(parent_idx)) + .collect::>(); + + // Get this node input_spec + let missing_keys: HashSet<&String> = self + .get_input_spec_for_node(node_idx) + .into_iter() + .filter(|expected_key| { + !(incoming_packet_keys.contains(expected_key) + || input_keys_for_node.contains(expected_key)) + }) + .collect(); + + // Verify that there are no missing keys, otherwise return error + if !missing_keys.is_empty() { + return Err(OrcaError { + kind: Kind::PipelineValidationErrorMissingKeys { + node_name: self.graph[node_idx].label.clone(), + missing_keys: missing_keys.into_iter().cloned().collect(), + backtrace: Some(Backtrace::capture()), + }, + }); + } + Ok(()) + } + + fn get_input_spec_for_node(&self, node_idx: NodeIndex) -> HashSet<&String> { + match &self.graph[node_idx].kernel { + Kernel::Pod { pod } => pod.input_spec.keys().collect(), + Kernel::JoinOperator => { + // JoinOperator input_spec is derived from its parents + self.get_parent_node_indices(node_idx) + .flat_map(|parent_idx| self.get_input_spec_for_node(parent_idx)) + .collect() + } + Kernel::MapOperator { mapper } => mapper.map.keys().collect(), + } + } + + fn get_output_spec_for_node(&self, node_idx: NodeIndex) -> HashSet<&String> { + match &self.graph[node_idx].kernel { + Kernel::Pod { pod } => pod.output_spec.keys().collect(), + Kernel::JoinOperator => { + // JoinOperator output_spec is derived from its parents + self.get_parent_node_indices(node_idx) + .flat_map(|parent_idx| self.get_input_spec_for_node(parent_idx)) + .collect() + } + Kernel::MapOperator { mapper } => mapper.map.values().collect(), + } + } + /// Function to get the parents of a node pub(crate) fn get_node_parents( &self, node: &PipelineNode, - ) -> impl Iterator { + ) -> Result> { // Find the NodeIndex for the given node_key - let node_index = self + let node_idx = self .graph .node_indices() - .find(|&idx| self.graph[idx] == *node); - node_index.into_iter().flat_map(move |idx| { - self.graph - .neighbors_directed(idx, Incoming) - .map(move |parent_idx| &self.graph[parent_idx]) - }) + .find(|&idx| self.graph[idx] == *node) + .ok_or(OrcaError { + kind: Kind::KeyMissing { + key: node.label.clone(), + backtrace: Some(Backtrace::capture()), + }, + })?; + + Ok(self + .get_parent_node_indices(node_idx) + .map(|parent_idx| &self.graph[parent_idx])) } /// Return a vec of `node_names` that takes in inputs based on the `input_spec` @@ -53,6 +154,73 @@ impl Pipeline { input_nodes } + + fn get_parent_node_indices(&self, node_idx: NodeIndex) -> impl Iterator { + self.graph.neighbors_directed(node_idx, Incoming) + } + + /// Find the leaf nodes in the graph (nodes with no outgoing edges) + /// # Returns + /// A vector of `NodeIndex` representing the leaf nodes in the graph + pub fn find_leaf_nodes(&self) -> Vec { + self.graph + .node_indices() + .filter(|&idx| { + self.graph + .neighbors_directed(idx, petgraph::Direction::Outgoing) + .next() + .is_none() + }) + .collect() + } + + /// Compute the hash for each node in the graph which is defined as the hash of its kernel + the hashes of its parents + pub(crate) fn compute_hash_for_node( + node_idx: NodeIndex, + input_spec: &HashMap>, + graph: &mut DiGraph, + ) { + // Collect parent indices first to avoid borrowing issues + let parent_indices: Vec = graph.neighbors_directed(node_idx, Incoming).collect(); + + // Sort the parent hashes to ensure consistent ordering + let mut parent_hashes: Vec = if parent_indices.is_empty() { + // This is parent node, thus we will need to use the input_spec to generate a unique hash for the node + // Find all the input keys that map to this node + let input_keys = input_spec.iter().filter_map(|(input_key, node_uris)| { + node_uris.iter().find_map(|node_uri| { + (node_uri.node_id == graph[node_idx].label).then(|| input_key.clone()) + }) + }); + + input_keys.collect() + } else { + parent_indices + .into_iter() + .map(|parent_idx| { + // Check if hash has been computed for this node, if not trigger computation + if graph[parent_idx].hash.is_empty() { + // Recursive call to compute the parent's hash + Self::compute_hash_for_node(parent_idx, input_spec, graph); + } + graph[parent_idx].hash.clone() + }) + .collect() + }; + + parent_hashes.sort(); + + // Combine the node's kernel hash + the parent_hashes by concatenation only if there are parents hashes, else it is just the kernel hash + if parent_hashes.is_empty() { + } else { + let hash_for_node = format!( + "{}{}", + &graph[node_idx].kernel.get_hash(), + parent_hashes.into_iter().join("") + ); + graph[node_idx].hash = hash_buffer(hash_for_node.as_bytes()); + } + } } impl PipelineJob { diff --git a/src/core/model/pod.rs b/src/core/model/pod.rs index a2ebb0fd..ff4c57d5 100644 --- a/src/core/model/pod.rs +++ b/src/core/model/pod.rs @@ -48,3 +48,216 @@ where }, ) } + +#[cfg(test)] +mod tests { + #![expect(clippy::unwrap_used, reason = "OK in tests.")] + use indoc::indoc; + use std::sync::{Arc, LazyLock}; + use std::{collections::HashMap, path::PathBuf}; + + use crate::core::model::ToYaml as _; + use crate::uniffi::model::packet::{Blob, BlobKind, PathSet, URI}; + use crate::uniffi::model::pod::PodResult; + use crate::uniffi::orchestrator::PodStatus; + use crate::uniffi::{ + error::Result, + model::{ + Annotation, + packet::PathInfo, + pod::{Pod, PodJob, RecommendSpecs}, + }, + }; + + use pretty_assertions::assert_eq; + + static TEST_FILE_NAMESPACE_LOOKUP: LazyLock> = LazyLock::new(|| { + HashMap::from([ + ("input".into(), PathBuf::from("tests/extra/data/input_txt")), + ("output".into(), PathBuf::from("tests/extra/data/output")), + ]) + }); + + fn basic_pod() -> Result { + Pod::new( + Some(Annotation { + name: "test".into(), + version: "0.1".into(), + description: "Basic pod for testing hashing and yaml serialization".into(), + }), + "alpine:3.14".into(), + vec!["cp", "/input/input.txt", "/output/output.txt"] + .into_iter() + .map(String::from) + .collect(), + HashMap::from([( + "input_txt".into(), + PathInfo { + path: "/input/input.txt".into(), + match_pattern: r".*\.txt".into(), + }, + )]), + "/output".into(), + HashMap::from([( + "output_txt".into(), + PathInfo { + path: "output.txt".into(), + match_pattern: r".*\.txt".into(), + }, + )]), + RecommendSpecs { + cpus: 0.20, + memory: 128 << 20, + }, + None, + ) + } + + fn basic_pod_job() -> Result { + let pod = Arc::new(basic_pod()?); + PodJob::new( + Some(Annotation { + name: "test_job".into(), + version: "0.1".into(), + description: "Basic pod job for testing hashing and yaml serialization".into(), + }), + Arc::clone(&pod), + HashMap::from([( + "input_txt".into(), + PathSet::Unary(Blob::new( + BlobKind::File, + URI { + namespace: "input".into(), + path: "cat.txt".into(), + }, + )), + )]), + URI { + namespace: "output".into(), + path: "".into(), + }, + pod.recommend_specs.cpus, + pod.recommend_specs.memory, + Some(HashMap::from([("FAKE_ENV".into(), "FakeValue".into())])), + &TEST_FILE_NAMESPACE_LOOKUP, + ) + } + + fn basic_pod_result() -> Result { + PodResult::new( + Some(Annotation { + name: "test".into(), + version: "0.1".into(), + description: "Basic Result for testing hashing and yaml serialization".into(), + }), + basic_pod_job()?.into(), + "randomly_assigned_name".into(), + PodStatus::Completed, + 1_737_922_307, + 1_737_925_907, + &TEST_FILE_NAMESPACE_LOOKUP, + ) + } + + #[test] + fn pod_hash() { + assert_eq!( + basic_pod().unwrap().hash, + "b5574e2efdf26361e8e8e886389a250cfbfcceed08b29325a78fd738cbb2a1b8", + "Hash didn't match." + ); + } + + #[test] + fn pod_to_yaml() { + assert_eq!( + basic_pod().unwrap().to_yaml().unwrap(), + indoc! {r" + class: pod + image: alpine:3.14 + command: + - cp + - /input/input.txt + - /output/output.txt + input_spec: + input_txt: + path: /input/input.txt + match_pattern: .*\.txt + output_dir: /output + output_spec: + output_txt: + path: output.txt + match_pattern: .*\.txt + gpu_requirements: null + "}, + "YAML serialization didn't match." + ); + } + + #[test] + fn pod_job_hash() { + assert_eq!( + basic_pod_job().unwrap().hash, + "80348a4ef866a9dfc1a5d0a48467a6592ef2ed9e8de67930d64afefbb395f1c6", + "Hash didn't match." + ); + } + + #[test] + fn pod_job_to_yaml() { + assert_eq!( + basic_pod_job().unwrap().to_yaml().unwrap(), + indoc! {" + class: pod_job + pod: b5574e2efdf26361e8e8e886389a250cfbfcceed08b29325a78fd738cbb2a1b8 + input_packet: + input_txt: + kind: File + location: + namespace: input + path: cat.txt + checksum: 175cc6f362b2f75acd08a373e000144fdb8d14a833d4b70fd743f16a7039103f + output_dir: + namespace: output + path: '' + cpu_limit: 0.2 + memory_limit: 134217728 + env_vars: + FAKE_ENV: FakeValue + "}, + "YAML serialization didn't match." + ); + } + + #[test] + fn pod_result_hash() { + assert_eq!( + basic_pod_result().unwrap().hash, + "42aef3e7671bd8762e36978d0e7ac79e2f416dbacb153b22b8c509fa5d8aa2ed", + "Hash didn't match." + ); + } + + #[test] + fn pod_result_to_yaml() { + assert_eq!( + basic_pod_result().unwrap().to_yaml().unwrap(), + indoc! {" + class: pod_result + pod_job: 80348a4ef866a9dfc1a5d0a48467a6592ef2ed9e8de67930d64afefbb395f1c6 + output_packet: + output_txt: + kind: File + location: + namespace: output + path: output.txt + checksum: 175cc6f362b2f75acd08a373e000144fdb8d14a833d4b70fd743f16a7039103f + assigned_name: randomly_assigned_name + status: Completed + created: 1737922307 + terminated: 1737925907 + "}, + "YAML serialization didn't match." + ); + } +} diff --git a/src/core/pipeline_runner.rs b/src/core/pipeline_runner.rs index a8c75a88..955b3108 100644 --- a/src/core/pipeline_runner.rs +++ b/src/core/pipeline_runner.rs @@ -425,7 +425,7 @@ impl DockerPipelineRunner { let parent_nodes = pipeline_run .pipeline_job .pipeline - .get_node_parents(&node) + .get_node_parents(&node)? .collect::>(); // Create the correct processor for the node based on the kernel type diff --git a/src/core/store/filestore.rs b/src/core/store/filestore.rs index 3329efcb..684ab2db 100644 --- a/src/core/store/filestore.rs +++ b/src/core/store/filestore.rs @@ -194,7 +194,7 @@ impl LocalFileStore { Ok(( serde_yaml::from_str( &fs::read_to_string(path.clone()) - .context(selector::InvalidFilepath { path })?, + .context(selector::InvalidPath { path })?, )?, None, hash.to_owned(), diff --git a/src/uniffi/error.rs b/src/uniffi/error.rs index df9358b1..b9cb584a 100644 --- a/src/uniffi/error.rs +++ b/src/uniffi/error.rs @@ -11,6 +11,7 @@ use serde_yaml; use snafu::prelude::Snafu; use std::{ backtrace::Backtrace, + collections::HashSet, error::Error, io, path::{self, PathBuf}, @@ -53,7 +54,7 @@ pub(crate) enum Kind { backtrace: Option, }, #[snafu(display("{source} ({path:?})."))] - InvalidFilepath { + InvalidPath { path: PathBuf, source: io::Error, backtrace: Option, @@ -73,6 +74,12 @@ pub(crate) enum Kind { details: String, backtrace: Option, }, + #[snafu(display("Node '{node_name}' is missing required keys: {missing_keys:?}."))] + PipelineValidationErrorMissingKeys { + node_name: String, + missing_keys: HashSet, + backtrace: Option, + }, #[snafu(display("Pod job submission failed with reason: {reason}."))] PodJobSubmissionFailed { reason: String, diff --git a/src/uniffi/model/packet.rs b/src/uniffi/model/packet.rs index df9f4a91..3a606c5a 100644 --- a/src/uniffi/model/packet.rs +++ b/src/uniffi/model/packet.rs @@ -48,6 +48,19 @@ pub struct Blob { pub checksum: String, } +#[uniffi::export] +impl Blob { + #[uniffi::constructor] + /// Create a new `Blob` + pub const fn new(kind: BlobKind, location: URI) -> Self { + Self { + kind, + location, + checksum: String::new(), + } + } +} + /// A single BLOB or a collection of BLOBs. #[derive(uniffi::Enum, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] #[serde(untagged)] diff --git a/src/uniffi/model/pipeline.rs b/src/uniffi/model/pipeline.rs index b3e1ca5c..b6f4646d 100644 --- a/src/uniffi/model/pipeline.rs +++ b/src/uniffi/model/pipeline.rs @@ -55,11 +55,16 @@ impl Pipeline { ) -> Result { let graph = make_graph(graph_dot, metadata)?; - Ok(Self { + // Run verifications and preprocessing steps + let pipeline = Self { graph, input_spec, output_spec, - }) + }; + + pipeline.validate()?; + + Ok(pipeline) } } diff --git a/src/uniffi/model/pod.rs b/src/uniffi/model/pod.rs index 539009ce..2d0934b8 100644 --- a/src/uniffi/model/pod.rs +++ b/src/uniffi/model/pod.rs @@ -10,7 +10,7 @@ use crate::{ validation::validate_packet, }, uniffi::{ - error::{OrcaError, Result}, + error::{Kind, OrcaError, Result}, model::{ Annotation, packet::{Blob, BlobKind, Packet, PathInfo, PathSet, URI}, @@ -21,7 +21,7 @@ use crate::{ use derive_more::Display; use getset::CloneGetters; use serde::{Deserialize, Serialize}; -use std::{collections::HashMap, path::PathBuf, sync::Arc}; +use std::{backtrace::Backtrace, collections::HashMap, path::PathBuf, sync::Arc}; use uniffi; /// A reusable, containerized computational unit. @@ -301,7 +301,13 @@ impl PodResult { match local_location.try_exists() { Ok(false) => None, - Err(error) => Some(Err(OrcaError::from(error))), + Err(error) => Some(Err(OrcaError { + kind: Kind::InvalidPath { + path: local_location.clone(), + source: error, + backtrace: Some(Backtrace::capture()), + }, + })), Ok(true) => Some(Ok(( packet_key, Blob { diff --git a/src/uniffi/operator.rs b/src/uniffi/operator.rs index 15320dc1..007209b7 100644 --- a/src/uniffi/operator.rs +++ b/src/uniffi/operator.rs @@ -2,7 +2,8 @@ use std::collections::HashMap; use serde::{Deserialize, Serialize}; -use crate::core::{crypto::hash_buffer, model::ToYaml as _, model::serialize_hashmap}; +use crate::core::model::ToYaml as _; +use crate::core::{crypto::hash_buffer, model::serialize_hashmap}; use crate::uniffi::error::Result; /// Operator class that map `input_keys` to `output_key`, effectively renaming it diff --git a/src/uniffi/orchestrator/docker.rs b/src/uniffi/orchestrator/docker.rs index a08137b3..70bd79cc 100644 --- a/src/uniffi/orchestrator/docker.rs +++ b/src/uniffi/orchestrator/docker.rs @@ -94,7 +94,7 @@ impl Orchestrator for LocalDockerOrchestrator { let location = namespace_lookup[&image_info.namespace].join(&image_info.path); let byte_stream = FramedRead::new( File::open(&location) - .context(selector::InvalidFilepath { path: &location }) + .context(selector::InvalidPath { path: &location }) .await?, BytesCodec::new(), ) diff --git a/tests/extra/data/output/output.txt b/tests/extra/data/output/output.txt new file mode 100644 index 00000000..ef07ddcd --- /dev/null +++ b/tests/extra/data/output/output.txt @@ -0,0 +1 @@ +cat diff --git a/tests/model.rs b/tests/model.rs deleted file mode 100644 index fb48fc47..00000000 --- a/tests/model.rs +++ /dev/null @@ -1,138 +0,0 @@ -#![expect(missing_docs, reason = "OK in tests.")] - -pub mod fixture; -use fixture::{NAMESPACE_LOOKUP_READ_ONLY, pod_job_style, pod_result_style, pod_style}; -use indoc::indoc; -use orcapod::{core::model::ToYaml as _, uniffi::error::Result}; -use pretty_assertions::assert_eq as pretty_assert_eq; - -#[test] -fn hash_pod() -> Result<()> { - pretty_assert_eq!( - pod_style()?.hash, - "2104a9b471bec3a54f1f5437d887e16626bed3e818241410ce1ec7a63f0361fb", - "Hash didn't match." - ); - Ok(()) -} - -#[test] -fn pod_to_yaml() -> Result<()> { - pretty_assert_eq!( - pod_style()?.to_yaml()?, - indoc! {r" - class: pod - image: example.server.com/user/style-transfer:1.0.0 - command: - - python - - /run.py - input_spec: - base-input: - path: /input - match_pattern: input/.* - extra-style: - path: /extra_styles/style2.t7 - match_pattern: .*\.t7 - output_dir: /output - output_spec: - result1: - path: result1.jpeg - match_pattern: .*\.jpeg - result2: - path: result2.jpeg - match_pattern: .*\.jpeg - gpu_requirements: null - "}, - "YAML serialization didn't match." - ); - Ok(()) -} - -#[test] -fn hash_pod_job() -> Result<()> { - pretty_assert_eq!( - pod_job_style(&NAMESPACE_LOOKUP_READ_ONLY)?.hash, - "009588059284ab7e62c6af040eca44dbd8b32964ebb1406a34b174dafcf4520d", - "Hash didn't match." - ); - Ok(()) -} - -#[test] -fn pod_job_to_yaml() -> Result<()> { - pretty_assert_eq!( - pod_job_style(&NAMESPACE_LOOKUP_READ_ONLY)?.to_yaml()?, - indoc! {" - class: pod_job - pod: 2104a9b471bec3a54f1f5437d887e16626bed3e818241410ce1ec7a63f0361fb - input_packet: - base-input: - - kind: File - location: - namespace: default - path: styles/style1.t7 - checksum: 69e709c1697e290994d2da75ddfb2097bf801a9436a3727a282e0230e703da2b - - kind: File - location: - namespace: default - path: images/subject.jpeg - checksum: 8b44b8ea83b1f5eec3ac16cf941767e629896c465803fb69c21adbbf984516bd - extra-style: - kind: File - location: - namespace: default - path: styles/mosaic.t7 - checksum: fbd7d882e9e02aafb57366e726762025ff6b2e12cd41abd44b874542b7693771 - output_dir: - namespace: default - path: output - cpu_limit: 0.5 - memory_limit: 2147483648 - env_vars: - AAA: SORT - ZZZ: PLEASE - "}, - "YAML serialization didn't match." - ); - Ok(()) -} - -#[test] -fn hash_pod_result() -> Result<()> { - pretty_assert_eq!( - pod_result_style(&NAMESPACE_LOOKUP_READ_ONLY)?.hash, - "56d750bae1e9f70be2bca38c121cdbb7bd2e67a3c40d28df9bc5b5133eaedf70", - "Hash didn't match." - ); - Ok(()) -} - -#[test] -fn pod_result_to_yaml() -> Result<()> { - pretty_assert_eq!( - pod_result_style(&NAMESPACE_LOOKUP_READ_ONLY)?.to_yaml()?, - indoc! {" - class: pod_result - pod_job: 009588059284ab7e62c6af040eca44dbd8b32964ebb1406a34b174dafcf4520d - output_packet: - result1: - kind: File - location: - namespace: default - path: output/result1.jpeg - checksum: 5898ca5bed67147680c6489056cbf2e90074bc51d8ca2645453742580ce74b7a - result2: - kind: File - location: - namespace: default - path: output/result2.jpeg - checksum: a1458fc7d7d9d23a66feae88b5a89f1756055bdbb6be02fdf672f7d31ed92735 - assigned_name: simple-endeavour - status: Completed - created: 1737922307 - terminated: 1737925907 - "}, - "YAML serialization didn't match." - ); - Ok(()) -} diff --git a/tests/pipeline.rs b/tests/pipeline.rs index 56e10870..5940fde5 100644 --- a/tests/pipeline.rs +++ b/tests/pipeline.rs @@ -83,5 +83,8 @@ fn input_packet_checksum() -> Result<()> { "8b44b8ea83b1f5eec3ac16cf941767e629896c465803fb69c21adbbf984516bd".to_owned(), "Incorrect checksum" ); + + // Print out pipeline job for visual inspection + println!("Pipeline Job: {:#?}", pipeline_job.pipeline.graph); Ok(()) } From 0436c3869d78bcb7179c8f266e1067a333da0fa1 Mon Sep 17 00:00:00 2001 From: synicix Date: Tue, 23 Sep 2025 05:24:27 +0000 Subject: [PATCH 52/65] Fix incorrect validation --- src/core/model/pipeline.rs | 41 +++++++++++++++++++++++++++----------- 1 file changed, 29 insertions(+), 12 deletions(-) diff --git a/src/core/model/pipeline.rs b/src/core/model/pipeline.rs index 2150fccb..756bcb76 100644 --- a/src/core/model/pipeline.rs +++ b/src/core/model/pipeline.rs @@ -34,36 +34,43 @@ pub struct PipelineNode { impl Pipeline { pub(crate) fn validate(&self) -> Result<()> { - // For verification we check that each node has it's input_spec covered by either it's parent or input_spec - // Build a map from input_spec where HashMap, - let mut input_nodes_key_lut = HashMap::<&String, HashSet<&String>>::new(); - for (input_key, node_uris) in &self.input_spec { + // For verification we check that each node has it's input_spec covered by either it's parent or input_spec of the pipeline + // Build a map from input_spec where HashMap, + let mut keys_covered_by_input_spec_lut: HashMap<&String, HashSet<&String>> = + HashMap::<&String, HashSet<&String>>::new(); + for node_uris in self.input_spec.values() { for node_uri in node_uris { - input_nodes_key_lut + keys_covered_by_input_spec_lut .entry(&node_uri.node_id) .or_default() - .insert(input_key); + .insert(&node_uri.key); } } + println!( + "keys_covered_by_input_spec_lut: {:#?}", + keys_covered_by_input_spec_lut + ); + // Iterate over each node in the graph and verify that its input spec is met for node_idx in self.graph.node_indices() { self.validate_valid_input_spec( node_idx, - get(&input_nodes_key_lut, &self.graph[node_idx].label)?, + keys_covered_by_input_spec_lut.get(&self.graph[node_idx].label), )?; } Ok(()) } + /// Validates that the input spec for a given node is valid based on its parents and the input spec of the pipeline fn validate_valid_input_spec( &self, node_idx: NodeIndex, - input_keys_for_node: &HashSet<&String>, + keys_covered_by_input_spec: Option<&HashSet<&String>>, ) -> Result<()> { // We need to get the input spec of the current node and build the packet based on the - // parent nodes to verify that the input_spec if met + // parent nodes output spec + input // Get the parent nodes input specs and combine them into let incoming_packet_keys = self @@ -71,13 +78,23 @@ impl Pipeline { .flat_map(|parent_idx| self.get_output_spec_for_node(parent_idx)) .collect::>(); + println!( + "Validating node: {}, incoming_packet_keys: {:#?}, keys_covered_by_input_spec: {:#?}", + self.graph[node_idx].label, incoming_packet_keys, keys_covered_by_input_spec + ); + + println!( + "Node input_spec: {:#?}", + self.get_input_spec_for_node(node_idx) + ); + // Get this node input_spec let missing_keys: HashSet<&String> = self .get_input_spec_for_node(node_idx) .into_iter() .filter(|expected_key| { !(incoming_packet_keys.contains(expected_key) - || input_keys_for_node.contains(expected_key)) + || keys_covered_by_input_spec.map_or(false, |keys| keys.contains(expected_key))) }) .collect(); @@ -100,7 +117,7 @@ impl Pipeline { Kernel::JoinOperator => { // JoinOperator input_spec is derived from its parents self.get_parent_node_indices(node_idx) - .flat_map(|parent_idx| self.get_input_spec_for_node(parent_idx)) + .flat_map(|parent_idx| self.get_output_spec_for_node(parent_idx)) .collect() } Kernel::MapOperator { mapper } => mapper.map.keys().collect(), @@ -113,7 +130,7 @@ impl Pipeline { Kernel::JoinOperator => { // JoinOperator output_spec is derived from its parents self.get_parent_node_indices(node_idx) - .flat_map(|parent_idx| self.get_input_spec_for_node(parent_idx)) + .flat_map(|parent_idx| self.get_output_spec_for_node(parent_idx)) .collect() } Kernel::MapOperator { mapper } => mapper.map.values().collect(), From c2fe9141a75f30a635e41f37f568cb2daf83c644 Mon Sep 17 00:00:00 2001 From: synicix Date: Wed, 24 Sep 2025 12:42:59 +0000 Subject: [PATCH 53/65] Add hashing to pipeline node --- src/core/graph.rs | 5 +-- src/core/model/pipeline.rs | 49 +++++++---------------- src/core/pipeline_runner.rs | 1 - src/uniffi/model/pipeline.rs | 18 +++++++-- tests/fixture/mod.rs | 2 +- tests/pipeline.rs | 77 ++++++++++++++++++++++++++++++++++-- 6 files changed, 106 insertions(+), 46 deletions(-) diff --git a/src/core/graph.rs b/src/core/graph.rs index 729b185e..a31e6d66 100644 --- a/src/core/graph.rs +++ b/src/core/graph.rs @@ -10,7 +10,6 @@ use petgraph::{ use std::collections::HashMap; #[expect( - clippy::needless_pass_by_value, clippy::panic_in_result_fn, clippy::panic, reason = " @@ -20,13 +19,13 @@ use std::collections::HashMap; )] pub fn make_graph( input_dot: &str, - metadata: HashMap, + metadata: &HashMap, ) -> Result> { let graph = DiGraph::::from_dot_graph(DOTGraph::try_from(input_dot)?).map( |node_idx, node| PipelineNode { hash: String::new(), - kernel: get(&metadata, &node.id) + kernel: get(metadata, &node.id) .unwrap_or_else(|error| panic!("{error}")) .clone(), label: node.id.clone(), diff --git a/src/core/model/pipeline.rs b/src/core/model/pipeline.rs index 756bcb76..612be2cf 100644 --- a/src/core/model/pipeline.rs +++ b/src/core/model/pipeline.rs @@ -4,20 +4,17 @@ use std::{ }; use crate::{ - core::{crypto::hash_buffer, util::get}, + core::crypto::hash_buffer, uniffi::{ error::{Kind, OrcaError, Result}, model::{ packet::PathSet, - pipeline::{Kernel, NodeURI, Pipeline, PipelineJob}, + pipeline::{Kernel, Pipeline, PipelineJob}, }, }, }; use itertools::Itertools as _; -use petgraph::{ - Direction::Incoming, - graph::{DiGraph, NodeIndex}, -}; +use petgraph::{Direction::Incoming, graph::NodeIndex}; use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] @@ -47,11 +44,6 @@ impl Pipeline { } } - println!( - "keys_covered_by_input_spec_lut: {:#?}", - keys_covered_by_input_spec_lut - ); - // Iterate over each node in the graph and verify that its input spec is met for node_idx in self.graph.node_indices() { self.validate_valid_input_spec( @@ -78,23 +70,13 @@ impl Pipeline { .flat_map(|parent_idx| self.get_output_spec_for_node(parent_idx)) .collect::>(); - println!( - "Validating node: {}, incoming_packet_keys: {:#?}, keys_covered_by_input_spec: {:#?}", - self.graph[node_idx].label, incoming_packet_keys, keys_covered_by_input_spec - ); - - println!( - "Node input_spec: {:#?}", - self.get_input_spec_for_node(node_idx) - ); - // Get this node input_spec let missing_keys: HashSet<&String> = self .get_input_spec_for_node(node_idx) .into_iter() .filter(|expected_key| { !(incoming_packet_keys.contains(expected_key) - || keys_covered_by_input_spec.map_or(false, |keys| keys.contains(expected_key))) + || keys_covered_by_input_spec.is_some_and(|keys| keys.contains(expected_key))) }) .collect(); @@ -192,21 +174,18 @@ impl Pipeline { } /// Compute the hash for each node in the graph which is defined as the hash of its kernel + the hashes of its parents - pub(crate) fn compute_hash_for_node( - node_idx: NodeIndex, - input_spec: &HashMap>, - graph: &mut DiGraph, - ) { + pub(crate) fn compute_hash_for_node_and_parents(&mut self, node_idx: NodeIndex) { // Collect parent indices first to avoid borrowing issues - let parent_indices: Vec = graph.neighbors_directed(node_idx, Incoming).collect(); + let parent_indices: Vec = + self.graph.neighbors_directed(node_idx, Incoming).collect(); // Sort the parent hashes to ensure consistent ordering let mut parent_hashes: Vec = if parent_indices.is_empty() { // This is parent node, thus we will need to use the input_spec to generate a unique hash for the node // Find all the input keys that map to this node - let input_keys = input_spec.iter().filter_map(|(input_key, node_uris)| { + let input_keys = self.input_spec.iter().filter_map(|(input_key, node_uris)| { node_uris.iter().find_map(|node_uri| { - (node_uri.node_id == graph[node_idx].label).then(|| input_key.clone()) + (node_uri.node_id == self.graph[node_idx].label).then(|| input_key.clone()) }) }); @@ -216,11 +195,11 @@ impl Pipeline { .into_iter() .map(|parent_idx| { // Check if hash has been computed for this node, if not trigger computation - if graph[parent_idx].hash.is_empty() { + if self.graph[parent_idx].hash.is_empty() { // Recursive call to compute the parent's hash - Self::compute_hash_for_node(parent_idx, input_spec, graph); + self.compute_hash_for_node_and_parents(parent_idx); } - graph[parent_idx].hash.clone() + self.graph[parent_idx].hash.clone() }) .collect() }; @@ -232,10 +211,10 @@ impl Pipeline { } else { let hash_for_node = format!( "{}{}", - &graph[node_idx].kernel.get_hash(), + &self.graph[node_idx].kernel.get_hash(), parent_hashes.into_iter().join("") ); - graph[node_idx].hash = hash_buffer(hash_for_node.as_bytes()); + self.graph[node_idx].hash = hash_buffer(hash_for_node.as_bytes()); } } } diff --git a/src/core/pipeline_runner.rs b/src/core/pipeline_runner.rs index 955b3108..baeaceb6 100644 --- a/src/core/pipeline_runner.rs +++ b/src/core/pipeline_runner.rs @@ -259,7 +259,6 @@ impl DockerPipelineRunner { } // Wait for all nodes to be ready before sending inputs - let num_of_nodes = graph.node_count(); let mut ready_nodes = 0; diff --git a/src/uniffi/model/pipeline.rs b/src/uniffi/model/pipeline.rs index b6f4646d..1da18249 100644 --- a/src/uniffi/model/pipeline.rs +++ b/src/uniffi/model/pipeline.rs @@ -49,21 +49,27 @@ impl Pipeline { #[uniffi::constructor] pub fn new( graph_dot: &str, - metadata: HashMap, + metadata: &HashMap, input_spec: HashMap>, output_spec: HashMap, ) -> Result { + // Note this gives us the graph, but the nodes do not have their hashes computed yet. let graph = make_graph(graph_dot, metadata)?; - // Run verifications and preprocessing steps - let pipeline = Self { + let mut pipeline = Self { graph, input_spec, output_spec, }; + // Run verification on the pipeline first before computing hash pipeline.validate()?; + // Verification passed, thus we can now compute the hash for each node + for node_idx in pipeline.graph.node_indices() { + pipeline.compute_hash_for_node_and_parents(node_idx); + } + Ok(pipeline) } } @@ -197,6 +203,12 @@ impl From for Kernel { } } +impl From> for Kernel { + fn from(pod: Arc) -> Self { + Self::Pod { pod } + } +} + impl Kernel { /// Get a unique hash that represents the kernel. /// The exception here is the `JoinOperator` doesn't have any pre execution configuration, since it's logic is completely dependent on what is fed to it during execution. diff --git a/tests/fixture/mod.rs b/tests/fixture/mod.rs index 7c0e7ce4..9332b836 100644 --- a/tests/fixture/mod.rs +++ b/tests/fixture/mod.rs @@ -399,7 +399,7 @@ pub fn pipeline() -> Result { Pipeline::new( dot, - kernel_map, + &kernel_map, HashMap::from([ ( "where".into(), diff --git a/tests/pipeline.rs b/tests/pipeline.rs index 5940fde5..a50d2802 100644 --- a/tests/pipeline.rs +++ b/tests/pipeline.rs @@ -16,8 +16,81 @@ use orcapod::uniffi::{ pipeline::{Kernel, NodeURI, Pipeline, PipelineJob}, }, }; +use pretty_assertions::assert_eq; use std::collections::HashMap; +use crate::fixture::pipeline; + +#[test] +fn node_hashing() -> Result<()> { + let pipeline = pipeline()?; + + // Assert that every node has a non-empty hash + let node_hashes = pipeline + .graph + .node_indices() + .map(|idx| { + ( + pipeline.graph[idx].label.as_str(), + pipeline.graph[idx].hash.as_str(), + ) + }) + .collect::>(); + + assert_eq!( + node_hashes, + HashMap::from([ + ( + "pod_c_joiner", + "d2141ce0c203a8b556d7dbbbc6268ac4bbfa444748f92baff42235787f2b7550" + ), + ( + "B", + "964ebb9ddd6bb7db56e53c19e9ac34dfd08779a656295b01e70b5973adc61103" + ), + ( + "C", + "96b30227e0243f282f7a898bd85a246127e664635a3969577932d7653cfb79cb" + ), + ( + "pod_a_mapper", + "83bd3d17026c882db6b6cca7ccca0173f478c11449cfa8bfb13a0518a7e5e32a" + ), + ( + "pod_b_mapper", + "dd73cd3ab345917b25fc028131d83da7ce1c53702fcbabdd19b86a8bdde158b3" + ), + ( + "pod_d_mapper", + "d37f595093e8f7235f97213b3f7ff88b12786e48ec4f22275018cc7d22c113f8" + ), + ( + "A", + "8e43dbc9fd55fa7d1a36fc4a6c036f4113b7aa7fcf38646a2f2472bac6774962" + ), + ( + "E", + "6ec68cc43ea15472731a318584cc8792fb2ff93c96fed6f3f998849b75976694" + ), + ( + "D", + "04cb341a09eeb771846377405a5f33d011f99a7dfa4739fd7876a7e70c994e4e" + ), + ( + "pod_c_mapper", + "240c8e7fa5e0bd88239aba625387ea495fc5323a5d4b6b519946b8f8b907ddf6" + ), + ( + "pod_e_joiner", + "36f3e88889ecf89183205f340043de61f3c6a254026aae5aa1ce587a666e8c30" + ), + ]), + "Node hashes did not match" + ); + + Ok(()) +} + #[test] fn input_packet_checksum() -> Result<()> { let pipeline = Pipeline::new( @@ -26,7 +99,7 @@ fn input_packet_checksum() -> Result<()> { A } "}, - HashMap::from([( + &HashMap::from([( "A".into(), Kernel::Pod { pod: pod_custom( @@ -84,7 +157,5 @@ fn input_packet_checksum() -> Result<()> { "Incorrect checksum" ); - // Print out pipeline job for visual inspection - println!("Pipeline Job: {:#?}", pipeline_job.pipeline.graph); Ok(()) } From cc3e4f1f5e272883bf169e6535e05f37775bf890 Mon Sep 17 00:00:00 2001 From: synicix Date: Thu, 25 Sep 2025 11:56:13 +0000 Subject: [PATCH 54/65] Add test and fix bugs for pipeline validation --- src/core/error.rs | 3 + src/core/model/pipeline.rs | 65 ++++++++--- src/core/pipeline_runner.rs | 42 ++++---- src/uniffi/error.rs | 24 +++++ src/uniffi/model/pipeline.rs | 56 ++++++++-- tests/pipeline.rs | 204 ++++++++++++++++++++++++++++++++++- tests/pipeline_runner.rs | 5 +- 7 files changed, 350 insertions(+), 49 deletions(-) diff --git a/src/core/error.rs b/src/core/error.rs index 88399184..650b9477 100644 --- a/src/core/error.rs +++ b/src/core/error.rs @@ -125,6 +125,9 @@ impl fmt::Debug for OrcaError { | Kind::IncompletePacket { backtrace, .. } | Kind::InvalidPath { backtrace, .. } | Kind::InvalidIndex { backtrace, .. } + | Kind::InvalidInputSpecNodeNotInGraph { backtrace, .. } + | Kind::InvalidOutputSpecKeyNotInNode { backtrace, .. } + | Kind::InvalidOutputSpecNodeNotInGraph { backtrace, .. } | Kind::KeyMissing { backtrace, .. } | Kind::MissingInfo { backtrace, .. } | Kind::FailedToGetPodJobOutput { backtrace, .. } diff --git a/src/core/model/pipeline.rs b/src/core/model/pipeline.rs index 612be2cf..5aa6ace5 100644 --- a/src/core/model/pipeline.rs +++ b/src/core/model/pipeline.rs @@ -6,16 +6,20 @@ use std::{ use crate::{ core::crypto::hash_buffer, uniffi::{ - error::{Kind, OrcaError, Result}, + error::{Kind, OrcaError, Result, selector}, model::{ packet::PathSet, - pipeline::{Kernel, Pipeline, PipelineJob}, + pipeline::{Kernel, NodeURI, Pipeline, PipelineJob}, }, }, }; use itertools::Itertools as _; -use petgraph::{Direction::Incoming, graph::NodeIndex}; +use petgraph::{ + Direction::Incoming, + graph::{self, NodeIndex}, +}; use serde::{Deserialize, Serialize}; +use snafu::OptionExt as _; #[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] pub struct PipelineNode { @@ -30,6 +34,8 @@ pub struct PipelineNode { } impl Pipeline { + /// Validate the pipeline to ensure that, based on user labels: + /// 1. Each node's `input_spec` is covered by either its parent nodes or the pipeline's `input_spec` pub(crate) fn validate(&self) -> Result<()> { // For verification we check that each node has it's input_spec covered by either it's parent or input_spec of the pipeline // Build a map from input_spec where HashMap, @@ -48,10 +54,38 @@ impl Pipeline { for node_idx in self.graph.node_indices() { self.validate_valid_input_spec( node_idx, - keys_covered_by_input_spec_lut.get(&self.graph[node_idx].label), + keys_covered_by_input_spec_lut.get(&self.graph[node_idx].hash), )?; } + // Build a LUT for all node_hash to idx + let node_hash_to_idx_lut: HashMap<&String, NodeIndex> = self + .graph + .node_indices() + .map(|idx| (&self.graph[idx].hash, idx)) + .collect(); + + // Validate that all output_keys are valid + self.output_spec.iter().try_for_each(|(_, node_uri)| { + if !self + .get_output_spec_for_node(*node_hash_to_idx_lut.get(&node_uri.node_id).context( + selector::InvalidOutputSpecNodeNotInGraph { + node_name: node_uri.node_id.clone(), + }, + )?) + .contains(&node_uri.key) + { + return Err(OrcaError { + kind: Kind::InvalidOutputSpecKeyNotInNode { + node_name: node_uri.node_id.clone(), + key: node_uri.key.clone(), + backtrace: Some(Backtrace::capture()), + }, + }); + } + Ok(()) + })?; + Ok(()) } @@ -174,18 +208,21 @@ impl Pipeline { } /// Compute the hash for each node in the graph which is defined as the hash of its kernel + the hashes of its parents - pub(crate) fn compute_hash_for_node_and_parents(&mut self, node_idx: NodeIndex) { + pub(crate) fn compute_hash_for_node_and_parents( + node_idx: NodeIndex, + input_spec: &HashMap>, + graph: &mut graph::Graph, + ) { // Collect parent indices first to avoid borrowing issues - let parent_indices: Vec = - self.graph.neighbors_directed(node_idx, Incoming).collect(); + let parent_indices: Vec = graph.neighbors_directed(node_idx, Incoming).collect(); // Sort the parent hashes to ensure consistent ordering let mut parent_hashes: Vec = if parent_indices.is_empty() { // This is parent node, thus we will need to use the input_spec to generate a unique hash for the node // Find all the input keys that map to this node - let input_keys = self.input_spec.iter().filter_map(|(input_key, node_uris)| { + let input_keys = input_spec.iter().filter_map(|(input_key, node_uris)| { node_uris.iter().find_map(|node_uri| { - (node_uri.node_id == self.graph[node_idx].label).then(|| input_key.clone()) + (node_uri.node_id == graph[node_idx].label).then(|| input_key.clone()) }) }); @@ -195,11 +232,11 @@ impl Pipeline { .into_iter() .map(|parent_idx| { // Check if hash has been computed for this node, if not trigger computation - if self.graph[parent_idx].hash.is_empty() { + if graph[parent_idx].hash.is_empty() { // Recursive call to compute the parent's hash - self.compute_hash_for_node_and_parents(parent_idx); + Self::compute_hash_for_node_and_parents(parent_idx, input_spec, graph); } - self.graph[parent_idx].hash.clone() + graph[parent_idx].hash.clone() }) .collect() }; @@ -211,10 +248,10 @@ impl Pipeline { } else { let hash_for_node = format!( "{}{}", - &self.graph[node_idx].kernel.get_hash(), + &graph[node_idx].kernel.get_hash(), parent_hashes.into_iter().join("") ); - self.graph[node_idx].hash = hash_buffer(hash_for_node.as_bytes()); + graph[node_idx].hash = hash_buffer(hash_for_node.as_bytes()); } } } diff --git a/src/core/pipeline_runner.rs b/src/core/pipeline_runner.rs index baeaceb6..a263e8eb 100644 --- a/src/core/pipeline_runner.rs +++ b/src/core/pipeline_runner.rs @@ -265,7 +265,6 @@ impl DockerPipelineRunner { while (subscriber.recv_async().await).is_ok() { // Message is empty, just increment the counter ready_nodes += 1; - if ready_nodes == num_of_nodes { break; // All nodes are ready, we can start sending inputs } @@ -355,8 +354,6 @@ impl DockerPipelineRunner { pipeline_run: Arc, node_id: String, ) -> Result<()> { - // Determine which keys we are interested in for the given node_id - // Create a zenoh session let subscriber = pipeline_run .session @@ -489,6 +486,10 @@ impl DockerPipelineRunner { .await .context(selector::AgentCommunicationFailure {})?; + println!( + "Waiting for all event handlers for node {} to be ready... with hash {}", + node.label, node.hash + ); while status_subscriber.recv_async().await.is_ok() { num_of_ready_event_handler += 1; if num_of_ready_event_handler == nodes_to_sub_to.len() { @@ -497,6 +498,7 @@ impl DockerPipelineRunner { } } + println!("Node {} is ready with hash {}", node.label, node.hash); // Send a ready message so the pipeline knows when to start sending inputs pipeline_run .session @@ -606,12 +608,12 @@ impl DockerPipelineRunner { /// As a result, each processor only needs to worry about writing their own function to process the msg #[async_trait] trait NodeProcessor: Send + Sync { - async fn process_incoming_packet(&mut self, sender_node_id: &str, incoming_packet: &Packet); + async fn process_incoming_packet(&mut self, sender_node_hash: &str, incoming_packet: &Packet); /// Notifies the processor that the parent node has completed processing /// If it is the last parent to complete, it will wait for all processing task to finish /// Then send a completion signal - async fn mark_parent_as_complete(&mut self, parent_node_id: &str); + async fn mark_parent_as_complete(&mut self, parent_node_hash: &str); fn stop(&mut self); } @@ -620,16 +622,16 @@ trait NodeProcessor: Send + Sync { /// Currently missing implementation to call agents for actual pod processing struct PodProcessor { pipeline_run: Arc, - node_id: String, + node_hash: String, pod: Arc, processing_tasks: JoinSet<()>, } impl PodProcessor { - fn new(pipeline_run: Arc, node_id: String, pod: Arc) -> Self { + fn new(pipeline_run: Arc, node_hash: String, pod: Arc) -> Self { Self { pipeline_run, - node_id, + node_hash, pod, processing_tasks: JoinSet::new(), } @@ -640,7 +642,7 @@ impl PodProcessor { /// Will handle the creation of the pod job, submission to the agent, listening for completion, and extracting the `output_packet` if successful async fn process_packet( pipeline_run: Arc, - node_id: String, + node_hash: String, pod: Arc, incoming_packet: HashMap, ) -> Result { @@ -660,7 +662,7 @@ impl PodProcessor { URI { namespace: pipeline_run.namespace.clone(), path: format!( - "pipeline_outputs/{}/{node_id}/{input_packet_hash}", + "pipeline_outputs/{}/{node_hash}/{input_packet_hash}", pipeline_run.assigned_name ) .into(), @@ -754,12 +756,12 @@ impl PodProcessor { impl NodeProcessor for PodProcessor { async fn process_incoming_packet( &mut self, - _sender_node_id: &str, + _sender_node_hash: &str, incoming_packet: &HashMap, ) { // Clone all necessary fields from self to move into the async block let pipeline_run = Arc::clone(&self.pipeline_run); - let node_id = self.node_id.clone(); + let node_hash = self.node_hash.clone(); let pod = Arc::clone(&self.pod); let incoming_packet_inner = incoming_packet.clone(); @@ -767,7 +769,7 @@ impl NodeProcessor for PodProcessor { self.processing_tasks.spawn(async move { let result = match Self::process_packet( Arc::clone(&pipeline_run), - node_id.clone(), + node_hash.clone(), Arc::clone(&pod), incoming_packet_inner.clone(), ) @@ -775,7 +777,7 @@ impl NodeProcessor for PodProcessor { { Ok(output_packet) => { match pipeline_run - .send_packets(&node_id, &vec![output_packet]) + .send_packets(&node_hash, &vec![output_packet]) .await { Ok(()) => Ok(()), @@ -790,7 +792,7 @@ impl NodeProcessor for PodProcessor { // Successfully processed the packet, nothing to do } Err(err) => { - pipeline_run.send_err_msg(&node_id, err).await; + pipeline_run.send_err_msg(&node_hash, err).await; } } }); @@ -802,12 +804,12 @@ impl NodeProcessor for PodProcessor { // Send out completion signal match self .pipeline_run - .send_packets(&self.node_id, &Vec::new()) + .send_packets(&self.node_hash, &Vec::new()) .await { Ok(()) => {} Err(err) => { - self.pipeline_run.send_err_msg(&self.node_id, err).await; + self.pipeline_run.send_err_msg(&self.node_hash, err).await; } } } @@ -850,7 +852,7 @@ impl OperatorProcessor { impl NodeProcessor for OperatorProcessor { async fn process_incoming_packet( &mut self, - sender_node_id: &str, + sender_node_hash: &str, incoming_packet: &HashMap, ) { // Clone all necessary fields from self to move into the async block @@ -858,7 +860,7 @@ impl NodeProcessor for OperatorProcessor let pipeline_run = Arc::clone(&self.pipeline_run); let node_id = self.node_id.clone(); - let sender_node_id_inner = sender_node_id.to_owned(); + let sender_node_id_inner = sender_node_hash.to_owned(); let incoming_packet_inner = incoming_packet.clone(); self.processing_tasks.spawn(async move { @@ -885,7 +887,7 @@ impl NodeProcessor for OperatorProcessor }); } - async fn mark_parent_as_complete(&mut self, _parent_node_id: &str) { + async fn mark_parent_as_complete(&mut self, _parent_node_hash: &str) { // Figure out if this is the last parent or not self.num_of_completed_parents += 1; diff --git a/src/uniffi/error.rs b/src/uniffi/error.rs index b9cb584a..bb800bb0 100644 --- a/src/uniffi/error.rs +++ b/src/uniffi/error.rs @@ -64,6 +64,7 @@ pub(crate) enum Kind { idx: usize, backtrace: Option, }, + #[snafu(display("Key '{key}' was not found in map."))] KeyMissing { key: String, @@ -74,12 +75,35 @@ pub(crate) enum Kind { details: String, backtrace: Option, }, + #[snafu(display( + "Node '{node_name}' was referenced in input_spec, but is not a node in the graph." + ))] + InvalidInputSpecNodeNotInGraph { + node_name: String, + backtrace: Option, + }, + #[snafu(display( + "Key '{key}' was referenced in input_spec for node '{node_name}', but is not a key in that node's input spec." + ))] + InvalidOutputSpecKeyNotInNode { + node_name: String, + key: String, + backtrace: Option, + }, + #[snafu(display( + "Node '{node_name}' was referenced in output_spec, but is not a node in the graph." + ))] + InvalidOutputSpecNodeNotInGraph { + node_name: String, + backtrace: Option, + }, #[snafu(display("Node '{node_name}' is missing required keys: {missing_keys:?}."))] PipelineValidationErrorMissingKeys { node_name: String, missing_keys: HashSet, backtrace: Option, }, + #[snafu(display("Pod job submission failed with reason: {reason}."))] PodJobSubmissionFailed { reason: String, diff --git a/src/uniffi/model/pipeline.rs b/src/uniffi/model/pipeline.rs index 1da18249..2ec23b07 100644 --- a/src/uniffi/model/pipeline.rs +++ b/src/uniffi/model/pipeline.rs @@ -6,7 +6,7 @@ use crate::{ validation::validate_packet, }, uniffi::{ - error::Result, + error::{OrcaError, Result, selector}, model::{ packet::{PathSet, URI}, pod::Pod, @@ -18,6 +18,7 @@ use derive_more::Display; use getset::CloneGetters; use petgraph::graph::DiGraph; use serde::{Deserialize, Serialize}; +use snafu::OptionExt as _; use std::sync::LazyLock; use std::{collections::HashMap, path::PathBuf, sync::Arc}; use uniffi; @@ -50,13 +51,53 @@ impl Pipeline { pub fn new( graph_dot: &str, metadata: &HashMap, - input_spec: HashMap>, - output_spec: HashMap, + mut input_spec: HashMap>, + mut output_spec: HashMap, ) -> Result { // Note this gives us the graph, but the nodes do not have their hashes computed yet. - let graph = make_graph(graph_dot, metadata)?; + let mut graph = make_graph(graph_dot, metadata)?; - let mut pipeline = Self { + // Run preprocessing to compute the hash for each node + for node_idx in graph.node_indices() { + Self::compute_hash_for_node_and_parents(node_idx, &input_spec, &mut graph); + } + + // Build LUT for node_label -> node_hash + let label_to_hash_lut = + graph + .node_indices() + .fold(HashMap::<&String, &String>::new(), |mut acc, node_idx| { + let node = &graph[node_idx]; + acc.insert(&node.label, &node.hash); + acc + }); + + // Build the new input_spec to refer to the hash instead of label + input_spec.iter_mut().try_for_each(|(_, node_uris)| { + node_uris.iter_mut().try_for_each(|node_uri| { + node_uri.node_id = (*label_to_hash_lut.get(&node_uri.node_id).context( + selector::InvalidInputSpecNodeNotInGraph { + node_name: node_uri.node_id.clone(), + }, + )?) + .clone(); + Ok::<(), OrcaError>(()) + }) + })?; + + // Update the output_spec to refer to the hash instead of label + output_spec.iter_mut().try_for_each(|(_, node_uri)| { + node_uri.node_id = (*label_to_hash_lut.get(&node_uri.node_id).context( + selector::InvalidOutputSpecNodeNotInGraph { + node_name: node_uri.node_id.clone(), + }, + )?) + .clone(); + + Ok::<(), OrcaError>(()) + })?; + + let pipeline = Self { graph, input_spec, output_spec, @@ -65,11 +106,6 @@ impl Pipeline { // Run verification on the pipeline first before computing hash pipeline.validate()?; - // Verification passed, thus we can now compute the hash for each node - for node_idx in pipeline.graph.node_indices() { - pipeline.compute_hash_for_node_and_parents(node_idx); - } - Ok(pipeline) } } diff --git a/tests/pipeline.rs b/tests/pipeline.rs index a50d2802..0cc70d3f 100644 --- a/tests/pipeline.rs +++ b/tests/pipeline.rs @@ -3,6 +3,7 @@ clippy::panic_in_result_fn, clippy::indexing_slicing, clippy::panic, + clippy::type_complexity, reason = "OK in tests." )] @@ -19,10 +20,11 @@ use orcapod::uniffi::{ use pretty_assertions::assert_eq; use std::collections::HashMap; -use crate::fixture::pipeline; +use crate::fixture::{combine_txt_pod, pipeline}; +#[expect(clippy::too_many_lines, reason = "Test code")] #[test] -fn node_hashing() -> Result<()> { +fn preprocessing() -> Result<()> { let pipeline = pipeline()?; // Assert that every node has a non-empty hash @@ -88,6 +90,75 @@ fn node_hashing() -> Result<()> { "Node hashes did not match" ); + // Check if the input spec contains the correct node hashes + assert_eq!( + pipeline.input_spec, + HashMap::from([ + ( + "the".into(), + vec![NodeURI { + node_id: "964ebb9ddd6bb7db56e53c19e9ac34dfd08779a656295b01e70b5973adc61103" + .into(), + key: "input_1".into(), + },] + ), + ( + "where".into(), + vec![NodeURI { + node_id: "8e43dbc9fd55fa7d1a36fc4a6c036f4113b7aa7fcf38646a2f2472bac6774962" + .into(), + key: "input_1".into(), + },] + ), + ( + "cat_color".into(), + vec![NodeURI { + node_id: "964ebb9ddd6bb7db56e53c19e9ac34dfd08779a656295b01e70b5973adc61103" + .into(), + key: "input_2".into(), + },] + ), + ( + "is".into(), + vec![NodeURI { + node_id: "8e43dbc9fd55fa7d1a36fc4a6c036f4113b7aa7fcf38646a2f2472bac6774962" + .into(), + key: "input_2".into(), + },] + ), + ( + "cat".into(), + vec![NodeURI { + node_id: "04cb341a09eeb771846377405a5f33d011f99a7dfa4739fd7876a7e70c994e4e" + .into(), + key: "input_1".into(), + },] + ), + ( + "action".into(), + vec![NodeURI { + node_id: "04cb341a09eeb771846377405a5f33d011f99a7dfa4739fd7876a7e70c994e4e" + .into(), + key: "input_2".into(), + },] + ), + ]), + "Input spec did not match" + ); + + // Check if the output spec contain the correct node hashes + assert_eq!( + pipeline.output_spec, + HashMap::from([( + "output".into(), + NodeURI { + node_id: "6ec68cc43ea15472731a318584cc8792fb2ff93c96fed6f3f998849b75976694".into(), + key: "output".into(), + } + ),]), + "Output spec did not match" + ); + Ok(()) } @@ -159,3 +230,132 @@ fn input_packet_checksum() -> Result<()> { Ok(()) } + +/// Testing invalid conditions to make sure validation works +fn basic_pipeline_components() -> Result<( + String, + HashMap, + HashMap>, + HashMap, +)> { + let dot = indoc! {" + digraph { + A + } + "}; + + let metadata = HashMap::from([("A".into(), combine_txt_pod("A")?.into())]); + + let input_spec = HashMap::from([ + ( + "input_1".into(), + vec![NodeURI { + node_id: "A".into(), + key: "input_1".into(), + }], + ), + ( + "input_2".into(), + vec![NodeURI { + node_id: "A".into(), + key: "input_2".into(), + }], + ), + ]); + + let output_spec = HashMap::from([( + "output".into(), + NodeURI { + node_id: "A".into(), + key: "output".into(), + }, + )]); + + Ok((dot.to_owned(), metadata, input_spec, output_spec)) +} + +#[test] +fn invalid_input_spec() -> Result<()> { + let (dot, metadata, _, output_spec) = basic_pipeline_components()?; + + // Test invalid node reference in input_spec + assert!( + Pipeline::new( + &dot, + &metadata, + HashMap::from([( + "input_1".into(), + vec![NodeURI { + node_id: "B".into(), + key: "input_1".into(), + }], + )]), + output_spec.clone(), + ) + .is_err(), + "Pipeline creation should have failed due to invalid input_spec" + ); + + // Test invalid key reference in input_spec + assert!( + Pipeline::new( + &dot, + &metadata, + HashMap::from([( + "input_1".into(), + vec![NodeURI { + node_id: "A".into(), + key: "input_3".into(), + }], + )]), + output_spec, + ) + .is_err(), + "Pipeline creation should have failed due to invalid input_spec" + ); + + Ok(()) +} + +#[test] +fn invalid_output_spec() -> Result<()> { + let (dot, metadata, input_spec, _) = basic_pipeline_components()?; + + // Test invalid output_spec node reference + assert!( + Pipeline::new( + &dot, + &metadata, + input_spec.clone(), + HashMap::from([( + "A".into(), + NodeURI { + node_id: "B".into(), + key: "output".into(), + } + )]), + ) + .is_err(), + "Pipeline creation should have failed due to invalid output_spec" + ); + + // Test invalid output_spec key reference + assert!( + Pipeline::new( + &dot, + &metadata, + input_spec, + HashMap::from([( + "A".into(), + NodeURI { + node_id: "A".into(), + key: "output_dne".into(), + } + )]), + ) + .is_err(), + "Pipeline creation should have failed due to invalid output_spec" + ); + + Ok(()) +} diff --git a/tests/pipeline_runner.rs b/tests/pipeline_runner.rs index fa3c3ac2..f8f47248 100644 --- a/tests/pipeline_runner.rs +++ b/tests/pipeline_runner.rs @@ -13,6 +13,8 @@ use std::{ sync::Arc, }; +use crate::fixture::TestDirs; +use fixture::pipeline_job; use orcapod::{ core::pipeline_runner::DockerPipelineRunner, uniffi::{ @@ -23,9 +25,6 @@ use orcapod::{ }; use tokio::fs::read_to_string; -use crate::fixture::TestDirs; -use fixture::pipeline_job; - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn basic_run() -> Result<()> { // Create the test_dir and get the namespace lookup From c20a127ada8aa2af67b3afde456f6a5f502fb286 Mon Sep 17 00:00:00 2001 From: synicix Date: Thu, 2 Oct 2025 22:00:49 +0000 Subject: [PATCH 55/65] Remove unused code --- src/core/pipeline_runner.rs | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/src/core/pipeline_runner.rs b/src/core/pipeline_runner.rs index a263e8eb..a3493d90 100644 --- a/src/core/pipeline_runner.rs +++ b/src/core/pipeline_runner.rs @@ -23,7 +23,6 @@ use crate::{ }; use async_trait::async_trait; use names::{Generator, Name}; -use serde::{Deserialize, Serialize}; use serde_yaml::Serializer; use snafu::{OptionExt as _, ResultExt as _}; use std::{ @@ -39,18 +38,6 @@ use tokio::{ static NODE_OUTPUT_KEY_EXPR: &str = "output"; static FAILURE_KEY_EXP: &str = "failure"; -#[derive(Serialize, Deserialize, Clone, Debug)] -enum NodeOutput { - Packet(String, HashMap), - ProcessingCompleted(String), -} - -#[derive(Serialize, Deserialize, Clone, Debug)] -struct ProcessingFailure { - node_id: String, - error: String, -} - /// Internal representation of a pipeline run, which should not be made public due to the fact that it contains #[derive(Debug)] struct PipelineRunInternal { From 491e422266abe745f9acb8fd440e0e936e890bb8 Mon Sep 17 00:00:00 2001 From: synicix Date: Sat, 4 Oct 2025 03:34:14 +0000 Subject: [PATCH 56/65] Update rust version --- .github/workflows/tests.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 33299131..1d6291d6 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -17,7 +17,7 @@ jobs: - name: Install Rust + components uses: actions-rust-lang/setup-rust-toolchain@v1 with: - toolchain: 1.89 + toolchain: 1.90.0 components: rustfmt,clippy - name: Install code coverage uses: taiki-e/install-action@cargo-llvm-cov From 6d8873058c95902ab8f2ddbf94a0af02645438c2 Mon Sep 17 00:00:00 2001 From: synicix Date: Sat, 4 Oct 2025 03:52:56 +0000 Subject: [PATCH 57/65] Solve hidden cargo clippy issues by newer rust-analyzer --- .clippy.toml | 2 +- src/core/pipeline_runner.rs | 1 - tests/agent.rs | 1 - 3 files changed, 1 insertion(+), 3 deletions(-) diff --git a/.clippy.toml b/.clippy.toml index 5821063e..6b3b5fee 100644 --- a/.clippy.toml +++ b/.clippy.toml @@ -1,3 +1,3 @@ -excessive-nesting-threshold = 5 +excessive-nesting-threshold = 6 too-many-arguments-threshold = 10 allowed-idents-below-min-chars = ["..", "k", "v", "f", "re", "id", "Ok", "'_"] diff --git a/src/core/pipeline_runner.rs b/src/core/pipeline_runner.rs index a3493d90..fc98776b 100644 --- a/src/core/pipeline_runner.rs +++ b/src/core/pipeline_runner.rs @@ -738,7 +738,6 @@ impl PodProcessor { } } -#[expect(clippy::excessive_nesting, reason = "Nesting manageable")] #[async_trait] impl NodeProcessor for PodProcessor { async fn process_incoming_packet( diff --git a/tests/agent.rs b/tests/agent.rs index f31efce2..59469796 100644 --- a/tests/agent.rs +++ b/tests/agent.rs @@ -40,7 +40,6 @@ fn simple() -> Result<()> { Ok(()) } -#[expect(clippy::excessive_nesting, reason = "Nesting is manageable")] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn parallel_four_cores() -> Result<()> { let test_dirs = TestDirs::new(&HashMap::from([("default".to_owned(), None::)]))?; From 9ab69353b31ea8b561e810be3b1659937e5ab39f Mon Sep 17 00:00:00 2001 From: synicix Date: Sat, 4 Oct 2025 04:05:44 +0000 Subject: [PATCH 58/65] Mute github action expect issue with cargo clippy --- src/core/pipeline_runner.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/core/pipeline_runner.rs b/src/core/pipeline_runner.rs index fc98776b..3a3e19ed 100644 --- a/src/core/pipeline_runner.rs +++ b/src/core/pipeline_runner.rs @@ -833,7 +833,10 @@ impl OperatorProcessor { } } -#[expect(clippy::excessive_nesting, reason = "Nesting manageable")] +#[allow( + clippy::excessive_nesting, + reason = "Nesting manageable and mute github action error" +)] #[async_trait] impl NodeProcessor for OperatorProcessor { async fn process_incoming_packet( From 0a782748d930f12188bb52a8fb94ab9b92180d2a Mon Sep 17 00:00:00 2001 From: synicix Date: Sat, 4 Oct 2025 04:50:14 +0000 Subject: [PATCH 59/65] Fix unffi issue with record constructor --- Cargo.toml | 2 +- src/uniffi/model/packet.rs | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index c9991c08..be17fcef 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -83,7 +83,7 @@ tokio = { version = "1.41.0", features = ["full"] } # utilities for async calls tokio-util = "0.7.13" # automated CFFI + bindings in other languages -uniffi = { version = "0.29.1", features = ["cli", "tokio"] } +uniffi = { version = "0.29.4", features = ["cli", "tokio"] } # shared, distributed memory via communication zenoh = "1.3.4" diff --git a/src/uniffi/model/packet.rs b/src/uniffi/model/packet.rs index 3a606c5a..c1f9c013 100644 --- a/src/uniffi/model/packet.rs +++ b/src/uniffi/model/packet.rs @@ -45,12 +45,11 @@ pub struct Blob { /// BLOB location. pub location: URI, /// BLOB contents checksum. + #[uniffi(default = "")] pub checksum: String, } -#[uniffi::export] impl Blob { - #[uniffi::constructor] /// Create a new `Blob` pub const fn new(kind: BlobKind, location: URI) -> Self { Self { From af14f9289e5fa3a8faefac2bf5b2a01f01ff50df Mon Sep 17 00:00:00 2001 From: synicix Date: Fri, 17 Oct 2025 01:12:04 +0000 Subject: [PATCH 60/65] Fix merging issues --- .clippy.toml | 2 +- src/core/pipeline_runner.rs | 15 +-------------- 2 files changed, 2 insertions(+), 15 deletions(-) diff --git a/.clippy.toml b/.clippy.toml index 5821063e..6b3b5fee 100644 --- a/.clippy.toml +++ b/.clippy.toml @@ -1,3 +1,3 @@ -excessive-nesting-threshold = 5 +excessive-nesting-threshold = 6 too-many-arguments-threshold = 10 allowed-idents-below-min-chars = ["..", "k", "v", "f", "re", "id", "Ok", "'_"] diff --git a/src/core/pipeline_runner.rs b/src/core/pipeline_runner.rs index 283dbde1..d8bff399 100644 --- a/src/core/pipeline_runner.rs +++ b/src/core/pipeline_runner.rs @@ -23,7 +23,6 @@ use crate::{ }; use async_trait::async_trait; use names::{Generator, Name}; -use serde::{Deserialize, Serialize}; use serde_yaml::Serializer; use snafu::{OptionExt as _, ResultExt as _}; use std::{ @@ -39,18 +38,6 @@ use tokio::{ static NODE_OUTPUT_KEY_EXPR: &str = "output"; static FAILURE_KEY_EXP: &str = "failure"; -#[derive(Serialize, Deserialize, Clone, Debug)] -enum NodeOutput { - Packet(String, HashMap), - ProcessingCompleted(String), -} - -#[derive(Serialize, Deserialize, Clone, Debug)] -struct ProcessingFailure { - node_id: String, - error: String, -} - /// Internal representation of a pipeline run, which should not be made public due to the fact that it contains #[derive(Debug)] struct PipelineRunInternal { @@ -736,7 +723,7 @@ impl PodProcessor { }, }); } - PodStatus::Running | PodStatus::Unset => { + PodStatus::Running | PodStatus::Unset | PodStatus::Undefined => { // This should not happen, but if it does, we will return an error return Err(OrcaError { kind: Kind::PodJobProcessingError { From 78ceed62b2aa521b9f25106d05f554436cea6adb Mon Sep 17 00:00:00 2001 From: synicix Date: Fri, 17 Oct 2025 01:20:51 +0000 Subject: [PATCH 61/65] Fix clippy issues and orchestrator bugs --- src/core/pipeline_runner.rs | 1 + tests/orchestrator.rs | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/core/pipeline_runner.rs b/src/core/pipeline_runner.rs index d8bff399..cf866f60 100644 --- a/src/core/pipeline_runner.rs +++ b/src/core/pipeline_runner.rs @@ -832,6 +832,7 @@ impl OperatorProcessor { } } +#[expect(clippy::excessive_nesting, reason = "Nesting is manageable here")] #[async_trait] impl NodeProcessor for OperatorProcessor { async fn process_incoming_packet( diff --git a/tests/orchestrator.rs b/tests/orchestrator.rs index ff7de11c..f59ffc63 100644 --- a/tests/orchestrator.rs +++ b/tests/orchestrator.rs @@ -50,10 +50,10 @@ fn basic_test( orchestrator .list_blocking()? .into_iter() - .filter(|pod_run_from_list| *pod_run_from_list == pod_run) + .filter(|pod_run_from_list| pod_run_from_list == pod_run) .map(|run| Ok(orchestrator.get_info_blocking(&run)?.command)) .collect::>>()?, - vec![expected_command.clone()], + vec![expected_command], "Unexpected list." ); // await result @@ -68,7 +68,7 @@ fn basic_test( orchestrator .list_blocking()? .into_iter() - .filter(|pod_run_from_list| *pod_run_from_list == pod_run) + .filter(|pod_run_from_list| pod_run_from_list == pod_run) .map(|run| Ok(orchestrator.get_info_blocking(&run)?.command)) .collect::>>()?, vec![expected_command], From dd5c44fc627a7eee2414a8ce2a956f6189b3ace2 Mon Sep 17 00:00:00 2001 From: synicix Date: Fri, 17 Oct 2025 01:23:37 +0000 Subject: [PATCH 62/65] Remove clippy except since github action complained --- src/core/pipeline_runner.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/core/pipeline_runner.rs b/src/core/pipeline_runner.rs index cf866f60..d8bff399 100644 --- a/src/core/pipeline_runner.rs +++ b/src/core/pipeline_runner.rs @@ -832,7 +832,6 @@ impl OperatorProcessor { } } -#[expect(clippy::excessive_nesting, reason = "Nesting is manageable here")] #[async_trait] impl NodeProcessor for OperatorProcessor { async fn process_incoming_packet( From f9b53ba54d77b5164aa8b5071c4dd283b5fc9c9f Mon Sep 17 00:00:00 2001 From: synicix Date: Fri, 17 Oct 2025 01:29:53 +0000 Subject: [PATCH 63/65] Apply fixes requests from clippy --- tests/agent.rs | 1 - tests/orchestrator.rs | 1 - 2 files changed, 2 deletions(-) diff --git a/tests/agent.rs b/tests/agent.rs index 89e42c87..6d7ec1e7 100644 --- a/tests/agent.rs +++ b/tests/agent.rs @@ -42,7 +42,6 @@ fn simple() -> Result<()> { Ok(()) } -#[expect(clippy::excessive_nesting, reason = "Nesting is manageable")] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn parallel_four_cores() -> Result<()> { let test_dirs = TestDirs::new(&HashMap::from([("default".to_owned(), None::)]))?; diff --git a/tests/orchestrator.rs b/tests/orchestrator.rs index f59ffc63..44a0ee47 100644 --- a/tests/orchestrator.rs +++ b/tests/orchestrator.rs @@ -4,7 +4,6 @@ clippy::panic, clippy::expect_used, clippy::unwrap_used, - clippy::indexing_slicing, reason = "OK in tests." )] From 8365fdae811796ea338866cbf27c870a14cccb00 Mon Sep 17 00:00:00 2001 From: synicix Date: Fri, 17 Oct 2025 03:27:21 +0000 Subject: [PATCH 64/65] Fix incorrect assert check hash --- tests/model.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/model.rs b/tests/model.rs index 369c0f1e..660b78e0 100644 --- a/tests/model.rs +++ b/tests/model.rs @@ -101,7 +101,7 @@ fn pod_job_to_yaml() -> Result<()> { fn hash_pod_result() -> Result<()> { pretty_assert_eq!( pod_result_style(&NAMESPACE_LOOKUP_READ_ONLY)?.hash, - "56d750bae1e9f70be2bca38c121cdbb7bd2e67a3c40d28df9bc5b5133eaedf70", + "9c4ae0ab0659823d104a30ef81c19c9cb1584a07e5cb702dc189f9bd7604fda3", "Hash didn't match." ); Ok(()) From ed9edcb253e651acdaab5d7e40946e177584d179 Mon Sep 17 00:00:00 2001 From: synicix Date: Mon, 20 Oct 2025 20:49:09 +0000 Subject: [PATCH 65/65] Update compute_hash_for_node_and_parents recursive base cases to do check first. --- src/core/model/pipeline.rs | 83 ++++++++++++++++++++------------------ 1 file changed, 44 insertions(+), 39 deletions(-) diff --git a/src/core/model/pipeline.rs b/src/core/model/pipeline.rs index 5aa6ace5..87ef5f66 100644 --- a/src/core/model/pipeline.rs +++ b/src/core/model/pipeline.rs @@ -208,51 +208,56 @@ impl Pipeline { } /// Compute the hash for each node in the graph which is defined as the hash of its kernel + the hashes of its parents - pub(crate) fn compute_hash_for_node_and_parents( + pub(crate) fn compute_hash_for_node_and_parents<'g>( node_idx: NodeIndex, input_spec: &HashMap>, - graph: &mut graph::Graph, - ) { - // Collect parent indices first to avoid borrowing issues - let parent_indices: Vec = graph.neighbors_directed(node_idx, Incoming).collect(); + graph: &'g mut graph::Graph, + ) -> &'g str { + if graph[node_idx].hash.is_empty() { + // Collect parent indices first to avoid borrowing issues + let parent_indices: Vec = + graph.neighbors_directed(node_idx, Incoming).collect(); - // Sort the parent hashes to ensure consistent ordering - let mut parent_hashes: Vec = if parent_indices.is_empty() { - // This is parent node, thus we will need to use the input_spec to generate a unique hash for the node - // Find all the input keys that map to this node - let input_keys = input_spec.iter().filter_map(|(input_key, node_uris)| { - node_uris.iter().find_map(|node_uri| { - (node_uri.node_id == graph[node_idx].label).then(|| input_key.clone()) - }) - }); - - input_keys.collect() - } else { - parent_indices - .into_iter() - .map(|parent_idx| { - // Check if hash has been computed for this node, if not trigger computation - if graph[parent_idx].hash.is_empty() { - // Recursive call to compute the parent's hash - Self::compute_hash_for_node_and_parents(parent_idx, input_spec, graph); - } - graph[parent_idx].hash.clone() - }) - .collect() - }; + // Sort the parent hashes to ensure consistent ordering + let mut parent_hashes: Vec = if parent_indices.is_empty() { + // This is parent node, thus we will need to use the input_spec to generate a unique hash for the node + // Find all the input keys that map to this node + input_spec + .iter() + .filter_map(|(input_key, node_uris)| { + node_uris.iter().find_map(|node_uri| { + (node_uri.node_id == graph[node_idx].label).then(|| input_key.clone()) + }) + }) + .collect() + } else { + parent_indices + .into_iter() + .map(|parent_idx| { + // Check if hash has been computed for this node, if not trigger computation + Self::compute_hash_for_node_and_parents(parent_idx, input_spec, graph) + .to_owned() + }) + .collect() + }; - parent_hashes.sort(); + parent_hashes.sort(); - // Combine the node's kernel hash + the parent_hashes by concatenation only if there are parents hashes, else it is just the kernel hash - if parent_hashes.is_empty() { - } else { - let hash_for_node = format!( - "{}{}", - &graph[node_idx].kernel.get_hash(), - parent_hashes.into_iter().join("") - ); - graph[node_idx].hash = hash_buffer(hash_for_node.as_bytes()); + // Combine the node's kernel hash + the parent_hashes by concatenation only if there are parents hashes, else it is just the kernel hash + if parent_hashes.is_empty() { + let kernel_hash = graph[node_idx].kernel.get_hash().to_owned(); + graph[node_idx].hash.clone_from(&kernel_hash); + } else { + let hash_for_node = format!( + "{}{}", + &graph[node_idx].kernel.get_hash(), + parent_hashes.into_iter().join("") + ); + graph[node_idx].hash = hash_buffer(hash_for_node.as_bytes()); + } } + + &graph[node_idx].hash } }