1package usecase
2
3import (
4 "context"
5 "crypto/sha256"
6 "encoding/hex"
7 "net"
8 "net/url"
9 "slices"
10 "strings"
11 "sync"
12 "time"
13
14 appport "github.com/bnema/dumber/internal/application/port"
15 "github.com/bnema/dumber/internal/domain/favicon"
16)
17
18const (
19 // maxRefreshCandidates bounds how many icon URLs one refresh operation
20 // accepts; extras are skipped, never queued.
21 maxRefreshCandidates = 16
22 // maxRefreshCandidateLen bounds a single candidate URL length; longer
23 // inputs are skipped as invalid rather than fetched.
24 maxRefreshCandidateLen = 8 * 1024
25 // maxActiveRefreshes bounds distinct concurrent refresh operations
26 // across direct and scheduler-backed paths. Overload skips optional
27 // background work or reports ErrFaviconBusy to direct callers.
28 maxActiveRefreshes = 8
29 // refreshOpTimeout bounds a whole refresh operation, including
30 // context-aware DNS/validation, not per candidate.
31 refreshOpTimeout = 5 * time.Second
32 // faviconMissTTL bounds negative-cache entries for genuine absence.
33 faviconMissTTL = 30 * time.Second
34 // maxMissEntries bounds the negative cache; oldest entries are evicted.
35 maxMissEntries = 256
36)
37
38// refreshRequestKey builds a fixed-size digest identifying request-equivalent
39// refresh work: the validated page origin plus the ordered, normalized
40// candidate list. Scheme, host, port, path, and query are significant;
41// fragments are dropped and hosts lowercased. Relative candidates resolve
42// against the page URL. At most maxRefreshCandidates URLs of at most
43// maxRefreshCandidateLen bytes are accepted; the rest are skipped. It
44// returns false when the page URL has no valid origin hierarchy.
45func refreshRequestKey(pageURL string, iconURLs []string) (string, []string, bool) {
46 pageKey, ok := favicon.CanonicalKey(pageURL)
47 if !ok {
48 return "", nil, false
49 }
50 page, err := url.Parse(strings.TrimSpace(pageURL))
51 if err != nil || page.Hostname() == "" {
52 return "", nil, false
53 }
54 accepted := make([]string, 0, min(len(iconURLs), maxRefreshCandidates))
55 for _, raw := range iconURLs {
56 raw = strings.TrimSpace(raw)
57 if raw == "" || len(raw) > maxRefreshCandidateLen {
58 continue
59 }
60 candidate, err := page.Parse(raw)
61 if err != nil {
62 continue
63 }
64 if candidate.Scheme != "http" && candidate.Scheme != "https" {
65 continue
66 }
67 if candidate.Hostname() == "" {
68 continue
69 }
70 candidate.Fragment = ""
71 candidate.Host = strings.ToLower(candidate.Host)
72 if host, port, ok := splitHostPort(candidate.Host); ok {
73 if isDefaultPort(candidate.Scheme, port) {
74 // Re-bracket IPv6 literals: SplitHostPort strips
75 // brackets, and url.URL requires them in Host.
76 if strings.Contains(host, ":") {
77 candidate.Host = "[" + host + "]"
78 } else {
79 candidate.Host = host
80 }
81 }
82 }
83 accepted = append(accepted, candidate.String())
84 if len(accepted) >= maxRefreshCandidates {
85 break
86 }
87 }
88 sum := sha256.Sum256([]byte(string(pageKey) + "\x00" + strings.Join(accepted, "\x00")))
89 return hex.EncodeToString(sum[:]), accepted, true
90}
91
92// splitHostPort splits an explicit host:port pair. Bracketed IPv6 literals
93// (as produced by url.URL.Host) require net.SplitHostPort; a bare
94// strings.Cut on ":" misparses them. Hosts without a port report false.
95func splitHostPort(hostport string) (string, string, bool) {
96 host, port, err := net.SplitHostPort(hostport)
97 if err != nil {
98 return "", "", false
99 }
100 return host, port, true
101}
102
103func isDefaultPort(scheme, port string) bool {
104 return (scheme == "http" && port == "80") || (scheme == "https" && port == "443")
105}
106
107// refreshCall is one shared in-flight refresh operation. Waiters join by
108// request key; the first waiter executes while the rest observe. One
109// canceled waiter never cancels the others; the operation is canceled only
110// when the last waiter leaves, decided atomically under the coordinator
111// lock so no joiner can observe a half-canceled call.
112type refreshCall struct {
113 done chan struct{}
114 err error
115 waiters int
116 cancel context.CancelFunc
117 // closing marks a call whose last waiter left: it still occupies its
118 // slot and map entry until the executor finishes, but no new waiter
119 // may join it; joiners create a fresh operation instead.
120 closing bool
121}
122
123// refreshMiss records a negatively cached genuine absence.
124type refreshMiss struct {
125 expiresAt time.Time
126}
127
128// refreshCoordinator state lives on FaviconUseCase; see favicon.go.
129type refreshCoordinator struct {
130 mu sync.Mutex
131 active map[string]*refreshCall
132 slots chan struct{}
133 // wg tracks every spawned executor independently of the active map:
134 // a fresh same-key operation may replace a canceled-but-still-running
135 // call in active, so the map alone cannot prove shutdown quiescence.
136 // Adds happen under mu before the goroutine starts; Done runs when the
137 // executor finishes. Sealed admission (closed) guarantees no Add can
138 // race a completed Wait.
139 wg sync.WaitGroup
140 epoch uint64
141 // closed seals admission: set by Close before cancel/drain, so no
142 // refresh can start after the drain observes zero active operations.
143 closed bool
144 misses map[string]refreshMiss
145 order []string
146}
147
148func newRefreshCoordinator() *refreshCoordinator {
149 return &refreshCoordinator{
150 active: make(map[string]*refreshCall),
151 slots: make(chan struct{}, maxActiveRefreshes),
152 misses: make(map[string]refreshMiss),
153 }
154}
155
156// epochValue returns the current invalidation epoch.
157func (uc *FaviconUseCase) epochValue() uint64 {
158 uc.shared.mu.Lock()
159 defer uc.shared.mu.Unlock()
160 return uc.shared.epoch
161}
162
163// bumpEpoch advances the invalidation epoch so older in-flight completions
164// cannot repopulate invalidated entries.
165func (uc *FaviconUseCase) bumpEpoch() {
166 uc.shared.mu.Lock()
167 defer uc.shared.mu.Unlock()
168 uc.shared.epoch++
169}
170
171// cachedMiss reports whether key holds an unexpired genuine-absence entry.
172func (uc *FaviconUseCase) cachedMiss(key string) bool {
173 _, miss := uc.sealedOrMiss(key)
174 return miss
175}
176
177// sealedOrMiss atomically reports coordinator shutdown and negative-cache
178// state under one lock acquisition, giving late refreshes a single
179// linearization point: sealed admission always wins over a cached absence,
180// so a refresh arriving after Close reports ErrFaviconShutdown even when
181// the key holds a cached 404/410.
182func (uc *FaviconUseCase) sealedOrMiss(key string) (closed, miss bool) {
183 now := uc.now()
184 uc.shared.mu.Lock()
185 defer uc.shared.mu.Unlock()
186 if uc.shared.closed {
187 return true, false
188 }
189 m, ok := uc.shared.misses[key]
190 if !ok {
191 return false, false
192 }
193 if !now.Before(m.expiresAt) {
194 delete(uc.shared.misses, key)
195 return false, false
196 }
197 return false, true
198}
199
200// clearMiss drops any absence record for key: a later success proves the
201// resource exists and must not stay suppressed.
202func (uc *FaviconUseCase) clearMiss(key string) {
203 uc.shared.mu.Lock()
204 defer uc.shared.mu.Unlock()
205 delete(uc.shared.misses, key)
206}
207
208// noteMiss records a genuine absence (verified 404/410) for request key.
209// Only 404/410 are cacheable: 401/403, 5xx, cancellation, DNS/transport,
210// and validation failures must never suppress a later retry.
211func (uc *FaviconUseCase) noteMiss(key string, fetchErr error) {
212 status, ok := appport.FetchStatusCode(fetchErr)
213 if !ok || (status != 404 && status != 410) {
214 return
215 }
216 uc.shared.mu.Lock()
217 defer uc.shared.mu.Unlock()
218 if _, exists := uc.shared.misses[key]; !exists {
219 for len(uc.shared.misses) >= maxMissEntries {
220 uc.evictMissLocked()
221 }
222 uc.shared.order = append(uc.shared.order, key)
223 }
224 uc.shared.misses[key] = refreshMiss{expiresAt: uc.now().Add(faviconMissTTL)}
225 // Expired names linger in order until evicted; compact before the
226 // backlog can grow without bound.
227 if len(uc.shared.order) > 4*maxMissEntries {
228 uc.compactMissOrderLocked()
229 }
230}
231
232// compactMissOrderLocked drops order names with no live entry and collapses
233// duplicates to one entry per live key, keeping the newest occurrence.
234// Eviction removes entries from the map but not from order, so an evicted
235// key that is reinserted would otherwise leave a stale duplicate behind:
236// repeated cycles grow order without bound and let an old duplicate evict
237// the live entry early. Caller must hold refresh.mu.
238func (uc *FaviconUseCase) compactMissOrderLocked() {
239 last := make(map[string]int, len(uc.shared.misses))
240 for i, key := range uc.shared.order {
241 if _, ok := uc.shared.misses[key]; ok {
242 last[key] = i
243 }
244 }
245 ordered := make([]string, 0, len(last))
246 for key := range last {
247 ordered = append(ordered, key)
248 }
249 slices.SortFunc(ordered, func(a, b string) int { return last[a] - last[b] })
250 for i := len(ordered); i < len(uc.shared.order); i++ {
251 uc.shared.order[i] = ""
252 }
253 uc.shared.order = append(uc.shared.order[:0], ordered...)
254}
255
256// evictMissLocked removes one entry: expired first, oldest otherwise.
257// Caller must hold refresh.mu.
258func (uc *FaviconUseCase) evictMissLocked() {
259 now := uc.now()
260 for key, miss := range uc.shared.misses {
261 if !now.Before(miss.expiresAt) {
262 delete(uc.shared.misses, key)
263 return
264 }
265 }
266 for len(uc.shared.order) > 0 {
267 oldest := uc.shared.order[0]
268 uc.shared.order = uc.shared.order[1:]
269 if _, ok := uc.shared.misses[oldest]; ok {
270 delete(uc.shared.misses, oldest)
271 return
272 }
273 }
274 for key := range uc.shared.misses {
275 delete(uc.shared.misses, key)
276 return
277 }
278}
279
280// joinRefreshOp shares equivalent in-flight work identified by key. The
281// first waiter executes exec under a whole-operation deadline derived from
282// the owned background context; joiners observe the result. The creating
283// waiter also advances the invalidation epoch, so concurrent joiners never
284// invalidate the operation they joined. A canceled waiter leaves without
285// affecting others; the operation is canceled atomically when the last
286// waiter leaves, and late joiners start a fresh operation instead of
287// observing a dying call. When the admission budget is exhausted, or the
288// coordinator is closed, direct callers receive ErrFaviconBusy or
289// ErrFaviconShutdown respectively (never false success).
290func (uc *FaviconUseCase) joinRefreshOp(ctx context.Context, key string, exec func(opCtx context.Context, startEpoch uint64) error) error {
291 uc.shared.mu.Lock()
292 if uc.shared.closed {
293 uc.shared.mu.Unlock()
294 return appport.ErrFaviconShutdown
295 }
296 if call, ok := uc.shared.active[key]; ok && !call.closing {
297 call.waiters++
298 uc.shared.mu.Unlock()
299 return uc.awaitRefreshCall(ctx, call)
300 }
301 select {
302 case uc.shared.slots <- struct{}{}:
303 default:
304 uc.shared.mu.Unlock()
305 return appport.ErrFaviconBusy
306 }
307 opCtx, cancel := context.WithTimeout(uc.background, refreshOpTimeout)
308 // The creator advances the epoch with creation itself: older
309 // completions cannot repopulate entries this operation supersedes,
310 // and joiners share the creator's epoch instead of invalidating it.
311 uc.shared.epoch++
312 startEpoch := uc.shared.epoch
313 call := &refreshCall{done: make(chan struct{}), waiters: 1, cancel: cancel}
314 uc.shared.active[key] = call
315 uc.shared.wg.Add(1)
316 uc.shared.mu.Unlock()
317
318 go func() {
319 defer uc.shared.wg.Done()
320 err := exec(opCtx, startEpoch)
321 <-uc.shared.slots
322 cancel()
323 uc.shared.mu.Lock()
324 call.err = err
325 if uc.shared.active[key] == call {
326 delete(uc.shared.active, key)
327 }
328 uc.shared.mu.Unlock()
329 close(call.done)
330 }()
331
332 return uc.awaitRefreshCall(ctx, call)
333}
334
335// awaitRefreshCall waits for a shared operation or the waiter's own
336// cancellation, releasing the waiter slot exactly once. The last waiter to
337// leave marks the call closing and cancels it atomically under the lock:
338// context cancellation only signals, so invoking it while holding the
339// coordinator mutex cannot block.
340func (uc *FaviconUseCase) awaitRefreshCall(ctx context.Context, call *refreshCall) error {
341 select {
342 case <-call.done:
343 uc.shared.mu.Lock()
344 err := call.err
345 uc.shared.mu.Unlock()
346 return err
347 case <-ctx.Done():
348 uc.shared.mu.Lock()
349 call.waiters--
350 if call.waiters <= 0 && !call.closing {
351 call.closing = true
352 call.cancel()
353 }
354 uc.shared.mu.Unlock()
355 return ctx.Err()
356 }
357}
358
359// drainRefreshOps waits until every spawned executor finished, up to a
360// bounded multiple of the operation deadline. It waits on the executor
361// wait group rather than the active map: a replaced
362// canceled-but-still-running call no longer occupies its map entry, but
363// its executor may still touch repositories. Callers cancel first (see
364// Close); cooperative operations observe the background cancellation and
365// finish promptly, so hitting the bound means stuck work that shutdown
366// must not wait out.
367func (uc *FaviconUseCase) drainRefreshOps() {
368 done := make(chan struct{})
369 go func() {
370 uc.shared.wg.Wait()
371 close(done)
372 }()
373 deadline := time.NewTimer(3 * refreshOpTimeout)
374 defer deadline.Stop()
375 select {
376 case <-done:
377 case <-deadline.C:
378 }
379}