@nxtedition/lib 26.4.8 → 26.6.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/package.json +2 -1
- package/shared.js +367 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nxtedition/lib",
|
|
3
|
-
"version": "26.
|
|
3
|
+
"version": "26.6.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"author": "Robert Nagy <robert.nagy@boffins.se>",
|
|
6
6
|
"type": "module",
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
"deepstream.js",
|
|
25
25
|
"deepstream.d.ts",
|
|
26
26
|
"sequence.js",
|
|
27
|
+
"shared.js",
|
|
27
28
|
"logger.js",
|
|
28
29
|
"logger.d.ts",
|
|
29
30
|
"mime.js",
|
package/shared.js
ADDED
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
// By placing the read and write indices far apart (multiples of a common
|
|
2
|
+
// cache line size, 64 bytes), we prevent "false sharing". This is a
|
|
3
|
+
// low-level CPU optimization where two cores writing to different variables
|
|
4
|
+
// that happen to be on the same cache line would otherwise constantly
|
|
5
|
+
// invalidate each other's caches, hurting performance.
|
|
6
|
+
// Int32 is 4 bytes, so an index of 16 means 16 * 4 = 64 bytes offset.
|
|
7
|
+
const WRITE_INDEX = 0
|
|
8
|
+
const READ_INDEX = 16
|
|
9
|
+
|
|
10
|
+
// High-Water Mark for batching operations to reduce the frequency
|
|
11
|
+
// of expensive atomic writes.
|
|
12
|
+
const HWM_BYTES = 256 * 1024 // 256 KiB
|
|
13
|
+
const HWM_COUNT = 1024 // 1024 items
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Allocates the shared memory buffers.
|
|
17
|
+
* @param {number} size The size of the main data buffer in bytes.
|
|
18
|
+
* @returns {{sharedState: SharedArrayBuffer, sharedBuffer: SharedArrayBuffer}}
|
|
19
|
+
*/
|
|
20
|
+
export function alloc(size) {
|
|
21
|
+
return {
|
|
22
|
+
// A small buffer for sharing state (read/write pointers).
|
|
23
|
+
sharedState: new SharedArrayBuffer(128),
|
|
24
|
+
// The main buffer for transferring data.
|
|
25
|
+
sharedBuffer: new SharedArrayBuffer(size),
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Creates a reader for the ring buffer.
|
|
31
|
+
* @param {{ sharedState: SharedArrayBuffer, sharedBuffer: SharedArrayBuffer, hwmItems?: number }} options
|
|
32
|
+
* @returns {{ readSome: function }}
|
|
33
|
+
*/
|
|
34
|
+
export function reader({ sharedState, sharedBuffer }) {
|
|
35
|
+
const state = new Int32Array(sharedState)
|
|
36
|
+
const size = sharedBuffer.byteLength
|
|
37
|
+
const buffer = Buffer.from(sharedBuffer)
|
|
38
|
+
const view = new DataView(sharedBuffer)
|
|
39
|
+
|
|
40
|
+
// This object is reused to avoid creating new objects in a hot path.
|
|
41
|
+
// This helps V8 maintain a stable hidden class for the object,
|
|
42
|
+
// which is a key optimization (zero-copy read).
|
|
43
|
+
const data = { buffer, view, offset: 0, length: 0 }
|
|
44
|
+
|
|
45
|
+
// Local copies of the pointers. The `| 0` is a hint to the V8 JIT
|
|
46
|
+
// compiler that these are 32-bit integers, enabling optimizations.
|
|
47
|
+
let readPos = Atomics.load(state, READ_INDEX) | 0
|
|
48
|
+
let writePos = Atomics.load(state, WRITE_INDEX) | 0
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Reads a batch of messages from the buffer.
|
|
52
|
+
* @param {(data: {buffer: Buffer, view: DataView, offset: number, length: number}) => void} next Callback to process a message.
|
|
53
|
+
* @returns {number} The number of messages read.
|
|
54
|
+
*/
|
|
55
|
+
function readSome(next) {
|
|
56
|
+
let count = 0
|
|
57
|
+
let bytes = 0
|
|
58
|
+
|
|
59
|
+
// First, check if the local writePos matches the readPos.
|
|
60
|
+
// If so, refresh it from shared memory in case the writer has added data.
|
|
61
|
+
if (readPos === writePos) {
|
|
62
|
+
writePos = Atomics.load(state, WRITE_INDEX) | 0
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Process messages in a batch to minimize loop and atomic operation overhead.
|
|
66
|
+
while (count < HWM_COUNT && bytes < HWM_BYTES && readPos !== writePos) {
|
|
67
|
+
const dataPos = readPos + 4
|
|
68
|
+
const dataLen = view.getInt32(dataPos - 4, true) | 0
|
|
69
|
+
|
|
70
|
+
// A length of -1 is a special marker indicating the writer has
|
|
71
|
+
// wrapped around to the beginning of the buffer.
|
|
72
|
+
if (dataLen === -1) {
|
|
73
|
+
bytes += 4
|
|
74
|
+
readPos = 0
|
|
75
|
+
// After wrapping, we must re-check against the writer's position.
|
|
76
|
+
// It's possible the writer is now at a position > 0.
|
|
77
|
+
writePos = Atomics.load(state, WRITE_INDEX) | 0
|
|
78
|
+
} else {
|
|
79
|
+
if (dataLen < 0) {
|
|
80
|
+
throw new Error('Invalid data length')
|
|
81
|
+
}
|
|
82
|
+
if (dataPos + dataLen > size) {
|
|
83
|
+
throw new Error('Data exceeds buffer size')
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
bytes += dataLen
|
|
87
|
+
readPos += 4 + dataLen
|
|
88
|
+
count += 1
|
|
89
|
+
|
|
90
|
+
// This is a "zero-copy" operation. We don't copy the data out.
|
|
91
|
+
// Instead, we pass a "view" into the shared buffer.
|
|
92
|
+
data.offset = dataPos
|
|
93
|
+
data.length = dataLen
|
|
94
|
+
next(data)
|
|
95
|
+
|
|
96
|
+
if (readPos === writePos) {
|
|
97
|
+
// If we reach the end of the buffer, we must re-check the writer's position.
|
|
98
|
+
writePos = Atomics.load(state, WRITE_INDEX) | 0
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// IMPORTANT: The reader only updates its shared `readPos` after a batch
|
|
104
|
+
// is processed. This significantly reduces atomic write contention.
|
|
105
|
+
if (bytes > 0) {
|
|
106
|
+
Atomics.store(state, READ_INDEX, readPos)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return count
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return { readSome }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* @param {{ sharedState: SharedArrayBuffer, sharedBuffer: SharedArrayBuffer }} param0
|
|
117
|
+
* @param {{ yield?: function, logger?: import('pino').Logger }} param1
|
|
118
|
+
* @returns {{ write: function, cork: function(function) }}
|
|
119
|
+
*/
|
|
120
|
+
export function writer({ sharedState, sharedBuffer }, { yield: onYield, logger } = {}) {
|
|
121
|
+
const state = new Int32Array(sharedState)
|
|
122
|
+
const size = sharedBuffer.byteLength
|
|
123
|
+
const buffer = Buffer.from(sharedBuffer)
|
|
124
|
+
const view = new DataView(sharedBuffer)
|
|
125
|
+
|
|
126
|
+
// This object is reused to avoid creating new objects in a hot path.
|
|
127
|
+
// This helps V8 maintain a stable hidden class for the object,
|
|
128
|
+
// which is a key optimization (zero-copy read).
|
|
129
|
+
const data = { buffer, view, offset: 0, length: 0 }
|
|
130
|
+
|
|
131
|
+
// Local copies of the pointers. The `| 0` is a hint to the V8 JIT
|
|
132
|
+
// compiler that these are 32-bit integers, enabling optimizations.
|
|
133
|
+
let readPos = Atomics.load(state, READ_INDEX) | 0
|
|
134
|
+
let writePos = Atomics.load(state, WRITE_INDEX) | 0
|
|
135
|
+
|
|
136
|
+
let yielding = false
|
|
137
|
+
let corked = 0
|
|
138
|
+
let pending = 0
|
|
139
|
+
|
|
140
|
+
if (onYield != null && typeof onYield !== 'function') {
|
|
141
|
+
throw new TypeError('onYield must be a function')
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Pauses the writer thread to wait for the reader to catch up.
|
|
146
|
+
* @param {number} delay The timeout for Atomics.wait.
|
|
147
|
+
*/
|
|
148
|
+
function _yield(delay) {
|
|
149
|
+
if (yielding) {
|
|
150
|
+
throw new Error('Cannot yield while yielding')
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// First, ensure the very latest write position is visible to the reader.
|
|
154
|
+
_flush()
|
|
155
|
+
|
|
156
|
+
if (onYield) {
|
|
157
|
+
yielding = true
|
|
158
|
+
try {
|
|
159
|
+
// Call the user-provided yield function, if any. This can be important
|
|
160
|
+
// if the writer is waiting for the reader to process data which would
|
|
161
|
+
// otherwise deadlock.
|
|
162
|
+
onYield()
|
|
163
|
+
} finally {
|
|
164
|
+
yielding = false
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Atomics.wait is the most efficient way to pause. It puts the thread
|
|
169
|
+
// to sleep, consuming no CPU, until the reader changes the READ_INDEX.
|
|
170
|
+
if (delay > 0) {
|
|
171
|
+
Atomics.wait(state, READ_INDEX, readPos, delay)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// After waking up, refresh the local view of the reader's position.
|
|
175
|
+
readPos = Atomics.load(state, READ_INDEX) | 0
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Tries to acquire enough space in the buffer for a new message.
|
|
180
|
+
* @param {number} len The length of the data payload to be written.
|
|
181
|
+
* @returns {boolean} True if space was acquired, false otherwise.
|
|
182
|
+
*/
|
|
183
|
+
function _acquire(len) {
|
|
184
|
+
// Total space required: payload + its 4-byte length header + a potential
|
|
185
|
+
// 4-byte header for the *next* message (for wrap-around check).
|
|
186
|
+
const required = len + 4 + 4
|
|
187
|
+
|
|
188
|
+
if (required < 0) {
|
|
189
|
+
throw new Error(`Required length ${required} is negative, expected at least 0`)
|
|
190
|
+
}
|
|
191
|
+
if (required > size) {
|
|
192
|
+
throw new Error(`Required length ${required} exceeds buffer size ${size}`)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (writePos >= readPos) {
|
|
196
|
+
// Case 1: The writer is ahead of the reader. [ 0 ---- R ... W ---- S ]
|
|
197
|
+
// There is free space from W to the end (S) and from 0 to R.
|
|
198
|
+
if (size - writePos >= required) {
|
|
199
|
+
// Enough space at the end of the buffer.
|
|
200
|
+
return true
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Not enough space at the end. Check if there's space at the beginning.
|
|
204
|
+
if (readPos === 0) {
|
|
205
|
+
// Reader is at the very beginning, so no space to wrap around into.
|
|
206
|
+
return false
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Mark the current position with a wrap-around signal (-1).
|
|
210
|
+
view.setInt32(writePos, -1, true)
|
|
211
|
+
|
|
212
|
+
// Reset writer position to the beginning.
|
|
213
|
+
writePos = 0
|
|
214
|
+
|
|
215
|
+
if (writePos + 4 > size) {
|
|
216
|
+
throw new Error(`Write position ${writePos} with next header exceeds buffer size ${size}`)
|
|
217
|
+
}
|
|
218
|
+
if (writePos === readPos) {
|
|
219
|
+
throw new Error(`Write position ${writePos} cannot equal read position ${readPos}`)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
Atomics.store(state, WRITE_INDEX, writePos)
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Case 2: The writer has wrapped around. [ 0 ... W ---- R ... S ]
|
|
226
|
+
// The only free space is between W and R.
|
|
227
|
+
return readPos - writePos >= required
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* "Uncorks" the stream by publishing the pending write position.
|
|
232
|
+
* This is called from a microtask to batch atomic stores.
|
|
233
|
+
*/
|
|
234
|
+
function _uncork() {
|
|
235
|
+
corked -= 1
|
|
236
|
+
if (corked === 0) {
|
|
237
|
+
_flush()
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Performs the actual write into the buffer after space has been acquired.
|
|
243
|
+
* @param {number} len The exact length of the payload.
|
|
244
|
+
* @param {({ buffer, view, offset, length }) => number} fn The callback that writes the data.
|
|
245
|
+
* @returns {void}
|
|
246
|
+
*/
|
|
247
|
+
function _write(len, fn) {
|
|
248
|
+
const dataPos = writePos + 4
|
|
249
|
+
|
|
250
|
+
data.offset = dataPos
|
|
251
|
+
data.length = len
|
|
252
|
+
|
|
253
|
+
// The user-provided function writes the data and returns the final position.
|
|
254
|
+
// We calculate the actual bytes written from that.
|
|
255
|
+
const dataLen = fn(data) - dataPos
|
|
256
|
+
|
|
257
|
+
if (dataLen < 0) {
|
|
258
|
+
throw new Error(`Data length ${dataLen} is negative`)
|
|
259
|
+
}
|
|
260
|
+
if (dataLen > len) {
|
|
261
|
+
throw new Error(`Data length ${dataLen} exceeds expected length ${len}`)
|
|
262
|
+
}
|
|
263
|
+
if (dataPos + dataLen > size) {
|
|
264
|
+
throw new Error(`Data position ${dataPos} with length ${dataLen} exceeds buffer size ${size}`)
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Write the actual length of the data into the 4-byte header.
|
|
268
|
+
view.setInt32(writePos, dataLen, true)
|
|
269
|
+
writePos += 4 + dataLen
|
|
270
|
+
|
|
271
|
+
if (writePos + 4 > size) {
|
|
272
|
+
throw new Error(`Write position ${writePos} with next header exceeds buffer size ${size}`)
|
|
273
|
+
}
|
|
274
|
+
if (writePos === readPos) {
|
|
275
|
+
throw new Error(`Write position ${writePos} cannot equal read position ${readPos}`)
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// This is the "corking" optimization. Instead of calling Atomics.store
|
|
279
|
+
// on every write, we batch them. We either write when a certain
|
|
280
|
+
// amount of data is pending (HWM_BYTES) or at the end of the current
|
|
281
|
+
// JS microtask. This drastically reduces atomic operation overhead.
|
|
282
|
+
if (pending >= HWM_BYTES) {
|
|
283
|
+
Atomics.store(state, WRITE_INDEX, writePos)
|
|
284
|
+
pending = 0
|
|
285
|
+
} else {
|
|
286
|
+
pending += 4 + dataLen
|
|
287
|
+
if (corked === 0) {
|
|
288
|
+
corked += 1
|
|
289
|
+
setImmediate(_uncork)
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Public write method. Acquires space and writes data.
|
|
296
|
+
* @param {number} len The maximum expected length of the payload.
|
|
297
|
+
* @param {({ buffer, view, offset, length }) => number} fn The callback that writes the data.
|
|
298
|
+
*/
|
|
299
|
+
function writeSync(len, fn) {
|
|
300
|
+
if (len < 0) {
|
|
301
|
+
throw new Error(`Length ${len} is negative`)
|
|
302
|
+
}
|
|
303
|
+
if (len >= 2 ** 31 || len > size - 8) {
|
|
304
|
+
throw new Error(`Length ${len} exceeds maximum allowed size`)
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
if (!_acquire(len)) {
|
|
308
|
+
readPos = Atomics.load(state, READ_INDEX) | 0
|
|
309
|
+
if (!_acquire(len)) {
|
|
310
|
+
const startTime = performance.now()
|
|
311
|
+
logger?.warn({ readPos, writePos }, 'yield started')
|
|
312
|
+
_yield(0)
|
|
313
|
+
while (!_acquire(len)) {
|
|
314
|
+
_yield(3)
|
|
315
|
+
}
|
|
316
|
+
const elapsedTime = performance.now() - startTime
|
|
317
|
+
logger?.warn({ readPos, writePos, elapsedTime }, 'yield completed')
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
_write(len, fn)
|
|
322
|
+
|
|
323
|
+
if (writePos === readPos) {
|
|
324
|
+
throw new Error(`Write position ${writePos} cannot equal read position ${readPos}`)
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function tryWrite(len, fn) {
|
|
329
|
+
if (len < 0) {
|
|
330
|
+
throw new Error(`Length ${len} is negative`)
|
|
331
|
+
}
|
|
332
|
+
if (len >= 2 ** 31 || len > size - 8) {
|
|
333
|
+
throw new Error(`Length ${len} exceeds maximum allowed size`)
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
if (!_acquire(len)) {
|
|
337
|
+
readPos = Atomics.load(state, READ_INDEX) | 0
|
|
338
|
+
if (!_acquire(len)) {
|
|
339
|
+
return false
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
_write(len, fn)
|
|
344
|
+
|
|
345
|
+
if (writePos === readPos) {
|
|
346
|
+
throw new Error(`Write position ${writePos} cannot equal read position ${readPos}`)
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function _flush() {
|
|
351
|
+
if (pending > 0) {
|
|
352
|
+
Atomics.store(state, WRITE_INDEX, writePos)
|
|
353
|
+
pending = 0
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function cork(callback) {
|
|
358
|
+
corked += 1
|
|
359
|
+
try {
|
|
360
|
+
return callback()
|
|
361
|
+
} finally {
|
|
362
|
+
_uncork()
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
return { tryWrite, writeSync, cork }
|
|
367
|
+
}
|