From 0804b15441955cebfec7e70e313f1c279d387103 Mon Sep 17 00:00:00 2001 From: Christian Heim Date: Sun, 11 Oct 2026 21:22:42 +0200 Subject: [PATCH] On Postgres, metadata fetched_at is now stored in UTC, so metadata_ttl no longer expires hours late when the proxy runs east of UTC. Entries written before this expire late one last time and are stored in UTC on their next refresh. --- internal/database/metadata_cache_test.go | 39 ++++++++++++++++++++++++ internal/database/queries.go | 9 +++++- 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/internal/database/metadata_cache_test.go b/internal/database/metadata_cache_test.go index 46a38a71..26e0a639 100644 --- a/internal/database/metadata_cache_test.go +++ b/internal/database/metadata_cache_test.go @@ -362,3 +362,42 @@ func TestMetadataCacheRoundTripsContentEncoding(t *testing.T) { t.Fatalf("content encoding round-trip failed: %#v", got) } } + +// On Postgres, fetched_at is a TIMESTAMP column without a zone. East of UTC +// it must still read back as the moment it was written, or metadata_ttl +// expires hours late. last_modified is an HTTP date in UTC and must keep it. +func TestMetadataCacheTimesOffUTC(t *testing.T) { + tokyo, err := time.LoadLocation("Asia/Tokyo") + if err != nil { + t.Skipf("time zone data unavailable: %v", err) + } + prev := time.Local + time.Local = tokyo + t.Cleanup(func() { time.Local = prev }) + + runWithBothDatabases(t, func(t *testing.T, db *DB) { + fetched := time.Now().Add(-90 * time.Minute) + modified := time.Date(2026, 10, 1, 12, 0, 0, 0, time.UTC) + entry := &MetadataCacheEntry{ + Ecosystem: testEcosystemNPM, + Name: "tz", + StoragePath: "_metadata/npm/tz/metadata", + LastModified: sql.NullTime{Time: modified, Valid: true}, + FetchedAt: sql.NullTime{Time: fetched, Valid: true}, + } + if err := db.UpsertMetadataCache(entry); err != nil { + t.Fatalf("UpsertMetadataCache() error = %v", err) + } + + got, err := db.GetMetadataCache(testEcosystemNPM, "tz") + if err != nil || got == nil { + t.Fatalf("GetMetadataCache() = %v, %v", got, err) + } + if diff := got.FetchedAt.Time.Sub(fetched).Abs(); diff > time.Second { + t.Errorf("fetched_at read back as %v, want %v", got.FetchedAt.Time, fetched) + } + if !got.LastModified.Time.Equal(modified) { + t.Errorf("last_modified read back as %v, want %v", got.LastModified.Time, modified) + } + }) +} diff --git a/internal/database/queries.go b/internal/database/queries.go index 95030224..5897d959 100644 --- a/internal/database/queries.go +++ b/internal/database/queries.go @@ -1122,6 +1122,13 @@ func (db *DB) GetMetadataCache(ecosystem, name string) (*MetadataCacheEntry, err func (db *DB) UpsertMetadataCache(entry *MetadataCacheEntry) error { now := time.Now() + // fetched_at is a TIMESTAMP without zone on Postgres, and lib/pq reads it + // back labelled UTC, so it has to be stored in UTC. last_modified already + // is, since it comes from an HTTP date. + fetchedAt := entry.FetchedAt + if fetchedAt.Valid { + fetchedAt.Time = fetchedAt.Time.UTC() + } var query string if db.dialect == DialectPostgres { @@ -1162,7 +1169,7 @@ func (db *DB) UpsertMetadataCache(entry *MetadataCacheEntry) error { _, err := db.Exec(query, entry.Ecosystem, entry.Name, entry.StoragePath, entry.ETag, entry.Link, - entry.ContentType, entry.ContentEncoding, entry.ContentDigest, entry.Size, entry.LastModified, entry.FetchedAt, now, now, + entry.ContentType, entry.ContentEncoding, entry.ContentDigest, entry.Size, entry.LastModified, fetchedAt, now, now, ) if err != nil { return fmt.Errorf("upserting metadata cache: %w", err)