@mblaney/holster 1.0.13 → 1.0.14
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 +88 -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,43 @@ 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
|
+
const now = Date.now()
|
|
71
|
+
if (now - lastMemoryCheck < MEMORY_CHECK_INTERVAL) return
|
|
72
|
+
lastMemoryCheck = now
|
|
73
|
+
|
|
74
|
+
const memUsage = process.memoryUsage()
|
|
75
|
+
const heapUsed = memUsage.heapUsed
|
|
76
|
+
const heapTotal = memUsage.heapTotal
|
|
77
|
+
const maxHeapSize = opt.memoryLimit * 1024 * 1024
|
|
78
|
+
const heapUsageRatio = heapUsed / maxHeapSize
|
|
79
|
+
|
|
80
|
+
if (heapUsageRatio > HEAP_CLEANUP_THRESHOLD) {
|
|
81
|
+
const cacheSize = cache.size
|
|
82
|
+
console.log(
|
|
83
|
+
`[RADISK-MEMORY] High memory usage: ${Math.round(heapUsageRatio * 100)}% of heap limit. Clearing ${cacheSize} cached files.`,
|
|
84
|
+
)
|
|
85
|
+
cache.clear()
|
|
86
|
+
// Force garbage collection if available
|
|
87
|
+
if (global.gc) {
|
|
88
|
+
global.gc()
|
|
89
|
+
}
|
|
90
|
+
// Reset timer to avoid immediate re-check until GC has time to work
|
|
91
|
+
lastMemoryCheck = now + MEMORY_CHECK_INTERVAL
|
|
92
|
+
} else if (heapUsageRatio > HEAP_WARNING_THRESHOLD) {
|
|
93
|
+
console.log(
|
|
94
|
+
`[RADISK-MEMORY] Memory warning: ${Math.round(heapUsageRatio * 100)}% of heap limit used. Cache size: ${cache.size} files.`,
|
|
95
|
+
)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
56
99
|
// Any and all storage adapters should:
|
|
57
100
|
// 1. Because writing to disk takes time, we should batch data to disk.
|
|
58
101
|
// This improves performance, and reduces potential disk corruption.
|
|
@@ -100,6 +143,8 @@ const Radisk = opt => {
|
|
|
100
143
|
|
|
101
144
|
// Otherwise store the value provided.
|
|
102
145
|
radisk.batch(key, value)
|
|
146
|
+
// Check memory usage after writing to in-memory radix tree
|
|
147
|
+
checkMemoryUsage()
|
|
103
148
|
if (cb) {
|
|
104
149
|
radisk.batch.acks.push(cb)
|
|
105
150
|
}
|
|
@@ -221,8 +266,6 @@ const Radisk = opt => {
|
|
|
221
266
|
}
|
|
222
267
|
|
|
223
268
|
radisk.write = (file, rad, cb) => {
|
|
224
|
-
// Invalidate cache entry for this file on write
|
|
225
|
-
cache.delete(file)
|
|
226
269
|
const write = {
|
|
227
270
|
text: "",
|
|
228
271
|
limit: "",
|
|
@@ -291,13 +334,14 @@ const Radisk = opt => {
|
|
|
291
334
|
}
|
|
292
335
|
|
|
293
336
|
// Check multi-file cache first
|
|
294
|
-
if (cache.has(read.file)) {
|
|
337
|
+
if (opt.cache && cache.has(read.file)) {
|
|
295
338
|
const cachedRadix = cache.get(read.file)
|
|
296
339
|
read.value = cachedRadix(key)
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
340
|
+
// Return cached result (defined or undefined) since in-memory
|
|
341
|
+
// radix tree is authoritative
|
|
342
|
+
return cb(u, read.value)
|
|
300
343
|
}
|
|
344
|
+
|
|
301
345
|
radisk.parse(read.file, read.it)
|
|
302
346
|
return
|
|
303
347
|
}
|
|
@@ -310,14 +354,42 @@ const Radisk = opt => {
|
|
|
310
354
|
it: (err, disk) => {
|
|
311
355
|
if (err) opt.log(err)
|
|
312
356
|
if (disk) {
|
|
313
|
-
|
|
314
|
-
|
|
357
|
+
if (opt.cache) {
|
|
358
|
+
cache.set(read.file, disk)
|
|
359
|
+
checkMemoryUsage() // Check memory usage after adding to cache
|
|
360
|
+
}
|
|
315
361
|
read.value = disk(key)
|
|
316
362
|
}
|
|
317
363
|
cb(err, read.value)
|
|
318
364
|
},
|
|
319
365
|
}
|
|
320
|
-
|
|
366
|
+
const now = Date.now()
|
|
367
|
+
if (
|
|
368
|
+
opt.cache &&
|
|
369
|
+
fileListCache &&
|
|
370
|
+
now - fileListCacheTime < FILE_LIST_CACHE_TTL
|
|
371
|
+
) {
|
|
372
|
+
// Use cached file list
|
|
373
|
+
fileListCache.forEach(file => read.lex(file))
|
|
374
|
+
read.lex() // Signal end of list
|
|
375
|
+
} else {
|
|
376
|
+
// Refresh cache
|
|
377
|
+
const files = []
|
|
378
|
+
const originalLex = read.lex
|
|
379
|
+
read.lex = file => {
|
|
380
|
+
if (file) files.push(file)
|
|
381
|
+
return originalLex(file)
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
opt.store.list(file => {
|
|
385
|
+
read.lex(file)
|
|
386
|
+
if (!file && opt.cache) {
|
|
387
|
+
// End of list - cache the results
|
|
388
|
+
fileListCache = files
|
|
389
|
+
fileListCacheTime = now
|
|
390
|
+
}
|
|
391
|
+
})
|
|
392
|
+
}
|
|
321
393
|
}
|
|
322
394
|
|
|
323
395
|
// 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
|
+
})
|