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
|
||||
RUN apk add --no-cache python3 make g++
|
||||
|
||||
# Enable Yarn
|
||||
RUN corepack enable
|
||||
# Enable pnpm via corepack
|
||||
RUN corepack enable && corepack prepare pnpm@9.0.0 --activate
|
||||
|
||||
# Copy package files first to leverage Docker cache
|
||||
COPY package.json yarn.lock ./
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
|
||||
# Copy configuration files
|
||||
COPY tsconfig.json .eslintrc.json ./
|
||||
COPY tailwind.config.ts postcss.config.js ./
|
||||
|
||||
# Install dependencies with Yarn
|
||||
RUN yarn install --frozen-lockfile
|
||||
# Install dependencies with pnpm
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
# Copy source files
|
||||
COPY src ./src
|
||||
COPY public ./public
|
||||
|
||||
# Build the application
|
||||
RUN yarn build
|
||||
RUN pnpm build
|
||||
|
||||
# Production stage
|
||||
FROM node:20-alpine AS runner
|
||||
@@ -37,10 +37,11 @@ FROM node:20-alpine AS runner
|
||||
WORKDIR /app
|
||||
|
||||
# Install production dependencies only
|
||||
COPY package.json yarn.lock ./
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
RUN corepack enable && \
|
||||
yarn install --production --frozen-lockfile && \
|
||||
yarn cache clean
|
||||
corepack prepare pnpm@9.0.0 --activate && \
|
||||
pnpm install --prod --frozen-lockfile && \
|
||||
pnpm store prune
|
||||
|
||||
# Copy built application from builder stage
|
||||
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
|
||||
|
||||
# Start the application
|
||||
CMD ["yarn", "start"]
|
||||
CMD ["pnpm", "start"]
|
||||
@@ -7,6 +7,7 @@ services:
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
- NODE_ENV=production
|
||||
- MONGODB_URI=${MONGODB_URI}
|
||||
container_name: stripstream-app
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
"bcrypt": "^5.1.1",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.1",
|
||||
"framer-motion": "12.4.10",
|
||||
"framer-motion": "^10.18.0",
|
||||
"i18next": "^24.2.2",
|
||||
"i18next-browser-languagedetector": "^8.0.4",
|
||||
"lucide-react": "^0.487.0",
|
||||
@@ -36,10 +36,11 @@
|
||||
"sharp": "0.33.2",
|
||||
"tailwind-merge": "^3.0.2",
|
||||
"tailwindcss-animate": "1.0.7",
|
||||
"undici": "^7.16.0",
|
||||
"zod": "3.22.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "24.7.0",
|
||||
"@types/node": "24.7.2",
|
||||
"@types/react": "18.2.64",
|
||||
"@types/react-dom": "18.2.21",
|
||||
"@typescript-eslint/eslint-plugin": "^8.24.0",
|
||||
@@ -54,5 +55,5 @@
|
||||
"tailwindcss": "3.4.1",
|
||||
"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 [isInViewport, setIsInViewport] = useState(false);
|
||||
const loadAttempts = useRef(0);
|
||||
const maxAttempts = 3;
|
||||
const maxAttempts = 1; // Désactivé pour réduire la charge sur Komga
|
||||
const internalRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
useImperativeHandle(ref, () => internalRef.current as HTMLButtonElement);
|
||||
@@ -94,7 +94,14 @@ export const Thumbnail = forwardRef<HTMLButtonElement, ThumbnailProps>(
|
||||
// Réessayer avec un délai croissant
|
||||
const delay = Math.min(1000 * Math.pow(2, loadAttempts.current - 1), 5000);
|
||||
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);
|
||||
} else {
|
||||
console.error(
|
||||
|
||||
@@ -23,7 +23,7 @@ export const useThumbnails = ({ book, currentPage }: UseThumbnailsProps) => {
|
||||
|
||||
// Mettre à jour les thumbnails visibles autour de la page courante
|
||||
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 end = currentPage + windowSize;
|
||||
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 { ServerCacheService } from "./server-cache.service";
|
||||
import { DebugService } from "./debug.service";
|
||||
import { RequestMonitorService } from "./request-monitor.service";
|
||||
import { RequestQueueService } from "./request-queue.service";
|
||||
// Types de cache disponibles
|
||||
export type CacheType = "DEFAULT" | "HOME" | "LIBRARIES" | "SERIES" | "BOOKS" | "IMAGES";
|
||||
|
||||
@@ -99,8 +101,26 @@ export abstract class BaseApiService {
|
||||
|
||||
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 {
|
||||
const response = await fetch(url, { headers, ...options });
|
||||
// Enqueue la requête pour limiter la concurrence
|
||||
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
|
||||
@@ -131,6 +151,9 @@ export abstract class BaseApiService {
|
||||
});
|
||||
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
RequestMonitorService.decrementActive();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ export class ImageService extends BaseApiService {
|
||||
try {
|
||||
const headers = { Accept: "image/jpeg, image/png, image/gif, image/webp, */*" };
|
||||
|
||||
return this.fetchWithCache<ImageResponse>(
|
||||
const result = await this.fetchWithCache<ImageResponse>(
|
||||
`image-${path}`,
|
||||
async () => {
|
||||
const response = await this.fetchFromApi<Response>({ path }, headers, { isImage: true });
|
||||
@@ -27,6 +27,8 @@ export class ImageService extends BaseApiService {
|
||||
},
|
||||
"IMAGES"
|
||||
);
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error("Erreur lors de la récupération de l'image:", 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