offheap 0.1.0 → 0.2.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 (30) hide show
  1. package/.github/workflows/CI.yml +22 -1
  2. package/Cargo.toml +2 -2
  3. package/artifacts/bindings-aarch64-apple-darwin/index.d.ts +37 -25
  4. package/artifacts/bindings-aarch64-apple-darwin/index.js +165 -295
  5. package/artifacts/bindings-aarch64-unknown-linux-musl/index.d.ts +42 -0
  6. package/artifacts/bindings-aarch64-unknown-linux-musl/index.js +186 -0
  7. package/artifacts/bindings-x86_64-apple-darwin/index.d.ts +37 -25
  8. package/artifacts/bindings-x86_64-apple-darwin/index.js +165 -295
  9. package/artifacts/bindings-x86_64-pc-windows-msvc/index.d.ts +42 -30
  10. package/artifacts/bindings-x86_64-pc-windows-msvc/index.js +186 -316
  11. package/artifacts/bindings-x86_64-unknown-linux-gnu/index.d.ts +37 -25
  12. package/artifacts/bindings-x86_64-unknown-linux-gnu/index.js +165 -295
  13. package/artifacts/bindings-x86_64-unknown-linux-musl/index.d.ts +42 -0
  14. package/artifacts/bindings-x86_64-unknown-linux-musl/index.js +186 -0
  15. package/benchmarks/crossover_bench.js +139 -0
  16. package/docs/.vitepress/config.js +35 -0
  17. package/docs/guide/api.md +216 -0
  18. package/docs/guide/architecture.md +58 -0
  19. package/docs/guide/benchmarks.md +48 -0
  20. package/docs/guide/getting-started.md +74 -0
  21. package/docs/index.md +23 -0
  22. package/index.d.ts +37 -25
  23. package/index.js +165 -295
  24. package/package.json +21 -12
  25. package/src/algorithms/arc.rs +123 -25
  26. package/src/algorithms/lru.rs +94 -12
  27. package/src/algorithms/mod.rs +4 -0
  28. package/src/algorithms/tinylfu.rs +98 -35
  29. package/src/cache.rs +289 -94
  30. package/tests/index.test.js +134 -7
package/src/cache.rs CHANGED
@@ -1,11 +1,13 @@
1
1
  use std::sync::Arc;
2
- use napi::{Env, JsUnknown, JsBuffer, JsString, Result, ValueType};
3
- use super::algorithms::{CacheImpl, CacheStats, lru::LruCache, arc::ArcCache, tinylfu::TinyLfuCache};
2
+ use napi::{Env, JsUnknown, JsBuffer, JsString, JsObject, Result, ValueType};
3
+ use super::algorithms::{CacheImpl, lru::LruCache, arc::ArcCache, tinylfu::TinyLfuCache};
4
4
 
5
5
  #[napi(object)]
6
6
  pub struct CacheConfig {
7
7
  pub policy: String,
8
8
  pub capacity: u32,
9
+ pub shards: Option<u32>,
10
+ pub max_bytes: Option<f64>,
9
11
  }
10
12
 
11
13
  #[napi(object)]
@@ -14,17 +16,18 @@ pub struct CacheStatsJs {
14
16
  pub misses: f64,
15
17
  pub capacity: f64,
16
18
  pub size: f64,
19
+ pub bytes_used: f64,
17
20
  }
18
21
 
19
22
  #[napi]
20
23
  pub struct Cache {
21
- inner: Arc<parking_lot::Mutex<Box<dyn CacheImpl>>>,
24
+ shards: Vec<Arc<parking_lot::Mutex<Option<Box<dyn CacheImpl>>>>>,
22
25
  }
23
26
 
24
27
  impl Clone for Cache {
25
28
  fn clone(&self) -> Self {
26
29
  Self {
27
- inner: Arc::clone(&self.inner),
30
+ shards: self.shards.clone(),
28
31
  }
29
32
  }
30
33
  }
@@ -32,58 +35,32 @@ impl Clone for Cache {
32
35
  impl Cache {
33
36
  pub fn from_config(config: CacheConfig) -> Self {
34
37
  let capacity = config.capacity as usize;
38
+ let shards_count = config.shards.unwrap_or(8).max(1) as usize;
35
39
  let policy_lower = config.policy.to_lowercase();
36
- let inner: Box<dyn CacheImpl> = match policy_lower.as_str() {
37
- "arc" => Box::new(ArcCache::new(capacity)),
38
- "tinylfu" | "w-tinylfu" => Box::new(TinyLfuCache::new(capacity)),
39
- _ => Box::new(LruCache::new(capacity)),
40
- };
41
- Self {
42
- inner: Arc::new(parking_lot::Mutex::new(inner)),
40
+
41
+ let shard_capacity = (capacity / shards_count).max(1);
42
+ let shard_max_bytes = config.max_bytes.map(|mb| (mb as usize / shards_count).max(1));
43
+
44
+ let mut shards = Vec::with_capacity(shards_count);
45
+ for _ in 0..shards_count {
46
+ let inner: Box<dyn CacheImpl> = match policy_lower.as_str() {
47
+ "arc" => Box::new(ArcCache::new_with_max_bytes(shard_capacity, shard_max_bytes)),
48
+ "tinylfu" | "w-tinylfu" => Box::new(TinyLfuCache::new_with_max_bytes(shard_capacity, shard_max_bytes)),
49
+ _ => Box::new(LruCache::new_with_max_bytes(shard_capacity, shard_max_bytes)),
50
+ };
51
+ shards.push(Arc::new(parking_lot::Mutex::new(Some(inner))));
43
52
  }
53
+
54
+ Self { shards }
44
55
  }
45
- }
46
56
 
47
- #[napi]
48
- impl Cache {
49
- #[napi]
50
- pub fn get(&self, env: Env, key: String) -> Result<JsUnknown> {
51
- let mut lock = self.inner.lock();
52
- if let Some(bytes) = lock.get(&key) {
53
- if bytes.is_empty() {
54
- return Ok(env.get_undefined()?.into_unknown());
55
- }
56
- let tag = bytes[0];
57
- let payload = &bytes[1..];
58
- match tag {
59
- 1 => {
60
- // Buffer
61
- let js_buf = env.create_buffer_copy(payload)?;
62
- Ok(js_buf.into_unknown())
63
- }
64
- 2 => {
65
- // String
66
- let s = std::str::from_utf8(payload)
67
- .map_err(|e| napi::Error::new(napi::Status::StringExpected, e.to_string()))?;
68
- let js_str = env.create_string(s)?;
69
- Ok(js_str.into_unknown())
70
- }
71
- 3 => {
72
- // JSON
73
- let json_val: serde_json::Value = serde_json::from_slice(payload)
74
- .map_err(|e| napi::Error::new(napi::Status::InvalidArg, e.to_string()))?;
75
- let parsed = env.to_js_value(&json_val)?;
76
- Ok(parsed)
77
- }
78
- _ => Err(napi::Error::new(napi::Status::InvalidArg, "Invalid data type tag in cache storage")),
79
- }
80
- } else {
81
- Ok(env.get_undefined()?.into_unknown())
82
- }
57
+ fn get_shard(&self, key: &str) -> Result<Arc<parking_lot::Mutex<Option<Box<dyn CacheImpl>>>>> {
58
+ let hash = seahash::hash(key.as_bytes()) as usize;
59
+ let idx = hash % self.shards.len();
60
+ Ok(Arc::clone(&self.shards[idx]))
83
61
  }
84
62
 
85
- #[napi]
86
- pub fn set(&self, env: Env, key: String, value: JsUnknown, ttl_ms: Option<f64>) -> Result<JsUnknown> {
63
+ fn serialize_value(&self, env: Env, value: JsUnknown) -> Result<Vec<u8>> {
87
64
  let mut bytes = Vec::new();
88
65
  let value_type = value.get_type()?;
89
66
 
@@ -97,76 +74,294 @@ impl Cache {
97
74
  let utf8 = js_str.into_utf8()?;
98
75
  bytes.push(2); // Tag: String
99
76
  bytes.extend_from_slice(utf8.as_slice());
77
+ } else if value_type == ValueType::Number {
78
+ let num = value.coerce_to_number()?;
79
+ let val = num.get_double()?;
80
+ if val.fract() == 0.0 && val >= (i64::MIN as f64) && val <= (i64::MAX as f64) {
81
+ bytes.push(4); // Tag: i64
82
+ bytes.extend_from_slice(&(val as i64).to_ne_bytes());
83
+ } else {
84
+ let json_val = serde_json::Value::Number(serde_json::Number::from_f64(val).unwrap());
85
+ let serialized = serde_json::to_vec(&json_val)
86
+ .map_err(|e| napi::Error::new(napi::Status::InvalidArg, e.to_string()))?;
87
+ bytes.push(3); // Tag: JSON
88
+ bytes.extend_from_slice(&serialized);
89
+ }
100
90
  } else {
101
- // Treat as JSON
102
91
  let json_val: serde_json::Value = env.from_js_value(value)?;
103
92
  let serialized = serde_json::to_vec(&json_val)
104
93
  .map_err(|e| napi::Error::new(napi::Status::InvalidArg, e.to_string()))?;
105
94
  bytes.push(3); // Tag: JSON
106
95
  bytes.extend_from_slice(&serialized);
107
96
  }
97
+ Ok(bytes)
98
+ }
108
99
 
109
- let ttl = ttl_ms.map(|ms| ms as u64);
110
- let mut lock = self.inner.lock();
111
- let old_bytes = lock.set(&key, bytes, ttl);
112
-
113
- if let Some(bytes) = old_bytes {
114
- if bytes.is_empty() {
115
- return Ok(env.get_undefined()?.into_unknown());
100
+ fn deserialize_value(&self, env: Env, bytes: Vec<u8>) -> Result<JsUnknown> {
101
+ if bytes.is_empty() {
102
+ return Ok(env.get_undefined()?.into_unknown());
103
+ }
104
+ let tag = bytes[0];
105
+ let payload = &bytes[1..];
106
+ match tag {
107
+ 1 => {
108
+ let js_buf = env.create_buffer_copy(payload)?;
109
+ Ok(js_buf.into_unknown())
116
110
  }
117
- let tag = bytes[0];
118
- let payload = &bytes[1..];
119
- match tag {
120
- 1 => {
121
- let js_buf = env.create_buffer_copy(payload)?;
122
- Ok(js_buf.into_unknown())
123
- }
124
- 2 => {
125
- let s = std::str::from_utf8(payload)
126
- .map_err(|e| napi::Error::new(napi::Status::StringExpected, e.to_string()))?;
127
- let js_str = env.create_string(s)?;
128
- Ok(js_str.into_unknown())
129
- }
130
- 3 => {
131
- let json_val: serde_json::Value = serde_json::from_slice(payload)
132
- .map_err(|e| napi::Error::new(napi::Status::InvalidArg, e.to_string()))?;
133
- let parsed = env.to_js_value(&json_val)?;
134
- Ok(parsed)
111
+ 2 => {
112
+ let s = std::str::from_utf8(payload)
113
+ .map_err(|e| napi::Error::new(napi::Status::StringExpected, e.to_string()))?;
114
+ let js_str = env.create_string(s)?;
115
+ Ok(js_str.into_unknown())
116
+ }
117
+ 3 => {
118
+ let json_val: serde_json::Value = serde_json::from_slice(payload)
119
+ .map_err(|e| napi::Error::new(napi::Status::InvalidArg, e.to_string()))?;
120
+ let parsed = env.to_js_value(&json_val)?;
121
+ Ok(parsed)
122
+ }
123
+ 4 => {
124
+ if payload.len() == 8 {
125
+ let val = i64::from_ne_bytes(payload.try_into().unwrap());
126
+ let js_num = env.create_double(val as f64)?;
127
+ Ok(js_num.into_unknown())
128
+ } else {
129
+ Err(napi::Error::new(napi::Status::InvalidArg, "Counter value is corrupted"))
135
130
  }
136
- _ => Err(napi::Error::new(napi::Status::InvalidArg, "Invalid data type tag in cache storage")),
137
131
  }
132
+ _ => Err(napi::Error::new(napi::Status::InvalidArg, "Invalid data type tag in cache storage")),
133
+ }
134
+ }
135
+ }
136
+
137
+ #[napi]
138
+ impl Cache {
139
+ #[napi]
140
+ pub fn get(&self, env: Env, key: String) -> Result<JsUnknown> {
141
+ let shard_lock = self.get_shard(&key)?;
142
+ let mut lock = shard_lock.lock();
143
+ let cache = lock.as_mut().ok_or_else(|| napi::Error::new(napi::Status::GenericFailure, "Cache has been disposed"))?;
144
+
145
+ if let Some(bytes) = cache.get(&key) {
146
+ self.deserialize_value(env, bytes)
138
147
  } else {
139
148
  Ok(env.get_undefined()?.into_unknown())
140
149
  }
141
150
  }
142
151
 
143
152
  #[napi]
144
- pub fn delete(&self, key: String) -> bool {
145
- let mut lock = self.inner.lock();
146
- lock.delete(&key)
153
+ pub fn peek(&self, env: Env, key: String) -> Result<JsUnknown> {
154
+ let shard_lock = self.get_shard(&key)?;
155
+ let mut lock = shard_lock.lock();
156
+ let cache = lock.as_mut().ok_or_else(|| napi::Error::new(napi::Status::GenericFailure, "Cache has been disposed"))?;
157
+
158
+ if let Some(bytes) = cache.peek(&key) {
159
+ self.deserialize_value(env, bytes)
160
+ } else {
161
+ Ok(env.get_undefined()?.into_unknown())
162
+ }
147
163
  }
148
164
 
149
165
  #[napi]
150
- pub fn clear(&self) {
151
- let mut lock = self.inner.lock();
152
- lock.clear();
166
+ pub fn has(&self, key: String) -> Result<bool> {
167
+ let shard_lock = self.get_shard(&key)?;
168
+ let mut lock = shard_lock.lock();
169
+ let cache = lock.as_mut().ok_or_else(|| napi::Error::new(napi::Status::GenericFailure, "Cache has been disposed"))?;
170
+ Ok(cache.has(&key))
153
171
  }
154
172
 
155
173
  #[napi]
156
- pub fn stats(&self) -> CacheStatsJs {
157
- let lock = self.inner.lock();
158
- let stats = lock.stats();
159
- CacheStatsJs {
160
- hits: stats.hits as f64,
161
- misses: stats.misses as f64,
162
- capacity: stats.capacity as f64,
163
- size: stats.size as f64,
174
+ pub fn set(&self, env: Env, key: String, value: JsUnknown, ttl_ms: Option<f64>) -> Result<JsUnknown> {
175
+ let bytes = self.serialize_value(env, value)?;
176
+ let ttl = ttl_ms.map(|ms| ms as u64);
177
+
178
+ let shard_lock = self.get_shard(&key)?;
179
+ let mut lock = shard_lock.lock();
180
+ let cache = lock.as_mut().ok_or_else(|| napi::Error::new(napi::Status::GenericFailure, "Cache has been disposed"))?;
181
+
182
+ let old_bytes = cache.set(&key, bytes, ttl);
183
+ if let Some(bytes) = old_bytes {
184
+ self.deserialize_value(env, bytes)
185
+ } else {
186
+ Ok(env.get_undefined()?.into_unknown())
164
187
  }
165
188
  }
166
189
 
167
190
  #[napi]
168
- pub fn keys(&self) -> Vec<String> {
169
- let lock = self.inner.lock();
170
- lock.keys()
191
+ pub fn touch(&self, key: String, ttl_ms: Option<f64>) -> Result<bool> {
192
+ let ttl = ttl_ms.map(|ms| ms as u64);
193
+ let shard_lock = self.get_shard(&key)?;
194
+ let mut lock = shard_lock.lock();
195
+ let cache = lock.as_mut().ok_or_else(|| napi::Error::new(napi::Status::GenericFailure, "Cache has been disposed"))?;
196
+ Ok(cache.touch(&key, ttl))
197
+ }
198
+
199
+ #[napi]
200
+ pub fn delete(&self, key: String) -> Result<bool> {
201
+ let shard_lock = self.get_shard(&key)?;
202
+ let mut lock = shard_lock.lock();
203
+ let cache = lock.as_mut().ok_or_else(|| napi::Error::new(napi::Status::GenericFailure, "Cache has been disposed"))?;
204
+ Ok(cache.delete(&key))
205
+ }
206
+
207
+ #[napi]
208
+ pub fn clear(&self) -> Result<()> {
209
+ for shard_lock in &self.shards {
210
+ let mut lock = shard_lock.lock();
211
+ let cache = lock.as_mut().ok_or_else(|| napi::Error::new(napi::Status::GenericFailure, "Cache has been disposed"))?;
212
+ cache.clear();
213
+ }
214
+ Ok(())
215
+ }
216
+
217
+ #[napi]
218
+ pub fn stats(&self) -> Result<CacheStatsJs> {
219
+ let mut total_hits = 0.0;
220
+ let mut total_misses = 0.0;
221
+ let mut total_capacity = 0.0;
222
+ let mut total_size = 0.0;
223
+ let mut total_bytes = 0.0;
224
+
225
+ for shard_lock in &self.shards {
226
+ let lock = shard_lock.lock();
227
+ let cache = lock.as_ref().ok_or_else(|| napi::Error::new(napi::Status::GenericFailure, "Cache has been disposed"))?;
228
+ let stats = cache.stats();
229
+ total_hits += stats.hits as f64;
230
+ total_misses += stats.misses as f64;
231
+ total_capacity += stats.capacity as f64;
232
+ total_size += stats.size as f64;
233
+ total_bytes += stats.bytes_used as f64;
234
+ }
235
+
236
+ Ok(CacheStatsJs {
237
+ hits: total_hits,
238
+ misses: total_misses,
239
+ capacity: total_capacity,
240
+ size: total_size,
241
+ bytes_used: total_bytes,
242
+ })
243
+ }
244
+
245
+ #[napi]
246
+ pub fn keys(&self) -> Result<Vec<String>> {
247
+ let mut all_keys = Vec::new();
248
+ for shard_lock in &self.shards {
249
+ let lock = shard_lock.lock();
250
+ let cache = lock.as_ref().ok_or_else(|| napi::Error::new(napi::Status::GenericFailure, "Cache has been disposed"))?;
251
+ all_keys.extend(cache.keys());
252
+ }
253
+ Ok(all_keys)
254
+ }
255
+
256
+ #[napi]
257
+ pub fn dispose(&self) -> Result<()> {
258
+ for shard_lock in &self.shards {
259
+ let mut lock = shard_lock.lock();
260
+ *lock = None;
261
+ }
262
+ Ok(())
263
+ }
264
+
265
+ #[napi]
266
+ pub fn increment(&self, env: Env, key: String, delta: i64, ttl_ms: Option<f64>) -> Result<JsUnknown> {
267
+ let shard_lock = self.get_shard(&key)?;
268
+ let mut lock = shard_lock.lock();
269
+ let cache = lock.as_mut().ok_or_else(|| napi::Error::new(napi::Status::GenericFailure, "Cache has been disposed"))?;
270
+
271
+ let mut current_val = 0i64;
272
+
273
+ if let Some(bytes) = cache.get(&key) {
274
+ if !bytes.is_empty() {
275
+ let tag = bytes[0];
276
+ let payload = &bytes[1..];
277
+ match tag {
278
+ 4 => {
279
+ if payload.len() == 8 {
280
+ current_val = i64::from_ne_bytes(payload.try_into().unwrap());
281
+ } else {
282
+ return Err(napi::Error::new(napi::Status::InvalidArg, "Counter value is corrupted"));
283
+ }
284
+ }
285
+ 3 => {
286
+ let json_val: serde_json::Value = serde_json::from_slice(payload)
287
+ .map_err(|e| napi::Error::new(napi::Status::InvalidArg, e.to_string()))?;
288
+ if let Some(n) = json_val.as_i64() {
289
+ current_val = n;
290
+ } else {
291
+ return Err(napi::Error::new(napi::Status::InvalidArg, "Value is not a valid 64-bit integer"));
292
+ }
293
+ }
294
+ _ => return Err(napi::Error::new(napi::Status::InvalidArg, "Value is not a numeric counter")),
295
+ }
296
+ }
297
+ }
298
+
299
+ let new_val = current_val.wrapping_add(delta);
300
+ let mut new_bytes = Vec::with_capacity(9);
301
+ new_bytes.push(4); // Tag 4: i64
302
+ new_bytes.extend_from_slice(&new_val.to_ne_bytes());
303
+
304
+ let ttl = ttl_ms.map(|ms| ms as u64);
305
+ cache.set(&key, new_bytes, ttl);
306
+
307
+ let js_num = env.create_double(new_val as f64)?;
308
+ Ok(js_num.into_unknown())
309
+ }
310
+
311
+ #[napi]
312
+ pub fn decrement(&self, env: Env, key: String, delta: i64, ttl_ms: Option<f64>) -> Result<JsUnknown> {
313
+ self.increment(env, key, -delta, ttl_ms)
314
+ }
315
+
316
+ #[napi]
317
+ pub fn mget(&self, env: Env, keys: Vec<String>) -> Result<JsObject> {
318
+ let mut result_obj = env.create_object()?;
319
+ for key in keys {
320
+ let shard_lock = self.get_shard(&key)?;
321
+ let mut lock = shard_lock.lock();
322
+ let cache = lock.as_mut().ok_or_else(|| napi::Error::new(napi::Status::GenericFailure, "Cache has been disposed"))?;
323
+
324
+ if let Some(bytes) = cache.get(&key) {
325
+ let val = self.deserialize_value(env, bytes)?;
326
+ result_obj.set(&key, val)?;
327
+ }
328
+ }
329
+ Ok(result_obj)
330
+ }
331
+
332
+ #[napi]
333
+ pub fn mset(&self, env: Env, entries: JsObject, ttl_ms: Option<f64>) -> Result<()> {
334
+ let keys_array = entries.get_property_names()?;
335
+ let len = keys_array.get_array_length()?;
336
+ let ttl = ttl_ms.map(|ms| ms as u64);
337
+
338
+ for i in 0..len {
339
+ let key_unknown: JsUnknown = keys_array.get_element(i)?;
340
+ let key_str = key_unknown.coerce_to_string()?;
341
+ let key = key_str.into_utf8()?.into_owned()?;
342
+
343
+ let val: JsUnknown = entries.get_named_property(&key)?;
344
+ let bytes = self.serialize_value(env, val)?;
345
+
346
+ let shard_lock = self.get_shard(&key)?;
347
+ let mut lock = shard_lock.lock();
348
+ let cache = lock.as_mut().ok_or_else(|| napi::Error::new(napi::Status::GenericFailure, "Cache has been disposed"))?;
349
+ cache.set(&key, bytes, ttl);
350
+ }
351
+ Ok(())
352
+ }
353
+
354
+ #[napi]
355
+ pub fn mdelete(&self, keys: Vec<String>) -> Result<u32> {
356
+ let mut count = 0;
357
+ for key in keys {
358
+ let shard_lock = self.get_shard(&key)?;
359
+ let mut lock = shard_lock.lock();
360
+ let cache = lock.as_mut().ok_or_else(|| napi::Error::new(napi::Status::GenericFailure, "Cache has been disposed"))?;
361
+ if cache.delete(&key) {
362
+ count += 1;
363
+ }
364
+ }
365
+ Ok(count)
171
366
  }
172
367
  }
@@ -1,6 +1,6 @@
1
1
  const test = require('node:test');
2
2
  const assert = require('node:assert');
3
- const { CacheManager } = require('../index.js'); // NAPI generated file
3
+ const { CacheManager } = require('../index.js');
4
4
 
5
5
  test('CacheManager - isolated caches', () => {
6
6
  const manager = new CacheManager();
@@ -44,7 +44,8 @@ test('Cache - DataType Preservation (String, Buffer, JSON Object)', () => {
44
44
 
45
45
  test('Cache Policies - LRU eviction', () => {
46
46
  const manager = new CacheManager();
47
- const cache = manager.createCache('lru-evict', { policy: 'lru', capacity: 3 });
47
+ // Set shards: 1 to ensure a single eviction pool of size 3
48
+ const cache = manager.createCache('lru-evict', { policy: 'lru', capacity: 3, shards: 1 });
48
49
 
49
50
  cache.set('a', 1);
50
51
  cache.set('b', 2);
@@ -64,7 +65,8 @@ test('Cache Policies - LRU eviction', () => {
64
65
 
65
66
  test('Cache Policies - ARC adaptation & eviction', () => {
66
67
  const manager = new CacheManager();
67
- const cache = manager.createCache('arc-evict', { policy: 'arc', capacity: 4 });
68
+ // Set shards: 1 to ensure a single eviction pool of size 4
69
+ const cache = manager.createCache('arc-evict', { policy: 'arc', capacity: 4, shards: 1 });
68
70
 
69
71
  // Warm up ARC
70
72
  cache.set('a', 1);
@@ -80,22 +82,20 @@ test('Cache Policies - ARC adaptation & eviction', () => {
80
82
  // Insert e, which triggers replace. Since |T1| > p, it evicts from T1 (evicting c or d)
81
83
  cache.set('e', 5);
82
84
 
83
- // Check stats
84
85
  const stats = cache.stats();
85
86
  assert.strictEqual(stats.size, 4);
86
87
  });
87
88
 
88
89
  test('Cache Policies - W-TinyLFU eviction competition', () => {
89
90
  const manager = new CacheManager();
90
- // W-TinyLFU capacity 10
91
- const cache = manager.createCache('tinylfu-evict', { policy: 'tinylfu', capacity: 10 });
91
+ // Set shards: 1 to ensure a single eviction pool of size 10
92
+ const cache = manager.createCache('tinylfu-evict', { policy: 'tinylfu', capacity: 10, shards: 1 });
92
93
 
93
94
  // Fill cache
94
95
  for (let i = 0; i < 12; i++) {
95
96
  cache.set(`key-${i}`, i);
96
97
  }
97
98
 
98
- // Check that size is limited to capacity
99
99
  const stats = cache.stats();
100
100
  assert.strictEqual(stats.capacity, 10);
101
101
  assert.ok(stats.size <= 10);
@@ -133,3 +133,130 @@ test('Cache - keys() and delete()', () => {
133
133
 
134
134
  assert.deepStrictEqual(cache.keys().sort(), ['a', 'c']);
135
135
  });
136
+
137
+ test('Cache - touch(key, ttl)', async () => {
138
+ const manager = new CacheManager();
139
+ const cache = manager.createCache('lru-touch', { policy: 'lru', capacity: 10 });
140
+
141
+ cache.set('a', 'value', 15); // 15ms TTL
142
+ assert.strictEqual(cache.touch('a', 1000), true); // Extend to 1000ms
143
+
144
+ // Wait 30ms. It would normally have expired, but should remain due to touch
145
+ await new Promise((resolve) => setTimeout(resolve, 30));
146
+ assert.strictEqual(cache.get('a'), 'value');
147
+
148
+ assert.strictEqual(cache.touch('non-existent', 100), false);
149
+ });
150
+
151
+ test('Cache - Atomic Counters (increment / decrement)', () => {
152
+ const manager = new CacheManager();
153
+ const cache = manager.createCache('lru-counters', { policy: 'lru', capacity: 10 });
154
+
155
+ // Init and increment
156
+ assert.strictEqual(cache.increment('counter', 5), 5);
157
+ assert.strictEqual(cache.increment('counter', 2), 7);
158
+ assert.strictEqual(cache.get('counter'), 7);
159
+
160
+ // Decrement
161
+ assert.strictEqual(cache.decrement('counter', 3), 4);
162
+ assert.strictEqual(cache.decrement('counter', 1), 3);
163
+ assert.strictEqual(cache.get('counter'), 3);
164
+ });
165
+
166
+ test('Cache - Batch Operations (mget / mset / mdelete)', () => {
167
+ const manager = new CacheManager();
168
+ const cache = manager.createCache('lru-batch', { policy: 'lru', capacity: 10 });
169
+
170
+ // mset
171
+ cache.mset({ a: 1, b: 'hello', c: { x: 9 } });
172
+
173
+ // mget
174
+ const retrieved = cache.mget(['a', 'b', 'c', 'd']);
175
+ assert.deepStrictEqual(retrieved, {
176
+ a: 1,
177
+ b: 'hello',
178
+ c: { x: 9 },
179
+ });
180
+
181
+ // mdelete
182
+ assert.strictEqual(cache.mdelete(['a', 'b', 'z']), 2); // 'a' and 'b' deleted, 'z' not found
183
+ assert.strictEqual(cache.get('a'), undefined);
184
+ assert.deepStrictEqual(cache.get('c'), { x: 9 });
185
+ });
186
+
187
+ test('Cache - has() and peek()', () => {
188
+ const manager = new CacheManager();
189
+ const cache = manager.createCache('lru-has-peek', { policy: 'lru', capacity: 3, shards: 1 });
190
+
191
+ cache.set('a', 1);
192
+ cache.set('b', 2);
193
+ cache.set('c', 3);
194
+
195
+ // has
196
+ assert.strictEqual(cache.has('a'), true);
197
+ assert.strictEqual(cache.has('z'), false);
198
+
199
+ // peek does not update eviction order
200
+ assert.strictEqual(cache.peek('a'), 1);
201
+ cache.set('d', 4); // Should evict 'a' (the tail, since peek didn't promote it!)
202
+ assert.strictEqual(cache.get('a'), undefined);
203
+ });
204
+
205
+ test('Cache - getOrSet() Coalescing', async () => {
206
+ const manager = new CacheManager();
207
+ const cache = manager.createCache('lru-getorset', { policy: 'lru', capacity: 10 });
208
+
209
+ let calls = 0;
210
+ const factory = () => {
211
+ calls++;
212
+ return new Promise((resolve) => setTimeout(() => resolve('computed'), 20));
213
+ };
214
+
215
+ // Trigger concurrent calls
216
+ const [res1, res2] = await Promise.all([
217
+ cache.getOrSet('key', factory),
218
+ cache.getOrSet('key', factory),
219
+ ]);
220
+
221
+ assert.strictEqual(res1, 'computed');
222
+ assert.strictEqual(res2, 'computed');
223
+ assert.strictEqual(calls, 1); // Factory must only be called once!
224
+
225
+ assert.strictEqual(cache.get('key'), 'computed');
226
+ });
227
+
228
+ test('Cache - maxBytes Memory Eviction Limit', () => {
229
+ const manager = new CacheManager();
230
+ // Set capacity = 100, shards = 1, but maxBytes = 35.
231
+ const cache = manager.createCache('lru-bytes', {
232
+ policy: 'lru',
233
+ capacity: 100,
234
+ maxBytes: 35,
235
+ shards: 1
236
+ });
237
+
238
+ cache.set('k1', '1234567890'); // key len 2 + value len 10 = 12 bytes + 1 tag = 13 bytes.
239
+ cache.set('k2', '1234567890'); // key len 2 + value len 10 = 12 bytes + 1 tag = 13 bytes. Total = 26 bytes.
240
+
241
+ assert.strictEqual(cache.get('k1'), '1234567890');
242
+ assert.strictEqual(cache.get('k2'), '1234567890');
243
+
244
+ // Insert k3. Total would be 39 bytes (exceeds 35 maxBytes). Should evict k1.
245
+ cache.set('k3', '1234567890');
246
+
247
+ assert.strictEqual(cache.get('k1'), undefined);
248
+ assert.strictEqual(cache.get('k2'), '1234567890');
249
+ assert.strictEqual(cache.get('k3'), '1234567890');
250
+ });
251
+
252
+ test('Cache - Deterministic dispose()', () => {
253
+ const manager = new CacheManager();
254
+ const cache = manager.createCache('lru-dispose', { policy: 'lru', capacity: 10 });
255
+
256
+ cache.set('a', 1);
257
+ cache.dispose();
258
+
259
+ assert.throws(() => {
260
+ cache.get('a');
261
+ }, /Cache has been disposed/);
262
+ });