feat: Add Redis upload cache for multi-replica deployments (#5724)

This commit is contained in:
Arran
2026-02-01 16:08:40 +00:00
committed by GitHub
parent b8da36e630
commit 08d7a1504c
9 changed files with 262 additions and 55 deletions

View File

@@ -46,6 +46,7 @@ var (
"disable-type-detection-by-header": "disableTypeDetectionByHeader",
"img-processors": "imageProcessors",
"cache-dir": "cacheDir",
"redis-cache-url": "redisCacheUrl",
"token-expiration-time": "tokenExpirationTime",
"baseurl": "baseURL",
}
@@ -88,6 +89,7 @@ func init() {
flags.String("password", "", "hashed password for the first user when using quick setup")
flags.Uint32("socketPerm", 0666, "unix socket file permissions")
flags.String("cacheDir", "", "file cache directory (disabled if empty)")
flags.String("redisCacheUrl", "", "redis cache URL (for multi-instance deployments), e.g. redis://user:pass@host:port")
flags.Int("imageProcessors", 4, "image processors count")
addServerFlags(flags)
}
@@ -176,6 +178,12 @@ user created with the credentials from options "username" and "password".`,
fileCache = diskcache.New(afero.NewOsFs(), cacheDir)
}
redisCacheURL := v.GetString("redisCacheUrl")
uploadCache, err := fbhttp.NewUploadCache(redisCacheURL)
if err != nil {
return fmt.Errorf("failed to initialize upload cache: %w", err)
}
server, err := getServerSettings(v, st.Storage)
if err != nil {
return err
@@ -227,7 +235,7 @@ user created with the credentials from options "username" and "password".`,
panic(err)
}
handler, err := fbhttp.NewHandler(imageService, fileCache, st.Storage, server, assetsFs)
handler, err := fbhttp.NewHandler(imageService, fileCache, uploadCache, st.Storage, server, assetsFs)
if err != nil {
return err
}

17
compose.redis.yaml Normal file
View File

@@ -0,0 +1,17 @@
# Run using:
# docker compose -f compose.yaml -f compose.redis.yaml up --build
services:
redis:
container_name: redis
image: redis:latest
networks:
- filebrowser
command:
- sh
- -c
- |
cat > /tmp/users.acl <<EOF
user default on >filebrowser ~* +@all
EOF
redis-server --aclfile /tmp/users.acl

37
compose.yaml Normal file
View File

@@ -0,0 +1,37 @@
# Run using:
# docker compose up --build
services:
filebrowser:
container_name: filebrowser
build:
dockerfile: Dockerfile
context: .
networks:
- filebrowser
ports:
- 8000:80
volumes:
- filebrowser:/flux/vault
environment:
- REDIS_CACHE_URL=redis://default:filebrowser@redis:6379 # Use rediss:// for ssl
redis:
container_name: redis
image: redis:latest
networks:
- filebrowser
command:
- sh
- -c
- |
cat > /tmp/users.acl <<EOF
user default on >filebrowser ~* +@all
EOF
redis-server --aclfile /tmp/users.acl
networks:
filebrowser:
volumes:
filebrowser:

3
go.mod
View File

@@ -16,6 +16,7 @@ require (
github.com/marusama/semaphore/v2 v2.5.0
github.com/mholt/archives v0.1.5
github.com/mitchellh/go-homedir v1.1.0
github.com/redis/go-redis/v9 v9.17.2
github.com/samber/lo v1.52.0
github.com/shirou/gopsutil/v4 v4.26.1
github.com/spf13/afero v1.15.0
@@ -39,8 +40,10 @@ require (
github.com/bodgit/plumbing v1.3.0 // indirect
github.com/bodgit/sevenzip v1.6.1 // indirect
github.com/bodgit/windows v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 // indirect
github.com/dsoprea/go-logging v0.0.0-20200710184922-b02d349568dd // indirect
github.com/dsoprea/go-utility/v2 v2.0.0-20221003172846-a3e1774ef349 // indirect

10
go.sum
View File

@@ -42,7 +42,13 @@ github.com/bodgit/sevenzip v1.6.1 h1:kikg2pUMYC9ljU7W9SaqHXhym5HyKm8/M/jd31fYan4
github.com/bodgit/sevenzip v1.6.1/go.mod h1:GVoYQbEVbOGT8n2pfqCIMRUaRjQ8F9oSqoBEqZh5fQ8=
github.com/bodgit/windows v1.0.1 h1:tF7K6KOluPYygXa3Z2594zxlkbKPAOvqr97etrGNIz4=
github.com/bodgit/windows v1.0.1/go.mod h1:a6JLwrB4KrTR5hBpp8FI9/9W9jJfeQ2h4XDXU74ZCdM=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
@@ -53,6 +59,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 h1:2tV76y6Q9BB+NEBasnqvs7e49aEBFI8ejC89PSnWH+4=
@@ -194,6 +202,8 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/redis/go-redis/v9 v9.17.2 h1:P2EGsA4qVIM3Pp+aPocCJ7DguDHhqrXNhVcEp4ViluI=
github.com/redis/go-redis/v9 v9.17.2/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=

View File

@@ -19,6 +19,7 @@ type modifyRequest struct {
func NewHandler(
imgSvc ImgService,
fileCache FileCache,
uploadCache UploadCache,
store *storage.Storage,
server *settings.Server,
assetsFs fs.FS,
@@ -67,10 +68,10 @@ func NewHandler(
api.PathPrefix("/resources").Handler(monkey(resourcePutHandler, "/api/resources")).Methods("PUT")
api.PathPrefix("/resources").Handler(monkey(resourcePatchHandler(fileCache), "/api/resources")).Methods("PATCH")
api.PathPrefix("/tus").Handler(monkey(tusPostHandler(), "/api/tus")).Methods("POST")
api.PathPrefix("/tus").Handler(monkey(tusHeadHandler(), "/api/tus")).Methods("HEAD", "GET")
api.PathPrefix("/tus").Handler(monkey(tusPatchHandler(), "/api/tus")).Methods("PATCH")
api.PathPrefix("/tus").Handler(monkey(tusDeleteHandler(), "/api/tus")).Methods("DELETE")
api.PathPrefix("/tus").Handler(monkey(tusPostHandler(uploadCache), "/api/tus")).Methods("POST")
api.PathPrefix("/tus").Handler(monkey(tusHeadHandler(uploadCache), "/api/tus")).Methods("HEAD", "GET")
api.PathPrefix("/tus").Handler(monkey(tusPatchHandler(uploadCache), "/api/tus")).Methods("PATCH")
api.PathPrefix("/tus").Handler(monkey(tusDeleteHandler(uploadCache), "/api/tus")).Methods("DELETE")
api.PathPrefix("/usage").Handler(monkey(diskUsage, "/api/usage")).Methods("GET")

View File

@@ -1,7 +1,6 @@
package fbhttp
import (
"context"
"errors"
"fmt"
"io"
@@ -12,48 +11,13 @@ import (
"strconv"
"time"
"github.com/jellydator/ttlcache/v3"
"github.com/spf13/afero"
"github.com/filebrowser/filebrowser/v2/files"
)
const maxUploadWait = 3 * time.Minute
// Tracks active uploads along with their respective upload lengths
var activeUploads = initActiveUploads()
func initActiveUploads() *ttlcache.Cache[string, int64] {
cache := ttlcache.New[string, int64]()
cache.OnEviction(func(_ context.Context, reason ttlcache.EvictionReason, item *ttlcache.Item[string, int64]) {
if reason == ttlcache.EvictionReasonExpired {
fmt.Printf("deleting incomplete upload file: \"%s\"", item.Key())
os.Remove(item.Key())
}
})
go cache.Start()
return cache
}
func registerUpload(filePath string, fileSize int64) {
activeUploads.Set(filePath, fileSize, maxUploadWait)
}
func completeUpload(filePath string) {
activeUploads.Delete(filePath)
}
func getActiveUploadLength(filePath string) (int64, error) {
item := activeUploads.Get(filePath)
if item == nil {
return 0, fmt.Errorf("no active upload found for the given path")
}
return item.Value(), nil
}
func keepUploadActive(filePath string) func() {
// keepUploadActive periodically touches the cache entry to prevent eviction during transfer
func keepUploadActive(cache UploadCache, filePath string) func() {
stop := make(chan bool)
go func() {
@@ -65,7 +29,7 @@ func keepUploadActive(filePath string) func() {
case <-stop:
return
case <-ticker.C:
activeUploads.Touch(filePath)
cache.Touch(filePath)
}
}
}()
@@ -75,7 +39,7 @@ func keepUploadActive(filePath string) func() {
}
}
func tusPostHandler() handleFunc {
func tusPostHandler(cache UploadCache) handleFunc {
return withUser(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
if !d.user.Perm.Create || !d.Check(r.URL.Path) {
return http.StatusForbidden, nil
@@ -146,7 +110,7 @@ func tusPostHandler() handleFunc {
}
// Enables the user to utilize the PATCH endpoint for uploading file data
registerUpload(file.RealPath(), uploadLength)
cache.Register(file.RealPath(), uploadLength)
path, err := url.JoinPath("/", d.server.BaseURL, "/api/tus", r.URL.Path)
if err != nil {
@@ -158,7 +122,7 @@ func tusPostHandler() handleFunc {
})
}
func tusHeadHandler() handleFunc {
func tusHeadHandler(cache UploadCache) handleFunc {
return withUser(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
w.Header().Set("Cache-Control", "no-store")
if !d.user.Perm.Create || !d.Check(r.URL.Path) {
@@ -177,7 +141,7 @@ func tusHeadHandler() handleFunc {
return errToStatus(err), err
}
uploadLength, err := getActiveUploadLength(file.RealPath())
uploadLength, err := cache.GetLength(file.RealPath())
if err != nil {
return http.StatusNotFound, err
}
@@ -189,7 +153,7 @@ func tusHeadHandler() handleFunc {
})
}
func tusPatchHandler() handleFunc {
func tusPatchHandler(cache UploadCache) handleFunc {
return withUser(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
if !d.user.Perm.Create || !d.Check(r.URL.Path) {
return http.StatusForbidden, nil
@@ -219,13 +183,13 @@ func tusPatchHandler() handleFunc {
return errToStatus(err), err
}
uploadLength, err := getActiveUploadLength(file.RealPath())
uploadLength, err := cache.GetLength(file.RealPath())
if err != nil {
return http.StatusNotFound, err
}
// Prevent the upload from being evicted during the transfer
stop := keepUploadActive(file.RealPath())
stop := keepUploadActive(cache, file.RealPath())
defer stop()
switch {
@@ -266,7 +230,7 @@ func tusPatchHandler() handleFunc {
w.Header().Set("Upload-Offset", strconv.FormatInt(newOffset, 10))
if newOffset >= uploadLength {
completeUpload(file.RealPath())
cache.Complete(file.RealPath())
_ = d.RunHook(func() error { return nil }, "upload", r.URL.Path, "", d.user)
}
@@ -274,7 +238,7 @@ func tusPatchHandler() handleFunc {
})
}
func tusDeleteHandler() handleFunc {
func tusDeleteHandler(cache UploadCache) handleFunc {
return withUser(func(_ http.ResponseWriter, r *http.Request, d *data) (int, error) {
if r.URL.Path == "/" || !d.user.Perm.Create {
return http.StatusForbidden, nil
@@ -292,7 +256,7 @@ func tusDeleteHandler() handleFunc {
return errToStatus(err), err
}
_, err = getActiveUploadLength(file.RealPath())
_, err = cache.GetLength(file.RealPath())
if err != nil {
return http.StatusNotFound, err
}
@@ -302,7 +266,7 @@ func tusDeleteHandler() handleFunc {
return errToStatus(err), err
}
completeUpload(file.RealPath())
cache.Complete(file.RealPath())
return http.StatusNoContent, nil
})

View File

@@ -0,0 +1,85 @@
package fbhttp
import (
"context"
"fmt"
"os"
"time"
"github.com/jellydator/ttlcache/v3"
)
const uploadCacheTTL = 3 * time.Minute
// UploadCache is an interface for tracking active uploads.
// Allows for different backends (e.g. in-memory or redis)
// to support both single instance and multi replica deployments.
type UploadCache interface {
// Register stores an upload with its expected file size
Register(filePath string, fileSize int64)
// Complete removes an upload from the cache
Complete(filePath string)
// GetLength returns the expected file size for an active upload
GetLength(filePath string) (int64, error)
// Touch refreshes the TTL for an active upload
Touch(filePath string)
// Close cleans up any resources
Close()
}
// memoryUploadCache is an upload cache for single replica deployments
type memoryUploadCache struct {
cache *ttlcache.Cache[string, int64]
}
func newMemoryUploadCache() *memoryUploadCache {
cache := ttlcache.New[string, int64]()
cache.OnEviction(func(_ context.Context, reason ttlcache.EvictionReason, item *ttlcache.Item[string, int64]) {
if reason == ttlcache.EvictionReasonExpired {
fmt.Printf("deleting incomplete upload file: \"%s\"\n", item.Key())
os.Remove(item.Key())
}
})
go cache.Start()
return &memoryUploadCache{cache: cache}
}
func (c *memoryUploadCache) Register(filePath string, fileSize int64) {
c.cache.Set(filePath, fileSize, uploadCacheTTL)
}
func (c *memoryUploadCache) Complete(filePath string) {
c.cache.Delete(filePath)
}
func (c *memoryUploadCache) GetLength(filePath string) (int64, error) {
item := c.cache.Get(filePath)
if item == nil {
return 0, fmt.Errorf("no active upload found for the given path")
}
return item.Value(), nil
}
func (c *memoryUploadCache) Touch(filePath string) {
c.cache.Touch(filePath)
}
func (c *memoryUploadCache) Close() {
c.cache.Stop()
}
// NewUploadCache creates a new upload cache.
// If redisURL is empty, an in-memory cache will be used (suitable for single instance deployments).
// Otherwise, Redis will be used for the cache (suitable for multi-instance deployments).
// The redisURL can include credentials, e.g. redis://user:pass@host:port
func NewUploadCache(redisURL string) (UploadCache, error) {
if redisURL != "" {
return newRedisUploadCache(redisURL)
}
return newMemoryUploadCache(), nil
}

View File

@@ -0,0 +1,82 @@
package fbhttp
import (
"context"
"errors"
"fmt"
"log"
"strconv"
"github.com/redis/go-redis/v9"
)
// redisUploadCache is an upload cache for multi replica deployments
type redisUploadCache struct {
client *redis.Client
}
func newRedisUploadCache(redisURL string) (*redisUploadCache, error) {
if redisURL == "" {
return nil, fmt.Errorf("redis URL is required")
}
opts, err := redis.ParseURL(redisURL)
if err != nil {
return nil, fmt.Errorf("invalid redis URL: %w", err)
}
client := redis.NewClient(opts)
// Test connection
if err := client.Ping(context.Background()).Err(); err != nil {
return nil, fmt.Errorf("failed to connect to redis: %w", err)
}
return &redisUploadCache{client: client}, nil
}
func (c *redisUploadCache) filePathKey(filePath string) string {
return "filebrowser:upload:" + filePath
}
func (c *redisUploadCache) Register(filePath string, fileSize int64) {
err := c.client.Set(context.Background(), c.filePathKey(filePath), fileSize, uploadCacheTTL).Err()
if err != nil {
log.Printf("failed to register upload in redis cache: %v", err)
}
}
func (c *redisUploadCache) Complete(filePath string) {
err := c.client.Del(context.Background(), c.filePathKey(filePath)).Err()
if err != nil {
log.Printf("failed to complete upload in redis cache: %v", err)
}
}
func (c *redisUploadCache) GetLength(filePath string) (int64, error) {
result, err := c.client.Get(context.Background(), c.filePathKey(filePath)).Result()
if err != nil {
if errors.Is(err, redis.Nil) {
return 0, fmt.Errorf("no active upload found for the given path")
}
return 0, fmt.Errorf("redis error: %w", err)
}
size, err := strconv.ParseInt(result, 10, 64)
if err != nil {
return 0, fmt.Errorf("invalid upload length in cache: %w", err)
}
return size, nil
}
func (c *redisUploadCache) Touch(filePath string) {
err := c.client.Expire(context.Background(), c.filePathKey(filePath), uploadCacheTTL).Err()
if err != nil {
log.Printf("failed to touch upload in redis cache: %v", err)
}
}
func (c *redisUploadCache) Close() {
c.client.Close()
}