Skip to content

Commit d7114d7

Browse files
Decompress: reuse the caller-supplied buffer for unknown-size frames
Decompress sized its destination from decompressSizeHint(src), which reads the frame's content-size field. Frames that do not carry that field make the hint fall back to a pessimistic upper bound (>= decompressSizeBufferLimit, i.e. 1 MB): this is the case for legacy zstd v0.5 frames and for streaming frames compressed without a pledged source size. When the caller passed a smaller-but-adequate buffer, Decompress discarded it and allocated that bound, so every such decode allocated at least 1 MB regardless of the real payload size. decompressSizeHint now also reports whether the size was read from the frame (foundHint). When it was not, Decompress and ctx.Decompress try the caller-supplied buffer first via DecompressInto -- which reports a too-small buffer without writing past it -- before falling back to the hint-sized allocation and then the stream API. When the size is known (the common case where the same zstd version compressed and decompressed) the original path is unchanged, so callers passing a too-small buffer do not pay for a failed attempt. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent aad66fa commit d7114d7

4 files changed

Lines changed: 215 additions & 25 deletions

File tree

zstd.go

Lines changed: 38 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -58,30 +58,34 @@ func cCompressBound(srcSize int) int {
5858
}
5959

6060
// decompressSizeHint tries to give a hint on how much of the output buffer size we should have
61-
// based on zstd frame descriptors. To prevent DOS from maliciously-created payloads, limit the size
62-
func decompressSizeHint(src []byte) int {
61+
// based on zstd frame descriptors. To prevent DOS from maliciously-created payloads, limit the size.
62+
// foundHint reports whether the size was read from the frame header. When it is false the returned
63+
// hint is only a pessimistic upper bound: the frame does not advertise its decompressed size (as is
64+
// the case for legacy zstd v0.5 frames and streaming frames compressed without a pledged size).
65+
func decompressSizeHint(src []byte) (hint int, foundHint bool) {
6366
// 1 MB or 50x input size
6467
upperBound := 50 * len(src)
6568
if upperBound < decompressSizeBufferLimit {
6669
upperBound = decompressSizeBufferLimit
6770
}
6871

69-
hint := upperBound
72+
hint = upperBound
7073
if len(src) >= zstdFrameHeaderSizeMin {
71-
hint = int(C.ZSTD_getFrameContentSize(unsafe.Pointer(&src[0]), C.size_t(len(src))))
72-
if hint < 0 { // On error, just use upperBound
73-
hint = upperBound
74-
}
75-
if hint == 0 { // When compressing the empty slice, we need an output of at least 1 to pass down to the C lib
76-
hint = 1
74+
contentSize := int(C.ZSTD_getFrameContentSize(unsafe.Pointer(&src[0]), C.size_t(len(src))))
75+
if contentSize >= 0 { // a negative value means the size is unknown or the header is in error
76+
foundHint = true
77+
hint = contentSize
78+
if hint == 0 { // When compressing the empty slice, we need an output of at least 1 to pass down to the C lib
79+
hint = 1
80+
}
7781
}
7882
}
7983

8084
// Take the minimum of both
8185
if hint > upperBound {
82-
return upperBound
86+
return upperBound, foundHint
8387
}
84-
return hint
88+
return hint, foundHint
8589
}
8690

8791
// Compress src into dst. If you have a buffer to use, you can pass it to
@@ -136,11 +140,31 @@ func Decompress(dst, src []byte) ([]byte, error) {
136140
return []byte{}, ErrEmptySlice
137141
}
138142

139-
bound := decompressSizeHint(src)
140-
if cap(dst) >= bound {
143+
hint, foundHint := decompressSizeHint(src)
144+
145+
// When the frame does not advertise its decompressed size, the hint is only
146+
// a pessimistic upper bound (>= decompressSizeBufferLimit). This happens for
147+
// legacy zstd v0.5 frames and for streaming frames compressed without a
148+
// pledged source size. Rather than discard an adequate caller-supplied
149+
// buffer and allocate that bound, try the caller buffer as-is first.
150+
// DecompressInto reports a too-small buffer without writing past it, so this
151+
// attempt is safe. When the size is known (the common case where the same
152+
// zstd version compressed and decompressed) we skip this and size from the
153+
// hint, so callers passing a too-small buffer do not pay for a failed attempt.
154+
if !foundHint && cap(dst) > 0 {
155+
written, err := DecompressInto(dst[:cap(dst)], src)
156+
if err == nil {
157+
return dst[:written], nil
158+
}
159+
if !IsDstSizeTooSmallError(err) {
160+
return nil, err
161+
}
162+
}
163+
164+
if cap(dst) >= hint {
141165
dst = dst[0:cap(dst)]
142166
} else {
143-
dst = make([]byte, bound)
167+
dst = make([]byte, hint)
144168
}
145169

146170
written, err := DecompressInto(dst, src)

zstd_bulk.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ func (p *BulkProcessor) Decompress(dst, src []byte) ([]byte, error) {
110110
return nil, ErrEmptySlice
111111
}
112112

113-
contentSize := decompressSizeHint(src)
113+
contentSize, _ := decompressSizeHint(src)
114114
if cap(dst) >= contentSize {
115115
dst = dst[0:cap(dst)]
116116
} else {

zstd_ctx.go

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -41,14 +41,14 @@ type ctx struct {
4141
}
4242

4343
// Create a new ZStd Context.
44-
// When compressing/decompressing many times, it is recommended to allocate a
45-
// context just once, and re-use it for each successive compression operation.
46-
// This will make workload friendlier for system's memory.
47-
// Note : re-using context is just a speed / resource optimization.
48-
// It doesn't change the compression ratio, which remains identical.
49-
// Note 2 : In multi-threaded environments,
50-
// use one different context per thread for parallel execution.
5144
//
45+
// When compressing/decompressing many times, it is recommended to allocate a
46+
// context just once, and re-use it for each successive compression operation.
47+
// This will make workload friendlier for system's memory.
48+
// Note : re-using context is just a speed / resource optimization.
49+
// It doesn't change the compression ratio, which remains identical.
50+
// Note 2 : In multi-threaded environments,
51+
// use one different context per thread for parallel execution.
5252
func NewCtx() Ctx {
5353
c := &ctx{
5454
cctx: C.ZSTD_createCCtx(),
@@ -106,11 +106,24 @@ func (c *ctx) Decompress(dst, src []byte) ([]byte, error) {
106106
return []byte{}, ErrEmptySlice
107107
}
108108

109-
bound := decompressSizeHint(src)
110-
if cap(dst) >= bound {
109+
hint, foundHint := decompressSizeHint(src)
110+
111+
// See Decompress: when the frame does not advertise its size, prefer a
112+
// usable caller buffer over allocating the pessimistic upper bound.
113+
if !foundHint && cap(dst) > 0 {
114+
written, err := c.DecompressInto(dst[:cap(dst)], src)
115+
if err == nil {
116+
return dst[:written], nil
117+
}
118+
if !IsDstSizeTooSmallError(err) {
119+
return nil, err
120+
}
121+
}
122+
123+
if cap(dst) >= hint {
111124
dst = dst[0:cap(dst)]
112125
} else {
113-
dst = make([]byte, bound)
126+
dst = make([]byte, hint)
114127
}
115128

116129
written, err := c.DecompressInto(dst, src)

zstd_reuse_buffer_test.go

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
package zstd
2+
3+
import (
4+
"bytes"
5+
"testing"
6+
)
7+
8+
// unknownSizeFrame returns a zstd frame that does not advertise its decompressed
9+
// size in the header. The streaming writer produces such frames, as do legacy
10+
// zstd v0.5 frames; ZSTD_getFrameContentSize reports "unknown" for both, so
11+
// decompressSizeHint falls back to its (large) upper bound.
12+
func unknownSizeFrame(t *testing.T, payload []byte) []byte {
13+
t.Helper()
14+
var b bytes.Buffer
15+
w := NewWriter(&b)
16+
if _, err := w.Write(payload); err != nil {
17+
t.Fatalf("write: %v", err)
18+
}
19+
if err := w.Close(); err != nil {
20+
t.Fatalf("close: %v", err)
21+
}
22+
return b.Bytes()
23+
}
24+
25+
type namedDecompressor struct {
26+
name string
27+
fn func(dst, src []byte) ([]byte, error)
28+
}
29+
30+
// decompressors are the entry points that share the caller-buffer-reuse logic.
31+
func decompressors() []namedDecompressor {
32+
return []namedDecompressor{
33+
{"Decompress", Decompress},
34+
{"ctx.Decompress", NewCtx().Decompress},
35+
}
36+
}
37+
38+
// TestDecompressReusesCallerBufferUnknownSize verifies that when a frame does
39+
// not advertise its content size, decompression uses an adequately sized caller
40+
// buffer instead of discarding it and allocating decompressSizeBufferLimit.
41+
func TestDecompressReusesCallerBufferUnknownSize(t *testing.T) {
42+
payload := bytes.Repeat([]byte("datadog-"), 525) // 4200 bytes
43+
frame := unknownSizeFrame(t, payload)
44+
45+
// Sanity: the frame has unknown content size, so the hint is the upper
46+
// bound (>= decompressSizeBufferLimit). Without reusing the caller buffer,
47+
// decompression would allocate that many bytes.
48+
if hint, found := decompressSizeHint(frame); found || hint < decompressSizeBufferLimit {
49+
t.Fatalf("expected unknown-size frame to hint the upper bound: found=%v hint=%d", found, hint)
50+
}
51+
52+
for _, d := range decompressors() {
53+
t.Run(d.name, func(t *testing.T) {
54+
buf := make([]byte, 8192) // adequate for the payload, far below the bound
55+
out, err := d.fn(buf, frame)
56+
if err != nil {
57+
t.Fatalf("decompress: %v", err)
58+
}
59+
if !bytes.Equal(out, payload) {
60+
t.Fatalf("round-trip mismatch")
61+
}
62+
if cap(out) != cap(buf) {
63+
t.Fatalf("caller buffer (cap %d) should be reused, got cap %d", cap(buf), cap(out))
64+
}
65+
})
66+
}
67+
}
68+
69+
// TestDecompressUnknownSizeTooSmallBuffer verifies the fallback still works when
70+
// the caller buffer is too small for an unknown-size frame.
71+
func TestDecompressUnknownSizeTooSmallBuffer(t *testing.T) {
72+
payload := bytes.Repeat([]byte("datadog-"), 525)
73+
frame := unknownSizeFrame(t, payload)
74+
75+
for _, d := range decompressors() {
76+
t.Run(d.name, func(t *testing.T) {
77+
out, err := d.fn(make([]byte, 8), frame) // too small; must fall back
78+
if err != nil {
79+
t.Fatalf("decompress: %v", err)
80+
}
81+
if !bytes.Equal(out, payload) {
82+
t.Fatalf("round-trip mismatch on fallback")
83+
}
84+
})
85+
}
86+
}
87+
88+
// TestDecompressUnknownSizeNilBuffer verifies nil dst still decompresses.
89+
func TestDecompressUnknownSizeNilBuffer(t *testing.T) {
90+
payload := bytes.Repeat([]byte("datadog-"), 525)
91+
frame := unknownSizeFrame(t, payload)
92+
93+
for _, d := range decompressors() {
94+
t.Run(d.name, func(t *testing.T) {
95+
out, err := d.fn(nil, frame)
96+
if err != nil {
97+
t.Fatalf("decompress: %v", err)
98+
}
99+
if !bytes.Equal(out, payload) {
100+
t.Fatalf("round-trip mismatch on nil dst")
101+
}
102+
})
103+
}
104+
}
105+
106+
// TestDecompressKnownSizeReusesCallerBuffer verifies that for frames that do
107+
// advertise their size, an adequate caller buffer is still reused (the common
108+
// path is unchanged).
109+
func TestDecompressKnownSizeReusesCallerBuffer(t *testing.T) {
110+
payload := bytes.Repeat([]byte("datadog-"), 525)
111+
frame, err := Compress(nil, payload)
112+
if err != nil {
113+
t.Fatalf("Compress: %v", err)
114+
}
115+
if _, found := decompressSizeHint(frame); !found {
116+
t.Fatal("Compress frame should advertise its content size")
117+
}
118+
119+
for _, d := range decompressors() {
120+
t.Run(d.name, func(t *testing.T) {
121+
buf := make([]byte, 8192)
122+
out, err := d.fn(buf, frame)
123+
if err != nil {
124+
t.Fatalf("decompress: %v", err)
125+
}
126+
if !bytes.Equal(out, payload) {
127+
t.Fatalf("round-trip mismatch")
128+
}
129+
if cap(out) != cap(buf) {
130+
t.Fatalf("caller buffer (cap %d) should be reused, got cap %d", cap(buf), cap(out))
131+
}
132+
})
133+
}
134+
}
135+
136+
// TestDecompressSizeHintFound verifies foundHint distinguishes frames that
137+
// advertise their content size (produced by Compress) from those that do not
138+
// (streaming, as here, and legacy zstd v0.5 frames).
139+
func TestDecompressSizeHintFound(t *testing.T) {
140+
payload := bytes.Repeat([]byte("datadog-"), 525)
141+
142+
known, err := Compress(nil, payload)
143+
if err != nil {
144+
t.Fatalf("Compress: %v", err)
145+
}
146+
if hint, found := decompressSizeHint(known); !found || hint != len(payload) {
147+
t.Fatalf("known-size frame: found=%v hint=%d, want found=true hint=%d", found, hint, len(payload))
148+
}
149+
150+
if _, found := decompressSizeHint(unknownSizeFrame(t, payload)); found {
151+
t.Fatalf("streaming frame should not advertise its content size")
152+
}
153+
}

0 commit comments

Comments
 (0)