280 lines
6.4 KiB
JavaScript
280 lines
6.4 KiB
JavaScript
'use strict';
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
|
|
const db = require('../db/knex');
|
|
|
|
const {
|
|
ensurePhotoIngestQueue,
|
|
resetStalePhotoIngestJobs,
|
|
getPhotoIngestQueueCounts,
|
|
} = require('../api_v1/scanner/photoIngestQueue');
|
|
|
|
const { runOneJob } = require('../api_v1/scanner/photoIngestWorker');
|
|
|
|
const PROJECT_ROOT = path.resolve(__dirname, '..');
|
|
const DEFAULT_LOCK_PATH = path.join(
|
|
PROJECT_ROOT,
|
|
'photo-ingest-daemon.lock'
|
|
);
|
|
|
|
function positiveInteger(value, option) {
|
|
const number = Number(value);
|
|
|
|
if (!Number.isInteger(number) || number <= 0) {
|
|
throw new Error(`${option} deve essere un intero positivo`);
|
|
}
|
|
|
|
return number;
|
|
}
|
|
|
|
function parseArguments(argv) {
|
|
const options = {
|
|
pollMs: 2000,
|
|
resetStaleMinutes: 30,
|
|
staleCheckMs: 60000,
|
|
lockPath:
|
|
process.env.PHOTO_INGEST_DAEMON_LOCK || DEFAULT_LOCK_PATH,
|
|
help: false,
|
|
dryRun: false,
|
|
};
|
|
|
|
for (let i = 0; i < argv.length; i += 1) {
|
|
const arg = argv[i];
|
|
|
|
if (arg === '--help' || arg === '-h') {
|
|
options.help = true;
|
|
continue;
|
|
}
|
|
|
|
if (arg === '--dry-run') {
|
|
options.dryRun = true;
|
|
continue;
|
|
}
|
|
|
|
const names = {
|
|
'--poll-ms': 'pollMs',
|
|
'--reset-stale-minutes': 'resetStaleMinutes',
|
|
'--stale-check-ms': 'staleCheckMs',
|
|
'--lock-file': 'lockPath',
|
|
};
|
|
|
|
const key = names[arg];
|
|
|
|
if (!key) {
|
|
throw new Error(`Argomento sconosciuto: ${arg}`);
|
|
}
|
|
|
|
const value = argv[i + 1];
|
|
|
|
if (value == null || value.startsWith('--')) {
|
|
throw new Error(`Valore mancante dopo ${arg}`);
|
|
}
|
|
|
|
options[key] = key === 'lockPath'
|
|
? path.resolve(value)
|
|
: positiveInteger(value, arg);
|
|
|
|
i += 1;
|
|
}
|
|
|
|
return options;
|
|
}
|
|
|
|
function isProcessAlive(pid) {
|
|
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
|
|
try {
|
|
process.kill(pid, 0);
|
|
return true;
|
|
} catch (error) {
|
|
return error.code === 'EPERM';
|
|
}
|
|
}
|
|
|
|
function readLock(lockPath) {
|
|
try {
|
|
return JSON.parse(fs.readFileSync(lockPath, 'utf8'));
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function acquireLock(lockPath) {
|
|
fs.mkdirSync(path.dirname(lockPath), { recursive: true });
|
|
|
|
try {
|
|
const fd = fs.openSync(lockPath, 'wx');
|
|
|
|
try {
|
|
fs.writeFileSync(fd, JSON.stringify({
|
|
pid: process.pid,
|
|
started_at: new Date().toISOString(),
|
|
project_root: PROJECT_ROOT,
|
|
}, null, 2) + '\n', 'utf8');
|
|
} finally {
|
|
fs.closeSync(fd);
|
|
}
|
|
|
|
return;
|
|
} catch (error) {
|
|
if (error.code !== 'EEXIST') throw error;
|
|
}
|
|
|
|
const existing = readLock(lockPath);
|
|
const pid = Number(existing?.pid);
|
|
|
|
if (isProcessAlive(pid)) {
|
|
throw new Error(
|
|
`Daemon già attivo con PID ${pid}. Lock: ${lockPath}`
|
|
);
|
|
}
|
|
|
|
// Non cancellare automaticamente un lock illeggibile:
|
|
// potrebbe appartenere a un processo in fase di avvio.
|
|
if (!existing || !Number.isInteger(pid) || pid <= 0) {
|
|
throw new Error(
|
|
`Lock esistente non verificabile: ${lockPath}. `
|
|
+ 'Verificarlo manualmente prima di rimuoverlo.'
|
|
);
|
|
}
|
|
|
|
fs.unlinkSync(lockPath);
|
|
acquireLock(lockPath);
|
|
}
|
|
|
|
function releaseLock(lockPath) {
|
|
const lock = readLock(lockPath);
|
|
|
|
if (Number(lock?.pid) !== process.pid) return;
|
|
|
|
try {
|
|
fs.unlinkSync(lockPath);
|
|
} catch (error) {
|
|
if (error.code !== 'ENOENT') {
|
|
console.error('[PhotoIngestDaemon] Errore rimozione lock:', error.message);
|
|
}
|
|
}
|
|
}
|
|
|
|
function sleep(ms) {
|
|
return new Promise(resolve => setTimeout(resolve, ms));
|
|
}
|
|
|
|
async function main(options) {
|
|
if (options.help) {
|
|
console.log(
|
|
'Uso: node tools/runPhotoIngestDaemon.js [opzioni]\n'
|
|
+ ' --dry-run Test senza elaborare o recuperare job\n'
|
|
+ ' --poll-ms N Attesa tra controlli (default 2000)\n'
|
|
+ ' --reset-stale-minutes N Età job bloccati (default 30)\n'
|
|
+ ' --stale-check-ms N Intervallo recupero (default 60000)\n'
|
|
+ ' --lock-file PERCORSO Percorso lock personalizzato'
|
|
);
|
|
return;
|
|
}
|
|
|
|
let stopping = false;
|
|
let lockAcquired = false;
|
|
let lastStaleCheck = 0;
|
|
|
|
const requestStop = signal => {
|
|
if (!stopping) {
|
|
console.log(`[PhotoIngestDaemon] Ricevuto ${signal}; arresto richiesto.`);
|
|
stopping = true;
|
|
}
|
|
};
|
|
|
|
const onSigint = () => requestStop('SIGINT');
|
|
const onSigterm = () => requestStop('SIGTERM');
|
|
|
|
process.on('SIGINT', onSigint);
|
|
process.on('SIGTERM', onSigterm);
|
|
|
|
try {
|
|
acquireLock(options.lockPath);
|
|
lockAcquired = true;
|
|
|
|
await ensurePhotoIngestQueue(db);
|
|
|
|
// Recupero iniziale eseguito soltanto dopo l'acquisizione del lock.
|
|
const recovered = options.dryRun
|
|
? 0
|
|
: await resetStalePhotoIngestJobs(
|
|
db,
|
|
options.resetStaleMinutes
|
|
);
|
|
|
|
lastStaleCheck = Date.now();
|
|
|
|
console.log('[PhotoIngestDaemon] Avviato.', {
|
|
pid: process.pid,
|
|
pollMs: options.pollMs,
|
|
staleCheckMs: options.staleCheckMs,
|
|
dryRun: options.dryRun,
|
|
recovered,
|
|
queue: await getPhotoIngestQueueCounts(db),
|
|
});
|
|
|
|
while (!stopping) {
|
|
try {
|
|
// Durante un job non eseguiamo un recupero concorrente.
|
|
if (options.dryRun) {
|
|
await sleep(options.pollMs);
|
|
continue;
|
|
}
|
|
|
|
const result = await runOneJob(db);
|
|
|
|
if (result.error) {
|
|
console.error('[PhotoIngestDaemon] Job fallito:', result.error);
|
|
}
|
|
|
|
if (!result.processed && !stopping) {
|
|
await sleep(options.pollMs);
|
|
}
|
|
|
|
if (
|
|
!stopping &&
|
|
Date.now() - lastStaleCheck >= options.staleCheckMs
|
|
) {
|
|
const count = await resetStalePhotoIngestJobs(
|
|
db,
|
|
options.resetStaleMinutes
|
|
);
|
|
|
|
lastStaleCheck = Date.now();
|
|
|
|
if (count) {
|
|
console.warn(
|
|
`[PhotoIngestDaemon] Recuperati ${count} job bloccati.`
|
|
);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('[PhotoIngestDaemon] Errore ciclo:', error);
|
|
if (!stopping) await sleep(options.pollMs);
|
|
}
|
|
}
|
|
} finally {
|
|
process.removeListener('SIGINT', onSigint);
|
|
process.removeListener('SIGTERM', onSigterm);
|
|
|
|
if (lockAcquired) releaseLock(options.lockPath);
|
|
}
|
|
}
|
|
|
|
(async () => {
|
|
let options;
|
|
|
|
try {
|
|
options = parseArguments(process.argv.slice(2));
|
|
await main(options);
|
|
} catch (error) {
|
|
console.error('[PhotoIngestDaemon] Errore fatale:', error.message);
|
|
process.exitCode = 1;
|
|
} finally {
|
|
await db.destroy();
|
|
}
|
|
})();
|