starvation-free-priority-queue
Version:
An in-memory priority queue that prevents starvation by balancing priority and arrival time. Items are greedily prioritized within each batch of the longest-waiting items, ensuring fairness alongside prioritization and bounded delays for low-priority task
214 lines (196 loc) • 8.85 kB
text/typescript
/**
* Copyright 2024 Ori Cohen https://github.com/ori88c
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { SlimQueue } from 'data-oriented-slim-queue';
/**
* A higher priority number indicates that the item should be prioritized in greedy settings,
* meaning it would be returned **before** items with lower priority values.
*
* Note: The `StarvationFreePriorityQueue` does not follow a purely greedy approach. It may
* override the raw priority attribute when the starvation limit (Maximum Deferment) is reached.
*/
export type PriorityGetter<T> = (item: Readonly<T>) => number;
type ItemsComparer<T> = (item1: Readonly<T>, item2: Readonly<T>) => number;
/**
* StarvationFreePriorityQueue
*
* The `StarvationFreePriorityQueue` class implements an in-memory priority queue designed to mitigate
* the issue of starvation—where low-priority items may never be processed due to the continuous arrival
* of higher-priority items. It achieves this by balancing two key factors:
* - **Priority**: Represented by a numeric value.
* - **Arrival time**: Ensures fairness for items that have waited longer.
*
* This balanced approach makes the implementation ideal for task scheduling where fairness is critical,
* though it may not be suitable for greedy algorithms such as Dijkstra's algorithm.
*
* ### Algorithm
* The balance between priority and arrival time is achieved through the following data structures:
* - **FIFO Queue**: Holds all pending items in their order of arrival.
* - **Priority Frontier**: A sorted array representing the **next batch** of items ready for removal,
* prioritized by their numeric priority value.
*
* **Push and Pop Operations**:
* - **Pushing Items**: New items are added to the FIFO Queue.
* - **Popping Items**:
* - If the Priority Frontier contains items, the top (highest-priority) item is removed and returned,
* prioritizing the priority attribute greedily within each batch.
* - If the Priority Frontier is empty, up to `_maxDeferment + 1` items are pulled from the FIFO Queue,
* sorted by priority, and moved to the Priority Frontier for removal. This ensures fairness by
* accounting for the order of arrival.
*
* ### Starvation Coefficient - Maximum Deferment
* Low-priority items can be delayed by **at most** `maxDeferment` pop attempts, as defined by the user.
* This ensures fairness while maintaining the priority order, provided that `maxDeferment` is not exceeded.
*
* ### Complexity
* - **Push**: O(1).
* - **Pop**:
* - **Amortized**: O(log(maxDeferment)).
* - **Best case**: O(1).
* - **Worst case**: O(maxDeferment * log(maxDeferment)) when a new frontier is populated.
*
* ### Example Use Cases
* - Task scheduling where fairness is critical but priority remains significant.
* - Resource allocation systems requiring bounded delays for low-priority tasks.
*/
export class StarvationFreePriorityQueue<T> {
private readonly _maxDeferment: number;
private readonly _ascByPriorityComparer: ItemsComparer<T>;
private readonly _pendingItemsQueue: SlimQueue<T>;
private readonly _priorityFrontier: T[] = []; // Frontier of items ready to be removed, sorted by ascending priority.
/**
* Constructor
*
* The `StarvationFreePriorityQueue` constructor provides precise control over the
* Starvation Coefficient (Maximum Deferment), determining the maximum number of
* 'pop' operations during which low-priority items can be delayed by higher-priority items.
*
* @param maxDeferment The maximum number of 'pop' operations a low-priority item
* can be delayed by higher-priority items within the same batch.
* @param getPriority A callback function to extract the priority of an item.
* Larger numeric values indicate higher priority.
* @param estimatedMaxCapacity An estimate of the queue's maximum capacity, representing
* the expected backpressure of items waiting to be removed
* by the 'pop' operation. This serves as an optimization
* parameter, as higher estimates reduce the frequency of
* dynamic memory allocations at runtime.
*/
constructor(
maxDeferment: number,
getPriority: PriorityGetter<T>,
estimatedMaxCapacity?: number
) {
if (!isNaturalNumber(maxDeferment)) {
throw new Error(
`Failed to instantiate StarvationFreePriorityQueue: maxDeferment must ` +
`be a natural number, but received ${maxDeferment}`
);
}
if (estimatedMaxCapacity && !isNaturalNumber(estimatedMaxCapacity)) {
throw new Error(
`Failed to instantiate StarvationFreePriorityQueue: estimatedMaxCapacity must ` +
`be a natural number, but received ${estimatedMaxCapacity}`
);
}
this._maxDeferment = maxDeferment;
this._pendingItemsQueue = new SlimQueue<T>(estimatedMaxCapacity);
this._ascByPriorityComparer = (item1: Readonly<T>, item2: Readonly<T>): number =>
getPriority(item1) - getPriority(item2);
}
/**
* size
*
* @returns The number of items currently stored in the priority queue.
*/
public get size(): number {
return this._pendingItemsQueue.size + this._priorityFrontier.length;
}
/**
* isEmpty
*
* @returns True if and only if the queue does not contain any item.
*/
public get isEmpty(): boolean {
return this._pendingItemsQueue.size === 0 && this._priorityFrontier.length === 0;
}
/**
* push
*
* Appends an item to the end of the queue (i.e., the Last In), increasing its size by one.
*
* ### Complexity
* O(1) under normal conditions. However, if the internal FIFO Queue capacity is exceeded,
* a reallocation occurs, which can temporarily increase latency. This behavior may result
* in latency spikes during the warm-up period. To mitigate this, consider setting the
* optional `estimatedMaxCapacity` parameter in the constructor.
*
* @param item The item to add as the newest entry in the queue (i.e., the Last In).
*/
public push(item: T): void {
this._pendingItemsQueue.push(item);
}
/**
* pop
*
* Removes and returns the most prioritized item from the current batch, decreasing the queue's
* size by one.
*
* ### Algorithm
* - If the Priority Frontier is empty, up to `_maxDeferment + 1` items are pulled from the FIFO
* Queue and moved to the Priority Frontier. Once shifted, the items in the Priority Frontier
* are sorted by priority.
* - The top (highest-priority) item is then removed from the Priority Frontier and returned.
*
* ### Complexity
* - **Amortized**: O(log(maxDeferment)).
* - **Best case**: O(1).
* - **Worst case**: O(maxDeferment * log(maxDeferment)) when a new frontier is populated.
*
* @returns The item that was removed from the queue.
* @throws Error if the queue is empty.
*/
public pop(): T {
if (this._priorityFrontier.length > 0) {
return this._priorityFrontier.pop();
}
if (this._pendingItemsQueue.size === 0) {
throw new Error('Cannot perform a pop operation on an empty StarvationFreePriorityQueue');
}
// Populate the frontier, representing the current batch of prioritized items to be removed first.
// Unlike a typical priority queue, this approach considers *both* the waiting time of items
// and their raw priority attribute. This mechanism ensures *limited aging* of items in the queue.
for (let movedCount = 0; movedCount <= this._maxDeferment && !this._pendingItemsQueue.isEmpty; ++movedCount) {
const oldestPendingItem = this._pendingItemsQueue.pop();
this._priorityFrontier.push(oldestPendingItem);
}
this._priorityFrontier.sort(this._ascByPriorityComparer);
return this._priorityFrontier.pop();
}
/**
* clear
*
* Removes all items from the queue, leaving it empty.
*/
public clear(): void {
this._pendingItemsQueue.clear();
while (this._priorityFrontier.length > 0) {
this._priorityFrontier.pop();
}
}
}
function isNaturalNumber(num: number): boolean {
const floored = Math.floor(num);
return floored >= 1 && floored === num;
}