feat(scanner): add work queue for book dock pipeline and auto-size node heap

Introduce BookDockWorkQueue to serialize metadata extraction and auto-finalize
jobs. Files process newest-first with a 250 ms drain window and 500-1000 ms
inter-job spacing to avoid hammering metadata providers. Watched-folder rescans
now requeue files stuck in extracting or fetching status after a server restart
rather than silently dropping them.

Replace the global ThrottlerGuard with SensitiveEndpointThrottlerGuard, which
only activates on endpoints that carry explicit @Throttle() metadata. This
eliminates all @SkipThrottle() decorators from non-auth controllers and adds
missing throttle coverage for oidcGenerateLinkState and oidcGeneratePreviewState.
Update the login page to match the simplified 429 error message.

Fix countsByStatus to aggregate extracting and fetching rows into the pending
bucket so the UI counter stays accurate mid-pipeline. Add a secondary id sort
to findAll for deterministic pagination ordering.

Auto-detect the container memory limit via cgroup v1/v2 in entrypoint.sh and
compute --max-old-space-size proportionally (50-75 % of limit) instead of
defaulting to a fixed 2048 MB that OOMs small containers.

Closes #441
This commit is contained in:
neon
2026-06-28 16:54:53 +00:00
parent 9951a107af
commit e453618463
22 changed files with 1021 additions and 96 deletions
+1 -1
View File
@@ -63,7 +63,7 @@ function resolveLoginError(err: unknown): string {
if (!(err instanceof Error)) return 'Invalid username or password'
const normalizedMessage = err.message.trim().toLowerCase()
if (normalizedMessage.includes('too many requests') || normalizedMessage.includes('throttler')) {
if (normalizedMessage.includes('too many requests')) {
return 'Too many login attempts. Please wait a minute and try again.'
}
if (normalizedMessage.includes('invalid credentials')) {
+1 -1
View File
@@ -25,7 +25,7 @@ services:
CLIENT_URL: ${CLIENT_URL:-${APP_URL?required}}
PUID: ${PUID:-1000}
PGID: ${PGID:-1000}
NODE_MAX_OLD_SPACE_SIZE: ${NODE_MAX_OLD_SPACE_SIZE:-2048}
NODE_MAX_OLD_SPACE_SIZE: ${NODE_MAX_OLD_SPACE_SIZE:-auto}
BOOK_DOCK_PATH: ${BOOK_DOCK_PATH:-}
depends_on:
postgres:
+63 -7
View File
@@ -52,6 +52,57 @@ check_writable_current_user() {
fi
}
read_memory_limit_bytes() {
for path in /sys/fs/cgroup/memory.max /sys/fs/cgroup/memory/memory.limit_in_bytes; do
if [ ! -r "$path" ]; then
continue
fi
value="$(cat "$path" 2>/dev/null || true)"
case "$value" in
'' | max | *[!0-9]*) continue ;;
esac
# cgroup v1 reports near-2^63 values for unlimited memory.
if [ "${#value}" -gt 15 ]; then
continue
fi
echo "$value"
return 0
done
return 1
}
auto_old_space_size_mb() {
limit_bytes="$(read_memory_limit_bytes || true)"
if [ -z "$limit_bytes" ]; then
echo 2048
return 0
fi
limit_mb=$((limit_bytes / 1048576))
if [ "$limit_mb" -lt 1 ]; then
echo 2048
return 0
fi
if [ "$limit_mb" -lt 512 ]; then
heap_mb=$((limit_mb / 2))
elif [ "$limit_mb" -lt 2048 ]; then
heap_mb=$((limit_mb * 65 / 100))
else
heap_mb=$((limit_mb * 75 / 100))
fi
if [ "$heap_mb" -lt 128 ]; then
heap_mb=128
fi
echo "$heap_mb"
}
# Percent-encode a single URL component so credentials containing reserved
# characters (#, /, ?, @, :, spaces, ...) don't corrupt the DATABASE_URL.
urlencode() {
@@ -71,7 +122,7 @@ export HOME="${HOME:-/tmp}"
APP_UID="${PUID:-1000}"
APP_GID="${PGID:-1000}"
NODE_MAX_OLD_SPACE_SIZE="${NODE_MAX_OLD_SPACE_SIZE:-2048}"
NODE_MAX_OLD_SPACE_SIZE="${NODE_MAX_OLD_SPACE_SIZE:-auto}"
case "$APP_UID" in
'' | *[!0-9]*)
@@ -87,12 +138,17 @@ case "$APP_GID" in
;;
esac
case "$NODE_MAX_OLD_SPACE_SIZE" in
'' | *[!0-9]*)
log "NODE_MAX_OLD_SPACE_SIZE must be a numeric MB value, got '$NODE_MAX_OLD_SPACE_SIZE'."
exit 1
;;
esac
if [ "$NODE_MAX_OLD_SPACE_SIZE" = "auto" ]; then
NODE_MAX_OLD_SPACE_SIZE="$(auto_old_space_size_mb)"
log "using NODE_MAX_OLD_SPACE_SIZE=${NODE_MAX_OLD_SPACE_SIZE} MB from auto detection."
else
case "$NODE_MAX_OLD_SPACE_SIZE" in
'' | *[!0-9]*)
log "NODE_MAX_OLD_SPACE_SIZE must be a numeric MB value or 'auto', got '$NODE_MAX_OLD_SPACE_SIZE'."
exit 1
;;
esac
fi
if [ "$NODE_MAX_OLD_SPACE_SIZE" -lt 1 ]; then
log "NODE_MAX_OLD_SPACE_SIZE must be greater than 0."
+3 -2
View File
@@ -2,7 +2,7 @@ import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
import { ScheduleModule } from '@nestjs/schedule';
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
import { ThrottlerModule } from '@nestjs/throttler';
import { LoggerModule } from 'nestjs-pino';
import { appConfig, authConfig, dbConfig, emailConfig, fileWriteConfig, migrationConfig, oidcRuntimeConfig, storageConfig } from './config/config';
@@ -14,6 +14,7 @@ import { AuditInterceptor } from './common/interceptors/audit.interceptor';
import { JwtAuthGuard } from './common/guards/jwt-auth.guard';
import { LibraryAccessGuard } from './common/guards/library-access.guard';
import { PermissionGuard } from './common/guards/permission.guard';
import { SensitiveEndpointThrottlerGuard } from './common/guards/sensitive-endpoint-throttler.guard';
import { DbModule } from './db/db.module';
import { AnnotationModule } from './modules/annotation/annotation.module';
import { DashboardModule } from './modules/dashboard/dashboard.module';
@@ -140,8 +141,8 @@ import { CustomIconModule } from './modules/custom-icon/custom-icon.module';
HardcoverModule,
],
providers: [
{ provide: APP_GUARD, useClass: ThrottlerGuard },
{ provide: APP_INTERCEPTOR, useClass: AuditInterceptor },
{ provide: APP_GUARD, useClass: SensitiveEndpointThrottlerGuard },
{ provide: APP_GUARD, useClass: JwtAuthGuard },
{ provide: APP_GUARD, useClass: PermissionGuard },
{ provide: APP_GUARD, useClass: LibraryAccessGuard },
@@ -0,0 +1,85 @@
import 'reflect-metadata';
import type { ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Throttle, ThrottlerGuard } from '@nestjs/throttler';
import { SensitiveEndpointThrottlerGuard } from './sensitive-endpoint-throttler.guard';
class TestController {
unthrottled() {}
@Throttle({ default: { limit: 2, ttl: 60_000 } })
throttled() {}
}
@Throttle({ default: { limit: 5, ttl: 60_000 } })
class ThrottledController {
anyMethod() {}
}
function makeContext(handler: () => void, classRef: new (...args: never[]) => unknown = TestController): ExecutionContext {
return {
getHandler: () => handler,
getClass: () => classRef,
} as unknown as ExecutionContext;
}
function makeGuard(): SensitiveEndpointThrottlerGuard {
return new SensitiveEndpointThrottlerGuard(
{ throttlers: [{ name: 'default', limit: 120, ttl: 60_000 }] },
{ increment: vi.fn() } as never,
new Reflector(),
);
}
describe('SensitiveEndpointThrottlerGuard', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('bypasses handlers without throttle metadata', async () => {
const guard = makeGuard();
await guard.onModuleInit();
const superCanActivate = vi.spyOn(ThrottlerGuard.prototype, 'canActivate').mockResolvedValue(true);
await expect(guard.canActivate(makeContext(TestController.prototype.unthrottled))).resolves.toBe(true);
expect(superCanActivate).not.toHaveBeenCalled();
});
it('delegates handlers with method-level throttle metadata to Nest throttler', async () => {
const guard = makeGuard();
await guard.onModuleInit();
const context = makeContext(TestController.prototype.throttled);
const superCanActivate = vi.spyOn(ThrottlerGuard.prototype, 'canActivate').mockResolvedValue(true);
await expect(guard.canActivate(context)).resolves.toBe(true);
expect(superCanActivate).toHaveBeenCalledWith(context);
});
it('delegates to Nest throttler when the class itself carries throttle metadata', async () => {
const guard = makeGuard();
await guard.onModuleInit();
const context = makeContext(ThrottledController.prototype.anyMethod, ThrottledController);
const superCanActivate = vi.spyOn(ThrottlerGuard.prototype, 'canActivate').mockResolvedValue(true);
await expect(guard.canActivate(context)).resolves.toBe(true);
expect(superCanActivate).toHaveBeenCalledWith(context);
});
it('uses configured throttlers from options when throttlers list is not yet populated', async () => {
const guard = new SensitiveEndpointThrottlerGuard(
{ throttlers: [{ name: 'default', limit: 10, ttl: 60_000 }] },
{ increment: vi.fn() } as never,
new Reflector(),
);
const context = makeContext(TestController.prototype.throttled);
const superCanActivate = vi.spyOn(ThrottlerGuard.prototype, 'canActivate').mockResolvedValue(true);
await expect(guard.canActivate(context)).resolves.toBe(true);
expect(superCanActivate).toHaveBeenCalledWith(context);
});
});
@@ -0,0 +1,34 @@
import { ExecutionContext, Injectable } from '@nestjs/common';
import { ThrottlerGuard, type ThrottlerOptions } from '@nestjs/throttler';
const DEFAULT_THROTTLER_NAME = 'default';
const THROTTLER_METADATA_KEYS = [
'THROTTLER:LIMIT',
'THROTTLER:TTL',
'THROTTLER:BLOCK_DURATION',
'THROTTLER:TRACKER',
'THROTTLER:KEY_GENERATOR',
] as const;
@Injectable()
export class SensitiveEndpointThrottlerGuard extends ThrottlerGuard {
override async canActivate(context: ExecutionContext): Promise<boolean> {
if (!this.hasThrottleMetadata(context)) return true;
return super.canActivate(context);
}
private hasThrottleMetadata(context: ExecutionContext): boolean {
const handler = context.getHandler();
const classRef = context.getClass();
return this.configuredThrottlers().some((throttler) => {
const name = throttler.name ?? DEFAULT_THROTTLER_NAME;
return THROTTLER_METADATA_KEYS.some((key) => this.reflector.getAllAndOverride(`${key}${name}`, [handler, classRef]) !== undefined);
});
}
private configuredThrottlers(): ThrottlerOptions[] {
if (this.throttlers?.length) return this.throttlers;
return Array.isArray(this.options) ? this.options : this.options.throttlers;
}
}
+22 -41
View File
@@ -134,51 +134,32 @@ describe('AuthController', () => {
expect(() => controller.listMagicLinks(user)).toThrow();
});
it('defines throttling metadata for sensitive public endpoints', () => {
it('defines throttling metadata only for sensitive auth endpoints', () => {
const limitKey = 'THROTTLER:LIMITdefault';
const ttlKey = 'THROTTLER:TTLdefault';
const throttledEndpoints: Array<[object, number, number]> = [
[AuthController.prototype.register, 3, 60_000],
[AuthController.prototype.setup, 3, 60_000],
[AuthController.prototype.login, 5, 60_000],
[AuthController.prototype.forgotPassword, 3, 3_600_000],
[AuthController.prototype.resetPassword, 5, 60_000],
[AuthController.prototype.oidcGenerateState, 10, 60_000],
[AuthController.prototype.oidcCallback, 5, 60_000],
[AuthController.prototype.oidcGenerateLinkState, 5, 60_000],
[AuthController.prototype.oidcGeneratePreviewState, 5, 60_000],
[AuthController.prototype.createMagicLink, 5, 60_000],
[AuthController.prototype.loginWithMagicLink, 10, 60_000],
];
const registerLimit = Reflect.getMetadata(limitKey, AuthController.prototype.register) as number;
const registerTtl = Reflect.getMetadata(ttlKey, AuthController.prototype.register) as number;
const setupLimit = Reflect.getMetadata(limitKey, AuthController.prototype.setup) as number;
const setupTtl = Reflect.getMetadata(ttlKey, AuthController.prototype.setup) as number;
const loginLimit = Reflect.getMetadata(limitKey, AuthController.prototype.login) as number;
const loginTtl = Reflect.getMetadata(ttlKey, AuthController.prototype.login) as number;
const forgotLimit = Reflect.getMetadata(limitKey, AuthController.prototype.forgotPassword) as number;
const forgotTtl = Reflect.getMetadata(ttlKey, AuthController.prototype.forgotPassword) as number;
const resetLimit = Reflect.getMetadata(limitKey, AuthController.prototype.resetPassword) as number;
const resetTtl = Reflect.getMetadata(ttlKey, AuthController.prototype.resetPassword) as number;
for (const [handler, limit, ttl] of throttledEndpoints) {
expect(Reflect.getMetadata(limitKey, handler)).toBe(limit);
expect(Reflect.getMetadata(ttlKey, handler)).toBe(ttl);
}
expect(registerLimit).toBe(3);
expect(registerTtl).toBe(60_000);
expect(setupLimit).toBe(3);
expect(setupTtl).toBe(60_000);
expect(loginLimit).toBe(5);
expect(loginTtl).toBe(60_000);
expect(forgotLimit).toBe(3);
expect(forgotTtl).toBe(3_600_000);
expect(resetLimit).toBe(5);
expect(resetTtl).toBe(60_000);
const oidcStateLimit = Reflect.getMetadata(limitKey, AuthController.prototype.oidcGenerateState) as number;
const oidcStateTtl = Reflect.getMetadata(ttlKey, AuthController.prototype.oidcGenerateState) as number;
const oidcCallbackLimit = Reflect.getMetadata(limitKey, AuthController.prototype.oidcCallback) as number;
const oidcCallbackTtl = Reflect.getMetadata(ttlKey, AuthController.prototype.oidcCallback) as number;
expect(oidcStateLimit).toBe(10);
expect(oidcStateTtl).toBe(60_000);
expect(oidcCallbackLimit).toBe(5);
expect(oidcCallbackTtl).toBe(60_000);
const createMlLimit = Reflect.getMetadata(limitKey, AuthController.prototype.createMagicLink) as number;
const createMlTtl = Reflect.getMetadata(ttlKey, AuthController.prototype.createMagicLink) as number;
const loginMlLimit = Reflect.getMetadata(limitKey, AuthController.prototype.loginWithMagicLink) as number;
const loginMlTtl = Reflect.getMetadata(ttlKey, AuthController.prototype.loginWithMagicLink) as number;
expect(createMlLimit).toBe(5);
expect(createMlTtl).toBe(60_000);
expect(loginMlLimit).toBe(10);
expect(loginMlTtl).toBe(60_000);
expect(Reflect.getMetadata(limitKey, AuthController.prototype.refresh)).toBeUndefined();
expect(Reflect.getMetadata(limitKey, AuthController.prototype.logout)).toBeUndefined();
expect(Reflect.getMetadata(limitKey, AuthController.prototype.me)).toBeUndefined();
expect(Reflect.getMetadata(limitKey, AuthController.prototype.listMagicLinks)).toBeUndefined();
});
it('defines forbidden-permission metadata for account edit endpoints', () => {
@@ -18,7 +18,6 @@ import {
Res,
Sse,
} from '@nestjs/common';
import { SkipThrottle } from '@nestjs/throttler';
import { createReadStream } from 'fs';
import { stat } from 'fs/promises';
import type { FastifyReply } from 'fastify';
@@ -195,7 +194,6 @@ export class AuthorsController {
return this.authorsService.findBooks(user, id, dto);
}
@SkipThrottle()
@Get(':id/thumbnail')
async getThumbnail(@CurrentUser() user: RequestUser, @Param('id', ParseIntPipe) id: number, @Res() reply: FastifyReply) {
const thumbnailPath = await this.authorsService.getThumbnailPath(user, id);
@@ -211,7 +209,6 @@ export class AuthorsController {
reply.send(createReadStream(thumbnailPath));
}
@SkipThrottle()
@Get(':id/image')
async getImage(@CurrentUser() user: RequestUser, @Param('id', ParseIntPipe) id: number, @Res() reply: FastifyReply) {
const imagePath = await this.authorsService.getImagePath(user, id);
@@ -1469,7 +1469,7 @@ describe('BookDockFinalizeService', () => {
const handler = events.on.mock.calls[0]?.[1] as ((fileId: number) => void) | undefined;
expect(handler).toBeDefined();
handler?.(77);
await Promise.resolve();
await (service as any).autoFinalizeQueue.waitForIdle();
expect(triggerSpy).toHaveBeenCalledWith(77);
});
@@ -1,4 +1,4 @@
import { BadRequestException, Inject, Injectable, Logger, NotFoundException, OnModuleInit, Optional } from '@nestjs/common';
import { BadRequestException, Inject, Injectable, Logger, NotFoundException, OnModuleDestroy, OnModuleInit, Optional } from '@nestjs/common';
import { basename, dirname, extname, join, resolve } from 'path';
import { access as fsAccess, readFile, stat, unlink } from 'fs/promises';
import { and, eq, inArray, or, sql } from 'drizzle-orm';
@@ -15,6 +15,7 @@ import { NotificationType, resolveUploadPath } from '@bookorbit/types';
import { NotificationService } from '../notification/notification.service';
import { SeriesIdentityService } from '../../common/services/series-identity.service';
import { SeriesMembershipService } from '../../common/services/series-membership.service';
import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
import { formatSeriesIndex } from '../../common/utils/series-index-format.utils';
import { DB } from '../../db';
import * as schema from '../../db/schema';
@@ -28,6 +29,7 @@ import { UploadValidatorService } from '../upload/upload-validator.service';
import { BookDockRepository } from './book-dock.repository';
import { BookDockEventsService, BOOK_DOCK_FILE_INGESTED } from './book-dock-events.service';
import { BookDockGateway } from './book-dock.gateway';
import { BookDockWorkQueue } from './book-dock-work-queue';
import type { BookDockFileRow } from '../../db/schema';
type Db = NodePgDatabase<typeof schema>;
@@ -40,6 +42,7 @@ type FinalizeOverrideEntry = {
};
const BATCH_SIZE = 100;
const AUTO_FINALIZE_QUEUE_CONCURRENCY = 1;
const MIN_PUBLISHED_YEAR = 1000;
const MAX_PUBLISHED_YEAR = 2200;
const PUBLISHED_YEAR_RANGE_CONSTRAINT = 'book_metadata_published_year_range_chk';
@@ -64,8 +67,9 @@ type NormalizedFinalizeMetadata = {
};
@Injectable()
export class BookDockFinalizeService implements OnModuleInit {
export class BookDockFinalizeService implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(BookDockFinalizeService.name);
private readonly autoFinalizeQueue: BookDockWorkQueue;
constructor(
@Inject(DB) private readonly db: Db,
@@ -81,14 +85,24 @@ export class BookDockFinalizeService implements OnModuleInit {
private readonly notificationService: NotificationService,
@Optional() private readonly seriesIdentity?: SeriesIdentityService,
@Optional() private readonly seriesMemberships?: SeriesMembershipService,
) {}
) {
this.autoFinalizeQueue = new BookDockWorkQueue(
AUTO_FINALIZE_QUEUE_CONCURRENCY,
(fileId) => this.triggerAutoFinalize(fileId),
(fileId, error) => this.logAutoFinalizeQueueFailure(fileId, error),
);
}
onModuleInit() {
this.events.on(BOOK_DOCK_FILE_INGESTED, (fileId: number) => {
void this.triggerAutoFinalize(fileId);
this.enqueueAutoFinalize(fileId);
});
}
onModuleDestroy(): void {
this.autoFinalizeQueue.stop();
}
async finalize(
userId: number,
isSuperuser: boolean,
@@ -306,6 +320,18 @@ export class BookDockFinalizeService implements OnModuleInit {
}
}
private enqueueAutoFinalize(fileId: number): void {
this.autoFinalizeQueue.enqueue(fileId);
}
private logAutoFinalizeQueueFailure(fileId: number, err: unknown): void {
const errorClass = err instanceof Error ? err.name : 'Error';
const errorMessage = sanitizeLogValue(err instanceof Error ? err.message : String(err));
this.logger.warn(
`[book_dock.auto_finalize_queue] [fail] fileId=${fileId} errorClass=${errorClass} error="${errorMessage}" - auto-finalize queue job failed`,
);
}
async previewNames(
fileIds: number[] | undefined,
selectAll: boolean | undefined,
@@ -232,6 +232,7 @@ describe('BookDockIngestService', () => {
const { service, repo } = makeService();
mockedStat.mockResolvedValue({ size: 1024 } as never);
repo.create.mockResolvedValue({ id: 5 });
repo.findById.mockResolvedValue({ id: 5, status: 'pending', format: 'epub', absolutePath: '/watched/book.epub' });
const metadataService = (service as any).metadataService;
metadataService.extractAndSave.mockRejectedValue(new Error('parse failed'));
@@ -239,10 +240,35 @@ describe('BookDockIngestService', () => {
const result = await service.ingestFromWatchedFolder('/watched/book.epub');
expect(result).toBe(5);
await new Promise((resolve) => setTimeout(resolve, 50));
await (service as any).metadataQueue.waitForIdle();
expect(Logger.prototype.warn).toHaveBeenCalledWith(expect.stringContaining('metadata queue job failed'));
expect(Logger.prototype.warn).toHaveBeenCalledWith(expect.stringContaining('parse failed'));
});
it('requeues existing unfinished rows found during a watched-folder rescan', async () => {
const { service, repo, metadataService } = makeService();
const row = { id: 6, status: 'extracting', format: 'epub', absolutePath: '/watched/book.epub' };
repo.findByAbsolutePath.mockResolvedValue(row);
repo.findById.mockResolvedValue(row);
const result = await service.ingestFromWatchedFolder('/watched/book.epub');
await (service as any).metadataQueue.waitForIdle();
expect(result).toBeNull();
expect(metadataService.extractAndSave).toHaveBeenCalledWith(6, '/watched/book.epub', 'epub', '/books/book-dock/covers');
});
it('does not requeue existing ready rows during a watched-folder rescan', async () => {
const { service, repo, metadataService } = makeService();
repo.findByAbsolutePath.mockResolvedValue({ id: 7, status: 'ready', format: 'epub', absolutePath: '/watched/book.epub' });
const result = await service.ingestFromWatchedFolder('/watched/book.epub');
await (service as any).metadataQueue.waitForIdle();
expect(result).toBeNull();
expect(metadataService.extractAndSave).not.toHaveBeenCalled();
});
});
describe('retryFetch', () => {
@@ -269,7 +295,7 @@ describe('BookDockIngestService', () => {
await service.retryFetch(3);
expect(repo.update).toHaveBeenCalledWith(3, { status: 'pending', errorMessage: null });
expect(extractSpy).toHaveBeenCalledWith(3, '/bucket/3.epub', 'epub');
expect(extractSpy).toHaveBeenCalledWith(3, 'epub', { primary: 3, secondary: 3 });
});
});
@@ -418,19 +444,180 @@ describe('BookDockIngestService', () => {
expect(repo.update).toHaveBeenNthCalledWith(1, 13, { status: 'fetching' });
expect(repo.update).toHaveBeenNthCalledWith(2, 13, { status: 'ready' });
});
it('emits a summary refresh before provider fetching starts', async () => {
const { service, appSettings, repo, metadataFetchPipeline } = makeService();
appSettings.isBookDockAutoFetchEnabled.mockResolvedValue(true);
repo.findById.mockResolvedValue({
id: 14,
status: 'ready',
embeddedMetadata: { title: 'Dune' },
});
const emitSummarySpy = vi.spyOn(service as any, 'emitSummary').mockResolvedValue(undefined);
const runWithSources = vi.fn().mockResolvedValue({ resolved: {}, sources: {} });
(metadataFetchPipeline as any).runWithSources = runWithSources;
await (service as any).autoFetchMetadataAsync(14);
expect(repo.update).toHaveBeenNthCalledWith(1, 14, { status: 'fetching' });
expect(emitSummarySpy).toHaveBeenCalledTimes(1);
expect(emitSummarySpy.mock.invocationCallOrder[0]).toBeLessThan(runWithSources.mock.invocationCallOrder[0]);
});
});
it('extractMetadataAsync emits summary and ingestion events after successful extraction chain', async () => {
const { service, metadataService, events } = makeService();
const { service, repo, metadataService, events } = makeService();
const autoFetchSpy = vi.spyOn(service as any, 'autoFetchMetadataAsync').mockResolvedValue(undefined);
const emitSummarySpy = vi.spyOn(service as any, 'emitSummary').mockResolvedValue(undefined);
repo.findById.mockResolvedValue({ id: 12, status: 'pending', format: 'epub', absolutePath: '/bucket/12.epub' });
(service as any).extractMetadataAsync(12, '/bucket/12.epub', 'epub');
await new Promise((resolve) => setTimeout(resolve, 0));
(service as any).extractMetadataAsync(12, 'epub');
await (service as any).metadataQueue.waitForIdle();
expect(metadataService.extractAndSave).toHaveBeenCalledWith(12, '/bucket/12.epub', 'epub', '/books/book-dock/covers');
expect(autoFetchSpy).toHaveBeenCalledWith(12);
expect(emitSummarySpy).toHaveBeenCalled();
expect(repo.update).toHaveBeenCalledWith(12, { status: 'extracting' });
expect(emitSummarySpy).toHaveBeenCalledTimes(2);
expect(events.emit).toHaveBeenCalledWith('book-dock.file.ingested', 12);
});
it('runs metadata extraction jobs newest first and one at a time', async () => {
vi.useFakeTimers();
const randomSpy = vi.spyOn(Math, 'random').mockReturnValue(0);
try {
const { service, repo, metadataService } = makeService();
repo.findById.mockImplementation((fileId: number) =>
Promise.resolve({ id: fileId, status: 'pending', format: 'epub', absolutePath: `/bucket/${fileId}.epub` }),
);
let active = 0;
let maxActive = 0;
let resolveNewest!: () => void;
let resolveNewestStarted!: () => void;
let resolveOlderStarted!: () => void;
const newestDone = new Promise<void>((resolve) => {
resolveNewest = resolve;
});
const newestStarted = new Promise<void>((resolve) => {
resolveNewestStarted = resolve;
});
const olderStarted = new Promise<void>((resolve) => {
resolveOlderStarted = resolve;
});
metadataService.extractAndSave.mockImplementation((fileId: number) =>
(async () => {
active++;
maxActive = Math.max(maxActive, active);
try {
if (fileId === 2) {
resolveNewestStarted();
await newestDone;
} else {
resolveOlderStarted();
}
} finally {
active--;
}
})(),
);
(service as any).extractMetadataAsync(1, 'epub');
(service as any).extractMetadataAsync(2, 'epub');
await vi.advanceTimersByTimeAsync(250);
await newestStarted;
expect(metadataService.extractAndSave).toHaveBeenCalledTimes(1);
expect(metadataService.extractAndSave).toHaveBeenCalledWith(2, '/bucket/2.epub', 'epub', '/books/book-dock/covers');
resolveNewest();
await Promise.resolve();
await vi.advanceTimersByTimeAsync(499);
expect(metadataService.extractAndSave).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(1);
await olderStarted;
await (service as any).metadataQueue.waitForIdle();
expect(metadataService.extractAndSave).toHaveBeenCalledTimes(2);
expect(maxActive).toBe(1);
} finally {
randomSpy.mockRestore();
vi.useRealTimers();
}
});
it('does not start the next Book Dock file while auto-fetch providers are still running', async () => {
vi.useFakeTimers();
const randomSpy = vi.spyOn(Math, 'random').mockReturnValue(0);
try {
const { service, repo, appSettings, metadataService, metadataFetchPipeline } = makeService();
appSettings.isBookDockAutoFetchEnabled.mockResolvedValue(true);
repo.findById.mockImplementation((fileId: number) =>
Promise.resolve({
id: fileId,
status: 'pending',
format: 'epub',
absolutePath: `/bucket/${fileId}.epub`,
embeddedMetadata: { title: `Book ${fileId}` },
}),
);
let activeFetches = 0;
let maxActiveFetches = 0;
let resolveNewestFetch!: () => void;
let resolveNewestFetchStarted!: () => void;
let resolveOlderFetchStarted!: () => void;
const newestFetchDone = new Promise<void>((resolve) => {
resolveNewestFetch = resolve;
});
const newestFetchStarted = new Promise<void>((resolve) => {
resolveNewestFetchStarted = resolve;
});
const olderFetchStarted = new Promise<void>((resolve) => {
resolveOlderFetchStarted = resolve;
});
(metadataFetchPipeline as any).runWithSources = vi.fn((params: { title?: string }) =>
(async () => {
activeFetches++;
maxActiveFetches = Math.max(maxActiveFetches, activeFetches);
try {
if (params.title === 'Book 2') {
resolveNewestFetchStarted();
await newestFetchDone;
} else {
resolveOlderFetchStarted();
}
return { resolved: {}, sources: {} };
} finally {
activeFetches--;
}
})(),
);
(service as any).extractMetadataAsync(1, 'epub');
(service as any).extractMetadataAsync(2, 'epub');
await vi.advanceTimersByTimeAsync(250);
await newestFetchStarted;
expect(metadataService.extractAndSave).toHaveBeenCalledTimes(1);
resolveNewestFetch();
await Promise.resolve();
await vi.advanceTimersByTimeAsync(499);
expect(metadataService.extractAndSave).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(1);
await olderFetchStarted;
await (service as any).metadataQueue.waitForIdle();
expect(metadataService.extractAndSave).toHaveBeenCalledTimes(2);
expect((metadataFetchPipeline as any).runWithSources).toHaveBeenCalledTimes(2);
expect(maxActiveFetches).toBe(1);
} finally {
randomSpy.mockRestore();
vi.useRealTimers();
}
});
});
@@ -1,10 +1,12 @@
import { BadRequestException, Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
import { BadRequestException, Injectable, Logger, OnApplicationBootstrap, OnModuleDestroy } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { basename, extname, join } from 'path';
import { mkdir, realpath, stat } from 'fs/promises';
import { Readable } from 'stream';
import type { BookDockMetadata } from '@bookorbit/types';
import type { BookDockFileRow } from '../../db/schema';
import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
import { SUPPORTED_BOOK_FORMATS, UploadValidatorService } from '../upload/upload-validator.service';
import { UploadStorageService } from '../upload/upload-storage.service';
import { AppSettingsService } from '../app-settings/app-settings.service';
@@ -13,11 +15,19 @@ import { BookDockRepository } from './book-dock.repository';
import { BookDockMetadataService } from './book-dock-metadata.service';
import { BookDockEventsService, BOOK_DOCK_FILE_INGESTED } from './book-dock-events.service';
import { BookDockGateway } from './book-dock.gateway';
import { BookDockWorkQueue, type BookDockWorkPriority } from './book-dock-work-queue';
const METADATA_QUEUE_CONCURRENCY = 1;
const METADATA_QUEUE_DRAIN_DELAY_MS = 250;
const METADATA_QUEUE_INTER_BOOK_DELAY_MIN_MS = 500;
const METADATA_QUEUE_INTER_BOOK_DELAY_MAX_MS = 1_000;
const PROCESSABLE_METADATA_STATUSES = new Set(['pending', 'extracting', 'fetching']);
@Injectable()
export class BookDockIngestService implements OnApplicationBootstrap {
export class BookDockIngestService implements OnApplicationBootstrap, OnModuleDestroy {
private readonly logger = new Logger(BookDockIngestService.name);
private bookDockPath: string;
private readonly metadataQueue: BookDockWorkQueue;
constructor(
private readonly config: ConfigService,
@@ -32,6 +42,16 @@ export class BookDockIngestService implements OnApplicationBootstrap {
) {
const appDataPath = this.config.get<string>('storage.appDataPath') ?? '/data';
this.bookDockPath = this.config.get<string>('storage.bookDockPath') ?? join(appDataPath, 'book-dock');
this.metadataQueue = new BookDockWorkQueue(
METADATA_QUEUE_CONCURRENCY,
(fileId) => this.processMetadataJob(fileId),
(fileId, error) => this.logMetadataQueueFailure(fileId, error),
{
pendingOrder: 'priority-desc',
drainDelayMs: METADATA_QUEUE_DRAIN_DELAY_MS,
interJobDelayMs: { minMs: METADATA_QUEUE_INTER_BOOK_DELAY_MIN_MS, maxMs: METADATA_QUEUE_INTER_BOOK_DELAY_MAX_MS },
},
);
}
async onApplicationBootstrap(): Promise<void> {
@@ -39,6 +59,10 @@ export class BookDockIngestService implements OnApplicationBootstrap {
this.bookDockPath = await realpath(this.bookDockPath);
}
onModuleDestroy(): void {
this.metadataQueue.stop();
}
async ingestUpload(rawFilename: string, fileStream: Readable, uploadedBy?: number): Promise<number> {
const filename = this.validator.sanitizeFilename(rawFilename);
const ext = extname(filename).toLowerCase().slice(1);
@@ -64,7 +88,7 @@ export class BookDockIngestService implements OnApplicationBootstrap {
uploadedBy: uploadedBy ?? null,
});
this.extractMetadataAsync(row.id, destPath, ext);
this.extractMetadataAsync(row.id, ext, metadataQueuePriority(row));
return row.id;
} catch (err) {
@@ -77,12 +101,15 @@ export class BookDockIngestService implements OnApplicationBootstrap {
const row = await this.repo.findById(fileId);
if (!row || row.status !== 'error' || !row.format) return;
await this.repo.update(fileId, { status: 'pending', errorMessage: null });
this.extractMetadataAsync(fileId, row.absolutePath, row.format);
this.extractMetadataAsync(fileId, row.format, metadataQueuePriority(row));
}
async ingestFromWatchedFolder(absolutePath: string): Promise<number | null> {
const existing = await this.repo.findByAbsolutePath(absolutePath);
if (existing) return null;
if (existing) {
this.requeueExistingIfProcessable(existing);
return null;
}
const ext = extname(absolutePath).toLowerCase().slice(1);
if (!SUPPORTED_BOOK_FORMATS.has(ext)) return null;
@@ -103,19 +130,45 @@ export class BookDockIngestService implements OnApplicationBootstrap {
status: 'pending',
});
this.extractMetadataAsync(row.id, absolutePath, ext);
this.extractMetadataAsync(row.id, ext, metadataQueuePriority(row));
return row.id;
}
private extractMetadataAsync(fileId: number, absolutePath: string, format: string): void {
private extractMetadataAsync(fileId: number, format: string, priority?: BookDockWorkPriority): void {
if (!isSupportedFormat(format)) return;
this.metadataQueue.enqueue(fileId, priority);
}
private requeueExistingIfProcessable(row: BookDockFileRow): void {
if (!PROCESSABLE_METADATA_STATUSES.has(row.status)) return;
const format = resolveSupportedFormat(row);
if (!format) return;
this.extractMetadataAsync(row.id, format, metadataQueuePriority(row));
}
private async processMetadataJob(fileId: number): Promise<void> {
const row = await this.repo.findById(fileId);
if (!row || !PROCESSABLE_METADATA_STATUSES.has(row.status)) return;
const format = resolveSupportedFormat(row);
if (!format) return;
const coversDir = join(this.bookDockPath, 'covers');
this.metadataService
.extractAndSave(fileId, absolutePath, format, coversDir)
.then(() => this.autoFetchMetadataAsync(fileId))
.then(() => this.emitSummary())
.then(() => this.events.emit(BOOK_DOCK_FILE_INGESTED, fileId))
.catch((err: Error) => this.logger.warn(`Background metadata extraction failed for Book Dock file ${fileId}: ${err.message}`));
await this.repo.update(fileId, { status: 'extracting' });
await this.emitSummary();
await this.metadataService.extractAndSave(fileId, row.absolutePath, format, coversDir);
await this.autoFetchMetadataAsync(fileId);
await this.emitSummary();
this.events.emit(BOOK_DOCK_FILE_INGESTED, fileId);
}
private logMetadataQueueFailure(fileId: number, err: unknown): void {
const errorClass = err instanceof Error ? err.name : 'Error';
const errorMessage = sanitizeLogValue(err instanceof Error ? err.message : String(err));
this.logger.warn(
`[book_dock.metadata_queue] [fail] fileId=${fileId} errorClass=${errorClass} error="${errorMessage}" - metadata queue job failed`,
);
}
private async autoFetchMetadataAsync(fileId: number): Promise<void> {
@@ -134,6 +187,7 @@ export class BookDockIngestService implements OnApplicationBootstrap {
if (!params.title && !params.isbn) return;
await this.repo.update(fileId, { status: 'fetching' });
await this.emitSummary();
try {
const { resolved, sources } = await this.metadataFetchPipeline.runWithSources(params, {});
const fetched: Record<string, unknown> = {};
@@ -176,6 +230,23 @@ export class BookDockIngestService implements OnApplicationBootstrap {
}
}
function resolveSupportedFormat(row: Pick<BookDockFileRow, 'format' | 'absolutePath'>): string | null {
const format = row.format ?? extname(row.absolutePath).toLowerCase().slice(1);
return isSupportedFormat(format) ? format : null;
}
function isSupportedFormat(format: string | null | undefined): format is string {
return typeof format === 'string' && SUPPORTED_BOOK_FORMATS.has(format);
}
function metadataQueuePriority(row: Pick<BookDockFileRow, 'id'> & Partial<Pick<BookDockFileRow, 'createdAt'>>): BookDockWorkPriority {
const createdAtMs = row.createdAt instanceof Date ? row.createdAt.getTime() : Number.NaN;
return {
primary: Number.isFinite(createdAtMs) ? createdAtMs : row.id,
secondary: row.id,
};
}
const STOP_WORDS = new Set(['a', 'an', 'the', 'and', 'or', 'of', 'in', 'to', 'for', 'by', 'at', 'on', 'is', 'it']);
function tokenize(s: string): Set<string> {
@@ -0,0 +1,271 @@
import { BookDockWorkQueue } from './book-dock-work-queue';
describe('BookDockWorkQueue', () => {
it('runs queued work within the configured concurrency', async () => {
let active = 0;
let maxActive = 0;
let releaseFirst!: () => void;
let firstStarted!: () => void;
let secondStarted!: () => void;
const firstDone = new Promise<void>((resolve) => {
releaseFirst = resolve;
});
const firstStart = new Promise<void>((resolve) => {
firstStarted = resolve;
});
const secondStart = new Promise<void>((resolve) => {
secondStarted = resolve;
});
const queue = new BookDockWorkQueue(
1,
async (fileId) => {
active++;
maxActive = Math.max(maxActive, active);
try {
if (fileId === 1) {
firstStarted();
await firstDone;
} else {
secondStarted();
}
} finally {
active--;
}
},
vi.fn(),
);
queue.enqueue(1);
queue.enqueue(2);
await firstStart;
expect(maxActive).toBe(1);
releaseFirst();
await secondStart;
await queue.waitForIdle();
expect(maxActive).toBe(1);
});
it('coalesces duplicate queued or running file ids', async () => {
const handled: number[] = [];
let releaseFirst!: () => void;
let firstStarted!: () => void;
const firstDone = new Promise<void>((resolve) => {
releaseFirst = resolve;
});
const firstStart = new Promise<void>((resolve) => {
firstStarted = resolve;
});
const queue = new BookDockWorkQueue(
1,
async (fileId) => {
handled.push(fileId);
firstStarted();
await firstDone;
},
vi.fn(),
);
queue.enqueue(7);
queue.enqueue(7);
await firstStart;
queue.enqueue(7);
releaseFirst();
await queue.waitForIdle();
expect(handled).toEqual([7]);
});
it('reports job errors and keeps draining the queue', async () => {
const error = new Error('failed');
const handled: number[] = [];
const onError = vi.fn();
const queue = new BookDockWorkQueue(
1,
(fileId) => {
handled.push(fileId);
return fileId === 1 ? Promise.reject(error) : Promise.resolve();
},
onError,
);
queue.enqueue(1);
queue.enqueue(2);
await queue.waitForIdle();
expect(handled).toEqual([1, 2]);
expect(onError).toHaveBeenCalledWith(1, error);
});
it('can batch delayed work and run highest-priority jobs first', async () => {
vi.useFakeTimers();
try {
const handled: number[] = [];
const queue = new BookDockWorkQueue(
1,
(fileId) => {
handled.push(fileId);
return Promise.resolve();
},
vi.fn(),
{ pendingOrder: 'priority-desc', drainDelayMs: 100 },
);
queue.enqueue(1, { primary: 10, secondary: 1 });
await vi.advanceTimersByTimeAsync(90);
queue.enqueue(2, { primary: 30, secondary: 2 });
queue.enqueue(3, { primary: 20, secondary: 3 });
expect(handled).toEqual([]);
await vi.advanceTimersByTimeAsync(99);
expect(handled).toEqual([]);
await vi.advanceTimersByTimeAsync(100);
await queue.waitForIdle();
expect(handled).toEqual([2, 3, 1]);
} finally {
vi.useRealTimers();
}
});
it('waits the configured inter-job delay before starting the next queued job', async () => {
vi.useFakeTimers();
try {
let releaseFirst!: () => void;
let firstStarted!: () => void;
let secondStarted!: () => void;
const firstDone = new Promise<void>((resolve) => {
releaseFirst = resolve;
});
const firstStart = new Promise<void>((resolve) => {
firstStarted = resolve;
});
const secondStart = new Promise<void>((resolve) => {
secondStarted = resolve;
});
const queue = new BookDockWorkQueue(
1,
async (fileId) => {
if (fileId === 1) {
firstStarted();
await firstDone;
} else {
secondStarted();
}
},
vi.fn(),
{ interJobDelayMs: { minMs: 100, maxMs: 100 } },
);
queue.enqueue(1);
queue.enqueue(2);
await firstStart;
releaseFirst();
await Promise.resolve();
await vi.advanceTimersByTimeAsync(99);
let secondHasStarted = false;
void secondStart.then(() => {
secondHasStarted = true;
});
await Promise.resolve();
expect(secondHasStarted).toBe(false);
await vi.advanceTimersByTimeAsync(1);
await secondStart;
await queue.waitForIdle();
} finally {
vi.useRealTimers();
}
});
it('resolves waitForIdle immediately when the queue is already empty', async () => {
const queue = new BookDockWorkQueue(1, vi.fn().mockResolvedValue(undefined), vi.fn());
await expect(queue.waitForIdle()).resolves.toBeUndefined();
});
it('ignores enqueue calls with invalid file ids', async () => {
const handler = vi.fn().mockResolvedValue(undefined);
const queue = new BookDockWorkQueue(1, handler, vi.fn());
queue.enqueue(0);
queue.enqueue(-1);
queue.enqueue(1.5);
await queue.waitForIdle();
expect(handler).not.toHaveBeenCalled();
});
it('stop clears pending items and prevents new work from starting', async () => {
vi.useFakeTimers();
try {
const handler = vi.fn().mockResolvedValue(undefined);
const queue = new BookDockWorkQueue(1, handler, vi.fn(), { drainDelayMs: 500 });
queue.enqueue(1);
queue.enqueue(2);
queue.stop();
await queue.waitForIdle();
expect(handler).not.toHaveBeenCalled();
} finally {
vi.useRealTimers();
}
});
it('stop resolves pre-registered idle waiters when no jobs are running', async () => {
vi.useFakeTimers();
try {
const queue = new BookDockWorkQueue(1, vi.fn().mockResolvedValue(undefined), vi.fn(), { drainDelayMs: 500 });
queue.enqueue(1);
const idle = queue.waitForIdle();
queue.stop();
await expect(idle).resolves.toBeUndefined();
} finally {
vi.useRealTimers();
}
});
it('ignores enqueue calls after stop', async () => {
const handler = vi.fn().mockResolvedValue(undefined);
const queue = new BookDockWorkQueue(1, handler, vi.fn());
queue.stop();
queue.enqueue(1);
await queue.waitForIdle();
expect(handler).not.toHaveBeenCalled();
});
it('processes queued items in lifo order', async () => {
const handled: number[] = [];
const queue = new BookDockWorkQueue(
1,
(fileId) => {
handled.push(fileId);
return Promise.resolve();
},
vi.fn(),
{ pendingOrder: 'lifo' },
);
queue.enqueue(1);
queue.enqueue(2);
queue.enqueue(3);
await queue.waitForIdle();
expect(handled[0]).toBe(1);
expect(handled.slice(1)).toEqual([3, 2]);
});
});
@@ -0,0 +1,156 @@
type WorkHandler = (fileId: number) => Promise<void>;
type ErrorHandler = (fileId: number, error: unknown) => void;
type PendingOrder = 'fifo' | 'lifo' | 'priority-desc';
export interface BookDockWorkPriority {
primary: number;
secondary?: number;
}
interface DelayRange {
minMs: number;
maxMs: number;
}
interface BookDockWorkQueueOptions {
pendingOrder?: PendingOrder;
drainDelayMs?: number;
interJobDelayMs?: DelayRange;
}
interface PendingEntry {
fileId: number;
priority: BookDockWorkPriority;
}
export class BookDockWorkQueue {
private readonly pending: PendingEntry[] = [];
private readonly queued = new Set<number>();
private readonly running = new Set<number>();
private readonly idleResolvers: Array<() => void> = [];
private drainTimer: ReturnType<typeof setTimeout> | null = null;
private nextDrainNotBeforeMs = 0;
private activeCount = 0;
private stopped = false;
constructor(
private readonly concurrency: number,
private readonly handler: WorkHandler,
private readonly onError: ErrorHandler,
private readonly options: BookDockWorkQueueOptions = {},
) {}
enqueue(fileId: number, priority?: BookDockWorkPriority): void {
if (this.stopped || !Number.isInteger(fileId) || fileId < 1) return;
if (this.queued.has(fileId) || this.running.has(fileId)) return;
this.queued.add(fileId);
this.pending.push({ fileId, priority: priority ?? { primary: fileId, secondary: fileId } });
this.scheduleDrain({ includeDrainDelay: true });
}
stop(): void {
this.stopped = true;
if (this.drainTimer) {
clearTimeout(this.drainTimer);
this.drainTimer = null;
}
this.pending.length = 0;
this.queued.clear();
this.resolveIdleIfNeeded();
}
waitForIdle(): Promise<void> {
if (this.isIdle()) return Promise.resolve();
return new Promise((resolve) => this.idleResolvers.push(resolve));
}
private scheduleDrain({ includeDrainDelay }: { includeDrainDelay: boolean }): void {
if (this.stopped || this.pending.length === 0 || this.activeCount >= this.concurrency) return;
const drainDelayMs = includeDrainDelay && this.activeCount === 0 ? (this.options.drainDelayMs ?? 0) : 0;
const delayMs = Math.max(drainDelayMs, this.nextDrainNotBeforeMs - Date.now());
if (delayMs > 0) {
if (this.drainTimer) clearTimeout(this.drainTimer);
this.drainTimer = setTimeout(() => {
this.drainTimer = null;
this.drain();
}, delayMs);
return;
}
this.drain();
}
private drain(): void {
while (!this.stopped && this.activeCount < this.concurrency && this.pending.length > 0) {
const { fileId } = this.takeNextPending();
this.queued.delete(fileId);
this.running.add(fileId);
this.activeCount++;
void this.handler(fileId)
.catch((error) => this.onError(fileId, error))
.finally(() => {
this.running.delete(fileId);
this.activeCount--;
this.markInterJobDelay();
this.scheduleDrain({ includeDrainDelay: false });
this.resolveIdleIfNeeded();
});
}
this.resolveIdleIfNeeded();
}
private takeNextPending(): PendingEntry {
if (this.options.pendingOrder === 'lifo') {
return this.pending.pop()!;
}
if (this.options.pendingOrder !== 'priority-desc') {
return this.pending.shift()!;
}
let bestIndex = 0;
for (let index = 1; index < this.pending.length; index++) {
if (comparePriorityDesc(this.pending[index].priority, this.pending[bestIndex].priority) < 0) {
bestIndex = index;
}
}
const [entry] = this.pending.splice(bestIndex, 1);
return entry;
}
private isIdle(): boolean {
return this.pending.length === 0 && this.activeCount === 0;
}
private resolveIdleIfNeeded(): void {
if (!this.isIdle()) return;
const resolvers = this.idleResolvers.splice(0);
for (const resolve of resolvers) resolve();
}
private markInterJobDelay(): void {
const delayMs = this.resolveInterJobDelayMs();
if (delayMs <= 0) return;
this.nextDrainNotBeforeMs = Date.now() + delayMs;
}
private resolveInterJobDelayMs(): number {
const range = this.options.interJobDelayMs;
if (!range) return 0;
const minMs = Math.max(0, Math.floor(range.minMs));
const maxMs = Math.max(minMs, Math.floor(range.maxMs));
if (minMs === maxMs) return minMs;
return minMs + Math.floor(Math.random() * (maxMs - minMs + 1));
}
}
function comparePriorityDesc(left: BookDockWorkPriority, right: BookDockWorkPriority): number {
if (left.primary !== right.primary) return right.primary - left.primary;
return (right.secondary ?? 0) - (left.secondary ?? 0);
}
@@ -95,14 +95,15 @@ describe('BookDockRepository', () => {
{ status: 'ready', cnt: '5' },
{ status: 'error', cnt: '2' },
{ status: 'extracting', cnt: '1' },
{ status: 'fetching', cnt: '4' },
]);
const repo = new BookDockRepository(db as never);
await expect(repo.countsByStatus()).resolves.toEqual({
pending: 3,
pending: 8,
ready: 5,
error: 2,
total: 11,
total: 15,
});
});
@@ -166,6 +167,24 @@ describe('BookDockRepository', () => {
});
});
it('findAll adds id as a deterministic tie-breaker for pagination order', async () => {
const { db, selectBuilder } = makeDb();
selectBuilder.where.mockReturnValueOnce(selectBuilder).mockResolvedValueOnce([{ total: 2 }]);
selectBuilder.offset.mockResolvedValueOnce([{ id: 2 }, { id: 1 }]);
const repo = new BookDockRepository(db as never);
await repo.findAll({
page: 1,
limit: 20,
sort: 'createdAt',
order: 'desc',
userId: 1,
isSuperuser: true,
});
expect(selectBuilder.orderBy).toHaveBeenCalledWith(expect.objectContaining({ op: 'desc' }), expect.objectContaining({ op: 'desc' }));
});
it('findById and findByAbsolutePath return the first selected row', async () => {
const { db, selectBuilder } = makeDb();
selectBuilder.limit.mockResolvedValueOnce([{ id: 9 }]).mockResolvedValueOnce([{ id: 10 }]);
@@ -55,7 +55,7 @@ export class BookDockRepository {
.select()
.from(bookDockFiles)
.where(where)
.orderBy(orderFn(sortCol))
.orderBy(orderFn(sortCol), orderFn(bookDockFiles.id))
.limit(opts.limit)
.offset((opts.page - 1) * opts.limit),
this.db.select({ total: count() }).from(bookDockFiles).where(where),
@@ -142,7 +142,7 @@ export class BookDockRepository {
const result = { pending: 0, ready: 0, error: 0, total: 0 };
for (const row of rows) {
const n = Number(row.cnt);
if (row.status === 'pending') result.pending = n;
if (row.status === 'pending' || row.status === 'extracting' || row.status === 'fetching') result.pending += n;
else if (row.status === 'ready') result.ready = n;
else if (row.status === 'error') result.error = n;
result.total += n;
@@ -16,7 +16,6 @@ import {
Query,
Res,
} from '@nestjs/common';
import { SkipThrottle } from '@nestjs/throttler';
import { ZipArchive } from 'archiver';
import { createReadStream } from 'fs';
import { stat } from 'fs/promises';
@@ -314,7 +313,6 @@ export class BookController {
}
}
@SkipThrottle()
@Get(':id/cover')
async getCover(
@Param('id', ParseIntPipe) id: number,
@@ -342,7 +340,6 @@ export class BookController {
reply.send(createReadStream(coverPath));
}
@SkipThrottle()
@Get(':id/thumbnail')
async getThumbnail(
@Param('id', ParseIntPipe) id: number,
@@ -1,7 +1,6 @@
import { Permission } from '@bookorbit/types';
import { BadRequestException, Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, ParseIntPipe, Post, Query, Req, Res } from '@nestjs/common';
import type { FastifyReply } from 'fastify';
import { SkipThrottle } from '@nestjs/throttler';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
import type { MultipartRequest } from '../../common/types/multipart-request';
@@ -27,7 +26,6 @@ export class CoverController {
}
@Get('cover/proxy')
@SkipThrottle()
@RequirePermission(Permission.LibraryEditMetadata)
async proxyImage(@Query() query: ProxyCoverQueryDto, @Res() res: FastifyReply) {
const { buffer, contentType } = await this.coverService.proxyImage(query.url);
@@ -1,5 +1,4 @@
import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common';
import { SkipThrottle } from '@nestjs/throttler';
import { AuditAction, type EntityType, INLINE_ENTITY_TYPES, Permission } from '@bookorbit/types';
import { Auditable } from '../../common/decorators/auditable.decorator';
@@ -36,7 +35,6 @@ export class EntityManagerController {
}
@Get(':entityType/duplicates/scan')
@SkipThrottle()
@RequirePermission(Permission.LibraryEditMetadata)
scanDuplicates(@Param('entityType', EntityTypePipe) entityType: EntityType, @Query() dto: ScanDuplicatesDto, @CurrentUser() user: RequestUser) {
return this.service.scanDuplicates(entityType, user, dto.libraryId, dto.minSimilarity, dto.page, dto.pageSize);
@@ -35,6 +35,14 @@ function makeUser(overrides: Partial<RequestUser> = {}): RequestUser {
};
}
function deferred(): { promise: Promise<void>; resolve: () => void } {
let resolve!: () => void;
const promise = new Promise<void>((r) => {
resolve = r;
});
return { promise, resolve };
}
describe('MetadataFetchService', () => {
let registry: Mocked<ProviderRegistry>;
let throttleTracker: Mocked<ProviderThrottleTracker>;
@@ -101,6 +109,52 @@ describe('MetadataFetchService', () => {
expect(openLibrary.search).toHaveBeenCalledWith(expect.objectContaining({ title: 'Dune' }));
});
it('starts selected providers concurrently within one search', async () => {
let active = 0;
let maxActive = 0;
function makeBlockedProvider(key: MetadataProviderKey): MetadataProvider & { started: Promise<void>; release: () => void } {
const started = deferred();
const release = deferred();
return {
key,
label: key,
identifiable: false,
started: started.promise,
release: release.resolve,
search: vi.fn().mockImplementation(async (params) => {
active++;
maxActive = Math.max(maxActive, active);
started.resolve();
try {
await release.promise;
return [candidate(key, `${key}-1`, params.title ?? key)];
} finally {
active--;
}
}),
};
}
const google = makeBlockedProvider(MetadataProviderKey.GOOGLE);
const openLibrary = makeBlockedProvider(MetadataProviderKey.OPEN_LIBRARY);
registry.select.mockReturnValue([google, openLibrary]);
const search = firstValueFrom(service.search({ title: 'Dune' }).pipe(toArray()));
await Promise.all([google.started, openLibrary.started]);
expect(maxActive).toBe(2);
google.release();
openLibrary.release();
await expect(search).resolves.toEqual([
candidate(MetadataProviderKey.GOOGLE, 'google-1', 'Dune'),
candidate(MetadataProviderKey.OPEN_LIBRARY, 'openLibrary-1', 'Dune'),
]);
});
it('falls back to non-isbn search when isbn search returns no results', async () => {
const google: MetadataProvider = {
key: MetadataProviderKey.GOOGLE,
@@ -82,10 +82,6 @@ describe('EpubController', () => {
expect(reply.header).toHaveBeenCalledWith('Cache-Control', 'public, max-age=3600');
});
it('skips global throttling for EPUB archive file streams', () => {
expect(Reflect.getMetadata('THROTTLER:SKIPdefault', EpubController.prototype.getFile)).toBe(true);
});
it('rejects malformed encoded file paths', async () => {
await expect(
controller.getFile(9, 'OPS/text/%E0%A4%A', undefined, { id: 1, isSuperuser: false, permissions: [] } as any, {} as any),
@@ -1,5 +1,4 @@
import { BadRequestException, Controller, Get, Param, ParseIntPipe, Query, Res } from '@nestjs/common';
import { SkipThrottle } from '@nestjs/throttler';
import type { FastifyReply } from 'fastify';
import { CurrentUser } from '../../../common/decorators/current-user.decorator';
@@ -15,7 +14,6 @@ export class EpubController {
return this.epubService.getBookInfo(bookId, this.parseFileId(fileId), user);
}
@SkipThrottle()
@Get(':bookId/file/*')
async getFile(
@Param('bookId', ParseIntPipe) bookId: number,