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.
- package/.github/workflows/CI.yml +22 -1
- package/Cargo.toml +2 -2
- package/artifacts/bindings-aarch64-apple-darwin/index.d.ts +37 -25
- package/artifacts/bindings-aarch64-apple-darwin/index.js +165 -295
- package/artifacts/bindings-aarch64-unknown-linux-musl/index.d.ts +42 -0
- package/artifacts/bindings-aarch64-unknown-linux-musl/index.js +186 -0
- package/artifacts/bindings-x86_64-apple-darwin/index.d.ts +37 -25
- package/artifacts/bindings-x86_64-apple-darwin/index.js +165 -295
- package/artifacts/bindings-x86_64-pc-windows-msvc/index.d.ts +42 -30
- package/artifacts/bindings-x86_64-pc-windows-msvc/index.js +186 -316
- package/artifacts/bindings-x86_64-unknown-linux-gnu/index.d.ts +37 -25
- package/artifacts/bindings-x86_64-unknown-linux-gnu/index.js +165 -295
- package/artifacts/bindings-x86_64-unknown-linux-musl/index.d.ts +42 -0
- package/artifacts/bindings-x86_64-unknown-linux-musl/index.js +186 -0
- package/benchmarks/crossover_bench.js +139 -0
- package/docs/.vitepress/config.js +35 -0
- package/docs/guide/api.md +216 -0
- package/docs/guide/architecture.md +58 -0
- package/docs/guide/benchmarks.md +48 -0
- package/docs/guide/getting-started.md +74 -0
- package/docs/index.md +23 -0
- package/index.d.ts +37 -25
- package/index.js +165 -295
- package/package.json +21 -12
- package/src/algorithms/arc.rs +123 -25
- package/src/algorithms/lru.rs +94 -12
- package/src/algorithms/mod.rs +4 -0
- package/src/algorithms/tinylfu.rs +98 -35
- package/src/cache.rs +289 -94
- 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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
let
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
|
145
|
-
let
|
|
146
|
-
lock.
|
|
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
|
|
151
|
-
let
|
|
152
|
-
lock.
|
|
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
|
|
157
|
-
let
|
|
158
|
-
let
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
|
169
|
-
let
|
|
170
|
-
|
|
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
|
}
|
package/tests/index.test.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
const test = require('node:test');
|
|
2
2
|
const assert = require('node:assert');
|
|
3
|
-
const { CacheManager } = require('../index.js');
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
+
});
|