mirror of
https://github.com/git-pkgs/proxy.git
synced 2026-11-09 15:49:16 -05:00
A mirror run that finds a blob cached records it in mirrored_at, which retention counts as use. LRU eviction keeps using last_accessed_at, so #508's decision not to count mirror runs as cache hits stands. Fixes #648.
144 lines
5.2 KiB
Go
144 lines
5.2 KiB
Go
package database
|
|
|
|
import (
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
)
|
|
|
|
// RetentionCandidate is a cached artifact that has not been accessed since a
|
|
// cutoff, together with the package record it belongs to.
|
|
type RetentionCandidate struct {
|
|
ID int64 `db:"id"`
|
|
VersionPURL string `db:"version_purl"`
|
|
Filename string `db:"filename"`
|
|
StoragePath string `db:"storage_path"`
|
|
Size sql.NullInt64 `db:"size"`
|
|
FetchedAt sql.NullTime `db:"fetched_at"`
|
|
LastAccessedAt sql.NullTime `db:"last_accessed_at"`
|
|
MirroredAt sql.NullTime `db:"mirrored_at"`
|
|
Ecosystem string `db:"ecosystem"`
|
|
PackagePURL string `db:"package_purl"`
|
|
}
|
|
|
|
// ExpiredBefore reports whether the artifact was neither fetched, accessed
|
|
// nor found by a mirror run at or after cutoff. Fetch and access are both
|
|
// checked: clearing a record keeps its last access time, so a record fetched
|
|
// again after an eviction has a fresh fetch time next to an old access time.
|
|
func (c RetentionCandidate) ExpiredBefore(cutoff time.Time) bool {
|
|
if !c.FetchedAt.Valid || !c.FetchedAt.Time.Before(cutoff) {
|
|
return false
|
|
}
|
|
if c.MirroredAt.Valid && !c.MirroredAt.Time.Before(cutoff) {
|
|
return false
|
|
}
|
|
return !c.LastAccessedAt.Valid || c.LastAccessedAt.Time.Before(cutoff)
|
|
}
|
|
|
|
// expiredCondition selects cached artifacts fetched before the cutoff and
|
|
// neither accessed nor mirrored since. It takes the cutoff three times.
|
|
const expiredCondition = `
|
|
storage_path IS NOT NULL
|
|
AND source <> 'local'
|
|
AND fetched_at < ?
|
|
AND (last_accessed_at IS NULL OR last_accessed_at < ?)
|
|
AND (mirrored_at IS NULL OR mirrored_at < ?)`
|
|
|
|
// MaxArtifactID returns the highest artifact id, or zero for an empty table.
|
|
func (db *DB) MaxArtifactID() (int64, error) {
|
|
var maxID sql.NullInt64
|
|
if err := db.Get(&maxID, `SELECT MAX(id) FROM artifacts`); err != nil {
|
|
return 0, err
|
|
}
|
|
return maxID.Int64, nil
|
|
}
|
|
|
|
// GetRetentionCandidates returns the cached artifacts with fromID < id <= toID
|
|
// that expired before cutoff. Bounding the scan by id keeps each query short,
|
|
// which matters on SQLite, where every request shares one connection.
|
|
func (db *DB) GetRetentionCandidates(fromID, toID int64, cutoff time.Time) ([]RetentionCandidate, error) {
|
|
var candidates []RetentionCandidate
|
|
query := db.Rebind(`
|
|
SELECT a.id, a.version_purl, a.filename, a.storage_path, a.size,
|
|
a.fetched_at, a.last_accessed_at, a.mirrored_at, p.ecosystem, p.purl AS package_purl
|
|
FROM (
|
|
SELECT * FROM artifacts
|
|
WHERE id > ? AND id <= ? AND` + expiredCondition + `
|
|
) a
|
|
JOIN versions v ON v.purl = a.version_purl
|
|
JOIN packages p ON p.purl = v.package_purl
|
|
ORDER BY a.id
|
|
`)
|
|
if err := db.Select(&candidates, query, fromID, toID, cutoff.UTC(), cutoff.UTC(), cutoff.UTC()); err != nil {
|
|
return nil, err
|
|
}
|
|
return candidates, nil
|
|
}
|
|
|
|
// 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.
|
|
func (db *DB) ClearExpiredArtifact(versionPURL, filename, storagePath string, cutoff time.Time) (bool, error) {
|
|
tx, err := db.Beginx()
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
defer func() { _ = tx.Rollback() }()
|
|
|
|
query := db.Rebind(`
|
|
UPDATE artifacts
|
|
SET storage_path = NULL, content_hash = NULL, size = NULL,
|
|
content_type = NULL, fetched_at = NULL, updated_at = ?
|
|
WHERE version_purl = ? AND filename = ? AND storage_path = ? AND` + expiredCondition)
|
|
res, err := tx.Exec(query, time.Now(), versionPURL, filename, storagePath, cutoff.UTC(), cutoff.UTC(), cutoff.UTC())
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
n, err := res.RowsAffected()
|
|
if err != nil || n == 0 {
|
|
return false, err
|
|
}
|
|
if _, err := tx.Exec(db.Rebind(queuePendingDeleteQuery), storagePath, time.Now().UTC()); err != nil {
|
|
return false, fmt.Errorf("queueing %s for deletion: %w", storagePath, err)
|
|
}
|
|
return true, tx.Commit()
|
|
}
|
|
|
|
// MarkArtifactMirrored records that a mirror run found the artifact cached,
|
|
// which keeps it from retention without counting as a cache hit.
|
|
func (db *DB) MarkArtifactMirrored(versionPURL, filename string) error {
|
|
_, err := db.Exec(db.Rebind(`
|
|
UPDATE artifacts SET mirrored_at = ?
|
|
WHERE version_purl = ? AND filename = ? AND storage_path IS NOT NULL
|
|
`), time.Now().UTC(), versionPURL, filename)
|
|
return err
|
|
}
|
|
|
|
// GetArtifactEcosystem returns the packages.ecosystem value of the package an
|
|
// artifact version belongs to, or "" when it has no package record.
|
|
func (db *DB) GetArtifactEcosystem(versionPURL string) (string, error) {
|
|
var ecosystem string
|
|
query := db.Rebind(`
|
|
SELECT p.ecosystem FROM versions v
|
|
JOIN packages p ON p.purl = v.package_purl
|
|
WHERE v.purl = ?
|
|
`)
|
|
err := db.Get(&ecosystem, query, versionPURL)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return "", nil
|
|
}
|
|
return ecosystem, err
|
|
}
|
|
|
|
// FlushHits writes hits buffered by BatchHits now, so a caller about to read
|
|
// last_accessed_at sees them.
|
|
func (db *DB) FlushHits() error {
|
|
if db.hits == nil {
|
|
return nil
|
|
}
|
|
return db.flushHits()
|
|
}
|