Skip to content

fix: reschedule autopurge timer when updateAgeOnGet resets TTL start#392

Closed
thekauer wants to merge 1 commit intoisaacs:mainfrom
thekauer:fix/updateAgeOnGet-autopurge-timer
Closed

fix: reschedule autopurge timer when updateAgeOnGet resets TTL start#392
thekauer wants to merge 1 commit intoisaacs:mainfrom
thekauer:fix/updateAgeOnGet-autopurge-timer

Conversation

@thekauer
Copy link
Copy Markdown
Contributor

@thekauer thekauer commented Mar 13, 2026

I am not sure if this is the correct way to report this, but this is what I've found. Feel free to close this if this is inappropriate.

Bug

When using updateAgeOnGet: true with ttlAutopurge: true, entries that are .get()'d at least once become permanently stuck in the cache. The autopurge timer fires but finds the entry "not stale" (because updateAgeOnGet reset the start time), and no replacement timer is ever scheduled.

Reproduction

import { LRUCache } from 'lru-cache';

const cache = new LRUCache({
  max: 100,
  ttl: 2000,
  ttlAutopurge: true,
  updateAgeOnGet: true,
});

cache.set('A', { name: 'A' });
cache.set('B', { name: 'B' });

// One .get() is enough to make an entry immortal
setTimeout(() => cache.get('A'), 500);

// B (never .get()'d) correctly expires at ~2s
// A stays forever - .has('A') returns false but size never drops
setTimeout(() => {
  console.log(`size=${cache.size}, has(A)=${cache.has('A')}`);
  // size=1, has(A)=false - entry is stale but never purged
}, 15000);

Output:

size=1, has(A)=false

Root cause

#setItemTTL (called on .set()) is the only place that schedules an autopurge timer. #updateItemAge (called on .get() when updateAgeOnGet: true) only resets the start time without rescheduling the timer:

this.#updateItemAge = index => {
  starts[index] = ttls[index] !== 0 ? this.#perf.now() : 0
  // no timer rescheduled
}

The sequence:

  1. .set('A') at t=0 - schedules setTimeout(fn, 2001)
  2. .get('A') at t=500ms - starts[0] = now(), no timer change
  3. Timer fires at t=2001ms - now() - starts[0] = ~1500ms < 2000ms, not stale, does nothing
  4. No timer ever runs again, entry is immortal

Fix

Reschedule the autopurge timer in #updateItemAge when one exists, following the same pattern as #setItemTTL. The fix only activates when all three conditions are true:

  • ttlAutopurge: true (autopurge timers exist)
  • updateAgeOnGet: true or updateAgeOnHas: true (age is being updated)
  • The entry was previously .set() with a TTL (timer exists for that index)

All 17,670 existing tests pass with this change. Two new test cases are included.

Impact

We discovered this in production where ~160 connection pool objects were permanently stuck in an LRU cache despite a 10-minute TTL, causing ~1.5Gi of unreclaimable native memory per Node.js process.

Workaround

Use .set(key, existingValue) instead of relying on updateAgeOnGet, which properly calls #setItemTTL and reschedules the timer. Set noDisposeOnSet: true to prevent the dispose callback from firing on re-set.

Version

lru-cache 11.2.4 (also verified on 11.2.6)

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.
@isaacs isaacs closed this in 88ae941 Mar 13, 2026
@isaacs
Copy link
Copy Markdown
Owner

isaacs commented Mar 13, 2026

Good find, thanks!

I noticed that the method was functionally identical to the other code setting the ttlAutopurge timer, so abstracted them out, but to answer your question, yes, sending a test with a patch that fixes it is 100% correct and appropriate and much appreciated :)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants