libstore/binary-cache-store: don't cache narinfo on nix copy, remove negative entry

Snix's nar-bridge[1] stores NARs under a different URL, i.e.
`nar/snix-castore/<hash>.nar` rather than `nar/<filehash>.nar`. Right
now, when copying into such a store via the http binary-cache, we'd end
up with wrong cache entries that point to the wrong NAR URL.

On Hydra, this is a fatal error, i.e. builds that depend on previously
built paths (that were written to the cache before by the queue runner)
would be aborted because of that.

This patch removes the caching since we'd have to re-fetch the narinfo
to do taht and this can also happen the next time, the narinfo is
queried. Also, removes the negative cache entry indicating that the
store-path doesn't exist in the store.

We don't have any coverage for http-stores so far, so I wrote a small
testcase for the "default" case and the nar-bridge case in functional2
since it has a very nice fixture for an HTTP server ready. I'm aware
that there's a CL for a nicer cache server[2], but given I'm down a
pretty deep rabbit hole by playing around with Snix, I decided to not
finish the CL and write something small for the tests in here. This can
be replaced by the fixtures from that CL later on as well.

[1] https://snix.dev/docs/components/overview/#nar-bridge
[2] https://gerrit.lix.systems/c/lix/+/2431/1

Change-Id: I4fcdf47a6bf9c3c8fbeb235eeca7a48914a4d693
This commit is contained in:
Maximilian Bosch
2025-08-01 12:44:28 +02:00
parent d5cfc6f19c
commit f077a6f36e
6 changed files with 191 additions and 7 deletions
@@ -0,0 +1,17 @@
---
synopsis: "libstore/binary-cache-store: don't cache narinfo on nix copy, remove negative entry"
issues: []
cls: [3789]
category: Fixes
credits: [ma27]
---
When using e.g. [Snix's nar-bridge](https://snix.dev/docs/components/overview/#nar-bridge) via
an `http`-store, Lix would create cache entries with a wrong URL to the NAR when uploading
a store-path.
This caused hard build failures for Hydra.
Lix doesn't create these entries on upload anymore. Instead, it only removes negative cache entries.
The cache entry for a narinfo is now created the first time, Lix queries the cache
for the previously uploaded store-path again.
+4 -5
View File
@@ -108,13 +108,12 @@ try {
{
auto state_(co_await state.lock());
state_->pathInfoCache.upsert(
std::string(narInfo->path.to_string()),
PathInfoCacheValue { .value = std::shared_ptr<NarInfo>(narInfo) });
state_->pathInfoCache.erase(std::string(narInfo->path.to_string()));
}
if (diskCache)
diskCache->upsertNarInfo(getUri(), std::string(narInfo->path.hashPart()), std::shared_ptr<NarInfo>(narInfo));
if (diskCache) {
diskCache->removeNegativeCacheEntry(getUri(), std::string(narInfo->path.hashPart()));
}
co_return result::success();
} catch (...) {
co_return result::current_exception();
+17 -2
View File
@@ -71,8 +71,8 @@ public:
struct State
{
SQLite db;
SQLiteStmt insertCache, queryCache, insertNAR, insertMissingNAR,
queryNAR, purgeCache;
SQLiteStmt insertCache, queryCache, insertNAR, insertMissingNAR, queryNAR, purgeCache,
removeNegativeCacheEntry;
std::map<std::string, Cache> caches;
};
@@ -106,6 +106,9 @@ public:
state->queryNAR = state->db.create(
"select present, namePart, url, compression, fileHash, fileSize, narHash, narSize, refs, deriver, sigs, ca from NARs where cache = ? and hashPart = ? and ((present = 0 and timestamp > ?) or (present = 1 and timestamp > ?))");
state->removeNegativeCacheEntry =
state->db.create("delete from NARs where present = 0 and hashPart = ? and cache = ?");
/* Periodically purge expired entries from the database. */
retrySQLite([&]() {
auto now = time(0);
@@ -252,6 +255,18 @@ public:
}, always_progresses);
}
void removeNegativeCacheEntry(const std::string & uri, const std::string & hashPart) override
{
retrySQLite(
[&]() {
auto state(_state.lock());
auto & cache(getCache(*state, uri));
state->removeNegativeCacheEntry.use()(hashPart)(cache.id).exec();
},
always_progresses
);
}
void upsertNarInfo(
const std::string & uri, const std::string & hashPart,
std::shared_ptr<const ValidPathInfo> info) override
+3
View File
@@ -32,6 +32,9 @@ public:
virtual void upsertNarInfo(
const std::string & uri, const std::string & hashPart,
std::shared_ptr<const ValidPathInfo> info) = 0;
virtual void
removeNegativeCacheEntry(const std::string & uri, const std::string & hashPart) = 0;
};
/**
+149
View File
@@ -0,0 +1,149 @@
from pathlib import Path
import sqlite3
import aiohttp.web as web
import pytest
from functional2.testlib.fixtures.file_helper import File
from functional2.testlib.fixtures.http_server import http_server
from functional2.testlib.fixtures.nix import Nix
class HTTPStore:
def __init__(self):
self.uploaded_nars = {}
self.known_nar_hashes = set()
async def upload_narinfo(self, req: web.Request) -> web.Response:
self.uploaded_nars[req.match_info["hash"]] = self._parse_narinfo(await req.text())
return web.Response(text="")
async def upload_nar(self, req: web.Request) -> web.Response:
self.known_nar_hashes.add(req.match_info["narhash"])
return web.Response(text="")
async def nix_cache_info(self, _: web.Request) -> web.Response:
return web.Response(text="StoreDir: /nix/store")
async def get_narinfo(self, req: web.Request) -> web.Response:
narinfo_hash = req.match_info["hash"]
if narinfo_hash not in self.uploaded_nars:
return web.Response(text="", status=404)
return web.Response(
text="\n".join(f"{k}: {v}" for k, v in self.uploaded_nars[narinfo_hash].items()) + "\n"
)
async def nar_head(self, req: web.Request) -> web.Response:
return web.Response(
text="", status=200 if req.match_info["narhash"] in self.known_nar_hashes else 404
)
def _parse_narinfo(self, text: str) -> dict[str, str]:
narinfo = {}
for line in text.splitlines():
key, value = line.split(": ", 1)
narinfo[key] = value
_, hashpart = narinfo["FileHash"].split(":", 1)
assert hashpart in self.known_nar_hashes
return narinfo
class FakeNARBridge(HTTPStore):
"""
HTTP Store that mutates the URL field of the narinfo, just like
nar-bridge from snix.dev.
Testcase to ensure that the correct URL to the nar (i.e. nar/snix-castore/...)
ends up in the disk-cache.
"""
async def upload_narinfo(self, req: web.Request) -> web.Response:
narinfo = self._parse_narinfo(await req.text())
narinfo["URL"] = (
f"nar/snix-castore/00000000000000000000000000000000000000000000000000000?narsize=f{narinfo['FileSize']}"
)
self.uploaded_nars[req.match_info["hash"]] = narinfo
return web.Response(text="")
@pytest.fixture(params=[HTTPStore, FakeNARBridge])
def store(request: pytest.FixtureRequest) -> HTTPStore:
store_class = request.param
return store_class()
def start_server(store: HTTPStore) -> web.Application:
app = web.Application()
app.add_routes(
[
web.put("/{hash}.narinfo", store.upload_narinfo),
web.get("/{hash}.narinfo", store.get_narinfo),
web.put("/nar/{narhash}.nar", store.upload_nar),
web.get("/nix-cache-info", store.nix_cache_info),
web.head("/nar/{narhash}.nar", store.nar_head),
]
)
return app
def nars_from_narinfo_cache(db_path: Path) -> list[dict[str, str | bool]]:
assert db_path.exists()
db = sqlite3.connect(db_path)
rows = db.execute(
"SELECT n.present, n.hashPart, n.namePart, n.url FROM NARs n INNER JOIN BinaryCaches b ON n.cache = b.id WHERE b.url LIKE '%localhost%'"
)
return [
{"present": bool(present), "hashPart": hashPart, "namePart": namePart, "url": url}
for present, hashPart, namePart, url in rows
]
@pytest.mark.parametrize("files", [{"test-file": File("hello world")}], indirect=True)
def test_http_simple(nix: Nix, store: HTTPStore, files: Path):
test_file = files / "test-file"
result = nix.nix(cmd=["store", "add-file", test_file], flake=True).run()
result.ok()
store_path = result.stdout_plain
hash_part, _ = Path(store_path).stem.split("-", 1)
nar_info_cache = nix.test_root / "test-home" / ".cache" / "nix" / "binary-cache-v6.sqlite"
app = start_server(store)
with http_server(app) as httpd:
url = f"http://localhost:{httpd.port}?compression=none"
nix.nix(cmd=["store", "ping", "--store", url], flake=True).run().ok()
# Narinfo shouldn't exist yet
nix.nix(
cmd=["path-info", "--store", f"http://localhost:{httpd.port}", store_path], flake=True
).run().expect(1)
cache_entries = nars_from_narinfo_cache(nar_info_cache)
assert len(cache_entries) == 1
assert not cache_entries[0]["present"]
assert cache_entries[0]["hashPart"] == hash_part
# Successful upload
nix.nix(
cmd=["copy", "--from", nix.test_root / "store", "--to", url, store_path], flake=True
).run().ok()
assert hash_part in store.uploaded_nars
# Make sure the negative entry got removed
assert not nars_from_narinfo_cache(nar_info_cache)
# Ensure that the narinfo can be found now.
nix.nix(
cmd=["path-info", "--store", f"http://localhost:{httpd.port}", store_path], flake=True
).run().ok()
# Ensure local narinfo cache is up-to-date.
nar_entries = nars_from_narinfo_cache(nar_info_cache)
assert len(nar_entries) == 1
assert nar_entries[0]["present"]
assert nar_entries[0]["hashPart"] == hash_part
assert nar_entries[0]["namePart"] == "test-file"
assert nar_entries[0]["url"] == store.uploaded_nars[hash_part]["URL"]
@@ -104,6 +104,7 @@ class Nix:
"NIX_DAEMON_SOCKET_PATH": self.test_root / "daemon-socket",
"NIX_USER_CONF_FILES": "",
"HOME": home,
"XDG_CACHE_HOME": home / ".cache",
}
def make_env(self) -> dict[AnyStr, AnyStr]: