UNPKG

wise-json-db

Version:

Blazing fast, crash-proof embedded JSON database for Node.js with batch operations, TTL, indexes, and segmented checkpointing.

362 lines (317 loc) 15.1 kB
// wise-json/wal-manager.js const fs = require('fs/promises'); const path = require('path'); // const logger = require('./logger'); // --- УДАЛЕНО function getWalPath(collectionDirPath, collectionName) { return path.join(collectionDirPath, `wal_${collectionName}.log`); } // +++ ИЗМЕНЕНИЕ: Добавлен параметр `logger` +++ async function initializeWal(walPath, collectionDirPath, logger) { const log = logger || require('./logger'); // Фоллбэк для обратной совместимости if (typeof walPath !== 'string') { log.error(`[WAL Critical] initializeWal: walPath не является строкой! Тип: ${typeof walPath}, Значение: ${walPath}`); throw new TypeError('walPath должен быть строкой в initializeWal'); } await fs.mkdir(collectionDirPath, { recursive: true }); try { await fs.access(walPath); } catch (e) { if (e.code === 'ENOENT') { await fs.writeFile(walPath, '', 'utf8'); } else { throw e; } } } function delay(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } // +++ ИЗМЕНЕНИЕ: Добавлен параметр `logger` +++ async function appendAndSyncWalRecord(walPath, text, logger, appendRetries = 5, fsyncRetries = 3, fsyncInitialDelayMs = 100) { const log = logger || require('./logger'); const lineToWrite = text + '\n'; let lastAppendError = null; for (let i = 0; i <= appendRetries; i++) { try { await fs.appendFile(walPath, lineToWrite, 'utf8'); lastAppendError = null; break; } catch (err) { lastAppendError = err; if (i < appendRetries && ['ENOSPC', 'EBUSY', 'EIO', 'EMFILE', 'EAGAIN'].includes(err.code)) { const wait = 100 * (i + 1); await delay(wait); continue; } else { log.error(`[WAL] Ошибка appendFile для WAL '${walPath}' (после ${i + 1} попыток): ${lastAppendError?.message}`); throw lastAppendError; } } } if (lastAppendError) { throw lastAppendError; } let fileHandle; let lastSyncError = null; let currentFsyncDelay = fsyncInitialDelayMs; for (let j = 0; j < fsyncRetries; j++) { fileHandle = undefined; try { fileHandle = await fs.open(walPath, 'r+'); await fileHandle.sync(); lastSyncError = null; break; } catch (syncErr) { lastSyncError = syncErr; log.warn(`[WAL] Ошибка sync для файла ${walPath} (попытка ${j + 1}/${fsyncRetries}): ${syncErr.message}`); if (j < fsyncRetries - 1) { await delay(currentFsyncDelay); currentFsyncDelay = Math.min(currentFsyncDelay * 2, 2000); } } finally { if (fileHandle) { try { await fileHandle.close(); } catch (closeErr) { log.warn(`[WAL] Ошибка закрытия fileHandle после попытки sync для ${walPath}: ${closeErr.message}`); } } } } if (lastSyncError) { log.error(`[WAL] КРИТИЧЕСКАЯ ОШИБКА: не удалось выполнить sync для ${walPath} после ${fsyncRetries} попыток. Ошибка: ${lastSyncError?.message}.`); throw lastSyncError; } } // +++ ИЗМЕНЕНИЕ: Добавлен параметр `logger` +++ async function appendWalEntry(walPath, entry, logger) { try { await appendAndSyncWalRecord(walPath, JSON.stringify(entry), logger); } catch (err) { throw err; } } // +++ ИЗМЕНЕНИЕ: Добавлен параметр `logger` +++ async function writeTransactionBlock(walPath, txid, ops, logger) { const nowISO = new Date().toISOString(); const block = []; block.push({ txn: 'start', id: txid, ts: nowISO }); for (const op of ops) { block.push({ txn: 'op', txid, col: op.colName, type: op.type, args: op.args, ts: op.ts || nowISO }); } block.push({ txn: 'commit', id: txid, ts: new Date().toISOString() }); const fullTextBlock = block.map(e => JSON.stringify(e)).join('\n'); try { await appendAndSyncWalRecord(walPath, fullTextBlock, logger); } catch (err) { throw err; } } async function readWal(walPath, sinceTimestamp = null, options = {}) { // +++ ИЗМЕНЕНИЕ: Получаем логгер из опций или используем фоллбэк +++ const log = options.logger || require('./logger'); const effectiveOptions = { strict: false, recover: false, isInitialLoad: false, ...options }; let rawContent; try { rawContent = await fs.readFile(walPath, 'utf8'); } catch (e) { if (e.code === 'ENOENT') return []; throw e; } const lines = rawContent.trim().split('\n'); const recoveredEntries = []; const transactionStates = {}; let cutoffDateTime = null; if (sinceTimestamp) { try { cutoffDateTime = Date.parse(sinceTimestamp); if (isNaN(cutoffDateTime)) { log.warn(`[WAL] Невалидный sinceTimestamp '${sinceTimestamp}' при чтении ${walPath}. Фильтрация по времени отключена.`); cutoffDateTime = null; } } catch (e) { log.warn(`[WAL] Ошибка парсинга sinceTimestamp '${sinceTimestamp}' (${e.message}) при чтении ${walPath}. Фильтрация по времени отключена.`); cutoffDateTime = null; } } for (const [idx, line] of lines.entries()) { const currentLineNumber = idx + 1; if (!line.trim()) continue; const MAX_LINE_LEN = 20 * 1024 * 1024; if (line.length > MAX_LINE_LEN) { const msg = `[WAL] Строка ${currentLineNumber} в ${walPath} превышает лимит длины (${line.length} > ${MAX_LINE_LEN}), пропускается.`; if (effectiveOptions.strict) { log.error(msg + " (strict mode)"); throw new Error(msg); } log.warn(msg); continue; } let entry; try { entry = JSON.parse(line); } catch (e) { const errorContext = `Ошибка парсинга JSON на строке ${currentLineNumber} в ${walPath}: ${e.message}.`; const linePreview = line.substring(0, 150) + (line.length > 150 ? '...' : ''); if (typeof effectiveOptions.onError === 'function') { try { effectiveOptions.onError(e, line, currentLineNumber); } catch (userCallbackError) { log.error(`[WAL] Ошибка в пользовательском onError callback: ${userCallbackError.message}`); } } if (effectiveOptions.strict) { log.error(errorContext + ` Содержимое (начало): "${linePreview}" (strict mode).`); throw new Error(errorContext + ` (strict mode).`); } log.warn(errorContext + ` Содержимое (начало): "${linePreview}" (строка пропущена).`); continue; } if (typeof entry !== 'object' || entry === null) { log.warn(`[WAL] Запись на строке ${currentLineNumber} в ${walPath} не является объектом. Пропущена.`); continue; } if (entry.txn) { const txTimestampStr = entry.ts; const txId = entry.id || entry.txid; if (!txId) { log.warn(`[WAL] Транз. запись '${entry.txn}' без ID на строке ${currentLineNumber}. Игнор.`); continue; } if (entry.txn === 'start') { if (transactionStates[txId]) { log.warn(`[WAL] Повтор TXN_START '${txId}' на стр ${currentLineNumber}. Старая отменена.`); } transactionStates[txId] = { ops: [], committed: false, startLine: currentLineNumber, timestampStr: txTimestampStr }; } else if (entry.txn === 'op') { if (!transactionStates[txId] || transactionStates[txId].committed) { continue; } transactionStates[txId].ops.push(entry); } else if (entry.txn === 'commit') { if (!transactionStates[txId] || transactionStates[txId].committed) { continue; } transactionStates[txId].committed = true; transactionStates[txId].commitLine = currentLineNumber; transactionStates[txId].commitTimestampStr = txTimestampStr; } } else { const entryTsSource = entry.doc?.updatedAt || (Array.isArray(entry.docs) && entry.docs.length > 0 && entry.docs[0]?.updatedAt) || entry.data?.updatedAt || entry.ts; let entryDateTime = entryTsSource ? Date.parse(entryTsSource) : null; if (entryTsSource && isNaN(entryDateTime)) { entryDateTime = null; } if (cutoffDateTime !== null && (entryDateTime === null || entryDateTime <= cutoffDateTime)) { continue; } recoveredEntries.push(entry); } } for (const txid of Object.keys(transactionStates)) { const state = transactionStates[txid]; if (state.committed) { let txCommitDateTime = state.commitTimestampStr ? Date.parse(state.commitTimestampStr) : null; if(state.commitTimestampStr && isNaN(txCommitDateTime)) txCommitDateTime = null; if (cutoffDateTime !== null && (txCommitDateTime === null || txCommitDateTime <= cutoffDateTime)) { continue; } for (const op of state.ops) { recoveredEntries.push({ ...op, _txn_applied_from_wal: true, _tx_origin_id: txid }); } } else { log.warn(`[WAL] Транзакция ${txid} (начата на строке ${state.startLine}) в ${walPath} не завершена (нет COMMIT) и проигнорирована.`); } } const logMsg = `[WAL] Завершено чтение ${walPath}. Обработано строк: ${lines.length}. Записей для применения: ${recoveredEntries.length}.` + (sinceTimestamp ? ` (Фильтр по времени: после ${sinceTimestamp})` : ``); if (effectiveOptions.isInitialLoad) { log.log(logMsg.replace('[WAL]', '[WAL Init]')); } return recoveredEntries; } // +++ ИЗМЕНЕНИЕ: Добавлен параметр `logger` +++ async function compactWal(walPath, checkpointTimestamp = null, logger) { const log = logger || require('./logger'); if (!checkpointTimestamp) { return; } let checkpointTimeNum; try { checkpointTimeNum = Date.parse(checkpointTimestamp); if (isNaN(checkpointTimeNum)) { log.error(`[WAL] Невалидный checkpointTimestamp '${checkpointTimestamp}' при компакции WAL ${walPath}. ОТМЕНА.`); return; } } catch (e) { log.error(`[WAL] Ошибка парсинга checkpointTimestamp '${checkpointTimestamp}' (${e.message}) при компакции WAL ${walPath}. ОТМЕНА.`); return; } const allCurrentWalEntries = await readWal(walPath, null, { recover: true, strict: false, logger: log }); const entriesToKeep = []; for (const entry of allCurrentWalEntries) { let entryTime = null; if (entry._txn_applied_from_wal && entry.ts) { entryTime = Date.parse(entry.ts); } else if (!entry.txn) { const entryTsSource = entry.doc?.updatedAt || (Array.isArray(entry.docs) && entry.docs.length > 0 && entry.docs[0]?.updatedAt) || entry.data?.updatedAt || entry.ts; entryTime = entryTsSource ? Date.parse(entryTsSource) : null; } if (entryTime !== null && !isNaN(entryTime)) { if (entryTime > checkpointTimeNum) { entriesToKeep.push(entry); } } } const cleanEntriesToKeep = entriesToKeep.map(e => { const { _txn_applied_from_wal, _tx_origin_id, ...rest } = e; return rest; }); const newWalContent = cleanEntriesToKeep.map(e => JSON.stringify(e)).join('\n') + (cleanEntriesToKeep.length > 0 ? '\n' : ''); let attempt = 0; const maxAttempts = 3; while (true) { try { await fs.writeFile(walPath, newWalContent, 'utf8'); let fileHandleCompact; try { fileHandleCompact = await fs.open(walPath, 'r+'); await fileHandleCompact.sync(); } catch (syncErr) { log.warn(`[WAL] Ошибка sync после перезаписи WAL ${walPath} при компакции: ${syncErr.message}`); } finally { if (fileHandleCompact !== undefined) { await fileHandleCompact.close().catch(closeErr => log.warn(`[WAL] Ошибка закрытия fileHandle WAL ${walPath} после sync в compactWal: ${closeErr.message}`)); } } log.log(`[WAL] Компакция WAL для ${walPath} завершена. Осталось ${cleanEntriesToKeep.length} записей (было до фильтрации: ${allCurrentWalEntries.length}).`); break; } catch (err) { attempt++; if (attempt < maxAttempts) { await delay(100 * attempt); } else { log.error(`[WAL] КРИТ. ОШИБКА перезаписи WAL ${walPath} при компакции (после ${maxAttempts} попыток): ${err.message}.`); break; } } } } module.exports = { getWalPath, initializeWal, readWal, compactWal, appendWalEntry, writeTransactionBlock };