filer.remote.sync: bounded filer lookups, one chunk cache per process

The filer lookups on the sync path (currentEntry, isSuperseded,
uploadCurrentEntry, liveRemoteEntry, the mount directory read) ran with no
deadline. A lookup that never answers keeps its job alive, the job keeps
its path in the MetadataProcessor conflict index, AddSyncJob waits on the
condition and the daemon stops consuming events with no log line. Each
lookup now carries a 30 s deadline, so the job fails and the processor's
stop-and-resubscribe path runs.

remoteUploadReader built a chunk cache per upload; the cache's worker
goroutine is never stopped, so each upload leaked one goroutine and its
cache (21k goroutines, ~150 MB/h under lock-file churn, OOM at the memory
limit). One chunk cache per process.

(cherry picked from commit ff77fd222772d4f3b68f34d5ee39a66684f90e78)
This commit is contained in:
james committed 2026-11-05 18:58:31 -07:00
1 parent 0dd33f5f0f
commit 3b8c4702bd
2 files changed
+91 -6

No files matched your search

@@ -8,6 +8,7 @@ import (
"io"
"net/http"
"net/http/httptest"
"runtime"
"slices"
"strings"
"testing"
@@ -553,6 +554,67 @@ func entryWith(name string, remote *filer_pb.RemoteEntry, chunks ...*filer_pb.Fi
// of a write in progress: a mount flushes metadata while chunk uploads are
// still running, so the snapshot can have holes, and the stream reader stops
// at the first one on every retry.
// blockingFilerClient answers a lookup only when its context ends.
type blockingFilerClient struct {
stubFilerClient
}
func (c *blockingFilerClient) LookupDirectoryEntry(ctx context.Context, _ *filer_pb.LookupDirectoryEntryRequest, _ ...grpc.CallOption) (*filer_pb.LookupDirectoryEntryResponse, error) {
c.lookups++
<-ctx.Done()
return nil, ctx.Err()
}
func (c *blockingFilerClient) WithFilerClient(_ bool, fn func(filer_pb.SeaweedFilerClient) error) error {
return fn(c)
}
func TestFilerLookupsOnTheSyncPathCarryADeadline(t *testing.T) {
remoteSyncLookupTimeout = 50 * time.Millisecond
t.Cleanup(func() { remoteSyncLookupTimeout = 30 * time.Second })
const dir = "/buckets/ingest"
event := entryWith("lease.json", nil, chunk("3,01", "e1"))
started := time.Now()
current, err := currentEntry(&blockingFilerClient{}, dir, event.Name)
if current != nil || !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("currentEntry = %v, %v; want nil, deadline exceeded", current, err)
}
if isSuperseded(&blockingFilerClient{}, dir, event) {
t.Error("isSuperseded = true on a lookup that timed out; want false (the write failure is kept and retried)")
}
if elapsed := time.Since(started); elapsed > 5*time.Second {
t.Fatalf("two timed-out lookups took %v; the deadline did not bound them", elapsed)
}
}
func TestRemoteUploadReaderSharesOneChunkCache(t *testing.T) {
entry := entryWith("blob.bin", nil, chunk("3,01", "e1"))
for i := 0; i < 3; i++ {
if _, closeFn, err := remoteUploadReader(&stubFilerClient{}, entry); err != nil {
t.Fatal(err)
} else if closeFn != nil {
_ = closeFn()
}
}
runtime.GC()
before := runtime.NumGoroutine()
for i := 0; i < 200; i++ {
_, closeFn, err := remoteUploadReader(&stubFilerClient{}, entry)
if err != nil {
t.Fatal(err)
}
if closeFn != nil {
_ = closeFn()
}
}
runtime.GC()
time.Sleep(100 * time.Millisecond)
if after := runtime.NumGoroutine(); after-before > 20 {
t.Fatalf("200 upload readers left %d goroutines behind (%d -> %d); one chunk cache per reader leaks its worker", after-before, before, after)
}
}
func TestIsSuperseded(t *testing.T) {
const dir = "/buckets/ingest"
event := entryWith("tmpjt9req69__Alan_Doe_Resume-1.pdf", nil, chunk("3,01", "e1"), chunk("3,02", "e2"))