ublda
Version:
Universal Block-Level Data Accelerator for efficient, deduplicated file storage
515 lines (451 loc) • 19.9 kB
JavaScript
import path from 'node:path';
import fs from 'fs-extra';
import zlib from 'node:zlib';
import { pipeline } from 'node:stream/promises';
import { promisify } from 'node:util';
import pLimit from 'p-limit';
import { splitFile, writeFileFromBlocks, createReadStreamFromBlocks } from './file.js';
import { hashBuffer } from './hasher.js';
import { writeBlocksBatch, readBlock } from './storage.js';
import { saveManifest, loadManifest } from './manifest.js';
import os from 'node:os';
const limit = pLimit(Math.max(4, os.cpus().length)); // Balanced concurrency
const deflateAsync = promisify(zlib.deflate);
const inflateAsync = promisify(zlib.inflate);
const brotliCompressAsync = promisify(zlib.brotliCompress);
const brotliDecompressAsync = promisify(zlib.brotliDecompress);
const COMPRESSION_ALGORITHMS = {
DEFLATE: 'deflate',
BROTLI: 'brotli',
NONE: 'none',
};
// Fast heuristic for compression
function shouldCompress(fileName, buffer) {
if (buffer.length < 2048) return false;
const ext = path.extname(fileName).toLowerCase();
return !['.jpg', '.png', '.mp4', '.zip', '.gz', '.pdf', '.mp3', '.avi'].includes(ext);
}
/**
* Store a file as blocks + manifest.
* @param {string} filePath
* @param {object} [options]
* @returns {Promise<{ manifestPath: string, blockCount: number }>}
*/
export async function storeFile(filePath, {
storageDir = './storage',
blockSize = 8 * 1024 * 1024, // 8MB
compress = true,
verbose = false,
compressionAlgorithm = COMPRESSION_ALGORITHMS.BROTLI,
} = {}) {
if (!(await fs.access(filePath).then(() => true).catch(() => false))) throw new Error(`File not found: ${filePath}`);
if (blockSize <= 0) throw new Error('Block size must be positive');
const fileName = path.basename(filePath);
const blockDir = path.join(storageDir, 'blocks');
const manifestDir = path.join(storageDir, 'manifests');
await fs.mkdir(blockDir, { recursive: true });
await fs.mkdir(manifestDir, { recursive: true });
const stats = await fs.stat(filePath);
if (stats.size === 0) {
const metadata = {
originalSize: 0,
blockSize,
compress: false,
compressionAlgorithm: COMPRESSION_ALGORITHMS.NONE,
originalName: fileName,
createdAt: new Date().toISOString()
};
const manifestPath = path.join(manifestDir, `${fileName}.manifest.json`);
await fs.writeJson(manifestPath, { hashes: [], blockCompression: [], metadata });
return { manifestPath, blockCount: 0 };
}
// Fast path for small files (<8MB)
if (stats.size <= blockSize) {
const buffer = await fs.readFile(filePath);
const { manifestPath, blockCount } = await writeFile(fileName, buffer, {
storageDir,
blockSize,
compress,
verbose,
compressionAlgorithm
});
return { manifestPath, blockCount };
}
const metadata = {
originalSize: stats.size,
blockSize,
compress,
compressionAlgorithm: compress ? compressionAlgorithm : COMPRESSION_ALGORITHMS.NONE,
originalName: fileName,
createdAt: new Date().toISOString()
};
const stream = fs.createReadStream(filePath, { highWaterMark: blockSize });
const hashes = [];
const blockCompression = [];
const blocksToWrite = [];
let blockIndex = 0;
return new Promise((resolve, reject) => {
stream.on('data', async (chunk) => {
if (chunk.length === 0) return;
stream.pause();
blockIndex++;
let processedBlock = chunk;
let actualCompression = COMPRESSION_ALGORITHMS.NONE;
if (compress && shouldCompress(fileName, chunk)) {
actualCompression = metadata.compressionAlgorithm;
try {
processedBlock = actualCompression === COMPRESSION_ALGORITHMS.BROTLI
? await limit(() => brotliCompressAsync(chunk, { params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 2 } }))
: await limit(() => deflateAsync(chunk));
if (processedBlock.length >= chunk.length) {
processedBlock = chunk;
actualCompression = COMPRESSION_ALGORITHMS.NONE;
}
} catch {
processedBlock = chunk;
actualCompression = COMPRESSION_ALGORITHMS.NONE;
}
}
const hash = hashBuffer(processedBlock);
blocksToWrite.push({ hash, data: processedBlock });
hashes.push(hash);
blockCompression.push(actualCompression);
if (blocksToWrite.length >= 8) { // Batch writes
await limit(() => writeBlocksBatch(blockDir, blocksToWrite.splice(0), { verbose }));
}
stream.resume();
});
stream.on('end', async () => {
if (blocksToWrite.length > 0) {
await limit(() => writeBlocksBatch(blockDir, blocksToWrite, { verbose }));
}
const manifestPath = path.join(manifestDir, `${fileName}.manifest.json`);
await fs.writeJson(manifestPath, { hashes, blockCompression, metadata });
resolve({ manifestPath, blockCount: hashes.length });
});
stream.on('error', (err) => reject(new Error(`Stream error: ${err.message}`)));
});
}
/**
* Restore a file from manifest and blocks.
* @param {string} manifestPath
* @param {string} outputPath
* @param {object} [options]
* @returns {Promise<void>}
*/
export async function restoreFile(manifestPath, outputPath, {
storageDir = './storage',
verbose = false,
} = {}) {
if (!(await fs.access(manifestPath).then(() => true).catch(() => false))) throw new Error(`Manifest file not found: ${manifestPath}`);
const blockDir = path.join(storageDir, 'blocks');
if (!(await fs.access(blockDir).then(() => true).catch(() => false))) throw new Error(`Block directory not found: ${blockDir}`);
const { hashes, blockCompression, metadata } = await loadManifest(manifestPath);
if (!Array.isArray(hashes) || !Array.isArray(blockCompression) || hashes.length !== blockCompression.length) {
throw new Error('Invalid manifest: hashes or blockCompression is missing or inconsistent.');
}
if (hashes.length === 0 && metadata.originalSize === 0) {
await fs.writeFile(outputPath, '');
return;
}
if (hashes.length === 0 || metadata.originalSize === 0) {
throw new Error('Manifest is invalid: inconsistent hashes and originalSize.');
}
const outputStream = fs.createWriteStream(outputPath);
await pipeline(
createReadStreamFromBlocks(blockDir, hashes, { blockCompression, verbose, compress: metadata.compress, compressionAlgorithm: metadata.compressionAlgorithm }),
outputStream
);
}
/**
* Reads logical file from manifest and blocks.
* @param {string} manifestPath
* @param {object} [options]
* @returns {Promise<Buffer|Readable>}
*/
export async function readFile(manifestPath, {
storageDir = './storage',
verbose = false,
stream = false,
} = {}) {
if (!manifestPath.endsWith('.json')) throw new Error('Manifest path must point to a .json file');
if (!(await fs.access(manifestPath).then(() => true).catch(() => false))) throw new Error(`Manifest file not found: ${manifestPath}`);
const blockDir = path.join(storageDir, 'blocks');
if (!(await fs.access(blockDir).then(() => true).catch(() => false))) throw new Error(`Block directory not found: ${blockDir}`);
const { hashes, blockCompression, metadata } = await loadManifest(manifestPath);
if (!Array.isArray(hashes) || !Array.isArray(blockCompression) || hashes.length !== blockCompression.length) {
throw new Error('Invalid manifest: hashes or blockCompression is missing or inconsistent.');
}
if (stream) {
return createReadStreamFromBlocks(blockDir, hashes, {
compress: metadata.compress,
compressionAlgorithm: metadata.compressionAlgorithm,
verbose,
blockCompression
});
}
const blockPromises = hashes.map((hash, i) => limit(async () => {
const compression = blockCompression[i];
const block = await readBlock(blockDir, hash);
try {
return compression !== COMPRESSION_ALGORITHMS.NONE
? compression === COMPRESSION_ALGORITHMS.BROTLI
? await brotliDecompressAsync(block)
: await inflateAsync(block)
: block;
} catch (err) {
throw new Error(`Decompression failed for block ${i + 1}: ${err.message}`);
}
}));
return Buffer.concat(await Promise.all(blockPromises));
}
/**
* Verifies a restored file against its manifest.
* @param {string} filePath
* @param {string} manifestPath
* @param {object} [options]
* @returns {Promise<boolean>}
*/
export async function verifyFile(filePath, manifestPath, {
storageDir = './storage',
verbose = false,
} = {}) {
if (!(await fs.access(filePath).then(() => true).catch(() => false))) throw new Error(`File not found: ${filePath}`);
if (!(await fs.access(manifestPath).then(() => true).catch(() => false))) throw new Error(`Manifest file not found: ${manifestPath}`);
const { hashes: manifestHashes, blockCompression, metadata } = await loadManifest(manifestPath);
if (!metadata.blockSize) throw new Error('Manifest missing blockSize metadata');
if (!Array.isArray(manifestHashes) || manifestHashes.length === 0 && metadata.originalSize > 0) {
throw new Error('Manifest contains no hashes for a non-empty file.');
}
if (metadata.originalSize === 0 && manifestHashes.length > 0) {
throw new Error('Manifest contains hashes for an empty file.');
}
if (metadata.originalSize === 0 && manifestHashes.length === 0) {
const actualFileStat = await fs.stat(filePath);
if (actualFileStat.size !== 0) throw new Error(`Manifest indicates empty file, but file on disk has size ${actualFileStat.size}.`);
if (verbose) console.log('File and manifest correctly represent an empty file.');
return true;
}
const blockDir = path.join(storageDir, 'blocks');
const fileStream = fs.createReadStream(filePath, { highWaterMark: metadata.blockSize });
let currentFileBlockBuffer = Buffer.alloc(0);
let manifestHashIndex = 0;
let totalBytesReadFromFile = 0;
return new Promise((resolve, reject) => {
const processBlock = async (fileBlockChunk, isLastBlock = false) => {
if (manifestHashIndex >= manifestHashes.length) {
fileStream.destroy();
reject(new Error('File contains more blocks than specified in the manifest.'));
return false;
}
const compression = blockCompression[manifestHashIndex];
let blockToHash = fileBlockChunk;
try {
if (compression !== COMPRESSION_ALGORITHMS.NONE) {
blockToHash = compression === COMPRESSION_ALGORITHMS.BROTLI
? await limit(() => brotliCompressAsync(fileBlockChunk, { params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 2 } }))
: await limit(() => deflateAsync(fileBlockChunk));
}
} catch (err) {
fileStream.destroy();
reject(new Error(`Compression failed for block ${manifestHashIndex + 1}: ${err.message}`));
return false;
}
const computedFileBlockHash = hashBuffer(blockToHash);
const expectedManifestHash = manifestHashes[manifestHashIndex];
if (computedFileBlockHash !== expectedManifestHash) {
fileStream.destroy();
reject(new Error(`Hash mismatch for file block ${manifestHashIndex + 1}: expected ${expectedManifestHash}, got ${computedFileBlockHash}`));
return false;
}
const storedBlock = await limit(() => readBlock(blockDir, expectedManifestHash));
const storedBlockHash = hashBuffer(storedBlock);
if (storedBlockHash !== expectedManifestHash) {
fileStream.destroy();
reject(new Error(`Stored block ${manifestHashIndex + 1} is corrupted or does not match manifest: expected ${expectedManifestHash}, got ${storedBlockHash}`));
return false;
}
if (verbose) console.log(`Verified block ${manifestHashIndex + 1}/${manifestHashes.length} (hash: ${expectedManifestHash})`);
manifestHashIndex++;
return true;
};
fileStream.on('data', async (chunk) => {
totalBytesReadFromFile += chunk.length;
currentFileBlockBuffer = Buffer.concat([currentFileBlockBuffer, chunk]);
while (currentFileBlockBuffer.length >= metadata.blockSize) {
if (manifestHashIndex >= manifestHashes.length && currentFileBlockBuffer.length > 0) {
fileStream.destroy();
reject(new Error('File contains more data than specified by manifest hashes after processing full blocks.'));
return;
}
const blockToProcess = currentFileBlockBuffer.slice(0, metadata.blockSize);
currentFileBlockBuffer = currentFileBlockBuffer.slice(metadata.blockSize);
fileStream.pause();
if (!(await processBlock(blockToProcess))) return;
fileStream.resume();
}
});
fileStream.on('end', async () => {
try {
if (currentFileBlockBuffer.length > 0) {
if (manifestHashIndex >= manifestHashes.length) {
reject(new Error('File has trailing data not accounted for in manifest.'));
return;
}
if (!(await processBlock(currentFileBlockBuffer, true))) return;
}
if (totalBytesReadFromFile !== metadata.originalSize) {
reject(new Error(`File size mismatch: manifest indicates ${metadata.originalSize} bytes, but read ${totalBytesReadFromFile} bytes from file.`));
return;
}
if (manifestHashIndex !== manifestHashes.length) {
reject(new Error(`Block count mismatch: manifest indicates ${manifestHashes.length} blocks, but only processed ${manifestHashIndex} blocks from file.`));
return;
}
resolve(true);
} catch (err) {
reject(err);
}
});
fileStream.on('error', (err) => reject(err));
});
}
/**
* Writes a logical file from a Buffer.
* @param {string} filename
* @param {Buffer} buffer
* @param {object} [options]
* @returns {Promise<{ manifestPath: string, blockCount: number, totalSize: number }>}
*/
export async function writeFile(filename, buffer, {
storageDir = './storage',
blockSize = 8 * 1024 * 1024, // 8MB
compress = true,
verbose = false,
compressionAlgorithm = COMPRESSION_ALGORITHMS.BROTLI,
} = {}) {
if (!filename) throw new Error('Filename is required');
if (!(buffer instanceof Buffer)) throw new Error('Input must be a Buffer');
const blocksDir = path.join(storageDir, 'blocks');
const manifestsDir = path.join(storageDir, 'manifests');
await fs.mkdir(blocksDir, { recursive: true });
await fs.mkdir(manifestsDir, { recursive: true });
if (buffer.length === 0) {
const metadata = {
originalSize: 0,
blockSize,
compress: false,
compressionAlgorithm: COMPRESSION_ALGORITHMS.NONE,
originalName: filename,
createdAt: new Date().toISOString()
};
const manifestPath = path.join(manifestsDir, `${filename}.manifest.json`);
await fs.writeJson(manifestPath, { hashes: [], blockCompression: [], metadata });
return { manifestPath, blockCount: 0, totalSize: 0 };
}
// Fast path for small buffers (<8MB)
if (buffer.length <= blockSize) {
let processedBlock = buffer;
let actualCompression = COMPRESSION_ALGORITHMS.NONE;
if (compress && shouldCompress(filename, buffer)) {
actualCompression = compressionAlgorithm;
try {
processedBlock = actualCompression === COMPRESSION_ALGORITHMS.BROTLI
? await brotliCompressAsync(buffer, { params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 2 } })
: await deflateAsync(buffer);
if (processedBlock.length >= buffer.length) {
processedBlock = buffer;
actualCompression = COMPRESSION_ALGORITHMS.NONE;
}
} catch (err) {
processedBlock = buffer;
actualCompression = COMPRESSION_ALGORITHMS.NONE;
}
}
const hash = hashBuffer(processedBlock);
await writeBlocksBatch(blocksDir, [{ hash, data: processedBlock }], { verbose });
const metadata = {
originalSize: buffer.length,
blockSize: buffer.length,
compress,
compressionAlgorithm: actualCompression,
originalName: filename,
createdAt: new Date().toISOString()
};
const manifestPath = path.join(manifestsDir, `${filename}.manifest.json`);
await fs.writeJson(manifestPath, { hashes: [hash], blockCompression: [actualCompression], metadata });
return { manifestPath, blockCount: 1, totalSize: buffer.length };
}
const hashes = [];
const blockCompression = [];
const metadata = {
originalSize: buffer.length,
blockSize,
compress,
compressionAlgorithm: compress ? compressionAlgorithm : COMPRESSION_ALGORITHMS.NONE,
originalName: filename,
createdAt: new Date().toISOString()
};
const blocksToWrite = [];
for (let offset = 0; offset < buffer.length; offset += blockSize) {
const chunk = buffer.slice(offset, offset + blockSize);
let blockToStore = chunk;
let actualCompression = COMPRESSION_ALGORITHMS.NONE;
if (compress && shouldCompress(filename, chunk)) {
actualCompression = metadata.compressionAlgorithm;
try {
blockToStore = actualCompression === COMPRESSION_ALGORITHMS.BROTLI
? await limit(() => brotliCompressAsync(chunk, { params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 2 } }))
: await limit(() => deflateAsync(chunk));
if (blockToStore.length >= chunk.length) {
blockToStore = chunk;
actualCompression = COMPRESSION_ALGORITHMS.NONE;
}
} catch (err) {
blockToStore = chunk;
actualCompression = COMPRESSION_ALGORITHMS.NONE;
}
}
const hash = hashBuffer(blockToStore);
blocksToWrite.push({ hash, data: blockToStore });
hashes.push(hash);
blockCompression.push(actualCompression);
if (blocksToWrite.length >= 8) {
await limit(() => writeBlocksBatch(blocksDir, blocksToWrite.splice(0), { verbose }));
}
}
if (blocksToWrite.length > 0) {
await limit(() => writeBlocksBatch(blocksDir, blocksToWrite, { verbose }));
}
const manifestPath = path.join(manifestsDir, `${filename}.manifest.json`);
await fs.writeJson(manifestPath, { hashes, blockCompression, metadata });
return { manifestPath, blockCount: hashes.length, totalSize: buffer.length };
}
/**
* Cleans up unused blocks in the storage directory.
* @param {string} storageDir
* @param {object} [options]
* @returns {Promise<number>}
*/
export async function cleanupBlocks(storageDir = './storage', { verbose = false } = {}) {
const blockDir = path.join(storageDir, 'blocks');
const manifestDir = path.join(storageDir, 'manifests');
if (!(await pathExists(blockDir))) throw new Error(`Block directory not found: ${blockDir}`);
if (!(await pathExists(manifestDir))) throw new Error(`Manifest directory not found: ${manifestDir}`);
const blockFiles = await fs.readdir(blockDir);
const manifests = await fs.readdir(manifestDir);
const usedHashes = new Set();
for (const manifest of manifests) {
const { hashes } = await loadManifest(path.join(manifestDir, manifest));
hashes.forEach(hash => usedHashes.add(hash));
}
let deletedCount = 0;
for (const blockFile of blockFiles) {
if (!usedHashes.has(blockFile)) {
await fs.unlink(path.join(blockDir, blockFile));
deletedCount++;
if (verbose) console.log(`Deleted unused block: ${blockFile}`);
}
}
return deletedCount;
}