Skip to content

feat: add OnStart hook for synchronous startup jobs #2079

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 24 commits into
base: development
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
8202ed1
feat: improve OnStart hook with DI context, update docs, and ensure c…
Jul 19, 2025
0cb726b
refactor: improve OnStart hook context, move logic to method, and tes…
Jul 21, 2025
8526959
feat(onstart): implement all reviewer feedback
Jul 21, 2025
2cbe7e1
Merge branch 'development' into fix/onstart-di-context
HeerakKashyap Jul 22, 2025
79f0fa5
Merge branch 'development' into fix/onstart-di-context
Umang01-hash Jul 22, 2025
2cf9cbf
Merge branch 'development' into fix/onstart-di-context
HeerakKashyap Jul 22, 2025
9fd31d4
fix: improve startup error handling for OnStart hooks (graceful shutd…
Jul 22, 2025
7c38332
Merge branch 'fix/onstart-di-context' of https://github.com/HeerakKas…
Jul 22, 2025
27ca38f
fix: address linter errors in OnStart tests
Jul 23, 2025
463b194
fix: remove os.Exit call to resolve deep-exit linter error
Jul 23, 2025
9e8bcdf
fix: resolve remaining linter errors
Jul 23, 2025
55d94eb
refactor: remove newContextForHooks and reuse existing newContext fun…
Jul 23, 2025
e13b8cd
fix: resolve linting errors and refactor Run() method for better main…
Jul 24, 2025
dd95596
fix: resolve remaining linting issues in TestApp_OnStart
Jul 24, 2025
60b6ca9
fix: resolve godot and wsl linter errors in run.go and gofr_test.go
Jul 24, 2025
6b9639b
Update go.work.sum
HeerakKashyap Jul 24, 2025
8c6a4b6
fix: restore comments as requested by reviewer
Jul 24, 2025
e950953
Update run.go
HeerakKashyap Jul 24, 2025
bada9a7
Update run.go
HeerakKashyap Jul 24, 2025
9f2b697
Merge branch 'development' into fix/onstart-di-context
Umang01-hash Jul 25, 2025
2767521
merge: resolve conflicts and fix formatting
Jul 25, 2025
eddd360
Merge branch 'development' into fix/onstart-di-context
Umang01-hash Jul 25, 2025
d21b836
Merge branch 'development' into fix/onstart-di-context
HeerakKashyap Jul 25, 2025
0e871e8
Merge branch 'development' into fix/onstart-di-context
Umang01-hash Jul 28, 2025
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
55 changes: 55 additions & 0 deletions docs/advanced-guide/startup-hooks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# Startup Hooks

GoFr provides a way to run synchronous jobs when your application starts, before any servers begin handling requests. This is useful for tasks like seeding a database, warming up a cache, or performing other critical setup procedures.

## `OnStart`

You can register a startup hook using the `a.OnStart()` method on your `app` instance.

### Usage

The method accepts a function with the signature `func(ctx *gofr.Context) error`.

- The `*gofr.Context` passed to the hook is fully initialized and provides access to all dependency-injection-managed services (e.g., `ctx.Container.SQL`, `ctx.Container.Redis`).
- If any `OnStart` hook returns an error, the application will log the error and refuse to start.

### Example: Warming up a Cache

Here is an example of using `OnStart` to set an initial value in a Redis cache when the application starts.

```go
package main

import (
"gofr.dev/pkg/gofr"
)

func main() {
a := gofr.New()

// Register an OnStart hook to warm up a cache.
a.OnStart(func(ctx *gofr.Context) error {
ctx.Container.Logger.Info("Warming up the cache...")

// In a real app, this might come from a database or another service.
cacheKey := "initial-data"
cacheValue := "This is some data cached at startup."

err := ctx.Redis.Set(ctx, cacheKey, cacheValue, 0).Err()
if err != nil {
ctx.Container.Logger.Errorf("Failed to warm up cache: %v", err)
return err // Return the error to halt startup if caching fails.
}

ctx.Container.Logger.Info("Cache warmed up successfully!")

return nil
})

// ... register your routes

a.Run()
}
```

This ensures that critical startup tasks are completed successfully before the application begins accepting traffic.
20 changes: 20 additions & 0 deletions examples/http-server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,26 @@ func main() {
a.GET("/trace", TraceHandler)
a.GET("/mysql", MysqlHandler)

// Register an OnStart hook to warm up a cache.
a.OnStart(func(ctx *gofr.Context) error {
ctx.Container.Logger.Info("Warming up the cache...")

// Example: Fetch some data and store it in Redis.
// In a real app, this might come from a database or another service.
cacheKey := "initial-data"
cacheValue := "This is some data cached at startup."

err := ctx.Redis.Set(ctx, cacheKey, cacheValue, 0).Err()
if err != nil {
ctx.Container.Logger.Errorf("Failed to warm up cache: %v", err)
return err // Return the error to halt startup if caching fails.
}

ctx.Container.Logger.Info("Cache warmed up successfully!")

return nil
})

// Run the application
a.Run()
}
Expand Down
4 changes: 4 additions & 0 deletions go.work.sum
Copy link
Contributor

Choose a reason for hiding this comment

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

I'm unsure why this was changed. As if code using a different logger was added

Copy link
Author

Choose a reason for hiding this comment

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

I was building a new docker container, it automatically changes if u run so "go build " kinds of prompts

Copy link
Member

Choose a reason for hiding this comment

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

@HeerakKashyap Please reomve these changes. There should be no changes to go.work.sum file.

Original file line number Diff line number Diff line change
Expand Up @@ -1144,13 +1144,15 @@ go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0/go.mod h1:umTcuxiv1n/s/S6/c2AT/g2CQ7u5C59sHDNmfSwgz7Q=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=
go.opentelemetry.io/otel v1.22.0/go.mod h1:eoV4iAi3Ea8LkAEI9+GFT44O6T/D0GWAVFyZVCC6pMI=
go.opentelemetry.io/otel v1.24.0/go.mod h1:W7b9Ozg4nkF5tWI5zsXkaKKDjdVjpD4oAt9Qi/MArHo=
go.opentelemetry.io/otel v1.29.0/go.mod h1:N/WtXPs1CNCUEx+Agz5uouwCba+i+bJGFicT8SR4NP8=
go.opentelemetry.io/otel v1.31.0/go.mod h1:O0C14Yl9FgkjqcCZAsE053C13OaddMYr/hz6clDkEJE=
go.opentelemetry.io/otel v1.32.0/go.mod h1:00DCVSB0RQcnzlwyTfqtxSm+DRr9hpYrHjNGiBHVQIg=
go.opentelemetry.io/otel v1.34.0/go.mod h1:OWFPOQ+h4G8xpyjgqo4SxJYdDQ/qmRH+wivy7zzx9oI=
go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y=
go.opentelemetry.io/otel v1.36.0/go.mod h1:/TcFMXYjyRNh8khOAO9ybYkqaDBb/70aVwkNML4pP8E=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0 h1:IeMeyr1aBvBiPVYihXIaeIZba6b8E1bYp7lbdxK8CQg=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0/go.mod h1:oVdCUtjq9MK9BlS7TtucsQwUcXcymNiEDjgDD2jMtZU=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.29.0 h1:WDdP9acbMYjbKIyJUhTvtzj601sVJOqgWdUxSdR/Ysc=
Expand All @@ -1163,6 +1165,7 @@ go.opentelemetry.io/otel/metric v1.32.0/go.mod h1:jH7CIbbK6SH2V2wE16W05BHCtIDzau
go.opentelemetry.io/otel/metric v1.33.0/go.mod h1:L9+Fyctbp6HFTddIxClbQkjtubW6O9QS3Ann/M82u6M=
go.opentelemetry.io/otel/metric v1.34.0/go.mod h1:CEDrp0fy2D0MvkXE+dPV7cMi8tWZwX3dmaIhwPOaqHE=
go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE=
go.opentelemetry.io/otel/metric v1.36.0/go.mod h1:zC7Ks+yeyJt4xig9DEw9kuUFe5C3zLbVjV2PzT6qzbs=
go.opentelemetry.io/otel/sdk v1.22.0/go.mod h1:iu7luyVGYovrRpe2fmj3CVKouQNdTOkxtLzPvPz1DOc=
go.opentelemetry.io/otel/sdk v1.29.0/go.mod h1:pM8Dx5WKnvxLCb+8lG1PRNIDxu9g9b9g59Qr7hfAAok=
go.opentelemetry.io/otel/sdk v1.32.0/go.mod h1:LqgegDBjKMmb2GC6/PrTnteJG39I8/vJCAP9LlJXEjU=
Expand All @@ -1175,6 +1178,7 @@ go.opentelemetry.io/otel/trace v1.31.0/go.mod h1:TXZkRk7SM2ZQLtR6eoAWQFIHPvzQ06F
go.opentelemetry.io/otel/trace v1.32.0/go.mod h1:+i4rkvCraA+tG6AzwloGaCtkx53Fa+L+V8e9a7YvhT8=
go.opentelemetry.io/otel/trace v1.34.0/go.mod h1:Svm7lSjQD7kG7KJ/MUHPVXSDGz2OX4h0M2jHBhmSfRE=
go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc=
go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA=
go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
Expand Down
19 changes: 19 additions & 0 deletions pkg/gofr/gofr.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,21 @@ type App struct {
httpRegistered bool

subscriptionManager SubscriptionManager
onStartHooks []func(ctx *Context) error
}

func (a *App) runOnStartHooks(_ context.Context) error {
// Use the existing newContext function with noopRequest
gofrCtx := newContext(nil, noopRequest{}, a.container)

for _, hook := range a.onStartHooks {
if err := hook(gofrCtx); err != nil {
a.Logger().Errorf("OnStart hook failed: %v", err)
return err
}
}

return nil
}

// Shutdown stops the service(s) and close the application.
Expand Down Expand Up @@ -296,3 +311,7 @@ func (a *App) AddStaticFiles(endpoint, filePath string) {

a.httpServer.staticFiles[filePath] = endpoint
}

func (a *App) OnStart(hook func(ctx *Context) error) {
a.onStartHooks = append(a.onStartHooks, hook)
}
36 changes: 36 additions & 0 deletions pkg/gofr/gofr_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package gofr
import (
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
Expand Down Expand Up @@ -1162,3 +1163,38 @@ func TestApp_Subscribe(t *testing.T) {
assert.False(t, ok)
})
}

// Define static error for testing.
var errHookFailed = errors.New("hook failed")

func TestApp_OnStart(t *testing.T) {
// Test case 1: Hook executes successfully
t.Run("success", func(t *testing.T) {
var hookCalled bool

app := New()

app.OnStart(func(_ *Context) error {
hookCalled = true
return nil
})

err := app.runOnStartHooks(t.Context())

require.NoError(t, err, "Expected no error from runOnStartHooks")
assert.True(t, hookCalled, "Expected the OnStart hook to be called")
})

// Test case 2: Hook returns an error
t.Run("error", func(t *testing.T) {
app := New()

app.OnStart(func(_ *Context) error {
return errHookFailed
})

err := app.runOnStartHooks(t.Context())

require.Equal(t, errHookFailed, err, "Expected an error from runOnStartHooks")
})
}
61 changes: 55 additions & 6 deletions pkg/gofr/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@ package gofr

import (
"context"
"errors"
"net/http"
"os"
"os/signal"
"sync"
"syscall"
"time"
)

// Run starts the application. If it is an HTTP server, it will start the server.
Expand All @@ -19,11 +21,39 @@ func (a *App) Run() {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGINT, syscall.SIGTERM)
defer stop()

if !a.handleStartupHooks(ctx) {
return
}

timeout, err := getShutdownTimeoutFromConfig(a.Config)
if err != nil {
a.Logger().Errorf("error parsing value of shutdown timeout from config: %v. Setting default timeout of 30 sec.", err)
}

a.startShutdownHandler(ctx, timeout)
a.startTelemetryIfEnabled()
a.startAllServers(ctx)
}

// handleStartupHooks runs the startup hooks and returns false if the application should exit.
func (a *App) handleStartupHooks(ctx context.Context) bool {
if err := a.runOnStartHooks(ctx); err != nil {
if !errors.Is(err, context.Canceled) {
a.Logger().Errorf("Startup failed: %v", err)

return false
}
// If the error is context.Canceled, do not exit; allow graceful shutdown.
a.Logger().Info("Startup canceled by context, shutting down gracefully.")

return false
}

return true
}

// startShutdownHandler starts a goroutine to handle graceful shutdown.
func (a *App) startShutdownHandler(ctx context.Context, timeout time.Duration) {
// Goroutine to handle shutdown when context is canceled
go func() {
<-ctx.Done()
Expand All @@ -43,15 +73,29 @@ func (a *App) Run() {
a.Logger().Debugf("Server shutdown failed: %v", shutdownErr)
}
}()
}

// startTelemetryIfEnabled starts telemetry if it's enabled.
func (a *App) startTelemetryIfEnabled() {
if a.hasTelemetry() {
go a.sendTelemetry(http.DefaultClient, true)
}
}

// startAllServers starts all registered servers concurrently.
func (a *App) startAllServers(ctx context.Context) {
wg := sync.WaitGroup{}

// Start Metrics Server
// running metrics server before HTTP and gRPC
a.startMetricsServer(&wg)
a.startHTTPServer(&wg)
a.startGRPCServer(&wg)
a.startSubscriptionManager(ctx, &wg)

wg.Wait()
}

// startMetricsServer starts the metrics server if configured.
func (a *App) startMetricsServer(wg *sync.WaitGroup) {
if a.metricServer != nil {
wg.Add(1)

Expand All @@ -60,8 +104,10 @@ func (a *App) Run() {
m.Run(a.container)
}(a.metricServer)
}
}

// Start HTTP Server
// startHTTPServer starts the HTTP server if registered.
func (a *App) startHTTPServer(wg *sync.WaitGroup) {
if a.httpRegistered {
wg.Add(1)
a.httpServerSetup()
Expand All @@ -71,8 +117,10 @@ func (a *App) Run() {
s.run(a.container)
}(a.httpServer)
}
}

// Start gRPC Server only if a service is registered
// startGRPCServer starts the gRPC server if registered.
func (a *App) startGRPCServer(wg *sync.WaitGroup) {
if a.grpcRegistered {
wg.Add(1)

Expand All @@ -81,7 +129,10 @@ func (a *App) Run() {
s.Run(a.container)
}(a.grpcServer)
}
}

// startSubscriptionManager starts the subscription manager.
func (a *App) startSubscriptionManager(ctx context.Context, wg *sync.WaitGroup) {
wg.Add(1)

go func() {
Expand All @@ -92,6 +143,4 @@ func (a *App) Run() {
a.Logger().Errorf("Subscription Error : %v", err)
}
}()

wg.Wait()
}
Loading