zeroshade commented on code in PR #2392:
URL: https://github.com/apache/iceberg-go/pull/2392#discussion_r4469929656
##########
io/gocloud/blobfs/blob.go:
##########
@@ -300,12 +407,17 @@ func (bfs *FileIO) Open(_ context.Context, path string)
(icebergio.File, error)
key, name := path, pathpkg.Base(path)
- r, err := bfs.NewReader(bfs.ctx, key, nil)
+ // Read only the attributes: a reader opened here would start a GET of
the
+ // whole object, which ReadAt, the way Parquet files are read, never
uses.
+ attrs, err := bfs.Attributes(bfs.ctx, key)
Review Comment:
`Attributes` is a HEAD request. For files read with `Open` + `Read`
(manifest lists, manifests), `main` costs 1 GET because `Read` consumes the GET
that `Open` starts; this costs HEAD + GET. Could the size come from the GET
response (`blob.Reader.Size()`) instead, with that response reused by `Read`
and by the first small-object `ReadAt`?
##########
io/gocloud/blobfs/blob.go:
##########
@@ -37,26 +37,131 @@ import (
"gocloud.dev/gcerrors"
)
+// wholeObjectReadMaxSize is the largest object ReadAt downloads whole on its
+// first call. Reading a Parquet file issues a ReadAt for its footer and for
+// every column chunk; for a small file one GET of the object is far cheaper
+// than one GET per column. The downloaded object is held until Close.
+const wholeObjectReadMaxSize = 8 << 20
+
// blobOpenFile describes a single open blob as a File.
// It implements the iceberg-go/io.File interface.
-// It is based on gocloud.dev/blob.iofsOpenFile which:
-// - Doesn't support the `io.ReaderAt` interface
-// - Is not externally accessible, so copied here
+//
+// Opening a blobOpenFile reads only the object's attributes. Read starts a
+// streaming GET from the current offset on first use, and ReadAt issues a
+// ranged GET per call, or downloads an object of at most
+// wholeObjectReadMaxSize once and serves every ReadAt from memory.
type blobOpenFile struct {
- *blob.Reader
-
name, key string
+ size int64
+ modTime time.Time
b *FileIO
ctx context.Context
+
+ mu sync.Mutex // guards r and pos, used by Read and Seek
+ r io.ReadCloser
+ pos int64
+
+ whole sync.Once // downloads data for ReadAt of a small object
+ data []byte
+ wholeErr error
}
-func (f *blobOpenFile) ReadAt(p []byte, off int64) (n int, err error) {
- var rdr io.ReadCloser
+func (f *blobOpenFile) rangeReader(off, length int64) (io.ReadCloser, error) {
if f.b.newRangeReader != nil {
- rdr, err = f.b.newRangeReader(f.ctx, f.key, off, int64(len(p)))
- } else {
- rdr, err = f.b.NewRangeReader(f.ctx, f.key, off, int64(len(p)),
nil)
+ return f.b.newRangeReader(f.ctx, f.key, off, length)
+ }
+
+ return f.b.NewRangeReader(f.ctx, f.key, off, length, nil)
+}
+
+func (f *blobOpenFile) Read(p []byte) (int, error) {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+
+ if f.pos >= f.size {
+ return 0, io.EOF
}
+ if f.r == nil {
+ r, err := f.rangeReader(f.pos, -1)
+ if err != nil {
+ return 0, err
+ }
+ f.r = r
+ }
+ n, err := f.r.Read(p)
+ f.pos += int64(n)
+
+ return n, err
+}
+
+func (f *blobOpenFile) Seek(offset int64, whence int) (int64, error) {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+
+ switch whence {
+ case io.SeekCurrent:
+ offset += f.pos
+ case io.SeekEnd:
+ offset += f.size
+ case io.SeekStart:
+ default:
+ return 0, fmt.Errorf("blobfs: invalid whence %d", whence)
+ }
+ if offset < 0 {
+ return 0, fmt.Errorf("blobfs: negative position %d", offset)
+ }
+ if offset != f.pos && f.r != nil {
+ err := f.r.Close()
+ f.r = nil
+ if err != nil {
+ return 0, err
+ }
+ }
+ f.pos = offset
+
+ return offset, nil
+}
+
+func (f *blobOpenFile) Close() error {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+
+ if f.r == nil {
+ return nil
+ }
+ err := f.r.Close()
+ f.r = nil
+
+ return err
+}
+
+func (f *blobOpenFile) ReadAt(p []byte, off int64) (int, error) {
+ if f.size > wholeObjectReadMaxSize {
+ return f.readRange(p, off)
+ }
+
+ f.whole.Do(func() {
+ data := make([]byte, f.size)
+ _, f.wholeErr = f.readRange(data, 0)
Review Comment:
`wholeErr` is stored by the `sync.Once`, so one transient GET failure makes
every later `ReadAt` on this file fail; on `main` each `ReadAt` was
independent. A zero-byte object also sends a zero-length GET here.
##########
io/gocloud/blobfs/blob.go:
##########
@@ -37,26 +37,131 @@ import (
"gocloud.dev/gcerrors"
)
+// wholeObjectReadMaxSize is the largest object ReadAt downloads whole on its
+// first call. Reading a Parquet file issues a ReadAt for its footer and for
+// every column chunk; for a small file one GET of the object is far cheaper
+// than one GET per column. The downloaded object is held until Close.
+const wholeObjectReadMaxSize = 8 << 20
Review Comment:
What is 8 MiB based on? Reading only the footer of a 7.5 MiB file goes from
about 2.3 KB to 7.5 MiB, and reading 2 of 20 columns from about 786 KiB to 7.5
MiB. Footer-only reads are what `AddDataFilesByPath` does.
##########
io/gocloud/blobfs/blob_test.go:
##########
@@ -1169,3 +1170,94 @@ func TestBlobFileIORemoveFallsBackToDirectoryMarker(t
*testing.T) {
require.NoError(t, err)
assert.False(t, exists)
}
+
+// countingFileIO returns a FileIO over bucket that counts the GETs it issues.
+func countingFileIO(ctx context.Context, bucket *blob.Bucket, gets *int)
*FileIO {
+ return &FileIO{
+ Bucket: bucket,
+ extractObject: identityObjectLocation,
+ ctx: ctx,
+ newRangeReader: func(ctx context.Context, key string, offset,
length int64) (io.ReadCloser, error) {
+ *gets++
+
+ return bucket.NewRangeReader(ctx, key, offset, length,
nil)
+ },
+ }
+}
+
+func TestOpenReadAtGets(t *testing.T) {
+ ctx := context.Background()
+ bucket := memblob.OpenBucket(nil)
+ defer bucket.Close()
+
+ small := []byte("0123456789")
+ large := bytes.Repeat([]byte{7}, wholeObjectReadMaxSize+1)
+ require.NoError(t, bucket.WriteAll(ctx, "small", small, nil))
+ require.NoError(t, bucket.WriteAll(ctx, "large", large, nil))
+
+ tests := []struct {
+ key string
+ content []byte
+ wantGets int
+ }{
+ {"small", small, 1},
+ {"large", large, 3},
+ }
+ for _, tt := range tests {
+ t.Run(tt.key, func(t *testing.T) {
+ var gets int
+ f, err := countingFileIO(ctx, bucket, &gets).Open(ctx,
tt.key)
+ require.NoError(t, err)
+ defer f.Close()
+
+ info, err := f.Stat()
+ require.NoError(t, err)
+ assert.Equal(t, int64(len(tt.content)), info.Size())
+ assert.Zero(t, gets, "Open must not read the object")
Review Comment:
`countingFileIO` only counts `newRangeReader`, so this passes even though
`Open` now sends a HEAD. Counting `Attributes` too would make the test cover
the real request count.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]