server: serialize DrainPending per host (avoid drain double-dispatch)

Add a per-host drain mutex (drainLocks map guarded by drainLocksMu) on
the Server struct. DrainPending acquires it with TryLock: if a drain is
already in-flight for this host, the call returns immediately — the
running drain will see every pending row. This prevents the on-hello
goroutine and the 30s tick from both listing the same host's rows and
dispatching them twice.

Update three existing tests that called srv.DrainPending explicitly
after the on-hello goroutine had already been spawned: replace the
now-redundant direct call with a waitForPendingCount poll so they don't
race the goroutine's mutex ownership. Add TestDrainPendingSerializesPerHost
which fires 10 concurrent DrainPending goroutines against a 5-row queue
and asserts exactly 5 job rows result.
This commit is contained in:
2026-05-04 00:33:13 +01:00
parent 9ec69456fe
commit adece5eb72
3 changed files with 141 additions and 7 deletions
+9 -1
View File
@@ -7,6 +7,7 @@ import (
"context"
"errors"
stdhttp "net/http"
"sync"
"time"
"github.com/go-chi/chi/v5"
@@ -41,6 +42,13 @@ type Deps struct {
type Server struct {
srv *stdhttp.Server
deps Deps
// drainLocks serializes DrainPending per host. The on-hello
// goroutine and the 30s ticker can otherwise race for the same
// host, double-dispatching every pending row. Map of hostID →
// sync.Mutex; checked-and-locked atomically via drainLocksMu.
drainLocksMu sync.Mutex
drainLocks map[string]*sync.Mutex
}
// New builds a configured but not-yet-started server.
@@ -59,7 +67,7 @@ func New(deps Deps) *Server {
w.WriteHeader(stdhttp.StatusNoContent)
})
s := &Server{deps: deps}
s := &Server{deps: deps, drainLocks: make(map[string]*sync.Mutex)}
s.routes(r)
s.srv = &stdhttp.Server{