release: S3Store.Put stays byte for byte as reviewed; PutOnce restates its body with the per-call NopRetryer
The record-write census pins S3Store.Put's digest as a record transport, so the single attempt is its own copy of Put's condition and error mapping rather than a shared helper that would move Put.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1 file changed,
+30
−14
internal/release/store.go+30 −14
diff --git a/internal/release/store.go b/internal/release/store.go| index 5d1546134..3e3bf1f16 100644 |
| --- a/internal/release/store.go |
| +++ b/internal/release/store.go |
| @@ -827,19 +827,6 @@ func (s *S3Store) Open(ctx context.Context, key string) (io.ReadCloser, string, |
| } |
| func (s *S3Store) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) { |
| - return s.put(ctx, key, body, ifMatch) |
| -} |
| - |
| -// PutOnce is Put sent as one HTTP attempt: the per-call NopRetryer replaces |
| -// the client's default retryer for this call only (MaxAttempts 1, nothing |
| -// retried), so a timeout, a transport error or a 5xx comes back to the |
| -// caller after the one request, never resent by the SDK. The condition and |
| -// the error mapping are Put's. |
| -func (s *S3Store) PutOnce(ctx context.Context, key string, body []byte, ifMatch string) (string, error) { |
| - return s.put(ctx, key, body, ifMatch, func(o *awss3.Options) { o.Retryer = aws.NopRetryer{} }) |
| -} |
| - |
| -func (s *S3Store) put(ctx context.Context, key string, body []byte, ifMatch string, optFns ...func(*awss3.Options)) (string, error) { |
| in := &awss3.PutObjectInput{ |
| Bucket: &s.Bucket, |
| Key: &key, |
| @@ -851,7 +838,7 @@ func (s *S3Store) put(ctx context.Context, key string, body []byte, ifMatch stri |
| } else { |
| in.IfMatch = aws.String(ifMatch) // replace-only: 412 if the object moved on |
| } |
| - out, err := s.Client.PutObject(ctx, in, optFns...) |
| + out, err := s.Client.PutObject(ctx, in) |
| if err != nil { |
| // A replace-only write whose object has been DELETED under it comes |
| // back 404/NoSuchKey, not 412: there is no etag left to fail the |
| @@ -871,6 +858,35 @@ func (s *S3Store) put(ctx context.Context, key string, body []byte, ifMatch stri |
| return aws.ToString(out.ETag), nil |
| } |
| +// PutOnce is Put sent as one HTTP attempt: the per-call NopRetryer replaces |
| +// the client's default retryer for this call only (MaxAttempts 1, nothing |
| +// retried), so a timeout, a transport error or a 5xx comes back to the |
| +// caller after the one request, never resent by the SDK. The condition and |
| +// the error mapping are Put's, restated rather than shared so Put stays |
| +// byte for byte what the record-write census reviewed. |
| +func (s *S3Store) PutOnce(ctx context.Context, key string, body []byte, ifMatch string) (string, error) { |
| + in := &awss3.PutObjectInput{ |
| + Bucket: &s.Bucket, |
| + Key: &key, |
| + Body: bytes.NewReader(body), |
| + ContentLength: aws.Int64(int64(len(body))), |
| + } |
| + if ifMatch == "" { |
| + in.IfNoneMatch = aws.String("*") // create-only: 412 if someone created it first |
| + } else { |
| + in.IfMatch = aws.String(ifMatch) // replace-only: 412 if the object moved on |
| + } |
| + out, err := s.Client.PutObject(ctx, in, func(o *awss3.Options) { o.Retryer = aws.NopRetryer{} }) |
| + if err != nil { |
| + // As Put: a 404 under If-Match is a lost conditional write. |
| + if s3CASConflict(err) || (ifMatch != "" && s3NotFound(err)) { |
| + return "", fmt.Errorf("s3store: %s: %w", key, ErrCASConflict) |
| + } |
| + return "", err |
| + } |
| + return aws.ToString(out.ETag), nil |
| +} |
| + |
| // PutFrom is Put with a streamed, seekable body and an explicit length. |
| func (s *S3Store) PutFrom(ctx context.Context, key string, body io.ReadSeeker, size int64, ifMatch string) (string, error) { |
| in := &awss3.PutObjectInput{ |