diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index d9d6023..fec882e 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -14,6 +14,7 @@ import ( "time" "github.com/Sosokker/todolist-backend/internal/api" + "github.com/Sosokker/todolist-backend/internal/cache" "github.com/Sosokker/todolist-backend/internal/config" "github.com/Sosokker/todolist-backend/internal/repository" "github.com/Sosokker/todolist-backend/internal/service" @@ -53,7 +54,10 @@ func main() { os.Exit(1) } - repoRegistry := repository.NewRepositoryRegistry(pool) + // --- Cache Setup --- + appCache := cache.NewMemoryCache(cfg.Cache, logger) + + repoRegistry := repository.NewRepositoryRegistry(pool, appCache, logger) var storageService service.FileStorageService storageService, err = service.NewGCStorageService(cfg.Storage.GCS, logger) diff --git a/backend/internal/cache/cache.go b/backend/internal/cache/cache.go index 17cd13f..1189315 100644 --- a/backend/internal/cache/cache.go +++ b/backend/internal/cache/cache.go @@ -18,19 +18,30 @@ type Cache interface { // memoryCache is an in-memory implementation of the Cache interface type memoryCache struct { - client *gocache.Cache - logger *slog.Logger + client *gocache.Cache + logger *slog.Logger + defaultExpiration time.Duration } // NewMemoryCache creates a new in-memory cache func NewMemoryCache(cfg config.CacheConfig, logger *slog.Logger) Cache { - c := gocache.New(cfg.DefaultExpiration, cfg.CleanupInterval) + defaultExp := cfg.DefaultExpiration + if defaultExp <= 0 { + defaultExp = 5 * time.Minute + } + cleanupInterval := cfg.CleanupInterval + if cleanupInterval <= 0 { + cleanupInterval = 10 * time.Minute + } + + c := gocache.New(defaultExp, cleanupInterval) logger.Info("In-memory cache initialized", - "defaultExpiration", cfg.DefaultExpiration, - "cleanupInterval", cfg.CleanupInterval) + "defaultExpiration", defaultExp, + "cleanupInterval", cleanupInterval) return &memoryCache{ - client: c, - logger: logger, + client: c, + logger: logger.With("component", "memoryCache"), + defaultExpiration: defaultExp, } } @@ -45,8 +56,12 @@ func (m *memoryCache) Get(ctx context.Context, key string) (interface{}, bool) { } func (m *memoryCache) Set(ctx context.Context, key string, value interface{}, duration time.Duration) { - m.logger.DebugContext(ctx, "Setting cache", "key", key, "duration", duration) - m.client.Set(key, value, duration) // duration=0 means use default, -1 means never expire (DefaultExpiration) + exp := duration + if exp <= 0 { + exp = m.defaultExpiration + } + m.logger.DebugContext(ctx, "Setting cache", "key", key, "duration", exp) + m.client.Set(key, value, exp) } func (m *memoryCache) Delete(ctx context.Context, key string) { diff --git a/backend/internal/repository/interfaces.go b/backend/internal/repository/interfaces.go index 21641ec..b5f38e4 100644 --- a/backend/internal/repository/interfaces.go +++ b/backend/internal/repository/interfaces.go @@ -2,8 +2,10 @@ package repository import ( "context" + "log/slog" "time" + "github.com/Sosokker/todolist-backend/internal/cache" "github.com/Sosokker/todolist-backend/internal/domain" db "github.com/Sosokker/todolist-backend/internal/repository/sqlc/generated" "github.com/google/uuid" @@ -82,14 +84,22 @@ type RepositoryRegistry struct { Pool *pgxpool.Pool } -// NewRepositoryRegistry creates a new registry -func NewRepositoryRegistry(pool *pgxpool.Pool) *RepositoryRegistry { +// NewRepositoryRegistry creates a new registry, now with caching decorators +func NewRepositoryRegistry(pool *pgxpool.Pool, cache cache.Cache, logger *slog.Logger) *RepositoryRegistry { queries := db.New(pool) + + pgxUserRepo := NewPgxUserRepository(queries) + pgxTagRepo := NewPgxTagRepository(queries) + pgxTodoRepo := NewPgxTodoRepository(queries, pool) + pgxSubtaskRepo := NewPgxSubtaskRepository(queries) + + cachingTagRepo := NewCachingTagRepository(pgxTagRepo, cache, logger) + return &RepositoryRegistry{ - UserRepo: NewPgxUserRepository(queries), - TagRepo: NewPgxTagRepository(queries), - TodoRepo: NewPgxTodoRepository(queries, pool), - SubtaskRepo: NewPgxSubtaskRepository(queries), + UserRepo: pgxUserRepo, // Not cached yet in this example + TagRepo: cachingTagRepo, // Use the caching decorator + TodoRepo: pgxTodoRepo, // Not cached yet in this example + SubtaskRepo: pgxSubtaskRepo, // Not cached yet in this example Queries: queries, Pool: pool, } diff --git a/backend/internal/repository/tag_repo_cache.go b/backend/internal/repository/tag_repo_cache.go new file mode 100644 index 0000000..9863d19 --- /dev/null +++ b/backend/internal/repository/tag_repo_cache.go @@ -0,0 +1,95 @@ +package repository + +import ( + "context" + "fmt" + "log/slog" + + "github.com/Sosokker/todolist-backend/internal/cache" + "github.com/Sosokker/todolist-backend/internal/domain" + "github.com/google/uuid" +) + +type cachingTagRepository struct { + next TagRepository + cache cache.Cache + logger *slog.Logger +} + +func NewCachingTagRepository(next TagRepository, cache cache.Cache, logger *slog.Logger) TagRepository { + return &cachingTagRepository{ + next: next, + cache: cache, + logger: logger.With("repository", "tag_cache_decorator"), + } +} + +// --- Cache Key Generation --- +func tagCacheKey(userID, tagID uuid.UUID) string { + return fmt.Sprintf("user:%s:tag:%s", userID, tagID) +} + +// --- TagRepository Interface Implementation --- + +func (r *cachingTagRepository) Create(ctx context.Context, tag *domain.Tag) (*domain.Tag, error) { + createdTag, err := r.next.Create(ctx, tag) + // Invalidate list caches if/when implemented + return createdTag, err +} + +func (r *cachingTagRepository) GetByID(ctx context.Context, id, userID uuid.UUID) (*domain.Tag, error) { + cacheKey := tagCacheKey(userID, id) + if cached, found := r.cache.Get(ctx, cacheKey); found { + if tag, ok := cached.(*domain.Tag); ok { + r.logger.DebugContext(ctx, "GetTagByID cache hit", "key", cacheKey) + return tag, nil + } + // Invalid type in cache, treat as miss and delete + r.logger.WarnContext(ctx, "Invalid type found in tag cache", "key", cacheKey, "type", fmt.Sprintf("%T", cached)) + r.cache.Delete(ctx, cacheKey) + } + + r.logger.DebugContext(ctx, "GetTagByID cache miss", "key", cacheKey) + tag, err := r.next.GetByID(ctx, id, userID) + if err != nil { + return nil, err + } + + if tag != nil { + r.cache.Set(ctx, cacheKey, tag, 0) + r.logger.DebugContext(ctx, "Set tag in cache", "key", cacheKey) + } + + return tag, nil +} + +func (r *cachingTagRepository) Update(ctx context.Context, id, userID uuid.UUID, updateData *domain.Tag) (*domain.Tag, error) { + updatedTag, err := r.next.Update(ctx, id, userID, updateData) + if err != nil { + return nil, err + } + cacheKey := tagCacheKey(userID, id) + r.cache.Delete(ctx, cacheKey) + r.logger.DebugContext(ctx, "Invalidated tag cache after update", "key", cacheKey) + return updatedTag, nil +} + +func (r *cachingTagRepository) Delete(ctx context.Context, id, userID uuid.UUID) error { + err := r.next.Delete(ctx, id, userID) + if err != nil { + return err + } + cacheKey := tagCacheKey(userID, id) + r.cache.Delete(ctx, cacheKey) + r.logger.DebugContext(ctx, "Invalidated tag cache after delete", "key", cacheKey) + return nil +} + +// --- Pass-through methods --- +func (r *cachingTagRepository) GetByIDs(ctx context.Context, ids []uuid.UUID, userID uuid.UUID) ([]domain.Tag, error) { + return r.next.GetByIDs(ctx, ids, userID) +} + +func (r *cachingTagRepository) ListByUser(ctx context.Context, userID uuid.UUID) ([]domain.Tag, error) { + return r.next.ListByUser(ctx, userID) +}