Files
picpeak/backend/src/services/backgroundProcessor.js
T
Paul Nothaft 851744c3c4 feat(upload): async photo processing — backend (PR-B part 1)
Move thumbnail / EXIF / dimensions / watermark / webhook work off the
upload request thread and into a background worker pool. Upload
requests now return 202 in seconds even on NFS-backed storage; the
worker(s) drain the pending queue independently and update each
photo's processing_status to 'complete' or 'failed' on its own.

Schema (migration 085_async_photo_processing.js):
  - photos.processing_status     enum default 'complete' (existing
                                 rows are already done)
  - photos.processing_error      populated on 'failed'
  - photos.processing_started_at timestamp for janitor recovery
  - photos.upload_id             groups all photos from one upload
                                 request so the frontend can poll
                                 status by group
  - indexes on processing_status and upload_id for queue lookups

services/photoProcessor.js
  - queueFilesForProcessing(files, options) — shared helper used by
    the admin and gallery upload routes. Moves files to final storage
    + inserts pending rows; returns { uploadId, photos, errors }.
  - processPhoto(photoId) — worker-mode: reads original from storage
    via withLocalCopy (transparent local/S3), generates thumbnail and
    EXIF/dimensions or video metadata, queues watermark, fires
    photo.uploaded webhook, marks 'complete'. Throws => caller marks
    'failed' with the error message.
  - processUploadedPhotos kept untouched — chunkedUploadService still
    uses the synchronous path.

services/backgroundProcessor.js (new)
  - N independent worker loops per backend instance (default 2,
    UPLOAD_PROCESSOR_CONCURRENCY env override).
  - Multi-pod safe: postgres SELECT FOR UPDATE SKIP LOCKED, sqlite
    UPDATE-with-status-guard. Pods race on rows, exactly one wins.
  - Janitor every minute resets photos stuck in 'processing' for >10
    minutes (worker died, pod restarted) back to 'pending'.
  - UPLOAD_PROCESSOR_DISABLED=true opt-out for CI/test.
  - Started from server.js after the other long-running workers.

routes/adminPhotos.js — POST /:eventId/upload
  - Replaced batch-of-25 sync processing loop with per-file
    move-to-storage + insert-pending. Response is now 202 with
    upload_id, count, photo_ids in addition to the legacy
    successCount / replacedCount fields the existing frontend reads.
  - Per-request temp directory cleanup is now a single idempotent
    handler on res.finish/res.close (was three inline blocks for
    error paths only, leaking dirs on success — original bug from
    contributor analysis).
  - GET /uploads/:upload_id/status — JSON snapshot of pending /
    processing / complete / failed counts plus per-photo state.
  - GET /uploads/:upload_id/stream — SSE upgrade. Polls internally
    every 1.5s, emits on snapshot change, ends when all photos
    reach a terminal state.
  - POST /photos/:photoId/retry — flips a 'failed' photo back to
    'pending' so the worker picks it up again.
  - GET /:eventId/thumbnail/:photoId now returns 503 with Retry-After
    while the photo is still pending/processing, and 422 on 'failed'.
    The admin grid renders placeholders accordingly.

routes/gallery.js — POST /:eventId/upload (guest)
  - Refactored to use queueFilesForProcessing instead of the synchronous
    processUploadedPhotos. Same 202 + upload_id shape.
  - GET /:slug/photos now filters processing_status to 'complete' (or
    NULL for pre-migration rows) so guests never see in-flight photos.

Side-effect timing change:
  - photo.uploaded webhook now fires from the worker after the photo
    is actually processed (thumbnail + dimensions populated) instead
    of from inside the upload request. Same payload fields. Worth a
    one-line note in the changelog.
2026-05-02 22:56:04 +02:00

177 lines
5.6 KiB
JavaScript

/**
* Background photo-processing worker pool.
*
* Polls `photos.processing_status = 'pending'`, atomically claims one
* row per worker, hands it to `photoProcessor.processPhoto(photoId)`,
* and marks the row 'complete' or 'failed' depending on outcome. A
* janitor loop resets rows stuck in 'processing' for too long (worker
* died, pod restarted, etc.).
*
* Concurrency model: N independent worker loops per backend instance.
* Multi-pod safe via:
* - Postgres: SELECT ... FOR UPDATE SKIP LOCKED — pods race for rows,
* only one wins, the others move on.
* - SQLite: SELECT then UPDATE-with-status-guard — second writer
* loses the guard and tries again (single-pod typical; the guard
* is enough for the rare two-process case during dev).
*
* Tunables (env, all optional):
* UPLOAD_PROCESSOR_CONCURRENCY default 2
* UPLOAD_PROCESSOR_POLL_MS default 1000
* UPLOAD_PROCESSOR_STUCK_TIMEOUT_MS default 600000 (10 minutes)
* UPLOAD_PROCESSOR_DISABLED default false (set 'true' to opt out, e.g. in CI)
*/
const { db } = require('../database/db');
const logger = require('../utils/logger');
const { processPhoto } = require('./photoProcessor');
const POLL_INTERVAL_MS = parseInt(process.env.UPLOAD_PROCESSOR_POLL_MS || '1000', 10);
const CONCURRENCY = Math.max(1, parseInt(process.env.UPLOAD_PROCESSOR_CONCURRENCY || '2', 10));
const STUCK_TIMEOUT_MS = parseInt(process.env.UPLOAD_PROCESSOR_STUCK_TIMEOUT_MS || '600000', 10);
const JANITOR_INTERVAL_MS = 60 * 1000;
let running = false;
let workerHandles = [];
let janitorHandle = null;
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, 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.
* The claimed row's processing_status is now 'processing' and
* processing_started_at is set so the janitor can recover it.
*/
async function claimNextPhoto() {
if (isPostgres()) {
return db.transaction(async (trx) => {
const row = await trx('photos')
.where('processing_status', 'pending')
.orderBy('id', 'asc')
.forUpdate()
.skipLocked()
.first();
if (!row) return null;
await trx('photos').where('id', row.id).update({
processing_status: 'processing',
processing_started_at: new Date(),
});
return row;
});
}
// SQLite path — no SKIP LOCKED, but the UPDATE-with-guard ensures
// exactly one worker wins per row.
return db.transaction(async (trx) => {
const row = await trx('photos')
.where('processing_status', 'pending')
.orderBy('id', 'asc')
.first();
if (!row) return null;
const updated = await trx('photos')
.where({ id: row.id, processing_status: 'pending' })
.update({
processing_status: 'processing',
processing_started_at: new Date(),
});
return updated > 0 ? row : null;
});
}
async function workerLoop(workerIdx) {
while (running) {
let claimed;
try {
claimed = await claimNextPhoto();
} catch (e) {
logger.warn(`backgroundProcessor[${workerIdx}]: claim error`, { error: e.message });
await sleep(POLL_INTERVAL_MS);
continue;
}
if (!claimed) {
await sleep(POLL_INTERVAL_MS);
continue;
}
try {
await processPhoto(claimed.id);
} catch (err) {
logger.error(`backgroundProcessor[${workerIdx}]: photo ${claimed.id} failed`, {
error: err.message,
stack: err.stack,
});
try {
await db('photos').where({ id: claimed.id }).update({
processing_status: 'failed',
processing_error: String(err.message || err).slice(0, 1000),
});
} catch (updateErr) {
logger.error(`backgroundProcessor[${workerIdx}]: failed to mark photo ${claimed.id} as failed`, {
error: updateErr.message,
});
}
}
}
}
async function janitorLoop() {
while (running) {
try {
const cutoff = new Date(Date.now() - STUCK_TIMEOUT_MS);
const reset = await db('photos')
.where('processing_status', 'processing')
.where('processing_started_at', '<', cutoff)
.update({ processing_status: 'pending', processing_started_at: null });
if (reset > 0) {
logger.warn(
`backgroundProcessor: janitor reset ${reset} stuck photo(s) from 'processing' to 'pending'`
);
}
} catch (e) {
logger.warn('backgroundProcessor: janitor error', { error: e.message });
}
await sleep(JANITOR_INTERVAL_MS);
}
}
function start() {
if (running) return;
if (process.env.UPLOAD_PROCESSOR_DISABLED === 'true') {
logger.info('backgroundProcessor: disabled via UPLOAD_PROCESSOR_DISABLED');
return;
}
running = true;
workerHandles = [];
for (let i = 0; i < CONCURRENCY; i++) {
workerHandles.push(
workerLoop(i).catch((e) =>
logger.error(`backgroundProcessor[${i}]: crashed`, { error: e.message, stack: e.stack })
)
);
}
janitorHandle = janitorLoop().catch((e) =>
logger.error('backgroundProcessor: janitor crashed', { error: e.message, stack: e.stack })
);
logger.info(
`backgroundProcessor: started ${CONCURRENCY} worker(s), poll=${POLL_INTERVAL_MS}ms, stuck=${STUCK_TIMEOUT_MS}ms`
);
}
async function stop() {
if (!running) return;
running = false;
await Promise.all([...workerHandles, janitorHandle].filter(Boolean));
workerHandles = [];
janitorHandle = null;
}
module.exports = { start, stop, claimNextPhoto };