Skip to content

Commit 980748f

Browse files
authored
fix(storage): compression upload & cache correctness fixes (#3231)
1 parent 40d690e commit 980748f

12 files changed

Lines changed: 536 additions & 58 deletions

packages/orchestrator/pkg/sandbox/block/streaming_chunk.go

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -290,13 +290,18 @@ func (c *Chunker) progressiveFetch(ctx context.Context, s *fetchSession, mmapSli
290290
var closeErr error
291291
res.stats, closeErr = reader.Close(context.WithoutCancel(ctx))
292292

293+
// A compressed frame's CRC is only verified once its footer is consumed
294+
// on Close, so a Close error is a verification failure that must fail
295+
// the fetch (don't cache or release a corrupt frame). A read error, if
296+
// any, takes precedence.
297+
if err == nil {
298+
err = closeErr
299+
}
300+
293301
ct := ft.CompressionType()
294302
attrs := storage.OKAttrs(c.objType, source, ct)
295-
switch {
296-
case err != nil:
303+
if err != nil {
297304
attrs = storage.ErrAttrs(c.objType, source, ct, err)
298-
case closeErr != nil:
299-
attrs = storage.ErrAttrs(c.objType, source, ct, closeErr)
300305
}
301306

302307
var readDur time.Duration
@@ -318,9 +323,14 @@ func (c *Chunker) progressiveFetch(ctx context.Context, s *fetchSession, mmapSli
318323
n, readErr := io.ReadFull(reader, mmapSlice[totalRead:readEnd])
319324
totalRead += int64(n)
320325

321-
if n > 0 {
326+
if n > 0 && !ft.IsCompressed() {
322327
// Dirty marking is deferred to runFetch after the full chunk is fetched.
323328
// With coarse dirty granularity, marking here would expose partially-written data.
329+
//
330+
// Compressed chunks are a single frame whose CRC is only verified
331+
// once the footer is consumed on Close. Releasing waiters here
332+
// would hand out bytes that a later CRC failure proves corrupt, so
333+
// their release is deferred to setDone after verification.
324334
s.advance(totalRead)
325335
}
326336

packages/orchestrator/pkg/sandbox/block/streaming_chunk_test.go

Lines changed: 46 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -50,10 +50,12 @@ func makeTestData(size int) []byte {
5050
// fakeSeekable implements storage.Seekable backed by in-memory data.
5151
// When ctrl is non-nil, reads are gated through its channels for concurrency tests.
5252
type fakeSeekable struct {
53-
data []byte
54-
failAfter int64 // >0: truncate reads at this offset; 0 = disabled
55-
fetchCount atomic.Int64
56-
ctrl *testControl // nil = ungated immediate reads
53+
data []byte
54+
failAfter int64 // >0: truncate reads at this offset; 0 = disabled
55+
corrupt bool // enable corruptByte injection
56+
corruptByte int64 // absolute offset to XOR 0xFF in served bytes (requires corrupt=true)
57+
fetchCount atomic.Int64
58+
ctrl *testControl // nil = ungated immediate reads
5759
}
5860

5961
var _ storage.Seekable = (*fakeSeekable)(nil)
@@ -126,7 +128,13 @@ func (s *fakeSeekable) OpenRangeReader(_ context.Context, offsetU int64, length
126128
end = min(end, s.failAfter)
127129
}
128130

129-
r := io.Reader(bytes.NewReader(s.data[fetchOff:end]))
131+
served := s.data[fetchOff:end]
132+
if s.corrupt && s.corruptByte >= fetchOff && s.corruptByte < end {
133+
served = bytes.Clone(served)
134+
served[s.corruptByte-fetchOff] ^= 0xFF
135+
}
136+
137+
r := io.Reader(bytes.NewReader(served))
130138
if frameTable.IsCompressed() {
131139
dec, err := storage.NewDecompressReader(storage.NewRangeReader(io.NopCloser(r)), frameTable.CompressionType(), storage.UnknownSource, storage.UnknownSeekableObjectType)
132140

@@ -645,3 +653,36 @@ func (r *controlledReader) Close(context.Context) (*storage.ReadStats, error) {
645653

646654
return nil, nil
647655
}
656+
657+
// TestChunker_CorruptCompressedFrameNotServedOrCached verifies the integrity
658+
// gap fix: a compressed frame whose CRC only fails at the footer (content
659+
// decodes to plausible bytes) must not be released to waiters or marked
660+
// cached. Without the fix, an exact-size read never triggers the codec's
661+
// footer CRC, the Close error was ignored, and the frame was advanced to
662+
// waiters and cached. Corrupts the final byte of the first compressed frame.
663+
func TestChunker_CorruptCompressedFrameNotServedOrCached(t *testing.T) {
664+
t.Parallel()
665+
666+
data := makeTestData(testFileSize)
667+
ft, file := makeCompressedTestData(t, data)
668+
669+
// Corrupt the last byte of the first frame's compressed range (footer).
670+
r, err := ft.LocateCompressed(0)
671+
require.NoError(t, err)
672+
file.corrupt = true
673+
file.corruptByte = r.Offset + int64(r.Length) - 1
674+
675+
chunker := newTestChunker(t, int64(len(data)))
676+
defer chunker.Close()
677+
678+
_, err = chunker.Slice(t.Context(), 0, testBlockSize, file, ft)
679+
require.Error(t, err, "corrupt compressed frame must surface an error, not serve unverified bytes")
680+
require.False(t, chunker.IsCached(t.Context(), 0, testBlockSize),
681+
"corrupt compressed frame must not be marked cached")
682+
683+
// A later read of a clean frame still works (chunker remains usable).
684+
lastOff := int64(testFileSize) - testBlockSize
685+
slice, err := chunker.Slice(t.Context(), lastOff, testBlockSize, file, ft)
686+
require.NoError(t, err)
687+
require.Equal(t, data[lastOff:], slice)
688+
}

packages/shared/pkg/storage/compress_decode.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,17 @@ func (r *decompressReader) Read(p []byte) (int, error) {
117117
}
118118

119119
func (r *decompressReader) Close(ctx context.Context) (*ReadStats, error) {
120+
// Drain any remaining decoded bytes so the codec consumes the frame footer
121+
// and surfaces a CRC/truncation error. A caller reading only the exact
122+
// uncompressed frame size never pulls the footer through, so zstd would
123+
// otherwise report success on a footer-corrupted or truncated frame.
124+
// Bounded: the source is a single frame.
125+
if r.readErr == nil {
126+
if _, err := io.Copy(io.Discard, r.meteredOut); err != nil && !errors.Is(err, io.EOF) {
127+
r.readErr = err
128+
}
129+
}
130+
120131
r.releaseCodec()
121132

122133
stats := &ReadStats{
@@ -130,5 +141,9 @@ func (r *decompressReader) Close(ctx context.Context) (*ReadStats, error) {
130141

131142
_, innerErr := r.inner.Close(ctx)
132143

144+
if r.readErr != nil {
145+
return stats, r.readErr
146+
}
147+
133148
return stats, innerErr
134149
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
package storage
2+
3+
import (
4+
"bytes"
5+
"crypto/sha256"
6+
"io"
7+
"testing"
8+
9+
"github.com/stretchr/testify/require"
10+
)
11+
12+
// TestDecompressReaderVerifiesCRCOnClose ensures a footer-corrupt frame is not
13+
// reported as success when the caller reads exactly the uncompressed size.
14+
// zstd only verifies the frame checksum once the footer is consumed, which an
15+
// exact-size read never triggers, so Close must drain-verify and surface the
16+
// error.
17+
func TestDecompressReaderVerifiesCRCOnClose(t *testing.T) {
18+
t.Parallel()
19+
20+
const frameU = 512 * 1024
21+
data := generateSemiRandomData(frameU)
22+
23+
up := &memPartUploader{}
24+
fullFT, _, err := compressStream(t.Context(), bytes.NewReader(data), defaultCfg(CompressionZstd, 1, frameU), up, 1, nil)
25+
require.NoError(t, err)
26+
require.Equal(t, 1, fullFT.Table().NumFrames())
27+
blob := up.Assemble()
28+
29+
// Corrupt the frame footer (last byte) so only the trailing checksum,
30+
// verified after the last content byte, is wrong.
31+
corrupt := bytes.Clone(blob)
32+
corrupt[len(corrupt)-1] ^= 0xFF
33+
34+
dec, err := NewDecompressReader(bytesRangeReader(corrupt), CompressionZstd, SourceAWS, SeekableObjectType(0))
35+
require.NoError(t, err)
36+
37+
// Read EXACTLY the uncompressed frame size — no read past EOF.
38+
buf := make([]byte, frameU)
39+
_, _ = io.ReadFull(dec, buf)
40+
_, closeErr := dec.Close(t.Context())
41+
require.Error(t, closeErr, "exact-size read of a footer-corrupt frame must fail on Close")
42+
43+
// A clean frame still round-trips and closes without error.
44+
dec2, err := NewDecompressReader(bytesRangeReader(blob), CompressionZstd, SourceAWS, SeekableObjectType(0))
45+
require.NoError(t, err)
46+
got := make([]byte, frameU)
47+
_, err = io.ReadFull(dec2, got)
48+
require.NoError(t, err)
49+
_, closeErr = dec2.Close(t.Context())
50+
require.NoError(t, closeErr)
51+
require.Equal(t, sha256.Sum256(data), sha256.Sum256(got))
52+
}

packages/shared/pkg/storage/compress_frame_table.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,14 @@ func (ft *FrameTable) LocateCompressed(offset int64) (Range, error) {
236236
return Range{}, err
237237
}
238238

239+
// The compressed range covers a whole frame; a mid-frame uncompressed
240+
// offset would fetch and decode that frame from its start, silently
241+
// returning data for the wrong position. Callers must frame-align via
242+
// LocateUncompressed, so reject anything else rather than mis-serve.
243+
if offset != e.StartU {
244+
return Range{}, fmt.Errorf("offset %d is not frame-aligned (frame starts at %d); align via LocateUncompressed", offset, e.StartU)
245+
}
246+
239247
return Range{Offset: e.StartC, Length: int(e.SizeC)}, nil
240248
}
241249

packages/shared/pkg/storage/compress_frame_table_test.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,16 @@ func TestLocate(t *testing.T) {
6666
require.Error(t, err)
6767
})
6868

69+
t.Run("mid-frame offset errors", func(t *testing.T) {
70+
t.Parallel()
71+
// A mid-frame uncompressed offset would fetch and decode the whole
72+
// containing frame from its start, silently returning data for the
73+
// wrong position. It must be rejected so callers frame-align first.
74+
_, err := ft.LocateCompressed((1 << 20) / 2)
75+
require.Error(t, err)
76+
require.Contains(t, err.Error(), "frame-aligned")
77+
})
78+
6979
t.Run("nil table errors", func(t *testing.T) {
7080
t.Parallel()
7181
_, err := (*FrameTable)(nil).LocateCompressed(0)

packages/shared/pkg/storage/gcp_multipart.go

Lines changed: 81 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import (
1515
"net/http"
1616
"os"
1717
"slices"
18+
"strings"
1819
"sync"
1920
"time"
2021

@@ -55,6 +56,7 @@ func createRetryableClient(ctx context.Context, config RetryConfig) *retryableht
5556
client.RetryMax = config.MaxAttempts - 1 // go-retryablehttp counts retries, not total attempts
5657
client.RetryWaitMin = config.InitialBackoff
5758
client.RetryWaitMax = config.MaxBackoff
59+
client.CheckRetry = retryOnCompleteError
5860

5961
// Custom backoff function with full jitter to avoid thundering herd
6062
client.Backoff = func(start, maxBackoff time.Duration, attemptNum int, _ *http.Response) time.Duration {
@@ -95,6 +97,40 @@ func createRetryableClient(ctx context.Context, config RetryConfig) *retryableht
9597
return client
9698
}
9799

100+
// retryOnCompleteError extends the default retry policy so a transient
101+
// CompleteMultipartUpload failure is retried like a 5xx. S3 and the GCS XML
102+
// API can return HTTP 200 with an <Error> body (e.g. InternalError) for a
103+
// commit that didn't happen; the default policy only looks at the status code,
104+
// so without this such a response fails on the first attempt and ignores the
105+
// configured retry budget. Only the complete request (URL query "uploadId=…",
106+
// small XML body) is inspected, so buffering the body to peek + restore it is
107+
// cheap.
108+
func retryOnCompleteError(ctx context.Context, resp *http.Response, err error) (bool, error) {
109+
if retry, rerr := retryablehttp.DefaultRetryPolicy(ctx, resp, err); retry || rerr != nil {
110+
return retry, rerr
111+
}
112+
113+
if resp == nil || resp.StatusCode != http.StatusOK || resp.Request == nil ||
114+
!strings.HasPrefix(resp.Request.URL.RawQuery, "uploadId=") || resp.Body == nil {
115+
return false, nil
116+
}
117+
118+
body, readErr := io.ReadAll(resp.Body)
119+
resp.Body.Close()
120+
// Restore the body so the caller (or a retry) can read it.
121+
resp.Body = io.NopCloser(bytes.NewReader(body))
122+
123+
// Retry when the body couldn't be fully read (transient truncation/reset
124+
// after the headers) or when it carries a transient <Error> (e.g.
125+
// InternalError). Crucially, a read failure must not fall through as
126+
// no-retry: that would hand completeUpload a clean, buffered partial body
127+
// and let it commit on a truncated response.
128+
var apiErr completeMultipartError
129+
retry := readErr != nil || (xml.Unmarshal(body, &apiErr) == nil && apiErr.Code != "")
130+
131+
return retry, nil //nolint:nilerr // decision is carried by the bool; returning readErr would abort the retry loop instead of retrying
132+
}
133+
98134
// zapLogger adapts zap.Logger to retryablehttp.LeveledLogger interface
99135
var _ retryablehttp.LeveledLogger = &leveledLogger{}
100136

@@ -134,6 +170,15 @@ type Part struct {
134170
ETag string `xml:"ETag"`
135171
}
136172

173+
// completeMultipartError matches the S3/GCS XML error payload that can arrive
174+
// with an HTTP 200 status on CompleteMultipartUpload. Unmarshal only succeeds
175+
// (Code populated) when the response root is <Error>.
176+
type completeMultipartError struct {
177+
XMLName xml.Name `xml:"Error"`
178+
Code string `xml:"Code"`
179+
Message string `xml:"Message"`
180+
}
181+
137182
type MultipartUploader struct {
138183
bucketName string
139184
objectName string
@@ -257,13 +302,20 @@ func (m *MultipartUploader) uploadPart(ctx context.Context, uploadID string, par
257302
url := fmt.Sprintf("%s/%s?partNumber=%d&uploadId=%s",
258303
m.baseURL, m.objectName, partNumber, uploadID)
259304

260-
req, err := retryablehttp.NewRequestWithContext(ctx, "PUT", url, bytes.NewReader(data))
305+
// A non-nil zero-length body counts as "unknown length" to net/http and
306+
// is sent chunked, which S3-compatible XML backends reject with 411. Pass
307+
// no body for empty parts so Content-Length: 0 is sent instead.
308+
var body any
309+
if len(data) > 0 {
310+
body = bytes.NewReader(data)
311+
}
312+
313+
req, err := retryablehttp.NewRequestWithContext(ctx, "PUT", url, body)
261314
if err != nil {
262315
return "", err
263316
}
264317

265318
req.Header.Set("Authorization", "Bearer "+m.token)
266-
req.Header.Set("Content-Length", fmt.Sprintf("%d", len(data)))
267319
sum := md5.Sum(data) //nolint:gosec // GCS multipart uses Content-MD5 for transport integrity.
268320
req.Header.Set("Content-MD5", base64.StdEncoding.EncodeToString(sum[:]))
269321

@@ -297,18 +349,26 @@ func (m *MultipartUploader) uploadPartSlices(ctx context.Context, uploadID strin
297349
url := fmt.Sprintf("%s/%s?partNumber=%d&uploadId=%s",
298350
m.baseURL, m.objectName, partNumber, uploadID)
299351

300-
// Use a ReaderFunc so the retryable client can replay the body on retries
301-
bodyFn := func() (io.Reader, error) {
302-
return newMultiSliceReader(slices), nil
352+
// Use a ReaderFunc so the retryable client can replay the body on
353+
// retries. The multiSliceReader's Len makes retryablehttp set the
354+
// request's ContentLength, so parts are sent with an explicit
355+
// Content-Length rather than chunked transfer encoding (which GCS
356+
// tolerates but S3-compatible XML backends reject with 411). Empty parts
357+
// send no body at all for the same reason: a zero-length reader still
358+
// counts as "unknown length" to net/http and would be chunked.
359+
var body any
360+
if totalLen > 0 {
361+
body = retryablehttp.ReaderFunc(func() (io.Reader, error) {
362+
return newMultiSliceReader(slices), nil
363+
})
303364
}
304365

305-
req, err := retryablehttp.NewRequestWithContext(ctx, "PUT", url, retryablehttp.ReaderFunc(bodyFn))
366+
req, err := retryablehttp.NewRequestWithContext(ctx, "PUT", url, body)
306367
if err != nil {
307368
return "", err
308369
}
309370

310371
req.Header.Set("Authorization", "Bearer "+m.token)
311-
req.Header.Set("Content-Length", fmt.Sprintf("%d", totalLen))
312372
h := md5.New() //nolint:gosec // GCS multipart uses Content-MD5 for transport integrity.
313373
for _, s := range slices {
314374
_, _ = h.Write(s)
@@ -365,12 +425,24 @@ func (m *MultipartUploader) completeUpload(ctx context.Context, uploadID string,
365425
}
366426
defer resp.Body.Close()
367427

368-
if resp.StatusCode != http.StatusOK {
369-
body, _ := io.ReadAll(resp.Body)
428+
body, readErr := io.ReadAll(resp.Body)
429+
if readErr != nil {
430+
return fmt.Errorf("read complete upload response (status %d): %w", resp.StatusCode, readErr)
431+
}
370432

433+
if resp.StatusCode != http.StatusOK {
371434
return fmt.Errorf("failed to complete upload (status %d): %s", resp.StatusCode, string(body))
372435
}
373436

437+
// S3 and the GCS XML API can return HTTP 200 with an <Error> body when the
438+
// commit fails server-side (documented for internal errors/timeouts).
439+
// Treating that as success would record a frame table for an object that
440+
// was never committed, so reject any response whose root is an Error.
441+
var apiErr completeMultipartError
442+
if xml.Unmarshal(body, &apiErr) == nil && apiErr.Code != "" {
443+
return fmt.Errorf("failed to complete upload (status %d, code %s): %s", resp.StatusCode, apiErr.Code, apiErr.Message)
444+
}
445+
374446
return nil
375447
}
376448

0 commit comments

Comments
 (0)