Skip to content

refactor: improve server lifecycle management and context handling - #467

Open
e-repo wants to merge 1 commit into
evrone:masterfrom
e-repo:refactor/server-lifecycle-management
Open

refactor: improve server lifecycle management and context handling#467
e-repo wants to merge 1 commit into
evrone:masterfrom
e-repo:refactor/server-lifecycle-management

Conversation

@e-repo

@e-repo e-repo commented Aug 15, 2026

Copy link
Copy Markdown

Problem

The current server implementation had several architectural issues:

  1. Context anti-pattern: Storing context.Context in server structs violates Go best practices (see official docs: "Do not store Contexts inside a struct type")

  2. Redundant errgroup usage: Each server used errgroup.Group with SetLimit(1) for a single goroutine, adding unnecessary complexity

  3. Duplicated error handling: Errors were sent both to notify channel and returned from errgroup, creating redundant code paths

  4. Sequential shutdown: Servers shut down one by one, multiplying the total shutdown time (4 servers × 3s = 12s instead of ~3s)

  5. Missing context propagation: Shutdown context wasn't properly isolated from cancellation state

Solution

Refactored server lifecycle management following production-ready patterns:

Server layer changes:

  • Removed context.Context and errgroup.Group from server structs
  • Replaced Start() + Notify() + Shutdown() pattern with single Start(ctx context.Context) error method
  • Servers now block until context is canceled and handle graceful shutdown internally

Application layer changes:

  • Introduced unified Server interface for all transport types
  • Centralized lifecycle management using errgroup in app.Run()
  • All servers start in parallel and share the same context
  • Signal handling integrated into errgroup
  • Proper shutdown context isolation using context.WithoutCancel()

## Problem

The current server implementation had several architectural issues:

1. **Context anti-pattern**: Storing `context.Context` in server structs
   violates Go best practices (see official docs: "Do not store Contexts
   inside a struct type")

2. **Redundant errgroup usage**: Each server used `errgroup.Group` with
   `SetLimit(1)` for a single goroutine, adding unnecessary complexity

3. **Duplicated error handling**: Errors were sent both to `notify` channel
   and returned from errgroup, creating redundant code paths

4. **Sequential shutdown**: Servers shut down one by one, multiplying the
   total shutdown time (4 servers × 3s = 12s instead of ~3s)

5. **Missing context propagation**: Shutdown context wasn't properly
   isolated from cancellation state

## Solution

Refactored server lifecycle management following production-ready patterns:

**Server layer changes:**
- Removed `context.Context` and `errgroup.Group` from server structs
- Replaced `Start()` + `Notify()` + `Shutdown()` pattern with single
  `Start(ctx context.Context) error` method
- Servers now block until context is canceled and handle graceful
  shutdown internally

**Application layer changes:**
- Introduced unified `Server` interface for all transport types
- Centralized lifecycle management using `errgroup` in `app.Run()`
- All servers start in parallel and share the same context
- Signal handling integrated into errgroup
- Proper shutdown context isolation using `context.WithoutCancel()`
@soltanoff

Copy link
Copy Markdown
Collaborator

Hi! Thanks for contributing. I'll review it 👀

@soltanoff soltanoff left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Direction looks right — context out of the structs, no more errgroup with SetLimit(1) per server, parallel shutdown. build, vet and golangci-lint are all clean on the branch.

Comments are on the files. The rmq/nats one is the blocker: the cancelled context now reaches in-flight handlers, and messages get acked without the work being done.

Also worth adding a test or two — this is 195/279 lines of lifecycle and concurrency with none. Something like: server starts, context is cancelled, Start returns within a timeout, port is released.

One thing I checked that turned out fine: I thought grpc Serve returned before the detached GracefulStop finished draining RPCs. It doesn't — Serve blocks on <-s.done.Done() until stop() completes.

}

s.serveCall(&d)
s.serveCall(ctx, &d)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This passes the errgroup context down into the handler, and that context is cancelled on SIGTERM. It reaches the usecase and then pgx — internal/controller/amqp_rpc/v1/task.go:33 calls r.tk.Create(ctx, ...).

So during shutdown a handler that already opened a transaction gets context.Canceled from pgx, returns an error, serveCall publishes ErrInternalServer, and defer s.ack(d, false) acks the delivery anyway. The message is confirmed, the work was never done, and there is no redelivery. That happens on every deploy.

Before this change the handler context came from errgroup.WithContext(context.Background()) and was never cancelled during normal operation, so it is a regression.

Cheapest fix is to skip the ack when the handler failed with context.Canceled, so the message goes to another instance. The better one is to hand handlers a context that survives cancellation and drain them separately:

handlerCtx := context.WithoutCancel(ctx)
var inflight sync.WaitGroup

then wait on inflight with its own timeout before closing the connection, same idea as shutdownTimeout in httpserver.

subscription, err := s.connection.Subscribe(s.subject, s.handleMessage)
func (s *Server) subscribe(ctx context.Context) error {
subscription, err := s.connection.Subscribe(s.subject, func(msg *nats.Msg) {
s.handleMessage(ctx, msg)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as in the rmq server: ctx here is the errgroup context, so every in-flight handler sees context.Canceled the moment SIGTERM lands and the caller gets ErrInternalServer instead of a result.

}

// Close connection
s.connection.Close()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unsubscribe and Close run right after the context is cancelled, but handleMessage callbacks are still running on nats.go client goroutines and nothing waits for them. Their s.publish lands on a closed connection, so the caller times out instead of getting a reply. Unsubscribe also drops messages that were delivered but not processed yet.

nats.go has Drain for this — Subscription.Drain() / Conn.Drain() stop new deliveries, let the buffer finish and then close. Drain is asynchronous, so you still need to wait for it, either through nats.ClosedHandler or a bounded wait on conn.IsClosed().

Comment thread internal/app/app.go
})

// Wait for all servers to finish
if err := g.Wait(); err != nil && !errors.Is(err, context.Canceled) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two things here.

The error is logged, then line 159 prints "application stopped gracefully" and Run returns with exit code 0. If the HTTP port is taken, the process exits successfully and an orchestrator will not restart it under a failure policy. Returning the error from runServers and calling l.Fatal on it would fix that.

The other one: every server returns ctx.Err() on a clean shutdown, so g.Wait() returns non-nil on the happy path and you have to filter context.Canceled out. That filter also swallows a real context.Canceled coming from pgx or an external client. Returning nil on graceful shutdown makes non-nil always mean failure.

Comment thread internal/app/app.go
g.Go(func() error {
sigCh := make(chan os.Signal, 1)

signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM) does all of this, and then runServers no longer needs the cancel parameter either.

Comment thread internal/app/app.go
)

// Server represents a service that can be started and gracefully stopped.
type Server interface {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment promises a graceful stop, but there is no stop method — stopping is expressed by cancelling the context. Worth rewording.

Comment thread pkg/httpserver/server.go

close(s.notify)
// Start server in goroutine
go func() {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Listen goes into a goroutine and the select below immediately waits on ctx.Done(). If cancellation arrives before fasthttp has bound — SIGTERM right after start, or another server in the group failing fast — ShutdownWithContext hits if s.ln == nil { return nil } in fasthttp server.go and does nothing, and its defer s.stop.Store(0) resets the stop flag. Start returns, and then this goroutine brings up a listening socket that nobody will ever close.

Narrow window, but reproducible. Signalling "listening" before entering the select, or waiting for this goroutine after shutdown, closes it.

Comment thread pkg/grpcserver/server.go
serverOpts []pbgrpc.ServerOption
address string
serverOpts []pbgrpc.ServerOption
shutdownTimeout time.Duration

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This field has no Option, so it cannot be changed — httpserver has ShutdownTimeout in options.go. Either add one or keep it a plain constant.

Also, line 72 uses time.After, which keeps the timer alive until it fires; time.NewTimer with defer t.Stop() is cheaper.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants