1package usecase
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "slices"
8 "sync"
9 "time"
10
11 appport "github.com/bnema/dumber/internal/application/port"
12 "github.com/bnema/dumber/internal/domain/favicon"
13)
14
15var ErrFaviconMiss = appport.ErrFaviconMiss
16
17type ResolvePurpose = appport.FaviconResolvePurpose
18
19const (
20 ResolvePurposeUI = appport.FaviconResolvePurposeUI
21 ResolvePurposeSystemview = appport.FaviconResolvePurposeSystemview
22 ResolvePurposeRefresh = appport.FaviconResolvePurposeRefresh
23)
24
25type ResolveOptions = appport.FaviconResolveOptions
26type ResolvedFavicon = appport.ResolvedFavicon
27
28// SystemviewIconSize is the fixed PNG size served to internal systemview pages.
29const SystemviewIconSize = 32
30
31type faviconInvalidatorRegistry struct {
32 mu sync.RWMutex
33 fallback appport.FaviconInvalidators
34 invalidators []appport.FaviconInvalidator
35}
36
37func (r *faviconInvalidatorRegistry) Register(invalidator appport.FaviconInvalidator) {
38 if r == nil || invalidator == nil {
39 return
40 }
41 r.mu.Lock()
42 defer r.mu.Unlock()
43 r.invalidators = append(r.invalidators, invalidator)
44}
45
46func (r *faviconInvalidatorRegistry) InvalidateAll(ctx context.Context, key favicon.Key) error {
47 if r == nil {
48 return nil
49 }
50 r.mu.RLock()
51 fallback := r.fallback
52 invalidators := append([]appport.FaviconInvalidator(nil), r.invalidators...)
53 r.mu.RUnlock()
54 // The fallback invalidator owns pre-registered invalidation sets and is
55 // treated as fatal; dynamic invalidators below are best-effort and joined.
56 if fallback != nil {
57 if err := fallback.InvalidateAll(ctx, key); err != nil {
58 return err
59 }
60 }
61 var errs []error
62 for _, invalidator := range invalidators {
63 if err := invalidator.Invalidate(ctx, key); err != nil {
64 errs = append(errs, err)
65 }
66 }
67 return errors.Join(errs...)
68}
69
70type FaviconUseCase struct {
71 repo appport.FaviconRepository
72 blobs appport.FaviconBlobStore
73 fetcher appport.FaviconFetcher
74 discoverer appport.FaviconDiscoverer
75 converter appport.FaviconImageConverter
76 scheduler appport.FaviconRefreshScheduler
77 invalidators *faviconInvalidatorRegistry
78 now func() time.Time
79 ttl time.Duration
80 background context.Context
81 bgCancel context.CancelFunc
82 shared *refreshCoordinator
83}
84
85type FaviconDeps struct {
86 Repository appport.FaviconRepository
87 BlobStore appport.FaviconBlobStore
88 Fetcher appport.FaviconFetcher
89 Discoverer appport.FaviconDiscoverer
90 Converter appport.FaviconImageConverter
91 Scheduler appport.FaviconRefreshScheduler
92 Invalidators appport.FaviconInvalidators
93 Now func() time.Time
94 TTL time.Duration
95 // Background scopes background refresh work to the application
96 // lifetime. The use case derives its own cancelable scope from it,
97 // aborted by Close; callers pass the owning context (nil defaults to
98 // context.Background with shutdown drain only).
99 Background context.Context
100}
101
102func NewFaviconUseCase(deps FaviconDeps) *FaviconUseCase {
103 if deps.Now == nil {
104 deps.Now = time.Now
105 }
106 if deps.TTL == 0 {
107 deps.TTL = favicon.DefaultTTL
108 }
109 background := deps.Background
110 if background == nil {
111 background = context.Background()
112 }
113 // Owned cancelable scope for background refresh: parent cancellation
114 // propagates, and Close aborts it before draining shared operations.
115 background, bgCancel := context.WithCancel(background)
116 registry := &faviconInvalidatorRegistry{}
117 if deps.Invalidators != nil {
118 registry.fallback = deps.Invalidators
119 }
120 return &FaviconUseCase{
121 repo: deps.Repository,
122 blobs: deps.BlobStore,
123 fetcher: deps.Fetcher,
124 discoverer: deps.Discoverer,
125 converter: deps.Converter,
126 scheduler: deps.Scheduler,
127 invalidators: registry,
128 now: deps.Now,
129 ttl: deps.TTL,
130 background: background,
131 bgCancel: bgCancel,
132 shared: newRefreshCoordinator(),
133 }
134}
135
136func (uc *FaviconUseCase) RegisterFaviconInvalidator(invalidator appport.FaviconInvalidator) {
137 if uc == nil || uc.invalidators == nil || invalidator == nil {
138 return
139 }
140 uc.invalidators.Register(invalidator)
141}
142
143func (uc *FaviconUseCase) Resolve(
144 ctx context.Context,
145 rawURLOrDomain string,
146 size int,
147 options ResolveOptions,
148) (*appport.ResolvedFavicon, error) {
149 keys := favicon.Candidates(rawURLOrDomain)
150 if len(keys) == 0 {
151 return nil, ErrFaviconMiss
152 }
153
154 for _, key := range keys {
155 if err := ctx.Err(); err != nil {
156 return nil, err
157 }
158 meta, err := uc.repo.Get(ctx, key)
159 if err != nil {
160 return nil, err
161 }
162 if meta == nil {
163 repaired, repairErr := uc.repairOrphanBlobMetadata(ctx, key, rawURLOrDomain, size)
164 if repairErr != nil {
165 if !errors.Is(repairErr, ErrFaviconMiss) {
166 return nil, repairErr
167 }
168 continue
169 }
170 return repaired, nil
171 }
172 bytes, contentType, err := uc.readForSize(ctx, key, size)
173 if err != nil {
174 if !errors.Is(err, ErrFaviconMiss) {
175 return nil, err
176 }
177 continue
178 }
179 res := &appport.ResolvedFavicon{Key: key, Bytes: bytes, ContentType: contentType, Metadata: meta}
180 if favicon.ShouldRefresh(meta, uc.now(), uc.ttl) && options.ScheduleBackgroundRefresh {
181 res.BackgroundRefreshScheduled = uc.scheduleRefresh(key, meta.PageURL)
182 }
183 return res, nil
184 }
185
186 if options.AllowBlockingRefresh {
187 if err := uc.refresh(ctx, rawURLOrDomain, true); err != nil {
188 return nil, err
189 }
190 return uc.Resolve(ctx, rawURLOrDomain, size, ResolveOptions{Purpose: options.Purpose})
191 }
192 if options.ScheduleBackgroundRefresh {
193 uc.scheduleRefresh(keys[0], rawURLOrDomain)
194 }
195 return nil, ErrFaviconMiss
196}
197
198func (uc *FaviconUseCase) ResolveSystemviewIcon(ctx context.Context, rawDomain string, size int) (*appport.ResolvedFavicon, error) {
199 if size != SystemviewIconSize {
200 return nil, ErrFaviconMiss
201 }
202 return uc.Resolve(ctx, rawDomain, size, ResolveOptions{Purpose: ResolvePurposeSystemview})
203}
204
205func (uc *FaviconUseCase) repairOrphanBlobMetadata(
206 ctx context.Context,
207 key favicon.Key,
208 rawURLOrDomain string,
209 size int,
210) (*appport.ResolvedFavicon, error) {
211 bytes, contentType, err := uc.readForSize(ctx, key, size)
212 if err != nil {
213 return nil, err
214 }
215 if contentType == "" {
216 contentType = "image/png"
217 }
218 now := uc.now()
219 meta := favicon.Metadata{
220 Key: key,
221 SourceURL: rawURLOrDomain,
222 PageURL: rawURLOrDomain,
223 Source: favicon.SourceImported,
224 ContentHash: favicon.Hash(bytes),
225 ContentType: contentType,
226 UpdatedAt: now,
227 LastCheckedAt: now,
228 }
229 if err := uc.repo.Upsert(ctx, meta); err != nil {
230 return nil, err
231 }
232 return &appport.ResolvedFavicon{Key: key, Bytes: bytes, ContentType: contentType, Metadata: &meta}, nil
233}
234
235func (uc *FaviconUseCase) Observe(
236 ctx context.Context,
237 pageURL, iconURL string,
238 bytes []byte,
239 source favicon.Source,
240 contentType string,
241) (*favicon.Metadata, error) {
242 key, ok := favicon.CanonicalKey(pageURL)
243 if !ok || len(bytes) == 0 {
244 return nil, ErrFaviconMiss
245 }
246 return uc.observeKey(ctx, key, pageURL, iconURL, bytes, source, contentType)
247}
248
249func (uc *FaviconUseCase) observeKey(
250 ctx context.Context,
251 key favicon.Key,
252 pageURL, iconURL string,
253 bytes []byte,
254 source favicon.Source,
255 contentType string,
256) (*favicon.Metadata, error) {
257 if key == "" || len(bytes) == 0 {
258 return nil, ErrFaviconMiss
259 }
260 old, err := uc.repo.Get(ctx, key)
261 if err != nil {
262 return nil, err
263 }
264 now := uc.now()
265 if !favicon.HasContentChanged(old, bytes) {
266 if err := uc.repo.UpdateLastChecked(ctx, key, old.ContentHash, now); err != nil {
267 return nil, err
268 }
269 old.LastCheckedAt = now
270 return old, nil
271 }
272
273 var converted *appport.ConvertedFavicon
274 if uc.converter != nil {
275 var err error
276 converted, err = uc.converter.Convert(ctx, bytes, contentType, []int{SystemviewIconSize})
277 if err != nil {
278 return nil, err
279 }
280 }
281
282 if err := uc.blobs.WriteOriginal(ctx, key, bytes, contentType); err != nil {
283 return nil, err
284 }
285 if err := uc.blobs.RemoveDerived(ctx, key); err != nil {
286 return nil, err
287 }
288 if converted != nil {
289 if len(converted.PNG) > 0 {
290 if err := uc.blobs.WritePNG(ctx, key, converted.PNG); err != nil {
291 return nil, err
292 }
293 }
294 if b := converted.SizedPNG[SystemviewIconSize]; len(b) > 0 {
295 if err := uc.blobs.WriteSizedPNG(ctx, key, SystemviewIconSize, b); err != nil {
296 return nil, err
297 }
298 }
299 }
300 meta := favicon.Metadata{
301 Key: key,
302 SourceURL: iconURL,
303 PageURL: pageURL,
304 Source: source,
305 ContentHash: favicon.Hash(bytes),
306 ContentType: contentType,
307 UpdatedAt: now,
308 LastCheckedAt: now,
309 }
310 if err := uc.repo.Upsert(ctx, meta); err != nil {
311 return nil, err
312 }
313 if err := uc.invalidateCaches(ctx, key); err != nil {
314 return nil, err
315 }
316 return &meta, nil
317}
318
319func (uc *FaviconUseCase) RefreshIfStale(ctx context.Context, pageURL string) error {
320 return uc.refresh(ctx, pageURL, false)
321}
322
323func (uc *FaviconUseCase) RefreshFromIconURLs(ctx context.Context, pageURL string, iconURLs []string) error {
324 if uc.fetcher == nil {
325 return ErrFaviconMiss
326 }
327 key, candidates, ok := refreshRequestKey(pageURL, iconURLs)
328 if !ok {
329 return ErrFaviconMiss
330 }
331 // Sealed admission wins over a cached absence: one atomic check so a
332 // refresh arriving after Close reports shutdown, never a stale miss.
333 if closed, miss := uc.sealedOrMiss(key); closed {
334 return appport.ErrFaviconShutdown
335 } else if miss {
336 return ErrFaviconMiss
337 }
338 return uc.joinRefreshOp(ctx, key, func(opCtx context.Context, startEpoch uint64) error {
339 var lastMiss error
340 // allMissesCacheable tracks whether EVERY attempted candidate
341 // failed with a cacheable genuine absence (verified 404/410).
342 // A single non-cacheable miss (401/403, 5xx, validation,
343 // transport) poisons the batch: the next call must retry the
344 // transient candidate instead of reading a cached absence.
345 allMissesCacheable := true
346 for _, iconURL := range candidates {
347 if err := opCtx.Err(); err != nil {
348 return err
349 }
350 fetched, err := uc.fetcher.Fetch(opCtx, appport.FaviconFetchRequest{PageURL: pageURL, IconURL: iconURL})
351 if err != nil {
352 if status, classified := appport.FetchStatusCode(err); classified {
353 if status != 404 && status != 410 {
354 allMissesCacheable = false
355 }
356 lastMiss = err
357 continue
358 }
359 if errors.Is(err, ErrFaviconMiss) {
360 allMissesCacheable = false
361 lastMiss = err
362 continue
363 }
364 return err
365 }
366 if uc.epochValue() != startEpoch {
367 return nil
368 }
369 err = uc.observeFetched(opCtx, pageURL, fetched)
370 if errors.Is(err, ErrFaviconMiss) {
371 allMissesCacheable = false
372 lastMiss = err
373 continue
374 }
375 if err == nil {
376 // Success contradicts any earlier absence record for
377 // this request: drop it instead of letting it linger.
378 uc.clearMiss(key)
379 }
380 return err
381 }
382 if lastMiss != nil {
383 // Only a fully missed request populates the negative cache:
384 // recording per candidate would poison later candidates
385 // that were never tried, and a mixed batch must never
386 // suppress retry of its transient candidates.
387 if allMissesCacheable {
388 uc.noteMiss(key, lastMiss)
389 }
390 return lastMiss
391 }
392 if uc.epochValue() != startEpoch {
393 return nil
394 }
395 return uc.RefreshIfStale(opCtx, pageURL)
396 })
397}
398
399func (uc *FaviconUseCase) refresh(ctx context.Context, pageURL string, force bool) error {
400 keys := favicon.Candidates(pageURL)
401 if len(keys) == 0 {
402 return ErrFaviconMiss
403 }
404 if !force {
405 fresh, err := uc.anyCandidateFresh(ctx, keys)
406 if err != nil {
407 return err
408 }
409 if fresh {
410 return nil
411 }
412 }
413 return uc.fetchAndObserve(ctx, pageURL)
414}
415
416func (uc *FaviconUseCase) refreshKey(ctx context.Context, key favicon.Key, pageURL string) error {
417 meta, err := uc.repo.Get(ctx, key)
418 if err != nil {
419 return err
420 }
421 if meta != nil && !favicon.ShouldRefresh(meta, uc.now(), uc.ttl) {
422 return nil
423 }
424 // Scheduler-backed work joins the same admission budget as direct
425 // refreshes; discovery has no upfront candidates, so it shares the
426 // page-scoped discovery identity.
427 discoveryKey, _, ok := refreshRequestKey(pageURL, nil)
428 if !ok {
429 return ErrFaviconMiss
430 }
431 return uc.joinRefreshOp(ctx, discoveryKey, func(opCtx context.Context, _ uint64) error {
432 return uc.fetchAndObserve(opCtx, pageURL)
433 })
434}
435
436func (uc *FaviconUseCase) anyCandidateFresh(ctx context.Context, keys []favicon.Key) (bool, error) {
437 for _, key := range keys {
438 meta, err := uc.repo.Get(ctx, key)
439 if err != nil {
440 return false, err
441 }
442 if meta != nil && !favicon.ShouldRefresh(meta, uc.now(), uc.ttl) {
443 return true, nil
444 }
445 }
446 return false, nil
447}
448
449func (uc *FaviconUseCase) fetchAndObserve(ctx context.Context, pageURL string) error {
450 if uc.fetcher == nil {
451 return ErrFaviconMiss
452 }
453 fetched, err := uc.fetcher.Fetch(ctx, appport.FaviconFetchRequest{PageURL: pageURL})
454 if err != nil {
455 return err
456 }
457 err = uc.observeFetched(ctx, pageURL, fetched)
458 return err
459}
460
461func (uc *FaviconUseCase) observeFetched(ctx context.Context, requestedPageURL string, fetched *appport.FaviconFetchedIcon) error {
462 if fetched == nil {
463 return ErrFaviconMiss
464 }
465 if fetched.ResolvedKey != "" {
466 allowed := slices.Contains(favicon.Candidates(requestedPageURL), fetched.ResolvedKey)
467 if !allowed {
468 return ErrFaviconMiss
469 }
470 _, err := uc.observeKey(ctx, fetched.ResolvedKey, requestedPageURL, fetched.IconURL, fetched.Bytes, fetched.Source, fetched.ContentType)
471 return err
472 }
473 _, err := uc.Observe(ctx, requestedPageURL, fetched.IconURL, fetched.Bytes, fetched.Source, fetched.ContentType)
474 return err
475}
476
477func (uc *FaviconUseCase) EnsureSized(ctx context.Context, key favicon.Key, size int) error {
478 if _, _, err := uc.blobs.ReadSizedPNG(ctx, key, size); err == nil {
479 return nil
480 } else if !errors.Is(err, ErrFaviconMiss) {
481 return err
482 }
483 orig, ct, err := uc.blobs.ReadOriginal(ctx, key)
484 if err != nil {
485 return err
486 }
487 if uc.converter == nil {
488 return ErrFaviconMiss
489 }
490 converted, err := uc.converter.Convert(ctx, orig, ct, []int{size})
491 if err != nil {
492 return err
493 }
494 if len(converted.PNG) > 0 {
495 if err := uc.blobs.WritePNG(ctx, key, converted.PNG); err != nil {
496 return err
497 }
498 }
499 if b := converted.SizedPNG[size]; len(b) > 0 {
500 return uc.blobs.WriteSizedPNG(ctx, key, size, b)
501 }
502 return ErrFaviconMiss
503}
504
505func (uc *FaviconUseCase) Invalidate(ctx context.Context, key favicon.Key) error {
506 // Invalidation advances the epoch so in-flight completions that have not
507 // yet passed their pre-commit epoch check cannot repopulate the cleared
508 // entries. This is best-effort suppression, not a transactional commit:
509 // a completion that already passed its check may still write after the
510 // clear (a transient stale icon until the next refresh), because the
511 // check and the blob/repository writes are not atomic. Closing that
512 // residual window would require versioned commits in the repository
513 // layer and is out of scope for this stack.
514 uc.bumpEpoch()
515 if uc.blobs != nil {
516 if err := uc.blobs.RemoveDerived(ctx, key); err != nil {
517 return err
518 }
519 }
520 return uc.invalidateCaches(ctx, key)
521}
522
523func (uc *FaviconUseCase) invalidateCaches(ctx context.Context, key favicon.Key) error {
524 if uc.invalidators != nil {
525 return uc.invalidators.InvalidateAll(ctx, key)
526 }
527 return nil
528}
529
530func (uc *FaviconUseCase) readForSize(ctx context.Context, key favicon.Key, size int) ([]byte, string, error) {
531 if size < 0 {
532 return nil, "", fmt.Errorf("invalid favicon size: %d", size)
533 }
534 if size > 0 {
535 if err := uc.EnsureSized(ctx, key, size); err != nil {
536 return nil, "", err
537 }
538 return uc.blobs.ReadSizedPNG(ctx, key, size)
539 }
540 return uc.blobs.ReadPNG(ctx, key)
541}
542
543func (uc *FaviconUseCase) scheduleRefresh(key favicon.Key, pageURL string) bool {
544 if uc.scheduler == nil {
545 return false
546 }
547 return uc.scheduler.Schedule(uc.background, key, func(ctx context.Context) { _ = uc.refreshKey(ctx, key, pageURL) })
548}
549
550// Close aborts owned background refresh work and drains shared in-flight
551// operations. Admission is sealed first so no refresh can start after the
552// drain observes zero active operations. It is safe to call on a nil
553// receiver and more than once.
554func (uc *FaviconUseCase) Close() {
555 if uc == nil {
556 return
557 }
558 uc.shared.mu.Lock()
559 uc.shared.closed = true
560 uc.shared.mu.Unlock()
561 if uc.bgCancel != nil {
562 uc.bgCancel()
563 }
564 uc.drainRefreshOps()
565}