From 8d5ca97b1073a2ba270ee210ec609e9da86ea1a2 Mon Sep 17 00:00:00 2001 From: Andriy Yevtushyn <129842532+Avionic23@users.noreply.github.com> Date: Fri, 29 May 2026 11:23:51 +0300 Subject: [PATCH 1/5] feat: add TCP health checker with concurrent backend probing --- healthcheck/healthchecker.go | 58 ++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 healthcheck/healthchecker.go diff --git a/healthcheck/healthchecker.go b/healthcheck/healthchecker.go new file mode 100644 index 0000000..517a4eb --- /dev/null +++ b/healthcheck/healthchecker.go @@ -0,0 +1,58 @@ +package healthcheck + +import ( + "context" + "load-balancer/backend" + "log" + "net" + "sync" + "time" +) + +type BackendPoolIO interface { + GetPool() []*backend.Backend +} + +type HealthChecker struct { + bp BackendPoolIO +} + +func NewHealthChecker(bp BackendPoolIO) *HealthChecker { + if bp == nil { + panic("backend pool cannot be nil") + } + return &HealthChecker{bp: bp} +} + +func (hc *HealthChecker) Run(ctx context.Context, duration time.Duration) { + ticker := time.NewTicker(duration) + defer ticker.Stop() + for { + select { + case <-ticker.C: + hc.RunOnce() + case <-ctx.Done(): + return + } + } +} + +func (hc *HealthChecker) RunOnce() { + bp := hc.bp.GetPool() + wg := sync.WaitGroup{} + for _, b := range bp { + wg.Add(1) + go func() { + defer wg.Done() + conn, err := net.DialTimeout("tcp", b.GetUrl(), 10*time.Second) + if err != nil { + b.SetHealth(false) + log.Printf("dial %s: %v", b.GetUrl(), err) + return + } + b.SetHealth(true) + conn.Close() + }() + } + wg.Wait() +} From 5fee4e59db0b5ad053594d027fa0a02702e2a8b3 Mon Sep 17 00:00:00 2001 From: Andriy Yevtushyn <129842532+Avionic23@users.noreply.github.com> Date: Fri, 29 May 2026 11:24:11 +0300 Subject: [PATCH 2/5] test: add health checker tests covering healthy, unhealthy, and shutdown --- healthcheck/healthchecker_test.go | 95 +++++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 healthcheck/healthchecker_test.go diff --git a/healthcheck/healthchecker_test.go b/healthcheck/healthchecker_test.go new file mode 100644 index 0000000..0cc7f8b --- /dev/null +++ b/healthcheck/healthchecker_test.go @@ -0,0 +1,95 @@ +package healthcheck + +import ( + "context" + "load-balancer/backend" + "net" + "testing" + "time" +) + +type stubPool struct { + backends []*backend.Backend +} + +func (s *stubPool) GetPool() []*backend.Backend { + return s.backends +} + +func startTCPServer(t *testing.T) string { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { ln.Close() }) + go func() { + for { + conn, err := ln.Accept() + if err != nil { + return + } + conn.Close() + } + }() + return ln.Addr().String() +} + +func TestRunOnceMarksBackendHealthy(t *testing.T) { + addr := startTCPServer(t) + b := backend.NewBackend(backend.BackendOptions{Url: addr}) + hc := NewHealthChecker(&stubPool{backends: []*backend.Backend{b}}) + + hc.RunOnce() + + if !b.IsHealthy() { + t.Error("expected backend to be marked healthy") + } +} + +func TestRunOnceMarksBackendUnhealthy(t *testing.T) { + b := backend.NewBackend(backend.BackendOptions{Url: "127.0.0.1:1"}) + hc := NewHealthChecker(&stubPool{backends: []*backend.Backend{b}}) + + hc.RunOnce() + + if b.IsHealthy() { + t.Error("expected backend to be marked unhealthy") + } +} + +func TestRunOnceChecksAllBackends(t *testing.T) { + healthyAddr := startTCPServer(t) + healthy := backend.NewBackend(backend.BackendOptions{Url: healthyAddr}) + unhealthy := backend.NewBackend(backend.BackendOptions{Url: "127.0.0.1:1"}) + + hc := NewHealthChecker(&stubPool{backends: []*backend.Backend{healthy, unhealthy}}) + hc.RunOnce() + + if !healthy.IsHealthy() { + t.Error("expected healthy backend to be marked healthy") + } + if unhealthy.IsHealthy() { + t.Error("expected unreachable backend to be marked unhealthy") + } +} + +func TestRunStopsOnContextCancellation(t *testing.T) { + b := backend.NewBackend(backend.BackendOptions{Url: "127.0.0.1:1"}) + hc := NewHealthChecker(&stubPool{backends: []*backend.Backend{b}}) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + done := make(chan struct{}) + go func() { + hc.Run(ctx, time.Hour) + close(done) + }() + + select { + case <-done: + case <-time.After(time.Second): + t.Error("Run did not stop after context cancellation") + } +} From f37de124e0b83b6c052d93b8c1680353d010caaa Mon Sep 17 00:00:00 2001 From: Andriy Yevtushyn <129842532+Avionic23@users.noreply.github.com> Date: Fri, 29 May 2026 11:24:44 +0300 Subject: [PATCH 3/5] feat: wire health checker into main with initial check before listener starts --- main.go | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/main.go b/main.go index 427e289..5d67379 100644 --- a/main.go +++ b/main.go @@ -5,6 +5,7 @@ import ( "fmt" "load-balancer/backend" "load-balancer/config" + "load-balancer/healthcheck" "load-balancer/listener" "load-balancer/proxy" "load-balancer/router" @@ -48,14 +49,18 @@ func main() { }) l := listener.NewListener(p) + hc := healthcheck.NewHealthChecker(bp) + hc.RunOnce() + + ctx, cancel := context.WithTimeout(context.Background(), conf.Timeouts.Shutdown) + defer cancel() + + go hc.Run(ctx, conf.Timeouts.HealthCheck) go l.Listen(ln) sigCh := make(chan os.Signal, 1) signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGINT) <-sigCh - ctx, cancel := context.WithTimeout(context.Background(), conf.Timeouts.Shutdown) - defer cancel() - l.GracefulShutdown(ctx) } From 579c4d4551e391a56b3820fd00cb25684c8913a8 Mon Sep 17 00:00:00 2001 From: Andriy Yevtushyn <129842532+Avionic23@users.noreply.github.com> Date: Fri, 29 May 2026 11:25:01 +0300 Subject: [PATCH 4/5] feat: add health check interval and timeout to config --- config/config_test.yaml | 3 ++- config/loader.go | 7 ++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/config/config_test.yaml b/config/config_test.yaml index eebbbec..8d32b9d 100644 --- a/config/config_test.yaml +++ b/config/config_test.yaml @@ -6,4 +6,5 @@ algorithm: "round-robin" timeouts: dial: 10s connect: 20s - shutdown: 30s \ No newline at end of file + shutdown: 30s + health_check: 5s \ No newline at end of file diff --git a/config/loader.go b/config/loader.go index 781e45a..060167e 100644 --- a/config/loader.go +++ b/config/loader.go @@ -13,9 +13,10 @@ type BackendCfg struct { } type TimeoutsCfg struct { - Dial time.Duration `yaml:"dial"` - Connect time.Duration `yaml:"connect"` - Shutdown time.Duration `yaml:"shutdown"` + Dial time.Duration `yaml:"dial"` + Connect time.Duration `yaml:"connect"` + Shutdown time.Duration `yaml:"shutdown"` + HealthCheck time.Duration `yaml:"health_check"` } type Config struct { From 9e9284c7cafab39a3170b7d975e2bc745c0bca5b Mon Sep 17 00:00:00 2001 From: Andriy Yevtushyn <129842532+Avionic23@users.noreply.github.com> Date: Fri, 29 May 2026 11:30:33 +0300 Subject: [PATCH 5/5] fix: unset manual healthy status at startup --- main.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/main.go b/main.go index 5d67379..42571f1 100644 --- a/main.go +++ b/main.go @@ -27,10 +27,9 @@ func main() { } backends := make([]*backend.Backend, 0, len(conf.Backends)) - for i, b := range conf.Backends { + for _, b := range conf.Backends { backends = append(backends, backend.NewBackend(backend.BackendOptions{ Url: b.Url})) - backends[i].SetHealth(true) } bp := backend.NewBackendPool(backends)