feat(cache): add recipe cache with index-version-based staleness check
This commit is contained in:
@@ -0,0 +1,435 @@
|
||||
/// tofu.cache — Recipe cache with index-version-based invalidation.
|
||||
///
|
||||
/// Cached recipes get a small JSON manifest (`.tofu-cache.json`) recording
|
||||
/// the index version that was current at fetch time. Callers compare the
|
||||
/// stored version against the current index version to decide whether the
|
||||
/// recipe needs re-fetching (stale).
|
||||
///
|
||||
/// All public API is `@safe`. Filesystem and JSON parsing operations are
|
||||
/// isolated in small `@trusted` helpers following the project convention
|
||||
/// established in `tofu.config`, `tofu.http`, and `tofu.fetch`.
|
||||
///
|
||||
/// Atomic writes: the manifest is written to a `.tmp` file then renamed,
|
||||
/// preventing interrupted writes from producing a corrupted manifest.
|
||||
///
|
||||
/// Directory removal: `rmdirRecurse` (std.file, available since D 2.104)
|
||||
/// is used for recursive directory cleanup. On DMD 2.112 this is present.
|
||||
module tofu.cache;
|
||||
|
||||
import tofu.types : CacheManifest;
|
||||
import tofu.config : Config, load;
|
||||
import tofu.vercmp : compare;
|
||||
import tofu.log : logWarn;
|
||||
|
||||
import std.file;
|
||||
import std.path : buildPath;
|
||||
import std.json : JSONValue, JSONType, parseJSON;
|
||||
import std.datetime : Clock;
|
||||
import std.conv : to;
|
||||
import std.format : format;
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// Exception
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
/// Thrown on fatal cache errors. Corrupted caches are handled
|
||||
/// gracefully by returning stale=true — this exception is reserved
|
||||
/// for truly unrecoverable cases.
|
||||
class CacheException : Exception
|
||||
{
|
||||
@safe this(string msg)
|
||||
{
|
||||
super(msg);
|
||||
}
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// Trusted filesystem / JSON helpers
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
@trusted
|
||||
bool fExists(string p) { return exists(p); }
|
||||
@trusted
|
||||
void fMkdirRecurse(string p) { mkdirRecurse(p); }
|
||||
@trusted
|
||||
void fRemove(string p) { remove(p); }
|
||||
@trusted
|
||||
void fRmdirRecurse(string p) { rmdirRecurse(p); }
|
||||
@trusted
|
||||
void fRename(string from, string to) { rename(from, to); }
|
||||
@trusted
|
||||
void fWrite(string p, string c) { write(p, c); }
|
||||
@trusted
|
||||
string fReadText(string p) { return readText(p); }
|
||||
@trusted
|
||||
JSONValue fParseJSON(string c) { return parseJSON(c); }
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// cacheRecipe — write (or update) the recipe cache manifest
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
/// Store a cache manifest for `name` recording the current `indexVersion`.
|
||||
///
|
||||
/// The manifest is written atomically: first to a `.tmp` file, then
|
||||
/// renamed to `.tofu-cache.json`. This prevents corrupted manifests
|
||||
/// from interrupted writes.
|
||||
///
|
||||
/// Params:
|
||||
/// name = package name
|
||||
/// indexVersion = version string from the current index (e.g. "2.1.0")
|
||||
/// cfg = resolved configuration providing cache-directory paths
|
||||
void cacheRecipe(string name, string indexVersion, Config cfg) @safe
|
||||
{
|
||||
auto dir = cfg.recipesCacheDir(name);
|
||||
auto manifestFile = buildPath(dir, ".tofu-cache.json");
|
||||
auto tmpFile = manifestFile ~ ".tmp";
|
||||
|
||||
if (!fExists(dir))
|
||||
fMkdirRecurse(dir);
|
||||
|
||||
long fetchedAt = Clock.currTime().toUnixTime();
|
||||
|
||||
// Build a JSON object manually via formatted string rather than
|
||||
// relying on std.json serializers (which have API variations across
|
||||
// Phobos versions). Three-field object: name, ver, fetchedAt.
|
||||
// Package names and version strings are simple identifiers that
|
||||
// do not require JSON escaping.
|
||||
auto jsonStr = format(
|
||||
q"EOS
|
||||
{"name":"%s","ver":"%s","fetchedAt":%d}
|
||||
EOS", name, indexVersion, fetchedAt);
|
||||
|
||||
fWrite(tmpFile, jsonStr);
|
||||
|
||||
if (fExists(manifestFile))
|
||||
fRemove(manifestFile);
|
||||
fRename(tmpFile, manifestFile);
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// isRecipeStale — check whether the recipe needs re-fetching
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
/// Returns `true` when the recipe cache for `name` is missing,
|
||||
/// corrupted, or its stored index version differs from `indexVersion`.
|
||||
///
|
||||
/// Version comparison uses `tofu.vercmp.compare` (RPM-style semantic
|
||||
/// comparison) rather than exact string matching. Never throws —
|
||||
/// read failures and parse errors are logged via `logWarn` and
|
||||
/// treated as stale.
|
||||
///
|
||||
/// Params:
|
||||
/// name = package name
|
||||
/// indexVersion = current index version to compare against
|
||||
/// cfg = resolved configuration
|
||||
///
|
||||
/// Returns:
|
||||
/// `true` if the recipe should be re-fetched, `false` if the cache
|
||||
/// is current.
|
||||
bool isRecipeStale(string name, string indexVersion, Config cfg) @safe
|
||||
{
|
||||
auto manifestFile = buildPath(
|
||||
cfg.recipesCacheDir(name), ".tofu-cache.json");
|
||||
|
||||
if (!fExists(manifestFile))
|
||||
return true;
|
||||
|
||||
string content;
|
||||
try
|
||||
{
|
||||
content = fReadText(manifestFile);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
logWarn("cannot read cache for %s: %s", name, e.msg);
|
||||
return true;
|
||||
}
|
||||
|
||||
string cachedVer;
|
||||
try
|
||||
{
|
||||
auto jv = fParseJSON(content);
|
||||
cachedVer = jv["ver"].str;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
logWarn("corrupted cache for %s: %s", name, e.msg);
|
||||
return true;
|
||||
}
|
||||
|
||||
return compare(cachedVer, indexVersion) != 0;
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// Cleanup helpers
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
/// Remove the recipe cache directory for `name` (and all cached files).
|
||||
///
|
||||
/// Safe to call when the directory does not exist — silently returns.
|
||||
void cleanRecipeCache(string name, Config cfg) @safe
|
||||
{
|
||||
auto dir = cfg.recipesCacheDir(name);
|
||||
if (fExists(dir))
|
||||
{
|
||||
try
|
||||
{
|
||||
fRmdirRecurse(dir);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
logWarn("failed to clean recipe cache for %s: %s",
|
||||
name, e.msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove the entire built-packages directory tree.
|
||||
///
|
||||
/// Safe to call when the directory does not exist — silently returns.
|
||||
void clearBuildCache(Config cfg) @safe
|
||||
{
|
||||
auto dir = cfg.builtDir();
|
||||
if (fExists(dir))
|
||||
{
|
||||
try
|
||||
{
|
||||
fRmdirRecurse(dir);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
logWarn("failed to clear build cache: %s", e.msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove ALL recipe caches (`<cacheDir>/recipes/`).
|
||||
///
|
||||
/// Useful for `-Scc`-style full clean operations.
|
||||
void clearRecipeCacheAll(Config cfg) @safe
|
||||
{
|
||||
auto dir = buildPath(cfg.cacheDir, "recipes");
|
||||
if (fExists(dir))
|
||||
{
|
||||
try
|
||||
{
|
||||
fRmdirRecurse(dir);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
logWarn("failed to clear all recipe caches: %s", e.msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════
|
||||
// Unittests — pure file ops, no network required
|
||||
// ════════════════════════════════════════════════════════════
|
||||
|
||||
version (unittest)
|
||||
{
|
||||
import std.process : thisProcessID;
|
||||
|
||||
/// Build a Config pointing at a unique temp directory for test isolation.
|
||||
/// Each call returns a Config with a distinct cache dir, preventing
|
||||
/// tests from stepping on each other.
|
||||
@safe Config makeTestConfig(string suffix)
|
||||
{
|
||||
auto dir = buildPath(
|
||||
tempDir, "tofu-test-cache-" ~ thisProcessID.to!string ~ "-" ~ suffix);
|
||||
const string[string] env = ["TOFU_CACHE_DIR": dir];
|
||||
return load(null, env);
|
||||
}
|
||||
|
||||
/// Recursively remove a test directory, ignoring errors.
|
||||
@trusted void cleanupTestDir(string dir)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (exists(dir))
|
||||
rmdirRecurse(dir);
|
||||
}
|
||||
catch (Exception) {}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Test (1): cacheRecipe then isRecipeStale(same ver) → false ─────
|
||||
|
||||
@safe unittest
|
||||
{
|
||||
auto cfg = makeTestConfig("t1");
|
||||
scope (exit) cleanupTestDir(cfg.cacheDir);
|
||||
|
||||
cacheRecipe("mypkg", "2.1.0", cfg);
|
||||
assert(!isRecipeStale("mypkg", "2.1.0", cfg),
|
||||
"same version should not be stale");
|
||||
}
|
||||
|
||||
// ── Test (2): isRecipeStale(different ver) → true ──────────────────
|
||||
|
||||
@safe unittest
|
||||
{
|
||||
auto cfg = makeTestConfig("t2");
|
||||
scope (exit) cleanupTestDir(cfg.cacheDir);
|
||||
|
||||
cacheRecipe("mypkg", "1.0", cfg);
|
||||
assert(isRecipeStale("mypkg", "2.0", cfg),
|
||||
"different version should be stale");
|
||||
}
|
||||
|
||||
// ── Test (3): missing cache → isRecipeStale → true ─────────────────
|
||||
|
||||
@safe unittest
|
||||
{
|
||||
auto cfg = makeTestConfig("t3");
|
||||
scope (exit) cleanupTestDir(cfg.cacheDir);
|
||||
|
||||
assert(isRecipeStale("nopkg", "1.0", cfg),
|
||||
"missing cache should be stale");
|
||||
}
|
||||
|
||||
// ── Test (4): cleanRecipeCache removes dir ─────────────────────────
|
||||
|
||||
@safe unittest
|
||||
{
|
||||
auto cfg = makeTestConfig("t4");
|
||||
scope (exit) cleanupTestDir(cfg.cacheDir);
|
||||
|
||||
cacheRecipe("mypkg", "1.0", cfg);
|
||||
auto dir = cfg.recipesCacheDir("mypkg");
|
||||
|
||||
// Verify the directory exists after caching
|
||||
assert(fExists(dir), "cache dir should exist after cacheRecipe");
|
||||
|
||||
cleanRecipeCache("mypkg", cfg);
|
||||
|
||||
// After cleanRecipeCache, the dir should be gone
|
||||
assert(!fExists(dir), "cache dir should be removed after cleanRecipeCache");
|
||||
}
|
||||
|
||||
// ── Test (5): clearBuildCache removes built tree ───────────────────
|
||||
|
||||
@safe unittest
|
||||
{
|
||||
auto cfg = makeTestConfig("t5");
|
||||
scope (exit) cleanupTestDir(cfg.cacheDir);
|
||||
|
||||
auto builtDir = cfg.builtDir();
|
||||
fMkdirRecurse(builtDir);
|
||||
|
||||
// Touch a file so the dir is non-empty
|
||||
fWrite(buildPath(builtDir, "sentinel"), "x");
|
||||
|
||||
assert(fExists(builtDir), "built dir should exist before clear");
|
||||
|
||||
clearBuildCache(cfg);
|
||||
|
||||
assert(!fExists(builtDir),
|
||||
"built dir should be removed after clearBuildCache");
|
||||
}
|
||||
|
||||
// ── Test (6): cache file is valid JSON (read back + parse) ─────────
|
||||
|
||||
@safe unittest
|
||||
{
|
||||
auto cfg = makeTestConfig("t6");
|
||||
scope (exit) cleanupTestDir(cfg.cacheDir);
|
||||
|
||||
cacheRecipe("firefox", "1.0", cfg);
|
||||
|
||||
auto manifestFile = buildPath(
|
||||
cfg.recipesCacheDir("firefox"), ".tofu-cache.json");
|
||||
assert(fExists(manifestFile), "manifest file should exist");
|
||||
|
||||
string content = fReadText(manifestFile);
|
||||
JSONValue jv = fParseJSON(content);
|
||||
|
||||
assert(jv["name"].str == "firefox", "name field mismatch");
|
||||
assert(jv["ver"].str == "1.0", "ver field mismatch");
|
||||
assert(jv["fetchedAt"].integer > 0, "fetchedAt should be positive timestamp");
|
||||
}
|
||||
|
||||
// ── Test (7): vercmp-based — semantic equality means NOT stale ─────
|
||||
|
||||
@safe unittest
|
||||
{
|
||||
auto cfg = makeTestConfig("t7");
|
||||
scope (exit) cleanupTestDir(cfg.cacheDir);
|
||||
|
||||
// Same version string → not stale
|
||||
cacheRecipe("pkg", "1.0.0", cfg);
|
||||
assert(!isRecipeStale("pkg", "1.0.0", cfg),
|
||||
"identical version strings should not be stale");
|
||||
|
||||
// Leading zeros are ignored per vercmp: "001" == "1"
|
||||
cacheRecipe("pkg2", "01.05", cfg);
|
||||
assert(!isRecipeStale("pkg2", "1.5", cfg),
|
||||
"leading-zero variants should not be stale per vercmp");
|
||||
|
||||
// Different versions ARE stale
|
||||
cacheRecipe("pkg3", "1.0", cfg);
|
||||
assert(isRecipeStale("pkg3", "2.0", cfg),
|
||||
"actually different versions should be stale");
|
||||
}
|
||||
|
||||
// ── Test (8): corrupted cache JSON → isRecipeStale returns true ────
|
||||
// (never throws, logs a warning)
|
||||
|
||||
@safe unittest
|
||||
{
|
||||
auto cfg = makeTestConfig("t8");
|
||||
scope (exit) cleanupTestDir(cfg.cacheDir);
|
||||
|
||||
auto dir = cfg.recipesCacheDir("pkg");
|
||||
if (!fExists(dir))
|
||||
fMkdirRecurse(dir);
|
||||
|
||||
// Write deliberately broken JSON
|
||||
auto manifestFile = buildPath(dir, ".tofu-cache.json");
|
||||
fWrite(manifestFile, "this is not json {{{");
|
||||
|
||||
// Should not throw — returns true (stale) and logs a warning
|
||||
bool stale;
|
||||
try
|
||||
{
|
||||
stale = isRecipeStale("pkg", "1.0", cfg);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
assert(false, "isRecipeStale must never throw: " ~ e.msg);
|
||||
}
|
||||
assert(stale, "corrupted cache should be considered stale");
|
||||
}
|
||||
|
||||
// ── Extra: clearRecipeCacheAll removes the entire recipes/ tree ────
|
||||
|
||||
@safe unittest
|
||||
{
|
||||
auto cfg = makeTestConfig("t9");
|
||||
scope (exit) cleanupTestDir(cfg.cacheDir);
|
||||
|
||||
// Cache two different packages
|
||||
cacheRecipe("pkg-a", "1.0", cfg);
|
||||
cacheRecipe("pkg-b", "2.0", cfg);
|
||||
|
||||
auto recipesDir = buildPath(cfg.cacheDir, "recipes");
|
||||
assert(fExists(recipesDir), "recipes dir should exist");
|
||||
|
||||
clearRecipeCacheAll(cfg);
|
||||
|
||||
assert(!fExists(recipesDir),
|
||||
"recipes dir should be removed after clearRecipeCacheAll");
|
||||
}
|
||||
|
||||
// ── Extra: isRecipeStale with vercmp numeric ordering ──────────────
|
||||
|
||||
@safe unittest
|
||||
{
|
||||
auto cfg = makeTestConfig("t10");
|
||||
scope (exit) cleanupTestDir(cfg.cacheDir);
|
||||
|
||||
cacheRecipe("pkg", "1.9", cfg);
|
||||
// vercmp: 1.10 > 1.9, so cached 1.9 != index 1.10 → stale
|
||||
assert(isRecipeStale("pkg", "1.10", cfg),
|
||||
"1.9 cached vs 1.10 index should be stale per vercmp");
|
||||
}
|
||||
Reference in New Issue
Block a user