@open-mercato/cache 0.6.6-develop.5538.1.82478a59eb → 0.6.6-develop.5548.1.ce0de513a9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,5 @@
1
1
  import { matchCacheKeyPattern } from "../patterns.js";
2
+ const EXPIRED_SWEEP_WRITE_INTERVAL = 256;
2
3
  const DEFAULT_MEMORY_MAX_ENTRIES = 5e4;
3
4
  function normalizeMaxEntries(raw) {
4
5
  if (raw === void 0) return DEFAULT_MEMORY_MAX_ENTRIES;
@@ -10,6 +11,7 @@ function createMemoryStrategy(options) {
10
11
  const tagIndex = /* @__PURE__ */ new Map();
11
12
  const defaultTtl = options?.defaultTtl;
12
13
  const maxEntries = normalizeMaxEntries(options?.maxEntries);
14
+ let writesSinceSweep = 0;
13
15
  function isExpired(entry) {
14
16
  if (entry.expiresAt === null) return false;
15
17
  return Date.now() > entry.expiresAt;
@@ -60,6 +62,15 @@ function createMemoryStrategy(options) {
60
62
  }
61
63
  }
62
64
  }
65
+ function sweepExpiredIfDue() {
66
+ if (++writesSinceSweep < EXPIRED_SWEEP_WRITE_INTERVAL) return;
67
+ writesSinceSweep = 0;
68
+ for (const [key, entry] of store.entries()) {
69
+ if (isExpired(entry)) {
70
+ cleanupExpiredEntry(key, entry);
71
+ }
72
+ }
73
+ }
63
74
  const get = async (key, options2) => {
64
75
  const entry = store.get(key);
65
76
  if (!entry) return null;
@@ -90,6 +101,7 @@ function createMemoryStrategy(options) {
90
101
  };
91
102
  store.set(key, entry);
92
103
  addToTagIndex(key, tags);
104
+ sweepExpiredIfDue();
93
105
  evictIfNeeded();
94
106
  };
95
107
  const has = async (key) => {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../src/strategies/memory.ts"],
4
- "sourcesContent": ["import type { CacheStrategy, CacheEntry, CacheGetOptions, CacheSetOptions, CacheValue } from '../types'\nimport { matchCacheKeyPattern } from '../patterns'\n\n/**\n * Default upper bound on the number of entries a memory cache retains.\n * Bounds memory for long-lived (process-wide) instances; LRU eviction drops\n * the least-recently-used entries once the cap is exceeded. Override via the\n * `maxEntries` option (or `CACHE_MEMORY_MAX_ENTRIES`); a non-positive value\n * disables the cap (unbounded \u2014 only safe for short-lived instances).\n */\nexport const DEFAULT_MEMORY_MAX_ENTRIES = 50_000\n\nfunction normalizeMaxEntries(raw?: number): number {\n if (raw === undefined) return DEFAULT_MEMORY_MAX_ENTRIES\n if (!Number.isFinite(raw) || raw <= 0) return Number.POSITIVE_INFINITY\n return Math.floor(raw)\n}\n\n/**\n * In-memory cache strategy with tag support\n * Fast but data is lost when process restarts\n */\nexport function createMemoryStrategy(options?: { defaultTtl?: number; maxEntries?: number }): CacheStrategy {\n const store = new Map<string, CacheEntry>()\n const tagIndex = new Map<string, Set<string>>() // tag -> Set of keys\n const defaultTtl = options?.defaultTtl\n const maxEntries = normalizeMaxEntries(options?.maxEntries)\n\n function isExpired(entry: CacheEntry): boolean {\n if (entry.expiresAt === null) return false\n return Date.now() > entry.expiresAt\n }\n\n // LRU bookkeeping: re-insert on read so the most-recently-used entry moves\n // to the tail (Map preserves insertion order), mirroring the rbacDefaultCache\n // precedent; evictIfNeeded then drops from the head (least-recently-used).\n function touchKey(key: string, entry: CacheEntry): void {\n if (maxEntries === Number.POSITIVE_INFINITY) return\n store.delete(key)\n store.set(key, entry)\n }\n\n function evictIfNeeded(): void {\n if (maxEntries === Number.POSITIVE_INFINITY) return\n while (store.size > maxEntries) {\n const oldest = store.keys().next().value\n if (typeof oldest !== 'string') break\n const entry = store.get(oldest)\n store.delete(oldest)\n if (entry) removeFromTagIndex(oldest, entry.tags)\n }\n }\n\n function cleanupExpiredEntry(key: string, entry: CacheEntry): void {\n store.delete(key)\n // Remove from tag index\n for (const tag of entry.tags) {\n const keys = tagIndex.get(tag)\n if (keys) {\n keys.delete(key)\n if (keys.size === 0) {\n tagIndex.delete(tag)\n }\n }\n }\n }\n\n function addToTagIndex(key: string, tags: string[]): void {\n for (const tag of tags) {\n if (!tagIndex.has(tag)) {\n tagIndex.set(tag, new Set())\n }\n tagIndex.get(tag)!.add(key)\n }\n }\n\n function removeFromTagIndex(key: string, tags: string[]): void {\n for (const tag of tags) {\n const keys = tagIndex.get(tag)\n if (keys) {\n keys.delete(key)\n if (keys.size === 0) {\n tagIndex.delete(tag)\n }\n }\n }\n }\n\n const get = async (key: string, options?: CacheGetOptions): Promise<CacheValue | null> => {\n const entry = store.get(key)\n if (!entry) return null\n\n if (isExpired(entry)) {\n if (options?.returnExpired) {\n return entry.value\n }\n cleanupExpiredEntry(key, entry)\n return null\n }\n\n touchKey(key, entry)\n return entry.value\n }\n\n const set = async (key: string, value: CacheValue, options?: CacheSetOptions): Promise<void> => {\n // Remove old entry from tag index if it exists\n const oldEntry = store.get(key)\n if (oldEntry) {\n removeFromTagIndex(key, oldEntry.tags)\n }\n\n const ttl = options?.ttl ?? defaultTtl\n const tags = options?.tags || []\n const expiresAt = ttl ? Date.now() + ttl : null\n\n const entry: CacheEntry = {\n key,\n value,\n tags,\n expiresAt,\n createdAt: Date.now(),\n }\n\n store.set(key, entry)\n addToTagIndex(key, tags)\n evictIfNeeded()\n }\n\n const has = async (key: string): Promise<boolean> => {\n const entry = store.get(key)\n if (!entry) return false\n if (isExpired(entry)) {\n cleanupExpiredEntry(key, entry)\n return false\n }\n return true\n }\n\n const deleteKey = async (key: string): Promise<boolean> => {\n const entry = store.get(key)\n if (!entry) return false\n\n removeFromTagIndex(key, entry.tags)\n return store.delete(key)\n }\n\n const deleteByTags = async (tags: string[]): Promise<number> => {\n const keysToDelete = new Set<string>()\n\n // Collect all keys that have any of the specified tags\n for (const tag of tags) {\n const keys = tagIndex.get(tag)\n if (keys) {\n for (const key of keys) {\n keysToDelete.add(key)\n }\n }\n }\n\n // Delete all collected keys\n let deleted = 0\n for (const key of keysToDelete) {\n const success = await deleteKey(key)\n if (success) deleted++\n }\n\n return deleted\n }\n\n const clear = async (): Promise<number> => {\n const size = store.size\n store.clear()\n tagIndex.clear()\n return size\n }\n\n const keys = async (pattern?: string): Promise<string[]> => {\n const allKeys = Array.from(store.keys())\n if (!pattern) return allKeys\n return allKeys.filter((key) => matchCacheKeyPattern(key, pattern))\n }\n\n const stats = async (): Promise<{ size: number; expired: number }> => {\n let expired = 0\n for (const entry of store.values()) {\n if (isExpired(entry)) {\n expired++\n }\n }\n return { size: store.size, expired }\n }\n\n const cleanup = async (): Promise<number> => {\n let removed = 0\n for (const [key, entry] of store.entries()) {\n if (isExpired(entry)) {\n cleanupExpiredEntry(key, entry)\n removed++\n }\n }\n return removed\n }\n\n return {\n get,\n set,\n has,\n delete: deleteKey,\n deleteByTags,\n clear,\n keys,\n stats,\n cleanup,\n }\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import type { CacheStrategy, CacheEntry, CacheGetOptions, CacheSetOptions, CacheValue } from '../types'\nimport { matchCacheKeyPattern } from '../patterns'\n\nconst EXPIRED_SWEEP_WRITE_INTERVAL = 256\n\n/**\n * Default upper bound on the number of entries a memory cache retains.\n * Bounds memory for long-lived (process-wide) instances; LRU eviction drops\n * the least-recently-used entries once the cap is exceeded. Override via the\n * `maxEntries` option (or `CACHE_MEMORY_MAX_ENTRIES`); a non-positive value\n * disables the cap (unbounded \u2014 only safe for short-lived instances).\n */\nexport const DEFAULT_MEMORY_MAX_ENTRIES = 50_000\n\nfunction normalizeMaxEntries(raw?: number): number {\n if (raw === undefined) return DEFAULT_MEMORY_MAX_ENTRIES\n if (!Number.isFinite(raw) || raw <= 0) return Number.POSITIVE_INFINITY\n return Math.floor(raw)\n}\n\n/**\n * In-memory cache strategy with tag support.\n * Fast but data is lost when process restarts.\n *\n * Bounded by an LRU cap (`maxEntries`, default {@link DEFAULT_MEMORY_MAX_ENTRIES},\n * env-tunable via `CACHE_MEMORY_MAX_ENTRIES` resolved in the cache service) so a\n * process-shared instance (OM_BOOTSTRAP_CACHE, long-lived workers, memory-backed\n * CRUD list cache) cannot grow without limit on user-controllable key\n * cardinality. Recency is refreshed on read (Map re-insertion), the oldest\n * entries are evicted on write, and expired entries are reclaimed by an\n * amortized sweep every N writes \u2014 no per-instance timer, so the per-request\n * default stays leak-free (a per-instance setInterval would pin every request's\n * Maps for the process lifetime).\n */\nexport function createMemoryStrategy(options?: { defaultTtl?: number; maxEntries?: number }): CacheStrategy {\n const store = new Map<string, CacheEntry>()\n const tagIndex = new Map<string, Set<string>>() // tag -> Set of keys\n const defaultTtl = options?.defaultTtl\n const maxEntries = normalizeMaxEntries(options?.maxEntries)\n let writesSinceSweep = 0\n\n function isExpired(entry: CacheEntry): boolean {\n if (entry.expiresAt === null) return false\n return Date.now() > entry.expiresAt\n }\n\n // LRU bookkeeping: re-insert on read so the most-recently-used entry moves\n // to the tail (Map preserves insertion order), mirroring the rbacDefaultCache\n // precedent; evictIfNeeded then drops from the head (least-recently-used).\n function touchKey(key: string, entry: CacheEntry): void {\n if (maxEntries === Number.POSITIVE_INFINITY) return\n store.delete(key)\n store.set(key, entry)\n }\n\n function evictIfNeeded(): void {\n if (maxEntries === Number.POSITIVE_INFINITY) return\n while (store.size > maxEntries) {\n const oldest = store.keys().next().value\n if (typeof oldest !== 'string') break\n const entry = store.get(oldest)\n store.delete(oldest)\n if (entry) removeFromTagIndex(oldest, entry.tags)\n }\n }\n\n function cleanupExpiredEntry(key: string, entry: CacheEntry): void {\n store.delete(key)\n // Remove from tag index\n for (const tag of entry.tags) {\n const keys = tagIndex.get(tag)\n if (keys) {\n keys.delete(key)\n if (keys.size === 0) {\n tagIndex.delete(tag)\n }\n }\n }\n }\n\n function addToTagIndex(key: string, tags: string[]): void {\n for (const tag of tags) {\n if (!tagIndex.has(tag)) {\n tagIndex.set(tag, new Set())\n }\n tagIndex.get(tag)!.add(key)\n }\n }\n\n function removeFromTagIndex(key: string, tags: string[]): void {\n for (const tag of tags) {\n const keys = tagIndex.get(tag)\n if (keys) {\n keys.delete(key)\n if (keys.size === 0) {\n tagIndex.delete(tag)\n }\n }\n }\n }\n\n // Amortized reclamation of already-expired entries. Runs every N writes\n // instead of on a timer, keeping the no-shared-state property that makes the\n // per-request default safe. Independent of the LRU cap so expired-but-cold\n // entries are reclaimed even when the store stays under `maxEntries`.\n function sweepExpiredIfDue(): void {\n if (++writesSinceSweep < EXPIRED_SWEEP_WRITE_INTERVAL) return\n writesSinceSweep = 0\n for (const [key, entry] of store.entries()) {\n if (isExpired(entry)) {\n cleanupExpiredEntry(key, entry)\n }\n }\n }\n\n const get = async (key: string, options?: CacheGetOptions): Promise<CacheValue | null> => {\n const entry = store.get(key)\n if (!entry) return null\n\n if (isExpired(entry)) {\n if (options?.returnExpired) {\n return entry.value\n }\n cleanupExpiredEntry(key, entry)\n return null\n }\n\n touchKey(key, entry)\n return entry.value\n }\n\n const set = async (key: string, value: CacheValue, options?: CacheSetOptions): Promise<void> => {\n // Remove old entry from tag index if it exists\n const oldEntry = store.get(key)\n if (oldEntry) {\n removeFromTagIndex(key, oldEntry.tags)\n }\n\n const ttl = options?.ttl ?? defaultTtl\n const tags = options?.tags || []\n const expiresAt = ttl ? Date.now() + ttl : null\n\n const entry: CacheEntry = {\n key,\n value,\n tags,\n expiresAt,\n createdAt: Date.now(),\n }\n\n store.set(key, entry)\n addToTagIndex(key, tags)\n sweepExpiredIfDue()\n evictIfNeeded()\n }\n\n const has = async (key: string): Promise<boolean> => {\n const entry = store.get(key)\n if (!entry) return false\n if (isExpired(entry)) {\n cleanupExpiredEntry(key, entry)\n return false\n }\n return true\n }\n\n const deleteKey = async (key: string): Promise<boolean> => {\n const entry = store.get(key)\n if (!entry) return false\n\n removeFromTagIndex(key, entry.tags)\n return store.delete(key)\n }\n\n const deleteByTags = async (tags: string[]): Promise<number> => {\n const keysToDelete = new Set<string>()\n\n // Collect all keys that have any of the specified tags\n for (const tag of tags) {\n const keys = tagIndex.get(tag)\n if (keys) {\n for (const key of keys) {\n keysToDelete.add(key)\n }\n }\n }\n\n // Delete all collected keys\n let deleted = 0\n for (const key of keysToDelete) {\n const success = await deleteKey(key)\n if (success) deleted++\n }\n\n return deleted\n }\n\n const clear = async (): Promise<number> => {\n const size = store.size\n store.clear()\n tagIndex.clear()\n return size\n }\n\n const keys = async (pattern?: string): Promise<string[]> => {\n const allKeys = Array.from(store.keys())\n if (!pattern) return allKeys\n return allKeys.filter((key) => matchCacheKeyPattern(key, pattern))\n }\n\n const stats = async (): Promise<{ size: number; expired: number }> => {\n let expired = 0\n for (const entry of store.values()) {\n if (isExpired(entry)) {\n expired++\n }\n }\n return { size: store.size, expired }\n }\n\n const cleanup = async (): Promise<number> => {\n let removed = 0\n for (const [key, entry] of store.entries()) {\n if (isExpired(entry)) {\n cleanupExpiredEntry(key, entry)\n removed++\n }\n }\n return removed\n }\n\n return {\n get,\n set,\n has,\n delete: deleteKey,\n deleteByTags,\n clear,\n keys,\n stats,\n cleanup,\n }\n}\n"],
5
+ "mappings": "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",
6
6
  "names": ["keys", "options"]
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/cache",
3
- "version": "0.6.6-develop.5538.1.82478a59eb",
3
+ "version": "0.6.6-develop.5548.1.ce0de513a9",
4
4
  "description": "Multi-strategy cache service with tag-based invalidation support",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -233,6 +233,31 @@ describe('Memory Cache Strategy', () => {
233
233
  })
234
234
  })
235
235
 
236
+ describe('Amortized expired-entry sweep', () => {
237
+ it('should reclaim expired entries via the amortized sweep on writes', async () => {
238
+ jest.useFakeTimers()
239
+ jest.setSystemTime(new Date('2025-01-01T00:00:00.000Z'))
240
+ try {
241
+ const bounded = createMemoryStrategy()
242
+ for (let i = 0; i < 200; i++) {
243
+ await bounded.set(`expiring:${i}`, i, { ttl: 50 })
244
+ }
245
+ // All 200 entries are now expired but still resident (no sweep yet).
246
+ await jest.advanceTimersByTimeAsync(100)
247
+ expect((await bounded.stats()).size).toBe(200)
248
+
249
+ // Cross the sweep interval (256 cumulative writes) with fresh entries.
250
+ for (let i = 0; i < 60; i++) {
251
+ await bounded.set(`fresh:${i}`, i)
252
+ }
253
+ // The expired cohort was reclaimed; only the 60 fresh entries remain.
254
+ expect((await bounded.stats()).size).toBe(60)
255
+ } finally {
256
+ jest.useRealTimers()
257
+ }
258
+ })
259
+ })
260
+
236
261
  describe('Statistics', () => {
237
262
  beforeEach(() => {
238
263
  jest.useFakeTimers()
@@ -1,6 +1,8 @@
1
1
  import type { CacheStrategy, CacheEntry, CacheGetOptions, CacheSetOptions, CacheValue } from '../types'
2
2
  import { matchCacheKeyPattern } from '../patterns'
3
3
 
4
+ const EXPIRED_SWEEP_WRITE_INTERVAL = 256
5
+
4
6
  /**
5
7
  * Default upper bound on the number of entries a memory cache retains.
6
8
  * Bounds memory for long-lived (process-wide) instances; LRU eviction drops
@@ -17,14 +19,25 @@ function normalizeMaxEntries(raw?: number): number {
17
19
  }
18
20
 
19
21
  /**
20
- * In-memory cache strategy with tag support
21
- * Fast but data is lost when process restarts
22
+ * In-memory cache strategy with tag support.
23
+ * Fast but data is lost when process restarts.
24
+ *
25
+ * Bounded by an LRU cap (`maxEntries`, default {@link DEFAULT_MEMORY_MAX_ENTRIES},
26
+ * env-tunable via `CACHE_MEMORY_MAX_ENTRIES` resolved in the cache service) so a
27
+ * process-shared instance (OM_BOOTSTRAP_CACHE, long-lived workers, memory-backed
28
+ * CRUD list cache) cannot grow without limit on user-controllable key
29
+ * cardinality. Recency is refreshed on read (Map re-insertion), the oldest
30
+ * entries are evicted on write, and expired entries are reclaimed by an
31
+ * amortized sweep every N writes — no per-instance timer, so the per-request
32
+ * default stays leak-free (a per-instance setInterval would pin every request's
33
+ * Maps for the process lifetime).
22
34
  */
23
35
  export function createMemoryStrategy(options?: { defaultTtl?: number; maxEntries?: number }): CacheStrategy {
24
36
  const store = new Map<string, CacheEntry>()
25
37
  const tagIndex = new Map<string, Set<string>>() // tag -> Set of keys
26
38
  const defaultTtl = options?.defaultTtl
27
39
  const maxEntries = normalizeMaxEntries(options?.maxEntries)
40
+ let writesSinceSweep = 0
28
41
 
29
42
  function isExpired(entry: CacheEntry): boolean {
30
43
  if (entry.expiresAt === null) return false
@@ -86,6 +99,20 @@ export function createMemoryStrategy(options?: { defaultTtl?: number; maxEntries
86
99
  }
87
100
  }
88
101
 
102
+ // Amortized reclamation of already-expired entries. Runs every N writes
103
+ // instead of on a timer, keeping the no-shared-state property that makes the
104
+ // per-request default safe. Independent of the LRU cap so expired-but-cold
105
+ // entries are reclaimed even when the store stays under `maxEntries`.
106
+ function sweepExpiredIfDue(): void {
107
+ if (++writesSinceSweep < EXPIRED_SWEEP_WRITE_INTERVAL) return
108
+ writesSinceSweep = 0
109
+ for (const [key, entry] of store.entries()) {
110
+ if (isExpired(entry)) {
111
+ cleanupExpiredEntry(key, entry)
112
+ }
113
+ }
114
+ }
115
+
89
116
  const get = async (key: string, options?: CacheGetOptions): Promise<CacheValue | null> => {
90
117
  const entry = store.get(key)
91
118
  if (!entry) return null
@@ -123,6 +150,7 @@ export function createMemoryStrategy(options?: { defaultTtl?: number; maxEntries
123
150
 
124
151
  store.set(key, entry)
125
152
  addToTagIndex(key, tags)
153
+ sweepExpiredIfDue()
126
154
  evictIfNeeded()
127
155
  }
128
156