Monorepo for Tangled
tangled.org
1package knotacl
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "slices"
8 "sync"
9 "testing"
10 "time"
11)
12
13var cacheTestBase = time.Unix(1700000000, 0)
14
15type fakeLister struct {
16 mu sync.Mutex
17 memberCalls int
18 members []string
19 err error
20 started chan struct{}
21 block chan struct{}
22}
23
24func (f *fakeLister) GetKnotMembers(ctx context.Context, host string) ([]string, error) {
25 f.mu.Lock()
26 f.memberCalls++
27 members, err, started, block := f.members, f.err, f.started, f.block
28 f.mu.Unlock()
29 if started != nil {
30 close(started)
31 }
32 if block != nil {
33 <-block
34 }
35 if err != nil {
36 return nil, err
37 }
38 return members, nil
39}
40
41func (f *fakeLister) GetRepoCollaborators(ctx context.Context, host, repoDid string) ([]string, error) {
42 f.mu.Lock()
43 defer f.mu.Unlock()
44 return f.members, f.err
45}
46
47func (f *fakeLister) calls() int {
48 f.mu.Lock()
49 defer f.mu.Unlock()
50 return f.memberCalls
51}
52
53func (f *fakeLister) set(members []string, err error) {
54 f.mu.Lock()
55 defer f.mu.Unlock()
56 f.members, f.err = members, err
57}
58
59type fakeClock struct {
60 mu sync.Mutex
61 t time.Time
62}
63
64func (c *fakeClock) now() time.Time {
65 c.mu.Lock()
66 defer c.mu.Unlock()
67 return c.t
68}
69
70func (c *fakeClock) advance(d time.Duration) {
71 c.mu.Lock()
72 defer c.mu.Unlock()
73 c.t = c.t.Add(d)
74}
75
76func TestCache_TTLCollapsesThenExpires(t *testing.T) {
77 clk := &fakeClock{t: cacheTestBase}
78 f := &fakeLister{members: []string{"did:plc:boltless"}}
79 c := newCache(f, cacheTTL, clk.now)
80 ctx := context.Background()
81
82 if _, err := c.GetKnotMembers(ctx, "knot.nel.pet"); err != nil {
83 t.Fatal(err)
84 }
85 if _, err := c.GetKnotMembers(ctx, "knot.nel.pet"); err != nil {
86 t.Fatal(err)
87 }
88 if f.calls() != 1 {
89 t.Errorf("memberCalls=%d, want 1 within the TTL window", f.calls())
90 }
91
92 clk.advance(cacheTTL)
93 if _, err := c.GetKnotMembers(ctx, "knot.nel.pet"); err != nil {
94 t.Fatal(err)
95 }
96 if f.calls() != 2 {
97 t.Errorf("memberCalls=%d, want 2 once the entry expired", f.calls())
98 }
99}
100
101func TestCache_ErrorsNotCached(t *testing.T) {
102 clk := &fakeClock{t: cacheTestBase}
103 f := &fakeLister{err: errors.New("knot unreachable")}
104 c := newCache(f, cacheTTL, clk.now)
105 ctx := context.Background()
106
107 if _, err := c.GetKnotMembers(ctx, "knot.nel.pet"); err == nil {
108 t.Fatal("want error on the first call")
109 }
110 f.set([]string{"did:plc:boltless"}, nil)
111 got, err := c.GetKnotMembers(ctx, "knot.nel.pet")
112 if err != nil {
113 t.Fatal(err)
114 }
115 if !slices.Equal(got, []string{"did:plc:boltless"}) {
116 t.Errorf("got %v after recovery, want the live value", got)
117 }
118 if f.calls() != 2 {
119 t.Errorf("memberCalls=%d, want 2; a failed fetch must not be cached", f.calls())
120 }
121}
122
123func TestCache_MemoShortCircuitsWithinRequest(t *testing.T) {
124 clk := &fakeClock{t: cacheTestBase}
125 f := &fakeLister{members: []string{"did:plc:boltless"}}
126 c := newCache(f, cacheTTL, clk.now)
127 ctx := WithMemo(context.Background())
128
129 first, err := c.GetKnotMembers(ctx, "knot.nel.pet")
130 if err != nil {
131 t.Fatal(err)
132 }
133
134 clk.advance(2 * cacheTTL)
135 f.set([]string{"did:plc:akshay"}, nil)
136
137 second, err := c.GetKnotMembers(ctx, "knot.nel.pet")
138 if err != nil {
139 t.Fatal(err)
140 }
141 if !slices.Equal(first, second) {
142 t.Errorf("memo must hold one snapshot per request: first=%v second=%v", first, second)
143 }
144 if f.calls() != 1 {
145 t.Errorf("memberCalls=%d, want 1; the request memo must not re-query even past the TTL", f.calls())
146 }
147}
148
149func TestCache_ReturnedSliceCannotCorruptCache(t *testing.T) {
150 clk := &fakeClock{t: cacheTestBase}
151 f := &fakeLister{members: []string{"did:plc:boltless", "did:plc:akshay"}}
152 c := newCache(f, cacheTTL, clk.now)
153 ctx := context.Background()
154
155 got, err := c.GetKnotMembers(ctx, "knot.nel.pet")
156 if err != nil {
157 t.Fatal(err)
158 }
159 for i := range got {
160 got[i] = "did:plc:squid"
161 }
162
163 again, err := c.GetKnotMembers(ctx, "knot.nel.pet")
164 if err != nil {
165 t.Fatal(err)
166 }
167 if slices.Contains(again, "did:plc:squid") {
168 t.Errorf("a caller mutating its returned slice corrupted the cached entry: %v", again)
169 }
170 if f.calls() != 1 {
171 t.Errorf("memberCalls=%d, want 1; the second read should be served from cache", f.calls())
172 }
173}
174
175func TestCache_SingleflightCollapsesConcurrentMisses(t *testing.T) {
176 clk := &fakeClock{t: cacheTestBase}
177 started := make(chan struct{})
178 release := make(chan struct{})
179 f := &fakeLister{members: []string{"did:plc:boltless"}, started: started, block: release}
180 c := newCache(f, cacheTTL, clk.now)
181 ctx := context.Background()
182
183 var wg sync.WaitGroup
184 call := func() {
185 wg.Add(1)
186 go func() {
187 defer wg.Done()
188 if _, err := c.GetKnotMembers(ctx, "knot.nel.pet"); err != nil {
189 t.Errorf("GetKnotMembers: %v", err)
190 }
191 }()
192 }
193
194 call()
195 <-started
196 for range make([]struct{}, 8) {
197 call()
198 }
199 time.Sleep(20 * time.Millisecond)
200 close(release)
201 wg.Wait()
202
203 if f.calls() != 1 {
204 t.Errorf("memberCalls=%d, want 1; concurrent misses must collapse into a single knot query", f.calls())
205 }
206}
207
208func TestCache_CapIsHardUnderFreshFlood(t *testing.T) {
209 clk := &fakeClock{t: cacheTestBase}
210 f := &fakeLister{members: []string{"did:plc:limpet"}}
211 c := newCache(f, cacheTTL, clk.now)
212 ctx := context.Background()
213
214 for i := 0; i < cacheMaxEntries+100; i++ {
215 if _, err := c.GetKnotMembers(ctx, fmt.Sprintf("knot-%d.nel.pet", i)); err != nil {
216 t.Fatal(err)
217 }
218 }
219
220 c.mu.Lock()
221 n := len(c.entries)
222 c.mu.Unlock()
223 if n > cacheMaxEntries {
224 t.Errorf("entries=%d, want <= %d; all-fresh keys must not grow past the cap", n, cacheMaxEntries)
225 }
226}