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 { 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");
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|
||||||
|
|||||||
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