kvbits
Version:
A persistent key-value database for Node.js with Write-Ahead Logging, TTL, and compaction.
461 lines (401 loc) • 19.3 kB
JavaScript
// src/core/kv-core.js
const fs = 'fs'; // Placeholder, fs operations are now in Storage/IndexManager
const path = require('path');
const EventEmitter = require('events');
const Storage = require('../storage/storage');
const IndexManager = require('./index');
// Constants are not directly used here but passed to IndexManager/Storage if needed by them
class KV extends EventEmitter {
constructor(options = {}) {
super(); // Call EventEmitter constructor
// Default options merging
const defaultOptions = {
dbPath: 'kv.db',
indexPath: 'kv.index',
walPath: `${options.indexPath || 'kv.index'}.wal`,
flushInterval: 100,
syncOnWrite: false,
defaultTTL: null,
preload: true,
maxMemoryKeys: Infinity,
compact: { interval: 3600000, threshold: 0.5 },
checkpoint: { interval: 600000, walSizeThreshold: 5 * 1024 * 1024 },
eventEmitter: this, // Pass self as emitter for submodules
};
this.options = { ...defaultOptions, ...options };
// Ensure sub-options are also defaulted if not fully provided
this.options.compact = { ...defaultOptions.compact, ...(options.compact || {}) };
this.options.checkpoint = { ...defaultOptions.checkpoint, ...(options.checkpoint || {}) };
this.store = new Map(); // In-memory cache (LRU managed)
this.ttlMap = new Map(); // Manages TTL timeouts: key -> { expiry, timeoutId }
this.dirtyDataKeys = new Set(); // Keys whose data in `store` needs writing to data file
this.flushDataPending = false;
this.flushIndexPending = false; // For scheduling IndexManager's flushToWAL
this.isClosing = false;
this.isCompacting = false;
// isCheckpointing is managed by IndexManager
this.stats = {
reads: 0, writes: 0, deletes: 0, hits: 0, misses: 0, diskReads: 0,
lastAccess: Date.now(), bytesWrittenToDataFile: 0, bytesReadFromDataFile: 0,
bytesWrittenToWAL: 0, compactions: 0, lastCompactionTime: 0,
wastedSpace: 0, indexSizeBytes: 0, walSizeBytes: 0, checkpoints: 0, lastCheckpointTime: 0,
};
this.lruKeys = [];
// Initialize submodules
try {
this.storage = new Storage(this.options.dbPath, this.stats, this);
// Pass KV's _setTTL method to IndexManager for WAL replay TTL setup
this.indexManager = new IndexManager(
{ indexPath: this.options.indexPath, walPath: this.options.walPath, syncOnWrite: this.options.syncOnWrite, checkpoint: this.options.checkpoint },
this.stats,
this,
this.ttlMap,
(key, ttl, schedule) => this._setTTL(key, ttl, schedule) // Bound _setTTL
);
} catch (initErr) {
this.emit('error', new Error(`KV Core: Submodule initialization failed: ${initErr.message}`));
throw initErr; // Critical failure
}
this._init();
}
_init() {
// Post-submodule initialization tasks
if (this.options.preload) {
this._loadAllKeysToMemory();
}
this.stats.wastedSpace = this.storage.getWastedSpace(this.indexManager.index); // Initial wasted space
this._setupBackgroundTasks();
this._boundClose = this.close.bind(this);
process.on('exit', this._boundClose);
this._boundCloseAndExitSignal = () => this.close().then(() => process.exit(0)).catch(() => process.exit(1));
process.on('SIGINT', this._boundCloseAndExitSignal);
process.on('SIGTERM', this._boundCloseAndExitSignal);
process.on('SIGHUP', this._boundCloseAndExitSignal);
this.emit('ready', this.indexManager.size);
}
_loadAllKeysToMemory() {
let count = 0;
for (const [key, meta] of this.indexManager.entries()) {
if (this.store.size >= this.options.maxMemoryKeys && this.options.maxMemoryKeys !== Infinity) {
this.emit('warn', `Preload limit reached: maxMemoryKeys (${this.options.maxMemoryKeys}). Some keys not preloaded.`);
break;
}
try {
const value = this.storage.readValue(meta);
this.store.set(key, value);
this._updateLRU(key, true);
count++;
} catch (err) {
this.emit('error', new Error(`KV Core: Failed to preload key "${key}": ${err.message}.`));
}
}
if (count > 0) this.emit('log', `KV Core: Preloaded ${count} keys into memory cache.`);
}
_updateLRU(key, isNewEntryToCache) {
if (this.options.maxMemoryKeys === Infinity) return;
const lruIndex = this.lruKeys.indexOf(key);
if (lruIndex !== -1) this.lruKeys.splice(lruIndex, 1);
this.lruKeys.push(key);
if (this.lruKeys.length > this.options.maxMemoryKeys) {
const keyToEvict = this.lruKeys.shift();
this.store.delete(keyToEvict);
}
}
_setupBackgroundTasks() {
if (this.options.flushInterval !== null && this.options.flushInterval > 0) {
this._flushIntervalId = setInterval(() => {
if (!this.isClosing) {
this._flushDataToStorage();
this._flushIndexManagerToWAL();
}
}, this.options.flushInterval);
}
if (this.options.compact.interval !== null && this.options.compact.interval > 0) {
this._compactIntervalId = setInterval(() => {
if (!this.isClosing && !this.isCompacting) this._compactIfNeeded();
}, this.options.compact.interval);
}
if (this.options.checkpoint.interval !== null && this.options.checkpoint.interval > 0 && this.indexManager) {
this._checkpointIntervalId = setInterval(() => {
if(!this.isClosing && !this.indexManager.isCheckpointing) {
this.indexManager.performCheckpoint(this.options.syncOnWrite); // Use configured sync for interval checkpoints
}
}, this.options.checkpoint.interval);
}
}
set(key, value, options = {}) {
if (this.isClosing) { /* ... error ... */ return false; }
if (typeof key !== 'string' || key.length === 0) throw new TypeError('Key must be a non-empty string.');
const oldMetaInMemory = this.indexManager.get(key);
this.store.set(key, value);
this._updateLRU(key, oldMetaInMemory === undefined);
this.dirtyDataKeys.add(key);
this.stats.writes++;
this.stats.lastAccess = Date.now();
const ttl = options.ttl || this.options.defaultTTL;
if (ttl) {
this._setTTL(key, ttl, true);
} else if (this.ttlMap.has(key)) {
clearTimeout(this.ttlMap.get(key).timeout);
this.ttlMap.delete(key);
if (this.indexManager.has(key)) {
const currentMeta = { ...this.indexManager.get(key) };
delete currentMeta.expiry;
this.indexManager.set(key, currentMeta); // Update in-memory index directly
this.indexManager.addChange(key, IndexManager.WAL_OP_SET, currentMeta); // Use constant from IndexManager if defined there
this._scheduleIndexFlush();
}
}
this._scheduleDataFlush();
if (this.options.syncOnWrite) {
this._flushDataToStorage(true);
this._flushIndexManagerToWAL(true);
}
this.emit('set', key, value);
return true;
}
get(key) {
if (this.isClosing) return undefined;
if (typeof key !== 'string' || key.length === 0) throw new TypeError('Key must be a non-empty string.');
this.stats.reads++; this.stats.lastAccess = Date.now();
if (this.store.has(key)) {
this.stats.hits++; const value = this.store.get(key);
this._updateLRU(key, false); this.emit('get', key, value); return value;
}
if (this.indexManager.has(key)) {
try {
const meta = this.indexManager.get(key);
const value = this.storage.readValue(meta);
this.stats.diskReads++; this.store.set(key, value);
this._updateLRU(key, true); this.stats.hits++;
this.emit('get', key, value); return value;
} catch (err) { /* ... error ... */ this.stats.misses++; this.emit('miss', key); return undefined; }
}
this.stats.misses++; this.emit('miss', key); return undefined;
}
has(key) {
if (this.isClosing) return false;
if (typeof key !== 'string' || key.length === 0) throw new TypeError('Key must be a non-empty string.');
return this.indexManager.has(key);
}
delete(key) {
if (this.isClosing) return false;
if (typeof key !== 'string' || key.length === 0) throw new TypeError('Key must be a non-empty string.');
let changed = false;
if (this.store.has(key)) { this.store.delete(key); /* ... LRU ... */ changed = true; }
if (this.indexManager.has(key)) {
const oldMeta = this.indexManager.get(key);
this.stats.wastedSpace += oldMeta.size;
this.indexManager.delete(key);
this.indexManager.addChange(key, IndexManager.WAL_OP_DELETE || 2); // Use constant from IndexManager (WAL_OP_DELETE)
changed = true;
}
if (changed) {
if (this.ttlMap.has(key)) { /* ... clear TTL ... */ }
this.stats.deletes++; this.stats.lastAccess = Date.now();
this._scheduleIndexFlush();
if (this.options.syncOnWrite) this._flushIndexManagerToWAL(true);
this.emit('delete', key);
}
return changed;
}
clear() {
if (this.isClosing) return;
const oldSize = this.indexManager.size;
this.emit('log', 'KV Core: Clear operation initiated.');
this.store.clear(); this.lruKeys = [];
this.indexManager.index.clear(); // Clear in-memory map in IndexManager
this.dirtyDataKeys.clear();
this.indexManager.pendingChanges.clear(); // Clear pending WAL changes in IndexManager
for (const [, data] of this.ttlMap) clearTimeout(data.timeout);
this.ttlMap.clear();
try {
this.storage.truncateDataFile();
this.indexManager.truncateWALAndIndex(); // This will also perform a checkpoint of empty state
} catch (err) { /* ... error handling ... */ }
this.stats.lastAccess = Date.now();
// Reset relevant stats
this.stats.writes = 0; this.stats.reads = 0; this.stats.deletes = 0;
this.stats.hits = 0; this.stats.misses = 0; this.stats.diskReads = 0;
this.emit('clear', oldSize);
}
keys() { return this.indexManager.keys(); }
size() { return this.indexManager.size; }
getStats() { /* ... update from submodules and self ... */
this.stats.wastedSpace = this.storage.getWastedSpace(this.indexManager.index); // Ensure fresh
return {
...this.stats,
activeKeys: this.indexManager.size,
memoryStoreKeys: this.store.size,
lruKeysCount: this.lruKeys.length,
pendingDataWrites: this.dirtyDataKeys.size,
pendingIndexChangesToWAL: this.indexManager.pendingChanges.size,
dataFileSize: (this.storage.getFd() && !this.isClosing && fs.existsSync(this.options.dbPath)) ? fs.fstatSync(this.storage.getFd()).size : 0,
};
}
async flush(forceSync = false) {
if (this.isClosing) return false;
let dataFlushed = false, indexFlushed = false;
try {
dataFlushed = this._flushDataToStorage(this.options.syncOnWrite || forceSync);
indexFlushed = this._flushIndexManagerToWAL(this.options.syncOnWrite || forceSync);
} catch (e) { /* ... error handling ... */ return false; }
return dataFlushed || indexFlushed;
}
_scheduleDataFlush() {
if (this.isClosing || this.flushDataPending || this.dirtyDataKeys.size === 0) return;
this.flushDataPending = true;
setImmediate(() => {
this.flushDataPending = false;
if (!this.isClosing && this.dirtyDataKeys.size > 0) this._flushDataToStorage(this.options.syncOnWrite);
});
}
_scheduleIndexFlush() {
if (this.isClosing || this.flushIndexPending || this.indexManager.pendingChanges.size === 0) return;
this.flushIndexPending = true;
setImmediate(() => {
this.flushIndexPending = false;
if (!this.isClosing && this.indexManager.pendingChanges.size > 0) this._flushIndexManagerToWAL(this.options.syncOnWrite);
});
}
_flushDataToStorage(forceSyncDataFile = false) {
if (this.isClosing || this.dirtyDataKeys.size === 0) return false;
let dataFileCurrentOffset;
try { dataFileCurrentOffset = fs.fstatSync(this.storage.getFd()).size; }
catch (err) { /* ... error ... */ return false; }
const flushedCount = this.dirtyDataKeys.size;
const keysToProcess = Array.from(this.dirtyDataKeys);
this.dirtyDataKeys.clear();
for (const key of keysToProcess) {
if (!this.store.has(key)) { /* ... log skip ... */ continue; }
const value = this.store.get(key);
try {
const oldMeta = this.indexManager.get(key);
if (oldMeta) this.stats.wastedSpace += oldMeta.size;
const newMeta = this.storage.writeData(key, value, dataFileCurrentOffset);
dataFileCurrentOffset += newMeta.size; // Update offset for next write
if (this.ttlMap.has(key)) newMeta.expiry = this.ttlMap.get(key).expiry;
this.indexManager.set(key, newMeta); // Update in-memory index
this.indexManager.addChange(key, IndexManager.WAL_OP_SET || 1, newMeta); // Use constant
} catch (err) { this.dirtyDataKeys.add(key); /* ... error ... */ }
}
// Note: stats.bytesWrittenToDataFile is updated by storage.writeData
if (forceSyncDataFile && this.storage.getFd() && (dataFileCurrentOffset > (fs.fstatSync(this.storage.getFd()).size - (keysToProcess.length * 1024)) ) ) { // Heuristic: only fsync if data actually written
try { fs.fsyncSync(this.storage.getFd()); }
catch (err) { /* ... error ... */ }
}
if (flushedCount > 0) {
this.emit('data_flush', flushedCount);
if (this.indexManager.pendingChanges.size > 0) this._scheduleIndexFlush();
}
return flushedCount > 0;
}
_flushIndexManagerToWAL(forceSyncWAL = false) {
if (this.isClosing) return false;
return this.indexManager.flushToWAL(forceSyncWAL);
}
_setTTL(key, ttlMs, scheduleIndexUpdate = true) {
if (this.ttlMap.has(key)) clearTimeout(this.ttlMap.get(key).timeout);
if (ttlMs <= 0) {
if (this.ttlMap.has(key)) {
this.ttlMap.delete(key);
if (scheduleIndexUpdate && this.indexManager.has(key)) {
const meta = { ...this.indexManager.get(key) }; delete meta.expiry;
this.indexManager.set(key, meta);
this.indexManager.addChange(key, IndexManager.WAL_OP_SET || 1, meta);
this._scheduleIndexFlush();
}
} return;
}
const expiry = Date.now() + ttlMs;
const timeoutId = setTimeout(() => { /* ... expiry logic ... */
if (this.isClosing) return;
const currentTTL = this.ttlMap.get(key);
if (currentTTL && currentTTL.expiry === expiry) {
this.emit('log', `KV Core: TTL expired for key "${key}". Deleting.`);
this.delete(key);
if (this.ttlMap.has(key) && this.ttlMap.get(key).expiry === expiry) this.ttlMap.delete(key);
this.emit('expired', key);
}
}, ttlMs);
timeoutId.unref(); this.ttlMap.set(key, { expiry, timeout: timeoutId });
if (scheduleIndexUpdate && this.indexManager.has(key)) {
const meta = { ...this.indexManager.get(key) }; meta.expiry = expiry;
this.indexManager.set(key, meta);
this.indexManager.addChange(key, IndexManager.WAL_OP_SET || 1, meta);
this._scheduleIndexFlush();
}
}
async compact() {
if (this.isClosing || this.isCompacting) { /* ... warn ... */ return false; }
this.isCompacting = true; this.emit('compact_start');
let success = false;
try {
await this.flush(true); // Sync all pending changes first
const tempDataPath = `${this.options.dbPath}.compacting`;
const tempStorage = new Storage(tempDataPath, { bytesWrittenToDataFile: 0 }, this); // Temp stats for this op
const newIndexMap = new Map();
let newOffset = 0;
for (const [key, oldMeta] of this.indexManager.entries()) {
let value;
if (this.store.has(key)) value = this.store.get(key);
else { try { value = this.storage.readValue(oldMeta); this.stats.diskReads++; } catch (e) { /* ... skip ... */ continue; } }
try {
const newTempMeta = tempStorage.writeData(key, value, newOffset);
newOffset += newTempMeta.size;
if (oldMeta.expiry) newTempMeta.expiry = oldMeta.expiry; // Preserve TTL
newIndexMap.set(key, newTempMeta);
} catch (e) { /* ... skip ... */ continue; }
}
fs.fsyncSync(tempStorage.getFd()); tempStorage.close(); // Sync and close temp data file
this.storage.close(); // Close old data file
fs.renameSync(tempDataPath, this.options.dbPath); // Replace old with new
this.storage.reopenDataFile(); // Reopen new data file as primary
this.indexManager.index = newIndexMap; // Update IndexManager's internal index
this.indexManager.performCheckpoint(true, this.isClosing); // Force checkpoint, new index, clears WAL
this.stats.compactions++; this.stats.lastCompactionTime = Date.now();
this.stats.bytesWrittenToDataFile = newOffset; // Reset to size of new file
this.stats.wastedSpace = 0;
this.emit('compact_end', newOffset); success = true;
} catch (err) { /* ... error handling ... */ }
finally { this.isCompacting = false; }
return success;
}
_compactIfNeeded() {
if (this.isClosing || this.isCompacting) return;
this.stats.wastedSpace = this.storage.getWastedSpace(this.indexManager.index);
const dataFileSize = (this.storage.getFd() && !this.isClosing && fs.existsSync(this.options.dbPath)) ? fs.fstatSync(this.storage.getFd()).size : 0;
if (dataFileSize === 0 || this.indexManager.size === 0) { /* ... */ return; }
const wasteRatio = this.stats.wastedSpace / dataFileSize;
if (wasteRatio >= this.options.compact.threshold) {
this.compact().catch(err => this.emit('error', new Error(`KV Core: Automatic compaction failed: ${err.message}`)));
}
}
async close() {
if (this.isClosing) return true;
this.isClosing = true; this.emit('closing');
this.emit('log', 'KV Core: Close operation initiated.');
if (this._flushIntervalId) clearInterval(this._flushIntervalId);
if (this._compactIntervalId) clearInterval(this._compactIntervalId);
if (this._checkpointIntervalId) clearInterval(this._checkpointIntervalId);
for (const [, data] of this.ttlMap) if (data.timeout) clearTimeout(data.timeout);
this.ttlMap.clear();
try {
this.emit('log', 'KV Core: Final flush...');
await this._flushDataToStorage(true);
await this._flushIndexManagerToWAL(true);
this.emit('log', 'KV Core: Final checkpoint...');
if (this.indexManager) await this.indexManager.performCheckpoint(true, true); // true for sync, true for closing
} catch (err) { this.emit('error', new Error(`KV Core: Error during final operations on close: ${err.message}`)); }
this.emit('log', 'KV Core: Closing submodules...');
if (this.storage) this.storage.close();
if (this.indexManager) this.indexManager.close();
process.removeListener('exit', this._boundClose);
process.removeListener('SIGINT', this._boundCloseAndExitSignal);
/* ... remove other listeners ... */
this.emit('log', 'KV Core: Database closed.');
this.emit('close'); this.removeAllListeners();
return true;
}
}
module.exports = KV;