Skip to content

Commit 480c4c7

Browse files
committed
feat(shutdown): add graceful shutdown to pipeline service and HTTP server
- Add Shutdown(ctx) to PipelineService interface; implementation cancels all in-flight pipeline contexts and waits for pollDebounceExpiry to exit - Add stopCh/stoppedCh channels to pipelineService; pollDebounceExpiry now exits cleanly on close(stopCh) instead of running indefinitely - Replace r.Run() in main with http.Server + signal.NotifyContext for SIGTERM/SIGINT with 10s drain budget
1 parent ae33a8f commit 480c4c7

3 files changed

Lines changed: 73 additions & 7 deletions

File tree

cmd/server/main.go

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,12 @@ package main
22

33
import (
44
"context"
5+
"errors"
56
"log"
7+
"net/http"
8+
"os/signal"
9+
"syscall"
10+
"time"
611

712
"github.com/gin-gonic/gin"
813
"github.com/go-redsync/redsync/v4"
@@ -63,9 +68,28 @@ func main() {
6368
r.Use(gin.Recovery())
6469
handler.RegisterRoutes(r)
6570

66-
// Step 10: start server
67-
log.Printf("evo-bot-runtime starting on %s", cfg.ListenAddr)
68-
if err := r.Run(cfg.ListenAddr); err != nil {
69-
log.Fatalf("server failed: %v", err)
71+
// Step 10: start server (non-blocking)
72+
srv := &http.Server{Addr: cfg.ListenAddr, Handler: r}
73+
go func() {
74+
log.Printf("evo-bot-runtime starting on %s", cfg.ListenAddr)
75+
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
76+
log.Fatalf("server failed: %v", err)
77+
}
78+
}()
79+
80+
// Step 11: wait for SIGTERM or SIGINT
81+
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
82+
defer stop()
83+
<-ctx.Done()
84+
log.Printf("evo-bot-runtime shutting down")
85+
86+
// Step 12: graceful shutdown — 10s budget for in-flight HTTP requests
87+
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
88+
defer cancel()
89+
if err := srv.Shutdown(shutdownCtx); err != nil {
90+
log.Printf("HTTP server shutdown error: %v", err)
7091
}
92+
93+
// Step 13: stop pipeline (cancel in-flight goroutines, drain poller)
94+
pipeline.Shutdown(shutdownCtx)
7195
}

pkg/pipeline/handler/handler_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ type mockSvc struct{ processErr error }
5656
func (m *mockSvc) Process(_ context.Context, _ *model.MessageEvent) error { return m.processErr }
5757
func (m *mockSvc) Cancel(_, _ int64) error { return nil }
5858
func (m *mockSvc) Start() error { return nil }
59+
func (m *mockSvc) Shutdown(_ context.Context) {}
5960

6061
var _ pipelineService.PipelineService = (*mockSvc)(nil)
6162

pkg/pipeline/service/pipeline_service.go

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ type PipelineService interface {
2525
Process(ctx context.Context, event *model.MessageEvent) error
2626
Cancel(contactID, conversationID int64) error
2727
Start() error
28+
// Shutdown stops the polling goroutine and cancels all in-flight pipeline
29+
// contexts. It blocks until the poller exits or ctx is cancelled.
30+
Shutdown(ctx context.Context)
2831
}
2932

3033
type pipelineEntry struct {
@@ -39,7 +42,9 @@ type pipelineService struct {
3942
debounce debounceIface.DebounceEngine
4043
aiAdapter aiIface.AIAdapter
4144
dispatchEng dispatchIface.DispatchEngine
42-
entries sync.Map // string → pipelineEntry
45+
entries sync.Map // string → pipelineEntry
46+
stopCh chan struct{} // closed by Shutdown to stop pollDebounceExpiry
47+
stoppedCh chan struct{} // closed by pollDebounceExpiry when it exits
4348
}
4449

4550
// NewPipelineService constructs the service. Returns interface (GEAR R03).
@@ -49,7 +54,14 @@ func NewPipelineService(
4954
aiAdapter aiIface.AIAdapter,
5055
dispatchEng dispatchIface.DispatchEngine,
5156
) PipelineService {
52-
return &pipelineService{repo: repo, debounce: debounce, aiAdapter: aiAdapter, dispatchEng: dispatchEng}
57+
return &pipelineService{
58+
repo: repo,
59+
debounce: debounce,
60+
aiAdapter: aiAdapter,
61+
dispatchEng: dispatchEng,
62+
stopCh: make(chan struct{}),
63+
stoppedCh: make(chan struct{}),
64+
}
5365
}
5466

5567
// Start recovers in-progress debounce pairs from Redis, then launches the single
@@ -433,11 +445,19 @@ func (s *pipelineService) runDispatchStage(
433445

434446
// pollDebounceExpiry is the single timer-detection mechanism (AC: #1).
435447
// It runs a 100ms ticker and advances expired StageDebounce pairs to StageAI.
448+
// It exits when s.stopCh is closed and signals s.stoppedCh before returning.
436449
func (s *pipelineService) pollDebounceExpiry() {
450+
defer close(s.stoppedCh)
451+
437452
ticker := time.NewTicker(100 * time.Millisecond)
438453
defer ticker.Stop()
439454

440-
for range ticker.C {
455+
for {
456+
select {
457+
case <-s.stopCh:
458+
return
459+
case <-ticker.C:
460+
}
441461
s.entries.Range(func(k, v any) bool {
442462
entry, ok := v.(pipelineEntry)
443463
if !ok {
@@ -494,6 +514,27 @@ func (s *pipelineService) Cancel(contactID, conversationID int64) error {
494514
return nil
495515
}
496516

517+
// Shutdown stops the polling goroutine and cancels all in-flight pipeline
518+
// contexts. It blocks until the poller exits or ctx is cancelled.
519+
func (s *pipelineService) Shutdown(ctx context.Context) {
520+
// Cancel all in-flight pipeline goroutines.
521+
s.entries.Range(func(k, v any) bool {
522+
if entry, ok := v.(pipelineEntry); ok {
523+
entry.cancel()
524+
}
525+
return true
526+
})
527+
528+
// Signal poller to stop and wait for it to exit.
529+
close(s.stopCh)
530+
select {
531+
case <-s.stoppedCh:
532+
slog.Info("pipeline.shutdown.complete")
533+
case <-ctx.Done():
534+
slog.Warn("pipeline.shutdown.timeout")
535+
}
536+
}
537+
497538
// recoverPipeline is deferred in every pipeline goroutine to handle panics safely.
498539
func (s *pipelineService) recoverPipeline(contactID, conversationID int64) {
499540
if r := recover(); r != nil {

0 commit comments

Comments
 (0)