Files
portal/tools/night/state.mjs
T

101 lines
6.4 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.
// Атомарная запись состояния. Работник может умереть в любую миллисекунду,
// поэтому пишем во временный файл и переименовываем: переименование атомарно,
// недописанного состояния не бывает.
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;
}