Skip to content

Commit 49b3fbe

Browse files
committed
clean up Timer objects when using ttlAutopurge
This prevents a possible runaway resource utilization issue when using ttlAutopurge. If a single key is written to repetedly, then many NodeJS.Timer objects will be created (or equivalent in the browser), and they are not unscheduled when no longer needed. This results in many checks for staleness happening unnecessarily, and so on. Now, when a key is overwritten, set with no TTL, or any other behavior that will mean that autopurge is no longer necessary, the timer is cleared and deleted.
1 parent b7b7c4e commit 49b3fbe

File tree

2 files changed

+87
-1
lines changed

2 files changed

+87
-1
lines changed

src/index.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1253,6 +1253,7 @@ export class LRUCache<K extends {}, V extends {}, FC = unknown> {
12531253
#sizes?: ZeroArray
12541254
#starts?: ZeroArray
12551255
#ttls?: ZeroArray
1256+
#autopurgeTimers?: (undefined | ReturnType<typeof setTimeout>)[]
12561257

12571258
#hasDispose: boolean
12581259
#hasFetchMethod: boolean
@@ -1277,6 +1278,7 @@ export class LRUCache<K extends {}, V extends {}, FC = unknown> {
12771278
// properties
12781279
starts: c.#starts,
12791280
ttls: c.#ttls,
1281+
autopurgeTimers: c.#autopurgeTimers,
12801282
sizes: c.#sizes,
12811283
keyMap: c.#keyMap as Map<K, number>,
12821284
keyList: c.#keyList,
@@ -1553,11 +1555,20 @@ export class LRUCache<K extends {}, V extends {}, FC = unknown> {
15531555
const starts = new ZeroArray(this.#max)
15541556
this.#ttls = ttls
15551557
this.#starts = starts
1558+
const purgeTimers = this.ttlAutopurge ? new Array<undefined | ReturnType<typeof setTimeout>>(this.#max) : undefined
1559+
this.#autopurgeTimers = purgeTimers
15561560

15571561
this.#setItemTTL = (index, ttl, start = this.#perf.now()) => {
15581562
starts[index] = ttl !== 0 ? start : 0
15591563
ttls[index] = ttl
1560-
if (ttl !== 0 && this.ttlAutopurge) {
1564+
// clear out the purge timer if we're setting TTL to 0, and
1565+
// previously had a ttl purge timer running, so it doesn't
1566+
// fire unnecessarily.
1567+
if (purgeTimers?.[index]) {
1568+
clearTimeout(purgeTimers[index])
1569+
purgeTimers[index] = undefined
1570+
}
1571+
if (ttl !== 0 && purgeTimers) {
15611572
const t = setTimeout(() => {
15621573
if (this.#isStale(index)) {
15631574
this.#delete(this.#keyList[index] as K, 'expire')
@@ -1569,6 +1580,7 @@ export class LRUCache<K extends {}, V extends {}, FC = unknown> {
15691580
t.unref()
15701581
}
15711582
/* c8 ignore stop */
1583+
purgeTimers[index] = t
15721584
}
15731585
}
15741586

@@ -2897,6 +2909,10 @@ export class LRUCache<K extends {}, V extends {}, FC = unknown> {
28972909
if (this.#size !== 0) {
28982910
const index = this.#keyMap.get(k)
28992911
if (index !== undefined) {
2912+
if (this.#autopurgeTimers?.[index]) {
2913+
clearTimeout(this.#autopurgeTimers?.[index])
2914+
this.#autopurgeTimers[index] = undefined
2915+
}
29002916
deleted = true
29012917
if (this.#size === 1) {
29022918
this.#clear(reason)
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
if (typeof performance === 'undefined') {
2+
Object.assign(global, {
3+
performance: (await import('perf_hooks')).performance,
4+
})
5+
}
6+
7+
import t from 'tap'
8+
9+
// verify that a large number of ttlAutopurge timeouts won't
10+
// result in a resource exhaustion problem due to timers being
11+
// created.
12+
13+
const clock = t.clock
14+
clock.advance(1)
15+
16+
let timeouts = 0
17+
const origST = global.setTimeout
18+
const newST = function (this: any, ...args: Parameters<typeof setTimeout>) {
19+
++timeouts
20+
return origST.apply(this, args)
21+
}
22+
let clears = 0
23+
const origCT = global.clearTimeout
24+
const newCT = function (this: any, ...args: Parameters<typeof clearTimeout>) {
25+
++clears
26+
return origCT.apply(this, args)
27+
}
28+
29+
//@ts-ignore
30+
global.setTimeout = newST
31+
//@ts-ignore
32+
global.clearTimeout = newCT
33+
34+
35+
const { LRUCache: LRU } = await import('../dist/esm/index.js')
36+
37+
const cache = new LRU<string, number>({ ttl: 10, ttlAutopurge: true })
38+
39+
40+
const N = 10//_000
41+
for (let i = 0; i < N; i++) {
42+
cache.set('hot-key', i)
43+
}
44+
t.equal(timeouts, N)
45+
t.equal(clears, N + 1)
46+
47+
timeouts = 0
48+
clears = 0
49+
cache.set('hot-key', 99, { ttl: 0 })
50+
const clearsAfterSetTTL0 = clears
51+
const timeoutsAfterSetTTL0 = timeouts
52+
53+
t.equal(timeoutsAfterSetTTL0, 0)
54+
t.equal(clearsAfterSetTTL0, 1)
55+
56+
timeouts = 0
57+
clears = 0
58+
cache.set('hot-key', 100)
59+
const clearsAfterSetTTLDef = clears
60+
const timeoutsAfterSetTTLDef = timeouts
61+
t.equal(clearsAfterSetTTLDef, 0)
62+
t.equal(timeoutsAfterSetTTLDef, 1)
63+
64+
timeouts = 0
65+
clears = 0
66+
cache.delete('hot-key')
67+
const clearsAfterDelete = clears
68+
const timeoutsAfterDelete = timeouts
69+
t.equal(clearsAfterDelete, 1)
70+
t.equal(timeoutsAfterDelete, 0)

0 commit comments

Comments
 (0)