Repository navigation
Add per-ecosystem artifact retention (age-based eviction) #306
Copy link
Copy link
Closed
Labels
enhancementNew feature or requestNew feature or request
Description
Summary
Cached artifacts currently stay in storage forever unless storage.max_size is set, and that limit is a single global LRU across all ecosystems. There is no way to say "keep npm tarballs for 30 days, OCI blobs for 14 days, and Maven artifacts indefinitely". This issue proposes a storage.retention block that evicts artifacts per ecosystem (and optionally per package) after they have not been accessed for a configurable duration.
Current behaviour
storage.max_sizetriggers size-based LRU eviction, global for all ecosystems (internal/server/eviction.go). It is DB-driven viaartifacts.last_accessed_at.gradle.build_cache.max_age/max_sizeevict Gradle build-cache entries only, storage-listing-driven (internal/server/gradle_cache_eviction.go).- Nothing else is ever evicted. Without
max_size, the cache grows without bound.
Proposed configuration
Follows the existing cooldown pattern (default + ecosystems map + packages map keyed by PURL):
storage:
max_size: "10GB" # unchanged, size-based LRU still applies
retention:
# Evict artifacts that have not been accessed for longer than this.
# "0" or empty disables age-based eviction (current behaviour).
default: "0"
ecosystems:
npm: "30d"
oci: "14d"
alpine: "7d"
maven: "0" # explicitly unlimited
packages: # optional, keys as in cooldown.packages
"pkg:npm/lodash": "0"
sweep_interval: "10m"Environment overrides: PROXY_STORAGE_RETENTION_DEFAULT, PROXY_STORAGE_RETENTION_SWEEP_INTERVAL (maps have no env override, same as cooldown).
Semantics
- Retention is measured from the last access, not from the fetch time. Artifacts are immutable, so a fixed max age would only force re-downloads of actively used packages. For artifacts that were never served (for example mirrored ones),
fetched_atis used as fallback:COALESCE(last_accessed_at, fetched_at) < cutoff. - Resolution order: package override → ecosystem override → default. A value of
"0"at any level means "never evict by age" and stops the lookup. - Durations accept a
dsuffix, using the same parser as cooldown. - Valid ecosystem keys are the literals the handlers pass to the artifact fetch (also the first segment of the storage path):
npm,pypi,cargo,gem,golang,hex,pub,maven,nuget,composer,conan,conda,cran,julia,swift,deb,rpm,alpine,helm,oci. Unknown keys fail validation so typos are not silently ignored. - The retention sweep runs before the size-based LRU pass, so
max_sizeoperates on the already-pruned set.
Implementation outline
internal/config/config.go: addRetentionConfigunderStorageConfig, validation of durations and ecosystem keys, parse helpers.internal/database/queries.go: query for expired artifacts per ecosystem (joinartifacts→versions→packages.ecosystem, batch limit as inGetLeastRecentlyUsedArtifacts) plusSELECT DISTINCT ecosystem FROM packages.internal/server/retention.go(new): sweeper modelled onevictLRU, reusingstore.DeleteandClearArtifactCache. Start it fromServer.Startnext tostartEvictionLoop.- Tests:
internal/server/retention_test.gousing the harness fromeviction_test.go; config parsing and validation cases inconfig_test.go. - Docs:
config.example.yaml, storage section indocs/configuration.md, README example config. - Optional in the same PR: a
proxy_artifacts_evicted_total{reason, ecosystem}counter covering both LRU and retention eviction (neither has a metric today).
Notes and edge cases
- Mirrored artifacts. Packages preloaded with
proxy mirrorfor offline use are evicted once they age out, even if never served. Document that the clock starts at mirror time. Apinnedflag would need a schema migration and is out of scope here. - Multiple replicas on PostgreSQL. Concurrent sweeps are safe:
DeleteandClearArtifactCacheare idempotent, matching the existing LRU behaviour. - Key naming. Artifact ecosystem keys differ from metadata-cache keys in a few places (
debvsdebian,ocivsoci-tags/oci-manifest). Retention uses the artifact keys. - Existing bug worth fixing alongside. The comment on
gradle.build_cache.max_agepromises"7d", but validation usestime.ParseDurationand rejects it. Switching to the day-aware parser fixes both.
Out of scope
- Retention for the metadata cache (
_metadata/is never evicted today; separate issue). - Pinning mirrored artifacts.
- Retention based on fetch age instead of last access.
Reactions are currently unavailable
Metadata
Metadata
Assignees
Labels
enhancementNew feature or requestNew feature or request
Activity
pinguinfuss commentedon Sep 3, 2026
@andrew is that something worth implementing?
andrew commentedon Sep 3, 2026
Yeah sounds good!
pinguinfuss commentedon Oct 3, 2026
Hey @andrew,
Before I start, here's how I'd like to build this. I deviate from the issue text in a few places, so I'd like to check with you first.
I'd split it up: one PR with the infrastructure (config, sweeper, queries, an eviction counter, plus the Gradle
"7d"fix), and then one small PR per ecosystem. The first PR doesn't enable retention for any ecosystem, so on its own it changes nothing. Each ecosystem PR registers itself from its handler file and brings tests for its own quirks. Smaller reviews, and if one ecosystem causes trouble it's easy to revert.That means
defaultonly applies to ecosystems that are already enabled. If someone configures one that isn't enabled yet, they get a warning and the rule is ignored. Typos still fail validation. On startup I'd log which ecosystems are covered and which ones aren't yet, so nobody gets surprised when a new release adds one. Without adefault, nothing changes compared to today, andmax_sizestays as it is.I'd also drop the "retention runs before LRU" ordering. A cleared artifact already counts as freed in
GetTotalCacheSize, so the order doesn't buy much. A shared loop would also make LRU wait behind a long first retention sweep.Speaking of long first sweeps: retention would go through
pending_deletesrather than deleting directly, because buffered hits can make an artifact that's being served right now look expired. At 100 deletes per minute, though, an old cache would take days to actually free disk. So I'd add an index onqueued_at, let reclaim work through the queue within a time budget per tick, and cap how many artifacts one sweep clears.Two things I ran into while reading the code:
COALESCE(last_accessed_at, fetched_at) < cutofffrom the issue would evict an artifact again right after it's re-fetched. The clear keeps the oldlast_accessed_at, and a cache miss doesn't record a hit. I'd check both columns separately instead:fetched_at < cutoff AND (last_accessed_at IS NULL OR last_accessed_at < cutoff).library/nginxis stored aspkg:oci/library%2Fnginx, alpine adds thealpinenamespace, and swift is stored aspkg:generic/swift-registry/.... Each ecosystem would convert the configured key to its stored form, and the docs would list the accepted form. Julia gets no package overrides, becausepkg:julia/Xdoesn't parse without auuid.One side note:
proxy mirrorstoresrubygems/packagistas the ecosystem, while the handlers usegem/composer. Retention handles both spellings, but should I open a separate issue to make mirror use the handler names?I'd leave
genericout for now. Release assets can disappear upstream, and then the file would be gone for good.Does that work for you?
andrew commentedon Oct 8, 2026
That all sounds right.
pending_deleteswith aqueued_atindex, a time budget per reclaim tick, and a per-sweep clear cap: yes. That matches how reclaim already works after Give each fetch its own storage path #367.fetched_at < cutoff AND (last_accessed_at IS NULL OR last_accessed_at < cutoff): good catch, use that.genericout: agreed.On the mirror
rubygems/packagistnaming: yes, that's a separate bug, I've opened #415 for it. Retention handling both spellings in the meantime is the right call.The gradle
"7d"parse fix is fine to fold into the infra PR.One thing: for the "ecosystem not enabled yet" case I'd rather fail validation than warn-and-ignore, same as unknown keys. Someone configuring
oci: 14dbefore the oci PR lands should find out at startup, not from a log line they might miss. Happy to be talked out of that if it makes the per-ecosystem rollout awkward.Reject qualifiers and subpaths in retention package keys
Say when a package key doesn't match how packages are stored
Note that re-running the mirror keeps packages cached
Say when an ecosystem takes no package rules
Use helm as the example of an ecosystem without package rules
Say that each cached file ages on its own
Clear an expired artifact and queue its file in one transaction
Show artifact evictions on the analytics page
Store artifact times in UTC
Add age-based retention for cached artifacts (#428)
31 remaining items
Enable storage retention for nuget
Enable storage retention for pub
Enable storage retention for rpm
Enable storage retention for cargo
Enable storage retention for composer
Enable storage retention for conan
Enable storage retention for swift
Fix gem filenames whose platform ends in a number
Store mirrored NuGet ids lowercased
Enable storage retention for hex
Fix hex tarballs whose version contains a hyphen
Enable storage retention for conda
Refuse conda rules with more than a channel and a name
Enable storage retention for deb