Cache manifests in memory when using the S3 backend.

This commit is contained in:
Catherine
2025-09-17 06:53:39 +00:00
parent 876b4596ba
commit 1faf0a4431
6 changed files with 67 additions and 17 deletions

View File

@@ -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.

1
go.mod
View File

@@ -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

2
go.sum
View File

@@ -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=

View File

@@ -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 {

View File

@@ -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

View File

@@ -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)
}