/* Сервис интеграции ПП Парус 8 с WEB API Модуль ядра: запись протокола работы в файл */ //---------------------- // Подключение библиотек //---------------------- const fs = require("fs"); //Файловая система const path = require("path"); //Пути файловой системы const { validateObject, getNowString, makeErrorText } = require("./utils"); //Вспомогательные функции const { ServerError } = require("./server_errors"); //Типовая ошибка const { SCONSOLE_LOG_COLOR_PATTERN_ERR, SCONSOLE_LOG_COLOR_PATTERN_INF, SERR_COMMON, SLOG_PREFIX_INF, SLOG_PREFIX_ERR, SLOG_PREFIX_PROTOCOL_ERR } = require("./constants"); //Общие константы const prmsLogFileWriterSchema = require("../models/prms_log_file_writer"); //Схемы валидации параметров функций модуля //-------------------------- // Глобальные идентификаторы //-------------------------- //Таймаут ожидания опустошения очереди записи в файл при закрытии (мс) const NLOG_FILE_CLOSE_TIMEOUT = 10000; //Интервал повторного сообщения об ошибке записи в файл (мс) const NLOG_FILE_ERROR_REPORT_INTERVAL = 10000; //Интервал повторного открытия файла лога после ошибки (мс) const NLOG_FILE_REOPEN_INTERVAL = 10000; //Максимум строк в очереди записи (защита памяти при высокой нагрузке / медленном диске) const NLOG_FILE_MAX_PENDING = 2000; //------------ // Тело модуля //------------ //Дополнение числа ведущим нулём до двух знаков const pad2 = nVal => String(nVal).padStart(2, "0"); //Формирование имени файла лога: log_yyyymmdd_hh24miss.log const buildLogFileName = () => { const dNow = new Date(); const sDate = `${dNow.getFullYear()}${pad2(dNow.getMonth() + 1)}${pad2(dNow.getDate())}`; const sTime = `${pad2(dNow.getHours())}${pad2(dNow.getMinutes())}${pad2(dNow.getSeconds())}`; return `log_${sDate}_${sTime}.log`; }; //Проверка, что каталог существует и доступен для записи const checkLogDirWritable = sPath => { try { //Каталог должен существовать if (!fs.existsSync(sPath) || !fs.statSync(sPath).isDirectory()) return false; //Права на запись fs.accessSync(sPath, fs.constants.W_OK); //Пробная запись const sProbe = path.join(sPath, `.exs_log_write_probe_${process.pid}`); fs.writeFileSync(sProbe, ""); fs.unlinkSync(sProbe); return true; } catch { return false; } }; //Признак, что поток записи в файл непригоден const isLogStreamBroken = stream => !stream || stream.destroyed || !stream.writable; //Гарантия перевода строки в конце текста const ensureLogLineEnd = sLine => (sLine.endsWith("\n") ? sLine : `${sLine}\n`); //Безопасное уничтожение потока записи (removeAllListeners + destroy) const destroyLogStream = stream => { if (!stream) return null; try { stream.removeAllListeners("error"); if (!stream.destroyed) stream.destroy(); return null; } catch (e) { return e; } }; //Класс записи протокола в файл class LogFileWriter { //Конструктор класса constructor() { this.logStream = null; this.sLogFile = null; this.bFileLog = false; this.bClosing = false; this.writeChain = Promise.resolve(); this.nQueued = 0; this.nLastFileWriteErrorAt = 0; this.nLastLogFileReopenAt = 0; this.sPendingLogLine = null; } //Признак активного протоколирования в файл isEnabled() { return this.bFileLog === true; } //Сброс состояния писателя (без уничтожения потока) clearState() { this.bFileLog = false; this.bClosing = false; this.logStream = null; this.sLogFile = null; this.writeChain = Promise.resolve(); this.nQueued = 0; this.nLastFileWriteErrorAt = 0; this.nLastLogFileReopenAt = 0; this.sPendingLogLine = null; } //Создание потока записи в файл createWriteStream(sFilePath) { const stream = fs.createWriteStream(sFilePath, { flags: "a", encoding: "utf8" }); stream.on("error", e => this.handleWriteError(e)); return stream; } //Уничтожение текущего потока записи без сброса пути файла destroyStream() { destroyLogStream(this.logStream); this.logStream = null; } //Уведомление в консоль о протоколировании в файл (без записи в файл) notifyOpenedConsole() { if (!this.isEnabled() || !this.sLogFile) return; const sNow = getNowString(); console.log(SCONSOLE_LOG_COLOR_PATTERN_INF, `${sNow} ${SLOG_PREFIX_INF}: `, `Протоколирование в файл: ${this.sLogFile}`); } //Уведомление в консоль о результате закрытия файла лога (без записи в файл) notifyClosedConsole(sLogFile, e) { const sNow = getNowString(); if (e) { console.log( SCONSOLE_LOG_COLOR_PATTERN_ERR, `${sNow} ${SLOG_PREFIX_ERR}: `, `Ошибка закрытия файла лога${sLogFile ? ` (${sLogFile})` : ""}: ${makeErrorText(e)}` ); return; } if (!sLogFile) return; console.log(SCONSOLE_LOG_COLOR_PATTERN_INF, `${sNow} ${SLOG_PREFIX_INF}: `, `Файл лога закрыт: ${sLogFile}`); } //Сообщение об ошибке записи в файл с ограничением частоты reportWriteError(e) { const nNow = Date.now(); //Ограничиваем спам повторных сообщений if (nNow - this.nLastFileWriteErrorAt < NLOG_FILE_ERROR_REPORT_INTERVAL) return; this.nLastFileWriteErrorAt = nNow; const sNow = getNowString(); console.log(SCONSOLE_LOG_COLOR_PATTERN_ERR, `${sNow} ${SLOG_PREFIX_PROTOCOL_ERR} В ФАЙЛ: `, makeErrorText(e)); } //Обработка ошибки записи в файл лога handleWriteError(e, sFailedLine) { //Если протоколирование в файл не активно - выходим if (!this.bFileLog && !this.sLogFile) return; //Сообщим в консоль (с ограничением спама) this.reportWriteError(e); //Запомним неудачную строку для однократного повтора после переоткрытия if (sFailedLine) this.sPendingLogLine = sFailedLine; //При закрытии не пытаемся восстановить поток - close/release завершат освобождение if (this.bClosing) return; //Повтор только после переоткрытия непригодного потока (без цикла на "живом" потоке) if (isLogStreamBroken(this.logStream)) { if (this.reopen()) this.retryPendingLine(); } } //Открытие файла лога open(sLogPath) { //Если путь не задан - протоколирование в файл не ведётся if (!sLogPath || !String(sLogPath).trim()) return; //Нормализуем путь const sPath = path.resolve(String(sLogPath).trim()); //Проверяем параметры let sCheckResult = validateObject({ sLogPath: sPath }, prmsLogFileWriterSchema.open, "Параметры функции открытия файла лога"); if (sCheckResult) throw new ServerError(SERR_COMMON, sCheckResult); //Каталог должен существовать и быть доступен для записи if (!checkLogDirWritable(sPath)) throw new ServerError(SERR_COMMON, `Каталог размещения логов не существует или недоступен для записи: ${sPath}`); //Если уже открыт - сначала освободим без сообщения в console if (this.logStream || this.bFileLog) this.release(undefined, true); //Формируем полный путь файла сессии const sFullPath = path.join(sPath, buildLogFileName()); //Открываем поток записи this.logStream = this.createWriteStream(sFullPath); this.sLogFile = sFullPath; this.bFileLog = true; } //Повторное открытие текущего файла лога reopen() { //Нет пути файла или идёт закрытие - не переоткрываем if (!this.sLogFile || this.bClosing) return false; //Ограничиваем частоту переоткрытия const nNow = Date.now(); if (this.nLastLogFileReopenAt > 0 && nNow - this.nLastLogFileReopenAt < NLOG_FILE_REOPEN_INTERVAL) return false; this.nLastLogFileReopenAt = nNow; try { //Освободим прежний поток, если он ещё есть this.destroyStream(); //Откроем тот же файл на дозапись this.logStream = this.createWriteStream(this.sLogFile); this.bFileLog = true; return true; } catch (e) { this.logStream = null; this.reportWriteError(e); return false; } } //Освобождение ресурсов файла лога release(eNotify, bSilentNotify) { const stream = this.logStream; const sLogFile = this.sLogFile; this.clearState(); const eClose = destroyLogStream(stream); //Результат закрытия - только в console if (!bSilentNotify && sLogFile) this.notifyClosedConsole(sLogFile, eNotify !== undefined ? eNotify : eClose); } //Запись одной строки в файл writeLine(sData) { return new Promise((resolve, reject) => { const stream = this.logStream; //Уже поставленные в очередь строки дописываем даже при bClosing (close ждёт writeChain) if (isLogStreamBroken(stream) || !this.bFileLog) { resolve(); return; } //Пишем строку целиком stream.write(sData, err => { if (err) reject(err); else resolve(); }); }); } //Постановка записи в сериализованную очередь (с ограничением глубины) scheduleWrite(sData) { //Защита от разрастания очереди при отставании диска if (this.nQueued >= NLOG_FILE_MAX_PENDING) { this.reportWriteError(new Error(`Переполнена очередь записи в файл лога (лимит ${NLOG_FILE_MAX_PENDING}), строка пропущена`)); return; } this.nQueued++; this.writeChain = this.writeChain .then(() => this.writeLine(sData)) .catch(e => this.handleWriteError(e, sData)) .finally(() => { if (this.nQueued > 0) this.nQueued--; }); } //Повторная запись строки, не попавшей в файл из-за ошибки retryPendingLine() { //Нечего повторять или нельзя писать if (!this.sPendingLogLine || this.bClosing || isLogStreamBroken(this.logStream)) return; const sData = this.sPendingLogLine; this.sPendingLogLine = null; this.scheduleWrite(sData); } //Постановка строки в очередь записи в файл (без ожидания записи вызывающей стороной) enqueue(sLine) { //Проверяем параметры if (typeof sLine !== "string") return; //Если файл не активен или идёт закрытие - ничего не делаем if (!this.bFileLog || this.bClosing) return; //Если поток потерян - попробуем переоткрыть if (isLogStreamBroken(this.logStream)) { if (!this.reopen()) return; } //Однократный повтор отложенной строки (если была ошибка записи) this.retryPendingLine(); if (isLogStreamBroken(this.logStream)) return; //Гарантируем перевод строки и ставим в очередь this.scheduleWrite(ensureLogLineEnd(sLine)); } //Синхронная дозапись строки в файл (авария / process.on("exit")) appendSync(sLine) { if (!this.sLogFile || typeof sLine !== "string") return; try { if (this.logStream) { this.destroyStream(); this.bFileLog = false; } fs.appendFileSync(this.sLogFile, ensureLogLineEnd(sLine), "utf8"); } catch (e) { this.reportWriteError(e); } } //Корректное закрытие файла лога async close() { //Если файла нет - нечего закрывать if (!this.logStream && !this.bFileLog && !this.sLogFile) return; //Фиксируем режим закрытия (новые строки и reopen не принимаем) this.bClosing = true; this.sPendingLogLine = null; const sLogFile = this.sLogFile; let nDrainTimer = null; try { //Дождёмся опустошения очереди записи (с ограничением по времени) const bDrained = await Promise.race([ this.writeChain.then(() => true).catch(() => true), new Promise(resolve => { nDrainTimer = setTimeout(() => resolve(false), NLOG_FILE_CLOSE_TIMEOUT); }) ]); if (nDrainTimer) { clearTimeout(nDrainTimer); nDrainTimer = null; } //Очередь не успела опустеть - принудительно освободим файл if (!bDrained) { this.reportWriteError( new Error(`Очередь записи в файл лога не опустела за ${NLOG_FILE_CLOSE_TIMEOUT} мс, файл будет закрыт принудительно`) ); this.release(); return; } //Закроем поток штатно await new Promise((resolve, reject) => { if (!this.logStream || this.logStream.destroyed) { resolve(); return; } this.logStream.end(err => { if (err) reject(err); else resolve(); }); }); } catch (e) { //Только сообщение - без reopen при закрытии this.reportWriteError(e); this.release(e); return; } finally { if (nDrainTimer) clearTimeout(nDrainTimer); } //Освобождаем состояние без повторного destroy уже закрытого потока if (this.logStream) { try { this.logStream.removeAllListeners("error"); } catch {} } this.clearState(); //Результат закрытия - только в console this.notifyClosedConsole(sLogFile, null); } //Аварийное синхронное освобождение файла лога closeSync() { if (!this.logStream && !this.bFileLog && !this.sLogFile) return; this.bClosing = true; this.release(); } } //----------------- // Интерфейс модуля //----------------- exports.LogFileWriter = LogFileWriter;