Files
seaweedfs/weed/util/async_worker_test.go
T
Jaehoon Kim 18868e5204 fix(mount): run entry invalidations off the meta-cache apply loop (#10002)
* fix(mount): run entry invalidations off the meta-cache apply loop

The apply loop ran invalidateFunc inline, which acquires the open file
handle's lock in fhLockTable. Meanwhile flushMetadataToFiler holds that
same fh lock and then waits on the apply loop (applyLocalMetadataEvent).
When both target the same open file concurrently, the loop blocks on the
fh lock while the lock holder blocks on the loop: an ABBA deadlock that
backs up every later readdir/flush and hangs the mount.

Fix: dispatch entry invalidations to a dedicated FIFO worker goroutine so
the apply loop never blocks on locks held by goroutines waiting on it.
Adds a regression test reproducing the interleaving.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* perf(mount): update invalidate counter once per batch

Run the batch's invalidateFunc calls without re-taking invalidateMu per
item, then bump invalidateProcessed and broadcast once after the loop.
WaitForEntryInvalidations only needs the count to reach its target and a
batch always completes together, so the per-item lock + broadcast was
wasted work.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* mount: extract the invalidate worker into util.AsyncBatchWorker

The apply loop's off-thread entry-invalidation queue was a one-off mutex +
cond + slice + counters living inside MetaCache. Pull it out as a generic
unbounded FIFO worker so the deadlock-avoidance contract (never block the
producer, drain on shutdown, wait-for-quiesce) lives in one place.

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Chris Lu <chris.lu@gmail.com>
2026-06-19 09:19:35 -07:00

124 lines
3.1 KiB
Go

package util
import (
"sync"
"sync/atomic"
"testing"
"time"
)
func TestAsyncBatchWorkerProcessesInOrder(t *testing.T) {
var mu sync.Mutex
var got []int
w := NewAsyncBatchWorker(func(batch []int) {
mu.Lock()
got = append(got, batch...)
mu.Unlock()
})
for i := 0; i < 100; i++ {
w.Enqueue(i)
}
w.Drain()
mu.Lock()
defer mu.Unlock()
if len(got) != 100 {
t.Fatalf("processed %d items, want 100", len(got))
}
for i, v := range got {
if v != i {
t.Fatalf("item %d = %d, want %d (FIFO order broken)", i, v, i)
}
}
}
func TestAsyncBatchWorkerDrainWaitsForEnqueued(t *testing.T) {
var processed int64
w := NewAsyncBatchWorker(func(batch []int) {
time.Sleep(time.Millisecond)
atomic.AddInt64(&processed, int64(len(batch)))
})
w.Enqueue(1, 2, 3)
w.Enqueue(4, 5)
w.Drain()
if n := atomic.LoadInt64(&processed); n != 5 {
t.Fatalf("processed %d, want 5 after Drain", n)
}
}
// Enqueue must not block even while the worker is busy processing under a lock
// the producer also holds — the unbounded queue is what guarantees this.
func TestAsyncBatchWorkerEnqueueNeverBlocks(t *testing.T) {
release := make(chan struct{})
entered := make(chan struct{})
var once sync.Once
w := NewAsyncBatchWorker(func(batch []int) {
once.Do(func() { close(entered) })
<-release
})
w.Enqueue(0) // worker picks this up and blocks in process
<-entered
done := make(chan struct{})
go func() {
for i := 1; i < 1000; i++ {
w.Enqueue(i)
}
close(done)
}()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("Enqueue blocked while worker was busy")
}
close(release)
w.Shutdown()
}
// Drain must honor its contract even when Shutdown runs concurrently: it may
// not return until the in-flight batch has actually been processed.
func TestAsyncBatchWorkerDrainCompletesDuringShutdown(t *testing.T) {
var processed int64
release := make(chan struct{})
entered := make(chan struct{})
var once sync.Once
w := NewAsyncBatchWorker(func(batch []int) {
once.Do(func() { close(entered) })
<-release
atomic.AddInt64(&processed, int64(len(batch)))
})
w.Enqueue(1, 2, 3)
<-entered // worker has picked up the batch and is blocked in process
drained := make(chan struct{})
go func() { w.Drain(); close(drained) }()
go w.Shutdown() // sets closed and broadcasts while the batch is still in flight
select {
case <-drained:
t.Fatal("Drain returned before the in-flight batch finished")
case <-time.After(100 * time.Millisecond):
}
close(release)
<-drained
if n := atomic.LoadInt64(&processed); n != 3 {
t.Fatalf("processed %d after Drain, want 3", n)
}
}
func TestAsyncBatchWorkerShutdownDrainsThenStops(t *testing.T) {
var processed int64
w := NewAsyncBatchWorker(func(batch []int) {
atomic.AddInt64(&processed, int64(len(batch)))
})
w.Enqueue(1, 2, 3, 4)
w.Shutdown()
if n := atomic.LoadInt64(&processed); n != 4 {
t.Fatalf("processed %d, want 4 (Shutdown should drain)", n)
}
// Enqueue after Shutdown is dropped, not processed.
w.Enqueue(5, 6)
if n := atomic.LoadInt64(&processed); n != 4 {
t.Fatalf("processed %d after post-shutdown Enqueue, want 4", n)
}
}