@far-world-labs/verblets 0.1.4 → 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 +38 -43
- package/.vitest.config.examples.js +4 -0
- package/DEVELOPING.md +1 -1
- package/package.json +9 -9
- package/scripts/clear-redis.js +74 -0
- package/src/chains/conversation/README.md +26 -0
- package/src/chains/conversation/index.examples.js +398 -0
- package/src/chains/conversation/index.js +126 -0
- package/src/chains/conversation/index.spec.js +148 -0
- package/src/chains/conversation/turn-policies.js +93 -0
- package/src/chains/conversation/turn-policies.md +123 -0
- package/src/chains/conversation/turn-policies.spec.js +135 -0
- package/src/chains/expect/index.js +34 -0
- package/src/chains/intersections/README.md +20 -6
- package/src/chains/intersections/index.examples.js +9 -8
- package/src/chains/intersections/index.js +39 -187
- package/src/chains/llm-logger/README.md +291 -133
- package/src/chains/llm-logger/index.js +451 -65
- package/src/chains/llm-logger/index.spec.js +85 -24
- package/src/chains/llm-logger/schema.json +105 -0
- package/src/chains/set-interval/index.examples.js +34 -6
- package/src/chains/set-interval/index.js +53 -32
- package/src/chains/themes/index.js +2 -2
- package/src/constants/common.js +7 -1
- package/src/constants/models.js +21 -9
- package/src/index.js +14 -4
- package/src/lib/assert/README.md +84 -0
- package/src/lib/assert/index.js +50 -0
- package/src/lib/ring-buffer/README.md +50 -428
- package/src/lib/ring-buffer/index.js +148 -987
- package/src/lib/ring-buffer/index.spec.js +388 -0
- package/src/verblets/conversation-turn/README.md +33 -0
- package/src/verblets/conversation-turn/index.examples.js +218 -0
- package/src/verblets/conversation-turn/index.js +68 -0
- package/src/verblets/conversation-turn/index.spec.js +77 -0
- package/src/verblets/conversation-turn-multi/README.md +31 -0
- package/src/verblets/conversation-turn-multi/index.examples.js +160 -0
- package/src/verblets/conversation-turn-multi/index.js +104 -0
- package/src/verblets/conversation-turn-multi/index.spec.js +63 -0
- package/src/verblets/intent/index.examples.js +1 -1
- package/src/verblets/intersection/index.js +46 -5
- package/src/verblets/people-list/README.md +28 -0
- package/src/verblets/people-list/index.examples.js +184 -0
- package/src/verblets/people-list/index.js +44 -0
- package/src/verblets/people-list/index.spec.js +49 -0
- package/scripts/version-bump.js +0 -33
|
@@ -1,1074 +1,235 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* Designed for high-throughput data ingestion, lane-based processing, and batch operations
|
|
6
|
-
* where memory efficiency and fast access patterns are critical.
|
|
7
|
-
*
|
|
8
|
-
* Features:
|
|
9
|
-
* - Automatic memory management with configurable size limits
|
|
10
|
-
* - Multiple cursor support for concurrent processing
|
|
11
|
-
* - Lane-based filtering and processing with independent flush loops
|
|
12
|
-
* - Batch operations optimized for various workflows
|
|
13
|
-
* - Slice operations with ID-based and index-based access
|
|
14
|
-
* - Statistics and analysis helpers
|
|
15
|
-
* - Iterator support for streaming operations
|
|
16
|
-
* - Generic data support (not limited to logs)
|
|
2
|
+
* High-performance ring buffer with single writer, multiple async readers.
|
|
3
|
+
* Uses double-buffer technique to eliminate wraparound logic.
|
|
4
|
+
* Optimized for performance over safety - assumes buffer is sized appropriately.
|
|
17
5
|
*/
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* @typedef {Object} RingBufferEntry
|
|
21
|
-
* @property {number} id - Unique incrementing identifier
|
|
22
|
-
* @property {Date} timestamp - When entry was added
|
|
23
|
-
* @property {any} data - The actual data payload
|
|
24
|
-
* @property {Map<string, any>} [meta] - Optional metadata
|
|
25
|
-
*/
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
* @typedef {Object} DataEntry
|
|
29
|
-
* @property {number} id - Unique incrementing identifier
|
|
30
|
-
* @property {Date} ts - When entry was created
|
|
31
|
-
* @property {any} raw - The raw data payload
|
|
32
|
-
* @property {Object} variables - Extracted/computed variables
|
|
33
|
-
* @property {Object} context - Context information (file, line, etc.)
|
|
34
|
-
* @property {string[]} tags - Classification tags/biomarkers
|
|
35
|
-
* @property {Map<string, any>} meta - Additional metadata
|
|
36
|
-
*/
|
|
37
|
-
|
|
38
|
-
/**
|
|
39
|
-
* @typedef {Object} ProcessingLane
|
|
40
|
-
* @property {string} id - Lane identifier
|
|
41
|
-
* @property {Function} [filter] - Filter function (entry) => boolean
|
|
42
|
-
* @property {Function} writer - Output writer function (lines: string[]) => void
|
|
43
|
-
* @property {Function} [batchHandler] - Batch processing function
|
|
44
|
-
* @property {RingBufferEntry[]} buffer - Pending entries for this lane
|
|
45
|
-
* @property {boolean} flushActive - Whether flush loop is active
|
|
46
|
-
* @property {string} [cursorName] - Associated cursor name for tracking
|
|
47
|
-
* @property {Object} [config] - Lane-specific configuration
|
|
48
|
-
*/
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* @typedef {Object} RingBufferCursor
|
|
52
|
-
* @property {string} name - Cursor identifier
|
|
53
|
-
* @property {number} position - Current position (entry ID)
|
|
54
|
-
* @property {Date} lastMoved - When cursor was last updated
|
|
55
|
-
* @property {Map<string, any>} meta - Cursor-specific metadata
|
|
56
|
-
*/
|
|
57
|
-
|
|
58
|
-
/**
|
|
59
|
-
* @typedef {Object} RingBufferStats
|
|
60
|
-
* @property {number} size - Current number of entries
|
|
61
|
-
* @property {number} capacity - Maximum capacity
|
|
62
|
-
* @property {number} totalAdded - Total entries added (including evicted)
|
|
63
|
-
* @property {number} totalEvicted - Total entries evicted
|
|
64
|
-
* @property {number} oldestId - ID of oldest entry in buffer
|
|
65
|
-
* @property {number} newestId - ID of newest entry in buffer
|
|
66
|
-
* @property {Date} oldestTimestamp - Timestamp of oldest entry
|
|
67
|
-
* @property {Date} newestTimestamp - Timestamp of newest entry
|
|
68
|
-
* @property {number} cursors - Number of active cursors
|
|
69
|
-
* @property {number} lanes - Number of active processing lanes
|
|
70
|
-
*/
|
|
71
|
-
|
|
72
|
-
/**
|
|
73
|
-
* @typedef {Object} StableBatch
|
|
74
|
-
* @property {string} batchId - Unique identifier for the batch
|
|
75
|
-
* @property {number} batchIndex - Index of the batch in the sequence
|
|
76
|
-
* @property {number} startId - Starting entry ID (inclusive)
|
|
77
|
-
* @property {number} endId - Ending entry ID (exclusive)
|
|
78
|
-
* @property {number} size - Number of entries in the batch
|
|
79
|
-
* @property {Date} timestamp - When the batch definition was created
|
|
80
|
-
*/
|
|
81
|
-
|
|
82
|
-
/**
|
|
83
|
-
* @typedef {Object} StableBatchResult
|
|
84
|
-
* @property {string} batchId - Unique identifier for the batch
|
|
85
|
-
* @property {StableBatch} batchDef - The batch definition
|
|
86
|
-
* @property {any} result - Result from processing the batch
|
|
87
|
-
* @property {boolean} skipped - Whether the batch was skipped
|
|
88
|
-
* @property {number} entriesProcessed - Number of entries processed
|
|
89
|
-
* @property {number} attempts - Number of processing attempts
|
|
90
|
-
*/
|
|
91
|
-
|
|
92
|
-
/**
|
|
93
|
-
* @typedef {Object} BatchCursorResult
|
|
94
|
-
* @property {boolean} done - Whether iteration is complete
|
|
95
|
-
* @property {RingBufferEntry[]} entries - Entries in the current batch
|
|
96
|
-
* @property {StableBatch|null} batchDef - Batch definition (null if done)
|
|
97
|
-
* @property {boolean} [hasMore] - Whether more batches are available
|
|
98
|
-
*/
|
|
99
|
-
|
|
100
|
-
/**
|
|
101
|
-
* @typedef {Object} BatchCursor
|
|
102
|
-
* @property {string} cursorName - Name of the cursor
|
|
103
|
-
* @property {number} batchSize - Size of each batch
|
|
104
|
-
* @property {string} batchIdPrefix - Prefix for batch IDs
|
|
105
|
-
* @property {Function} next - Get next batch: (moveCursor?: boolean) => BatchCursorResult
|
|
106
|
-
* @property {Function} reset - Reset cursor: (position?: number) => void
|
|
107
|
-
* @property {Function} getStatus - Get cursor status: () => Object
|
|
108
|
-
*/
|
|
109
|
-
|
|
110
|
-
/**
|
|
111
|
-
* @typedef {Object} BatchHandler
|
|
112
|
-
* @property {Function} process - Process batch: (params: {head: number, cursor: number, entries: RingBufferEntry[], batch: RingBufferEntry[]}) => Promise<string[][]> | undefined
|
|
113
|
-
*/
|
|
114
|
-
|
|
115
6
|
export default class RingBuffer {
|
|
116
|
-
constructor(maxSize =
|
|
7
|
+
constructor(maxSize = 1000) {
|
|
117
8
|
this.maxSize = maxSize;
|
|
118
|
-
this.
|
|
119
|
-
this.
|
|
120
|
-
this.
|
|
121
|
-
this.totalEvicted = 0;
|
|
122
|
-
this.cursors = new Map();
|
|
123
|
-
this.lanes = new Map(); // Processing lanes
|
|
124
|
-
}
|
|
9
|
+
this.writeIndex = 0;
|
|
10
|
+
this.sequence = 0;
|
|
11
|
+
this.nextReaderId = 0;
|
|
125
12
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
* @param {any} data - Data to store
|
|
129
|
-
* @param {Map<string, any>} [meta] - Entry metadata
|
|
130
|
-
* @returns {RingBufferEntry} The created entry
|
|
131
|
-
*/
|
|
132
|
-
push(data, meta = new Map()) {
|
|
133
|
-
const entry = {
|
|
134
|
-
id: this.nextId++,
|
|
135
|
-
ts: new Date(),
|
|
136
|
-
data,
|
|
137
|
-
meta: new Map(meta),
|
|
138
|
-
};
|
|
139
|
-
|
|
140
|
-
// Handle eviction if buffer is full
|
|
141
|
-
if (this.entries.length >= this.maxSize) {
|
|
142
|
-
const evicted = this.entries.shift();
|
|
143
|
-
this.totalEvicted++;
|
|
144
|
-
|
|
145
|
-
// Update cursors that point to evicted entries
|
|
146
|
-
for (const cursor of this.cursors.values()) {
|
|
147
|
-
if (cursor.position <= evicted.id) {
|
|
148
|
-
cursor.position = evicted.id + 1;
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
}
|
|
13
|
+
// Double buffer: second half mirrors first half for wraparound-free reads
|
|
14
|
+
this.buffer = new Array(maxSize * 2);
|
|
152
15
|
|
|
153
|
-
|
|
154
|
-
this.
|
|
16
|
+
// Reader tracking - Map for O(1) lookups
|
|
17
|
+
this.readers = new Map(); // readerId -> lastReadSequence
|
|
155
18
|
|
|
156
|
-
//
|
|
157
|
-
this.
|
|
158
|
-
|
|
159
|
-
return entry;
|
|
19
|
+
// Notification system for blocking reads
|
|
20
|
+
this.waitingReaders = new Set(); // Set of { resolve, readerId, batchSize? }
|
|
160
21
|
}
|
|
161
22
|
|
|
162
23
|
/**
|
|
163
|
-
*
|
|
164
|
-
* @
|
|
165
|
-
* @param {Object} [options] - Structured data options
|
|
166
|
-
* @param {Object} [options.variables] - Variable data
|
|
167
|
-
* @param {Object} [options.context] - Context information
|
|
168
|
-
* @param {string[]} [options.tags] - Tags array
|
|
169
|
-
* @param {Map} [options.meta] - Additional metadata
|
|
170
|
-
* @returns {DataEntry} The created structured entry
|
|
24
|
+
* Register a new reader.
|
|
25
|
+
* @returns {string} Reader ID
|
|
171
26
|
*/
|
|
172
|
-
|
|
173
|
-
const
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
raw,
|
|
177
|
-
variables: options.variables || {},
|
|
178
|
-
context: options.context || { filePath: '', line: 0 },
|
|
179
|
-
tags: options.tags || [],
|
|
180
|
-
meta: options.meta || new Map(),
|
|
181
|
-
};
|
|
182
|
-
|
|
183
|
-
// Handle eviction if buffer is full
|
|
184
|
-
if (this.entries.length >= this.maxSize) {
|
|
185
|
-
const evicted = this.entries.shift();
|
|
186
|
-
this.totalEvicted++;
|
|
187
|
-
|
|
188
|
-
// Update cursors that point to evicted entries
|
|
189
|
-
for (const cursor of this.cursors.values()) {
|
|
190
|
-
if (cursor.position <= evicted.id) {
|
|
191
|
-
cursor.position = evicted.id + 1;
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
this.entries.push(entry);
|
|
197
|
-
this.totalAdded++;
|
|
198
|
-
|
|
199
|
-
// Process through lanes
|
|
200
|
-
this._processLanes(entry);
|
|
201
|
-
|
|
202
|
-
return entry;
|
|
27
|
+
registerReader() {
|
|
28
|
+
const readerId = `r${this.nextReaderId++}`;
|
|
29
|
+
this.readers.set(readerId, -1);
|
|
30
|
+
return readerId;
|
|
203
31
|
}
|
|
204
32
|
|
|
205
33
|
/**
|
|
206
|
-
*
|
|
207
|
-
* @param {
|
|
208
|
-
* @returns {ProcessingLane} The registered lane
|
|
34
|
+
* Unregister a reader.
|
|
35
|
+
* @param {string} readerId
|
|
209
36
|
*/
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
config: lane.config || {},
|
|
220
|
-
...lane,
|
|
221
|
-
};
|
|
222
|
-
|
|
223
|
-
this.lanes.set(lane.id, laneConfig);
|
|
224
|
-
|
|
225
|
-
// Create cursor for this lane if specified
|
|
226
|
-
if (laneConfig.cursorName) {
|
|
227
|
-
this.setCursor(laneConfig.cursorName, this.nextId - 1);
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
return laneConfig;
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
/**
|
|
234
|
-
* Remove a processing lane
|
|
235
|
-
* @param {string} laneId - Lane identifier
|
|
236
|
-
* @returns {boolean} True if lane was removed
|
|
237
|
-
*/
|
|
238
|
-
removeLane(laneId) {
|
|
239
|
-
const lane = this.lanes.get(laneId);
|
|
240
|
-
if (lane && lane.cursorName) {
|
|
241
|
-
this.removeCursor(lane.cursorName);
|
|
242
|
-
}
|
|
243
|
-
return this.lanes.delete(laneId);
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
/**
|
|
247
|
-
* Get processing lane by ID
|
|
248
|
-
* @param {string} laneId - Lane identifier
|
|
249
|
-
* @returns {ProcessingLane|undefined} The lane configuration
|
|
250
|
-
*/
|
|
251
|
-
getLane(laneId) {
|
|
252
|
-
return this.lanes.get(laneId);
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
/**
|
|
256
|
-
* Get all processing lanes
|
|
257
|
-
* @returns {ProcessingLane[]} Array of all lanes
|
|
258
|
-
*/
|
|
259
|
-
getAllLanes() {
|
|
260
|
-
return Array.from(this.lanes.values());
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
/**
|
|
264
|
-
* Process entry through all registered lanes
|
|
265
|
-
* @private
|
|
266
|
-
* @param {RingBufferEntry|DataEntry} entry - Entry to process
|
|
267
|
-
*/
|
|
268
|
-
_processLanes(entry) {
|
|
269
|
-
for (const lane of this.lanes.values()) {
|
|
270
|
-
// Apply filter if specified
|
|
271
|
-
if (!lane.filter || lane.filter(entry)) {
|
|
272
|
-
lane.buffer.push(entry);
|
|
273
|
-
|
|
274
|
-
// Check if we should trigger flush
|
|
275
|
-
const batchSize = lane.config.batchSize || 10;
|
|
276
|
-
if (lane.buffer.length >= batchSize && !lane.flushActive) {
|
|
277
|
-
// Use setTimeout to avoid blocking
|
|
278
|
-
setTimeout(() => this._flushLane(lane), 0);
|
|
37
|
+
unregisterReader(readerId) {
|
|
38
|
+
this.readers.delete(readerId);
|
|
39
|
+
// Remove any waiting operations for this reader
|
|
40
|
+
for (const waiter of this.waitingReaders) {
|
|
41
|
+
if (waiter.readerId === readerId) {
|
|
42
|
+
if (waiter.batchSize) {
|
|
43
|
+
waiter.resolve({ data: [], startOffset: 0, lastOffset: -1 });
|
|
44
|
+
} else {
|
|
45
|
+
waiter.resolve({ data: null, offset: -1 });
|
|
279
46
|
}
|
|
47
|
+
this.waitingReaders.delete(waiter);
|
|
280
48
|
}
|
|
281
49
|
}
|
|
282
50
|
}
|
|
283
51
|
|
|
284
52
|
/**
|
|
285
|
-
*
|
|
286
|
-
* @
|
|
287
|
-
* @
|
|
53
|
+
* Write data to buffer (single writer only).
|
|
54
|
+
* @param {any} data
|
|
55
|
+
* @returns {number} Sequence number
|
|
288
56
|
*/
|
|
289
|
-
|
|
290
|
-
|
|
57
|
+
write(data) {
|
|
58
|
+
const seq = this.sequence++;
|
|
59
|
+
const idx = this.writeIndex;
|
|
291
60
|
|
|
292
|
-
|
|
61
|
+
// Write to both positions (main and mirror)
|
|
62
|
+
this.buffer[idx] = data;
|
|
63
|
+
this.buffer[idx + this.maxSize] = data;
|
|
293
64
|
|
|
294
|
-
|
|
295
|
-
while (lane.buffer.length > 0) {
|
|
296
|
-
const batchSize = lane.config.batchSize || 10;
|
|
297
|
-
const batch = lane.buffer.slice(0, Math.min(batchSize, lane.buffer.length));
|
|
65
|
+
this.writeIndex = (this.writeIndex + 1) % this.maxSize;
|
|
298
66
|
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
const cursor = batch[batch.length - 1].id;
|
|
302
|
-
let result = batch; // Default to the batch itself
|
|
303
|
-
|
|
304
|
-
// Use batch handler if available
|
|
305
|
-
if (lane.batchHandler) {
|
|
306
|
-
const params = {
|
|
307
|
-
head: this.nextId - 1,
|
|
308
|
-
cursor,
|
|
309
|
-
entries: this.entries,
|
|
310
|
-
batch,
|
|
311
|
-
};
|
|
312
|
-
|
|
313
|
-
try {
|
|
314
|
-
result = await lane.batchHandler.process(params);
|
|
315
|
-
} catch (error) {
|
|
316
|
-
console.error(`Lane ${lane.id} batch handler error:`, error);
|
|
317
|
-
// Skip this batch on error
|
|
318
|
-
lane.buffer.splice(0, batch.length);
|
|
319
|
-
continue;
|
|
320
|
-
}
|
|
321
|
-
}
|
|
67
|
+
// Notify waiting readers
|
|
68
|
+
this._notifyWaiters(seq);
|
|
322
69
|
|
|
323
|
-
|
|
324
|
-
if (lane.writer && result) {
|
|
325
|
-
const lines = Array.isArray(result) ? result.flat() : [String(result)];
|
|
326
|
-
if (lines.length > 0) {
|
|
327
|
-
try {
|
|
328
|
-
await lane.writer(lines);
|
|
329
|
-
} catch (error) {
|
|
330
|
-
console.error(`Lane ${lane.id} writer error:`, error);
|
|
331
|
-
}
|
|
332
|
-
}
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
// Remove processed entries from buffer
|
|
336
|
-
lane.buffer.splice(0, batch.length);
|
|
337
|
-
|
|
338
|
-
// Update cursor if specified
|
|
339
|
-
if (lane.cursorName) {
|
|
340
|
-
this.moveCursor(lane.cursorName, cursor);
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
// If batch was smaller than requested size, we're done
|
|
344
|
-
if (batch.length < batchSize) {
|
|
345
|
-
break;
|
|
346
|
-
}
|
|
347
|
-
}
|
|
348
|
-
} finally {
|
|
349
|
-
lane.flushActive = false;
|
|
350
|
-
}
|
|
70
|
+
return seq;
|
|
351
71
|
}
|
|
352
72
|
|
|
353
73
|
/**
|
|
354
|
-
*
|
|
355
|
-
* @param {string}
|
|
356
|
-
* @returns {Promise<
|
|
74
|
+
* Read latest data for a reader (blocks until available).
|
|
75
|
+
* @param {string} readerId
|
|
76
|
+
* @returns {Promise<{data: any, offset: number}>}
|
|
357
77
|
*/
|
|
358
|
-
|
|
359
|
-
const
|
|
360
|
-
if (
|
|
361
|
-
|
|
78
|
+
read(readerId) {
|
|
79
|
+
const lastSeq = this.readers.get(readerId);
|
|
80
|
+
if (lastSeq === undefined) {
|
|
81
|
+
throw new Error(`Reader ${readerId} not registered`);
|
|
362
82
|
}
|
|
363
|
-
}
|
|
364
83
|
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
await Promise.all(flushPromises);
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
/**
|
|
375
|
-
* Add multiple entries in batch
|
|
376
|
-
* @param {any[]} dataArray - Array of data to add
|
|
377
|
-
* @param {Map<string, any>} [sharedMeta] - Metadata to apply to all entries
|
|
378
|
-
* @returns {RingBufferEntry[]} Array of created entries
|
|
379
|
-
*/
|
|
380
|
-
pushBatch(dataArray, sharedMeta = new Map()) {
|
|
381
|
-
const entries = [];
|
|
382
|
-
for (const data of dataArray) {
|
|
383
|
-
entries.push(this.push(data, new Map(sharedMeta)));
|
|
84
|
+
// Check if new data is available
|
|
85
|
+
if (this.sequence > lastSeq + 1) {
|
|
86
|
+
const nextSeq = lastSeq + 1;
|
|
87
|
+
const data = this._getDataAtSequence(nextSeq);
|
|
88
|
+
this.readers.set(readerId, nextSeq);
|
|
89
|
+
return Promise.resolve({ data, offset: nextSeq });
|
|
384
90
|
}
|
|
385
|
-
return entries;
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
/**
|
|
389
|
-
* Get all entries in buffer (oldest → newest)
|
|
390
|
-
* @returns {RingBufferEntry[]} All entries
|
|
391
|
-
*/
|
|
392
|
-
all() {
|
|
393
|
-
return [...this.entries];
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
/**
|
|
397
|
-
* Get entries within ID range
|
|
398
|
-
* @param {number} startId - Starting ID (inclusive)
|
|
399
|
-
* @param {number} [endId] - Ending ID (exclusive)
|
|
400
|
-
* @returns {RingBufferEntry[]} Filtered entries
|
|
401
|
-
*/
|
|
402
|
-
slice(startId, endId) {
|
|
403
|
-
return this.entries.filter((entry) => {
|
|
404
|
-
return entry.id >= startId && (endId === undefined || entry.id < endId);
|
|
405
|
-
});
|
|
406
|
-
}
|
|
407
|
-
|
|
408
|
-
/**
|
|
409
|
-
* Get entries within index range (like Array.slice)
|
|
410
|
-
* @param {number} [start=0] - Start index
|
|
411
|
-
* @param {number} [end] - End index
|
|
412
|
-
* @returns {RingBufferEntry[]} Sliced entries
|
|
413
|
-
*/
|
|
414
|
-
sliceByIndex(start = 0, end) {
|
|
415
|
-
return this.entries.slice(start, end);
|
|
416
|
-
}
|
|
417
91
|
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
* @param {Date} [endTime] - End time (exclusive)
|
|
422
|
-
* @returns {RingBufferEntry[]} Filtered entries
|
|
423
|
-
*/
|
|
424
|
-
sliceByTime(startTime, endTime) {
|
|
425
|
-
return this.entries.filter((entry) => {
|
|
426
|
-
const timestamp = entry.timestamp || entry.ts;
|
|
427
|
-
return timestamp >= startTime && (endTime === undefined || timestamp < endTime);
|
|
92
|
+
// No data available, wait for it
|
|
93
|
+
return new Promise((resolve) => {
|
|
94
|
+
this.waitingReaders.add({ resolve, readerId });
|
|
428
95
|
});
|
|
429
96
|
}
|
|
430
97
|
|
|
431
98
|
/**
|
|
432
|
-
*
|
|
433
|
-
* @param {
|
|
434
|
-
* @
|
|
435
|
-
|
|
436
|
-
tail(count) {
|
|
437
|
-
return this.entries.slice(-count);
|
|
438
|
-
}
|
|
439
|
-
|
|
440
|
-
/**
|
|
441
|
-
* Get the oldest N entries
|
|
442
|
-
* @param {number} count - Number of entries to retrieve
|
|
443
|
-
* @returns {RingBufferEntry[]} Oldest entries
|
|
444
|
-
*/
|
|
445
|
-
head(count) {
|
|
446
|
-
return this.entries.slice(0, count);
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
/**
|
|
450
|
-
* Find entries matching a predicate
|
|
451
|
-
* @param {Function} predicate - Function to test entries (entry) => boolean
|
|
452
|
-
* @returns {RingBufferEntry[]} Matching entries
|
|
453
|
-
*/
|
|
454
|
-
filter(predicate) {
|
|
455
|
-
return this.entries.filter(predicate);
|
|
456
|
-
}
|
|
457
|
-
|
|
458
|
-
/**
|
|
459
|
-
* Find first entry matching predicate
|
|
460
|
-
* @param {Function} predicate - Function to test entries
|
|
461
|
-
* @returns {RingBufferEntry|undefined} First matching entry
|
|
462
|
-
*/
|
|
463
|
-
find(predicate) {
|
|
464
|
-
return this.entries.find(predicate);
|
|
465
|
-
}
|
|
466
|
-
|
|
467
|
-
/**
|
|
468
|
-
* Transform entries using a mapping function
|
|
469
|
-
* @param {Function} mapper - Function to transform entries (entry) => any
|
|
470
|
-
* @returns {any[]} Transformed results
|
|
471
|
-
*/
|
|
472
|
-
map(mapper) {
|
|
473
|
-
return this.entries.map(mapper);
|
|
474
|
-
}
|
|
475
|
-
|
|
476
|
-
/**
|
|
477
|
-
* Reduce entries to a single value
|
|
478
|
-
* @param {Function} reducer - Reducer function (acc, entry, index) => any
|
|
479
|
-
* @param {any} initialValue - Initial accumulator value
|
|
480
|
-
* @returns {any} Reduced value
|
|
481
|
-
*/
|
|
482
|
-
reduce(reducer, initialValue) {
|
|
483
|
-
return this.entries.reduce(reducer, initialValue);
|
|
484
|
-
}
|
|
485
|
-
|
|
486
|
-
/**
|
|
487
|
-
* Create or update a cursor for tracking position
|
|
488
|
-
* @param {string} name - Cursor name
|
|
489
|
-
* @param {number} [position] - Position to set (defaults to current newest)
|
|
490
|
-
* @returns {RingBufferCursor} The cursor
|
|
491
|
-
*/
|
|
492
|
-
setCursor(name, position) {
|
|
493
|
-
const currentPosition =
|
|
494
|
-
position !== undefined
|
|
495
|
-
? position
|
|
496
|
-
: this.entries.length > 0
|
|
497
|
-
? this.entries[this.entries.length - 1].id
|
|
498
|
-
: 0;
|
|
499
|
-
|
|
500
|
-
const cursor = {
|
|
501
|
-
name,
|
|
502
|
-
position: currentPosition,
|
|
503
|
-
lastMoved: new Date(),
|
|
504
|
-
meta: new Map(),
|
|
505
|
-
};
|
|
506
|
-
|
|
507
|
-
this.cursors.set(name, cursor);
|
|
508
|
-
return cursor;
|
|
509
|
-
}
|
|
510
|
-
|
|
511
|
-
/**
|
|
512
|
-
* Get cursor by name
|
|
513
|
-
* @param {string} name - Cursor name
|
|
514
|
-
* @returns {RingBufferCursor|undefined} The cursor
|
|
99
|
+
* Read batch of data (blocks until batch is full).
|
|
100
|
+
* @param {string} readerId
|
|
101
|
+
* @param {number} batchSize
|
|
102
|
+
* @returns {Promise<{data: any[], startOffset: number, lastOffset: number}>}
|
|
515
103
|
*/
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
/**
|
|
521
|
-
* Move cursor to new position
|
|
522
|
-
* @param {string} name - Cursor name
|
|
523
|
-
* @param {number} position - New position
|
|
524
|
-
* @returns {RingBufferCursor|undefined} Updated cursor
|
|
525
|
-
*/
|
|
526
|
-
moveCursor(name, position) {
|
|
527
|
-
const cursor = this.cursors.get(name);
|
|
528
|
-
if (cursor) {
|
|
529
|
-
cursor.position = position;
|
|
530
|
-
cursor.lastMoved = new Date();
|
|
531
|
-
return cursor;
|
|
532
|
-
}
|
|
533
|
-
return undefined;
|
|
534
|
-
}
|
|
535
|
-
|
|
536
|
-
/**
|
|
537
|
-
* Get entries since cursor position
|
|
538
|
-
* @param {string} cursorName - Cursor name
|
|
539
|
-
* @param {boolean} [moveCursor=true] - Whether to move cursor to end
|
|
540
|
-
* @returns {RingBufferEntry[]} Entries since cursor
|
|
541
|
-
*/
|
|
542
|
-
getSinceCursor(cursorName, moveCursor = true) {
|
|
543
|
-
const cursor = this.cursors.get(cursorName);
|
|
544
|
-
if (!cursor) {
|
|
545
|
-
return [];
|
|
104
|
+
readBatch(readerId, batchSize) {
|
|
105
|
+
const lastSeq = this.readers.get(readerId);
|
|
106
|
+
if (lastSeq === undefined) {
|
|
107
|
+
throw new Error(`Reader ${readerId} not registered`);
|
|
546
108
|
}
|
|
547
109
|
|
|
548
|
-
const
|
|
110
|
+
const availableCount = this.sequence - (lastSeq + 1);
|
|
549
111
|
|
|
550
|
-
if (
|
|
551
|
-
|
|
552
|
-
|
|
112
|
+
if (availableCount >= batchSize) {
|
|
113
|
+
// Batch is ready
|
|
114
|
+
const result = this._readBatchSync(readerId, batchSize);
|
|
115
|
+
return Promise.resolve(result);
|
|
553
116
|
}
|
|
554
117
|
|
|
555
|
-
|
|
118
|
+
// Wait for full batch
|
|
119
|
+
return new Promise((resolve) => {
|
|
120
|
+
this.waitingReaders.add({ resolve, readerId, batchSize });
|
|
121
|
+
});
|
|
556
122
|
}
|
|
557
123
|
|
|
558
124
|
/**
|
|
559
|
-
*
|
|
560
|
-
* @
|
|
561
|
-
* @param {Function} processor - Function to process each batch (batch, batchIndex) => Promise|any
|
|
562
|
-
* @param {Object} [options] - Processing options
|
|
563
|
-
* @param {number} [options.startIndex=0] - Index to start from
|
|
564
|
-
* @param {number} [options.endIndex] - Index to end at
|
|
565
|
-
* @param {boolean} [options.parallel=false] - Process batches in parallel
|
|
566
|
-
* @returns {Promise<any[]>} Results from each batch
|
|
125
|
+
* Get data at specific sequence (uses double buffer for wraparound-free access).
|
|
126
|
+
* @private
|
|
567
127
|
*/
|
|
568
|
-
|
|
569
|
-
const
|
|
570
|
-
|
|
571
|
-
const batches = [];
|
|
572
|
-
|
|
573
|
-
// Create batches
|
|
574
|
-
for (let i = startIndex; i < endIndex; i += batchSize) {
|
|
575
|
-
const batch = this.entries.slice(i, Math.min(i + batchSize, endIndex));
|
|
576
|
-
batches.push({ batch, batchIndex: Math.floor(i / batchSize) });
|
|
577
|
-
}
|
|
578
|
-
|
|
579
|
-
if (parallel) {
|
|
580
|
-
// Process all batches in parallel
|
|
581
|
-
const promises = batches.map(({ batch, batchIndex }) => processor(batch, batchIndex));
|
|
582
|
-
results.push(...(await Promise.all(promises)));
|
|
583
|
-
} else {
|
|
584
|
-
// Process batches sequentially
|
|
585
|
-
for (const { batch, batchIndex } of batches) {
|
|
586
|
-
const result = await processor(batch, batchIndex);
|
|
587
|
-
results.push(result);
|
|
588
|
-
}
|
|
589
|
-
}
|
|
590
|
-
|
|
591
|
-
return results;
|
|
128
|
+
_getDataAtSequence(seq) {
|
|
129
|
+
const bufferPos = seq % this.maxSize;
|
|
130
|
+
return this.buffer[bufferPos];
|
|
592
131
|
}
|
|
593
132
|
|
|
594
133
|
/**
|
|
595
|
-
*
|
|
596
|
-
*
|
|
597
|
-
* @param {number} batchSize - Size of each batch
|
|
598
|
-
* @param {Object} [options] - Batch creation options
|
|
599
|
-
* @param {number} [options.startId] - Starting entry ID (defaults to oldest)
|
|
600
|
-
* @param {number} [options.endId] - Ending entry ID (defaults to newest)
|
|
601
|
-
* @param {string} [options.batchIdPrefix='batch'] - Prefix for batch IDs
|
|
602
|
-
* @returns {StableBatch[]} Array of stable batch definitions
|
|
134
|
+
* Read batch synchronously when data is available.
|
|
135
|
+
* @private
|
|
603
136
|
*/
|
|
604
|
-
|
|
605
|
-
const
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
batchIdPrefix = 'batch',
|
|
609
|
-
} = options;
|
|
610
|
-
|
|
611
|
-
const batches = [];
|
|
612
|
-
let currentId = startId;
|
|
613
|
-
let batchIndex = 0;
|
|
614
|
-
|
|
615
|
-
while (currentId < endId) {
|
|
616
|
-
const batchEndId = Math.min(currentId + batchSize, endId);
|
|
617
|
-
batches.push({
|
|
618
|
-
batchId: `${batchIdPrefix}_${batchIndex}`,
|
|
619
|
-
batchIndex,
|
|
620
|
-
startId: currentId,
|
|
621
|
-
endId: batchEndId,
|
|
622
|
-
size: batchEndId - currentId,
|
|
623
|
-
timestamp: new Date(),
|
|
624
|
-
});
|
|
137
|
+
_readBatchSync(readerId, batchSize) {
|
|
138
|
+
const lastSeq = this.readers.get(readerId);
|
|
139
|
+
const startSeq = lastSeq + 1;
|
|
140
|
+
const startPos = startSeq % this.maxSize;
|
|
625
141
|
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
}
|
|
629
|
-
|
|
630
|
-
return batches;
|
|
631
|
-
}
|
|
142
|
+
// Use double buffer to avoid wraparound logic
|
|
143
|
+
const batch = this.buffer.slice(startPos, startPos + batchSize);
|
|
632
144
|
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
* @param {StableBatch} batchDef - Stable batch definition
|
|
636
|
-
* @returns {RingBufferEntry[]} Entries in the batch (may be empty if evicted)
|
|
637
|
-
*/
|
|
638
|
-
getBatchEntries(batchDef) {
|
|
639
|
-
return this.slice(batchDef.startId, batchDef.endId);
|
|
145
|
+
this.readers.set(readerId, lastSeq + batchSize);
|
|
146
|
+
return { data: batch, startOffset: startSeq, lastOffset: lastSeq + batchSize };
|
|
640
147
|
}
|
|
641
148
|
|
|
642
149
|
/**
|
|
643
|
-
*
|
|
644
|
-
* @
|
|
645
|
-
* @param {Function} processor - Function to process each batch (entries, batchDef, context) => Promise|any
|
|
646
|
-
* @param {Object} [options] - Processing options
|
|
647
|
-
* @param {string} [options.cursorName] - Cursor name for tracking progress
|
|
648
|
-
* @param {number} [options.startId] - Starting entry ID
|
|
649
|
-
* @param {number} [options.endId] - Ending entry ID
|
|
650
|
-
* @param {boolean} [options.parallel=false] - Process batches in parallel
|
|
651
|
-
* @param {number} [options.maxRetries=3] - Maximum retry attempts per batch
|
|
652
|
-
* @param {number} [options.retryDelay] - Function to calculate retry delay (attempt) => ms
|
|
653
|
-
* @param {Function} [options.onBatchStart] - Callback when batch starts (batchDef) => void
|
|
654
|
-
* @param {Function} [options.onBatchComplete] - Callback when batch completes (batchDef, result) => void
|
|
655
|
-
* @param {Function} [options.onBatchError] - Callback when batch fails (batchDef, error, attempt) => void
|
|
656
|
-
* @param {boolean} [options.skipMissingEntries=false] - Skip batches with no available entries
|
|
657
|
-
* @returns {Promise<StableBatchResult[]>} Results from each batch
|
|
150
|
+
* Notify waiting readers when new data arrives.
|
|
151
|
+
* @private
|
|
658
152
|
*/
|
|
659
|
-
|
|
660
|
-
const
|
|
661
|
-
startId = this.entries.length > 0 ? this.entries[0].id : 1,
|
|
662
|
-
endId = this.nextId,
|
|
663
|
-
cursorName,
|
|
664
|
-
parallel = false,
|
|
665
|
-
maxRetries = 0,
|
|
666
|
-
retryDelay = 100,
|
|
667
|
-
skipMissingEntries = false,
|
|
668
|
-
batchIdPrefix = 'batch',
|
|
669
|
-
} = options;
|
|
670
|
-
|
|
671
|
-
// Create stable batch definitions
|
|
672
|
-
const batches = this.createStableBatches(batchSize, {
|
|
673
|
-
startId,
|
|
674
|
-
endId,
|
|
675
|
-
batchIdPrefix,
|
|
676
|
-
});
|
|
677
|
-
|
|
678
|
-
// Filter batches based on cursor position if provided
|
|
679
|
-
let batchesToProcess = batches;
|
|
680
|
-
if (cursorName) {
|
|
681
|
-
let cursor = this.getCursor(cursorName);
|
|
682
|
-
if (!cursor) {
|
|
683
|
-
// Create cursor if it doesn't exist
|
|
684
|
-
cursor = this.setCursor(cursorName, startId - 1);
|
|
685
|
-
}
|
|
686
|
-
batchesToProcess = batches.filter((batch) => batch.startId > cursor.position);
|
|
687
|
-
}
|
|
688
|
-
|
|
689
|
-
const processBatchWithRetry = async (batchDef) => {
|
|
690
|
-
let attempts = 0;
|
|
691
|
-
let lastError;
|
|
153
|
+
_notifyWaiters(newSeq) {
|
|
154
|
+
const toRemove = [];
|
|
692
155
|
|
|
693
|
-
|
|
694
|
-
const
|
|
156
|
+
for (const waiter of this.waitingReaders) {
|
|
157
|
+
const { resolve, readerId, batchSize } = waiter;
|
|
158
|
+
const lastSeq = this.readers.get(readerId);
|
|
695
159
|
|
|
696
|
-
|
|
697
|
-
attempts++;
|
|
698
|
-
try {
|
|
699
|
-
const entries = this.getBatchEntries(batchDef);
|
|
160
|
+
if (lastSeq === undefined) continue; // Reader was unregistered
|
|
700
161
|
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
const
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
});
|
|
718
|
-
|
|
719
|
-
// Update cursor if provided
|
|
720
|
-
if (cursorName) {
|
|
721
|
-
this.moveCursor(cursorName, batchDef.endId - 1);
|
|
722
|
-
}
|
|
723
|
-
|
|
724
|
-
return {
|
|
725
|
-
batchId: batchDef.batchId,
|
|
726
|
-
batchDef,
|
|
727
|
-
result,
|
|
728
|
-
skipped: false,
|
|
729
|
-
entriesProcessed: entries.length,
|
|
730
|
-
attempts,
|
|
731
|
-
};
|
|
732
|
-
} catch (error) {
|
|
733
|
-
lastError = error;
|
|
734
|
-
// Only delay if we have more attempts left
|
|
735
|
-
if (attempts < maxAttempts) {
|
|
736
|
-
await new Promise((resolve) => setTimeout(resolve, retryDelay * attempts));
|
|
737
|
-
}
|
|
162
|
+
if (batchSize) {
|
|
163
|
+
// Batch reader
|
|
164
|
+
const availableCount = newSeq - lastSeq;
|
|
165
|
+
if (availableCount >= batchSize) {
|
|
166
|
+
const result = this._readBatchSync(readerId, batchSize);
|
|
167
|
+
resolve(result);
|
|
168
|
+
toRemove.push(waiter);
|
|
169
|
+
}
|
|
170
|
+
} else {
|
|
171
|
+
// Single reader
|
|
172
|
+
if (newSeq > lastSeq) {
|
|
173
|
+
const nextSeq = lastSeq + 1;
|
|
174
|
+
const data = this._getDataAtSequence(nextSeq);
|
|
175
|
+
this.readers.set(readerId, nextSeq);
|
|
176
|
+
resolve({ data, offset: nextSeq });
|
|
177
|
+
toRemove.push(waiter);
|
|
738
178
|
}
|
|
739
179
|
}
|
|
740
|
-
|
|
741
|
-
// If we get here, all retries failed
|
|
742
|
-
return {
|
|
743
|
-
batchId: batchDef.batchId,
|
|
744
|
-
batchDef,
|
|
745
|
-
result: null,
|
|
746
|
-
skipped: false,
|
|
747
|
-
entriesProcessed: 0,
|
|
748
|
-
attempts,
|
|
749
|
-
error: lastError,
|
|
750
|
-
};
|
|
751
|
-
};
|
|
752
|
-
|
|
753
|
-
// Process batches
|
|
754
|
-
if (parallel) {
|
|
755
|
-
return await Promise.all(batchesToProcess.map(processBatchWithRetry));
|
|
756
|
-
} else {
|
|
757
|
-
const results = [];
|
|
758
|
-
for (const batchDef of batchesToProcess) {
|
|
759
|
-
results.push(await processBatchWithRetry(batchDef));
|
|
760
|
-
}
|
|
761
|
-
return results;
|
|
762
180
|
}
|
|
763
|
-
}
|
|
764
|
-
|
|
765
|
-
/**
|
|
766
|
-
* Create a batch cursor for iterating over stable batches
|
|
767
|
-
* @param {string} cursorName - Name for the batch cursor
|
|
768
|
-
* @param {number} batchSize - Size of each batch
|
|
769
|
-
* @param {Object} [options] - Cursor options
|
|
770
|
-
* @param {number} [options.startId] - Starting entry ID
|
|
771
|
-
* @param {string} [options.batchIdPrefix] - Prefix for batch IDs
|
|
772
|
-
* @returns {BatchCursor} Batch cursor for iteration
|
|
773
|
-
*/
|
|
774
|
-
createBatchCursor(cursorName, batchSize, options = {}) {
|
|
775
|
-
const { startId, batchIdPrefix = 'batch' } = options;
|
|
776
181
|
|
|
777
|
-
//
|
|
778
|
-
const
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
this.setCursor(cursorName, initialPosition);
|
|
782
|
-
|
|
783
|
-
return {
|
|
784
|
-
cursorName,
|
|
785
|
-
batchSize,
|
|
786
|
-
batchIdPrefix,
|
|
787
|
-
|
|
788
|
-
/**
|
|
789
|
-
* Get the next batch of entries
|
|
790
|
-
* @param {boolean} [moveCursor=true] - Whether to advance the cursor
|
|
791
|
-
* @returns {BatchCursorResult} Next batch result
|
|
792
|
-
*/
|
|
793
|
-
next: (moveCursor = true) => {
|
|
794
|
-
const cursor = this.getCursor(cursorName);
|
|
795
|
-
if (!cursor) {
|
|
796
|
-
return { done: true, entries: [], batchDef: null };
|
|
797
|
-
}
|
|
798
|
-
|
|
799
|
-
const startId = cursor.position;
|
|
800
|
-
const endId = startId + batchSize;
|
|
801
|
-
const entries = this.slice(startId, endId);
|
|
802
|
-
|
|
803
|
-
if (entries.length === 0) {
|
|
804
|
-
return { done: true, entries: [], batchDef: null };
|
|
805
|
-
}
|
|
806
|
-
|
|
807
|
-
const batchDef = {
|
|
808
|
-
batchId: `${batchIdPrefix}_${Math.floor(startId / batchSize)}`,
|
|
809
|
-
startId,
|
|
810
|
-
endId: startId + entries.length,
|
|
811
|
-
size: entries.length,
|
|
812
|
-
timestamp: new Date(),
|
|
813
|
-
};
|
|
814
|
-
|
|
815
|
-
if (moveCursor) {
|
|
816
|
-
this.moveCursor(cursorName, batchDef.endId);
|
|
817
|
-
}
|
|
818
|
-
|
|
819
|
-
return {
|
|
820
|
-
done: false,
|
|
821
|
-
entries,
|
|
822
|
-
batchDef,
|
|
823
|
-
hasMore: this.entries.some((entry) => entry.id >= batchDef.endId),
|
|
824
|
-
};
|
|
825
|
-
},
|
|
826
|
-
|
|
827
|
-
/**
|
|
828
|
-
* Reset cursor to beginning or specific position
|
|
829
|
-
* @param {number} [position] - Position to reset to
|
|
830
|
-
*/
|
|
831
|
-
reset: (position) => {
|
|
832
|
-
const resetPos =
|
|
833
|
-
position !== undefined ? position : this.entries.length > 0 ? this.entries[0].id : 0;
|
|
834
|
-
this.moveCursor(cursorName, resetPos);
|
|
835
|
-
},
|
|
836
|
-
|
|
837
|
-
/**
|
|
838
|
-
* Get cursor status
|
|
839
|
-
* @returns {Object} Cursor status information
|
|
840
|
-
*/
|
|
841
|
-
getStatus: () => {
|
|
842
|
-
const cursor = this.getCursor(cursorName);
|
|
843
|
-
const stats = this.getStats();
|
|
844
|
-
return {
|
|
845
|
-
cursorName,
|
|
846
|
-
position: cursor?.position || 0,
|
|
847
|
-
lastMoved: cursor?.lastMoved,
|
|
848
|
-
batchSize,
|
|
849
|
-
remainingEntries: Math.max(0, stats.newestId - (cursor?.position || 0)),
|
|
850
|
-
bufferSize: stats.size,
|
|
851
|
-
};
|
|
852
|
-
},
|
|
853
|
-
};
|
|
182
|
+
// Remove resolved waiters
|
|
183
|
+
for (const waiter of toRemove) {
|
|
184
|
+
this.waitingReaders.delete(waiter);
|
|
185
|
+
}
|
|
854
186
|
}
|
|
855
187
|
|
|
856
188
|
/**
|
|
857
|
-
*
|
|
858
|
-
*
|
|
859
|
-
* @
|
|
860
|
-
* @param {number} batchSize - Size of each batch
|
|
861
|
-
* @param {Object} [options] - Cursor options
|
|
862
|
-
* @returns {Object} Map of cursor names to BatchCursor objects
|
|
189
|
+
* Get available data count for a reader.
|
|
190
|
+
* @param {string} readerId
|
|
191
|
+
* @returns {number}
|
|
863
192
|
*/
|
|
864
|
-
|
|
865
|
-
const
|
|
866
|
-
|
|
867
|
-
// Create stable batch definitions first
|
|
868
|
-
const batchDefs = this.createStableBatches(batchSize, options);
|
|
869
|
-
|
|
870
|
-
// Create cursors with identical starting positions
|
|
871
|
-
const startPosition =
|
|
872
|
-
options.startId !== undefined
|
|
873
|
-
? options.startId
|
|
874
|
-
: this.entries.length > 0
|
|
875
|
-
? this.entries[0].id
|
|
876
|
-
: 0;
|
|
877
|
-
|
|
878
|
-
for (const cursorName of cursorNames) {
|
|
879
|
-
this.setCursor(cursorName, startPosition);
|
|
880
|
-
|
|
881
|
-
cursors[cursorName] = {
|
|
882
|
-
cursorName,
|
|
883
|
-
batchSize,
|
|
884
|
-
batchDefs,
|
|
885
|
-
currentBatchIndex: 0,
|
|
886
|
-
|
|
887
|
-
/**
|
|
888
|
-
* Get the next batch using stable batch definitions
|
|
889
|
-
* @param {boolean} [moveCursor=true] - Whether to advance the cursor
|
|
890
|
-
* @returns {BatchCursorResult} Next batch result
|
|
891
|
-
*/
|
|
892
|
-
next: (moveCursor = true) => {
|
|
893
|
-
const cursor = this.getCursor(cursorName);
|
|
894
|
-
if (!cursor || cursors[cursorName].currentBatchIndex >= batchDefs.length) {
|
|
895
|
-
return { done: true, entries: [], batchDef: null };
|
|
896
|
-
}
|
|
897
|
-
|
|
898
|
-
const batchDef = batchDefs[cursors[cursorName].currentBatchIndex];
|
|
899
|
-
const entries = this.getBatchEntries(batchDef);
|
|
900
|
-
|
|
901
|
-
if (moveCursor) {
|
|
902
|
-
this.moveCursor(cursorName, batchDef.endId - 1);
|
|
903
|
-
cursors[cursorName].currentBatchIndex++;
|
|
904
|
-
}
|
|
905
|
-
|
|
906
|
-
return {
|
|
907
|
-
done: false,
|
|
908
|
-
entries,
|
|
909
|
-
batchDef,
|
|
910
|
-
hasMore: cursors[cursorName].currentBatchIndex < batchDefs.length - 1,
|
|
911
|
-
};
|
|
912
|
-
},
|
|
913
|
-
|
|
914
|
-
/**
|
|
915
|
-
* Reset cursor to beginning or specific batch
|
|
916
|
-
* @param {number} [batchIndex=0] - Batch index to reset to
|
|
917
|
-
*/
|
|
918
|
-
reset: (batchIndex = 0) => {
|
|
919
|
-
cursors[cursorName].currentBatchIndex = Math.max(
|
|
920
|
-
0,
|
|
921
|
-
Math.min(batchIndex, batchDefs.length - 1)
|
|
922
|
-
);
|
|
923
|
-
const batchDef = batchDefs[cursors[cursorName].currentBatchIndex];
|
|
924
|
-
if (batchDef) {
|
|
925
|
-
this.moveCursor(cursorName, batchDef.startId);
|
|
926
|
-
}
|
|
927
|
-
},
|
|
928
|
-
|
|
929
|
-
/**
|
|
930
|
-
* Get cursor status
|
|
931
|
-
* @returns {Object} Cursor status information
|
|
932
|
-
*/
|
|
933
|
-
getStatus: () => {
|
|
934
|
-
const cursor = this.getCursor(cursorName);
|
|
935
|
-
return {
|
|
936
|
-
cursorName,
|
|
937
|
-
position: cursor?.position || 0,
|
|
938
|
-
lastMoved: cursor?.lastMoved,
|
|
939
|
-
batchSize,
|
|
940
|
-
currentBatchIndex: cursors[cursorName].currentBatchIndex,
|
|
941
|
-
totalBatches: batchDefs.length,
|
|
942
|
-
remainingBatches: batchDefs.length - cursors[cursorName].currentBatchIndex,
|
|
943
|
-
};
|
|
944
|
-
},
|
|
945
|
-
};
|
|
946
|
-
}
|
|
947
|
-
|
|
948
|
-
return cursors;
|
|
193
|
+
getAvailableCount(readerId) {
|
|
194
|
+
const lastSeq = this.readers.get(readerId);
|
|
195
|
+
return lastSeq === undefined ? 0 : Math.max(0, this.sequence - (lastSeq + 1));
|
|
949
196
|
}
|
|
950
197
|
|
|
951
198
|
/**
|
|
952
|
-
* Get buffer statistics
|
|
953
|
-
* @returns {
|
|
199
|
+
* Get buffer statistics.
|
|
200
|
+
* @returns {object}
|
|
954
201
|
*/
|
|
955
202
|
getStats() {
|
|
956
|
-
const size = this.entries.length;
|
|
957
|
-
const oldest = size > 0 ? this.entries[0] : null;
|
|
958
|
-
const newest = size > 0 ? this.entries[size - 1] : null;
|
|
959
|
-
|
|
960
203
|
return {
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
newestId: newest?.id || 0,
|
|
967
|
-
oldestTimestamp: oldest?.timestamp || oldest?.ts || null,
|
|
968
|
-
newestTimestamp: newest?.timestamp || newest?.ts || null,
|
|
969
|
-
cursors: this.cursors.size,
|
|
970
|
-
lanes: this.lanes.size,
|
|
204
|
+
maxSize: this.maxSize,
|
|
205
|
+
writeIndex: this.writeIndex,
|
|
206
|
+
sequence: this.sequence,
|
|
207
|
+
registeredReaders: this.readers.size,
|
|
208
|
+
waitingReaders: this.waitingReaders.size,
|
|
971
209
|
};
|
|
972
210
|
}
|
|
973
211
|
|
|
974
212
|
/**
|
|
975
|
-
* Clear
|
|
976
|
-
* @param {boolean} [keepCursors=false] - Whether to keep cursor positions
|
|
977
|
-
* @param {boolean} [keepLanes=false] - Whether to keep processing lanes
|
|
213
|
+
* Clear buffer and reset state.
|
|
978
214
|
*/
|
|
979
|
-
clear(
|
|
980
|
-
this.
|
|
981
|
-
this.
|
|
982
|
-
this.
|
|
215
|
+
clear() {
|
|
216
|
+
this.writeIndex = 0;
|
|
217
|
+
this.sequence = 0;
|
|
218
|
+
this.buffer.fill(undefined);
|
|
983
219
|
|
|
984
|
-
|
|
985
|
-
|
|
220
|
+
// Reset reader sequences
|
|
221
|
+
for (const readerId of this.readers.keys()) {
|
|
222
|
+
this.readers.set(readerId, -1);
|
|
986
223
|
}
|
|
987
224
|
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
lane.flushActive = false;
|
|
225
|
+
// Resolve all waiting readers with empty results
|
|
226
|
+
for (const waiter of this.waitingReaders) {
|
|
227
|
+
if (waiter.batchSize) {
|
|
228
|
+
waiter.resolve({ data: [], startOffset: 0, lastOffset: -1 });
|
|
229
|
+
} else {
|
|
230
|
+
waiter.resolve({ data: null, offset: -1 });
|
|
995
231
|
}
|
|
996
232
|
}
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
/**
|
|
1000
|
-
* Remove cursor
|
|
1001
|
-
* @param {string} name - Cursor name to remove
|
|
1002
|
-
* @returns {boolean} True if cursor was removed
|
|
1003
|
-
*/
|
|
1004
|
-
removeCursor(name) {
|
|
1005
|
-
return this.cursors.delete(name);
|
|
1006
|
-
}
|
|
1007
|
-
|
|
1008
|
-
/**
|
|
1009
|
-
* Get all cursor names
|
|
1010
|
-
* @returns {string[]} Array of cursor names
|
|
1011
|
-
*/
|
|
1012
|
-
getCursorNames() {
|
|
1013
|
-
return Array.from(this.cursors.keys());
|
|
1014
|
-
}
|
|
1015
|
-
|
|
1016
|
-
/**
|
|
1017
|
-
* Check if buffer is full
|
|
1018
|
-
* @returns {boolean} True if at capacity
|
|
1019
|
-
*/
|
|
1020
|
-
isFull() {
|
|
1021
|
-
return this.entries.length >= this.maxSize;
|
|
1022
|
-
}
|
|
1023
|
-
|
|
1024
|
-
/**
|
|
1025
|
-
* Check if buffer is empty
|
|
1026
|
-
* @returns {boolean} True if empty
|
|
1027
|
-
*/
|
|
1028
|
-
isEmpty() {
|
|
1029
|
-
return this.entries.length === 0;
|
|
1030
|
-
}
|
|
1031
|
-
|
|
1032
|
-
/**
|
|
1033
|
-
* Get current size
|
|
1034
|
-
* @returns {number} Number of entries
|
|
1035
|
-
*/
|
|
1036
|
-
size() {
|
|
1037
|
-
return this.entries.length;
|
|
1038
|
-
}
|
|
1039
|
-
|
|
1040
|
-
/**
|
|
1041
|
-
* Get capacity
|
|
1042
|
-
* @returns {number} Maximum capacity
|
|
1043
|
-
*/
|
|
1044
|
-
capacity() {
|
|
1045
|
-
return this.maxSize;
|
|
1046
|
-
}
|
|
1047
|
-
|
|
1048
|
-
/**
|
|
1049
|
-
* Iterator support - allows for...of loops
|
|
1050
|
-
* @returns {Iterator<RingBufferEntry>} Iterator over entries
|
|
1051
|
-
*/
|
|
1052
|
-
*[Symbol.iterator]() {
|
|
1053
|
-
for (const entry of this.entries) {
|
|
1054
|
-
yield entry;
|
|
1055
|
-
}
|
|
1056
|
-
}
|
|
1057
|
-
|
|
1058
|
-
/**
|
|
1059
|
-
* Create a new ring buffer from this one with filtered entries
|
|
1060
|
-
* @param {Function} predicate - Filter function
|
|
1061
|
-
* @param {number} [maxSize] - Size of new buffer (defaults to same)
|
|
1062
|
-
* @returns {RingBuffer} New filtered ring buffer
|
|
1063
|
-
*/
|
|
1064
|
-
createFiltered(predicate, maxSize = this.maxSize) {
|
|
1065
|
-
const newBuffer = new RingBuffer(maxSize);
|
|
1066
|
-
const filtered = this.entries.filter(predicate);
|
|
1067
|
-
|
|
1068
|
-
for (const entry of filtered) {
|
|
1069
|
-
newBuffer.push(entry.data, entry.meta);
|
|
1070
|
-
}
|
|
1071
|
-
|
|
1072
|
-
return newBuffer;
|
|
233
|
+
this.waitingReaders.clear();
|
|
1073
234
|
}
|
|
1074
235
|
}
|