diff --git a/docs/advanced-guide/startup-hooks/page.md b/docs/advanced-guide/startup-hooks/page.md new file mode 100644 index 000000000..23f5a78b4 --- /dev/null +++ b/docs/advanced-guide/startup-hooks/page.md @@ -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. diff --git a/examples/http-server/main.go b/examples/http-server/main.go index ebba9314a..871a52f4f 100644 --- a/examples/http-server/main.go +++ b/examples/http-server/main.go @@ -21,6 +21,27 @@ func main() { //HTTP service with default health check endpoint a.AddHTTPService("anotherService", "http://localhost:9000") + // Register an OnStart hook to warm up a cache. + // This runs before route registration as intended. + a.OnStart(func(ctx *gofr.Context) error { + ctx.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.Logger.Errorf("Failed to warm up cache: %v", err) + return err // Return the error to halt startup if caching fails. + } + + ctx.Logger.Info("Cache warmed up successfully!") + + return nil + }) + // Add all the routes a.GET("/hello", HelloHandler) a.GET("/error", ErrorHandler) diff --git a/go.work.sum b/go.work.sum index ec87d4cd5..d00f257b4 100644 --- a/go.work.sum +++ b/go.work.sum @@ -1151,7 +1151,7 @@ go.opentelemetry.io/otel v1.31.0/go.mod h1:O0C14Yl9FgkjqcCZAsE053C13OaddMYr/hz6c 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/exporters/otlp/otlptrace/otlptracehttp v1.19.0 h1:IeMeyr1aBvBiPVYihXIaeIZba6b8E1bYp7lbdxK8CQg= +go.opentelemetry.io/otel v1.36.0/go.mod h1:/TcFMXYjyRNh8khOAO9ybYkqaDBb/70aVwkNML4pP8E= 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= go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.29.0/go.mod h1:BLbf7zbNIONBLPwvFnwNHGj4zge8uTCM/UPIVW1Mq2I= @@ -1163,8 +1163,8 @@ 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= go.opentelemetry.io/otel/sdk v1.33.0/go.mod h1:A1Q5oi7/9XaMlIWzPSxLRWOI8nG3FnzHJNbiENQuihM= go.opentelemetry.io/otel/sdk/metric v1.32.0/go.mod h1:PWeZlq0zt9YkYAp3gjKZ0eicRYvOh1Gd+X99x6GHpCQ= @@ -1175,9 +1175,9 @@ 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= go.uber.org/automaxprocs v1.5.3 h1:kWazyxZUrS3Gs4qUpbwo5kEIMGe/DAvi5Z4tl2NW4j8= go.uber.org/automaxprocs v1.5.3/go.mod h1:eRbA25aqJrxAbsLO0xy5jVwPt7FQnRgjW+efnwa1WM0= go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs= diff --git a/pkg/gofr/gofr.go b/pkg/gofr/gofr.go index 04070c279..f0ee863f8 100644 --- a/pkg/gofr/gofr.go +++ b/pkg/gofr/gofr.go @@ -46,6 +46,29 @@ type App struct { httpRegistered bool subscriptionManager SubscriptionManager + onStartHooks []func(ctx *Context) error +} + +func (a *App) runOnStartHooks(ctx context.Context) error { + // Use the existing newContext function with noopRequest + gofrCtx := newContext(nil, noopRequest{}, a.container) + + // Set the context for cancellation support + gofrCtx.Context = ctx + + for _, hook := range a.onStartHooks { + if err := hook(gofrCtx); err != nil { + a.Logger().Errorf("OnStart hook failed: %v", err) + return err + } + + // Check if context was canceled + if ctx.Err() != nil { + return ctx.Err() + } + } + + return nil } // Shutdown stops the service(s) and close the application. @@ -296,3 +319,25 @@ func (a *App) AddStaticFiles(endpoint, filePath string) { a.httpServer.staticFiles[filePath] = endpoint } + +// OnStart registers a startup hook that will be executed when the application starts. +// The hook function receives a Context that provides access to the application's +// container, logger, and configuration. This is useful for performing initialization +// tasks such as database connections, service registrations, or other setup operations +// that need to be completed before the application begins serving requests. +// +// Example usage: +// +// app := gofr.New() +// app.OnStart(func(ctx *gofr.Context) error { +// // Initialize database connection +// db, err := database.Connect(ctx.Config.Get("DB_URL")) +// if err != nil { +// return err +// } +// ctx.Container.SQL = db +// return nil +// }) +func (a *App) OnStart(hook func(ctx *Context) error) { + a.onStartHooks = append(a.onStartHooks, hook) +} diff --git a/pkg/gofr/gofr_test.go b/pkg/gofr/gofr_test.go index 970987f5a..e2b1720fd 100644 --- a/pkg/gofr/gofr_test.go +++ b/pkg/gofr/gofr_test.go @@ -3,6 +3,7 @@ package gofr import ( "encoding/base64" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -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") + }) +} diff --git a/pkg/gofr/run.go b/pkg/gofr/run.go index 5acee4608..d7b0e9621 100644 --- a/pkg/gofr/run.go +++ b/pkg/gofr/run.go @@ -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. @@ -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() @@ -43,13 +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{} + 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) { // Start Metrics Server // running metrics server before HTTP and gRPC if a.metricServer != nil { @@ -60,8 +106,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() @@ -71,8 +119,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) @@ -81,7 +131,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() { @@ -92,6 +145,4 @@ func (a *App) Run() { a.Logger().Errorf("Subscription Error : %v", err) } }() - - wg.Wait() }