hold: report blob size on read presigns so the appview can skip its S3 HEAD

distribution calls Stat before every blob GET and HEAD. The appview's
Stat asked the hold for a presigned HEAD URL and then HEADed S3 with it
purely to read Content-Length for the descriptor: two round trips to
learn one number.

The hold's getBlob response for OCI digests on GET and HEAD now carries
"size". It comes from the records index when a layer record exists (a
SQLite lookup on a new digest index, no network) and from a HeadObject
otherwise, which is where config blobs land. If storage says the object
does not exist the hold answers 404 instead of signing a URL that can
only fail. The PUT and ATProto CID paths are untouched.

The appview builds the descriptor from the reported size and makes no
S3 request. When the field is absent it HEADs the presigned URL as
before, so a new appview works against a hold that has not been
upgraded, and an old appview ignores the extra field. A hold 404 maps
to ErrBlobUnknown.

Tests prove the index answered by leaving the mock bucket empty and
counting zero HeadObject calls, prove the fallback with exactly one, and
count requests reaching the fake S3 origin on the appview side rather
than trusting the returned size.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Yf1ZVA7sXYhQNb9tCo1m5
This commit is contained in:
Evan Jarrett
2026-09-09 09:31:16 -05:00
co-authored by Claude Fable 5.1
parent 61a934debb
commit 034ea5988b
10 changed files with 729 additions and 38 deletions
+16 -1
View File
@@ -49,6 +49,12 @@ type MockS3Client struct {
HeadObjectCalls []HeadObjectCall
PutObjectCalls []PutObjectCall
// RealHeadObjectCalls records HeadObject calls. HeadObjectCalls above
// records PresignHeadObject, which is a different thing: one asks storage
// about an object, the other only mints a URL. Tests that care whether a
// code path actually touched S3 need to count the former.
RealHeadObjectCalls []HeadObjectCall
// Error injection for testing error handling
CreateMultipartError error
CompleteError error
@@ -118,6 +124,7 @@ func NewMockS3Client(testServerURL string) *MockS3Client {
GetObjectCalls: []GetObjectCall{},
HeadObjectCalls: []HeadObjectCall{},
PutObjectCalls: []PutObjectCall{},
RealHeadObjectCalls: []HeadObjectCall{},
}
}
@@ -209,9 +216,17 @@ func (m *MockS3Client) HeadObject(ctx context.Context, input *awss3.HeadObjectIn
}
key := aws.ToString(input.Key)
m.RealHeadObjectCalls = append(m.RealHeadObjectCalls, HeadObjectCall{
Bucket: aws.ToString(input.Bucket),
Key: key,
})
data, ok := m.Objects[key]
if !ok {
return nil, fmt.Errorf("NoSuchKey: object %s not found", key)
// Return the error type the SDK actually produces for a missing object
// on HeadObject, so callers that branch on s3.IsNotFound are exercised
// here the same way they are against real storage.
return nil, &s3types.NotFound{Message: aws.String(fmt.Sprintf("object %s not found", key))}
}
size := int64(len(data))
+39
View File
@@ -5,17 +5,21 @@ package s3
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"strings"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
awshttp "github.com/aws/aws-sdk-go-v2/aws/transport/http"
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/credentials"
awss3 "github.com/aws/aws-sdk-go-v2/service/s3"
s3types "github.com/aws/aws-sdk-go-v2/service/s3/types"
)
// S3Client defines the S3 operations used by the hold service.
@@ -282,6 +286,41 @@ func (s *S3Service) Stat(ctx context.Context, blobPath string) (int64, error) {
return 0, nil
}
// IsNotFound reports whether err is S3 saying the object does not exist, as
// opposed to any other failure (credentials, network, throttling, a 5xx).
//
// The distinction matters wherever a missing object is a legitimate answer
// rather than an outage: only a genuine "not there" should be turned into a
// 404, and a transient error must never be mistaken for one.
//
// HeadObject has no response body, so the SDK deserializes its errors from the
// HTTP status alone and a 404 arrives as *s3types.NotFound. GetObject and the
// other body-carrying operations report NoSuchKey instead. The raw response
// check is the belt and braces for S3-compatible backends whose error payload
// does not map onto either type.
func IsNotFound(err error) bool {
if err == nil {
return false
}
var notFound *s3types.NotFound
if errors.As(err, &notFound) {
return true
}
var noSuchKey *s3types.NoSuchKey
if errors.As(err, &noSuchKey) {
return true
}
var respErr *awshttp.ResponseError
if errors.As(err, &respErr) && respErr.HTTPStatusCode() == http.StatusNotFound {
return true
}
return false
}
// PutBytes uploads data to blobPath with the given content type.
func (s *S3Service) PutBytes(ctx context.Context, blobPath string, data []byte, contentType string) error {
key := s.s3Key(blobPath)