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
13 changes: 13 additions & 0 deletions TODO.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# TODO - PR #958 (Go Backend Graceful Shutdown)

## Completed
- [x] Remove extraneous audit/verification markdown files from `backend/`.

## Next Steps
- [ ] (2) Remove duplicate server: delete `backend/main.go`.
- [ ] Update `backend/README.md` to reference the canonical server path `backend/go/cmd/server/`.
- [ ] (3) Add graceful shutdown tests:
- [ ] Add `backend/go/cmd/server/main_test.go`.
- [ ] Refactor `backend/go/cmd/server/main.go` slightly (only what’s needed) to make shutdown testable without executing `os.Exit`.
Comment thread
Suji2007hub marked this conversation as resolved.
- [ ] Run `cd backend && go test ./...`.

125 changes: 125 additions & 0 deletions backend/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
# DraftDeckAI Backend - Go Servers

This directory contains two Go backend server implementations with **graceful shutdown** support.

## Files

- **`go/cmd/server/main.go`** - Production-ready server with health/ready endpoints
- **`go.mod`** - Go module definition


## Quick Start

### Prerequisites
- Go 1.21 or later (install from https://golang.org/dl/)

### Run the first server:
```bash
cd backend
go run main.go
```

Expected output:
```
2026/06/06 10:23:45 [INFO] Starting server on :8080
```
Comment thread
Suji2007hub marked this conversation as resolved.

### Test graceful shutdown:
```bash
# In a new terminal, send graceful shutdown signal
taskkill /PID <process-id> /SIGTERM # Windows
# OR press Ctrl+C in the server terminal
```

Expected output:
```
[INFO] Received signal: interrupt - initiating graceful shutdown
[INFO] Server shut down gracefully - all in-flight requests completed
```
Comment thread
Suji2007hub marked this conversation as resolved.

## Features

✅ **Signal Handling**
- SIGINT (Ctrl+C) support
- SIGTERM (deployment/scaling) support

✅ **Graceful Shutdown**
- 30-second timeout for in-flight requests
- Stops accepting new connections immediately
- Waits for existing requests to complete
- Force closes on timeout
- Exit code 1 on failure, 0 on success

✅ **Logging**
- Timestamp-based logs
- Signal reception logged
- Shutdown progress logged
- Completion status logged

✅ **Health Checks**
- `/health` - Basic health status
- `/ready` - Readiness probe
- `/api/echo` - Echo endpoint (cmd/server version)

## Configuration

### Shutdown Timeout (Configurable)

Edit the constant in either main.go:

```go
const shutdownTimeoutSeconds = 30 // Change this value
```

### Server Port

```go
const port = ":8080" // Change to desired port
```

### Timeouts

```go
srv := &http.Server{
ReadTimeout: 15 * time.Second, // Request read timeout
WriteTimeout: 15 * time.Second, // Response write timeout
IdleTimeout: 60 * time.Second, // Idle connection timeout
}
```

## Testing

See **`GRACEFUL_SHUTDOWN_TESTING.md`** for comprehensive testing guide including:
- Graceful shutdown verification
- Request draining verification
- Timeout behavior verification
- Health check testing
- Production build instructions

## Architecture

Both servers implement the standard Go HTTP graceful shutdown pattern:

1. Create `http.Server` with timeouts
2. Start server in background goroutine
3. Listen for OS signals (SIGINT, SIGTERM)
4. Call `srv.Shutdown(ctx)` with timeout context
5. Force close if shutdown times out
6. Exit with appropriate code (0 = success, 1 = error/timeout)

## Standards Compliance

- ✅ Go 1.21+ compatible
- ✅ Follows `http.Server` graceful shutdown best practices
- ✅ Implements proper signal handling
- ✅ Includes structured logging
- ✅ Returns correct exit codes
- ✅ Compatible with Kubernetes liveness/readiness probes

## Next Steps

1. Install Go if not already installed
2. Run `go run main.go` to start a server
3. Test endpoints with `curl http://localhost:8080/health`
4. Send graceful shutdown signal and verify logs
5. See GRACEFUL_SHUTDOWN_TESTING.md for detailed tests
3 changes: 3 additions & 0 deletions backend/go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
module draftdeckai/backend

go 1.21
Comment thread
Suji2007hub marked this conversation as resolved.
86 changes: 86 additions & 0 deletions backend/go/cmd/server/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package main

import (
"context"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
)

const shutdownTimeout = 30 * time.Second

func main() {
run()
}

func run() {
srv := &http.Server{
Addr: ":8080",
Handler: routes(),
ReadTimeout: 15 * time.Second,
WriteTimeout: 15 * time.Second,
IdleTimeout: 60 * time.Second,
}

go func() {
log.Println("Server starting on :8080")
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Printf("ListenAndServe error: %v", err)
os.Exit(1)
}
}()

sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)

sig := <-sigChan
log.Printf("Signal: %v - initiating shutdown", sig)

ctx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
defer cancel()

if err := srv.Shutdown(ctx); err != nil {
log.Printf("Shutdown error: %v", err)
srv.Close()
os.Exit(1)
}

log.Println("Server stopped gracefully")
os.Exit(0)
}


func routes() http.Handler {
mux := http.NewServeMux()

mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
fmt.Fprint(w, `{"status":"ok"}`)
})

mux.HandleFunc("/ready", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
fmt.Fprint(w, `{"ready":true}`)
})

mux.HandleFunc("/api/echo", func(w http.ResponseWriter, r *http.Request) {
msg := r.URL.Query().Get("msg")
if msg == "" {
msg = "empty"
}

time.Sleep(100 * time.Millisecond)

w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
fmt.Fprintf(w, `{"echo":"%s"}`, msg)
})
Comment thread
Suji2007hub marked this conversation as resolved.

return mux
}
145 changes: 145 additions & 0 deletions backend/go/cmd/server/main_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
package main

import (
"context"
"net"
"net/http"
"sync"
"testing"
"time"
)

func TestGracefulShutdownDrainsRequests(t *testing.T) {
// Keep timeout short for test speed, but still validate draining.
shutdownTimeout := 2 * time.Second

// Create a listener on an ephemeral port.
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("net.Listen: %v", err)
}
defer ln.Close()

start := make(chan struct{})
startedOnce := sync.Once{}

srv := &http.Server{
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
startedOnce.Do(func() { close(start) })
// Simulate in-flight work longer than typical request latency.
time.Sleep(300 * time.Millisecond)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"ok":true}`))
}),
}

serverErr := make(chan error, 1)
go func() {
serverErr <- srv.Serve(ln)
}()
defer func() {
// Ensure the server is stopped even if the test fails.
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel()
_ = srv.Shutdown(ctx)
}()

<-start // ensure handler has started

reqDone := make(chan error, 1)
go func() {
client := &http.Client{Timeout: 3 * time.Second}
resp, err := client.Get("http://" + ln.Addr().String() + "/")
if err != nil {
reqDone <- err
return
}
defer resp.Body.Close()
reqDone <- nil
}()

// Wait a moment to ensure the request is in-flight.
time.Sleep(50 * time.Millisecond)

ctx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
defer cancel()

shutdownStart := time.Now()
if err := srv.Shutdown(ctx); err != nil {
t.Fatalf("Shutdown: %v", err)
}
shutdownDur := time.Since(shutdownStart)
if shutdownDur > shutdownTimeout {
t.Fatalf("Shutdown exceeded timeout: %v > %v", shutdownDur, shutdownTimeout)
}

if err := <-reqDone; err != nil {
t.Fatalf("in-flight request error after shutdown: %v", err)
}

// Serve should return ErrServerClosed after Shutdown.
if err := <-serverErr; err != http.ErrServerClosed {
// Serve may return nil in rare cases; accept nil.
if err != nil {
t.Fatalf("Serve error: %v", err)
}
}
Comment thread
Suji2007hub marked this conversation as resolved.
}

func TestGracefulShutdownTimeout(t *testing.T) {
shutdownTimeout := 200 * time.Millisecond

ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("net.Listen: %v", err)
}
defer ln.Close()

block := make(chan struct{})

srv := &http.Server{
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
<-block // will exceed shutdown timeout
w.WriteHeader(http.StatusOK)
}),
}

serverErr := make(chan error, 1)
go func() {
serverErr <- srv.Serve(ln)
}()
defer func() {
close(block)
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel()
_ = srv.Shutdown(ctx)
}()

// Fire the request.
client := &http.Client{Timeout: 2 * time.Second}
go func() {
_, _ = client.Get("http://" + ln.Addr().String() + "/")
}()

ctx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
defer cancel()

shutdownStart := time.Now()
err = srv.Shutdown(ctx)
Comment thread
Suji2007hub marked this conversation as resolved.
shutdownDur := time.Since(shutdownStart)

if err == nil {
t.Fatalf("expected Shutdown error due to timeout, got nil")
}
if shutdownDur < shutdownTimeout {
t.Fatalf("Shutdown returned too quickly: %v < %v", shutdownDur, shutdownTimeout)
}

if serveErr := <-serverErr; serveErr != http.ErrServerClosed {
if serveErr != nil {
t.Fatalf("Serve error: %v", serveErr)
}
}
}

Loading