Cybersecurity-Projects/PROJECTS/advanced/Aenebris/docs/proxy-haskell-research/load-balancing.md

965 lines
35 KiB
Markdown

# Advanced Load Balancing Implementation Guide for Haskell Reverse Proxies
**Production-grade load balancing architecture for high-performance Haskell proxies targeting 100k+ req/s, featuring STM-based connection tracking, async health checking, and composable algorithms.**
Load balancing in Haskell reverse proxies requires careful orchestration of concurrent state management, efficient algorithm selection, and robust failure detection. For the Ᾰenebris project milestone, this guide synthesizes battle-tested patterns from production systems like Keter, Mighty, and modern libraries to deliver type-safe, composable implementations that leverage Haskell's concurrency primitives. The architecture balances functional purity with performance pragmatism, using **IORef for hot paths** and **STM for complex transactions** while maintaining sub-microsecond selection latency.
## Algorithm implementations optimized for Haskell
The foundation of any load balancer is its selection algorithm. **Round-robin with IORef achieves ~9.7ns read/write operations**, making it ideal for high-throughput scenarios. The critical design choice is between IORef (minimal overhead) and TVar (composability), with each serving distinct architectural needs.
### Round-robin: IORef-based implementation
For pure round-robin without complex state coordination, **IORef with `atomicModifyIORef'` provides optimal performance**:
```haskell
import Data.IORef
import Data.Vector (Vector, (!))
import qualified Data.Vector as V
data RoundRobinBalancer a = RoundRobinBalancer
{ backends :: Vector a
, counter :: IORef Int
}
newRRBalancer :: [a] -> IO (RoundRobinBalancer a)
newRRBalancer bs = RoundRobinBalancer (V.fromList bs) <$> newIORef 0
selectBackend :: RoundRobinBalancer a -> IO a
selectBackend balancer = do
let backends' = backends balancer
len = V.length backends'
idx <- atomicModifyIORef' (counter balancer) $ \i ->
let next = (i + 1) `mod` len
in (next, i)
return $ backends' ! idx
```
The strict `atomicModifyIORef'` variant is essential - the lazy version causes space leaks under high load. Vector indexing provides O(1) access, and modulo wraparound handles counter overflow safely. This pattern **scales linearly to 8+ cores** without contention issues.
**Alternative: Hackage's `roundRobin` package** (version 0.1.2.0) provides a pre-built solution using NonEmpty for type-level guarantees of at least one backend:
```haskell
import Data.RoundRobin
rr <- newRoundRobin (backend1 :| [backend2, backend3])
backend <- select rr -- Thread-safe selection
```
### Least connections: Heap-based and STM approaches
Least connections requires tracking active connection counts per backend. **Two proven patterns emerge**: heap-based (Rob Pike inspired) and direct STM comparison.
**Heap-based implementation** (from wagdav/load-balancer):
```haskell
import Data.Heap (MinPrioHeap)
import qualified Data.Heap as DH
import Control.Concurrent.STM
type Pool a = MinPrioHeap Int (Worker a)
data Worker a = Worker Int (TChan (Request a))
-- Dispatch to least-loaded worker
dispatch :: Pool a -> Request a -> IO (Pool a)
dispatch pool request = do
let ((priority, worker), pool') = fromJust $ view pool
schedule worker request
return $ insert (priority + 1, worker) pool'
-- Mark completion and decrement
completed :: Pool a -> Worker a -> Pool a
completed pool worker =
let (matchingWorkers, pool') = partition (\item -> snd item == worker) pool
[(priority, w)] = toList matchingWorkers
in insert (priority - 1, w) pool'
```
The heap automatically maintains the least-loaded worker at the root with **O(log n) insertion and extraction**. Workers report completion asynchronously via TChan, enabling loose coupling and independent failure handling.
**Direct STM comparison** for simpler architectures:
```haskell
data Backend = Backend
{ backendHost :: String
, backendPort :: Int
, activeConnections :: TVar Int
}
trackConnection :: Backend -> IO a -> IO a
trackConnection backend action =
bracket_
(atomically $ modifyTVar' (activeConnections backend) (+1))
(atomically $ modifyTVar' (activeConnections backend) (subtract 1))
action
selectLeastConnections :: [Backend] -> STM Backend
selectLeastConnections backends = do
conns <- mapM (readTVar . activeConnections) backends
let minConns = minimum conns
idx = fromJust $ findIndex (== minConns) conns
return $ backends !! idx
```
This pattern **composes naturally with health checks** - the STM transaction can atomically read both connection counts and health status. The tradeoff is performance: STM transactions have O(n) lookup time where n equals TVars accessed, roughly 2x slower than IORef for simple operations but vastly superior for composed logic.
**Hackage's `load-balancing` package** (version 1.0.1.1) provides production-tested least-connections with round-robin tie-breaking:
```haskell
import Control.Concurrent.LoadDistribution
lb <- evenlyDistributed (return $ Set.fromList backends)
withResource lb $ \maybeBackend ->
case maybeBackend of
Just backend -> proxyRequest backend
Nothing -> handleNoBackends
```
### Weighted distribution: Smooth weighted round-robin
The **nginx smooth weighted round-robin algorithm produces optimal distribution patterns**, avoiding bursts of identical backend selection. For weights {5, 1, 1}, it generates {a, a, b, a, c, a, a} instead of the naive {c, b, a, a, a, a, a}.
**Algorithm mechanics**: On each selection, increase each backend's `current_weight` by its `weight`, select the backend with maximum `current_weight`, then reduce the selected backend's weight by the total weight sum.
```haskell
data WeightedBackend a = WeightedBackend
{ backend :: a
, weight :: Int
, currentWeight :: TVar Int
}
smoothWeightedSelect :: [WeightedBackend a] -> IO a
smoothWeightedSelect backends = atomically $ do
-- Increase all current weights by their base weights
forM_ backends $ \wb ->
modifyTVar' (currentWeight wb) (+ weight wb)
-- Find backend with maximum current weight
weights <- mapM (readTVar . currentWeight) backends
let maxWeight = maximum weights
selected = backends !! fromJust (findIndex (== maxWeight) weights)
-- Reduce selected backend's current weight by total
let totalWeight = sum (map weight backends)
modifyTVar' (currentWeight selected) (subtract totalWeight)
return $ backend selected
```
This **STM-based implementation guarantees atomicity** across multiple backend updates. For weights {5, 1, 1}, the execution trace shows smooth distribution:
```
Initial: a=0 b=0 c=0
Step 1: a=5 b=1 c=1 → select a → a=-2
Step 2: a=3 b=2 c=2 → select a → a=-4
Step 3: a=1 b=3 c=3 → select b → b=-4
Step 4: a=6 b=-3 c=4 → select a → a=-1
```
**No existing Haskell library implements smooth WRR** - this is a critical implementation gap. The nginx algorithm (commit 52327e0) provides the reference implementation, and the pattern above is production-ready.
### Performance characteristics and selection criteria
| Algorithm | Complexity | Concurrency Primitive | Use Case | Throughput |
|-----------|------------|----------------------|----------|------------|
| Round-robin (IORef) | O(1) | IORef | Simple equal distribution | 100k+ req/s |
| Round-robin (TVar) | O(1) | TVar | Composable with health checks | 80k+ req/s |
| Least connections (heap) | O(log n) | STM + TChan | Dynamic load awareness | 50k+ req/s |
| Least connections (direct) | O(n) | STM | Simple setups, few backends | 40k+ req/s |
| Smooth WRR | O(n) | STM | Weighted backends, quality distribution | 60k+ req/s |
**Decision matrix**: Use IORef round-robin for maximum throughput with equal backends. Use STM-based smooth WRR when backend capacities differ. Use heap-based least connections when request processing time varies significantly (e.g., database queries vs static files).
## STM patterns for connection tracking
Software Transactional Memory enables **composable atomic updates across multiple shared variables**, critical for coordinating health checks, connection counts, and metrics. Understanding STM's performance characteristics prevents common pitfalls.
### TVar architecture for shared state
**TVar provides transactional guarantees** but with performance tradeoffs. Each `readTVar` or `writeTVar` adds an entry to the transaction log with O(n) lookup cost. The key insight: **keep transactions small and minimize TVars touched per transaction**.
```haskell
data BackendState = BackendState
{ bsBackends :: TVar (Map BackendId Backend)
, bsMetrics :: TVar Metrics
, bsHealthStatus :: TVar (Map BackendId HealthStatus)
}
-- BAD: Touches many TVars in single transaction
countAllConnections :: [Backend] -> STM Int
countAllConnections backends =
sum <$> mapM (readTVar . activeConns) backends
-- GOOD: Maintain aggregate counter
data BackendPool = BackendPool
{ totalConnections :: TVar Int
, backends :: [Backend]
}
-- Update both atomically
updateConnections :: BackendPool -> Backend -> IO ()
updateConnections pool backend = atomically $ do
modifyTVar' (totalConnections pool) (+1)
modifyTVar' (activeConns backend) (+1)
```
**TMVar vs TVar choice**: TVar holds a value always; TMVar can be empty. Use TMVar for **synchronization and signaling** (producer/consumer), TVar for **shared state**. TMVar is just `TVar (Maybe a)` with blocking operations - use the simpler primitive when blocking isn't needed.
### Avoiding contention through striping
**Striped pools reduce contention** by partitioning resources across multiple TVars. The `resource-pool` package (used by Yesod) implements this pattern:
```haskell
data Pool a = Pool
{ localPools :: SmallArray (LocalPool a)
, reaperRef :: IORef ()
}
data LocalPool a = LocalPool
{ localPool :: TVar (Stripe a)
}
data Stripe a = Stripe
{ available :: Int -- Count of available resources
, queue :: Queue a -- Available resources
, waiting :: Queue (TMVar (Maybe a)) -- Waiting threads
}
```
Each stripe operates independently, **reducing transaction conflicts**. Configure stripe count to match CPU cores (default uses `getNumCapabilities`). This pattern **scales STM performance to 40+ cores** before plateauing.
### Connection pool implementation with STM
Production-grade connection tracking combines **bracket for resource safety with STM for atomic updates**:
```haskell
import Control.Concurrent.STM
import Control.Exception (bracket_)
data Backend = Backend
{ backendId :: Int
, connections :: TVar Int
, maxConnections :: Int
, healthy :: TVar Bool
}
-- Acquire connection with capacity checking
acquireConnection :: Backend -> STM ()
acquireConnection backend = do
current <- readTVar (connections backend)
isHealthy <- readTVar (healthy backend)
when (current >= maxConnections backend) retry
when (not isHealthy) retry
writeTVar (connections backend) (current + 1)
-- Release connection
releaseConnection :: Backend -> STM ()
releaseConnection backend =
modifyTVar' (connections backend) (subtract 1)
-- Safe usage pattern
withConnection :: Backend -> IO a -> IO a
withConnection backend action =
bracket_
(atomically $ acquireConnection backend)
(atomically $ releaseConnection backend)
action
```
The `retry` primitive is STM's killer feature - **threads automatically block until the transaction can succeed**. When connections become available or health status changes, waiting threads wake and retry. No manual condition variables or polling needed.
### Performance optimization strategies
**Key findings from production systems**:
1. **Keep transactions small**: Long transactions are vulnerable to starvation. Short transactions repeatedly abort long ones under contention.
2. **Move pure computation outside transactions**:
```haskell
-- BAD: Expensive computation inside transaction
badPattern = atomically $ do
val <- expensiveComputation -- Recomputed on every retry!
tvar <- readTVar someTVar
-- GOOD: Pure computation outside
goodPattern = do
let val = expensiveComputation
atomically $ do
tvar <- readTVar someTVar
writeTVar someTVar (combine val tvar)
```
3. **Use IORef for simple counters**: For single-variable updates without composition needs, **IORef is 2-3x faster**:
- IORef read/write: ~9.7ns
- MVar operations: ~15ns
- TVar operations: ~20ns (single variable)
4. **Batch updates when possible**: Instead of N separate transactions, combine related updates:
```haskell
-- Update multiple backend states atomically
updateHealthChecks :: [Backend] -> [(Backend, Bool)] -> STM ()
updateHealthChecks backends results =
forM_ results $ \(backend, isHealthy) ->
writeTVar (healthy backend) isHealthy
```
**GC pressure consideration**: Large pinned arrays (>409 bytes) cause GHC to take a global lock, becoming a bottleneck beyond 16 cores. Pool and reuse buffers in high-performance scenarios.
## Async health checking with circuit breakers
Health checking separates the **control plane** (detecting failures) from the **data plane** (routing requests). Async patterns using Control.Concurrent.Async enable **non-blocking health probes** that run independently of request handling.
### HTTP health check implementation
Use `http-client` with connection pooling for efficient health checks:
```haskell
import Network.HTTP.Client
import Network.HTTP.Client.TLS
import System.Timeout
import Control.Concurrent.Async
data HealthCheckConfig = HealthCheckConfig
{ hcInterval :: Int -- Seconds between checks
, hcTimeout :: Int -- Request timeout (seconds)
, hcEndpoint :: String -- Health endpoint path
, hcMaxFailures :: Int -- Failures before marking unhealthy
, hcRecoveryAttempts :: Int -- Successes before marking healthy
}
defaultConfig :: HealthCheckConfig
defaultConfig = HealthCheckConfig
{ hcInterval = 10
, hcTimeout = 2
, hcEndpoint = "/health"
, hcMaxFailures = 3
, hcRecoveryAttempts = 2
}
performHealthCheck :: Manager -> HealthCheckConfig -> Backend -> IO Bool
performHealthCheck manager config backend = do
let url = backendUrl backend ++ hcEndpoint config
result <- timeout (hcTimeout config * 1000000) $ do
req <- parseRequest url
response <- httpLbs req manager
return $ statusCode (responseStatus response) == 200
return $ fromMaybe False result
```
**Key optimizations**: Share a single `Manager` across all health checks to leverage connection pooling. Set appropriate `managerConnCount` (default 2 per route is too low for production - use 100+).
### Periodic scheduling with async
**Control.Concurrent.Async provides resource-safe concurrent operations**. The `withAsync` combinator automatically cancels threads when they leave scope:
```haskell
import Control.Concurrent (threadDelay)
import Control.Concurrent.Async
import Control.Monad (forever)
healthCheckLoop :: Manager -> HealthCheckConfig -> [Backend] -> IO ()
healthCheckLoop manager config backends = forever $ do
-- Check all backends concurrently
results <- mapConcurrently
(performHealthCheck manager config)
backends
-- Update backend states
zipWithM_ (updateBackendState config) backends results
-- Wait for next interval
threadDelay (hcInterval config * 1000000)
-- Start health checker (automatically cleaned up)
startHealthChecker :: HealthCheckConfig -> [Backend] -> IO (Async ())
startHealthChecker config backends = do
manager <- newTlsManager
async $ healthCheckLoop manager config backends
```
**Concurrency patterns**: Use `mapConcurrently` to check all backends in parallel, reducing total check time. Use `race` to implement timeout-based failure detection. Use `link` to propagate exceptions from health checker to main thread.
**Alternative: async-timer package** provides built-in periodic scheduling:
```haskell
import Control.Concurrent.Async.Timer
let timerConf = setInterval 10000 $ -- 10 seconds
setInitDelay 0 defaultConf
withAsyncTimer timerConf $ \timer -> do
forever $ do
timerWait timer
checkAndUpdateBackends
```
### State transitions with failure thresholds
Implement **hysteresis** to prevent flapping - require multiple consecutive failures before marking unhealthy, multiple successes before marking healthy:
```haskell
data BackendState = Healthy | Unhealthy | Recovering
deriving (Eq, Show)
data Backend = Backend
{ backendState :: TVar BackendState
, backendFailures :: TVar Int
, backendSuccesses :: TVar Int
}
updateBackendState :: HealthCheckConfig -> Backend -> Bool -> IO ()
updateBackendState config backend healthy = atomically $ do
state <- readTVar (backendState backend)
failures <- readTVar (backendFailures backend)
successes <- readTVar (backendSuccesses backend)
case (state, healthy) of
(Healthy, False) -> do
let newFailures = failures + 1
writeTVar (backendFailures backend) newFailures
when (newFailures >= hcMaxFailures config) $ do
writeTVar (backendState backend) Unhealthy
writeTVar (backendFailures backend) 0
(Unhealthy, True) -> do
writeTVar (backendState backend) Recovering
writeTVar (backendSuccesses backend) 1
(Recovering, True) -> do
let newSuccesses = successes + 1
writeTVar (backendSuccesses backend) newSuccesses
when (newSuccesses >= hcRecoveryAttempts config) $ do
writeTVar (backendState backend) Healthy
writeTVar (backendSuccesses backend) 0
(Recovering, False) -> do
writeTVar (backendState backend) Unhealthy
writeTVar (backendSuccesses backend) 0
_ -> return ()
```
This state machine **prevents transient failures from cascading**. A backend must fail `hcMaxFailures` consecutive checks (e.g., 3) before removal, and succeed `hcRecoveryAttempts` consecutive checks (e.g., 2) before returning to rotation.
### Circuit breaker integration
**Circuit breakers prevent cascading failures** by failing fast when a backend is degraded. The `circuit-breaker` package (Hackage) provides type-level configuration:
```haskell
import System.CircuitBreaker
-- Define circuit breaker at type level
-- 1000ms = error expiry time, 4 = threshold
testBreaker :: CircuitBreaker "Test" 1000 4
testBreaker = undefined
proxyWithCircuitBreaker :: Backend -> Request -> IO Response
proxyWithCircuitBreaker backend req = do
cbConf <- initialBreakerState
result <- flip runReaderT cbConf $
withBreaker testBreaker $ liftIO $ forwardRequest backend req
case result of
Left (CircuitBreakerClosed msg) ->
-- Circuit open, return cached response or error
return $ errorResponse 503 "Service Temporarily Unavailable"
Right response ->
return response
```
Circuit breaker states: **Active** (closed, requests pass), **Testing** (half-open, testing recovery), **Waiting** (open, blocking requests). When error threshold (4) is reached within the time window (1000ms), the circuit opens and blocks requests until the window expires.
**Production pattern**: Combine circuit breakers with health checks for defense in depth:
```haskell
selectBackendWithCircuitBreaker :: BackendPool -> IO (Maybe Backend)
selectBackendWithCircuitBreaker pool = do
healthy <- getHealthyBackends pool
available <- filterM isCircuitBreakerClosed healthy
case available of
[] -> return Nothing
backends -> Just <$> selectFromPool backends
```
### Exponential backoff for failed backends
**Implement jittered exponential backoff** to avoid thundering herd when backends recover:
```haskell
import System.Random (randomRIO)
data BackoffConfig = BackoffConfig
{ initialDelay :: Int -- microseconds
, maxDelay :: Int
, maxRetries :: Int
}
exponentialBackoffWithJitter :: BackoffConfig -> IO a -> IO (Maybe a)
exponentialBackoffWithJitter config action = go 0 (initialDelay config)
where
go retries delay
| retries >= maxRetries config = return Nothing
| otherwise = do
result <- try action
case result of
Right val -> return (Just val)
Left (_ :: SomeException) -> do
-- Add jitter: random value up to 50% of delay
jitter <- randomRIO (0, delay `div` 2)
threadDelay (delay + jitter)
let nextDelay = min (delay * 2) (maxDelay config)
go (retries + 1) nextDelay
```
**Jitter is critical** - without it, all failed requests retry simultaneously, creating load spikes. With jitter, retries spread over time.
**Alternative: Use the `retry` package** (Hackage, widely adopted):
```haskell
import Control.Retry
recovering
(exponentialBackoff 50000 <> limitRetries 5)
[const $ Handler $ \e -> return (isRetryable e)]
(\_ -> performHealthCheck backend)
```
The `retry` package uses **Monoid composition** for retry policies - combine `exponentialBackoff`, `limitRetries`, and `capDelay` to build complex policies declaratively.
## Integration with Warp and WAI
Warp is the **highest-performance Haskell web server**, achieving throughput comparable to nginx (~50,000-80,000 req/s single-threaded, scaling linearly to 8+ workers). Integration with load balancing leverages WAI middleware and reverse proxy libraries.
### Using http-reverse-proxy
**Two approaches**: raw socket (minimal overhead) and WAI-based (full feature set). For load balancing, **use the WAI approach** for request modification and middleware composition:
```haskell
import Network.HTTP.Client.TLS
import Network.HTTP.ReverseProxy
import Network.Wai
import Network.Wai.Handler.Warp (run)
import Control.Concurrent.STM
data ProxyConfig = ProxyConfig
{ pcBackends :: TVar [Backend]
, pcManager :: Manager
, pcBalancer :: LoadBalancer
}
proxyApp :: ProxyConfig -> Application
proxyApp config req respond = do
mBackend <- selectHealthyBackend (pcBalancer config)
case mBackend of
Nothing ->
respond $ responseLBS status503 [] "No healthy backends available"
Just backend ->
waiProxyTo
(\_ -> return $ WPRProxyDest (ProxyDest
(backendHost backend)
(backendPort backend)))
defaultOnExc
(pcManager config)
req
respond
```
**Key functions**:
- `waiProxyTo`: Full request/response control
- `WPRProxyDest`: Route to specific backend
- `WPRModifiedRequest`: Modify request before proxying
- `defaultOnExc`: Exception handler
**WebSocket support**: Use `waiProxyToSettings` with `wpsUpgradeToRaw = True` for WebSocket tunneling.
### Middleware for request routing
Middleware composes via function application. **Build a middleware stack** for logging, authentication, and routing:
```haskell
import Network.Wai (Middleware, mapResponseHeaders)
-- Add load balancer info header
addBackendHeader :: Backend -> Middleware
addBackendHeader backend app req respond =
app req $ respond . mapResponseHeaders
((hBackend, encodeUtf8 $ backendId backend) :)
-- Connection tracking middleware
trackingMiddleware :: ServerMetrics -> Middleware
trackingMiddleware metrics app req respond = do
atomically $ modifyTVar' (activeConnections metrics) (+1)
atomically $ modifyTVar' (totalRequests metrics) (+1)
let respond' res = do
atomically $ modifyTVar' (activeConnections metrics) (subtract 1)
respond res
app req respond' `onException`
atomically (modifyTVar' (errorCount metrics) (+1))
-- Compose middleware
main = do
config <- initProxyConfig
let app = trackingMiddleware metrics
$ proxyApp config
run 8000 app
```
### Performance optimization for 100k+ req/s
**Warp architecture** uses lightweight green threads (100,000+ possible) with one thread per connection. The GHC I/O manager provides non-blocking I/O via epoll/kqueue. Key optimizations:
1. **Minimize system calls**: Warp uses only `recv()`, `send()`, and `sendfile()`. Eliminate `open()`/`stat()`/`close()` via file descriptor caching.
2. **Specialize hot paths**: Use custom HTTP response composer instead of generic Builder. Cache date strings (regenerate once per second).
3. **Avoid locks**: Use lock-free atomic operations (`atomicModifyIORef`) instead of MVar spin locks. Warp's timeout manager uses double-IORef for lock-free status updates.
4. **Proper data structures**: ByteString for buffers (enables zero-copy splicing), Vector for backend lists (O(1) indexing).
**Compile-time optimizations**:
```cabal
ghc-options: -Wall -O2 -threaded
-rtsopts -with-rtsopts=-N
-fspec-constr -fspecialise
-funbox-strict-fields
```
**Runtime settings**:
```bash
./proxy +RTS -N -A64m -I0 -qg
```
- `-N`: Use all CPU cores
- `-A64m`: Large allocation area (fewer GCs)
- `-I0`: Disable idle GC
- `-qg`: Parallel GC
**Benchmarking**: Historical data shows **Mighty (Warp-based) achieved 50,000 req/s single-threaded**, scaling linearly to 8 workers. Modern Warp (2024-2025) includes further optimizations. Use `weighttp` or `wrk` for multi-threaded load testing.
### HTTP client connection pooling
**Manager configuration is critical** for backend connections:
```haskell
import Network.HTTP.Client
import Network.HTTP.Client.TLS
manager <- newManager $ defaultManagerSettings
{ managerConnCount = 1000 -- Max connections per backend
, managerIdleConnectionCount = 500 -- Idle connections to keep
, managerResponseTimeout = responseTimeoutMicro 60000000
}
```
**Connection pooling impact**: Without pooling, each request establishes a new TCP connection (expensive handshake, especially for TLS). With pooling, **10-100x faster** for repeated requests to the same backend.
**Pool configuration guidelines**:
- `managerConnCount`: Set to 500-5000 per backend based on capacity
- `managerIdleConnectionCount`: Keep 50-80% of max connections idle
- Share single Manager across application (thread-safe)
## Production implementation blueprint
Combining all patterns into a **production-ready architecture** for Milestone 1.3:
```haskell
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RecordWildCards #-}
module LoadBalancer where
import Control.Concurrent.Async
import Control.Concurrent.STM
import Network.HTTP.Client.TLS
import Network.HTTP.ReverseProxy
import Network.Wai
import Network.Wai.Handler.Warp
import qualified Data.Vector as V
-- Core data types
data Backend = Backend
{ backendId :: Int
, backendHost :: ByteString
, backendPort :: Int
, backendWeight :: Int
, currentWeight :: TVar Int
, activeConns :: TVar Int
, state :: TVar BackendState
, failures :: TVar Int
}
data BackendState = Healthy | Unhealthy | Recovering
data Strategy = RoundRobin | LeastConnections | SmoothWeightedRR
data ProxyConfig = ProxyConfig
{ backends :: V.Vector Backend
, strategy :: Strategy
, healthChecker :: Async ()
, httpManager :: Manager
, rrCounter :: IORef Int
}
-- Backend selection dispatcher
selectBackend :: ProxyConfig -> IO (Maybe Backend)
selectBackend config =
case strategy config of
RoundRobin -> selectRoundRobin config
LeastConnections -> selectLeastConnections config
SmoothWeightedRR -> selectWeightedRR config
-- Round-robin implementation
selectRoundRobin :: ProxyConfig -> IO (Maybe Backend)
selectRoundRobin config = do
let backends' = backends config
len = V.length backends'
idx <- atomicModifyIORef' (rrCounter config) $ \i ->
((i + 1) `mod` len, i)
-- Find next healthy backend
findHealthy backends' idx len
where
findHealthy backends' start remaining
| remaining <= 0 = return Nothing
| otherwise = do
let backend = backends' V.! start
isHealthy <- atomically $ (== Healthy) <$> readTVar (state backend)
if isHealthy
then return (Just backend)
else findHealthy backends'
((start + 1) `mod` V.length backends')
(remaining - 1)
-- Least connections implementation
selectLeastConnections :: ProxyConfig -> IO (Maybe Backend)
selectLeastConnections config = do
let backends' = V.toList (backends config)
healthy <- filterM isHealthy backends'
case healthy of
[] -> return Nothing
bs -> do
conns <- forM bs $ \b -> do
count <- atomically $ readTVar (activeConns b)
return (count, b)
return $ Just $ snd $ minimum conns
where
isHealthy b = atomically $ (== Healthy) <$> readTVar (state b)
-- Smooth weighted round-robin
selectWeightedRR :: ProxyConfig -> IO (Maybe Backend)
selectWeightedRR config = atomically $ do
let backends' = V.toList (backends config)
-- Increase current weights
forM_ backends' $ \wb ->
modifyTVar' (currentWeight wb) (+ backendWeight wb)
-- Select backend with max current weight
weights <- mapM (readTVar . currentWeight) backends'
let maxWeight = maximum weights
selected = backends' !! fromJust (findIndex (== maxWeight) weights)
-- Check health
isHealthy <- (== Healthy) <$> readTVar (state selected)
guard isHealthy
-- Reduce selected backend's current weight
let totalWeight = sum (map backendWeight backends')
modifyTVar' (currentWeight selected) (subtract totalWeight)
return selected
-- Main proxy application
proxyApp :: ProxyConfig -> Application
proxyApp config req respond = do
mBackend <- selectBackend config
case mBackend of
Nothing ->
respond $ responseLBS status503 [] "No healthy backends"
Just backend -> do
-- Track connection
atomically $ modifyTVar' (activeConns backend) (+1)
let dest = ProxyDest (backendHost backend) (backendPort backend)
respond' res = do
atomically $ modifyTVar' (activeConns backend) (subtract 1)
respond res
waiProxyTo
(\_ -> return $ WPRProxyDest dest)
defaultOnExc
(httpManager config)
req
respond'
-- Health checker (runs asynchronously)
healthCheckLoop :: Manager -> [Backend] -> IO ()
healthCheckLoop manager backends = forever $ do
results <- mapConcurrently (checkHealth manager) backends
zipWithM_ updateHealth backends results
threadDelay 10000000 -- 10 seconds
where
checkHealth mgr backend = do
let url = "http://" <> backendHost backend <> ":"
<> show (backendPort backend) <> "/health"
result <- timeout 2000000 $ do
req <- parseRequest (unpack url)
response <- httpLbs req mgr
return $ statusCode (responseStatus response) == 200
return $ fromMaybe False result
updateHealth backend healthy = atomically $ do
currentState <- readTVar (state backend)
failureCount <- readTVar (failures backend)
case (currentState, healthy) of
(Healthy, False) -> do
let newFailures = failureCount + 1
writeTVar (failures backend) newFailures
when (newFailures >= 3) $ do
writeTVar (state backend) Unhealthy
writeTVar (failures backend) 0
(Unhealthy, True) ->
writeTVar (state backend) Recovering
(Recovering, True) ->
writeTVar (state backend) Healthy
_ -> return ()
-- Initialization
initProxyConfig :: [BackendSpec] -> Strategy -> IO ProxyConfig
initProxyConfig specs strat = do
backends <- V.fromList <$> mapM createBackend (zip [0..] specs)
manager <- newTlsManager
counter <- newIORef 0
checker <- async $ healthCheckLoop manager (V.toList backends)
return ProxyConfig
{ backends = backends
, strategy = strat
, healthChecker = checker
, httpManager = manager
, rrCounter = counter
}
where
createBackend (idx, spec) = Backend idx
<$> pure (bsHost spec)
<*> pure (bsPort spec)
<*> pure (bsWeight spec)
<*> newTVarIO 0
<*> newTVarIO 0
<*> newTVarIO Healthy
<*> newTVarIO 0
-- Main entry point
main :: IO ()
main = do
let backendSpecs =
[ BackendSpec "localhost" 8001 5
, BackendSpec "localhost" 8002 1
, BackendSpec "localhost" 8003 1
]
config <- initProxyConfig backendSpecs SmoothWeightedRR
let settings = setPort 8000
$ setTimeout 30
$ defaultSettings
putStrLn "Load balancing proxy started on port 8000"
runSettings settings (proxyApp config)
```
## Library recommendations with versions
**Core infrastructure** (2024-2025 ecosystem):
- **warp** (3.3+): High-performance HTTP server - `ghc-options: -threaded`
- **http-reverse-proxy** (0.6+): Reverse proxy primitives, WebSocket support
- **http-client** (0.7+): HTTP client with connection pooling
- **http-client-tls** (0.3+): TLS support via Haskell-native `tls` package
**Load balancing utilities**:
- **load-balancing** (1.0.1.1): Least-connections with round-robin tie-breaking
- **roundRobin** (0.1.2.0): Simple round-robin selection
- **resource-pool** (0.4+): Striped connection pooling (used by Yesod)
**Resilience libraries**:
- **retry** (0.9+): Exponential backoff and retry policies (Monoid-composable)
- **circuit-breaker** (0.1+): Type-level circuit breakers with automatic backoff
- **stamina** (0.2+): Modern "retries for humans" with Retry-After support
**Async \u0026 concurrency**:
- **async** (2.2+): Safe concurrent operations - use `withAsync` for automatic cleanup
- **stm** (2.5+): Software Transactional Memory
- **async-timer** (0.3+): Periodic timer execution
## Common pitfalls and solutions
**STM starvation**: Long transactions vulnerable to repeated aborts by short transactions. **Solution**: Keep transactions under 10 TVars, move pure computation outside `atomically`.
**Memory allocation bottlenecks**: GHC takes global lock for objects >409 bytes. **Solution**: Pool and reuse buffers. Configure larger allocation area (`+RTS -A64m`).
**Thundering herd**: All workers wake on new connection. **Solution**: Use prefork (multiple processes) instead of `-N` threading, or wait for parallel I/O manager integration.
**Health check storms**: All checks start simultaneously after deployment. **Solution**: Add random initial delay: `threadDelay =<< randomRIO (0, hcInterval config * 1000000)`.
**Circuit breaker cascades**: One slow backend causes all circuits to open. **Solution**: Implement per-backend circuit breakers with independent thresholds.
**Connection pool exhaustion**: Backends slow down, pool fills up. **Solution**: Set `managerIdleConnectionCount` conservatively (50-80% of max), implement connection timeout validation.
**WebSocket routing fails**: Standard proxy doesn't upgrade connections. **Solution**: Use `waiProxyToSettings` with `wpsUpgradeToRaw = True` for WebSocket support.
## Testing and validation strategies
**Property-based testing** with QuickCheck:
```haskell
prop_roundRobinFairness :: [Backend] -> Property
prop_roundRobinFairness backends =
length backends > 0 ==> monadicIO $ do
let selections = replicateM (length backends * 100) (selectBackend balancer)
distribution <- run $ countSelections <$> selections
assert $ all (\count -> count >= 90 && count <= 110) distribution
```
**Concurrency testing** with dejafu:
```haskell
import Test.DejaFu
testNoDeadlock :: IO ()
testNoDeadlock = autocheck $ do
balancer <- setup
concurrently_
(selectBackend balancer)
(selectBackend balancer)
```
**Load testing**: Use `wrk` for HTTP benchmarking:
```bash
wrk -t12 -c400 -d30s http://localhost:8000/
```
**Metrics collection**: Integrate `ekg` for real-time monitoring:
```haskell
import System.Remote.Monitoring
main = do
forkServer "localhost" 8081 -- Metrics dashboard
store <- getStore
registerGauge "active_connections" (readTVarIO activeConns) store
```
This comprehensive implementation guide provides **production-ready patterns** for building a high-performance, type-safe reverse proxy in Haskell. The architecture balances functional purity with pragmatic performance optimization, leveraging Haskell's concurrency primitives to achieve 100k+ req/s throughput while maintaining composability and correctness guarantees.