package usecase import ( "context" "errors" "fmt" "slices" "sync" "time" appport "github.com/bnema/dumber/internal/application/port" "github.com/bnema/dumber/internal/domain/favicon" ) var ErrFaviconMiss = appport.ErrFaviconMiss type ResolvePurpose = appport.FaviconResolvePurpose const ( ResolvePurposeUI = appport.FaviconResolvePurposeUI ResolvePurposeSystemview = appport.FaviconResolvePurposeSystemview ResolvePurposeRefresh = appport.FaviconResolvePurposeRefresh ) type ResolveOptions = appport.FaviconResolveOptions type ResolvedFavicon = appport.ResolvedFavicon // SystemviewIconSize is the fixed PNG size served to internal systemview pages. const SystemviewIconSize = 32 type faviconInvalidatorRegistry struct { mu sync.RWMutex fallback appport.FaviconInvalidators invalidators []appport.FaviconInvalidator } func (r *faviconInvalidatorRegistry) Register(invalidator appport.FaviconInvalidator) { if r == nil || invalidator == nil { return } r.mu.Lock() defer r.mu.Unlock() r.invalidators = append(r.invalidators, invalidator) } func (r *faviconInvalidatorRegistry) InvalidateAll(ctx context.Context, key favicon.Key) error { if r == nil { return nil } r.mu.RLock() fallback := r.fallback invalidators := append([]appport.FaviconInvalidator(nil), r.invalidators...) r.mu.RUnlock() // The fallback invalidator owns pre-registered invalidation sets and is // treated as fatal; dynamic invalidators below are best-effort and joined. if fallback != nil { if err := fallback.InvalidateAll(ctx, key); err != nil { return err } } var errs []error for _, invalidator := range invalidators { if err := invalidator.Invalidate(ctx, key); err != nil { errs = append(errs, err) } } return errors.Join(errs...) } type FaviconUseCase struct { repo appport.FaviconRepository blobs appport.FaviconBlobStore fetcher appport.FaviconFetcher discoverer appport.FaviconDiscoverer converter appport.FaviconImageConverter scheduler appport.FaviconRefreshScheduler invalidators *faviconInvalidatorRegistry now func() time.Time ttl time.Duration background context.Context bgCancel context.CancelFunc shared *refreshCoordinator } type FaviconDeps struct { Repository appport.FaviconRepository BlobStore appport.FaviconBlobStore Fetcher appport.FaviconFetcher Discoverer appport.FaviconDiscoverer Converter appport.FaviconImageConverter Scheduler appport.FaviconRefreshScheduler Invalidators appport.FaviconInvalidators Now func() time.Time TTL time.Duration // Background scopes background refresh work to the application // lifetime. The use case derives its own cancelable scope from it, // aborted by Close; callers pass the owning context (nil defaults to // context.Background with shutdown drain only). Background context.Context } func NewFaviconUseCase(deps FaviconDeps) *FaviconUseCase { if deps.Now == nil { deps.Now = time.Now } if deps.TTL == 0 { deps.TTL = favicon.DefaultTTL } background := deps.Background if background == nil { background = context.Background() } // Owned cancelable scope for background refresh: parent cancellation // propagates, and Close aborts it before draining shared operations. background, bgCancel := context.WithCancel(background) registry := &faviconInvalidatorRegistry{} if deps.Invalidators != nil { registry.fallback = deps.Invalidators } return &FaviconUseCase{ repo: deps.Repository, blobs: deps.BlobStore, fetcher: deps.Fetcher, discoverer: deps.Discoverer, converter: deps.Converter, scheduler: deps.Scheduler, invalidators: registry, now: deps.Now, ttl: deps.TTL, background: background, bgCancel: bgCancel, shared: newRefreshCoordinator(), } } func (uc *FaviconUseCase) RegisterFaviconInvalidator(invalidator appport.FaviconInvalidator) { if uc == nil || uc.invalidators == nil || invalidator == nil { return } uc.invalidators.Register(invalidator) } func (uc *FaviconUseCase) Resolve( ctx context.Context, rawURLOrDomain string, size int, options ResolveOptions, ) (*appport.ResolvedFavicon, error) { keys := favicon.Candidates(rawURLOrDomain) if len(keys) == 0 { return nil, ErrFaviconMiss } for _, key := range keys { if err := ctx.Err(); err != nil { return nil, err } meta, err := uc.repo.Get(ctx, key) if err != nil { return nil, err } if meta == nil { repaired, repairErr := uc.repairOrphanBlobMetadata(ctx, key, rawURLOrDomain, size) if repairErr != nil { if !errors.Is(repairErr, ErrFaviconMiss) { return nil, repairErr } continue } return repaired, nil } bytes, contentType, err := uc.readForSize(ctx, key, size) if err != nil { if !errors.Is(err, ErrFaviconMiss) { return nil, err } continue } res := &appport.ResolvedFavicon{Key: key, Bytes: bytes, ContentType: contentType, Metadata: meta} if favicon.ShouldRefresh(meta, uc.now(), uc.ttl) && options.ScheduleBackgroundRefresh { res.BackgroundRefreshScheduled = uc.scheduleRefresh(key, meta.PageURL) } return res, nil } if options.AllowBlockingRefresh { if err := uc.refresh(ctx, rawURLOrDomain, true); err != nil { return nil, err } return uc.Resolve(ctx, rawURLOrDomain, size, ResolveOptions{Purpose: options.Purpose}) } if options.ScheduleBackgroundRefresh { uc.scheduleRefresh(keys[0], rawURLOrDomain) } return nil, ErrFaviconMiss } func (uc *FaviconUseCase) ResolveSystemviewIcon(ctx context.Context, rawDomain string, size int) (*appport.ResolvedFavicon, error) { if size != SystemviewIconSize { return nil, ErrFaviconMiss } return uc.Resolve(ctx, rawDomain, size, ResolveOptions{Purpose: ResolvePurposeSystemview}) } func (uc *FaviconUseCase) repairOrphanBlobMetadata( ctx context.Context, key favicon.Key, rawURLOrDomain string, size int, ) (*appport.ResolvedFavicon, error) { bytes, contentType, err := uc.readForSize(ctx, key, size) if err != nil { return nil, err } if contentType == "" { contentType = "image/png" } now := uc.now() meta := favicon.Metadata{ Key: key, SourceURL: rawURLOrDomain, PageURL: rawURLOrDomain, Source: favicon.SourceImported, ContentHash: favicon.Hash(bytes), ContentType: contentType, UpdatedAt: now, LastCheckedAt: now, } if err := uc.repo.Upsert(ctx, meta); err != nil { return nil, err } return &appport.ResolvedFavicon{Key: key, Bytes: bytes, ContentType: contentType, Metadata: &meta}, nil } func (uc *FaviconUseCase) Observe( ctx context.Context, pageURL, iconURL string, bytes []byte, source favicon.Source, contentType string, ) (*favicon.Metadata, error) { key, ok := favicon.CanonicalKey(pageURL) if !ok || len(bytes) == 0 { return nil, ErrFaviconMiss } return uc.observeKey(ctx, key, pageURL, iconURL, bytes, source, contentType) } func (uc *FaviconUseCase) observeKey( ctx context.Context, key favicon.Key, pageURL, iconURL string, bytes []byte, source favicon.Source, contentType string, ) (*favicon.Metadata, error) { if key == "" || len(bytes) == 0 { return nil, ErrFaviconMiss } old, err := uc.repo.Get(ctx, key) if err != nil { return nil, err } now := uc.now() if !favicon.HasContentChanged(old, bytes) { if err := uc.repo.UpdateLastChecked(ctx, key, old.ContentHash, now); err != nil { return nil, err } old.LastCheckedAt = now return old, nil } var converted *appport.ConvertedFavicon if uc.converter != nil { var err error converted, err = uc.converter.Convert(ctx, bytes, contentType, []int{SystemviewIconSize}) if err != nil { return nil, err } } if err := uc.blobs.WriteOriginal(ctx, key, bytes, contentType); err != nil { return nil, err } if err := uc.blobs.RemoveDerived(ctx, key); err != nil { return nil, err } if converted != nil { if len(converted.PNG) > 0 { if err := uc.blobs.WritePNG(ctx, key, converted.PNG); err != nil { return nil, err } } if b := converted.SizedPNG[SystemviewIconSize]; len(b) > 0 { if err := uc.blobs.WriteSizedPNG(ctx, key, SystemviewIconSize, b); err != nil { return nil, err } } } meta := favicon.Metadata{ Key: key, SourceURL: iconURL, PageURL: pageURL, Source: source, ContentHash: favicon.Hash(bytes), ContentType: contentType, UpdatedAt: now, LastCheckedAt: now, } if err := uc.repo.Upsert(ctx, meta); err != nil { return nil, err } if err := uc.invalidateCaches(ctx, key); err != nil { return nil, err } return &meta, nil } func (uc *FaviconUseCase) RefreshIfStale(ctx context.Context, pageURL string) error { return uc.refresh(ctx, pageURL, false) } func (uc *FaviconUseCase) RefreshFromIconURLs(ctx context.Context, pageURL string, iconURLs []string) error { if uc.fetcher == nil { return ErrFaviconMiss } key, candidates, ok := refreshRequestKey(pageURL, iconURLs) if !ok { return ErrFaviconMiss } // Sealed admission wins over a cached absence: one atomic check so a // refresh arriving after Close reports shutdown, never a stale miss. if closed, miss := uc.sealedOrMiss(key); closed { return appport.ErrFaviconShutdown } else if miss { return ErrFaviconMiss } return uc.joinRefreshOp(ctx, key, func(opCtx context.Context, startEpoch uint64) error { var lastMiss error // allMissesCacheable tracks whether EVERY attempted candidate // failed with a cacheable genuine absence (verified 404/410). // A single non-cacheable miss (401/403, 5xx, validation, // transport) poisons the batch: the next call must retry the // transient candidate instead of reading a cached absence. allMissesCacheable := true for _, iconURL := range candidates { if err := opCtx.Err(); err != nil { return err } fetched, err := uc.fetcher.Fetch(opCtx, appport.FaviconFetchRequest{PageURL: pageURL, IconURL: iconURL}) if err != nil { if status, classified := appport.FetchStatusCode(err); classified { if status != 404 && status != 410 { allMissesCacheable = false } lastMiss = err continue } if errors.Is(err, ErrFaviconMiss) { allMissesCacheable = false lastMiss = err continue } return err } if uc.epochValue() != startEpoch { return nil } err = uc.observeFetched(opCtx, pageURL, fetched) if errors.Is(err, ErrFaviconMiss) { allMissesCacheable = false lastMiss = err continue } if err == nil { // Success contradicts any earlier absence record for // this request: drop it instead of letting it linger. uc.clearMiss(key) } return err } if lastMiss != nil { // Only a fully missed request populates the negative cache: // recording per candidate would poison later candidates // that were never tried, and a mixed batch must never // suppress retry of its transient candidates. if allMissesCacheable { uc.noteMiss(key, lastMiss) } return lastMiss } if uc.epochValue() != startEpoch { return nil } return uc.RefreshIfStale(opCtx, pageURL) }) } func (uc *FaviconUseCase) refresh(ctx context.Context, pageURL string, force bool) error { keys := favicon.Candidates(pageURL) if len(keys) == 0 { return ErrFaviconMiss } if !force { fresh, err := uc.anyCandidateFresh(ctx, keys) if err != nil { return err } if fresh { return nil } } return uc.fetchAndObserve(ctx, pageURL) } func (uc *FaviconUseCase) refreshKey(ctx context.Context, key favicon.Key, pageURL string) error { meta, err := uc.repo.Get(ctx, key) if err != nil { return err } if meta != nil && !favicon.ShouldRefresh(meta, uc.now(), uc.ttl) { return nil } // Scheduler-backed work joins the same admission budget as direct // refreshes; discovery has no upfront candidates, so it shares the // page-scoped discovery identity. discoveryKey, _, ok := refreshRequestKey(pageURL, nil) if !ok { return ErrFaviconMiss } return uc.joinRefreshOp(ctx, discoveryKey, func(opCtx context.Context, _ uint64) error { return uc.fetchAndObserve(opCtx, pageURL) }) } func (uc *FaviconUseCase) anyCandidateFresh(ctx context.Context, keys []favicon.Key) (bool, error) { for _, key := range keys { meta, err := uc.repo.Get(ctx, key) if err != nil { return false, err } if meta != nil && !favicon.ShouldRefresh(meta, uc.now(), uc.ttl) { return true, nil } } return false, nil } func (uc *FaviconUseCase) fetchAndObserve(ctx context.Context, pageURL string) error { if uc.fetcher == nil { return ErrFaviconMiss } fetched, err := uc.fetcher.Fetch(ctx, appport.FaviconFetchRequest{PageURL: pageURL}) if err != nil { return err } err = uc.observeFetched(ctx, pageURL, fetched) return err } func (uc *FaviconUseCase) observeFetched(ctx context.Context, requestedPageURL string, fetched *appport.FaviconFetchedIcon) error { if fetched == nil { return ErrFaviconMiss } if fetched.ResolvedKey != "" { allowed := slices.Contains(favicon.Candidates(requestedPageURL), fetched.ResolvedKey) if !allowed { return ErrFaviconMiss } _, err := uc.observeKey(ctx, fetched.ResolvedKey, requestedPageURL, fetched.IconURL, fetched.Bytes, fetched.Source, fetched.ContentType) return err } _, err := uc.Observe(ctx, requestedPageURL, fetched.IconURL, fetched.Bytes, fetched.Source, fetched.ContentType) return err } func (uc *FaviconUseCase) EnsureSized(ctx context.Context, key favicon.Key, size int) error { if _, _, err := uc.blobs.ReadSizedPNG(ctx, key, size); err == nil { return nil } else if !errors.Is(err, ErrFaviconMiss) { return err } orig, ct, err := uc.blobs.ReadOriginal(ctx, key) if err != nil { return err } if uc.converter == nil { return ErrFaviconMiss } converted, err := uc.converter.Convert(ctx, orig, ct, []int{size}) if err != nil { return err } if len(converted.PNG) > 0 { if err := uc.blobs.WritePNG(ctx, key, converted.PNG); err != nil { return err } } if b := converted.SizedPNG[size]; len(b) > 0 { return uc.blobs.WriteSizedPNG(ctx, key, size, b) } return ErrFaviconMiss } func (uc *FaviconUseCase) Invalidate(ctx context.Context, key favicon.Key) error { // Invalidation advances the epoch so in-flight completions that have not // yet passed their pre-commit epoch check cannot repopulate the cleared // entries. This is best-effort suppression, not a transactional commit: // a completion that already passed its check may still write after the // clear (a transient stale icon until the next refresh), because the // check and the blob/repository writes are not atomic. Closing that // residual window would require versioned commits in the repository // layer and is out of scope for this stack. uc.bumpEpoch() if uc.blobs != nil { if err := uc.blobs.RemoveDerived(ctx, key); err != nil { return err } } return uc.invalidateCaches(ctx, key) } func (uc *FaviconUseCase) invalidateCaches(ctx context.Context, key favicon.Key) error { if uc.invalidators != nil { return uc.invalidators.InvalidateAll(ctx, key) } return nil } func (uc *FaviconUseCase) readForSize(ctx context.Context, key favicon.Key, size int) ([]byte, string, error) { if size < 0 { return nil, "", fmt.Errorf("invalid favicon size: %d", size) } if size > 0 { if err := uc.EnsureSized(ctx, key, size); err != nil { return nil, "", err } return uc.blobs.ReadSizedPNG(ctx, key, size) } return uc.blobs.ReadPNG(ctx, key) } func (uc *FaviconUseCase) scheduleRefresh(key favicon.Key, pageURL string) bool { if uc.scheduler == nil { return false } return uc.scheduler.Schedule(uc.background, key, func(ctx context.Context) { _ = uc.refreshKey(ctx, key, pageURL) }) } // Close aborts owned background refresh work and drains shared in-flight // operations. Admission is sealed first so no refresh can start after the // drain observes zero active operations. It is safe to call on a nil // receiver and more than once. func (uc *FaviconUseCase) Close() { if uc == nil { return } uc.shared.mu.Lock() uc.shared.closed = true uc.shared.mu.Unlock() if uc.bgCancel != nil { uc.bgCancel() } uc.drainRefreshOps() }