Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
54 commits
Select commit Hold shift + click to select a range
1acd19a
docs: point security reports at Redis VDP (#3949)
ndyakov Aug 4, 2026
36d9752
fix(pool): log buffered push data at debug level (#3948)
ndyakov Aug 5, 2026
eb8681e
feat(csc): reader-miss coalescing modes (workers + full-duplex) and i…
ndyakov Aug 13, 2026
2f6a9e5
fix(csc): address #3965 review and lint in coalescing modes
ndyakov Aug 13, 2026
a5a231a
fix(csc): FD apply-and-gate on id/gen mismatch; drain buffered pushes…
ndyakov Aug 13, 2026
6526e8c
fix(search): read the full RESP3 map reply in FTHybridCmd (#3956)
saddamr3e Aug 14, 2026
5cae5e1
fix(csc): retry uncached on mid-miss disable; guard cancelled fetches
ndyakov Aug 14, 2026
e4975ba
fix(csc): drop batched deletes on flush; rebuild batcher on window ch…
ndyakov Aug 14, 2026
636ca53
ci(govulncheck): use stable Go to pick up security patches
ndyakov Aug 14, 2026
68aced9
fix(csc): preserve queued deletes on rebuild; honor configured timeouts
ndyakov Aug 14, 2026
62ec782
fix(csc): batcher stop/release safety; FD idle release; flush budget
ndyakov Aug 14, 2026
f2af313
fix(csc): close coalescer lifecycle races; honor pool budget on acquire
ndyakov Aug 14, 2026
62a626b
fix(csc): coalescer latency, shutdown and clone fixes
ndyakov Aug 14, 2026
d3fddc4
fix(csc): make FD session I/O interruptible; refresh rebuild; stat CAS
ndyakov Aug 14, 2026
296622e
fix(csc): join FD supervisor before release; drain queue in GC cleanup
ndyakov Aug 14, 2026
fef79a8
fix(csc): clear the push-probe deadline for negative ReadTimeout
ndyakov Aug 14, 2026
00b7903
refactor(csc): full-duplex is the only miss-coalescer engine
ndyakov Aug 14, 2026
aa68099
fix(csc): held-conn probe safety and lifetime bounds
ndyakov Aug 14, 2026
12e9709
fix(csc): scope idle-drain fallback to held conns; drainer deadline h…
ndyakov Aug 14, 2026
c644e8b
refactor(csc): drop the public coalescer stats API
ndyakov Aug 14, 2026
968fa02
refactor(csc): drop the public coalescer stats API
ndyakov Aug 15, 2026
ad6e68e
fix(csc): bound the recycle-path drain like the stop path
ndyakov Aug 15, 2026
883be56
fix(csc): progress-based drain backstop; departial test flake
ndyakov Aug 15, 2026
5c759f5
fix(pool): clear residual read deadline in checkForData
ndyakov Aug 15, 2026
6c7c6fc
fix(csc): claim FD writes; progress-aware drain backstop
ndyakov Aug 15, 2026
e844cc0
fix(csc): snapshot miss wire form at enqueue
ndyakov Aug 15, 2026
eb220c9
fix(csc): harden FD session recycle and probe paths
ndyakov Aug 15, 2026
8771aa7
fix(csc): epoch inval drop; refresh ownership; close order
ndyakov Aug 15, 2026
911bf54
fix(csc): serialize inval apply with drop; FD handler adapter
ndyakov Aug 15, 2026
37a130c
fix(csc): retry-uncached drains; refresh stack; weak close
ndyakov Aug 15, 2026
d97d831
fix(csc): write-side backstop; probe gate; clone bypass
ndyakov Aug 15, 2026
95ea487
fix(csc): honor lifetime jitter in the FD recycle age
ndyakov Aug 15, 2026
e802dc7
fix(csc): fail the FD session on push-processor errors
ndyakov Aug 15, 2026
ce21ffb
feat(csc): native OTel attribution for coalesced misses
ndyakov Aug 16, 2026
1e6d30f
fix(csc): FD miss-coalescer review fixes
ndyakov Aug 18, 2026
bc29792
fix(csc): harden refresh-on-invalidate push drain
ndyakov Aug 18, 2026
e059187
fix(maintnotifications): skip endpoint DNS detect when mode is disabl…
Phalanyx Aug 18, 2026
7766eb1
perf(proto): add zero-copy semantics to scan, remove redundant data c…
vlady-kotsev Aug 18, 2026
ed5d210
fix(csc): record cancel metric on coalesced miss
ndyakov Aug 18, 2026
90180a1
fix(csc): retry-uncached on coalescer stop-acquire
ndyakov Aug 18, 2026
ee25c4e
fix(csc): detect coalescer stop by signal, not error
ndyakov Aug 18, 2026
73a8d91
docs(csc): note per-session Limiter for miss coalescing
ndyakov Aug 18, 2026
3275e99
chore(deps): bump rojopolis/spellcheck-github-actions (#3967)
dependabot[bot] Aug 18, 2026
0a0cb0f
Merge commit '3275e9941026d9152f75d22dc8c06f420facd218' into HEAD
ndyakov Aug 18, 2026
d33c190
fix(csc): propagate drain errors on fragmented attributes
ndyakov Aug 18, 2026
a429433
fix(csc): limit bare-socket push drain to built-in processor
ndyakov Aug 19, 2026
fd9392a
fix(csc): peek frame type before a custom push drain
ndyakov Aug 19, 2026
4c8c54d
fix(csc): propagate custom push-drain peek errors
ndyakov Aug 19, 2026
a51876c
fix(csc): drain miss-reader pushes through the shared safe helper
ndyakov Aug 19, 2026
2fa85cd
fix(csc): retire conn on pre-command drain desync; refresh drain helper
ndyakov Aug 19, 2026
1168bef
fix(csc): stop the refresh worker on GC cleanup
ndyakov Aug 19, 2026
589e8c0
feat(csc): add refresh-failure metric
ndyakov Aug 19, 2026
6ad25b6
fix(csc): drain pending pushes before reading the reply
ndyakov Aug 20, 2026
9001bd8
fix(csc): harden miss-coalescing per review
ndyakov Aug 25, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/govulncheck.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v7
with:
go-version: "1.26.x"
go-version: "stable"
cache: true

- name: Install govulncheck
Expand Down
61 changes: 61 additions & 0 deletions csc_coalesce_options_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package redis

import (
"testing"
"time"
)

// TestCSCCoalesceModeRejectsPinnedPublicly pins that the buggy "pinned" PROTOTYPE
// engine (no idle invalidation drain; can serve stale) is NOT selectable from the
// public ClientSideCacheCoalesceMode option — it falls back to "workers" — while
// the internal benchmark hook can still force it.
func TestCSCCoalesceModeRejectsPinnedPublicly(t *testing.T) {
if got := cscCoalesceMode(&Options{ClientSideCacheCoalesceMode: "pinned"}); got != "workers" {
t.Fatalf("public \"pinned\" => %q, want \"workers\"", got)
}
if got := cscCoalesceMode(&Options{ClientSideCacheCoalesceMode: "fullduplex"}); got != "fullduplex" {
t.Fatalf("\"fullduplex\" => %q, want \"fullduplex\"", got)
}
if got := cscCoalesceMode(&Options{ClientSideCacheCoalesceMode: ""}); got != "workers" {
t.Fatalf("\"\" => %q, want \"workers\"", got)
}
if got := cscCoalesceMode(nil); got != "workers" {
t.Fatalf("nil opt => %q, want \"workers\"", got)
}

cscForcePinned = true
defer func() { cscForcePinned = false }()
if got := cscCoalesceMode(&Options{ClientSideCacheCoalesceMode: "pinned"}); got != "pinned" {
t.Fatalf("forced \"pinned\" => %q, want \"pinned\" (benchmark hook)", got)
}
}

// TestUniversalOptionsSimpleCopiesCSCCoalesce guards that the new CSC miss-
// coalescing / invalidation-batching knobs reach a standalone Client through
// UniversalOptions.Simple() (they were previously Options-only, so UniversalClient
// users could not enable them).
func TestUniversalOptionsSimpleCopiesCSCCoalesce(t *testing.T) {
u := &UniversalOptions{
ClientSideCacheRefreshOnInvalidate: true,
ClientSideCacheCoalesceMisses: true,
ClientSideCacheCoalesceMode: "fullduplex",
ClientSideCacheCoalesceWorkers: 5,
ClientSideCacheInvalidationBatchWindow: 7 * time.Millisecond,
}
o := u.Simple()
if !o.ClientSideCacheRefreshOnInvalidate {
t.Error("Simple() dropped ClientSideCacheRefreshOnInvalidate")
}
if !o.ClientSideCacheCoalesceMisses {
t.Error("Simple() dropped ClientSideCacheCoalesceMisses")
}
if o.ClientSideCacheCoalesceMode != "fullduplex" {
t.Errorf("Simple() ClientSideCacheCoalesceMode = %q, want \"fullduplex\"", o.ClientSideCacheCoalesceMode)
}
if o.ClientSideCacheCoalesceWorkers != 5 {
t.Errorf("Simple() ClientSideCacheCoalesceWorkers = %d, want 5", o.ClientSideCacheCoalesceWorkers)
}
if o.ClientSideCacheInvalidationBatchWindow != 7*time.Millisecond {
t.Errorf("Simple() ClientSideCacheInvalidationBatchWindow = %v, want 7ms", o.ClientSideCacheInvalidationBatchWindow)
}
}
139 changes: 137 additions & 2 deletions csc_integration.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,73 @@ type invalidateHandler struct {
// refresh, when set, receives evicted-but-hot entries for immediate refetch.
// Feeding it must never block the invalidation-delivery path.
refresh *cscRefreshQueue

// batcher offloads invalidation cache-deletes to a windowed background
// goroutine (Options.ClientSideCacheInvalidationBatchWindow). Lazily started
// (ensureBatcher) and nil when disabled; guarded by mu. Stopped and cleared
// when the last user releases (releaseLocked), so its goroutine does not live
// past the binding re-arming its timer forever; a later re-acquire starts a
// fresh one (picking up the successor's window).
batcher *cscInvalBatcher

// invalBatchWindow is the coalescing window for the batcher above, threaded
// from the owning client's Options at attach time. 0 (default) deletes inline.
// Read under mu alongside cache/keyPrefix/refresh.
invalBatchWindow time.Duration
}

// setInvalBatchWindow records the invalidation-batch coalescing window from the
// owning client's Options. Normally set before any push can arrive (attach
// time). When a second client binds to the same shared handler with a different
// window, an already-running batcher was created with the previous window (its
// window is fixed at creation), so it would keep honoring the old cadence and the
// new client's staleness bound would not hold. Stop and drop that batcher so the
// next invalidation lazily starts a fresh one with the new window via
// ensureBatcher; stop() flushes what it holds, so no queued delete is lost.
func (h *invalidateHandler) setInvalBatchWindow(w time.Duration) {
h.mu.Lock()
defer h.mu.Unlock()
if h.invalBatchWindow == w {
return
}
h.invalBatchWindow = w
if h.batcher != nil {
Comment thread
ndyakov marked this conversation as resolved.
Outdated
h.batcher.stop()
h.batcher = nil
Comment thread
ndyakov marked this conversation as resolved.
Comment thread
ndyakov marked this conversation as resolved.
}
}

// ensureBatcher lazily starts the windowed invalidation batcher. The common
// case (already started) is a shared RLock; only first-start takes the write
// lock, so the hot invalidation path stays cheap.
func (h *invalidateHandler) ensureBatcher(w time.Duration) *cscInvalBatcher {
h.mu.RLock()
b := h.batcher
h.mu.RUnlock()
if b != nil {
return b
Comment thread
ndyakov marked this conversation as resolved.
}
h.mu.Lock()
defer h.mu.Unlock()
// Do not start a batcher for a released binding. releaseLocked stops+nils the
// batcher under this same lock when users hits 0, so a push racing that last
// release must NOT resurrect a goroutine that nothing would ever stop (once
// users is 0, release() no longer runs). The caller falls back to the inline
// delete path when this returns nil.
if h.users == 0 {
return nil
}
if h.batcher == nil {
h.batcher = &cscInvalBatcher{
h: h,
window: w,
ch: make(chan string, 8192),
stopCh: make(chan struct{}),
dropCh: make(chan struct{}, 1),
}
go h.batcher.run()
}
return h.batcher
Comment thread
cursor[bot] marked this conversation as resolved.
Comment thread
ndyakov marked this conversation as resolved.
}

func (h *invalidateHandler) setRefreshQueue(q *cscRefreshQueue) {
Expand All @@ -97,6 +164,8 @@ func (h *invalidateHandler) HandlePushNotification(
) error {
h.mu.RLock()
cache, keyPrefix, refresh := h.cache, h.keyPrefix, h.refresh
window := h.invalBatchWindow
batcher := h.batcher
h.mu.RUnlock()
if cache == nil || len(notification) < 2 {
return nil
Expand All @@ -105,7 +174,39 @@ func (h *invalidateHandler) HandlePushNotification(
switch payload := notification[1].(type) {
case nil:
cache.Flush()
// A full flush (FLUSHDB/FLUSHALL) removed every entry, so any per-key
// deletes the batcher still has queued are redundant — applying them after
// the flush would evict entries a reader repopulated post-flush, an extra
// miss for up to the window. Drop the batcher's pending queue.
if batcher != nil {
batcher.drop()
Comment thread
ndyakov marked this conversation as resolved.
Comment thread
ndyakov marked this conversation as resolved.
}
Comment thread
ndyakov marked this conversation as resolved.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Flush misses replaced batcher

Medium Severity

HandlePushNotification snapshots batcher under RLock then calls drop() only on that pointer before Flush. A concurrent window/refresh rebuild can stop() and nil the handler batcher first, so FLUSHDB/FLUSHALL never bumps that batcher’s epoch. Its async stop-drain still applies the pre-flush deletes afterward and can evict entries repopulated after the flush, the failure mode TestInvalBatchDroppedOnFlush already pins for the non-racy path.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit d33c190. Configure here.

case []interface{}:
// Offload path: enqueue keys to the windowed background batcher instead of
Comment thread
ndyakov marked this conversation as resolved.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When the invalidation queue is full, enqueue calls apply inline and may block on applyMu. On the full duplex connection, this also blocks miss replies. Can overflow stay asynchronous, maybe by signaling the worker to flush the cache?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 9001bd8. On a full invalidation queue, enqueue no longer applies the delete inline on the reader — it parks the key on a spill buffer and nudges the worker, which drains spill through the same seen/pending dedup as the fast path. So a burst collapses to one delete per unique key and no cache work (and no applyMu) runs on the full-duplex reply reader.

Correctness is unchanged: spilled items carry their enqueue-time epoch and are applied under applyMu with the same epoch check as the ch path. No hard cap — distinct invalidations are bounded by the tracked keyset, duplicate keys collapse, and a co-occurring full Flush() skips the whole backlog via the epoch bump; the worker is now panic-safe so it always catches up. Added a spilled counter so the overflow is observable instead of silent, plus tests for overflow-spills-not-inline and dedup-collapse.

// deleting inline, so invalidation work does not steal time from the
// coalescer's miss-reply reader (the low-concurrency churn p99 tail).
if window > 0 {
if _, ok := cache.(*LocalCache); ok {
Comment thread
ndyakov marked this conversation as resolved.
// nil when the binding was just released (users==0): fall through
// to the inline delete path below rather than enqueue on a nil
// batcher (which would panic).
if b := h.ensureBatcher(window); b != nil {
for _, k := range payload {
var name string
switch v := k.(type) {
case string:
name = v
case []byte:
name = string(v)
default:
continue
}
b.enqueue(cscNamespacedKey(keyPrefix, name))
}
return nil
}
}
}
Comment thread
ndyakov marked this conversation as resolved.
var hot []cscRefreshTarget
lc, canRefresh := cache.(*LocalCache)
canRefresh = canRefresh && refresh != nil
Expand Down Expand Up @@ -146,6 +247,14 @@ func (h *invalidateHandler) releaseLocked() {
if h.users == 0 {
h.cache = nil
h.keyPrefix = ""
// Stop the windowed batcher so its goroutine does not outlive the binding
// (re-arming its timer forever). stop() only closes a channel — it never
// touches h.mu and does not wait — so it is safe under the lock. A later
// re-acquire starts a fresh batcher via ensureBatcher.
if h.batcher != nil {
h.batcher.stop()
h.batcher = nil
Comment thread
ndyakov marked this conversation as resolved.
}
}
}

Expand Down Expand Up @@ -297,6 +406,12 @@ func (c *baseClient) attachSharedTrackingCSC(ctx context.Context, cache Cache) {
internal.Logger.Printf(ctx, "csc: failed to register invalidate handler: %v", err)
return
}
// Thread the invalidation-batch window from Options before any push can
// arrive, so the batcher (if enabled) sees the configured window on the very
// first invalidation rather than a zero default.
if ih := lookupInvalidateHandler(c.pushProcessor); ih != nil {
ih.setInvalBatchWindow(c.opt.ClientSideCacheInvalidationBatchWindow)
}
c.csc = cache
c.registerConnEvictHook(cache, reg)
c.startBackgroundDrainer()
Expand Down Expand Up @@ -803,6 +918,18 @@ func applyCachedReply(cmd Cmder, raw []byte) error {
return cmd.readReply(proto.NewReaderSize(bytes.NewReader(raw), len(raw)+1))
}

// classifyCachedReply reports the same error applyCachedReply would, without a
Comment thread
ndyakov marked this conversation as resolved.
// caller command to populate. The miss coalescer uses it on the abandoned path
// (the caller returned and owns its Cmder again) to decide cache-vs-cancel: a
// value or Nil is cacheable, a top-level RESP error is not. It reads the frame
// generically, so it can only diverge from a concrete cmd's readReply on a
// well-formed reply of an unexpected shape — which the next reader re-parses and
// drops (see processCached), so a rare mis-cache self-heals.
func classifyCachedReply(raw []byte) error {
_, err := proto.NewReaderSize(bytes.NewReader(raw), len(raw)+1).ReadReply()
return err
}

// 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.
Expand Down Expand Up @@ -894,13 +1021,21 @@ func (c *baseClient) processCached(ctx context.Context, cmd Cmder, state *proces
c.csc.DeleteByCacheKey(key)
}
// Original fetcher cancelled or its value was invalidated; try to take
// over so later waiters still benefit from the cache.
// over so later waiters still benefit from the cache. This is the 2x-RTT
// path under churn: we waited a round trip and still must fetch ourselves.
token, shouldFetch = c.csc.Reserve(key, nsRedisKeys)
}

// Reader-miss coalescing: hand the reserved miss to the batcher (no-op when off).
if shouldFetch && c.cscMissCoalescer != nil {
return c.cscMissCoalescer.fetch(ctx, cmd, key, token)
err := c.cscMissCoalescer.fetch(ctx, cmd, key, token)
Comment thread
ndyakov marked this conversation as resolved.
Outdated
if err == errCSCRetryUncached {
// The coalescer bowed out because CSC serving was disabled mid-miss; the
// command itself is fine and the reservation was already cancelled. Run it
// uncached on the normal path rather than surfacing a spurious ErrClosed.
return c.processWithRetry(ctx, cmd, nil, state)
}
return err
}

var fc cscFetchCapture
Expand Down
Loading
Loading