UNPKG

indinis

Version:

A storage library using LSM trees for storage and B-trees for indices with MVCC support

291 lines (241 loc) 13.1 kB
// @tssrc/test/wal.test.ts import { Indinis } from '../index'; import * as fs from 'fs'; import * as path from 'path'; import { rimraf } from 'rimraf'; const TEST_DATA_DIR_BASE = path.resolve(__dirname, '..', '..', '.test-data', 'indinis-wal-tests'); // Crude LogRecordType enum mapping for test (must match C++) enum LogRecordType { INVALID = 0, BEGIN_TXN = 1, COMMIT_TXN = 2, ABORT_TXN = 3, DATA_PUT = 4, DATA_DELETE = 5, } interface DecodedLogRecord { lsn: bigint; txn_id: bigint; type: LogRecordType; key: string; value: string; } // Basic parser for the WAL file for testing function parseWalFile(filePath: string): DecodedLogRecord[] { if (!fs.existsSync(filePath)) { return []; } const buffer = fs.readFileSync(filePath); const records: DecodedLogRecord[] = []; let offset = 0; while (offset < buffer.length) { const recordStartOffset = offset; // For debugging on error try { if (offset + 8 > buffer.length) break; // Not enough for LSN const lsn = buffer.readBigUInt64LE(offset); offset += 8; if (offset + 8 > buffer.length) break; // Not enough for TxnID const txn_id = buffer.readBigUInt64LE(offset); offset += 8; if (offset + 1 > buffer.length) break; // Not enough for type const type = buffer.readUInt8(offset); offset += 1; if (offset + 4 > buffer.length) break; // Not enough for keyLen const keyLen = buffer.readUInt32LE(offset); offset += 4; if (offset + keyLen > buffer.length) break; // Not enough for key data const key = buffer.toString('utf8', offset, offset + keyLen); offset += keyLen; if (offset + 4 > buffer.length) break; // Not enough for valLen const valLen = buffer.readUInt32LE(offset); offset += 4; if (offset + valLen > buffer.length) break; // Not enough for value data const value = buffer.toString('utf8', offset, offset + valLen); offset += valLen; records.push({ lsn, txn_id, type: type as LogRecordType, key, value }); } catch (e: any) { console.error("Error parsing WAL record starting at offset", recordStartOffset, "current offset", offset, "error:", e.message); // Log the problematic part of the buffer const errorContextLength = Math.min(32, buffer.length - recordStartOffset); console.error("Buffer around error (hex):", buffer.subarray(recordStartOffset, recordStartOffset + errorContextLength).toString('hex')); break; // Stop parsing on error } } return records; } describe('Indinis Basic WAL Functionality', () => { let db: Indinis; let testDataDir: string; let walFilePath: string; beforeEach(async () => { const randomSuffix = `${Date.now()}-${Math.random().toString(36).substring(7)}`; testDataDir = path.join(TEST_DATA_DIR_BASE, `test-${randomSuffix}`); await fs.promises.mkdir(testDataDir, { recursive: true }); const walDir = path.join(testDataDir, 'wal'); // Mirror C++ structure await fs.promises.mkdir(walDir, {recursive: true}); walFilePath = path.join(walDir, 'wal.log'); db = new Indinis(testDataDir); console.log(`\n[WAL TEST START] Using data directory: ${testDataDir}`); }); afterEach(async () => { console.log("[WAL TEST END] Closing database..."); if (db) await db.close(); console.log("[WAL TEST END] Cleaning up test directory..."); if (fs.existsSync(testDataDir)) await rimraf(testDataDir); }); afterAll(async () => { console.log("[WAL SUITE END] Cleaning up base directory..."); if (fs.existsSync(TEST_DATA_DIR_BASE)) await rimraf(TEST_DATA_DIR_BASE); }); it('should create a WAL file on initialization', () => { expect(fs.existsSync(walFilePath)).toBe(true); }); it('should log BEGIN_TXN, DATA_PUT, and COMMIT_TXN for a simple transaction', async () => { const key = 'walTestKey1'; const value = 'walTestValue1'; let txnId = -1; await db.transaction(async tx => { txnId = await tx.getId(); await tx.set(key, value); }); const records = parseWalFile(walFilePath); expect(records.length).toBeGreaterThanOrEqual(3); const beginRecord = records.find(r => r.txn_id === BigInt(txnId) && r.type === LogRecordType.BEGIN_TXN); const putRecord = records.find(r => r.txn_id === BigInt(txnId) && r.type === LogRecordType.DATA_PUT && r.key === key); const commitRecord = records.find(r => r.txn_id === BigInt(txnId) && r.type === LogRecordType.COMMIT_TXN); expect(beginRecord).toBeDefined(); expect(putRecord).toBeDefined(); expect(putRecord?.value).toContain(value); // Basic check, serializer adds type prefix expect(commitRecord).toBeDefined(); // Check LSN ordering (basic) if (beginRecord && putRecord && commitRecord) { expect(putRecord.lsn).toBeGreaterThan(beginRecord.lsn); expect(commitRecord.lsn).toBeGreaterThan(putRecord.lsn); } }); it('should log DATA_DELETE for a remove operation', async () => { const key = 'walTestKey2'; const value = 'initialValue'; let setupTxnId = -1; let deleteTxnId = -1; await db.transaction(async tx => { setupTxnId = await tx.getId(); await tx.set(key, value); }); await db.transaction(async tx => { deleteTxnId = await tx.getId(); await tx.remove(key); }); const records = parseWalFile(walFilePath); const deleteLogRecord = records.find(r => r.txn_id === BigInt(deleteTxnId) && r.type === LogRecordType.DATA_DELETE && r.key === key); expect(deleteLogRecord).toBeDefined(); expect(deleteLogRecord?.value).toBe('[TOMBSTONE]'); }); it('should log ABORT_TXN if a transaction is aborted', async () => { const key = 'walTestKeyAbort'; const value = 'neverCommitted'; let abortTxnId = -1; let txCtx: any; try { await db.transaction(async tx => { abortTxnId = await tx.getId(); txCtx = tx; // Save context to call abort await tx.set(key, value); throw new Error("Intentional abort"); }); } catch (e) { // Expected error, now check logs. // NAPI binding might call abort automatically. If not, call it here explicitly. if (txCtx && typeof txCtx.abort === 'function' && !txCtx.isAborted()) { console.log("Manually calling abort on Txn ", abortTxnId); await txCtx.abort(); // Ensure abort is called if error happens before internal auto-abort } } const records = parseWalFile(walFilePath); console.log("WAL Records after abort attempt:", records.filter(r => r.txn_id === BigInt(abortTxnId))); const abortLogRecord = records.find(r => r.txn_id === BigInt(abortTxnId) && r.type === LogRecordType.ABORT_TXN); expect(abortLogRecord).toBeDefined(); // Ensure no commit record for this txn const commitLogRecord = records.find(r => r.txn_id === BigInt(abortTxnId) && r.type === LogRecordType.COMMIT_TXN); expect(commitLogRecord).toBeUndefined(); }); it('should recover next LSN correctly after restart', async () => { let initialLsnRecords: DecodedLogRecord[] = []; await db.transaction(async tx => { await tx.set("key1", "val1"); }); await db.transaction(async tx => { await tx.set("key2", "val2"); }); await db.close(); // Close DB to flush WAL initialLsnRecords = parseWalFile(walFilePath); const lastInitialLsn = initialLsnRecords.length > 0 ? initialLsnRecords[initialLsnRecords.length - 1].lsn : 0n; console.log("Last LSN before restart: ", lastInitialLsn); // Reopen DB (simulates restart) db = new Indinis(testDataDir); let txnIdAfterRestart = -1; await db.transaction(async tx => { txnIdAfterRestart = await tx.getId(); await tx.set("key3", "val3"); }); await db.close(); const recordsAfterRestart = parseWalFile(walFilePath); console.log("WAL Records after restart:", recordsAfterRestart); const expectedLsnAfterRestart = lastInitialLsn + 1n; const firstRecordOfNewTxn = recordsAfterRestart.find(r => r.lsn === expectedLsnAfterRestart && r.type === LogRecordType.BEGIN_TXN); expect(firstRecordOfNewTxn).toBeDefined(); if (firstRecordOfNewTxn) { console.log("First LSN after restart: ", firstRecordOfNewTxn.lsn); expect(firstRecordOfNewTxn.lsn).toBe(lastInitialLsn + 1n); } }); it('should recover committed data after a simulated crash', async () => { const key1 = 'recoverKey1'; const value1 = 'recoveredValue1'; const key2 = 'recoverKey2'; const value2 = { msg: "recovered object" }; let txn1Id = -1; // --- Phase 1: Write data with first DB instance --- console.log("[RecoveryTest] Phase 1: Writing initial data."); await db.transaction(async tx => { txn1Id = await tx.getId(); await tx.set(key1, value1); await tx.set(key2, JSON.stringify(value2)); }); console.log(`[RecoveryTest] Phase 1: Txn ${txn1Id} committed.`); // CRITICAL: To simulate a crash, we ensure the WAL's COMMIT record is flushed, // but the main data structures (LSMTree SSTables, BTree pages via BPM) might not be. // Our StorageEngine::commitTransaction flushes the COMMIT WAL record. // We *don't* call db.close() here to simulate that caches weren't flushed. const recordsBeforeCrash = parseWalFile(walFilePath); const commitRecordExists = recordsBeforeCrash.some( r => r.txn_id === BigInt(txn1Id) && r.type === LogRecordType.COMMIT_TXN ); expect(commitRecordExists).toBe(true); // Ensure commit was logged console.log(`[RecoveryTest] Phase 1: WAL contains COMMIT for Txn ${txn1Id}. Simulating crash...`); // --- Phase 2: "Restart" - Create a new DB instance pointing to the same directory --- // The previous `db` instance will be GC'd without its explicit `close()` // (though in Jest's test environment, afterEach might call it anyway, // the key is that we are testing the startup of a *new* instance). console.log("[RecoveryTest] Phase 2: Creating new DB instance to trigger recovery."); db = new Indinis(testDataDir); // This will trigger performRecovery() console.log("[RecoveryTest] Phase 2: New DB instance created."); // --- Phase 3: Verify data --- console.log("[RecoveryTest] Phase 3: Verifying recovered data."); await db.transaction(async tx => { const rVal1 = await tx.get(key1); expect(rVal1).toBe(value1); console.log(` [Verify] ${key1} -> ${rVal1} (Expected: ${value1})`); const rVal2Str = await tx.get(key2); expect(rVal2Str).not.toBeNull(); if (rVal2Str) { expect(JSON.parse(rVal2Str as string)).toEqual(value2); console.log(` [Verify] ${key2} -> Parsed object matches (Expected: ${JSON.stringify(value2)})`); } }); console.log("[RecoveryTest] Phase 3: Verification complete."); // Bonus: Write another transaction to ensure LSN/TxnID continues correctly let txn2Id_afterRecovery = -1; await db.transaction(async tx => { txn2Id_afterRecovery = await tx.getId(); await tx.set("afterRecoveryKey", "new data"); }); expect(txn2Id_afterRecovery).toBeGreaterThan(txn1Id); // Txn IDs should progress console.log(`[RecoveryTest] New Txn ID after recovery: ${txn2Id_afterRecovery} (Original Txn ID: ${txn1Id})`); const recordsAfterRecoveryAndNewTxn = parseWalFile(walFilePath); const lastLsnAfterNewTxn = recordsAfterRecoveryAndNewTxn.length > 0 ? recordsAfterRecoveryAndNewTxn[recordsAfterRecoveryAndNewTxn.length - 1].lsn : 0n; const originalCommitLsn = recordsBeforeCrash.find(r => r.txn_id === BigInt(txn1Id) && r.type === LogRecordType.COMMIT_TXN)!.lsn; expect(lastLsnAfterNewTxn).toBeGreaterThan(originalCommitLsn); console.log(`[RecoveryTest] Last LSN in WAL after new Txn: ${lastLsnAfterNewTxn} (Original commit LSN: ${originalCommitLsn})`); }); });