diff --git a/app.go b/app.go index 32d90ef402d..f5cfbf8ae89 100644 --- a/app.go +++ b/app.go @@ -90,6 +90,8 @@ type App struct { mountFields *mountFields // state management state *State + // shared state management (prefork-safe, storage-backed) + sharedState *SharedState // Route stack divided by HTTP methods stack [][]*Route // customConstraints is a list of external constraints @@ -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. @@ -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 } @@ -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 @@ -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 }) diff --git a/docs/api/app.md b/docs/api/app.md index a10283a8f35..c595aae8574 100644 --- a/docs/api/app.md +++ b/docs/api/app.md @@ -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. diff --git a/docs/api/state.md b/docs/api/state.md index 36be5f67b89..a761f770ca5 100644 --- a/docs/api/state.md +++ b/docs/api/state.md @@ -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. diff --git a/docs/whats_new.md b/docs/whats_new.md index eb50b3f82ee..f0877c5d287 100644 --- a/docs/whats_new.md +++ b/docs/whats_new.md @@ -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. diff --git a/services_test.go b/services_test.go index 5e29063bb95..f03db3333e0 100644 --- a/services_test.go +++ b/services_test.go @@ -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" @@ -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(): @@ -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) @@ -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) @@ -202,10 +273,6 @@ func Test_InitServices(t *testing.T) { require.NotPanics(t, app.initServices) - type stringsLogger struct { - strings.Builder - } - var buf stringsLogger log.SetOutput(&buf) @@ -213,6 +280,50 @@ func Test_InitServices(t *testing.T) { 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") + }) } func Test_StartServices(t *testing.T) { diff --git a/shared_state.go b/shared_state.go new file mode 100644 index 00000000000..2f06fb50a64 --- /dev/null +++ b/shared_state.go @@ -0,0 +1,416 @@ +package fiber + +import ( + "context" + "encoding/json" + "encoding/xml" + "errors" + "fmt" + "time" + + "github.com/gofiber/utils/v2" +) + +const defaultSharedStatePrefix = "gofiber-shared-state-" + +var ErrSharedStorageNotConfigured = errors.New("fiber: shared storage is not configured") + +type SharedState struct { + storage Storage + jsonEncoder utils.JSONMarshal + jsonDecoder utils.JSONUnmarshal + msgPackEncoder utils.MsgPackMarshal + msgPackDecoder utils.MsgPackUnmarshal + cborEncoder utils.CBORMarshal + cborDecoder utils.CBORUnmarshal + xmlEncoder utils.XMLMarshal + xmlDecoder utils.XMLUnmarshal + prefix string +} + +func newSharedState(cfg *Config) *SharedState { + if cfg == nil { + cfg = &Config{} + } + + prefix := cfg.SharedStatePrefix + if prefix == "" { + prefix = defaultSharedStatePrefix + if cfg.AppName != "" { + prefix += cfg.AppName + "-" + } + } + + jsonEncoder := cfg.JSONEncoder + if jsonEncoder == nil { + jsonEncoder = json.Marshal + } + jsonDecoder := cfg.JSONDecoder + if jsonDecoder == nil { + jsonDecoder = json.Unmarshal + } + xmlEncoder := cfg.XMLEncoder + if xmlEncoder == nil { + xmlEncoder = xml.Marshal + } + xmlDecoder := cfg.XMLDecoder + if xmlDecoder == nil { + xmlDecoder = xml.Unmarshal + } + + return &SharedState{ + storage: cfg.SharedStorage, + jsonEncoder: jsonEncoder, + jsonDecoder: jsonDecoder, + msgPackEncoder: cfg.MsgPackEncoder, + msgPackDecoder: cfg.MsgPackDecoder, + cborEncoder: cfg.CBOREncoder, + cborDecoder: cfg.CBORDecoder, + xmlEncoder: xmlEncoder, + xmlDecoder: xmlDecoder, + prefix: prefix, + } +} + +func (s *SharedState) Set(key string, val []byte, ttl time.Duration) error { + return s.SetWithContext(context.Background(), key, val, ttl) +} + +func (s *SharedState) SetWithContext(ctx context.Context, key string, val []byte, ttl time.Duration) error { + if err := s.ensureStorage(); err != nil { + return err + } + + storageKey, ok := s.storageKey(key) + if !ok { + return nil + } + + return s.storage.SetWithContext(ctx, storageKey, val, ttl) +} + +func (s *SharedState) Get(key string) ([]byte, bool, error) { //nolint:gocritic // Keep unnamed returns for clarity. + return s.GetWithContext(context.Background(), key) +} + +func (s *SharedState) GetWithContext(ctx context.Context, key string) ([]byte, bool, error) { //nolint:gocritic // Keep unnamed returns for clarity. + if err := s.ensureStorage(); err != nil { + return nil, false, err + } + + storageKey, ok := s.storageKey(key) + if !ok { + return nil, false, nil + } + + data, err := s.storage.GetWithContext(ctx, storageKey) + if err != nil { + return nil, false, err + } + if data == nil { + return nil, false, nil + } + + return append([]byte(nil), data...), true, nil +} + +func (s *SharedState) SetJSON(key string, v any, ttl time.Duration) error { + return s.SetJSONWithContext(context.Background(), key, v, ttl) +} + +func (s *SharedState) SetJSONWithContext(ctx context.Context, key string, v any, ttl time.Duration) error { + if err := s.ensureStorage(); err != nil { + return err + } + + return s.setEncodedWithContext(ctx, key, v, ttl, s.jsonEncoder, "json") +} + +func (s *SharedState) GetJSON(key string, out any) ([]byte, bool, error) { //nolint:gocritic // Keep unnamed returns for clarity. + return s.GetJSONWithContext(context.Background(), key, out) +} + +func (s *SharedState) GetJSONWithContext(ctx context.Context, key string, out any) ([]byte, bool, error) { //nolint:gocritic // Keep unnamed returns for clarity. + if err := s.ensureStorage(); err != nil { + return nil, false, err + } + + return s.getEncodedWithContext(ctx, key, out, s.jsonDecoder, "json") +} + +func (s *SharedState) SetMsgPack(key string, v any, ttl time.Duration) error { + return s.SetMsgPackWithContext(context.Background(), key, v, ttl) +} + +func (s *SharedState) SetMsgPackWithContext(ctx context.Context, key string, v any, ttl time.Duration) error { + if err := s.ensureStorage(); err != nil { + return err + } + + return s.setEncodedWithContext(ctx, key, v, ttl, s.msgPackEncoder, "msgpack") +} + +func (s *SharedState) GetMsgPack(key string, out any) ([]byte, bool, error) { //nolint:gocritic // Keep unnamed returns for clarity. + return s.GetMsgPackWithContext(context.Background(), key, out) +} + +func (s *SharedState) GetMsgPackWithContext(ctx context.Context, key string, out any) ([]byte, bool, error) { //nolint:gocritic // Keep unnamed returns for clarity. + if err := s.ensureStorage(); err != nil { + return nil, false, err + } + + return s.getEncodedWithContext(ctx, key, out, s.msgPackDecoder, "msgpack") +} + +func (s *SharedState) SetCBOR(key string, v any, ttl time.Duration) error { + return s.SetCBORWithContext(context.Background(), key, v, ttl) +} + +func (s *SharedState) SetCBORWithContext(ctx context.Context, key string, v any, ttl time.Duration) error { + if err := s.ensureStorage(); err != nil { + return err + } + + return s.setEncodedWithContext(ctx, key, v, ttl, s.cborEncoder, "cbor") +} + +func (s *SharedState) GetCBOR(key string, out any) ([]byte, bool, error) { //nolint:gocritic // Keep unnamed returns for clarity. + return s.GetCBORWithContext(context.Background(), key, out) +} + +func (s *SharedState) GetCBORWithContext(ctx context.Context, key string, out any) ([]byte, bool, error) { //nolint:gocritic // Keep unnamed returns for clarity. + if err := s.ensureStorage(); err != nil { + return nil, false, err + } + + return s.getEncodedWithContext(ctx, key, out, s.cborDecoder, "cbor") +} + +func (s *SharedState) SetXML(key string, v any, ttl time.Duration) error { + return s.SetXMLWithContext(context.Background(), key, v, ttl) +} + +func (s *SharedState) SetXMLWithContext(ctx context.Context, key string, v any, ttl time.Duration) error { + if err := s.ensureStorage(); err != nil { + return err + } + + return s.setEncodedWithContext(ctx, key, v, ttl, s.xmlEncoder, "xml") +} + +func (s *SharedState) GetXML(key string, out any) ([]byte, bool, error) { //nolint:gocritic // Keep unnamed returns for clarity. + return s.GetXMLWithContext(context.Background(), key, out) +} + +func (s *SharedState) GetXMLWithContext(ctx context.Context, key string, out any) ([]byte, bool, error) { //nolint:gocritic // Keep unnamed returns for clarity. + if err := s.ensureStorage(); err != nil { + return nil, false, err + } + + return s.getEncodedWithContext(ctx, key, out, s.xmlDecoder, "xml") +} + +func (s *SharedState) Delete(key string) error { + return s.DeleteWithContext(context.Background(), key) +} + +func (s *SharedState) DeleteWithContext(ctx context.Context, key string) error { + if err := s.ensureStorage(); err != nil { + return err + } + + storageKey, ok := s.storageKey(key) + if !ok { + return nil + } + + return s.storage.DeleteWithContext(ctx, storageKey) +} + +func (s *SharedState) Has(key string) (bool, error) { + return s.HasWithContext(context.Background(), key) +} + +func (s *SharedState) HasWithContext(ctx context.Context, key string) (bool, error) { + if err := s.ensureStorage(); err != nil { + return false, err + } + + storageKey, ok := s.storageKey(key) + if !ok { + return false, nil + } + + data, err := s.storage.GetWithContext(ctx, storageKey) + if err != nil { + return false, err + } + + return data != nil, nil +} + +func (s *SharedState) Reset() error { + return s.ResetWithContext(context.Background()) +} + +func (s *SharedState) ResetWithContext(ctx context.Context) error { + if err := s.ensureStorage(); err != nil { + return err + } + + return s.storage.ResetWithContext(ctx) +} + +func (s *SharedState) Close() error { + if err := s.ensureStorage(); err != nil { + return err + } + + return s.storage.Close() +} + +func (s *SharedState) ensureStorage() error { + if s == nil || s.storage == nil { + return ErrSharedStorageNotConfigured + } + + return nil +} + +func (s *SharedState) setEncodedWithContext( + ctx context.Context, + key string, + v any, + ttl time.Duration, + encoder func(any) ([]byte, error), + format string, +) error { + if err := s.ensureStorage(); err != nil { + return err + } + + storageKey, ok := s.storageKey(key) + if !ok { + return nil + } + + encoded, err := encodeSharedStateValue(v, encoder, format) + if err != nil { + return err + } + + return s.storage.SetWithContext(ctx, storageKey, encoded, ttl) +} + +//nolint:gocritic // Keep unnamed returns for clarity. +func (s *SharedState) getEncodedWithContext( + ctx context.Context, + key string, + out any, + decoder func([]byte, any) error, + format string, +) ([]byte, bool, error) { + if err := s.ensureStorage(); err != nil { + return nil, false, err + } + + storageKey, ok := s.storageKey(key) + if !ok { + return nil, false, nil + } + + data, err := s.storage.GetWithContext(ctx, storageKey) + if err != nil { + return nil, false, err + } + if data == nil { + return nil, false, nil + } + + if err := decodeSharedStateValue(data, out, decoder, format); err != nil { + return nil, false, err + } + + return append([]byte(nil), data...), true, nil +} + +func encodeSharedStateValue(v any, encoder func(any) ([]byte, error), format string) ([]byte, error) { + if encoder == nil { + return nil, sharedStateCodecNotConfiguredError(format, "encoder") + } + + var ( + encoded []byte + err error + recovered any + ) + func() { + // App-configured codecs may be nil or may still use Fiber's + // binder.Unimplemented* placeholders, which panic instead of returning an + // error, so recover here and surface a regular error. + defer func() { + recovered = recover() + }() + + encoded, err = encoder(v) + }() + + if recovered != nil { + return nil, sharedStateCodecPanicError("encode", format, recovered) + } + if err != nil { + return nil, fmt.Errorf("fiber: failed to encode shared state %s value: %w", format, err) + } + + return encoded, nil +} + +func decodeSharedStateValue(data []byte, out any, decoder func([]byte, any) error, format string) error { + if decoder == nil { + return sharedStateCodecNotConfiguredError(format, "decoder") + } + + var ( + err error + recovered any + ) + func() { + // App-configured codecs may be nil or may still use Fiber's + // binder.Unimplemented* placeholders, which panic instead of returning an + // error, so recover here and surface a regular error. + defer func() { + recovered = recover() + }() + + err = decoder(data, out) + }() + + if recovered != nil { + return sharedStateCodecPanicError("decode", format, recovered) + } + if err != nil { + return fmt.Errorf("fiber: failed to decode shared state %s value: %w", format, err) + } + + return nil +} + +func sharedStateCodecNotConfiguredError(format, direction string) error { + return fmt.Errorf("fiber: shared state %s %s is not configured", format, direction) +} + +func sharedStateCodecPanicError(operation, format string, recovered any) error { + if err, ok := recovered.(error); ok { + return fmt.Errorf("fiber: failed to %s shared state %s value: %w", operation, format, err) + } + + return fmt.Errorf("fiber: failed to %s shared state %s value: %v", operation, format, recovered) +} + +func (s *SharedState) storageKey(key string) (string, bool) { + if key == "" { + return "", false + } + + return s.prefix + key, true +} diff --git a/shared_state_test.go b/shared_state_test.go new file mode 100644 index 00000000000..0fe0437694b --- /dev/null +++ b/shared_state_test.go @@ -0,0 +1,737 @@ +package fiber + +import ( + "context" + "encoding/json" + "errors" + "testing" + "time" + + storagememory "github.com/gofiber/fiber/v3/internal/storage/memory" + "github.com/stretchr/testify/require" +) + +func newSharedStateMemoryStorage(t *testing.T) *storagememory.Storage { + t.Helper() + + store := storagememory.New() + t.Cleanup(func() { + require.NoError(t, store.Close()) + }) + + return store +} + +type contextCheckingStorage struct { + base Storage + ctxKey any +} + +type errorStorage struct { + err error + closeErr error +} + +func (s *errorStorage) GetWithContext(context.Context, string) ([]byte, error) { + return nil, s.err +} + +func (s *errorStorage) Get(string) ([]byte, error) { + return nil, s.err +} + +func (s *errorStorage) SetWithContext(context.Context, string, []byte, time.Duration) error { + return s.err +} + +func (s *errorStorage) Set(string, []byte, time.Duration) error { + return s.err +} + +func (s *errorStorage) DeleteWithContext(context.Context, string) error { + return s.err +} + +func (s *errorStorage) Delete(string) error { + return s.err +} + +func (s *errorStorage) ResetWithContext(context.Context) error { + return s.err +} + +func (s *errorStorage) Reset() error { + return s.err +} + +func (s *errorStorage) Close() error { + return s.closeErr +} + +func (s *contextCheckingStorage) SetWithContext(ctx context.Context, key string, val []byte, exp time.Duration) error { + if ctx.Value(s.ctxKey) == nil { + return errors.New("context value not found") + } + return s.base.SetWithContext(ctx, key, val, exp) +} + +func (s *contextCheckingStorage) GetWithContext(ctx context.Context, key string) ([]byte, error) { + if ctx.Value(s.ctxKey) == nil { + return nil, errors.New("context value not found") + } + return s.base.GetWithContext(ctx, key) +} + +func (s *contextCheckingStorage) DeleteWithContext(ctx context.Context, key string) error { + if ctx.Value(s.ctxKey) == nil { + return errors.New("context value not found") + } + return s.base.DeleteWithContext(ctx, key) +} + +func (s *contextCheckingStorage) Get(key string) ([]byte, error) { + return s.GetWithContext(context.Background(), key) +} + +func (s *contextCheckingStorage) Set(key string, val []byte, exp time.Duration) error { + return s.SetWithContext(context.Background(), key, val, exp) +} + +func (s *contextCheckingStorage) Delete(key string) error { + return s.DeleteWithContext(context.Background(), key) +} + +func (s *contextCheckingStorage) ResetWithContext(ctx context.Context) error { + return s.base.ResetWithContext(ctx) +} + +func (s *contextCheckingStorage) Reset() error { + return s.base.Reset() +} + +func (s *contextCheckingStorage) Close() error { + return s.base.Close() +} + +func TestSharedState_NotConfigured(t *testing.T) { + t.Parallel() + + app := New() + + err := app.SharedState().Set("raw-key", []byte("raw"), time.Second) + require.ErrorIs(t, err, ErrSharedStorageNotConfigured) + + raw, found, err := app.SharedState().Get("raw-key") + require.Nil(t, raw) + require.False(t, found) + require.ErrorIs(t, err, ErrSharedStorageNotConfigured) + + err = app.SharedState().SetJSON("key", Map{"v": 1}, time.Second) + require.ErrorIs(t, err, ErrSharedStorageNotConfigured) + err = app.SharedState().SetMsgPack("key", Map{"v": 1}, time.Second) + require.ErrorIs(t, err, ErrSharedStorageNotConfigured) + err = app.SharedState().SetCBOR("key", Map{"v": 1}, time.Second) + require.ErrorIs(t, err, ErrSharedStorageNotConfigured) + err = app.SharedState().SetXML("key", Map{"v": 1}, time.Second) + require.ErrorIs(t, err, ErrSharedStorageNotConfigured) + + _, found, err = app.SharedState().GetJSON("key", &Map{}) + require.False(t, found) + require.ErrorIs(t, err, ErrSharedStorageNotConfigured) + _, found, err = app.SharedState().GetMsgPack("key", &Map{}) + require.False(t, found) + require.ErrorIs(t, err, ErrSharedStorageNotConfigured) + _, found, err = app.SharedState().GetCBOR("key", &Map{}) + require.False(t, found) + require.ErrorIs(t, err, ErrSharedStorageNotConfigured) + _, found, err = app.SharedState().GetXML("key", &Map{}) + require.False(t, found) + require.ErrorIs(t, err, ErrSharedStorageNotConfigured) + + err = app.SharedState().Delete("key") + require.ErrorIs(t, err, ErrSharedStorageNotConfigured) + + has, err := app.SharedState().Has("key") + require.False(t, has) + require.ErrorIs(t, err, ErrSharedStorageNotConfigured) + + err = app.SharedState().Reset() + require.ErrorIs(t, err, ErrSharedStorageNotConfigured) + + err = app.SharedState().Close() + require.ErrorIs(t, err, ErrSharedStorageNotConfigured) +} + +func TestSharedState_PreforkSafeWithSharedStorage(t *testing.T) { + t.Parallel() + + store := newSharedStateMemoryStorage(t) + workerA := New(Config{AppName: "prefork-app", SharedStorage: store}) + workerB := New(Config{AppName: "prefork-app", SharedStorage: store}) + + workerA.State().Set("process-only", "from-worker-a") + _, ok := workerB.State().Get("process-only") + require.False(t, ok) + + payload := Map{"worker": "a", "version": 3} + err := workerA.SharedState().SetJSON("cluster-key", payload, time.Minute) + require.NoError(t, err) + + err = workerA.SharedState().Set("raw-cluster-key", []byte("raw-value"), time.Minute) + require.NoError(t, err) + + var out map[string]any + rawJSON, found, err := workerB.SharedState().GetJSON("cluster-key", &out) + require.NoError(t, err) + require.True(t, found) + require.NotNil(t, rawJSON) + require.Equal(t, "a", out["worker"]) + require.EqualValues(t, 3, out["version"]) + + raw, found, err := workerB.SharedState().Get("raw-cluster-key") + require.NoError(t, err) + require.True(t, found) + require.Equal(t, []byte("raw-value"), raw) + + has, err := workerB.SharedState().Has("cluster-key") + require.NoError(t, err) + require.True(t, has) + + err = workerB.SharedState().Delete("cluster-key") + require.NoError(t, err) + + has, err = workerA.SharedState().Has("cluster-key") + require.NoError(t, err) + require.False(t, has) + + require.NoError(t, workerA.SharedState().Delete("raw-cluster-key")) + raw, found, err = workerB.SharedState().Get("raw-cluster-key") + require.NoError(t, err) + require.Nil(t, raw) + require.False(t, found) +} + +func TestSharedState_ExplicitSerializationError(t *testing.T) { + t.Parallel() + + app := New(Config{SharedStorage: newSharedStateMemoryStorage(t)}) + err := app.SharedState().SetJSON("invalid", make(chan int), time.Second) + require.Error(t, err) +} + +func TestSharedState_ContextAwareVariants(t *testing.T) { + t.Parallel() + + type testContextKey string + + ctxKey := testContextKey("tenant") + store := &contextCheckingStorage{ctxKey: ctxKey, base: newSharedStateMemoryStorage(t)} + app := New(Config{SharedStorage: store}) + + t.Run("missing context", func(t *testing.T) { + t.Parallel() + + err := app.SharedState().SetJSONWithContext(context.Background(), "key", Map{"ok": true}, time.Second) + require.Error(t, err) + }) + + t.Run("context propagation", func(t *testing.T) { + t.Parallel() + + ctx := context.WithValue(context.Background(), ctxKey, "value") + err := app.SharedState().SetJSONWithContext(ctx, "key", Map{"ok": true}, time.Second) + require.NoError(t, err) + + var out map[string]bool + _, found, err := app.SharedState().GetJSONWithContext(ctx, "key", &out) + require.NoError(t, err) + require.True(t, found) + require.True(t, out["ok"]) + + has, err := app.SharedState().HasWithContext(ctx, "key") + require.NoError(t, err) + require.True(t, has) + + err = app.SharedState().DeleteWithContext(ctx, "key") + require.NoError(t, err) + }) +} + +func TestSharedState_KeyNamespacing(t *testing.T) { + t.Parallel() + + store := newSharedStateMemoryStorage(t) + appOne := New(Config{AppName: "app-one", SharedStorage: store}) + appTwo := New(Config{AppName: "app-two", SharedStorage: store}) + + err := appOne.SharedState().SetJSON("same-key", Map{"app": 1}, time.Minute) + require.NoError(t, err) + err = appTwo.SharedState().SetJSON("same-key", Map{"app": 2}, time.Minute) + require.NoError(t, err) + + var outOne map[string]int + _, found, err := appOne.SharedState().GetJSON("same-key", &outOne) + require.NoError(t, err) + require.True(t, found) + require.Equal(t, 1, outOne["app"]) + + var outTwo map[string]int + _, found, err = appTwo.SharedState().GetJSON("same-key", &outTwo) + require.NoError(t, err) + require.True(t, found) + require.Equal(t, 2, outTwo["app"]) +} + +func TestSharedState_StorageErrorsArePropagated(t *testing.T) { + t.Parallel() + + expectedErr := errors.New("storage failed") + closeErr := errors.New("close failed") + app := New(Config{ + SharedStorage: &errorStorage{err: expectedErr, closeErr: closeErr}, + MsgPackEncoder: func(any) ([]byte, error) { + return []byte("msgpack"), nil + }, + MsgPackDecoder: func([]byte, any) error { + return nil + }, + CBOREncoder: func(any) ([]byte, error) { + return []byte("cbor"), nil + }, + CBORDecoder: func([]byte, any) error { + return nil + }, + XMLEncoder: func(any) ([]byte, error) { + return []byte(""), nil + }, + XMLDecoder: func([]byte, any) error { + return nil + }, + }) + + err := app.SharedState().SetJSON("k", Map{"v": 1}, time.Second) + require.ErrorIs(t, err, expectedErr) + err = app.SharedState().SetMsgPack("k", Map{"v": 1}, time.Second) + require.ErrorIs(t, err, expectedErr) + err = app.SharedState().SetCBOR("k", Map{"v": 1}, time.Second) + require.ErrorIs(t, err, expectedErr) + err = app.SharedState().SetXML("k", Map{"v": 1}, time.Second) + require.ErrorIs(t, err, expectedErr) + + err = app.SharedState().Set("k", []byte("v"), time.Second) + require.ErrorIs(t, err, expectedErr) + + _, _, err = app.SharedState().Get("k") + require.ErrorIs(t, err, expectedErr) + + _, _, err = app.SharedState().GetJSON("k", &Map{}) + require.ErrorIs(t, err, expectedErr) + _, _, err = app.SharedState().GetMsgPack("k", &Map{}) + require.ErrorIs(t, err, expectedErr) + _, _, err = app.SharedState().GetCBOR("k", &Map{}) + require.ErrorIs(t, err, expectedErr) + _, _, err = app.SharedState().GetXML("k", &Map{}) + require.ErrorIs(t, err, expectedErr) + + _, err = app.SharedState().Has("k") + require.ErrorIs(t, err, expectedErr) + + err = app.SharedState().Delete("k") + require.ErrorIs(t, err, expectedErr) + + err = app.SharedState().Reset() + require.ErrorIs(t, err, expectedErr) + + err = app.SharedState().Close() + require.ErrorIs(t, err, closeErr) +} + +func TestSharedState_NilReceiver(t *testing.T) { + t.Parallel() + + var state *SharedState + + err := state.SetJSON("k", Map{"v": 1}, time.Second) + require.ErrorIs(t, err, ErrSharedStorageNotConfigured) + err = state.SetMsgPack("k", Map{"v": 1}, time.Second) + require.ErrorIs(t, err, ErrSharedStorageNotConfigured) + err = state.SetCBOR("k", Map{"v": 1}, time.Second) + require.ErrorIs(t, err, ErrSharedStorageNotConfigured) + err = state.SetXML("k", Map{"v": 1}, time.Second) + require.ErrorIs(t, err, ErrSharedStorageNotConfigured) + + err = state.Set("k", []byte("v"), time.Second) + require.ErrorIs(t, err, ErrSharedStorageNotConfigured) + + _, _, err = state.Get("k") + require.ErrorIs(t, err, ErrSharedStorageNotConfigured) + + _, _, err = state.GetJSON("k", &Map{}) + require.ErrorIs(t, err, ErrSharedStorageNotConfigured) + _, _, err = state.GetMsgPack("k", &Map{}) + require.ErrorIs(t, err, ErrSharedStorageNotConfigured) + _, _, err = state.GetCBOR("k", &Map{}) + require.ErrorIs(t, err, ErrSharedStorageNotConfigured) + _, _, err = state.GetXML("k", &Map{}) + require.ErrorIs(t, err, ErrSharedStorageNotConfigured) + + err = state.Delete("k") + require.ErrorIs(t, err, ErrSharedStorageNotConfigured) + + _, err = state.Has("k") + require.ErrorIs(t, err, ErrSharedStorageNotConfigured) + + err = state.Reset() + require.ErrorIs(t, err, ErrSharedStorageNotConfigured) + + err = state.Close() + require.ErrorIs(t, err, ErrSharedStorageNotConfigured) +} + +func TestSharedState_DefaultPrefixFallback(t *testing.T) { + t.Parallel() + + state := newSharedState(&Config{SharedStorage: newSharedStateMemoryStorage(t)}) + require.Equal(t, defaultSharedStatePrefix, state.prefix) +} + +func TestSharedState_NewAppDefaultPrefixIncludesAppName(t *testing.T) { + t.Parallel() + + app := New(Config{AppName: "my-app", SharedStorage: newSharedStateMemoryStorage(t)}) + require.Equal(t, defaultSharedStatePrefix+"my-app-", app.SharedState().prefix) +} + +func TestSharedState_GetJSON_UnmarshalError(t *testing.T) { + t.Parallel() + + store := newSharedStateMemoryStorage(t) + app := New(Config{SharedStorage: store}) + + storageKey, ok := app.SharedState().storageKey("broken") + require.True(t, ok) + require.NoError(t, store.Set(storageKey, []byte("{"), 0)) + + var out map[string]any + _, found, err := app.SharedState().GetJSON("broken", &out) + require.False(t, found) + require.Error(t, err) +} + +func TestSharedState_Get_ReturnsCopy(t *testing.T) { + t.Parallel() + + app := New(Config{SharedStorage: newSharedStateMemoryStorage(t)}) + require.NoError(t, app.SharedState().Set("raw", []byte("value"), time.Minute)) + + got, found, err := app.SharedState().Get("raw") + require.NoError(t, err) + require.True(t, found) + require.Equal(t, []byte("value"), got) + + got[0] = 'X' + + gotAgain, found, err := app.SharedState().Get("raw") + require.NoError(t, err) + require.True(t, found) + require.Equal(t, []byte("value"), gotAgain) +} + +func TestSharedState_RawWithContextVariants(t *testing.T) { + t.Parallel() + + type testContextKey string + + ctxKey := testContextKey("tenant") + store := &contextCheckingStorage{ctxKey: ctxKey, base: newSharedStateMemoryStorage(t)} + app := New(Config{SharedStorage: store}) + + err := app.SharedState().SetWithContext(context.Background(), "raw", []byte("x"), time.Second) + require.Error(t, err) + + ctx := context.WithValue(context.Background(), ctxKey, "value") + require.NoError(t, app.SharedState().SetWithContext(ctx, "raw", []byte("x"), time.Second)) + + raw, found, err := app.SharedState().GetWithContext(ctx, "raw") + require.NoError(t, err) + require.True(t, found) + require.Equal(t, []byte("x"), raw) +} + +func TestSharedState_SetGet_RawDataKinds(t *testing.T) { + t.Parallel() + + app := New(Config{SharedStorage: newSharedStateMemoryStorage(t)}) + + testCases := []struct { + key string + value []byte + }{ + {key: "plain", value: []byte("text")}, + {key: "json", value: []byte(`{"id":42}`)}, + {key: "binary", value: []byte{0x00, 0xFF, 0x10, 0x7F}}, + } + + for _, tc := range testCases { + t.Run(tc.key, func(t *testing.T) { + t.Parallel() + + require.NoError(t, app.SharedState().Set(tc.key, tc.value, time.Minute)) + + got, found, err := app.SharedState().Get(tc.key) + require.NoError(t, err) + require.True(t, found) + require.Equal(t, tc.value, got) + }) + } +} + +func TestSharedState_SetGet_JSONDataKinds(t *testing.T) { + t.Parallel() + + type sample struct { + Name string `json:"name"` + Count int `json:"count"` + } + + app := New(Config{SharedStorage: newSharedStateMemoryStorage(t)}) + + t.Run("map", func(t *testing.T) { + t.Parallel() + + expected := map[string]any{ + "name": "fiber", + "ok": true, + } + require.NoError(t, app.SharedState().SetJSON("map", expected, time.Minute)) + + var out map[string]any + raw, found, err := app.SharedState().GetJSON("map", &out) + require.NoError(t, err) + require.True(t, found) + require.NotEmpty(t, raw) + require.Equal(t, expected["name"], out["name"]) + require.Equal(t, expected["ok"], out["ok"]) + }) + + t.Run("slice", func(t *testing.T) { + t.Parallel() + + expected := []int{1, 2, 3, 4} + require.NoError(t, app.SharedState().SetJSON("slice", expected, time.Minute)) + + var out []int + _, found, err := app.SharedState().GetJSON("slice", &out) + require.NoError(t, err) + require.True(t, found) + require.Equal(t, expected, out) + }) + + t.Run("struct", func(t *testing.T) { + t.Parallel() + + expected := sample{Name: "shared", Count: 7} + require.NoError(t, app.SharedState().SetJSON("struct", expected, time.Minute)) + + var out sample + _, found, err := app.SharedState().GetJSON("struct", &out) + require.NoError(t, err) + require.True(t, found) + require.Equal(t, expected, out) + }) +} + +func TestSharedState_UsesAppJSONCodec(t *testing.T) { + t.Parallel() + + encoderCalled := false + decoderCalled := false + + app := New(Config{ + SharedStorage: newSharedStateMemoryStorage(t), + JSONEncoder: func(_ any) ([]byte, error) { + encoderCalled = true + return json.Marshal(Map{"via": "custom-encoder"}) + }, + JSONDecoder: func(data []byte, out any) error { + decoderCalled = true + return json.Unmarshal(data, out) + }, + }) + + require.NoError(t, app.SharedState().SetJSON("codec", Map{"ignored": true}, time.Minute)) + + var out map[string]string + _, found, err := app.SharedState().GetJSON("codec", &out) + require.NoError(t, err) + require.True(t, found) + require.Equal(t, "custom-encoder", out["via"]) + require.True(t, encoderCalled) + require.True(t, decoderCalled) +} + +func TestSharedState_UsesAppMsgPackCodec(t *testing.T) { + t.Parallel() + + encoderCalled := false + decoderCalled := false + + app := New(Config{ + SharedStorage: newSharedStateMemoryStorage(t), + MsgPackEncoder: func(_ any) ([]byte, error) { + encoderCalled = true + return []byte("msgpack-payload"), nil + }, + MsgPackDecoder: func(data []byte, out any) error { + decoderCalled = true + ptr, ok := out.(*string) + if ok { + *ptr = string(data) + } + return nil + }, + }) + + require.NoError(t, app.SharedState().SetMsgPack("codec", Map{"ignored": true}, time.Minute)) + + var out string + raw, found, err := app.SharedState().GetMsgPack("codec", &out) + require.NoError(t, err) + require.True(t, found) + require.Equal(t, []byte("msgpack-payload"), raw) + require.Equal(t, "msgpack-payload", out) + require.True(t, encoderCalled) + require.True(t, decoderCalled) +} + +func TestSharedState_UnconfiguredCodecsReturnErrorInsteadOfPanic(t *testing.T) { + t.Parallel() + + app := New(Config{SharedStorage: newSharedStateMemoryStorage(t)}) + + err := app.SharedState().SetMsgPack("codec", Map{"ignored": true}, time.Minute) + require.ErrorContains(t, err, "shared state msgpack") + + require.NoError(t, app.SharedState().Set("msgpack-payload", []byte("payload"), time.Minute)) + + var out Map + _, found, err := app.SharedState().GetMsgPack("msgpack-payload", &out) + require.False(t, found) + require.ErrorContains(t, err, "shared state msgpack") + + err = app.SharedState().SetCBOR("codec", Map{"ignored": true}, time.Minute) + require.ErrorContains(t, err, "shared state cbor") + + require.NoError(t, app.SharedState().Set("cbor-payload", []byte("payload"), time.Minute)) + + _, found, err = app.SharedState().GetCBOR("cbor-payload", &out) + require.False(t, found) + require.ErrorContains(t, err, "shared state cbor") +} + +func TestSharedState_UsesAppCBORCodec(t *testing.T) { + t.Parallel() + + encoderCalled := false + decoderCalled := false + + app := New(Config{ + SharedStorage: newSharedStateMemoryStorage(t), + CBOREncoder: func(_ any) ([]byte, error) { + encoderCalled = true + return []byte("cbor-payload"), nil + }, + CBORDecoder: func(data []byte, out any) error { + decoderCalled = true + ptr, ok := out.(*string) + if ok { + *ptr = string(data) + } + return nil + }, + }) + + require.NoError(t, app.SharedState().SetCBOR("codec", Map{"ignored": true}, time.Minute)) + + var out string + raw, found, err := app.SharedState().GetCBOR("codec", &out) + require.NoError(t, err) + require.True(t, found) + require.Equal(t, []byte("cbor-payload"), raw) + require.Equal(t, "cbor-payload", out) + require.True(t, encoderCalled) + require.True(t, decoderCalled) +} + +func TestSharedState_UsesAppXMLCodec(t *testing.T) { + t.Parallel() + + encoderCalled := false + decoderCalled := false + + app := New(Config{ + SharedStorage: newSharedStateMemoryStorage(t), + XMLEncoder: func(_ any) ([]byte, error) { + encoderCalled = true + return []byte("xml-payload"), nil + }, + XMLDecoder: func(data []byte, out any) error { + decoderCalled = true + ptr, ok := out.(*string) + if ok { + *ptr = string(data) + } + return nil + }, + }) + + require.NoError(t, app.SharedState().SetXML("codec", Map{"ignored": true}, time.Minute)) + + var out string + raw, found, err := app.SharedState().GetXML("codec", &out) + require.NoError(t, err) + require.True(t, found) + require.Equal(t, []byte("xml-payload"), raw) + require.Equal(t, "xml-payload", out) + require.True(t, encoderCalled) + require.True(t, decoderCalled) +} + +func TestSharedState_EmptyKeyBehavior(t *testing.T) { + t.Parallel() + + app := New(Config{SharedStorage: newSharedStateMemoryStorage(t)}) + + require.NoError(t, app.SharedState().Set("", []byte("raw"), time.Minute)) + require.NoError(t, app.SharedState().SetJSON("", Map{"v": 1}, time.Minute)) + require.NoError(t, app.SharedState().SetMsgPack("", Map{"v": 1}, time.Minute)) + require.NoError(t, app.SharedState().SetCBOR("", Map{"v": 1}, time.Minute)) + require.NoError(t, app.SharedState().SetXML("", Map{"v": 1}, time.Minute)) + + raw, found, err := app.SharedState().Get("") + require.NoError(t, err) + require.Nil(t, raw) + require.False(t, found) + + _, found, err = app.SharedState().GetJSON("", &Map{}) + require.NoError(t, err) + require.False(t, found) + + _, found, err = app.SharedState().GetMsgPack("", &Map{}) + require.NoError(t, err) + require.False(t, found) + + _, found, err = app.SharedState().GetCBOR("", &Map{}) + require.NoError(t, err) + require.False(t, found) + + _, found, err = app.SharedState().GetXML("", &Map{}) + require.NoError(t, err) + require.False(t, found) + + require.NoError(t, app.SharedState().Delete("")) + + has, err := app.SharedState().Has("") + require.NoError(t, err) + require.False(t, has) +}