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:
parent
599daf7547
commit
bbb9c193ce
20 changed files with 1313 additions and 108 deletions
12
faceai/apps/processor/src/config.js
Normal file
12
faceai/apps/processor/src/config.js
Normal 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)
|
||||
};
|
||||
99
faceai/apps/processor/src/worker-utils.js
Normal file
99
faceai/apps/processor/src/worker-utils.js
Normal 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
|
||||
};
|
||||
});
|
||||
}
|
||||
82
faceai/apps/processor/src/worker.js
Normal file
82
faceai/apps/processor/src/worker.js
Normal 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}`);
|
||||
Loading…
Add table
Add a link
Reference in a new issue