Skip to content

Commit 42dc5df

Browse files
0g-peterzhbclaude
andcommitted
indexer: reuse one discovery client per URL (#197)
FileLocationCache.getFileLocation built a ZgsClient for every candidate URL on every lookup that reached discovery, inside a loop over locations and ports. Each client carries its own HTTP transport, and Close does not release its connections - measured at two descriptors per client, the same whether the client succeeded or failed and whether or not it was closed. So the descriptor count grew with traffic. Report finding 25 proposed closing the client on the shard-config error path. That fixes nothing measurable: 50 successful, explicitly closed clients leak exactly as much as 50 unclosed failures. The error path was never the problem. Key clients by URL and keep them, so the cost is proportional to the number of distinct nodes seen rather than to the number of lookups. The network bounds the former. Deliberately no eviction: an evicted client's connections would not be reclaimed either, so eviction would restore the growth rather than cap it. Close now shuts down every cached client. The defer inside the loop is gone too - it held every probed client until the enclosing call returned, which for a multi-port sweep meant all of them at once. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 7d4a015 commit 42dc5df

2 files changed

Lines changed: 140 additions & 2 deletions

File tree

indexer/file_location_cache.go

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ type FileLocationCache struct {
3535
latestFindFile sync.Map // tx seq -> time.Time
3636
latestFailedCall sync.Map // url -> time.Time
3737
latestSuccessCall sync.Map // url -> successCall
38+
zgsClients sync.Map // url -> *node.ZgsClient, kept for the process lifetime
3839
discoverNode *node.AdminClient
3940
discoveryPorts []int
4041
}
@@ -56,6 +57,40 @@ func (c *FileLocationCache) Close() {
5657
if c.discoverNode != nil {
5758
c.discoverNode.Close()
5859
}
60+
61+
c.zgsClients.Range(func(_, value any) bool {
62+
value.(*node.ZgsClient).Close()
63+
return true
64+
})
65+
}
66+
67+
// zgsClient returns a client for url, creating it once and reusing it thereafter.
68+
//
69+
// Discovery probes a client per candidate URL on every lookup that reaches it, and each
70+
// client carries its own HTTP transport whose connections its Close does not release -
71+
// so constructing one per probe grew the process's descriptor count with traffic. Keying
72+
// them by URL makes that cost proportional to the number of distinct nodes seen instead,
73+
// which the network bounds.
74+
//
75+
// They are deliberately not evicted: an evicted client's connections would not be
76+
// reclaimed either, so eviction would restore the unbounded growth rather than cap it.
77+
func (c *FileLocationCache) zgsClient(url string) (*node.ZgsClient, error) {
78+
if cached, ok := c.zgsClients.Load(url); ok {
79+
return cached.(*node.ZgsClient), nil
80+
}
81+
82+
client, err := node.NewZgsClient(url, nil, defaultZgsClientOpt)
83+
if err != nil {
84+
return nil, err
85+
}
86+
87+
// Another goroutine may have won the race; keep whichever is stored and discard ours.
88+
if actual, loaded := c.zgsClients.LoadOrStore(url, client); loaded {
89+
client.Close()
90+
return actual.(*node.ZgsClient), nil
91+
}
92+
93+
return client, nil
5994
}
6095

6196
func (c *FileLocationCache) GetFileLocations(ctx context.Context, txSeq uint64) ([]*shard.ShardedNode, error) {
@@ -143,11 +178,10 @@ func (c *FileLocationCache) getFileLocation(ctx context.Context, txSeq uint64, c
143178
continue
144179
}
145180
}
146-
zgsClient, err := node.NewZgsClient(url, nil, defaultZgsClientOpt)
181+
zgsClient, err := c.zgsClient(url)
147182
if err != nil {
148183
continue
149184
}
150-
defer zgsClient.Close()
151185
fileInfo, err := zgsClient.GetFileInfoByTxSeq(ctx, txSeq)
152186
if err != nil {
153187
c.latestFailedCall.Store(url, time.Now())
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
package indexer
2+
3+
import (
4+
"net/http"
5+
"net/http/httptest"
6+
"os"
7+
"testing"
8+
9+
"github.com/stretchr/testify/assert"
10+
"github.com/stretchr/testify/require"
11+
)
12+
13+
// countOpenFDs returns the number of descriptors currently open in this process.
14+
// Readdirnames is used rather than os.ReadDir because the latter lstats every entry, and
15+
// on Darwin the directory's own transient descriptor is already gone by then.
16+
func countOpenFDs(t *testing.T) int {
17+
t.Helper()
18+
19+
for _, dir := range []string{"/proc/self/fd", "/dev/fd"} {
20+
dirFile, err := os.Open(dir)
21+
if err != nil {
22+
continue
23+
}
24+
names, err := dirFile.Readdirnames(-1)
25+
dirFile.Close()
26+
if err == nil {
27+
return len(names)
28+
}
29+
}
30+
31+
t.Skip("cannot enumerate open descriptors on this platform")
32+
return 0
33+
}
34+
35+
func shardConfigServer(t *testing.T) *httptest.Server {
36+
t.Helper()
37+
38+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
39+
w.Header().Set("Content-Type", "application/json")
40+
w.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":{"shardId":0,"numShard":1}}`))
41+
}))
42+
t.Cleanup(server.Close)
43+
44+
return server
45+
}
46+
47+
// Discovery probes a client per candidate URL on every lookup that reaches it. Each
48+
// client carries its own HTTP transport whose connections Close does not release, so
49+
// constructing one per probe grew the descriptor count with traffic rather than with the
50+
// number of distinct nodes.
51+
func TestFileLocationCache_ReusesOneClientPerURL(t *testing.T) {
52+
server := shardConfigServer(t)
53+
54+
var cache FileLocationCache
55+
defer cache.Close()
56+
57+
first, err := cache.zgsClient(server.URL)
58+
require.NoError(t, err)
59+
require.NotNil(t, first)
60+
61+
const iterations = 50
62+
before := countOpenFDs(t)
63+
for i := 0; i < iterations; i++ {
64+
again, err := cache.zgsClient(server.URL)
65+
require.NoError(t, err)
66+
require.Same(t, first, again, "the same URL must yield the same client")
67+
}
68+
after := countOpenFDs(t)
69+
70+
assert.LessOrEqual(t, after, before+2,
71+
"repeated lookups leaked descriptors: %d open before, %d after %d lookups", before, after, iterations)
72+
}
73+
74+
// Distinct URLs get distinct clients — the cost scales with nodes, not with traffic.
75+
func TestFileLocationCache_DistinctURLsGetDistinctClients(t *testing.T) {
76+
one, two := shardConfigServer(t), shardConfigServer(t)
77+
78+
var cache FileLocationCache
79+
defer cache.Close()
80+
81+
a, err := cache.zgsClient(one.URL)
82+
require.NoError(t, err)
83+
b, err := cache.zgsClient(two.URL)
84+
require.NoError(t, err)
85+
86+
assert.NotSame(t, a, b)
87+
}
88+
89+
// A URL whose shard-config lookup fails is not cached, so a later attempt can retry it.
90+
func TestFileLocationCache_FailedClientIsNotCached(t *testing.T) {
91+
failing := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
92+
http.Error(w, "unavailable", http.StatusInternalServerError)
93+
}))
94+
defer failing.Close()
95+
96+
var cache FileLocationCache
97+
defer cache.Close()
98+
99+
_, err := cache.zgsClient(failing.URL)
100+
require.Error(t, err)
101+
102+
_, cached := cache.zgsClients.Load(failing.URL)
103+
assert.False(t, cached, "a client that could not be initialized must not be stored")
104+
}

0 commit comments

Comments
 (0)