query_cache.go raw
1 package archive
2
3 import (
4 "container/list"
5 "crypto/sha256"
6 "encoding/binary"
7 "encoding/hex"
8 "sort"
9 "sync"
10 "time"
11
12 "next.orly.dev/pkg/nostr/encoders/filter"
13 )
14
15 // QueryCache tracks which filters have been queried recently to avoid
16 // repeated requests to archive relays for the same filter.
17 type QueryCache struct {
18 mu sync.RWMutex
19 entries map[string]*list.Element
20 order *list.List
21 maxSize int
22 ttl time.Duration
23 }
24
25 // queryCacheEntry holds a cached query fingerprint and timestamp.
26 type queryCacheEntry struct {
27 fingerprint string
28 queriedAt time.Time
29 }
30
31 // NewQueryCache creates a new query cache.
32 func NewQueryCache(ttl time.Duration, maxSize int) *QueryCache {
33 if maxSize <= 0 {
34 maxSize = 100000
35 }
36 if ttl <= 0 {
37 ttl = 24 * time.Hour
38 }
39
40 return &QueryCache{
41 entries: make(map[string]*list.Element),
42 order: list.New(),
43 maxSize: maxSize,
44 ttl: ttl,
45 }
46 }
47
48 // HasQueried returns true if the filter was queried within the TTL.
49 func (qc *QueryCache) HasQueried(f *filter.F) bool {
50 fingerprint := qc.normalizeAndHash(f)
51
52 qc.mu.RLock()
53 elem, exists := qc.entries[fingerprint]
54 qc.mu.RUnlock()
55
56 if !exists {
57 return false
58 }
59
60 entry := elem.Value.(*queryCacheEntry)
61
62 // Check if still within TTL
63 if time.Since(entry.queriedAt) > qc.ttl {
64 // Expired - remove it
65 qc.mu.Lock()
66 if elem, exists := qc.entries[fingerprint]; exists {
67 delete(qc.entries, fingerprint)
68 qc.order.Remove(elem)
69 }
70 qc.mu.Unlock()
71 return false
72 }
73
74 return true
75 }
76
77 // MarkQueried marks a filter as having been queried.
78 func (qc *QueryCache) MarkQueried(f *filter.F) {
79 fingerprint := qc.normalizeAndHash(f)
80
81 qc.mu.Lock()
82 defer qc.mu.Unlock()
83
84 // Update existing entry
85 if elem, exists := qc.entries[fingerprint]; exists {
86 qc.order.MoveToFront(elem)
87 elem.Value.(*queryCacheEntry).queriedAt = time.Now()
88 return
89 }
90
91 // Evict oldest if at capacity
92 if len(qc.entries) >= qc.maxSize {
93 oldest := qc.order.Back()
94 if oldest != nil {
95 entry := oldest.Value.(*queryCacheEntry)
96 delete(qc.entries, entry.fingerprint)
97 qc.order.Remove(oldest)
98 }
99 }
100
101 // Add new entry
102 entry := &queryCacheEntry{
103 fingerprint: fingerprint,
104 queriedAt: time.Now(),
105 }
106 elem := qc.order.PushFront(entry)
107 qc.entries[fingerprint] = elem
108 }
109
110 // normalizeAndHash creates a canonical fingerprint for a filter.
111 // This ensures that differently-ordered filters with the same content
112 // produce identical fingerprints.
113 func (qc *QueryCache) normalizeAndHash(f *filter.F) string {
114 h := sha256.New()
115
116 // Normalize and hash IDs (sorted)
117 if f.Ids != nil && f.Ids.Len() > 0 {
118 ids := make([]string, 0, f.Ids.Len())
119 for _, id := range f.Ids.T {
120 ids = append(ids, string(id))
121 }
122 sort.Strings(ids)
123 h.Write([]byte("ids:"))
124 for _, id := range ids {
125 h.Write([]byte(id))
126 }
127 }
128
129 // Normalize and hash Authors (sorted)
130 if f.Authors != nil && f.Authors.Len() > 0 {
131 authors := make([]string, 0, f.Authors.Len())
132 for _, author := range f.Authors.T {
133 authors = append(authors, string(author))
134 }
135 sort.Strings(authors)
136 h.Write([]byte("authors:"))
137 for _, a := range authors {
138 h.Write([]byte(a))
139 }
140 }
141
142 // Normalize and hash Kinds (sorted)
143 if f.Kinds != nil && f.Kinds.Len() > 0 {
144 kinds := f.Kinds.ToUint16()
145 sort.Slice(kinds, func(i, j int) bool { return kinds[i] < kinds[j] })
146 h.Write([]byte("kinds:"))
147 for _, k := range kinds {
148 var buf [2]byte
149 binary.BigEndian.PutUint16(buf[:], k)
150 h.Write(buf[:])
151 }
152 }
153
154 // Normalize and hash Tags (sorted by key, then values)
155 if f.Tags != nil && f.Tags.Len() > 0 {
156 // Collect all tag keys and sort them
157 tagMap := make(map[string][]string)
158 for _, t := range *f.Tags {
159 if t.Len() > 0 {
160 key := string(t.Key())
161 values := make([]string, 0, t.Len()-1)
162 for j := 1; j < t.Len(); j++ {
163 values = append(values, string(t.T[j]))
164 }
165 sort.Strings(values)
166 tagMap[key] = values
167 }
168 }
169
170 // Sort keys and hash
171 keys := make([]string, 0, len(tagMap))
172 for k := range tagMap {
173 keys = append(keys, k)
174 }
175 sort.Strings(keys)
176
177 h.Write([]byte("tags:"))
178 for _, k := range keys {
179 h.Write([]byte(k))
180 h.Write([]byte(":"))
181 for _, v := range tagMap[k] {
182 h.Write([]byte(v))
183 }
184 }
185 }
186
187 // Hash Since timestamp
188 if f.Since != nil {
189 h.Write([]byte("since:"))
190 var buf [8]byte
191 binary.BigEndian.PutUint64(buf[:], uint64(f.Since.V))
192 h.Write(buf[:])
193 }
194
195 // Hash Until timestamp
196 if f.Until != nil {
197 h.Write([]byte("until:"))
198 var buf [8]byte
199 binary.BigEndian.PutUint64(buf[:], uint64(f.Until.V))
200 h.Write(buf[:])
201 }
202
203 // Hash Limit
204 if f.Limit != nil && *f.Limit > 0 {
205 h.Write([]byte("limit:"))
206 var buf [4]byte
207 binary.BigEndian.PutUint32(buf[:], uint32(*f.Limit))
208 h.Write(buf[:])
209 }
210
211 // Hash Search (NIP-50)
212 if len(f.Search) > 0 {
213 h.Write([]byte("search:"))
214 h.Write(f.Search)
215 }
216
217 return hex.EncodeToString(h.Sum(nil))
218 }
219
220 // Len returns the number of cached queries.
221 func (qc *QueryCache) Len() int {
222 qc.mu.RLock()
223 defer qc.mu.RUnlock()
224 return len(qc.entries)
225 }
226
227 // MaxSize returns the maximum cache size.
228 func (qc *QueryCache) MaxSize() int {
229 return qc.maxSize
230 }
231
232 // Clear removes all entries from the cache.
233 func (qc *QueryCache) Clear() {
234 qc.mu.Lock()
235 defer qc.mu.Unlock()
236 qc.entries = make(map[string]*list.Element)
237 qc.order.Init()
238 }
239