fix(favicon): repair orphan canonical blob metadata
- commit
- 9f6779c4b930166e6afcd3de47290ecb523a34d5
- author
- brice · · 2026-06-07 18:48 UTC
- parent
- 88cfbd5
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
3 files · +60 -1
M internal/application/usecase/favicon.go +38 -1
| @@ -147,7 +147,14 @@ func (uc *FaviconUseCase) Resolve( | |||
| 147 | 147 | return nil, err | |
| 148 | 148 | } | |
| 149 | 149 | if meta == nil { | |
| 150 | - | continue | |
| 150 | + | repaired, repairErr := uc.repairOrphanBlobMetadata(ctx, key, rawURLOrDomain, size) | |
| 151 | + | if repairErr != nil { | |
| 152 | + | if !errors.Is(repairErr, ErrFaviconMiss) { | |
| 153 | + | return nil, repairErr | |
| 154 | + | } | |
| 155 | + | continue | |
| 156 | + | } | |
| 157 | + | return repaired, nil | |
| 151 | 158 | } | |
| 152 | 159 | bytes, contentType, err := uc.readForSize(ctx, key, size) | |
| 153 | 160 | if err != nil { | |
| @@ -182,6 +189,36 @@ func (uc *FaviconUseCase) ResolveSystemviewIcon(ctx context.Context, rawDomain s | |||
| 182 | 189 | return uc.Resolve(ctx, rawDomain, size, ResolveOptions{Purpose: ResolvePurposeSystemview}) | |
| 183 | 190 | } | |
| 184 | 191 | ||
| 192 | + | func (uc *FaviconUseCase) repairOrphanBlobMetadata( | |
| 193 | + | ctx context.Context, | |
| 194 | + | key favicon.Key, | |
| 195 | + | rawURLOrDomain string, | |
| 196 | + | size int, | |
| 197 | + | ) (*appport.ResolvedFavicon, error) { | |
| 198 | + | bytes, contentType, err := uc.readForSize(ctx, key, size) | |
| 199 | + | if err != nil { | |
| 200 | + | return nil, err | |
| 201 | + | } | |
| 202 | + | if contentType == "" { | |
| 203 | + | contentType = "image/png" | |
| 204 | + | } | |
| 205 | + | now := uc.now() | |
| 206 | + | meta := favicon.Metadata{ | |
| 207 | + | Key: key, | |
| 208 | + | SourceURL: rawURLOrDomain, | |
| 209 | + | PageURL: rawURLOrDomain, | |
| 210 | + | Source: favicon.SourceImported, | |
| 211 | + | ContentHash: favicon.Hash(bytes), | |
| 212 | + | ContentType: contentType, | |
| 213 | + | UpdatedAt: now, | |
| 214 | + | LastCheckedAt: now, | |
| 215 | + | } | |
| 216 | + | if err := uc.repo.Upsert(ctx, meta); err != nil { | |
| 217 | + | return nil, err | |
| 218 | + | } | |
| 219 | + | return &appport.ResolvedFavicon{Key: key, Bytes: bytes, ContentType: contentType, Metadata: &meta}, nil | |
| 220 | + | } | |
| 221 | + | ||
| 185 | 222 | func (uc *FaviconUseCase) Observe( | |
| 186 | 223 | ctx context.Context, | |
| 187 | 224 | pageURL, iconURL string, | |
| @@ -147,7 +147,14 @@ func (uc *FaviconUseCase) Resolve( | |||||
| 147 | return nil, err | 147 | return nil, err | ||
| 148 | } | 148 | } | ||
| 149 | if meta == nil { | 149 | if meta == nil { | ||
| 150 | - | continue | 150 | + | repaired, repairErr := uc.repairOrphanBlobMetadata(ctx, key, rawURLOrDomain, size) |
| 151 | + | if repairErr != nil { | |||
| 152 | + | if !errors.Is(repairErr, ErrFaviconMiss) { | |||
| 153 | + | return nil, repairErr | |||
| 154 | + | } | |||
| 155 | + | continue | |||
| 156 | + | } | |||
| 157 | + | return repaired, nil | |||
| 151 | } | 158 | } | ||
| 152 | bytes, contentType, err := uc.readForSize(ctx, key, size) | 159 | bytes, contentType, err := uc.readForSize(ctx, key, size) | ||
| 153 | if err != nil { | 160 | if err != nil { | ||
| @@ -182,6 +189,36 @@ func (uc *FaviconUseCase) ResolveSystemviewIcon(ctx context.Context, rawDomain s | |||||
| 182 | return uc.Resolve(ctx, rawDomain, size, ResolveOptions{Purpose: ResolvePurposeSystemview}) | 189 | return uc.Resolve(ctx, rawDomain, size, ResolveOptions{Purpose: ResolvePurposeSystemview}) | ||
| 183 | } | 190 | } | ||
| 184 | 191 | ||||
| 192 | + | func (uc *FaviconUseCase) repairOrphanBlobMetadata( | |||
| 193 | + | ctx context.Context, | |||
| 194 | + | key favicon.Key, | |||
| 195 | + | rawURLOrDomain string, | |||
| 196 | + | size int, | |||
| 197 | + | ) (*appport.ResolvedFavicon, error) { | |||
| 198 | + | bytes, contentType, err := uc.readForSize(ctx, key, size) | |||
| 199 | + | if err != nil { | |||
| 200 | + | return nil, err | |||
| 201 | + | } | |||
| 202 | + | if contentType == "" { | |||
| 203 | + | contentType = "image/png" | |||
| 204 | + | } | |||
| 205 | + | now := uc.now() | |||
| 206 | + | meta := favicon.Metadata{ | |||
| 207 | + | Key: key, | |||
| 208 | + | SourceURL: rawURLOrDomain, | |||
| 209 | + | PageURL: rawURLOrDomain, | |||
| 210 | + | Source: favicon.SourceImported, | |||
| 211 | + | ContentHash: favicon.Hash(bytes), | |||
| 212 | + | ContentType: contentType, | |||
| 213 | + | UpdatedAt: now, | |||
| 214 | + | LastCheckedAt: now, | |||
| 215 | + | } | |||
| 216 | + | if err := uc.repo.Upsert(ctx, meta); err != nil { | |||
| 217 | + | return nil, err | |||
| 218 | + | } | |||
| 219 | + | return &appport.ResolvedFavicon{Key: key, Bytes: bytes, ContentType: contentType, Metadata: &meta}, nil | |||
| 220 | + | } | |||
| 221 | + | ||||
| 185 | func (uc *FaviconUseCase) Observe( | 222 | func (uc *FaviconUseCase) Observe( | ||
| 186 | ctx context.Context, | 223 | ctx context.Context, | ||
| 187 | pageURL, iconURL string, | 224 | pageURL, iconURL string, | ||
M internal/application/usecase/favicon_test.go +21 -0
| @@ -34,6 +34,27 @@ func TestFaviconResolveParentFallback(t *testing.T) { | |||
| 34 | 34 | } | |
| 35 | 35 | } | |
| 36 | 36 | ||
| 37 | + | func TestFaviconResolveRepairsOrphanCanonicalBlobMetadata(t *testing.T) { | |
| 38 | + | fx := newFaviconFixture() | |
| 39 | + | fx.blobs.png["example.com"] = []byte("png") | |
| 40 | + | fx.blobs.sized["example.com"] = map[int][]byte{SystemviewIconSize: []byte("png32")} | |
| 41 | + | ||
| 42 | + | got, err := fx.uc.ResolveSystemviewIcon(context.Background(), "example.com", SystemviewIconSize) | |
| 43 | + | if err != nil { | |
| 44 | + | t.Fatal(err) | |
| 45 | + | } | |
| 46 | + | if got.Key != "example.com" || string(got.Bytes) != "png32" { | |
| 47 | + | t.Fatalf("unexpected resolve: %#v %q", got, got.Bytes) | |
| 48 | + | } | |
| 49 | + | meta := fx.repo.byKey["example.com"] | |
| 50 | + | if meta == nil { | |
| 51 | + | t.Fatal("expected metadata to be repaired") | |
| 52 | + | } | |
| 53 | + | if meta.Source != favicon.SourceImported || meta.ContentType != "image/png" || meta.ContentHash != favicon.Hash([]byte("png32")) { | |
| 54 | + | t.Fatalf("unexpected repaired metadata: %#v", meta) | |
| 55 | + | } | |
| 56 | + | } | |
| 57 | + | ||
| 37 | 58 | func TestFaviconResolveStaleSchedulesDedupedBackgroundRefresh(t *testing.T) { | |
| 38 | 59 | fx := newFaviconFixture() | |
| 39 | 60 | fx.seed("example.com", []byte("old"), true) | |
| @@ -34,6 +34,27 @@ func TestFaviconResolveParentFallback(t *testing.T) { | |||||
| 34 | } | 34 | } | ||
| 35 | } | 35 | } | ||
| 36 | 36 | ||||
| 37 | + | func TestFaviconResolveRepairsOrphanCanonicalBlobMetadata(t *testing.T) { | |||
| 38 | + | fx := newFaviconFixture() | |||
| 39 | + | fx.blobs.png["example.com"] = []byte("png") | |||
| 40 | + | fx.blobs.sized["example.com"] = map[int][]byte{SystemviewIconSize: []byte("png32")} | |||
| 41 | + | ||||
| 42 | + | got, err := fx.uc.ResolveSystemviewIcon(context.Background(), "example.com", SystemviewIconSize) | |||
| 43 | + | if err != nil { | |||
| 44 | + | t.Fatal(err) | |||
| 45 | + | } | |||
| 46 | + | if got.Key != "example.com" || string(got.Bytes) != "png32" { | |||
| 47 | + | t.Fatalf("unexpected resolve: %#v %q", got, got.Bytes) | |||
| 48 | + | } | |||
| 49 | + | meta := fx.repo.byKey["example.com"] | |||
| 50 | + | if meta == nil { | |||
| 51 | + | t.Fatal("expected metadata to be repaired") | |||
| 52 | + | } | |||
| 53 | + | if meta.Source != favicon.SourceImported || meta.ContentType != "image/png" || meta.ContentHash != favicon.Hash([]byte("png32")) { | |||
| 54 | + | t.Fatalf("unexpected repaired metadata: %#v", meta) | |||
| 55 | + | } | |||
| 56 | + | } | |||
| 57 | + | ||||
| 37 | func TestFaviconResolveStaleSchedulesDedupedBackgroundRefresh(t *testing.T) { | 58 | func TestFaviconResolveStaleSchedulesDedupedBackgroundRefresh(t *testing.T) { | ||
| 38 | fx := newFaviconFixture() | 59 | fx := newFaviconFixture() | ||
| 39 | fx.seed("example.com", []byte("old"), true) | 60 | fx.seed("example.com", []byte("old"), true) | ||
M internal/domain/favicon/metadata.go +1 -0
| @@ -9,6 +9,7 @@ const ( | |||
| 9 | 9 | SourcePageDiscovery Source = "page_discovery" | |
| 10 | 10 | SourceDuckDuckGo Source = "duckduckgo" | |
| 11 | 11 | SourceInternal Source = "internal" | |
| 12 | + | SourceImported Source = "imported" | |
| 12 | 13 | SourceManual Source = "manual" | |
| 13 | 14 | ) | |
| 14 | 15 | ||
| @@ -9,6 +9,7 @@ const ( | |||||
| 9 | SourcePageDiscovery Source = "page_discovery" | 9 | SourcePageDiscovery Source = "page_discovery" | ||
| 10 | SourceDuckDuckGo Source = "duckduckgo" | 10 | SourceDuckDuckGo Source = "duckduckgo" | ||
| 11 | SourceInternal Source = "internal" | 11 | SourceInternal Source = "internal" | ||
| 12 | + | SourceImported Source = "imported" | |||
| 12 | SourceManual Source = "manual" | 13 | SourceManual Source = "manual" | ||
| 13 | ) | 14 | ) | ||
| 14 | 15 | ||||