@zudojs/cache 0.1.0 → 1.0.0

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.
Files changed (89) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +90 -14
  3. package/dist/cache.d.ts +107 -18
  4. package/dist/cache.js +393 -33
  5. package/dist/constants.d.ts +46 -12
  6. package/dist/constants.js +47 -19
  7. package/dist/index.d.ts +10 -9
  8. package/dist/index.js +9 -8
  9. package/dist/invalidation.d.ts +23 -16
  10. package/dist/invalidation.js +43 -33
  11. package/dist/key-builder.d.ts +15 -0
  12. package/dist/key-builder.js +43 -3
  13. package/dist/lock.d.ts +36 -2
  14. package/dist/lock.js +160 -30
  15. package/dist/memory.d.ts +63 -7
  16. package/dist/memory.js +223 -39
  17. package/dist/metrics.d.ts +10 -0
  18. package/dist/metrics.js +49 -4
  19. package/dist/serializer.d.ts +19 -10
  20. package/dist/serializer.js +88 -21
  21. package/dist/store.d.ts +21 -11
  22. package/dist/store.js +125 -65
  23. package/dist/tags.d.ts +32 -12
  24. package/dist/tags.js +113 -33
  25. package/dist/types-adapter.d.ts +23 -9
  26. package/dist/types-config.d.ts +46 -9
  27. package/dist/types-health.d.ts +5 -2
  28. package/dist/types-keys.d.ts +8 -4
  29. package/dist/types-lock.d.ts +8 -0
  30. package/dist/types-operations.d.ts +13 -20
  31. package/dist/types-results.d.ts +7 -1
  32. package/dist/types-tags.d.ts +9 -0
  33. package/dist/types-utility.d.ts +6 -10
  34. package/dist/types-values.d.ts +9 -14
  35. package/dist/types.d.ts +6 -6
  36. package/dist/utils.d.ts +65 -0
  37. package/dist/utils.js +210 -0
  38. package/package.json +21 -14
  39. package/dist/.tsbuildinfo +0 -1
  40. package/dist/cache.d.ts.map +0 -1
  41. package/dist/cache.js.map +0 -1
  42. package/dist/constants.d.ts.map +0 -1
  43. package/dist/constants.js.map +0 -1
  44. package/dist/errors.d.ts.map +0 -1
  45. package/dist/errors.js.map +0 -1
  46. package/dist/index.d.ts.map +0 -1
  47. package/dist/index.js.map +0 -1
  48. package/dist/invalidation.d.ts.map +0 -1
  49. package/dist/invalidation.js.map +0 -1
  50. package/dist/key-builder.d.ts.map +0 -1
  51. package/dist/key-builder.js.map +0 -1
  52. package/dist/lock.d.ts.map +0 -1
  53. package/dist/lock.js.map +0 -1
  54. package/dist/memory.d.ts.map +0 -1
  55. package/dist/memory.js.map +0 -1
  56. package/dist/metrics.d.ts.map +0 -1
  57. package/dist/metrics.js.map +0 -1
  58. package/dist/serializer.d.ts.map +0 -1
  59. package/dist/serializer.js.map +0 -1
  60. package/dist/store.d.ts.map +0 -1
  61. package/dist/store.js.map +0 -1
  62. package/dist/tags.d.ts.map +0 -1
  63. package/dist/tags.js.map +0 -1
  64. package/dist/types-adapter.d.ts.map +0 -1
  65. package/dist/types-adapter.js.map +0 -1
  66. package/dist/types-config.d.ts.map +0 -1
  67. package/dist/types-config.js.map +0 -1
  68. package/dist/types-events.d.ts.map +0 -1
  69. package/dist/types-events.js.map +0 -1
  70. package/dist/types-health.d.ts.map +0 -1
  71. package/dist/types-health.js.map +0 -1
  72. package/dist/types-keys.d.ts.map +0 -1
  73. package/dist/types-keys.js.map +0 -1
  74. package/dist/types-lock.d.ts.map +0 -1
  75. package/dist/types-lock.js.map +0 -1
  76. package/dist/types-metrics.d.ts.map +0 -1
  77. package/dist/types-metrics.js.map +0 -1
  78. package/dist/types-operations.d.ts.map +0 -1
  79. package/dist/types-operations.js.map +0 -1
  80. package/dist/types-results.d.ts.map +0 -1
  81. package/dist/types-results.js.map +0 -1
  82. package/dist/types-tags.d.ts.map +0 -1
  83. package/dist/types-tags.js.map +0 -1
  84. package/dist/types-utility.d.ts.map +0 -1
  85. package/dist/types-utility.js.map +0 -1
  86. package/dist/types-values.d.ts.map +0 -1
  87. package/dist/types-values.js.map +0 -1
  88. package/dist/types.d.ts.map +0 -1
  89. package/dist/types.js.map +0 -1
package/dist/lock.js CHANGED
@@ -1,52 +1,86 @@
1
1
  /**
2
2
  * @zudojs/cache — Lock Manager
3
- * Distributed lock manager for preventing concurrent cache operations.
3
+ *
4
+ * In-process lock manager with a pluggable `CacheLockStore`, for preventing
5
+ * concurrent cache operations. The bundled `InMemoryLockStore` holds leases
6
+ * in a single `Map` in a single process — it is NOT distributed. Pass a
7
+ * shared store (the exported `defaultLockStore` to share across services in
8
+ * one process, or a Redis-backed `CacheLockStore` to share across processes)
9
+ * via `CacheConfig.lockStore` when wider mutual exclusion is required.
10
+ *
11
+ * Leases are measured on a monotonic clock and are renewed by a heartbeat
12
+ * while the critical section runs, so a slow `fn()` does not silently lose
13
+ * its lock to expiry. If a lease is lost anyway, `withLock` aborts the
14
+ * signal it passed to `fn` and throws rather than reporting success.
4
15
  */
5
- import { randomBytes, randomUUID } from "node:crypto";
6
- import { DEFAULT_LOCK_RETRY_ATTEMPTS, DEFAULT_LOCK_RETRY_DELAY_MS, DEFAULT_LOCK_TTL_MS, } from "./constants.js";
16
+ import { randomUUID } from "node:crypto";
17
+ import { DEFAULT_LOCK_RETRY_ATTEMPTS, DEFAULT_LOCK_RETRY_DELAY_MS, DEFAULT_LOCK_TTL_MS, LOCK_HEARTBEAT_DIVISOR, } from "./constants.js";
7
18
  import { CacheError, CacheOperation } from "./errors.js";
19
+ import { assertValidTtl, monotonicNow, wallClockFor } from "./utils.js";
8
20
  function generateToken() {
9
- if (typeof randomUUID === "function") {
10
- return randomUUID();
11
- }
12
- return randomBytes(16).toString("hex");
21
+ return randomUUID();
22
+ }
23
+ /** Separates the namespace from the lock name in the internal map key. */
24
+ const LOCK_SCOPE_SEPARATOR = "\u0000";
25
+ /**
26
+ * Composes the scoped lock name. Two callers holding the same lock name
27
+ * under different namespaces do not contend.
28
+ */
29
+ function scopedLockKey(key, options) {
30
+ return options?.namespace !== undefined
31
+ ? `${options.namespace}${LOCK_SCOPE_SEPARATOR}${key}`
32
+ : key;
13
33
  }
14
34
  export class InMemoryLockStore {
15
35
  locks = new Map();
16
36
  async acquire(key, options) {
17
- const ttl = options?.ttl ?? DEFAULT_LOCK_TTL_MS;
37
+ const ttl = options?.ttl !== undefined ? options.ttl : DEFAULT_LOCK_TTL_MS;
38
+ // A negative or zero TTL would mint an already-expired lease that the
39
+ // next sweep drops, giving no exclusion at all while appearing to work.
40
+ assertValidTtl(ttl);
18
41
  const token = generateToken();
19
- const now = Date.now();
20
- const expiresAt = now + ttl;
21
- const existing = this.locks.get(key);
22
- if (existing && existing.expiresAt !== null && now > existing.expiresAt)
23
- this.locks.delete(key);
24
- if (this.locks.has(key))
42
+ const now = monotonicNow();
43
+ const expiresAt = ttl !== null ? now + ttl : null;
44
+ const scoped = scopedLockKey(key, options);
45
+ this.sweepExpired(now);
46
+ if (this.locks.has(scoped))
25
47
  return null;
26
- this.locks.set(key, { token, expiresAt });
48
+ this.locks.set(scoped, { token, expiresAt });
27
49
  return {
28
50
  key,
29
51
  token,
30
- acquiredAt: new Date(now),
31
- expiresAt: new Date(expiresAt),
52
+ acquiredAt: wallClockFor(now),
53
+ expiresAt: expiresAt !== null ? wallClockFor(expiresAt) : null,
32
54
  release: async () => {
33
- const current = this.locks.get(key);
55
+ const current = this.locks.get(scoped);
34
56
  if (current && current.token === token) {
35
- this.locks.delete(key);
57
+ this.locks.delete(scoped);
36
58
  return true;
37
59
  }
38
60
  return false;
39
61
  },
40
62
  extend: async (newTtl) => {
41
- const current = this.locks.get(key);
63
+ assertValidTtl(newTtl);
64
+ const current = this.locks.get(scoped);
42
65
  if (current && current.token === token) {
43
- this.locks.set(key, { ...current, expiresAt: Date.now() + newTtl });
66
+ this.locks.set(scoped, {
67
+ ...current,
68
+ expiresAt: newTtl !== null ? monotonicNow() + newTtl : null,
69
+ });
44
70
  return true;
45
71
  }
46
72
  return false;
47
73
  },
48
74
  };
49
75
  }
76
+ /** Removes all expired locks. Called opportunistically on each acquire. */
77
+ sweepExpired(now = monotonicNow()) {
78
+ for (const [key, lock] of [...this.locks]) {
79
+ if (lock.expiresAt !== null && now >= lock.expiresAt) {
80
+ this.locks.delete(key);
81
+ }
82
+ }
83
+ }
50
84
  get size() {
51
85
  return this.locks.size;
52
86
  }
@@ -63,34 +97,126 @@ export class CacheLockManager {
63
97
  this.retryAttempts = options?.retryAttempts ?? DEFAULT_LOCK_RETRY_ATTEMPTS;
64
98
  this.retryDelayMs = options?.retryDelayMs ?? DEFAULT_LOCK_RETRY_DELAY_MS;
65
99
  }
100
+ /**
101
+ * Acquires a lock, retrying on contention.
102
+ *
103
+ * Returns `null` for ordinary contention (someone else holds it) and
104
+ * throws only when the *last* attempt failed with a store error — the two
105
+ * outcomes decide differently (retry vs. fail the request), so they are
106
+ * never conflated.
107
+ */
66
108
  async acquire(key, options) {
109
+ // Per-call retry options take precedence over manager defaults.
110
+ // `attempts: 0` is honored (single attempt, no retries).
111
+ const attempts = options?.retry?.attempts ?? this.retryAttempts;
112
+ const delay = options?.retry?.delay ?? this.retryDelayMs;
67
113
  let lastError;
68
- for (let attempt = 0; attempt <= this.retryAttempts; attempt++) {
114
+ for (let attempt = 0; attempt <= attempts; attempt++) {
69
115
  try {
70
116
  const lock = await this.store.acquire(key, options);
117
+ // An attempt that completed clears any earlier transient error:
118
+ // `lastError` must describe the final outcome, not a stale one.
119
+ lastError = undefined;
71
120
  if (lock)
72
121
  return lock;
73
122
  }
74
123
  catch (error) {
75
124
  lastError = error;
76
125
  }
77
- if (attempt < this.retryAttempts)
78
- await sleep(this.retryDelayMs);
126
+ if (attempt < attempts)
127
+ await sleep(delay);
128
+ }
129
+ if (lastError) {
130
+ const code = "CACHE_LOCK_ACQUIRE_FAILED";
131
+ throw new CacheError(`Failed to acquire lock "${key}" after ${attempts} retries.`, {
132
+ code,
133
+ cause: lastError,
134
+ operation: CacheOperation.LOCK_ACQUIRE,
135
+ key,
136
+ });
79
137
  }
80
- if (lastError)
81
- throw new CacheError(`Failed to acquire lock "${key}" after ${this.retryAttempts} retries.`, { cause: lastError, operation: CacheOperation.LOCK_ACQUIRE, key });
82
138
  return null;
83
139
  }
140
+ /**
141
+ * Runs `fn` while holding the lock.
142
+ *
143
+ * The lease is renewed on an interval of roughly `ttl / 3` for as long as
144
+ * `fn` runs, so a critical section longer than the TTL does not silently
145
+ * lose mutual exclusion. If renewal or release reports that the lease is
146
+ * no longer ours, the `AbortSignal` passed to `fn` fires and the call
147
+ * throws — losing a lease is never reported as success.
148
+ */
84
149
  async withLock(key, fn, options) {
85
150
  const lock = await this.acquire(key, options);
86
- if (!lock)
87
- throw new CacheError(`Could not acquire lock "${key}" for exclusive operation.`, { operation: CacheOperation.LOCK_ACQUIRE, key, statusCode: 409 });
151
+ if (!lock) {
152
+ const code = "CACHE_LOCK_UNAVAILABLE";
153
+ throw new CacheError(`Could not acquire lock "${key}" for exclusive operation.`, {
154
+ code,
155
+ operation: CacheOperation.LOCK_ACQUIRE,
156
+ key,
157
+ statusCode: 409,
158
+ });
159
+ }
160
+ const ttl = options?.ttl !== undefined ? options.ttl : DEFAULT_LOCK_TTL_MS;
161
+ const controller = new AbortController();
162
+ let leaseLost = false;
163
+ let heartbeat;
164
+ if (ttl !== null) {
165
+ const interval = Math.max(1, Math.floor(ttl / LOCK_HEARTBEAT_DIVISOR));
166
+ heartbeat = setInterval(() => {
167
+ void (async () => {
168
+ try {
169
+ const extended = await lock.extend(ttl);
170
+ if (extended)
171
+ return;
172
+ }
173
+ catch {
174
+ /* fall through — treat a failed renewal as a lost lease */
175
+ }
176
+ leaseLost = true;
177
+ if (heartbeat !== undefined)
178
+ clearInterval(heartbeat);
179
+ controller.abort(new CacheError(`Lock "${key}" lease was lost during renewal.`, {
180
+ code: "CACHE_LOCK_LOST",
181
+ operation: CacheOperation.LOCK_ACQUIRE,
182
+ key,
183
+ }));
184
+ })();
185
+ }, interval);
186
+ // Never hold the event loop open on the cache's behalf.
187
+ heartbeat.unref?.();
188
+ }
189
+ let value;
190
+ let failure;
191
+ let failed = false;
88
192
  try {
89
- return await fn();
193
+ value = await fn(controller.signal);
194
+ }
195
+ catch (error) {
196
+ failed = true;
197
+ failure = error;
90
198
  }
91
199
  finally {
92
- await lock.release();
200
+ if (heartbeat !== undefined)
201
+ clearInterval(heartbeat);
93
202
  }
203
+ // `release()` returning false is the one signal that the lease expired or
204
+ // was taken over mid-critical-section. Surfacing it is the whole point of
205
+ // minting a fencing token. An error thrown by `fn` still wins, so the
206
+ // real cause is never masked.
207
+ const released = await lock.release();
208
+ if (failed)
209
+ throw failure;
210
+ if (!released || leaseLost) {
211
+ const code = "CACHE_LOCK_LOST";
212
+ throw new CacheError(`Lock "${key}" was lost before the critical section completed; its result cannot be trusted.`, {
213
+ code,
214
+ operation: CacheOperation.LOCK_RELEASE,
215
+ key,
216
+ statusCode: 409,
217
+ });
218
+ }
219
+ return value;
94
220
  }
95
221
  }
96
222
  function sleep(ms) {
@@ -99,5 +225,9 @@ function sleep(ms) {
99
225
  export function createLockManager(options) {
100
226
  return new CacheLockManager(options);
101
227
  }
228
+ /**
229
+ * Process-wide lock store. Pass it as `CacheConfig.lockStore` when several
230
+ * `CacheService` instances in one process must share locks.
231
+ */
102
232
  export const defaultLockStore = new InMemoryLockStore();
103
233
  //# sourceMappingURL=lock.js.map
package/dist/memory.d.ts CHANGED
@@ -1,16 +1,51 @@
1
1
  /**
2
2
  * @zudojs/cache — Memory Adapter
3
3
  * In-memory cache adapter using a Map. Suitable for development, testing, and single-process deployments.
4
+ *
5
+ * Receives fully-qualified keys; namespace resolution happens in CacheService.
6
+ *
7
+ * Bounds: the store is capped both by entry count (`maxEntries`) and by an
8
+ * approximate byte budget (`maxBytes`). Eviction is LRU — reads move an
9
+ * entry to the back of the queue, so the hottest keys survive.
10
+ *
11
+ * Expiry is measured on a monotonic clock, so an NTP step or a VM resume
12
+ * cannot extend (or collapse) an entry's lifetime. The wall-clock
13
+ * `expiresAt` returned to callers is derived from it for display only.
4
14
  */
5
- import type { CacheAdapter, CacheClearOptions, CacheClearResult, CacheDeleteResult, CacheGetResult, CacheKeysOptions, CacheSetManyOptions, CacheSetOptions, CacheSetResult, CacheTTL } from "./types.js";
15
+ import type { CacheClearOptions, CacheClearResult, CacheDeleteManyResult, CacheDeleteResult, CacheGetResult, CacheKeysOptions, CacheSetManyOptions, CacheSetOptions, CacheSetResult, CacheTTL } from "./types.js";
16
+ import type { CacheAdapter } from "./types.js";
17
+ /**
18
+ * Approximates the retained size of a value in bytes.
19
+ *
20
+ * Deliberately bounded: at most `SIZE_ESTIMATE_NODE_BUDGET` nodes are
21
+ * visited, so `set` stays cheap for large object graphs. Beyond the budget
22
+ * the remainder is charged a flat per-node estimate, which keeps the number
23
+ * monotone in value size without walking the whole graph.
24
+ */
25
+ export declare function estimateValueBytes(value: unknown): number;
6
26
  export declare class MemoryCacheAdapter implements CacheAdapter {
7
27
  readonly name = "memory";
8
28
  private readonly store;
9
29
  private readonly maxEntries;
30
+ private readonly maxBytes;
10
31
  private readonly defaultTtl;
32
+ private readonly separator;
33
+ private bytesUsed;
34
+ private lastPurgeAt;
11
35
  constructor(options?: {
12
36
  readonly maxEntries?: number;
37
+ /**
38
+ * Approximate memory budget in bytes. Entries are evicted (LRU) until
39
+ * the estimated total fits. Defaults to `DEFAULT_MAX_MEMORY_BYTES`.
40
+ */
41
+ readonly maxBytes?: number;
13
42
  readonly defaultTtl?: CacheTTL;
43
+ /**
44
+ * Key separator used when matching glob patterns. `*` never crosses it.
45
+ * Defaults to `DEFAULT_SEPARATOR`; set it to match a custom key-builder
46
+ * separator.
47
+ */
48
+ readonly separator?: string;
14
49
  });
15
50
  get<TValue = unknown>(key: string): Promise<CacheGetResult<TValue>>;
16
51
  set<TValue = unknown>(key: string, value: TValue, options?: CacheSetOptions): Promise<CacheSetResult>;
@@ -18,20 +53,41 @@ export declare class MemoryCacheAdapter implements CacheAdapter {
18
53
  has(key: string): Promise<boolean>;
19
54
  clear(options?: CacheClearOptions): Promise<CacheClearResult>;
20
55
  keys(options?: CacheKeysOptions): Promise<readonly string[]>;
56
+ /** Number of live entries currently held. */
57
+ size(): Promise<number>;
58
+ /** Estimated bytes currently retained by cached values. */
59
+ get estimatedBytes(): number;
21
60
  getMany<TValue = unknown>(keys: readonly string[]): Promise<ReadonlyMap<string, CacheGetResult<TValue>>>;
22
61
  setMany<TValue = unknown>(entries: ReadonlyMap<string, TValue>, options?: CacheSetManyOptions): Promise<readonly CacheSetResult[]>;
23
- deleteMany(keys: readonly string[]): Promise<{
24
- readonly deleted: number;
25
- readonly keys: readonly string[];
26
- }>;
27
- ttl(key: string): Promise<number | null>;
62
+ deleteMany(keys: readonly string[]): Promise<CacheDeleteManyResult>;
63
+ /**
64
+ * Remaining TTL for a key in milliseconds.
65
+ * - `undefined` — the key does not exist (expired entries are deleted)
66
+ * - `null` — the key exists and never expires
67
+ * - `number` — remaining milliseconds until expiry
68
+ */
69
+ ttl(key: string): Promise<number | null | undefined>;
28
70
  expire(key: string, ttl: CacheTTL): Promise<boolean>;
71
+ private toCacheEntry;
72
+ /**
73
+ * An entry is dead *at* its deadline, not one millisecond after it, so
74
+ * `ttl(key) === 0` and "present" are never both true.
75
+ */
29
76
  private isExpired;
77
+ private remove;
78
+ private purgeExpired;
79
+ private maybePurgeExpired;
80
+ /**
81
+ * Makes room for an incoming entry of `incomingBytes`, honouring both the
82
+ * entry-count cap and the memory budget. Expired entries are purged before
83
+ * live ones are evicted; live eviction is least-recently-used.
84
+ */
30
85
  private evictIfNeeded;
31
- private patternToRegex;
32
86
  }
33
87
  export declare function createMemoryCacheAdapter(options?: {
34
88
  readonly maxEntries?: number;
89
+ readonly maxBytes?: number;
35
90
  readonly defaultTtl?: CacheTTL;
91
+ readonly separator?: string;
36
92
  }): MemoryCacheAdapter;
37
93
  //# sourceMappingURL=memory.d.ts.map