diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 93663cb63..961cdffe0 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -50,14 +50,8 @@ func main() { } defer func() { - if err := app.Repo.Close(); err != nil { - panic(fmt.Sprintf("failed to close repo: %v", err)) - } - if app.TemporalWorker != nil { - app.TemporalWorker.Stop() - } - if app.TemporalClient != nil { - app.TemporalClient.Close() + if err := app.Close(); err != nil { + panic(fmt.Sprintf("failed to close app resources: %v", err)) } }() diff --git a/backend/config/config.go b/backend/config/config.go index 964044922..cd5b8eb23 100644 --- a/backend/config/config.go +++ b/backend/config/config.go @@ -7,5 +7,6 @@ type Config struct { LLM `env:",prefix=LLM_"` Temporal `env:",prefix=TEMPORAL_"` Clerk `env:",prefix=CLERK_"` + Redis `env:",prefix=REDIS_"` OpenSearch `env:",prefix=OPENSEARCH_"` } diff --git a/backend/config/redis.go b/backend/config/redis.go new file mode 100644 index 000000000..68f87b179 --- /dev/null +++ b/backend/config/redis.go @@ -0,0 +1,11 @@ +package config + +import "time" + +type Redis struct { + Enabled bool `env:"ENABLED,default=false"` + Addr string `env:"ADDR,default=localhost:6379"` + Password string `env:"PASSWORD"` + DB int `env:"DB,default=0"` + PingTimeout time.Duration `env:"PING_TIMEOUT,default=1s"` +} diff --git a/backend/config/redis_test.go b/backend/config/redis_test.go new file mode 100644 index 000000000..ef173f734 --- /dev/null +++ b/backend/config/redis_test.go @@ -0,0 +1,25 @@ +package config + +import ( + "context" + "testing" + "time" + + "github.com/sethvargo/go-envconfig" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRedisConfigDefaults(t *testing.T) { + t.Parallel() + + var cfg Redis + err := envconfig.Process(context.Background(), &cfg) + + require.NoError(t, err) + assert.False(t, cfg.Enabled) + assert.Equal(t, "localhost:6379", cfg.Addr) + assert.Equal(t, "", cfg.Password) + assert.Equal(t, 0, cfg.DB) + assert.Equal(t, time.Second, cfg.PingTimeout) +} diff --git a/backend/internal/cache/domain/guest_bookings.go b/backend/internal/cache/domain/guest_bookings.go new file mode 100644 index 000000000..9f7f5a918 --- /dev/null +++ b/backend/internal/cache/domain/guest_bookings.go @@ -0,0 +1,47 @@ +package domain + +import ( + "context" + "time" + + "github.com/generate/selfserve/internal/cache/object" + storage "github.com/generate/selfserve/internal/service/storage/postgres" +) + +type CachedGuestBookingsRepository struct { + cache *object.Cache + next storage.GuestBookingsRepository +} + +func NewCachedGuestBookingsRepository(cache *object.Cache, next storage.GuestBookingsRepository) *CachedGuestBookingsRepository { + return &CachedGuestBookingsRepository{cache: cache, next: next} +} + +func (r *CachedGuestBookingsRepository) FindGroupSizeOptions(ctx context.Context, hotelID string) ([]int, error) { + if r.cache == nil { + return r.next.FindGroupSizeOptions(ctx, hotelID) + } + + key := guestBookingGroupSizesKey(hotelID) + var cached []int + if hit, err := r.cache.Get(ctx, key, &cached); err != nil { + r.cache.WarnReadError(key, err) + } else if hit { + return cached, nil + } + + sizes, err := r.next.FindGroupSizeOptions(ctx, hotelID) + if err != nil { + return nil, err + } + + if err := r.cache.Set(ctx, key, sizes, guestBookingGroupSizesTTL); err != nil { + r.cache.WarnWriteError(key, err) + } + return sizes, nil +} + +func (r *CachedGuestBookingsRepository) InsertGuestBooking(ctx context.Context, guestID, roomID, hotelID string, arrivalDate, departureDate time.Time) error { + // Future guest booking writes should invalidate hotel group sizes and affected guest stay history. + return r.next.InsertGuestBooking(ctx, guestID, roomID, hotelID, arrivalDate, departureDate) +} diff --git a/backend/internal/cache/domain/guests.go b/backend/internal/cache/domain/guests.go new file mode 100644 index 000000000..66b271304 --- /dev/null +++ b/backend/internal/cache/domain/guests.go @@ -0,0 +1,100 @@ +package domain + +import ( + "context" + + "github.com/generate/selfserve/internal/cache/object" + "github.com/generate/selfserve/internal/models" + storage "github.com/generate/selfserve/internal/service/storage/postgres" +) + +type CachedGuestsRepository struct { + cache *object.Cache + next storage.GuestsRepository +} + +func NewCachedGuestsRepository(cache *object.Cache, next storage.GuestsRepository) *CachedGuestsRepository { + return &CachedGuestsRepository{cache: cache, next: next} +} + +func (r *CachedGuestsRepository) InsertGuest(ctx context.Context, guest *models.CreateGuest) (*models.Guest, error) { + created, err := r.next.InsertGuest(ctx, guest) + if err != nil { + return nil, err + } + r.invalidateGuest(ctx, created.ID) + return created, nil +} + +func (r *CachedGuestsRepository) FindGuest(ctx context.Context, id string) (*models.Guest, error) { + if r.cache == nil { + return r.next.FindGuest(ctx, id) + } + + key := guestKey(id) + var cached models.Guest + if hit, err := r.cache.Get(ctx, key, &cached); err != nil { + r.cache.WarnReadError(key, err) + } else if hit { + return &cached, nil + } + + guest, err := r.next.FindGuest(ctx, id) + if err != nil { + return nil, err + } + + if err := r.cache.Set(ctx, key, guest, guestTTL); err != nil { + r.cache.WarnWriteError(key, err) + } + return guest, nil +} + +func (r *CachedGuestsRepository) UpdateGuest(ctx context.Context, id string, update *models.UpdateGuest) (*models.Guest, error) { + updated, err := r.next.UpdateGuest(ctx, id, update) + if err != nil { + return nil, err + } + r.invalidateGuest(ctx, id) + return updated, nil +} + +func (r *CachedGuestsRepository) FindGuestsWithActiveBooking(ctx context.Context, filters *models.GuestFilters) (*models.GuestPage, error) { + return r.next.FindGuestsWithActiveBooking(ctx, filters) +} + +func (r *CachedGuestsRepository) FindGuestWithStayHistory(ctx context.Context, id string) (*models.GuestWithStays, error) { + if r.cache == nil { + return r.next.FindGuestWithStayHistory(ctx, id) + } + + key := guestStayHistoryKey(id) + var cached models.GuestWithStays + if hit, err := r.cache.Get(ctx, key, &cached); err != nil { + r.cache.WarnReadError(key, err) + } else if hit { + return &cached, nil + } + + guest, err := r.next.FindGuestWithStayHistory(ctx, id) + if err != nil { + return nil, err + } + + if err := r.cache.Set(ctx, key, guest, guestStayHistoryTTL); err != nil { + r.cache.WarnWriteError(key, err) + } + return guest, nil +} + +func (r *CachedGuestsRepository) invalidateGuest(ctx context.Context, id string) { + if r.cache == nil || id == "" { + return + } + + for _, key := range []string{guestKey(id), guestStayHistoryKey(id)} { + if err := r.cache.Delete(ctx, key); err != nil { + r.cache.WarnDeleteError(key, err) + } + } +} diff --git a/backend/internal/cache/domain/hotels.go b/backend/internal/cache/domain/hotels.go new file mode 100644 index 000000000..0e50b4d8c --- /dev/null +++ b/backend/internal/cache/domain/hotels.go @@ -0,0 +1,62 @@ +package domain + +import ( + "context" + + "github.com/generate/selfserve/internal/cache/object" + "github.com/generate/selfserve/internal/models" + storage "github.com/generate/selfserve/internal/service/storage/postgres" +) + +type CachedHotelsRepository struct { + cache *object.Cache + next storage.HotelsRepository +} + +func NewCachedHotelsRepository(cache *object.Cache, next storage.HotelsRepository) *CachedHotelsRepository { + return &CachedHotelsRepository{cache: cache, next: next} +} + +func (r *CachedHotelsRepository) FindByID(ctx context.Context, id string) (*models.Hotel, error) { + if r.cache == nil { + return r.next.FindByID(ctx, id) + } + + key := hotelKey(id) + var cached models.Hotel + if hit, err := r.cache.Get(ctx, key, &cached); err != nil { + r.cache.WarnReadError(key, err) + } else if hit { + return &cached, nil + } + + hotel, err := r.next.FindByID(ctx, id) + if err != nil { + return nil, err + } + + if err := r.cache.Set(ctx, key, hotel, hotelTTL); err != nil { + r.cache.WarnWriteError(key, err) + } + return hotel, nil +} + +func (r *CachedHotelsRepository) InsertHotel(ctx context.Context, hotel *models.CreateHotelRequest) (*models.Hotel, error) { + return r.next.InsertHotel(ctx, hotel) +} + +func (r *CachedHotelsRepository) GetDepartmentsByHotelID(ctx context.Context, hotelID string) ([]*models.Department, error) { + return r.next.GetDepartmentsByHotelID(ctx, hotelID) +} + +func (r *CachedHotelsRepository) InsertDepartment(ctx context.Context, hotelID, name string) (*models.Department, error) { + return r.next.InsertDepartment(ctx, hotelID, name) +} + +func (r *CachedHotelsRepository) UpdateDepartment(ctx context.Context, id, hotelID, name string) (*models.Department, error) { + return r.next.UpdateDepartment(ctx, id, hotelID, name) +} + +func (r *CachedHotelsRepository) DeleteDepartment(ctx context.Context, id, hotelID string) error { + return r.next.DeleteDepartment(ctx, id, hotelID) +} diff --git a/backend/internal/cache/domain/keys.go b/backend/internal/cache/domain/keys.go new file mode 100644 index 000000000..9c16c4836 --- /dev/null +++ b/backend/internal/cache/domain/keys.go @@ -0,0 +1,23 @@ +package domain + +const keyPrefix = "selfserve:v1" + +func userKey(id string) string { + return keyPrefix + ":users:" + id +} + +func hotelKey(id string) string { + return keyPrefix + ":hotels:" + id +} + +func guestKey(id string) string { + return keyPrefix + ":guests:" + id +} + +func guestStayHistoryKey(id string) string { + return keyPrefix + ":guests:" + id + ":stay_history" +} + +func guestBookingGroupSizesKey(hotelID string) string { + return keyPrefix + ":hotels:" + hotelID + ":guest_booking_group_sizes" +} diff --git a/backend/internal/cache/domain/policy.go b/backend/internal/cache/domain/policy.go new file mode 100644 index 000000000..6775de860 --- /dev/null +++ b/backend/internal/cache/domain/policy.go @@ -0,0 +1,11 @@ +package domain + +import "time" + +const ( + userTTL = 5 * time.Minute + hotelTTL = 15 * time.Minute + guestTTL = 2 * time.Minute + guestStayHistoryTTL = 1 * time.Minute + guestBookingGroupSizesTTL = 5 * time.Minute +) diff --git a/backend/internal/cache/domain/users.go b/backend/internal/cache/domain/users.go new file mode 100644 index 000000000..16ffe6c22 --- /dev/null +++ b/backend/internal/cache/domain/users.go @@ -0,0 +1,128 @@ +package domain + +import ( + "context" + + "github.com/generate/selfserve/internal/cache/object" + "github.com/generate/selfserve/internal/models" + storage "github.com/generate/selfserve/internal/service/storage/postgres" +) + +type CachedUsersRepository struct { + cache *object.Cache + next storage.UsersRepository +} + +func NewCachedUsersRepository(cache *object.Cache, next storage.UsersRepository) *CachedUsersRepository { + return &CachedUsersRepository{cache: cache, next: next} +} + +func (r *CachedUsersRepository) FindUser(ctx context.Context, id string) (*models.User, error) { + if r.cache == nil { + return r.next.FindUser(ctx, id) + } + + key := userKey(id) + var cached models.User + if hit, err := r.cache.Get(ctx, key, &cached); err != nil { + r.cache.WarnReadError(key, err) + } else if hit { + return &cached, nil + } + + user, err := r.next.FindUser(ctx, id) + if err != nil { + return nil, err + } + + if err := r.cache.Set(ctx, key, user, userTTL); err != nil { + r.cache.WarnWriteError(key, err) + } + return user, nil +} + +func (r *CachedUsersRepository) InsertUser(ctx context.Context, user *models.CreateUser) (*models.User, error) { + created, err := r.next.InsertUser(ctx, user) + if err != nil { + return nil, err + } + r.invalidateUser(ctx, created.ID) + return created, nil +} + +func (r *CachedUsersRepository) UpdateUser(ctx context.Context, id string, update *models.UpdateUser) (*models.User, error) { + updated, err := r.next.UpdateUser(ctx, id, update) + if err != nil { + return nil, err + } + r.invalidateUser(ctx, id) + return updated, nil +} + +func (r *CachedUsersRepository) UpdateProfilePicture(ctx context.Context, userID string, key string) error { + if err := r.next.UpdateProfilePicture(ctx, userID, key); err != nil { + return err + } + r.invalidateUser(ctx, userID) + return nil +} + +func (r *CachedUsersRepository) DeleteProfilePicture(ctx context.Context, userID string) error { + if err := r.next.DeleteProfilePicture(ctx, userID); err != nil { + return err + } + r.invalidateUser(ctx, userID) + return nil +} + +func (r *CachedUsersRepository) GetKey(ctx context.Context, userID string) (string, error) { + return r.next.GetKey(ctx, userID) +} + +func (r *CachedUsersRepository) BulkInsertUsers(ctx context.Context, users []*models.CreateUser) error { + return r.next.BulkInsertUsers(ctx, users) +} + +func (r *CachedUsersRepository) GetUsersByHotel(ctx context.Context, hotelID, cursor string, limit int) ([]*models.User, string, error) { + return r.next.GetUsersByHotel(ctx, hotelID, cursor, limit) +} + +func (r *CachedUsersRepository) SearchUsersByHotel(ctx context.Context, hotelID, cursor, query string, limit int) ([]*models.User, string, error) { + return r.next.SearchUsersByHotel(ctx, hotelID, cursor, query, limit) +} + +func (r *CachedUsersRepository) AddEmployeeDepartment(ctx context.Context, employeeID, departmentID string) error { + if err := r.next.AddEmployeeDepartment(ctx, employeeID, departmentID); err != nil { + return err + } + r.invalidateUser(ctx, employeeID) + return nil +} + +func (r *CachedUsersRepository) RemoveEmployeeDepartment(ctx context.Context, employeeID, departmentID string) error { + if err := r.next.RemoveEmployeeDepartment(ctx, employeeID, departmentID); err != nil { + return err + } + r.invalidateUser(ctx, employeeID) + return nil +} + +func (r *CachedUsersRepository) CompleteOnboarding(ctx context.Context, id string, data *models.OnboardUser) (*models.User, error) { + user, err := r.next.CompleteOnboarding(ctx, id, data) + if err != nil { + return nil, err + } + r.invalidateUser(ctx, id) + return user, nil +} + +func (r *CachedUsersRepository) invalidateUser(ctx context.Context, id string) { + if r.cache == nil || id == "" { + return + } + + key := userKey(id) + if err := r.cache.Delete(ctx, key); err != nil { + r.cache.WarnDeleteError(key, err) + } +} diff --git a/backend/internal/cache/object/cache.go b/backend/internal/cache/object/cache.go new file mode 100644 index 000000000..ce3a4708f --- /dev/null +++ b/backend/internal/cache/object/cache.go @@ -0,0 +1,74 @@ +package object + +import ( + "context" + "encoding/json" + "errors" + "log/slog" + "time" + + "github.com/generate/selfserve/internal/cache/store" +) + +type Cache struct { + store store.KVStore + logger *slog.Logger +} + +func New(cacheStore store.KVStore, logger ...*slog.Logger) *Cache { + if cacheStore == nil { + return nil + } + + resolvedLogger := slog.Default() + if len(logger) > 0 && logger[0] != nil { + resolvedLogger = logger[0] + } + + return &Cache{store: cacheStore, logger: resolvedLogger} +} + +func (c *Cache) Get(ctx context.Context, key string, dest any) (bool, error) { + value, err := c.store.Get(ctx, key) + if errors.Is(err, store.ErrCacheMiss) { + return false, nil + } + if err != nil { + return false, err + } + if err := json.Unmarshal([]byte(value), dest); err != nil { + return false, err + } + return true, nil +} + +func (c *Cache) Set(ctx context.Context, key string, value any, ttl time.Duration) error { + encoded, err := json.Marshal(value) + if err != nil { + return err + } + return c.store.Set(ctx, key, string(encoded), ttl) +} + +func (c *Cache) Delete(ctx context.Context, key string) error { + return c.store.Delete(ctx, key) +} + +func (c *Cache) WarnReadError(key string, err error) { + c.warn("redis cache read failed", key, err) +} + +func (c *Cache) WarnWriteError(key string, err error) { + c.warn("redis cache write failed", key, err) +} + +func (c *Cache) WarnDeleteError(key string, err error) { + c.warn("redis cache delete failed", key, err) +} + +func (c *Cache) warn(message, key string, err error) { + if c == nil || err == nil { + return + } + c.logger.Warn(message, "key", key, "err", err) +} diff --git a/backend/internal/cache/store/redis_store.go b/backend/internal/cache/store/redis_store.go new file mode 100644 index 000000000..3627a6baf --- /dev/null +++ b/backend/internal/cache/store/redis_store.go @@ -0,0 +1,52 @@ +package store + +import ( + "context" + "errors" + "time" + + goredis "github.com/redis/go-redis/v9" +) + +type RedisStore struct { + getValue func(ctx context.Context, key string) (string, error) + setValue func(ctx context.Context, key string, value string, ttl time.Duration) error + deleteValue func(ctx context.Context, key string) error +} + +func NewRedisStore(client *goredis.Client) *RedisStore { + if client == nil { + return nil + } + + return &RedisStore{ + getValue: func(ctx context.Context, key string) (string, error) { + return client.Get(ctx, key).Result() + }, + setValue: func(ctx context.Context, key string, value string, ttl time.Duration) error { + return client.Set(ctx, key, value, ttl).Err() + }, + deleteValue: func(ctx context.Context, key string) error { + return client.Del(ctx, key).Err() + }, + } +} + +func (s *RedisStore) Get(ctx context.Context, key string) (string, error) { + value, err := s.getValue(ctx, key) + if errors.Is(err, goredis.Nil) { + return "", ErrCacheMiss + } + if err != nil { + return "", err + } + return value, nil +} + +func (s *RedisStore) Set(ctx context.Context, key string, value string, ttl time.Duration) error { + return s.setValue(ctx, key, value, ttl) +} + +func (s *RedisStore) Delete(ctx context.Context, key string) error { + return s.deleteValue(ctx, key) +} diff --git a/backend/internal/cache/store/store.go b/backend/internal/cache/store/store.go new file mode 100644 index 000000000..134300ee9 --- /dev/null +++ b/backend/internal/cache/store/store.go @@ -0,0 +1,15 @@ +package store + +import ( + "context" + "errors" + "time" +) + +var ErrCacheMiss = errors.New("cache miss") + +type KVStore interface { + Get(ctx context.Context, key string) (string, error) + Set(ctx context.Context, key string, value string, ttl time.Duration) error + Delete(ctx context.Context, key string) error +} diff --git a/backend/internal/service/cached_repos.go b/backend/internal/service/cached_repos.go new file mode 100644 index 000000000..a143eb400 --- /dev/null +++ b/backend/internal/service/cached_repos.go @@ -0,0 +1,37 @@ +package service + +import ( + "github.com/generate/selfserve/internal/cache/domain" + "github.com/generate/selfserve/internal/cache/object" + "github.com/generate/selfserve/internal/handler" + "github.com/generate/selfserve/internal/repository" + storage "github.com/generate/selfserve/internal/service/storage/postgres" +) + +func buildUsersRepository(cache *object.Cache, repo *repository.UsersRepository) storage.UsersRepository { + if cache == nil { + return repo + } + return domain.NewCachedUsersRepository(cache, repo) +} + +func buildGuestsRepository(cache *object.Cache, repo *repository.GuestsRepository) storage.GuestsRepository { + if cache == nil { + return repo + } + return domain.NewCachedGuestsRepository(cache, repo) +} + +func buildHotelsRepository(cache *object.Cache, repo *repository.HotelsRepository) handler.HotelsRepository { + if cache == nil { + return repo + } + return domain.NewCachedHotelsRepository(cache, repo) +} + +func buildGuestBookingsRepository(cache *object.Cache, repo *repository.GuestBookingsRepository) handler.GuestBookingsRepository { + if cache == nil { + return repo + } + return domain.NewCachedGuestBookingsRepository(cache, repo) +} diff --git a/backend/internal/service/server.go b/backend/internal/service/server.go index 749715cc0..cabb26151 100644 --- a/backend/internal/service/server.go +++ b/backend/internal/service/server.go @@ -11,6 +11,8 @@ import ( clerksdk "github.com/clerk/clerk-sdk-go/v2" "github.com/generate/selfserve/config" "github.com/generate/selfserve/internal/aiflows" + "github.com/generate/selfserve/internal/cache/object" + "github.com/generate/selfserve/internal/cache/store" "github.com/generate/selfserve/internal/errs" "github.com/generate/selfserve/internal/handler" "github.com/generate/selfserve/internal/repository" @@ -37,6 +39,12 @@ import ( "go.temporal.io/sdk/worker" ) +type redisClient = *goredis.Client + +var initRedisClient = func(cfg config.Redis) (redisClient, error) { + return redis.InitRedis(cfg) +} + type App struct { Server *fiber.App Repo *storage.Repository @@ -46,6 +54,26 @@ type App struct { TemporalWorker worker.Worker } +func (a *App) Close() error { + if a == nil { + return nil + } + + if a.TemporalWorker != nil { + a.TemporalWorker.Stop() + } + if a.TemporalClient != nil { + a.TemporalClient.Close() + } + + var err error + if a.Repo != nil { + err = errors.Join(err, a.Repo.Close()) + } + + return errors.Join(err, redis.Close(a.RedisClient)) +} + func InitApp(cfg *config.Config) (*App, error) { validation.Init() @@ -56,14 +84,12 @@ func InitApp(cfg *config.Config) (*App, error) { return nil, err } - redisClient := tryInitRedis() + redisClient := tryInitRedis(cfg.Redis) + objectCache := object.New(store.NewRedisStore(redisClient)) s3Store, err := s3storage.NewS3Storage(cfg.S3) if err != nil { - if e := repo.Close(); e != nil { - return nil, errors.Join(err, e) - } - return nil, err + return nil, errors.Join(err, repo.Close(), redis.Close(redisClient)) } openSearchRepos := tryInitOpenSearchRepositories(cfg) @@ -77,14 +103,14 @@ func InitApp(cfg *config.Config) (*App, error) { app := setupApp() setupClerk(cfg) - if err = setupRoutes(app, repo, genkitInstance, workflowClient, cfg, s3Store, openSearchRepos); err != nil { //nolint:wsl - if e := repo.Close(); e != nil { - return nil, errors.Join(err, e) + if err = setupRoutes(app, repo, genkitInstance, workflowClient, cfg, s3Store, openSearchRepos, objectCache); err != nil { //nolint:wsl + if temporalWorker != nil { + temporalWorker.Stop() } if temporalClient != nil { temporalClient.Close() } - return nil, err + return nil, errors.Join(err, repo.Close(), redis.Close(redisClient)) } return &App{ @@ -116,8 +142,12 @@ func tryInitOpenSearchRepositories(cfg *config.Config) openSearchRepositories { } } -func tryInitRedis() *goredis.Client { - redisClient, err := redis.InitRedis() +func tryInitRedis(cfg config.Redis) *goredis.Client { + if !cfg.Enabled { + return nil + } + + redisClient, err := initRedisClient(cfg) if err != nil { log.Printf("Warning: Redis not available: %v", err) return nil @@ -144,7 +174,7 @@ func tryInitTemporal(cfg *config.Config, genkitService aiflows.GenerateRequestSe } func setupRoutes(app *fiber.App, repo *storage.Repository, genkitInstance *aiflows.GenkitService, - workflowClient temporalservice.GenerateRequestWorkflowClient, cfg *config.Config, s3Store *s3storage.Storage, openSearchRepos openSearchRepositories) error { + workflowClient temporalservice.GenerateRequestWorkflowClient, cfg *config.Config, s3Store *s3storage.Storage, openSearchRepos openSearchRepositories, objectCache *object.Cache) error { // Swagger documentation app.Get("/swagger/*", handler.ServeSwagger) @@ -161,6 +191,12 @@ func setupRoutes(app *fiber.App, repo *storage.Repository, genkitInstance *aiflo // initialize users and hotels repos for clerk webhook handler usersRepo := repository.NewUsersRepository(repo.DB) hotelsRepo := repository.NewHotelsRepository(repo.DB) + usersReadRepo := buildUsersRepository(objectCache, usersRepo) + guestsRepo := repository.NewGuestsRepository(repo.DB) + guestsReadRepo := buildGuestsRepository(objectCache, guestsRepo) + hotelsReadRepo := buildHotelsRepository(objectCache, hotelsRepo) + guestBookingsRepo := repository.NewGuestBookingsRepository(repo.DB) + guestBookingsReadRepo := buildGuestBookingsRepository(objectCache, guestBookingsRepo) // initialize notifications notifRepo := repository.NewNotificationsRepository(repo.DB) @@ -170,14 +206,14 @@ func setupRoutes(app *fiber.App, repo *storage.Repository, genkitInstance *aiflo // initialize handler(s) helloHandler := handler.NewHelloHandler() devsHandler := handler.NewDevsHandler(repository.NewDevsRepository(repo.DB)) - usersHandler := handler.NewUsersHandler(repository.NewUsersRepository(repo.DB), s3Store) - guestsHandler := handler.NewGuestsHandler(repository.NewGuestsRepository(repo.DB), repository.NewUsersRepository(repo.DB), openSearchRepos.Guests) + usersHandler := handler.NewUsersHandler(usersReadRepo, s3Store) + guestsHandler := handler.NewGuestsHandler(guestsReadRepo, usersReadRepo, openSearchRepos.Guests) reqsHandler := handler.NewRequestsHandler(repository.NewRequestsRepo(repo.DB), genkitInstance, notifService) reqsHandler.WorkflowClient = workflowClient - hotelsHandler := handler.NewHotelsHandler(repository.NewHotelsRepository(repo.DB), repository.NewUsersRepository(repo.DB)) + hotelsHandler := handler.NewHotelsHandler(hotelsReadRepo, usersReadRepo) s3Handler := handler.NewS3Handler(s3Store) roomsHandler := handler.NewRoomsHandler(repository.NewRoomsRepository(repo.DB)) - guestBookingsHandler := handler.NewGuestBookingsHandler(repository.NewGuestBookingsRepository(repo.DB)) + guestBookingsHandler := handler.NewGuestBookingsHandler(guestBookingsReadRepo) viewsHandler := handler.NewViewsHandler(repository.NewViewsRepository(repo.DB)) clerkWhSignatureVerifier, err := handler.NewWebhookVerifier(cfg) diff --git a/backend/internal/storage/redis/client.go b/backend/internal/storage/redis/client.go index bb749b039..8aaa873ab 100644 --- a/backend/internal/storage/redis/client.go +++ b/backend/internal/storage/redis/client.go @@ -3,21 +3,21 @@ package redis import ( "context" "fmt" - "os" + "time" + "github.com/generate/selfserve/config" "github.com/redis/go-redis/v9" ) // InitRedis initializes and returns a Redis client -func InitRedis() (*redis.Client, error) { - client := redis.NewClient(&redis.Options{ - Addr: getRedisAddr(), - Password: getRedisPassword(), - DB: 0, - }) - - ctx := context.Background() +func InitRedis(cfg config.Redis) (*redis.Client, error) { + client := redis.NewClient(newOptions(cfg)) + + ctx, cancel := context.WithTimeout(context.Background(), pingTimeout(cfg)) + defer cancel() + if err := client.Ping(ctx).Err(); err != nil { + _ = client.Close() return nil, fmt.Errorf("failed to connect to Redis: %w", err) } @@ -32,14 +32,17 @@ func Close(client *redis.Client) error { return nil } -func getRedisAddr() string { - addr := os.Getenv("REDIS_ADDR") - if addr == "" { - return "localhost:6379" +func newOptions(cfg config.Redis) *redis.Options { + return &redis.Options{ + Addr: cfg.Addr, + Password: cfg.Password, + DB: cfg.DB, } - return addr } -func getRedisPassword() string { - return os.Getenv("REDIS_PASSWORD") +func pingTimeout(cfg config.Redis) time.Duration { + if cfg.PingTimeout <= 0 { + return time.Second + } + return cfg.PingTimeout } diff --git a/backend/internal/storage/redis/client_test.go b/backend/internal/storage/redis/client_test.go deleted file mode 100644 index a171e6ea6..000000000 --- a/backend/internal/storage/redis/client_test.go +++ /dev/null @@ -1,34 +0,0 @@ -package redis - -import ( - "context" - "testing" -) - -func TestRedisConnection(t *testing.T) { - client, err := InitRedis() - if err != nil { - t.Skipf("Skipping test: Redis not available: %v", err) - } - defer func() { - _ = Close(client) - }() - - ctx := context.Background() - - // Test Set - err = client.Set(ctx, "test_key", "test_value", 0).Err() - if err != nil { - t.Fatalf("Failed to set value: %v", err) - } - defer client.Del(ctx, "test_key") - - // Test Get - val, err := client.Get(ctx, "test_key").Result() - if err != nil { - t.Fatalf("Failed to get value: %v", err) - } - if val != "test_value" { - t.Errorf("Expected 'test_value', got '%s'", val) - } -}