Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions diagnostic/build-a2d43c2b.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
{
"generated_at": "2026-06-24T13:01:56.201583+00:00",
"commit": "a2d43c2b",
"diagnostic_logd": "diagnostic\\build-a2d43c2b.logd",
"diagnostic_logd_error": null,
"message_blocker": null,
"chunked": false,
"chunk_size_bytes": null,
"password": "a1c6154dbdc295f5971b",
"decrypt_command": "encryptly unpack diagnostic\\build-a2d43c2b.logd <outdir> --password a1c6154dbdc295f5971b",
"total_modules": 1,
"passed": 0,
"failed": 1,
"modules": [
{
"name": "v2-market-stream",
"status": "FAIL",
"elapsed_seconds": 0,
"artifact": null,
"output": "Command not found: [WinError 2] \u7cfb\u7edf\u627e\u4e0d\u5230\u6307\u5b9a\u7684\u6587\u4ef6\u3002"
}
],
"pr_note": "Include the encrypted diagnostic logd artifact(s): diagnostic\\build-a2d43c2b.logd. The encrypted .logd is the required diagnostic content for PR review; this JSON file is metadata. Maintainers may ask you to remove these diagnostic artifacts before merging."
}
Binary file added diagnostic/build-a2d43c2b.logd
Binary file not shown.
51 changes: 51 additions & 0 deletions docs/OPERATIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -310,3 +310,54 @@ Audit logs are retained for 365 days and include:
2. Update Kubernetes secret: `kubectl create secret tls tot-tls --cert=new.crt --key=new.key -n tent-production --dry-run=client -o yaml | kubectl apply -f -`
3. Restart services: `kubectl rollout restart deployment -n tent-production`
4. Verify new certificate: `openssl s_client -connect api.example.com:443 -servername api.example.com`

## Market Gateway Readiness Drain

The market gateway supports a readiness drain flag that allows operators to
mark the gateway as draining during deployments or shutdown coordination.
When draining, the `/health/ready` endpoint returns HTTP 503 with a
`{"status":"draining"}` response so load balancers and Kubernetes stop
routing new traffic while in-flight requests continue to be served.

### States

| State | `/health/ready` Response | Behavior |
|-------|--------------------------|----------|
| Ready | `200 {"status":"ready"}` | Accepts new traffic normally |
| Draining | `503 {"status":"draining"}` | No new traffic; existing requests served |

### API

The `ReadinessProbe` is defined in `market/gateway/readiness.go`:

| Method | Description |
|--------|-------------|
| `NewReadinessProbe()` | Create a probe in the Ready state |
| `IsReady() bool` | Check if the gateway is ready |
| `State() ReadinessState` | Get the current state (Ready/Draining) |
| `SetDraining()` | Transition to Draining |
| `SetReady()` | Transition back to Ready |

The `Gateway` exposes convenience methods:

| Method | Description |
|--------|-------------|
| `Drain()` | Set readiness to Draining and log the transition |
| `Readiness() ReadinessState` | Return the current readiness state |

### Kubernetes Integration

During a rolling deployment, the preStop hook can set the drain flag:

```yaml
lifecycle:
preStop:
exec:
command: ["/bin/sh", "-c", "curl -XPOST http://localhost:8081/admin/drain && sleep 10"]
```

### Running the Tests

```bash
cd market && go test ./gateway/ -run Readiness -v
```
22 changes: 20 additions & 2 deletions market/gateway/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,7 @@ type Gateway struct {
logger *log.Logger
startedAt time.Time
health atomic.Value
readiness *ReadinessProbe
mu sync.RWMutex
routes []Route
middleware []MiddlewareFunc
Expand Down Expand Up @@ -254,6 +255,7 @@ func NewGateway(config GatewayConfig) *Gateway {
logger: log.New(os.Stdout, "[gateway] ", log.LstdFlags),
startedAt: time.Now(),
health: atomic.Value{},
readiness: NewReadinessProbe(),
}
g.health.Store(true)
g.registerDefaultRoutes()
Expand Down Expand Up @@ -313,6 +315,18 @@ func (g *Gateway) Health() bool {
return ok && val
}

// Drain transitions the readiness probe to Draining so the /health/ready
// endpoint returns 503. Existing in-flight requests continue to be served.
func (g *Gateway) Drain() {
g.readiness.SetDraining()
g.logger.Println("Readiness set to draining; /health/ready returns 503")
}

// Readiness returns the current readiness probe state.
func (g *Gateway) Readiness() ReadinessState {
return g.readiness.State()
}

func (g *Gateway) Stats() GatewayMetrics {
g.metrics.mu.Lock()
defer g.metrics.mu.Unlock()
Expand Down Expand Up @@ -545,8 +559,12 @@ func (g *Gateway) handleHealth() http.HandlerFunc {

func (g *Gateway) handleReadiness() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !g.Health() {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"status": "not ready"})
if !g.Health() || !g.readiness.IsReady() {
status := "not ready"
if g.readiness.State() == Draining {
status = "draining"
}
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"status": status})
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ready"})
Expand Down
77 changes: 77 additions & 0 deletions market/gateway/readiness.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
// Package readiness provides a readiness state helper for the market gateway.
//
// The gateway can be in one of two readiness states: Ready or Draining.
// When draining, the /health/ready endpoint returns a non-ready response
// so load balancers and Kubernetes stop routing traffic, while the process
// remains alive to finish in-flight work.
package gateway

import (
"sync"
)

// ReadinessState represents the gateway's readiness state.
type ReadinessState int

const (
// Ready means the gateway accepts traffic normally.
Ready ReadinessState = iota
// Draining means the gateway is shutting down; do not send new traffic.
Draining
)

// String returns a human-readable label for the state.
func (s ReadinessState) String() string {
switch s {
case Ready:
return "ready"
case Draining:
return "draining"
default:
return "unknown"
}
}

// ReadinessProbe tracks whether the gateway is ready or draining.
// It is safe for concurrent use.
type ReadinessProbe struct {
mu sync.RWMutex
state ReadinessState
}

// NewReadinessProbe returns a probe in the Ready state.
func NewReadinessProbe() *ReadinessProbe {
return &ReadinessProbe{state: Ready}
}

// State returns the current readiness state.
func (p *ReadinessProbe) State() ReadinessState {
p.mu.RLock()
defer p.mu.RUnlock()
return p.state
}

// IsReady reports whether the gateway is in the Ready state.
func (p *ReadinessProbe) IsReady() bool {
return p.State() == Ready
}

// SetDraining transitions the gateway to the Draining state.
// Returns the previous state.
func (p *ReadinessProbe) SetDraining() ReadinessState {
p.mu.Lock()
defer p.mu.Unlock()
prev := p.state
p.state = Draining
return prev
}

// SetReady transitions the gateway back to the Ready state.
// Returns the previous state. Useful in tests or admin rollback.
func (p *ReadinessProbe) SetReady() ReadinessState {
p.mu.Lock()
defer p.mu.Unlock()
prev := p.state
p.state = Ready
return prev
}
95 changes: 95 additions & 0 deletions market/gateway/readiness_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
package gateway

import (
"testing"
)

func TestNewReadinessProbeStartsReady(t *testing.T) {
p := NewReadinessProbe()
if !p.IsReady() {
t.Fatal("new probe should start in Ready state")
}
if s := p.State(); s != Ready {
t.Fatalf("expected Ready, got %v", s)
}
}

func TestSetDrainingTransitionsState(t *testing.T) {
p := NewReadinessProbe()

prev := p.SetDraining()
if prev != Ready {
t.Fatalf("previous state should be Ready, got %v", prev)
}
if p.IsReady() {
t.Fatal("probe should not be ready after draining")
}
if s := p.State(); s != Draining {
t.Fatalf("expected Draining, got %v", s)
}
}

func TestSetReadyTransitionsBack(t *testing.T) {
p := NewReadinessProbe()
p.SetDraining()

prev := p.SetReady()
if prev != Draining {
t.Fatalf("previous state should be Draining, got %v", prev)
}
if !p.IsReady() {
t.Fatal("probe should be ready after SetReady")
}
}

func TestReadinessStateString(t *testing.T) {
tests := []struct {
state ReadinessState
expected string
}{
{Ready, "ready"},
{Draining, "draining"},
{ReadinessState(99), "unknown"},
}
for _, tt := range tests {
got := tt.state.String()
if got != tt.expected {
t.Errorf("ReadinessState(%d).String() = %q, want %q", tt.state, got, tt.expected)
}
}
}

func TestDrainingGatewayReturnsNotReady(t *testing.T) {
g := NewGateway(DefaultGatewayConfig())
g.readiness = NewReadinessProbe()
g.readiness.SetDraining()

// The health/ready endpoint should return 503 when draining
// We verify the probe state directly since starting an HTTP server
// in unit tests adds complexity.
if g.readiness.IsReady() {
t.Fatal("gateway readiness probe should report not-ready when draining")
}
}

func TestConcurrentReadinessAccess(t *testing.T) {
p := NewReadinessProbe()
done := make(chan struct{})

// Writer goroutine: toggle state rapidly
go func() {
defer close(done)
for i := 0; i < 1000; i++ {
p.SetDraining()
p.SetReady()
}
}()

// Reader goroutine: observe state without racing
for i := 0; i < 1000; i++ {
_ = p.IsReady()
_ = p.State()
}

<-done // ensure writer finished
}