From 1faf0a4431794324bece0606e44a4cf46fc7021c Mon Sep 17 00:00:00 2001 From: Catherine Date: Wed, 17 Sep 2025 06:53:39 +0000 Subject: [PATCH] Cache manifests in memory when using the S3 backend. --- README.md | 1 + go.mod | 1 + go.sum | 2 ++ src/backend.go | 62 ++++++++++++++++++++++++++++++++++++++++++-------- src/config.go | 12 ++++++---- src/main.go | 6 ++--- 6 files changed, 67 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 197155e..d6b6897 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,7 @@ An object store (filesystem, S3, ...) is used as the sole mechanism for state st - The `site/` prefix contains site manifests organized by domain and project name (e.g. `site/example.org/myproject` or `site/example.org/.index`). - The manifest is a Protobuf object containing a flat mapping of paths to entries. An entry is comprised of type (file, directory, symlink, etc) and data, which may be stored inline or refer to a blob. - A small amount of internal metadata within a manifest allows attributing deployments to their source and computing quotas. + - The S3 backend caches manifests in memory. Since a manifest is necessary and sufficient to return `304 Not Modified` responses for a matching `ETag`, this drastically reduces navigation latency. - Additionally, the object store contains *staged manifests*, representing an in-progress update operation. - An update first creates a staged manifest, then uploads blobs, then replaces the deployed manifest with the staged one. This avoids TOCTTOU race conditions during garbage collection. - Stable marshalling allows addressing staged manifests by the hash of their contents. diff --git a/go.mod b/go.mod index 39db435..00f8632 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.25.0 require ( github.com/go-git/go-git/v6 v6.0.0-20250910120214-3a68d0404116 + github.com/maypok86/otter/v2 v2.2.1 github.com/minio/minio-go/v7 v7.0.95 github.com/pelletier/go-toml/v2 v2.2.4 google.golang.org/protobuf v1.36.9 diff --git a/go.sum b/go.sum index ad87d7c..9d0c3a1 100644 --- a/go.sum +++ b/go.sum @@ -51,6 +51,8 @@ github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/maypok86/otter/v2 v2.2.1 h1:hnGssisMFkdisYcvQ8L019zpYQcdtPse+g0ps2i7cfI= +github.com/maypok86/otter/v2 v2.2.1/go.mod h1:1NKY9bY+kB5jwCXBJfE59u+zAwOt6C7ni1FTlFFMqVs= github.com/minio/crc64nvme v1.0.2 h1:6uO1UxGAD+kwqWWp7mBFsi5gAse66C4NXO8cmcVculg= github.com/minio/crc64nvme v1.0.2/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg= github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34= diff --git a/src/backend.go b/src/backend.go index e3d78a4..bfadf96 100644 --- a/src/backend.go +++ b/src/backend.go @@ -18,6 +18,8 @@ import ( "github.com/minio/minio-go/v7" "github.com/minio/minio-go/v7/pkg/credentials" + + "github.com/maypok86/otter/v2" ) type Backend interface { @@ -205,10 +207,16 @@ func (fs *FSBackend) DeleteManifest(name string) error { return fs.siteRoot.Remove(name) } +type CachedManifest struct { + manifest *Manifest + weight uint32 +} + type S3Backend struct { ctx context.Context client *minio.Client bucket string + cache *otter.Cache[string, *CachedManifest] } func NewS3Backend( @@ -244,7 +252,27 @@ func NewS3Backend( } } - return &S3Backend{ctx, client, bucket}, nil + cacheConfig := config.Backend.S3.Cache + var maxWeight uint64 = 134217728 + if cacheConfig.MaxSize != 0 { + maxWeight = cacheConfig.MaxSize + } + var maxAge time.Duration = 5 * time.Second + if cacheConfig.MaxAge != "" { + maxAge, err = time.ParseDuration(cacheConfig.MaxAge) + if err != nil { + return nil, fmt.Errorf("max-age: %s", err) + } + } + cache := otter.Must[string, *CachedManifest](&otter.Options[string, *CachedManifest]{ + MaximumWeight: maxWeight, + Weigher: func(key string, value *CachedManifest) uint32 { + return value.weight + }, + ExpiryCalculator: otter.ExpiryCreating[string, *CachedManifest](maxAge), + }) + + return &S3Backend{ctx, client, bucket, cache}, nil } func (s3 *S3Backend) Backend() Backend { @@ -308,20 +336,34 @@ func stagedManifestObjectName(manifestData []byte) string { } func (s3 *S3Backend) GetManifest(name string) (*Manifest, error) { - log.Printf("s3: get manifest %s\n", name) + loader := func(ctx context.Context, name string) (*CachedManifest, error) { + log.Printf("s3: get manifest %s\n", name) - object, err := s3.client.GetObject(s3.ctx, s3.bucket, manifestObjectName(name), - minio.GetObjectOptions{}) + object, err := s3.client.GetObject(s3.ctx, s3.bucket, manifestObjectName(name), + minio.GetObjectOptions{}) + if err != nil { + return nil, err + } + + data, err := io.ReadAll(object) + if err != nil { + return nil, err + } + + manifest, err := DecodeManifest(data) + if err != nil { + return nil, err + } + + return &CachedManifest{manifest, uint32(len(data))}, nil + } + + cached, err := s3.cache.Get(s3.ctx, name, otter.LoaderFunc[string, *CachedManifest](loader)) if err != nil { return nil, err } - data, err := io.ReadAll(object) - if err != nil { - return nil, err - } - - return DecodeManifest(data) + return cached.manifest, err } func (s3 *S3Backend) StageManifest(manifest *Manifest) error { diff --git a/src/config.go b/src/config.go index 1ae24de..53bbcac 100644 --- a/src/config.go +++ b/src/config.go @@ -6,14 +6,14 @@ import ( "github.com/pelletier/go-toml/v2" ) -type Listen struct { +type ListenConfig struct { Protocol string `toml:"protocol"` Address string `toml:"address"` } type Config struct { - Pages Listen `toml:"pages"` - Caddy Listen `toml:"caddy"` + Pages ListenConfig `toml:"pages"` + Caddy ListenConfig `toml:"caddy"` Wildcard struct { Domain string `toml:"domain"` CloneURL string `toml:"clone-url"` @@ -31,11 +31,15 @@ type Config struct { SecretAccessKey string `toml:"secret-access-key"` Region string `toml:"region"` Bucket string `toml:"bucket"` + Cache struct { + MaxSize uint64 `toml:"max-size"` // in bytes + MaxAge string `toml:"max-age"` + } `toml:"cache"` } } `toml:"backend"` } -func readConfig(path string, config *Config) error { +func ReadConfig(path string, config *Config) error { file, err := os.Open(path) if err != nil { return err diff --git a/src/main.go b/src/main.go index b81f57a..dbdfe55 100644 --- a/src/main.go +++ b/src/main.go @@ -10,7 +10,7 @@ import ( var config Config var backend Backend -func serveHandler(name string, listen Listen, serve func(http.ResponseWriter, *http.Request)) { +func serveHandler(name string, listen ListenConfig, serve func(http.ResponseWriter, *http.Request)) { listener, err := net.Listen(listen.Protocol, listen.Address) if err != nil { log.Fatalf("%s: %s\n", name, err) @@ -29,7 +29,7 @@ func main() { configPath := flag.String("config", "config.toml", "path to configuration file") flag.Parse() - if err = readConfig(*configPath, &config); err != nil { + if err = ReadConfig(*configPath, &config); err != nil { log.Fatalln("configuration:", err) } @@ -57,7 +57,7 @@ func main() { log.Println("ready") - if config.Caddy != (Listen{}) { + if config.Caddy != (ListenConfig{}) { go serveHandler("caddy", config.Caddy, ServeCaddy) }