filepool: add generic file handle pool in lib/filepool
Factor the connection-backed write handle pool out of the smb backend into a generic lib/filepool.Pool[T] with its own tests, so it can be reused by other backends that implement fs.OpenWriterAter over a connection pool. The smb backend keeps its behaviour, opening and releasing handles through small closures passed to the pool. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: Splainte <r.wycke@hotmail.fr>
This commit is contained in:
committed by
Nick Craig-Wood
co-authored by
Claude Opus 4.8
parent
6a617a379b
commit
613b335962
@@ -1,99 +0,0 @@
|
||||
package smb
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
|
||||
"github.com/cloudsoda/go-smb2"
|
||||
"golang.org/x/sync/errgroup"
|
||||
)
|
||||
|
||||
// FsInterface defines the methods that filePool needs from Fs
|
||||
type FsInterface interface {
|
||||
getConnection(ctx context.Context, share string) (*conn, error)
|
||||
putConnection(pc **conn, err error)
|
||||
removeSession()
|
||||
}
|
||||
|
||||
type file struct {
|
||||
*smb2.File
|
||||
c *conn
|
||||
}
|
||||
|
||||
type filePool struct {
|
||||
ctx context.Context
|
||||
fs FsInterface
|
||||
share string
|
||||
path string
|
||||
|
||||
mu sync.Mutex
|
||||
pool []*file
|
||||
}
|
||||
|
||||
func newFilePool(ctx context.Context, fs FsInterface, share, path string) *filePool {
|
||||
return &filePool{
|
||||
ctx: ctx,
|
||||
fs: fs,
|
||||
share: share,
|
||||
path: path,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *filePool) get() (*file, error) {
|
||||
p.mu.Lock()
|
||||
if len(p.pool) > 0 {
|
||||
f := p.pool[len(p.pool)-1]
|
||||
p.pool = p.pool[:len(p.pool)-1]
|
||||
p.mu.Unlock()
|
||||
return f, nil
|
||||
}
|
||||
p.mu.Unlock()
|
||||
|
||||
c, err := p.fs.getConnection(p.ctx, p.share)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
fl, err := c.smbShare.OpenFile(p.path, os.O_WRONLY, 0o644)
|
||||
if err != nil {
|
||||
p.fs.putConnection(&c, err)
|
||||
return nil, fmt.Errorf("failed to open: %w", err)
|
||||
}
|
||||
|
||||
return &file{File: fl, c: c}, nil
|
||||
}
|
||||
|
||||
func (p *filePool) put(f *file, err error) {
|
||||
if f == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
_ = f.Close()
|
||||
p.fs.putConnection(&f.c, err)
|
||||
return
|
||||
}
|
||||
|
||||
p.mu.Lock()
|
||||
p.pool = append(p.pool, f)
|
||||
p.mu.Unlock()
|
||||
}
|
||||
|
||||
func (p *filePool) drain() error {
|
||||
p.mu.Lock()
|
||||
files := p.pool
|
||||
p.pool = nil
|
||||
p.mu.Unlock()
|
||||
|
||||
g, _ := errgroup.WithContext(p.ctx)
|
||||
for _, f := range files {
|
||||
g.Go(func() error {
|
||||
err := f.Close()
|
||||
p.fs.putConnection(&f.c, err)
|
||||
return err
|
||||
})
|
||||
}
|
||||
return g.Wait()
|
||||
}
|
||||
@@ -1,228 +0,0 @@
|
||||
package smb
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/cloudsoda/go-smb2"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// Mock Fs that implements FsInterface
|
||||
type mockFs struct {
|
||||
mu sync.Mutex
|
||||
putConnectionCalled bool
|
||||
putConnectionErr error
|
||||
getConnectionCalled bool
|
||||
getConnectionErr error
|
||||
getConnectionResult *conn
|
||||
removeSessionCalled bool
|
||||
}
|
||||
|
||||
func (m *mockFs) putConnection(pc **conn, err error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.putConnectionCalled = true
|
||||
m.putConnectionErr = err
|
||||
}
|
||||
|
||||
func (m *mockFs) getConnection(ctx context.Context, share string) (*conn, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.getConnectionCalled = true
|
||||
if m.getConnectionErr != nil {
|
||||
return nil, m.getConnectionErr
|
||||
}
|
||||
if m.getConnectionResult != nil {
|
||||
return m.getConnectionResult, nil
|
||||
}
|
||||
return &conn{}, nil
|
||||
}
|
||||
|
||||
func (m *mockFs) removeSession() {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.removeSessionCalled = true
|
||||
}
|
||||
|
||||
func (m *mockFs) isPutConnectionCalled() bool {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.putConnectionCalled
|
||||
}
|
||||
|
||||
func (m *mockFs) getPutConnectionErr() error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.putConnectionErr
|
||||
}
|
||||
|
||||
func (m *mockFs) isGetConnectionCalled() bool {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.getConnectionCalled
|
||||
}
|
||||
|
||||
func newMockFs() *mockFs {
|
||||
return &mockFs{}
|
||||
}
|
||||
|
||||
// Helper function to create a mock file
|
||||
func newMockFile() *file {
|
||||
return &file{
|
||||
File: &smb2.File{},
|
||||
c: &conn{},
|
||||
}
|
||||
}
|
||||
|
||||
// Test filePool creation
|
||||
func TestNewFilePool(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
fs := newMockFs()
|
||||
share := "testshare"
|
||||
path := "/test/path"
|
||||
|
||||
pool := newFilePool(ctx, fs, share, path)
|
||||
|
||||
assert.NotNil(t, pool)
|
||||
assert.Equal(t, ctx, pool.ctx)
|
||||
assert.Equal(t, fs, pool.fs)
|
||||
assert.Equal(t, share, pool.share)
|
||||
assert.Equal(t, path, pool.path)
|
||||
assert.Empty(t, pool.pool)
|
||||
}
|
||||
|
||||
// Test getting file from pool when pool has files
|
||||
func TestFilePool_Get_FromPool(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
fs := newMockFs()
|
||||
pool := newFilePool(ctx, fs, "testshare", "/test/path")
|
||||
|
||||
// Add a mock file to the pool
|
||||
mockFile := newMockFile()
|
||||
pool.pool = append(pool.pool, mockFile)
|
||||
|
||||
// Get file from pool
|
||||
f, err := pool.get()
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, f)
|
||||
assert.Equal(t, mockFile, f)
|
||||
assert.Empty(t, pool.pool)
|
||||
}
|
||||
|
||||
// Test getting file when pool is empty
|
||||
func TestFilePool_Get_EmptyPool(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
fs := newMockFs()
|
||||
|
||||
// Set up the mock to return an error from getConnection
|
||||
// This tests that the pool calls getConnection when empty
|
||||
fs.getConnectionErr = errors.New("connection failed")
|
||||
|
||||
pool := newFilePool(ctx, fs, "testshare", "test/path")
|
||||
|
||||
// This should call getConnection and return the error
|
||||
f, err := pool.get()
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, f)
|
||||
assert.True(t, fs.isGetConnectionCalled())
|
||||
assert.Equal(t, "connection failed", err.Error())
|
||||
}
|
||||
|
||||
// Test putting file successfully
|
||||
func TestFilePool_Put_Success(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
fs := newMockFs()
|
||||
pool := newFilePool(ctx, fs, "testshare", "/test/path")
|
||||
|
||||
mockFile := newMockFile()
|
||||
|
||||
pool.put(mockFile, nil)
|
||||
|
||||
assert.Len(t, pool.pool, 1)
|
||||
assert.Equal(t, mockFile, pool.pool[0])
|
||||
}
|
||||
|
||||
// Test putting file with error
|
||||
func TestFilePool_Put_WithError(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
fs := newMockFs()
|
||||
pool := newFilePool(ctx, fs, "testshare", "/test/path")
|
||||
|
||||
mockFile := newMockFile()
|
||||
|
||||
pool.put(mockFile, errors.New("write error"))
|
||||
|
||||
// Should call putConnection with error
|
||||
assert.True(t, fs.isPutConnectionCalled())
|
||||
assert.Equal(t, errors.New("write error"), fs.getPutConnectionErr())
|
||||
assert.Empty(t, pool.pool)
|
||||
}
|
||||
|
||||
// Test putting nil file
|
||||
func TestFilePool_Put_NilFile(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
fs := newMockFs()
|
||||
pool := newFilePool(ctx, fs, "testshare", "/test/path")
|
||||
|
||||
// Should not panic
|
||||
pool.put(nil, nil)
|
||||
pool.put(nil, errors.New("some error"))
|
||||
|
||||
assert.Empty(t, pool.pool)
|
||||
}
|
||||
|
||||
// Test draining pool with files
|
||||
func TestFilePool_Drain_WithFiles(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
fs := newMockFs()
|
||||
pool := newFilePool(ctx, fs, "testshare", "/test/path")
|
||||
|
||||
// Add mock files to pool
|
||||
mockFile1 := newMockFile()
|
||||
mockFile2 := newMockFile()
|
||||
pool.pool = append(pool.pool, mockFile1, mockFile2)
|
||||
|
||||
// Before draining
|
||||
assert.Len(t, pool.pool, 2)
|
||||
|
||||
_ = pool.drain()
|
||||
assert.Empty(t, pool.pool)
|
||||
}
|
||||
|
||||
// Test concurrent access to pool
|
||||
func TestFilePool_ConcurrentAccess(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
fs := newMockFs()
|
||||
pool := newFilePool(ctx, fs, "testshare", "/test/path")
|
||||
|
||||
const numGoroutines = 10
|
||||
for range numGoroutines {
|
||||
mockFile := newMockFile()
|
||||
pool.pool = append(pool.pool, mockFile)
|
||||
}
|
||||
|
||||
// Test concurrent get operations
|
||||
done := make(chan bool, numGoroutines)
|
||||
|
||||
for range numGoroutines {
|
||||
go func() {
|
||||
defer func() { done <- true }()
|
||||
|
||||
f, err := pool.get()
|
||||
if err == nil {
|
||||
pool.put(f, nil)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
for range numGoroutines {
|
||||
<-done
|
||||
}
|
||||
|
||||
// Pool should be in a consistent after the concurrence access
|
||||
assert.Len(t, pool.pool, numGoroutines)
|
||||
}
|
||||
+42
-6
@@ -13,6 +13,7 @@ import (
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/cloudsoda/go-smb2"
|
||||
"github.com/rclone/rclone/fs"
|
||||
"github.com/rclone/rclone/fs/config"
|
||||
"github.com/rclone/rclone/fs/config/configmap"
|
||||
@@ -21,6 +22,7 @@ import (
|
||||
"github.com/rclone/rclone/lib/bucket"
|
||||
"github.com/rclone/rclone/lib/encoder"
|
||||
"github.com/rclone/rclone/lib/env"
|
||||
"github.com/rclone/rclone/lib/filepool"
|
||||
"github.com/rclone/rclone/lib/pacer"
|
||||
"github.com/rclone/rclone/lib/readers"
|
||||
)
|
||||
@@ -514,8 +516,15 @@ func (f *Fs) About(ctx context.Context) (_ *fs.Usage, err error) {
|
||||
return usage, nil
|
||||
}
|
||||
|
||||
// file is a pooled write handle together with the connection it lives on.
|
||||
type file struct {
|
||||
*smb2.File
|
||||
c *conn
|
||||
}
|
||||
|
||||
type smbWriterAt struct {
|
||||
pool *filePool
|
||||
fs *Fs
|
||||
pool *filepool.Pool[*file]
|
||||
closed bool
|
||||
closeMu sync.Mutex
|
||||
wg sync.WaitGroup
|
||||
@@ -531,13 +540,13 @@ func (w *smbWriterAt) WriteAt(p []byte, off int64) (int, error) {
|
||||
w.closeMu.Unlock()
|
||||
defer w.wg.Done()
|
||||
|
||||
f, err := w.pool.get()
|
||||
f, err := w.pool.Get()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to get file from pool: %w", err)
|
||||
}
|
||||
|
||||
n, writeErr := f.WriteAt(p, off)
|
||||
w.pool.put(f, writeErr)
|
||||
w.pool.Put(f, writeErr)
|
||||
|
||||
if writeErr != nil {
|
||||
return n, fmt.Errorf("failed to write at offset %d: %w", off, writeErr)
|
||||
@@ -561,12 +570,12 @@ func (w *smbWriterAt) Close() error {
|
||||
var errs []error
|
||||
|
||||
// Drain the pool
|
||||
if err := w.pool.drain(); err != nil {
|
||||
if err := w.pool.Drain(); err != nil {
|
||||
errs = append(errs, fmt.Errorf("failed to drain file pool: %w", err))
|
||||
}
|
||||
|
||||
// Remove session
|
||||
w.pool.fs.removeSession()
|
||||
w.fs.removeSession()
|
||||
|
||||
if len(errs) > 0 {
|
||||
return errors.Join(errs...)
|
||||
@@ -575,6 +584,32 @@ func (w *smbWriterAt) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// openPoolFile opens a fresh write handle on its own connection for share/path.
|
||||
func (f *Fs) openPoolFile(share, path string) func(context.Context) (*file, error) {
|
||||
return func(ctx context.Context) (*file, error) {
|
||||
c, err := f.getConnection(ctx, share)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fl, err := c.smbShare.OpenFile(path, os.O_WRONLY, 0o644)
|
||||
if err != nil {
|
||||
f.putConnection(&c, err)
|
||||
return nil, fmt.Errorf("failed to open: %w", err)
|
||||
}
|
||||
return &file{File: fl, c: c}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// releasePoolFile closes a pooled handle and returns its connection.
|
||||
func (f *Fs) releasePoolFile(fl *file, err error) error {
|
||||
closeErr := fl.Close()
|
||||
if err == nil {
|
||||
err = closeErr
|
||||
}
|
||||
f.putConnection(&fl.c, err)
|
||||
return closeErr
|
||||
}
|
||||
|
||||
// OpenWriterAt opens with a handle for random access writes
|
||||
//
|
||||
// Pass in the remote desired and the size if known.
|
||||
@@ -624,7 +659,8 @@ func (f *Fs) OpenWriterAt(ctx context.Context, remote string, size int64) (fs.Wr
|
||||
o.fs.addSession()
|
||||
|
||||
return &smbWriterAt{
|
||||
pool: newFilePool(ctx, o.fs, share, smbPath),
|
||||
fs: o.fs,
|
||||
pool: filepool.New(ctx, o.fs.openPoolFile(share, smbPath), o.fs.releasePoolFile),
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user