feat: add check_mutable_updates flag for conditional upstream revalidation

When check_mutable_updates: true is set on a remote, expired user-defined
mutable files are revalidated before re-downloading:

- On expiry a conditional HEAD is sent with If-None-Match / If-Modified-Since
- 304 Not Modified: TTL is refreshed in Redis, S3 cache is untouched
- 200 / no conditional support: cache is invalidated and file re-downloaded
- Network error: safe fallback — assume changed, re-download

ETag and Last-Modified from upstream responses are stored in Redis under
mutable:meta:<remote>:<hash> (no expiry, cleaned up on re-download or
cache flush). The flag only applies to user-configured mutable_patterns;
built-in package-type defaults (APKINDEX, repomd.xml, Docker manifests)
are always re-fetched unconditionally.

cache/flush also clears mutable:meta:* keys alongside index:* keys.
This commit is contained in:
2026-04-27 01:00:00 +10:00
parent 8bc9285117
commit 8fe4bac2b9
8 changed files with 265 additions and 16 deletions
+49 -2
View File
@@ -419,6 +419,53 @@ class TestGenericArtifactRoute:
response = client.get("/api/v1/remote/alpine-test/alpine/v3.18/x86_64/APKINDEX.tar.gz")
assert response.status_code == 200
def test_mutable_unchanged_refreshes_ttl_without_redownload(self, client, patched_deps):
"""When check_mutable_updates=True and upstream says 304, TTL is refreshed in place."""
deps = patched_deps
deps["storage"].exists.return_value = True
deps["storage"].download_object.return_value = b"metadata content"
# File is mutable and its TTL has expired
deps["cache"].is_mutable_file.return_value = True
deps["cache"].is_index_valid.return_value = False
deps["cache"].get_mutable_meta.return_value = {"etag": '"abc"'}
with patch("artifactapi.main.check_upstream_changed", new_callable=AsyncMock, return_value=False):
response = client.get("/api/v1/remote/check-mutable-test/metadata.json")
assert response.status_code == 200
deps["cache"].mark_index_cached.assert_called()
# S3 object must NOT have been deleted (no re-download)
deps["storage"].client.delete_object.assert_not_called()
def test_mutable_changed_triggers_redownload(self, client, patched_deps):
"""When check_mutable_updates=True and upstream says 200, cache is invalidated."""
deps = patched_deps
deps["storage"].exists.return_value = False
deps["cache"].is_mutable_file.return_value = True
deps["cache"].is_index_valid.return_value = False
deps["cache"].get_mutable_meta.return_value = {"etag": '"abc"'}
with patch("artifactapi.main.check_upstream_changed", new_callable=AsyncMock, return_value=True):
with patch("artifactapi.main.cache_single_artifact", new_callable=AsyncMock) as mock_cache:
mock_cache.return_value = {"status": "error", "error": "upstream gone"}
response = client.get("/api/v1/remote/check-mutable-test/metadata.json")
assert response.status_code == 502
def test_mutable_flag_off_skips_conditional_check(self, client, patched_deps):
"""When check_mutable_updates is not set, expired mutable files are always re-fetched."""
deps = patched_deps
deps["storage"].exists.return_value = False
deps["cache"].is_mutable_file.return_value = True
deps["cache"].is_index_valid.return_value = False
with patch("artifactapi.main.check_upstream_changed", new_callable=AsyncMock) as mock_check:
with patch("artifactapi.main.cache_single_artifact", new_callable=AsyncMock) as mock_cache:
mock_cache.return_value = {"status": "error", "error": "upstream gone"}
client.get("/api/v1/remote/custom-index-test/metadata.json")
mock_check.assert_not_called()
def test_local_repo_file_not_found_returns_404(self, client, patched_deps):
deps = patched_deps
deps["database"].get_local_file_metadata.return_value = None
@@ -519,8 +566,8 @@ class TestCacheFlushEndpoint:
deps["cache"].available = True
redis_mock = MagicMock()
deps["cache"].client = redis_mock
# First pattern (index:*) returns keys; subsequent pattern returns nothing
redis_mock.keys.side_effect = [["index:test:abc", "index:test:def"], []]
# index:* returns keys; mutable:meta:* and metrics:* return nothing
redis_mock.keys.side_effect = [["index:test:abc", "index:test:def"], [], []]
deps["storage"].client.list_objects_v2.return_value = {}
response = client.put("/cache/flush")