diff --git a/packages/core/src/enums/RequestType.ts b/packages/core/src/enums/RequestType.ts index 98d40e1675..a53bc27850 100644 --- a/packages/core/src/enums/RequestType.ts +++ b/packages/core/src/enums/RequestType.ts @@ -2,9 +2,11 @@ * Request types for requesting images from the imageLoadPoolManager */ enum RequestType { - /** Highest priority for loading*/ + /** Highest priority for loading as this is needed before rendering images */ + Metadata = 'metadata', + /** Highest priority for loading once metadata has been fetched */ Interaction = 'interaction', - /** Second highest priority for loading*/ + /** Second highest priority for loading of images */ Thumbnail = 'thumbnail', /** Third highest priority for loading, usually used for image loading in the background*/ Prefetch = 'prefetch', diff --git a/packages/core/src/requestPool/requestPoolManager.ts b/packages/core/src/requestPool/requestPoolManager.ts index 81aad7dffe..7f4268a8cc 100644 --- a/packages/core/src/requestPool/requestPoolManager.ts +++ b/packages/core/src/requestPool/requestPoolManager.ts @@ -66,6 +66,12 @@ type RequestPool = { * You don't need to directly use the imageRetrievalPoolManager to load images * since the imageLoadPoolManager will automatically use it for retrieval. However, * maximum number of concurrent requests can be set by calling `setMaxConcurrentRequests`. + * + * ### Requests between types + * There are different types of requests, and each type starves the next request + * type, under the assumption that they are needed in order for fetching. + * The total number of requests is set by the setMaxConcurrentRequests, defaulting to 50 + * concurrent requests. */ class RequestPoolManager { private id: string; @@ -77,8 +83,11 @@ class RequestPoolManager { [RequestType.Prefetch]: 0, [RequestType.Compute]: 0, } as Record; + private maxConcurrentRequests = 50; + /* maximum number of requests of each type. */ public maxNumRequests: { + [RequestType.Metadata]: number; [RequestType.Interaction]: number; [RequestType.Thumbnail]: number; [RequestType.Prefetch]: number; @@ -97,6 +106,7 @@ class RequestPoolManager { this.id = id ? id : uuidv4(); this.requestPool = { + [RequestType.Metadata]: { 0: [] }, [RequestType.Interaction]: { 0: [] }, [RequestType.Thumbnail]: { 0: [] }, [RequestType.Prefetch]: { 0: [] }, @@ -107,6 +117,7 @@ class RequestPoolManager { this.awake = false; this.numRequests = { + [RequestType.Metadata]: 0, [RequestType.Interaction]: 0, [RequestType.Thumbnail]: 0, [RequestType.Prefetch]: 0, @@ -114,6 +125,7 @@ class RequestPoolManager { } as Record; this.maxNumRequests = { + [RequestType.Metadata]: 6, [RequestType.Interaction]: 6, [RequestType.Thumbnail]: 6, [RequestType.Prefetch]: 5, @@ -127,6 +139,14 @@ class RequestPoolManager { }; } + /** + * Sets the overall maximum number of requests combined between the various types. + * Only applies to metadata, interaction, thumbnail and prefetch + */ + public setMaxConcurrentRequests(maxNumRequests: number) { + this.maxConcurrentRequests = maxNumRequests; + } + /** * This function sets the maximum number of requests for a given request type. * @param type - The type of request you want to set the max number @@ -231,8 +251,18 @@ class RequestPoolManager { this.requestPool[type] = { 0: [] }; } - private sendRequests(type) { - const requestsToSend = this.maxNumRequests[type] - this.numRequests[type]; + protected getRequestsAvailable(type) { + return this.maxNumRequests[type] - this.numRequests[type]; + } + + private sendRequests(type, maxRemaining = 1000) { + if (maxRemaining <= 0) { + return this.getRequestsAvailable(type) > 0; + } + const requestsToSend = Math.min( + this.getRequestsAvailable(type), + maxRemaining + ); let syncImageCount = 0; for (let i = 0; i < requestsToSend; i++) { @@ -280,19 +310,50 @@ class RequestPoolManager { return null; } + public get outstandingRequests() { + return ( + this.numRequests[RequestType.Interaction] + + this.numRequests[RequestType.Thumbnail] + + this.numRequests[RequestType.Prefetch] + ); + } + protected startGrabbing(): void { + const hasRemainingMetadataRequests = this.sendRequests( + RequestType.Metadata, + this.maxConcurrentRequests - this.outstandingRequests + ); + let available = this.maxConcurrentRequests - this.outstandingRequests; + // The && available after checking for > 0 means it uses the available requests as a positive value + // Then, allow 1 interaction request always + // Only allow 0 requests for other types - this is the || 0 part to get a 0 result rather than a false one const hasRemainingInteractionRequests = this.sendRequests( - RequestType.Interaction + RequestType.Interaction, + (available > 0 && available) || + (this.numRequests[RequestType.Interaction] === 0 && 1) || + 0 ); + available = this.maxConcurrentRequests - this.outstandingRequests; + // Allow an extra request for thumbnail if there are no outstanding + // interaction or thumbnail requests. const hasRemainingThumbnailRequests = this.sendRequests( - RequestType.Thumbnail + RequestType.Thumbnail, + (available > 0 && available) || + (this.numRequests[RequestType.Interaction] === 0 && + this.numRequests[RequestType.Thumbnail] === 0 && + 1) || + 0 ); const hasRemainingPrefetchRequests = this.sendRequests( - RequestType.Prefetch + RequestType.Prefetch, + this.maxConcurrentRequests - this.outstandingRequests ); + + // Compute requests are on a different queue as they don't use http requests const hasRemainingComputeRequests = this.sendRequests(RequestType.Compute); if ( + !hasRemainingMetadataRequests && !hasRemainingInteractionRequests && !hasRemainingThumbnailRequests && !hasRemainingPrefetchRequests && diff --git a/packages/core/test/requestPoolManager.jest.js b/packages/core/test/requestPoolManager.jest.js new file mode 100644 index 0000000000..7b957f630e --- /dev/null +++ b/packages/core/test/requestPoolManager.jest.js @@ -0,0 +1,142 @@ +import { RequestPoolManager } from '../src/requestPool/requestPoolManager'; +import RequestType from '../src/enums/RequestType'; + +/** + * Builds a request function that returns a promise which never settles, so the + * pool keeps counting it as "in flight". Each call is recorded on `requestFn` + * (a jest.fn) so tests can assert exactly how many requests were dispatched. + */ +function pendingRequest() { + return jest.fn(() => new Promise(() => {})); +} + +/** + * Adds `count` requests of a given type, each backed by its own pending + * requestFn, and returns the array of jest.fns so the caller can count how + * many were actually invoked (i.e. dispatched) by the pool. + */ +function addPending(manager, type, count, priority = 0) { + const fns = []; + for (let i = 0; i < count; i++) { + const fn = pendingRequest(); + fns.push(fn); + manager.addRequest(fn, type, {}, priority); + } + return fns; +} + +function dispatchedCount(fns) { + return fns.filter((fn) => fn.mock.calls.length > 0).length; +} + +describe('RequestPoolManager', () => { + describe('default configuration', () => { + it('includes the Metadata type in the per-type maximums', () => { + const manager = new RequestPoolManager(); + + // Metadata was added alongside the combined pool; it defaults to the same + // per-type ceiling as interaction/thumbnail. + expect(manager.getMaxSimultaneousRequests(RequestType.Metadata)).toBe(6); + expect(manager.getMaxSimultaneousRequests(RequestType.Interaction)).toBe( + 6 + ); + expect(manager.getMaxSimultaneousRequests(RequestType.Thumbnail)).toBe(6); + expect(manager.getMaxSimultaneousRequests(RequestType.Prefetch)).toBe(5); + expect(manager.getMaxSimultaneousRequests(RequestType.Compute)).toBe( + 1000 + ); + }); + + it('starts with no outstanding requests', () => { + const manager = new RequestPoolManager(); + expect(manager.outstandingRequests).toBe(0); + }); + }); + + describe('combined pool limit', () => { + it('caps the total in-flight requests across types at maxConcurrentRequests', () => { + const manager = new RequestPoolManager(); + manager.setMaxConcurrentRequests(3); + + // Prefetch alone would allow 5 concurrent (its per-type max), but the + // combined pool of 3 must win. + const fns = addPending(manager, RequestType.Prefetch, 10); + + expect(dispatchedCount(fns)).toBe(3); + expect(manager.outstandingRequests).toBe(3); + }); + + it('is bounded by the per-type maximum when it is lower than the combined pool', () => { + const manager = new RequestPoolManager(); + manager.setMaxConcurrentRequests(20); + + // Combined pool allows 20, but prefetch is capped at 5 per type. + const fns = addPending(manager, RequestType.Prefetch, 10); + + expect(dispatchedCount(fns)).toBe(5); + expect(manager.outstandingRequests).toBe(5); + }); + }); + + describe('interaction is never starved by lower-priority fetches', () => { + it('still dispatches one interaction request when the combined pool is full', () => { + const manager = new RequestPoolManager(); + manager.setMaxConcurrentRequests(2); + + // Saturate the combined pool with prefetch (lower priority) work. + const prefetchFns = addPending(manager, RequestType.Prefetch, 5); + expect(dispatchedCount(prefetchFns)).toBe(2); + expect(manager.outstandingRequests).toBe(2); + + // An interaction arriving after the pool is full must still get through - + // this is the guarantee that background fetches cannot block interaction. + const interactionFns = addPending(manager, RequestType.Interaction, 3); + + expect(dispatchedCount(interactionFns)).toBe(1); + }); + + it('only forces through a single interaction, not the whole backlog', () => { + const manager = new RequestPoolManager(); + manager.setMaxConcurrentRequests(2); + + addPending(manager, RequestType.Prefetch, 5); + const interactionFns = addPending(manager, RequestType.Interaction, 4); + + // With one interaction already in flight and the pool over capacity, the + // remaining interaction requests wait their turn. + expect(dispatchedCount(interactionFns)).toBe(1); + }); + }); + + describe('outstandingRequests', () => { + it('counts interaction, thumbnail and prefetch but excludes metadata', () => { + const manager = new RequestPoolManager(); + + // A metadata request is in flight... + const metadataFns = addPending(manager, RequestType.Metadata, 1); + expect(dispatchedCount(metadataFns)).toBe(1); + // ...but it does not count toward outstandingRequests. + expect(manager.outstandingRequests).toBe(0); + + // A prefetch request does count. + const prefetchFns = addPending(manager, RequestType.Prefetch, 1); + expect(dispatchedCount(prefetchFns)).toBe(1); + expect(manager.outstandingRequests).toBe(1); + }); + }); + + describe('compute queue', () => { + it('runs on a separate queue that ignores the combined pool limit', () => { + const manager = new RequestPoolManager(); + manager.setMaxConcurrentRequests(1); + + // Compute requests do not use HTTP requests, so they are dispatched off a + // separate queue that the combined pool size does not throttle. + const fns = addPending(manager, RequestType.Compute, 5); + + expect(dispatchedCount(fns)).toBe(5); + // And they are not reflected in outstandingRequests. + expect(manager.outstandingRequests).toBe(0); + }); + }); +}); diff --git a/packages/docs/docs/concepts/cornerstone-core/requestPoolManager.md b/packages/docs/docs/concepts/cornerstone-core/requestPoolManager.md index 12e3c84a05..190a8c7a99 100644 --- a/packages/docs/docs/concepts/cornerstone-core/requestPoolManager.md +++ b/packages/docs/docs/concepts/cornerstone-core/requestPoolManager.md @@ -82,6 +82,40 @@ imageLoadPoolManager.addRequest( ); ``` +## Combined concurrent request limit + +In addition to the per-type maximums above, each pool enforces a **combined +cap** on the total number of concurrent HTTP requests across all types +(`metadata`, `interaction`, `thumbnail`, and `prefetch`). This prevents +lower-priority work (such as a large prefetch backlog) from saturating the +browser's connection pool and starving interaction fetches. + +The combined cap defaults to `50` and can be changed with +`setMaxConcurrentRequests`: + +```js +import { imageLoadPoolManager } from '@cornerstonejs/core'; + +// Total concurrent HTTP requests across all types for this pool. +imageLoadPoolManager.setMaxConcurrentRequests(100); +``` + +The number of requests actually dispatched for a given type is the smaller of +its per-type maximum (`setMaxSimultaneousRequests`) and the remaining combined +budget. Even when the combined pool is full, at least one `Interaction` request +is always allowed through so background fetches can never fully block +interaction. + +The request types, in priority order, are: + +| Type | Purpose | +| ------------- | -------------------------------------------------------------- | +| `Metadata` | Metadata that must resolve before images can render (highest). | +| `Interaction` | Images needed for the current interaction. | +| `Thumbnail` | Thumbnail images. | +| `Prefetch` | Background image loading. | +| `Compute` | Non-HTTP compute work; runs on a separate, un-throttled queue. | + ## Requests re-ordering You could have a certain sequence in mind for retrieving the images. For example, diff --git a/packages/docs/docs/migration-guides/5x/3-request-pool.md b/packages/docs/docs/migration-guides/5x/3-request-pool.md new file mode 100644 index 0000000000..5dec7c177a --- /dev/null +++ b/packages/docs/docs/migration-guides/5x/3-request-pool.md @@ -0,0 +1,63 @@ +# Combined Request Pool Limit + +In 5.x the `RequestPoolManager` gains a **combined concurrency cap** that is +shared across all HTTP request types (metadata, interaction, thumbnail, and +prefetch), in addition to the existing per-type maximums. + +## What Changed + +Previously each request type had its own independent maximum +(`setMaxSimultaneousRequests(type, n)`) and there was no ceiling on the total +number of in-flight requests. On the shared `imageLoadPoolManager` this meant +interaction, thumbnail, and prefetch were each allowed up to 1000 concurrent +requests, so a large prefetch backlog could saturate the browser's connection +pool and delay interaction fetches. + +5.x adds: + +- A new **combined cap**, `maxConcurrentRequests`, that bounds the _total_ + number of concurrent metadata + interaction + thumbnail + prefetch requests. + It defaults to **50** and can be set with + `poolManager.setMaxConcurrentRequests(n)`. +- A new highest-priority request type, `RequestType.Metadata`, intended for + metadata fetches that must complete before images can render. +- A **starvation guarantee**: even when the combined pool is full, at least one + `Interaction` request is always allowed through, and one `Thumbnail` request + is allowed when nothing else is outstanding. This ensures background prefetch + work can never fully block interaction. + +The per-type maximums still apply — the effective number of requests dispatched +for a type is the smaller of its per-type maximum and the remaining combined +budget. + +The `Compute` queue is unaffected: compute requests do not use HTTP and run on a +separate queue that the combined pool does not throttle. + +## Why This Matters + +If your application relied on the previous behavior of effectively unbounded +concurrent image requests, the shared `imageLoadPoolManager` is now capped at +**50** combined concurrent requests by default. For most applications this is a +safer default that prevents low-priority fetches from starving interaction, but +if you deliberately depended on very high concurrency you should raise the cap +explicitly. + +## Migration Guidance + +- **No change required** for most applications; the new default cap of 50 is a + reasonable ceiling and the interaction guarantee preserves responsiveness. +- To tune the combined ceiling for a specific pool, call + `setMaxConcurrentRequests`: + + ```js + import { imageLoadPoolManager } from '@cornerstonejs/core'; + + // Raise (or lower) the total concurrent HTTP requests for the image-load pool. + imageLoadPoolManager.setMaxConcurrentRequests(100); + ``` + +- The per-type API is unchanged; keep using + `setMaxSimultaneousRequests(type, n)` to cap an individual type. The combined + cap is applied on top of the per-type maximums. +- If you fetch metadata through the pool and want it prioritized ahead of image + loading, enqueue those requests with `RequestType.Metadata`.