fix(backend): invalidate thumbnail cache on replace and permanent delete
Replacing a file's content left the old {id}_thumb.jpg / {id}_preview.jpg
in the cache, and the cache-hit fast path kept serving the stale image
forever; permanent deletion left those files orphaned. FileStorage gains
InvalidateCache, which Replace and PermanentDelete now call.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -21,6 +21,10 @@ type FileStorage interface {
|
|||||||
// Delete removes the file content from storage.
|
// Delete removes the file content from storage.
|
||||||
Delete(ctx context.Context, id uuid.UUID) error
|
Delete(ctx context.Context, id uuid.UUID) error
|
||||||
|
|
||||||
|
// InvalidateCache removes any cached thumbnail/preview for the file so they
|
||||||
|
// are regenerated from the current content on next request.
|
||||||
|
InvalidateCache(ctx context.Context, id uuid.UUID) error
|
||||||
|
|
||||||
// Thumbnail opens the pre-generated thumbnail (JPEG). Returns ErrNotFound
|
// Thumbnail opens the pre-generated thumbnail (JPEG). Returns ErrNotFound
|
||||||
// if the thumbnail has not been generated yet.
|
// if the thumbnail has not been generated yet.
|
||||||
Thumbnail(ctx context.Context, id uuid.UUID) (io.ReadCloser, error)
|
Thumbnail(ctx context.Context, id uuid.UUID) (io.ReadCloser, error)
|
||||||
|
|||||||
@@ -345,6 +345,7 @@ func (s *FileService) PermanentDelete(ctx context.Context, id uuid.UUID) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
_ = s.storage.Delete(ctx, id)
|
_ = s.storage.Delete(ctx, id)
|
||||||
|
_ = s.storage.InvalidateCache(ctx, id)
|
||||||
|
|
||||||
objType := fileObjectType
|
objType := fileObjectType
|
||||||
_ = s.audit.Log(ctx, "file_permanent_delete", &objType, &id, nil)
|
_ = s.audit.Log(ctx, "file_permanent_delete", &objType, &id, nil)
|
||||||
@@ -383,6 +384,8 @@ func (s *FileService) Replace(ctx context.Context, id uuid.UUID, p UploadParams)
|
|||||||
if _, err := s.storage.Save(ctx, id, bytes.NewReader(data)); err != nil {
|
if _, err := s.storage.Save(ctx, id, bytes.NewReader(data)); err != nil {
|
||||||
return nil, fmt.Errorf("FileService.Replace: save to storage: %w", err)
|
return nil, fmt.Errorf("FileService.Replace: save to storage: %w", err)
|
||||||
}
|
}
|
||||||
|
// Drop stale thumbnail/preview so they regenerate from the new content.
|
||||||
|
_ = s.storage.InvalidateCache(ctx, id)
|
||||||
|
|
||||||
patch := &domain.File{
|
patch := &domain.File{
|
||||||
MIMEType: mime.Name,
|
MIMEType: mime.Name,
|
||||||
|
|||||||
@@ -109,6 +109,17 @@ func (s *DiskStorage) Delete(_ context.Context, id uuid.UUID) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// InvalidateCache removes the cached thumbnail and preview for id, if present,
|
||||||
|
// so they are regenerated from the current file content on the next request.
|
||||||
|
func (s *DiskStorage) InvalidateCache(_ context.Context, id uuid.UUID) error {
|
||||||
|
for _, p := range []string{s.thumbCachePath(id), s.previewCachePath(id)} {
|
||||||
|
if err := os.Remove(p); err != nil && !os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf("storage.InvalidateCache remove %q: %w", p, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// Thumbnail returns a JPEG that fits within the configured max width×height
|
// Thumbnail returns a JPEG that fits within the configured max width×height
|
||||||
// (never upscaled, never cropped). Generated on first call and cached.
|
// (never upscaled, never cropped). Generated on first call and cached.
|
||||||
// Video files are thumbnailed via ffmpeg; other non-image files get a placeholder.
|
// Video files are thumbnailed via ffmpeg; other non-image files get a placeholder.
|
||||||
|
|||||||
Reference in New Issue
Block a user