Files
picpeak/backend/src/services/faceQueue.js
T
2026-09-08 15:54:01 +02:00

465 lines
19 KiB
JavaScript

/**
* Background face-detection worker pool (#1074).
*
* Deliberately a near-copy of backgroundProcessor.js rather than a shared
* abstraction: the two differ in exactly one behaviour (below), and inventing
* a generic queue framework to save forty lines would make both harder to
* read. Claim semantics, the janitor and the tunable shape are identical, so
* anyone who understands one understands the other.
*
* THE ONE DIFFERENCE — a sidecar that is unreachable puts the photo back to
* 'pending' with backoff, never 'failed'. Turning the ML container off for a
* week must not require a manual re-scan afterwards. Only a 4xx (a genuinely
* unprocessable image) marks a row failed.
*
* Nothing here starts unless the `faces` feature flag is on. That matters
* more than usual because FACE_ML_URL now has a working default, so "is the
* variable set" is no longer a signal — without the flag check, every install
* would poll a hostname that does not resolve.
*
* Tunables (env, all optional):
* FACE_PROCESSOR_CONCURRENCY default 1. Face scanning shares a host
* with Sharp; 1 is the safe default on
* the 2GB VPS class this project supports.
* FACE_PROCESSOR_POLL_MS default 2000
* FACE_PROCESSOR_STUCK_TIMEOUT_MS default 600000 (10 minutes)
* FACE_PROCESSOR_DISABLED default false ('true' to opt out, e.g. CI)
*/
const { db } = require('../database/db');
const logger = require('../utils/logger');
const { createInterruptibleSleep } = require('../utils/interruptibleSleep');
const { processPhotoFaces } = require('./faceProcessor');
const { SidecarUnavailableError } = require('./faceClient');
const { TransientSourceError } = require('./faceProcessor');
const { isFeatureEnabled, isEnabledForEvent } = require('./faceSettings');
const POLL_INTERVAL_MS = parseInt(process.env.FACE_PROCESSOR_POLL_MS || '2000', 10);
const CONCURRENCY = Math.max(1, parseInt(process.env.FACE_PROCESSOR_CONCURRENCY || '1', 10));
const STUCK_TIMEOUT_MS = parseInt(process.env.FACE_PROCESSOR_STUCK_TIMEOUT_MS || '600000', 10);
const JANITOR_INTERVAL_MS = 60 * 1000;
// Backoff after the sidecar goes away. Without it a down sidecar turns into a
// hot loop: claim, fail, release, claim again, thousands of times a minute.
const UNAVAILABLE_BACKOFF_MS = parseInt(process.env.FACE_PROCESSOR_BACKOFF_MS || '30000', 10);
// A dropped mount hits every photo in the event, so the raw message would
// repeat once per claim. Rate-limited the same way faceClient limits its own
// unavailable line, and for the same reason: one warning per outage, not one
// per photo.
// How long an event whose storage is unreachable is left alone. Distinct from
// the janitor's stuck timeout on purpose: the janitor exists to rescue rows a
// crashed worker abandoned, and reusing it here meant that every sweep handed
// the whole dead gallery back to the worker, which then walked all of it again
// — one slow stat per photo against a mount that may be hard-mounted — before
// reaching any healthy event.
const SOURCE_BACKOFF_MS = parseInt(process.env.FACE_SOURCE_BACKOFF_MS || '300000', 10);
// eventId -> epoch ms before which this event's photos are not worth claiming.
// In-memory on purpose: a restart should retry immediately, since a restart is
// usually what follows fixing the mount.
const deferredEvents = new Map();
function deferEvent(eventId) {
if (eventId == null) return;
deferredEvents.set(eventId, Date.now() + SOURCE_BACKOFF_MS);
}
/** Event ids still inside their backoff window; also prunes expired entries. */
function currentlyDeferredEventIds() {
const now = Date.now();
for (const [id, until] of deferredEvents) {
if (until <= now) deferredEvents.delete(id);
}
return [...deferredEvents.keys()];
}
/**
* Exclude only the rows the outage actually affects.
*
* A reference event can hold managed uploads alongside its imported external
* ones, and those live in local storage that is fine. Excluding the whole
* event id would leave them unscanned for as long as external rows keep
* renewing the cooldown — which, during a real outage, is indefinitely.
*/
function applySourceBackoff(query, excludeEventIds) {
if (!excludeEventIds.length) return query;
return query.whereNot(function () {
this.whereIn('event_id', excludeEventIds)
.whereIn('source_origin', ['external', 'reference']);
});
}
const SOURCE_LOG_INTERVAL_MS = 5 * 60 * 1000;
let lastUnreachableLogAt = 0;
function logUnreachableSource(message) {
const now = Date.now();
if (now - lastUnreachableLogAt < SOURCE_LOG_INTERVAL_MS) return;
lastUnreachableLogAt = now;
logger.warn(
`faceQueue: ${message}. Photos stay queued and will be retried — check the `
+ 'external media mount. Further identical warnings are suppressed for 5 minutes.'
);
}
/**
* Events that have had a photo scanned since their last consolidation pass
* (#1107).
*
* There is no "scan finished" event to hook: the queue is per-photo, and a
* backfill is just a lot of independent claims. So a worker that finds nothing
* left to claim asks whether the events it touched have actually drained, and
* consolidates the ones that have — greedy assignment leaves "Anna in daylight"
* and "Anna at the party" as two clusters whose centroids have since converged,
* and until now nothing looked unless an admin pressed "Re-group people".
*
* In-memory, like `deferredEvents`: a restart loses at most a consolidation
* pass, and the next scan of that event schedules another one.
*/
const touchedEvents = new Set();
// A consolidation that fails keeps its place so a transient database error
// does not cost the gallery its pass — but the worker goes idle every
// POLL_INTERVAL_MS, so retrying immediately would hot-loop a permanently
// broken event and emit a warning every couple of seconds. Same shape as
// `deferredEvents` above: back it off, then try again.
const CONSOLIDATE_RETRY_MS = parseInt(process.env.FACE_CONSOLIDATE_RETRY_MS || '60000', 10);
const consolidationRetryAt = new Map();
// eventId -> how many workers are currently inside processPhotoFaces for it.
//
// `face_status` alone cannot answer "is anyone still working on this event":
// the photo is committed 'done' inside the transaction, and auto-categorisation
// then runs before the call returns. During that window the row looks drained
// to every other worker. Counting the callers closes it.
const inFlightByEvent = new Map();
function markInFlight(eventId, delta) {
if (eventId == null) return;
const next = (inFlightByEvent.get(eventId) || 0) + delta;
if (next > 0) inFlightByEvent.set(eventId, next);
else inFlightByEvent.delete(eventId);
}
/**
* Consolidate every touched event that has genuinely drained.
*
* "A worker went idle" is not the same as "the scan is done" — with
* FACE_PROCESSOR_CONCURRENCY > 1 the others may still be working, and photos
* released back to `pending` by a down sidecar are still owed. So the drain is
* tested directly against the queue rather than inferred, and an event that is
* still busy simply stays in the set for the next idle tick.
*
* Across multiple pods two workers can consolidate the same event at once.
* That is safe rather than coordinated: `mergePeople` is transactional, and a
* pair whose source was already absorbed merges nothing.
*/
async function drainConsolidation() {
if (!touchedEvents.size) return;
for (const eventId of [...touchedEvents]) {
const retryAt = consolidationRetryAt.get(eventId);
if (retryAt && Date.now() < retryAt) continue;
try {
const outstanding = await db('photos')
.where({ event_id: eventId })
.whereIn('face_status', ['pending', 'processing'])
.count({ c: '*' })
.first();
if (Number(outstanding?.c ?? 0) > 0) continue;
// A worker may still be inside processPhotoFaces for this event: the
// photo is committed 'done' before auto-categorisation runs, so the row
// stops counting as outstanding while the call is still going. Without
// this, an idle worker consolidates and records its count, the busy one
// then re-marks the event, and the next pass overwrites the real number
// with zero — losing exactly the report this feature exists to give.
if ((inFlightByEvent.get(eventId) || 0) > 0) continue;
// Detection may have been switched off mid-drain, after an earlier
// photo already marked this event. Merging someone's clusters just
// after they turned the feature off is not a thing to do quietly.
const event = await db('events').where({ id: eventId }).first();
if (!(await isEnabledForEvent(event))) {
touchedEvents.delete(eventId);
consolidationRetryAt.delete(eventId);
continue;
}
// Required here rather than at module load, matching faceProcessor's
// call into faceAutoCategories: the binding stays late, which keeps the
// seam this is tested through honest.
const { consolidate } = require('./faceClustering');
const merged = await consolidate(eventId);
// Dropped only once it has actually run. Removing it first meant a
// transient database error lost the pass entirely — no retry until the
// gallery happened to be scanned again.
touchedEvents.delete(eventId);
consolidationRetryAt.delete(eventId);
if (merged.length) {
logger.info(
`faceQueue: scan of event ${eventId} drained — consolidated ${merged.length} look-alike pair(s)`
);
}
} catch (e) {
// Never let a consolidation failure stop the queue. The event keeps its
// place so a transient error is retried, but not before the backoff —
// otherwise a permanently failing event warns on every idle tick.
consolidationRetryAt.set(eventId, Date.now() + CONSOLIDATE_RETRY_MS);
logger.warn(
`faceQueue: consolidation failed for event ${eventId}, retrying in `
+ `${Math.round(CONSOLIDATE_RETRY_MS / 1000)}s`,
{ error: e.message }
);
}
}
}
let running = false;
let workerHandles = [];
let janitorHandle = null;
let waits = null;
let stopping = null;
const sleep = ms => waits.sleep(ms);
function isPostgres() {
const c = db.client.config.client;
return c === 'pg' || (typeof c === 'string' && c.includes('postgres'));
}
/**
* Atomically claim the oldest pending photo. Returns the row or null.
* Same two-path approach as backgroundProcessor: SKIP LOCKED on Postgres so
* multiple pods race cleanly, a status-guarded UPDATE on SQLite.
*/
async function claimNextPhoto(excludeEventIds = []) {
if (isPostgres()) {
return db.transaction(async (trx) => {
const row = await trx('photos')
.where('face_status', 'pending')
.modify((q) => applySourceBackoff(q, excludeEventIds))
.orderBy('id', 'asc')
.forUpdate()
.skipLocked()
.first();
if (!row) return null;
await trx('photos').where('id', row.id).update({
face_status: 'processing',
face_started_at: new Date().toISOString(),
});
return row;
});
}
return db.transaction(async (trx) => {
const row = await trx('photos')
.where('face_status', 'pending')
.modify((q) => applySourceBackoff(q, excludeEventIds))
.orderBy('id', 'asc')
.first();
if (!row) return null;
const updated = await trx('photos')
.where({ id: row.id, face_status: 'pending' })
.update({
face_status: 'processing',
face_started_at: new Date().toISOString(),
});
return updated > 0 ? row : null;
});
}
async function releaseToPending(photoId) {
// Guarded on 'processing' — the state THIS worker put the row in. If the
// event was purged while the sidecar request was in flight, purgeEvent has
// already set face_status to NULL, and an unconditional update would put it
// back to 'pending' and rescan it: biometric rows reappearing after the
// purge endpoint reported success. Same reasoning as the commit guard in
// faceProcessor; the retry path needed it too.
await db('photos')
.where({ id: photoId, face_status: 'processing' })
.update({ face_status: 'pending', face_started_at: null });
}
async function workerLoop(workerIdx) {
while (running) {
// Re-checked every tick, not once at startup: an admin turning the flag
// off must stop the workers without a restart.
if (!(await isFeatureEnabled())) {
await sleep(POLL_INTERVAL_MS * 5);
continue;
}
if (!running) break;
let claimed;
try {
claimed = await claimNextPhoto(currentlyDeferredEventIds());
} catch (e) {
logger.warn(`faceQueue[${workerIdx}]: claim error`, { error: e.message });
await sleep(POLL_INTERVAL_MS);
continue;
}
if (!claimed) {
// Nothing left to claim is the only signal this queue has that a scan
// may have finished. Cheap when idle: no-ops unless work happened.
await drainConsolidation();
await sleep(POLL_INTERVAL_MS);
continue;
}
markInFlight(claimed.event_id, +1);
try {
const outcome = await processPhotoFaces(claimed.id);
// Only a photo that actually went through detection can have moved this
// event's clusters. 'skipped' covers videos, a purge that raced the
// scan, and — the one that matters — detection being switched OFF
// mid-drain: consolidating there would merge clusters moments after the
// admin turned the feature off. 'failed' produced no faces either.
//
// A photo containing no faces still reports 'done', so an event whose
// photos are all empty still gets its (harmless) pass.
if (outcome?.status === 'done' && claimed.event_id != null) {
touchedEvents.add(claimed.event_id);
}
} catch (err) {
// The sidecar being down stops EVERY photo, so returning this one to
// 'pending' and backing off costs nothing — there is no other work to
// get on with.
if (err instanceof SidecarUnavailableError) {
// Retry, don't fail. faceClient already rate-limits the log line.
await releaseToPending(claimed.id).catch(() => {});
await sleep(UNAVAILABLE_BACKOFF_MS);
continue;
}
// An unreachable source is per-EVENT, not global: other events are
// still perfectly scannable. Releasing to 'pending' here would be a
// trap — claimNextPhoto orders by id ascending, so the single default
// worker would reclaim this same row after every backoff and never
// reach any higher id. One dead mount would stall face scanning for
// the whole install.
//
// So leave it parked in 'processing' with its face_started_at intact.
// The worker moves straight on to the next claimable row, and the
// janitor returns this one to 'pending' once it passes
// STUCK_TIMEOUT_MS — which is exactly the "try again later" this
// needs, using machinery that already exists.
if (err instanceof TransientSourceError) {
// Back the whole EVENT off, not just this row. Its siblings are on the
// same storage and would each cost another failed preview attempt —
// which also logs — before landing here again.
deferEvent(err.eventId);
logUnreachableSource(err.message);
continue;
}
logger.error(`faceQueue[${workerIdx}]: photo ${claimed.id} failed`, {
error: err.message,
stack: err.stack,
});
try {
await db('photos').where({ id: claimed.id }).update({
face_status: 'failed',
face_started_at: null,
face_error: String(err.message || err).slice(0, 1000),
});
} catch (updateErr) {
logger.error(`faceQueue[${workerIdx}]: failed to mark photo ${claimed.id} as failed`, {
error: updateErr.message,
});
}
} finally {
// In a finally because the catch above returns to the loop via
// `continue` on two paths — a leaked count would block this event's
// consolidation for the lifetime of the process.
markInFlight(claimed.event_id, -1);
}
}
}
async function janitorLoop() {
while (running) {
try {
const cutoff = new Date(Date.now() - STUCK_TIMEOUT_MS).toISOString();
const reset = await db('photos')
.where('face_status', 'processing')
.where('face_started_at', '<', cutoff)
.update({ face_status: 'pending', face_started_at: null });
if (reset > 0) {
logger.warn(`faceQueue: janitor reset ${reset} stuck photo(s) from 'processing' to 'pending'`);
}
} catch (e) {
logger.warn('faceQueue: janitor error', { error: e.message });
}
// The worker only reaches its drain when it can claim NOTHING, anywhere.
// With the default single worker that means one gallery finishing during a
// large backfill waits for every other gallery — and under continuous
// ingestion it could wait indefinitely. Running the drain here too makes
// consolidation depend on the event being finished rather than the whole
// install being idle. It is per-event guarded, so this is a no-op for
// anything still in flight.
await drainConsolidation().catch((e) =>
logger.warn('faceQueue: janitor drain error', { error: e.message })
);
await sleep(JANITOR_INTERVAL_MS);
}
}
function start() {
if (running || stopping) return;
if (process.env.FACE_PROCESSOR_DISABLED === 'true') {
logger.info('faceQueue: disabled via FACE_PROCESSOR_DISABLED');
return;
}
waits = createInterruptibleSleep();
running = true;
workerHandles = [];
for (let i = 0; i < CONCURRENCY; i++) {
workerHandles.push(
workerLoop(i).catch((e) =>
logger.error(`faceQueue[${i}]: crashed`, { error: e.message, stack: e.stack })
)
);
}
janitorHandle = janitorLoop().catch((e) =>
logger.error('faceQueue: janitor crashed', { error: e.message, stack: e.stack })
);
logger.info(
`faceQueue: started ${CONCURRENCY} worker(s), poll=${POLL_INTERVAL_MS}ms, stuck=${STUCK_TIMEOUT_MS}ms ` +
'(idle until the `faces` feature flag is enabled)'
);
}
function stop() {
if (stopping) return stopping;
if (!running) return Promise.resolve();
running = false;
// Interrupt idle/backoff waits only. Claims, processing and janitor work
// already in flight still drain before the database can be closed.
waits.cancel();
stopping = Promise.all([...workerHandles, janitorHandle].filter(Boolean)).finally(() => {
workerHandles = [];
janitorHandle = null;
waits = null;
stopping = null;
});
return stopping;
}
// drainConsolidation, touchedEvents and consolidationRetryAt are exported for
// the same reason claimNextPhoto is: the drain condition and its failure
// backoff are the subtle parts of #1107 and are worth pinning directly, rather
// than through a running worker loop.
module.exports = {
start, stop, claimNextPhoto, drainConsolidation,
touchedEvents, consolidationRetryAt, inFlightByEvent,
};