Skip to content

Commit 5481e0f

Browse files
drpcstream: add per-stream receive-window grant policy
Add recvWindow, the per-stream receive side of flow control. It tracks buffered bytes (in-progress reassembly plus completed, not-yet-consumed data) and decides when to return credit to the sender: grants are withheld while buffered is at or above the high-water mark, and the accrued returnable credit is otherwise released once it reaches the threshold, coalescing many frames into a single grant. Consuming reopens a gate that the high-water mark had closed. dispatched/consumed return the credit delta the caller should emit as a KindWindowUpdate. Emitting the frame, wiring into the read path (HandleFrame/MsgRecv), and the connection-level receive budget come in later commits. Co-Authored-By: roachdev-claude <roachdev-claude-bot@cockroachlabs.com>
1 parent b14cb1f commit 5481e0f

2 files changed

Lines changed: 137 additions & 0 deletions

File tree

drpcstream/recv_window.go

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
// Copyright (C) 2026 Cockroach Labs.
2+
// See LICENSE for copying information.
3+
4+
package drpcstream
5+
6+
import "sync"
7+
8+
// recvWindow is the per-stream receive side of flow control. It tracks how many
9+
// bytes are currently buffered for the stream (in-progress reassembly plus
10+
// completed, not-yet-consumed data) and decides when to return credit to the
11+
// sender.
12+
//
13+
// The receiver holds no credit balance of its own; it accrues "returnable"
14+
// credit as bytes are dispatched off the wire and releases it as grants:
15+
// - while buffered is at or above the high-water mark, grants are withheld
16+
// (backpressure), though the returnable credit keeps accruing;
17+
// - otherwise, accrued credit is released once it reaches the threshold,
18+
// coalescing many frames into a single grant.
19+
//
20+
// dispatched and consumed each return the credit delta the caller should send
21+
// as a KindWindowUpdate (0 when nothing should be sent). Emitting the frame is
22+
// the caller's job. dispatched (reader goroutine) and consumed (application
23+
// goroutine) can run concurrently, so the state is mutex-guarded.
24+
type recvWindow struct {
25+
mu sync.Mutex
26+
buffered int64 // in-progress reassembly + completed, not-yet-consumed bytes
27+
pending int64 // returnable credit accrued but not yet granted
28+
highWater int64 // withhold grants while buffered is at or above this
29+
threshold int64 // release accrued credit once it reaches this
30+
}
31+
32+
// newRecvWindow returns a recvWindow with the given high-water mark and grant
33+
// threshold.
34+
func newRecvWindow(highWater, threshold int64) *recvWindow {
35+
return &recvWindow{highWater: highWater, threshold: threshold}
36+
}
37+
38+
// dispatched records that n bytes were dispatched off the wire into the
39+
// stream's buffers and returns the credit delta to grant (0 if none).
40+
func (w *recvWindow) dispatched(n int64) int64 {
41+
w.mu.Lock()
42+
defer w.mu.Unlock()
43+
w.buffered += n
44+
w.pending += n
45+
return w.maybeGrantLocked()
46+
}
47+
48+
// consumed records that n bytes were consumed by the application and returns
49+
// the credit delta to grant (0 if none). Consuming can reopen a gate that was
50+
// closed by the high-water mark, flushing credit accrued during the pause.
51+
func (w *recvWindow) consumed(n int64) int64 {
52+
w.mu.Lock()
53+
defer w.mu.Unlock()
54+
w.buffered -= n
55+
return w.maybeGrantLocked()
56+
}
57+
58+
// bufferedBytes returns the current buffered byte count.
59+
func (w *recvWindow) bufferedBytes() int64 {
60+
w.mu.Lock()
61+
defer w.mu.Unlock()
62+
return w.buffered
63+
}
64+
65+
// maybeGrantLocked releases the accrued returnable credit when the high-water
66+
// gate is open and the threshold is met, returning the delta (0 otherwise). It
67+
// must be called with w.mu held.
68+
func (w *recvWindow) maybeGrantLocked() int64 {
69+
if w.buffered < w.highWater && w.pending >= w.threshold {
70+
g := w.pending
71+
w.pending = 0
72+
return g
73+
}
74+
return 0
75+
}

drpcstream/recv_window_test.go

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
// Copyright (C) 2026 Cockroach Labs.
2+
// See LICENSE for copying information.
3+
4+
package drpcstream
5+
6+
import (
7+
"testing"
8+
9+
"github.com/zeebo/assert"
10+
)
11+
12+
// Below the high-water mark, accrued returnable credit is released as a grant
13+
// once it reaches the threshold, and withheld (0) below it.
14+
func TestRecvWindowGrantsAtThreshold(t *testing.T) {
15+
w := newRecvWindow(1000, 100)
16+
assert.Equal(t, w.dispatched(60), int64(0)) // pending 60 < 100
17+
assert.Equal(t, w.dispatched(60), int64(120)) // pending 120 >= 100, buffered 120 < 1000
18+
assert.Equal(t, w.bufferedBytes(), int64(120))
19+
assert.Equal(t, w.dispatched(50), int64(0)) // pending reset by the grant; 50 < 100
20+
}
21+
22+
// At or above the high-water mark, grants are withheld even when the accrued
23+
// credit is past the threshold.
24+
func TestRecvWindowWithholdsAtOrAboveHighWater(t *testing.T) {
25+
w := newRecvWindow(100, 50)
26+
assert.Equal(t, w.dispatched(120), int64(0)) // buffered 120 >= 100 -> withhold
27+
assert.Equal(t, w.bufferedBytes(), int64(120))
28+
}
29+
30+
// Consuming buffered data drops below the high-water mark and flushes the
31+
// credit accrued while the gate was closed (resume-on-consume).
32+
func TestRecvWindowResumesOnConsume(t *testing.T) {
33+
w := newRecvWindow(100, 50)
34+
assert.Equal(t, w.dispatched(120), int64(0)) // withheld; pending 120
35+
assert.Equal(t, w.consumed(30), int64(120)) // buffered 90 < 100 -> flush pending
36+
assert.Equal(t, w.bufferedBytes(), int64(90))
37+
}
38+
39+
// Consuming while the accrued credit is below the threshold emits no grant.
40+
func TestRecvWindowConsumeBelowThresholdNoGrant(t *testing.T) {
41+
w := newRecvWindow(1000, 100)
42+
assert.Equal(t, w.dispatched(50), int64(0)) // pending 50 < 100
43+
assert.Equal(t, w.consumed(20), int64(0)) // buffered 30, pending 50 < 100
44+
assert.Equal(t, w.bufferedBytes(), int64(30))
45+
}
46+
47+
// Many small dispatches coalesce into few grants, each carrying the accrued
48+
// amount.
49+
func TestRecvWindowCoalescesGrants(t *testing.T) {
50+
w := newRecvWindow(1_000_000, 100)
51+
grants, granted := 0, int64(0)
52+
for i := 0; i < 10; i++ {
53+
if g := w.dispatched(30); g > 0 {
54+
grants++
55+
granted += g
56+
}
57+
}
58+
// 10 dispatches of 30 (300 bytes) with threshold 100 coalesce into 2 grants
59+
// of 120 each; the remaining 60 stays accrued.
60+
assert.Equal(t, grants, 2)
61+
assert.Equal(t, granted, int64(240))
62+
}

0 commit comments

Comments
 (0)