8337a716b1
* fix(file-watcher): bound concurrent photo processing chokidar fires 'add' once per file — with no ignoreInitial option the boot scan fires it for every existing file, and a bulk drop into the watch folder fires it for every new one at once. Each handler runs DB lookups plus (for new files) a full sharp pipeline; sharp.concurrency(2) only caps libvips threads WITHIN one operation, not the number of parallel pipelines, so unbounded handlers can OOM small hosts. Gate both 'add' and 'unlink' through a shared p-limit (FILE_WATCHER_CONCURRENCY, default 2, floor 1) — mass deletes otherwise burst DB work and ZIP-cache invalidation the same way. p-limit is pinned to ^3.1.0, the last CommonJS release. Adapted from the filpgame fork (426ca491) — thanks @filpgame; extended to cover 'unlink', documented in .env.example, plus a lock-in test for the existing Sharp cache/concurrency caps this bound relies on. * chore(compose): pass FILE_WATCHER_CONCURRENCY into the backend container (codex review of #846) The backend service uses an explicit environment list (no env_file), so the documented override never reached the container in the default compose deployments. Added to both compose files + root .env.example.
126 lines
4.5 KiB
JavaScript
126 lines
4.5 KiB
JavaScript
/**
|
|
* Regression tests for the file-watcher concurrency bound.
|
|
*
|
|
* chokidar fires 'add' once per file — with no ignoreInitial option the boot
|
|
* scan fires it for every existing file, and a bulk drop fires it for every
|
|
* new one at once. Unbounded handlers each run DB lookups plus a full sharp
|
|
* pipeline (sharp.concurrency(2) only caps libvips threads WITHIN one
|
|
* operation), which can OOM small hosts. Both 'add' and 'unlink' must go
|
|
* through the shared p-limit gate.
|
|
*
|
|
* Adapted from the filpgame fork (426ca491), extended to cover 'unlink'.
|
|
*/
|
|
|
|
const mockLimit = jest.fn((operation) => Promise.resolve().then(operation));
|
|
const mockPLimit = jest.fn(() => mockLimit);
|
|
const mockHandlers = {};
|
|
const mockWatcher = {
|
|
on: jest.fn((event, handler) => {
|
|
mockHandlers[event] = handler;
|
|
return mockWatcher;
|
|
}),
|
|
};
|
|
|
|
// Shared instances captured by the mock factories: jest.isolateModules re-runs
|
|
// each factory in a fresh registry, so the factories must return these same
|
|
// objects for the test to observe calls made inside the isolated module.
|
|
const mockLogger = { error: jest.fn(), warn: jest.fn(), info: jest.fn(), debug: jest.fn() };
|
|
// Chainable no-row query — enough for removePhoto's lookup/delete calls.
|
|
const mockDb = jest.fn(() => ({
|
|
where: jest.fn().mockReturnThis(),
|
|
first: jest.fn().mockResolvedValue(null),
|
|
delete: jest.fn().mockResolvedValue(0),
|
|
}));
|
|
|
|
jest.mock('p-limit', () => mockPLimit);
|
|
jest.mock('chokidar', () => ({
|
|
watch: jest.fn(() => mockWatcher),
|
|
}));
|
|
jest.mock('../../src/database/db', () => ({ db: mockDb }));
|
|
jest.mock('../../src/utils/logger', () => mockLogger);
|
|
jest.mock('../../src/services/imageProcessor', () => ({
|
|
generateThumbnail: jest.fn(),
|
|
generateVideoPlaceholder: jest.fn(),
|
|
}));
|
|
jest.mock('../../src/services/videoProcessor', () => ({
|
|
isVideoMimeType: jest.fn(() => false),
|
|
}));
|
|
jest.mock('../../src/services/downloadZipService', () => ({ invalidate: jest.fn() }));
|
|
jest.mock('../../src/utils/dbCompat', () => ({
|
|
formatBoolean: jest.fn((value) => value),
|
|
}));
|
|
|
|
const loadFileWatcher = () => {
|
|
let fileWatcher;
|
|
jest.isolateModules(() => {
|
|
fileWatcher = require('../../src/services/fileWatcher');
|
|
});
|
|
return fileWatcher;
|
|
};
|
|
|
|
describe('fileWatcher concurrency bound', () => {
|
|
const originalBackend = process.env.STORAGE_BACKEND;
|
|
const originalConcurrency = process.env.FILE_WATCHER_CONCURRENCY;
|
|
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
Object.keys(mockHandlers).forEach((key) => delete mockHandlers[key]);
|
|
process.env.STORAGE_BACKEND = 'local';
|
|
delete process.env.FILE_WATCHER_CONCURRENCY;
|
|
});
|
|
|
|
afterAll(() => {
|
|
if (originalBackend === undefined) delete process.env.STORAGE_BACKEND;
|
|
else process.env.STORAGE_BACKEND = originalBackend;
|
|
if (originalConcurrency === undefined) delete process.env.FILE_WATCHER_CONCURRENCY;
|
|
else process.env.FILE_WATCHER_CONCURRENCY = originalConcurrency;
|
|
});
|
|
|
|
it.each([
|
|
[undefined, 2], // default
|
|
['3', 3], // explicit
|
|
['0', 1], // floored to 1
|
|
['-4', 1], // floored to 1
|
|
['invalid', 2], // falls back to default
|
|
])('configures the limiter with FILE_WATCHER_CONCURRENCY=%s as %i', (configured, expected) => {
|
|
if (configured === undefined) delete process.env.FILE_WATCHER_CONCURRENCY;
|
|
else process.env.FILE_WATCHER_CONCURRENCY = configured;
|
|
|
|
loadFileWatcher().startFileWatcher();
|
|
|
|
expect(mockPLimit).toHaveBeenCalledWith(expected);
|
|
});
|
|
|
|
it('routes add events through the shared limiter', async () => {
|
|
loadFileWatcher().startFileWatcher();
|
|
|
|
expect(mockHandlers.add).toEqual(expect.any(Function));
|
|
mockHandlers.add('/outside-watch-root'); // early-returns inside processNewPhoto
|
|
|
|
expect(mockLimit).toHaveBeenCalledTimes(1);
|
|
expect(mockLimit).toHaveBeenCalledWith(expect.any(Function));
|
|
await mockLimit.mock.results[0].value;
|
|
});
|
|
|
|
it('routes unlink events through the same limiter', async () => {
|
|
loadFileWatcher().startFileWatcher();
|
|
|
|
expect(mockHandlers.unlink).toEqual(expect.any(Function));
|
|
mockHandlers.unlink('/outside-watch-root'); // early-returns inside removePhoto
|
|
|
|
expect(mockLimit).toHaveBeenCalledTimes(1);
|
|
await mockLimit.mock.results[0].value;
|
|
});
|
|
|
|
it('logs instead of rejecting when a queued handler throws', async () => {
|
|
loadFileWatcher().startFileWatcher();
|
|
|
|
const failure = new Error('boom');
|
|
mockLimit.mockImplementationOnce(() => Promise.reject(failure));
|
|
mockHandlers.add('/whatever');
|
|
|
|
await new Promise(process.nextTick);
|
|
expect(mockLogger.error).toHaveBeenCalledWith('Error processing new photo:', failure);
|
|
});
|
|
});
|