chore(photo-ai): раздел 0 — жёсткие инварианты I1–I7 зафиксированы
План фото-ИИ с восстановлением лиц разбит на этапы; этот коммит закрывает
раздел 0 — семь инвариантов, которые нельзя ломать дальше. Два из них были
нарушены в текущем коде и исправлены здесь.
I5 (мягкие ошибки не сжигают попытки). Раньше любой сбой photo-ai —
503, обрыв сети, таймаут — попадал в общий catch, инкрементил attempts и
через три попытки переводил задание в error. Теперь ошибки разделены:
5xx/429/425/408 и сетевая недоступность возвращают задание в pending без
инкремента attempts, с экспоненциальной паузой 10 с → 300 с; лимит мягких
повторов (по умолчанию 60) даёт одну честную ошибку с понятным текстом.
Таймаут AbortSignal.timeout — жёсткая ошибка с попытками, как и раньше.
Счётчик мягких повторов живёт в памяти процесса и в счётчиках воркера,
метаданные повтора — в audit_log.target (soft_attempt/soft_limit), без
новых колонок. Новый аудит-код photo.job.soft_retry и подпись в audit.js.
Переменные PHOTO_AI_SOFT_MAX_RETRIES, PHOTO_AI_SOFT_BACKOFF_MS,
PHOTO_AI_SOFT_BACKOFF_MAX_MS описаны в .env.example и отдаются в
worker.config в GET /api/photo-jobs/status.
I7 (никаких прямых fs.* по uploads/). runAiEnhance писал результат
fs.writeFileSync в uploads/ и только потом persist в S3; enhanceWithSharp
делал то же через sharp toFile. Оба теперь считают буфер и пишут его
через storage.put — драйвер выбирает сам, локальной копии не остаётся.
Из worker.js убраны require('fs'), require('path') и параметр uploadsDir.
I3 (photo-ai не обязателен). photo_ai_enabled вычислялся внутри
cacheWrap('public-settings'), поэтому после перезапуска с пустым
PHOTO_AI_URL кнопка «🤖 ИИ» оставалась видимой до истечения кэша (60 с),
хотя enhance-ai уже отдавал 503. Флаг вынесен из кэша: он выводится из
PHOTO_AI_URL в памяти процесса и всегда актуален.
Проверено на стенде (журнал — в TODO_PHOTO_FACE_AI.md, раздел 0):
- I1: эталон /enhance снят на 5 фото (3 реальных, 2 синтетических),
два независимых прогона и прогон после правок совпали байт-в-байт
(sha256), /health отдаёт ok. Скрипты и эталон — в backups/ (вне git)
- I2: задание с params IS NULL и action='ai' дошло до done при
attempts=0, результат отдан из S3 (200)
- I3: с пустым PHOTO_AI_URL photo_ai_enabled=false сразу после старта,
enhance-ai → 503, остальные маршруты API живы
- I4: nvidia-ctk и nvidia-container-runtime на хосте отсутствуют, runtime
только runc — фото-ИИ поднялся на CPU, /health не падает. Проверка
PHOTO_AI_DEVICE переносится на приёмку Stage 1 (переменной ещё нет)
- I6: db/ не тронут, состав колонок photo_jobs прежний
- I7: node --check для всех изменённых JS, комментариев в диффе нет,
весь SQL параметризован
api.smoketest.js: контракт фото-воркера — согласованность
photo_ai_enabled и ai_configured, ключи мягких повторов в worker.config,
503/404 для enhance-ai на несуществующей записи (тест не создаёт реальных
заданий). Вместе с планом и чек-листом этапов.
This commit is contained in:
@@ -3,14 +3,42 @@ const IDLE_MAX_MS = 60000;
|
||||
const REQUEST_TIMEOUT_MS = parseInt(process.env.AI_REQUEST_TIMEOUT_MS || '120000', 10);
|
||||
const MAX_INPUT_CHARS = 2000;
|
||||
const MIN_TEXT_CHARS = 4;
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const crypto = require('crypto');
|
||||
const { textDiff, FIELD_LABELS } = require('./diff');
|
||||
const PHOTO_MAX_ATTEMPTS = 3;
|
||||
const PHOTO_AI_TIMEOUT_MS = 300000;
|
||||
const PHOTO_SOFT_MAX_RETRIES = Math.max(1, parseInt(process.env.PHOTO_AI_SOFT_MAX_RETRIES || '60', 10) || 60);
|
||||
const PHOTO_SOFT_BACKOFF_MS = Math.max(1000, parseInt(process.env.PHOTO_AI_SOFT_BACKOFF_MS || '10000', 10) || 10000);
|
||||
const PHOTO_SOFT_BACKOFF_MAX_MS = Math.max(PHOTO_SOFT_BACKOFF_MS, parseInt(process.env.PHOTO_AI_SOFT_BACKOFF_MAX_MS || '300000', 10) || 300000);
|
||||
|
||||
function createPhotoEnhanceWorker({ pool, getSetting, logAudit, invalidateEntries, sharp, photoAiUrl, uploadsDir, storage, bus, notifyEvent }) {
|
||||
function isTimeoutFailure(e) {
|
||||
const name = e && e.name;
|
||||
return name === 'TimeoutError' || name === 'AbortError';
|
||||
}
|
||||
|
||||
function isSoftStatus(status) {
|
||||
return status === 408 || status === 425 || status === 429 || status >= 500;
|
||||
}
|
||||
|
||||
function failureReason(e) {
|
||||
const parts = [];
|
||||
let cur = e;
|
||||
for (let i = 0; cur && i < 5; i++) {
|
||||
const code = cur.code || (cur.errors && cur.errors.code);
|
||||
if (code && !parts.includes(code)) parts.push(code);
|
||||
cur = cur.cause;
|
||||
}
|
||||
return parts.join(', ');
|
||||
}
|
||||
|
||||
function softFailure(message, e) {
|
||||
const reason = e ? failureReason(e) || (e.message || '') : '';
|
||||
const err = new Error(reason ? `${message} (${reason})` : message);
|
||||
err.soft = true;
|
||||
return err;
|
||||
}
|
||||
|
||||
function createPhotoEnhanceWorker({ pool, getSetting, logAudit, invalidateEntries, sharp, photoAiUrl, storage, bus, notifyEvent }) {
|
||||
const AI_URL = photoAiUrl || process.env.PHOTO_AI_URL || '';
|
||||
const IDLE_MIN = 2000;
|
||||
const IDLE_MAX = 30000;
|
||||
@@ -31,12 +59,16 @@ function createPhotoEnhanceWorker({ pool, getSetting, logAudit, invalidateEntrie
|
||||
let processing = false;
|
||||
let currentId = null;
|
||||
let startedAt = null;
|
||||
const stats = { jobs: 0, done: 0, errors: 0, last_at: null, last_error: null };
|
||||
const stats = { jobs: 0, done: 0, errors: 0, soft_retries: 0, last_at: null, last_error: null };
|
||||
const softTries = new Map();
|
||||
const CONFIG = {
|
||||
idle_min_ms: IDLE_MIN,
|
||||
idle_max_ms: IDLE_MAX,
|
||||
ai_timeout_ms: PHOTO_AI_TIMEOUT_MS,
|
||||
max_attempts: PHOTO_MAX_ATTEMPTS,
|
||||
soft_max_retries: PHOTO_SOFT_MAX_RETRIES,
|
||||
soft_backoff_ms: PHOTO_SOFT_BACKOFF_MS,
|
||||
soft_backoff_max_ms: PHOTO_SOFT_BACKOFF_MAX_MS,
|
||||
ai_url: AI_URL,
|
||||
};
|
||||
|
||||
@@ -113,9 +145,8 @@ function createPhotoEnhanceWorker({ pool, getSetting, logAudit, invalidateEntrie
|
||||
pipeline = pipeline.sharpen({ sigma: 0.5 + (sharpAmt / 100) * 1.5, m1: 0, m2: 1 + sharpAmt / 50 });
|
||||
}
|
||||
const newName = crypto.randomBytes(12).toString('hex') + '.jpg';
|
||||
const outPath = path.join(uploadsDir, newName);
|
||||
await pipeline.jpeg({ quality: 92, mozjpeg: true }).toFile(outPath);
|
||||
await storage.persist(newName, outPath);
|
||||
const out = await pipeline.jpeg({ quality: 92, mozjpeg: true }).toBuffer();
|
||||
await storage.put(newName, out);
|
||||
return `/uploads/${newName}`;
|
||||
}
|
||||
|
||||
@@ -126,17 +157,28 @@ function createPhotoEnhanceWorker({ pool, getSetting, logAudit, invalidateEntrie
|
||||
const fd = new FormData();
|
||||
fd.append('image', new Blob([buf], { type: 'image/jpeg' }), 'photo.jpg');
|
||||
fd.append('scale', '2');
|
||||
const resp = await fetch(AI_URL.replace(/\/+$/, '') + '/enhance', {
|
||||
method: 'POST',
|
||||
body: fd,
|
||||
signal: AbortSignal.timeout(PHOTO_AI_TIMEOUT_MS),
|
||||
});
|
||||
if (!resp.ok) throw new Error('AI service error: ' + resp.status);
|
||||
let resp;
|
||||
try {
|
||||
resp = await fetch(AI_URL.replace(/\/+$/, '') + '/enhance', {
|
||||
method: 'POST',
|
||||
body: fd,
|
||||
signal: AbortSignal.timeout(PHOTO_AI_TIMEOUT_MS),
|
||||
});
|
||||
} catch (e) {
|
||||
if (isTimeoutFailure(e)) {
|
||||
throw new Error(`ИИ-сервис не ответил за ${Math.round(PHOTO_AI_TIMEOUT_MS / 1000)} с`);
|
||||
}
|
||||
throw softFailure('ИИ-сервис недоступен', e);
|
||||
}
|
||||
if (!resp.ok) {
|
||||
const retryAfter = resp.headers.get('retry-after');
|
||||
const detail = `ИИ-сервис ответил ${resp.status}${retryAfter ? ` (Retry-After: ${retryAfter})` : ''}`;
|
||||
if (isSoftStatus(resp.status)) throw softFailure(detail);
|
||||
throw new Error(detail);
|
||||
}
|
||||
const out = Buffer.from(await resp.arrayBuffer());
|
||||
const newName = crypto.randomBytes(12).toString('hex') + '.jpg';
|
||||
const outPath = path.join(uploadsDir, newName);
|
||||
fs.writeFileSync(outPath, out);
|
||||
await storage.persist(newName, outPath);
|
||||
await storage.put(newName, out);
|
||||
return `/uploads/${newName}`;
|
||||
}
|
||||
async function applyResult(job, newPath) {
|
||||
@@ -152,6 +194,44 @@ function createPhotoEnhanceWorker({ pool, getSetting, logAudit, invalidateEntrie
|
||||
target: { job_id: job.id, action: job.action, after_path: newPath },
|
||||
});
|
||||
}
|
||||
async function softRetry(job, message) {
|
||||
const n = (softTries.get(job.id) || 0) + 1;
|
||||
if (n <= PHOTO_SOFT_MAX_RETRIES) {
|
||||
softTries.set(job.id, n);
|
||||
stats.soft_retries++;
|
||||
await pool.query(`UPDATE photo_jobs SET status = 'pending', error = $1 WHERE id = $2`, [message, job.id]);
|
||||
if (logAudit && (n === 1 || n % 10 === 0)) {
|
||||
await logAudit(null, 'photo.job.soft_retry', {
|
||||
entry_id: job.entry_id,
|
||||
job_id: job.id,
|
||||
soft_attempt: n,
|
||||
soft_limit: PHOTO_SOFT_MAX_RETRIES,
|
||||
error: message,
|
||||
});
|
||||
}
|
||||
const delay = Math.min(PHOTO_SOFT_BACKOFF_MS * Math.pow(2, n - 1), PHOTO_SOFT_BACKOFF_MAX_MS);
|
||||
return { ok: false, delay };
|
||||
}
|
||||
softTries.delete(job.id);
|
||||
const text = `${message} — ИИ-сервис недоступен, мягкие повторы исчерпаны (${PHOTO_SOFT_MAX_RETRIES}), задание остановлено`;
|
||||
await pool.query(
|
||||
`UPDATE photo_jobs SET status = 'error', error = $1, finished_at = now() WHERE id = $2`,
|
||||
[text, job.id]
|
||||
);
|
||||
stats.errors++;
|
||||
stats.last_error = text;
|
||||
if (logAudit) {
|
||||
await logAudit(null, 'photo.job.error', { entry_id: job.entry_id, job_id: job.id, error: text, soft_attempts: PHOTO_SOFT_MAX_RETRIES });
|
||||
}
|
||||
await sendNotification(job.entry_id, {
|
||||
type: 'photo.job.error',
|
||||
title: 'Ошибка обработки фото: {student}',
|
||||
body: `Группа {group} · запись #${job.entry_id} · ${text}`,
|
||||
target: { job_id: job.id, action: job.action, error: text },
|
||||
});
|
||||
return { ok: true, delay: 0 };
|
||||
}
|
||||
|
||||
async function processOne(job) {
|
||||
stats.jobs++;
|
||||
const { rows } = await pool.query('SELECT photo_path FROM entries WHERE id = $1', [job.entry_id]);
|
||||
@@ -169,27 +249,30 @@ function createPhotoEnhanceWorker({ pool, getSetting, logAudit, invalidateEntrie
|
||||
body: `Группа {group} · запись #${job.entry_id} · у записи нет фото для обработки`,
|
||||
target: { job_id: job.id, action: job.action },
|
||||
});
|
||||
return true;
|
||||
return { ok: true, delay: 0 };
|
||||
}
|
||||
const photoPath = rows[0].photo_path;
|
||||
try {
|
||||
const newPath = job.action === 'ai' ? await runAiEnhance(photoPath) : await enhanceWithSharp(photoPath, job.params);
|
||||
await applyResult(job, newPath);
|
||||
softTries.delete(job.id);
|
||||
stats.done++;
|
||||
stats.last_at = new Date().toISOString();
|
||||
stats.last_error = null;
|
||||
return true;
|
||||
return { ok: true, delay: 0 };
|
||||
} catch (e) {
|
||||
const message = (e && e.message ? e.message : 'error').slice(0, 500);
|
||||
stats.last_at = new Date().toISOString();
|
||||
stats.last_error = message;
|
||||
if (e && e.soft) return await softRetry(job, message);
|
||||
const { rows: cur } = await pool.query('SELECT attempts FROM photo_jobs WHERE id = $1', [job.id]);
|
||||
const tries = ((cur[0] && cur[0].attempts) || 0) + 1;
|
||||
if (tries < PHOTO_MAX_ATTEMPTS) {
|
||||
await pool.query(`UPDATE photo_jobs SET status = 'pending', attempts = $1, error = $2 WHERE id = $3`, [tries, message, job.id]);
|
||||
if (logAudit) await logAudit(null, 'photo.job.retry', { entry_id: job.entry_id, job_id: job.id, attempt: tries, error: message });
|
||||
return false;
|
||||
return { ok: false, delay: 0 };
|
||||
}
|
||||
softTries.delete(job.id);
|
||||
await pool.query(
|
||||
`UPDATE photo_jobs SET status = 'error', attempts = $1, error = $2, finished_at = now() WHERE id = $3`,
|
||||
[tries, message, job.id]
|
||||
@@ -202,7 +285,7 @@ function createPhotoEnhanceWorker({ pool, getSetting, logAudit, invalidateEntrie
|
||||
body: `Группа {group} · запись #${job.entry_id} · ${message}`,
|
||||
target: { job_id: job.id, action: job.action, error: message },
|
||||
});
|
||||
return true;
|
||||
return { ok: true, delay: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -228,16 +311,16 @@ function createPhotoEnhanceWorker({ pool, getSetting, logAudit, invalidateEntrie
|
||||
idleMs = IDLE_MIN;
|
||||
processing = true;
|
||||
currentId = job.id;
|
||||
let ok = true;
|
||||
let res = { ok: true, delay: 0 };
|
||||
try {
|
||||
ok = await processOne(job);
|
||||
res = await processOne(job);
|
||||
} catch (e) {
|
||||
console.error('Photo worker process error:', e);
|
||||
} finally {
|
||||
processing = false;
|
||||
currentId = null;
|
||||
}
|
||||
if (!ok) await sleep(IDLE_MAX);
|
||||
if (!res || !res.ok) await sleep((res && res.delay) || IDLE_MAX);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user