From 8f2f5e28367aae0f59624cd460f079cc6ab7aba6 Mon Sep 17 00:00:00 2001 From: Alec Thomas Date: Tue, 20 Jan 2026 21:01:24 +1100 Subject: [PATCH] feat: add a Cache.Stat() method that returns headers --- internal/cache/api.go | 5 +++++ internal/cache/disk.go | 20 ++++++++++++++++++++ internal/cache/memory.go | 16 ++++++++++++++++ internal/cache/remote.go | 28 ++++++++++++++++++++++++++++ internal/cache/s3.go | 21 ++++++++++++++++----- internal/cache/tiered.go | 18 ++++++++++++++++++ internal/strategy/apiv1.go | 22 ++++++++++++++++++++++ 7 files changed, 125 insertions(+), 5 deletions(-) diff --git a/internal/cache/api.go b/internal/cache/api.go index 419f305..e63b570 100644 --- a/internal/cache/api.go +++ b/internal/cache/api.go @@ -90,6 +90,11 @@ func FilterTransportHeaders(headers textproto.MIMEHeader) textproto.MIMEHeader { type Cache interface { // String describes the Cache implementation. String() string + // Stat returns the headers of an existing object in the cache. + // + // Expired files SHOULD not be returned. + // Must return os.ErrNotExist if the file does not exist. + Stat(ctx context.Context, key Key) (textproto.MIMEHeader, error) // Open an existing file in the cache. // // Expired files SHOULD not be returned. diff --git a/internal/cache/disk.go b/internal/cache/disk.go index 858b7c1..6c21882 100644 --- a/internal/cache/disk.go +++ b/internal/cache/disk.go @@ -190,6 +190,26 @@ func (d *Disk) Delete(_ context.Context, key Key) error { return nil } +func (d *Disk) Stat(ctx context.Context, key Key) (textproto.MIMEHeader, error) { + path := d.keyToPath(key) + fullPath := filepath.Join(d.config.Root, path) + + if _, err := os.Stat(fullPath); err != nil { + return nil, errors.Errorf("failed to stat file: %w", err) + } + + expiresAt, headers, err := d.ttl.get(key) + if err != nil { + return nil, errors.Errorf("failed to get metadata: %w", err) + } + + if time.Now().After(expiresAt) { + return nil, errors.Join(fs.ErrNotExist, d.Delete(ctx, key)) + } + + return headers, nil +} + func (d *Disk) Open(ctx context.Context, key Key) (io.ReadCloser, textproto.MIMEHeader, error) { path := d.keyToPath(key) fullPath := filepath.Join(d.config.Root, path) diff --git a/internal/cache/memory.go b/internal/cache/memory.go index 102a1fa..6151986 100644 --- a/internal/cache/memory.go +++ b/internal/cache/memory.go @@ -47,6 +47,22 @@ func NewMemory(ctx context.Context, config MemoryConfig) (*Memory, error) { func (m *Memory) String() string { return fmt.Sprintf("memory:%dMB", m.config.LimitMB) } +func (m *Memory) Stat(_ context.Context, key Key) (textproto.MIMEHeader, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + entry, exists := m.entries[key] + if !exists { + return nil, os.ErrNotExist + } + + if time.Now().After(entry.expiresAt) { + return nil, os.ErrNotExist + } + + return entry.headers, nil +} + func (m *Memory) Open(_ context.Context, key Key) (io.ReadCloser, textproto.MIMEHeader, error) { m.mu.RLock() defer m.mu.RUnlock() diff --git a/internal/cache/remote.go b/internal/cache/remote.go index 36559a2..b8becf7 100644 --- a/internal/cache/remote.go +++ b/internal/cache/remote.go @@ -58,6 +58,34 @@ func (c *Remote) Open(ctx context.Context, key Key) (io.ReadCloser, textproto.MI return resp.Body, headers, nil } +// Stat retrieves headers for an object from the remote. +func (c *Remote) Stat(ctx context.Context, key Key) (textproto.MIMEHeader, error) { + url := fmt.Sprintf("%s/%s", c.baseURL, key.String()) + req, err := http.NewRequestWithContext(ctx, http.MethodHead, url, nil) + if err != nil { + return nil, errors.Wrap(err, "failed to create request") + } + + resp, err := c.client.Do(req) + if err != nil { + return nil, errors.Wrap(err, "failed to execute request") + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + return nil, os.ErrNotExist + } + + if resp.StatusCode != http.StatusOK { + return nil, errors.Errorf("unexpected status code: %d", resp.StatusCode) + } + + // Filter out HTTP transport headers + headers := FilterTransportHeaders(textproto.MIMEHeader(resp.Header)) + + return headers, nil +} + // Create stores a new object in the remote. func (c *Remote) Create(ctx context.Context, key Key, headers textproto.MIMEHeader, ttl time.Duration) (io.WriteCloser, error) { pr, pw := io.Pipe() diff --git a/internal/cache/s3.go b/internal/cache/s3.go index 3595b5e..c2e38c1 100644 --- a/internal/cache/s3.go +++ b/internal/cache/s3.go @@ -157,7 +157,7 @@ func (s *S3) keyToPath(key Key) string { return hexKey[:2] + "/" + hexKey } -func (s *S3) Open(ctx context.Context, key Key) (io.ReadCloser, textproto.MIMEHeader, error) { +func (s *S3) Stat(ctx context.Context, key Key) (textproto.MIMEHeader, error) { objectName := s.keyToPath(key) // Get object info to check metadata @@ -165,9 +165,9 @@ func (s *S3) Open(ctx context.Context, key Key) (io.ReadCloser, textproto.MIMEHe if err != nil { errResponse := minio.ToErrorResponse(err) if errResponse.Code == "NoSuchKey" { - return nil, nil, os.ErrNotExist + return nil, os.ErrNotExist } - return nil, nil, errors.Errorf("failed to stat object: %w", err) + return nil, errors.Errorf("failed to stat object: %w", err) } // Check if object has expired @@ -178,7 +178,7 @@ func (s *S3) Open(ctx context.Context, key Key) (io.ReadCloser, textproto.MIMEHe if err := expiresAt.UnmarshalText([]byte(expiresAtStr)); err == nil { if time.Now().After(expiresAt) { // Object expired, delete it and return not found - return nil, nil, errors.Join(os.ErrNotExist, s.Delete(ctx, key)) + return nil, errors.Join(os.ErrNotExist, s.Delete(ctx, key)) } } } @@ -188,10 +188,21 @@ func (s *S3) Open(ctx context.Context, key Key) (io.ReadCloser, textproto.MIMEHe headers := make(textproto.MIMEHeader) if headersJSON := objInfo.UserMetadata["Headers"]; headersJSON != "" { if err := json.Unmarshal([]byte(headersJSON), &headers); err != nil { - return nil, nil, errors.Errorf("failed to unmarshal headers: %w", err) + return nil, errors.Errorf("failed to unmarshal headers: %w", err) } } + return headers, nil +} + +func (s *S3) Open(ctx context.Context, key Key) (io.ReadCloser, textproto.MIMEHeader, error) { + headers, err := s.Stat(ctx, key) + if err != nil { + return nil, nil, errors.WithStack(err) + } + + objectName := s.keyToPath(key) + // Get object obj, err := s.client.GetObject(ctx, s.config.Bucket, objectName, minio.GetObjectOptions{}) if err != nil { diff --git a/internal/cache/tiered.go b/internal/cache/tiered.go index fdc4181..8b3505f 100644 --- a/internal/cache/tiered.go +++ b/internal/cache/tiered.go @@ -88,6 +88,24 @@ func (t Tiered) Delete(ctx context.Context, key Key) error { return errors.Join(errs...) } +// Stat returns headers from the first cache that succeeds. +// +// If all caches fail, all errors are returned. +func (t Tiered) Stat(ctx context.Context, key Key) (textproto.MIMEHeader, error) { + errs := make([]error, len(t.caches)) + for i, c := range t.caches { + headers, err := c.Stat(ctx, key) + errs[i] = err + if errors.Is(err, os.ErrNotExist) { + continue + } else if err != nil { + return nil, errors.WithStack(err) + } + return headers, nil + } + return nil, errors.Join(errs...) +} + // Open returns a reader from the first cache that succeeds. // // If all caches fail, all errors are returned. diff --git a/internal/strategy/apiv1.go b/internal/strategy/apiv1.go index 5d82da3..4e72346 100644 --- a/internal/strategy/apiv1.go +++ b/internal/strategy/apiv1.go @@ -34,6 +34,7 @@ func NewAPIV1(ctx context.Context, _ jobscheduler.Scheduler, _ struct{}, cache c cache: cache, } mux.Handle("GET /api/v1/object/{key}", http.HandlerFunc(s.getObject)) + mux.Handle("HEAD /api/v1/object/{key}", http.HandlerFunc(s.statObject)) mux.Handle("POST /api/v1/object/{key}", http.HandlerFunc(s.putObject)) mux.Handle("DELETE /api/v1/object/{key}", http.HandlerFunc(s.deleteObject)) return s, nil @@ -41,6 +42,27 @@ func NewAPIV1(ctx context.Context, _ jobscheduler.Scheduler, _ struct{}, cache c func (d *APIV1) String() string { return "default" } +func (d *APIV1) statObject(w http.ResponseWriter, r *http.Request) { + key, err := cache.ParseKey(r.PathValue("key")) + if err != nil { + d.httpError(w, http.StatusBadRequest, err, "Invalid key") + return + } + + headers, err := d.cache.Stat(r.Context(), key) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + d.httpError(w, http.StatusNotFound, err, "Cache object not found", slog.String("key", key.String())) + return + } + d.httpError(w, http.StatusInternalServerError, err, "Failed to open cache object", slog.String("key", key.String())) + return + } + + maps.Copy(w.Header(), headers) + w.WriteHeader(http.StatusOK) +} + func (d *APIV1) getObject(w http.ResponseWriter, r *http.Request) { key, err := cache.ParseKey(r.PathValue("key")) if err != nil {