Skip to content
Merged
31 changes: 30 additions & 1 deletion app.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,8 @@ type App struct {
mountFields *mountFields
// state management
state *State
// shared state management (prefork-safe, storage-backed)
sharedState *SharedState
Comment thread
coderabbitai[bot] marked this conversation as resolved.
// Route stack divided by HTTP methods
stack [][]*Route
// customConstraints is a list of external constraints
Expand Down Expand Up @@ -279,6 +281,19 @@ type Config struct { //nolint:govet // Aligning the struct fields is not necessa
// Default: nil
AppName string `json:"app_name"`

// SharedStorage configures storage-backed shared state that can be used
// safely across prefork workers and processes.
//
// Default: nil
SharedStorage Storage `json:"-"`

// SharedStatePrefix customizes the namespace prefix for keys written to
// SharedStorage. If empty, Fiber derives a prefixed namespace using
// AppName (when set) or an internal default.
//
// Default: ""
SharedStatePrefix string `json:"shared_state_prefix"`

// StreamRequestBody enables request body streaming,
// and calls the handler sooner when given body is
// larger than the current limit.
Expand Down Expand Up @@ -638,6 +653,8 @@ func New(config ...Config) *App {
if app.config.XMLDecoder == nil {
app.config.XMLDecoder = xml.Unmarshal
}

app.sharedState = newSharedState(&app.config)
if len(app.config.RequestMethods) == 0 {
app.config.RequestMethods = DefaultMethods
}
Expand Down Expand Up @@ -1172,11 +1189,18 @@ func (app *App) Hooks() *Hooks {
return app.hooks
}

// State returns the state struct to store global data in order to share it between handlers.
// State returns the in-process state struct to store global data between handlers.
// State is process-local and is not shared across prefork workers.
func (app *App) State() *State {
return app.state
}

// SharedState returns storage-backed shared state.
// SharedState is prefork-safe when Config.SharedStorage is configured.
func (app *App) SharedState() *SharedState {
return app.sharedState
}

var ErrTestGotEmptyResponse = errors.New("test: got empty response")

// TestConfig is a struct holding Test settings
Expand Down Expand Up @@ -1375,6 +1399,11 @@ func (app *App) init() *App {
if err := app.shutdownServices(app.servicesShutdownCtx()); err != nil {
log.Errorf("failed to shutdown services: %v", err)
}
if app.sharedState != nil && app.sharedState.storage != nil {
if err := app.sharedState.Close(); err != nil {
log.Errorf("failed to close sharedState: %v", err)
}
}
return nil
})

Expand Down
12 changes: 12 additions & 0 deletions docs/api/app.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,18 @@ func main() {
}
```

### State / SharedState

`State()` returns in-process state (local to the current process).
`SharedState()` returns storage-backed state intended for prefork/multi-process sharing.

```go title="Signature"
func (app *App) State() *State
func (app *App) SharedState() *SharedState
```

See [State Management](./state.md) for usage and examples.

### MountPath

The `MountPath` property contains one or more path patterns on which a sub-app was mounted.
Expand Down
122 changes: 122 additions & 0 deletions docs/api/state.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,128 @@ State management provides a global key–value store for application dependencie
When prefork is enabled, each worker process has an independent state store, meaning state is not shared between them.
:::

## SharedState (Prefork-safe)

For data that must be shared across prefork workers or multiple app processes, use `app.SharedState()` backed by `fiber.Storage`.

Configure storage in `fiber.Config`:

```go
app := fiber.New(fiber.Config{
AppName: "billing-api",
SharedStorage: redisStorage, // any implementation of fiber.Storage
SharedStatePrefix: "billing-shared-", // optional
})
```

If `SharedStatePrefix` is empty, Fiber derives a default namespace and includes `AppName` (when set) to reduce collisions between apps/services.

MsgPack and CBOR helpers require the corresponding `Config` encoders/decoders to be configured. If they are unavailable, the helper methods return an error instead of panicking.

:::warning Memory storage caveat
`SharedState` is only cross-worker / cross-process when the configured `SharedStorage` backend is shared.

If you use an in-memory backend (for example memory storage), data remains process-local. In prefork mode, each worker process has its own independent in-memory store.
:::

### SharedState Methods

```go title="Signature"
func (app *App) SharedState() *SharedState

func (s *SharedState) Set(key string, val []byte, ttl time.Duration) error
func (s *SharedState) SetWithContext(ctx context.Context, key string, val []byte, ttl time.Duration) error

func (s *SharedState) Get(key string) (val []byte, found bool, err error)
func (s *SharedState) GetWithContext(ctx context.Context, key string) (val []byte, found bool, err error)

func (s *SharedState) SetJSON(key string, v any, ttl time.Duration) error
func (s *SharedState) SetJSONWithContext(ctx context.Context, key string, v any, ttl time.Duration) error

func (s *SharedState) GetJSON(key string, out any) (raw []byte, found bool, err error)
func (s *SharedState) GetJSONWithContext(ctx context.Context, key string, out any) (raw []byte, found bool, err error)

func (s *SharedState) SetMsgPack(key string, v any, ttl time.Duration) error
func (s *SharedState) SetMsgPackWithContext(ctx context.Context, key string, v any, ttl time.Duration) error

func (s *SharedState) GetMsgPack(key string, out any) (raw []byte, found bool, err error)
func (s *SharedState) GetMsgPackWithContext(ctx context.Context, key string, out any) (raw []byte, found bool, err error)

func (s *SharedState) SetCBOR(key string, v any, ttl time.Duration) error
func (s *SharedState) SetCBORWithContext(ctx context.Context, key string, v any, ttl time.Duration) error

func (s *SharedState) GetCBOR(key string, out any) (raw []byte, found bool, err error)
func (s *SharedState) GetCBORWithContext(ctx context.Context, key string, out any) (raw []byte, found bool, err error)

func (s *SharedState) SetXML(key string, v any, ttl time.Duration) error
func (s *SharedState) SetXMLWithContext(ctx context.Context, key string, v any, ttl time.Duration) error

func (s *SharedState) GetXML(key string, out any) (raw []byte, found bool, err error)
func (s *SharedState) GetXMLWithContext(ctx context.Context, key string, out any) (raw []byte, found bool, err error)

func (s *SharedState) Delete(key string) error
func (s *SharedState) DeleteWithContext(ctx context.Context, key string) error

func (s *SharedState) Has(key string) (bool, error)
func (s *SharedState) HasWithContext(ctx context.Context, key string) (bool, error)

func (s *SharedState) Reset() error
func (s *SharedState) ResetWithContext(ctx context.Context) error
func (s *SharedState) Close() error
```

### SharedState Example

```go
type SessionSnapshot struct {
UserID string `json:"user_id"`
UpdatedAt time.Time `json:"updated_at"`
}

app.Post("/sessions/:id", func(c fiber.Ctx) error {
key := "session:" + c.Params("id")
value := SessionSnapshot{
UserID: c.Params("id"),
UpdatedAt: time.Now().UTC(),
}

if err := app.SharedState().SetJSON(key, value, 30*time.Minute); err != nil {
return err
}

return c.SendStatus(fiber.StatusAccepted)
})

app.Get("/sessions/:id", func(c fiber.Ctx) error {
key := "session:" + c.Params("id")
var snapshot SessionSnapshot

_, found, err := app.SharedState().GetJSON(key, &snapshot)
if err != nil {
return err
}
if !found {
return c.SendStatus(fiber.StatusNotFound)
}

return c.JSON(snapshot)
})
```

### SharedState with Context (timeouts/cancellation)

```go
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel()

err := app.SharedState().SetJSONWithContext(ctx, "job:42", fiber.Map{
"status": "queued",
}, 2*time.Minute)
if err != nil {
// timeout, cancellation, storage error, or JSON serialization error
}
```

## State Type

`State` is a key–value store built on top of `sync.Map` to ensure safe concurrent access. It allows storage and retrieval of dependencies and configurations in a Fiber application as well as thread–safe access to runtime data.
Expand Down
1 change: 1 addition & 0 deletions docs/whats_new.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ We have made several changes to the Fiber app, including:
- **RegisterCustomConstraint**: Allows for the registration of custom constraints.
- **NewWithCustomCtx**: Initialize an app with a custom context in one step.
- **State**: Provides a global state for the application, which can be used to store and retrieve data across the application. Check out the [State](./api/state) method for further details.
- **SharedState**: Introduces storage-backed app state for prefork-safe/multi-process coordination via `Config.SharedStorage`, with optional `Config.SharedStatePrefix` namespacing, codec-aware helpers (`SetJSON`, `SetMsgPack`, `SetCBOR`, `SetXML`, matching getters, and `WithContext` variants), empty-key no-op handling, and `Reset`/`Close` passthrough helpers.
- **NewErrorf**: Allows variadic parameters when creating formatted errors.
- **GetBytes / GetString**: Helpers that detach values only when `Immutable` is enabled and the data still references request or response buffers. Access via `c.App().GetString` and `c.App().GetBytes`.
- **ReloadViews**: Lets you re-run the configured view engine's `Load()` logic at runtime, including guard rails for missing or nil view engines so development hot-reload hooks can refresh templates safely.
Expand Down
127 changes: 119 additions & 8 deletions services_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@ import (
"context"
"errors"
"fmt"
"io"
stdlog "log" //nolint:depguard // Test needs the concrete stdlib logger type to restore the previous writer.
"strings"
"sync"
"sync/atomic"
"testing"
"time"

Expand All @@ -30,6 +34,75 @@ type mockService struct {
terminateDelay time.Duration
}

type shutdownHookStorage struct {
closeErr error
closeCalled atomic.Bool
}

type stringsLogger struct {
strings.Builder
}

var servicesTestLogOutputMu sync.Mutex

func withCapturedLogOutput(t *testing.T, writer io.Writer) {
t.Helper()

servicesTestLogOutputMu.Lock()
cleanupRegistered := false
defer func() {
if !cleanupRegistered {
servicesTestLogOutputMu.Unlock()
}
}()

currentOutput := log.DefaultLogger[*stdlog.Logger]().Logger().Writer()
t.Cleanup(func() {
log.SetOutput(currentOutput)
servicesTestLogOutputMu.Unlock()
})
cleanupRegistered = true

log.SetOutput(writer)
}

func (*shutdownHookStorage) GetWithContext(context.Context, string) ([]byte, error) {
return nil, nil
}

func (*shutdownHookStorage) Get(string) ([]byte, error) {
return nil, nil
}

func (*shutdownHookStorage) SetWithContext(context.Context, string, []byte, time.Duration) error {
return nil
}

func (*shutdownHookStorage) Set(string, []byte, time.Duration) error {
return nil
}

func (*shutdownHookStorage) DeleteWithContext(context.Context, string) error {
return nil
}

func (*shutdownHookStorage) Delete(string) error {
return nil
}

func (*shutdownHookStorage) ResetWithContext(context.Context) error {
return nil
}

func (*shutdownHookStorage) Reset() error {
return nil
}

func (s *shutdownHookStorage) Close() error {
s.closeCalled.Store(true)
return s.closeErr
}

func (m *mockService) Start(ctx context.Context) error {
select {
case <-ctx.Done():
Expand Down Expand Up @@ -127,6 +200,8 @@ func Test_HasConfiguredServices(t *testing.T) {
}

func Test_InitServices(t *testing.T) {
t.Parallel()

t.Run("no-services", func(t *testing.T) {
app := &App{configured: Config{}}
require.NotPanics(t, app.initServices)
Expand Down Expand Up @@ -178,10 +253,6 @@ func Test_InitServices(t *testing.T) {

require.NotPanics(t, app.initServices)

type stringsLogger struct {
strings.Builder
}

var buf stringsLogger
log.SetOutput(&buf)

Expand All @@ -202,17 +273,57 @@ func Test_InitServices(t *testing.T) {

require.NotPanics(t, app.initServices)

type stringsLogger struct {
strings.Builder
}

var buf stringsLogger
log.SetOutput(&buf)

app.Hooks().executeOnPostShutdownHooks(nil)

require.Contains(t, buf.String(), "failed to shutdown services: service dep2 terminate: terminate error 2")
})

t.Run("shutdown-hooks/close-shared-state", func(t *testing.T) {
t.Parallel()

storage := &shutdownHookStorage{}
app := New(Config{
Services: []Service{&mockService{name: "dep1"}},
SharedStorage: storage,
})

require.NotPanics(t, app.initServices)

var buf stringsLogger
withCapturedLogOutput(t, &buf)

app.Hooks().executeOnPostShutdownHooks(nil)

require.True(t, storage.closeCalled.Load())
require.NotContains(t, buf.String(), "failed to close sharedState:")
})

t.Run("shutdown-hooks/close-shared-state-after-service-error", func(t *testing.T) {
t.Parallel()

storage := &shutdownHookStorage{closeErr: errors.New("close error")}
app := New(Config{
Services: []Service{
&mockService{name: "dep1"},
&mockService{name: "dep2", terminateError: errors.New(terminateErrorMessage + " 2")},
},
SharedStorage: storage,
})

require.NotPanics(t, app.initServices)

var buf stringsLogger
withCapturedLogOutput(t, &buf)

app.Hooks().executeOnPostShutdownHooks(nil)

require.True(t, storage.closeCalled.Load())
require.Contains(t, buf.String(), "failed to shutdown services: service dep2 terminate: terminate error 2")
require.Contains(t, buf.String(), "failed to close sharedState: close error")
})
Comment thread
gaby marked this conversation as resolved.
}

func Test_StartServices(t *testing.T) {
Expand Down
Loading
Loading