autoresearch iter 4: stop one disconnected client from failing a shared image flight

EnsureCompressedImageCache passed the arriving caller's request context into
the singleflight body, which runs once for every concurrent requester of that
cache key. If the first client disconnected, gin canceled the context, the
shared generation aborted, and every follower received that failure and fell
back to the uncompressed original. Detach cancellation with
context.WithoutCancel so trace values still propagate but the shared work
outlives any single requester.
This commit is contained in:
ryan
2026-08-29 08:07:19 +08:00
parent 5b84fd906d
commit 7e6b9e7c2f
2 changed files with 53 additions and 2 deletions
@@ -227,8 +227,11 @@ func EnsureCompressedImageCache(
return nil, false, fmt.Errorf("read compressed image cache: %w", err)
}
// The flight body serves every concurrent requester for this key, so it must
// not die when whichever caller happened to arrive first disconnects.
flightCtx := context.WithoutCancel(ctx)
result, err, _ := compressedImageFlight.Do(cacheKey, func() (any, error) {
return generateCompressedImageCache(ctx, upload, quality, cacheKey)
return generateCompressedImageCache(flightCtx, upload, quality, cacheKey)
})
if err != nil {
return nil, false, err
@@ -24,6 +24,7 @@ import (
"path/filepath"
"sync"
"testing"
"time"
"github.com/gin-contrib/sessions"
"github.com/gin-contrib/sessions/cookie"
@@ -55,7 +56,10 @@ func (s *localTestStorageService) Put(_ context.Context, key string, body io.Rea
return contracts.StoragePutResult{Key: key, Bucket: "local"}, err
}
func (s *localTestStorageService) Get(_ context.Context, key string) (*contracts.StorageObject, error) {
func (s *localTestStorageService) Get(ctx context.Context, key string) (*contracts.StorageObject, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
s.mu.RLock()
defer s.mu.RUnlock()
path := filepath.Join(s.root, key)
@@ -357,6 +361,50 @@ func TestServeFileByIDImageCompression(t *testing.T) {
})
}
// The singleflight body generates once on behalf of every concurrent requester
// sharing a cache key, so the caller that happens to arrive first must not be
// able to fail the others by disconnecting.
func TestEnsureCompressedImageCacheSurvivesCallerCancellation(t *testing.T) {
tempDir := t.TempDir()
shared.SetStorageService(&localTestStorageService{root: tempDir})
img := image.NewRGBA(image.Rect(0, 0, 1, 1))
img.Set(0, 0, color.RGBA{R: 0, G: 255, B: 0, A: 255})
var pngBuf bytes.Buffer
if err := png.Encode(&pngBuf, img); err != nil {
t.Fatalf("failed to encode test png: %v", err)
}
const filePath = "cancel_probe.png"
if err := os.WriteFile(filepath.Join(tempDir, filePath), pngBuf.Bytes(), 0o600); err != nil {
t.Fatalf("failed to write test png: %v", err)
}
upload := &models.Upload{
ID: 990001,
FilePath: filePath,
FileSize: int64(pngBuf.Len()),
MimeType: "image/png",
Extension: "png",
// Unique per run so the persistent disk cache can never serve this key.
Hash: fmt.Sprintf("cancel-probe-%d", time.Now().UnixNano()),
UpdatedAt: time.Now(),
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
webpBytes, cached, err := EnsureCompressedImageCache(ctx, upload, "medium")
if err != nil {
t.Fatalf("compressed image generation failed with a canceled caller context: %v", err)
}
if cached {
t.Errorf("expected a freshly generated image, got a cache hit")
}
if len(webpBytes) == 0 {
t.Errorf("expected non-empty webp bytes")
}
}
func TestNormalizeImageQuality(t *testing.T) {
tests := []struct {
name string