Skip to content

Commit 7fb0331

Browse files
drpcstream: add per-stream send-window credit primitive
Add sendWindow, the per-stream flow-control credit balance on the sender. acquire spends credit, blocking until enough is available; grant adds credit; close terminates the window. acquire is interruptible by both context cancellation and close, consuming no credit when it bails. Grants are no-revoke: grant is strictly additive and the receiver never takes back credit it has already issued. The balance is a signed int64; acquire never drives it below zero, but the enablement layer may debit credit for bytes sent before flow control begins enforcing, leaving it negative, in which case acquire blocks until grants restore it. This is the primitive only -- it is not yet wired into the stream write path (that gate comes in a later commit). Co-Authored-By: roachdev-claude <roachdev-claude-bot@cockroachlabs.com>
1 parent 4273044 commit 7fb0331

2 files changed

Lines changed: 217 additions & 0 deletions

File tree

drpcstream/send_window.go

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
// Copyright (C) 2026 Cockroach Labs.
2+
// See LICENSE for copying information.
3+
4+
package drpcstream
5+
6+
import (
7+
"context"
8+
"sync"
9+
)
10+
11+
// sendWindow is a per-stream flow-control credit balance on the sender. It
12+
// tracks how many more bytes the stream is allowed to put on the wire right
13+
// now. acquire spends credit (blocking until enough is available), grant adds
14+
// credit, and close terminates the window.
15+
//
16+
// Grants are no-revoke: grant is strictly additive, and the receiver never
17+
// takes back credit it has already issued. The balance is a signed int64;
18+
// acquire never drives it below zero, but the enablement layer may debit credit
19+
// for bytes sent before flow control begins enforcing, leaving it negative, in
20+
// which case acquire blocks until grants restore it.
21+
type sendWindow struct {
22+
mu sync.Mutex
23+
avail int64 // available credit; signed (may be negative — see doc)
24+
closed bool // set once by close; no further acquires succeed
25+
err error // terminal error returned by acquire after close
26+
notify chan struct{} // closed+replaced to wake parked acquirers
27+
}
28+
29+
// newSendWindow returns a sendWindow seeded with initial credit.
30+
func newSendWindow(initial int64) *sendWindow {
31+
return &sendWindow{avail: initial, notify: make(chan struct{})}
32+
}
33+
34+
// available returns the current credit balance.
35+
func (w *sendWindow) available() int64 {
36+
w.mu.Lock()
37+
defer w.mu.Unlock()
38+
return w.avail
39+
}
40+
41+
// acquire blocks until n bytes of credit are available, then debits them and
42+
// returns nil. It returns early if ctx is canceled (with ctx.Err()) or the
43+
// window is closed (with the close error), consuming no credit in either case.
44+
func (w *sendWindow) acquire(ctx context.Context, n int64) error {
45+
for {
46+
w.mu.Lock()
47+
if w.closed {
48+
err := w.err
49+
w.mu.Unlock()
50+
return err
51+
}
52+
if w.avail >= n {
53+
w.avail -= n
54+
w.mu.Unlock()
55+
return nil
56+
}
57+
// Snapshot the notify channel under the lock before parking, so a grant
58+
// or close that fires the instant we unlock is not missed.
59+
ch := w.notify
60+
w.mu.Unlock()
61+
62+
select {
63+
case <-ch:
64+
// Credit was granted or the window closed; loop and re-check.
65+
case <-ctx.Done():
66+
return ctx.Err()
67+
}
68+
}
69+
}
70+
71+
// grant adds n bytes of credit and wakes any parked acquirer. Grants after
72+
// close are ignored (the window is dead).
73+
func (w *sendWindow) grant(n int64) {
74+
w.mu.Lock()
75+
if !w.closed {
76+
w.avail += n
77+
w.wakeLocked()
78+
}
79+
w.mu.Unlock()
80+
}
81+
82+
// close terminates the window with err, waking every parked acquirer, which
83+
// then returns err. Subsequent acquires also return err. It is a no-op if the
84+
// window is already closed.
85+
func (w *sendWindow) close(err error) {
86+
w.mu.Lock()
87+
if !w.closed {
88+
w.closed = true
89+
w.err = err
90+
w.wakeLocked()
91+
}
92+
w.mu.Unlock()
93+
}
94+
95+
// wakeLocked broadcasts to all parked acquirers by closing the current notify
96+
// channel and installing a fresh one. It must be called with w.mu held.
97+
func (w *sendWindow) wakeLocked() {
98+
close(w.notify)
99+
w.notify = make(chan struct{})
100+
}

drpcstream/send_window_test.go

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
// Copyright (C) 2026 Cockroach Labs.
2+
// See LICENSE for copying information.
3+
4+
package drpcstream
5+
6+
import (
7+
"context"
8+
"errors"
9+
"testing"
10+
"time"
11+
12+
"github.com/zeebo/assert"
13+
"github.com/zeebo/errs"
14+
)
15+
16+
// blockShort is how long we wait to conclude that an acquire is (correctly)
17+
// blocked before we release it.
18+
const blockShort = 20 * time.Millisecond
19+
20+
func TestSendWindowAcquireImmediate(t *testing.T) {
21+
w := newSendWindow(1000)
22+
assert.Equal(t, w.available(), int64(1000))
23+
24+
assert.NoError(t, w.acquire(context.Background(), 400))
25+
assert.Equal(t, w.available(), int64(600))
26+
27+
assert.NoError(t, w.acquire(context.Background(), 600))
28+
assert.Equal(t, w.available(), int64(0))
29+
}
30+
31+
func TestSendWindowGrantsAccumulate(t *testing.T) {
32+
w := newSendWindow(0)
33+
w.grant(100)
34+
w.grant(50)
35+
assert.Equal(t, w.available(), int64(150))
36+
37+
assert.NoError(t, w.acquire(context.Background(), 150))
38+
assert.Equal(t, w.available(), int64(0))
39+
}
40+
41+
func TestSendWindowAcquireBlocksUntilGrant(t *testing.T) {
42+
w := newSendWindow(100)
43+
done := make(chan error, 1)
44+
go func() { done <- w.acquire(context.Background(), 300) }()
45+
46+
// Not enough credit yet: acquire must block.
47+
select {
48+
case <-done:
49+
t.Fatal("acquire returned before sufficient credit")
50+
case <-time.After(blockShort):
51+
}
52+
53+
w.grant(250) // 100 + 250 = 350 >= 300
54+
55+
select {
56+
case err := <-done:
57+
assert.NoError(t, err)
58+
case <-time.After(time.Second):
59+
t.Fatal("acquire did not return after grant")
60+
}
61+
assert.Equal(t, w.available(), int64(50))
62+
}
63+
64+
func TestSendWindowAcquireContextCancel(t *testing.T) {
65+
w := newSendWindow(0)
66+
ctx, cancel := context.WithCancel(context.Background())
67+
done := make(chan error, 1)
68+
go func() { done <- w.acquire(ctx, 100) }()
69+
70+
select {
71+
case <-done:
72+
t.Fatal("acquire returned before cancellation")
73+
case <-time.After(blockShort):
74+
}
75+
76+
cancel()
77+
78+
select {
79+
case err := <-done:
80+
assert.That(t, errors.Is(err, context.Canceled))
81+
case <-time.After(time.Second):
82+
t.Fatal("acquire did not wake on context cancellation")
83+
}
84+
// Credit was not consumed by a failed acquire.
85+
assert.Equal(t, w.available(), int64(0))
86+
}
87+
88+
func TestSendWindowCloseWakesAcquire(t *testing.T) {
89+
w := newSendWindow(0)
90+
closeErr := errs.New("terminated")
91+
done := make(chan error, 1)
92+
go func() { done <- w.acquire(context.Background(), 100) }()
93+
94+
select {
95+
case <-done:
96+
t.Fatal("acquire returned before close")
97+
case <-time.After(blockShort):
98+
}
99+
100+
w.close(closeErr)
101+
102+
select {
103+
case err := <-done:
104+
assert.That(t, errors.Is(err, closeErr))
105+
case <-time.After(time.Second):
106+
t.Fatal("acquire did not wake on close")
107+
}
108+
}
109+
110+
func TestSendWindowAcquireAfterClose(t *testing.T) {
111+
w := newSendWindow(1000)
112+
closeErr := errs.New("closed")
113+
w.close(closeErr)
114+
115+
// Even though credit is available, a closed window returns the close error.
116+
assert.That(t, errors.Is(w.acquire(context.Background(), 1), closeErr))
117+
}

0 commit comments

Comments
 (0)