@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.
Files changed (2) hide show
  1. package/package.json +2 -1
  2. package/shared.js +367 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nxtedition/lib",
3
- "version": "26.4.8",
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
+ }