Skip to content

Commit 88ae941

Browse files
thekauerisaacs
authored andcommitted
fix: reschedule autopurge timer when updateAgeOnGet resets TTL start
When updateAgeOnGet (or updateAgeOnHas) resets the start time via #updateItemAge, the one-shot autopurge timer scheduled by #setItemTTL is not rescheduled. The timer fires at the original deadline, finds the entry non-stale (start was reset), does nothing, and no new timer is ever created -- making the entry immortal. Fix: reschedule the autopurge timer in #updateItemAge when one exists, following the same pattern as #setItemTTL. PR-URL: #392 Credit: @thekauer Close: #392 Reviewed-by: @isaacs
1 parent 757c157 commit 88ae941

2 files changed

Lines changed: 70 additions & 0 deletions

File tree

src/index.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1569,6 +1569,22 @@ export class LRUCache<K extends {}, V extends {}, FC = unknown> {
15691569

15701570
this.#updateItemAge = index => {
15711571
starts[index] = ttls[index] !== 0 ? this.#perf.now() : 0
1572+
// reschedule autopurge timer so it doesn't find the entry non-stale and go silent
1573+
if (purgeTimers?.[index]) {
1574+
clearTimeout(purgeTimers[index])
1575+
const ttl = ttls[index] as number
1576+
const t = setTimeout(() => {
1577+
if (this.#isStale(index)) {
1578+
this.#delete(this.#keyList[index] as K, 'expire')
1579+
}
1580+
}, ttl + 1)
1581+
/* c8 ignore start */
1582+
if (t.unref) {
1583+
t.unref()
1584+
}
1585+
/* c8 ignore stop */
1586+
purgeTimers[index] = t
1587+
}
15721588
}
15731589

15741590
this.#statusTTL = (status, index) => {

test/ttl.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -480,6 +480,60 @@ const runTests = (LRU: typeof LRUCache, t: Test) => {
480480
t.end()
481481
})
482482

483+
t.test('updateAgeOnGet reschedules ttlAutopurge timer', t => {
484+
const c = new LRU({
485+
ttl: 100,
486+
ttlAutopurge: true,
487+
updateAgeOnGet: true,
488+
ttlResolution: 0,
489+
})
490+
// set an entry
491+
c.set('a', 1)
492+
t.equal(c.size, 1)
493+
494+
// get it before original TTL expires — this should refresh the TTL
495+
clock.advance(80)
496+
t.equal(c.get('a'), 1, 'still alive before original TTL')
497+
t.equal(c.size, 1, 'size is 1 after get')
498+
499+
// advance past the original TTL (80 + 30 = 110 from set, but only 30 from get)
500+
clock.advance(30)
501+
t.equal(c.size, 1, 'entry survives past original TTL because get refreshed it')
502+
t.equal(c.get('a'), 1, 'entry is still retrievable')
503+
504+
// now let the refreshed TTL expire without any more gets
505+
// after the second get above, the TTL was refreshed again
506+
// advance past that refreshed TTL (100 + 1 for the timer margin)
507+
clock.advance(102)
508+
t.equal(c.size, 0, 'entry is autopurged after refreshed TTL expires')
509+
t.equal(c.get('a'), undefined, 'entry is gone')
510+
511+
t.end()
512+
})
513+
514+
t.test('updateAgeOnGet + ttlAutopurge: entry eventually purged if not re-accessed', t => {
515+
const c = new LRU({
516+
ttl: 50,
517+
ttlAutopurge: true,
518+
updateAgeOnGet: true,
519+
ttlResolution: 0,
520+
})
521+
c.set('b', 2)
522+
t.equal(c.size, 1)
523+
524+
// access once before TTL expires, refreshing the timer
525+
clock.advance(30)
526+
t.equal(c.get('b'), 2, 'alive before TTL')
527+
528+
// do NOT access again — let the refreshed TTL expire
529+
// the refreshed TTL starts at t=30, expires at t=30+50+1=81
530+
clock.advance(52)
531+
t.equal(c.size, 0, 'entry autopurged after refreshed TTL with no further access')
532+
t.equal(c.get('b'), undefined, 'entry is gone')
533+
534+
t.end()
535+
})
536+
483537
t.end()
484538
}
485539

0 commit comments

Comments
 (0)