event-storage 1.0.0 → 1.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.
@@ -1,7 +1,9 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
3
  import events from 'events';
4
- import { assert, alignTo, hash, binarySearch } from '../util.js';
4
+ import { assert, alignTo, hash, binarySearch } from '../utils/util.js';
5
+
6
+
5
7
 
6
8
  const DEFAULT_READ_BUFFER_SIZE = 64 * 1024;
7
9
  const DOCUMENT_HEADER_SIZE = 16;
@@ -114,10 +116,10 @@ class ReadablePartition extends events.EventEmitter {
114
116
  fs.readSync(this.fd, headerBuffer, 0, 8 + 4, 0);
115
117
  const headerMagic = headerBuffer.toString('utf8', 0, 8);
116
118
 
117
- assert(headerMagic.substr(0, 6) === HEADER_MAGIC.substr(0, 6), `Invalid file header in partition ${this.name}.`);
119
+ assert(headerMagic.substring(0, 6) === HEADER_MAGIC.substring(0, 6), `Invalid file header in partition ${this.name}.`);
118
120
 
119
121
  this.header = headerMagic;
120
- assert(headerMagic === HEADER_MAGIC, `Invalid file version. The partition ${this.name} was created with a different library version (${headerMagic.substr(6)}).`);
122
+ assert(headerMagic === HEADER_MAGIC, `Invalid file version. The partition ${this.name} was created with a different library version (${headerMagic.substring(6)}).`);
121
123
 
122
124
  const metadataSize = headerBuffer.readUInt32BE(8);
123
125
  assert(metadataSize > 2 && metadataSize <= 4096, 'Invalid metadata size.');
@@ -200,51 +202,35 @@ class ReadablePartition extends events.EventEmitter {
200
202
  const dataSize = buffer.readUInt32BE(offset + 0);
201
203
  assert(dataSize > 0 && dataSize <= 64 * 1024 * 1024, `Error reading document size from ${position}, got ${dataSize}.`);
202
204
 
203
- if (size && dataSize !== size) {
204
- throw new InvalidDataSizeError(`Invalid document size ${dataSize} at position ${position}, expected ${size}.`);
205
- }
205
+ assert(!size || dataSize === size, `Invalid document size ${dataSize} at position ${position}, expected ${size}.`, InvalidDataSizeError);
206
206
 
207
207
  const sequenceNumber = buffer.readUInt32BE(offset + 4);
208
208
  const time64 = buffer.readDoubleBE(offset + 8);
209
209
  return ({ dataSize, sequenceNumber, time64 });
210
210
  }
211
211
 
212
- /**
213
- * Prepare the read buffer for reading from the specified position.
214
- *
215
- * @protected
216
- * @param {number} position The position in the file to prepare the read buffer for reading from.
217
- * @returns {{ buffer: Buffer|null, cursor: number, length: number }} A reader object with properties `buffer`, `cursor` and `length`.
218
- */
219
- prepareReadBuffer(position) {
220
- if (position + DOCUMENT_HEADER_SIZE >= this.size) {
221
- return ({ buffer: null, cursor: 0, length: 0 });
222
- }
223
- let bufferCursor = position - this.readBufferPos;
224
- if (this.readBufferPos < 0 || bufferCursor < 0 || bufferCursor + DOCUMENT_HEADER_SIZE + DOCUMENT_ALIGNMENT > this.readBufferLength) {
225
- this.fillBuffer(position);
226
- bufferCursor = 0;
227
- }
228
- return ({ buffer: this.readBuffer, cursor: bufferCursor, length: this.readBufferLength });
212
+ resolveIterationPosition(position) {
213
+ return position < 0 ? this.size + position + 1 : position;
229
214
  }
230
215
 
231
- /**
232
- * Prepare the read buffer for reading *before* the specified position. Don't try to reader *after* the returned cursor.
233
- *
234
- * @protected
235
- * @param {number} position The position in the file to prepare the read buffer for reading before.
236
- * @returns {{ buffer: Buffer|null, cursor: number, length: number }} A reader object with properties `buffer`, `cursor` and `length`.
237
- */
238
- prepareReadBufferBackwards(position) {
239
- if (position < 0) {
240
- return ({ buffer: null, cursor: 0, length: 0 });
216
+ selectReader(position, size, backwardsHint) {
217
+ if (size > 0 && backwardsHint) {
218
+ const bufferOffset = DOCUMENT_HEADER_SIZE + size;
219
+ const reader = this.prepareReadBufferBackwards(position + bufferOffset, bufferOffset);
220
+ return { reader, bufferOffset };
241
221
  }
242
- let bufferCursor = position - this.readBufferPos;
243
- if (this.readBufferPos < 0 || (this.readBufferPos > 0 && bufferCursor < DOCUMENT_FOOTER_SIZE) || bufferCursor > this.readBufferLength) {
244
- this.fillBuffer(Math.max(position - this.readBuffer.byteLength, 0));
245
- bufferCursor = position - this.readBufferPos;
222
+ return { reader: this.prepareReadBuffer(position), bufferOffset: 0 };
223
+ }
224
+
225
+ assignHeaderOutput(headerOut, header) {
226
+ if (headerOut === null) {
227
+ return;
246
228
  }
247
- return ({ buffer: this.readBuffer, cursor: bufferCursor, length: this.readBufferLength });
229
+ headerOut.dataSize = header.dataSize;
230
+ headerOut.sequenceNumber = header.sequenceNumber;
231
+ // Denormalize time64 relative to this partition's epoch so callers can compare
232
+ // timestamps across partitions without needing to know the epoch value.
233
+ headerOut.time64 = this.metadata.epoch + header.time64;
248
234
  }
249
235
 
250
236
  /**
@@ -255,39 +241,37 @@ class ReadablePartition extends events.EventEmitter {
255
241
  * @param {number} [size] The expected byte size of the document at the given position.
256
242
  * @param {object|null} [headerOut] Optional object to populate with the document header fields
257
243
  * (`dataSize`, `sequenceNumber`, `time64`). Pass an existing object to avoid extra allocation.
258
- * @returns {string|boolean} The data stored at the given position or false if no data could be read.
244
+ * @param {boolean} [backwardsHint] If set to true, will optimize buffering for backwards reads.
245
+ * @returns {Buffer|boolean} The data stored at the given position or false if no data could be read.
259
246
  * @throws {Error} if the storage entry at the given position is corrupted.
260
247
  * @throws {InvalidDataSizeError} if the document size at the given position does not match the provided size.
261
248
  * @throws {CorruptFileError} if the document at the given position can not be read completely.
262
249
  */
263
- readFrom(position, size = 0, headerOut = null) {
250
+ readFrom(position, size = 0, headerOut = null, backwardsHint = false) {
264
251
  assert(this.fd, 'Partition is not opened.');
265
252
  assert((position % DOCUMENT_ALIGNMENT) === 0, `Invalid read position ${position}. Needs to be a multiple of ${DOCUMENT_ALIGNMENT}.`);
266
253
 
267
- const reader = this.prepareReadBuffer(position);
268
- if (reader.length < size + DOCUMENT_HEADER_SIZE) {
254
+ const { reader, bufferOffset } = this.selectReader(position, size, backwardsHint);
255
+ if (reader.length < DOCUMENT_HEADER_SIZE) {
269
256
  return false;
270
257
  }
271
258
 
259
+ // prepareReadBufferBackwards positions the cursor at position + bufferOffset (the end of
260
+ // the document data), so the previous document in file order lands inside the buffer on the next
261
+ // backwards read. Adjust the cursor back to the document header start before reading.
262
+ reader.cursor -= bufferOffset;
272
263
  let dataPosition = reader.cursor + DOCUMENT_HEADER_SIZE;
273
- const { dataSize, sequenceNumber, time64 } = this.readDocumentHeader(reader.buffer, reader.cursor, position, size);
274
- if (headerOut !== null) {
275
- headerOut.dataSize = dataSize;
276
- headerOut.sequenceNumber = sequenceNumber;
277
- headerOut.time64 = time64;
278
- }
264
+ const header = this.readDocumentHeader(reader.buffer, reader.cursor, position, size);
265
+ const dataSize = header.dataSize;
266
+ this.assignHeaderOutput(headerOut, header);
279
267
 
280
- // TODO: This should only be checked on opening
281
268
  const writeSize = this.documentWriteSize(dataSize);
282
- if (position + writeSize > this.size) {
283
- throw new CorruptFileError(`Invalid document at position ${position}. This may be caused by an unfinished write.`);
284
- }
269
+ assert(position + writeSize <= this.size, `Invalid document at position ${position}. This may be caused by an unfinished write.`, CorruptFileError);
285
270
 
286
271
  if (dataSize + DOCUMENT_HEADER_SIZE > reader.buffer.byteLength) {
287
- //console.log('sync read for large document size', dataLength, 'at position', position);
288
272
  const tempReadBuffer = Buffer.allocUnsafe(dataSize);
289
273
  fs.readSync(this.fd, tempReadBuffer, 0, dataSize, this.headerSize + position + DOCUMENT_HEADER_SIZE);
290
- return tempReadBuffer.toString('utf8');
274
+ return tempReadBuffer;
291
275
  }
292
276
 
293
277
  if (reader.cursor > 0 && dataPosition + dataSize > reader.length) {
@@ -295,7 +279,48 @@ class ReadablePartition extends events.EventEmitter {
295
279
  dataPosition = DOCUMENT_HEADER_SIZE;
296
280
  }
297
281
 
298
- return reader.buffer.toString('utf8', dataPosition, dataPosition + dataSize);
282
+ // reader.buffer is a shared buffer filled by fillBuffer; callers must consume the returned
283
+ // view before the next readFrom call (which may fill the same buffer region).
284
+ return reader.buffer.subarray(dataPosition, dataPosition + dataSize);
285
+ }
286
+
287
+ /**
288
+ * Prepare the read buffer for reading from the specified position.
289
+ *
290
+ * @protected
291
+ * @param {number} position The position in the file to prepare the read buffer for reading from.
292
+ * @returns {{ buffer: Buffer|null, cursor: number, length: number }} A reader object with properties `buffer`, `cursor` and `length`.
293
+ */
294
+ prepareReadBuffer(position) {
295
+ if (position + DOCUMENT_HEADER_SIZE >= this.size) {
296
+ return ({ buffer: null, cursor: 0, length: 0 });
297
+ }
298
+ let bufferCursor = position - this.readBufferPos;
299
+ if (this.readBufferPos < 0 || bufferCursor < 0 || bufferCursor + DOCUMENT_HEADER_SIZE + DOCUMENT_ALIGNMENT > this.readBufferLength) {
300
+ this.fillBuffer(position);
301
+ bufferCursor = 0;
302
+ }
303
+ return ({ buffer: this.readBuffer, cursor: bufferCursor, length: this.readBufferLength });
304
+ }
305
+
306
+ /**
307
+ * Prepare the read buffer for reading *before* the specified position. Don't try to read *after* the returned cursor.
308
+ *
309
+ * @protected
310
+ * @param {number} position The position in the file to prepare the read buffer for reading before.
311
+ * @param {number} [size] The amount of bytes that need to be buffered before position. By default, only guarantees that the document footer can be read.
312
+ * @returns {{ buffer: Buffer|null, cursor: number, length: number }} A reader object with properties `buffer`, `cursor` and `length`.
313
+ */
314
+ prepareReadBufferBackwards(position, size = 0) {
315
+ if (position < 0) {
316
+ return ({ buffer: null, cursor: 0, length: 0 });
317
+ }
318
+ let bufferCursor = position - this.readBufferPos;
319
+ if (this.readBufferPos < 0 || (this.readBufferPos > 0 && bufferCursor < size + DOCUMENT_FOOTER_SIZE) || bufferCursor > this.readBufferLength) {
320
+ this.fillBuffer(Math.max(position - this.readBuffer.byteLength, 0));
321
+ bufferCursor = position - this.readBufferPos;
322
+ }
323
+ return ({ buffer: this.readBuffer, cursor: bufferCursor, length: this.readBufferLength });
299
324
  }
300
325
 
301
326
  /**
@@ -307,7 +332,7 @@ class ReadablePartition extends events.EventEmitter {
307
332
  */
308
333
  findDocumentPositionBefore(position) {
309
334
  assert(this.fd, 'Partition is not opened.');
310
- position -= (position % DOCUMENT_ALIGNMENT);
335
+ if (position > 0) position -= (position % DOCUMENT_ALIGNMENT);
311
336
  if (position <= 0) {
312
337
  return false;
313
338
  }
@@ -373,49 +398,49 @@ class ReadablePartition extends events.EventEmitter {
373
398
  }
374
399
 
375
400
  /**
376
- * Find the first document whose sequenceNumber is >= the given value.
401
+ * Find a document around the target sequence number using one of two binary-search modes.
377
402
  * Uses readLast() to short-circuit when the partition contains no such document.
378
403
  * Uses a binary search over file positions via readDocumentBefore() to locate the
379
- * document. The search tracks both the lower bound (position just after the last
380
- * confirmed "< sequenceNumber" doc) and the upper bound (minimum position of any
381
- * probed doc with sequenceNumber >= target). The upper bound, when available, is
382
- * the exact target document, so no further linear scan is needed.
404
+ * document and tracks nearest candidates on both sides of the target.
383
405
  *
384
406
  * @api
385
407
  * @param {number} sequenceNumber The 0-based sequence number to search for.
386
- * @returns {{ reader: Generator<string>, headerOut: object, data: string }|null}
387
- * The matched document with its reader and shared headerOut, or null if no such document exists.
408
+ * @param {boolean} [min=true] When true, returns the first document with sequenceNumber >= target.
409
+ * When false, returns the last document with sequenceNumber <= target.
410
+ * @returns {{ data: Buffer, header: object, position: number }|null}
411
+ * The matched document with its header and position, or null if no such document exists.
388
412
  */
389
- findDocument(sequenceNumber) {
413
+ findDocument(sequenceNumber, min = true) {
390
414
  const last = this.readLast();
391
- if (!last || last.header.sequenceNumber < sequenceNumber) {
415
+ if (!last) {
392
416
  return null;
393
417
  }
394
418
 
395
- let startPosition = this.size;
396
- binarySearch(
419
+ if (min && last.header.sequenceNumber < sequenceNumber) {
420
+ return null;
421
+ }
422
+
423
+ const [low, high] = binarySearch(
397
424
  sequenceNumber,
398
425
  this.size,
399
426
  (pos) => {
400
427
  const doc = this.readDocumentBefore(pos);
401
- if (!doc) return sequenceNumber;
402
- if (doc.header.sequenceNumber < sequenceNumber) {
403
- startPosition = Math.max(startPosition, doc.position + this.documentWriteSize(doc.header.dataSize));
404
- } else {
405
- startPosition = Math.min(startPosition, doc.position);
406
- }
407
- return doc.header.sequenceNumber;
428
+ return doc ? doc.header.sequenceNumber : Number.MIN_SAFE_INTEGER;
408
429
  }
409
430
  );
410
431
 
411
- const headerOut = {};
412
- const data = this.readFrom(startPosition, 0, headerOut);
432
+ const position = this.findDocumentPositionBefore(min ? low : high);
433
+ if (position === false || position < 0) {
434
+ return null;
435
+ }
436
+
437
+ const header = {};
438
+ const data = this.readFrom(position, 0, header);
413
439
  /* istanbul ignore if */
414
440
  if (data === false) {
415
441
  return null;
416
442
  }
417
- headerOut.position = startPosition;
418
- return { headerOut, data };
443
+ return { data, header, position };
419
444
  }
420
445
 
421
446
  /**
@@ -424,10 +449,10 @@ class ReadablePartition extends events.EventEmitter {
424
449
  * @param {object|null} [headerOut] Optional object to populate with document header fields
425
450
  * (`dataSize`, `sequenceNumber`, `time64`, `position`) on each yield. Pass an existing object
426
451
  * to avoid extra allocation. The object is mutated in place before each yield.
427
- * @returns {Generator<string>} A generator that returns all documents in this partition.
452
+ * @returns {Generator<Buffer>} A generator that returns all documents in this partition.
428
453
  */
429
454
  *readAll(after = 0, headerOut = null) {
430
- let position = after < 0 ? this.size + after + 1 : after;
455
+ let position = this.resolveIterationPosition(after);
431
456
  const internalHeader = headerOut !== null ? headerOut : {};
432
457
  let data;
433
458
  while ((data = this.readFrom(position, 0, internalHeader)) !== false) {
@@ -442,16 +467,59 @@ class ReadablePartition extends events.EventEmitter {
442
467
  /**
443
468
  * @api
444
469
  * @param {number} [before] The document position to start reading backward from.
445
- * @returns {Generator<string>} A generator that returns all documents in this partition in reverse order.
470
+ * @param {object|null} [headerOut] Optional object to populate with document header fields
471
+ * (`dataSize`, `sequenceNumber`, `time64`, `position`) on each yield.
472
+ * @returns {Generator<Buffer>} A generator that returns all documents in this partition in reverse order.
446
473
  */
447
- *readAllBackwards(before = -1) {
448
- let position = before < 0 ? this.size + before + 1 : before;
474
+ *readAllBackwards(before = -1, headerOut = null) {
475
+ let position = this.resolveIterationPosition(before);
476
+ const internalHeader = headerOut !== null ? headerOut : {};
449
477
  while ((position = this.findDocumentPositionBefore(position)) !== false) {
450
- const data = this.readFrom(position);
478
+ const data = this.readFrom(position, 0, internalHeader, true);
479
+ if (headerOut !== null) {
480
+ headerOut.position = position;
481
+ }
451
482
  yield data;
452
483
  }
453
484
  }
485
+
486
+ /**
487
+ * Read documents with sequenceNumber in the inclusive range [from, until].
488
+ * If from > until, documents are yielded in reverse order.
489
+ *
490
+ * @api
491
+ * @param {number} [from=0]
492
+ * @param {number} [until=Number.MAX_SAFE_INTEGER]
493
+ * @returns {Generator<{ data: Buffer, header: object, entry: object }>}
494
+ */
495
+ *readRange(from = 0, until = Number.MAX_SAFE_INTEGER) {
496
+ const forwards = from <= until;
497
+ const lo = Math.min(from, until);
498
+ const hi = Math.max(from, until);
499
+ const found = this.findDocument(forwards ? lo : hi, forwards);
500
+ if (!found || found.header.sequenceNumber < lo || found.header.sequenceNumber > hi) {
501
+ return;
502
+ }
503
+
504
+ const entry = { partition: this.id };
505
+ const header = {};
506
+ const iterator = forwards
507
+ ? this.readAll(found.position, header)
508
+ : this.readAllBackwards(found.position + this.documentWriteSize(found.header.dataSize), header);
509
+
510
+ for (const data of iterator) {
511
+ if (header.sequenceNumber < lo || header.sequenceNumber > hi) {
512
+ return;
513
+ }
514
+ entry.number = header.sequenceNumber;
515
+ entry.position = header.position;
516
+ entry.size = header.dataSize;
517
+ yield { data, header, entry };
518
+ }
519
+ }
520
+
521
+
454
522
  }
455
523
 
456
524
  export default ReadablePartition;
457
- export { CorruptFileError, InvalidDataSizeError, HEADER_MAGIC, DOCUMENT_SEPARATOR, DOCUMENT_ALIGNMENT, DOCUMENT_HEADER_SIZE, DOCUMENT_FOOTER_SIZE };
525
+ export { CorruptFileError, InvalidDataSizeError, HEADER_MAGIC, DOCUMENT_SEPARATOR, DOCUMENT_ALIGNMENT, DOCUMENT_HEADER_SIZE, DOCUMENT_FOOTER_SIZE };
@@ -1,6 +1,8 @@
1
1
  import fs from 'fs';
2
2
  import ReadablePartition, { CorruptFileError, HEADER_MAGIC, DOCUMENT_ALIGNMENT, DOCUMENT_SEPARATOR, DOCUMENT_HEADER_SIZE, DOCUMENT_FOOTER_SIZE } from './ReadablePartition.js';
3
- import { assert, buildMetadataHeader, alignTo, ensureDirectory } from '../util.js';
3
+ import { assert, alignTo } from '../utils/util.js';
4
+ import { buildMetadataHeader } from '../utils/metadataUtil.js';
5
+ import { ensureDirectory } from '../utils/fsUtil.js';
4
6
  import Clock from '../Clock.js';
5
7
 
6
8
  const DEFAULT_WRITE_BUFFER_SIZE = 16 * 1024;
@@ -175,22 +177,34 @@ class WritablePartition extends ReadablePartition {
175
177
  * @returns {number} The size of the document header
176
178
  */
177
179
  writeDocumentHeader(buffer, offset, dataSize, sequenceNumber = null, time64 = null) {
178
- if (sequenceNumber === null) {
179
- sequenceNumber = 0;
180
- }
181
- if (time64 === null) {
182
- time64 = this.clock.time();
183
- }
180
+ ({ sequenceNumber, time64 } = this.normalizeWriteMetadata(sequenceNumber, time64));
184
181
  /* istanbul ignore if */
185
- if (time64 < 0) {
186
- throw new Error('Time may not be negative!');
187
- }
182
+ assert(time64 >= 0, 'Time may not be negative!');
183
+
188
184
  buffer.writeUInt32BE(dataSize, offset);
189
185
  buffer.writeUInt32BE(sequenceNumber, offset + 4);
190
186
  buffer.writeDoubleBE(time64, offset + 8);
191
187
  return DOCUMENT_HEADER_SIZE;
192
188
  }
193
189
 
190
+ normalizeWriteMetadata(sequenceNumber, time64) {
191
+ return {
192
+ sequenceNumber: sequenceNumber === null ? 0 : sequenceNumber,
193
+ time64: time64 === null ? this.clock.time() : time64
194
+ };
195
+ }
196
+
197
+ normalizeWriteArguments(sequenceNumber, callback) {
198
+ if (typeof sequenceNumber === 'function') {
199
+ return { sequenceNumber: null, callback: sequenceNumber };
200
+ }
201
+ return { sequenceNumber, callback };
202
+ }
203
+
204
+ shouldWriteUnbuffered(dataSize) {
205
+ return dataSize + DOCUMENT_HEADER_SIZE >= this.writeBuffer.byteLength * 4 / 5;
206
+ }
207
+
194
208
  /**
195
209
  * Write the given data to the partition without buffering.
196
210
  * @private
@@ -208,7 +222,7 @@ class WritablePartition extends ReadablePartition {
208
222
  bytesWritten += fs.writeSync(this.fd, this.writeMetaBuffer, 0, DOCUMENT_HEADER_SIZE);
209
223
  bytesWritten += fs.writeSync(this.fd, data);
210
224
  const padSize = alignTo(dataSize + DOCUMENT_FOOTER_SIZE, DOCUMENT_ALIGNMENT);
211
- bytesWritten += fs.writeSync(this.fd, DOCUMENT_PAD.substr(0, padSize));
225
+ bytesWritten += fs.writeSync(this.fd, DOCUMENT_PAD.substring(0, padSize));
212
226
  this.writeMetaBuffer.writeUInt32BE(dataSize, 0);
213
227
  bytesWritten += fs.writeSync(this.fd, this.writeMetaBuffer, 0, 4);
214
228
  bytesWritten += fs.writeSync(this.fd, DOCUMENT_SEPARATOR);
@@ -235,7 +249,7 @@ class WritablePartition extends ReadablePartition {
235
249
  bytesWritten += this.writeDocumentHeader(this.writeBuffer, this.writeBufferCursor, dataSize, sequenceNumber);
236
250
  bytesWritten += this.writeBuffer.write(data, this.writeBufferCursor + bytesWritten, 'utf8');
237
251
  const padSize = alignTo(dataSize + DOCUMENT_FOOTER_SIZE, DOCUMENT_ALIGNMENT);
238
- bytesWritten += this.writeBuffer.write(DOCUMENT_PAD.substr(0, padSize), this.writeBufferCursor + bytesWritten, 'utf8');
252
+ bytesWritten += this.writeBuffer.write(DOCUMENT_PAD.substring(0, padSize), this.writeBufferCursor + bytesWritten, 'utf8');
239
253
  this.writeBuffer.writeUInt32BE(dataSize, this.writeBufferCursor + bytesWritten);
240
254
  bytesWritten += 4;
241
255
  bytesWritten += this.writeBuffer.write(DOCUMENT_SEPARATOR, this.writeBufferCursor + bytesWritten, 'utf8');
@@ -257,15 +271,12 @@ class WritablePartition extends ReadablePartition {
257
271
  */
258
272
  write(data, sequenceNumber, callback) {
259
273
  assert(this.fd, 'Partition is not opened.');
260
- if (typeof sequenceNumber === 'function') {
261
- callback = sequenceNumber;
262
- sequenceNumber = null;
263
- }
274
+ ({ sequenceNumber, callback } = this.normalizeWriteArguments(sequenceNumber, callback));
264
275
  const dataSize = Buffer.byteLength(data, 'utf8');
265
276
  assert(dataSize <= 64 * 1024 * 1024, 'Document is too large! Maximum is 64 MB');
266
277
 
267
278
  const dataPosition = this.size;
268
- if (dataSize + DOCUMENT_HEADER_SIZE >= this.writeBuffer.byteLength * 4 / 5) {
279
+ if (this.shouldWriteUnbuffered(dataSize)) {
269
280
  this.size += this.writeUnbuffered(data, dataSize, sequenceNumber, callback);
270
281
  } else {
271
282
  this.size += this.writeBuffered(data, dataSize, sequenceNumber, callback);
@@ -298,15 +309,16 @@ class WritablePartition extends ReadablePartition {
298
309
  *
299
310
  * @protected
300
311
  * @param {number} position The position in the file to prepare the read buffer for reading before.
312
+ * @param {number} [size] The amount of bytes that need to be buffered before position. By default, only guarantees that the document footer can be read.
301
313
  * @returns {object} A reader object with properties `buffer`, `cursor` and `length`.
302
314
  */
303
- prepareReadBufferBackwards(position) {
315
+ prepareReadBufferBackwards(position, size = 0) {
304
316
  const bufferPos = this.size - this.writeBufferCursor;
305
317
  // Handle the case when data that is still in write buffer is supposed to be read backwards
306
318
  if (this.dirtyReads && this.writeBufferCursor > 0 && position > bufferPos) {
307
319
  return { buffer: this.writeBuffer, cursor: position - bufferPos, length: this.writeBufferCursor };
308
320
  }
309
- return super.prepareReadBufferBackwards(position);
321
+ return super.prepareReadBufferBackwards(position, size);
310
322
  }
311
323
 
312
324
  /**
@@ -315,16 +327,17 @@ class WritablePartition extends ReadablePartition {
315
327
  *
316
328
  * @api
317
329
  * @param {number} [before] The document position to start reading backward from.
318
- * @returns {Generator<string>} A generator that returns all documents in this partition in reverse order.
330
+ * @param {object|null} [headerOut] Optional object to populate with document header fields on each yield.
331
+ * @returns {Generator<Buffer>} A generator that returns all documents in this partition in reverse order.
319
332
  */
320
- *readAllBackwards(before = -1) {
333
+ *readAllBackwards(before = -1, headerOut = null) {
321
334
  if (!this.dirtyReads && this.writeBufferCursor > 0) {
322
335
  const flushedSize = this.size - this.writeBufferCursor;
323
336
  const clampedBefore = before < 0 ? flushedSize : Math.min(before, flushedSize);
324
- yield* super.readAllBackwards(clampedBefore);
337
+ yield* super.readAllBackwards(clampedBefore, headerOut);
325
338
  return;
326
339
  }
327
- yield* super.readAllBackwards(before);
340
+ yield* super.readAllBackwards(before, headerOut);
328
341
  }
329
342
 
330
343
  /**
@@ -386,9 +399,7 @@ class WritablePartition extends ReadablePartition {
386
399
  try {
387
400
  this.readFrom(after);
388
401
  } catch (e) {
389
- if (!(e instanceof CorruptFileError)) {
390
- throw new Error('Can only truncate on valid document boundaries.');
391
- }
402
+ assert(e instanceof CorruptFileError, 'Can only truncate on valid document boundaries.');
392
403
  }
393
404
 
394
405
  fs.truncateSync(this.fileName, this.headerSize + after);
@@ -0,0 +1,149 @@
1
+ /**
2
+ * A fixed-capacity registry of partitions with LRU eviction of open file handles.
3
+ *
4
+ * All partitions are stored by their numeric id and may be queried at any time.
5
+ * The pool additionally tracks which partitions currently have an open file descriptor
6
+ * in LRU (least-recently-used) order. When the pool is asked to open a partition and
7
+ * doing so would exceed the configured cap, the least-recently-used open partition is
8
+ * closed first to stay within the limit.
9
+ *
10
+ * Setting the cap to 0 disables eviction: all partitions are allowed to remain open
11
+ * simultaneously, which matches the uncapped behaviour of the original implementation.
12
+ */
13
+ class PartitionPool {
14
+
15
+ /**
16
+ * @param {number} [maxOpen=0] Maximum number of simultaneously open partition file
17
+ * handles. 0 disables the limit (no eviction).
18
+ */
19
+ constructor(maxOpen = 0) {
20
+ this.maxOpen = maxOpen;
21
+ /** Registry of all known partitions keyed by id. */
22
+ this.registry = Object.create(null);
23
+ /**
24
+ * Insertion-order map used for LRU tracking of open file handles.
25
+ * Key = partition id, value = true.
26
+ * Oldest (least-recently-used) entry is first; newest (most-recently-used) is last.
27
+ */
28
+ this.handles = new Map();
29
+ }
30
+
31
+ /**
32
+ * Register a partition under the given id.
33
+ *
34
+ * @param {number|string} id
35
+ * @param {object} partition
36
+ */
37
+ add(id, partition) {
38
+ this.registry[id] = partition;
39
+ }
40
+
41
+ /**
42
+ * Retrieve a registered partition without opening it.
43
+ *
44
+ * @param {number|string} id
45
+ * @returns {object|undefined}
46
+ */
47
+ get(id) {
48
+ return this.registry[id];
49
+ }
50
+
51
+ /**
52
+ * Check whether a partition with the given id is registered in the pool.
53
+ *
54
+ * @param {number|string} id
55
+ * @returns {boolean}
56
+ */
57
+ has(id) {
58
+ return id in this.registry;
59
+ }
60
+
61
+ /**
62
+ * Open the partition with the given id, applying LRU eviction if necessary.
63
+ *
64
+ * If the partition is not yet open and adding it would exceed `maxOpen`, the
65
+ * least-recently-used open partition is closed first. Stale entries (partitions
66
+ * that were closed externally) are discarded from the LRU map as they are
67
+ * encountered; if all tracked entries turn out to be stale the loop exits without
68
+ * closing any partition — the handle count stays temporarily inflated (bounded by
69
+ * the number of external closes since the last `open()` call) but correctness is
70
+ * preserved.
71
+ *
72
+ * @param {number|string} id
73
+ * @returns {object} The opened partition.
74
+ */
75
+ open(id) {
76
+ const partition = this.registry[id];
77
+
78
+ if (this.maxOpen > 0) {
79
+ // Remove id first — this may already bring the handle count below the cap.
80
+ this.handles.delete(id);
81
+ if (this.handles.size >= this.maxOpen) {
82
+ for (const [lruId] of this.handles) {
83
+ this.handles.delete(lruId);
84
+ const lruPartition = this.registry[lruId];
85
+ if (lruPartition && lruPartition.isOpen()) {
86
+ lruPartition.close();
87
+ break;
88
+ }
89
+ }
90
+ }
91
+ // (Re-)add id at the MRU end of the map.
92
+ this.handles.set(id, true);
93
+ }
94
+
95
+ partition.open();
96
+ return partition;
97
+ }
98
+
99
+ /**
100
+ * Invoke `callback` for every registered partition.
101
+ *
102
+ * @param {function(object): void} callback
103
+ */
104
+ forEach(callback) {
105
+ for (const id of Object.keys(this.registry)) {
106
+ callback(this.registry[id]);
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Yield every registered partition object.
112
+ *
113
+ * @returns {Generator<object>}
114
+ */
115
+ *values() {
116
+ for (const id of Object.keys(this.registry)) {
117
+ yield this.registry[id];
118
+ }
119
+ }
120
+
121
+ /**
122
+ * The total number of registered partitions.
123
+ * @returns {number}
124
+ */
125
+ get count() {
126
+ return Object.keys(this.registry).length;
127
+ }
128
+
129
+ /**
130
+ * The number of open partition file handles currently tracked by the pool.
131
+ * @returns {number}
132
+ */
133
+ get openCount() {
134
+ return this.handles.size;
135
+ }
136
+
137
+ /**
138
+ * Reset the open-handle tracking without closing any partitions.
139
+ *
140
+ * Call this after externally closing all partitions (e.g. after
141
+ * `checkTornWrites`) to keep the pool's LRU state consistent with reality.
142
+ */
143
+ clearOpenHandles() {
144
+ this.handles.clear();
145
+ }
146
+
147
+ }
148
+
149
+ export default PartitionPool;