kist
Version:
Lightweight Package Pipeline Processor with Plugin Architecture
291 lines • 9.92 kB
JavaScript
import crypto from "crypto";
import fs from "fs";
import path from "path";
import { AbstractProcess } from "../abstract/AbstractProcess.js";
import { FileCache } from "./FileCache.js";
export class BuildCache extends AbstractProcess {
static instance;
cacheIndex = new Map();
cacheDir;
maxCacheSize;
ttl;
indexPath;
initialized = false;
fileCache;
stats = {
hits: 0,
misses: 0,
stored: 0,
restored: 0,
evicted: 0,
};
constructor(options = {}) {
super();
this.cacheDir =
options.cacheDir ||
path.join(process.cwd(), ".kist-cache", "build");
this.maxCacheSize = options.maxCacheSize || 1024 * 1024 * 1024;
this.ttl = options.ttl || 7 * 24 * 60 * 60 * 1000;
this.indexPath = path.join(this.cacheDir, "build-index.json");
this.fileCache = FileCache.getInstance();
}
static getInstance(options) {
if (!BuildCache.instance) {
BuildCache.instance = new BuildCache(options);
}
return BuildCache.instance;
}
static resetInstance() {
BuildCache.instance = undefined;
}
async initialize() {
if (this.initialized)
return;
try {
await fs.promises.mkdir(this.cacheDir, { recursive: true });
await this.loadIndex();
await this.fileCache.initialize();
this.initialized = true;
this.logDebug(`BuildCache initialized with ${this.cacheIndex.size} entries.`);
}
catch (error) {
this.logWarn(`Failed to initialize build cache: ${error}`);
this.cacheIndex.clear();
this.initialized = true;
}
}
async lookup(actionName, inputFiles, config = {}) {
await this.initialize();
const cacheKey = await this.computeCacheKey(actionName, inputFiles, config);
const entry = this.cacheIndex.get(cacheKey);
if (!entry) {
this.stats.misses++;
return { found: false };
}
if (Date.now() - entry.createdAt > this.ttl) {
this.cacheIndex.delete(cacheKey);
this.stats.misses++;
return { found: false };
}
const currentInputHash = await this.computeInputHash(inputFiles);
if (currentInputHash !== entry.inputHash) {
this.cacheIndex.delete(cacheKey);
this.stats.misses++;
return { found: false };
}
const outputsExist = await this.verifyOutputFiles(entry.outputFiles);
if (!outputsExist) {
const restored = await this.restoreFromArtifacts(cacheKey, entry);
if (restored) {
this.stats.restored++;
this.stats.hits++;
return {
found: true,
outputFiles: entry.outputFiles,
restored: true,
};
}
this.cacheIndex.delete(cacheKey);
this.stats.misses++;
return { found: false };
}
this.stats.hits++;
this.logDebug(`Cache hit for ${actionName}`);
return { found: true, outputFiles: entry.outputFiles };
}
async store(actionName, inputFiles, outputFiles, config = {}, buildDuration) {
await this.initialize();
const cacheKey = await this.computeCacheKey(actionName, inputFiles, config);
const inputHash = await this.computeInputHash(inputFiles);
const configHash = this.computeConfigHash(config);
const entry = {
inputHash,
outputFiles,
configHash,
createdAt: Date.now(),
buildDuration,
};
await this.storeArtifacts(cacheKey, outputFiles);
this.cacheIndex.set(cacheKey, entry);
this.stats.stored++;
await this.fileCache.updateFileEntries(inputFiles);
await this.cleanupIfNeeded();
this.logDebug(`Stored cache entry for ${actionName}`);
}
invalidateAction(actionName) {
for (const [key] of this.cacheIndex) {
if (key.startsWith(`${actionName}:`)) {
this.cacheIndex.delete(key);
}
}
}
async clear() {
this.cacheIndex.clear();
this.stats = {
hits: 0,
misses: 0,
stored: 0,
restored: 0,
evicted: 0,
};
try {
await fs.promises.rm(this.cacheDir, {
recursive: true,
force: true,
});
await fs.promises.mkdir(this.cacheDir, { recursive: true });
}
catch {
}
this.logInfo("BuildCache cleared.");
}
async save() {
try {
const data = JSON.stringify(Object.fromEntries(this.cacheIndex), null, 2);
await fs.promises.writeFile(this.indexPath, data, "utf-8");
this.logDebug("BuildCache index saved.");
}
catch (error) {
this.logWarn(`Failed to save build cache index: ${error}`);
}
}
getStats() {
const total = this.stats.hits + this.stats.misses;
const hitRate = total > 0
? ((this.stats.hits / total) * 100).toFixed(2) + "%"
: "N/A";
return {
...this.stats,
size: this.cacheIndex.size,
hitRate,
};
}
async computeCacheKey(actionName, inputFiles, config) {
const sortedFiles = [...inputFiles].sort().join("|");
const configStr = JSON.stringify(config);
const data = `${actionName}:${sortedFiles}:${configStr}`;
return `${actionName}:${crypto.createHash("md5").update(data).digest("hex")}`;
}
async computeInputHash(inputFiles) {
const hashes = [];
for (const file of inputFiles.sort()) {
try {
const content = await fs.promises.readFile(file);
hashes.push(crypto.createHash("sha256").update(content).digest("hex"));
}
catch {
hashes.push("missing");
}
}
return crypto
.createHash("sha256")
.update(hashes.join(":"))
.digest("hex");
}
computeConfigHash(config) {
return crypto
.createHash("md5")
.update(JSON.stringify(config))
.digest("hex");
}
async verifyOutputFiles(outputFiles) {
for (const file of outputFiles) {
try {
await fs.promises.access(file, fs.constants.R_OK);
}
catch {
return false;
}
}
return true;
}
async storeArtifacts(cacheKey, outputFiles) {
const artifactDir = path.join(this.cacheDir, "artifacts", cacheKey);
try {
await fs.promises.mkdir(artifactDir, { recursive: true });
for (const file of outputFiles) {
const artifactPath = path.join(artifactDir, path.basename(file));
await fs.promises.copyFile(file, artifactPath);
}
}
catch (error) {
this.logWarn(`Failed to store artifacts for ${cacheKey}: ${error}`);
}
}
async restoreFromArtifacts(cacheKey, entry) {
const artifactDir = path.join(this.cacheDir, "artifacts", cacheKey);
try {
for (const outputFile of entry.outputFiles) {
const artifactPath = path.join(artifactDir, path.basename(outputFile));
const outputDir = path.dirname(outputFile);
await fs.promises.mkdir(outputDir, { recursive: true });
await fs.promises.copyFile(artifactPath, outputFile);
}
return true;
}
catch {
return false;
}
}
async loadIndex() {
try {
const data = await fs.promises.readFile(this.indexPath, "utf-8");
const parsed = JSON.parse(data);
this.cacheIndex = new Map(Object.entries(parsed));
}
catch {
this.cacheIndex = new Map();
}
}
async cleanupIfNeeded() {
const artifactsPath = path.join(this.cacheDir, "artifacts");
try {
const size = await this.getDirectorySize(artifactsPath);
if (size > this.maxCacheSize) {
const entries = Array.from(this.cacheIndex.entries()).sort(([, a], [, b]) => a.createdAt - b.createdAt);
const toEvict = Math.ceil(entries.length * 0.2);
for (let i = 0; i < toEvict; i++) {
const [key] = entries[i];
await this.evictEntry(key);
this.stats.evicted++;
}
}
}
catch {
}
}
async evictEntry(cacheKey) {
this.cacheIndex.delete(cacheKey);
const artifactDir = path.join(this.cacheDir, "artifacts", cacheKey);
try {
await fs.promises.rm(artifactDir, {
recursive: true,
force: true,
});
}
catch {
}
}
async getDirectorySize(dirPath) {
let totalSize = 0;
try {
const entries = await fs.promises.readdir(dirPath, {
withFileTypes: true,
});
for (const entry of entries) {
const fullPath = path.join(dirPath, entry.name);
if (entry.isDirectory()) {
totalSize += await this.getDirectorySize(fullPath);
}
else {
const stat = await fs.promises.stat(fullPath);
totalSize += stat.size;
}
}
}
catch {
}
return totalSize;
}
}
//# sourceMappingURL=BuildCache.js.map