feat: add processor service with Redis-backed job queue

- Introduced a new `processor` service in the Docker Compose setup to handle face matching jobs.
- Configured Redis as a job queue and state management system for processing searches.
- Updated the backend to enqueue jobs and manage user locks using Redis.
- Added environment variables for Redis configuration and runtime paths.
- Created technical design documentation for the processor service outlining architecture, queue model, and search lifecycle.
- Updated package.json and package-lock.json to include dependencies for BullMQ and ioredis in the processor workspace.
- Added sample PKL files for local testing in the `test_pkl` directory.
This commit is contained in:
MaddoScientisto 2026-04-11 17:53:22 +02:00
commit bbb9c193ce
20 changed files with 1313 additions and 108 deletions

View file

@ -0,0 +1,12 @@
export const config = {
redisUrl: process.env.FACEAI_REDIS_URL || 'redis://redis:6379',
queueName: process.env.FACEAI_QUEUE_NAME || 'faceai-searches',
workerConcurrency: Number(process.env.FACEAI_WORKER_CONCURRENCY || 2),
workerTimeoutMs: Number(process.env.FACEAI_WORKER_TIMEOUT_MS || 5 * 60 * 1000),
runtimeRoot: process.env.FACEAI_RUNTIME_ROOT || '/data/runtime',
pklRoot: process.env.FACEAI_PKL_ROOT || '/data/pkl',
fallbackPklRoot: process.env.FACEAI_TEST_PKL_ROOT || '/data/pkl/test',
matcherBinary: process.env.FACEAI_MATCHER_BINARY || '/opt/face-recognition/face_matcher',
searchTtlSeconds: Number(process.env.FACEAI_SEARCH_TTL_SECONDS || 24 * 60 * 60),
resultTtlSeconds: Number(process.env.FACEAI_RESULT_TTL_SECONDS || 24 * 60 * 60)
};

View file

@ -0,0 +1,99 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { spawn } from 'node:child_process';
async function fileExists(filePath) {
try {
await fs.access(filePath);
return true;
} catch {
return false;
}
}
export async function resolvePklPath({ raceId, pklRoot, fallbackPklRoot }) {
const preferred = path.join(pklRoot, String(raceId), 'face_encodings.pkl');
if (await fileExists(preferred)) {
return preferred;
}
const flatFile = path.join(pklRoot, `${raceId}.pkl`);
if (await fileExists(flatFile)) {
return flatFile;
}
const fallbackEntries = await fs.readdir(fallbackPklRoot).catch(() => []);
const fallbackFile = fallbackEntries.find((entry) => entry.toLowerCase().endsWith('.pkl'));
if (fallbackFile) {
return path.join(fallbackPklRoot, fallbackFile);
}
throw new Error(`No PKL file available for race ${raceId}`);
}
export async function runFaceMatcher({ matcherBinary, selfiePath, pklPath, csvPath, logPath, timeoutMs }) {
await fs.mkdir(path.dirname(csvPath), { recursive: true });
await fs.mkdir(path.dirname(logPath), { recursive: true });
return new Promise((resolve, reject) => {
const child = spawn(matcherBinary, [
'--image', selfiePath,
'--encodings', pklPath,
'--out', csvPath,
'--log', logPath
], {
stdio: 'ignore'
});
const timer = setTimeout(() => {
child.kill('SIGKILL');
reject(new Error('face_matcher timed out'));
}, timeoutMs);
child.on('error', (error) => {
clearTimeout(timer);
reject(error);
});
child.on('exit', (code) => {
clearTimeout(timer);
if (code === 0) {
resolve();
return;
}
reject(new Error(`face_matcher exited with code ${code}`));
});
});
}
export async function parseMatcherCsv(csvPath) {
const content = await fs.readFile(csvPath, 'utf8');
const lines = content
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
if (!lines.length) {
return [];
}
const rows = lines.map((line) => line.split(',').map((part) => part.trim().replace(/^"|"$/g, '')));
const firstRow = rows[0];
const hasHeader = firstRow.some((cell) => /file|image|score|distance|confidence/i.test(cell));
const dataRows = hasHeader ? rows.slice(1) : rows;
return dataRows
.filter((cells) => cells[0])
.map((cells) => {
const photoId = path.basename(cells[0]);
const numericCell = cells.find((cell, index) => index > 0 && !Number.isNaN(Number(cell)));
const score = numericCell ? Number(numericCell) : null;
return {
photoId,
score,
label: photoId
};
});
}

View file

@ -0,0 +1,82 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { Worker } from 'bullmq';
import { config } from './config.js';
import {
createRedisConnection,
getSearchRecord,
markSearchCompleted,
markSearchFailed,
markSearchProcessing,
releaseActiveSearchLock,
storeResultRecord
} from '../../backend/src/redis-store.js';
import { parseMatcherCsv, resolvePklPath, runFaceMatcher } from './worker-utils.js';
const connection = createRedisConnection(config.redisUrl);
async function processJob(job) {
const searchId = String(job.data.searchId || '');
const search = await getSearchRecord(connection, searchId);
if (!search) {
throw new Error(`Search ${searchId} not found`);
}
await markSearchProcessing(connection, searchId, config.searchTtlSeconds);
const searchDir = path.join(config.runtimeRoot, 'searches', searchId);
await fs.mkdir(searchDir, { recursive: true });
try {
const pklPath = await resolvePklPath({
raceId: search.raceId,
pklRoot: config.pklRoot,
fallbackPklRoot: config.fallbackPklRoot
});
const csvPath = path.join(searchDir, 'result.csv');
const logPath = path.join(searchDir, 'matcher.log');
await runFaceMatcher({
matcherBinary: config.matcherBinary,
selfiePath: search.selfiePath,
pklPath,
csvPath,
logPath,
timeoutMs: config.workerTimeoutMs
});
const matches = await parseMatcherCsv(csvPath);
const result = await storeResultRecord(connection, {
raceId: search.raceId,
raceName: search.raceName,
userId: search.userId,
returnUrl: search.returnUrl,
lang: search.lang,
matches
}, config.resultTtlSeconds);
await markSearchCompleted(connection, searchId, result.id, matches.length, config.searchTtlSeconds);
await releaseActiveSearchLock(connection, search.userId, searchId);
} catch (error) {
await markSearchFailed(connection, searchId, 'PROCESSOR_ERROR', error.message, config.searchTtlSeconds);
await releaseActiveSearchLock(connection, search.userId, searchId);
throw error;
}
}
const worker = new Worker(config.queueName, processJob, {
connection,
concurrency: config.workerConcurrency
});
worker.on('completed', (job) => {
console.log(`Completed FaceAI search ${job.data.searchId}`);
});
worker.on('failed', (job, error) => {
const searchId = job?.data?.searchId || 'unknown';
console.error(`Failed FaceAI search ${searchId}: ${error.message}`);
});
console.log(`FaceAI processor listening on queue ${config.queueName} with concurrency ${config.workerConcurrency}`);