Watch
1
0
Fork
You've already forked pkg-proxy
1
mirror of https://github.com/git-pkgs/proxy.git synced 2026-11-09 15:49:16 -05:00
pkg-proxy/internal/server/analytics_runtime.go

375 lines
11 KiB
Go
Raw Permalink Normal View History

package server
import (
"fmt"
"sort"
"strconv"
"time"
"github.com/git-pkgs/proxy/internal/metrics"
)
// RuntimeView holds the process-lifetime figures read out of the Prometheus
// registry. Unlike the cache figures, which are computed from the database and
// survive a restart, everything here starts at zero when the proxy starts.
type RuntimeView struct {
Available bool
Requests string
ActiveRequests string
RequestMean string
StatusClasses []LabelledCount
// Artifact and metadata lookups are counted apart: they are separate
// caches. Only artifacts feed the database figures above.
ArtifactCache CacheLookups
MetadataCache CacheLookups
MetadataServed string
UpstreamFetches string
UpstreamFetchMean string
UpstreamErrors []LabelledCount
StorageOps []LabelledStat
StorageErrors []LabelledCount
IntegrityFailures []LabelledCount
ProbeFailures []LabelledCount
Add age-based retention for cached artifacts (#428) * Add age-based retention for cached artifacts Cached artifacts stayed forever unless storage.max_size pushed them out. storage.retention adds a default, per-ecosystem and per-package duration after which an artifact nobody downloaded gets evicted. Ecosystems opt in one at a time: a handler registers with retention.Default, and until it does, configuring that ecosystem (or one of its packages) fails validation, and the default doesn't touch it. This commit registers none, so nothing changes yet. Startup logs which ecosystems the default covers and which it doesn't. An artifact counts as expired when both its fetch time and its last access are older than the cutoff. Checking only the last access would evict a refetched artifact again right away, since clearing a record keeps its old access time and a cache miss doesn't record a hit. The sweep scans artifacts in id windows, so no single query holds the SQLite connection for long, and checks each row against its own rule. Expired records are cleared and their files go through pending_deletes instead of being deleted directly, because a buffered hit can make an artifact that's being served right now look expired. Buffered hits are flushed before each sweep, and one sweep clears at most 10,000 artifacts. Since a big first sweep can queue a lot, reclaim now keeps working through due entries for up to 30 seconds per tick instead of stopping after 100, and pending_deletes gets an index on queued_at. Also: - proxy_artifacts_evicted_total{reason, ecosystem} counts LRU and retention evictions. - gradle.build_cache.max_age accepts "7d", as its comment always said. Refs #306 * Document storage retention Describes the new storage.retention block, the order rules apply in, that the default only covers ecosystems that support retention yet, and what to expect from the first sweep over an old cache. Also lists the new eviction counter and notes that gradle max_age takes days. Refs #306 * Fix retention timing on Postgres and tighten the sweep On Postgres, fetched_at and last_accessed_at are TIMESTAMP columns without a zone. They hold the local time the proxy wrote, but lib/pq reads them back labelled UTC. East of UTC that makes every artifact look hours younger than it is, so the sweep evicted late: two hours in Berlin, nine in Tokyo. The candidates' times are now read back as local time, and the database tests run on both SQLite and Postgres, with one that sets the local zone to Asia/Tokyo. A few smaller things: - When a delete fails, reclaim stops for the rest of the minute instead of trying every queued path. Otherwise a storage outage turns into thirty seconds of failed deletes and warnings every minute. - If the buffered hits can't be written, the sweep skips that round. Running on stale access times could clear something that is being downloaded right now. - DefaultCanonical only accepts a package key when rebuilding it gives the same PURL. For alpine and deb it would otherwise produce keys that never match. - sweep_interval has to be at least a minute. - New tests cover the eviction counter and an artifact that gets a hit between the scan and the clear. - The docs no longer say nothing changes without retention, and they point out that the example's ecosystem and package rules only pass validation once those ecosystems support retention. Refs #306 * Reject qualifiers and subpaths in retention package keys A rule names a whole package. Qualifiers like ?type=pom were dropped on the way to the stored PURL and the rule quietly covered every file of the package. Now such a key fails validation. Refs #306 * Say when a package key doesn't match how packages are stored A package rule for an ecosystem that supports retention, written in a form its packages aren't stored under (pkg:npm/babel/core without the @), failed with "retention is not supported for npm packages". Once the ecosystem is enabled that is no longer true. Such a key now fails with "does not match how npm packages are stored" and points to the package rule forms in the docs. Keys for ecosystems without retention support keep the old message. Refs #306 * Note that re-running the mirror keeps packages cached A mirror run over a package that is still cached goes through the normal cache lookup and records a download, so the package's age starts over. The docs only said that mirrored packages age from the time they were mirrored. Refs #306 * Say when an ecosystem takes no package rules Some ecosystems will support retention without package rules, because nothing in their stored records names a package the way a PURL would (conan, helm). A package key for one of them used to fail with "does not match how conan packages are stored; see the package rule forms", which sends people looking for a form that doesn't exist. It now says package rules aren't supported there and points to the ecosystem rule. Refs #306 * Use helm as the example of an ecosystem without package rules Conan gets package rules once its handler stores archives under the recipe name, so the config test that shows the refusal now uses helm, whose cache records name the repository, not the chart. Refs #306 * Say that each cached file ages on its own The retention docs said each artifact, each version of a package, ages on its own. A version can have several cached files (a jar and its pom, several wheels, Conan sources and binaries), and each of them ages separately. Refs #306 * Clear an expired artifact and queue its file in one transaction The sweep cleared the record and queued the file for deletion as two separate writes. If queueing failed, or the process stopped in between, the file stayed in storage with no record pointing at it and no queue entry, and no later sweep would find it again. Both writes now share a transaction, so a failed queue rolls the clear back and the next sweep retries. Refs #306 * Show artifact evictions on the analytics page proxy_artifacts_evicted_total had no place on /ui/analytics. The Runtime card now lists evicted artifacts by reason and ecosystem, without the failure tint, and the coverage test records an eviction so it doesn't depend on test order. Refs #306 * Store artifact times in UTC fetched_at and last_accessed_at are now written in UTC, and the retention cutoffs are compared in UTC, the same convention #434 uses for the metadata cache. lib/pq sends a time with its offset and a Postgres TIMESTAMP keeps only the wall clock, so UTC on write is what reads back correctly. That replaces reinterpreting the times as local on read. Rows written before this still hold local time. East of UTC they look younger by the offset until they are rewritten, so retention evicts them a few hours late once; west of UTC that much early. Refs #306 --------- Co-authored-by: t <t@t>
2026-10-12 10:06:01 +02:00
Evictions []LabelledCount
MetadataEvictions []LabelledCount
ChangeFeedExpired []LabelledCount
Breakers []BreakerRow
BreakerTrips []LabelledCount
Scans []LabelledStat
ScansBlocked []LabelledCount
ScanErrors []LabelledCount
ScanningOn bool
ResponseBytes string
Clients []ClientRow
Sources []SourceRow
SourceCount int
TrustsForward bool
// SourcesOn gates the caller table. The page is unauthenticated, so the
// addresses behind it are published only when asked for.
SourcesOn bool
// FailureDetailsOn gates the recent failures listed under each failure
// row, for the same reason as SourcesOn.
FailureDetailsOn bool
// HasFailures is set when any failure counter has fired, which is when the
// note about turning failure details on is worth showing.
HasFailures bool
}
// CacheLookups is one cache's hits and misses since the process started.
type CacheLookups struct {
Hits string
Misses string
HitRatio string
HasTraffic bool
}
// cacheLookups reads one kind's share of the hit and miss counters.
func cacheLookups(hits, misses map[string]float64, kind string) CacheLookups {
h, m := hits[kind], misses[kind]
c := CacheLookups{
Hits: formatCount(int64(h)),
Misses: formatCount(int64(m)),
HasTraffic: h+m > 0,
}
if c.HasTraffic {
c.HitRatio = strconv.FormatFloat(h/(h+m)*100, 'f', 1, 64) //nolint:mnd // percent
}
return c
}
// ClientRow is one client tool's share of requests and bytes.
type ClientRow struct {
Client string
Requests string
Bytes string
}
// LabelledCount is a single counter series rendered as a row.
type LabelledCount struct {
Label string
Count string
// Bad marks a failure row, which the template tints.
Bad bool
// Recent lists the latest failures behind the count, newest first.
Recent []FailureRow
// series is the counter series the row was rendered from, for looking up
// what was recorded against it.
series map[string]string
}
// FailureRow is one recorded failure behind a failure counter.
type FailureRow struct {
Age string
PURL string
Filename string
URL string
Path string
Error string
}
// LabelledStat is a histogram series rendered as a row: how many observations,
// and their mean.
type LabelledStat struct {
Label string
Count string
Mean string
}
// BreakerRow is one upstream's circuit breaker state.
type BreakerRow struct {
Registry string
State string
Open bool
}
// runtimeView shapes a registry snapshot for the analytics page. With
// failureDetails set, each failure row carries the latest failures behind it.
func runtimeView(snap *metrics.Snapshot, failureDetails bool) RuntimeView {
if snap == nil {
return RuntimeView{}
}
v := RuntimeView{Available: true, FailureDetailsOn: failureDetails}
failures := func(name string, labels ...string) []LabelledCount {
return failureRows(snap, name, failureDetails, labels...)
}
requests := snap.Sum("proxy_requests_total")
v.Requests = formatCount(int64(requests))
v.ActiveRequests = formatCount(int64(snap.Sum("proxy_active_requests")))
v.RequestMean = formatDuration(snap.Mean("proxy_request_duration_seconds"))
v.StatusClasses = statusClasses(snap)
hits := snap.SumBy("proxy_cache_hits_total", "kind")
misses := snap.SumBy("proxy_cache_misses_total", "kind")
v.ArtifactCache = cacheLookups(hits, misses, metrics.CacheKindArtifact)
v.MetadataCache = cacheLookups(hits, misses, metrics.CacheKindMetadata)
v.MetadataServed = formatSize(int64(snap.Sum("proxy_metadata_cache_served_bytes_total")))
v.UpstreamFetches = formatCount(int64(snap.Count("proxy_upstream_fetch_duration_seconds")))
v.UpstreamFetchMean = formatDuration(snap.Mean("proxy_upstream_fetch_duration_seconds"))
v.UpstreamErrors = failures("proxy_upstream_errors_total", "ecosystem", "error_type")
v.StorageOps = histogramRows(snap, "proxy_storage_operation_duration_seconds", "operation")
v.StorageErrors = failures("proxy_storage_errors_total", "operation")
v.IntegrityFailures = failures("proxy_integrity_failures_total", "ecosystem")
v.ProbeFailures = failures("proxy_health_probe_failures_total", "step")
Add age-based retention for cached artifacts (#428) * Add age-based retention for cached artifacts Cached artifacts stayed forever unless storage.max_size pushed them out. storage.retention adds a default, per-ecosystem and per-package duration after which an artifact nobody downloaded gets evicted. Ecosystems opt in one at a time: a handler registers with retention.Default, and until it does, configuring that ecosystem (or one of its packages) fails validation, and the default doesn't touch it. This commit registers none, so nothing changes yet. Startup logs which ecosystems the default covers and which it doesn't. An artifact counts as expired when both its fetch time and its last access are older than the cutoff. Checking only the last access would evict a refetched artifact again right away, since clearing a record keeps its old access time and a cache miss doesn't record a hit. The sweep scans artifacts in id windows, so no single query holds the SQLite connection for long, and checks each row against its own rule. Expired records are cleared and their files go through pending_deletes instead of being deleted directly, because a buffered hit can make an artifact that's being served right now look expired. Buffered hits are flushed before each sweep, and one sweep clears at most 10,000 artifacts. Since a big first sweep can queue a lot, reclaim now keeps working through due entries for up to 30 seconds per tick instead of stopping after 100, and pending_deletes gets an index on queued_at. Also: - proxy_artifacts_evicted_total{reason, ecosystem} counts LRU and retention evictions. - gradle.build_cache.max_age accepts "7d", as its comment always said. Refs #306 * Document storage retention Describes the new storage.retention block, the order rules apply in, that the default only covers ecosystems that support retention yet, and what to expect from the first sweep over an old cache. Also lists the new eviction counter and notes that gradle max_age takes days. Refs #306 * Fix retention timing on Postgres and tighten the sweep On Postgres, fetched_at and last_accessed_at are TIMESTAMP columns without a zone. They hold the local time the proxy wrote, but lib/pq reads them back labelled UTC. East of UTC that makes every artifact look hours younger than it is, so the sweep evicted late: two hours in Berlin, nine in Tokyo. The candidates' times are now read back as local time, and the database tests run on both SQLite and Postgres, with one that sets the local zone to Asia/Tokyo. A few smaller things: - When a delete fails, reclaim stops for the rest of the minute instead of trying every queued path. Otherwise a storage outage turns into thirty seconds of failed deletes and warnings every minute. - If the buffered hits can't be written, the sweep skips that round. Running on stale access times could clear something that is being downloaded right now. - DefaultCanonical only accepts a package key when rebuilding it gives the same PURL. For alpine and deb it would otherwise produce keys that never match. - sweep_interval has to be at least a minute. - New tests cover the eviction counter and an artifact that gets a hit between the scan and the clear. - The docs no longer say nothing changes without retention, and they point out that the example's ecosystem and package rules only pass validation once those ecosystems support retention. Refs #306 * Reject qualifiers and subpaths in retention package keys A rule names a whole package. Qualifiers like ?type=pom were dropped on the way to the stored PURL and the rule quietly covered every file of the package. Now such a key fails validation. Refs #306 * Say when a package key doesn't match how packages are stored A package rule for an ecosystem that supports retention, written in a form its packages aren't stored under (pkg:npm/babel/core without the @), failed with "retention is not supported for npm packages". Once the ecosystem is enabled that is no longer true. Such a key now fails with "does not match how npm packages are stored" and points to the package rule forms in the docs. Keys for ecosystems without retention support keep the old message. Refs #306 * Note that re-running the mirror keeps packages cached A mirror run over a package that is still cached goes through the normal cache lookup and records a download, so the package's age starts over. The docs only said that mirrored packages age from the time they were mirrored. Refs #306 * Say when an ecosystem takes no package rules Some ecosystems will support retention without package rules, because nothing in their stored records names a package the way a PURL would (conan, helm). A package key for one of them used to fail with "does not match how conan packages are stored; see the package rule forms", which sends people looking for a form that doesn't exist. It now says package rules aren't supported there and points to the ecosystem rule. Refs #306 * Use helm as the example of an ecosystem without package rules Conan gets package rules once its handler stores archives under the recipe name, so the config test that shows the refusal now uses helm, whose cache records name the repository, not the chart. Refs #306 * Say that each cached file ages on its own The retention docs said each artifact, each version of a package, ages on its own. A version can have several cached files (a jar and its pom, several wheels, Conan sources and binaries), and each of them ages separately. Refs #306 * Clear an expired artifact and queue its file in one transaction The sweep cleared the record and queued the file for deletion as two separate writes. If queueing failed, or the process stopped in between, the file stayed in storage with no record pointing at it and no queue entry, and no later sweep would find it again. Both writes now share a transaction, so a failed queue rolls the clear back and the next sweep retries. Refs #306 * Show artifact evictions on the analytics page proxy_artifacts_evicted_total had no place on /ui/analytics. The Runtime card now lists evicted artifacts by reason and ecosystem, without the failure tint, and the coverage test records an eviction so it doesn't depend on test order. Refs #306 * Store artifact times in UTC fetched_at and last_accessed_at are now written in UTC, and the retention cutoffs are compared in UTC, the same convention #434 uses for the metadata cache. lib/pq sends a time with its offset and a Postgres TIMESTAMP keeps only the wall clock, so UTC on write is what reads back correctly. That replaces reinterpreting the times as local on read. Rows written before this still hold local time. East of UTC they look younger by the offset until they are rewritten, so retention evicts them a few hours late once; west of UTC that much early. Refs #306 --------- Co-authored-by: t <t@t>
2026-10-12 10:06:01 +02:00
v.Evictions = countRows(snap, "proxy_artifacts_evicted_total", "reason", "ecosystem")
v.MetadataEvictions = countRows(snap, "proxy_metadata_evicted_total", "ecosystem")
v.ChangeFeedExpired = countRows(snap, "proxy_metadata_expired_by_change_feed_total", "ecosystem")
v.Breakers = breakerRows(snap)
v.BreakerTrips = failures("proxy_circuit_breaker_trips_total", "registry")
v.Scans = histogramRows(snap, "proxy_scan_duration_seconds", "ecosystem", "scanner")
v.ScansBlocked = failures("proxy_scan_blocked_total", "ecosystem", "scanner")
v.ScanErrors = failures("proxy_scan_errors_total", "ecosystem", "scanner", "error_type")
v.ScanningOn = len(v.Scans) > 0 || len(v.ScansBlocked) > 0 || len(v.ScanErrors) > 0
v.HasFailures = len(v.UpstreamErrors) > 0 || len(v.StorageErrors) > 0 || len(v.IntegrityFailures) > 0 ||
len(v.ProbeFailures) > 0 || len(v.ScanErrors) > 0
v.ResponseBytes = formatSize(int64(snap.Sum("proxy_response_bytes_total")))
v.Clients = clientRows(snap)
return v
}
// clientRows pairs each client tool's request count with the bytes it pulled,
// busiest first. Both come from the same closed label set, so the two counters
// line up row for row.
func clientRows(snap *metrics.Snapshot) []ClientRow {
bytesByClient := snap.SumBy("proxy_client_response_bytes_total", "client")
type entry struct {
client string
requests float64
bytes float64
}
entries := make([]entry, 0)
for _, s := range snap.Samples("proxy_client_requests_total") {
client := s.Label("client")
entries = append(entries, entry{
client: client,
requests: s.Value,
bytes: bytesByClient[client],
})
}
sort.Slice(entries, func(i, j int) bool {
a, b := entries[i], entries[j]
if a.bytes != b.bytes {
return a.bytes > b.bytes
}
return a.requests > b.requests
})
rows := make([]ClientRow, 0, len(entries))
for _, e := range entries {
rows = append(rows, ClientRow{
Client: e.client,
Requests: formatCount(int64(e.requests)),
Bytes: formatSize(int64(e.bytes)),
})
}
return rows
}
// statusClasses groups proxy_requests_total into 2xx/3xx/4xx/5xx buckets, which
// is the breakdown worth showing; the per-code detail stays in Prometheus.
func statusClasses(snap *metrics.Snapshot) []LabelledCount {
byClass := make(map[string]float64)
for _, s := range snap.Samples("proxy_requests_total") {
status := s.Label("status")
if len(status) == 0 {
continue
}
byClass[string(status[0])+"xx"] += s.Value
}
classes := make([]string, 0, len(byClass))
for c := range byClass {
classes = append(classes, c)
}
sort.Strings(classes)
rows := make([]LabelledCount, 0, len(classes))
for _, c := range classes {
rows = append(rows, LabelledCount{
Label: c,
Count: formatCount(int64(byClass[c])),
Bad: c == "5xx",
})
}
return rows
}
// failureRows renders every non-zero series of a failure counter, keyed by the
// joined values of the given labels. Zero-valued series are dropped: a counter
// that has never fired carries no information, and a wall of zeroes buries the
// rows that matter. With withRecent set, each row carries the latest failures
// recorded for its series.
func failureRows(snap *metrics.Snapshot, name string, withRecent bool, labels ...string) []LabelledCount {
Add age-based retention for cached artifacts (#428) * Add age-based retention for cached artifacts Cached artifacts stayed forever unless storage.max_size pushed them out. storage.retention adds a default, per-ecosystem and per-package duration after which an artifact nobody downloaded gets evicted. Ecosystems opt in one at a time: a handler registers with retention.Default, and until it does, configuring that ecosystem (or one of its packages) fails validation, and the default doesn't touch it. This commit registers none, so nothing changes yet. Startup logs which ecosystems the default covers and which it doesn't. An artifact counts as expired when both its fetch time and its last access are older than the cutoff. Checking only the last access would evict a refetched artifact again right away, since clearing a record keeps its old access time and a cache miss doesn't record a hit. The sweep scans artifacts in id windows, so no single query holds the SQLite connection for long, and checks each row against its own rule. Expired records are cleared and their files go through pending_deletes instead of being deleted directly, because a buffered hit can make an artifact that's being served right now look expired. Buffered hits are flushed before each sweep, and one sweep clears at most 10,000 artifacts. Since a big first sweep can queue a lot, reclaim now keeps working through due entries for up to 30 seconds per tick instead of stopping after 100, and pending_deletes gets an index on queued_at. Also: - proxy_artifacts_evicted_total{reason, ecosystem} counts LRU and retention evictions. - gradle.build_cache.max_age accepts "7d", as its comment always said. Refs #306 * Document storage retention Describes the new storage.retention block, the order rules apply in, that the default only covers ecosystems that support retention yet, and what to expect from the first sweep over an old cache. Also lists the new eviction counter and notes that gradle max_age takes days. Refs #306 * Fix retention timing on Postgres and tighten the sweep On Postgres, fetched_at and last_accessed_at are TIMESTAMP columns without a zone. They hold the local time the proxy wrote, but lib/pq reads them back labelled UTC. East of UTC that makes every artifact look hours younger than it is, so the sweep evicted late: two hours in Berlin, nine in Tokyo. The candidates' times are now read back as local time, and the database tests run on both SQLite and Postgres, with one that sets the local zone to Asia/Tokyo. A few smaller things: - When a delete fails, reclaim stops for the rest of the minute instead of trying every queued path. Otherwise a storage outage turns into thirty seconds of failed deletes and warnings every minute. - If the buffered hits can't be written, the sweep skips that round. Running on stale access times could clear something that is being downloaded right now. - DefaultCanonical only accepts a package key when rebuilding it gives the same PURL. For alpine and deb it would otherwise produce keys that never match. - sweep_interval has to be at least a minute. - New tests cover the eviction counter and an artifact that gets a hit between the scan and the clear. - The docs no longer say nothing changes without retention, and they point out that the example's ecosystem and package rules only pass validation once those ecosystems support retention. Refs #306 * Reject qualifiers and subpaths in retention package keys A rule names a whole package. Qualifiers like ?type=pom were dropped on the way to the stored PURL and the rule quietly covered every file of the package. Now such a key fails validation. Refs #306 * Say when a package key doesn't match how packages are stored A package rule for an ecosystem that supports retention, written in a form its packages aren't stored under (pkg:npm/babel/core without the @), failed with "retention is not supported for npm packages". Once the ecosystem is enabled that is no longer true. Such a key now fails with "does not match how npm packages are stored" and points to the package rule forms in the docs. Keys for ecosystems without retention support keep the old message. Refs #306 * Note that re-running the mirror keeps packages cached A mirror run over a package that is still cached goes through the normal cache lookup and records a download, so the package's age starts over. The docs only said that mirrored packages age from the time they were mirrored. Refs #306 * Say when an ecosystem takes no package rules Some ecosystems will support retention without package rules, because nothing in their stored records names a package the way a PURL would (conan, helm). A package key for one of them used to fail with "does not match how conan packages are stored; see the package rule forms", which sends people looking for a form that doesn't exist. It now says package rules aren't supported there and points to the ecosystem rule. Refs #306 * Use helm as the example of an ecosystem without package rules Conan gets package rules once its handler stores archives under the recipe name, so the config test that shows the refusal now uses helm, whose cache records name the repository, not the chart. Refs #306 * Say that each cached file ages on its own The retention docs said each artifact, each version of a package, ages on its own. A version can have several cached files (a jar and its pom, several wheels, Conan sources and binaries), and each of them ages separately. Refs #306 * Clear an expired artifact and queue its file in one transaction The sweep cleared the record and queued the file for deletion as two separate writes. If queueing failed, or the process stopped in between, the file stayed in storage with no record pointing at it and no queue entry, and no later sweep would find it again. Both writes now share a transaction, so a failed queue rolls the clear back and the next sweep retries. Refs #306 * Show artifact evictions on the analytics page proxy_artifacts_evicted_total had no place on /ui/analytics. The Runtime card now lists evicted artifacts by reason and ecosystem, without the failure tint, and the coverage test records an eviction so it doesn't depend on test order. Refs #306 * Store artifact times in UTC fetched_at and last_accessed_at are now written in UTC, and the retention cutoffs are compared in UTC, the same convention #434 uses for the metadata cache. lib/pq sends a time with its offset and a Postgres TIMESTAMP keeps only the wall clock, so UTC on write is what reads back correctly. That replaces reinterpreting the times as local on read. Rows written before this still hold local time. East of UTC they look younger by the offset until they are rewritten, so retention evicts them a few hours late once; west of UTC that much early. Refs #306 --------- Co-authored-by: t <t@t>
2026-10-12 10:06:01 +02:00
rows := countRows(snap, name, labels...)
for i := range rows {
rows[i].Bad = true
if withRecent {
rows[i].Recent = recentFailureRows(metrics.RecentFailures(name, rows[i].series))
}
Add age-based retention for cached artifacts (#428) * Add age-based retention for cached artifacts Cached artifacts stayed forever unless storage.max_size pushed them out. storage.retention adds a default, per-ecosystem and per-package duration after which an artifact nobody downloaded gets evicted. Ecosystems opt in one at a time: a handler registers with retention.Default, and until it does, configuring that ecosystem (or one of its packages) fails validation, and the default doesn't touch it. This commit registers none, so nothing changes yet. Startup logs which ecosystems the default covers and which it doesn't. An artifact counts as expired when both its fetch time and its last access are older than the cutoff. Checking only the last access would evict a refetched artifact again right away, since clearing a record keeps its old access time and a cache miss doesn't record a hit. The sweep scans artifacts in id windows, so no single query holds the SQLite connection for long, and checks each row against its own rule. Expired records are cleared and their files go through pending_deletes instead of being deleted directly, because a buffered hit can make an artifact that's being served right now look expired. Buffered hits are flushed before each sweep, and one sweep clears at most 10,000 artifacts. Since a big first sweep can queue a lot, reclaim now keeps working through due entries for up to 30 seconds per tick instead of stopping after 100, and pending_deletes gets an index on queued_at. Also: - proxy_artifacts_evicted_total{reason, ecosystem} counts LRU and retention evictions. - gradle.build_cache.max_age accepts "7d", as its comment always said. Refs #306 * Document storage retention Describes the new storage.retention block, the order rules apply in, that the default only covers ecosystems that support retention yet, and what to expect from the first sweep over an old cache. Also lists the new eviction counter and notes that gradle max_age takes days. Refs #306 * Fix retention timing on Postgres and tighten the sweep On Postgres, fetched_at and last_accessed_at are TIMESTAMP columns without a zone. They hold the local time the proxy wrote, but lib/pq reads them back labelled UTC. East of UTC that makes every artifact look hours younger than it is, so the sweep evicted late: two hours in Berlin, nine in Tokyo. The candidates' times are now read back as local time, and the database tests run on both SQLite and Postgres, with one that sets the local zone to Asia/Tokyo. A few smaller things: - When a delete fails, reclaim stops for the rest of the minute instead of trying every queued path. Otherwise a storage outage turns into thirty seconds of failed deletes and warnings every minute. - If the buffered hits can't be written, the sweep skips that round. Running on stale access times could clear something that is being downloaded right now. - DefaultCanonical only accepts a package key when rebuilding it gives the same PURL. For alpine and deb it would otherwise produce keys that never match. - sweep_interval has to be at least a minute. - New tests cover the eviction counter and an artifact that gets a hit between the scan and the clear. - The docs no longer say nothing changes without retention, and they point out that the example's ecosystem and package rules only pass validation once those ecosystems support retention. Refs #306 * Reject qualifiers and subpaths in retention package keys A rule names a whole package. Qualifiers like ?type=pom were dropped on the way to the stored PURL and the rule quietly covered every file of the package. Now such a key fails validation. Refs #306 * Say when a package key doesn't match how packages are stored A package rule for an ecosystem that supports retention, written in a form its packages aren't stored under (pkg:npm/babel/core without the @), failed with "retention is not supported for npm packages". Once the ecosystem is enabled that is no longer true. Such a key now fails with "does not match how npm packages are stored" and points to the package rule forms in the docs. Keys for ecosystems without retention support keep the old message. Refs #306 * Note that re-running the mirror keeps packages cached A mirror run over a package that is still cached goes through the normal cache lookup and records a download, so the package's age starts over. The docs only said that mirrored packages age from the time they were mirrored. Refs #306 * Say when an ecosystem takes no package rules Some ecosystems will support retention without package rules, because nothing in their stored records names a package the way a PURL would (conan, helm). A package key for one of them used to fail with "does not match how conan packages are stored; see the package rule forms", which sends people looking for a form that doesn't exist. It now says package rules aren't supported there and points to the ecosystem rule. Refs #306 * Use helm as the example of an ecosystem without package rules Conan gets package rules once its handler stores archives under the recipe name, so the config test that shows the refusal now uses helm, whose cache records name the repository, not the chart. Refs #306 * Say that each cached file ages on its own The retention docs said each artifact, each version of a package, ages on its own. A version can have several cached files (a jar and its pom, several wheels, Conan sources and binaries), and each of them ages separately. Refs #306 * Clear an expired artifact and queue its file in one transaction The sweep cleared the record and queued the file for deletion as two separate writes. If queueing failed, or the process stopped in between, the file stayed in storage with no record pointing at it and no queue entry, and no later sweep would find it again. Both writes now share a transaction, so a failed queue rolls the clear back and the next sweep retries. Refs #306 * Show artifact evictions on the analytics page proxy_artifacts_evicted_total had no place on /ui/analytics. The Runtime card now lists evicted artifacts by reason and ecosystem, without the failure tint, and the coverage test records an eviction so it doesn't depend on test order. Refs #306 * Store artifact times in UTC fetched_at and last_accessed_at are now written in UTC, and the retention cutoffs are compared in UTC, the same convention #434 uses for the metadata cache. lib/pq sends a time with its offset and a Postgres TIMESTAMP keeps only the wall clock, so UTC on write is what reads back correctly. That replaces reinterpreting the times as local on read. Rows written before this still hold local time. East of UTC they look younger by the offset until they are rewritten, so retention evicts them a few hours late once; west of UTC that much early. Refs #306 --------- Co-authored-by: t <t@t>
2026-10-12 10:06:01 +02:00
}
return rows
}
// countRows renders every non-zero series of a counter as a row.
func countRows(snap *metrics.Snapshot, name string, labels ...string) []LabelledCount {
var rows []LabelledCount
for _, s := range snap.Samples(name) {
if s.Value == 0 {
continue
}
rows = append(rows, LabelledCount{
Label: joinLabels(s, labels...),
Count: formatCount(int64(s.Value)),
series: s.Labels,
})
}
return rows
}
func recentFailureRows(failures []metrics.Failure) []FailureRow {
if len(failures) == 0 {
return nil
}
rows := make([]FailureRow, 0, len(failures))
for _, f := range failures {
rows = append(rows, FailureRow{
Age: formatTimeAgo(f.At),
PURL: f.PURL,
Filename: f.Filename,
URL: f.URL,
Path: f.Path,
Error: f.Error,
})
}
return rows
}
// histogramRows renders every observed series of a histogram with its count and mean.
func histogramRows(snap *metrics.Snapshot, name string, labels ...string) []LabelledStat {
var rows []LabelledStat
for _, s := range snap.Samples(name) {
if s.Count == 0 {
continue
}
rows = append(rows, LabelledStat{
Label: joinLabels(s, labels...),
Count: formatCount(int64(s.Count)),
Mean: formatDuration(s.Mean()),
})
}
return rows
}
func breakerRows(snap *metrics.Snapshot) []BreakerRow {
var rows []BreakerRow
for _, s := range snap.Samples("proxy_circuit_breaker_state") {
state := "closed"
switch s.Value {
case 1:
state = "half-open"
case 2: //nolint:mnd // 2 = open, per the gauge's documented encoding
state = "open"
}
rows = append(rows, BreakerRow{
Registry: s.Label("registry"),
State: state,
Open: s.Value > 0,
})
}
return rows
}
func joinLabels(s metrics.Sample, labels ...string) string {
out := ""
for _, l := range labels {
v := s.Label(l)
if v == "" {
continue
}
if out != "" {
out += " · "
}
out += v
}
if out == "" {
return "-"
}
return out
}
// formatDuration renders a mean latency at a sensible unit. Storage operations
// land in microseconds and upstream fetches in seconds, so a fixed unit would
// read as either 0.000 or an unreadable pile of digits.
func formatDuration(seconds float64) string {
if seconds <= 0 {
return "-"
}
d := time.Duration(seconds * float64(time.Second))
switch {
case d < time.Microsecond:
return fmt.Sprintf("%.0f ns", float64(d))
case d < time.Millisecond:
return fmt.Sprintf("%.0f µs", float64(d)/float64(time.Microsecond))
case d < time.Second:
return fmt.Sprintf("%.1f ms", float64(d)/float64(time.Millisecond))
default:
return fmt.Sprintf("%.2f s", d.Seconds())
}
}