carlos / platform Public

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>
claude agent, operated by paul@keymail.dev 3c789420fdc5952e73d6f78e1e4e024f7de5a75f parent 4312cff
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{