From 06b2bca340c774f66382fb2f8751599b670b629c Mon Sep 17 00:00:00 2001 From: schroda <50052685+schroda@users.noreply.github.com> Date: Sun, 24 Mar 2024 19:08:36 +0100 Subject: [PATCH] Add prioritization to image requests (#671) In the reader the preloaded page requests were triggered before the actual page got rendered and thus, the image request of the rendered page had to wait for all the preloaded images to finish --- src/components/util/SpinnerImage.tsx | 3 +- src/lib/ControlledPromise.ts | 30 +++++++++++ src/lib/Queue.ts | 75 ++++++++++++++++++++++++++++ src/lib/requests/RequestManager.ts | 33 ++++++------ 4 files changed, 125 insertions(+), 16 deletions(-) create mode 100644 src/lib/ControlledPromise.ts create mode 100644 src/lib/Queue.ts diff --git a/src/components/util/SpinnerImage.tsx b/src/components/util/SpinnerImage.tsx index 9727c6e7..0cda0f3c 100644 --- a/src/components/util/SpinnerImage.tsx +++ b/src/components/util/SpinnerImage.tsx @@ -16,6 +16,7 @@ import RefreshIcon from '@mui/icons-material/Refresh'; import { useTranslation } from 'react-i18next'; import { requestManager } from '@/lib/requests/RequestManager.ts'; import { defaultPromiseErrorHandler } from '@/util/defaultPromiseErrorHandler.ts'; +import { Priority } from '@/lib/Queue.ts'; interface IProps { src: string; @@ -48,7 +49,7 @@ export const SpinnerImage = forwardRef((props: IProps, imgRef: ForwardedRef { let tmpImageSourceUrl: string; - const imageRequest = requestManager.requestImage(src); + const imageRequest = requestManager.requestImage(src, Priority.HIGH); let cacheTimeout: NodeJS.Timeout; const fetchImage = async () => { diff --git a/src/lib/ControlledPromise.ts b/src/lib/ControlledPromise.ts new file mode 100644 index 00000000..60c999d7 --- /dev/null +++ b/src/lib/ControlledPromise.ts @@ -0,0 +1,30 @@ +/* + * Copyright (C) Contributors to the Suwayomi project + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +export class ControlledPromise { + private orgResolve!: (value: T | PromiseLike) => void; + + private orgReject!: (reason?: any) => void; + + public readonly promise: Promise; + + constructor() { + this.promise = new Promise((resolve, reject) => { + this.orgResolve = resolve; + this.orgReject = reject; + }); + } + + resolve(value: T): void { + this.orgResolve(value); + } + + reject(reason?: any): void { + this.orgReject(reason); + } +} diff --git a/src/lib/Queue.ts b/src/lib/Queue.ts new file mode 100644 index 00000000..cc40dd06 --- /dev/null +++ b/src/lib/Queue.ts @@ -0,0 +1,75 @@ +/* + * Copyright (C) Contributors to the Suwayomi project + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +import pLimit, { LimitFunction } from 'p-limit'; +import { ControlledPromise } from '@/lib/ControlledPromise.ts'; + +export enum Priority { + LOW = 0, + NORMAL = 1, + HIGH = 2, +} +export type QueuePriority = Priority | number; + +type Key = string; +type QueueItemFunction = () => PromiseLike | T; + +export class Queue { + private readonly queue: LimitFunction; + + private counter: number = 0; + + private pendingKeyToPriorityMap = new Map(); + + private pendingKeyToFnMap = new Map(); + + private pendingKeyToPromiseMap = new Map>(); + + constructor(concurrency: number) { + this.queue = pLimit(concurrency); + } + + enqueue(key: Key, fn: () => PromiseLike | T, priority: QueuePriority = Priority.NORMAL): Promise { + this.counter = (this.counter + 1) % Infinity; + const actualKey = `${key}_${this.counter}`; + + this.pendingKeyToPriorityMap.set(actualKey, priority); + this.pendingKeyToFnMap.set(actualKey, fn); + + const processPromise = new ControlledPromise(); + this.pendingKeyToPromiseMap.set(actualKey, processPromise); + + this.queue(() => this.process()); + + return processPromise.promise; + } + + private async process(): Promise { + const { fn, promise } = this.getNextItemToProcess(); + try { + const result = await fn(); + promise.resolve(result); + } catch (e) { + promise.reject(e); + } + } + + private getNextItemToProcess(): { key: Key; fn: QueueItemFunction; promise: ControlledPromise } { + const [key] = [...this.pendingKeyToPriorityMap.entries()].toSorted( + ([, priorityA], [, priorityB]) => priorityB - priorityA, + )[0]; + const fn = this.pendingKeyToFnMap.get(key) as () => PromiseLike | T; + const promise = this.pendingKeyToPromiseMap.get(key) as ControlledPromise; + + this.pendingKeyToPriorityMap.delete(key); + this.pendingKeyToFnMap.delete(key); + this.pendingKeyToPromiseMap.delete(key); + + return { key, fn, promise }; + } +} diff --git a/src/lib/requests/RequestManager.ts b/src/lib/requests/RequestManager.ts index cfc047cc..3507bcbc 100644 --- a/src/lib/requests/RequestManager.ts +++ b/src/lib/requests/RequestManager.ts @@ -27,7 +27,6 @@ import { } from '@apollo/client'; import { OperationVariables } from '@apollo/client/core'; import { useEffect, useMemo, useRef, useState } from 'react'; -import pLimit from 'p-limit'; import { IRestClient, RestClient } from '@/lib/requests/client/RestClient.ts'; import { GraphQLClient } from '@/lib/requests/client/GraphQLClient.ts'; import { @@ -257,6 +256,7 @@ import { RESET_WEBUI_UPDATE_STATUS, UPDATE_WEBUI } from '@/lib/graphql/mutations import { WEBUI_UPDATE_SUBSCRIPTION } from '@/lib/graphql/subscriptions/ServerInfoSubscription.ts'; import { GET_DOWNLOAD_STATUS } from '@/lib/graphql/queries/DownloaderQuery.ts'; import { defaultPromiseErrorHandler } from '@/util/defaultPromiseErrorHandler.ts'; +import { Queue, QueuePriority } from '@/lib/Queue.ts'; enum GQLMethod { QUERY = 'QUERY', @@ -366,7 +366,7 @@ export class RequestManager { private readonly cache = new CustomCache(); - private readonly imageQueue = pLimit(5); + private readonly imageQueue = new Queue(5); public getClient(): IRestClient { return this.restClient; @@ -768,20 +768,23 @@ export class RequestManager { * img.src = imageUrl; * */ - public requestImage(url: string): { response: Promise } & AbortableRequest { + public requestImage(url: string, priority?: QueuePriority): { response: Promise } & AbortableRequest { const { abortRequest, signal } = this.createAbortController(); - const response = this.imageQueue(() => - this.restClient - .fetcher(url, { - checkResponseIsJson: false, - config: { - signal, - // @ts-ignore - typing has not been updated yet - priority: 'low', - }, - }) - .then((data) => data.blob()) - .then((data) => URL.createObjectURL(data)), + const response = this.imageQueue.enqueue( + url, + () => + this.restClient + .fetcher(url, { + checkResponseIsJson: false, + config: { + signal, + // @ts-ignore - typing has not been updated yet + priority: 'low', + }, + }) + .then((data) => data.blob()) + .then((data) => URL.createObjectURL(data)), + priority, ); return { response, abortRequest };