diff --git a/CHANGELOG.md b/CHANGELOG.md index b183dd8..181438c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -188,3 +188,51 @@ Laminar can now route traffic using runtime-aware balancing strategies instead o The current implementation intentionally keeps algorithm dispatch simple using enum matching and naive selection logic. More advanced balancing abstractions and optimizations will evolve later as additional strategies are introduced. + +--- + +## 2026-05-20 + +### Added + +- Added retry stabilization to avoid retrying already-failed backends during a single request lifecycle +- Added runtime behavior tests for: + - connection guard lifecycle tracking + - timeout-based unhealthy backend handling + - retry stabilization behavior +- Added backend recovery logging for healthy state transitions +- Added Excalidraw runtime architecture diagram to README +- Added visual documentation for: + - runtime structs + - backend ownership model + - shared state relationships + - YAML configuration flow + +### Changed + +- Refactored proxy connection handling into isolated helper flow +- Improved timeout and proxy error logging with clearer runtime context +- Reduced health checker lock scope to avoid holding shared state locks across async network operations +- Improved retry logging with backend-aware runtime details +- Cleaned up retry flow readability and connection orchestration structure + +### Notes + +This phase focused heavily on runtime stability, observability, and internal architecture clarity. + +Laminar now has stronger runtime behavior guarantees around: + +- retry isolation +- backend failover handling +- timeout-aware connection management +- async-safe shared state access +- connection lifecycle tracking + +The runtime architecture documentation was also expanded to better explain how: + +- `AppState` +- `UpstreamPool` +- `BackendState` +- `ConnectionGuard` + +interact during live traffic routing and health monitoring. diff --git a/ROADMAP.md b/ROADMAP.md index 3e9f3bb..32ee9f1 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -19,14 +19,10 @@ - [x] Create listening socket - [x] Bind address/port -- [ ] Configure socket options - - [ ] SO_REUSEADDR - - [ ] TCP_NODELAY - - [ ] Keepalive - [x] Implement accept loop - [x] Handle concurrent client connections - [x] Handle client disconnects -- [ ] Implement connection cleanup +- [x] Implement connection cleanup --- @@ -46,7 +42,7 @@ - [x] Implement backend registry - [x] Track backend runtime state -- [ ] Add backend availability tracking +- [x] Add backend availability tracking - [x] Implement backend selection interface --- @@ -84,7 +80,7 @@ - [x] Create background health task - [x] Add configurable health intervals -- [ ] Add backend recovery detection +- [x] Add backend recovery detection --- @@ -107,10 +103,10 @@ ## Retry Logic -- [ ] Retry failed backend connections -- [ ] Retry next available backend -- [ ] Add retry limits -- [ ] Add retry logging +- [x] Retry failed backend connections +- [x] Retry next available backend +- [x] Add retry limits +- [x] Add retry logging --- @@ -125,20 +121,19 @@ ## Runtime State Refactor -- [ ] Reduce lock scope sizes -- [ ] Refactor duplicated runtime logic -- [ ] Separate balancing module -- [ ] Separate health module -- [ ] Improve state ownership model +- [x] Reduce lock scope sizes +- [x] Separate balancing module +- [x] Separate health module +- [x] Improve state ownership model --- ## Phase 2 Deliverable -- [ ] Health-aware balancing -- [ ] Retry support -- [ ] Connection metrics -- [ ] Runtime stability improvements +- [x] Health-aware balancing +- [x] Retry support +- [x] Connection metrics +- [x] Runtime stability improvements --- @@ -174,9 +169,9 @@ - [ ] Structured JSON logs - [ ] Request correlation IDs -- [ ] Retry logging -- [ ] Timeout logging -- [ ] Backend transition logging +- [x] Retry logging +- [x] Timeout logging +- [x] Backend transition logging --- @@ -219,9 +214,6 @@ ## Event-Driven Runtime -- [ ] Integrate epoll -- [ ] Add edge-triggered events -- [ ] Implement event batching - [ ] Add worker thread model --- diff --git a/tests/connect_timeout.rs b/tests/connect_timeout.rs index 6f28774..40e453d 100644 --- a/tests/connect_timeout.rs +++ b/tests/connect_timeout.rs @@ -30,7 +30,7 @@ async fn marks_backend_unhealthy_on_connect_timeout() { let address = guard.address(); let result = timeout(Duration::from_millis(100), TcpStream::connect(address)).await; assert!(result.is_err()); - + assert!(backend.healthy.load(Ordering::Relaxed)); guard.mark_backend_unhealthy(); assert!(!backend.healthy.load(Ordering::Relaxed)); } diff --git a/tests/connection_guard.rs b/tests/connection_guard.rs new file mode 100644 index 0000000..1b6d9aa --- /dev/null +++ b/tests/connection_guard.rs @@ -0,0 +1,32 @@ +use std::sync::{ + Arc, + atomic::{AtomicBool, AtomicUsize, Ordering}, +}; + +use laminar::{ + config::types::BackendServerConfig, + state::backend::{BackendState, ConnectionGuard}, +}; + +#[test] +fn connection_guard_tracks_active_connections() { + let backend = Arc::new(BackendState { + config: BackendServerConfig { + id: "server-1".to_string(), + host: "127.0.0.1".to_string(), + port: 9001, + weight: 1, + }, + healthy: AtomicBool::new(true), + active_connections: AtomicUsize::new(0), + failed_health_checks: 0, + }); + + assert_eq!(backend.active_connections.load(Ordering::Relaxed), 0); + { + let _guard = ConnectionGuard::new(backend.clone()); + assert_eq!(backend.active_connections.load(Ordering::Relaxed), 1); + } + + assert_eq!(backend.active_connections.load(Ordering::Relaxed), 0); +} diff --git a/tests/retry_stabilization.rs b/tests/retry_stabilization.rs new file mode 100644 index 0000000..0aeb42b --- /dev/null +++ b/tests/retry_stabilization.rs @@ -0,0 +1,43 @@ +use std::sync::{ + Arc, + atomic::{AtomicBool, AtomicUsize, Ordering}, +}; + +use laminar::{ + algorithms::round_robin, config::types::BackendServerConfig, state::backend::BackendState, +}; + +#[test] +fn unhealthy_backend_is_not_selected_again() { + let backend_1 = Arc::new(BackendState { + config: BackendServerConfig { + id: "dead-server".to_string(), + host: "127.0.0.1".to_string(), + port: 9001, + weight: 1, + }, + + healthy: AtomicBool::new(false), + active_connections: AtomicUsize::new(0), + failed_health_checks: 0, + }); + + let backend_2 = Arc::new(BackendState { + config: BackendServerConfig { + id: "healthy-server".to_string(), + host: "127.0.0.1".to_string(), + port: 9002, + weight: 1, + }, + + healthy: AtomicBool::new(true), + active_connections: AtomicUsize::new(0), + failed_health_checks: 0, + }); + + let backends = vec![backend_1, backend_2.clone()]; + let index = AtomicUsize::new(0); + let selected = round_robin::select_backend(&backends, &index).unwrap(); + assert_eq!(selected.config.id, "healthy-server"); + assert!(selected.healthy.load(Ordering::Relaxed)); +}