@managani/cache 0.1.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.
- package/LICENSE +9 -0
- package/README.md +35 -0
- package/index.js +571 -0
- package/lib/l1_cache.js +66 -0
- package/lib/mongo_coordinator.js +163 -0
- package/lib/rate_limit_store.js +67 -0
- package/lib/serialization.js +122 -0
- package/package.json +39 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) Managani
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
6
|
+
|
|
7
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# @managani/cache
|
|
2
|
+
|
|
3
|
+
Shared CommonJS Memcached cache and MongoDB coordination primitives for Managani applications.
|
|
4
|
+
|
|
5
|
+
```js
|
|
6
|
+
const { createCache } = require("@managani/cache");
|
|
7
|
+
|
|
8
|
+
const cache = createCache({
|
|
9
|
+
servers: [
|
|
10
|
+
"cache-memcached-1:11211",
|
|
11
|
+
"cache-memcached-2:11211",
|
|
12
|
+
"cache-memcached-3:11211",
|
|
13
|
+
],
|
|
14
|
+
namespace: "product:v3",
|
|
15
|
+
l1: { maxBytes: 16 * 1024 * 1024, maxTtlMs: 2000 },
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
await cache.set("key", { value: true }, { ttlMs: 300000 });
|
|
19
|
+
const value = await cache.get("key");
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Values use a versioned JSON envelope. Objects, arrays, dates, buffers, `null`,
|
|
23
|
+
primitives, and bigints retain their types. Values above 64 KiB use fast
|
|
24
|
+
compression when it saves at least 10%; values above 3.5 MiB on wire are skipped.
|
|
25
|
+
|
|
26
|
+
The cache exposes `get`, `getMany`, `set`, `setMany`, `delete`,
|
|
27
|
+
`invalidateScope`, `getOrLoad`, atomic `add`, `increment`, `decrement`,
|
|
28
|
+
`consumeOnce`, advisory `acquireLease`, `health`, and `close`.
|
|
29
|
+
|
|
30
|
+
Ordinary backend failures return misses or unsuccessful writes. `consumeOnce`
|
|
31
|
+
probes the whole cluster and fails closed while any node is degraded.
|
|
32
|
+
|
|
33
|
+
Use `createMongoCoordinator({ db })` for token-checked locks, bounded
|
|
34
|
+
semaphores, and cooldowns whose loss could affect correctness. It uses primary
|
|
35
|
+
reads, atomic writes, and a TTL index.
|
package/index.js
ADDED
|
@@ -0,0 +1,571 @@
|
|
|
1
|
+
const crypto = require("node:crypto");
|
|
2
|
+
const { Memcache } = require("memcache");
|
|
3
|
+
const L1Cache = require("./lib/l1_cache");
|
|
4
|
+
const MongoCoordinator = require("./lib/mongo_coordinator");
|
|
5
|
+
const MemcachedRateLimitStore = require("./lib/rate_limit_store");
|
|
6
|
+
const { decodeEntry, encodeEntry } = require("./lib/serialization");
|
|
7
|
+
|
|
8
|
+
const DEFAULT_TTL_MS = 5 * 60 * 1000;
|
|
9
|
+
const DEFAULT_MAX_VALUE_BYTES = Math.floor(3.5 * 1024 * 1024);
|
|
10
|
+
|
|
11
|
+
class CacheUnavailableError extends Error {
|
|
12
|
+
constructor(message = "Cache unavailable") {
|
|
13
|
+
super(message);
|
|
14
|
+
this.name = "CacheUnavailableError";
|
|
15
|
+
this.code = "CACHE_UNAVAILABLE";
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
class DistributedCache {
|
|
20
|
+
constructor(options = {}) {
|
|
21
|
+
this.namespace = String(options.namespace || "app");
|
|
22
|
+
this.logger = options.logger || null;
|
|
23
|
+
this.metrics = options.metrics || null;
|
|
24
|
+
this.timeoutMs = Math.max(10, Number(options.timeoutMs) || 100);
|
|
25
|
+
this.maxValueBytes = Math.max(
|
|
26
|
+
1024,
|
|
27
|
+
Number(options.maxValueBytes) || DEFAULT_MAX_VALUE_BYTES,
|
|
28
|
+
);
|
|
29
|
+
this.compressionThreshold = Math.max(
|
|
30
|
+
0,
|
|
31
|
+
Number(options.compressionThreshold) || 64 * 1024,
|
|
32
|
+
);
|
|
33
|
+
this.servers = [
|
|
34
|
+
...new Set(
|
|
35
|
+
(options.servers || [])
|
|
36
|
+
.map(String)
|
|
37
|
+
.map((value) => value.trim())
|
|
38
|
+
.filter(Boolean),
|
|
39
|
+
),
|
|
40
|
+
];
|
|
41
|
+
this.l1 = new L1Cache(options.l1 || {});
|
|
42
|
+
this.inflight = new Map();
|
|
43
|
+
this.scopeGenerations = new Map();
|
|
44
|
+
this.scopeGenerationTtlMs = Math.max(
|
|
45
|
+
100,
|
|
46
|
+
Number(options.scopeGenerationTtlMs) || 2000,
|
|
47
|
+
);
|
|
48
|
+
this.errorLogTimes = new Map();
|
|
49
|
+
this.errorLogIntervalMs = Math.max(
|
|
50
|
+
1000,
|
|
51
|
+
Number(options.errorLogIntervalMs) || 60000,
|
|
52
|
+
);
|
|
53
|
+
this.client = options.client || this._createClient(this.servers);
|
|
54
|
+
this.probes = new Map();
|
|
55
|
+
this.nodeState = new Map();
|
|
56
|
+
this._reconcilePromise = Promise.resolve();
|
|
57
|
+
this._closed = false;
|
|
58
|
+
this._customClient = !!options.client;
|
|
59
|
+
if (!this._customClient) this._initializeHealth(options.healthIntervalMs);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async get(key, options = {}) {
|
|
63
|
+
const entry = await this._getEntry(key, options);
|
|
64
|
+
return entry ? entry.value : null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async getMany(keys, options = {}) {
|
|
68
|
+
return Promise.all((keys || []).map((key) => this.get(key, options)));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async set(key, value, options = {}) {
|
|
72
|
+
const ttlMs = Math.max(1, Number(options.ttlMs) || DEFAULT_TTL_MS);
|
|
73
|
+
const staleTtlMs = Math.max(0, Number(options.staleTtlMs) || 0);
|
|
74
|
+
const generation = await this._scopeGeneration(options.scope);
|
|
75
|
+
const wireKey = this._wireKey("value", key, options.scope, generation);
|
|
76
|
+
const entry = { version: 1, value, freshUntil: Date.now() + ttlMs };
|
|
77
|
+
return this._storeEncoded(wireKey, entry, {
|
|
78
|
+
ttlMs: ttlMs + staleTtlMs,
|
|
79
|
+
scope: options.scope || "",
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async setMany(entries, options = {}) {
|
|
84
|
+
return Promise.all(
|
|
85
|
+
(entries || []).map((entry) =>
|
|
86
|
+
this.set(entry.key, entry.value, {
|
|
87
|
+
...options,
|
|
88
|
+
...(entry.options || {}),
|
|
89
|
+
ttlMs: entry.ttlMs || entry.options?.ttlMs || options.ttlMs,
|
|
90
|
+
}),
|
|
91
|
+
),
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async add(key, value, options = {}) {
|
|
96
|
+
const ttlMs = Math.max(1, Number(options.ttlMs) || DEFAULT_TTL_MS);
|
|
97
|
+
const generation = await this._scopeGeneration(options.scope);
|
|
98
|
+
const wireKey = this._wireKey("value", key, options.scope, generation);
|
|
99
|
+
const entry = { version: 1, value, freshUntil: Date.now() + ttlMs };
|
|
100
|
+
const encoded = await encodeEntry(entry, {
|
|
101
|
+
compressionThreshold: this.compressionThreshold,
|
|
102
|
+
});
|
|
103
|
+
if (encoded.bytes > this.maxValueBytes) {
|
|
104
|
+
this._metric("cache_oversize", { bytes: encoded.bytes });
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
try {
|
|
108
|
+
const stored = await this.client.add(
|
|
109
|
+
wireKey,
|
|
110
|
+
encoded.encoded,
|
|
111
|
+
this._ttlSeconds(ttlMs),
|
|
112
|
+
);
|
|
113
|
+
if (stored)
|
|
114
|
+
this.l1.set(wireKey, entry, {
|
|
115
|
+
size: encoded.bytes,
|
|
116
|
+
ttlMs,
|
|
117
|
+
scope: options.scope || "",
|
|
118
|
+
});
|
|
119
|
+
this._metric("cache_add", { stored: !!stored });
|
|
120
|
+
return !!stored;
|
|
121
|
+
} catch (error) {
|
|
122
|
+
this._cacheError("add", error);
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async increment(key, options = {}) {
|
|
128
|
+
const amount = Math.max(1, Number(options.amount) || 1);
|
|
129
|
+
const initial = Math.max(0, Number(options.initial) || 0);
|
|
130
|
+
const wireKey = this._wireKey("counter", key);
|
|
131
|
+
try {
|
|
132
|
+
let value = await this.client.incr(wireKey, amount);
|
|
133
|
+
if (Number.isFinite(value)) return Number(value);
|
|
134
|
+
if (
|
|
135
|
+
await this.client.add(
|
|
136
|
+
wireKey,
|
|
137
|
+
String(initial + amount),
|
|
138
|
+
this._ttlSeconds(options.ttlMs || DEFAULT_TTL_MS),
|
|
139
|
+
)
|
|
140
|
+
)
|
|
141
|
+
return initial + amount;
|
|
142
|
+
value = await this.client.incr(wireKey, amount);
|
|
143
|
+
return Number.isFinite(value) ? Number(value) : null;
|
|
144
|
+
} catch (error) {
|
|
145
|
+
this._cacheError("increment", error);
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async decrement(key, options = {}) {
|
|
151
|
+
const wireKey = this._wireKey("counter", key);
|
|
152
|
+
try {
|
|
153
|
+
const value = await this.client.decr(
|
|
154
|
+
wireKey,
|
|
155
|
+
Math.max(1, Number(options.amount) || 1),
|
|
156
|
+
);
|
|
157
|
+
return Number.isFinite(value) ? Number(value) : null;
|
|
158
|
+
} catch (error) {
|
|
159
|
+
this._cacheError("decrement", error);
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async getCounter(key) {
|
|
165
|
+
const wireKey = this._wireKey("counter", key);
|
|
166
|
+
try {
|
|
167
|
+
const value = await this.client.get(wireKey);
|
|
168
|
+
return value === undefined || value === null ? 0 : Number(value) || 0;
|
|
169
|
+
} catch (error) {
|
|
170
|
+
this._cacheError("get_counter", error);
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async delete(key, options = {}) {
|
|
176
|
+
const generation = await this._scopeGeneration(options.scope, false);
|
|
177
|
+
const wireKey = this._wireKey(
|
|
178
|
+
options.kind || "value",
|
|
179
|
+
key,
|
|
180
|
+
options.scope,
|
|
181
|
+
generation,
|
|
182
|
+
);
|
|
183
|
+
this.l1.delete(wireKey);
|
|
184
|
+
try {
|
|
185
|
+
return !!(await this.client.delete(wireKey));
|
|
186
|
+
} catch (error) {
|
|
187
|
+
this._cacheError("delete", error);
|
|
188
|
+
return false;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async invalidateScope(scope) {
|
|
193
|
+
if (!scope) return false;
|
|
194
|
+
const generation = crypto.randomUUID();
|
|
195
|
+
const key = this._scopeKey(scope);
|
|
196
|
+
try {
|
|
197
|
+
const stored = await this.client.set(key, generation, 0);
|
|
198
|
+
if (!stored) return false;
|
|
199
|
+
this.scopeGenerations.set(scope, {
|
|
200
|
+
value: generation,
|
|
201
|
+
expiresAt: Date.now() + this.scopeGenerationTtlMs,
|
|
202
|
+
});
|
|
203
|
+
this.l1.invalidateScope(scope);
|
|
204
|
+
this._metric("cache_scope_invalidated", {});
|
|
205
|
+
return true;
|
|
206
|
+
} catch (error) {
|
|
207
|
+
this._cacheError("invalidate_scope", error);
|
|
208
|
+
return false;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async getOrLoad(key, loader, options = {}) {
|
|
213
|
+
if (typeof loader !== "function")
|
|
214
|
+
throw new TypeError("getOrLoad requires loader");
|
|
215
|
+
const entry = await this._getEntry(key, options);
|
|
216
|
+
const inflightKey = this._wireKey(
|
|
217
|
+
"inflight",
|
|
218
|
+
key,
|
|
219
|
+
options.scope,
|
|
220
|
+
await this._scopeGeneration(options.scope),
|
|
221
|
+
);
|
|
222
|
+
if (entry && entry.freshUntil > Date.now()) return entry.value;
|
|
223
|
+
if (entry && Number(options.staleTtlMs) > 0) {
|
|
224
|
+
this._loadOnce(inflightKey, key, loader, options).catch((error) =>
|
|
225
|
+
this._log("warn", "Cache background refresh failed", error),
|
|
226
|
+
);
|
|
227
|
+
return entry.value;
|
|
228
|
+
}
|
|
229
|
+
return this._loadOnce(inflightKey, key, loader, options);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async acquireLease(key, options = {}) {
|
|
233
|
+
const token = crypto.randomUUID();
|
|
234
|
+
const ttlMs = Math.max(1, Number(options.ttlMs) || 60000);
|
|
235
|
+
const acquired = await this.add(
|
|
236
|
+
`lease:${key}`,
|
|
237
|
+
{ token },
|
|
238
|
+
{ ttlMs, scope: options.scope },
|
|
239
|
+
);
|
|
240
|
+
return acquired
|
|
241
|
+
? { key, token, expiresAt: new Date(Date.now() + ttlMs) }
|
|
242
|
+
: null;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
async consumeOnce(key, options = {}) {
|
|
246
|
+
const status = await this.health();
|
|
247
|
+
if (!status.healthy || status.degraded)
|
|
248
|
+
throw new CacheUnavailableError(
|
|
249
|
+
"Cache degraded during one-time value consumption",
|
|
250
|
+
);
|
|
251
|
+
const value = await this.get(key, options);
|
|
252
|
+
if (value === null) return null;
|
|
253
|
+
const markerTtlMs = Math.max(1000, Number(options.ttlMs) || DEFAULT_TTL_MS);
|
|
254
|
+
const marked = await this.add(`consumed:${key}`, true, {
|
|
255
|
+
ttlMs: markerTtlMs,
|
|
256
|
+
scope: options.scope,
|
|
257
|
+
});
|
|
258
|
+
if (!marked) return null;
|
|
259
|
+
await this.delete(key, options);
|
|
260
|
+
return value;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
isDegraded() {
|
|
264
|
+
if (this._customClient) return false;
|
|
265
|
+
return (
|
|
266
|
+
!this.servers.length ||
|
|
267
|
+
[...this.nodeState.values()].some(
|
|
268
|
+
(state) => state.ejected || state.healthy === false,
|
|
269
|
+
)
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
async health() {
|
|
274
|
+
if (this._customClient)
|
|
275
|
+
return { healthy: true, degraded: false, nodes: [] };
|
|
276
|
+
await Promise.all(this.servers.map((server) => this._probe(server)));
|
|
277
|
+
const nodes = this.servers.map((server) => ({
|
|
278
|
+
server,
|
|
279
|
+
...this.nodeState.get(server),
|
|
280
|
+
}));
|
|
281
|
+
return {
|
|
282
|
+
healthy: nodes.some((node) => !node.ejected && node.healthy),
|
|
283
|
+
degraded: nodes.some((node) => node.ejected || !node.healthy),
|
|
284
|
+
nodes,
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
async close() {
|
|
289
|
+
this._closed = true;
|
|
290
|
+
if (this._healthTimer) clearInterval(this._healthTimer);
|
|
291
|
+
this.l1.clear();
|
|
292
|
+
await Promise.allSettled([
|
|
293
|
+
this.client?.quit?.(),
|
|
294
|
+
...[...this.probes.values()].map((probe) => probe.quit?.()),
|
|
295
|
+
]);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
async _getEntry(key, options = {}) {
|
|
299
|
+
const generation = await this._scopeGeneration(options.scope);
|
|
300
|
+
const wireKey = this._wireKey("value", key, options.scope, generation);
|
|
301
|
+
const l1 = options.l1 === false ? null : this.l1.get(wireKey);
|
|
302
|
+
if (l1) {
|
|
303
|
+
this._metric("cache_get", { layer: "l1", hit: true });
|
|
304
|
+
return l1;
|
|
305
|
+
}
|
|
306
|
+
try {
|
|
307
|
+
const encoded = await this.client.get(wireKey);
|
|
308
|
+
if (encoded === undefined || encoded === null) {
|
|
309
|
+
this._metric("cache_get", { layer: "l2", hit: false });
|
|
310
|
+
return null;
|
|
311
|
+
}
|
|
312
|
+
const entry = await decodeEntry(encoded);
|
|
313
|
+
if (!entry) return null;
|
|
314
|
+
if (options.l1 !== false)
|
|
315
|
+
this.l1.set(wireKey, entry, {
|
|
316
|
+
size: Buffer.byteLength(encoded),
|
|
317
|
+
ttlMs: Math.max(1, entry.freshUntil - Date.now()),
|
|
318
|
+
scope: options.scope || "",
|
|
319
|
+
});
|
|
320
|
+
this._metric("cache_get", { layer: "l2", hit: true });
|
|
321
|
+
return entry;
|
|
322
|
+
} catch (error) {
|
|
323
|
+
this._cacheError("get", error);
|
|
324
|
+
return null;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
async _storeEncoded(wireKey, entry, options) {
|
|
329
|
+
const encoded = await encodeEntry(entry, {
|
|
330
|
+
compressionThreshold: this.compressionThreshold,
|
|
331
|
+
});
|
|
332
|
+
if (encoded.bytes > this.maxValueBytes) {
|
|
333
|
+
this._metric("cache_oversize", { bytes: encoded.bytes });
|
|
334
|
+
return false;
|
|
335
|
+
}
|
|
336
|
+
try {
|
|
337
|
+
const stored = await this.client.set(
|
|
338
|
+
wireKey,
|
|
339
|
+
encoded.encoded,
|
|
340
|
+
this._ttlSeconds(options.ttlMs),
|
|
341
|
+
);
|
|
342
|
+
if (stored)
|
|
343
|
+
this.l1.set(wireKey, entry, {
|
|
344
|
+
size: encoded.bytes,
|
|
345
|
+
ttlMs: options.ttlMs,
|
|
346
|
+
scope: options.scope,
|
|
347
|
+
});
|
|
348
|
+
this._metric("cache_set", {
|
|
349
|
+
stored: !!stored,
|
|
350
|
+
bytes: encoded.bytes,
|
|
351
|
+
compressed: encoded.compressed,
|
|
352
|
+
});
|
|
353
|
+
return !!stored;
|
|
354
|
+
} catch (error) {
|
|
355
|
+
this._cacheError("set", error);
|
|
356
|
+
return false;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
async _loadOnce(inflightKey, key, loader, options) {
|
|
361
|
+
if (this.inflight.has(inflightKey)) return this.inflight.get(inflightKey);
|
|
362
|
+
const promise = Promise.resolve()
|
|
363
|
+
.then(loader)
|
|
364
|
+
.then(async (value) => {
|
|
365
|
+
await this.set(key, value, options);
|
|
366
|
+
return value;
|
|
367
|
+
})
|
|
368
|
+
.finally(() => this.inflight.delete(inflightKey));
|
|
369
|
+
this.inflight.set(inflightKey, promise);
|
|
370
|
+
return promise;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
async _scopeGeneration(scope, create = true) {
|
|
374
|
+
if (!scope) return "";
|
|
375
|
+
const cached = this.scopeGenerations.get(scope);
|
|
376
|
+
if (cached && cached.expiresAt > Date.now()) return cached.value;
|
|
377
|
+
const key = this._scopeKey(scope);
|
|
378
|
+
try {
|
|
379
|
+
let generation = await this.client.get(key);
|
|
380
|
+
if (!generation && create) {
|
|
381
|
+
const candidate = crypto.randomUUID();
|
|
382
|
+
if (await this.client.add(key, candidate, 0)) generation = candidate;
|
|
383
|
+
else generation = await this.client.get(key);
|
|
384
|
+
}
|
|
385
|
+
generation = generation || "";
|
|
386
|
+
this.scopeGenerations.set(scope, {
|
|
387
|
+
value: generation,
|
|
388
|
+
expiresAt: Date.now() + this.scopeGenerationTtlMs,
|
|
389
|
+
});
|
|
390
|
+
return generation;
|
|
391
|
+
} catch (error) {
|
|
392
|
+
this._cacheError("scope_generation", error);
|
|
393
|
+
return "";
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
_scopeKey(scope) {
|
|
398
|
+
return this._wireKey("scope", scope);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
_wireKey(kind, key, scope = "", generation = "") {
|
|
402
|
+
return `mc:${crypto
|
|
403
|
+
.createHash("sha256")
|
|
404
|
+
.update(
|
|
405
|
+
`${this.namespace}\0${kind}\0${scope || ""}\0${generation || ""}\0${String(key)}`,
|
|
406
|
+
)
|
|
407
|
+
.digest("hex")}`;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
_ttlSeconds(ttlMs) {
|
|
411
|
+
return Math.max(
|
|
412
|
+
1,
|
|
413
|
+
Math.ceil(
|
|
414
|
+
Math.min(Number(ttlMs) || DEFAULT_TTL_MS, 30 * 24 * 60 * 60 * 1000) /
|
|
415
|
+
1000,
|
|
416
|
+
),
|
|
417
|
+
);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
_createClient(servers) {
|
|
421
|
+
return new Memcache({
|
|
422
|
+
nodes: servers,
|
|
423
|
+
timeout: this.timeoutMs,
|
|
424
|
+
keepAlive: true,
|
|
425
|
+
retries: 0,
|
|
426
|
+
retryOnlyIdempotent: true,
|
|
427
|
+
lazyConnect: true,
|
|
428
|
+
maxValueSize: 4 * 1024 * 1024,
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
_initializeHealth(intervalMs) {
|
|
433
|
+
for (const server of this.servers) {
|
|
434
|
+
this.nodeState.set(server, {
|
|
435
|
+
healthy: true,
|
|
436
|
+
ejected: false,
|
|
437
|
+
failures: 0,
|
|
438
|
+
successes: 0,
|
|
439
|
+
});
|
|
440
|
+
this.probes.set(server, this._createClient([server]));
|
|
441
|
+
}
|
|
442
|
+
this._attachNodeHandlers();
|
|
443
|
+
this._healthTimer = setInterval(
|
|
444
|
+
() => {
|
|
445
|
+
Promise.all(this.servers.map((server) => this._probe(server))).catch(
|
|
446
|
+
() => {},
|
|
447
|
+
);
|
|
448
|
+
},
|
|
449
|
+
Math.max(1000, Number(intervalMs) || 5000),
|
|
450
|
+
);
|
|
451
|
+
if (typeof this._healthTimer.unref === "function")
|
|
452
|
+
this._healthTimer.unref();
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
_attachNodeHandlers() {
|
|
456
|
+
for (const node of this.client.getNodes()) {
|
|
457
|
+
if (node._managaniCacheHandlersAttached) continue;
|
|
458
|
+
node._managaniCacheHandlersAttached = true;
|
|
459
|
+
node.on("error", (error) => this._recordFailure(node.id, error));
|
|
460
|
+
node.on("timeout", (error) => this._recordFailure(node.id, error));
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
_recordFailure(server, error) {
|
|
465
|
+
const state = this.nodeState.get(server);
|
|
466
|
+
if (!state) return;
|
|
467
|
+
state.failures++;
|
|
468
|
+
state.successes = 0;
|
|
469
|
+
state.healthy = false;
|
|
470
|
+
this._metric("cache_node_failure", { server, failures: state.failures });
|
|
471
|
+
if (state.failures >= 2 && !state.ejected)
|
|
472
|
+
this._queueReconcile(() => this._eject(server, error));
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
async _probe(server) {
|
|
476
|
+
if (this._closed) return;
|
|
477
|
+
const state = this.nodeState.get(server);
|
|
478
|
+
const probe = this.probes.get(server);
|
|
479
|
+
try {
|
|
480
|
+
const versions = await probe.version();
|
|
481
|
+
if (!versions?.size)
|
|
482
|
+
throw new Error("Memcached version probe returned no node");
|
|
483
|
+
state.healthy = true;
|
|
484
|
+
state.failures = 0;
|
|
485
|
+
state.successes++;
|
|
486
|
+
if (state.ejected && state.successes >= 2)
|
|
487
|
+
await this._queueReconcile(() => this._rejoin(server));
|
|
488
|
+
} catch (error) {
|
|
489
|
+
this._recordFailure(server, error);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
_queueReconcile(operation) {
|
|
494
|
+
this._reconcilePromise = this._reconcilePromise
|
|
495
|
+
.then(operation)
|
|
496
|
+
.catch((error) =>
|
|
497
|
+
this._log("warn", "Memcached ring reconciliation failed", error),
|
|
498
|
+
);
|
|
499
|
+
return this._reconcilePromise;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
async _eject(server, error) {
|
|
503
|
+
const state = this.nodeState.get(server);
|
|
504
|
+
if (!state || state.ejected) return;
|
|
505
|
+
await this.client.removeNode(server);
|
|
506
|
+
state.ejected = true;
|
|
507
|
+
state.healthy = false;
|
|
508
|
+
state.successes = 0;
|
|
509
|
+
this._log("warn", `Memcached node ejected '${server}'`, error);
|
|
510
|
+
this._metric("cache_node_ejected", { server });
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
async _rejoin(server) {
|
|
514
|
+
const state = this.nodeState.get(server);
|
|
515
|
+
if (!state?.ejected) return;
|
|
516
|
+
const flushed = await this.probes.get(server).flush();
|
|
517
|
+
if (!flushed) throw new Error(`Memcached node flush failed before rejoin '${server}'`);
|
|
518
|
+
await this.client.addNode(server);
|
|
519
|
+
state.ejected = false;
|
|
520
|
+
state.healthy = true;
|
|
521
|
+
state.failures = 0;
|
|
522
|
+
state.successes = 0;
|
|
523
|
+
this._attachNodeHandlers();
|
|
524
|
+
this._log("info", `Memcached node rejoined '${server}'`);
|
|
525
|
+
this._metric("cache_node_rejoined", { server });
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
_cacheError(operation, error) {
|
|
529
|
+
this._metric("cache_error", { operation });
|
|
530
|
+
const now = Date.now();
|
|
531
|
+
const lastLogAt = this.errorLogTimes.get(operation) || 0;
|
|
532
|
+
if (now - lastLogAt < this.errorLogIntervalMs) return;
|
|
533
|
+
this.errorLogTimes.set(operation, now);
|
|
534
|
+
this._log("warn", `Memcached ${operation} failed`, error);
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
_metric(name, fields) {
|
|
538
|
+
if (typeof this.metrics === "function") this.metrics(name, fields);
|
|
539
|
+
else if (typeof this.metrics?.increment === "function")
|
|
540
|
+
this.metrics.increment(name, fields);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
_log(level, message, error) {
|
|
544
|
+
const logger = this.logger;
|
|
545
|
+
if (!logger || typeof logger[level] !== "function") return;
|
|
546
|
+
if (error) logger[level]({ err: error }, message);
|
|
547
|
+
else logger[level](message);
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function createCache(options) {
|
|
552
|
+
return new DistributedCache(options);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
function createMongoCoordinator(options) {
|
|
556
|
+
return new MongoCoordinator(options);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function createRateLimitStore(options) {
|
|
560
|
+
return new MemcachedRateLimitStore(options);
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
module.exports = {
|
|
564
|
+
CacheUnavailableError,
|
|
565
|
+
DistributedCache,
|
|
566
|
+
MemcachedRateLimitStore,
|
|
567
|
+
MongoCoordinator,
|
|
568
|
+
createCache,
|
|
569
|
+
createMongoCoordinator,
|
|
570
|
+
createRateLimitStore,
|
|
571
|
+
};
|
package/lib/l1_cache.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
class L1Cache {
|
|
2
|
+
constructor(options = {}) {
|
|
3
|
+
this.maxBytes = Math.max(0, Number(options.maxBytes) || 0);
|
|
4
|
+
this.maxTtlMs = Math.max(0, Number(options.maxTtlMs) || 2000);
|
|
5
|
+
this.entries = new Map();
|
|
6
|
+
this.bytes = 0;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
get(key, now = Date.now()) {
|
|
10
|
+
const entry = this.entries.get(key);
|
|
11
|
+
if (!entry) return null;
|
|
12
|
+
if (entry.expiresAt <= now) {
|
|
13
|
+
this.delete(key);
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
this.entries.delete(key);
|
|
17
|
+
this.entries.set(key, entry);
|
|
18
|
+
return entry.value;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
set(key, value, options = {}) {
|
|
22
|
+
if (!this.maxBytes) return;
|
|
23
|
+
const size = Math.max(1, Number(options.size) || 1);
|
|
24
|
+
if (size > this.maxBytes) return;
|
|
25
|
+
const ttlMs = Math.min(
|
|
26
|
+
Math.max(1, Number(options.ttlMs) || this.maxTtlMs),
|
|
27
|
+
this.maxTtlMs,
|
|
28
|
+
);
|
|
29
|
+
this.delete(key);
|
|
30
|
+
this.entries.set(key, {
|
|
31
|
+
value,
|
|
32
|
+
size,
|
|
33
|
+
scope: options.scope || "",
|
|
34
|
+
expiresAt: Date.now() + ttlMs,
|
|
35
|
+
});
|
|
36
|
+
this.bytes += size;
|
|
37
|
+
this._trim();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
delete(key) {
|
|
41
|
+
const entry = this.entries.get(key);
|
|
42
|
+
if (!entry) return false;
|
|
43
|
+
this.entries.delete(key);
|
|
44
|
+
this.bytes = Math.max(0, this.bytes - entry.size);
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
invalidateScope(scope) {
|
|
49
|
+
for (const [key, entry] of this.entries.entries()) {
|
|
50
|
+
if (entry.scope === scope) this.delete(key);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
clear() {
|
|
55
|
+
this.entries.clear();
|
|
56
|
+
this.bytes = 0;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
_trim() {
|
|
60
|
+
while (this.bytes > this.maxBytes && this.entries.size) {
|
|
61
|
+
this.delete(this.entries.keys().next().value);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
module.exports = L1Cache;
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
const crypto = require("node:crypto");
|
|
2
|
+
|
|
3
|
+
function hashKey(namespace, kind, key) {
|
|
4
|
+
return crypto
|
|
5
|
+
.createHash("sha256")
|
|
6
|
+
.update(`${namespace}\0${kind}\0${key}`)
|
|
7
|
+
.digest("hex");
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function returnedDocument(result) {
|
|
11
|
+
if (!result) return null;
|
|
12
|
+
return Object.prototype.hasOwnProperty.call(result, "value")
|
|
13
|
+
? result.value
|
|
14
|
+
: result;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
class MongoCoordinator {
|
|
18
|
+
constructor(options = {}) {
|
|
19
|
+
if (!options.db || typeof options.db.collection !== "function")
|
|
20
|
+
throw new Error("MongoCoordinator requires a connected MongoDB Db");
|
|
21
|
+
this.namespace = String(options.namespace || "app");
|
|
22
|
+
this.collection = options.db.collection(
|
|
23
|
+
options.collectionName || "cache_coordination",
|
|
24
|
+
);
|
|
25
|
+
this.logger = options.logger || null;
|
|
26
|
+
this.metrics = options.metrics || null;
|
|
27
|
+
this._indexes = null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async acquireLock(key, options = {}) {
|
|
31
|
+
await this._ensureIndexes();
|
|
32
|
+
const ttlMs = Math.max(1, Number(options.ttlMs) || 60000);
|
|
33
|
+
const token = options.token || crypto.randomUUID();
|
|
34
|
+
const now = new Date();
|
|
35
|
+
const expiresAt = new Date(now.getTime() + ttlMs);
|
|
36
|
+
const _id = hashKey(this.namespace, "lock", key);
|
|
37
|
+
const update = {
|
|
38
|
+
$set: {
|
|
39
|
+
namespace: this.namespace,
|
|
40
|
+
kind: "lock",
|
|
41
|
+
key_hash: _id,
|
|
42
|
+
token,
|
|
43
|
+
expires_at: expiresAt,
|
|
44
|
+
updated_at: now,
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
let document = returnedDocument(
|
|
48
|
+
await this.collection.findOneAndUpdate(
|
|
49
|
+
{
|
|
50
|
+
_id,
|
|
51
|
+
$or: [
|
|
52
|
+
{ expires_at: { $lte: now } },
|
|
53
|
+
{ expires_at: { $exists: false } },
|
|
54
|
+
],
|
|
55
|
+
},
|
|
56
|
+
update,
|
|
57
|
+
{ returnDocument: "after" },
|
|
58
|
+
),
|
|
59
|
+
);
|
|
60
|
+
if (!document) {
|
|
61
|
+
try {
|
|
62
|
+
await this.collection.insertOne({
|
|
63
|
+
_id,
|
|
64
|
+
namespace: this.namespace,
|
|
65
|
+
kind: "lock",
|
|
66
|
+
key_hash: _id,
|
|
67
|
+
token,
|
|
68
|
+
expires_at: expiresAt,
|
|
69
|
+
updated_at: now,
|
|
70
|
+
});
|
|
71
|
+
document = { token };
|
|
72
|
+
} catch (error) {
|
|
73
|
+
if (error?.code !== 11000) throw error;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
const acquired = document?.token === token;
|
|
77
|
+
this._metric("mongo_lock", { acquired });
|
|
78
|
+
return acquired ? { key, token, expiresAt } : null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async releaseLock(lock) {
|
|
82
|
+
if (!lock?.key || !lock?.token) return false;
|
|
83
|
+
await this._ensureIndexes();
|
|
84
|
+
const _id = hashKey(this.namespace, "lock", lock.key);
|
|
85
|
+
const result = await this.collection.deleteOne({ _id, token: lock.token });
|
|
86
|
+
return result.deletedCount === 1;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async acquireSemaphore(key, limit, options = {}) {
|
|
90
|
+
const max = Math.max(1, Number(limit) || 1);
|
|
91
|
+
const start = Math.floor(Math.random() * max);
|
|
92
|
+
for (let offset = 0; offset < max; offset++) {
|
|
93
|
+
const slot = (start + offset) % max;
|
|
94
|
+
const lock = await this.acquireLock(`semaphore:${key}:${slot}`, options);
|
|
95
|
+
if (lock) return { ...lock, semaphoreKey: key, slot };
|
|
96
|
+
}
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async releaseSemaphore(lease) {
|
|
101
|
+
if (!lease || lease.slot === undefined) return false;
|
|
102
|
+
return this.releaseLock({
|
|
103
|
+
key: `semaphore:${lease.semaphoreKey}:${lease.slot}`,
|
|
104
|
+
token: lease.token,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async setCooldown(key, until) {
|
|
109
|
+
await this._ensureIndexes();
|
|
110
|
+
const untilDate = new Date(until);
|
|
111
|
+
const _id = hashKey(this.namespace, "cooldown", key);
|
|
112
|
+
await this.collection.updateOne(
|
|
113
|
+
{ _id },
|
|
114
|
+
{
|
|
115
|
+
$setOnInsert: {
|
|
116
|
+
namespace: this.namespace,
|
|
117
|
+
kind: "cooldown",
|
|
118
|
+
key_hash: _id,
|
|
119
|
+
},
|
|
120
|
+
$max: { until: untilDate, expires_at: untilDate },
|
|
121
|
+
$set: { updated_at: new Date() },
|
|
122
|
+
},
|
|
123
|
+
{ upsert: true },
|
|
124
|
+
);
|
|
125
|
+
return untilDate;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async getCooldown(key) {
|
|
129
|
+
await this._ensureIndexes();
|
|
130
|
+
const _id = hashKey(this.namespace, "cooldown", key);
|
|
131
|
+
const document = await this.collection.findOne(
|
|
132
|
+
{ _id, until: { $gt: new Date() } },
|
|
133
|
+
{ readPreference: "primary" },
|
|
134
|
+
);
|
|
135
|
+
return document?.until || null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async _ensureIndexes() {
|
|
139
|
+
if (!this._indexes)
|
|
140
|
+
this._indexes = Promise.all([
|
|
141
|
+
this.collection.createIndex(
|
|
142
|
+
{ expires_at: 1 },
|
|
143
|
+
{ expireAfterSeconds: 0, name: "expires_at_ttl" },
|
|
144
|
+
),
|
|
145
|
+
this.collection.createIndex(
|
|
146
|
+
{ namespace: 1, kind: 1 },
|
|
147
|
+
{ name: "namespace_kind" },
|
|
148
|
+
),
|
|
149
|
+
]).catch((error) => {
|
|
150
|
+
this._indexes = null;
|
|
151
|
+
throw error;
|
|
152
|
+
});
|
|
153
|
+
return this._indexes;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
_metric(name, fields) {
|
|
157
|
+
if (typeof this.metrics === "function") this.metrics(name, fields);
|
|
158
|
+
else if (typeof this.metrics?.increment === "function")
|
|
159
|
+
this.metrics.increment(name, fields);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
module.exports = MongoCoordinator;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
class MemcachedRateLimitStore {
|
|
2
|
+
constructor(options = {}) {
|
|
3
|
+
if (!options.cache)
|
|
4
|
+
throw new Error("MemcachedRateLimitStore requires cache");
|
|
5
|
+
this.cache = options.cache;
|
|
6
|
+
this.prefix = String(options.prefix || "rl:");
|
|
7
|
+
this.windowMs = Math.max(1, Number(options.windowMs) || 60000);
|
|
8
|
+
this.localKeys = false;
|
|
9
|
+
this.memory = new Map();
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
init(options = {}) {
|
|
13
|
+
if (options.windowMs) this.windowMs = Math.max(1, Number(options.windowMs));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async increment(key) {
|
|
17
|
+
const bucket = this._bucket();
|
|
18
|
+
const cacheKey = `${this.prefix}${bucket.start}:${key}`;
|
|
19
|
+
let totalHits = await this.cache.increment(cacheKey, {
|
|
20
|
+
ttlMs: this.windowMs * 2,
|
|
21
|
+
});
|
|
22
|
+
if (!Number.isFinite(totalHits))
|
|
23
|
+
totalHits = this._memoryIncrement(cacheKey, bucket.end);
|
|
24
|
+
return { totalHits, resetTime: new Date(bucket.end) };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async decrement(key) {
|
|
28
|
+
const bucket = this._bucket();
|
|
29
|
+
const cacheKey = `${this.prefix}${bucket.start}:${key}`;
|
|
30
|
+
const value = await this.cache.decrement(cacheKey);
|
|
31
|
+
if (!Number.isFinite(value)) this._memoryDecrement(cacheKey);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async resetKey(key) {
|
|
35
|
+
const bucket = this._bucket();
|
|
36
|
+
const cacheKey = `${this.prefix}${bucket.start}:${key}`;
|
|
37
|
+
this.memory.delete(cacheKey);
|
|
38
|
+
await this.cache.delete(cacheKey, {
|
|
39
|
+
kind: "counter",
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async resetAll() {
|
|
44
|
+
this.memory.clear();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
_bucket(now = Date.now()) {
|
|
48
|
+
const start = Math.floor(now / this.windowMs) * this.windowMs;
|
|
49
|
+
return { start, end: start + this.windowMs };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
_memoryIncrement(key, resetAt) {
|
|
53
|
+
const current = this.memory.get(key);
|
|
54
|
+
const count =
|
|
55
|
+
!current || current.resetAt <= Date.now() ? 1 : current.count + 1;
|
|
56
|
+
this.memory.set(key, { count, resetAt });
|
|
57
|
+
return count;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
_memoryDecrement(key) {
|
|
61
|
+
const current = this.memory.get(key);
|
|
62
|
+
if (!current) return;
|
|
63
|
+
current.count = Math.max(0, current.count - 1);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
module.exports = MemcachedRateLimitStore;
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
const { promisify } = require("node:util");
|
|
2
|
+
const zlib = require("node:zlib");
|
|
3
|
+
|
|
4
|
+
const deflateRaw = promisify(zlib.deflateRaw);
|
|
5
|
+
const inflateRaw = promisify(zlib.inflateRaw);
|
|
6
|
+
|
|
7
|
+
const RAW_PREFIX = "mc1:r:";
|
|
8
|
+
const COMPRESSED_PREFIX = "mc1:z:";
|
|
9
|
+
|
|
10
|
+
function encodeValue(value) {
|
|
11
|
+
if (value === null) return ["null"];
|
|
12
|
+
if (value === undefined) return ["undefined"];
|
|
13
|
+
if (Buffer.isBuffer(value)) return ["buffer", value.toString("base64")];
|
|
14
|
+
if (value instanceof Date) return ["date", value.toISOString()];
|
|
15
|
+
if (Array.isArray(value)) return ["array", Array.from(value, encodeValue)];
|
|
16
|
+
|
|
17
|
+
switch (typeof value) {
|
|
18
|
+
case "string":
|
|
19
|
+
return ["string", value];
|
|
20
|
+
case "boolean":
|
|
21
|
+
return ["boolean", value];
|
|
22
|
+
case "bigint":
|
|
23
|
+
return ["bigint", value.toString()];
|
|
24
|
+
case "number":
|
|
25
|
+
if (Number.isNaN(value)) return ["number", "NaN"];
|
|
26
|
+
if (value === Infinity) return ["number", "Infinity"];
|
|
27
|
+
if (value === -Infinity) return ["number", "-Infinity"];
|
|
28
|
+
if (Object.is(value, -0)) return ["number", "-0"];
|
|
29
|
+
return ["number", value];
|
|
30
|
+
case "object": {
|
|
31
|
+
if (typeof value.toJSON === "function") return encodeValue(value.toJSON());
|
|
32
|
+
return [
|
|
33
|
+
"object",
|
|
34
|
+
Object.keys(value).map((key) => [key, encodeValue(value[key])]),
|
|
35
|
+
];
|
|
36
|
+
}
|
|
37
|
+
default:
|
|
38
|
+
throw new TypeError(`Unsupported cache value type '${typeof value}'`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function decodeValue(encoded) {
|
|
43
|
+
if (!Array.isArray(encoded) || typeof encoded[0] !== "string")
|
|
44
|
+
throw new TypeError("Invalid cache value envelope");
|
|
45
|
+
const [type, value] = encoded;
|
|
46
|
+
switch (type) {
|
|
47
|
+
case "null":
|
|
48
|
+
return null;
|
|
49
|
+
case "undefined":
|
|
50
|
+
return undefined;
|
|
51
|
+
case "buffer":
|
|
52
|
+
return Buffer.from(value, "base64");
|
|
53
|
+
case "date":
|
|
54
|
+
return new Date(value);
|
|
55
|
+
case "array":
|
|
56
|
+
return value.map(decodeValue);
|
|
57
|
+
case "string":
|
|
58
|
+
case "boolean":
|
|
59
|
+
return value;
|
|
60
|
+
case "bigint":
|
|
61
|
+
return BigInt(value);
|
|
62
|
+
case "number":
|
|
63
|
+
if (value === "NaN") return NaN;
|
|
64
|
+
if (value === "Infinity") return Infinity;
|
|
65
|
+
if (value === "-Infinity") return -Infinity;
|
|
66
|
+
if (value === "-0") return -0;
|
|
67
|
+
return value;
|
|
68
|
+
case "object":
|
|
69
|
+
return Object.fromEntries(
|
|
70
|
+
value.map(([key, item]) => [key, decodeValue(item)]),
|
|
71
|
+
);
|
|
72
|
+
default:
|
|
73
|
+
throw new TypeError(`Unsupported cache value envelope '${type}'`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function encodeEntry(entry, options = {}) {
|
|
78
|
+
const compressionThreshold = Math.max(
|
|
79
|
+
0,
|
|
80
|
+
Number(options.compressionThreshold) || 64 * 1024,
|
|
81
|
+
);
|
|
82
|
+
const raw = Buffer.from(
|
|
83
|
+
JSON.stringify({ version: 1, entry: encodeValue(entry) }),
|
|
84
|
+
);
|
|
85
|
+
let prefix = RAW_PREFIX;
|
|
86
|
+
let payload = raw;
|
|
87
|
+
if (raw.length >= compressionThreshold) {
|
|
88
|
+
const compressed = await deflateRaw(raw, {
|
|
89
|
+
level: zlib.constants.Z_BEST_SPEED,
|
|
90
|
+
});
|
|
91
|
+
if (compressed.length <= raw.length * 0.9) {
|
|
92
|
+
prefix = COMPRESSED_PREFIX;
|
|
93
|
+
payload = compressed;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
const encoded = `${prefix}${payload.toString("base64")}`;
|
|
97
|
+
return {
|
|
98
|
+
encoded,
|
|
99
|
+
bytes: Buffer.byteLength(encoded),
|
|
100
|
+
compressed: prefix === COMPRESSED_PREFIX,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function decodeEntry(encoded) {
|
|
105
|
+
if (typeof encoded !== "string") return null;
|
|
106
|
+
let payload;
|
|
107
|
+
if (encoded.startsWith(RAW_PREFIX)) {
|
|
108
|
+
payload = Buffer.from(encoded.slice(RAW_PREFIX.length), "base64");
|
|
109
|
+
} else if (encoded.startsWith(COMPRESSED_PREFIX)) {
|
|
110
|
+
payload = await inflateRaw(
|
|
111
|
+
Buffer.from(encoded.slice(COMPRESSED_PREFIX.length), "base64"),
|
|
112
|
+
);
|
|
113
|
+
} else {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
const envelope = JSON.parse(payload.toString("utf8"));
|
|
117
|
+
if (!envelope || envelope.version !== 1) return null;
|
|
118
|
+
const entry = decodeValue(envelope.entry);
|
|
119
|
+
return entry && entry.version === 1 ? entry : null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
module.exports = { decodeEntry, decodeValue, encodeEntry, encodeValue };
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@managani/cache",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Shared Memcached cache and MongoDB coordination primitives",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"type": "commonjs",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"files": [
|
|
9
|
+
"index.js",
|
|
10
|
+
"lib",
|
|
11
|
+
"README.md",
|
|
12
|
+
"LICENSE"
|
|
13
|
+
],
|
|
14
|
+
"scripts": {
|
|
15
|
+
"test": "node --test test/cache.test.js test/mongo_coordinator.test.js",
|
|
16
|
+
"test:integration": "node --test test/memcached.integration.test.js",
|
|
17
|
+
"test:cluster": "node test/cluster_chaos.js"
|
|
18
|
+
},
|
|
19
|
+
"keywords": [
|
|
20
|
+
"cache",
|
|
21
|
+
"memcached",
|
|
22
|
+
"mongodb",
|
|
23
|
+
"distributed-cache"
|
|
24
|
+
],
|
|
25
|
+
"repository": {
|
|
26
|
+
"type": "git",
|
|
27
|
+
"url": "git+https://github.com/streamient/managani.git",
|
|
28
|
+
"directory": "packages/cache"
|
|
29
|
+
},
|
|
30
|
+
"publishConfig": {
|
|
31
|
+
"access": "public"
|
|
32
|
+
},
|
|
33
|
+
"engines": {
|
|
34
|
+
"node": ">=24"
|
|
35
|
+
},
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"memcache": "1.10.0"
|
|
38
|
+
}
|
|
39
|
+
}
|