Skip to content

Commit d2d3a06

Browse files
committed
Add fixed-size circular byte buffer
Introduce RingFixed, a fixed-capacity circular byte buffer that implements io.Writer. When full, new writes overwrite the oldest data, retaining only the most recent N bytes. Useful for capturing the tail of command output or log streams where memory must be bounded. Signed-off-by: Davanum Srinivas <davanum@gmail.com>
1 parent bc988d5 commit d2d3a06

2 files changed

Lines changed: 303 additions & 0 deletions

File tree

buffer/ring_fixed.go

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
/*
2+
Copyright 2025 The Kubernetes Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package buffer
18+
19+
import (
20+
"errors"
21+
)
22+
23+
// ErrInvalidSize is returned when a non-positive size is provided to NewRingFixed.
24+
var ErrInvalidSize = errors.New("size must be positive")
25+
26+
// RingFixed implements a fixed-size circular byte buffer. New writes overwrite
27+
// older data, such that for a buffer of size N, only the last N bytes written
28+
// are retained. It implements io.Writer.
29+
// Not thread safe.
30+
type RingFixed struct {
31+
data []byte
32+
size int
33+
writeCursor int
34+
written int64
35+
}
36+
37+
// NewRingFixed creates a new fixed-size circular byte buffer of the given size.
38+
// The size must be greater than 0.
39+
func NewRingFixed(size int) (*RingFixed, error) {
40+
if size <= 0 {
41+
return nil, ErrInvalidSize
42+
}
43+
return &RingFixed{
44+
data: make([]byte, size),
45+
size: size,
46+
}, nil
47+
}
48+
49+
// Write writes p to the buffer, overwriting older data if necessary.
50+
// It always returns len(p), nil.
51+
func (r *RingFixed) Write(p []byte) (int, error) {
52+
originalLen := len(p)
53+
r.written += int64(originalLen)
54+
55+
// If the input is larger than our buffer, only keep the last 'size' bytes
56+
if originalLen > r.size {
57+
p = p[originalLen-r.size:]
58+
}
59+
60+
// Copy data, handling wrap-around
61+
n := len(p)
62+
remain := r.size - r.writeCursor
63+
if n <= remain {
64+
copy(r.data[r.writeCursor:], p)
65+
} else {
66+
copy(r.data[r.writeCursor:], p[:remain])
67+
copy(r.data, p[remain:])
68+
}
69+
70+
r.writeCursor = (r.writeCursor + n) % r.size
71+
return originalLen, nil
72+
}
73+
74+
// Bytes returns the contents of the buffer in the order they were written.
75+
// The returned slice should not be modified by the caller.
76+
func (r *RingFixed) Bytes() []byte {
77+
if r.written == 0 {
78+
return nil
79+
}
80+
81+
// Buffer hasn't wrapped yet
82+
if r.written < int64(r.size) {
83+
return r.data[:r.writeCursor]
84+
}
85+
86+
// Buffer has wrapped - need to return data in correct order
87+
// Data from writeCursor to end is oldest, data from 0 to writeCursor is newest
88+
if r.writeCursor == 0 {
89+
return r.data
90+
}
91+
92+
out := make([]byte, r.size)
93+
copy(out, r.data[r.writeCursor:])
94+
copy(out[r.size-r.writeCursor:], r.data[:r.writeCursor])
95+
return out
96+
}
97+
98+
// String returns the contents of the buffer as a string.
99+
func (r *RingFixed) String() string {
100+
return string(r.Bytes())
101+
}
102+
103+
// Size returns the capacity of the buffer.
104+
func (r *RingFixed) Size() int {
105+
return r.size
106+
}
107+
108+
// Len returns the number of bytes currently stored in the buffer.
109+
func (r *RingFixed) Len() int {
110+
if r.written < int64(r.size) {
111+
return int(r.written)
112+
}
113+
return r.size
114+
}
115+
116+
// TotalWritten returns the total number of bytes ever written to the buffer.
117+
func (r *RingFixed) TotalWritten() int64 {
118+
return r.written
119+
}
120+
121+
// Reset clears the buffer, discarding all data.
122+
func (r *RingFixed) Reset() {
123+
r.writeCursor = 0
124+
r.written = 0
125+
}

buffer/ring_fixed_test.go

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
/*
2+
Copyright 2025 The Kubernetes Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package buffer
18+
19+
import (
20+
"io"
21+
"testing"
22+
)
23+
24+
func TestRingFixedNew(t *testing.T) {
25+
t.Parallel()
26+
27+
buf, err := NewRingFixed(10)
28+
if err != nil {
29+
t.Errorf("size 10: unexpected error: %v", err)
30+
}
31+
if buf.Size() != 10 {
32+
t.Errorf("Size() = %d, want 10", buf.Size())
33+
}
34+
if _, err := NewRingFixed(1); err != nil {
35+
t.Errorf("size 1: unexpected error: %v", err)
36+
}
37+
if _, err := NewRingFixed(0); err != ErrInvalidSize {
38+
t.Errorf("size 0: expected ErrInvalidSize, got %v", err)
39+
}
40+
if _, err := NewRingFixed(-1); err != ErrInvalidSize {
41+
t.Errorf("size -1: expected ErrInvalidSize, got %v", err)
42+
}
43+
}
44+
45+
func TestRingFixedWriterInterface(t *testing.T) {
46+
t.Parallel()
47+
var _ io.Writer = &RingFixed{}
48+
}
49+
50+
func TestRingFixedWrite(t *testing.T) {
51+
t.Parallel()
52+
53+
tests := []struct {
54+
name string
55+
size int
56+
writes []string
57+
wantString string
58+
wantLen int
59+
wantWritten int64
60+
}{
61+
{"short write", 1024, []string{"hello world"}, "hello world", 11, 11},
62+
{"full write", 11, []string{"hello world"}, "hello world", 11, 11},
63+
{"long write", 6, []string{"hello world"}, " world", 6, 11},
64+
{"huge write", 3, []string{"hello world"}, "rld", 3, 11},
65+
{"empty write", 10, []string{"hello", ""}, "hello", 5, 5},
66+
{"size one", 1, []string{"a", "b", "xyz"}, "z", 1, 5},
67+
{"overwrite", 10, []string{"0123456789", "abc"}, "3456789abc", 10, 13},
68+
{"multiple small", 10, []string{"aa", "bb", "cc", "dd", "ee", "ff"}, "bbccddeeff", 10, 12},
69+
{"many single bytes", 3, []string{"h", "e", "l", "l", "o", " ", "w", "o", "r", "l", "d"}, "rld", 3, 11},
70+
{
71+
"multi part",
72+
16,
73+
[]string{"hello world\n", "this is a test\n", "my cool input\n"},
74+
"t\nmy cool input\n",
75+
16,
76+
41,
77+
},
78+
}
79+
80+
for _, tt := range tests {
81+
t.Run(tt.name, func(t *testing.T) {
82+
buf, _ := NewRingFixed(tt.size)
83+
for _, w := range tt.writes {
84+
buf.Write([]byte(w))
85+
}
86+
if got := buf.String(); got != tt.wantString {
87+
t.Errorf("String() = %q, want %q", got, tt.wantString)
88+
}
89+
if got := buf.Len(); got != tt.wantLen {
90+
t.Errorf("Len() = %d, want %d", got, tt.wantLen)
91+
}
92+
if got := buf.TotalWritten(); got != tt.wantWritten {
93+
t.Errorf("TotalWritten() = %d, want %d", got, tt.wantWritten)
94+
}
95+
})
96+
}
97+
}
98+
99+
func TestRingFixedWriteReturnValue(t *testing.T) {
100+
t.Parallel()
101+
102+
buf, _ := NewRingFixed(5)
103+
104+
// Write returns original length even when truncated
105+
if n, err := buf.Write([]byte("hello world")); n != 11 || err != nil {
106+
t.Errorf("Write() = (%d, %v), want (11, nil)", n, err)
107+
}
108+
}
109+
110+
func TestRingFixedReset(t *testing.T) {
111+
t.Parallel()
112+
113+
buf, _ := NewRingFixed(4)
114+
buf.Write([]byte("hello world\n"))
115+
buf.Write([]byte("this is a test\n"))
116+
buf.Reset()
117+
118+
if buf.Len() != 0 || buf.TotalWritten() != 0 || buf.Bytes() != nil {
119+
t.Errorf("after Reset: Len=%d, TotalWritten=%d, Bytes=%v; want 0, 0, nil",
120+
buf.Len(), buf.TotalWritten(), buf.Bytes())
121+
}
122+
123+
// Write after reset
124+
buf.Write([]byte("hello"))
125+
if got := buf.String(); got != "ello" {
126+
t.Errorf("after Reset+Write: String() = %q, want %q", got, "ello")
127+
}
128+
}
129+
130+
func TestRingFixedBytes(t *testing.T) {
131+
t.Parallel()
132+
133+
buf, _ := NewRingFixed(10)
134+
135+
// Empty
136+
if buf.Bytes() != nil {
137+
t.Errorf("empty buffer: Bytes() = %v, want nil", buf.Bytes())
138+
}
139+
140+
// Partial fill - returns slice of internal buffer
141+
buf.Write([]byte("hello"))
142+
if got := string(buf.Bytes()); got != "hello" {
143+
t.Errorf("partial fill: Bytes() = %q, want %q", got, "hello")
144+
}
145+
146+
// Exact fill at cursor 0 - returns internal buffer directly
147+
buf.Reset()
148+
buf.Write([]byte("0123456789"))
149+
if got := string(buf.Bytes()); got != "0123456789" {
150+
t.Errorf("exact fill: Bytes() = %q, want %q", got, "0123456789")
151+
}
152+
153+
// Wrapped - returns new slice with reordered data
154+
buf.Write([]byte("ab"))
155+
if got := string(buf.Bytes()); got != "23456789ab" {
156+
t.Errorf("wrapped: Bytes() = %q, want %q", got, "23456789ab")
157+
}
158+
}
159+
160+
func BenchmarkRingFixed_Write(b *testing.B) {
161+
b.ReportAllocs()
162+
buf, _ := NewRingFixed(1024)
163+
data := make([]byte, 100)
164+
165+
for i := 0; i < b.N; i++ {
166+
buf.Write(data)
167+
}
168+
}
169+
170+
func BenchmarkRingFixed_Bytes(b *testing.B) {
171+
b.ReportAllocs()
172+
buf, _ := NewRingFixed(1024)
173+
buf.Write(make([]byte, 2000)) // Ensure buffer is wrapped
174+
175+
for i := 0; i < b.N; i++ {
176+
_ = buf.Bytes()
177+
}
178+
}

0 commit comments

Comments
 (0)