rx-player
Version:
Canal+ HTML5 Video Player
470 lines (431 loc) • 15.8 kB
text/typescript
import log from "../../../log";
import arrayFindIndex from "../../../utils/array_find_index";
import createCancellablePromise from "../../../utils/create_cancellable_promise";
import type { CancellationSignal } from "../../../utils/task_canceller";
import TaskCanceller, { CancellationError } from "../../../utils/task_canceller";
/**
* Utilitary class which allows to perform multiple tasks at once each with an
* associated priority.
*
* This class will then schedule the given tasks in the right order based on the
* priorities.
*
* @class TaskPrioritizer
*/
export default class TaskPrioritizer<T> {
/**
* Priority of the most prioritary task currently running.
* `null` if no task is currently running.
*/
private _minPendingPriority: number | null;
/** Queue of tasks currently waiting for more prioritary ones to finish. */
private _waitingQueue: Array<IPrioritizerTask<T>>;
/** Tasks currently pending. */
private _pendingTasks: Array<IPrioritizerTask<T>>;
/** @see IPrioritizerPrioritySteps */
private _prioritySteps: IPrioritizerPrioritySteps;
/**
* @param {Options} prioritizerOptions
*/
constructor({ prioritySteps }: IPrioritizerOptions) {
this._minPendingPriority = null;
this._waitingQueue = [];
this._pendingTasks = [];
this._prioritySteps = prioritySteps;
if (this._prioritySteps.high >= this._prioritySteps.low) {
throw new Error(
"The max high level priority should be given a lower" +
"priority number than the min low priority.",
);
}
}
/**
* Create a priorized Promise from a base task.
*
* This task will immediately have its priority compared to all the
* already-running ones created from this class.
*
* Only if this number is inferior or equal to the priority of the
* minimum priority number of all currently-running tasks will it be
* immediately started.
* In the opposite case, we will wait for higher-priority tasks to
* finish before starting it.
*
* Note that while this task is waiting for its turn, it is possible
* to update its property through the updatePriority method, by providing
* the task again and its new priority number.
*
* @param {Function} taskFn
* @param {number} priority
* @param {Object} callbacks
* @param {Object} cancelSignal
* @returns {Promise}
*/
public create(
taskFn: ITaskFn<T>,
priority: number,
callbacks: ITaskPrioritizerCallbacks,
cancelSignal: CancellationSignal,
): Promise<T> {
let newTask: IPrioritizerTask<T>;
return createCancellablePromise<T>(cancelSignal, (resolve, reject) => {
/** Function allowing to start the underlying Promise. */
const trigger = () => {
if (newTask.hasEnded) {
return;
}
const finishTask = () => {
unlinkInterrupter();
this._endTask(newTask);
};
const onResolve = (value: T) => {
callbacks.beforeEnded();
finishTask();
resolve(value);
};
const onReject = (err: unknown) => {
finishTask();
reject(err);
};
const interrupter = new TaskCanceller(undefined);
const unlinkInterrupter = interrupter.linkToSignal(cancelSignal);
newTask.interrupter = interrupter;
interrupter.signal.register(() => {
newTask.interrupter = null;
if (!cancelSignal.isCancelled()) {
callbacks.beforeInterrupted();
}
});
this._minPendingPriority =
this._minPendingPriority === null
? newTask.priority
: Math.min(this._minPendingPriority, newTask.priority);
this._pendingTasks.push(newTask);
newTask
.taskFn(interrupter.signal)
.then(onResolve)
.catch((err) => {
if (
!cancelSignal.isCancelled() &&
interrupter.isUsed() &&
err instanceof CancellationError
) {
return;
}
onReject(err);
});
};
newTask = {
hasEnded: false,
priority,
trigger,
taskFn,
interrupter: null,
};
if (!this._canBeStartedNow(newTask)) {
this._waitingQueue.push(newTask);
} else {
// We can start the task right away
newTask.trigger();
if (this._isRunningHighPriorityTasks()) {
// Note: we want to begin interrupting low-priority tasks just
// after starting the current one because the interrupting
// logic can call external code.
// This would mean re-entrancy, itself meaning that some weird
// half-state could be reached unless we're very careful.
// To be sure no harm is done, we put that code at the last
// possible position.
this._interruptCancellableTasks();
}
}
return () => this._endTask(newTask);
});
}
private _endTask(task: IPrioritizerTask<T>): void {
task.hasEnded = true;
const waitingQueueIndex = _findTaskIndex(task.taskFn, this._waitingQueue);
if (waitingQueueIndex >= 0) {
// If it was still waiting for its turn
this._waitingQueue.splice(waitingQueueIndex, 1);
} else {
// remove it from pending queue if in it
const pendingTasksIndex = _findTaskIndex(task.taskFn, this._pendingTasks);
if (pendingTasksIndex < 0) {
return;
}
this._pendingTasks.splice(pendingTasksIndex, 1);
if (this._pendingTasks.length > 0) {
if (this._minPendingPriority === task.priority) {
this._minPendingPriority = Math.min(
...this._pendingTasks.map((t) => t.priority),
);
}
} else {
this._minPendingPriority = null;
}
this._loopThroughWaitingQueue();
}
}
/**
* Update the priority of a promise given to the TaskPrioritizer.
* @param {Object} promise
* @param {number} priority
*/
public updatePriority(promise: ITaskFn<T>, priority: number): void {
const waitingQueueIndex = _findTaskIndex(promise, this._waitingQueue);
if (waitingQueueIndex >= 0) {
// If it was still waiting for its turn
const waitingQueueElt = this._waitingQueue[waitingQueueIndex];
if (waitingQueueElt.priority === priority) {
return;
}
waitingQueueElt.priority = priority;
if (!this._canBeStartedNow(waitingQueueElt)) {
return;
}
this._findAndRunWaitingQueueTask(waitingQueueIndex);
if (this._isRunningHighPriorityTasks()) {
// Re-check to cancel every "cancellable" pending task
//
// Note: We start the task before interrupting cancellable tasks on
// purpose.
// Because both `_findAndRunWaitingQueueTask` and
// `_interruptCancellableTasks` can emit events and thus call external
// code, we could retrieve ourselves in a very weird state at this point
//
// By starting the task first, we ensure that this is manageable:
// `_getMinPendingPriority()` has already been updated to the right value at
// the time we reached external code, the priority of the current
// Task has just been updated, and `_interruptCancellableTasks`
// will ensure that we're basing ourselves on the last `priority` value
// each time.
// Doing it in the reverse order is an order of magnitude more difficult
// to write and to reason about.
this._interruptCancellableTasks();
}
return;
}
const pendingTasksIndex = _findTaskIndex(promise, this._pendingTasks);
if (pendingTasksIndex < 0) {
log.warn("SF", "request to update the priority of a non-existent task");
return;
}
const task = this._pendingTasks[pendingTasksIndex];
if (task.priority === priority) {
return;
}
const prevPriority = task.priority;
task.priority = priority;
if (this._minPendingPriority === null || priority < this._minPendingPriority) {
this._minPendingPriority = priority;
} else if (this._minPendingPriority === prevPriority) {
// was highest priority
if (this._pendingTasks.length === 1) {
this._minPendingPriority = priority;
} else {
this._minPendingPriority = Math.min(...this._pendingTasks.map((t) => t.priority));
}
this._loopThroughWaitingQueue();
}
if (this._isRunningHighPriorityTasks()) {
// Always interrupt cancellable tasks after all other side-effects, to
// avoid re-entrancy issues
this._interruptCancellableTasks();
}
}
/**
* Browse the current waiting queue and start all task in it that needs to be
* started: start the ones with the lowest priority value below
* `_minPendingPriority`.
*
* Private properties, such as `_minPendingPriority` are updated accordingly
* while this method is called.
*/
private _loopThroughWaitingQueue(): void {
const minWaitingPriority = this._waitingQueue.reduce((acc: number | null, elt) => {
return acc === null || acc > elt.priority ? elt.priority : acc;
}, null);
if (
minWaitingPriority === null ||
(this._minPendingPriority !== null && this._minPendingPriority < minWaitingPriority)
) {
return;
}
for (let i = 0; i < this._waitingQueue.length; i++) {
const priorityToCheck =
this._minPendingPriority === null
? minWaitingPriority
: Math.min(this._minPendingPriority, minWaitingPriority);
const elt = this._waitingQueue[i];
if (elt.priority <= priorityToCheck) {
this._findAndRunWaitingQueueTask(i);
i--; // previous operation should have removed that element from the
// the waiting queue
}
}
}
/**
* Interrupt and move back to the waiting queue all pending tasks that are
* low priority (having a higher priority number than
* `this._prioritySteps.low`).
*/
private _interruptCancellableTasks(): void {
for (const pendingObj of this._pendingTasks) {
if (pendingObj.priority >= this._prioritySteps.low) {
this._interruptPendingTask(pendingObj);
// The previous call could have a lot of potential side-effects.
// It is safer to re-start the function to not miss any pending
// task that needs to be cancelled.
return this._interruptCancellableTasks();
}
}
}
/**
* Start task which is at the given index in the waiting queue.
* The task will be removed from the waiting queue in the process.
* @param {number} index
*/
private _findAndRunWaitingQueueTask(index: number): boolean {
if (index >= this._waitingQueue.length || index < 0) {
log.warn("SF", "Tried to start a non existing task");
return false;
}
const task = this._waitingQueue.splice(index, 1)[0];
task.trigger();
return true;
}
/**
* Move back pending task to the waiting queue and interrupt it.
* @param {object} task
*/
private _interruptPendingTask(task: IPrioritizerTask<T>): void {
const pendingTasksIndex = _findTaskIndex(task.taskFn, this._pendingTasks);
if (pendingTasksIndex < 0) {
log.warn("SF", "Interrupting a non-existent pending task. Aborting...");
return;
}
// Stop task and put it back in the waiting queue
this._pendingTasks.splice(pendingTasksIndex, 1);
this._waitingQueue.push(task);
if (this._pendingTasks.length === 0) {
this._minPendingPriority = null;
} else if (this._minPendingPriority === task.priority) {
this._minPendingPriority = Math.min(...this._pendingTasks.map((t) => t.priority));
}
task.interrupter?.cancel("TaskPrioritizer interrupt"); // Interrupt at last step because it calls external code
}
/**
* Return `true` if the given task can be started immediately based on its
* priority.
* @param {Object} task
* @returns {boolean}
*/
private _canBeStartedNow(task: IPrioritizerTask<T>): boolean {
return this._minPendingPriority === null || task.priority <= this._minPendingPriority;
}
/**
* Returns `true` if any running task is considered "high priority".
* returns `false` otherwise.
* @returns {boolean}
*/
private _isRunningHighPriorityTasks(): boolean {
return (
this._minPendingPriority !== null &&
this._minPendingPriority <= this._prioritySteps.high
);
}
}
/**
* Simple utils function allowing to find a given task function in the given
* `queue`.
*
* Returns `-1` if `taskFn` is not found.
* @param {Function} taskFn
* @param {Array.<Object>} queue
* @returns {number}
*/
function _findTaskIndex<T>(
taskFn: ITaskFn<T>,
queue: Array<IPrioritizerTask<T>>,
): number {
return arrayFindIndex(queue, (elt) => elt.taskFn === taskFn);
}
/**
* Task function as given to the TaskPrioritizer.
* The `CancellationSignal` given as argument should be used to directly
* interrupt the task.
*
* A same task might be re-run multiple times.
*/
export type ITaskFn<T> = (cancellationSignal: CancellationSignal) => Promise<T>;
/** Stored object representing a single task. */
interface IPrioritizerTask<T> {
/**
* Actual task called by the TaskPrioritizer, as well as the object used as an
* identifier to update its priority.
* This is the same task than the one the TaskPrioritizer is asked to run.
*/
taskFn: ITaskFn<T>;
/** Function allowing to start the task. */
trigger: () => void;
/** Priority of the task. Lower that number is, higher is the priority. */
priority: number;
/** `true` if the underlying wrapped promise is either errored or completed. */
hasEnded: boolean;
/**
* Allows to temporarily cancel the task, for example due to changing
* priorities.
* It will then be possible to restart the task by calling `trigger` again.
*/
interrupter: TaskCanceller | null;
}
/** Options to give to the `TaskPrioritizer`. */
export interface IPrioritizerOptions {
/** @see IPrioritizerPrioritySteps */
prioritySteps: IPrioritizerPrioritySteps;
}
/**
* Define both the `low` and `high` priority steps:
*
* - Any task with a priority number that is lower or equal to the
* `high` value will be a task with high priority.
*
* When tasks with high priorities are scheduled, they immediately
* abort pending tasks with low priorities (which will have then to
* wait until all higher-priority task have ended before re-starting).
*
* - Any task with a priority number that is higher or equal to the
* `low` value will be a task with low priority.
*
* Pending tasks with low priorities have the added particularity*
* of being aborted as soon as a high priority task is scheduled.
*
* * Other pending tasks are not aborted when a higher-priority
* task is scheduled, as their priorities only affect them before
* they are started (to know when to start them).
*/
export interface IPrioritizerPrioritySteps {
low: number;
high: number;
}
/**
* Callbacks called by the `TaskPrioritizer` on various events associated to a
* single task.
*/
export interface ITaskPrioritizerCallbacks {
/**
* Callback called just before the `TaskPrioritizer` temporarily cancels
* a task, to prioritize higher-priority tasks.
*
* Interrupted tasks are restarted from scratch at a later time.
*/
beforeInterrupted(): void;
/**
* Callback called when the task ended just before the `TaskPrioritizer`
* chooses the next task(s) to run.
*
* You can use this callback to add yet other tasks just in time before the
* `TaskPrioritizer` chooses the next one.
*/
beforeEnded(): void;
}