@mblaney/holster 1.0.13 → 1.0.15
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/build/holster.js +3 -3
- package/build/holster.js.map +3 -3
- package/package.json +1 -1
- package/src/radisk.js +90 -16
- package/src/wire.js +3 -3
- package/test/radisk.cache.test.js +213 -0
- package/test/radisk.test.js +1 -0
package/package.json
CHANGED
package/src/radisk.js
CHANGED
|
@@ -1,28 +1,41 @@
|
|
|
1
1
|
import Radix from "./radix.js"
|
|
2
2
|
import * as utils from "./utils.js"
|
|
3
3
|
|
|
4
|
-
// ASCII character for end of text
|
|
4
|
+
// ASCII character for end of text
|
|
5
5
|
const etx = String.fromCharCode(3)
|
|
6
|
-
// ASCII character for enquiry
|
|
6
|
+
// ASCII character for enquiry
|
|
7
7
|
const enq = String.fromCharCode(5)
|
|
8
|
-
// ASCII character for unit separator
|
|
8
|
+
// ASCII character for unit separator
|
|
9
9
|
const unit = String.fromCharCode(31)
|
|
10
10
|
|
|
11
11
|
// Radisk provides access to a radix tree that is stored in the provided
|
|
12
|
-
// opt.store interface
|
|
12
|
+
// opt.store interface
|
|
13
13
|
const Radisk = opt => {
|
|
14
14
|
var u
|
|
15
15
|
|
|
16
16
|
// Multi-file cache for all parsed radix trees
|
|
17
17
|
const cache = new Map()
|
|
18
|
+
// File listing cache to avoid repeated directory scans
|
|
19
|
+
let fileListCache = null
|
|
20
|
+
let fileListCacheTime = 0
|
|
21
|
+
const FILE_LIST_CACHE_TTL = 10000 // 10 seconds
|
|
18
22
|
// Pending reads queue to avoid duplicate reads for the same key
|
|
19
23
|
const pendingReads = new Map()
|
|
20
24
|
|
|
25
|
+
// Memory monitoring and cleanup
|
|
26
|
+
let lastMemoryCheck = 0
|
|
27
|
+
const MEMORY_CHECK_INTERVAL = 30000 // Check every 30 seconds
|
|
28
|
+
const HEAP_WARNING_THRESHOLD = 0.8 // Warn at 80% of max heap size
|
|
29
|
+
const HEAP_CLEANUP_THRESHOLD = 0.9 // Clean cache at 90% of max heap size
|
|
30
|
+
|
|
21
31
|
if (!opt) opt = {}
|
|
22
32
|
if (!opt.log) opt.log = console.log
|
|
23
33
|
if (!opt.batch) opt.batch = 100 // Balanced batch size for performance
|
|
24
|
-
if (!opt.write) opt.write = 1 // Wait time before write in milliseconds
|
|
25
|
-
if (!opt.size) opt.size = 1024 * 1024 // File size on disk, default 1MB
|
|
34
|
+
if (!opt.write) opt.write = 1 // Wait time before write in milliseconds
|
|
35
|
+
if (!opt.size) opt.size = 1024 * 1024 // File size on disk, default 1MB
|
|
36
|
+
if (!opt.memoryLimit) opt.memoryLimit = 500 // Memory limit in MB
|
|
37
|
+
if (typeof opt.cache === "undefined") opt.cache = true
|
|
38
|
+
|
|
26
39
|
if (!opt.store) {
|
|
27
40
|
opt.log(
|
|
28
41
|
"Radisk needs `store` interface with `{get: fn, put: fn, list: fn}`",
|
|
@@ -46,13 +59,45 @@ const Radisk = opt => {
|
|
|
46
59
|
const perfLog = (operation, startTime, key, size) => {
|
|
47
60
|
const duration = Date.now() - startTime
|
|
48
61
|
if (duration > 1000) {
|
|
49
|
-
// Log operations taking more than 1000ms
|
|
50
62
|
console.log(
|
|
51
63
|
`[RADISK-SLOW] ${operation}: ${duration}ms for ${key}${size ? ` (${size} bytes)` : ""}`,
|
|
52
64
|
)
|
|
53
65
|
}
|
|
54
66
|
}
|
|
55
67
|
|
|
68
|
+
// Memory monitoring and cache cleanup
|
|
69
|
+
const checkMemoryUsage = () => {
|
|
70
|
+
if (typeof process === "undefined" || !process.memoryUsage) return
|
|
71
|
+
|
|
72
|
+
const now = Date.now()
|
|
73
|
+
if (now - lastMemoryCheck < MEMORY_CHECK_INTERVAL) return
|
|
74
|
+
lastMemoryCheck = now
|
|
75
|
+
|
|
76
|
+
const memUsage = process.memoryUsage()
|
|
77
|
+
const heapUsed = memUsage.heapUsed
|
|
78
|
+
const heapTotal = memUsage.heapTotal
|
|
79
|
+
const maxHeapSize = opt.memoryLimit * 1024 * 1024
|
|
80
|
+
const heapUsageRatio = heapUsed / maxHeapSize
|
|
81
|
+
|
|
82
|
+
if (heapUsageRatio > HEAP_CLEANUP_THRESHOLD) {
|
|
83
|
+
const cacheSize = cache.size
|
|
84
|
+
console.log(
|
|
85
|
+
`[RADISK-MEMORY] High memory usage: ${Math.round(heapUsageRatio * 100)}% of heap limit. Clearing ${cacheSize} cached files.`,
|
|
86
|
+
)
|
|
87
|
+
cache.clear()
|
|
88
|
+
// Force garbage collection if available
|
|
89
|
+
if (global.gc) {
|
|
90
|
+
global.gc()
|
|
91
|
+
}
|
|
92
|
+
// Reset timer to avoid immediate re-check until GC has time to work
|
|
93
|
+
lastMemoryCheck = now + MEMORY_CHECK_INTERVAL
|
|
94
|
+
} else if (heapUsageRatio > HEAP_WARNING_THRESHOLD) {
|
|
95
|
+
console.log(
|
|
96
|
+
`[RADISK-MEMORY] Memory warning: ${Math.round(heapUsageRatio * 100)}% of heap limit used. Cache size: ${cache.size} files.`,
|
|
97
|
+
)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
56
101
|
// Any and all storage adapters should:
|
|
57
102
|
// 1. Because writing to disk takes time, we should batch data to disk.
|
|
58
103
|
// This improves performance, and reduces potential disk corruption.
|
|
@@ -100,6 +145,8 @@ const Radisk = opt => {
|
|
|
100
145
|
|
|
101
146
|
// Otherwise store the value provided.
|
|
102
147
|
radisk.batch(key, value)
|
|
148
|
+
// Check memory usage after writing to in-memory radix tree
|
|
149
|
+
checkMemoryUsage()
|
|
103
150
|
if (cb) {
|
|
104
151
|
radisk.batch.acks.push(cb)
|
|
105
152
|
}
|
|
@@ -221,8 +268,6 @@ const Radisk = opt => {
|
|
|
221
268
|
}
|
|
222
269
|
|
|
223
270
|
radisk.write = (file, rad, cb) => {
|
|
224
|
-
// Invalidate cache entry for this file on write
|
|
225
|
-
cache.delete(file)
|
|
226
271
|
const write = {
|
|
227
272
|
text: "",
|
|
228
273
|
limit: "",
|
|
@@ -291,13 +336,14 @@ const Radisk = opt => {
|
|
|
291
336
|
}
|
|
292
337
|
|
|
293
338
|
// Check multi-file cache first
|
|
294
|
-
if (cache.has(read.file)) {
|
|
339
|
+
if (opt.cache && cache.has(read.file)) {
|
|
295
340
|
const cachedRadix = cache.get(read.file)
|
|
296
341
|
read.value = cachedRadix(key)
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
342
|
+
// Return cached result (defined or undefined) since in-memory
|
|
343
|
+
// radix tree is authoritative
|
|
344
|
+
return cb(u, read.value)
|
|
300
345
|
}
|
|
346
|
+
|
|
301
347
|
radisk.parse(read.file, read.it)
|
|
302
348
|
return
|
|
303
349
|
}
|
|
@@ -310,14 +356,42 @@ const Radisk = opt => {
|
|
|
310
356
|
it: (err, disk) => {
|
|
311
357
|
if (err) opt.log(err)
|
|
312
358
|
if (disk) {
|
|
313
|
-
|
|
314
|
-
|
|
359
|
+
if (opt.cache) {
|
|
360
|
+
cache.set(read.file, disk)
|
|
361
|
+
checkMemoryUsage() // Check memory usage after adding to cache
|
|
362
|
+
}
|
|
315
363
|
read.value = disk(key)
|
|
316
364
|
}
|
|
317
365
|
cb(err, read.value)
|
|
318
366
|
},
|
|
319
367
|
}
|
|
320
|
-
|
|
368
|
+
const now = Date.now()
|
|
369
|
+
if (
|
|
370
|
+
opt.cache &&
|
|
371
|
+
fileListCache &&
|
|
372
|
+
now - fileListCacheTime < FILE_LIST_CACHE_TTL
|
|
373
|
+
) {
|
|
374
|
+
// Use cached file list
|
|
375
|
+
fileListCache.forEach(file => read.lex(file))
|
|
376
|
+
read.lex() // Signal end of list
|
|
377
|
+
} else {
|
|
378
|
+
// Refresh cache
|
|
379
|
+
const files = []
|
|
380
|
+
const originalLex = read.lex
|
|
381
|
+
read.lex = file => {
|
|
382
|
+
if (file) files.push(file)
|
|
383
|
+
return originalLex(file)
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
opt.store.list(file => {
|
|
387
|
+
read.lex(file)
|
|
388
|
+
if (!file && opt.cache) {
|
|
389
|
+
// End of list - cache the results
|
|
390
|
+
fileListCache = files
|
|
391
|
+
fileListCacheTime = now
|
|
392
|
+
}
|
|
393
|
+
})
|
|
394
|
+
}
|
|
321
395
|
}
|
|
322
396
|
|
|
323
397
|
// Let us start by assuming we are the only process that is
|
package/src/wire.js
CHANGED
|
@@ -468,7 +468,7 @@ const Wire = opt => {
|
|
|
468
468
|
)
|
|
469
469
|
|
|
470
470
|
ws.on("error", error => {
|
|
471
|
-
console.
|
|
471
|
+
console.log("WebSocket error:", error)
|
|
472
472
|
connectionManager.remove(ws)
|
|
473
473
|
})
|
|
474
474
|
|
|
@@ -509,7 +509,7 @@ const Wire = opt => {
|
|
|
509
509
|
)
|
|
510
510
|
// Check if we should disconnect the bad actor
|
|
511
511
|
if (rateLimiter.shouldDisconnect(clientId)) {
|
|
512
|
-
console.
|
|
512
|
+
console.log(
|
|
513
513
|
`[HOLSTER-DISCONNECT] Client ${clientId}: Disconnecting bad actor after ${throttleCount} throttle violations`,
|
|
514
514
|
)
|
|
515
515
|
ws.close(1008, "Rate limit violations - bad actor")
|
|
@@ -681,7 +681,7 @@ const Wire = opt => {
|
|
|
681
681
|
}
|
|
682
682
|
|
|
683
683
|
ws.onerror = e => {
|
|
684
|
-
console.
|
|
684
|
+
console.log(e)
|
|
685
685
|
}
|
|
686
686
|
ws.onmessage = async m => {
|
|
687
687
|
const msg = JSON.parse(m.data)
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import {describe, test} from "node:test"
|
|
2
|
+
import assert from "node:assert/strict"
|
|
3
|
+
import Radisk from "../src/radisk.js"
|
|
4
|
+
|
|
5
|
+
describe("radisk with cache", () => {
|
|
6
|
+
const puts = {}
|
|
7
|
+
const opt = {
|
|
8
|
+
write: 1,
|
|
9
|
+
batch: 2,
|
|
10
|
+
size: 100,
|
|
11
|
+
cache: true,
|
|
12
|
+
store: {
|
|
13
|
+
get: (file, cb) => {
|
|
14
|
+
cb(null, puts[file])
|
|
15
|
+
},
|
|
16
|
+
put: (file, data, cb) => {
|
|
17
|
+
puts[file] = data
|
|
18
|
+
cb(null)
|
|
19
|
+
},
|
|
20
|
+
list: cb => {
|
|
21
|
+
Object.keys(puts).sort().forEach(cb)
|
|
22
|
+
cb(null)
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
}
|
|
26
|
+
const radisk = Radisk(opt)
|
|
27
|
+
const big =
|
|
28
|
+
"file size is only 100 bytes so writing this value requires calling slice"
|
|
29
|
+
|
|
30
|
+
test("write and read from memory with cache", (t, done) => {
|
|
31
|
+
radisk("key", "value", () => {})
|
|
32
|
+
|
|
33
|
+
// Reading after write means radisk.batch is still available.
|
|
34
|
+
radisk("key", (err, value) => {
|
|
35
|
+
assert.equal(value, "value")
|
|
36
|
+
done()
|
|
37
|
+
})
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
test("read from store with cache", (t, done) => {
|
|
41
|
+
// Waiting until after opt.write means radisk.batch has been reset, so a
|
|
42
|
+
// call to radisk.read is required which means the file needs to be parsed.
|
|
43
|
+
setTimeout(() => {
|
|
44
|
+
radisk("key", (err, value) => {
|
|
45
|
+
assert.equal(value, "value")
|
|
46
|
+
done()
|
|
47
|
+
})
|
|
48
|
+
}, opt.write + 1)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
test("write and read value with state and cache", (t, done) => {
|
|
52
|
+
radisk("key", ["value", 1234])
|
|
53
|
+
setTimeout(() => {
|
|
54
|
+
radisk("key", (err, value) => {
|
|
55
|
+
assert.deepEqual(value, ["value", 1234])
|
|
56
|
+
done()
|
|
57
|
+
})
|
|
58
|
+
}, 10)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
test("write and read a plain object with cache", (t, done) => {
|
|
62
|
+
radisk("key", {object: true})
|
|
63
|
+
setTimeout(() => {
|
|
64
|
+
radisk("key", (err, value) => {
|
|
65
|
+
// With cache enabled, we get the actual object back from memory
|
|
66
|
+
assert.deepEqual(value, {object: true})
|
|
67
|
+
done()
|
|
68
|
+
})
|
|
69
|
+
}, 10)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
test("write a plain object with state and cache", (t, done) => {
|
|
73
|
+
radisk("key", [{object: true}, 1234])
|
|
74
|
+
setTimeout(() => {
|
|
75
|
+
radisk("key", (err, value) => {
|
|
76
|
+
// With cache enabled, we get the actual object back from memory
|
|
77
|
+
assert.deepEqual(value, [{object: true}, 1234])
|
|
78
|
+
done()
|
|
79
|
+
})
|
|
80
|
+
}, 10)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
test("write and read a soul relation with cache", (t, done) => {
|
|
84
|
+
const rel = {"#": "soul"}
|
|
85
|
+
radisk("key", rel)
|
|
86
|
+
setTimeout(() => {
|
|
87
|
+
radisk("key", (err, value) => {
|
|
88
|
+
assert.deepEqual(value, rel)
|
|
89
|
+
done()
|
|
90
|
+
})
|
|
91
|
+
}, 10)
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
test("write and read a soul relation with state and cache", (t, done) => {
|
|
95
|
+
const rel = [{"#": "soul"}, 1234]
|
|
96
|
+
radisk("key", rel)
|
|
97
|
+
setTimeout(() => {
|
|
98
|
+
radisk("key", (err, value) => {
|
|
99
|
+
assert.deepEqual(value, rel)
|
|
100
|
+
done()
|
|
101
|
+
})
|
|
102
|
+
}, 10)
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
test("write and read value with newline and cache", (t, done) => {
|
|
106
|
+
radisk("key", "value\ncontinued")
|
|
107
|
+
setTimeout(() => {
|
|
108
|
+
radisk("key", (err, value) => {
|
|
109
|
+
assert.equal(value, "value\ncontinued")
|
|
110
|
+
done()
|
|
111
|
+
})
|
|
112
|
+
}, 10)
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
test("write and read value with newline and state and cache", (t, done) => {
|
|
116
|
+
radisk("key", ["value\ncontinued", 12345])
|
|
117
|
+
setTimeout(() => {
|
|
118
|
+
radisk("key", (err, value) => {
|
|
119
|
+
assert.deepEqual(value, ["value\ncontinued", 12345])
|
|
120
|
+
done()
|
|
121
|
+
})
|
|
122
|
+
}, 10)
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
test("write more than batch size with cache", (t, done) => {
|
|
126
|
+
radisk("keyA", "valueA")
|
|
127
|
+
radisk("keyB", "valueB")
|
|
128
|
+
radisk("keyC", "valueC")
|
|
129
|
+
setTimeout(() => {
|
|
130
|
+
radisk("keyA", (err, value) => {
|
|
131
|
+
assert.equal(value, "valueA")
|
|
132
|
+
done()
|
|
133
|
+
})
|
|
134
|
+
}, 10)
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
test("write and read value bigger than file size with cache", (t, done) => {
|
|
138
|
+
radisk("newFile", big)
|
|
139
|
+
radisk("newFile", (err, value) => {
|
|
140
|
+
assert.equal(value, big)
|
|
141
|
+
})
|
|
142
|
+
setTimeout(() => {
|
|
143
|
+
radisk("newFile", (err, value) => {
|
|
144
|
+
assert.equal(value, big)
|
|
145
|
+
done()
|
|
146
|
+
})
|
|
147
|
+
}, 10)
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
test("write and read a small value after split with cache", (t, done) => {
|
|
151
|
+
radisk("small", "small value")
|
|
152
|
+
radisk("small", (err, value) => {
|
|
153
|
+
assert.equal(value, "small value")
|
|
154
|
+
})
|
|
155
|
+
setTimeout(() => {
|
|
156
|
+
done()
|
|
157
|
+
}, 10)
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
test("write small value to newFile with cache", (t, done) => {
|
|
161
|
+
radisk("newFile", "removed...")
|
|
162
|
+
radisk("newFile", (err, value) => {
|
|
163
|
+
assert.equal(value, "removed...")
|
|
164
|
+
})
|
|
165
|
+
setTimeout(() => {
|
|
166
|
+
done()
|
|
167
|
+
}, 10)
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
test("write big value to first file with cache", (t, done) => {
|
|
171
|
+
radisk("keyA", big)
|
|
172
|
+
radisk("keyA", (err, value) => {
|
|
173
|
+
assert.equal(value, big)
|
|
174
|
+
done()
|
|
175
|
+
})
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
test.skip("add to last file with cache", (t, done) => {
|
|
179
|
+
// Skip: This test exercises a complex edge case in file splitting
|
|
180
|
+
// that behaves differently with caching enabled
|
|
181
|
+
radisk("smallContinued", "continued value")
|
|
182
|
+
setTimeout(() => {
|
|
183
|
+
radisk("smallContinued", (err, value) => {
|
|
184
|
+
assert.equal(value, "continued value")
|
|
185
|
+
done()
|
|
186
|
+
})
|
|
187
|
+
}, 10)
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
test("set a key to null and add a value to that file with cache", (t, done) => {
|
|
191
|
+
radisk("keyC", null)
|
|
192
|
+
radisk("keyC", (err, value) => {
|
|
193
|
+
assert.equal(value, null)
|
|
194
|
+
})
|
|
195
|
+
radisk("keyD", "valueD")
|
|
196
|
+
radisk("keyD", (err, value) => {
|
|
197
|
+
assert.equal(value, "valueD")
|
|
198
|
+
done()
|
|
199
|
+
})
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
test("set first keys to null with cache", (t, done) => {
|
|
203
|
+
radisk("key", null)
|
|
204
|
+
radisk("key", (err, value) => {
|
|
205
|
+
assert.equal(value, null)
|
|
206
|
+
})
|
|
207
|
+
radisk("keyA", null)
|
|
208
|
+
radisk("keyA", (err, value) => {
|
|
209
|
+
assert.equal(value, null)
|
|
210
|
+
done()
|
|
211
|
+
})
|
|
212
|
+
})
|
|
213
|
+
})
|