hush/vendor/github.com/redis/go-redis/v9/csc_integration.go
jx12n 4d9a26498e hush: one-time secret links the server cannot read
Paste a secret, get a link, send it. The first person to open it and press
Reveal sees the secret; the link dies at that moment. The recipient needs a
browser and nothing else — no account, no client, no installed tooling.

The server cannot read what it stores. AES-256-GCM happens in the browser and
the key lives in the URL fragment, which browsers never transmit, so hushd
holds ciphertext and no key material. That is a property of where the key sits
rather than a promise about our conduct, which is why there is deliberately no
endpoint accepting a plaintext secret and no server-side-encryption fallback:
two guarantees behind one URL would be worse than one honest guarantee.

Three decisions carry the design:

  * GET /s/{id} touches NO storage, not even to check existence. Slack, Teams,
    WhatsApp, iMessage and Outlook Safe Links all fetch a URL before a human
    sees it, so destroying on GET would destroy most secrets in transit and the
    recipient's "already used" would be indistinguishable from interception.
    Only POST /reveal consumes. Bot user-agent detection is an arms race;
    removing the side effect from GET is not. Pinned by
    TestGettingTheRevealPageNeverConsumesTheSecret.
  * Destruction is one Redis GETDEL, which is atomic. GET-then-DEL has a window
    where two simultaneous readers both win, and for a one-time secret that
    window is the product. The store contract demands atomicity and the same
    concurrency test runs against both implementations.
  * Missing, already-revealed, expired and evicted are ONE indistinguishable
    410. Separating them would confirm to a prober that a given link was real.

The secret id IS the capability, so secret.ID is a struct whose every
accidental path — %v, %s, String(), slog, json.Marshal — emits a redacted
handle or refuses, and the raw value needs an explicit Value(). The first
version tried to prevent leaks by implementing no String() at all; its own test
caught that Go's fmt prints unexported fields anyway, so forbidding the method
had removed the control rather than the leak.

Operationally: structured JSON on stdout in the fleet's wire format, which
Vector already collects with no annotation; six hush_* metrics on the chassis
registry with no id, IP or path in any label; five alert rules wired into
vmalert. The public Ingress enumerates /, /s/ and /api/ so /metrics, /healthz
and /readyz share the port but are unreachable from the internet — no
basic-auth middleware to maintain and get wrong.

Dependencies are vendored because go-chassis is private: the Woodpecker test
step and the in-cluster Kaniko build both run -mod=vendor with GOPROXY=off and
hold no git credential.

cmd/hush-mcp is a stdio MCP server doing the same client-side crypto locally,
so using hush from an agent preserves the same guarantee as using it from a
browser.
2026-09-03 00:08:38 -06:00

923 lines
32 KiB
Go

package redis
import (
"bytes"
"context"
"errors"
"reflect"
"runtime"
"strconv"
"sync"
"sync/atomic"
"time"
"github.com/redis/go-redis/v9/internal"
"github.com/redis/go-redis/v9/internal/pool"
"github.com/redis/go-redis/v9/internal/proto"
"github.com/redis/go-redis/v9/push"
)
// cscRegisterCleanups arranges for a client dropped without Close to stop its
// background CSC drainer. The drainer's exit path revokes its pool's cache
// coverage; the runtime cleanup itself stays non-blocking and never captures
// *Client, so the wrapper remains collectible.
func cscRegisterCleanups(c *Client) {
h := c.baseClient.cscDrainHandle
if h == nil {
return
}
// Capture cscActive (a standalone *atomic.Bool, not *Client) so the cleanup
// also stops clones from serving once the drainer is gone.
active := c.baseClient.cscActive
runtime.AddCleanup(c, func(h *cscDrainHandle) {
if active != nil {
active.Store(false)
}
h.signalStop()
}, h)
}
// ClientSideCacheConfig configures the built-in client-side cache. Pass a
// non-nil value to Options.ClientSideCacheConfig to enable caching on a RESP3
// client.
//
// Experimental: this API may change in a minor release.
type ClientSideCacheConfig = CacheConfig
const (
invalidatePushName = "invalidate"
// cscNamespaceSep separates fixed-width/logically-delimited namespace parts
// from the command or Redis key.
cscNamespaceSep = "\x00"
)
// cscNamespacePrefix scopes a shared cache by database and fixed ACL identity.
// Password rotation does not change identity; provider-backed identities are
// rejected before attachment.
func cscNamespacePrefix(db int, username string) string {
return strconv.Itoa(db) + cscNamespaceSep +
strconv.Itoa(len(username)) + ":" + username + cscNamespaceSep
}
func cscNamespacedKey(prefix, key string) string {
return prefix + key
}
// invalidateHandler propagates RESP3 "invalidate" push notifications into the
// shared client-side cache. keyPrefix scopes incoming key names so a shared
// cache cannot collide across databases or fixed ACL identities.
//
// The binding (cache, keyPrefix) is mutable under mu: the owning client's teardown
// RELEASES it (cache=nil) instead of unregistering the handler, so the handler
// can stay registered protected — application code holding the processor
// cannot silently unregister invalidation out from under a live client — while
// a successor client on the same processor can still rebind it (see
// registerInvalidateHandler).
type invalidateHandler struct {
mu sync.RWMutex
cache Cache
keyPrefix string
users int
}
// HandlePushNotification decodes ["invalidate", <keys>] notifications. A nil
// <keys> payload is emitted on FLUSHDB/FLUSHALL and triggers a full cache flush.
func (h *invalidateHandler) HandlePushNotification(
_ context.Context, _ push.NotificationHandlerContext, notification []interface{},
) error {
h.mu.RLock()
cache, keyPrefix := h.cache, h.keyPrefix
h.mu.RUnlock()
if cache == nil || len(notification) < 2 {
return nil
}
switch payload := notification[1].(type) {
case nil:
cache.Flush()
case []interface{}:
for _, k := range payload {
var name string
switch v := k.(type) {
case string:
name = v
case []byte:
name = string(v)
default:
continue
}
cache.DeleteByRedisKey(cscNamespacedKey(keyPrefix, name))
}
}
return nil
}
func (h *invalidateHandler) release() {
h.mu.Lock()
if h.users > 0 {
h.releaseLocked()
}
h.mu.Unlock()
}
func (h *invalidateHandler) releaseLocked() {
h.users--
if h.users == 0 {
h.cache = nil
h.keyPrefix = ""
}
}
// sameCache compares Cache interface values without panicking when an
// implementation uses a non-comparable value type.
func sameCache(a, b Cache) bool {
if a == nil || b == nil {
return a == nil && b == nil
}
typ := reflect.TypeOf(a)
return typ == reflect.TypeOf(b) && typ.Comparable() && a == b
}
func isNilCache(cache Cache) bool {
if cache == nil {
return true
}
v := reflect.ValueOf(cache)
switch v.Kind() {
case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Ptr, reflect.Slice:
return v.IsNil()
default:
return false
}
}
// errInvalidateHandlerBound: piggybacking on a handler bound to a live
// different cache would leave the new cache uninvalidated.
var errInvalidateHandlerBound = errors.New(`csc: a different "invalidate" push handler is already registered`)
// bindTo binds the handler to (cache, keyPrefix). Success when that is already the
// binding (a derived Client.Conn sharing the parent's processor and cache) or
// when the handler was released by a previous owner's teardown (rebind);
// errInvalidateHandlerBound otherwise.
func (h *invalidateHandler) bindTo(cache Cache, keyPrefix string) error {
h.mu.Lock()
defer h.mu.Unlock()
switch {
case sameCache(h.cache, cache) && h.keyPrefix == keyPrefix:
h.users++
return nil
case h.cache == nil:
h.cache, h.keyPrefix = cache, keyPrefix
h.users = 1
return nil
default:
return errInvalidateHandlerBound
}
}
// lookupInvalidateHandler returns the processor's CSC invalidate handler, nil
// when absent or foreign.
func lookupInvalidateHandler(p push.NotificationProcessor) *invalidateHandler {
if p == nil {
return nil
}
h, _ := p.GetHandler(invalidatePushName).(*invalidateHandler)
return h
}
func registerInvalidateHandler(p push.NotificationProcessor, cache Cache, keyPrefix string) error {
if p == nil || cache == nil {
return nil
}
if existing := p.GetHandler(invalidatePushName); existing != nil {
h, ok := existing.(*invalidateHandler)
if !ok {
return errInvalidateHandlerBound
}
return h.bindTo(cache, keyPrefix)
}
// VoidProcessor (RESP2) returns an error here; the caller treats it as
// "CSC not available" rather than fatal. Registered PROTECTED: application
// code holding the processor must not be able to unregister invalidation
// under a live client (that would serve unbounded-stale hits with no
// signal); owner teardown releases the BINDING instead of the handler.
err := p.RegisterHandler(invalidatePushName, &invalidateHandler{
cache: cache,
keyPrefix: keyPrefix,
users: 1,
}, true)
if err == nil {
return nil
}
// Another client can register the same protected handler between GetHandler
// and RegisterHandler. Re-read it and accept the compatible binding.
if existing := p.GetHandler(invalidatePushName); existing != nil {
h, ok := existing.(*invalidateHandler)
if !ok {
return errInvalidateHandlerBound
}
return h.bindTo(cache, keyPrefix)
}
return err
}
// attachCSC dispatches to the invalidation strategy in
// Options.ClientSideCacheStrategy. Safe with a nil cache; on failure c.csc stays
// nil and commands fall back to normal round-trips. Adding a strategy: a new
// CSCStrategy constant plus cases in Options.init and here.
func (c *baseClient) attachCSC(ctx context.Context, cache Cache) {
if isNilCache(cache) || c.opt.Protocol != 3 {
return
}
// Credential providers may return a different ACL identity over the
// client's lifetime (or per context/connection), while the cache namespace
// is fixed when the client is created. Fixed credentials remain safe because
// the ACL username is included in the length-delimited namespace below.
if c.opt.StreamingCredentialsProvider != nil ||
c.opt.CredentialsProviderContext != nil ||
c.opt.CredentialsProvider != nil {
internal.Logger.Printf(ctx,
"redis: client-side caching is disabled with credential providers")
return
}
c.cscKeyPrefix = cscNamespacePrefix(c.opt.DB, c.opt.Username)
switch c.opt.ClientSideCacheStrategy {
case CSCStrategySharedTracking:
c.attachSharedTrackingCSC(ctx, cache)
default:
// Options.init clamps unknown strategies to SharedTracking; delegate anyway.
c.attachSharedTrackingCSC(ctx, cache)
}
}
// attachSharedTrackingCSC wires SharedTracking: one shared cache, per-conn CLIENT
// TRACKING, a background drainer, and the owning-conn eviction hook. DB-0 only:
// tracking is bound to the conn's DB and a runtime SELECT does not re-key it.
func (c *baseClient) attachSharedTrackingCSC(ctx context.Context, cache Cache) {
if c.opt.DB != 0 {
internal.Logger.Printf(ctx,
"csc: client-side caching is restricted to DB 0; disabling CSC for client configured with DB=%d. "+
"Use one client per DB if you need caching against non-zero databases.", c.opt.DB)
return
}
// A pooler without idle-conn draining (e.g. Client.Conn's StickyConnPool)
// can't apply buffered invalidations, so stay uncached.
if _, ok := c.connPool.(idleConnDrainer); !ok {
return
}
// The lifecycle hook serializes cache publication with connection removal
// and socket replacement. Without it, a reply can become visible after its
// tracking coverage is gone.
reg, ok := c.connPool.(poolHookSupport)
if !ok || !reg.SupportsPoolHooks() {
return
}
if err := registerInvalidateHandler(c.pushProcessor, cache, c.cscKeyPrefix); err != nil {
internal.Logger.Printf(ctx, "csc: failed to register invalidate handler: %v", err)
return
}
c.csc = cache
c.registerConnEvictHook(cache, reg)
c.startBackgroundDrainer()
}
// cscHook returns the shared evict-on-remove hook, nil when CSC is off.
func (c *baseClient) cscHook() *cscEvictOnRemoveHook {
h, _ := c.cscPoolHook.(*cscEvictOnRemoveHook)
return h
}
// cscInstallConnCloseHook evicts cn's owned entries on any close — including the
// ConnMaxLifetime/idle retirement path (CloseConn) that bypasses the OnRemove
// hook — so entries don't outlive the server tracking dropped at close. Uses the
// onCscClose slot so it doesn't clobber streaming-credentials cleanup.
func (c *baseClient) cscInstallConnCloseHook(cn *pool.Conn) {
cn.SetOnCscClose(func() error {
c.cscOnConnClose(cn.GetID())
return nil
})
}
// cscInstallConnReinitHook invalidates the old socket's cache coverage before
// SetNetConnAndInitConn replaces it. The later init can then safely enable
// tracking for the new socket without a post-swap publication window.
func (c *baseClient) cscInstallConnReinitHook(cn *pool.Conn) {
cn.SetOnCscReinit(func() {
c.cscEvictOwnedEntries(cn.GetID())
})
}
// cscOnConnClose evicts a closing conn's entries: via the shared hook (which
// records the removed-ring, closing the close-before-fulfill race), else scoped
// EvictByConn on the owning cache.
func (c *baseClient) cscOnConnClose(connID uint64) {
if h := c.cscHook(); h != nil {
h.markRemoved(connID)
return
}
if c.csc != nil {
c.csc.EvictByConn(connID)
}
}
// poolHookSupport is the pool capability SharedTracking needs to serialize
// cache publication with connection removal and reinitialization.
type poolHookSupport interface {
AddPoolHook(hook pool.PoolHook)
RemovePoolHook(hook pool.PoolHook)
SupportsPoolHooks() bool
}
// cscEvictOnRemoveHook evicts a connection's owned entries when the pool removes
// it (the server stops delivering their invalidations — Window 2), and tracks
// per-conn init generations so fulfillCached can catch a value whose owning
// conn was removed or re-initialized mid-fetch.
type cscEvictOnRemoveHook struct {
evictor Cache
mu sync.Mutex
// initGen counts a live conn's socket (re)initializations: bumped by
// cscEvictOwnedEntries before its eviction (first init included, so every
// serving conn has gen >= 1), deleted on removal/close. fulfillCached
// compares it with the generation captured at reply time.
initGen map[uint64]uint64
}
func (h *cscEvictOnRemoveHook) OnGet(_ context.Context, _ *pool.Conn, _ bool) (bool, error) {
return true, nil
}
func (h *cscEvictOnRemoveHook) OnPut(_ context.Context, _ *pool.Conn) (shouldPool, shouldRemove bool, err error) {
return true, false, nil
}
func (h *cscEvictOnRemoveHook) OnRemove(_ context.Context, cn *pool.Conn, _ error) {
if cn == nil {
return
}
h.markRemoved(cn.GetID())
}
// markRemoved forgets connID's generation, then evicts. Forgetting before
// evicting lets a racing fulfillCached see the change (a served conn's captured
// generation is >= 1, an absent entry reads 0) and drop an entry created after
// the eviction — closing the close-before-fulfill race.
func (h *cscEvictOnRemoveHook) markRemoved(connID uint64) {
h.forgetConn(connID)
h.evictor.EvictByConn(connID)
}
// bumpInitGen advances connID's coverage generation. On reinit it is called by
// the pre-swap hook, before the old socket and its server-side tracking table
// are replaced.
func (h *cscEvictOnRemoveHook) bumpInitGen(connID uint64) {
h.mu.Lock()
if h.initGen == nil {
h.initGen = make(map[uint64]uint64)
}
h.initGen[connID]++
h.mu.Unlock()
}
// invalidateConnCoverage revokes all cache coverage associated with connID.
// Bumping before eviction also rejects an in-flight fetch that completed on the
// connection just before it left the parent's invalidation drainer.
func (h *cscEvictOnRemoveHook) invalidateConnCoverage(connID uint64) {
h.bumpInitGen(connID)
h.evictor.EvictByConn(connID)
}
// initGenOf returns connID's current init generation (0 if never bumped).
func (h *cscEvictOnRemoveHook) initGenOf(connID uint64) uint64 {
h.mu.Lock()
defer h.mu.Unlock()
return h.initGen[connID]
}
// forgetConn drops connID's init-generation entry: the conn was removed/closed,
// or its init failed before ever serving (the pubsub path would otherwise leak
// the entry — no OnRemove hook, close hook not yet installed).
func (h *cscEvictOnRemoveHook) forgetConn(connID uint64) {
h.mu.Lock()
delete(h.initGen, connID)
h.mu.Unlock()
}
// fulfillOwnedIfCovered linearizes the final coverage check with connection
// removal/re-init generation changes. Holding h.mu through FulfillOwned means
// either the old generation is rejected before the placeholder becomes valid,
// or publication wins first and the subsequent lifecycle path evicts it before
// closing/replacing the tracked socket.
func (h *cscEvictOnRemoveHook) fulfillOwnedIfCovered(
cacheKey string,
token, ownerConnID, capturedGen uint64,
value []byte,
) bool {
h.mu.Lock()
defer h.mu.Unlock()
if h.initGen[ownerConnID] != capturedGen {
return false
}
return h.evictor.FulfillOwned(cacheKey, token, ownerConnID, value)
}
// invalidateAllCoverage revokes every connection generation known to this
// client's pool and evicts the entries those connections own. Incrementing
// instead of deleting keeps in-flight fetches that captured an old generation
// from publishing after a drainer stops.
func (h *cscEvictOnRemoveHook) invalidateAllCoverage() {
h.mu.Lock()
connIDs := make([]uint64, 0, len(h.initGen))
for connID := range h.initGen {
h.initGen[connID]++
connIDs = append(connIDs, connID)
}
h.mu.Unlock()
for _, connID := range connIDs {
h.evictor.EvictByConn(connID)
}
}
// registerConnEvictHook wires the required OnRemove eviction hook.
func (c *baseClient) registerConnEvictHook(cache Cache, reg poolHookSupport) {
h := &cscEvictOnRemoveHook{evictor: cache, initGen: make(map[uint64]uint64)}
reg.AddPoolHook(h)
c.cscPoolHook = h
}
// cscEvictOwnedEntries evicts connID's entries on first init or immediately
// before a reinit/handoff replaces the socket and its tracking table. It
// prefers the shared hook (so Conn/Tx, which carry it but have a nil csc, still
// evict from the parent cache). Scoped only — no removed-ring (the conn keeps
// serving, and the ring never ages out); the fulfill-vs-re-init race is closed
// by the init-generation bump instead. No custom-cache flush (this also runs on
// first init).
func (c *baseClient) cscEvictOwnedEntries(connID uint64) {
if h := c.cscHook(); h != nil {
h.invalidateConnCoverage(connID)
return
}
if c.csc == nil {
return
}
c.csc.EvictByConn(connID)
}
// newStickyConnPool creates a derived sticky pool and revokes the claimed
// connection's parent-cache ownership before it becomes unreachable to the
// parent's idle-connection drainer.
func (c *baseClient) newStickyConnPool() *pool.StickyConnPool {
sticky := pool.NewStickyConnPool(c.connPool)
if h := c.cscHook(); h != nil {
sticky.SetOnFirstConn(func(cn *pool.Conn) {
if cn != nil {
h.invalidateConnCoverage(cn.GetID())
}
})
}
return sticky
}
// cscFetchCapture receives, from the successful attempt's reply read — while
// the serving connection is still held — everything the CSC fetch path needs to
// attribute the cached entry: the raw RESP reply, the conn id, and the conn's
// CSC init generation. The generation must be captured before the conn is
// released: a handoff queued at Put can re-init the socket (bumping the
// generation) before fulfillCached runs.
type cscFetchCapture struct {
raw []byte
connID uint64
initGen uint64
}
// cscConnInitGen returns connID's CSC init generation, captured by _process at
// reply time (while the conn is still held) and compared by fulfillCached via
// fulfillOwnedIfCovered. Zero without an active evict-on-remove hook.
func (c *baseClient) cscConnInitGen(connID uint64) uint64 {
if h := c.cscHook(); h != nil {
return h.initGenOf(connID)
}
return 0
}
// cscForgetConn drops connID's init-generation entry when initialization does
// not establish tracked coverage, either because init failed or tracking was
// rejected and CSC was disabled.
func (c *baseClient) cscForgetConn(connID uint64) {
if h := c.cscHook(); h != nil {
h.forgetConn(connID)
}
}
// errClientTrackingWithCSC rejects CLIENT TRACKING on clients with built-in CSC
// (see the guards in baseClient.process and generalProcessPipeline). The raw
// escape hatches — Do(ctx, "client", "tracking", ...) with string or []byte
// args, and pipelines — are also caught: the guard matches on the command's
// leading args, not the typed method.
var errClientTrackingWithCSC = errors.New(
"redis: CLIENT TRACKING is not allowed when client-side caching is enabled")
// errSelectWithCSC rejects runtime SELECT on clients with built-in CSC. Cache
// keys use Options.DB, while SELECT mutates only the chosen pool connection.
var errSelectWithCSC = errors.New(
"redis: SELECT is not allowed when client-side caching is enabled")
// errAuthWithCSC rejects runtime authentication because it can change one
// connection's ACL identity without changing the client's fixed cache namespace.
var errAuthWithCSC = errors.New(
"redis: AUTH is not allowed when client-side caching is enabled")
// errHelloWithCSC rejects HELLO with arguments because it can switch a tracked
// connection out of RESP3 (and can also change authentication).
var errHelloWithCSC = errors.New(
"redis: HELLO with arguments is not allowed when client-side caching is enabled")
// errResetWithCSC rejects RESET because it disables tracking and switches the
// connection to RESP2.
var errResetWithCSC = errors.New(
"redis: RESET is not allowed when client-side caching is enabled")
// errSubscribeWithCSC rejects raw subscriptions on the ordinary pool. The
// typed Subscribe methods use dedicated PubSub connections and remain allowed.
var errSubscribeWithCSC = errors.New(
"redis: SUBSCRIBE is not allowed on pooled connections when client-side caching is enabled")
// cscCommandError rejects commands that can make a pooled connection's state
// diverge from the assumptions used by CSC.
func (c *baseClient) cscCommandError(cmd Cmder) error {
// The successful attachment signal is shared with derived clients.
// initConn's internal command wrapper is exempt during library setup.
if !c.cscTrackingRequested() || c.allowClientTracking {
return nil
}
switch {
case isClientTrackingCmd(cmd):
return errClientTrackingWithCSC
case isSelectCmd(cmd):
return errSelectWithCSC
case isAuthCmd(cmd):
return errAuthWithCSC
case isProtocolChangingHelloCmd(cmd):
return errHelloWithCSC
case isResetCmd(cmd):
return errResetWithCSC
case isSubscribeCmd(cmd):
return errSubscribeWithCSC
default:
return nil
}
}
// cscDrainHandle owns the drainer lifecycle and serializes client teardown.
// stop signals shutdown; done is closed on exit so Close can join.
type cscDrainHandle struct {
stop chan struct{}
done chan struct{}
stopOnce sync.Once
teardownOnce sync.Once
handlerCloseOnce sync.Once
closeOnce sync.Once
closeErr error
invalidateHandler *invalidateHandler
}
// signalStop closes stop at most once (so Close and the AddCleanup safety net
// can't double-close) and does not join — a GC cleanup must not block.
func (h *cscDrainHandle) signalStop() {
h.stopOnce.Do(func() { close(h.stop) })
}
// cscHandlerClient is exposed only through the background drainer's handler
// context. Close must return before the handler does, otherwise it would wait
// for the drainer goroutine that is currently invoking the handler.
type cscHandlerClient struct {
*baseClient
}
func (c cscHandlerClient) Close() error {
h := c.cscDrainHandle
if h == nil {
return c.baseClient.Close()
}
h.handlerCloseOnce.Do(func() {
// Close has logically started: stop cache hits immediately and let the
// drainer exit as soon as this handler returns.
if c.cscActive != nil {
c.cscActive.Store(false)
}
h.signalStop()
go func() {
if err := c.baseClient.Close(); err != nil {
internal.Logger.Printf(context.Background(), "csc: deferred client close failed: %v", err)
}
}()
})
return nil
}
// cscMinDrainInterval floors a user-supplied DrainInterval: sub-millisecond
// timers are unreliable (https://github.com/golang/go/issues/53824).
const cscMinDrainInterval = time.Millisecond
// cscDrainInterval returns DrainInterval clamped to cscMinDrainInterval, or the
// default (cscDrainSkipWindow) when unset.
func (c *baseClient) cscDrainInterval() time.Duration {
if cfg := c.opt.ClientSideCacheConfig; cfg != nil && cfg.DrainInterval > 0 {
if cfg.DrainInterval < cscMinDrainInterval {
return cscMinDrainInterval
}
return cfg.DrainInterval
}
return cscDrainSkipWindow
}
// idleConnDrainer is the pooler capability the drainer needs (*pool.ConnPool has
// it). attachSharedTrackingCSC leaves a pooler without it uncached, rather than
// serve entries nothing would invalidate.
type idleConnDrainer interface {
DrainIdleConns(ctx context.Context, st *pool.DrainState, fn func(cn *pool.Conn) error)
}
// startBackgroundDrainer launches the per-client invalidation drainer: each tick
// runs one pool.DrainIdleConns pass, draining idle conns' buffered push frames.
// No-op for poolers that don't implement idleConnDrainer.
func (c *baseClient) startBackgroundDrainer() {
cp, ok := c.connPool.(idleConnDrainer)
if !ok {
return
}
if c.cscDrainHandle != nil {
return // already running (startBackgroundDrainer runs once, in NewClient)
}
h := &cscDrainHandle{
stop: make(chan struct{}),
done: make(chan struct{}),
invalidateHandler: lookupInvalidateHandler(c.pushProcessor),
}
c.cscDrainHandle = h
active := &atomic.Bool{}
active.Store(true)
c.cscActive = active
interval := c.cscDrainInterval()
// Custom-processor drain errors are connection-fatal (drainPushNotifications),
// so a PERSISTENTLY failing custom processor would turn every tick into a
// conn removal + redial — a sustained dial storm. Damping: after
// cscDrainCustomErrCap consecutive fatal custom-processor drains, disable
// CSC serving and stop the drainer (with one log line) instead of churning.
// Built-in processor errors are real conn desyncs and are never damped.
_, builtinProc := c.pushProcessor.(*push.Processor)
go func() {
defer func() {
active.Store(false)
if c.cscPoolHook != nil {
if reg, ok := c.connPool.(poolHookSupport); ok {
reg.RemovePoolHook(c.cscPoolHook)
}
}
if hook := c.cscHook(); hook != nil {
hook.invalidateAllCoverage()
}
if h.invalidateHandler != nil {
h.invalidateHandler.release()
}
close(h.done)
}()
ticker := time.NewTicker(interval)
defer ticker.Stop()
// st persists round/visited across ticks; single-goroutine, no lock.
var st pool.DrainState
consecFatal := 0
drain := func(cn *pool.Conn) error {
processorSucceeded, err := c.drainPushNotifications(cn)
switch {
case err != nil:
consecFatal++
case processorSucceeded:
// A successful processor invocation resets consecutive
// failures. A conn skipped without invoking the processor —
// including a clean replacement after a fatal drain — does
// not reset the counter.
consecFatal = 0
}
return err
}
for {
select {
case <-h.stop:
return
case <-ticker.C:
if !active.Load() {
return
}
// ctx bounds the whole pass; the drain read has its own hard deadline.
cycleCtx, cancel := context.WithTimeout(context.Background(), interval/2)
cp.DrainIdleConns(cycleCtx, &st, drain)
cancel()
if !builtinProc && consecFatal >= cscDrainCustomErrCap {
internal.Logger.Printf(context.Background(),
"csc: disabling client-side caching: the custom push notification processor failed %d consecutive drains "+
"(each failure removes a connection because the reader may be mid-frame); "+
"caching cannot be kept fresh safely with this processor", consecFatal)
return
}
}
}
}()
}
// disableCSCServing atomically stops cache hits and revokes all tracked
// connection coverage. The owner drainer observes the shared active flag on its
// next tick, including when a derived Conn or Tx discovered the incompatibility.
func (c *baseClient) disableCSCServing(ctx context.Context, reason string) {
active := c.cscActive
if active == nil || !active.CompareAndSwap(true, false) {
return
}
if hook := c.cscHook(); hook != nil {
hook.invalidateAllCoverage()
}
internal.Logger.Printf(ctx, "csc: disabling client-side caching: %s", reason)
}
// stopBackgroundDrainer joins the drainer goroutine and flushes an owned cache.
// The drainer's exit path releases its handler binding and pool hook, including
// when it stops itself. Owner-only: clones have no handle and return early.
// The fields are never cleared here — fulfillCached reads cscPoolHook on the hot
// path, so niling under a concurrent Close would race; teardownOnce makes repeat
// Close idempotent instead.
func (c *baseClient) stopBackgroundDrainer() {
h := c.cscDrainHandle
if h == nil {
return
}
h.teardownOnce.Do(func() {
// Stop serving cache hits on any clone before the drainer is gone.
if c.cscActive != nil {
c.cscActive.Store(false)
}
h.signalStop()
<-h.done
// The drainer's exit defer revoked and evicted this pool's coverage
// before closing done, including for injected caches shared elsewhere.
if c.cscOwnsCache && c.csc != nil {
c.csc.Flush()
}
})
}
// applyCachedReply populates cmd from a previously captured raw RESP reply by
// replaying it through the command's own readReply.
func applyCachedReply(cmd Cmder, raw []byte) error {
return cmd.readReply(proto.NewReaderSize(bytes.NewReader(raw), len(raw)+1))
}
// isCacheableReplyResult reports whether a fully read Redis reply can be
// cached. redis.Nil is a normal negative lookup, not a transport/protocol
// failure; tracking will invalidate it if the key is later created.
func isCacheableReplyResult(err error) bool {
return err == nil || err == Nil
}
// cscDrainSkipWindow is the default SharedTracking drain period (overridable via
// ClientSideCacheConfig.DrainInterval). A buffered invalidation is picked up within
// roughly one round; MaxStaleness, when configured, is the hard time-based backstop.
const cscDrainSkipWindow = 5 * time.Millisecond
// cscDrainHardReadCap is the hard socket read deadline the drainer applies via
// Conn.WithReaderHardDeadline. It bounds only a rare partial-frame mid-read. A
// var (not const) so the tuning harness can sweep it.
var cscDrainHardReadCap = 50 * time.Millisecond
// cscDrainProbeReadCap bounds the non-consuming one-byte probe used only when
// an opaque transport may hold data that the socket readiness check cannot see.
const cscDrainProbeReadCap = 50 * time.Microsecond
// cscDrainCustomErrCap is the number of CONSECUTIVE fatal custom-processor
// drain errors after which the drainer disables CSC instead of removing (and
// redialing) a connection per tick indefinitely.
const cscDrainCustomErrCap = 8
// processCached runs the Get-Reserve-Fulfill lifecycle for a cacheable command.
// Only invoked after process has verified that CSC is active and cmd is
// eligible.
func (c *baseClient) processCached(ctx context.Context, cmd Cmder, state *processState) error {
if err := ctx.Err(); err != nil {
return err
}
// Once the drainer has stopped (owner Close, or the owner dropped without
// Close), no invalidations flow — a surviving clone must not serve stale hits.
if a := c.cscActive; a != nil && !a.Load() {
return c.processWithRetry(ctx, cmd, nil, state)
}
rawKey, ok := buildCacheKey(cmd)
if !ok {
return c.processWithRetry(ctx, cmd, nil, state)
}
redisKeys := extractRedisKeys(cmd)
if len(redisKeys) == 0 {
// Without a key list we cannot react to invalidations for this command.
return c.processWithRetry(ctx, cmd, nil, state)
}
keyPrefix := c.cscKeyPrefix
if keyPrefix == "" {
// A successfully attached client always has a namespace. Fail closed if
// an incomplete custom baseClient reaches this path.
return c.processWithRetry(ctx, cmd, nil, state)
}
key := cscNamespacedKey(keyPrefix, rawKey)
nsRedisKeys := make([]string, len(redisKeys))
for i, k := range redisKeys {
nsRedisKeys[i] = cscNamespacedKey(keyPrefix, k)
}
// Serve hits straight from the cache.
if data, ok := c.csc.Get(ctx, key); ok {
if err := ctx.Err(); err != nil {
return err
}
if err := applyCachedReply(cmd, data); isCacheableReplyResult(err) {
return err
}
c.csc.DeleteByCacheKey(key)
}
token, shouldFetch := c.csc.Reserve(key, nsRedisKeys)
if !shouldFetch {
// Another goroutine is fetching; Get below waits until it completes.
if data, ok := c.csc.Get(ctx, key); ok {
if err := ctx.Err(); err != nil {
return err
}
if err := applyCachedReply(cmd, data); isCacheableReplyResult(err) {
return err
}
c.csc.DeleteByCacheKey(key)
}
// Original fetcher cancelled or its value was invalidated; try to take
// over so later waiters still benefit from the cache.
token, shouldFetch = c.csc.Reserve(key, nsRedisKeys)
}
var fc cscFetchCapture
var capture *cscFetchCapture
if shouldFetch {
capture = &fc
// Release the placeholder if processWithRetry panics; Cancel on a
// stale token is a no-op.
defer func() {
if capture != nil {
c.csc.Cancel(key, token)
}
}()
}
err := c.processWithRetry(ctx, cmd, capture, state)
if shouldFetch {
capture = nil // disarm the deferred Cancel
if isCacheableReplyResult(err) {
c.fulfillCached(key, token, &fc)
} else {
c.csc.Cancel(key, token)
}
}
return err
}
// fulfillCached stores a fetched value, attributing it to its serving conn when
// an evict-on-remove hook is active so EvictByConn can drop it if that conn is
// removed. It also closes the attribute-vs-coverage races: the conn is released
// before this runs, so its OnRemove eviction — or a handoff re-init's scoped
// eviction — may fire before the entry exists. Publication is serialized with
// the hook's init-generation changes, so a reply whose invalidation coverage
// was already lost never becomes visible and never wakes waiters with stale
// data.
func (c *baseClient) fulfillCached(key string, token uint64, fc *cscFetchCapture) bool {
if active := c.cscActive; active != nil && !active.Load() {
c.csc.Cancel(key, token)
return false
}
if hook := c.cscHook(); hook != nil {
if fc.connID == 0 {
// Invariant: an active hook always gets a real conn id (>=1). A zero id
// would leave the entry unattributed and un-evictable, so fail closed.
c.csc.Cancel(key, token)
return false
}
if !hook.fulfillOwnedIfCovered(key, token, fc.connID, fc.initGen, fc.raw) {
// A coverage mismatch leaves the reservation IN_PROGRESS because
// FulfillOwned was deliberately skipped. Cancel wakes its waiters
// as misses so one can safely refetch on a covered connection.
c.csc.Cancel(key, token)
return false
}
return true
}
return c.csc.FulfillOwned(key, token, 0, fc.raw)
}