Skip to content

Commit 4c62e73

Browse files
authored
Add object implementation of FIFO (#4)
1 parent 6eaa022 commit 4c62e73

File tree

5 files changed

+352
-2
lines changed

5 files changed

+352
-2
lines changed

index.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import { fifo } from './src/fifo.js'
22
import { lru } from './src/lru.js'
33
import { lruObject } from './src/lru-object.js'
4+
import { fifoObject } from './src/fifo-object.js'
45

56
export { fifo }
67
export { lru }
78
export { lruObject }
9+
export { fifoObject }

src/fifo-object.js

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
class FIFOObject {
2+
constructor(max = 0, ttl = 0) {
3+
this.first = null
4+
this.items = Object.create(null)
5+
this.last = null
6+
this.size = 0
7+
this.max = max
8+
this.ttl = ttl
9+
}
10+
11+
clear() {
12+
this.items = Object.create(null)
13+
this.first = null
14+
this.last = null
15+
this.size = 0
16+
}
17+
18+
delete(key) {
19+
if (Object.prototype.hasOwnProperty.call(this.items, key)) {
20+
const deletedItem = this.items[key]
21+
22+
delete this.items[key]
23+
this.size--
24+
25+
if (deletedItem.prev !== null) {
26+
deletedItem.prev.next = deletedItem.next
27+
}
28+
29+
if (deletedItem.next !== null) {
30+
deletedItem.next.prev = deletedItem.prev
31+
}
32+
33+
if (this.first === deletedItem) {
34+
this.first = deletedItem.next
35+
}
36+
37+
if (this.last === deletedItem) {
38+
this.last = deletedItem.prev
39+
}
40+
}
41+
}
42+
43+
evict() {
44+
if (this.size > 0) {
45+
const item = this.first
46+
47+
delete this.items[item.key]
48+
49+
if (--this.size === 0) {
50+
this.first = null
51+
this.last = null
52+
} else {
53+
this.first = item.next
54+
this.first.prev = null
55+
}
56+
}
57+
}
58+
59+
expiresAt(key) {
60+
if (Object.prototype.hasOwnProperty.call(this.items, key)) {
61+
return this.items[key].expiry
62+
}
63+
}
64+
65+
get(key) {
66+
if (Object.prototype.hasOwnProperty.call(this.items, key)) {
67+
const item = this.items[key]
68+
69+
if (this.ttl > 0 && item.expiry <= Date.now()) {
70+
this.delete(key)
71+
return
72+
}
73+
74+
return item.value
75+
}
76+
}
77+
78+
keys() {
79+
return Object.keys(this.items)
80+
}
81+
82+
set(key, value) {
83+
// Replace existing item
84+
if (Object.prototype.hasOwnProperty.call(this.items, key)) {
85+
const item = this.items[key]
86+
item.value = value
87+
88+
item.expiry = this.ttl > 0 ? Date.now() + this.ttl : this.ttl
89+
90+
return
91+
}
92+
93+
// Add new item
94+
if (this.max > 0 && this.size === this.max) {
95+
this.evict()
96+
}
97+
98+
const item = {
99+
expiry: this.ttl > 0 ? Date.now() + this.ttl : this.ttl,
100+
key: key,
101+
prev: this.last,
102+
next: null,
103+
value,
104+
}
105+
this.items[key] = item
106+
107+
if (++this.size === 1) {
108+
this.first = item
109+
} else {
110+
this.last.next = item
111+
}
112+
113+
this.last = item
114+
}
115+
}
116+
117+
export function fifoObject(max = 1000, ttl = 0) {
118+
if (isNaN(max) || max < 0) {
119+
throw new TypeError('Invalid max value')
120+
}
121+
122+
if (isNaN(ttl) || ttl < 0) {
123+
throw new TypeError('Invalid ttl value')
124+
}
125+
126+
return new FIFOObject(max, ttl)
127+
}

src/lru-object.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
class LRU {
1+
class LRUObject {
22
constructor(max = 0, ttl = 0) {
33
this.first = null
44
this.items = Object.create(null)
@@ -153,5 +153,5 @@ export function lruObject(max = 1000, ttlInMsecs = 0) {
153153
throw new TypeError('Invalid ttl value')
154154
}
155155

156-
return new LRU(max, ttlInMsecs)
156+
return new LRUObject(max, ttlInMsecs)
157157
}

test/fifo-object.spec.js

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
import assert from 'node:assert'
2+
import { it, describe, beforeEach, expect } from 'vitest'
3+
import { fifoObject } from '../src/fifo-object.js'
4+
import { items, populateCache } from './utils/cachePopulator.js'
5+
import { setTimeout } from 'timers/promises'
6+
7+
describe('FIFO-object', function () {
8+
let cache
9+
10+
beforeEach(function () {
11+
cache = fifoObject(4)
12+
populateCache(cache)
13+
})
14+
15+
describe('constructor validations', () => {
16+
it('throws on invalid max', () => {
17+
expect(() => fifoObject('abc')).to.throw(/Invalid max value/)
18+
})
19+
20+
it('throws on invalid ttl', () => {
21+
expect(() => fifoObject(100, 'abc')).to.throw(/Invalid ttl value/)
22+
})
23+
})
24+
25+
describe('clear', () => {
26+
it('Clear whole cache', () => {
27+
expect(Array.from(cache.keys())).toHaveLength(4)
28+
29+
cache.clear()
30+
31+
expect(Array.from(cache.keys())).toHaveLength(0)
32+
})
33+
})
34+
35+
describe('evict', () => {
36+
it('It should evict', function () {
37+
expect(cache.first.key).toBe('b')
38+
expect(cache.last.key).toBe('e')
39+
expect(cache.size).toBe(4)
40+
cache.evict()
41+
expect(cache.first.key).toBe('c')
42+
})
43+
44+
it('does not crash when evicting from empty cache', () => {
45+
cache.clear()
46+
expect(Array.from(cache.keys())).toHaveLength(0)
47+
48+
cache.evict()
49+
expect(Array.from(cache.keys())).toHaveLength(0)
50+
})
51+
52+
it('adjusts links to null when evicting last entry', () => {
53+
cache = fifoObject(4)
54+
cache.set(items[0], items[0])
55+
expect(Array.from(cache.keys())).toHaveLength(1)
56+
expect(cache.last.value).toBe(items[0])
57+
expect(cache.first.value).toBe(items[0])
58+
59+
cache.evict()
60+
expect(Array.from(cache.keys())).toHaveLength(0)
61+
expect(cache.last).toBeNull()
62+
expect(cache.first).toBeNull()
63+
})
64+
})
65+
66+
describe('get', () => {
67+
it('deletes expired entries', async () => {
68+
cache = fifoObject(4, 500)
69+
populateCache(cache)
70+
await setTimeout(300)
71+
cache.set(items[2], items[2])
72+
const item1Pre = cache.get(items[1])
73+
const item2Pre = cache.get(items[2])
74+
75+
await setTimeout(300)
76+
77+
const item1Post = cache.get(items[1])
78+
const item2Post = cache.get(items[2])
79+
80+
expect(item1Pre).toBe(false)
81+
expect(item1Post).toBeUndefined()
82+
expect(item2Pre).toBe(items[2])
83+
expect(item2Post).toBe(items[2])
84+
})
85+
})
86+
87+
describe('set', () => {
88+
it('Does not set expiration time on resetting entry when ttl is 0', () => {
89+
cache = fifoObject(1000, 0)
90+
91+
cache.set(items[0], false)
92+
cache.set(items[0], items[0])
93+
94+
expect(cache.expiresAt(items[0])).toBe(0)
95+
})
96+
})
97+
98+
describe('delete', () => {
99+
it('It should delete', function () {
100+
assert.strictEqual(cache.first.key, 'b', "Should be 'b'")
101+
assert.strictEqual(cache.last.key, 'e', "Should be 'e'")
102+
assert.strictEqual(cache.size, 4, "Should be '4'")
103+
assert.strictEqual(cache.items['e'].next, null, "Should be 'null'")
104+
assert.strictEqual(cache.items['e'].prev.key, 'd', "Should be 'd'")
105+
assert.strictEqual(cache.items['d'].next.key, 'e', "Should be 'e'")
106+
assert.strictEqual(cache.items['d'].prev.key, 'c', "Should be 'c'")
107+
assert.strictEqual(cache.items['c'].next.key, 'd', "Should be 'd'")
108+
assert.strictEqual(cache.items['c'].prev.key, 'b', "Should be 'b'")
109+
assert.strictEqual(cache.items['b'].next.key, 'c', "Should be 'c'")
110+
assert.strictEqual(cache.items['b'].prev, null, "Should be 'null'")
111+
cache.delete('c')
112+
assert.strictEqual(cache.first.key, 'b', "Should be 'b'")
113+
assert.strictEqual(cache.last.key, 'e', "Should be 'e'")
114+
assert.strictEqual(cache.size, 3, "Should be '3'")
115+
assert.strictEqual(cache.items['e'].next, null, "Should be 'null'")
116+
assert.strictEqual(cache.items['e'].prev.key, 'd', "Should be 'd'")
117+
assert.strictEqual(cache.items['d'].next.key, 'e', "Should be 'e'")
118+
assert.strictEqual(cache.items['d'].prev.key, 'b', "Should be 'b'")
119+
assert.strictEqual(cache.items['b'].next.key, 'd', "Should be 'd'")
120+
assert.strictEqual(cache.items['b'].prev, null, "Should be 'null'")
121+
cache.delete('e')
122+
assert.strictEqual(cache.first.key, 'b', "Should be 'b'")
123+
assert.strictEqual(cache.last.key, 'd', "Should be 'd'")
124+
assert.strictEqual(cache.size, 2, "Should be '2'")
125+
cache.get('b')
126+
assert.strictEqual(cache.first.key, 'b', "Should be 'b'")
127+
assert.strictEqual(cache.first.prev, null, "Should be 'null'")
128+
assert.strictEqual(cache.first.next.key, 'd', "Should be 'd'")
129+
assert.strictEqual(cache.last.key, 'd', "Should be 'd'")
130+
assert.strictEqual(cache.last.prev.key, 'b', "Should be 'b'")
131+
assert.strictEqual(cache.last.next, null, "Should be 'null'")
132+
assert.strictEqual(cache.size, 2, "Should be '2'")
133+
})
134+
135+
it('Adjusts first item after it is deleted', () => {
136+
expect(cache.first.key).toBe('b')
137+
expect(cache.last.key).toBe('e')
138+
expect(cache.size).toBe(4)
139+
140+
cache.delete(cache.first.key)
141+
142+
expect(cache.first.key).toBe('c')
143+
expect(cache.last.key).toBe('e')
144+
expect(cache.size).toBe(3)
145+
})
146+
})
147+
148+
describe('core', () => {
149+
it('It should handle a small evict', function () {
150+
assert.strictEqual(cache.first.key, 'b', "Should be 'b'")
151+
assert.strictEqual(cache.last.key, 'e', "Should be 'e'")
152+
assert.strictEqual(cache.size, 4, "Should be '4'")
153+
assert.strictEqual(cache.items['e'].next, null, "Should be 'null'")
154+
assert.strictEqual(cache.items['e'].prev.key, 'd', "Should be 'd'")
155+
assert.strictEqual(cache.items['d'].next.key, 'e', "Should be 'e'")
156+
assert.strictEqual(cache.items['d'].prev.key, 'c', "Should be 'c'")
157+
assert.strictEqual(cache.items['c'].next.key, 'd', "Should be 'd'")
158+
assert.strictEqual(cache.items['c'].prev.key, 'b', "Should be 'b'")
159+
assert.strictEqual(cache.items['b'].next.key, 'c', "Should be 'c'")
160+
assert.strictEqual(cache.items['b'].prev, null, "Should be 'null'")
161+
cache.delete('c')
162+
assert.strictEqual(cache.first.key, 'b', "Should be 'b'")
163+
assert.strictEqual(cache.last.key, 'e', "Should be 'e'")
164+
assert.strictEqual(cache.size, 3, "Should be '3'")
165+
assert.strictEqual(cache.items['e'].next, null, "Should be 'null'")
166+
assert.strictEqual(cache.items['e'].prev.key, 'd', "Should be 'd'")
167+
assert.strictEqual(cache.items['d'].next.key, 'e', "Should be 'e'")
168+
assert.strictEqual(cache.items['d'].prev.key, 'b', "Should be 'b'")
169+
assert.strictEqual(cache.items['b'].next.key, 'd', "Should be 'd'")
170+
assert.strictEqual(cache.items['b'].prev, null, "Should be 'null'")
171+
cache.delete('e')
172+
assert.strictEqual(cache.first.key, 'b', "Should be 'b'")
173+
assert.strictEqual(cache.last.key, 'd', "Should be 'd'")
174+
assert.strictEqual(cache.size, 2, "Should be '2'")
175+
cache.get('b')
176+
assert.strictEqual(cache.first.key, 'b', "Should be 'b'")
177+
assert.strictEqual(cache.first.prev, null, "Should be 'null'")
178+
assert.strictEqual(cache.first.next.key, 'd', "Should be 'd'")
179+
assert.strictEqual(cache.last.key, 'd', "Should be 'd'")
180+
assert.strictEqual(cache.last.prev.key, 'b', "Should be 'b'")
181+
assert.strictEqual(cache.last.next, null, "Should be 'null'")
182+
assert.strictEqual(cache.size, 2, "Should be '2'")
183+
})
184+
185+
it('It should handle an empty evict', function () {
186+
cache = fifoObject(1)
187+
assert.strictEqual(cache.first, null, "Should be 'null'")
188+
assert.strictEqual(cache.last, null, "Should be 'null'")
189+
assert.strictEqual(cache.size, 0, "Should be 'null'")
190+
cache.evict()
191+
assert.strictEqual(cache.first, null, "Should be 'null'")
192+
assert.strictEqual(cache.last, null, "Should be 'null'")
193+
assert.strictEqual(cache.size, 0, "Should be 'null'")
194+
})
195+
196+
it('It should expose expiration time', function () {
197+
cache = fifoObject(1, 6e4)
198+
cache.set(items[0], false)
199+
assert.strictEqual(typeof cache.expiresAt(items[0]), 'number', 'Should be a number')
200+
assert.strictEqual(cache.expiresAt('invalid'), undefined, 'Should be undefined')
201+
})
202+
203+
it('It should reset the TTL after resetting value', async () => {
204+
cache = fifoObject(1, 100)
205+
cache.set(items[0], false)
206+
const n1 = cache.expiresAt(items[0])
207+
assert.strictEqual(typeof n1, 'number', 'Should be a number')
208+
assert.strictEqual(n1 > 0, true, 'Should be greater than zero')
209+
await setTimeout(50)
210+
211+
cache.set(items[0], false)
212+
const n2 = cache.expiresAt(items[0])
213+
assert.strictEqual(typeof n2, 'number', 'Should be a number')
214+
assert.strictEqual(n2 > 0, true, 'Should be greater than zero')
215+
assert.strictEqual(n2 > n1, true, 'Should be greater than first expiration timestamp')
216+
})
217+
})
218+
})

toad-cache.d.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@ export function lruObject<T = any>(max?: number, ttl?: number): ToadCache<T>;
33
export function lru<T = any>(max?: number, ttl?: number): ToadCache<T>;
44

55
export function fifo<T = any>(max?: number, ttl?: number): ToadCache<T>;
6+
7+
export function fifoObject<T = any>(max?: number, ttl?: number): ToadCache<T>;
8+
69
export interface ToadCache<T> {
710
first: T | null;
811
last: T | null;

0 commit comments

Comments
 (0)