diff --git a/.hermes/plans/mytheclipse-round13-spec.md b/.hermes/plans/mytheclipse-round13-spec.md new file mode 100644 index 0000000..5751208 --- /dev/null +++ b/.hermes/plans/mytheclipse-round13-spec.md @@ -0,0 +1,19 @@ +# Implementation Spec: Round 13 — COMPLETE + +## New Feature + +### AggregateError (mytheclipse-core, resiliency) +File: `crates/mytheclipse/src/aggregate_error.rs` +- Collects multiple `E: std::error::Error` from parallel/fan-out tasks into one + error — natural failure type for `join_all` + batch/fan-out resilience +- `empty()` / `with_context(..)` / push(E) / is_empty / len / iter +- `from_results(Vec>) -> Result, AggregateError>` — collects + ALL errors, returns values when all Ok +- Display lists count + first error; From>>, Extend +- 3 tests + +## Files +- new: core/src/aggregate_error.rs +- core/lib.rs: +module+export AggregateError (resiliency) + +Build: exit 0. Tests: 0 FAILED (97 core pass). Clippy: 0 new warnings. diff --git a/crates/mytheclipse/src/aggregate_error.rs b/crates/mytheclipse/src/aggregate_error.rs new file mode 100644 index 0000000..e1e4ede --- /dev/null +++ b/crates/mytheclipse/src/aggregate_error.rs @@ -0,0 +1,147 @@ +//! Error aggregation for parallel/fan-out work (feature `resiliency`). +//! +//! [`AggregateError`] collects multiple `E: std::error::Error` values produced +//! by concurrently executed tasks into one error, so a caller awaiting `N` +//! tasks via `join_all` can surface *every* failure at once instead of +//! stopping at the first. This is the natural failure type for +//! `futures::future::join_all(vec![...])` transactions, batch operations, and +//! fan-out resilience. + +use std::fmt; + +/// An error that groups one or more underlying errors. +#[derive(Debug)] +pub struct AggregateError { + errors: Vec>, + /// Optional label describing the operation that failed. + context: Option, +} + +impl AggregateError { + /// Creates an empty aggregate (no errors yet). + pub fn empty() -> Self { + Self { + errors: Vec::new(), + context: None, + } + } + + /// Creates a labeled aggregate with an operation context. + pub fn with_context(context: impl Into) -> Self { + Self { + errors: Vec::new(), + context: Some(context.into()), + } + } + + /// Adds an error to the aggregate. + pub fn push>>(&mut self, error: E) { + self.errors.push(error.into()); + } + + /// Returns `true` if the aggregate holds no errors. + pub fn is_empty(&self) -> bool { + self.errors.is_empty() + } + + /// Number of collected errors. + pub fn len(&self) -> usize { + self.errors.len() + } + + /// Iterator over the collected errors. + pub fn iter(&self) -> impl Iterator { + self.errors.iter().map(|b| b.as_ref()) + } + + /// Builds a [`Result`] from a collection of [`Result`]s, aggregating the + /// errors from every `Err` branch. + /// + /// If all inputs are `Ok`, the `V` values are collected and returned. + pub fn from_results(results: Vec>) -> Result, AggregateError> + where + E: std::error::Error + Send + Sync + 'static, + { + let mut values = Vec::with_capacity(results.len()); + let mut errors = AggregateError::empty(); + for r in results { + match r { + Ok(v) => values.push(v), + Err(e) => errors.push(Box::new(e)), + } + } + if errors.is_empty() { + Ok(values) + } else { + Err(errors) + } + } +} + +impl fmt::Display for AggregateError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + if let Some(ctx) = &self.context { + write!(f, "{ctx}: {} error(s)", self.errors.len())?; + } else { + write!(f, "{} error(s)", self.errors.len())?; + } + if !self.errors.is_empty() { + write!(f, " — first: {}", self.errors[0])?; + } + Ok(()) + } +} + +impl std::error::Error for AggregateError {} + +impl From>> for AggregateError { + fn from(errors: Vec>) -> Self { + Self { errors, context: None } + } +} + +impl Extend> for AggregateError { + fn extend>>(&mut self, iter: T) { + self.errors.extend(iter); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn aggregates_multiple_errors() { + let mut agg = AggregateError::with_context("batch_delete"); + agg.push(std::io::Error::new(std::io::ErrorKind::Other, "row 1")); + agg.push(std::io::Error::new(std::io::ErrorKind::Other, "row 2")); + assert_eq!(agg.len(), 2); + assert!(!agg.is_empty()); + let s = agg.to_string(); + assert!(s.contains("batch_delete")); + assert!(s.contains("2 error(s)")); + } + + #[test] + fn extracts_errors_from_results() { + let results: Vec> = vec![ + Ok(1), + Err(std::io::Error::new(std::io::ErrorKind::Other, "a")), + Ok(2), + Err(std::io::Error::new(std::io::ErrorKind::Other, "b")), + ]; + let out = AggregateError::from_results(results); + assert!(out.is_err()); + let err = out.unwrap_err(); + assert_eq!(err.len(), 2); + assert_eq!(err.iter().count(), 2); + } + + #[test] + fn collects_values_when_all_ok() { + let results: Vec> = + vec![Ok(1), Ok(2), Ok(3)]; + let out = AggregateError::from_results(results).unwrap(); + assert_eq!(out, vec![1, 2, 3]); + } +} diff --git a/crates/mytheclipse/src/lib.rs b/crates/mytheclipse/src/lib.rs index f2c6cb7..b800913 100644 --- a/crates/mytheclipse/src/lib.rs +++ b/crates/mytheclipse/src/lib.rs @@ -36,7 +36,11 @@ pub mod retry; #[cfg(feature = "resiliency")] pub mod retry_ext; #[cfg(feature = "resiliency")] +pub mod aggregate_error; +#[cfg(feature = "resiliency")] pub use retry_ext::RetryExt; +#[cfg(feature = "resiliency")] +pub use aggregate_error::AggregateError; #[cfg(feature = "observability")] pub mod auto_metrics_service; #[cfg(feature = "observability")]