Skip to content

Commit 6a32501

Browse files
authored
fix(artifact-cas): stream uploads to object-store backends to bound CAS memory (#3343)
Signed-off-by: Javier Rodriguez <javier@chainloop.dev>
1 parent e77e078 commit 6a32501

12 files changed

Lines changed: 1217 additions & 50 deletions

File tree

app/artifact-cas/internal/service/bytestream.go

Lines changed: 219 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -115,29 +115,33 @@ func (s *ByteStreamService) Write(stream bytestream.ByteStream_WriteServer) erro
115115
}
116116

117117
s.log.Infow("msg", "artifact does not exist, uploading", "digest", req.resource.Digest, "name", req.resource.FileName)
118-
// Create a buffer that will be filled in the background before sending its content to the backend
119-
buffer := newStreamReader(info.MaxBytes)
120-
// Add data from first request
121-
if err = buffer.Write(req.GetData()); err != nil {
122-
if backend.IsUploadSizeExceeded(err) {
123-
return status.Error(codes.ResourceExhausted, err.Error())
124-
}
125-
return sl.LogAndMaskErr(err, s.log)
126-
}
127118

128-
// Start a goroutine that will fill the buffer in the background
129-
go bufferStream(ctx, stream, buffer, s.log)
119+
// Streaming-capable backends (object stores such as S3/Azure) are fed
120+
// directly from the client stream through an io.Pipe, so CAS memory stays
121+
// bounded by the chunk/pipe size regardless of artifact size (PFM-6923).
122+
// The OCI backend, whose push path needs the whole layer content up front,
123+
// does not advertise streaming and keeps the fully-buffered path.
124+
var committedSize int64
125+
if su, ok := storageBackend.(backend.StreamingUploader); ok && su.SupportsStreaming() {
126+
committedSize, err = s.streamUpload(ctx, stream, storageBackend, req, info.MaxBytes)
127+
} else {
128+
committedSize, err = s.bufferedUpload(ctx, stream, storageBackend, req, info.MaxBytes)
129+
}
130130

131-
// Block until the buffer has been filled or the upload process has been canceled
132-
// This implementation is suboptimal since it requires the content to be uploaded in memory before pushing it
133-
// This is due to the fact that our OCI push implementation does not support streaming/chunks for uncompressed layers
134-
// We can not use stream.Layer since it only supports compressed layers, we want to store raw data and set custom mimetypes
135-
// https://github.com/google/go-containerregistry/blob/main/pkg/v1/stream/README.md
136-
// TODO: Split content in multiple layers and do concurrent uploads/downloads
137-
err = <-buffer.errorChan
138-
139-
// Now it's time to check if the data provider has sent an error
131+
// Classify the outcome. The error may come from two distinct stages, which
132+
// must be treated differently:
133+
// - A backend Upload failure (backendUploadError) is always masked as an
134+
// internal error. It must NOT be interpreted as a client disconnect even
135+
// when it wraps a network reset/cancellation originating backend-side —
136+
// doing so would falsely report success and silently drop the artifact.
137+
// - A stream-read (feed) error is classified: a client disconnect is not a
138+
// failure, an exceeded size cap maps to ResourceExhausted, anything else
139+
// is masked.
140140
if err != nil {
141+
var backendErr *backendUploadError
142+
if errors.As(err, &backendErr) {
143+
return sl.LogAndMaskErr(backendErr.err, s.log)
144+
}
141145
if isClientDisconnect(err) {
142146
s.log.Infow("msg", "upload canceled", "digest", req.resource.Digest, "name", req.resource.FileName)
143147
return nil
@@ -148,22 +152,189 @@ func (s *ByteStreamService) Write(stream bytestream.ByteStream_WriteServer) erro
148152
return sl.LogAndMaskErr(err, s.log)
149153
}
150154

151-
s.log.Infow("msg", "artifact received, uploading now to backend", "name", req.resource.FileName, "digest", req.resource.Digest, "size", buffer.size)
152-
if err := storageBackend.Upload(ctx, buffer, req.resource); err != nil {
153-
return sl.LogAndMaskErr(err, s.log)
154-
}
155-
156-
s.log.Infow("msg", "upload finished", "name", req.resource.FileName, "digest", req.resource.Digest, "size", buffer.size)
155+
s.log.Infow("msg", "upload finished", "name", req.resource.FileName, "digest", req.resource.Digest, "size", committedSize)
157156
s.audit.Dispatch(&events.CASArtifactUploaded{
158157
CASArtifactBase: &events.CASArtifactBase{
159158
Digest: req.resource.Digest,
160-
SizeBytes: buffer.size,
159+
SizeBytes: committedSize,
161160
FileName: req.resource.FileName,
162161
BackendType: info.BackendType,
163162
},
164163
}, info)
165164

166-
return stream.SendAndClose(&bytestream.WriteResponse{CommittedSize: buffer.size})
165+
return stream.SendAndClose(&bytestream.WriteResponse{CommittedSize: committedSize})
166+
}
167+
168+
// bufferedUpload accumulates the whole artifact in memory before handing it to
169+
// the backend. This is required by the OCI backend: its push implementation
170+
// does not support streaming/chunked uploads for uncompressed layers (we can not
171+
// use stream.Layer since it only supports compressed layers, and we want to
172+
// store raw data with custom mimetypes), so it needs the full content up front.
173+
// https://github.com/google/go-containerregistry/blob/main/pkg/v1/stream/README.md
174+
// It returns the total number of bytes committed to the backend. Feed errors are
175+
// returned unwrapped (classified by the caller); backend Upload failures are
176+
// wrapped in backendUploadError so the caller always masks them.
177+
func (s *ByteStreamService) bufferedUpload(ctx context.Context, stream bytestream.ByteStream_WriteServer, storageBackend backend.Uploader, req *writeRequest, maxBytes int64) (int64, error) {
178+
// Create a buffer that will be filled in the background before sending its content to the backend
179+
buffer := newStreamReader(maxBytes)
180+
// Add data from the first request
181+
if err := buffer.Write(req.GetData()); err != nil {
182+
return 0, err
183+
}
184+
185+
// Start a goroutine that will fill the buffer in the background
186+
go bufferStream(ctx, stream, buffer, s.log)
187+
188+
// Block until the buffer has been filled or the upload process has been canceled
189+
if err := <-buffer.errorChan; err != nil {
190+
return 0, err
191+
}
192+
193+
s.log.Infow("msg", "artifact received, uploading now to backend", "name", req.resource.FileName, "digest", req.resource.Digest, "size", buffer.size)
194+
if err := storageBackend.Upload(ctx, buffer, req.resource); err != nil {
195+
return 0, &backendUploadError{err}
196+
}
197+
198+
return buffer.size, nil
199+
}
200+
201+
// streamUpload pipes the client stream straight into the backend's Upload
202+
// without buffering the whole artifact in memory. A background goroutine feeds
203+
// received chunks into an io.Pipe while Upload consumes the other end, so the
204+
// two run concurrently and peak memory stays bounded (PFM-6923). It returns the
205+
// total number of bytes committed to the backend.
206+
func (s *ByteStreamService) streamUpload(ctx context.Context, stream bytestream.ByteStream_WriteServer, storageBackend backend.Uploader, req *writeRequest, maxBytes int64) (int64, error) {
207+
pr, pw := io.Pipe()
208+
209+
var (
210+
uploadedSize int64
211+
feedErr error
212+
)
213+
done := make(chan struct{})
214+
go func() {
215+
defer close(done)
216+
uploadedSize, feedErr = feedPipe(ctx, stream, pw, req.GetData(), maxBytes, s.log, req.resource.Digest)
217+
// Closing with feedErr signals EOF to the reader when nil, or propagates
218+
// the failure so Upload stops reading.
219+
_ = pw.CloseWithError(feedErr)
220+
}()
221+
222+
uploadErr := storageBackend.Upload(ctx, streamingReader{pr}, req.resource)
223+
// If Upload returned without draining the pipe (a backend failure, or a
224+
// backend that reports success without reading to EOF), the feeding goroutine
225+
// may still be blocked on Write; closing the read end unblocks it. Then wait
226+
// for it so uploadedSize/feedErr are safe to read.
227+
_ = pr.CloseWithError(uploadErr)
228+
<-done
229+
230+
// errPipeConsumerGone means the feed only failed because the reader (Upload)
231+
// stopped consuming — a consequence of the upload outcome, not a genuine
232+
// stream-read failure, so the backend's own result is authoritative.
233+
if errors.Is(feedErr, errPipeConsumerGone) {
234+
feedErr = nil
235+
}
236+
237+
// A genuine feed-side error (client disconnect, exceeded size cap, stream
238+
// read failure) is the precise signal and takes precedence: when it occurs it
239+
// is what induced the backend error through the pipe. Returned unwrapped so
240+
// the caller classifies it (disconnect / ResourceExhausted / mask).
241+
if feedErr != nil {
242+
return 0, feedErr
243+
}
244+
// A backend failure is wrapped so the caller always masks it, never mistaking
245+
// a backend-side reset/cancellation for a client disconnect.
246+
if uploadErr != nil {
247+
return 0, &backendUploadError{uploadErr}
248+
}
249+
250+
return uploadedSize, nil
251+
}
252+
253+
// backendUploadError marks a failure returned by the storage backend's Upload,
254+
// as opposed to an error reading the client stream. Backend failures are always
255+
// masked as internal errors and are never interpreted as a client disconnect or
256+
// a size-cap violation, both of which only originate on the stream-read side.
257+
type backendUploadError struct{ err error }
258+
259+
func (e *backendUploadError) Error() string { return e.err.Error() }
260+
func (e *backendUploadError) Unwrap() error { return e.err }
261+
262+
// errPipeConsumerGone is returned by feedPipe when a write to the pipe fails,
263+
// which only happens once the reader (the backend Upload) has stopped consuming
264+
// — because Upload returned and streamUpload closed the read end, or because it
265+
// failed. It is not a genuine stream-read failure; streamUpload defers to the
266+
// backend's own error in that case.
267+
var errPipeConsumerGone = errors.New("pipe consumer stopped reading")
268+
269+
// streamingReader wraps the upload pipe reader with a stable string form. The
270+
// pipe is written to concurrently while the backend reads it; exposing the bare
271+
// *io.PipeReader lets a reflective consumer (a structured logger, a test's mock
272+
// matcher, etc.) walk the pipe's internal state and race with the writer. The
273+
// wrapper keeps io.Reader behaviour while presenting an opaque identity to fmt.
274+
type streamingReader struct {
275+
io.Reader
276+
}
277+
278+
func (streamingReader) String() string { return "cas-streaming-upload" }
279+
280+
// feedPipe forwards the artifact from the client stream into pw, enforcing the
281+
// max upload size as it goes. firstData is the payload already read from the
282+
// first request. It returns the total number of bytes forwarded.
283+
func feedPipe(ctx context.Context, stream bytestream.ByteStream_WriteServer, pw *io.PipeWriter, firstData []byte, maxSize int64, log *log.Helper, digest string) (int64, error) {
284+
var size int64
285+
write := func(data []byte) error {
286+
if len(data) == 0 {
287+
return nil
288+
}
289+
size += int64(len(data))
290+
if err := checkUploadSize(size, maxSize); err != nil {
291+
return err
292+
}
293+
if _, err := pw.Write(data); err != nil {
294+
// A write only fails once the reader has gone away; surface it as the
295+
// consumer-gone sentinel so streamUpload defers to the backend result
296+
// rather than treating this as a client-side stream failure.
297+
return errPipeConsumerGone
298+
}
299+
return nil
300+
}
301+
302+
// Forward the data from the first request.
303+
if err := write(firstData); err != nil {
304+
return size, err
305+
}
306+
307+
for {
308+
select {
309+
case <-ctx.Done():
310+
// DeadlineExceeded, or Canceled
311+
return size, ctx.Err()
312+
default:
313+
// Extract the next chunk of data from the stream request
314+
req, err := getWriteRequest(stream)
315+
if err != nil {
316+
// Finished reading the stream is not a real error
317+
if errors.Is(err, io.EOF) {
318+
return size, nil
319+
}
320+
return size, err
321+
}
322+
323+
// Forward this request's data first: a spec-compliant client may set
324+
// finish_write=true on the same message that carries the final chunk,
325+
// so the data must be written before the finish check or it is lost.
326+
if err := write(req.GetData()); err != nil {
327+
return size, err
328+
}
329+
330+
log.Debugw("msg", "upload chunk received (streaming)", "digest", digest, "currentSize", size, "maxSize", maxSize, "chunkSize", len(req.GetData()))
331+
332+
// Check if the client has finished sending data
333+
if req.GetFinishWrite() {
334+
return size, nil
335+
}
336+
}
337+
}
167338
}
168339

169340
// Server-side streaming RPC for reading blobs, implements the bytestream interface
@@ -250,18 +421,20 @@ func bufferStream(ctx context.Context, stream bytestream.ByteStream_WriteServer,
250421
return
251422
}
252423

253-
// Check if the client has finished sending data
254-
if req.GetFinishWrite() {
255-
return
256-
}
257-
258-
// Write the data to the buffer
424+
// Write the data first: a spec-compliant client may set
425+
// finish_write=true on the same message that carries the final chunk,
426+
// so the data must be buffered before the finish check or it is lost.
259427
if err = buffer.Write(req.GetData()); err != nil {
260428
bufferErr = err
261429
return
262430
}
263431

264432
log.Debugw("msg", "upload chunk received", "digest", req.resource.Digest, "currentSize", buffer.size, "maxSize", buffer.maxSize, "chunkSize", len(req.GetData()))
433+
434+
// Check if the client has finished sending data
435+
if req.GetFinishWrite() {
436+
return
437+
}
265438
}
266439
}
267440
}
@@ -290,16 +463,24 @@ func newStreamReader(maxSize int64) *streamReader {
290463
func (r *streamReader) Write(data []byte) error {
291464
r.size += int64(len(data))
292465

293-
// Check if the size of the buffer has exceeded the maximum allowed size
294-
// if maxSize is 0, then there is no limit
295-
if r.maxSize != 0 && r.size > r.maxSize {
296-
return backend.NewErrUploadSizeExceeded(r.size, r.maxSize)
466+
if err := checkUploadSize(r.size, r.maxSize); err != nil {
467+
return err
297468
}
298469

299470
_, err := r.Buffer.Write(data)
300471
return err
301472
}
302473

474+
// checkUploadSize returns an ErrUploadSizeExceeded when total exceeds maxSize.
475+
// maxSize == 0 means no limit. It is shared by the buffered (streamReader) and
476+
// streaming (feedPipe) paths so their cap semantics cannot drift.
477+
func checkUploadSize(total, maxSize int64) error {
478+
if maxSize != 0 && total > maxSize {
479+
return backend.NewErrUploadSizeExceeded(total, maxSize)
480+
}
481+
return nil
482+
}
483+
303484
type writeRequest struct {
304485
*bytestream.WriteRequest
305486
resource *v1.CASResource

0 commit comments

Comments
 (0)