event-storage 0.7.2 → 0.9.1
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/README.md +51 -392
- package/index.js +2 -1
- package/package.json +28 -19
- package/src/Clock.js +20 -8
- package/src/Consumer.js +68 -18
- package/src/EventStore.js +305 -94
- package/src/EventStream.js +171 -17
- package/src/Index/ReadableIndex.js +33 -13
- package/src/Index/WritableIndex.js +33 -17
- package/src/IndexEntry.js +5 -1
- package/src/JoinEventStream.js +32 -30
- package/src/Partition/ReadOnlyPartition.js +1 -0
- package/src/Partition/ReadablePartition.js +201 -49
- package/src/Partition/WritablePartition.js +134 -61
- package/src/Storage/ReadOnlyStorage.js +6 -3
- package/src/Storage/ReadableStorage.js +147 -19
- package/src/Storage/WritableStorage.js +205 -27
- package/src/Watcher.js +38 -29
- package/src/WatchesFile.js +9 -8
- package/src/metadataUtil.js +79 -0
- package/src/util.js +102 -65
- package/test/Consumer.spec.js +0 -268
- package/test/EventStore.spec.js +0 -591
- package/test/EventStream.spec.js +0 -120
- package/test/Index.spec.js +0 -590
- package/test/JoinEventStream.spec.js +0 -113
- package/test/Partition.spec.js +0 -384
- package/test/Storage.spec.js +0 -955
- package/test/Watcher.spec.js +0 -131
|
@@ -1,46 +1,27 @@
|
|
|
1
1
|
const fs = require('fs');
|
|
2
2
|
const path = require('path');
|
|
3
|
-
const
|
|
4
|
-
const { assert } = require('../util');
|
|
3
|
+
const events = require('events');
|
|
4
|
+
const { assert, alignTo, hash, binarySearch } = require('../util');
|
|
5
5
|
|
|
6
6
|
const DEFAULT_READ_BUFFER_SIZE = 64 * 1024;
|
|
7
7
|
const DOCUMENT_HEADER_SIZE = 16;
|
|
8
8
|
const DOCUMENT_ALIGNMENT = 4;
|
|
9
|
+
const DOCUMENT_SEPARATOR = "\x00\x00\x1E\n";
|
|
10
|
+
const DOCUMENT_FOOTER_SIZE = 4 /* additional data size footer */ + DOCUMENT_SEPARATOR.length;
|
|
9
11
|
|
|
10
|
-
// node-event-store partition
|
|
11
|
-
const HEADER_MAGIC = "
|
|
12
|
+
// node-event-store partition V03
|
|
13
|
+
const HEADER_MAGIC = "nesprt03";
|
|
14
|
+
|
|
15
|
+
const NES_EPOCH = new Date('2020-01-01T00:00:00');
|
|
12
16
|
|
|
13
17
|
class CorruptFileError extends Error {}
|
|
14
18
|
class InvalidDataSizeError extends Error {}
|
|
15
19
|
|
|
16
|
-
/**
|
|
17
|
-
* Method for hashing a string (partition name) to a 32-bit unsigned integer.
|
|
18
|
-
*
|
|
19
|
-
* @param {string} str
|
|
20
|
-
* @returns {number}
|
|
21
|
-
*/
|
|
22
|
-
function hash(str) {
|
|
23
|
-
if (str.length === 0) {
|
|
24
|
-
return 0;
|
|
25
|
-
}
|
|
26
|
-
let hash = 5381,
|
|
27
|
-
i = str.length;
|
|
28
|
-
|
|
29
|
-
while(i) {
|
|
30
|
-
hash = ((hash << 5) + hash) ^ str.charCodeAt(--i); // jshint ignore:line
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/* JavaScript does bitwise operations (like XOR, above) on 32-bit signed
|
|
34
|
-
* integers. Since we want the results to be always positive, convert the
|
|
35
|
-
* signed int to an unsigned by doing an unsigned bitshift. */
|
|
36
|
-
return hash >>> 0; // jshint ignore:line
|
|
37
|
-
}
|
|
38
|
-
|
|
39
20
|
/**
|
|
40
21
|
* A partition is a single file where the storage will write documents to depending on some partitioning rules.
|
|
41
22
|
* In the case of an event store, this is most likely the (write) streams.
|
|
42
23
|
*/
|
|
43
|
-
class ReadablePartition extends EventEmitter {
|
|
24
|
+
class ReadablePartition extends events.EventEmitter {
|
|
44
25
|
|
|
45
26
|
/**
|
|
46
27
|
* Get the id for a specific partition name.
|
|
@@ -109,6 +90,7 @@ class ReadablePartition extends EventEmitter {
|
|
|
109
90
|
this.headerSize = 0;
|
|
110
91
|
this.size = this.readFileSize();
|
|
111
92
|
if (this.size <= 0) {
|
|
93
|
+
this.close();
|
|
112
94
|
return false;
|
|
113
95
|
}
|
|
114
96
|
|
|
@@ -146,6 +128,7 @@ class ReadablePartition extends EventEmitter {
|
|
|
146
128
|
const metadata = metadataBuffer.toString('utf8').trim();
|
|
147
129
|
try {
|
|
148
130
|
this.metadata = JSON.parse(metadata);
|
|
131
|
+
this.metadata.epoch = this.metadata.epoch /* istanbul ignore next */|| NES_EPOCH.getTime();
|
|
149
132
|
} catch (e) {
|
|
150
133
|
throw new Error('Invalid metadata.');
|
|
151
134
|
}
|
|
@@ -160,12 +143,12 @@ class ReadablePartition extends EventEmitter {
|
|
|
160
143
|
* @returns {number} The size of the data including header, padded to 16 bytes alignment and ended with a line break.
|
|
161
144
|
*/
|
|
162
145
|
documentWriteSize(dataSize) {
|
|
163
|
-
const padSize = (
|
|
164
|
-
return
|
|
146
|
+
const padSize = alignTo(dataSize + DOCUMENT_FOOTER_SIZE, DOCUMENT_ALIGNMENT);
|
|
147
|
+
return DOCUMENT_HEADER_SIZE + dataSize + padSize + DOCUMENT_FOOTER_SIZE;
|
|
165
148
|
}
|
|
166
149
|
|
|
167
150
|
/**
|
|
168
|
-
* @
|
|
151
|
+
* @protected
|
|
169
152
|
* @returns {number} The file size not including the file header.
|
|
170
153
|
*/
|
|
171
154
|
readFileSize() {
|
|
@@ -208,7 +191,7 @@ class ReadablePartition extends EventEmitter {
|
|
|
208
191
|
* @param {number} offset The position inside the buffer to start reading from.
|
|
209
192
|
* @param {number} position The file position to start reading from.
|
|
210
193
|
* @param {number} [size] The expected byte size of the document at the given position.
|
|
211
|
-
* @returns {{ dataSize: number, sequenceNumber
|
|
194
|
+
* @returns {{ dataSize: number, sequenceNumber: number, time64: number }} The metadata fields of the document
|
|
212
195
|
* @throws {Error} if the storage entry at the given position is corrupted.
|
|
213
196
|
* @throws {InvalidDataSizeError} if the document size at the given position does not match the provided size.
|
|
214
197
|
* @throws {CorruptFileError} if the document at the given position can not be read completely.
|
|
@@ -221,10 +204,6 @@ class ReadablePartition extends EventEmitter {
|
|
|
221
204
|
throw new InvalidDataSizeError(`Invalid document size ${dataSize} at position ${position}, expected ${size}.`);
|
|
222
205
|
}
|
|
223
206
|
|
|
224
|
-
if (position + dataSize + DOCUMENT_HEADER_SIZE > this.size) {
|
|
225
|
-
throw new CorruptFileError(`Invalid document at position ${position}. This may be caused by an unfinished write.`);
|
|
226
|
-
}
|
|
227
|
-
|
|
228
207
|
const sequenceNumber = buffer.readUInt32BE(offset + 4);
|
|
229
208
|
const time64 = buffer.readDoubleBE(offset + 8);
|
|
230
209
|
return ({ dataSize, sequenceNumber, time64 });
|
|
@@ -249,23 +228,41 @@ class ReadablePartition extends EventEmitter {
|
|
|
249
228
|
return ({ buffer: this.readBuffer, cursor: bufferCursor, length: this.readBufferLength });
|
|
250
229
|
}
|
|
251
230
|
|
|
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 });
|
|
241
|
+
}
|
|
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;
|
|
246
|
+
}
|
|
247
|
+
return ({ buffer: this.readBuffer, cursor: bufferCursor, length: this.readBufferLength });
|
|
248
|
+
}
|
|
249
|
+
|
|
252
250
|
/**
|
|
253
251
|
* Read the data from the given position.
|
|
254
252
|
*
|
|
255
253
|
* @api
|
|
256
254
|
* @param {number} position The file position to read from.
|
|
257
255
|
* @param {number} [size] The expected byte size of the document at the given position.
|
|
256
|
+
* @param {object|null} [headerOut] Optional object to populate with the document header fields
|
|
257
|
+
* (`dataSize`, `sequenceNumber`, `time64`). Pass an existing object to avoid extra allocation.
|
|
258
258
|
* @returns {string|boolean} The data stored at the given position or false if no data could be read.
|
|
259
259
|
* @throws {Error} if the storage entry at the given position is corrupted.
|
|
260
260
|
* @throws {InvalidDataSizeError} if the document size at the given position does not match the provided size.
|
|
261
261
|
* @throws {CorruptFileError} if the document at the given position can not be read completely.
|
|
262
262
|
*/
|
|
263
|
-
readFrom(position, size = 0) {
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
assert((position % DOCUMENT_ALIGNMENT) === 0, `Invalid read position. Needs to be a multiple of ${DOCUMENT_ALIGNMENT}.`);
|
|
263
|
+
readFrom(position, size = 0, headerOut = null) {
|
|
264
|
+
assert(this.fd, 'Partition is not opened.');
|
|
265
|
+
assert((position % DOCUMENT_ALIGNMENT) === 0, `Invalid read position ${position}. Needs to be a multiple of ${DOCUMENT_ALIGNMENT}.`);
|
|
269
266
|
|
|
270
267
|
const reader = this.prepareReadBuffer(position);
|
|
271
268
|
if (reader.length < size + DOCUMENT_HEADER_SIZE) {
|
|
@@ -273,7 +270,18 @@ class ReadablePartition extends EventEmitter {
|
|
|
273
270
|
}
|
|
274
271
|
|
|
275
272
|
let dataPosition = reader.cursor + DOCUMENT_HEADER_SIZE;
|
|
276
|
-
const { dataSize } = this.readDocumentHeader(reader.buffer, reader.cursor, position, 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
|
+
}
|
|
279
|
+
|
|
280
|
+
// TODO: This should only be checked on opening
|
|
281
|
+
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
|
+
}
|
|
277
285
|
|
|
278
286
|
if (dataSize + DOCUMENT_HEADER_SIZE > reader.buffer.byteLength) {
|
|
279
287
|
//console.log('sync read for large document size', dataLength, 'at position', position);
|
|
@@ -291,21 +299,165 @@ class ReadablePartition extends EventEmitter {
|
|
|
291
299
|
}
|
|
292
300
|
|
|
293
301
|
/**
|
|
302
|
+
* Find the start position of the document that precedes the given position.
|
|
303
|
+
*
|
|
304
|
+
* @protected
|
|
305
|
+
* @param {number} position The file position to read backwards from.
|
|
306
|
+
* @returns {number|boolean} The start position of the first document before the given position or false if no header could be found.
|
|
307
|
+
*/
|
|
308
|
+
findDocumentPositionBefore(position) {
|
|
309
|
+
assert(this.fd, 'Partition is not opened.');
|
|
310
|
+
position -= (position % DOCUMENT_ALIGNMENT);
|
|
311
|
+
if (position <= 0) {
|
|
312
|
+
return false;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const separatorSize = DOCUMENT_SEPARATOR.length;
|
|
316
|
+
// Optimization if we are at an exact document boundary, where we can just read the document size
|
|
317
|
+
let reader = this.prepareReadBufferBackwards(position);
|
|
318
|
+
const block = reader.buffer.toString('ascii', reader.cursor - separatorSize, reader.cursor);
|
|
319
|
+
if (block === DOCUMENT_SEPARATOR) {
|
|
320
|
+
const dataSize = reader.buffer.readUInt32BE(reader.cursor - separatorSize - 4);
|
|
321
|
+
return position - this.documentWriteSize(dataSize);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
do {
|
|
325
|
+
reader = this.prepareReadBufferBackwards(position - separatorSize);
|
|
326
|
+
|
|
327
|
+
const bufferSeparatorPosition = reader.buffer.lastIndexOf(DOCUMENT_SEPARATOR, reader.cursor - separatorSize, 'ascii');
|
|
328
|
+
if (bufferSeparatorPosition >= 0) {
|
|
329
|
+
position = this.readBufferPos + bufferSeparatorPosition + separatorSize;
|
|
330
|
+
break;
|
|
331
|
+
}
|
|
332
|
+
position -= this.readBufferLength;
|
|
333
|
+
} while (position > 0);
|
|
334
|
+
return Math.max(0, position);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Find the document that starts immediately before `position`, fill the read buffer
|
|
339
|
+
* centered around that document's start, and return its file position and parsed header.
|
|
340
|
+
* The buffer is centered by calling `prepareReadBufferBackwards` with a position
|
|
341
|
+
* half a buffer-length ahead of the document start, clamped to file size, so the
|
|
342
|
+
* document start lands near the middle of the buffer.
|
|
343
|
+
*
|
|
344
|
+
* @private
|
|
345
|
+
* @param {number} position The file position to search before.
|
|
346
|
+
* @returns {{ header: {dataSize: number, sequenceNumber: number, time64: number}, position: number }|null}
|
|
347
|
+
* The document header and file position, or null if no document could be found.
|
|
348
|
+
*/
|
|
349
|
+
readDocumentBefore(position) {
|
|
350
|
+
const docPos = this.findDocumentPositionBefore(position);
|
|
351
|
+
/* istanbul ignore if */
|
|
352
|
+
if (docPos === false || docPos < 0) return null;
|
|
353
|
+
const reader = this.prepareReadBufferBackwards(Math.min(docPos + (this.readBuffer.byteLength >> 1), this.size));
|
|
354
|
+
/* istanbul ignore if */
|
|
355
|
+
if (!reader.buffer) return null;
|
|
356
|
+
const cursor = docPos - this.readBufferPos;
|
|
357
|
+
/* istanbul ignore if */
|
|
358
|
+
if (cursor < 0 || cursor + DOCUMENT_HEADER_SIZE > reader.length) return null;
|
|
359
|
+
const header = this.readDocumentHeader(reader.buffer, cursor, docPos);
|
|
360
|
+
return { header, position: docPos };
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Read the header and file position of the last document in this partition.
|
|
365
|
+
*
|
|
294
366
|
* @api
|
|
295
|
-
* @returns {
|
|
367
|
+
* @returns {{ header: {dataSize: number, sequenceNumber: number, time64: number}, position: number } | null}
|
|
368
|
+
* The last document's header and its file position, or null if the partition is empty or unreadable.
|
|
296
369
|
*/
|
|
297
|
-
|
|
298
|
-
|
|
370
|
+
readLast() {
|
|
371
|
+
if (this.size === 0) return null;
|
|
372
|
+
return this.readDocumentBefore(this.size);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Find the first document whose sequenceNumber is >= the given value.
|
|
377
|
+
* Uses readLast() to short-circuit when the partition contains no such document.
|
|
378
|
+
* 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.
|
|
383
|
+
*
|
|
384
|
+
* @api
|
|
385
|
+
* @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.
|
|
388
|
+
*/
|
|
389
|
+
findDocument(sequenceNumber) {
|
|
390
|
+
const last = this.readLast();
|
|
391
|
+
if (!last || last.header.sequenceNumber < sequenceNumber) {
|
|
392
|
+
return null;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
let startPosition = this.size;
|
|
396
|
+
binarySearch(
|
|
397
|
+
sequenceNumber,
|
|
398
|
+
this.size,
|
|
399
|
+
(pos) => {
|
|
400
|
+
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;
|
|
408
|
+
}
|
|
409
|
+
);
|
|
410
|
+
|
|
411
|
+
const headerOut = {};
|
|
412
|
+
const data = this.readFrom(startPosition, 0, headerOut);
|
|
413
|
+
/* istanbul ignore if */
|
|
414
|
+
if (data === false) {
|
|
415
|
+
return null;
|
|
416
|
+
}
|
|
417
|
+
headerOut.position = startPosition;
|
|
418
|
+
return { headerOut, data };
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* @api
|
|
423
|
+
* @param {number} [after] The document position to start reading from.
|
|
424
|
+
* @param {object|null} [headerOut] Optional object to populate with document header fields
|
|
425
|
+
* (`dataSize`, `sequenceNumber`, `time64`, `position`) on each yield. Pass an existing object
|
|
426
|
+
* 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.
|
|
428
|
+
*/
|
|
429
|
+
*readAll(after = 0, headerOut = null) {
|
|
430
|
+
let position = after < 0 ? this.size + after + 1 : after;
|
|
431
|
+
const internalHeader = headerOut !== null ? headerOut : {};
|
|
299
432
|
let data;
|
|
300
|
-
while ((data = this.readFrom(position)) !== false) {
|
|
433
|
+
while ((data = this.readFrom(position, 0, internalHeader)) !== false) {
|
|
434
|
+
if (headerOut !== null) {
|
|
435
|
+
headerOut.position = position;
|
|
436
|
+
}
|
|
301
437
|
yield data;
|
|
302
|
-
position += this.documentWriteSize(
|
|
438
|
+
position += this.documentWriteSize(internalHeader.dataSize);
|
|
303
439
|
}
|
|
304
440
|
}
|
|
305
441
|
|
|
442
|
+
/**
|
|
443
|
+
* @api
|
|
444
|
+
* @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.
|
|
446
|
+
*/
|
|
447
|
+
*readAllBackwards(before = -1) {
|
|
448
|
+
let position = before < 0 ? this.size + before + 1 : before;
|
|
449
|
+
while ((position = this.findDocumentPositionBefore(position)) !== false) {
|
|
450
|
+
const data = this.readFrom(position);
|
|
451
|
+
yield data;
|
|
452
|
+
}
|
|
453
|
+
}
|
|
306
454
|
}
|
|
307
455
|
|
|
308
456
|
module.exports = ReadablePartition;
|
|
309
457
|
module.exports.CorruptFileError = CorruptFileError;
|
|
310
458
|
module.exports.InvalidDataSizeError = InvalidDataSizeError;
|
|
311
|
-
module.exports.HEADER_MAGIC = HEADER_MAGIC;
|
|
459
|
+
module.exports.HEADER_MAGIC = HEADER_MAGIC;
|
|
460
|
+
module.exports.DOCUMENT_SEPARATOR = DOCUMENT_SEPARATOR;
|
|
461
|
+
module.exports.DOCUMENT_ALIGNMENT = DOCUMENT_ALIGNMENT;
|
|
462
|
+
module.exports.DOCUMENT_HEADER_SIZE = DOCUMENT_HEADER_SIZE;
|
|
463
|
+
module.exports.DOCUMENT_FOOTER_SIZE = DOCUMENT_FOOTER_SIZE;
|
|
@@ -1,24 +1,11 @@
|
|
|
1
1
|
const fs = require('fs');
|
|
2
|
-
const mkdirpSync = require('mkdirp').sync;
|
|
3
2
|
const ReadablePartition = require('./ReadablePartition');
|
|
4
|
-
const { assert, buildMetadataHeader } = require('../util');
|
|
3
|
+
const { assert, buildMetadataHeader, alignTo, ensureDirectory } = require('../util');
|
|
5
4
|
const Clock = require('../Clock');
|
|
6
5
|
|
|
7
6
|
const DEFAULT_WRITE_BUFFER_SIZE = 16 * 1024;
|
|
8
|
-
const DOCUMENT_HEADER_SIZE =
|
|
9
|
-
const
|
|
10
|
-
const DOCUMENT_PAD = ' '.repeat(15) + "\n";
|
|
11
|
-
|
|
12
|
-
const NES_EPOCH = new Date('2020-01-01T00:00:00');
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* @param {number} dataSize
|
|
16
|
-
* @returns {string} The data padded to 16 bytes alignment and ended with a line break.
|
|
17
|
-
*/
|
|
18
|
-
function padData(dataSize) {
|
|
19
|
-
const padSize = (DOCUMENT_ALIGNMENT - ((dataSize + 1) % DOCUMENT_ALIGNMENT)) % DOCUMENT_ALIGNMENT;
|
|
20
|
-
return DOCUMENT_PAD.substr(-padSize - 1);
|
|
21
|
-
}
|
|
7
|
+
const { DOCUMENT_ALIGNMENT, DOCUMENT_SEPARATOR, DOCUMENT_HEADER_SIZE, DOCUMENT_FOOTER_SIZE } = ReadablePartition;
|
|
8
|
+
const DOCUMENT_PAD = ' '.repeat(DOCUMENT_ALIGNMENT);
|
|
22
9
|
|
|
23
10
|
/**
|
|
24
11
|
* A partition is a single file where the storage will write documents to depending on some partitioning rules.
|
|
@@ -49,11 +36,10 @@ class WritablePartition extends ReadablePartition {
|
|
|
49
36
|
},
|
|
50
37
|
clock: Clock
|
|
51
38
|
};
|
|
39
|
+
config.metadata = Object.assign(defaults.metadata, config.metadata);
|
|
52
40
|
config = Object.assign(defaults, config);
|
|
53
41
|
super(name, config);
|
|
54
|
-
|
|
55
|
-
mkdirpSync(this.dataDirectory);
|
|
56
|
-
}
|
|
42
|
+
ensureDirectory(this.dataDirectory);
|
|
57
43
|
this.fileMode = 'a+';
|
|
58
44
|
this.writeBufferSize = config.writeBufferSize >>> 0; // jshint ignore:line
|
|
59
45
|
this.maxWriteBufferDocuments = config.maxWriteBufferDocuments >>> 0; // jshint ignore:line
|
|
@@ -75,25 +61,28 @@ class WritablePartition extends ReadablePartition {
|
|
|
75
61
|
return true;
|
|
76
62
|
}
|
|
77
63
|
|
|
78
|
-
|
|
64
|
+
let success = super.open();
|
|
65
|
+
if (!success) {
|
|
66
|
+
if (this.size + this.headerSize !== 0) {
|
|
67
|
+
// If file is not empty, we can not open and initialize it
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
this.writeMetadata();
|
|
71
|
+
success = super.open();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
this.writeBuffer = success && Buffer.allocUnsafeSlow(this.writeBufferSize);
|
|
79
75
|
// Where inside the write buffer the next write is added
|
|
80
76
|
this.writeBufferCursor = 0;
|
|
81
77
|
// How many documents are currently in the write buffer
|
|
82
78
|
this.writeBufferDocuments = 0;
|
|
83
79
|
this.flushCallbacks = [];
|
|
80
|
+
// Pre-allocated buffer for document header (16 bytes) + size footer (4 bytes) used in writeUnbuffered
|
|
81
|
+
this.writeMetaBuffer = Buffer.allocUnsafe(DOCUMENT_HEADER_SIZE + 4);
|
|
84
82
|
|
|
85
|
-
|
|
86
|
-
const stat = fs.statSync(this.fileName);
|
|
87
|
-
if (stat.size !== 0) {
|
|
88
|
-
return false;
|
|
89
|
-
}
|
|
90
|
-
this.metadata.epoch = Date.now();
|
|
91
|
-
this.writeMetadata();
|
|
92
|
-
this.size = 0;
|
|
93
|
-
}
|
|
94
|
-
this.clock = new this.ClockConstructor(this.metadata.epoch || NES_EPOCH.getTime());
|
|
83
|
+
this.clock = new this.ClockConstructor(this.metadata.epoch);
|
|
95
84
|
|
|
96
|
-
return
|
|
85
|
+
return success;
|
|
97
86
|
}
|
|
98
87
|
|
|
99
88
|
/**
|
|
@@ -103,14 +92,14 @@ class WritablePartition extends ReadablePartition {
|
|
|
103
92
|
* @returns void
|
|
104
93
|
*/
|
|
105
94
|
close() {
|
|
106
|
-
if (this.fd) {
|
|
95
|
+
if (this.fd && this.writeBuffer) {
|
|
107
96
|
this.flush();
|
|
108
97
|
fs.fsyncSync(this.fd);
|
|
109
|
-
|
|
110
|
-
if (this.writeBuffer) {
|
|
98
|
+
|
|
111
99
|
this.writeBuffer = null;
|
|
112
100
|
this.writeBufferCursor = 0;
|
|
113
101
|
this.writeBufferDocuments = 0;
|
|
102
|
+
this.writeMetaBuffer = null;
|
|
114
103
|
}
|
|
115
104
|
super.close();
|
|
116
105
|
}
|
|
@@ -123,7 +112,7 @@ class WritablePartition extends ReadablePartition {
|
|
|
123
112
|
*/
|
|
124
113
|
writeMetadata() {
|
|
125
114
|
const metadataBuffer = buildMetadataHeader(ReadablePartition.HEADER_MAGIC, this.metadata);
|
|
126
|
-
fs.
|
|
115
|
+
fs.writeFileSync(this.fileName, metadataBuffer);
|
|
127
116
|
this.headerSize = metadataBuffer.byteLength;
|
|
128
117
|
}
|
|
129
118
|
|
|
@@ -148,8 +137,9 @@ class WritablePartition extends ReadablePartition {
|
|
|
148
137
|
|
|
149
138
|
this.writeBufferCursor = 0;
|
|
150
139
|
this.writeBufferDocuments = 0;
|
|
151
|
-
this.flushCallbacks
|
|
140
|
+
const callbacks = this.flushCallbacks;
|
|
152
141
|
this.flushCallbacks = [];
|
|
142
|
+
for (let i = 0; i < callbacks.length; i++) callbacks[i]();
|
|
153
143
|
|
|
154
144
|
return true;
|
|
155
145
|
}
|
|
@@ -192,6 +182,7 @@ class WritablePartition extends ReadablePartition {
|
|
|
192
182
|
if (time64 === null) {
|
|
193
183
|
time64 = this.clock.time();
|
|
194
184
|
}
|
|
185
|
+
/* istanbul ignore if */
|
|
195
186
|
if (time64 < 0) {
|
|
196
187
|
throw new Error('Time may not be negative!');
|
|
197
188
|
}
|
|
@@ -212,12 +203,16 @@ class WritablePartition extends ReadablePartition {
|
|
|
212
203
|
*/
|
|
213
204
|
writeUnbuffered(data, dataSize, sequenceNumber, callback) {
|
|
214
205
|
this.flush();
|
|
215
|
-
|
|
216
|
-
this.writeDocumentHeader(dataHeader, 0, dataSize, sequenceNumber);
|
|
206
|
+
this.writeDocumentHeader(this.writeMetaBuffer, 0, dataSize, sequenceNumber);
|
|
217
207
|
|
|
218
208
|
let bytesWritten = 0;
|
|
219
|
-
bytesWritten += fs.writeSync(this.fd,
|
|
209
|
+
bytesWritten += fs.writeSync(this.fd, this.writeMetaBuffer, 0, DOCUMENT_HEADER_SIZE);
|
|
220
210
|
bytesWritten += fs.writeSync(this.fd, data);
|
|
211
|
+
const padSize = alignTo(dataSize + DOCUMENT_FOOTER_SIZE, DOCUMENT_ALIGNMENT);
|
|
212
|
+
bytesWritten += fs.writeSync(this.fd, DOCUMENT_PAD.substr(0, padSize));
|
|
213
|
+
this.writeMetaBuffer.writeUInt32BE(dataSize, 0);
|
|
214
|
+
bytesWritten += fs.writeSync(this.fd, this.writeMetaBuffer, 0, 4);
|
|
215
|
+
bytesWritten += fs.writeSync(this.fd, DOCUMENT_SEPARATOR);
|
|
221
216
|
if (typeof callback === 'function') {
|
|
222
217
|
process.nextTick(callback);
|
|
223
218
|
}
|
|
@@ -234,12 +229,17 @@ class WritablePartition extends ReadablePartition {
|
|
|
234
229
|
* @returns {number} Number of bytes written.
|
|
235
230
|
*/
|
|
236
231
|
writeBuffered(data, dataSize, sequenceNumber, callback) {
|
|
237
|
-
const bytesToWrite =
|
|
232
|
+
const bytesToWrite = this.documentWriteSize(dataSize);
|
|
238
233
|
this.flushIfWriteBufferTooSmall(bytesToWrite);
|
|
239
234
|
|
|
240
235
|
let bytesWritten = 0;
|
|
241
236
|
bytesWritten += this.writeDocumentHeader(this.writeBuffer, this.writeBufferCursor, dataSize, sequenceNumber);
|
|
242
237
|
bytesWritten += this.writeBuffer.write(data, this.writeBufferCursor + bytesWritten, 'utf8');
|
|
238
|
+
const padSize = alignTo(dataSize + DOCUMENT_FOOTER_SIZE, DOCUMENT_ALIGNMENT);
|
|
239
|
+
bytesWritten += this.writeBuffer.write(DOCUMENT_PAD.substr(0, padSize), this.writeBufferCursor + bytesWritten, 'utf8');
|
|
240
|
+
this.writeBuffer.writeUInt32BE(dataSize, this.writeBufferCursor + bytesWritten);
|
|
241
|
+
bytesWritten += 4;
|
|
242
|
+
bytesWritten += this.writeBuffer.write(DOCUMENT_SEPARATOR, this.writeBufferCursor + bytesWritten, 'utf8');
|
|
243
243
|
this.writeBufferCursor += bytesWritten;
|
|
244
244
|
this.writeBufferDocuments++;
|
|
245
245
|
if (typeof callback === 'function') {
|
|
@@ -257,9 +257,7 @@ class WritablePartition extends ReadablePartition {
|
|
|
257
257
|
* @returns {number|boolean} The file position at which the data was written or false on error.
|
|
258
258
|
*/
|
|
259
259
|
write(data, sequenceNumber, callback) {
|
|
260
|
-
|
|
261
|
-
return false;
|
|
262
|
-
}
|
|
260
|
+
assert(this.fd, 'Partition is not opened.');
|
|
263
261
|
if (typeof sequenceNumber === 'function') {
|
|
264
262
|
callback = sequenceNumber;
|
|
265
263
|
sequenceNumber = null;
|
|
@@ -267,7 +265,6 @@ class WritablePartition extends ReadablePartition {
|
|
|
267
265
|
const dataSize = Buffer.byteLength(data, 'utf8');
|
|
268
266
|
assert(dataSize <= 64 * 1024 * 1024, 'Document is too large! Maximum is 64 MB');
|
|
269
267
|
|
|
270
|
-
data += padData(dataSize);
|
|
271
268
|
const dataPosition = this.size;
|
|
272
269
|
if (dataSize + DOCUMENT_HEADER_SIZE >= this.writeBuffer.byteLength * 4 / 5) {
|
|
273
270
|
this.size += this.writeUnbuffered(data, dataSize, sequenceNumber, callback);
|
|
@@ -298,7 +295,42 @@ class WritablePartition extends ReadablePartition {
|
|
|
298
295
|
}
|
|
299
296
|
|
|
300
297
|
/**
|
|
301
|
-
*
|
|
298
|
+
* Prepare the read buffer for reading *before* the specified position. Don't try to read *after* the returned cursor.
|
|
299
|
+
*
|
|
300
|
+
* @protected
|
|
301
|
+
* @param {number} position The position in the file to prepare the read buffer for reading before.
|
|
302
|
+
* @returns {object} A reader object with properties `buffer`, `cursor` and `length`.
|
|
303
|
+
*/
|
|
304
|
+
prepareReadBufferBackwards(position) {
|
|
305
|
+
const bufferPos = this.size - this.writeBufferCursor;
|
|
306
|
+
// Handle the case when data that is still in write buffer is supposed to be read backwards
|
|
307
|
+
if (this.dirtyReads && this.writeBufferCursor > 0 && position > bufferPos) {
|
|
308
|
+
return { buffer: this.writeBuffer, cursor: position - bufferPos, length: this.writeBufferCursor };
|
|
309
|
+
}
|
|
310
|
+
return super.prepareReadBufferBackwards(position);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Read all documents in reverse write order, ignoring any unflushed write-buffer data when
|
|
315
|
+
* dirty reads are disabled.
|
|
316
|
+
*
|
|
317
|
+
* @api
|
|
318
|
+
* @param {number} [before] The document position to start reading backward from.
|
|
319
|
+
* @returns {Generator<string>} A generator that returns all documents in this partition in reverse order.
|
|
320
|
+
*/
|
|
321
|
+
*readAllBackwards(before = -1) {
|
|
322
|
+
if (!this.dirtyReads && this.writeBufferCursor > 0) {
|
|
323
|
+
const flushedSize = this.size - this.writeBufferCursor;
|
|
324
|
+
const clampedBefore = before < 0 ? flushedSize : Math.min(before, flushedSize);
|
|
325
|
+
yield* super.readAllBackwards(clampedBefore);
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
yield* super.readAllBackwards(before);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
*
|
|
333
|
+
* @internal
|
|
302
334
|
* @param {number} after The byte position to truncate the read buffer after.
|
|
303
335
|
*/
|
|
304
336
|
truncateReadBuffer(after) {
|
|
@@ -310,39 +342,80 @@ class WritablePartition extends ReadablePartition {
|
|
|
310
342
|
}
|
|
311
343
|
}
|
|
312
344
|
|
|
345
|
+
/**
|
|
346
|
+
* Truncate the partition, removing all documents with sequenceNumber > after.
|
|
347
|
+
* Scans the partition file backwards to find the cutoff position.
|
|
348
|
+
*
|
|
349
|
+
* @api
|
|
350
|
+
* @param {number} after Keep all documents with sequenceNumber <= after. Documents with
|
|
351
|
+
* sequenceNumber > after (and any torn write at the end) are removed.
|
|
352
|
+
*/
|
|
353
|
+
truncateAfterSequence(after) {
|
|
354
|
+
let position = this.size;
|
|
355
|
+
let truncateAt = this.size; // default: nothing to truncate
|
|
356
|
+
while ((position = this.findDocumentPositionBefore(position)) !== false) {
|
|
357
|
+
const reader = this.prepareReadBufferBackwards(position);
|
|
358
|
+
if (!reader.buffer) break;
|
|
359
|
+
const { sequenceNumber } = this.readDocumentHeader(reader.buffer, reader.cursor, position);
|
|
360
|
+
if (sequenceNumber > after) {
|
|
361
|
+
// This document must be removed; record its start as the new cutoff.
|
|
362
|
+
truncateAt = position;
|
|
363
|
+
} else {
|
|
364
|
+
break;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
this.truncate(truncateAt);
|
|
368
|
+
}
|
|
369
|
+
|
|
313
370
|
/**
|
|
314
371
|
* Truncate the partition storage at the given position.
|
|
315
372
|
*
|
|
373
|
+
* @api
|
|
316
374
|
* @param {number} after The file position after which to truncate the partition.
|
|
317
375
|
*/
|
|
318
376
|
truncate(after) {
|
|
319
|
-
if (after
|
|
377
|
+
if (after >= this.size) {
|
|
320
378
|
return;
|
|
321
379
|
}
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
}
|
|
380
|
+
this.open();
|
|
381
|
+
after = Math.max(0, after);
|
|
325
382
|
this.flush();
|
|
326
383
|
|
|
327
|
-
|
|
384
|
+
// Always save the truncated part for manual recovery, even if it contains corrupted data
|
|
385
|
+
this.branchOff('truncated-' + Date.now(), after);
|
|
386
|
+
|
|
328
387
|
try {
|
|
329
|
-
|
|
388
|
+
this.readFrom(after);
|
|
330
389
|
} catch (e) {
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
const deletedBranch = new WritablePartition(this.name + '-' + after + '.branch', { dataDirectory: this.dataDirectory });
|
|
335
|
-
deletedBranch.open();
|
|
336
|
-
while (data) {
|
|
337
|
-
deletedBranch.write(data);
|
|
338
|
-
position += this.documentWriteSize(Buffer.byteLength(data, 'utf8'));
|
|
339
|
-
data = this.readFrom(position);
|
|
390
|
+
if (!(e instanceof ReadablePartition.CorruptFileError)) {
|
|
391
|
+
throw new Error('Can only truncate on valid document boundaries.');
|
|
392
|
+
}
|
|
340
393
|
}
|
|
341
|
-
deletedBranch.close();
|
|
342
394
|
|
|
343
395
|
fs.truncateSync(this.fileName, this.headerSize + after);
|
|
344
396
|
this.truncateReadBuffer(after);
|
|
345
397
|
this.size = after;
|
|
398
|
+
this.emit('truncated', after);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Create a branch of this partition starting from the given position.
|
|
403
|
+
*
|
|
404
|
+
* @internal
|
|
405
|
+
* @param {string} branchName The name that identifies the branch (will be prefixed with this partition name and suffixed with the position)
|
|
406
|
+
* @param {number} position The file position from where to branch off
|
|
407
|
+
* @returns {WritablePartition} The branched off partition
|
|
408
|
+
*/
|
|
409
|
+
branchOff(branchName, position) {
|
|
410
|
+
const deletedBranch = new WritablePartition(this.name + '-' + branchName + '-' + position + '.branch', { dataDirectory: this.dataDirectory, metadata: { epoch: this.metadata.epoch } });
|
|
411
|
+
deletedBranch.open();
|
|
412
|
+
do {
|
|
413
|
+
const reader = this.prepareReadBuffer(position);
|
|
414
|
+
fs.writeSync(deletedBranch.fd, reader.buffer, reader.cursor, reader.length);
|
|
415
|
+
position += reader.length;
|
|
416
|
+
} while (position < this.size);
|
|
417
|
+
deletedBranch.close();
|
|
418
|
+
return deletedBranch;
|
|
346
419
|
}
|
|
347
420
|
}
|
|
348
421
|
|