feat: implement request deduplication and concurrency management in image loading for improved performance
This commit is contained in:
@@ -5,6 +5,7 @@ import { ERROR_CODES } from "@/constants/errorCodes";
|
||||
import { getErrorMessage } from "@/utils/errors";
|
||||
import { AppError } from "@/utils/errors";
|
||||
import logger from "@/lib/logger";
|
||||
import { requestDeduplicationService } from "@/lib/services/request-deduplication.service";
|
||||
|
||||
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 headers = new Headers();
|
||||
headers.set("Content-Type", response.headers.get("Content-Type") || "image/jpeg");
|
||||
|
||||
@@ -67,6 +67,8 @@ export function PhotoswipeReader({ book, pages, onClose, nextBook }: BookReaderP
|
||||
|
||||
|
||||
// Prefetch current and next pages
|
||||
// Deduplication in useImageLoader prevents redundant requests
|
||||
// Server queue (RequestQueueService) handles concurrency limits
|
||||
useEffect(() => {
|
||||
// Prefetch pages starting from current page
|
||||
prefetchPages(currentPage, prefetchCount);
|
||||
|
||||
@@ -20,6 +20,8 @@ export function useImageLoader({ bookId, pages: _pages, prefetchCount = 5, nextB
|
||||
const [imageBlobUrls, setImageBlobUrls] = useState<Record<ImageKey, string>>({});
|
||||
const loadedImagesRef = useRef(loadedImages);
|
||||
const imageBlobUrlsRef = useRef(imageBlobUrls);
|
||||
// Track ongoing fetch requests to prevent duplicates
|
||||
const pendingFetchesRef = useRef<Set<ImageKey>>(new Set());
|
||||
|
||||
// Keep refs in sync with state
|
||||
useEffect(() => {
|
||||
@@ -42,8 +44,19 @@ export function useImageLoader({ bookId, pages: _pages, prefetchCount = 5, nextB
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if this page is already being fetched
|
||||
if (pendingFetchesRef.current.has(pageNum)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Mark as pending
|
||||
pendingFetchesRef.current.add(pageNum);
|
||||
|
||||
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) {
|
||||
return;
|
||||
}
|
||||
@@ -68,10 +81,15 @@ export function useImageLoader({ bookId, pages: _pages, prefetchCount = 5, nextB
|
||||
img.src = blobUrl;
|
||||
} catch {
|
||||
// Silently fail prefetch
|
||||
} finally {
|
||||
// Remove from pending set
|
||||
pendingFetchesRef.current.delete(pageNum);
|
||||
}
|
||||
}, [getPageUrl]);
|
||||
|
||||
// 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 pagesToPrefetch = [];
|
||||
|
||||
@@ -80,17 +98,22 @@ export function useImageLoader({ bookId, pages: _pages, prefetchCount = 5, nextB
|
||||
if (pageNum <= _pages.length) {
|
||||
const hasDimensions = loadedImagesRef.current[pageNum];
|
||||
const hasBlobUrl = imageBlobUrlsRef.current[pageNum];
|
||||
const isPending = pendingFetchesRef.current.has(pageNum);
|
||||
|
||||
// Prefetch if we don't have both dimensions AND blob URL
|
||||
if (!hasDimensions || !hasBlobUrl) {
|
||||
// Prefetch if we don't have both dimensions AND blob URL AND it's not already pending
|
||||
if ((!hasDimensions || !hasBlobUrl) && !isPending) {
|
||||
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) {
|
||||
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]);
|
||||
|
||||
@@ -108,17 +131,23 @@ export function useImageLoader({ bookId, pages: _pages, prefetchCount = 5, nextB
|
||||
const nextBookPageKey = `next-${pageNum}`;
|
||||
const hasDimensions = loadedImagesRef.current[nextBookPageKey];
|
||||
const hasBlobUrl = imageBlobUrlsRef.current[nextBookPageKey];
|
||||
const isPending = pendingFetchesRef.current.has(nextBookPageKey);
|
||||
|
||||
if (!hasDimensions || !hasBlobUrl) {
|
||||
if ((!hasDimensions || !hasBlobUrl) && !isPending) {
|
||||
pagesToPrefetch.push({ pageNum, nextBookPageKey });
|
||||
}
|
||||
}
|
||||
|
||||
// Prefetch all pages in parallel
|
||||
// Let all prefetch requests run - server queue handles concurrency
|
||||
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 {
|
||||
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) {
|
||||
return;
|
||||
}
|
||||
@@ -143,8 +172,12 @@ export function useImageLoader({ bookId, pages: _pages, prefetchCount = 5, nextB
|
||||
img.src = blobUrl;
|
||||
} catch {
|
||||
// Silently fail prefetch
|
||||
} finally {
|
||||
pendingFetchesRef.current.delete(nextBookPageKey);
|
||||
}
|
||||
}));
|
||||
})).catch(() => {
|
||||
// Silently fail - prefetch is non-critical
|
||||
});
|
||||
}
|
||||
}, [nextBook, prefetchCount]);
|
||||
|
||||
|
||||
70
src/lib/services/request-deduplication.service.ts
Normal file
70
src/lib/services/request-deduplication.service.ts
Normal 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();
|
||||
|
||||
Reference in New Issue
Block a user