perf: cache generated image thumbnails

This commit is contained in:
OpenClaw Agent
2026-09-22 05:51:06 +08:00
parent f799104fb2
commit e6806de5f6
4 changed files with 225 additions and 27 deletions
+5
View File
@@ -0,0 +1,5 @@
# Local build artifacts
bin/
# Local/debug runtime configuration
chfs.ini
+45 -27
View File
@@ -37,25 +37,27 @@ import (
var webIndex []byte var webIndex []byte
type Server struct { type Server struct {
Store storage.Storage Store storage.Storage
Auth *auth.Manager Auth *auth.Manager
Sessions map[string]*auth.Account Sessions map[string]*auth.Account
SessionExpiry map[string]time.Time SessionExpiry map[string]time.Time
SessionMu sync.RWMutex SessionMu sync.RWMutex
SessionTimeout int SessionTimeout int
JWTSecret string JWTSecret string
WebDAVRoot string WebDAVRoot string
WebDAVFS webdav.FileSystem WebDAVFS webdav.FileSystem
Allow string Allow string
MultiRoot bool MultiRoot bool
RemoveMode int RemoveMode int
ImagePreview bool ImagePreview bool
FolderDownload string FolderDownload string
HTMLTitle string HTMLTitle string
HTMLNotice string HTMLNotice string
Log *opslog.Logger Log *opslog.Logger
UserStore *auth.UserStore UserStore *auth.UserStore
UserHome string UserHome string
ThumbnailCache *thumbnailCache
thumbnailCacheOnce sync.Once
} }
type fileInfo struct { type fileInfo struct {
Name string `json:"name"` Name string `json:"name"`
@@ -1176,20 +1178,36 @@ func (s *Server) thumbnail(w http.ResponseWriter, r *http.Request) {
return return
} }
defer f.Close() defer f.Close()
src, _, err := image.Decode(f) fi, err := f.Stat()
if err != nil {
http.NotFound(w, r)
return
}
key := thumbnailCacheKey{Path: file, Size: fi.Size(), ModTime: fi.ModTime().UnixNano()}
s.thumbnailCacheOnce.Do(func() {
if s.ThumbnailCache == nil {
s.ThumbnailCache = newThumbnailCache()
}
})
data, err := s.ThumbnailCache.Do(key, func() ([]byte, error) {
src, _, err := image.Decode(f)
if err != nil {
return nil, err
}
thumb := transform.Resize(src, 320, 240, transform.Linear)
var buf bytes.Buffer
if err := png.Encode(&buf, thumb); err != nil {
return nil, err
}
return append([]byte(nil), buf.Bytes()...), nil
})
if err != nil { if err != nil {
http.Error(w, "thumbnail unavailable", http.StatusUnsupportedMediaType) http.Error(w, "thumbnail unavailable", http.StatusUnsupportedMediaType)
return return
} }
thumb := transform.Resize(src, 320, 240, transform.Linear)
var buf bytes.Buffer
if err := png.Encode(&buf, thumb); err != nil {
http.Error(w, "thumbnail unavailable", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "image/png") w.Header().Set("Content-Type", "image/png")
w.Header().Set("Cache-Control", "private, max-age=300") w.Header().Set("Cache-Control", "private, max-age=300")
_, _ = w.Write(buf.Bytes()) _, _ = w.Write(data)
} }
func (s *Server) fileHandler(w http.ResponseWriter, r *http.Request) { func (s *Server) fileHandler(w http.ResponseWriter, r *http.Request) {
+103
View File
@@ -0,0 +1,103 @@
package api
import (
"container/list"
"sync"
)
type thumbnailCacheKey struct {
Path string
Size int64
ModTime int64
}
type thumbnailCacheEntry struct {
key thumbnailCacheKey
data []byte
}
type thumbnailFlight struct {
done chan struct{}
data []byte
err error
}
type thumbnailCache struct {
mu sync.Mutex
items map[thumbnailCacheKey]*list.Element
order *list.List
flights map[thumbnailCacheKey]*thumbnailFlight
bytes int64
maxBytes int64
maxItems int
}
func newThumbnailCache() *thumbnailCache {
return &thumbnailCache{
items: make(map[thumbnailCacheKey]*list.Element),
order: list.New(),
flights: make(map[thumbnailCacheKey]*thumbnailFlight),
maxBytes: 32 << 20,
maxItems: 64,
}
}
func (c *thumbnailCache) Do(key thumbnailCacheKey, fn func() ([]byte, error)) ([]byte, error) {
c.mu.Lock()
if e, ok := c.items[key]; ok {
c.order.MoveToFront(e)
data := append([]byte(nil), e.Value.(thumbnailCacheEntry).data...)
c.mu.Unlock()
return data, nil
}
if f, ok := c.flights[key]; ok {
c.mu.Unlock()
<-f.done
return append([]byte(nil), f.data...), f.err
}
f := &thumbnailFlight{done: make(chan struct{})}
c.flights[key] = f
c.mu.Unlock()
data, err := fn()
if err == nil && len(data) > 0 && int64(len(data)) <= c.maxBytes {
c.mu.Lock()
c.putLocked(key, data)
f.data = append([]byte(nil), data...)
f.err = err
delete(c.flights, key)
close(f.done)
c.mu.Unlock()
return data, nil
}
c.mu.Lock()
f.err = err
delete(c.flights, key)
close(f.done)
c.mu.Unlock()
return data, err
}
func (c *thumbnailCache) putLocked(key thumbnailCacheKey, data []byte) {
if e, ok := c.items[key]; ok {
old := e.Value.(thumbnailCacheEntry)
c.bytes -= int64(len(old.data))
e.Value = thumbnailCacheEntry{key: key, data: append([]byte(nil), data...)}
c.bytes += int64(len(data))
c.order.MoveToFront(e)
} else {
e := c.order.PushFront(thumbnailCacheEntry{key: key, data: append([]byte(nil), data...)})
c.items[key] = e
c.bytes += int64(len(data))
}
for c.bytes > c.maxBytes || c.order.Len() > c.maxItems {
e := c.order.Back()
if e == nil {
break
}
entry := e.Value.(thumbnailCacheEntry)
delete(c.items, entry.key)
c.bytes -= int64(len(entry.data))
c.order.Remove(e)
}
}
+72
View File
@@ -0,0 +1,72 @@
package api
import (
"errors"
"sync"
"sync/atomic"
"testing"
)
func TestThumbnailCacheDeduplicatesConcurrentBuilds(t *testing.T) {
c := newThumbnailCache()
key := thumbnailCacheKey{Path: "photo.jpg", Size: 10, ModTime: 20}
var builds atomic.Int32
const workers = 24
var wg sync.WaitGroup
results := make([][]byte, workers)
errs := make([]error, workers)
wg.Add(workers)
for i := 0; i < workers; i++ {
go func(i int) {
defer wg.Done()
results[i], errs[i] = c.Do(key, func() ([]byte, error) {
builds.Add(1)
return []byte("thumbnail"), nil
})
}(i)
}
wg.Wait()
if builds.Load() != 1 {
t.Fatalf("thumbnail builder ran %d times, want 1", builds.Load())
}
for i := range results {
if errs[i] != nil || string(results[i]) != "thumbnail" {
t.Fatalf("worker %d got result %q, err=%v", i, results[i], errs[i])
}
}
}
func TestThumbnailCacheDoesNotCacheErrors(t *testing.T) {
c := newThumbnailCache()
key := thumbnailCacheKey{Path: "broken.jpg"}
wantErr := errors.New("decode failed")
var builds atomic.Int32
for i := 0; i < 2; i++ {
_, err := c.Do(key, func() ([]byte, error) {
builds.Add(1)
return nil, wantErr
})
if !errors.Is(err, wantErr) {
t.Fatalf("got err %v, want %v", err, wantErr)
}
}
if builds.Load() != 2 {
t.Fatalf("error result was cached; builder ran %d times", builds.Load())
}
}
func TestThumbnailCacheEvictsByBound(t *testing.T) {
c := newThumbnailCache()
c.maxItems = 2
for i := 0; i < 3; i++ {
key := thumbnailCacheKey{Path: string(rune('a' + i))}
if _, err := c.Do(key, func() ([]byte, error) { return []byte("x"), nil }); err != nil {
t.Fatal(err)
}
}
c.mu.Lock()
defer c.mu.Unlock()
if len(c.items) != 2 || c.order.Len() != 2 {
t.Fatalf("cache size=%d/list=%d, want 2/2", len(c.items), c.order.Len())
}
}