Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .githooks/pre-commit
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
#!/usr/bin/env bash

set -e

echo "==> Running auto-fixes"

make fix

git add .
7 changes: 7 additions & 0 deletions .githooks/pre-push
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
#!/usr/bin/env bash

set -e

echo "==> Running verification checks"

make verify
58 changes: 56 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
23 changes: 23 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Empty file modified scripts/fix.sh
100644 → 100755
Empty file.
1 change: 1 addition & 0 deletions src/config/default.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ server:
load_balancer:
retry_attempts: 2
sticky_sessions: false
health_check_interval_secs: 5

upstreams:
- id: "main"
Expand Down
2 changes: 2 additions & 0 deletions src/config/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
26 changes: 15 additions & 11 deletions src/health/tcp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -13,29 +13,33 @@ 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 {
for backend in &upstream.backends {
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;
}
}
8 changes: 3 additions & 5 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
Expand All @@ -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}");
Expand Down
51 changes: 51 additions & 0 deletions tests/health_checker.rs
Original file line number Diff line number Diff line change
@@ -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));
}
56 changes: 56 additions & 0 deletions tests/health_selection.rs
Original file line number Diff line number Diff line change
@@ -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());
}
Loading
Loading