diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e4ff63..974ee89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed (breaking) +- **Design 034 Phase 2 — panic-free builder validation: `build()` reports every configuration mistake at once (Issue #133, [review doc §3.4](docs/design/034-technical-debt-review.md)).** Builder methods never panic on user mistakes anymore. Conflicting `.source()`/`.transform()`/`.link_from()` registrations, missing serializers/deserializers, invalid connector URLs, unregistered schemes, and key-reused-with-different-type are *recorded* (the conflicting registration is skipped) and `build()` returns one `DbError::InvalidConfiguration { errors: Vec }` carrying **all** findings — each with the record key and, where applicable, the connector URL. The worst panic — "requires a buffer" firing at spawn time inside a connector factory closure — is now a build()-time check, which also makes `.buffer()` after `.link_to()`/`.link_from()` legal (order-independent). Duplicate keys and dependency-graph cycles fold into the same collected report (previously distinct `DuplicateRecordKey`/`CyclicDependency` returns from `build()`). Remaining `panic!`/`expect`s in the builder path are internal invariants and say "this is a bug in aimdb-core". ([aimdb-core](aimdb-core/CHANGELOG.md)) + - **Design 034 Phase 2 — registrar lifetime fix + de-erased builder internals (Issue #130, [review doc](docs/design/034-technical-debt-review.md)).** `RecordRegistrar`'s fluent methods now take fresh borrows (`&mut self -> &mut Self`) instead of borrowing the registrar for its entire lifetime — a `configure` closure can finally use separate statements (`reg.source_raw(…); reg.tap_raw(…);`) and reuse the registrar after a chain. `configure`'s closure bound drops its HRTB; `OutboundConnectorBuilder`/`InboundConnectorBuilder` gain a second lifetime parameter (`<'r, 'a, T, R>`); `RecordT::register` and the adapter/persistence extension traits follow. Internally, `AimDbBuilder` stores its spawn/start functions typed (`SpawnFnType`/`StartFnType`) instead of `Box` — the panicking downcasts in `build()` are gone, and `AimDb`'s struct-level bound moved to its impls. Closure-based user code compiles unchanged. ([aimdb-core](aimdb-core/CHANGELOG.md), [aimdb-embassy-adapter](aimdb-embassy-adapter/CHANGELOG.md), [aimdb-persistence](aimdb-persistence/CHANGELOG.md)) - **Design 034 Phase 1 — mechanical debt cleanup (Issues #129, #132, [review doc](docs/design/034-technical-debt-review.md)).** `DbError` is unified on `alloc::string::String`: every variant has one shape on all targets, `thiserror` derives `Display`/`Error` unconditionally (now a mandatory no_std dependency of `aimdb-core`), and no_std builds produce the same error messages as std builds instead of `Error 0xNNNN` codes — on no_std the `_field: ()` placeholders become the real `String` fields. The dead `Database` wrapper, the `TokioDatabase`/`EmbassyDatabase` aliases, and the deprecated `RecordRegistrar::link()` are removed. `ConsumerTrait::subscribe_any` is infallible (returns `Box`), `OutboundRoute` is a struct with named fields, and `aimdb-core`'s `std` feature no longer pulls a `tokio` dependency (tests cover it via dev-deps). Internally: all dual std/alloc import pairs and the duplicated `RuntimeContext` impl blocks are collapsed, and crate-private `log_*!` macros replace the 62 per-call-site `#[cfg(feature = "tracing")]` gates. The `aimdb-wasm-adapter` ring-lag error now carries a `buffer_name` like the other adapters. ([aimdb-core](aimdb-core/CHANGELOG.md), [aimdb-tokio-adapter](aimdb-tokio-adapter/CHANGELOG.md), [aimdb-embassy-adapter](aimdb-embassy-adapter/CHANGELOG.md), [aimdb-mqtt-connector](aimdb-mqtt-connector/CHANGELOG.md), [aimdb-knx-connector](aimdb-knx-connector/CHANGELOG.md)) diff --git a/aimdb-core/CHANGELOG.md b/aimdb-core/CHANGELOG.md index 3786360..6796104 100644 --- a/aimdb-core/CHANGELOG.md +++ b/aimdb-core/CHANGELOG.md @@ -43,6 +43,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed (breaking) +- **Phase 2 — panic-free builder validation; `build()` collects every configuration mistake (Issue #133, [design doc §3.4](../docs/design/034-technical-debt-review.md)).** One failure model instead of two: builder methods stay infallible and never panic on user mistakes; `build()` performs all validation and returns one `DbError::InvalidConfiguration { errors: Vec }` (new variant + new public `ConfigError { record_key, url, message }` type, error code `0x4003`) carrying **every** finding from the run. Specifics: + - `TypedRecord::set_producer`/`set_transform`/`add_inbound_connector` mutual-exclusion violations and duplicate producers/transforms: recorded, conflicting registration skipped (observable: `has_producer()` stays `false` after a conflicting `.source()`). + - `OutboundConnectorBuilder::finish()`/`InboundConnectorBuilder::finish()`: invalid URL, missing serializer/deserializer, unregistered scheme, and the transform/source conflicts are recorded with record key + URL; the link is not registered and the registrar is returned as usual. + - The spawn-time factory panics (missing buffer, record lookup) are now build()-time checks: any record with an outbound or inbound link and no buffer fails `build()` with key + link URL. Consequence: `.buffer()` may be called after `.link_to()`/`.link_from()` — the finish()-time buffer check is gone. + - `configure()` with a key already registered under a different type: recorded, the closure is skipped (previously `assert!`). + - Duplicate record keys and `DependencyGraph` findings (cycles, unregistered transform inputs) fold into the same `InvalidConfiguration` report — callers matching on `DuplicateRecordKey`/`CyclicDependency` from `build()` must update. + - Surviving `panic!`/`expect`s on the builder path are internal invariants only, worded "this is a bug in aimdb-core". `AnyRecord` gains `has_buffer()` and `drain_config_errors()` (breaking for external implementors). + - **Phase 2 — `RecordRegistrar` fluent methods take fresh borrows (Issue #130, [design doc §3.5](../docs/design/034-technical-debt-review.md)).** The methods previously took `&'a mut self` and returned `&'a mut Self` with `'a` = the struct's own lifetime parameter, so the first call borrowed the registrar for its entire remaining lifetime and only one unbroken chain per `configure` closure was possible. Now: - All fluent methods are `&mut self -> &mut Self`; separate statements (`reg.source_raw(…); reg.tap_raw(…);`) and registrar reuse after a chain work. - `configure`'s closure bound is `FnOnce(&mut RecordRegistrar<'_, T, R>)` (HRTB dropped) — existing closures compile unchanged. diff --git a/aimdb-core/src/builder.rs b/aimdb-core/src/builder.rs index bd298cb..72b703a 100644 --- a/aimdb-core/src/builder.rs +++ b/aimdb-core/src/builder.rs @@ -301,6 +301,12 @@ pub struct AimDbBuilder { /// Generic extension storage for external crates (e.g., persistence, metrics). /// Moved into AimDbInner during build() so it can be read on the live AimDb handle. extensions: Extensions, + + /// Builder-level configuration mistakes (e.g. re-registering a key with a + /// different type), recorded instead of panicking and reported — together + /// with the per-record errors — by `build()` via + /// [`DbError::InvalidConfiguration`]. + config_errors: Vec, } impl AimDbBuilder { @@ -315,6 +321,7 @@ impl AimDbBuilder { spawn_fns: Vec::new(), start_fns: Vec::new(), extensions: Extensions::new(), + config_errors: Vec::new(), } } @@ -353,6 +360,7 @@ impl AimDbBuilder { spawn_fns: Vec::new(), start_fns: Vec::new(), extensions: self.extensions, + config_errors: self.config_errors, } } } @@ -495,17 +503,24 @@ where let (rec, is_new_record) = match record_index { Some(idx) => { - // Use existing record + // Use existing record. A key re-registered with a different + // type is a user mistake: record it (reported from build()) + // and skip the closure — a registrar for the wrong `T` + // cannot even be constructed. let (_, existing_type, record) = &mut self.records[idx]; - assert!( - *existing_type == type_id, - "StringKey '{}' already registered with different type", - record_key.as_str() - ); + if *existing_type != type_id { + self.config_errors.push(crate::error::ConfigError::new( + record_key.as_str(), + None, + "key already registered with a different type", + )); + return self; + } ( - record - .as_typed_mut::() - .expect("type mismatch in record registry"), + record.as_typed_mut::().expect( + "record registry type mismatch despite TypeId check — \ + this is a bug in aimdb-core", + ), false, ) } @@ -515,9 +530,10 @@ where .push((record_key, type_id, Box::new(TypedRecord::::new()))); let (_, _, record) = self.records.last_mut().unwrap(); ( - record - .as_typed_mut::() - .expect("type mismatch in record registry"), + record.as_typed_mut::().expect( + "record registry type mismatch despite TypeId check — \ + this is a bug in aimdb-core", + ), true, ) } @@ -645,6 +661,16 @@ where /// `DbResult<(AimDb, AimDbRunner)>` — the handle (cloneable) and the /// non-`Clone` runner that owns the collected futures. /// + /// # Errors + /// + /// `build()` is the single surface for configuration mistakes: everything + /// recorded during configuration (conflicting `.source()`/`.transform()`/ + /// `.link_from()`, missing serializers, unregistered schemes, …) plus the + /// build-time checks here (record validation, linked records without a + /// buffer, duplicate keys, dependency-graph cycles) is collected and + /// returned as one [`DbError::InvalidConfiguration`] carrying **all** + /// findings — one run surfaces every mistake. + /// /// # Example /// /// ```rust,ignore @@ -657,22 +683,56 @@ where /// let handle = db.clone(); // clone freely before runner.run() /// runner.run().await; // drives everything to completion /// ``` - pub async fn build(self) -> DbResult<(AimDb, AimDbRunner)> + pub async fn build(mut self) -> DbResult<(AimDb, AimDbRunner)> where R: crate::RuntimeForProfiling, { - // Validate all records - for (key, _, record) in &self.records { - record.validate().map_err(|msg| { - DbError::runtime_error(alloc::format!( - "Record '{}' validation failed: {}", + use crate::error::ConfigError; + + // ── Validation pass: collect every configuration mistake before any + // effectful phase runs (issue #133). Nothing returns early here. + let mut errors = core::mem::take(&mut self.config_errors); + + for (key, _, record) in self.records.iter_mut() { + // Mistakes recorded during configuration — fill in the record key + // the setters didn't have. + for mut e in record.drain_config_errors() { + if e.record_key.is_empty() { + e.record_key = key.as_str().to_string(); + } + errors.push(e); + } + + // Record-level validation (e.g. remote access without a buffer). + if let Err(msg) = record.validate() { + errors.push(ConfigError::new(key.as_str(), None, msg)); + } + + // Connector links subscribe to / produce into the record's buffer; + // a linked record without one only surfaced at spawn time before. + let has_links = + record.outbound_connector_count() > 0 || !record.inbound_connectors().is_empty(); + if has_links && !record.has_buffer() { + let url = record + .outbound_connectors() + .first() + .map(|l| l.url.to_string()) + .or_else(|| { + record + .inbound_connectors() + .first() + .map(|l| l.url.to_string()) + }); + errors.push(ConfigError::new( key.as_str(), - msg - )) - })?; + url, + "linked record requires a buffer (call .buffer(...))", + )); + } } - // Ensure runtime is set + // Ensure runtime is set. Unreachable through the public API — the + // typed builder only exists once `.runtime()` was called. let runtime = self .runtime .ok_or_else(|| DbError::runtime_error("runtime not set (use .runtime())"))?; @@ -685,17 +745,16 @@ where let mut types: Vec = Vec::with_capacity(record_count); let mut keys: Vec = Vec::with_capacity(record_count); - for (i, (key, type_id, record)) in self.records.into_iter().enumerate() { - let id = RecordId::new(i as u32); - - // Check for duplicate keys (should not happen if configure() is used correctly) + for (key, type_id, record) in self.records.into_iter() { + // Duplicate keys (should not happen if configure() is used + // correctly): collect and skip so every mistake is reported. if by_key.contains_key(&key) { - return Err(DbError::DuplicateRecordKey { - key: key.as_str().to_string(), - }); + errors.push(ConfigError::new(key.as_str(), None, "duplicate record key")); + continue; } // Build index structures + let id = RecordId::new(storages.len() as u32); storages.push(record); by_key.insert(key, id); by_type.entry(type_id).or_default().push(id); @@ -726,8 +785,20 @@ where }) .collect(); - // Build and validate the dependency graph - let dependency_graph = DependencyGraph::build_and_validate(&record_infos)?; + // Build and validate the dependency graph; fold its findings (cycles, + // unregistered transform inputs) into the collected errors. + let dependency_graph = match DependencyGraph::build_and_validate(&record_infos) { + Ok(graph) => graph, + Err(e) => { + errors.push(ConfigError::new("", None, alloc::format!("{e}"))); + return Err(DbError::InvalidConfiguration { errors }); + } + }; + + // All validation done — report every collected mistake at once. + if !errors.is_empty() { + return Err(DbError::InvalidConfiguration { errors }); + } log_debug!( "Dependency graph built successfully ({} nodes, {} edges, topo order: {:?})", diff --git a/aimdb-core/src/error.rs b/aimdb-core/src/error.rs index f9b5cc0..4cabcaa 100644 --- a/aimdb-core/src/error.rs +++ b/aimdb-core/src/error.rs @@ -31,6 +31,67 @@ use thiserror::Error; #[cfg(feature = "std")] use std::io; +/// One configuration mistake found while configuring an `AimDbBuilder`. +/// +/// Builder methods never panic on user mistakes; they record one of these and +/// `build()` reports **all** of them at once via +/// [`DbError::InvalidConfiguration`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ConfigError { + /// Key of the record the mistake belongs to. Errors recorded before the + /// key is known (inside `TypedRecord` setters) carry an empty key that + /// `build()` fills in when draining. + pub record_key: String, + /// Connector URL, for link-related mistakes. + pub url: Option, + /// Human-readable description of the mistake. + pub message: String, +} + +impl ConfigError { + /// Builds a `ConfigError`. + pub fn new( + record_key: impl Into, + url: Option, + message: impl Into, + ) -> Self { + Self { + record_key: record_key.into(), + url, + message: message.into(), + } + } +} + +impl core::fmt::Display for ConfigError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + // Graph-level findings (cycles) span records and carry no single key. + if !self.record_key.is_empty() { + write!(f, "record '{}'", self.record_key)?; + if let Some(url) = &self.url { + write!(f, " ({url})")?; + } + write!(f, ": ")?; + } else if let Some(url) = &self.url { + write!(f, "({url}): ")?; + } + write!(f, "{}", self.message) + } +} + +/// Joins collected configuration errors for `InvalidConfiguration`'s `Display`. +fn join_config_errors(errors: &[ConfigError]) -> String { + use core::fmt::Write; + let mut out = String::new(); + for (i, e) in errors.iter().enumerate() { + if i > 0 { + out.push_str("; "); + } + let _ = write!(out, "{e}"); + } + out +} + /// Streamlined error type for AimDB operations /// /// Only includes errors that are actually used in the codebase, @@ -100,6 +161,11 @@ pub enum DbError { #[error("Missing configuration parameter: {parameter}")] MissingConfiguration { parameter: String }, + /// Configuration mistakes collected by `AimDbBuilder::build()` — one + /// entry per finding, so a single run surfaces every mistake. + #[error("invalid configuration ({} error(s)): {}", errors.len(), join_config_errors(errors))] + InvalidConfiguration { errors: Vec }, + // ===== Runtime Errors (0x7002 & 0x5000-0x5FFF) ===== /// Runtime execution error (task spawning, scheduling, etc.) #[error("Runtime error: {message}")] @@ -299,7 +365,10 @@ impl DbError { /// Returns true if this is a configuration-related error pub fn is_configuration_error(&self) -> bool { - matches!(self, DbError::MissingConfiguration { .. }) + matches!( + self, + DbError::MissingConfiguration { .. } | DbError::InvalidConfiguration { .. } + ) } /// Returns true if this is a runtime error @@ -341,6 +410,7 @@ impl DbError { // Configuration errors: 0x4000-0x4FFF DbError::MissingConfiguration { .. } => 0x4002, + DbError::InvalidConfiguration { .. } => 0x4003, // Resource errors: 0x5000-0x5FFF DbError::ResourceUnavailable { .. } => 0x5002, @@ -487,6 +557,8 @@ impl DbError { Self::prepend_context(&mut parameter, context); DbError::MissingConfiguration { parameter } } + // Collected configuration errors carry their own per-entry context + DbError::InvalidConfiguration { .. } => self, DbError::RuntimeError { mut message } => { Self::prepend_context(&mut message, context); DbError::RuntimeError { message } @@ -626,6 +698,31 @@ mod tests { ); } + #[test] + fn test_invalid_configuration_display_and_code() { + let err = DbError::InvalidConfiguration { + errors: vec![ + ConfigError::new( + "rec.a", + Some("mqtt://broker/a".to_string()), + "missing serializer", + ), + ConfigError::new("rec.b", None, "duplicate producer"), + ], + }; + assert_eq!(err.error_code(), 0x4003); + assert_eq!(err.error_category(), 0x4000); + assert!(err.is_configuration_error()); + + let s = err.to_string(); + assert!(s.contains("2 error(s)"), "got: {s}"); + assert!( + s.contains("record 'rec.a' (mqtt://broker/a): missing serializer"), + "got: {s}" + ); + assert!(s.contains("record 'rec.b': duplicate producer"), "got: {s}"); + } + #[test] fn test_error_codes() { let connection_error = DbError::ConnectionFailed { diff --git a/aimdb-core/src/lib.rs b/aimdb-core/src/lib.rs index 6b9e902..fb899ae 100644 --- a/aimdb-core/src/lib.rs +++ b/aimdb-core/src/lib.rs @@ -67,7 +67,7 @@ impl RuntimeForProfiling for R {} // Public API exports pub use context::RuntimeContext; -pub use error::{DbError, DbResult}; +pub use error::{ConfigError, DbError, DbResult}; pub use extensions::Extensions; // Runtime trait re-exports from aimdb-executor diff --git a/aimdb-core/src/typed_api.rs b/aimdb-core/src/typed_api.rs index 3d08a17..2302439 100644 --- a/aimdb-core/src/typed_api.rs +++ b/aimdb-core/src/typed_api.rs @@ -763,11 +763,30 @@ where } /// Finalizes the connector registration + /// + /// Configuration mistakes — an invalid URL, a missing serializer, or an + /// unregistered scheme — are recorded instead of panicking: the link is + /// **not** registered and `build()` reports every finding via + /// `DbError::InvalidConfiguration`. The registrar is returned either way + /// so chained configuration keeps compiling; the failed `build()` is the + /// single error surface. + /// + /// The buffer requirement is validated by `build()` (calling `.buffer()` + /// after `.link_to()` is fine). pub fn finish(self) -> &'r mut RecordRegistrar<'a, T, R> { use crate::connector::{ConnectorLink, ConnectorUrl}; + use crate::error::ConfigError; + + let record_key = self.registrar.record_key.clone(); - let url = ConnectorUrl::parse(&self.url) - .unwrap_or_else(|_| panic!("Invalid connector URL: {}", self.url)); + let Ok(url) = ConnectorUrl::parse(&self.url) else { + self.registrar.rec.push_config_error(ConfigError::new( + record_key, + Some(self.url), + "Invalid connector URL", + )); + return self.registrar; + }; let url_string = url.to_string(); let scheme = url.scheme().to_string(); @@ -790,10 +809,13 @@ where }); crate::connector::SerializerKind::Raw(erased) } else { - panic!( - "Outbound connector requires a serializer. Call .with_serializer() or .with_serializer_raw() for {}", - self.url - ); + self.registrar.rec.push_config_error(ConfigError::new( + record_key, + Some(self.url), + "Outbound connector requires a serializer. \ + Call .with_serializer() or .with_serializer_raw()", + )); + return self.registrar; }; link.serializer = Some(ser_kind); @@ -809,10 +831,14 @@ where .any(|b| b.scheme() == scheme); if !has_connector { - panic!( - "No connector registered for scheme '{}'. Register via .with_connector() for {}", - scheme, url_string - ); + self.registrar.rec.push_config_error(ConfigError::new( + record_key, + Some(url_string), + alloc::format!( + "No connector registered for scheme '{scheme}'. Register via .with_connector()" + ), + )); + return self.registrar; } // Register the link as a profiling stage (so `.with_name()` can name it @@ -834,26 +860,30 @@ where // Resolves the record at link-startup time (not per-message) and constructs a // `Consumer` bound to a pre-resolved buffer handle — same pattern as the // build-time path in `TypedRecord::collect_consumer_futures` (design 029). + // + // The factory runs during build() after every record is registered and + // validated (including the linked-records-need-a-buffer check), so + // failures here are aimdb bugs, not user mistakes. { let record_key = self.registrar.record_key.clone(); link.consumer_factory = Some(Arc::new( move |db_any: Arc| { - let db_ref = db_any - .downcast_ref::>() - .expect("Invalid db type in consumer factory"); + let db_ref = db_any.downcast_ref::>().expect( + "consumer factory: AimDb downcast failed — this is a bug in aimdb-core", + ); let typed_rec = db_ref .inner() .get_typed_record_by_key::(&record_key) .unwrap_or_else(|e| { panic!( - "outbound connector consumer factory: record '{}' lookup failed: {:?}", - record_key, e + "consumer factory: record '{record_key}' lookup failed ({e:?}) — \ + this is a bug in aimdb-core" ) }); let buffer = typed_rec.buffer_handle().unwrap_or_else(|| { panic!( - "outbound connector for '{}' requires a buffer (call .buffer(...) before .link_to(...))", - record_key + "consumer factory: record '{record_key}' has no buffer despite \ + build()-time validation — this is a bug in aimdb-core" ) }); @@ -1009,45 +1039,55 @@ where /// Finalizes the inbound connector registration /// - /// # Panics + /// Configuration mistakes — an invalid URL, a missing deserializer, an + /// unregistered scheme, or a conflict with `.source()`/`.transform()` + /// (local producer + inbound connector would race as last-writer-wins) — + /// are recorded instead of panicking: the link is **not** registered and + /// `build()` reports every finding via `DbError::InvalidConfiguration`. + /// The registrar is returned either way so chained configuration keeps + /// compiling; the failed `build()` is the single error surface. /// - /// - If no buffer is configured (inbound connectors require a buffer) - /// - If no deserializer is provided - /// - If no connector is registered for the URL scheme - /// - If the URL is invalid - /// - If the record already has a `.source()` or `.transform()` - /// (local producer + inbound connector would race as last-writer-wins) + /// The buffer requirement is validated by `build()` (calling `.buffer()` + /// after `.link_from()` is fine). pub fn finish(self) -> &'r mut RecordRegistrar<'a, T, R> { use crate::connector::{ConnectorUrl, DeserializerKind, InboundConnectorLink}; + use crate::error::ConfigError; - let url = ConnectorUrl::parse(&self.url) - .unwrap_or_else(|_| panic!("Invalid connector URL: {}", self.url)); + let record_key = self.registrar.record_key.clone(); + + let Ok(url) = ConnectorUrl::parse(&self.url) else { + self.registrar.rec.push_config_error(ConfigError::new( + record_key, + Some(self.url), + "Invalid connector URL", + )); + return self.registrar; + }; let scheme = url.scheme().to_string(); - // Validation: Buffer must exist for inbound connectors - if !self.registrar.rec.has_buffer() { - panic!( - "Inbound connector requires a buffer. Call .buffer() before .link_from() for record type {}", - core::any::type_name::() - ); - } + // NOTE: the buffer requirement is validated by `build()`, not here — + // `.buffer()` may legitimately be called after `.link_from()`. // Mutual exclusion with local producers — both write to the same - // buffer and would race as last-writer-wins. Builder-level check - // surfaces the URL in the message; `add_inbound_connector` enforces - // the same invariant from the other direction. + // buffer and would race as last-writer-wins. The check here carries + // the URL; `add_inbound_connector` enforces the same invariant from + // the other direction. if self.registrar.rec.has_transform() { - panic!( - "Record already has a .transform(); cannot also have a .link_from() for {}", - self.url - ); + self.registrar.rec.push_config_error(ConfigError::new( + record_key, + Some(self.url), + "Record already has a .transform(); cannot also have a .link_from().", + )); + return self.registrar; } if self.registrar.rec.has_producer() { - panic!( - "Record already has a .source(); cannot also have a .link_from() for {}", - self.url - ); + self.registrar.rec.push_config_error(ConfigError::new( + record_key, + Some(self.url), + "Record already has a .source(); cannot also have a .link_from().", + )); + return self.registrar; } // Resolve deserializer variant (mutually exclusive) @@ -1060,10 +1100,13 @@ where }); DeserializerKind::Raw(erased) } else { - panic!( - "Inbound connector requires a deserializer. Call .with_deserializer() or .with_deserializer_raw() for {}", - self.url - ); + self.registrar.rec.push_config_error(ConfigError::new( + record_key, + Some(self.url), + "Inbound connector requires a deserializer. \ + Call .with_deserializer() or .with_deserializer_raw()", + )); + return self.registrar; }; // Validation: Connector builder must be registered @@ -1074,10 +1117,14 @@ where .any(|b| b.scheme() == scheme); if !has_connector { - panic!( - "No connector registered for scheme '{}'. Register via .with_connector() for {}", - scheme, self.url - ); + self.registrar.rec.push_config_error(ConfigError::new( + record_key, + Some(self.url), + alloc::format!( + "No connector registered for scheme '{scheme}'. Register via .with_connector()" + ), + )); + return self.registrar; } // Create inbound connector link @@ -1088,19 +1135,21 @@ where link.topic_resolver = self.topic_resolver; // Add producer factory callback that captures type T and record key. + // The factory runs during build() after every record is registered and + // validated, so failures here are aimdb bugs, not user mistakes. { let record_key = self.registrar.record_key.clone(); link = link.with_producer_factory(move |db_any| { - let db = db_any - .downcast::>() - .expect("Failed to downcast to AimDb"); + let db = db_any.downcast::>().expect( + "producer factory: AimDb downcast failed — this is a bug in aimdb-core", + ); let typed_rec = db .inner() .get_typed_record_by_key::(&record_key) .unwrap_or_else(|e| { panic!( - "inbound connector producer factory: record '{}' lookup failed: {:?}", - record_key, e + "producer factory: record '{record_key}' lookup failed ({e:?}) — \ + this is a bug in aimdb-core" ) }); Box::new(Producer::::new(typed_rec.writer_handle())) @@ -1410,9 +1459,16 @@ mod tests { )); } + /// Drains the configuration errors a registrar/setter recorded on `rec`. + fn drain_errors( + rec: &mut crate::typed_record::TypedRecord, + ) -> Vec { + use crate::typed_record::AnyRecord; + rec.drain_config_errors() + } + #[test] - #[should_panic(expected = "Inbound connector requires a deserializer")] - fn inbound_finish_panics_without_deserializer() { + fn inbound_finish_without_deserializer_records_error() { let mut rec = crate::typed_record::TypedRecord::::new(); rec.set_buffer(Box::new(MockBuffer)); @@ -1424,8 +1480,17 @@ mod tests { let mut reg = make_registrar(&mut rec, &builders, &extensions); - // No deserializer set — should panic + // No deserializer set — error recorded, link not registered reg.link_from("mqtt://broker/topic").finish(); + + assert!(rec.inbound_connectors().is_empty()); + let errors = drain_errors(&mut rec); + assert_eq!(errors.len(), 1); + assert!(errors[0] + .message + .contains("Inbound connector requires a deserializer")); + assert_eq!(errors[0].record_key, "test::Record"); + assert_eq!(errors[0].url.as_deref(), Some("mqtt://broker/topic")); } // ==================================================================== @@ -1565,8 +1630,7 @@ mod tests { } #[test] - #[should_panic(expected = "Outbound connector requires a serializer")] - fn outbound_finish_panics_without_serializer() { + fn outbound_finish_without_serializer_records_error() { let mut rec = crate::typed_record::TypedRecord::::new(); rec.set_buffer(Box::new(MockBuffer)); @@ -1578,8 +1642,40 @@ mod tests { let mut reg = make_registrar(&mut rec, &builders, &extensions); - // No serializer set — should panic + // No serializer set — error recorded, link not registered reg.link_to("mqtt://broker/topic").finish(); + + assert!(rec.outbound_connectors().is_empty()); + let errors = drain_errors(&mut rec); + assert_eq!(errors.len(), 1); + assert!(errors[0] + .message + .contains("Outbound connector requires a serializer")); + assert_eq!(errors[0].record_key, "test::Record"); + assert_eq!(errors[0].url.as_deref(), Some("mqtt://broker/topic")); + } + + #[test] + fn finish_with_unregistered_scheme_records_error() { + let mut rec = crate::typed_record::TypedRecord::::new(); + rec.set_buffer(Box::new(MockBuffer)); + + // No connector builders registered at all + let builders: Vec>> = vec![]; + let extensions = crate::extensions::Extensions::new(); + + let mut reg = make_registrar(&mut rec, &builders, &extensions); + reg.link_to("mqtt://broker/topic") + .with_serializer_raw(|r: &TestRecord| Ok(r.value.to_le_bytes().to_vec())) + .finish(); + + assert!(rec.outbound_connectors().is_empty()); + let errors = drain_errors(&mut rec); + assert_eq!(errors.len(), 1); + assert!(errors[0] + .message + .contains("No connector registered for scheme 'mqtt'")); + assert_eq!(errors[0].record_key, "test::Record"); } // ==================================================================== @@ -1601,10 +1697,7 @@ mod tests { } #[test] - #[should_panic( - expected = "Record already has a .source(); cannot also have a .link_from() for mqtt://broker/topic" - )] - fn link_from_after_source_panics() { + fn link_from_after_source_records_error() { let mut rec = crate::typed_record::TypedRecord::::new(); rec.set_buffer(Box::new(MockBuffer)); rec.set_producer(|_p, _ctx| async move {}); @@ -1619,13 +1712,18 @@ mod tests { reg.link_from("mqtt://broker/topic") .with_deserializer_raw(|_b: &[u8]| Ok(TestRecord { value: 0 })) .finish(); + + assert!(rec.inbound_connectors().is_empty()); + let errors = drain_errors(&mut rec); + assert_eq!(errors.len(), 1); + assert!(errors[0] + .message + .contains("Record already has a .source(); cannot also have a .link_from().")); + assert_eq!(errors[0].url.as_deref(), Some("mqtt://broker/topic")); } #[test] - #[should_panic( - expected = "Record already has a .transform(); cannot also have a .link_from() for mqtt://broker/topic" - )] - fn link_from_after_transform_panics() { + fn link_from_after_transform_records_error() { let mut rec = crate::typed_record::TypedRecord::::new(); rec.set_buffer(Box::new(MockBuffer)); rec.set_transform(dummy_transform_descriptor()); @@ -1640,11 +1738,18 @@ mod tests { reg.link_from("mqtt://broker/topic") .with_deserializer_raw(|_b: &[u8]| Ok(TestRecord { value: 0 })) .finish(); + + assert!(rec.inbound_connectors().is_empty()); + let errors = drain_errors(&mut rec); + assert_eq!(errors.len(), 1); + assert!(errors[0] + .message + .contains("Record already has a .transform(); cannot also have a .link_from().")); + assert_eq!(errors[0].url.as_deref(), Some("mqtt://broker/topic")); } #[test] - #[should_panic(expected = "Record already has a .link_from(); cannot also have a .source().")] - fn source_after_link_from_panics() { + fn source_after_link_from_records_error() { let mut rec = crate::typed_record::TypedRecord::::new(); rec.set_buffer(Box::new(MockBuffer)); @@ -1661,13 +1766,17 @@ mod tests { } rec.set_producer(|_p, _ctx| async move {}); + + assert!(!rec.has_producer(), "conflicting producer must be skipped"); + let errors = drain_errors(&mut rec); + assert_eq!(errors.len(), 1); + assert!(errors[0] + .message + .contains("Record already has a .link_from(); cannot also have a .source().")); } #[test] - #[should_panic( - expected = "Record already has a .link_from(); cannot also have a .transform()." - )] - fn transform_after_link_from_panics() { + fn transform_after_link_from_records_error() { let mut rec = crate::typed_record::TypedRecord::::new(); rec.set_buffer(Box::new(MockBuffer)); @@ -1684,6 +1793,16 @@ mod tests { } rec.set_transform(dummy_transform_descriptor()); + + assert!( + !rec.has_transform(), + "conflicting transform must be skipped" + ); + let errors = drain_errors(&mut rec); + assert_eq!(errors.len(), 1); + assert!(errors[0] + .message + .contains("Record already has a .link_from(); cannot also have a .transform().")); } #[test] @@ -1733,4 +1852,116 @@ mod tests { assert!(rec.has_producer()); assert_eq!(rec.consumer_count(), 1); } + + // ==================================================================== + // build()-level validation tests (issue #133) + // ==================================================================== + + #[derive(Debug, Clone)] + struct OtherRecord; + + /// Connector builder whose `build()` contributes nothing — for tests + /// that must get through the connector phase. + struct NoopConnectorBuilder; + + impl crate::connector::ConnectorBuilder for NoopConnectorBuilder { + fn build<'a>( + &'a self, + _db: &'a crate::AimDb, + ) -> Pin< + Box< + dyn Future< + Output = DbResult + Send + 'static>>>>, + > + Send + + 'a, + >, + > { + Box::pin(async { Ok(Vec::new()) }) + } + fn scheme(&self) -> &str { + "mqtt" + } + } + + /// Acceptance criterion (issue #133): a builder with three distinct + /// mistakes reports all three from one `build()` call. + #[tokio::test] + async fn build_reports_all_configuration_mistakes_at_once() { + let mut builder = crate::AimDbBuilder::new().runtime(Arc::new(MockRuntime)); + + // Mistake 1: outbound link with no serializer + builder.configure::("rec.a", |reg| { + reg.link_to("mqtt://broker/a").finish(); + }); + // Mistake 2: two .source() registrations on one record + builder.configure::("rec.b", |reg| { + reg.source_raw(|_p, _ctx| async move {}); + reg.source_raw(|_p, _ctx| async move {}); + }); + // Mistake 3: key re-registered with a different type + builder.configure::("rec.c", |_reg| {}); + builder.configure::("rec.c", |_reg| {}); + + let Err(err) = builder.build().await else { + panic!("build must fail"); + }; + let crate::DbError::InvalidConfiguration { errors } = err else { + panic!("expected InvalidConfiguration, got {err:?}"); + }; + assert_eq!(errors.len(), 3, "expected 3 errors, got: {errors:?}"); + assert!(errors.iter().any(|e| e.record_key == "rec.a" + && e.url.as_deref() == Some("mqtt://broker/a") + && e.message.contains("requires a serializer"))); + assert!(errors.iter().any( + |e| e.record_key == "rec.b" && e.message.contains("already has a producer service") + )); + assert!(errors + .iter() + .any(|e| e.record_key == "rec.c" && e.message.contains("different type"))); + } + + /// `.buffer()` after `.link_from()` is legitimate now that the buffer + /// requirement is validated by `build()` instead of `finish()`. + #[tokio::test] + async fn buffer_after_link_from_is_valid() { + let mut builder = crate::AimDbBuilder::new() + .runtime(Arc::new(MockRuntime)) + .with_connector(NoopConnectorBuilder); + + builder.configure::("rec.x", |reg| { + reg.link_from("mqtt://broker/x") + .with_deserializer_raw(|_b: &[u8]| Ok(TestRecord { value: 0 })) + .finish(); + // Buffer configured AFTER the link — order-independent. + reg.buffer_raw(Box::new(MockBuffer)); + }); + + builder.build().await.expect("build must succeed"); + } + + /// A linked record without a buffer fails at build() — previously this + /// panicked at spawn time, deep inside a connector factory closure. + #[tokio::test] + async fn linked_record_without_buffer_fails_build() { + let mut builder = crate::AimDbBuilder::new() + .runtime(Arc::new(MockRuntime)) + .with_connector(NoopConnectorBuilder); + + builder.configure::("rec.x", |reg| { + reg.link_from("mqtt://broker/x") + .with_deserializer_raw(|_b: &[u8]| Ok(TestRecord { value: 0 })) + .finish(); + }); + + let Err(err) = builder.build().await else { + panic!("build must fail"); + }; + let crate::DbError::InvalidConfiguration { errors } = err else { + panic!("expected InvalidConfiguration, got {err:?}"); + }; + assert_eq!(errors.len(), 1); + assert!(errors[0].message.contains("requires a buffer")); + assert_eq!(errors[0].record_key, "rec.x"); + assert_eq!(errors[0].url.as_deref(), Some("mqtt://broker/x")); + } } diff --git a/aimdb-core/src/typed_record.rs b/aimdb-core/src/typed_record.rs index 2b5e0ee..ad80d94 100644 --- a/aimdb-core/src/typed_record.rs +++ b/aimdb-core/src/typed_record.rs @@ -239,6 +239,16 @@ pub trait AnyRecord: Send + Sync { /// Returns whether a producer service is registered fn has_producer(&self) -> bool; + /// Returns whether a buffer is configured + fn has_buffer(&self) -> bool; + + /// Drains the configuration mistakes recorded during registration. + /// + /// Called by `AimDbBuilder::build()`, which fills in the record key and + /// reports every finding via + /// [`DbError::InvalidConfiguration`](crate::DbError::InvalidConfiguration). + fn drain_config_errors(&mut self) -> Vec; + /// Returns whether a transform is registered for this record fn has_transform(&self) -> bool; @@ -523,6 +533,12 @@ pub struct TypedRecord< /// `T: RemoteSerialize` bound is known at the call site. #[cfg(feature = "json-serialize")] remote_codec: Option>, + + /// Configuration mistakes recorded during registration instead of + /// panicking (issue #133). Drained by `AimDbBuilder::build()`, which fills + /// in the record key and reports all of them via + /// [`DbError::InvalidConfiguration`](crate::DbError::InvalidConfiguration). + config_errors: Vec, } impl @@ -546,9 +562,18 @@ impl &RecordProfilingMetrics { @@ -566,9 +591,11 @@ impl(&mut self, f: F) where F: FnOnce(crate::Producer, Arc) -> Fut + Send + 'static, @@ -576,16 +603,31 @@ impl, ) { // Enforce mutual exclusion with .source() if lock(&self.producer).is_some() { - panic!("Record already has a .source(); cannot also have a .transform()."); + self.push_config_error(crate::error::ConfigError::new( + "", + None, + "Record already has a .source(); cannot also have a .transform().", + )); + return; } if !self.inbound_connectors.is_empty() { - panic!("Record already has a .link_from(); cannot also have a .transform()."); + self.push_config_error(crate::error::ConfigError::new( + "", + None, + "Record already has a .link_from(); cannot also have a .transform().", + )); + return; } - let mut slot = lock(&self.transform); - if slot.is_some() { - panic!("Record already has a .transform(); only one is allowed."); + if lock(&self.transform).is_some() { + self.push_config_error(crate::error::ConfigError::new( + "", + None, + "Record already has a .transform(); only one is allowed.", + )); + return; } - *slot = Some(descriptor); + *lock(&self.transform) = Some(descriptor); } /// Returns whether a transform is registered for this record. @@ -887,17 +944,28 @@ impl bool { + TypedRecord::has_buffer(self) + } + + fn drain_config_errors(&mut self) -> Vec { + core::mem::take(&mut self.config_errors) + } + fn has_transform(&self) -> bool { TypedRecord::has_transform(self) } diff --git a/aimdb-tokio-adapter/tests/multi_instance_tests.rs b/aimdb-tokio-adapter/tests/multi_instance_tests.rs index 98dffbb..dbcacbc 100644 --- a/aimdb-tokio-adapter/tests/multi_instance_tests.rs +++ b/aimdb-tokio-adapter/tests/multi_instance_tests.rs @@ -197,12 +197,10 @@ async fn test_type_mismatch_error() { } } -/// Test: Duplicate key error (registering same key twice panics at configure time) -/// -/// Note: The current implementation panics during configure() rather than -/// returning an error during build(). This is a fail-fast design choice. +/// Test: Re-registering a key with a different type is collected by +/// configure() and reported from build() (issue #133 — builder methods never +/// panic on user mistakes). #[tokio::test] -#[should_panic(expected = "already registered with different type")] async fn test_duplicate_key_error() { let runtime = Arc::new(TokioAdapter::new().unwrap()); @@ -213,10 +211,17 @@ async fn test_duplicate_key_error() { reg.buffer(BufferCfg::SingleLatest); }); - // Try to register second record with same key - should panic + // Re-register the same key with a different type — recorded, not panicked builder.configure::("shared.key", |reg| { reg.buffer(BufferCfg::SingleLatest); }); + + let Err(DbError::InvalidConfiguration { errors }) = builder.build().await else { + panic!("build() must fail with InvalidConfiguration"); + }; + assert_eq!(errors.len(), 1); + assert_eq!(errors[0].record_key, "shared.key"); + assert!(errors[0].message.contains("different type")); } /// Test: RecordId remains stable and can be used for O(1) access