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
157 changes: 157 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ Unlike traditional Go frameworks that focus only on request handling, Vodka heav
- [Minimal API Example](#minimal-api-example)
- [Using Vodka for APIs](#using-vodka-for-apis)
- [Core Concepts](#core-concepts)
- [Graceful Shutdown & Lifecycle Manager](#graceful-shutdown--lifecycle-manager)
- [Middleware](#middleware)
- [Validation](#validation)
- [Authentication](#authentication)
Expand Down Expand Up @@ -412,6 +413,162 @@ c.Error(400, errors.New("invalid request"))

---

# Graceful Shutdown & Lifecycle Manager

Vodka features a production-ready application lifecycle management system that handles:
- **Startup Hooks**: Run code before the HTTP server starts.
- **Shutdown Hooks**: Run code sequentially when the application receives termination signals (`SIGINT`, `SIGTERM`).
- **Priority-Based Execution**: Control the shutdown sequence of your resources.
- **Graceful HTTP Server Shutdown**: Stop accepting new connections and finish active requests.
- **Configurable Shutdown Timeout**: Cancel remaining work if the timeout expires.
- **Error Aggregation**: Collect and report errors from all hooks.

---

## Startup Hooks

Startup hooks allow executing initialization code (e.g., establishing database connections, seeding data) before the server begins serving requests. If any startup hook returns an error, server startup is immediately aborted.

```go
app.OnStart(func() error {
return initializeDatabase()
})
```

---

## Shutdown Hooks & Priority

Shutdown hooks are executed sequentially when a termination signal is received. You can register hooks with a priority value:

- Higher priority hooks execute first.
- If priorities are equal, hooks execute in registration order.
- The default priority is `0` when calling `OnShutdown`.

```go
// Priority 100: runs first
app.OnShutdownWithPriority(100, func(ctx context.Context) error {
return closeDatabase()
})

// Priority 50: runs second
app.OnShutdownWithPriority(50, func(ctx context.Context) error {
return stopWorkers()
})

// Default priority (0): runs last
app.OnShutdown(func(ctx context.Context) error {
return cleanupTempFiles()
})
```

---

## Timeout Configuration

By default, Vodka allows up to `30 seconds` for the entire shutdown sequence (including finishing active HTTP requests and running all shutdown hooks). You can customize this timeout:

```go
app.SetShutdownTimeout(45 * time.Second)
```

---

## Production Example

Here is a full production-ready example demonstrating database cleanup, worker queue termination, and graceful server shutdown:

```go
package main

import (
"context"
"database/sql"
"fmt"
"log"
"time"

"github.com/DevanshuTripathi/vodka"
_ "github.com/lib/pq"
)

func main() {
app := vodka.DefaultRouter()

var db *sql.DB

// 1. Register Startup Hook to initialize database
app.OnStart(func() error {
var err error
db, err = sql.Open("postgres", "postgres://user:pass@localhost/db?sslmode=disable")
if err != nil {
return fmt.Errorf("failed to open database: %w", err)
}

// Verify connection
if err := db.Ping(); err != nil {
return fmt.Errorf("failed to ping database: %w", err)
}
log.Println("Database connection established")
return nil
})

// 2. Start workers
workerCtx, cancelWorkers := context.WithCancel(context.Background())
app.OnStart(func() error {
go runBackgroundWorkers(workerCtx)
log.Println("Background workers started")
return nil
})

// 3. Configure Shutdown Timeout
app.SetShutdownTimeout(15 * time.Second)

// 4. Register Shutdown Hooks in priority order

// Stop background workers first (Priority 100)
app.OnShutdownWithPriority(100, func(ctx context.Context) error {
log.Println("Stopping background workers...")
cancelWorkers()
return nil
})

// Close database connection (Priority 50)
app.OnShutdownWithPriority(50, func(ctx context.Context) error {
log.Println("Closing database connection...")
if db != nil {
return db.Close()
}
return nil
})

// Simple route
app.GET("/", func(c *vodka.Context) {
c.String(200, "Hello, Graceful Vodka!")
})

// 5. Start the server (handles SIGINT/SIGTERM automatically)
if err := app.Run(":8080"); err != nil {
log.Fatalf("Server stopped with error: %v", err)
}
}

func runBackgroundWorkers(ctx context.Context) {
for {
select {
case <-ctx.Done():
log.Println("Workers stopped")
return
default:
// Perform background work
time.Sleep(1 * time.Second)
}
}
}
```

---

# Middleware

Vodka middleware is simply a `vodka.HandlerFunc`.
Expand Down
130 changes: 126 additions & 4 deletions engine.go
Original file line number Diff line number Diff line change
@@ -1,15 +1,21 @@
package vodka

import (
"context"
"fmt"
"html/template"
"log"
"net"
"net/http"
"os"
"os/signal"
"path"
"path/filepath"
"sort"
"strings"
"sync"
"syscall"
"time"

"github.com/gorilla/websocket"
"github.com/julienschmidt/httprouter"
Expand All @@ -29,15 +35,18 @@ type Engine struct {
templates map[string]*template.Template
templatesMu sync.RWMutex
*RouterGroup
lifecycle *LifecycleManager
shutdownOnce sync.Once
}

// creates a new router
func NewRouter() *Engine {
router := httprouter.New()
router.HandleOPTIONS = false
engine := &Engine{
router: router,
WSConfig: DefaultWSConfig(),
router: router,
WSConfig: DefaultWSConfig(),
lifecycle: NewLifecycleManager(),
}

engine.RouterGroup = &RouterGroup{
Expand Down Expand Up @@ -72,15 +81,128 @@ func (rg *RouterGroup) Use(middlewares ...HandlerFunc) {
}

// Runs the http server
// Runs the http server with startup/shutdown hook management and graceful shutdown
func (e *Engine) Run(addr string) error {
if addr == "" {
addr = ":8080"
}

log.Println("Starting application...")

// 1. Execute startup hooks before serving
if err := e.lifecycle.runStartupHooks(); err != nil {
return err
}
log.Println("Startup hooks completed")

log.Printf(Green+"Pouring Vodka on %s\n"+Reset, addr)

// Using net/http
return http.ListenAndServe(addr, e)
ln, err := net.Listen("tcp", addr)
if err != nil {
return err
}

srv := &http.Server{
Addr: addr,
Handler: e,
}

serverErr := make(chan error, 1)
go func() {
if err := srv.Serve(ln); err != nil && err != http.ErrServerClosed {
serverErr <- err
}
}()

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

select {
case err := <-serverErr:
// If server failed to start/serve, perform shutdown to clean up hooks
shutdownErr := e.shutdown(srv)
if shutdownErr != nil {
return fmt.Errorf("server error: %v; shutdown errors: %v", err, shutdownErr)
}
return err
case sig := <-quit:
log.Printf(Yellow+"Shutdown signal received: %v\n"+Reset, sig)
return e.shutdown(srv)
}
}

// shutdown ensures shutdown is performed only once and delegates to shutdownInternal.
func (e *Engine) shutdown(srv *http.Server) error {
var err error
e.shutdownOnce.Do(func() {
err = e.shutdownInternal(srv)
})
return err
}

// shutdownInternal handles graceful HTTP server shutdown and executes registered shutdown hooks.
func (e *Engine) shutdownInternal(srv *http.Server) error {
e.lifecycle.mu.Lock()
timeout := e.lifecycle.timeout
hooks := make([]lifecycleHook, len(e.lifecycle.shutdownHooks))
copy(hooks, e.lifecycle.shutdownHooks)
e.lifecycle.mu.Unlock()

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

var shutdownErrors []error

// Step 2: Gracefully finish active requests
if err := srv.Shutdown(ctx); err != nil {
shutdownErrors = append(shutdownErrors, fmt.Errorf("server shutdown failed: %w", err))
}

log.Println("Running shutdown hooks...")

// Step 3: Sort shutdown hooks by priority (descending), preserving registration order for equal priorities.
sort.SliceStable(hooks, func(i, j int) bool {
if hooks[i].priority != hooks[j].priority {
return hooks[i].priority > hooks[j].priority
}
return hooks[i].order < hooks[j].order
})

// Step 4: Execute registered shutdown hooks sequentially
for _, hook := range hooks {
if err := ctx.Err(); err != nil {
shutdownErrors = append(shutdownErrors, fmt.Errorf("shutdown timeout exceeded: %w", err))
break
}
if err := hook.fn(ctx); err != nil {
shutdownErrors = append(shutdownErrors, err)
}
}

if len(shutdownErrors) > 0 {
log.Println("Shutdown complete with errors")
return &ShutdownError{Errors: shutdownErrors}
}

log.Println("Shutdown complete")
return nil
}

func (e *Engine) OnStart(fn func() error) {
e.lifecycle.RegisterStart(fn)
}

func (e *Engine) OnShutdown(fn func(context.Context) error) {
e.lifecycle.RegisterShutdown(0, fn)
}

func (e *Engine) OnShutdownWithPriority(priority int, fn func(context.Context) error) {
e.lifecycle.RegisterShutdown(priority, fn)
}

func (e *Engine) SetShutdownTimeout(timeout time.Duration) {
e.lifecycle.SetTimeout(timeout)
}

// LoadHTMLGlob parses and caches templates from a glob pattern
Expand Down
Loading