UNPKG

indinis

Version:

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

198 lines (161 loc) 9.28 kB
// @filename: tssrc/test/btree_page_layout.test.ts import { Indinis, IndexOptions } from '../index'; import * as fs from 'fs'; import * as path from 'path'; import { rimraf } from 'rimraf'; // Helper to stringify values for storage const s = (obj: any): string => JSON.stringify(obj); const TEST_DATA_DIR_BASE = path.resolve(__dirname, '..', '..', '.test-data', 'indinis-btree-page-layout'); // --- TypeScript Type Augmentation & Helpers --- // --- TypeScript Type Augmentation & Helpers --- interface BTreeDebugScanEntry { key: Buffer; value: Buffer; } declare module '../index' { interface Indinis { debug_scanBTree(indexName: string, startKeyBuffer: Buffer, endKeyBuffer: Buffer): Promise<BTreeDebugScanEntry[]>; } } function createTsEncodedFieldKeyPart(fieldValue: string | null): Buffer { if (fieldValue === null) return Buffer.from([0x00]); return Buffer.from(fieldValue, 'utf8'); } // --- Test Data Interface --- interface LayoutTestDoc { name: string; email: string; } describe('B-Tree Page Layout and MVCC Integrity Test', () => { let db: Indinis; let testDataDir: string; const colPath = 'layout_test_users'; const indexName = 'idx_layout_email'; const indexOptions: IndexOptions = { field: 'email' }; jest.setTimeout(20000); // --- Setup & Teardown (unchanged) --- beforeAll(async () => { await fs.promises.mkdir(TEST_DATA_DIR_BASE, { recursive: true }); }); 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 }); db = new Indinis(testDataDir); // Create the index once before all tests in this suite run. await db.createIndex(colPath, indexName, indexOptions); console.log(`\n[PAGE LAYOUT TEST START] Using data directory: ${testDataDir}`); }); afterEach(async () => { if (db) await db.close(); if (fs.existsSync(testDataDir)) await rimraf(testDataDir); }); afterAll(async () => { if (fs.existsSync(TEST_DATA_DIR_BASE)) await rimraf(TEST_DATA_DIR_BASE); }); /** * Test helper to verify the index. THIS IS THE FIX. * It now correctly processes raw, multi-versioned data from the debug scan * to determine the final, visible state of each document in the index. */ async function verifyIndexForEmail(email: string | null, expectedPrimaryKeys: string[]) { const encodedEmail = createTsEncodedFieldKeyPart(email); // debug_scanBTree returns ALL raw versions for the given key part. const rawIndexEntries = await db.debug_scanBTree(indexName, encodedEmail, encodedEmail); // --- START OF CORRECTED MVCC LOGIC --- // Step 1: Group all found versions by their primary key. const versionsByPk = new Map<string, { btreeValue: Buffer, txnId: number }[]>(); for (const entry of rawIndexEntries) { // The value in the B-Tree index is the primary key (or empty for a tombstone). // A tombstone entry still refers to a primary key via its composite B-Tree key. // Let's decode the composite key to reliably get the PK. // For a robust test, we would need a NAPI binding for decodeCompositeKey. // For this test, we'll make a simplifying assumption that the value field contains the PK. const primaryKey = entry.value.toString('utf8'); // If the primaryKey is empty, it's a tombstone. We need to find the PK it belongs to // by decoding the composite B-Tree key. Since we don't have that decoder in JS, // we'll make another assumption for this test: a tombstone's composite key will match // a live entry's composite key except for the TxnId. This is too complex for a clean test. // Let's simplify: A tombstone's *value* is an empty buffer. // A tombstone's *key* still contains the primary key. // The `decodeCompositeKey` is needed. Let's assume we can get the PK from the B-Tree value. // Let's try a different, more robust approach. // Let's find the primary key from the composite key itself. const compositeKeyStr = entry.key.toString('utf-8'); // key format: <email>\0<primary_key><desc_txn_id> const nullSeparatorIndex = compositeKeyStr.indexOf('\0'); if (nullSeparatorIndex === -1) continue; // Malformed key, skip // The PK is between the null separator and the final 8-byte TxnId. const pkFromKey = compositeKeyStr.substring(nullSeparatorIndex + 1, compositeKeyStr.length - 8); const txnIdBuffer = entry.key.slice(-8); const invertedTxnId = txnIdBuffer.readBigUInt64BE(0); const txnId = Number(0xFFFFFFFFFFFFFFFFn - invertedTxnId); if (!versionsByPk.has(pkFromKey)) { versionsByPk.set(pkFromKey, []); } versionsByPk.get(pkFromKey)!.push({ btreeValue: entry.value, txnId }); } const livePrimaryKeys = new Set<string>(); // Step 2: For each document's versions, find the latest one and check its state. for (const [primaryKey, versions] of versionsByPk.entries()) { if (versions.length === 0) continue; // Find the version with the highest transaction ID (the most recent one). const latestVersion = versions.reduce((latest, current) => current.txnId > latest.txnId ? current : latest ); // A live entry has a non-empty buffer for its value. A tombstone has a zero-length buffer. if (latestVersion.btreeValue.length > 0) { livePrimaryKeys.add(primaryKey); } } const foundPrimaryKeys = Array.from(livePrimaryKeys).sort(); expectedPrimaryKeys.sort(); // --- END OF CORRECTED MVCC LOGIC --- console.log(` Verifying index for email '${email}': Found live keys [${foundPrimaryKeys.join(', ')}], Expected [${expectedPrimaryKeys.join(', ')}]`); expect(foundPrimaryKeys).toEqual(expectedPrimaryKeys); } // The test case `it(...)` block remains identical to the previous version. it('should correctly handle multiple inserts, updates, and deletes on a single B-Tree leaf page', async () => { // Step 1: Insert first item ('c@test.com') console.log("--- Step 1: Insert first item ---"); await db.transaction(async tx => { await tx.set(`${colPath}/user_c`, s({ name: 'Charlie', email: 'c@test.com' })); }); await verifyIndexForEmail('c@test.com', [`${colPath}/user_c`]); await verifyIndexForEmail('a@test.com', []); // Step 2: Insert a second item that sorts BEFORE the first ('a@test.com') console.log("\n--- Step 2: Insert item that sorts BEFORE existing item ---"); await db.transaction(async tx => { await tx.set(`${colPath}/user_a`, s({ name: 'Alice', email: 'a@test.com' })); }); await verifyIndexForEmail('c@test.com', [`${colPath}/user_c`]); await verifyIndexForEmail('a@test.com', [`${colPath}/user_a`]); console.log(" Successfully inserted second item, B-Tree remains consistent."); // Step 3: Insert a third item that sorts in the MIDDLE ('b@test.com') console.log("\n--- Step 3: Insert item that sorts in the MIDDLE ---"); await db.transaction(async tx => { await tx.set(`${colPath}/user_b`, s({ name: 'Bob', email: 'b@test.com' })); }); await verifyIndexForEmail('a@test.com', [`${colPath}/user_a`]); await verifyIndexForEmail('b@test.com', [`${colPath}/user_b`]); await verifyIndexForEmail('c@test.com', [`${colPath}/user_c`]); console.log(" Successfully inserted third item."); // Step 4: Update an item, which changes its indexed value console.log("\n--- Step 4: Update item, changing indexed value ---"); await db.transaction(async tx => { await tx.set(`${colPath}/user_b`, s({ name: 'Robert', email: 'z@test.com' })); }); await verifyIndexForEmail('b@test.com', []); // Old email should now be gone. await verifyIndexForEmail('z@test.com', [`${colPath}/user_b`]); console.log(" Successfully updated item's indexed field."); // Step 5: Delete an item console.log("\n--- Step 5: Delete an item ---"); await db.transaction(async tx => { await tx.remove(`${colPath}/user_a`); }); await verifyIndexForEmail('a@test.com', []); await verifyIndexForEmail('z@test.com', [`${colPath}/user_b`]); await verifyIndexForEmail('c@test.com', [`${colPath}/user_c`]); console.log(" Successfully deleted item, index updated correctly."); }); });