detached b93860a

fix: track in-flight registry proxy requests for graceful drain

Adds registryInFlight atomic.Int64 counter to proxy.Service and
instruments proxyToRegistry to increment/decrement it. Adds
DrainRegistryInFlight method for use during shutdown. This allows
gracefulShutdown to wait for active manifest pushes before stopping
the registry backend.
commit
b93860a8b973cb6b6a9d52565c63cd3110b4c43c
author
brice · · 2026-03-01 11:06 UTC
committer
brice · · 2026-03-01 11:08 UTC
parent
c8b7fd0
signature
unsigned
2 files · +92 -9
M internal/usecase/proxy/service.go +28 -9
@@ -70,15 +70,16 @@ type Config struct {
7070
7171 // Service implements the ProxyService interface.
7272 type Service struct {
73- runtime out.ContainerRuntime
74- containerSvc in.ContainerService
75- configSvc in.ConfigService
76- config Config
77- targets map[string]*domain.ProxyTarget
78- mu sync.RWMutex
79- activeConns atomic.Int64 // Atomic counter for concurrent connection limiting
80- inFlight map[string]int
81- inFlightMu sync.Mutex
73+ runtime out.ContainerRuntime
74+ containerSvc in.ContainerService
75+ configSvc in.ConfigService
76+ config Config
77+ targets map[string]*domain.ProxyTarget
78+ mu sync.RWMutex
79+ activeConns atomic.Int64 // Atomic counter for concurrent connection limiting
80+ inFlight map[string]int
81+ inFlightMu sync.Mutex
82+ registryInFlight atomic.Int64 // active registry proxy requests, for graceful drain
8283 }
8384
8485 // NewService creates a new proxy service.
@@ -574,6 +575,21 @@ func (s *Service) trackInFlight(containerID string) func() {
574575 }
575576 }
576577
578+// DrainRegistryInFlight blocks until all in-flight registry proxy requests
579+// complete or the timeout elapses. Returns true if drained cleanly, false if
580+// timed out with requests still in flight. Call this before shutting down the
581+// registry server.
582+func (s *Service) DrainRegistryInFlight(timeout time.Duration) bool {
583+ deadline := time.Now().Add(timeout)
584+ for time.Now().Before(deadline) {
585+ if s.registryInFlight.Load() == 0 {
586+ return true
587+ }
588+ time.Sleep(10 * time.Millisecond)
589+ }
590+ return false
591+}
592+
577593 // proxyToRegistry forwards requests to the local registry HTTP server.
578594 // SECURITY: This uses http://localhost:{port} which is safe because the registry
579595 // runs on the same host and traffic never leaves the loopback interface. If Gordon
@@ -585,6 +601,9 @@ func (s *Service) proxyToRegistry(w http.ResponseWriter, r *http.Request) {
585601 cfg := s.config
586602 s.mu.RUnlock()
587603
604+ s.registryInFlight.Add(1)
605+ defer s.registryInFlight.Add(-1)
606+
588607 log := zerowrap.FromCtx(r.Context())
589608
590609 targetURL, err := url.Parse(fmt.Sprintf("http://localhost:%d", cfg.RegistryPort))
@@ -70,15 +70,16 @@ type Config struct {
70 70
71 // Service implements the ProxyService interface.71 // Service implements the ProxyService interface.
72 type Service struct {72 type Service struct {
73- runtime out.ContainerRuntime73+ runtime out.ContainerRuntime
74- containerSvc in.ContainerService74+ containerSvc in.ContainerService
75- configSvc in.ConfigService75+ configSvc in.ConfigService
76- config Config76+ config Config
77- targets map[string]*domain.ProxyTarget77+ targets map[string]*domain.ProxyTarget
78- mu sync.RWMutex78+ mu sync.RWMutex
79- activeConns atomic.Int64 // Atomic counter for concurrent connection limiting79+ activeConns atomic.Int64 // Atomic counter for concurrent connection limiting
80- inFlight map[string]int80+ inFlight map[string]int
81- inFlightMu sync.Mutex81+ inFlightMu sync.Mutex
82+ registryInFlight atomic.Int64 // active registry proxy requests, for graceful drain
82 }83 }
83 84
84 // NewService creates a new proxy service.85 // NewService creates a new proxy service.
@@ -574,6 +575,21 @@ func (s *Service) trackInFlight(containerID string) func() {
574 }575 }
575 }576 }
576 577
578+// DrainRegistryInFlight blocks until all in-flight registry proxy requests
579+// complete or the timeout elapses. Returns true if drained cleanly, false if
580+// timed out with requests still in flight. Call this before shutting down the
581+// registry server.
582+func (s *Service) DrainRegistryInFlight(timeout time.Duration) bool {
583+ deadline := time.Now().Add(timeout)
584+ for time.Now().Before(deadline) {
585+ if s.registryInFlight.Load() == 0 {
586+ return true
587+ }
588+ time.Sleep(10 * time.Millisecond)
589+ }
590+ return false
591+}
592+
577 // proxyToRegistry forwards requests to the local registry HTTP server.593 // proxyToRegistry forwards requests to the local registry HTTP server.
578 // SECURITY: This uses http://localhost:{port} which is safe because the registry594 // SECURITY: This uses http://localhost:{port} which is safe because the registry
579 // runs on the same host and traffic never leaves the loopback interface. If Gordon595 // runs on the same host and traffic never leaves the loopback interface. If Gordon
@@ -585,6 +601,9 @@ func (s *Service) proxyToRegistry(w http.ResponseWriter, r *http.Request) {
585 cfg := s.config601 cfg := s.config
586 s.mu.RUnlock()602 s.mu.RUnlock()
587 603
604+ s.registryInFlight.Add(1)
605+ defer s.registryInFlight.Add(-1)
606+
588 log := zerowrap.FromCtx(r.Context())607 log := zerowrap.FromCtx(r.Context())
589 608
590 targetURL, err := url.Parse(fmt.Sprintf("http://localhost:%d", cfg.RegistryPort))609 targetURL, err := url.Parse(fmt.Sprintf("http://localhost:%d", cfg.RegistryPort))
M internal/usecase/proxy/service_test.go +64 -0
@@ -4,6 +4,7 @@ import (
44 "context"
55 "net/http/httptest"
66 "testing"
7+ "time"
78
89 "github.com/bnema/zerowrap"
910 "github.com/stretchr/testify/assert"
@@ -510,3 +511,66 @@ func TestServeHTTP_MaxBodySize(t *testing.T) {
510511 })
511512 }
512513 }
514+
515+func TestRegistryInFlightTracking(t *testing.T) {
516+ svc := &Service{
517+ inFlight: make(map[string]int),
518+ }
519+
520+ if got := svc.registryInFlight.Load(); got != 0 {
521+ t.Fatalf("expected 0 in-flight, got %d", got)
522+ }
523+
524+ svc.registryInFlight.Add(1)
525+ if got := svc.registryInFlight.Load(); got != 1 {
526+ t.Fatalf("expected 1 in-flight after Add, got %d", got)
527+ }
528+
529+ svc.registryInFlight.Add(-1)
530+ if got := svc.registryInFlight.Load(); got != 0 {
531+ t.Fatalf("expected 0 in-flight after release, got %d", got)
532+ }
533+}
534+
535+func TestDrainRegistryInFlight(t *testing.T) {
536+ svc := &Service{
537+ inFlight: make(map[string]int),
538+ }
539+
540+ svc.registryInFlight.Add(2)
541+
542+ done := make(chan struct{})
543+ go func() {
544+ drained := svc.DrainRegistryInFlight(50 * time.Millisecond)
545+ if !drained {
546+ // Signal failure via done channel by leaving it open — test will time out
547+ return
548+ }
549+ close(done)
550+ }()
551+
552+ time.Sleep(5 * time.Millisecond)
553+ svc.registryInFlight.Add(-1)
554+ svc.registryInFlight.Add(-1)
555+
556+ select {
557+ case <-done:
558+ // good — drained cleanly
559+ case <-time.After(500 * time.Millisecond):
560+ t.Fatal("DrainRegistryInFlight did not return true after requests completed")
561+ }
562+}
563+
564+func TestDrainRegistryInFlightTimeout(t *testing.T) {
565+ svc := &Service{
566+ inFlight: make(map[string]int),
567+ }
568+
569+ // Add a request and never release it
570+ svc.registryInFlight.Add(1)
571+
572+ drained := svc.DrainRegistryInFlight(30 * time.Millisecond)
573+ if drained {
574+ t.Fatal("expected DrainRegistryInFlight to return false on timeout, got true")
575+ }
576+}
@@ -4,6 +4,7 @@ import (
4 "context"4 "context"
5 "net/http/httptest"5 "net/http/httptest"
6 "testing"6 "testing"
7+ "time"
7 8
8 "github.com/bnema/zerowrap"9 "github.com/bnema/zerowrap"
9 "github.com/stretchr/testify/assert"10 "github.com/stretchr/testify/assert"
@@ -510,3 +511,66 @@ func TestServeHTTP_MaxBodySize(t *testing.T) {
510 })511 })
511 }512 }
512 }513 }
514+
515+func TestRegistryInFlightTracking(t *testing.T) {
516+ svc := &Service{
517+ inFlight: make(map[string]int),
518+ }
519+
520+ if got := svc.registryInFlight.Load(); got != 0 {
521+ t.Fatalf("expected 0 in-flight, got %d", got)
522+ }
523+
524+ svc.registryInFlight.Add(1)
525+ if got := svc.registryInFlight.Load(); got != 1 {
526+ t.Fatalf("expected 1 in-flight after Add, got %d", got)
527+ }
528+
529+ svc.registryInFlight.Add(-1)
530+ if got := svc.registryInFlight.Load(); got != 0 {
531+ t.Fatalf("expected 0 in-flight after release, got %d", got)
532+ }
533+}
534+
535+func TestDrainRegistryInFlight(t *testing.T) {
536+ svc := &Service{
537+ inFlight: make(map[string]int),
538+ }
539+
540+ svc.registryInFlight.Add(2)
541+
542+ done := make(chan struct{})
543+ go func() {
544+ drained := svc.DrainRegistryInFlight(50 * time.Millisecond)
545+ if !drained {
546+ // Signal failure via done channel by leaving it open — test will time out
547+ return
548+ }
549+ close(done)
550+ }()
551+
552+ time.Sleep(5 * time.Millisecond)
553+ svc.registryInFlight.Add(-1)
554+ svc.registryInFlight.Add(-1)
555+
556+ select {
557+ case <-done:
558+ // good — drained cleanly
559+ case <-time.After(500 * time.Millisecond):
560+ t.Fatal("DrainRegistryInFlight did not return true after requests completed")
561+ }
562+}
563+
564+func TestDrainRegistryInFlightTimeout(t *testing.T) {
565+ svc := &Service{
566+ inFlight: make(map[string]int),
567+ }
568+
569+ // Add a request and never release it
570+ svc.registryInFlight.Add(1)
571+
572+ drained := svc.DrainRegistryInFlight(30 * time.Millisecond)
573+ if drained {
574+ t.Fatal("expected DrainRegistryInFlight to return false on timeout, got true")
575+ }
576+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close