Skip to content

feat(cache): add optional maxEntries LRU eviction to InMemoryCacheAdapter#410

Merged
Lakes41 merged 1 commit into
Adamantine-guild:mainfrom
XxHugheadxX:feat/386-lru-eviction
Jul 27, 2026
Merged

feat(cache): add optional maxEntries LRU eviction to InMemoryCacheAdapter#410
Lakes41 merged 1 commit into
Adamantine-guild:mainfrom
XxHugheadxX:feat/386-lru-eviction

Conversation

@XxHugheadxX

Copy link
Copy Markdown
Contributor

Closes #386.

This is less a new feature than a documentation promise being made true.
docs/cache-adapters.md has been telling users that a ttl: undefined entry is stored
"until explicitly deleted or evicted by LRU" — but InMemoryCacheAdapter had no eviction
of any kind, and no cap. An unbounded Map in a long-lived process whose keys are
per-wallet or per-guild grows with the user base; that is a slow memory leak wearing a
cache's clothes.

The trap this issue is really about

In JavaScript, map.set(k, v) on a key that already exists keeps its original insertion
position. So the obvious way to refresh recency — just set it again — leaves the iteration
order as FIFO, and the simple test cases still pass. The eviction only misbehaves in the
case that actually matters: reading an old key and expecting it to survive.

I measured both implementations against the same scenario (maxEntries: 2; set a, set
b, get a, set c):

set() alone   -> [b, c]   'a' survives: false   <- silently FIFO
delete + set  -> [a, c]   'a' survives: true    <- actual LRU

refreshes recency on read, so a re-read entry outlives a newer one is the test that
separates them; every other eviction test passes under both. Worth looking at that one
first.

A second ordering detail: inside get, the TTL sweep runs before the recency refresh,
so an expired entry is dropped rather than promoted to most-recently-used.

Design decisions worth reviewing

The recency refresh in get is gated on maxEntries !== undefined. Without a cap,
mutating the Map on every read is pure cost for no benefit. More importantly it makes the
"identical behaviour when maxEntries is omitted" criterion demonstrable rather than
approximate — the bookkeeping does not execute at all on that path.

set re-inserts unconditionally, cap or no cap: overwriting a key counts as recent use,
and keeping the invariant in one place is cheaper than branching.

evictIfNeeded never throws — it runs on the write path, where a failure would break an
API call the cache exists only to accelerate. The constructor does throw on an invalid
maxEntries: that is user configuration, not the eviction path, and swallowing
maxEntries: -5 silently would be worse. This follows the existing convention at
httpClient.ts:114, which throws GuildPassConfigError / INVALID_CONFIG for an invalid
retryableStatuses.

size() counts entries whose TTL has elapsed but which have not been swept yet. Expiry
is lazy and happens on read, so an untouched expired entry keeps its slot until it is read,
overwritten, or evicted. Calling this out explicitly because it looks like a bug if you do
not know the adapter is lazily expired — it is documented on the method and in
docs/cache-adapters.md.

Notes on the issue text and the file layout

The issue refers to src/cache/InMemoryCacheAdapter.ts. The class actually lives in
src/cache/cache.types.ts:79 — there is no separate file, and src/cache/ contains only
that module. The issue hedges with "or equivalent", so this is a heads-up rather than a
correction.

InMemoryCacheAdapterOptions is exported so consumers can type their options. No change to
src/index.ts was needed: line 6 already does export * from './cache/cache.types'.

CacheAdapter itself is untouched. size() is on the class, not the interface — adding
it to the interface would break every third-party adapter.

API report

api-report/guildpass-sdk.api.md was regenerated with pnpm build && pnpm api-report, not
hand-edited. The diff is exactly the three additions:

 export class InMemoryCacheAdapter implements CacheAdapter {
+    constructor(options?: InMemoryCacheAdapterOptions);
     clear(): Promise<void>;
...
     set<T>(key: string, value: T, ttl?: number): Promise<void>;
+    size(): number;
+}
+
+// @public
+export interface InMemoryCacheAdapterOptions {
+    maxEntries?: number;
 }

I deliberately left temp/guildpass-sdk.api.md out of this commit. pnpm api-report
regenerates it too, but the committed copy is already stale against upstream's own source,
so regenerating produces ~450 lines of drift unrelated to this change. The gated artifact is
api-report/guildpass-sdk.api.mdscripts/check-api-diff.mjs:18 names it explicitly and
nothing in the tooling reads temp/.

Tests

+8, in a sibling describe next to the existing InMemoryCacheAdapter specific details
block. No existing test modified.

Eviction order and size(); recency on read (the FIFO discriminator above); recency on
overwrite; unbounded behaviour with 1000 entries when maxEntries is omitted; TTL × LRU
interaction — an expired entry is swept rather than promoted, size() drops, and the freed
slot is reused instead of evicting a live sibling; the maxEntries: 1 edge case, where a
write must not evict the entry it just made; constructor rejection of 0, -5 and 1.5;
and deleteByPrefix still working with a cap active, since that is the path
GuildPassClient.invalidateWalletCache depends on and a regression there would silently
stop cache invalidation.

Verification

before:  Test Files  2 failed | 65 passed (67)   Tests  4 failed | 1369 passed (1373)
after:   Test Files  2 failed | 65 passed (67)   Tests  4 failed | 1377 passed (1381)

pnpm build exits 0. ESLint reports 0 errors on the touched files (src/cache/cache.types.ts
is warning-free; the 27 no-explicit-any warnings in tests/cache.test.ts are all
pre-existing, on lines well below the added block).

Pre-existing failures, not attributable to this PR

Identical before and after:

  • tests/api-report-diff.test.ts — collection error: it imports
    ../scripts/lib/api-report-diff.mjs, which does not exist in the repo.
  • tests/compat/edge/edge.test.ts — 4 assertions under @edge-runtime/vm.

Con esto cierras los tres. Dime el número del PR cuando lo abras.

✻ Baked for 1m 19s

❯ $ git push -u origin feat/386-lru-eviction
Enumerating objects: 23, done.
Counting objects: 100% (23/23), done.
Delta compression using up to 12 threads
Compressing objects: 100% (10/10), done.
Writing objects: 100% (12/12), 5.14 KiB | 1.71 MiB/s, done.
Total 12 (delta 9), reused 0 (delta 0), pack-reused 0 (from 0)
remote: Resolving deltas: 100% (9/9), completed with 9 local objects.
remote:
remote: Create a pull request for 'feat/386-lru-eviction' on GitHub by visiting:
remote: https://github.com/XxHugheadxX/guildpass-sdk/pull/new/feat/386-lru-eviction
remote:
To https://github.com/XxHugheadxX/guildpass-sdk

  • [new branch] feat/386-lru-eviction -> feat/386-lru-eviction
    branch 'feat/386-lru-eviction' set up to track 'origin/feat/386-lru-eviction'.

@Lakes41
Lakes41 merged commit 35ae33b into Adamantine-guild:main Jul 27, 2026
1 check passed
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.

Implement max-size LRU eviction for InMemoryCacheAdapter

2 participants