Repository navigation
Conversation
Alpine mirrors keep only the current build of each package, so once a
package is upgraded `apk add curl=8.5.0-r0` fails: the current index no
longer lists the old build, even when the proxy still has it cached.
With upstream.apk_snapshots set (e.g. "90d"), each version of an index or
detached signature the metadata cache stores is copied to
_snapshots/alpine/ and recorded in a new metadata_snapshots table with the
time it was first served. /apk/{name}@{YYYY-MM-DD}/ serves the version that
was current at the end of that day; packages below it are the plain path's
requests. The kept bytes are the signed upstream bytes, so apk verifies
them unchanged.
An hourly job deletes versions replaced longer ago than the configured
duration and queues their blobs on the pending-delete queue; the newest
version of each index stays. Packages listed by kept snapshots are not
protected from eviction or retention; the docs say so.
andrew
left a comment
There was a problem hiding this comment.
Thanks, this looks good overall. One race needs fixing before merge, plus a few smaller things.
internal/handler/apk_snapshots.go: two requests can record the same index version into the same storage path, and the losing one deletes the blob the winner just recorded. The path is {cacheKey}/{ms}-{digest}. When coalesced fetches after an upstream index update finish together, both can call recordSnapshot in the same millisecond with the same digest. Both Store to that one path, one AddMetadataSnapshot inserts, the other gets added=false and discardSnapshotBlob removes the path the inserted row points at. That day's dated path then returns 500 "snapshot unavailable", and since it is the newest snapshot it is never pruned while it stays current. Making the path unique per attempt (e.g. a random suffix in apkSnapshotStoragePath) avoids it. Please add a test where two recorders collide on the path and the recorded blob is still readable afterwards.
The branch conflicts with main in internal/config/config_test.go, where main added TestCooldownShortest next to your new tests. Keeping both sides resolves it.
Smaller items:
- Some comments restate the PR description. The file-level block at the top of
internal/handler/apk_snapshots.gocan go or shrink to a line. The unattached block near the top ofinternal/database/metadata_snapshots.goshould fold into theMetadataSnapshotdoc comment. The comment on thesnapshotsfield inapk.gorepeatsWithSnapshots. internal/server/apk_snapshots.go: ifapk_snapshotsis turned off after being used, the prune loop exits straight away and the existingmetadata_snapshotsrows and_snapshots/blobs stay forever. Either prune everything when it's disabled and rows exist, or note in the docs that disabling doesn't clean up.
# Conflicts: # internal/config/config_test.go
Two requests recording the same index version in the same millisecond stored to one path, and the one whose row was not added deleted the blob the other's row points at. The path now ends in a random suffix. Also trims comments that restated the PR, notes in the docs that turning snapshots off leaves them in place, and moves the git cache setup out of serve, which lint found too long after the merge.
|
Fixed in 77525b0. Each recording now stores to a path with a random suffix, so the request whose row isn't added only deletes its own copy.
|
andrew
left a comment
There was a problem hiding this comment.
The random suffix fixes the blob deletion race from my earlier review. There is still a PostgreSQL deduplication race in AddMetadataSnapshot in internal/database/metadata_snapshots.go: putting the check and insert in one INSERT ... SELECT statement does not serialize concurrent callers. Both statements can read the previous digest and insert the same new digest, retaining separate blob copies until pruning.
I reproduced this against local PostgreSQL using the PR's table definition and insert query: one connection inserted without committing, a second connection ran the same insert successfully, and committing both left two rows for the same index and digest. TestAPKSnapshots_SameVersionRecordedTwice calls the recorders sequentially, so it does not cover this case.
Please serialize the latest-digest check and insert per ecosystem/name across replicas, preserving the ability to record a rollback to an earlier digest. Add a concurrent PostgreSQL regression test that verifies one snapshot row remains and its blob is readable.
AddMetadataSnapshot takes a transaction-scoped advisory lock on the document before its check and insert, so two replicas recording the same version can't both read the previous digest and both insert. SQLite runs one write at a time, so it keeps the single statement.
|
Fixed in ddba210.
Main is merged in. |
andrew
left a comment
There was a problem hiding this comment.
internal/handler/apk.go:172-174 records a snapshot only after ProxyCached finishes writing the response, and recordSnapshot in internal/handler/apk_snapshots.go:109 then rereads the mutable metadata cache row. A concurrent refresh can replace that row and blob before the first request records its version.
I reproduced this through the APK handler with metadata caching and snapshots enabled and metadata_ttl set to 0: pause the first response while it writes index v1, change upstream to v2, let a second request finish, then resume the first response. Both requests return 200 with their respective index bytes, but only one snapshot row remains, for v2. The first request rereads v2 and skips recording because that digest already exists. The v1 history is permanently lost.
Please preserve the exact document and its observation time before a concurrent refresh can replace it, rather than reconstructing the snapshot from the cache after writing the response. Add an overlapping-request regression test that verifies both served versions are retained with readable snapshot blobs.
A snapshot was recorded after the response by reading the metadata cache again, so a refresh that replaced the cached copy in between lost the version the request served. Record it from the served bytes before responding instead, and compare a new snapshot with the version current at its own time, so one recorded late keeps its place in the history.
|
Fixed in 04f732e. The snapshot is now recorded from the bytes the request serves, before the response is written, with the time they were fetched, instead of rereading the cache row afterwards. A cache hit records the row it read before serving, after checking its digest.
|
andrew
left a comment
There was a problem hiding this comment.
The overlapping-request fix works, and TestAPKSnapshots_OverlappingRequests covers it. One gap from this round, plus a conflict with main.
internal/handler/apk_snapshots.go:121-127: the cache-hit branch (freshMetadataEntry, then serveCachedMetadata, then recordStoredSnapshot) has no test. Every handler test builds its handler through newSnapshotAPKHandler, which sets MetadataTTL = 0, so this branch never runs. With the default metadata_ttl of 5m it is the path most index requests take in production. Please add a handler test with a TTL above 0: store a cached index row before snapshots are enabled, make one request through the plain path, and check that a snapshot row is added and its blob reads back. Also check that a hit on a version already recorded adds no row.
The branch conflicts with main in internal/server/server.go. Main now calls startRetentionLoop(bgCtx, retentionResolvers). Keep that line and add the startAPKSnapshotPruning goroutine after it.
Smaller items:
serveMetadataWithSnapshot(apk_snapshots.go:113-136) copies the body ofproxyCachedWithEncoding(handler.go:2156-2174) with recording added, so later changes toProxyCachedwould miss APK indexes when snapshots are on. Please giveproxyCachedWithEncodingoptional hooks for a cache hit and a fetched body, and call it from the APK handler instead of keeping a copy.- The comments added this round are longer than they need to be. The
AddMetadataSnapshotdoc (metadata_snapshots.go:63-74) can come down to the advisory-lock reason and the casts note, since the "current at that time" and rollback sentences say the same thing. TheserveMetadataWithSnapshotdoc (apk_snapshots.go:108-112) describes the history of the fix, and one line is enough. The inline comments atapk_snapshots.go:118-120and132-133repeat the function docs and can go.
# Conflicts: # internal/server/server.go
…ncoding TestAPKSnapshots_CacheHit covers the cache-hit branch with a metadata TTL above 0. proxyCachedWithEncoding takes optional hooks for a cache hit and a fetched body, so the APK handler no longer keeps a copy of it.
|
Fixed in 143eef7, with main merged in 7b91897.
|
Alpine mirrors keep only the current build of each package. After an upgrade,
apk add curl=8.5.0-r0fails even if the proxy still has the old.apkcached, because the current index no longer lists it. This PR lets the proxy keep earlier index versions and serve them by date, so pinned installs keep working.Opt-in with
upstream.apk_snapshots: "90d"(PROXY_UPSTREAM_APK_SNAPSHOTS). Clients then use a dated repository name, e.g.http://proxy/apk/alpine@2026-10-01/v3.22/mainin/etc/apk/repositories./apk/{name}@{YYYY-MM-DD}/…returns the index (or detached signature) that was current at the end of that day (UTC). Days before the first stored version, and days that haven't started yet, return 404..apkand other files under a snapshot path are handled exactly like the plain path, so they hit the same cached artifacts. A configured repository name that contains@takes precedence.How it works: indexes keep going through
ProxyCachedunchanged. Afterwards, the APK handler reads the metadata cache row. If its digest differs from the newest snapshot, the stored blob is copied to_snapshots/alpine/{key}/{ms}-{digest}, the hash is checked against the row's digest, and a row is added to a newmetadata_snapshotstable (migration 020) withvalid_from_ms. The check and the insert are a single statement, so concurrent requests add one row. The bytes are the signed upstream bytes, so nothing is re-signed. An hourly job (runExclusive) deletes versions that were replaced longer ago than the configured duration. It queues their blobs onpending_deletes, and the existing reclaim loop removes them. The newest version of each index is always kept.GetDuePendingDeletesnow also skips paths referenced bymetadata_snapshots.Tested with handler tests using an httptest upstream: two versions served by date, before-first/future/invalid date, unknown repository, a package under a snapshot path shares the cache entry, 304 on
If-None-Match, snapshots disabled, and configured name precedence. There are database tests for add/dedup/rollback/lookup and for pruning (including the pending-delete queue), and config tests. A server test wires the config throughmountProtocolHandlerswith real file storage, to check that the storage hash matches the cached digest.Open questions:
max_sizeeviction oralpineretention. The docs say so and suggest package rules with"0". Protecting them would mean parsing each kept APKINDEX/Packages.adb and feeding the referenced files to the retention sweep. That could be a follow-up.storage.max_size. They are bounded by the duration and by the number of indexes clients use.name@date. An alternative is/apk/{name}/snapshot/{date}/…, which would collide with upstream path segments.INSERT … SELECT), but I could only run it against SQLite locally.