redis

package
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jun 7, 2026 License: MIT Imports: 37 Imported by: 0

Documentation

Overview

Package redis provides a Redis adapter for the GoCell framework.

It wraps github.com/redis/go-redis/v9 and offers:

  • Client: connection management for standalone, Sentinel, and Cluster modes with Health/Close
  • RedisDriver: implements runtime/distlock.Driver (SetNX / Renew / Release Lua primitives) so runtime/distlock.New can construct a Locker backed by Redis
  • IdempotencyClaimer: kernel/idempotency.Claimer implementation using two-phase Claim/Commit/Release with Lua scripts
  • Cache: typed Get/Set/Delete with TTL and JSON generics helpers

Configuration follows the Options pattern inspired by go-micro store/redis. Error codes use the ERR_ADAPTER_REDIS_* prefix via pkg/errcode for adapter- specific failures (connect / ping). Distributed-lock errors live in runtime/distlock (ERR_DISTLOCK_*) because the Locker contract is defined there — see runtime/distlock/errors.go.

Distributed Locking

Distributed locking is a runtime/adapter split:

  • runtime/distlock owns the lifecycle (acquire, renewal scheduling, release timeout, retry budget) via a single shared manager goroutine
  • RedisDriver implements three semantic primitives (SetNX / Renew / Release) using Redis SET NX EX + two Lua scripts (token-matched PEXPIRE / DEL)

Wiring (every constructor below is error-first and takes a KeyNamespace — the per-cell or per-role keyspace prefix; see the KeyNamespace godoc for naming conventions):

client, err := redis.NewClient(ctx, redis.Config{Addr: "localhost:6379", Password: "s3cret"})
if err != nil { return err }

driver, err := redis.NewRedisDriver(client, redis.KeyNamespace("accesscore"))
if err != nil { return err }
locker, err := distlock.New(driver, clock.Real(),
    distlock.WithRenewFraction(0.5),
    distlock.WithReleaseTimeout(5*time.Second),
)
if err != nil { return err }

lock, err := locker.Acquire(reqCtx, "key", 30*time.Second)
if err != nil { return err }
defer func() {
    // best-effort: efficiency lock self-expires at TTL if Release fails;
    // see distlock.Lock.Release godoc for correctness-critical guidance.
    _ = lock.Release()
}()
// pair work with lock-end:
//   select { case <-lock.Done(): abort with lock.Cause(); default: proceed }
// caller-ctx cancellation does NOT release the held lock; use Release() in defer.

The Cache, IdempotencyClaimer, and NonceStore constructors follow the same shape:

cache, err := redis.NewCache(client, redis.KeyNamespace("accesscore"))
claimer, err := redis.NewIdempotencyClaimer(client, redis.KeyNamespace("_runtime"))
store, err := redis.NewNonceStore(client, redis.KeyNamespace("servicetoken-nonce"), auth.ServiceTokenNonceTTL)

Composition root (cmd/corebundle) injects the cell ID for per-cell resources and the "_runtime" / "servicetoken-nonce" sentinels for shared infrastructure with no cell context.

Distributed Locking Safety

Best-effort mutual exclusion. Suitable for efficiency (avoiding duplicate work) but does NOT guarantee correctness in the face of lock expiry during GC pauses, network delays, or clock skew — consistent with redsync, rueidis, and all major Redis lock libraries.

For correctness-critical paths, use application-level conditional writes (e.g., Postgres optimistic locking with row versions). The lock reduces contention; the conditional write guarantees safety.

ref: Martin Kleppmann "How to do distributed locking" (2016) ref: go-redsync/redsync redis/redis.go — Driver primitive split ref: kubernetes/client-go tools/leaderelection/resourcelock — runtime/adapter layering

Cluster Mode (B10 PR-V1-REDIS-CLUSTER)

Cluster mode is selected via Config.Mode = ModeCluster and the cluster node addresses populated in Config.ClusterAddrs (plain "host:port" or "rediss://host:port" URL forms — mixing forms within a single cluster definition is rejected). Compared to standalone/sentinel:

  • Config.DB must be 0 (Redis Cluster has no SELECT command).
  • Config.Addr must be empty (mutual exclusion with ClusterAddrs).
  • Config.PoolSize is per-node; total cluster connections = nodes × PoolSize.
  • go-redis ClusterClient handles MOVED/ASK redirection and topology refresh transparently; no business-side retry is required for slot migration scenarios.

IdempotencyClaimer's dual-KEY Lua scripts (claim/commit) require all KEYS to map to the same Redis Cluster slot. Keys are wrapped in a hashtag `<ns>:{businessKey}:lease` / `<ns>:{businessKey}:done` (the KeyNamespace prefix sits OUTSIDE the hashtag) so CRC16 hashes only the business key portion; lease and done keys colocate on the same slot under every Cluster topology regardless of namespace value. Standalone/Sentinel use the same naming for a single source of truth — the hashtag is a no-op outside Cluster.

ref: redis/go-redis osscluster.go — ClusterOptions / ParseClusterURL ref: Redis cluster-spec hash-tags — {tag} sub-string colocation rule

Package redis — error classification helpers.

classifyRedisError routes a Redis command error to transient (retriable) or permanent classification. Transient conditions route through errcode.WrapInfra (KindUnavailable + CategoryInfra + private transient marker), which errcode.IsTransient recognizes. Permanent conditions route through errcode.Wrap(KindInternal, …).

ref: redis/go-redis error.go reply codes (CLUSTERDOWN / LOADING / TRYAGAIN / MASTERDOWN) ref: errcode.WrapInfra funnel + archtest ADAPTER-ERROR-CLASSIFICATION-TRANSIENT-01

Index

Constants

View Source
const (
	ErrAdapterRedisConnect errcode.Code = "ERR_ADAPTER_REDIS_CONNECT"
	ErrAdapterRedisSet     errcode.Code = "ERR_ADAPTER_REDIS_SET"
	ErrAdapterRedisGet     errcode.Code = "ERR_ADAPTER_REDIS_GET"
	ErrAdapterRedisDelete  errcode.Code = "ERR_ADAPTER_REDIS_DELETE"
)

Error codes for the Redis adapter.

View Source
const ProbeHTTPIdempotencyStoreReady healthz.ProbeName = "http_idempotency_store_ready"

ProbeHTTPIdempotencyStoreReady is the ops-contract readiness probe name for the HTTP idempotency replay store. healthz.ProbeName-typed, funneled by PROBENAME-SEALED-FUNNEL-01. Its failure domain is distinct from redis_ready (bare PING on the shared client): this probe exercises the EVAL + write command family the Claim/Record Lua scripts depend on, so a Redis ACL that permits PING but denies EVAL/SET surfaces at /readyz instead of at the first mutating request once idempotency is default-on (gh #1469).

View Source
const ProbeReady healthz.ProbeName = "redis_ready"

ProbeReady is the ops-contract name for the Redis readiness probe. healthz.ProbeName-typed, funneled by PROBENAME-SEALED-FUNNEL-01.

Variables

This section is empty.

Functions

func GetJSON

func GetJSON[T any](ctx context.Context, c *Cache, key string) (T, error)

GetJSON retrieves the value for the given key and JSON-decodes it into T. Returns the zero value and nil error when the key does not exist.

func SetJSON

func SetJSON[T any](ctx context.Context, c *Cache, key string, value T, ttl time.Duration) error

SetJSON JSON-encodes the value and stores it with the given TTL. A zero TTL means the key does not expire.

Types

type Cache

type Cache struct {
	// contains filtered or unexported fields
}

Cache provides a typed key-value cache backed by Redis. All keys are prefixed with the constructor-injected KeyNamespace, giving each owner (cell or shared role) an isolated keyspace.

func NewCache

func NewCache(client *Client, ns KeyNamespace) (*Cache, error)

NewCache creates a new Cache using the given Client and KeyNamespace. ns is validated up front; nil client and invalid namespace produce structured errors so misconfiguration fails-fast at composition time.

func (*Cache) Delete

func (c *Cache) Delete(ctx context.Context, key string) error

Delete removes the given key from the cache. Deleting a non-existent key is a no-op and returns nil.

func (*Cache) Get

func (c *Cache) Get(ctx context.Context, key string) (string, error)

Get retrieves the raw string value for the given key. Returns ("", nil) when the key does not exist.

func (*Cache) Set

func (c *Cache) Set(ctx context.Context, key string, value string, ttl time.Duration) error

Set stores a string value with the given TTL. A zero TTL means the key does not expire.

type CachingSessionStore

type CachingSessionStore struct {
	// contains filtered or unexported fields
}

CachingSessionStore is a read-through Redis cache decorator over a runtime/auth/session.Store. The wrapped (inner) store remains the system of record; the Redis cache only accelerates Get. Construction-time fail-fast ensures wiring misconfigurations surface at startup, not at the first request.

Behavior summary (T5/AUTH-CACHE-01 plan; #794 metrics; #796 after-commit DEL):

  • Get: cache hit → unmarshal sessionCacheEntry → entry.validate(id). On validation failure: slog.Warn + synchronous cache.Delete (best-effort, Delete errors logged at Warn) + fall through to inner.Get. Miss / Redis error / corrupt JSON → slog.Warn + synchronous cache.Delete (same best-effort contract) + fall through to inner.Get. On inner success only active (RevokedAt == nil) views are lazily populated into the cache (Set error is also swallowed). Every Get records exactly one hit or miss metric; every cache-access error additionally records an error metric.
  • Create: delegated to inner. No cache write — the first Get after Create primes the cache via the read-through path (avoids "created then revoked" edge thinking and removes a method's worth of code).
  • Revoke: delegates to inner, then registers a post-commit cache-DEL via persistence.RegisterAfterCommit (#796). The DEL fires AFTER the revoke commit is durable, so it cannot race with concurrent re-population from a still-uncommitted PG row (the 2×TTL race that the rejected in-transaction cache.Delete suffered). This drives the single-session stale-cache window to near-zero (the commit→hook gap) rather than the prior full-TTL floor. Contract: Revoke MUST be called within a RunInTx scope (sessionlogout wraps it — see §Threat model); calling it outside an active tx panics via RegisterAfterCommit (programmer error). The hook is a pure transient side effect (cache.Delete only) — archtest AFTERCOMMIT-HOOK-PURE-TRANSIENT-01 forbids it from touching the tx/outbox, and archtest CACHING-SESSION-REVOKE-AFTERCOMMIT-DEL-01 forbids any cache mutation in the Revoke body OUTSIDE the after-commit hook (relocating, not removing, the 2×TTL protection).
  • RevokeForSubject: delegated to inner with NO cache operation. The only caller is credentialinvalidate.Apply (archtest CREDENTIAL-INVALIDATE-FUNNEL-01) which co-tx bumps users.authz_epoch. Any stale cached ValidateView is rejected by sessionvalidate's epoch invariant (user.AuthzEpoch != view.AuthzEpochAtIssue → fail-closed 401). user.AuthzEpoch is intentionally NOT cached, so this path needs no after-commit DEL — the epoch bump is its near-zero invalidation already. Hard-locked delegate-only by archtest CACHING-SESSION-REVOKE-DELEGATE-ONLY-01. (Subject-wide cache purge for the future case where user state IS cached is the deferred AUTH-CACHE-SUBJECT-REVERSE-INDEX-01 / gh #793.)
  • RepoReady: delegated to inner. Redis liveness is independently surfaced by adapters/redis Client.Checkers (probe redis_ready).

All cache.{Get,Set,Delete} read-path errors are fail-safe: the wrapper logs at Warn (and records a session_cache_errors_total metric) and falls through to / continues with the inner result. Only inner errors propagate to callers, preserving sessionvalidate's KindUnavailable → 503 semantics for genuine session-store outages.

Threat model

  • Stale-cache revoke window (single-session sessionlogout): Revoke now registers an after-commit cache.Delete hook (#796), so the cached entry is purged immediately after the revoke commit becomes durable. The residual window is the commit→hook gap (near-zero — the hook runs synchronously on the committing goroutine before RunInTx returns), NOT a full TTL. The earlier in-transaction cache.Delete was rejected (PR #524 → fix PR) because it raced with concurrent re-population from the still-uncommitted PG row (a Get arriving between inner Revoke and commit would lazyPopulate a fresh full-TTL entry), potentially extending the window to 2×TTL; firing the DEL after commit removes that race because the row is already revoked when the hook runs, so any concurrent lazyPopulate reads the revoked state and skips the write (revoked views are never cached). The TTL remains a fail-safe backstop: if the after-commit DEL itself fails (best-effort, logged at Warn + counted as session_cache_revoke_del_errors_total) the entry still expires at TTL — a stale ≤ TTL window that is the accepted degraded behavior on this path (single-session Revoke does not bump the epoch, so TTL is the only floor). RevokeForSubject paths (credentialinvalidate.Apply) have an independent, equally-near-zero floor — the co-tx user.AuthzEpoch bump fails the cached AuthzEpochAtIssue check in sessionvalidate.go regardless of cache state.
  • Redis keyspace enumeration: cache keys take the form accesscore:session:<rawSessionID>; anyone with redis-cli KEYS / SCAN access can enumerate active session identifiers. Operators MUST gate Redis with ACL so only ops accounts can enumerate the keyspace; the cache itself does not hash the session ID (consistent with PG which also stores raw sessions.id).
  • JSON wire schema: a dedicated sessionCacheEntry struct (not the full session.ValidateView) is the on-wire shape. Adding a sensitive field to ValidateView does NOT automatically propagate into Redis — the copy is explicit, providing an audit gate. The flip side of that gate (alexedwards/scs store contract: a cache projection must stay equivalent to the source-of-record for every field the validate path consumes): an authentication-DECISION field added to ValidateView MUST also land in sessionCacheEntry, or a cache HIT silently degrades the decision. TenantID (#1337 PR-3b: the RLS scope carrier) is such a field and is cached.

Ops guidance

Single-session logout invalidates the cache near-instantly via the after-commit DEL (#796) — the residual stale window is only the commit→hook gap (effectively zero; the hook runs synchronously on the committing goroutine before RunInTx returns). Any concurrent Get that arrives between inner.Revoke and commit cannot re-populate the cache (the row is still uncommitted) and will fall through to inner; a Get after commit reads the revoked row and skips lazyPopulate.

DEL-failure backstop is TTL, not epoch. Should the after-commit DEL itself fail (best-effort, logged at Warn + counted as session_cache_revoke_del_errors_total), the logged-out session's cached active ValidateView can still satisfy sessionvalidate (RevokedAt == nil, epoch unchanged) and be served on a cache HIT until the entry expires at TTL. Single-session Revoke does NOT bump users.authz_epoch, so the epoch-based fail-closed net does NOT apply on this path — that net is exclusive to RevokeForSubject / credential invalidation (which bumps the epoch co-tx, see §Threat model). The bounded residual (stale ≤ TTL, capped by GOCELL_SESSION_CACHE_TTL) is the accepted degraded behavior of #796's TTL backstop; operators alert on session_cache_revoke_del_errors_total to detect sustained DEL failures (docs/ops/alerting-rules.md). For zero-tolerance stale cache requirements (e.g. an ongoing breach investigation where every session must be invalidated atomically), disable the cache by leaving GOCELL_SESSION_CACHE_TTL empty — this removes the cache entirely from the trust path.

ref: alexedwards/scs redisstore/redisstore.go@master (PEXPIREAT object-level expiry alignment — we use fixed Duration TTL because ValidateView hides ExpiresAt by design, see runtime/auth/session.Session.ExpiresAt godoc). ref: go-redis/cache cache.go@v9 (fail-open model). ref: spring-tx TransactionSynchronization.afterCommit (post-commit cache eviction); kernel/persistence.RegisterAfterCommit is the GoCell analog.

func NewCachingSessionStore

func NewCachingSessionStore(
	inner session.Store, cache *Cache, ttl time.Duration, logger *slog.Logger, recorder cacheMetricsRecorder,
) (*CachingSessionStore, error)

NewCachingSessionStore constructs a CachingSessionStore. The three core dependencies are mandatory; nil inner / nil cache / non-positive ttl fail fast with errcode.ErrValidationFailed. Wiring layer is responsible for the enable/disable decision (env GOCELL_SESSION_CACHE_TTL = "" → do not call this constructor; ttl ≤ 0 → also do not call it). logger may be nil; the default slog logger is used in that case. recorder is the optional hit/miss/error metrics sink (#794) — nil falls back to a no-op so metrics stay an optional observability dependency.

func (*CachingSessionStore) Create

Create delegates to inner. No cache write — see godoc rationale.

func (*CachingSessionStore) Get

Get is the read-through hot path. Cache hit on a well-formed JSON entry returns immediately; any error in the cache path is logged at Warn and fallthrough occurs. On inner success the returned view is lazily populated into the cache for the next request.

Both fall-through paths — corrupt JSON (unmarshal-fail) and invalid entry (validate-fail) — synchronously delete the bad cache key before falling through. This prevents repeated hits on a known-bad entry within the same TTL window. Delete errors are logged at Warn and ignored (best-effort).

func (*CachingSessionStore) RepoReady

func (s *CachingSessionStore) RepoReady(ctx context.Context) error

RepoReady delegates to inner. Redis liveness is reported independently by the adapter-level redis_ready probe; a cached store does not need its own probe — cache outage is fail-safe (falls through to inner).

func (*CachingSessionStore) Revoke

func (s *CachingSessionStore) Revoke(ctx context.Context, id string) error

Revoke delegates to inner, then schedules a post-commit cache eviction so the stale-cache window after a single-session logout is near-zero (#796).

The cache.Delete is registered via persistence.RegisterAfterCommit, so it fires AFTER the enclosing transaction's commit is durable — never inside it. This is the crux of the fix: the previously-rejected in-transaction cache.Delete raced with concurrent re-population from the still-uncommitted PG row (a Get between inner Revoke and commit would lazyPopulate a fresh full-TTL entry, extending the window to 2×TTL). Firing after commit removes the race — the row is already revoked when the hook runs, so any racing lazyPopulate reads the revoked state and skips the write (revoked views are never cached). The DEL is best-effort: a failure is logged at Warn and the entry then expires at TTL (the TTL is the fail-safe backstop). DEL is wrapped in ctxutil.WithDetachedTimeout(2s) so a slow Redis cannot block the committing goroutine / RunInTx return.

Contract (decorator-specific LSP narrowing): Revoke MUST be invoked within a RunInTx scope (sessionlogout's persistRevoke wraps it). This is the canonical narrowing the general session.Store.Revoke contract (runtime/auth/session.Store) now documents as permitted for decorators — the bare PG / mem store has no tx-only precondition. RegisterAfterCommit panics if no ambient after-commit registry is present: calling Revoke outside a transaction is a programmer error, surfaced loudly rather than silently dropping the eviction. The shared storetest conformance suite, which calls Revoke bare, supplies the unit-of-work scope via the test-only txScopedRevokeStore bridge (session_cache_store_conformance_test.go). Compile-enforcing this precondition (a typed tx-scoped revoke capability rather than a runtime panic) was evaluated as gh #1615's F3 and resolved won't-do: a same-package seal is unreachable because the read path's evictBadEntry / lazyPopulate legitimately need a synchronous s.cache.Delete / s.cache.Set, so the cache-mutation capability must live on a struct field that every method (Revoke included) can reach — Go has no per-method field scoping. A cross-package internal-subpackage seal could reach Hard but is disproportionate for this single-caller P3 and has zero industry precedent (Spring TransactionSynchronization, Hibernate AfterTransactionCompletionProcess, ent CommitHook, Watermill forwarder all rely on runtime registration + convention, never a type seal). The "second production caller" risk is instead caught statically by archtest SESSION-REVOKE-CALLER-INTX-01, which allowlists the production callers of session.Store.Revoke (each verified RunInTx-wrapped).

archtest CACHING-SESSION-REVOKE-AFTERCOMMIT-DEL-01 locks the shape: the body must delegate to s.inner.Revoke, and any s.cache.Delete/Set must be lexically inside the RegisterAfterCommit hook literal (never in the tx body — that is the relocated 2×TTL protection). archtest AFTERCOMMIT-HOOK-PURE-TRANSIENT-01 independently forbids the hook from touching the tx or an outbox.Writer.

func (*CachingSessionStore) RevokeForSubject

func (s *CachingSessionStore) RevokeForSubject(
	ctx context.Context, subjectID string, event session.CredentialEvent, tok credentialfence.FenceToken,
) error

RevokeForSubject delegates to inner. Cache invalidation is omitted by design — the cached ValidateView's AuthzEpochAtIssue is compared by sessionvalidate.go against the live user.AuthzEpoch (bumped co-tx by credentialinvalidate.Apply); mismatch → 401 fail-closed regardless of cache state. user.AuthzEpoch is intentionally NOT cached.

Hard-locked by archtest CACHING-SESSION-REVOKE-DELEGATE-ONLY-01: this method body MUST be exactly one ReturnStmt delegating to inner with the same name.

type Client

type Client struct {
	// contains filtered or unexported fields
}

Client wraps a go-redis universal client and provides health checking and lifecycle management.

func NewClient

func NewClient(ctx context.Context, cfg Config) (*Client, error)

NewClient creates a new Redis Client with the given configuration. It pings the server to verify connectivity on creation.

func NewClientForTest

func NewClientForTest(rdb goredis.UniversalClient) *Client

NewClientForTest wraps a pre-constructed go-redis universal client in a *Client WITHOUT the construction-time Ping that NewClient performs. It is a test-support seam (AUTH-CACHE-HAPPY-PATH-WIRING-TEST-01 / #795): the composition-root session-cache wiring (cellmodules/accesscore) asserts the happy path by injecting a lazily-connected client that never dials — the wrap helper issues no Redis command, so no live server is required. Production code MUST use NewClient (which validates config + verifies connectivity).

func (*Client) Close

func (c *Client) Close(ctx context.Context) error

Close releases the underlying Redis connection, bounded by ctx.

go-redis Client.Close() is synchronous and may block on in-flight commands. Close wraps it in a goroutine so the caller's shutdown budget is honored; if ctx expires, in-flight commands may be abandoned (process-exit semantics).

ref: uber-go/fx app.go StopTimeout — ctx as shared shutdown budget. ref: uber-go/fx lifecycle OnStop(ctx) — ContextCloser pattern.

func (*Client) Config

func (c *Client) Config() Config

Config returns a copy of the client configuration. The returned Config is safe to pass to NewClient for round-trip use. For logging, Config implements slog.LogValuer which redacts the password.

Slice fields (SentinelAddrs, ClusterAddrs) are deep-copied so callers cannot mutate the live configuration through the returned value.

func (*Client) Health

func (c *Client) Health(ctx context.Context) error

Health pings the Redis server and returns an error if it is unreachable.

func (*Client) PoolStats

func (c *Client) PoolStats() PoolStats

PoolStats returns structured pool statistics suitable for metrics collection. Returns zero-value PoolStats for test mocks (no statsProvider).

func (*Client) Probes

func (c *Client) Probes() []healthz.Probe

Probes returns the typed readyz probe for the Redis client. The single entry ProbeReady (= "redis_ready") wraps Health with adapterutil.DefaultProbeTimeout so a slow ping does not hold the readyz response indefinitely.

ref: kubernetes/kubernetes pkg/util/healthz — named health checkers.

func (*Client) Statter

func (c *Client) Statter(name string) poolstats.Statter

Statter returns a poolstats.Statter bound to this Client with the supplied human-readable name (e.g. "redis-session-cache"). Used by the OTel pool collector to emit db.client.connection.* metrics without adapter-specific switching.

Cluster mode aggregation: *ClusterClient.PoolStats() returns counters summed across every cluster node, while Config.PoolSize is the *per-node* connection cap. To keep db.client.connection.{count,max} on the same scale (otherwise UsedConns can exceed MaxConns at any non-trivial pool utilization, which dashboards interpret as saturation), MaxConns is scaled by the configured seed count: MaxConns = PoolSize × len(ClusterAddrs). Seed count is a lower bound — a real cluster usually has more nodes (replicas plus rebalanced shards), so the reported max underestimates the true cap; for an exact figure consult Redis CLUSTER NODES out-of-band. Aggregation is the right comparison surface for OTel because UsedConns is itself aggregated; mismatched scopes were the bug, not the magnitude.

ref: redis/go-redis internal/pool/pool.go — PoolStats exposes TotalConns/IdleConns/StaleConns/Timeouts/etc. UsedConns is derived as TotalConns - IdleConns - StaleConns (stale connections are scheduled for removal but still counted in TotalConns until the background reaper prunes them).

func (*Client) Worker

func (c *Client) Worker() worker.Worker

Worker returns nil — the Redis client has no background goroutine. Bootstrap skips WithWorkers registration when nil is returned.

type Config

type Config struct {
	// Addr is the address of the standalone Redis instance (e.g. "localhost:6379").
	Addr string

	// SentinelAddrs is the list of Sentinel addresses for Sentinel mode.
	SentinelAddrs []string

	// SentinelMaster is the name of the master instance for Sentinel mode.
	SentinelMaster string

	// ClusterAddrs is the list of Redis Cluster node addresses for Cluster mode.
	// Plain "host:port" or "rediss://host:port" URL forms are accepted; they
	// must not be mixed within a single cluster definition. DB must be 0 in
	// Cluster mode (no SELECT command).
	ClusterAddrs []string

	// Mode selects standalone, sentinel, or cluster. Defaults to ModeStandalone.
	Mode Mode

	// Password is the auth password, if any.
	Password string

	// DB is the database number. Defaults to 0.
	DB int

	// DialTimeout is the connection dial timeout. Defaults to 5s.
	DialTimeout time.Duration

	// ReadTimeout is the read timeout. Defaults to 3s.
	ReadTimeout time.Duration

	// WriteTimeout is the write timeout. Defaults to ReadTimeout.
	WriteTimeout time.Duration

	// DistLockTTL is the default TTL for distributed locks. Defaults to 30s.
	DistLockTTL time.Duration

	// PoolSize is the maximum number of connections go-redis is allowed to
	// maintain. Zero applies a per-mode default mirroring go-redis: 10×GOMAXPROCS
	// for standalone/sentinel and 5×GOMAXPROCS for cluster (where the same
	// PoolSize applies *per node* — total cluster connections = nodes × PoolSize,
	// so the per-node default is halved to keep aggregate sizing comparable).
	// Set this explicitly for workloads whose steady-state checkouts would exceed
	// the library default — required for meaningful db.client.connection.max
	// emissions on the pool stats collector.
	PoolSize int

	// AllowUnsafeNoPassword is the explicit dev/test escape hatch for an
	// empty Password. The default (false) makes validateConfig reject any
	// configuration that would silently connect to an unauthenticated Redis,
	// closing B2-A-28's fail-open hole. Composition root sets this to true
	// only when bootstrap.Topology.RequireProductionControlPlane() is false
	// (i.e. dev / single-pod / non-production deployments).
	AllowUnsafeNoPassword bool
}

Config holds connection and behavioral settings for the Redis adapter.

func (Config) LogValue

func (c Config) LogValue() slog.Value

LogValue implements slog.LogValuer so that Config can be safely passed to structured loggers without leaking the password.

Cluster ClusterAddrs may carry rediss://user:pass@host URL form; pass each addr through url.Parse → URL.Redacted so the password segment is replaced with "xxxxx" before reaching slog. Plain host:port entries are returned verbatim. Parse failures return a sentinel "<unparseable>" rather than the raw string so a malformed URL never bypasses redaction.

ref: net/url URL.Redacted (https://pkg.go.dev/net/url#URL.Redacted)

type HTTPIdempotencyStore

type HTTPIdempotencyStore struct {
	// contains filtered or unexported fields
}

HTTPIdempotencyStore implements idemhttp.Store using a three-key Lua script model that stores the full HTTP response blob for replay plus the request fingerprint for key-reuse detection.

Consistency: L1 (LocalTx) — each Lua script executes atomically within Redis.

  • <store-ns>:<req-ns>:{key}:lease — SET NX with leaseTTL, value = random token. Indicates "processing".
  • <store-ns>:<req-ns>:{key}:resp — SET with doneTTL, value = MarshalRecordedResponse blob. Indicates "completed".
  • <store-ns>:<req-ns>:{key}:fp — SET with leaseTTL while processing; Record extends it to doneTTL. Value = body fingerprint; flags same-key/different-body reuse.

The key prefix has two segments, both OUTSIDE the hashtag:

  • <store-ns> = the construction-time KeyNamespace (s.ns), the owner dimension — e.g. "_runtime" for the shared corebundle store — following the same owner-prefix convention as the other adapters/redis primitives (see KeyNamespace doc).
  • <req-ns> = the request-time ns passed to Claim (caller TenantID or the "_notenant" sentinel), the tenant sub-partition.

Cluster: keys are wrapped in a Redis Cluster hashtag so CRC16 hashes only the business-key portion; the lease, resp, and fp keys all colocate on the same slot, keeping multi-KEY EVAL safe under Cluster mode. Both prefix segments sit outside the hashtag, so slot colocality is preserved regardless of either namespace value.

Claim compares fp only while resp or lease is active. With resp present it returns ClaimDone+replay after validating fp; with lease present it returns ClaimBusy after validating fp; when neither exists it acquires lease and stores fp for the leaseTTL. Record sets resp + extends fp to doneTTL + deletes lease. Release deletes lease + fp (token-guarded); there is no response to protect.

func NewHTTPIdempotencyStore

func NewHTTPIdempotencyStore(client *Client, ns KeyNamespace) (*HTTPIdempotencyStore, error)

NewHTTPIdempotencyStore creates an HTTPIdempotencyStore using the given Client and KeyNamespace. ns is validated up front; nil client and invalid namespace produce structured errors so misconfiguration fails-fast at composition time.

The construction-time KeyNamespace (REDIS-KEY-NAMESPACE-01) must be a valid non-empty, lowercase, brace-free string ≤48 chars. It is the OWNER segment of every runtime Redis key: keys are derived as <store-ns>:<request-ns>:{<key>}:<role>, where <store-ns> = this namespace (owner dimension, e.g. "_runtime" for the shared corebundle store) and <request-ns> = the ns passed to Claim (caller TenantID or "_notenant" sentinel when using the standard Middleware).

func (*HTTPIdempotencyStore) Claim

Claim implements idemhttp.Store. It attempts to acquire a processing lease for the given sealed key k.

The Redis keys are derived as:

<store-ns>:<ns>:{<key>}:lease  and  …:resp  and  …:fp

where <store-ns> is the construction-time KeyNamespace (s.ns, owner dimension), <ns> = k.Namespace() (the caller TenantID, or "_notenant" when absent), and <key> = k.Key() (the composed idempotency key). Both prefix segments sit outside the hashtag so Redis Cluster CRC16 only hashes {<key>}.

Both k.Namespace() and k.Key() must be non-empty and free of '{'/'}' so the Redis Cluster hashtag boundary is unambiguous — a Redis-Cluster-specific constraint validated here, deliberately NOT folded into the store-agnostic DeriveKey (MemStore has no such constraint).

fingerprint is hex(sha256(body)). If a previous Claim stored a different fingerprint for the same key, ErrFingerprintMismatch is returned.

func (*HTTPIdempotencyStore) ReadyCheck

func (s *HTTPIdempotencyStore) ReadyCheck(ctx context.Context) error

ReadyCheck is the readiness-probe body wired via bootstrap.WithHealthChecker under ProbeHTTPIdempotencyStoreReady. It runs a minimal Lua EVAL that writes a short-lived probe key, proving both EVAL capability and write permission in the store's namespace. Returns nil when the store can serve Claim/Record; returns a structured error (→ /readyz degraded) when EVAL or SET is denied or Redis is unreachable.

type IdempotencyClaimer

type IdempotencyClaimer struct {
	// contains filtered or unexported fields
}

IdempotencyClaimer implements idempotency.Claimer using a dual-key Lua script model:

Consistency: L1 (LocalTx) — each Lua script executes atomically within Redis.

  • <ns>:{key}:lease — SET NX with leaseTTL, value = random token. Indicates "processing".
  • <ns>:{key}:done — SET with doneTTL, value = "1". Indicates "completed".

Cluster: the business key is wrapped in a Redis Cluster hashtag so CRC16 hashes only the key portion; lease and done keys colocate on the same slot, keeping multi-KEY EVAL safe under Cluster mode (B10 PR-V1-REDIS-CLUSTER). The KeyNamespace prefix sits outside the hashtag so slot colocality is preserved regardless of namespace value.

Claim checks done first (ClaimDone), then attempts lease (ClaimAcquired or ClaimBusy). Commit sets done + deletes lease. Release deletes lease (token-guarded).

func NewIdempotencyClaimer

func NewIdempotencyClaimer(client *Client, ns KeyNamespace) (*IdempotencyClaimer, error)

NewIdempotencyClaimer creates an IdempotencyClaimer using the given Client and KeyNamespace. ns is validated up front; nil client and invalid namespace produce structured errors so misconfiguration fails-fast at composition time.

func (*IdempotencyClaimer) Claim

func (c *IdempotencyClaimer) Claim(
	ctx context.Context, key string, leaseTTL, doneTTL time.Duration,
) (idempotency.ClaimState, idempotency.Receipt, error)

func (*IdempotencyClaimer) Kind

Claim attempts to acquire a processing lease for the given key.

Cluster safety: the key is wrapped in a Redis Cluster hashtag (see below). Empty keys produce an empty hashtag `{}` which Redis treats as no hashtag at all — that path silently disables slot colocation and reintroduces CROSSSLOT failures. Keys containing `{` or `}` likewise destabilize the hashtag boundary. Both inputs are rejected at the entry rather than left to surface as obscure runtime errors. Kind reports the distributed classification: this claimer coordinates idempotency across replicas via shared Redis state, so it is safe for multi-pod deployments.

type KeyNamespace

type KeyNamespace string

KeyNamespace scopes every Redis key produced by the four primitives in this adapter (Cache, IdempotencyClaimer, NonceStore, RedisDriver) under a constructor-injected prefix.

Convention:

  • Per-cell resources (e.g. a cache owned by accesscore) use the cell ID as the namespace, e.g. KeyNamespace("accesscore").

  • Shared infrastructure constructed at composition root (cmd/corebundle) uses an explicit role label or the "_runtime" sentinel:

    newRedisNonceStore -> KeyNamespace("servicetoken-nonce") newRedisIdempotencyClaimer -> KeyNamespace("_runtime")

Validation forbids characters that would break Redis Cluster hashtag boundaries ('{', '}'), the prefix/key separator (':'), and other ambiguous punctuation. The first character may be a lowercase letter or underscore (the underscore lets sentinel namespaces like "_runtime" be visually distinct from cell IDs, which are constrained to lowercase letters by the no-dash policy on cell.yaml IDs).

ref: launchdarkly/go-server-sdk-redis-go-redis Builder.Prefix (constructor-injected prefix pattern) ref: dapr/components-contrib state appID prefix (constructor-injected tenancy) ref: cockroachdb/cockroach pkg/keys MakeTenantPrefix (lint-guarded tenant boundary)

func (KeyNamespace) Validate

func (n KeyNamespace) Validate() error

Validate reports whether n is a legal namespace. Errors are descriptive const-literal messages so MESSAGE-CONST-LITERAL-01 stays satisfied.

Validation order:

  1. Empty / over-length checks first — these have actionable single-cause diagnostics that the regex would compress into a generic "shape" error.
  2. Targeted character checks for the three security-sensitive forbidden characters (`:`, `{`/`}`, uppercase) — same single-cause diagnostics; these characters would also fail the regex below, but a precise error message helps callers fix misconfigurations quickly.
  3. Regex catch-all — authoritative full-pattern enforcer; rejects leading digits/dashes, dots, slashes, whitespace, and any other non-`[a-z0-9_-]` character.

Both the targeted checks and the regex are intentional: the targeted checks improve diagnostics, the regex is the single-source of the allowed shape. Removing either weakens the contract.

KindInvalid (not KindInternal) — this is a config-time programmer error from a constructor caller, not an infrastructure failure.

type Mode

type Mode string

Mode represents the Redis deployment topology.

const (
	// ModeStandalone connects to a single Redis instance.
	ModeStandalone Mode = "standalone"
	// ModeSentinel connects via Redis Sentinel for high availability.
	ModeSentinel Mode = "sentinel"
	// ModeCluster connects to a Redis Cluster (sharded keyspace).
	// AWS ElastiCache Cluster, Azure Cache Cluster and self-hosted Redis
	// Cluster deployments use this mode. DB selection is unavailable on
	// Cluster (no SELECT command); multi-KEY operations require all keys
	// to share a hashtag (B10 PR-V1-REDIS-CLUSTER).
	ModeCluster Mode = "cluster"
)

type NonceStore

type NonceStore struct {
	// contains filtered or unexported fields
}

NonceStore implements kauth.NonceStore using Redis SET NX EX semantics. It is safe for multi-pod service-token replay protection because every first-use claim is coordinated by Redis.

Keys land under the constructor-injected KeyNamespace, e.g. `servicetoken-nonce:<nonce>`. The namespace replaces the previous hard-coded `servicetoken:nonce:` prefix; layering namespace + role constant added no information beyond what KeyNamespace itself encodes.

func NewNonceStore

func NewNonceStore(client *Client, ns KeyNamespace, ttl time.Duration) (*NonceStore, error)

NewNonceStore creates a Redis-backed service-token nonce store.

The body validates `ns` before the `client == nil` check so the archtest REDIS-KEY-NAMESPACE-01 gate (which requires `ns.Validate()` near the top of every public Redis constructor) sees the call within its statement budget. The internal helper newNonceStoreFromCmdable re-validates as defense-in-depth: integration tests bypass the public constructor by calling the helper directly, so the helper has to police its own input.

func (*NonceStore) CheckAndMark

func (s *NonceStore) CheckAndMark(ctx context.Context, nonce string) error

CheckAndMark records nonce if it has not been seen within the TTL window. Replays return auth.ErrNonceReused so service-token middleware can map the condition to ERR_AUTH_REPLAY_DETECTED.

func (*NonceStore) Kind

Kind reports that this store is distributed and safe for multi-pod replay protection when Redis itself is available.

type PoolStats

type PoolStats struct {
	Hits       uint32 `json:"hits"`       // times free connection was found in pool
	Misses     uint32 `json:"misses"`     // times free connection was NOT found in pool
	Timeouts   uint32 `json:"timeouts"`   // times a wait timeout occurred
	TotalConns uint32 `json:"totalConns"` // total connections in pool
	IdleConns  uint32 `json:"idleConns"`  // idle connections in pool
	StaleConns uint32 `json:"staleConns"` // stale connections removed from pool
}

PoolStats holds structured connection pool statistics.

ref: go-redis PoolStats / redisprometheus — adopted same field set.

type RedisDriver

type RedisDriver struct {
	// contains filtered or unexported fields
}

RedisDriver implements runtime/distlock.Driver using Redis SET NX EX and two Lua scripts for atomic renew and release operations. All lock keys are prefixed with the constructor-injected KeyNamespace so per-cell or per-role driver instances cannot collide.

func NewRedisDriver

func NewRedisDriver(client *Client, ns KeyNamespace) (*RedisDriver, error)

NewRedisDriver creates a RedisDriver backed by the given Client and KeyNamespace. ns is validated up front; nil client and invalid namespace produce structured errors so misconfiguration fails-fast at composition time.

func (*RedisDriver) Release

func (d *RedisDriver) Release(ctx context.Context, key, token string) error

Release deletes the lock key only if token still matches. Returns nil on success or when the key is already gone (idempotent). Returns a non-nil error only on I/O failure.

func (*RedisDriver) Renew

func (d *RedisDriver) Renew(ctx context.Context, key, token string, ttl time.Duration) (bool, error)

Renew extends the TTL of an existing lock only if token still matches. Returns (true, nil) on success, (false, nil) when the token no longer matches (ownership lost — not an I/O error), and (false, err) on I/O failure.

func (*RedisDriver) SetNX

func (d *RedisDriver) SetNX(ctx context.Context, key, token string, ttl time.Duration) (bool, error)

SetNX attempts to set key=token with the given TTL using Redis SET NX EX. Returns (true, nil) on success, (false, nil) when the key is already held, and (false, err) on I/O failure.

type RedisReconcileElector

type RedisReconcileElector struct {
	// contains filtered or unexported fields
}

RedisReconcileElector implements reconcile.LeaderElector using Redis SET NX PX for the holder lease and an INCR'd epoch key for the monotonic fencing token. Release reuses the package's ownership-guarded releaseLockScript (the epoch key is never deleted on release so a subsequent holder always observes a strictly higher epoch); acquire/renew use the reconcile-specific scripts above.

Both keys are KeyNamespace-prefixed AND share a {reconcilerID} hash tag so they colocate on one Redis Cluster slot: the holder key is "<ns>:{<rid>}:lease" and the epoch key is "<ns>:{<rid>}:epoch".

func NewRedisReconcileElector

func NewRedisReconcileElector(
	client *Client, ns KeyNamespace, leaseDuration time.Duration, clk clock.Clock,
) (*RedisReconcileElector, error)

NewRedisReconcileElector builds a leader elector. The holderID (this replica's identity) is minted INTERNALLY as a fresh UUID, so accidental cross-process holderID reuse (treated as the same holder, defeating mutual exclusion — PR-A6 review C4) is impossible by construction. leaseDuration is the lease TTL; the reconcile Loop derives its renew cadence as TTL/3 unless overridden. clk is the injected clock stamping the token window (Redis has no server-side wall clock to return). ns / client / leaseDuration / clk are validated up front.

func (*RedisReconcileElector) AcquireLease

func (e *RedisReconcileElector) AcquireLease(ctx context.Context, reconcilerID string) (reconcile.LeaseToken, error)

AcquireLease implements reconcile.LeaderElector.

func (*RedisReconcileElector) ReleaseLease

func (e *RedisReconcileElector) ReleaseLease(ctx context.Context, token reconcile.LeaseToken) error

ReleaseLease implements reconcile.LeaderElector (ownership-guarded DEL of the holder key; the epoch key persists so the next holder sees a higher epoch). Idempotent: releasing a lease already lost is not an error.

func (*RedisReconcileElector) RenewLease

func (e *RedisReconcileElector) RenewLease(ctx context.Context, token reconcile.LeaseToken) error

RenewLease implements reconcile.LeaderElector (ownership-guarded TTL extend for BOTH the holder key and the epoch key, epoch value unchanged). Refreshing the epoch-key TTL here is what keeps the monotonic counter alive under a long-held leader. Returns ErrReconcileLeaseLost when no longer the holder.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL