@@ -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 {
290463func (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+
303484type writeRequest struct {
304485 * bytestream.WriteRequest
305486 resource * v1.CASResource
0 commit comments