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:
Splainte
2026-08-30 13:06:06 +01:00
committed by Nick Craig-Wood
co-authored by Claude Opus 4.8
parent 6a617a379b
commit 613b335962
5 changed files with 298 additions and 333 deletions
+78
View File
@@ -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()
}
+178
View File
@@ -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)
}