Skip to content

Commit 81d5d61

Browse files
[PureGo] Harden stream lifecycle (4/4) (#573)
Signed-off-by: Zlata Stefanovic <zlata.stefanovic@databricks.com>
1 parent 8d41b4c commit 81d5d61

16 files changed

Lines changed: 1640 additions & 297 deletions
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
package stream
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"testing"
7+
"time"
8+
)
9+
10+
func BenchmarkCoreStreamAckPath(b *testing.B) {
11+
for _, tc := range []struct {
12+
name string
13+
batch bool
14+
}{
15+
{name: "single"},
16+
{name: "batch", batch: true},
17+
} {
18+
b.Run(tc.name, func(b *testing.B) {
19+
rpc := newFakeRPC()
20+
cfg := testConfig()
21+
cfg.MaxInflight = 1024
22+
cs := newCoreForTest(testParams(), cfg, newFakeOpener(rpc), nil)
23+
defer cs.Close()
24+
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
25+
defer cancel()
26+
27+
ackErr := make(chan error, 1)
28+
go func() {
29+
for offset := range b.N {
30+
select {
31+
case <-rpc.sends:
32+
rpc.ack(int64(offset))
33+
case <-ctx.Done():
34+
ackErr <- ctx.Err()
35+
return
36+
}
37+
}
38+
ackErr <- nil
39+
}()
40+
41+
record := []byte(`{"value":1}`)
42+
b.ReportAllocs()
43+
b.ResetTimer()
44+
for range b.N {
45+
var err error
46+
if tc.batch {
47+
_, err = cs.IngestBatch(ctx, [][]byte{record, record})
48+
} else {
49+
_, err = cs.Ingest(ctx, record)
50+
}
51+
if err != nil {
52+
b.Fatal(err)
53+
}
54+
}
55+
if err := cs.Flush(ctx); err != nil {
56+
b.Fatal(err)
57+
}
58+
if err := <-ackErr; err != nil {
59+
b.Fatal(err)
60+
}
61+
})
62+
}
63+
}
64+
65+
func BenchmarkBufferRequeue(b *testing.B) {
66+
for _, depth := range []int{1_000, 100_000, 1_000_000} {
67+
b.Run(fmt.Sprintf("depth_%d", depth), func(b *testing.B) {
68+
b.ReportAllocs()
69+
for range b.N {
70+
b.StopTimer()
71+
buf := newBuffer[encodedMsg](depth, 0)
72+
for i := range depth {
73+
if err := buf.enqueue(context.Background(), int64(i), dummyMsg(int64(i))); err != nil {
74+
b.Fatal(err)
75+
}
76+
if _, err := buf.next(context.Background()); err != nil {
77+
b.Fatal(err)
78+
}
79+
}
80+
b.StartTimer()
81+
buf.requeue()
82+
}
83+
})
84+
}
85+
}

purego/internal/stream/buffer.go

Lines changed: 134 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,10 @@
1010
package stream
1111

1212
import (
13+
"container/list"
1314
"context"
15+
"fmt"
16+
"math"
1417
"sync"
1518
)
1619

@@ -25,6 +28,7 @@ const defaultMaxInflight = 1_000_000
2528
type item[Req any] struct {
2629
offset int64
2730
payload Req
31+
weight int64
2832
}
2933

3034
type discardResult struct {
@@ -33,6 +37,14 @@ type discardResult struct {
3337
count int
3438
}
3539

40+
type capacityWaiter struct {
41+
weight int64
42+
ready chan struct{}
43+
granted bool
44+
err error
45+
elem *list.Element
46+
}
47+
3648
// buffer is the bounded, offset-assigning queue between the caller's Ingest
3749
// calls and the sender goroutine. Callers enqueue already-encoded messages;
3850
// the sender observes them (moves to in-flight); acks cause them to be
@@ -46,72 +58,145 @@ type discardResult struct {
4658
// - The supervisor calls requeue and drain, but only while the sender is
4759
// stopped — so next never runs concurrently with requeue or drain.
4860
//
49-
// All state (queue, flight, sem, cond) is private; the sender and receiver
61+
// All state (queue, flight, capacity, cond) is private; the sender and receiver
5062
// interact only through these methods, never by touching the fields directly.
5163
//
52-
// The semaphore enforces the MaxInflight cap: enqueue blocks once the cap is
53-
// reached and unblocks as acks arrive and discardThrough releases permits.
64+
// Count and payload-byte limits apply to queued plus in-flight items.
5465
type buffer[Req any] struct {
55-
mu sync.Mutex
56-
cond *sync.Cond
57-
queue []item[Req] // pending: enqueued but not yet observed by the sender
58-
flight []item[Req] // in-flight: observed by the sender, waiting for ack
59-
closed bool
60-
sem chan struct{} // capacity = maxInflight; held while item is in queue or flight
61-
doneOnce sync.Once
62-
doneCh chan struct{} // closed when the buffer is closed/drained; unblocks sem waiters
66+
mu sync.Mutex
67+
cond *sync.Cond
68+
queue []item[Req] // pending: enqueued but not yet observed by the sender
69+
flight []item[Req] // in-flight: observed by the sender, waiting for ack
70+
closed bool
71+
maxInflight int
72+
maxBufferedBytes int64
73+
usedItems int
74+
usedBytes int64
75+
accountingReset bool
76+
waiters *list.List
6377
}
6478

65-
func newBuffer[Req any](maxInflight int) *buffer[Req] {
66-
// Normalize a non-positive cap, which would otherwise deadlock (0) or
67-
// panic (<0) at the semaphore.
79+
func newBuffer[Req any](maxInflight int, byteLimit int64) *buffer[Req] {
80+
// Normalize a non-positive count cap, which would otherwise prevent every
81+
// reservation from being granted.
6882
if maxInflight <= 0 {
6983
maxInflight = defaultMaxInflight
7084
}
71-
// queue/flight grow on demand; don't preallocate to maxInflight, which with
72-
// the default 1M cap would reserve tens of MB per stream before a single
73-
// record is ingested. Only the semaphore is sized to the cap, since it is the
74-
// backpressure gate and its capacity defines the bound.
85+
maxBufferedBytes := int64(math.MaxInt64)
86+
if byteLimit > 0 {
87+
maxBufferedBytes = byteLimit
88+
}
7589
b := &buffer[Req]{
76-
sem: make(chan struct{}, maxInflight),
77-
doneCh: make(chan struct{}),
90+
maxInflight: maxInflight,
91+
maxBufferedBytes: maxBufferedBytes,
92+
waiters: list.New(),
7893
}
7994
b.cond = sync.NewCond(&b.mu)
8095
return b
8196
}
8297

83-
func (b *buffer[Req]) closeDone() {
84-
b.doneOnce.Do(func() { close(b.doneCh) })
85-
}
86-
87-
// reserve acquires one backpressure slot.
88-
func (b *buffer[Req]) reserve(ctx context.Context) error {
98+
// reserve acquires one item slot and payload-byte weight.
99+
func (b *buffer[Req]) reserve(ctx context.Context, weight int64) error {
89100
if err := ctx.Err(); err != nil {
90101
return err
91102
}
92-
select {
93-
case b.sem <- struct{}{}:
103+
if weight < 0 || weight > b.maxBufferedBytes {
104+
return fmt.Errorf("%w: buffered payload weight %d exceeds limit %d",
105+
ErrPayloadTooLarge, weight, b.maxBufferedBytes)
106+
}
107+
b.mu.Lock()
108+
if b.closed {
109+
b.mu.Unlock()
110+
return errClosed
111+
}
112+
if b.waiters.Len() == 0 && b.canReserveLocked(weight) {
113+
b.usedItems++
114+
b.usedBytes += weight
115+
b.mu.Unlock()
94116
return nil
117+
}
118+
waiter := &capacityWaiter{weight: weight, ready: make(chan struct{})}
119+
waiter.elem = b.waiters.PushBack(waiter)
120+
b.mu.Unlock()
121+
122+
select {
123+
case <-waiter.ready:
124+
return waiter.err
95125
case <-ctx.Done():
126+
b.mu.Lock()
127+
if waiter.granted {
128+
// drain may have closed the buffer and reset accounting after this
129+
// reservation was granted but before cancellation won the select.
130+
if !b.accountingReset {
131+
b.usedItems--
132+
b.usedBytes -= weight
133+
}
134+
} else if waiter.err == nil {
135+
if waiter.elem != nil {
136+
b.waiters.Remove(waiter.elem)
137+
waiter.elem = nil
138+
}
139+
}
140+
b.grantWaitersLocked()
141+
b.mu.Unlock()
96142
return ctx.Err()
97-
case <-b.doneCh:
98-
return errClosed
99143
}
100144
}
101145

102-
func (b *buffer[Req]) release() {
103-
<-b.sem
146+
func (b *buffer[Req]) canReserveLocked(weight int64) bool {
147+
return b.usedItems < b.maxInflight &&
148+
weight <= b.maxBufferedBytes-b.usedBytes
149+
}
150+
151+
func (b *buffer[Req]) grantWaitersLocked() {
152+
for !b.closed && b.waiters.Len() > 0 {
153+
front := b.waiters.Front()
154+
waiter := front.Value.(*capacityWaiter)
155+
if !b.canReserveLocked(waiter.weight) {
156+
return
157+
}
158+
b.waiters.Remove(front)
159+
waiter.elem = nil
160+
b.usedItems++
161+
b.usedBytes += waiter.weight
162+
waiter.granted = true
163+
close(waiter.ready)
164+
}
165+
}
166+
167+
func (b *buffer[Req]) failWaitersLocked(err error) {
168+
for element := b.waiters.Front(); element != nil; element = element.Next() {
169+
waiter := element.Value.(*capacityWaiter)
170+
waiter.err = err
171+
waiter.elem = nil
172+
close(waiter.ready)
173+
}
174+
b.waiters.Init()
175+
}
176+
177+
func (b *buffer[Req]) release(weight int64) {
178+
b.mu.Lock()
179+
if !b.accountingReset {
180+
b.usedItems--
181+
b.usedBytes -= weight
182+
b.grantWaitersLocked()
183+
}
184+
b.mu.Unlock()
104185
}
105186

106187
// append adds an item after reserve succeeds.
107-
func (b *buffer[Req]) append(offset int64, msg Req) error {
188+
func (b *buffer[Req]) append(offset int64, msg Req, weight int64) error {
108189
b.mu.Lock()
109190
if b.closed {
191+
if !b.accountingReset {
192+
b.usedItems--
193+
b.usedBytes -= weight
194+
}
110195
b.mu.Unlock()
111-
b.release()
196+
b.cond.Broadcast()
112197
return errClosed
113198
}
114-
b.queue = append(b.queue, item[Req]{offset: offset, payload: msg})
199+
b.queue = append(b.queue, item[Req]{offset: offset, payload: msg, weight: weight})
115200
b.mu.Unlock()
116201
b.cond.Signal()
117202
return nil
@@ -163,30 +248,28 @@ func (b *buffer[Req]) next(ctx context.Context) (item[Req], error) {
163248
}
164249

165250
// discardThrough removes every in-flight item whose offset is <= offset (all
166-
// now acknowledged by the server) and releases one semaphore slot per removed
167-
// item so blocked enqueue callers can proceed. It is the sender/receiver's only
168-
// hook for ack-driven eviction, keeping the buffer's internals (flight, sem,
169-
// cond) private. Returns the contiguous discarded offset range without
170-
// allocating per-item callback metadata.
251+
// now acknowledged by the server), releases its count and byte capacity, and
252+
// grants queued admission waiters in FIFO order. It is the receiver's only hook
253+
// for ack-driven eviction. Returns the contiguous discarded offset range
254+
// without allocating per-item callback metadata.
171255
func (b *buffer[Req]) discardThrough(offset int64) discardResult {
172256
b.mu.Lock()
173257
var result discardResult
258+
var releasedBytes int64
174259
for len(b.flight) > 0 && b.flight[0].offset <= offset {
175260
if result.count == 0 {
176261
result.first = b.flight[0].offset
177262
}
178263
result.last = b.flight[0].offset
179264
result.count++
265+
releasedBytes += b.flight[0].weight
180266
b.flight[0] = item[Req]{} // release the acked payload for GC
181267
b.flight = b.flight[1:]
182268
}
269+
b.usedItems -= result.count
270+
b.usedBytes -= releasedBytes
271+
b.grantWaitersLocked()
183272
b.mu.Unlock()
184-
for range result.count {
185-
<-b.sem
186-
}
187-
if result.count > 0 {
188-
b.cond.Broadcast()
189-
}
190273
return result
191274
}
192275

@@ -239,9 +322,12 @@ func (b *buffer[Req]) drain() []item[Req] {
239322
b.queue = nil
240323
b.flight = nil
241324
b.closed = true
325+
b.accountingReset = true
326+
b.usedItems = 0
327+
b.usedBytes = 0
328+
b.failWaitersLocked(errClosed)
242329
b.mu.Unlock()
243330
b.cond.Broadcast()
244-
b.closeDone()
245331
return all
246332
}
247333

@@ -250,9 +336,9 @@ func (b *buffer[Req]) drain() []item[Req] {
250336
func (b *buffer[Req]) close() {
251337
b.mu.Lock()
252338
b.closed = true
339+
b.failWaitersLocked(errClosed)
253340
b.mu.Unlock()
254341
b.cond.Broadcast()
255-
b.closeDone()
256342
}
257343

258344
// inFlight returns the number of items observed by the sender but not yet

0 commit comments

Comments
 (0)