transitory
Version:
In-memory cache with high hit rates via LFU eviction. Supports time-based expiration, automatic loading and metrics.
209 lines (170 loc) • 5.14 kB
text/typescript
import { CacheNode } from '../CacheNode';
import { KeyType } from '../KeyType';
import { Expirable } from './Expirable';
/**
* Helper function to calculate the closest power of two to N.
*
* @param n -
* input
* @returns
* closest power of two to `n`
*/
function toPowerOfN(n: number) {
return Math.pow(2, Math.ceil(Math.log(n) / Math.LN2));
}
const LAYERS = [ 64, 64, 32, 4, 1 ];
const SPANS = [ toPowerOfN(1000), toPowerOfN(60000), toPowerOfN(3600000), toPowerOfN(86400000), LAYERS[3] * toPowerOfN(86400000), LAYERS[3] * toPowerOfN(86400000) ];
const SHIFTS = SPANS.slice(0, SPANS.length - 1).map(span => 1 + Math.floor(Math.log(span - 1) * Math.LOG2E));
export type EvictionListener<K extends KeyType> = (keys: K[]) => void;
/**
* A timer wheel for variable expiration of items in a cache. Stores items in
* layers that are circular buffers that represent a time span.
*
* This implementation takes some extra care to work with Number as they are
* actually doubles and shifting turns them into 32-bit ints. To represent
* time we need more than 32-bits so to fully support things this implementation
* uses a base which is removed from all of the numbers to make them fit into
* 32-bits.
*
* Based on an idea by Ben Manes implemented in Caffeine.
*/
export class TimerWheel<K extends KeyType, V> {
private evict: EvictionListener<K>;
private time: number;
private base: number;
private layers: TimerNode<K, V>[][];
public constructor(evict: EvictionListener<K>) {
this.evict = evict;
this.base = Date.now();
this.layers = LAYERS.map(b => {
const result = new Array(b);
for(let i = 0; i < b; i++) {
result[i] = new TimerNode(this, null, null);
}
return result;
});
this.time = 0;
}
public get localTime() {
return Date.now() - this.base;
}
private findBucket(node: TimerNode<K, V>): TimerNode<K, V> | null {
const d = node.time - this.time;
if(d <= 0) return null;
const layers = this.layers;
for(let i = 0, n = layers.length - 1; i < n; i++) {
if(d >= SPANS[i + 1]) continue;
const ticks = node.time >>> SHIFTS[i];
const index = ticks & (layers[i].length - 1);
return layers[i][index];
}
return layers[layers.length - 1][0];
}
public advance(localTime?: number) {
const previous = this.time;
const time = localTime || this.localTime;
this.time = time;
const layers = this.layers;
// Holder for expired keys
let expired: K[] | null = null;
/*
* Go through all of the layers on the wheel, evict things and move
* other stuff around.
*/
for(let i = 0, n = SHIFTS.length; i < n; i++) {
const previousTicks = previous >>> SHIFTS[i];
const timeTicks = time >>> SHIFTS[i];
// At the same tick, no need to keep working down the layers
if(timeTicks <= previousTicks) break;
const wheel = layers[i];
// Figure out the actual buckets to use
let start;
let end;
if(time - previous >= SPANS[i + 1]) {
start = 0;
end = wheel.length - 1;
} else {
start = previousTicks & (SPANS[i] - 1);
end = timeTicks & (SPANS[i] - 1);
}
// Go through all of the buckets and move stuff around
for(let j = start; j <= end; j++) {
const head = wheel[j & (wheel.length - 1)];
let node = head.next;
head.previous = head;
head.next = head;
while(node !== head) {
const next = node.next;
node.remove();
if(node.time <= time) {
// This node has expired, add it to the queue
if(! expired) expired = [];
expired.push(node.key as K);
} else {
// Find a new bucket to put this node in
const b = this.findBucket(node);
if(b) {
node.appendToTail(b);
}
}
node = next;
}
}
}
if(expired) {
this.evict(expired);
}
}
/**
* Create a node that that helps with tracking when a key and value
* should be evicted.
*
* @param key -
* key to set
* @param value -
* value to set
* @returns
* node
*/
public node(key: K, value: V): TimerNode<K, V> {
return new TimerNode(this, key, value);
}
/**
* Schedule eviction of the given node at the given timestamp.
*
* @param node -
* node to reschedule
* @param time -
* new expiration time
* @returns
* if the node was rescheduled
*/
public schedule(node: TimerNode<K, V>, time: number) {
node.remove();
if(time <= 0) return false;
node.time = this.localTime + time;
const parent = this.findBucket(node);
if(! parent) return false;
node.appendToTail(parent);
return true;
}
/*
* Remove the given node from the wheel.
*/
public deschedule(node: TimerNode<K, V>) {
node.remove();
}
}
/* Node in a doubly linked list. More or less the same as used in BoundedCache */
export class TimerNode<K extends KeyType, V> extends CacheNode<K, V> implements Expirable<V> {
private wheel: TimerWheel<K, V>;
public time: number;
public constructor(wheel: TimerWheel<K, V>, key: K | null, value: V | null) {
super(key, value);
this.time = Number.MAX_SAFE_INTEGER;
this.wheel = wheel;
}
public isExpired() {
return this.wheel.localTime > this.time;
}
}