From d6a38a7af6a455ceb27b59dca46dfe38ccc23797 Mon Sep 17 00:00:00 2001 From: Elizabeth Worstell Date: Tue, 10 Mar 2026 18:48:27 -0700 Subject: [PATCH] feat: implement LRU TTL refresh for S3 cache on Open Like the disk cache, S3 now resets the expiration time on each Open (read) using server-side copy-to-self with metadata replacement. This prevents frequently accessed snapshots from expiring while periodic snapshot jobs handle content freshness. Co-authored-by: Amp Amp-Thread-ID: https://ampcode.com/threads/T-019cda52-ee36-738c-86cd-1fd410c47d7f --- internal/cache/s3.go | 46 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/internal/cache/s3.go b/internal/cache/s3.go index 9b96118..ea90da2 100644 --- a/internal/cache/s3.go +++ b/internal/cache/s3.go @@ -249,6 +249,21 @@ func (s *S3) Open(ctx context.Context, key Key) (io.ReadCloser, http.Header, err headers.Set("Last-Modified", objInfo.LastModified.UTC().Format(http.TimeFormat)) } + // Reset expiration time to implement LRU (same as disk cache). + // Only refresh when remaining TTL is below 50% of max to avoid a + // server-side copy on every read. + now := time.Now() + if expiresAtStr != "" { + var expiresAt time.Time + if err := expiresAt.UnmarshalText([]byte(expiresAtStr)); err == nil { + remaining := expiresAt.Sub(now) + if remaining < s.config.MaxTTL/2 { + newExpiresAt := now.Add(s.config.MaxTTL) + s.refreshExpiration(ctx, objectName, objInfo, newExpiresAt) + } + } + } + // Get object obj, err := s.client.GetObject(ctx, s.config.Bucket, objectName, minio.GetObjectOptions{}) if err != nil { @@ -258,6 +273,37 @@ func (s *S3) Open(ctx context.Context, key Key) (io.ReadCloser, http.Header, err return &s3Reader{obj: obj}, headers, nil } +// refreshExpiration updates the Expires-At metadata on an S3 object using +// server-side copy-to-self with metadata replacement. This avoids re-uploading +// the object data. Errors are logged but not returned since this is best-effort. +func (s *S3) refreshExpiration(ctx context.Context, objectName string, objInfo minio.ObjectInfo, newExpiresAt time.Time) { + newExpiresAtBytes, err := newExpiresAt.MarshalText() + if err != nil { + return + } + + // Rebuild user metadata with updated expiration + newMetadata := make(map[string]string) + maps.Copy(newMetadata, objInfo.UserMetadata) + newMetadata["Expires-At"] = string(newExpiresAtBytes) + + src := minio.CopySrcOptions{ + Bucket: s.config.Bucket, + Object: objectName, + } + dst := minio.CopyDestOptions{ + Bucket: s.config.Bucket, + Object: objectName, + UserMetadata: newMetadata, + ReplaceMetadata: true, + } + if _, err := s.client.CopyObject(ctx, dst, src); err != nil { + s.logger.WarnContext(ctx, "Failed to refresh S3 expiration", + "object", objectName, + "error", err.Error()) + } +} + const s3ErrNoSuchKey = "NoSuchKey" // s3Reader wraps minio.Object to convert S3 errors to standard errors.