carlos / platform Public

release: PutStreamer for S3, dir and prefix stores (geoip spec §4.1)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 713984d56d5df6cbc34bd0a1d9408e869d46b420 parent 6b7fe66
3 files changed, +196 −0
  • internal/fleetapi/fleetapi.go +5 −0
  • internal/release/putfrom_test.go +72 −0
  • internal/release/store.go +119 −0
diff --git a/internal/fleetapi/fleetapi.go b/internal/fleetapi/fleetapi.go
index c21ea153d..facc0d8dc 100644
--- a/internal/fleetapi/fleetapi.go
+++ b/internal/fleetapi/fleetapi.go
@@ -180,6 +180,11 @@ func (s *store) Open(ctx context.Context, key string) (io.ReadCloser, string, er
var _ release.Opener = (*store)(nil)
+// No release.PutStreamer here, deliberately: the console never writes
+// through a fleet's store door, and streaming would need a server side in
+// internal/edge/storedoor.go. release.PutObjectFrom falls back to Put for
+// bodies up to PutFromFallbackMax (geoip spec §4.1).
+
// Put mirrors the door's CAS mapping exactly (storedoor.go's
// putPrecondition / F4): ifMatch == "" sends If-None-Match: * (create-only,
// the contract's only form of an unconditional-looking write), a non-empty
diff --git a/internal/release/putfrom_test.go b/internal/release/putfrom_test.go
new file mode 100644
index 000000000..1c2bfc4f0
--- /dev/null
+++ b/internal/release/putfrom_test.go
@@ -0,0 +1,72 @@
+package release
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "io"
+ "strings"
+ "testing"
+)
+
+func TestDirStorePutFromMatchesPutSemantics(t *testing.T) {
+ ctx := context.Background()
+ d := NewDirStore(t.TempDir())
+ var _ PutStreamer = d
+ etag, err := d.PutFrom(ctx, "a/b.bin", strings.NewReader("hello"), 5, "")
+ if err != nil {
+ t.Fatal(err)
+ }
+ got, getEtag, err := d.Get(ctx, "a/b.bin")
+ if err != nil || string(got) != "hello" || getEtag != etag {
+ t.Fatalf("Get = (%q, %q, %v), want hello with etag %q", got, getEtag, err, etag)
+ }
+ if _, err := d.PutFrom(ctx, "a/b.bin", strings.NewReader("again"), 5, ""); !errors.Is(err, ErrCASConflict) {
+ t.Fatalf("create-only over an existing key = %v, want ErrCASConflict", err)
+ }
+ if _, err := d.PutFrom(ctx, "a/b.bin", strings.NewReader("next!"), 5, etag); err != nil {
+ t.Fatalf("replace with the right etag: %v", err)
+ }
+ if _, err := d.PutFrom(ctx, "a/b.bin", strings.NewReader("short"), 9, ""); err == nil {
+ t.Fatal("a body shorter than size must fail")
+ }
+}
+
+func TestPrefixStorePutFromRebases(t *testing.T) {
+ ctx := context.Background()
+ d := NewDirStore(t.TempDir())
+ p := NewPrefixStore(d, "fleets/x/")
+ ps, ok := p.(PutStreamer)
+ if !ok {
+ t.Fatal("prefixStore must forward PutFrom (codex R1-8)")
+ }
+ if _, err := ps.PutFrom(ctx, "k", strings.NewReader("v"), 1, ""); err != nil {
+ t.Fatal(err)
+ }
+ if b, _, err := d.Get(ctx, "fleets/x/k"); err != nil || string(b) != "v" {
+ t.Fatalf("rebased object = (%q, %v)", b, err)
+ }
+}
+
+type putOnlyStore struct{ Store }
+
+func TestPutObjectFromFallsBackOnlyForSmallBodies(t *testing.T) {
+ ctx := context.Background()
+ s := putOnlyStore{NewDirStore(t.TempDir())}
+ if _, err := PutObjectFrom(ctx, s, "small", bytes.NewReader([]byte("abc")), 3, ""); err != nil {
+ t.Fatalf("small fallback: %v", err)
+ }
+ big := io.NewSectionReader(zeroReaderAt{}, 0, PutFromFallbackMax+1)
+ if _, err := PutObjectFrom(ctx, s, "big", big, PutFromFallbackMax+1, ""); !errors.Is(err, ErrTooLargeForPut) {
+ t.Fatalf("big fallback = %v, want ErrTooLargeForPut", err)
+ }
+}
+
+type zeroReaderAt struct{}
+
+func (zeroReaderAt) ReadAt(p []byte, _ int64) (int, error) {
+ for i := range p {
+ p[i] = 0
+ }
+ return len(p), nil
+}
diff --git a/internal/release/store.go b/internal/release/store.go
index 93edf1c67..edb13047f 100644
--- a/internal/release/store.go
+++ b/internal/release/store.go
@@ -134,6 +134,12 @@ func (p prefixStore) Put(ctx context.Context, key string, body []byte, ifMatch s
return p.Store.Put(ctx, p.full(key), body, ifMatch)
}
+// PutFrom is written out, not promoted, for the reason Open is: a promoted
+// method would forward the UNREBASED key.
+func (p prefixStore) PutFrom(ctx context.Context, key string, body io.ReadSeeker, size int64, ifMatch string) (string, error) {
+ return PutObjectFrom(ctx, p.Store, p.full(key), body, size, ifMatch)
+}
+
func (p prefixStore) Delete(ctx context.Context, key string) error {
return p.Store.Delete(ctx, p.full(key))
}
@@ -229,10 +235,51 @@ type Opener interface {
var (
_ Opener = (*DirStore)(nil)
+
_ Opener = (*S3Store)(nil)
_ Opener = prefixStore{}
)
+// PutStreamer is the optional streaming write: Store.Put takes the whole
+// body as []byte, which is wrong for a 130 MB GeoIP database on a box
+// under MemoryHigh. Implemented by every production store; a fake with only
+// Put still works through PutObjectFrom for bodies up to PutFromFallbackMax.
+// ifMatch has Put's exact semantics ("" = create-only).
+type PutStreamer interface {
+ PutFrom(ctx context.Context, key string, body io.ReadSeeker, size int64, ifMatch string) (string, error)
+}
+
+var (
+ _ PutStreamer = (*DirStore)(nil)
+ _ PutStreamer = (*S3Store)(nil)
+ _ PutStreamer = prefixStore{}
+)
+
+// PutFromFallbackMax bounds how much PutObjectFrom will buffer for a store
+// with no PutFrom.
+const PutFromFallbackMax = 64 << 20
+
+// ErrTooLargeForPut is PutObjectFrom refusing to buffer a large body.
+var ErrTooLargeForPut = errors.New("store cannot stream and the body is too large to buffer")
+
+// PutObjectFrom streams body into s at key.
+func PutObjectFrom(ctx context.Context, s Store, key string, body io.ReadSeeker, size int64, ifMatch string) (string, error) {
+ if ps, ok := s.(PutStreamer); ok {
+ return ps.PutFrom(ctx, key, body, size, ifMatch)
+ }
+ if size > PutFromFallbackMax {
+ return "", fmt.Errorf("%s: %w", key, ErrTooLargeForPut)
+ }
+ b, err := io.ReadAll(io.LimitReader(body, size+1))
+ if err != nil {
+ return "", err
+ }
+ if int64(len(b)) != size {
+ return "", fmt.Errorf("%s: body is %d bytes, want %d", key, len(b), size)
+ }
+ return s.Put(ctx, key, b, ifMatch)
+}
+
// OpenObject streams key from s, buffering through Get when s cannot stream.
func OpenObject(ctx context.Context, s Store, key string) (io.ReadCloser, string, error) {
if o, ok := s.(Opener); ok {
@@ -373,6 +420,55 @@ func (d *DirStore) Put(_ context.Context, key string, body []byte, ifMatch strin
return etagOf(body), nil
}
+// PutFrom is Put streamed: the body goes to a temp file (hashed as it
+// goes, so the etag is the same content sha256 Put would give) and is
+// renamed into place under d.mu after the same CAS gate.
+func (d *DirStore) PutFrom(_ context.Context, key string, body io.ReadSeeker, size int64, ifMatch string) (string, error) {
+ p, err := d.path(key)
+ if err != nil {
+ return "", err
+ }
+ if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
+ return "", err
+ }
+ tmp, err := os.CreateTemp(filepath.Dir(p), filepath.Base(p)+".*.tmp")
+ if err != nil {
+ return "", err
+ }
+ defer os.Remove(tmp.Name())
+ h := sha256.New()
+ n, err := io.Copy(io.MultiWriter(tmp, h), io.LimitReader(body, size+1))
+ if cerr := tmp.Close(); err == nil {
+ err = cerr
+ }
+ if err != nil {
+ return "", err
+ }
+ if n != size {
+ return "", fmt.Errorf("dirstore: %s: body is %d bytes, want %d", key, n, size)
+ }
+ d.mu.Lock()
+ defer d.mu.Unlock()
+ cur, err := os.ReadFile(p)
+ exists := err == nil
+ if err != nil && !errors.Is(err, fs.ErrNotExist) {
+ return "", err
+ }
+ if ifMatch == "" && exists {
+ return "", fmt.Errorf("dirstore: %s exists: %w", key, ErrCASConflict)
+ }
+ if ifMatch != "" && (!exists || etagOf(cur) != ifMatch) {
+ return "", fmt.Errorf("dirstore: %s moved on: %w", key, ErrCASConflict)
+ }
+ if err := os.Chmod(tmp.Name(), 0o644); err != nil {
+ return "", err
+ }
+ if err := os.Rename(tmp.Name(), p); err != nil {
+ return "", err
+ }
+ return hex.EncodeToString(h.Sum(nil)), nil
+}
+
// List is ListMod minus the mtimes — one walk owns the prefix filter and the
// .tmp exclusion, so the two listings cannot drift.
func (d *DirStore) List(ctx context.Context, prefix string) ([]string, error) {
@@ -666,6 +762,29 @@ func (s *S3Store) Put(ctx context.Context, key string, body []byte, ifMatch stri
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{
+ Bucket: &s.Bucket,
+ Key: &key,
+ Body: body,
+ ContentLength: aws.Int64(size),
+ }
+ if ifMatch == "" {
+ in.IfNoneMatch = aws.String("*")
+ } else {
+ in.IfMatch = aws.String(ifMatch)
+ }
+ out, err := s.Client.PutObject(ctx, in)
+ if err != nil {
+ if s3CASConflict(err) || (ifMatch != "" && s3NotFound(err)) {
+ return "", fmt.Errorf("s3store: %s: %w", key, ErrCASConflict)
+ }
+ return "", err
+ }
+ return aws.ToString(out.ETag), nil
+}
+
// List is ListMod minus the stamps — one paginator loop serves both.
func (s *S3Store) List(ctx context.Context, prefix string) ([]string, error) {
kms, err := s.ListMod(ctx, prefix)