From a5c8fc28a401072534bcf095ef06b6e85a400e78 Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Wed, 6 May 2026 11:42:18 -0600 Subject: [PATCH] fix(builds-cacher): coalesce concurrent getPackages for same name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When two HTTP requests arrived simultaneously for the same package on a cold in-memory cache (bc._caches[name] === undefined), they would both: 1. Enter getPackages, see no warm cache, 2. Read and parse the same _cache/{pkg}.json independently, 3. Both call transformAndUpdate, which re-runs _classify on every build. The first call populates bc._targetsByBuildIdCache as it classifies. The second call then hits the cache shortcut at the top of _classify and skips the projInfo.oses/arches/libcs/formats/triplets accumulation block entirely. Its projInfo ends up with empty tracking arrays (because the prior Object.assign(projInfo, meta) reset them), and that poisoned projInfo gets written to bc._caches[name], overwriting the first call's good cache. After this, every subsequent installer request returns errPackage because serve-installer.js checks projInfo.oses.includes(hostTarget.os) — and projInfo.oses is now []. Fix: a per-name in-flight promise. Concurrent callers for a cold package share a single load. Calls for warm packages take the fast path with no synchronization. Reproduced reliably with Promise.all of 6 cold-cache calls for the same package: 1/6 succeeded before the fix, 6/6 after. On staging at HTTP concurrency=12, installer cand-only-errors went from 24-229 (cause-dependent) to 0. --- _webi/builds-cacher.js | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/_webi/builds-cacher.js b/_webi/builds-cacher.js index b2780e6..a0c8da1 100644 --- a/_webi/builds-cacher.js +++ b/_webi/builds-cacher.js @@ -198,6 +198,9 @@ BuildsCacher.create = function ({ ALL_TERMS, installers, caches }) { bc._staleAge = 15 * 60 * 1000; bc._allFormats = {}; bc._allTriplets = {}; + // Per-name lock: serializes cold-cache getPackages so concurrent + // callers can't corrupt bc._caches[name] via a transformAndUpdate race. + bc._inflight = {}; for (let term of TERMS_META) { delete bc.orphanTerms[term]; @@ -317,7 +320,24 @@ BuildsCacher.create = function ({ ALL_TERMS, installers, caches }) { // Typically a package is organized by release (ex: go has 1.20, 1.21, etc), // but we will organize by the build (ex: go1.20-darwin-arm64.tar.gz, etc). - bc.getPackages = async function ({ Releases, name, date }) { + bc.getPackages = async function (args) { + let name = args.name; + let warm = bc._caches[name]; + if (warm) { + return _doGetPackages(args); + } + let inflight = bc._inflight[name]; + if (inflight) { + return inflight; + } + let p = _doGetPackages(args).finally(function () { + delete bc._inflight[name]; + }); + bc._inflight[name] = p; + return p; + }; + + async function _doGetPackages({ Releases, name, date }) { if (!date) { date = new Date(); } @@ -410,7 +430,7 @@ BuildsCacher.create = function ({ ALL_TERMS, installers, caches }) { }); return projInfo; - }; + } // Makes sure that packages are updated once an hour, on average bc._staleNames = [];