feat: implement request deduplication and concurrency management in image loading for improved performance

This commit is contained in:
Julien Froidefond
2025-10-31 13:07:37 +01:00
parent e0b90a7893
commit 349448ef69
4 changed files with 123 additions and 11 deletions

View File

@@ -5,6 +5,7 @@ import { ERROR_CODES } from "@/constants/errorCodes";
import { getErrorMessage } from "@/utils/errors"; import { getErrorMessage } from "@/utils/errors";
import { AppError } from "@/utils/errors"; import { AppError } from "@/utils/errors";
import logger from "@/lib/logger"; import logger from "@/lib/logger";
import { requestDeduplicationService } from "@/lib/services/request-deduplication.service";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -29,7 +30,13 @@ export async function GET(
); );
} }
const response = await BookService.getPage(bookIdParam, pageNumber); // Utiliser la déduplication pour éviter les requêtes dupliquées vers Komga
// Si plusieurs clients demandent la même page simultanément, une seule requête est faite
const deduplicationKey = `book-page:${bookIdParam}:${pageNumber}`;
const response = await requestDeduplicationService.deduplicate(
deduplicationKey,
() => BookService.getPage(bookIdParam, pageNumber)
);
const buffer = await response.arrayBuffer(); const buffer = await response.arrayBuffer();
const headers = new Headers(); const headers = new Headers();
headers.set("Content-Type", response.headers.get("Content-Type") || "image/jpeg"); headers.set("Content-Type", response.headers.get("Content-Type") || "image/jpeg");

View File

@@ -67,6 +67,8 @@ export function PhotoswipeReader({ book, pages, onClose, nextBook }: BookReaderP
// Prefetch current and next pages // Prefetch current and next pages
// Deduplication in useImageLoader prevents redundant requests
// Server queue (RequestQueueService) handles concurrency limits
useEffect(() => { useEffect(() => {
// Prefetch pages starting from current page // Prefetch pages starting from current page
prefetchPages(currentPage, prefetchCount); prefetchPages(currentPage, prefetchCount);

View File

@@ -20,6 +20,8 @@ export function useImageLoader({ bookId, pages: _pages, prefetchCount = 5, nextB
const [imageBlobUrls, setImageBlobUrls] = useState<Record<ImageKey, string>>({}); const [imageBlobUrls, setImageBlobUrls] = useState<Record<ImageKey, string>>({});
const loadedImagesRef = useRef(loadedImages); const loadedImagesRef = useRef(loadedImages);
const imageBlobUrlsRef = useRef(imageBlobUrls); const imageBlobUrlsRef = useRef(imageBlobUrls);
// Track ongoing fetch requests to prevent duplicates
const pendingFetchesRef = useRef<Set<ImageKey>>(new Set());
// Keep refs in sync with state // Keep refs in sync with state
useEffect(() => { useEffect(() => {
@@ -42,8 +44,19 @@ export function useImageLoader({ bookId, pages: _pages, prefetchCount = 5, nextB
return; return;
} }
// Check if this page is already being fetched
if (pendingFetchesRef.current.has(pageNum)) {
return;
}
// Mark as pending
pendingFetchesRef.current.add(pageNum);
try { try {
const response = await fetch(getPageUrl(pageNum)); // Use browser cache if available - the server sets Cache-Control headers
const response = await fetch(getPageUrl(pageNum), {
cache: 'default', // Respect Cache-Control headers from server
});
if (!response.ok) { if (!response.ok) {
return; return;
} }
@@ -68,10 +81,15 @@ export function useImageLoader({ bookId, pages: _pages, prefetchCount = 5, nextB
img.src = blobUrl; img.src = blobUrl;
} catch { } catch {
// Silently fail prefetch // Silently fail prefetch
} finally {
// Remove from pending set
pendingFetchesRef.current.delete(pageNum);
} }
}, [getPageUrl]); }, [getPageUrl]);
// Prefetch multiple pages starting from a given page // Prefetch multiple pages starting from a given page
// The server-side queue (RequestQueueService) handles concurrency limits
// We only deduplicate to avoid redundant HTTP requests
const prefetchPages = useCallback(async (startPage: number, count: number = prefetchCount) => { const prefetchPages = useCallback(async (startPage: number, count: number = prefetchCount) => {
const pagesToPrefetch = []; const pagesToPrefetch = [];
@@ -80,17 +98,22 @@ export function useImageLoader({ bookId, pages: _pages, prefetchCount = 5, nextB
if (pageNum <= _pages.length) { if (pageNum <= _pages.length) {
const hasDimensions = loadedImagesRef.current[pageNum]; const hasDimensions = loadedImagesRef.current[pageNum];
const hasBlobUrl = imageBlobUrlsRef.current[pageNum]; const hasBlobUrl = imageBlobUrlsRef.current[pageNum];
const isPending = pendingFetchesRef.current.has(pageNum);
// Prefetch if we don't have both dimensions AND blob URL // Prefetch if we don't have both dimensions AND blob URL AND it's not already pending
if (!hasDimensions || !hasBlobUrl) { if ((!hasDimensions || !hasBlobUrl) && !isPending) {
pagesToPrefetch.push(pageNum); pagesToPrefetch.push(pageNum);
} }
} }
} }
// Prefetch all pages in parallel // Let all prefetch requests run - the server queue will manage concurrency
// The browser cache and our deduplication prevent redundant requests
if (pagesToPrefetch.length > 0) { if (pagesToPrefetch.length > 0) {
await Promise.all(pagesToPrefetch.map(pageNum => prefetchImage(pageNum))); // Fire all requests in parallel - server queue handles throttling
Promise.all(pagesToPrefetch.map(pageNum => prefetchImage(pageNum))).catch(() => {
// Silently fail - prefetch is non-critical
});
} }
}, [prefetchImage, prefetchCount, _pages.length]); }, [prefetchImage, prefetchCount, _pages.length]);
@@ -108,17 +131,23 @@ export function useImageLoader({ bookId, pages: _pages, prefetchCount = 5, nextB
const nextBookPageKey = `next-${pageNum}`; const nextBookPageKey = `next-${pageNum}`;
const hasDimensions = loadedImagesRef.current[nextBookPageKey]; const hasDimensions = loadedImagesRef.current[nextBookPageKey];
const hasBlobUrl = imageBlobUrlsRef.current[nextBookPageKey]; const hasBlobUrl = imageBlobUrlsRef.current[nextBookPageKey];
const isPending = pendingFetchesRef.current.has(nextBookPageKey);
if (!hasDimensions || !hasBlobUrl) { if ((!hasDimensions || !hasBlobUrl) && !isPending) {
pagesToPrefetch.push({ pageNum, nextBookPageKey }); pagesToPrefetch.push({ pageNum, nextBookPageKey });
} }
} }
// Prefetch all pages in parallel // Let all prefetch requests run - server queue handles concurrency
if (pagesToPrefetch.length > 0) { if (pagesToPrefetch.length > 0) {
await Promise.all(pagesToPrefetch.map(async ({ pageNum, nextBookPageKey }) => { Promise.all(pagesToPrefetch.map(async ({ pageNum, nextBookPageKey }) => {
// Mark as pending
pendingFetchesRef.current.add(nextBookPageKey);
try { try {
const response = await fetch(`/api/komga/books/${nextBook.id}/pages/${pageNum}`); const response = await fetch(`/api/komga/books/${nextBook.id}/pages/${pageNum}`, {
cache: 'default', // Respect Cache-Control headers from server
});
if (!response.ok) { if (!response.ok) {
return; return;
} }
@@ -143,8 +172,12 @@ export function useImageLoader({ bookId, pages: _pages, prefetchCount = 5, nextB
img.src = blobUrl; img.src = blobUrl;
} catch { } catch {
// Silently fail prefetch // Silently fail prefetch
} finally {
pendingFetchesRef.current.delete(nextBookPageKey);
} }
})); })).catch(() => {
// Silently fail - prefetch is non-critical
});
} }
}, [nextBook, prefetchCount]); }, [nextBook, prefetchCount]);

View File

@@ -0,0 +1,70 @@
/**
* Service de déduplication des requêtes côté serveur
* Évite de lancer plusieurs requêtes identiques vers Komga simultanément
*/
type PendingRequest<T> = Promise<T>;
class RequestDeduplicationService {
// Map pour tracker les requêtes en cours par clé unique
private pendingRequests = new Map<string, PendingRequest<any>>();
/**
* Exécute une requête de manière dédupliquée
* Si une requête identique est déjà en cours, on attend son résultat
* au lieu d'en lancer une nouvelle
*/
async deduplicate<T>(
key: string,
fetcher: () => Promise<T>,
ttl: number = 60000 // 60 secondes max pour éviter les fuites mémoire
): Promise<T> {
// Vérifier si une requête identique est déjà en cours
const existing = this.pendingRequests.get(key);
if (existing) {
return existing as Promise<T>;
}
// Créer une nouvelle promesse pour cette requête
const promise = fetcher()
.then((result) => {
// Nettoyer après le succès
this.pendingRequests.delete(key);
return result;
})
.catch((error) => {
// Nettoyer après l'erreur
this.pendingRequests.delete(key);
throw error;
});
// Stocker la promesse en cours
this.pendingRequests.set(key, promise);
// Timeout de sécurité pour éviter les fuites mémoire
setTimeout(() => {
if (this.pendingRequests.has(key)) {
this.pendingRequests.delete(key);
}
}, ttl);
return promise;
}
/**
* Nettoie toutes les requêtes en cours (pour les tests)
*/
clear(): void {
this.pendingRequests.clear();
}
/**
* Retourne le nombre de requêtes en cours
*/
getPendingCount(): number {
return this.pendingRequests.size;
}
}
// Singleton instance
export const requestDeduplicationService = new RequestDeduplicationService();