feat: implement request monitoring and queuing services to manage concurrent requests to Komga
This commit is contained in:
21
Dockerfile
21
Dockerfile
@@ -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"]
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
6477
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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(
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
44
src/lib/services/request-monitor.service.ts
Normal file
44
src/lib/services/request-monitor.service.ts
Normal 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();
|
||||||
|
|
||||||
|
|
||||||
73
src/lib/services/request-queue.service.ts
Normal file
73
src/lib/services/request-queue.service.ts
Normal 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);
|
||||||
|
|
||||||
Reference in New Issue
Block a user