From 1e00cc2efc040c42c363fcc6b8086f2f0c288087 Mon Sep 17 00:00:00 2001 From: GRACENOBLE Date: Mon, 15 Jun 2026 01:14:00 +0300 Subject: [PATCH] feat(backend): add Redis cache layer and wire into bootstrap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Define CacheService port in usecase/, implement with go-redis in infrastructure/cache/redis/, and bootstrap with optional REDIS_URL env var — probed with the same retry logic as Postgres, closed on graceful shutdown. --- backend/.env.example | 1 + backend/cmd/api/main.go | 5 ++ backend/go.mod | 2 + backend/go.sum | 10 +++ backend/internal/bootstrap/bootstrap.go | 28 ++++++-- .../infrastructure/cache/redis/cache.go | 64 +++++++++++++++++++ backend/internal/usecase/cache.go | 18 ++++++ 7 files changed, 123 insertions(+), 5 deletions(-) create mode 100644 backend/internal/infrastructure/cache/redis/cache.go create mode 100644 backend/internal/usecase/cache.go diff --git a/backend/.env.example b/backend/.env.example index 67c1fc8..68cadb8 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -7,3 +7,4 @@ BLUEPRINT_DB_USERNAME= BLUEPRINT_DB_PASSWORD= BLUEPRINT_DB_SCHEMA=public BLUEPRINT_DB_SSLMODE=disable +REDIS_URL= \ No newline at end of file diff --git a/backend/cmd/api/main.go b/backend/cmd/api/main.go index f5259d9..5c6257d 100644 --- a/backend/cmd/api/main.go +++ b/backend/cmd/api/main.go @@ -61,5 +61,10 @@ func main() { } <-done + if app.Cache != nil { + if err := app.Cache.Close(); err != nil { + slog.Error("cache close error", "error", err) + } + } slog.Info("graceful shutdown complete") } diff --git a/backend/go.mod b/backend/go.mod index 430209c..eee7f5a 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -8,6 +8,7 @@ require ( github.com/jackc/pgx/v5 v5.10.0 github.com/joho/godotenv v1.5.1 github.com/pressly/goose/v3 v3.27.1 + github.com/redis/go-redis/v9 v9.20.1 github.com/swaggo/files v1.0.1 github.com/swaggo/gin-swagger v1.6.1 github.com/swaggo/swag v1.16.6 @@ -100,6 +101,7 @@ require ( go.opentelemetry.io/otel v1.43.0 // indirect go.opentelemetry.io/otel/metric v1.43.0 // indirect go.opentelemetry.io/otel/trace v1.43.0 // indirect + go.uber.org/atomic v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect golang.org/x/arch v0.28.0 // indirect golang.org/x/crypto v0.53.0 // indirect diff --git a/backend/go.sum b/backend/go.sum index d4920dc..4a5b51c 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -12,6 +12,10 @@ github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tN github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M= github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE= +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM= github.com/bytedance/gopkg v0.1.4/go.mod h1:v1zWfPm21Fb+OsyXN2VAHdL6TBb2L88anLQgdyje6R4= github.com/bytedance/sonic v1.15.2 h1:90H+rcF/FwLXwfB1cudOLq/je83n683Utf4Cbp0xHCo= @@ -184,6 +188,8 @@ github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= github.com/quic-go/quic-go v0.60.0 h1:xcQioE8OM66UQLeUMHltK1CCcOu3JbVB4JAQdDQSB+0= github.com/quic-go/quic-go v0.60.0/go.mod h1:wpKpjmPpftl30sL6pFh7REVpjbcCVy4zt2vDyK1TuJk= +github.com/redis/go-redis/v9 v9.20.1 h1:sfCU6A8P3dXbKyWes02uxA2baehGux9dZHfEKtsTB1w= +github.com/redis/go-redis/v9 v9.20.1/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= @@ -230,6 +236,8 @@ github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2W github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= +github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= go.mongodb.org/mongo-driver/v2 v2.6.0 h1:b9sJOYrkmt4l8bY43ZenFBcPlhYIjaOfYHLtbB/5qi8= go.mongodb.org/mongo-driver/v2 v2.6.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= @@ -246,6 +254,8 @@ go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfC go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= diff --git a/backend/internal/bootstrap/bootstrap.go b/backend/internal/bootstrap/bootstrap.go index b976419..f4c606d 100644 --- a/backend/internal/bootstrap/bootstrap.go +++ b/backend/internal/bootstrap/bootstrap.go @@ -15,7 +15,9 @@ import ( _ "github.com/jackc/pgx/v5/stdlib" _ "github.com/joho/godotenv/autoload" + "backend/internal/infrastructure/cache/redis" "backend/internal/infrastructure/database/postgres" + "backend/internal/usecase" "backend/pkg/logger" ) @@ -31,15 +33,17 @@ const ( // Constructed once by Run and passed to the HTTP server. type App struct { DB *sql.DB + Cache usecase.CacheService // nil when REDIS_URL is not set Config Config Log *slog.Logger } // Config holds all validated configuration values read from environment variables. type Config struct { - Port int - Env string - DB postgres.DBConfig + Port int + Env string + DB postgres.DBConfig + RedisURL string } // ConfigError is returned when required configuration is absent or invalid. @@ -83,10 +87,23 @@ func Run(ctx context.Context) (*App, error) { return nil, err } + var cache usecase.CacheService + if cfg.RedisURL != "" { + c, err := redis.New(cfg.RedisURL) + if err != nil { + return nil, fmt.Errorf("bootstrap: redis: %w", err) + } + if err := probeWithRetry(probeCtx, "redis", c, log); err != nil { + return nil, err + } + cache = c + } + log.Info("bootstrap: all checks passed — ready to serve") return &App{ DB: db, + Cache: cache, Config: cfg, Log: log, }, nil @@ -109,8 +126,9 @@ func loadConfig() Config { } return Config{ - Port: port, - Env: os.Getenv("ENV"), + Port: port, + Env: os.Getenv("ENV"), + RedisURL: os.Getenv("REDIS_URL"), DB: postgres.DBConfig{ Host: os.Getenv("BLUEPRINT_DB_HOST"), Port: os.Getenv("BLUEPRINT_DB_PORT"), diff --git a/backend/internal/infrastructure/cache/redis/cache.go b/backend/internal/infrastructure/cache/redis/cache.go new file mode 100644 index 0000000..b6bd020 --- /dev/null +++ b/backend/internal/infrastructure/cache/redis/cache.go @@ -0,0 +1,64 @@ +package redis + +import ( + "context" + "errors" + "time" + + "github.com/redis/go-redis/v9" + + "backend/internal/usecase" +) + +type cacheService struct { + client *redis.Client +} + +// New creates a CacheService backed by Redis at the given URL. +// The caller is responsible for calling Close when done. +func New(redisURL string) (usecase.CacheService, error) { + opts, err := redis.ParseURL(redisURL) + if err != nil { + return nil, err + } + return &cacheService{client: redis.NewClient(opts)}, nil +} + +func (c *cacheService) PingContext(ctx context.Context) error { + return c.client.Ping(ctx).Err() +} + +func (c *cacheService) Get(ctx context.Context, key string) (string, bool, error) { + val, err := c.client.Get(ctx, key).Result() + if errors.Is(err, redis.Nil) { + return "", false, nil + } + if err != nil { + return "", false, err + } + return val, true, nil +} + +func (c *cacheService) Set(ctx context.Context, key string, value string, ttl time.Duration) error { + return c.client.Set(ctx, key, value, ttl).Err() +} + +func (c *cacheService) Delete(ctx context.Context, key string) error { + return c.client.Del(ctx, key).Err() +} + +func (c *cacheService) Exists(ctx context.Context, key string) (bool, error) { + n, err := c.client.Exists(ctx, key).Result() + if err != nil { + return false, err + } + return n > 0, nil +} + +func (c *cacheService) SetNX(ctx context.Context, key string, value string, ttl time.Duration) (bool, error) { + return c.client.SetNX(ctx, key, value, ttl).Result() +} + +func (c *cacheService) Close() error { + return c.client.Close() +} diff --git a/backend/internal/usecase/cache.go b/backend/internal/usecase/cache.go new file mode 100644 index 0000000..9f0285b --- /dev/null +++ b/backend/internal/usecase/cache.go @@ -0,0 +1,18 @@ +package usecase + +import ( + "context" + "time" +) + +// CacheService is the cache port that use cases depend on. +// Infrastructure implementations live in internal/infrastructure/cache/. +type CacheService interface { + PingContext(ctx context.Context) error + Get(ctx context.Context, key string) (string, bool, error) + Set(ctx context.Context, key string, value string, ttl time.Duration) error + Delete(ctx context.Context, key string) error + Exists(ctx context.Context, key string) (bool, error) + SetNX(ctx context.Context, key string, value string, ttl time.Duration) (bool, error) + Close() error +}