From bc21a87de0a73226ca642f192b5bc556d60dac8d Mon Sep 17 00:00:00 2001 From: Rohit Date: Fri, 15 May 2026 18:19:55 +0530 Subject: [PATCH 1/2] feat: add initial setup instructions and health check functionality --- .githooks/pre-commit | 9 +++++++ .githooks/pre-push | 7 +++++ CONTRIBUTING.md | 23 ++++++++++++++++ README.md | 21 +++++++++++++++ makefile | 5 ++++ scripts/fix.sh | 0 src/config/default.rs | 1 + src/config/types.rs | 2 ++ src/health/tcp.rs | 26 ++++++++++-------- src/main.rs | 8 +++--- tests/health_checker.rs | 51 +++++++++++++++++++++++++++++++++++ tests/health_selection.rs | 56 +++++++++++++++++++++++++++++++++++++++ tests/round_robin.rs | 46 ++++++++++++++++++++++++++++++++ 13 files changed, 239 insertions(+), 16 deletions(-) create mode 100755 .githooks/pre-commit create mode 100755 .githooks/pre-push mode change 100644 => 100755 scripts/fix.sh create mode 100644 tests/health_checker.rs create mode 100644 tests/health_selection.rs create mode 100644 tests/round_robin.rs diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 0000000..fa3b9a0 --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,9 @@ +#!/usr/bin/env bash + +set -e + +echo "==> Running auto-fixes" + +make fix + +git add . \ No newline at end of file diff --git a/.githooks/pre-push b/.githooks/pre-push new file mode 100755 index 0000000..379326d --- /dev/null +++ b/.githooks/pre-push @@ -0,0 +1,7 @@ +#!/usr/bin/env bash + +set -e + +echo "==> Running verification checks" + +make verify \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 86d8f77..dd69b27 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,6 +4,29 @@ Thanks for contributing to Laminar. --- +# Initial Setup + +After cloning the repository, run: + +```bash +make setup +``` + +This configures: + +- local git hooks +- executable scripts +- automated pre-commit and pre-push checks + +After setup: + +- `git commit` automatically runs formatting and clippy fixes +- `git push` automatically runs verification checks + +This setup only needs to be run once per repository clone. + +--- + # Development Workflow Before pushing code, run: diff --git a/README.md b/README.md index 4010755..2cd14a8 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,27 @@ cargo install cargo-audit cargo install cargo-deny ``` +# Initial Setup + +After cloning the repository, run: + +```bash +make setup +``` + +This configures: + +- local git hooks +- executable development scripts +- automated pre-commit and pre-push checks + +After setup: + +- `git commit` automatically runs formatting and clippy fixes +- `git push` automatically runs verification checks + +This setup only needs to be run once per repository clone. + --- # Running The Project diff --git a/makefile b/makefile index 32dfd75..cfdf565 100644 --- a/makefile +++ b/makefile @@ -22,6 +22,11 @@ clean: ping-test: cargo test --test ping_check -- --nocapture +setup: + chmod +x scripts/*.sh + chmod +x .githooks/* + git config core.hooksPath .githooks + clippy: cargo clippy --all-targets --all-features -- -D warnings diff --git a/scripts/fix.sh b/scripts/fix.sh old mode 100644 new mode 100755 diff --git a/src/config/default.rs b/src/config/default.rs index b2b1b81..2bcc3e2 100644 --- a/src/config/default.rs +++ b/src/config/default.rs @@ -8,6 +8,7 @@ server: load_balancer: retry_attempts: 2 sticky_sessions: false + health_check_interval_secs: 5 upstreams: - id: "main" diff --git a/src/config/types.rs b/src/config/types.rs index 52bc1e1..70300f0 100644 --- a/src/config/types.rs +++ b/src/config/types.rs @@ -26,6 +26,8 @@ pub struct ServerConfig { pub struct LoadBalancerConfig { pub retry_attempts: usize, pub sticky_sessions: bool, + + pub health_check_interval_secs: u64, } // Static backend server definition loaded from configuration. diff --git a/src/health/tcp.rs b/src/health/tcp.rs index 9993d7b..52f1bb7 100644 --- a/src/health/tcp.rs +++ b/src/health/tcp.rs @@ -3,7 +3,7 @@ use std::{sync::atomic::Ordering, time::Duration}; use crate::state::{app::SharedAppState, backend::BackendState}; use anyhow::Result; use tokio::{net::TcpStream, time::sleep}; -use tracing::info; +use tracing::{info, warn}; // This will evolve later into: // - retries @@ -13,21 +13,23 @@ use tracing::info; pub async fn check_backend_status(backend: &BackendState) -> Result<()> { let backend_address = { format!("{}:{}", backend.config.host, backend.config.port) }; - match TcpStream::connect(&backend_address).await { - Ok(_) => { - backend.healthy.store(true, Ordering::Relaxed); - info!("backend {} healthy", backend.config.id); - } - Err(_) => { - backend.healthy.store(false, Ordering::Relaxed); - info!("backend {} unreachable", backend.config.id); + let was_healthy = backend.healthy.load(Ordering::Relaxed); + let is_healthy = TcpStream::connect(&backend_address).await.is_ok(); + + backend.healthy.store(is_healthy, Ordering::Relaxed); + + if was_healthy != is_healthy { + if is_healthy { + info!("backend '{}' recovered", backend.config.id); + } else { + warn!("backend '{}' became unhealthy", backend.config.id); } } Ok(()) } -pub async fn start_health_checker(state: SharedAppState) { +pub async fn start_health_checker(state: SharedAppState, interval_secs: u64) { loop { let state = state.read().await; for upstream in &state.upstreams { @@ -35,7 +37,9 @@ pub async fn start_health_checker(state: SharedAppState) { let _ = check_backend_status(backend).await; } } + + // releasing the lock before going to sleep .. drop(state); - sleep(Duration::from_secs(5)).await; + sleep(Duration::from_secs(interval_secs)).await; } } diff --git a/src/main.rs b/src/main.rs index 7c3c5bb..21d2a2f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -25,13 +25,12 @@ async fn main() -> Result<()> { info!("config validation successful"); let listener_host = config.server.host.clone(); - let listener_port = config.server.port; - let state = AppState::build(config); + let health_interval = config.load_balancer.health_check_interval_secs; + let state = AppState::build(config); info!("initialized {} upstream pools", state.upstreams.len()); - if state.upstreams.is_empty() { bail!("no upstreams configured"); } @@ -42,9 +41,8 @@ async fn main() -> Result<()> { // } let health_state = shared_state.clone(); - tokio::spawn(async move { - start_health_checker(health_state).await; + start_health_checker(health_state, health_interval).await; }); let listener_address = format!("{listener_host}:{listener_port}"); diff --git a/tests/health_checker.rs b/tests/health_checker.rs new file mode 100644 index 0000000..a086a37 --- /dev/null +++ b/tests/health_checker.rs @@ -0,0 +1,51 @@ +use std::sync::atomic::{AtomicBool, Ordering}; + +use tokio::net::TcpListener; + +use laminar::{ + config::types::BackendServerConfig, health::tcp::check_backend_status, + state::backend::BackendState, +}; + +fn create_backend(port: u16) -> BackendState { + BackendState { + config: BackendServerConfig { + id: "test-backend".to_string(), + host: "127.0.0.1".to_string(), + port, + weight: 1, + }, + + healthy: AtomicBool::new(false), + + active_connections: 0, + + failed_health_checks: 0, + } +} + +#[tokio::test] +async fn backend_becomes_healthy() { + let listener = TcpListener::bind("127.0.0.1:9999").await.unwrap(); + + tokio::spawn(async move { + loop { + let _ = listener.accept().await; + } + }); + + let backend = create_backend(9999); + + check_backend_status(&backend).await.unwrap(); + + assert!(backend.healthy.load(Ordering::Relaxed)); +} + +#[tokio::test] +async fn backend_becomes_unhealthy() { + let backend = create_backend(9998); + + check_backend_status(&backend).await.unwrap(); + + assert!(!backend.healthy.load(Ordering::Relaxed)); +} diff --git a/tests/health_selection.rs b/tests/health_selection.rs new file mode 100644 index 0000000..c2065c3 --- /dev/null +++ b/tests/health_selection.rs @@ -0,0 +1,56 @@ +use std::sync::atomic::AtomicBool; + +use laminar::{ + config::types::BackendServerConfig, + state::{app::UpstreamPool, backend::BackendState}, +}; + +fn create_backend(id: &str, port: u16, healthy: bool) -> BackendState { + BackendState { + config: BackendServerConfig { + id: id.to_string(), + host: "127.0.0.1".to_string(), + port, + weight: 1, + }, + + healthy: AtomicBool::new(healthy), + + active_connections: 0, + + failed_health_checks: 0, + } +} + +#[test] +fn unhealthy_backend_is_skipped() { + let upstream = UpstreamPool { + id: "main".to_string(), + + current_index: (0).into(), + + backends: vec![create_backend("dead", 9001, false), create_backend("healthy", 9002, true)], + }; + + let backend = upstream.next_backend().unwrap(); + + assert_eq!(backend.config.port, 9002); +} + +#[test] +fn returns_none_when_all_backends_dead() { + let upstream = UpstreamPool { + id: "main".to_string(), + + current_index: (0).into(), + + backends: vec![ + create_backend("dead-1", 9001, false), + create_backend("dead-2", 9002, false), + ], + }; + + let backend = upstream.next_backend(); + + assert!(backend.is_none()); +} diff --git a/tests/round_robin.rs b/tests/round_robin.rs new file mode 100644 index 0000000..153e1e8 --- /dev/null +++ b/tests/round_robin.rs @@ -0,0 +1,46 @@ +use std::sync::atomic::AtomicBool; + +use laminar::{ + config::types::BackendServerConfig, + state::{app::UpstreamPool, backend::BackendState}, +}; + +fn create_backend(id: &str, port: u16) -> BackendState { + BackendState { + config: BackendServerConfig { + id: id.to_string(), + host: "127.0.0.1".to_string(), + port, + weight: 1, + }, + + healthy: AtomicBool::new(true), + + active_connections: 0, + + failed_health_checks: 0, + } +} + +#[test] +fn round_robin_rotates_backends() { + let upstream = UpstreamPool { + id: "main".to_string(), + + current_index: (0).into(), + + backends: vec![create_backend("server-1", 9001), create_backend("server-2", 9002)], + }; + + let first = upstream.next_backend().unwrap(); + + let second = upstream.next_backend().unwrap(); + + let third = upstream.next_backend().unwrap(); + + assert_eq!(first.config.port, 9001); + + assert_eq!(second.config.port, 9002); + + assert_eq!(third.config.port, 9001); +} From 2667f1f2f478d4c556d9429edaddf76fd303c0f8 Mon Sep 17 00:00:00 2001 From: Rohit Date: Fri, 15 May 2026 18:27:06 +0530 Subject: [PATCH 2/2] update changelog --- CHANGELOG.md | 58 ++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 56 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 785c35d..154ed23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -74,8 +74,62 @@ This restructuring prepares Laminar for: The internal architecture now more closely resembles real-world proxy and load balancer systems. -## 2026-05-14 +--- + +## 2026-05-15 ### Added -- Added CI checks and scripts +- Added basic TCP reverse proxy functionality +- Added bidirectional TCP traffic forwarding using Tokio +- Added naive round robin backend selection +- Added runtime backend health tracking using `AtomicBool` +- Added periodic background backend health monitoring +- Added automatic unhealthy backend skipping during selection +- Added configurable health check intervals through YAML config +- Added graceful handling when no healthy backends are available +- Added integration tests for: + - round robin balancing + - unhealthy backend filtering + - backend health probing + - dead backend handling + +### Changed + +- Refactored backend selection flow to reduce shared state lock contention +- Replaced mutable round robin counters with `AtomicUsize` +- Reduced runtime lock scope during backend selection +- Improved health logging to only emit meaningful state transitions +- Refactored proxy flow to separate: + - backend selection + - backend connection + - traffic forwarding + +### Notes + +This phase transformed Laminar from a static proxy prototype into a dynamically adaptive load balancer runtime. + +Laminar can now: + +- forward real TCP traffic between clients and backend servers +- distribute requests across backend pools +- detect backend failures at runtime +- automatically avoid routing traffic to unhealthy servers +- recover backend availability without requiring restarts + +The implementation intentionally prioritizes simplicity and observability over advanced optimizations. + +Current health monitoring behavior remains intentionally naive: + +- direct TCP connectivity probing +- immediate healthy/unhealthy transitions +- sequential backend checking + +This creates a clean foundation for future improvements such as: + +- retry policies +- failure thresholds +- recovery delays +- latency-aware health scoring +- parallelized health probes +- advanced balancing strategies