import {Stats} from '@probe.gl/stats'; type Handle = any; type DoneFunction = () => any; type GetPriorityFunction = () => number; type RequestResult = { done: DoneFunction; } | null; /** RequestScheduler Options */ export type RequestSchedulerProps = { id?: string; throttleRequests?: boolean; maxRequests?: number; debounceTime?: number; }; const STAT_QUEUED_REQUESTS = 'Queued Requests'; const STAT_ACTIVE_REQUESTS = 'Active Requests'; const STAT_CANCELLED_REQUESTS = 'Cancelled Requests'; const STAT_QUEUED_REQUESTS_EVER = 'Queued Requests Ever'; const STAT_ACTIVE_REQUESTS_EVER = 'Active Requests Ever'; const DEFAULT_PROPS: Required = { id: 'request-scheduler', /** Specifies if the request scheduler should throttle incoming requests, mainly for comparative testing. */ throttleRequests: true, /** The maximum number of simultaneous active requests. Un-throttled requests do not observe this limit. */ maxRequests: 6, /** * Specifies a debounce time, in milliseconds. All requests are queued, until no new requests have * been added to the queue for this amount of time. */ debounceTime: 0 }; /** Tracks one request */ type Request = { handle: Handle; priority: number; getPriority: GetPriorityFunction; resolve?: (value: any) => any; }; /** * Used to issue a request, without having them "deeply queued" by the browser. * @todo - Track requests globally, across multiple servers */ export default class RequestScheduler { readonly props: Required; readonly stats: Stats; activeRequestCount: number = 0; /** Tracks the number of active requests and prioritizes/cancels queued requests. */ private requestQueue: Request[] = []; private requestMap: Map> = new Map(); private updateTimer: ReturnType | null = null; constructor(props: RequestSchedulerProps = {}) { this.props = {...DEFAULT_PROPS, ...props}; // Returns the statistics used by the request scheduler. this.stats = new Stats({id: this.props.id}); this.stats.get(STAT_QUEUED_REQUESTS); this.stats.get(STAT_ACTIVE_REQUESTS); this.stats.get(STAT_CANCELLED_REQUESTS); this.stats.get(STAT_QUEUED_REQUESTS_EVER); this.stats.get(STAT_ACTIVE_REQUESTS_EVER); } /** * Called by an application that wants to issue a request, without having it deeply queued by the browser * * When the returned promise resolved, it is OK for the application to issue a request. * The promise resolves to an object that contains a `done` method. * When the application's request has completed (or failed), the application must call the `done` function * * @param handle * @param getPriority will be called when request "slots" open up, * allowing the caller to update priority or cancel the request * Highest priority executes first, priority < 0 cancels the request * @returns a promise * - resolves to a object (with a `done` field) when the request can be issued without queueing, * - resolves to `null` if the request has been cancelled (by the callback return < 0). * In this case the application should not issue the request */ scheduleRequest( handle: Handle, getPriority: GetPriorityFunction = () => 0 ): Promise { // Allows throttling to be disabled if (!this.props.throttleRequests) { return Promise.resolve({done: () => {}}); } // dedupe if (this.requestMap.has(handle)) { return this.requestMap.get(handle) as Promise; } const request: Request = {handle, priority: 0, getPriority}; const promise = new Promise((resolve) => { // @ts-ignore request.resolve = resolve; return request; }); this.requestQueue.push(request); this.requestMap.set(handle, promise); this._issueNewRequests(); return promise; } // PRIVATE _issueRequest(request: Request): Promise { const {handle, resolve} = request; let isDone = false; const done = () => { // can only be called once if (!isDone) { isDone = true; // Stop tracking a request - it has completed, failed, cancelled etc this.requestMap.delete(handle); this.activeRequestCount--; // A slot just freed up, see if any queued requests are waiting this._issueNewRequests(); } }; // Track this request this.activeRequestCount++; return resolve ? resolve({done}) : Promise.resolve({done}); } /** We check requests asynchronously, to prevent multiple updates */ _issueNewRequests(): void { if (this.updateTimer !== null) { clearTimeout(this.updateTimer); } this.updateTimer = setTimeout(() => this._issueNewRequestsAsync(), this.props.debounceTime); } /** Refresh all requests */ _issueNewRequestsAsync() { if (this.updateTimer !== null) { clearTimeout(this.updateTimer); } this.updateTimer = null; const freeSlots = Math.max(this.props.maxRequests - this.activeRequestCount, 0); if (freeSlots === 0) { return; } this._updateAllRequests(); // Resolve pending promises for the top-priority requests for (let i = 0; i < freeSlots; ++i) { const request = this.requestQueue.shift(); if (request) { this._issueRequest(request); // eslint-disable-line @typescript-eslint/no-floating-promises } } // Uncomment to debug // console.log(`${freeSlots} free slots, ${this.requestQueue.length} queued requests`); } /** Ensure all requests have updated priorities, and that no longer valid requests are cancelled */ _updateAllRequests() { const requestQueue = this.requestQueue; for (let i = 0; i < requestQueue.length; ++i) { const request = requestQueue[i]; if (!this._updateRequest(request)) { // Remove the element and make sure to adjust the counter to account for shortened array requestQueue.splice(i, 1); this.requestMap.delete(request.handle); i--; } } // Sort the remaining requests based on priority requestQueue.sort((a, b) => a.priority - b.priority); } /** Update a single request by calling the callback */ _updateRequest(request) { request.priority = request.getPriority(request.handle); // eslint-disable-line callback-return // by returning a negative priority, the callback cancels the request if (request.priority < 0) { request.resolve(null); return false; } return true; } }