feat: implement request monitoring and queuing services to manage concurrent requests to Komga

This commit is contained in:
Julien Froidefond
2025-10-14 20:20:02 +02:00
parent 5afb495cd4
commit b954a271d6
11 changed files with 6648 additions and 4688 deletions

View File

@@ -11,25 +11,25 @@ WORKDIR /app
# Install dependencies for node-gyp # Install dependencies for node-gyp
RUN apk add --no-cache python3 make g++ RUN apk add --no-cache python3 make g++
# Enable Yarn # Enable pnpm via corepack
RUN corepack enable RUN corepack enable && corepack prepare pnpm@9.0.0 --activate
# Copy package files first to leverage Docker cache # Copy package files first to leverage Docker cache
COPY package.json yarn.lock ./ COPY package.json pnpm-lock.yaml ./
# Copy configuration files # Copy configuration files
COPY tsconfig.json .eslintrc.json ./ COPY tsconfig.json .eslintrc.json ./
COPY tailwind.config.ts postcss.config.js ./ COPY tailwind.config.ts postcss.config.js ./
# Install dependencies with Yarn # Install dependencies with pnpm
RUN yarn install --frozen-lockfile RUN pnpm install --frozen-lockfile
# Copy source files # Copy source files
COPY src ./src COPY src ./src
COPY public ./public COPY public ./public
# Build the application # Build the application
RUN yarn build RUN pnpm build
# Production stage # Production stage
FROM node:20-alpine AS runner FROM node:20-alpine AS runner
@@ -37,10 +37,11 @@ FROM node:20-alpine AS runner
WORKDIR /app WORKDIR /app
# Install production dependencies only # Install production dependencies only
COPY package.json yarn.lock ./ COPY package.json pnpm-lock.yaml ./
RUN corepack enable && \ RUN corepack enable && \
yarn install --production --frozen-lockfile && \ corepack prepare pnpm@9.0.0 --activate && \
yarn cache clean pnpm install --prod --frozen-lockfile && \
pnpm store prune
# Copy built application from builder stage # Copy built application from builder stage
COPY --from=builder /app/.next ./.next COPY --from=builder /app/.next ./.next
@@ -67,4 +68,4 @@ HEALTHCHECK --interval=30s --timeout=3s \
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/api/health || exit 1 CMD wget --no-verbose --tries=1 --spider http://localhost:3000/api/health || exit 1
# Start the application # Start the application
CMD ["yarn", "start"] CMD ["pnpm", "start"]

View File

@@ -7,6 +7,7 @@ services:
dockerfile: Dockerfile dockerfile: Dockerfile
args: args:
- NODE_ENV=production - NODE_ENV=production
- MONGODB_URI=${MONGODB_URI}
container_name: stripstream-app container_name: stripstream-app
restart: unless-stopped restart: unless-stopped
ports: ports:

View File

@@ -22,7 +22,7 @@
"bcrypt": "^5.1.1", "bcrypt": "^5.1.1",
"class-variance-authority": "^0.7.0", "class-variance-authority": "^0.7.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"framer-motion": "12.4.10", "framer-motion": "^10.18.0",
"i18next": "^24.2.2", "i18next": "^24.2.2",
"i18next-browser-languagedetector": "^8.0.4", "i18next-browser-languagedetector": "^8.0.4",
"lucide-react": "^0.487.0", "lucide-react": "^0.487.0",
@@ -36,10 +36,11 @@
"sharp": "0.33.2", "sharp": "0.33.2",
"tailwind-merge": "^3.0.2", "tailwind-merge": "^3.0.2",
"tailwindcss-animate": "1.0.7", "tailwindcss-animate": "1.0.7",
"undici": "^7.16.0",
"zod": "3.22.4" "zod": "3.22.4"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "24.7.0", "@types/node": "24.7.2",
"@types/react": "18.2.64", "@types/react": "18.2.64",
"@types/react-dom": "18.2.21", "@types/react-dom": "18.2.21",
"@typescript-eslint/eslint-plugin": "^8.24.0", "@typescript-eslint/eslint-plugin": "^8.24.0",
@@ -54,5 +55,5 @@
"tailwindcss": "3.4.1", "tailwindcss": "3.4.1",
"typescript": "5.3.3" "typescript": "5.3.3"
}, },
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" "packageManager": "pnpm@9.0.0"
} }

6477
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -22,7 +22,7 @@ export const Thumbnail = forwardRef<HTMLButtonElement, ThumbnailProps>(
const [hasError, setHasError] = useState(false); const [hasError, setHasError] = useState(false);
const [isInViewport, setIsInViewport] = useState(false); const [isInViewport, setIsInViewport] = useState(false);
const loadAttempts = useRef(0); const loadAttempts = useRef(0);
const maxAttempts = 3; const maxAttempts = 1; // Désactivé pour réduire la charge sur Komga
const internalRef = useRef<HTMLButtonElement>(null); const internalRef = useRef<HTMLButtonElement>(null);
useImperativeHandle(ref, () => internalRef.current as HTMLButtonElement); useImperativeHandle(ref, () => internalRef.current as HTMLButtonElement);
@@ -94,7 +94,14 @@ export const Thumbnail = forwardRef<HTMLButtonElement, ThumbnailProps>(
// Réessayer avec un délai croissant // Réessayer avec un délai croissant
const delay = Math.min(1000 * Math.pow(2, loadAttempts.current - 1), 5000); const delay = Math.min(1000 * Math.pow(2, loadAttempts.current - 1), 5000);
setTimeout(() => { setTimeout(() => {
setImageUrl((prev) => (prev ? `${prev}?retry=${loadAttempts.current}` : null)); setImageUrl((prev) => {
if (!prev) return null;
// Utiliser & si l'URL contient déjà des query params
const separator = prev.includes('?') ? '&' : '?';
// Supprimer l'ancien retry param si présent
const baseUrl = prev.replace(/[?&]retry=\d+/g, '');
return `${baseUrl}${separator}retry=${loadAttempts.current}`;
});
}, delay); }, delay);
} else { } else {
console.error( console.error(

View File

@@ -23,7 +23,7 @@ export const useThumbnails = ({ book, currentPage }: UseThumbnailsProps) => {
// Mettre à jour les thumbnails visibles autour de la page courante // Mettre à jour les thumbnails visibles autour de la page courante
useEffect(() => { useEffect(() => {
const windowSize = 10; // Nombre de pages à charger de chaque côté const windowSize = 0; // DÉSACTIVÉ TEMPORAIREMENT: Thumbnails désactivés pour éviter de surcharger Komga
const start = Math.max(1, currentPage - windowSize); const start = Math.max(1, currentPage - windowSize);
const end = currentPage + windowSize; const end = currentPage + windowSize;
const newVisibleThumbnails = Array.from({ length: end - start + 1 }, (_, i) => start + i); const newVisibleThumbnails = Array.from({ length: end - start + 1 }, (_, i) => start + i);

View File

@@ -6,6 +6,8 @@ import { AppError } from "../../utils/errors";
import type { KomgaConfig } from "@/types/komga"; import type { KomgaConfig } from "@/types/komga";
import type { ServerCacheService } from "./server-cache.service"; import type { ServerCacheService } from "./server-cache.service";
import { DebugService } from "./debug.service"; import { DebugService } from "./debug.service";
import { RequestMonitorService } from "./request-monitor.service";
import { RequestQueueService } from "./request-queue.service";
// Types de cache disponibles // Types de cache disponibles
export type CacheType = "DEFAULT" | "HOME" | "LIBRARIES" | "SERIES" | "BOOKS" | "IMAGES"; export type CacheType = "DEFAULT" | "HOME" | "LIBRARIES" | "SERIES" | "BOOKS" | "IMAGES";
@@ -99,9 +101,27 @@ export abstract class BaseApiService {
const startTime = performance.now(); const startTime = performance.now();
// Timeout de 60 secondes au lieu de 10 par défaut
const timeoutMs = 60000;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
try { try {
const response = await fetch(url, { headers, ...options }); // Enqueue la requête pour limiter la concurrence
const endTime = performance.now(); const response = await RequestQueueService.enqueue(async () => {
return await fetch(url, {
headers,
...options,
signal: controller.signal,
// Configure undici connection timeouts
// @ts-ignore - undici-specific options not in standard fetch types
connectTimeout: timeoutMs,
bodyTimeout: timeoutMs,
headersTimeout: timeoutMs,
});
});
clearTimeout(timeoutId);
const endTime = performance.now();
// Logger la requête côté serveur // Logger la requête côté serveur
await DebugService.logRequest({ await DebugService.logRequest({
@@ -131,6 +151,9 @@ export abstract class BaseApiService {
}); });
throw error; throw error;
} finally {
clearTimeout(timeoutId);
RequestMonitorService.decrementActive();
} }
} }
} }

View File

@@ -12,7 +12,7 @@ export class ImageService extends BaseApiService {
try { try {
const headers = { Accept: "image/jpeg, image/png, image/gif, image/webp, */*" }; const headers = { Accept: "image/jpeg, image/png, image/gif, image/webp, */*" };
return this.fetchWithCache<ImageResponse>( const result = await this.fetchWithCache<ImageResponse>(
`image-${path}`, `image-${path}`,
async () => { async () => {
const response = await this.fetchFromApi<Response>({ path }, headers, { isImage: true }); const response = await this.fetchFromApi<Response>({ path }, headers, { isImage: true });
@@ -27,6 +27,8 @@ export class ImageService extends BaseApiService {
}, },
"IMAGES" "IMAGES"
); );
return result;
} catch (error) { } catch (error) {
console.error("Erreur lors de la récupération de l'image:", error); console.error("Erreur lors de la récupération de l'image:", error);
throw new AppError(ERROR_CODES.IMAGE.FETCH_ERROR, {}, error); throw new AppError(ERROR_CODES.IMAGE.FETCH_ERROR, {}, error);

View File

@@ -0,0 +1,44 @@
/**
* Service de monitoring des requêtes concurrentes vers Komga
* Permet de tracker le nombre de requêtes actives et d'alerter en cas de charge élevée
*/
class RequestMonitor {
private activeRequests = 0;
private readonly thresholds = {
warning: 10,
high: 20,
critical: 30,
};
incrementActive(): number {
this.activeRequests++;
this.checkThresholds();
return this.activeRequests;
}
decrementActive(): number {
this.activeRequests = Math.max(0, this.activeRequests - 1);
return this.activeRequests;
}
getActiveCount(): number {
return this.activeRequests;
}
private checkThresholds(): void {
const count = this.activeRequests;
if (count >= this.thresholds.critical) {
console.warn(`[REQUEST-MONITOR] 🔴 CRITICAL concurrency: ${count} active requests`);
} else if (count >= this.thresholds.high) {
console.warn(`[REQUEST-MONITOR] ⚠️ HIGH concurrency: ${count} active requests`);
} else if (count >= this.thresholds.warning) {
console.log(`[REQUEST-MONITOR] ⚡ Warning concurrency: ${count} active requests`);
}
}
}
// Singleton instance
export const RequestMonitorService = new RequestMonitor();

View File

@@ -0,0 +1,73 @@
/**
* Service de gestion de queue pour limiter les requêtes concurrentes vers Komga
* Évite de surcharger Komga avec trop de requêtes simultanées
*/
interface QueuedRequest<T> {
execute: () => Promise<T>;
resolve: (value: T) => void;
reject: (error: any) => void;
}
class RequestQueue {
private queue: QueuedRequest<any>[] = [];
private activeCount = 0;
private maxConcurrent: number;
constructor(maxConcurrent: number = 5) {
this.maxConcurrent = maxConcurrent;
}
async enqueue<T>(execute: () => Promise<T>): Promise<T> {
return new Promise<T>((resolve, reject) => {
this.queue.push({ execute, resolve, reject });
this.processQueue();
});
}
private async delay(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
private async processQueue(): Promise<void> {
if (this.activeCount >= this.maxConcurrent || this.queue.length === 0) {
return;
}
this.activeCount++;
const request = this.queue.shift();
if (!request) {
this.activeCount--;
return;
}
try {
// Délai de 200ms entre chaque requête pour espacer la charge CPU sur Komga
await this.delay(200);
const result = await request.execute();
request.resolve(result);
} catch (error) {
request.reject(error);
} finally {
this.activeCount--;
this.processQueue();
}
}
getActiveCount(): number {
return this.activeCount;
}
getQueueLength(): number {
return this.queue.length;
}
setMaxConcurrent(max: number): void {
this.maxConcurrent = max;
}
}
// Singleton instance - Limite à 2 requêtes simultanées vers Komga (réduit pour CPU)
export const RequestQueueService = new RequestQueue(2);

4669
yarn.lock

File diff suppressed because it is too large Load Diff