diff --git a/internal/database/retention.go b/internal/database/retention.go index 0036362c..1c369335 100644 --- a/internal/database/retention.go +++ b/internal/database/retention.go @@ -77,12 +77,21 @@ func (db *DB) GetRetentionCandidates(fromID, toID int64, cutoff time.Time) ([]Re } // ClearExpiredArtifact marks an artifact uncached like ClearArtifactCache, -// but only while it is still expired before cutoff, so an artifact served -// between the scan and the clear stays cached. It queues storagePath for -// deletion in the same transaction: if queueing fails, the record keeps its -// path and a later sweep tries again, instead of the object staying in -// storage with nothing left pointing at it. +// but only while it is still expired before cutoff. It checks pending hits as +// well as persisted access times, so a hit buffered after the scan keeps the +// artifact cached. It queues storagePath for deletion in the same transaction: +// if queueing fails, the record keeps its path and a later sweep retries, +// instead of leaving an object in storage with no record pointing to it. func (db *DB) ClearExpiredArtifact(versionPURL, filename, storagePath string, cutoff time.Time) (bool, error) { + if db.hits != nil { + db.hits.mu.Lock() + pending, ok := db.hits.pending[hitKey{versionPURL, filename}] + db.hits.mu.Unlock() + if ok && !pending.last.Before(cutoff) { + return false, nil + } + } + tx, err := db.Beginx() if err != nil { return false, err diff --git a/internal/database/retention_test.go b/internal/database/retention_test.go index 23820110..6a76ec59 100644 --- a/internal/database/retention_test.go +++ b/internal/database/retention_test.go @@ -221,6 +221,29 @@ func TestClearExpiredArtifact(t *testing.T) { }) } +func TestClearExpiredArtifactSkipsHitBufferedAfterSweepStart(t *testing.T) { + runWithBothDatabases(t, func(t *testing.T, db *DB) { + now := time.Now() + old := now.Add(-48 * time.Hour) + cutoff := now.Add(-24 * time.Hour) + seedRetentionArtifact(t, db, "npm", "served", old, old) + db.BatchHits(time.Hour, nil) + + if err := db.FlushHits(); err != nil { + t.Fatalf("initial FlushHits: %v", err) + } + if err := db.RecordArtifactHit("pkg:npm/served@1.0.0", "served.tgz"); err != nil { + t.Fatalf("RecordArtifactHit: %v", err) + } + + cleared, err := db.ClearExpiredArtifact("pkg:npm/served@1.0.0", "served.tgz", + "npm/served/1.0.0/served.tgz", cutoff) + if err != nil || cleared { + t.Fatalf("ClearExpiredArtifact(served) = %v, %v, want false, nil", cleared, err) + } + }) +} + // When the object can't be queued for deletion, the clear is rolled back: // the record keeps pointing at the object, so a later sweep retries it // rather than leaving a file in storage that nothing refers to.