101 lines
6.4 KiB
JavaScript
101 lines
6.4 KiB
JavaScript
// Атомарная запись состояния. Работник может умереть в любую миллисекунду,
|
||
// поэтому пишем во временный файл и переименовываем: переименование атомарно,
|
||
// недописанного состояния не бывает.
|
||
import { writeFileSync, renameSync, readFileSync, existsSync, mkdirSync, appendFileSync, unlinkSync } from 'node:fs';
|
||
import { randomBytes } from 'node:crypto';
|
||
import { dirname, join } from 'node:path';
|
||
|
||
export function writeJsonAtomic(path, value) {
|
||
// Превращаем в текст ДО того, как трогать файлы: непригодное значение не должно
|
||
// испортить уже лежащее там годное.
|
||
const text = JSON.stringify(value, null, 2);
|
||
mkdirSync(dirname(path), { recursive: true });
|
||
// 🪤 Имя временного файла обязано быть НЕПОВТОРИМЫМ. Время и номер процесса его таким
|
||
// не делают: один и тот же надзиратель в одном круге пишет в каталог работника несколько
|
||
// файлов состояния подряд, и две записи в одну миллисекунду дали бы одно имя — одна
|
||
// переименовала бы чужую недописанную половину в чужое готовое состояние. Случайный
|
||
// хвост убирает этот класс целиком.
|
||
const tmp = join(dirname(path), `.${Date.now()}-${process.pid}-${randomBytes(6).toString('hex')}.tmp`);
|
||
try {
|
||
writeFileSync(tmp, text, 'utf8');
|
||
pereimenovatUporno(tmp, path);
|
||
} catch (err) {
|
||
if (existsSync(tmp)) unlinkSync(tmp);
|
||
throw err;
|
||
}
|
||
}
|
||
|
||
// 🪤 ВИНДОВЫЙ ОТКАЗ «EPERM: operation not permitted, rename». Пойман живьём 02.08.2026 под
|
||
// нагрузкой семерых: четверо живых процессов правили один список занятий, и переименование
|
||
// упало. Причина не в правах, а в самой Windows: переименовать поверх файла нельзя, пока
|
||
// его хоть кто-то держит открытым — а держат его постоянно. Читатели списка занятий
|
||
// (`mayTouch`, `nextFreeItem`) читают БЕЗ замка, по замыслу; плюс на этой машине в файл
|
||
// заглядывают ещё и защитник Windows с поисковым указателем. Замок в `file-claims.mjs`
|
||
// от этого не спасает: он разводит ПИШУЩИХ, а мешает читающий.
|
||
//
|
||
// 🔴 Почему это не мелочь: ночью надзиратель зовёт замок на файлы раз в тридцать секунд
|
||
// при семерых работниках. Один такой отказ — и запись состояния обваливается на ровном
|
||
// месте: работник получает «сверка не сделана» и идёт разбираться с бедой, которой нет.
|
||
// Ровно тот же вред, что от мигающей проверки, только на боевой стороне.
|
||
//
|
||
// 🔴 Ждём и пробуем снова — а НЕ глотаем отказ. Файл, который так и не переименовался,
|
||
// обязан обвалиться громко: молчаливая потеря состояния тут страшнее любого ожидания.
|
||
// Полсекунды с запасом: держат файл на чтение миллисекунды, а не секунды.
|
||
const RENAME_POPYTOK = 50;
|
||
const RENAME_ZHDAT_MS = 10;
|
||
|
||
function pereimenovatUporno(tmp, path) {
|
||
for (let i = 1; ; i++) {
|
||
try {
|
||
renameSync(tmp, path);
|
||
return;
|
||
} catch (err) {
|
||
// 🪤 Пробуем снова ТОЛЬКО на «файл сейчас занят». На «нет такого каталога» или
|
||
// «диск полон» повтор — это пятьсот миллисекунд впустую и та же ошибка в конце.
|
||
const zanyat = err && (err.code === 'EPERM' || err.code === 'EACCES' || err.code === 'EBUSY');
|
||
if (!zanyat || i >= RENAME_POPYTOK) throw err;
|
||
// Спим по-настоящему, не крутя колесо: запись состояния тут вся построчная.
|
||
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, RENAME_ZHDAT_MS);
|
||
}
|
||
}
|
||
}
|
||
|
||
export function readJson(path) {
|
||
if (!existsSync(path)) return null;
|
||
try {
|
||
return JSON.parse(readFileSync(path, 'utf8'));
|
||
} catch {
|
||
return null; // порванный файл — это отсутствие сведений, а не мусор в расчётах
|
||
}
|
||
}
|
||
|
||
// 🆕 Общий прибор затеи (решение диспетчера 01.08): три ответа вместо двух у readJson.
|
||
// «Файла нет» и «файл есть, но не читается» — разные вещи для того, кто зовёт (кусок 3
|
||
// в file-claims.mjs, кусок 6 в resume.mjs и heartbeat/summary у кусков 5 и 7): второе
|
||
// обязано уйти строкой в сводку как сбой, а не молча превратиться в «как будто не было».
|
||
// readJson выше не переписан — на нём уже стоят чужие проверки.
|
||
export function readJsonOrBroken(path) {
|
||
if (!existsSync(path)) return { found: false, broken: false, value: null };
|
||
try {
|
||
return { found: true, broken: false, value: JSON.parse(readFileSync(path, 'utf8')) };
|
||
} catch {
|
||
return { found: true, broken: true, value: null };
|
||
}
|
||
}
|
||
|
||
export function appendLine(path, value) {
|
||
mkdirSync(dirname(path), { recursive: true });
|
||
appendFileSync(path, JSON.stringify(value) + '\n', 'utf8');
|
||
}
|
||
|
||
export function readLines(path) {
|
||
if (!existsSync(path)) return [];
|
||
const out = [];
|
||
for (const line of readFileSync(path, 'utf8').split('\n')) {
|
||
const s = line.trim();
|
||
if (!s) continue;
|
||
try { out.push(JSON.parse(s)); } catch { /* порванная строка пропускается */ }
|
||
}
|
||
return out;
|
||
}
|