P8-ExchangeService/core/log_file_writer.js

365 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
Сервис интеграции ПП Парус 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;