Skip to content

Commit 71e43ab

Browse files
t-kikucclaude
andcommitted
feat(api): add connection-aware readiness probe for SSE
When SSE connections reach the configured threshold (default 95% of --sse-max-connections), the readiness probe returns 503. This removes the pod from Kubernetes Endpoints and GCLB NEG, preventing new connections from being routed to saturated pods while keeping existing SSE connections alive. The Envoy sidecar circuit breaker is ineffective for SSE over HTTP/2 because multiplexed streams are not counted as active TCP connections or requests. This readiness check bypasses Envoy and reads the Dispatcher's connection count directly. Closes #2786 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 5a82f0f commit 71e43ab

5 files changed

Lines changed: 130 additions & 8 deletions

File tree

pkg/api/cmd/server.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,7 @@ type server struct {
143143
cacheInvalidationTopic *string
144144
sseHeartbeatInterval *time.Duration
145145
sseMaxConnections *int
146+
sseReadinessThreshold *float64
146147
}
147148

148149
func RegisterCommand(r cli.CommandRegistry, p cli.ParentCommand) cli.Command {
@@ -339,6 +340,9 @@ func RegisterCommand(r cli.CommandRegistry, p cli.ParentCommand) cli.Command {
339340
sseMaxConnections: cmd.Flag("sse-max-connections",
340341
"Maximum number of concurrent SSE connections per pod.",
341342
).Default("10000").Int(),
343+
sseReadinessThreshold: cmd.Flag("sse-readiness-threshold",
344+
"Fraction of sse-max-connections at which the readiness probe starts failing (0.0-1.0).",
345+
).Default("0.95").Float64(),
342346
}
343347
r.RegisterCommand(server)
344348
return server
@@ -679,9 +683,15 @@ func (s *server) Run(ctx context.Context, metrics metrics.Metrics, logger *zap.L
679683
healthCheckCtx, healthCheckCancel := context.WithCancel(context.Background())
680684
defer healthCheckCancel()
681685

686+
sseReadinessLimit := int(float64(*s.sseMaxConnections) * *s.sseReadinessThreshold)
687+
sseReadinessCheck := func() bool {
688+
current, _ := streamDispatcher.ConnectionStatus()
689+
return current < sseReadinessLimit
690+
}
682691
healthChecker := health.NewGrpcChecker(
683692
health.WithTimeout(5*time.Second),
684693
health.WithCheck("metrics", metrics.Check),
694+
health.WithReadinessCheck("sse-connections", sseReadinessCheck),
685695
)
686696
go healthChecker.Run(healthCheckCtx)
687697

@@ -729,6 +739,7 @@ func (s *server) Run(ctx context.Context, metrics metrics.Metrics, logger *zap.L
729739
api.Version, api.Service,
730740
health.WithTimeout(5*time.Second),
731741
health.WithCheck("metrics", metrics.Check),
742+
health.WithReadinessCheck("sse-connections", sseReadinessCheck),
732743
)
733744
go restHealthChecker.Run(healthCheckCtx)
734745

pkg/api/stream/dispatcher.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,13 @@ func NewDispatcher(maxConns int, fetchFeatures FeaturesFetcher, logger *zap.Logg
6969
}
7070
}
7171

72+
// ConnectionStatus returns the current and maximum number of SSE connections.
73+
func (d *Dispatcher) ConnectionStatus() (current, max int) {
74+
d.mu.Lock()
75+
defer d.mu.Unlock()
76+
return d.totalConns, d.maxConns
77+
}
78+
7279
// Shutdown signals all active SSE handlers to exit immediately.
7380
func (d *Dispatcher) Shutdown() {
7481
d.shutdownOnce.Do(func() { close(d.shutdownCh) })

pkg/api/stream/dispatcher_test.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,32 @@ func TestDispatcherRegisterMaxConns(t *testing.T) {
135135
}
136136
}
137137

138+
func TestDispatcherConnectionStatus(t *testing.T) {
139+
t.Parallel()
140+
d := NewDispatcher(100, nil, zap.NewNop())
141+
142+
cur, max := d.ConnectionStatus()
143+
assert.Equal(t, 0, cur)
144+
assert.Equal(t, 100, max)
145+
146+
_, dereg1, err := d.register("env-1", "tag-A", "src")
147+
require.NoError(t, err)
148+
_, dereg2, err := d.register("env-1", "tag-B", "src")
149+
require.NoError(t, err)
150+
151+
cur, max = d.ConnectionStatus()
152+
assert.Equal(t, 2, cur)
153+
assert.Equal(t, 100, max)
154+
155+
dereg1()
156+
cur, _ = d.ConnectionStatus()
157+
assert.Equal(t, 1, cur)
158+
159+
dereg2()
160+
cur, _ = d.ConnectionStatus()
161+
assert.Equal(t, 0, cur)
162+
}
163+
138164
func TestDispatcherRegisterSlotFreedByDeregister(t *testing.T) {
139165
t.Parallel()
140166
maxConns := 1

pkg/health/health.go

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -45,13 +45,18 @@ func (s Status) String() string {
4545

4646
type check func(context.Context) Status
4747

48+
// ReadinessCheck is called synchronously on each readiness probe.
49+
// It returns true when the pod should accept new traffic.
50+
type ReadinessCheck func() bool
51+
4852
type checker struct {
4953
status uint32
5054
stopped uint32 // 0 = running, 1 = stopped
5155

52-
interval time.Duration
53-
timeout time.Duration
54-
checks map[string]check
56+
interval time.Duration
57+
timeout time.Duration
58+
checks map[string]check
59+
readinessChecks map[string]ReadinessCheck
5560
}
5661

5762
type option func(*checker)
@@ -77,12 +82,19 @@ func WithTimeout(timeout time.Duration) option {
7782
}
7883
}
7984

85+
func WithReadinessCheck(name string, rc ReadinessCheck) option {
86+
return func(c *checker) {
87+
c.readinessChecks[name] = rc
88+
}
89+
}
90+
8091
func newChecker(opts ...option) *checker {
8192
checker := &checker{
82-
status: uint32(Unhealthy),
83-
interval: 10 * time.Second,
84-
timeout: 5 * time.Second,
85-
checks: make(map[string]check),
93+
status: uint32(Unhealthy),
94+
interval: 10 * time.Second,
95+
timeout: 5 * time.Second,
96+
checks: make(map[string]check),
97+
readinessChecks: make(map[string]ReadinessCheck),
8698
}
8799
for _, o := range opts {
88100
o(checker)
@@ -135,11 +147,16 @@ func (hc *checker) check(ctx context.Context) {
135147
}
136148

137149
func (hc *checker) ServeReadyHTTP(resp http.ResponseWriter, req *http.Request) {
138-
// Readiness check: return 503 if health checks fail
139150
if hc.getStatus() == Unhealthy {
140151
resp.WriteHeader(http.StatusServiceUnavailable)
141152
return
142153
}
154+
for _, rc := range hc.readinessChecks {
155+
if !rc() {
156+
resp.WriteHeader(http.StatusServiceUnavailable)
157+
return
158+
}
159+
}
143160
resp.WriteHeader(http.StatusOK)
144161
}
145162

pkg/health/health_test.go

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,67 @@ func TestHTTPReadyUnhealthy(t *testing.T) {
137137
}
138138
}
139139

140+
func TestReadinessCheckBlocksReady(t *testing.T) {
141+
t.Parallel()
142+
ready := true
143+
checker := NewRestChecker(version, service,
144+
WithReadinessCheck("capacity", func() bool { return ready }),
145+
)
146+
checker.check(context.Background())
147+
148+
req := httptest.NewRequest("GET", fmt.Sprintf("%s%s%s", version, service, readyPath), nil)
149+
resp := httptest.NewRecorder()
150+
checker.ServeReadyHTTP(resp, req)
151+
if resp.Code != http.StatusOK {
152+
t.Errorf("Expected 200 when readiness check passes, got %d", resp.Code)
153+
}
154+
155+
ready = false
156+
resp = httptest.NewRecorder()
157+
checker.ServeReadyHTTP(resp, req)
158+
if resp.Code != http.StatusServiceUnavailable {
159+
t.Errorf("Expected 503 when readiness check fails, got %d", resp.Code)
160+
}
161+
}
162+
163+
func TestReadinessCheckDoesNotAffectLiveness(t *testing.T) {
164+
t.Parallel()
165+
checker := NewRestChecker(version, service,
166+
WithReadinessCheck("capacity", func() bool { return false }),
167+
)
168+
checker.check(context.Background())
169+
170+
req := httptest.NewRequest("GET", getTargetPath(t), nil)
171+
resp := httptest.NewRecorder()
172+
checker.ServeLiveHTTP(resp, req)
173+
if resp.Code != http.StatusOK {
174+
t.Errorf("Liveness should not be affected by readiness check, got %d", resp.Code)
175+
}
176+
}
177+
178+
func TestGRPCReadinessCheckBlocksReady(t *testing.T) {
179+
t.Parallel()
180+
ready := true
181+
checker := NewGrpcChecker(
182+
WithReadinessCheck("capacity", func() bool { return ready }),
183+
)
184+
checker.check(context.Background())
185+
186+
req := httptest.NewRequest("GET", readyPath, nil)
187+
resp := httptest.NewRecorder()
188+
checker.ServeHTTP(resp, req)
189+
if resp.Code != http.StatusOK {
190+
t.Errorf("Expected 200 when readiness check passes, got %d", resp.Code)
191+
}
192+
193+
ready = false
194+
resp = httptest.NewRecorder()
195+
checker.ServeHTTP(resp, req)
196+
if resp.Code != http.StatusServiceUnavailable {
197+
t.Errorf("Expected 503 when readiness check fails, got %d", resp.Code)
198+
}
199+
}
200+
140201
func TestHTTPHealthAffectedByStop(t *testing.T) {
141202
t.Parallel()
142203
patterns := []struct {

0 commit comments

Comments
 (0)