indinis
Version:
A storage library using LSM trees for storage and B-trees for indices with MVCC support
349 lines (314 loc) • 18.1 kB
text/typescript
// @tssrc/test/concurrent_engine_operations.test.ts
import { Indinis, IndinisOptions, StorageValue, IndexOptions, IndexInfo, ExtWALManagerConfigJs } from '../index';
import * as fs from 'fs';
import * as path from 'path';
import { rimraf } from 'rimraf';
import { Worker, isMainThread, workerData, parentPort } from 'worker_threads';
const TEST_DATA_DIR_BASE = path.resolve(__dirname, '..', '..', '.test-data', 'indinis-concurrent-engine-ops-v2'); // New version for clarity
const JEST_HOOK_TIMEOUT = 120000;
const TEST_CASE_TIMEOUT = 240000;
const s = (obj: any): string => JSON.stringify(obj);
const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
interface WorkerInput {
testDataDir: string; // Main data directory (shared)
workerId: number;
numOperations: number;
collectionPath: string;
indexPrefix: string;
sharedDataMapKeys: string[];
// IndinisOptions are now constructed by the worker using testDataDir and shared WAL dir
}
interface WorkerResult {
workerId: number;
operationsAttempted: number;
successfulCommits: number;
failedCommits: number;
readOperations: number;
// setData: Map<string, any>; // Removing this as reconciling last writer is too complex for this test
errors: string[];
}
// --- Helper to construct IndinisOptions consistently ---
// This will now be used by both the main thread and worker threads.
const getSharedTestIndinisOptions = (baseDataDir: string): IndinisOptions => {
const sharedWalDir = path.join(baseDataDir, 'shared_wal_ops'); // All instances use this
// The C++ layer will create this directory if it doesn't exist.
return {
checkpointIntervalSeconds: 3,
walOptions: {
wal_directory: sharedWalDir,
segment_size: 256 * 1024, // 256KB segments
wal_file_prefix: `concurrent_shared_wal_`,
flush_interval_ms: 50,
sync_on_commit_record: true,
background_flush_enabled: true,
},
sstableDataBlockUncompressedSizeKB: 16,
sstableCompressionType: "ZSTD",
sstableCompressionLevel: 1,
// enableCache: true, // Optional
// cacheOptions: { maxSize: 500, policy: "LRU", enableStats: true }
};
};
// --- Code to be executed by worker threads ---
if (!isMainThread) {
(async () => {
if (!parentPort) throw new Error("Worker started without parentPort");
const input = workerData as WorkerInput;
let db: Indinis | null = null;
const workerResult: WorkerResult = {
workerId: input.workerId,
operationsAttempted: 0,
successfulCommits: 0,
failedCommits: 0,
readOperations: 0,
errors: [],
};
try {
// Worker uses the shared testDataDir and constructs options using the shared helper
const workerDbOptions = getSharedTestIndinisOptions(input.testDataDir);
console.log(`Worker ${input.workerId}: Initializing DB for dir ${input.testDataDir} with WAL dir ${workerDbOptions.walOptions?.wal_directory}`);
db = new Indinis(input.testDataDir, workerDbOptions);
await delay(100 + Math.random() * 200); // Stagger worker starts slightly
for (let i = 0; i < input.numOperations; i++) {
workerResult.operationsAttempted++;
const opType = Math.random();
const localKeySuffix = `item_w${input.workerId}_op${i}`;
const sharedKeyIndex = Math.floor(Math.random() * input.sharedDataMapKeys.length);
const targetKey = (Math.random() < 0.3 && input.sharedDataMapKeys.length > 0) ?
input.sharedDataMapKeys[sharedKeyIndex] :
`${input.collectionPath}/${localKeySuffix}`;
try {
await db.transaction(async tx => {
if (opType < 0.55) { // 55% writes (increased slightly)
const value = {
worker: input.workerId, op: i, ts: Date.now(),
data: `W${input.workerId}|Op${i}|K${targetKey.split('/').pop()}|V${Math.random().toString(36).slice(2, 8)}`
};
await tx.set(targetKey, s(value));
} else if (opType < 0.90) { // 35% reads
await tx.get(targetKey);
workerResult.readOperations++;
} else { // 10% deletes
await tx.remove(targetKey);
}
});
workerResult.successfulCommits++;
} catch (e: any) {
workerResult.failedCommits++;
workerResult.errors.push(`Op ${i} on key ${targetKey}: ${e.message.substring(0, 100)}`); // Keep error msg short
}
if (i % 50 === 0) await delay(Math.random() * 5);
}
} catch (e: any) {
workerResult.errors.push(`Worker ${input.workerId} CRITICAL error: ${e.message.substring(0, 200)}`);
console.error(`Worker ${input.workerId} CRITICAL ERROR: ${e.message}`);
} finally {
if (db) {
try {
// console.log(`Worker ${input.workerId} closing DB.`);
await db.close();
} catch (closeErr: any) {
workerResult.errors.push(`Worker ${input.workerId} DB close error: ${closeErr.message.substring(0,100)}`);
}
}
parentPort.postMessage(workerResult);
}
})();
}
// --- End Worker Thread Code ---
describe('Indinis Concurrent Engine Operations (Multi-Worker Stress Test v2)', () => {
let mainDbInstance: Indinis | null = null;
let currentTestDir: string; // Root data directory for this specific test execution
const COLLECTION_PATH = 'concurrent_docs_v2'; // Unique collection path
const INDEX_PREFIX = 'idx_concurrent_v2';
const NUM_WORKERS = process.env.CI ? 2 : 4; // Fewer workers on CI for stability/speed
const OPS_PER_WORKER = process.env.CI ? 150 : 250;
const NUM_SHARED_KEYS = 10;
beforeAll(async () => {
await fs.promises.mkdir(TEST_DATA_DIR_BASE, { recursive: true });
}, JEST_HOOK_TIMEOUT);
afterAll(async () => {
console.log("[CONCURRENT ENGINE OPS SUITE END V2] Cleaning up base directory...");
if (fs.existsSync(TEST_DATA_DIR_BASE)) {
await rimraf(TEST_DATA_DIR_BASE).catch(err => console.error(`Base cleanup error:`, err));
}
}, JEST_HOOK_TIMEOUT);
beforeEach(async () => {
const randomSuffix = `${Date.now()}-${Math.random().toString(36).substring(7)}`;
currentTestDir = path.join(TEST_DATA_DIR_BASE, `test-${randomSuffix}`);
await fs.promises.mkdir(currentTestDir, { recursive: true });
// The shared WAL directory will be created by the C++ layer if it doesn't exist
// when the first Indinis instance (mainDbInstance) is created.
console.log(`\n[CONCURRENT ENGINE OPS TEST V2 START] Using data directory: ${currentTestDir}`);
mainDbInstance = new Indinis(currentTestDir, getSharedTestIndinisOptions(currentTestDir));
await delay(500);
});
afterEach(async () => {
const dirToClean = currentTestDir;
console.log(`[CONCURRENT ENGINE OPS TEST V2 END - ${path.basename(dirToClean)}] Closing main DB...`);
if (mainDbInstance) {
try { await mainDbInstance.close(); } catch (e) { console.error("Error closing main DB:", e); }
mainDbInstance = null;
}
await delay(1200);
if (dirToClean && fs.existsSync(dirToClean)) {
console.log(`[CONCURRENT ENGINE OPS TEST V2 END - ${path.basename(dirToClean)}] Cleaning up:`, dirToClean);
await rimraf(dirToClean, { maxRetries: 3, retryDelay: 1000 }).catch(err =>
console.error(`Cleanup error for ${dirToClean}:`, err)
);
}
}, JEST_HOOK_TIMEOUT);
it('should handle concurrent transactions, DDL, and checkpoints from multiple workers', async () => {
if (!mainDbInstance) throw new Error("Main DB not initialized for test");
const sharedDataMapKeys: string[] = [];
const finalExpectedState = new Map<string, any>(); // For more precise final verification
for (let i = 0; i < NUM_SHARED_KEYS; i++) {
const key = `${COLLECTION_PATH}/shared_item_${i}`;
sharedDataMapKeys.push(key);
const initialDoc = { content: "initial_shared_content", version: 0, writer: "main" };
finalExpectedState.set(key, initialDoc); // Set initial expected state
}
await mainDbInstance.transaction(async tx => {
for (const key of sharedDataMapKeys) {
await tx.set(key, s(finalExpectedState.get(key)));
}
});
console.log(`Initialized ${NUM_SHARED_KEYS} shared keys.`);
const workerPromises: Promise<WorkerResult>[] = [];
console.log(`Starting ${NUM_WORKERS} data worker threads...`);
for (let i = 0; i < NUM_WORKERS; i++) {
const workerInput: WorkerInput = {
testDataDir: currentTestDir, // All workers use the same main data directory
workerId: i,
numOperations: OPS_PER_WORKER,
collectionPath: COLLECTION_PATH,
indexPrefix: INDEX_PREFIX,
sharedDataMapKeys,
// Options are constructed inside the worker thread using getSharedTestIndinisOptions
// No need to pass indinisOptions in WorkerInput anymore if workers use shared helper
};
const worker = new Worker(__filename, { workerData: workerInput });
workerPromises.push(new Promise((resolve, reject) => {
worker.on('message', (result: WorkerResult) => {
// Update finalExpectedState based on worker's successful writes
// This is still a "last writer wins" view from the worker's perspective,
// but helps reconcile for final check.
// result.setData.forEach((value, key) => finalExpectedState.set(key, value));
resolve(result);
});
worker.on('error', reject);
worker.on('exit', (code) => {
if (code !== 0) reject(new Error(`Worker ${i} stopped with exit code ${code}`));
});
}));
}
let ddlOperationsCompleted = 0;
const ddlWorker = async () => {
console.log("DDL & Checkpoint control operations starting...");
// More DDL ops to stress index manager locking
for (let i = 0; i < Math.max(5, NUM_WORKERS * OPS_PER_WORKER / 150); i++) {
if (!mainDbInstance) { console.warn("DDL Worker: mainDbInstance became null."); break; }
try {
const idxName = `${INDEX_PREFIX}_dyn_field_${i}`;
const actionRoll = Math.random();
if (actionRoll < 0.5) { // Create index
console.log(` DDL Control: Creating index ${idxName}`);
await mainDbInstance.createIndex(COLLECTION_PATH, idxName, { field: `dynamicField${i % 3}` });
ddlOperationsCompleted++;
} else if (actionRoll < 0.75 && ddlOperationsCompleted > 1) { // Delete an index
const indexes = await mainDbInstance.listIndexes(COLLECTION_PATH);
const dynIndexes = indexes.filter(idx => idx.name.startsWith(`${INDEX_PREFIX}_dyn_field_`));
if (dynIndexes.length > 0) {
const idxToDelete = dynIndexes[Math.floor(Math.random() * dynIndexes.length)].name;
console.log(` DDL Control: Deleting index ${idxToDelete}`);
await mainDbInstance.deleteIndex(idxToDelete);
ddlOperationsCompleted++; // Count as an operation
}
} else if (mainDbInstance) { // Force checkpoint
console.log(` DDL Control: Forcing checkpoint...`);
await mainDbInstance.forceCheckpoint();
ddlOperationsCompleted++;
}
} catch (e: any) {
console.error(` DDL Control: Error: ${e.message.substring(0,150)}`);
}
await delay(Math.random() * 400 + 200);
}
console.log("DDL & Checkpoint control operations finished.");
};
const ddlPromise = ddlWorker();
console.log("Waiting for all data worker threads to complete...");
const results = await Promise.all(workerPromises);
console.log("All data worker threads finished.");
await ddlPromise;
console.log("DDL/Checkpoint control operations also finished.");
let totalSuccessfulCommits = 0;
let totalFailedCommits = 0;
let totalErrors = 0;
results.forEach(res => {
totalSuccessfulCommits += res.successfulCommits;
totalFailedCommits += res.failedCommits;
totalErrors += res.errors.length;
if(res.errors.length > 0) console.warn(`Worker ${res.workerId} reported errors:`, res.errors.slice(0,2));
});
console.log(`\n--- Aggregated Worker Results ---`);
console.log(`Total Ops Attempted (all workers): ${NUM_WORKERS * OPS_PER_WORKER}`);
console.log(`Total Successful Commits: ${totalSuccessfulCommits}`);
console.log(`Total Failed Commits: ${totalFailedCommits}`);
console.log(`Total Worker Errors Logged (non-commit): ${totalErrors}`);
expect(totalFailedCommits).toBeLessThan(totalSuccessfulCommits * 0.15);
expect(totalErrors).toBeLessThan(NUM_WORKERS * OPS_PER_WORKER * 0.05);
console.log("\n--- Final Data Integrity Verification ---");
if (!mainDbInstance) {
console.log("Re-initializing main DB for final verification...");
mainDbInstance = new Indinis(currentTestDir, getSharedTestIndinisOptions(currentTestDir));
await delay(300);
}
// To verify, we need to know the *actual* final state.
// Since workers update `finalExpectedState` locally, we need a final pass
// through the DB to determine the *true* last committed state for each key.
// This is hard without knowing commit order.
// A simpler verification: read all keys written by workers and ensure they exist and are parseable.
const allKeysAttemptedByWorkers = new Set<string>();
for (let workerId = 0; workerId < NUM_WORKERS; workerId++) {
for (let i = 0; i < OPS_PER_WORKER; i++) {
const localKeySuffix = `item_w${workerId}_op${i}`;
allKeysAttemptedByWorkers.add(`${COLLECTION_PATH}/${localKeySuffix}`);
}
}
sharedDataMapKeys.forEach(k => allKeysAttemptedByWorkers.add(k));
console.log(`Verifying a sample of potentially ${allKeysAttemptedByWorkers.size} keys...`);
let checkedCount = 0;
let foundAndValidCount = 0;
let notFoundCount = 0;
const sampleKeysToVerify = Array.from(allKeysAttemptedByWorkers).slice(0, Math.min(500, allKeysAttemptedByWorkers.size)); // Verify a subset
await mainDbInstance.transaction(async tx => {
for (const key of sampleKeysToVerify) {
checkedCount++;
const retrievedStr = await tx.get(key);
if (retrievedStr) {
try {
const retrievedDoc = JSON.parse(retrievedStr as string);
expect(retrievedDoc).toHaveProperty('worker'); // Basic check
expect(retrievedDoc).toHaveProperty('data');
foundAndValidCount++;
} catch(e) {
console.error(`Error parsing JSON for key ${key} in final verification. Data: ${retrievedStr}`);
// Fail test immediately if data is unparsable
throw new Error(`Unparsable JSON for key ${key}`);
}
} else {
notFoundCount++; // This key was likely deleted by another worker as the final op
}
}
});
console.log(`Final verification: Checked ${checkedCount} keys. Found and valid: ${foundAndValidCount}. Not found (potentially deleted): ${notFoundCount}.`);
// We expect most keys to be either present and valid, or legitimately deleted.
// A high number of foundAndValidCount compared to checkedCount is good.
expect(foundAndValidCount + notFoundCount).toEqual(sampleKeysToVerify.length); // All checked keys should either be valid or null
expect(foundAndValidCount).toBeGreaterThan(0); // At least some data should exist and be valid
const cpHistory = await mainDbInstance.getCheckpointHistory();
console.log(`Final Checkpoint History (last 5):`, cpHistory.slice(-5).map(c => `ID:${c.checkpoint_id} St:${c.status}`));
expect(cpHistory.length).toBeGreaterThanOrEqual(Math.floor((NUM_WORKERS * OPS_PER_WORKER / 200) / 2)); // Heuristic: expect some checkpoints
}, TEST_CASE_TIMEOUT);
});