From 6310b72aa06e24e5a738b839b74cefccf20cefab Mon Sep 17 00:00:00 2001 From: bong-water-water-bong Date: Thu, 12 Nov 2026 04:26:15 -0400 Subject: [PATCH] q4nx-cache: --dry-run says WHICH entries it would remove, from the same selection as the sweep Found by USING #2251 an hour after it merged: `--dry-run` reported "3 entries, 12 GiB" but not which entries, so the question an operator actually has before deleting another lane's imports -- what am I about to lose? -- was unanswerable. - `q4nx_cache_candidates()` is now the ONE selection: LRU first, `keep` and the age window respected, stop at the bound. `q4nx_cache_evict()` is that list plus a remove loop, so a dry run cannot drift from what a real run does. - `--dry-run` prints the entries (size and name, oldest first) and still removes nothing; exit 1 means "there is work". - sizes now go through one MiB/GiB formatter, so a 6 MiB cache no longer prints "0.0 GiB" -- which reads like "the sweep is off", a different state. 6 new arms (32 total): the list stops at the bound, LRU order, each entry carries its size, listing removes nothing, `--keep` is not a candidate, and the age window protects everything inside it. --- app/q4nx_cache.cpp | 76 ++++++++++++++++++++++++++------------- app/q4nx_cache.h | 12 +++++++ tests/q4nx_cache_test.cpp | 23 +++++++++++- 3 files changed, 86 insertions(+), 25 deletions(-) diff --git a/app/q4nx_cache.cpp b/app/q4nx_cache.cpp index d73bb571b..72d191345 100644 --- a/app/q4nx_cache.cpp +++ b/app/q4nx_cache.cpp @@ -38,40 +38,54 @@ int64_t q4nx_dir_bytes(const std::string& path) { return total; } -Q4nxCacheSweep q4nx_cache_evict(const std::string& root, const std::string& keep, int64_t max_bytes, - int64_t min_age_seconds) { - Q4nxCacheSweep r; +std::vector q4nx_cache_candidates(const std::string& root, const std::string& keep, + int64_t max_bytes, int64_t min_age_seconds) { + std::vector out; std::error_code ec; - if (max_bytes <= 0 || !fs::is_directory(root, ec)) return r; + if (max_bytes <= 0 || !fs::is_directory(root, ec)) return out; struct Ent { std::string path; int64_t bytes; fs::file_time_type mtime; }; std::vector all; for (auto it = fs::directory_iterator(root, fs::directory_options::skip_permission_denied, ec); !ec && it != fs::directory_iterator(); it.increment(ec)) { - std::error_code tec; - if (!it->is_directory(tec)) continue; Ent e; e.path = it->path().string(); - if (!keep.empty() && (e.path == keep || fs::path(e.path).filename() == fs::path(keep).filename())) continue; // never the new one + std::error_code tec; + if (!it->is_directory(tec)) continue; e.bytes = q4nx_dir_bytes(e.path); + if (!keep.empty() && (e.path == keep || fs::path(e.path).filename() == fs::path(keep).filename())) continue; e.mtime = fs::last_write_time(it->path(), tec); if (tec) continue; - r.bytes_before += e.bytes; all.push_back(std::move(e)); } - if (all.empty()) return r; - r.bytes_after = r.bytes_before; + if (all.empty()) return out; const auto now = fs::file_time_type::clock::now(); const auto min_age = std::chrono::seconds(min_age_seconds); - std::vector candidates; // LRU first; the age window protects an entry a serve may be reading + std::vector cands; for (auto& e : all) - if (now - e.mtime > min_age) candidates.push_back(&e); - std::sort(candidates.begin(), candidates.end(), [](const Ent* a, const Ent* b) { return a->mtime < b->mtime; }); - for (Ent* e : candidates) { - if (r.bytes_after <= max_bytes) break; + if (now - e.mtime > min_age) cands.push_back(&e); + std::sort(cands.begin(), cands.end(), [](const Ent* a, const Ent* b) { return a->mtime < b->mtime; }); + int64_t after = 0; + for (const auto& e : all) after += e.bytes; + for (Ent* e : cands) { + if (after <= max_bytes) break; + out.push_back({fs::path(e->path).filename().string(), e->bytes}); + after -= e->bytes; + } + return out; +} + +Q4nxCacheSweep q4nx_cache_evict(const std::string& root, const std::string& keep, int64_t max_bytes, + int64_t min_age_seconds) { + Q4nxCacheSweep r; + r.bytes_before = q4nx_dir_bytes(root); + r.bytes_after = r.bytes_before; + // One selection, shared with `--dry-run`: the list is what this function removes, in this order. + const std::vector cands = q4nx_cache_candidates(root, keep, max_bytes, min_age_seconds); + for (const auto& cand : cands) { std::error_code rec; - fs::remove_all(e->path, rec); + fs::remove_all(fs::path(root) / cand.name, rec); if (rec) continue; - r.bytes_after -= e->bytes; + r.bytes_after -= cand.bytes; ++r.removed; } if (r.removed > 0) { @@ -154,18 +168,32 @@ int run_q4nx_cache(int argc, char** argv) { std::printf("q4nx-cache: %s is %.1f GiB, the sweep is off (bound 0)\n", root.c_str(), double(before) / (1 << 30)); return 0; } + const auto size = [](int64_t b, char* out, size_t n) { + if (b >= (int64_t(1) << 30)) std::snprintf(out, n, "%.1f GiB", double(b) / (1 << 30)); + else std::snprintf(out, n, "%.1f MiB", double(b) / (1 << 20)); + }; if (dry) { - std::printf("q4nx-cache: %s is %.1f GiB against a %.1f GiB bound; --dry-run removes nothing\n", root.c_str(), - double(before) / (1 << 30), double(max_bytes) / (1 << 30)); - return before > max_bytes ? 1 : 0; + const std::vector would = q4nx_cache_candidates(root, keep, max_bytes, min_age); + int64_t would_free = 0; + for (const auto& c : would) would_free += c.bytes; + char b1[32], b2[32], b3[32]; + size(before, b1, sizeof b1); + size(max_bytes, b2, sizeof b2); + size(would_free, b3, sizeof b3); + std::printf("q4nx-cache: %s is %s against a %s bound; --dry-run removes nothing\n", root.c_str(), b1, b2); + if (would.empty()) { + std::printf("q4nx-cache: nothing would be removed (bound met, or every entry is inside the %lld s age window)\n", + (long long)min_age); + return before > max_bytes ? 1 : 0; + } + std::printf("q4nx-cache: would remove %zu entr%s, %s, oldest first:\n", would.size(), + would.size() == 1 ? "y" : "ies", b3); + for (const auto& c : would) std::printf(" %8.1f MiB %s\n", double(c.bytes) / (1 << 20), c.name.c_str()); + return 1; // over the bound: the dry run is telling you there IS work } const Q4nxCacheSweep s = q4nx_cache_evict(root, keep, max_bytes, min_age); // A 6 MiB bound printed as "0.0 GiB" reads like "the sweep is off", which is a different state -- so small sizes // are reported in MiB. This line is the whole output; it has to be unambiguous. - const auto size = [](int64_t b, char* out, size_t n) { - if (b >= (int64_t(1) << 30)) std::snprintf(out, n, "%.1f GiB", double(b) / (1 << 30)); - else std::snprintf(out, n, "%.1f MiB", double(b) / (1 << 20)); - }; char before_s[32], after_s[32], bound_s[32]; size(s.bytes_before, before_s, sizeof before_s); size(s.bytes_after, after_s, sizeof after_s); diff --git a/app/q4nx_cache.h b/app/q4nx_cache.h index bc211a3f7..7fb177741 100644 --- a/app/q4nx_cache.h +++ b/app/q4nx_cache.h @@ -17,6 +17,7 @@ #include #include +#include // The Q4NX container cache (engine#1956): app/serve.cpp repacks a GGUF into `$ONEBIT_Q4NX_CACHE` (default // ~/.cache/1bit/q4nx) and reuses the result, and until this existed nothing ever removed an old entry. Measured on strixhalo @@ -42,6 +43,17 @@ struct Q4nxCacheSweep { Q4nxCacheSweep q4nx_cache_evict(const std::string& root, const std::string& keep, int64_t max_bytes, int64_t min_age_seconds = 600); +// engine#1788 follow-up, from USING the command: `--dry-run` reported totals only, so an operator could see "3 entries, 12 GiB" +// but not WHICH entries -- which is the question before deleting another lane's imports. This is the same selection +// q4nx_cache_evict() makes (LRU first, `keep` and the age window respected, stop at max_bytes), returned instead of applied, +// so the two cannot drift: evict() is now this list plus a remove loop. +struct Q4nxCacheCandidate { + std::string name; // the entry directory's name, as it appears under the root + int64_t bytes = 0; +}; +std::vector q4nx_cache_candidates(const std::string& root, const std::string& keep, int64_t max_bytes, + int64_t min_age_seconds = 600); + // Directory bytes under `path` (0 when it does not exist). int64_t q4nx_dir_bytes(const std::string& path); diff --git a/tests/q4nx_cache_test.cpp b/tests/q4nx_cache_test.cpp index fe3c53403..f72a03b6c 100644 --- a/tests/q4nx_cache_test.cpp +++ b/tests/q4nx_cache_test.cpp @@ -161,8 +161,29 @@ int main() { } fs::remove_all(cr, ec); } + // engine#1788 follow-up, from USING the command: --dry-run now lists WHICH entries it would remove, and it lists them + // from the same selection the sweep applies, so the two cannot drift. + { + const fs::path cr = root / "cand"; + make_entry(cr, "c-old", 2, 500); + make_entry(cr, "c-mid", 2, 400); + make_entry(cr, "c-new", 2, 300); + const int64_t bound = 3 * 1024 * 1024; // 6 MiB of entries against a 3 MiB bound: two must go + const std::vector c1 = q4nx_cache_candidates(cr.string(), "", bound, 0); + check(c1.size() == 2, "q4nx-cache candidates: the list stops at the bound"); + check(c1[0].name == "c-old" && c1[1].name == "c-mid", "q4nx-cache candidates: least-recently-used first"); + check(c1[0].bytes == 2 * 1024 * 1024, "q4nx-cache candidates: each entry carries its size"); + check(fs::exists(cr / "c-old") && fs::exists(cr / "c-mid") && fs::exists(cr / "c-new"), + "q4nx-cache candidates: nothing is removed by listing"); + const std::vector c2 = q4nx_cache_candidates(cr.string(), "c-old", bound, 0); + // with c-old kept out of the total, 4 MiB remains against a 3 MiB bound: exactly one entry goes + check(c2.size() == 1 && c2[0].name == "c-mid", "q4nx-cache candidates: --keep is not a candidate"); + check(q4nx_cache_candidates(cr.string(), "", bound, 100000).empty(), + "q4nx-cache candidates: the age window protects everything inside it"); + fs::remove_all(cr, ec); + } fs::remove_all(root, ec); std::printf("q4nx cache: %d arm(s) over the sweep (bound, LRU order, keep, age window, disabled, touch on a hit, bound text)\n", - failures ? 0 : 26); + failures ? 0 : 32); return failures ? 1 : 0; }