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
@@ -0,0 +1,78 @@
|
||||
// Package filepool keeps a set of reusable write handles open on a single
|
||||
// remote path, one per connection, so several goroutines can write to the same
|
||||
// file at once without sharing a handle.
|
||||
//
|
||||
// It is used by backends that implement fs.OpenWriterAter over a connection
|
||||
// pool, where the core writes the chunks of a large file concurrently at
|
||||
// non-overlapping offsets.
|
||||
package filepool
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
|
||||
"golang.org/x/sync/errgroup"
|
||||
)
|
||||
|
||||
// Pool hands out handles of type T for a single file. Handles are reused when
|
||||
// free and opened on demand otherwise. It is safe for concurrent use.
|
||||
//
|
||||
// The zero value is not usable; call New.
|
||||
type Pool[T any] struct {
|
||||
ctx context.Context
|
||||
open func(context.Context) (T, error)
|
||||
release func(handle T, err error) error
|
||||
|
||||
mu sync.Mutex
|
||||
free []T
|
||||
}
|
||||
|
||||
// New returns a Pool.
|
||||
//
|
||||
// open opens a fresh handle on its own connection. release closes a handle and
|
||||
// returns its connection: err is the error that made the handle unusable (nil
|
||||
// when the handle is simply being drained) and the returned error is the result
|
||||
// of closing it.
|
||||
func New[T any](ctx context.Context, open func(context.Context) (T, error), release func(handle T, err error) error) *Pool[T] {
|
||||
return &Pool[T]{ctx: ctx, open: open, release: release}
|
||||
}
|
||||
|
||||
// Get returns a free handle, opening a new one if none are free.
|
||||
func (p *Pool[T]) Get() (T, error) {
|
||||
p.mu.Lock()
|
||||
if n := len(p.free); n > 0 {
|
||||
h := p.free[n-1]
|
||||
p.free = p.free[:n-1]
|
||||
p.mu.Unlock()
|
||||
return h, nil
|
||||
}
|
||||
p.mu.Unlock()
|
||||
return p.open(p.ctx)
|
||||
}
|
||||
|
||||
// Put returns a handle to the pool. If err is non-nil the write that used the
|
||||
// handle failed, so the handle is released instead of being reused.
|
||||
func (p *Pool[T]) Put(handle T, err error) {
|
||||
if err != nil {
|
||||
_ = p.release(handle, err)
|
||||
return
|
||||
}
|
||||
p.mu.Lock()
|
||||
p.free = append(p.free, handle)
|
||||
p.mu.Unlock()
|
||||
}
|
||||
|
||||
// Drain releases every free handle, closing them concurrently, and returns the
|
||||
// first error encountered.
|
||||
func (p *Pool[T]) Drain() error {
|
||||
p.mu.Lock()
|
||||
free := p.free
|
||||
p.free = nil
|
||||
p.mu.Unlock()
|
||||
|
||||
g := new(errgroup.Group)
|
||||
for _, h := range free {
|
||||
g.Go(func() error { return p.release(h, nil) })
|
||||
}
|
||||
return g.Wait()
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
package filepool
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// handle is a fake pooled handle used by the tests.
|
||||
type handle struct {
|
||||
id int
|
||||
released bool
|
||||
relErr error
|
||||
}
|
||||
|
||||
// harness wires a Pool up to counters so the tests can assert on the open and
|
||||
// release calls without a real backend.
|
||||
type harness struct {
|
||||
mu sync.Mutex
|
||||
next int
|
||||
opens int
|
||||
openErr error
|
||||
releases int
|
||||
closeErr error
|
||||
}
|
||||
|
||||
func (h *harness) open(context.Context) (*handle, error) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
h.opens++
|
||||
if h.openErr != nil {
|
||||
return nil, h.openErr
|
||||
}
|
||||
h.next++
|
||||
return &handle{id: h.next}, nil
|
||||
}
|
||||
|
||||
func (h *harness) release(hd *handle, err error) error {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
h.releases++
|
||||
hd.released = true
|
||||
hd.relErr = err
|
||||
return h.closeErr
|
||||
}
|
||||
|
||||
func newPool(h *harness) *Pool[*handle] {
|
||||
return New(context.Background(), h.open, h.release)
|
||||
}
|
||||
|
||||
func TestGetOpensWhenEmpty(t *testing.T) {
|
||||
h := &harness{}
|
||||
p := newPool(h)
|
||||
|
||||
hd, err := p.Get()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, hd.id)
|
||||
assert.Equal(t, 1, h.opens)
|
||||
assert.Empty(t, p.free)
|
||||
}
|
||||
|
||||
func TestGetReusesFreeHandle(t *testing.T) {
|
||||
h := &harness{}
|
||||
p := newPool(h)
|
||||
|
||||
hd, err := p.Get()
|
||||
require.NoError(t, err)
|
||||
p.Put(hd, nil)
|
||||
assert.Len(t, p.free, 1)
|
||||
|
||||
got, err := p.Get()
|
||||
require.NoError(t, err)
|
||||
assert.Same(t, hd, got, "a free handle should be reused instead of opening a new one")
|
||||
assert.Equal(t, 1, h.opens)
|
||||
}
|
||||
|
||||
func TestGetOpenError(t *testing.T) {
|
||||
h := &harness{openErr: errors.New("connection failed")}
|
||||
p := newPool(h)
|
||||
|
||||
hd, err := p.Get()
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, hd)
|
||||
assert.EqualError(t, err, "connection failed")
|
||||
}
|
||||
|
||||
func TestPutSuccessKeepsHandle(t *testing.T) {
|
||||
h := &harness{}
|
||||
p := newPool(h)
|
||||
|
||||
hd, err := p.Get()
|
||||
require.NoError(t, err)
|
||||
p.Put(hd, nil)
|
||||
|
||||
assert.Len(t, p.free, 1)
|
||||
assert.Zero(t, h.releases, "a healthy handle must not be released")
|
||||
}
|
||||
|
||||
func TestPutErrorReleasesHandle(t *testing.T) {
|
||||
h := &harness{}
|
||||
p := newPool(h)
|
||||
|
||||
hd, err := p.Get()
|
||||
require.NoError(t, err)
|
||||
writeErr := errors.New("write error")
|
||||
p.Put(hd, writeErr)
|
||||
|
||||
assert.Empty(t, p.free, "a handle put back with an error must not be reused")
|
||||
assert.Equal(t, 1, h.releases)
|
||||
assert.True(t, hd.released)
|
||||
assert.Equal(t, writeErr, hd.relErr, "release must receive the write error")
|
||||
}
|
||||
|
||||
func TestDrainReleasesEveryHandle(t *testing.T) {
|
||||
h := &harness{}
|
||||
p := newPool(h)
|
||||
|
||||
var handles []*handle
|
||||
for range 3 {
|
||||
hd, err := p.Get()
|
||||
require.NoError(t, err)
|
||||
handles = append(handles, hd)
|
||||
}
|
||||
for _, hd := range handles {
|
||||
p.Put(hd, nil)
|
||||
}
|
||||
|
||||
require.NoError(t, p.Drain())
|
||||
assert.Empty(t, p.free)
|
||||
assert.Equal(t, 3, h.releases)
|
||||
for _, hd := range handles {
|
||||
assert.True(t, hd.released)
|
||||
assert.NoError(t, hd.relErr, "draining passes a nil error to release")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrainReturnsCloseError(t *testing.T) {
|
||||
h := &harness{closeErr: errors.New("close failed")}
|
||||
p := newPool(h)
|
||||
|
||||
hd, err := p.Get()
|
||||
require.NoError(t, err)
|
||||
p.Put(hd, nil)
|
||||
|
||||
assert.EqualError(t, p.Drain(), "close failed")
|
||||
}
|
||||
|
||||
func TestConcurrentGetPut(t *testing.T) {
|
||||
h := &harness{}
|
||||
p := newPool(h)
|
||||
|
||||
const workers = 10
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(workers)
|
||||
for range workers {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for range 100 {
|
||||
hd, err := p.Get()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
p.Put(hd, nil)
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
// Every handle handed out was returned, so draining must release them all
|
||||
// with no leaks.
|
||||
require.NoError(t, p.Drain())
|
||||
assert.Empty(t, p.free)
|
||||
assert.Equal(t, h.opens, h.releases)
|
||||
}
|
||||
Reference in New Issue
Block a user