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
|
@@ -13,6 +13,8 @@ class ReadOnlyStorage extends ReadableStorage {
|
|
|
13
13
|
*/
|
|
14
14
|
constructor(storageName = 'storage', config = {}) {
|
|
15
15
|
super(storageName, config);
|
|
16
|
+
this.storageFilesFilter = this.storageFilesFilter.bind(this);
|
|
17
|
+
this.onStorageFileChanged = this.onStorageFileChanged.bind(this);
|
|
16
18
|
}
|
|
17
19
|
|
|
18
20
|
/**
|
|
@@ -33,8 +35,8 @@ class ReadOnlyStorage extends ReadableStorage {
|
|
|
33
35
|
*/
|
|
34
36
|
open() {
|
|
35
37
|
if (!this.watcher) {
|
|
36
|
-
this.watcher = new Watcher(this.dataDirectory, this.storageFilesFilter
|
|
37
|
-
this.watcher.on('rename', this.onStorageFileChanged
|
|
38
|
+
this.watcher = new Watcher([this.dataDirectory, this.indexDirectory], this.storageFilesFilter);
|
|
39
|
+
this.watcher.on('rename', this.onStorageFileChanged);
|
|
38
40
|
}
|
|
39
41
|
return super.open();
|
|
40
42
|
}
|
|
@@ -45,8 +47,9 @@ class ReadOnlyStorage extends ReadableStorage {
|
|
|
45
47
|
*/
|
|
46
48
|
onStorageFileChanged(filename) {
|
|
47
49
|
if (filename.substr(-6) === '.index') {
|
|
50
|
+
const indexName = filename.substr(this.storageFile.length + 1, filename.length - this.storageFile.length - 7);
|
|
48
51
|
// New indexes are not automatically opened in the reader
|
|
49
|
-
this.emit('index-created',
|
|
52
|
+
this.emit('index-created', indexName);
|
|
50
53
|
return;
|
|
51
54
|
}
|
|
52
55
|
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
const fs = require('fs');
|
|
2
2
|
const path = require('path');
|
|
3
|
-
const
|
|
3
|
+
const events = require('events');
|
|
4
4
|
const Partition = require('../Partition');
|
|
5
5
|
const Index = require('../Index');
|
|
6
|
-
const { assert,
|
|
6
|
+
const { assert, wrapAndCheck, kWayMerge } = require('../util');
|
|
7
|
+
const { createHmac, matches, buildMetadataForMatcher } = require('../metadataUtil');
|
|
7
8
|
|
|
8
9
|
const DEFAULT_READ_BUFFER_SIZE = 4 * 1024;
|
|
9
10
|
|
|
@@ -19,11 +20,15 @@ function *reverse(iterator) {
|
|
|
19
20
|
}
|
|
20
21
|
}
|
|
21
22
|
|
|
23
|
+
/**
|
|
24
|
+
* @typedef {object|function(object):boolean} Matcher
|
|
25
|
+
*/
|
|
26
|
+
|
|
22
27
|
/**
|
|
23
28
|
* An append-only storage with highly performant positional range scans.
|
|
24
29
|
* It's highly optimized for an event-store and hence does not support compaction or data-rewrite, nor any querying
|
|
25
30
|
*/
|
|
26
|
-
class ReadableStorage extends EventEmitter {
|
|
31
|
+
class ReadableStorage extends events.EventEmitter {
|
|
27
32
|
|
|
28
33
|
/**
|
|
29
34
|
* @param {string} [storageName] The name of the storage.
|
|
@@ -62,15 +67,15 @@ class ReadableStorage extends EventEmitter {
|
|
|
62
67
|
|
|
63
68
|
this.dataDirectory = path.resolve(config.dataDirectory);
|
|
64
69
|
|
|
65
|
-
this.initializeIndexes(config);
|
|
66
70
|
this.scanPartitions(config);
|
|
71
|
+
this.initializeIndexes(config);
|
|
67
72
|
}
|
|
68
73
|
|
|
69
74
|
/**
|
|
70
75
|
* @protected
|
|
71
76
|
* @param {string} name
|
|
72
77
|
* @param {object} [options]
|
|
73
|
-
* @returns {{ index: ReadableIndex, matcher?:
|
|
78
|
+
* @returns {{ index: ReadableIndex, matcher?: Matcher }}
|
|
74
79
|
*/
|
|
75
80
|
createIndex(name, options = {}) {
|
|
76
81
|
/** @type ReadableIndex */
|
|
@@ -105,6 +110,7 @@ class ReadableStorage extends EventEmitter {
|
|
|
105
110
|
const { index } = this.createIndex(config.indexFile, this.indexOptions);
|
|
106
111
|
this.index = index;
|
|
107
112
|
this.secondaryIndexes = {};
|
|
113
|
+
this.readonlyIndexes = {};
|
|
108
114
|
}
|
|
109
115
|
|
|
110
116
|
/**
|
|
@@ -128,12 +134,13 @@ class ReadableStorage extends EventEmitter {
|
|
|
128
134
|
readBufferSize: DEFAULT_READ_BUFFER_SIZE
|
|
129
135
|
};
|
|
130
136
|
this.partitionConfig = Object.assign(defaults, config);
|
|
131
|
-
this.partitions =
|
|
137
|
+
this.partitions = Object.create(null);
|
|
132
138
|
|
|
133
139
|
const files = fs.readdirSync(this.dataDirectory);
|
|
134
140
|
for (let file of files) {
|
|
135
141
|
if (file.substr(-6) === '.index') continue;
|
|
136
142
|
if (file.substr(-7) === '.branch') continue;
|
|
143
|
+
if (file.substr(-5) === '.lock') continue;
|
|
137
144
|
if (file.substr(0, this.storageFile.length) !== this.storageFile) continue;
|
|
138
145
|
|
|
139
146
|
const partition = this.createPartition(file, this.partitionConfig);
|
|
@@ -167,6 +174,9 @@ class ReadableStorage extends EventEmitter {
|
|
|
167
174
|
close() {
|
|
168
175
|
this.index.close();
|
|
169
176
|
this.forEachSecondaryIndex(index => index.close());
|
|
177
|
+
for (let index of Object.values(this.readonlyIndexes)) {
|
|
178
|
+
index.close();
|
|
179
|
+
}
|
|
170
180
|
this.forEachPartition(partition => partition.close());
|
|
171
181
|
this.emit('closed');
|
|
172
182
|
}
|
|
@@ -188,6 +198,19 @@ class ReadableStorage extends EventEmitter {
|
|
|
188
198
|
return this.partitions[partitionIdentifier];
|
|
189
199
|
}
|
|
190
200
|
|
|
201
|
+
/**
|
|
202
|
+
* Register a handler that is called before a document is read from a partition.
|
|
203
|
+
* The handler receives the position and the partition metadata and may throw to abort the read.
|
|
204
|
+
* Multiple handlers can be registered; all run on every read in registration order.
|
|
205
|
+
* Equivalent to `storage.on('preRead', hook)`.
|
|
206
|
+
*
|
|
207
|
+
* @api
|
|
208
|
+
* @param {function(number, object): void} hook A function receiving (position, partitionMetadata).
|
|
209
|
+
*/
|
|
210
|
+
preRead(hook) {
|
|
211
|
+
this.on('preRead', hook);
|
|
212
|
+
}
|
|
213
|
+
|
|
191
214
|
/**
|
|
192
215
|
* @protected
|
|
193
216
|
* @param {number} partitionId The partition to read from.
|
|
@@ -198,6 +221,9 @@ class ReadableStorage extends EventEmitter {
|
|
|
198
221
|
*/
|
|
199
222
|
readFrom(partitionId, position, size) {
|
|
200
223
|
const partition = this.getPartition(partitionId);
|
|
224
|
+
if (this.listenerCount('preRead') > 0) {
|
|
225
|
+
this.emit('preRead', position, partition.metadata);
|
|
226
|
+
}
|
|
201
227
|
const data = partition.readFrom(position, size);
|
|
202
228
|
return this.serializer.deserialize(data);
|
|
203
229
|
}
|
|
@@ -232,21 +258,25 @@ class ReadableStorage extends EventEmitter {
|
|
|
232
258
|
* @api
|
|
233
259
|
* @param {number} from The 1-based document number (inclusive) to start reading from.
|
|
234
260
|
* @param {number} [until] The 1-based document number (inclusive) to read until. Defaults to index.length.
|
|
235
|
-
* @param {ReadableIndex} [index] The index to use for finding the documents in the range.
|
|
261
|
+
* @param {ReadableIndex|false} [index] The index to use for finding the documents in the range.
|
|
262
|
+
* Pass `false` to skip the global index and iterate all partitions directly in sequenceNumber order
|
|
263
|
+
* (useful when the global index is unavailable or corrupted).
|
|
236
264
|
* @returns {Generator<object>} A generator that will read each document in the range one by one.
|
|
237
265
|
*/
|
|
238
266
|
*readRange(from, until = -1, index = null) {
|
|
239
|
-
|
|
240
|
-
|
|
267
|
+
const lengthSource = index || this.index;
|
|
268
|
+
if (!lengthSource.isOpen()) {
|
|
269
|
+
lengthSource.open();
|
|
270
|
+
}
|
|
241
271
|
|
|
242
|
-
const readFrom = wrapAndCheck(from,
|
|
243
|
-
const readUntil = wrapAndCheck(until,
|
|
244
|
-
assert(readFrom
|
|
272
|
+
const readFrom = wrapAndCheck(from, lengthSource.length);
|
|
273
|
+
const readUntil = wrapAndCheck(until, lengthSource.length);
|
|
274
|
+
assert(readFrom > 0 && readUntil > 0, `Range scan error for range ${from} - ${until}.`);
|
|
245
275
|
|
|
246
276
|
if (readFrom > readUntil) {
|
|
247
277
|
const batchSize = 10;
|
|
248
278
|
let batchUntil = readFrom;
|
|
249
|
-
while (batchUntil
|
|
279
|
+
while (batchUntil >= readUntil) {
|
|
250
280
|
const batchFrom = Math.max(readUntil, batchUntil - batchSize);
|
|
251
281
|
yield* reverse(this.iterateRange(batchFrom, batchUntil, index));
|
|
252
282
|
batchUntil = batchFrom - 1;
|
|
@@ -259,31 +289,66 @@ class ReadableStorage extends EventEmitter {
|
|
|
259
289
|
|
|
260
290
|
/**
|
|
261
291
|
* Iterate all documents in this storage in range from to until inside the index.
|
|
292
|
+
* If index is false, iterates all partitions directly in sequenceNumber order.
|
|
262
293
|
* @private
|
|
263
294
|
* @param {number} from
|
|
264
295
|
* @param {number} until
|
|
265
|
-
* @param {ReadableIndex} index
|
|
296
|
+
* @param {ReadableIndex|false|null} index
|
|
266
297
|
* @returns {Generator<object>}
|
|
267
298
|
*/
|
|
268
299
|
*iterateRange(from, until, index) {
|
|
269
|
-
|
|
300
|
+
if (index === false) {
|
|
301
|
+
// Explicitly disabled index: iterate all partitions and merge by sequenceNumber.
|
|
302
|
+
// Document header sequenceNumber is 0-based; from/until are 1-based index positions.
|
|
303
|
+
for (const entry of this.iterateDocumentsNoIndex(from - 1, until - 1)) {
|
|
304
|
+
yield entry.document;
|
|
305
|
+
}
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const idx = index || this.index;
|
|
310
|
+
const entries = idx.range(from, until);
|
|
270
311
|
for (let entry of entries) {
|
|
271
312
|
const document = this.readFrom(entry.partition, entry.position, entry.size);
|
|
272
313
|
yield document;
|
|
273
314
|
}
|
|
274
315
|
}
|
|
275
316
|
|
|
317
|
+
/**
|
|
318
|
+
* Open an existing readonly index for reading, without registering it in the secondary indexes write path.
|
|
319
|
+
* Use this for indexes whose files carry a status marker (e.g. `stream-foo.closed.index`).
|
|
320
|
+
*
|
|
321
|
+
* @api
|
|
322
|
+
* @param {string} name The readonly index name (e.g. 'stream-foo.closed').
|
|
323
|
+
* @returns {ReadableIndex}
|
|
324
|
+
* @throws {Error} if the readonly index does not exist.
|
|
325
|
+
*/
|
|
326
|
+
openReadonlyIndex(name) {
|
|
327
|
+
if (name in this.readonlyIndexes) {
|
|
328
|
+
return this.readonlyIndexes[name];
|
|
329
|
+
}
|
|
330
|
+
const indexName = this.storageFile + '.' + name + '.index';
|
|
331
|
+
assert(fs.existsSync(path.join(this.indexDirectory, indexName)), `Index "${name}" does not exist.`);
|
|
332
|
+
const { index } = this.createIndex(indexName, Object.assign({}, this.indexOptions));
|
|
333
|
+
index.open();
|
|
334
|
+
this.readonlyIndexes[name] = index;
|
|
335
|
+
return index;
|
|
336
|
+
}
|
|
337
|
+
|
|
276
338
|
/**
|
|
277
339
|
* Open an existing index.
|
|
278
340
|
*
|
|
279
341
|
* @api
|
|
280
342
|
* @param {string} name The index name.
|
|
281
|
-
* @param {
|
|
343
|
+
* @param {Matcher} [matcher] The matcher object or function that the index needs to have been defined with. If not given it will not be validated.
|
|
282
344
|
* @returns {ReadableIndex}
|
|
283
345
|
* @throws {Error} if the index with that name does not exist.
|
|
284
346
|
* @throws {Error} if the HMAC for the matcher does not match.
|
|
285
347
|
*/
|
|
286
348
|
openIndex(name, matcher) {
|
|
349
|
+
if (name === '_all') {
|
|
350
|
+
return this.index;
|
|
351
|
+
}
|
|
287
352
|
if (name in this.secondaryIndexes) {
|
|
288
353
|
return this.secondaryIndexes[name].index;
|
|
289
354
|
}
|
|
@@ -299,17 +364,79 @@ class ReadableStorage extends EventEmitter {
|
|
|
299
364
|
}
|
|
300
365
|
|
|
301
366
|
/**
|
|
302
|
-
*
|
|
367
|
+
* Iterate documents across all partitions in sequenceNumber order using a k-way merge.
|
|
368
|
+
* Opens any closed partition automatically.
|
|
303
369
|
*
|
|
304
370
|
* @protected
|
|
305
|
-
* @param {
|
|
371
|
+
* @param {number} [from=0] The 0-based sequenceNumber to start from (inclusive).
|
|
372
|
+
* @param {number} [until=Number.MAX_SAFE_INTEGER] The 0-based sequenceNumber to read until (inclusive).
|
|
373
|
+
* @returns {Generator<{document: object, sequenceNumber: number, partitionName: string, position: number, size: number, partition: number}>}
|
|
306
374
|
*/
|
|
307
|
-
|
|
375
|
+
*iterateDocumentsNoIndex(from = 0, until = Number.MAX_SAFE_INTEGER) {
|
|
376
|
+
const streams = [];
|
|
377
|
+
|
|
378
|
+
this.forEachPartition(partition => {
|
|
379
|
+
if (!partition.isOpen()) {
|
|
380
|
+
partition.open();
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const found = partition.findDocument(from);
|
|
384
|
+
if (found && found.headerOut.sequenceNumber <= until) {
|
|
385
|
+
const nextPosition = found.headerOut.position + partition.documentWriteSize(found.headerOut.dataSize);
|
|
386
|
+
const reader = partition.readAll(nextPosition, found.headerOut);
|
|
387
|
+
streams.push({ ...found, reader, partition: partition.id, partitionName: partition.name });
|
|
388
|
+
}
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
const items = [];
|
|
392
|
+
kWayMerge(
|
|
393
|
+
streams,
|
|
394
|
+
stream => stream.headerOut.sequenceNumber,
|
|
395
|
+
stream => {
|
|
396
|
+
const next = stream.reader.next();
|
|
397
|
+
if (!next.done && stream.headerOut.sequenceNumber <= until) {
|
|
398
|
+
stream.data = next.value;
|
|
399
|
+
return true;
|
|
400
|
+
}
|
|
401
|
+
return false;
|
|
402
|
+
},
|
|
403
|
+
stream => items.push({
|
|
404
|
+
document: this.serializer.deserialize(stream.data),
|
|
405
|
+
sequenceNumber: stream.headerOut.sequenceNumber,
|
|
406
|
+
partitionName: stream.partitionName,
|
|
407
|
+
position: stream.headerOut.position,
|
|
408
|
+
size: stream.headerOut.dataSize,
|
|
409
|
+
partition: stream.partition,
|
|
410
|
+
})
|
|
411
|
+
);
|
|
412
|
+
|
|
413
|
+
yield* items;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Helper method to iterate over all documents, invoking a callback for each one.
|
|
418
|
+
* Pass `noIndex = true` to iterate all partitions directly in sequenceNumber order
|
|
419
|
+
* (useful when the global index is unavailable or corrupted).
|
|
420
|
+
* When `noIndex` is false the second callback argument is the raw index `EntryInterface`.
|
|
421
|
+
* When `noIndex` is true the second callback argument has `{ partition, position, size, sequenceNumber, partitionName }`.
|
|
422
|
+
*
|
|
423
|
+
* @protected
|
|
424
|
+
* @param {function(object, object): void} iterationHandler
|
|
425
|
+
* @param {boolean} [noIndex=false] When true, bypasses the index and iterates partitions directly.
|
|
426
|
+
*/
|
|
427
|
+
forEachDocument(iterationHandler, noIndex = false) {
|
|
308
428
|
/* istanbul ignore if */
|
|
309
429
|
if (typeof iterationHandler !== 'function') {
|
|
310
430
|
return;
|
|
311
431
|
}
|
|
312
432
|
|
|
433
|
+
if (noIndex) {
|
|
434
|
+
for (const { document, ...entryInfo } of this.iterateDocumentsNoIndex()) {
|
|
435
|
+
iterationHandler(document, entryInfo);
|
|
436
|
+
}
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
|
|
313
440
|
const entries = this.index.all();
|
|
314
441
|
|
|
315
442
|
for (let entry of entries) {
|
|
@@ -359,3 +486,4 @@ class ReadableStorage extends EventEmitter {
|
|
|
359
486
|
|
|
360
487
|
module.exports = ReadableStorage;
|
|
361
488
|
module.exports.matches = matches;
|
|
489
|
+
module.exports.CorruptFileError = Partition.CorruptFileError;
|
|
@@ -1,15 +1,22 @@
|
|
|
1
1
|
const fs = require('fs');
|
|
2
|
-
const mkdirpSync = require('mkdirp').sync;
|
|
3
2
|
const path = require('path');
|
|
4
3
|
const WritablePartition = require('../Partition/WritablePartition');
|
|
5
4
|
const WritableIndex = require('../Index/WritableIndex');
|
|
6
5
|
const ReadableStorage = require('./ReadableStorage');
|
|
7
|
-
const { assert,
|
|
6
|
+
const { assert, ensureDirectory } = require('../util');
|
|
7
|
+
const { matches, buildMetadataForMatcher, buildMatcherFromMetadata } = require('../metadataUtil');
|
|
8
8
|
|
|
9
9
|
const DEFAULT_WRITE_BUFFER_SIZE = 16 * 1024;
|
|
10
10
|
|
|
11
|
+
const LOCK_RECLAIM = 0x1;
|
|
12
|
+
const LOCK_THROW = 0x2;
|
|
13
|
+
|
|
11
14
|
class StorageLockedError extends Error {}
|
|
12
15
|
|
|
16
|
+
/**
|
|
17
|
+
* @typedef {object|function(object):boolean} Matcher
|
|
18
|
+
*/
|
|
19
|
+
|
|
13
20
|
/**
|
|
14
21
|
* An append-only storage with highly performant positional range scans.
|
|
15
22
|
* It's highly optimized for an event-store and hence does not support compaction or data-rewrite, nor any querying
|
|
@@ -33,6 +40,7 @@ class WritableStorage extends ReadableStorage {
|
|
|
33
40
|
* @param {function(object, number): string} [config.partitioner] A function that takes a document and sequence number and returns a partition name that the document should be stored in. Defaults to write all documents to the primary partition.
|
|
34
41
|
* @param {object} [config.indexOptions] An options object that should be passed to all indexes on construction.
|
|
35
42
|
* @param {string} [config.hmacSecret] A private key that is used to verify matchers retrieved from indexes.
|
|
43
|
+
* @param {number} [config.lock] One of LOCK_* constants that defines how an existing lock should be handled.
|
|
36
44
|
*/
|
|
37
45
|
constructor(storageName = 'storage', config = {}) {
|
|
38
46
|
if (typeof storageName !== 'string') {
|
|
@@ -49,13 +57,13 @@ class WritableStorage extends ReadableStorage {
|
|
|
49
57
|
};
|
|
50
58
|
config = Object.assign(defaults, config);
|
|
51
59
|
config.indexOptions = Object.assign({ syncOnFlush: config.syncOnFlush }, config.indexOptions);
|
|
52
|
-
|
|
53
|
-
try {
|
|
54
|
-
mkdirpSync(config.dataDirectory);
|
|
55
|
-
} catch (e) {
|
|
56
|
-
}
|
|
57
|
-
}
|
|
60
|
+
ensureDirectory(config.dataDirectory);
|
|
58
61
|
super(storageName, config);
|
|
62
|
+
|
|
63
|
+
this.lockFile = path.resolve(this.dataDirectory, this.storageFile + '.lock');
|
|
64
|
+
if (config.lock === LOCK_RECLAIM) {
|
|
65
|
+
this.unlock();
|
|
66
|
+
}
|
|
59
67
|
this.partitioner = config.partitioner;
|
|
60
68
|
}
|
|
61
69
|
|
|
@@ -68,7 +76,135 @@ class WritableStorage extends ReadableStorage {
|
|
|
68
76
|
if (!this.lock()) {
|
|
69
77
|
return true;
|
|
70
78
|
}
|
|
71
|
-
|
|
79
|
+
const result = super.open();
|
|
80
|
+
this.emit('ready');
|
|
81
|
+
return result;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Helper method to iterate over all writable secondary indexes.
|
|
86
|
+
* Opens each index before calling the callback (passing the previous open status),
|
|
87
|
+
* and closes it afterwards if it was not already open.
|
|
88
|
+
*
|
|
89
|
+
* @protected
|
|
90
|
+
* @param {function(WritableIndex, string, boolean)} iterationHandler Called with (index, name, wasOpen).
|
|
91
|
+
* @param {object} [matchDocument] If supplied, only indexes the document matches on will be iterated.
|
|
92
|
+
*/
|
|
93
|
+
forEachWritableSecondaryIndex(iterationHandler, matchDocument) {
|
|
94
|
+
this.forEachSecondaryIndex((index, name) => {
|
|
95
|
+
/* istanbul ignore if */
|
|
96
|
+
if (!(index instanceof WritableIndex)) return;
|
|
97
|
+
const wasOpen = index.isOpen();
|
|
98
|
+
if (!wasOpen) index.open();
|
|
99
|
+
iterationHandler(index, name, wasOpen);
|
|
100
|
+
if (!wasOpen) index.close();
|
|
101
|
+
}, matchDocument);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Scan every partition's last document to detect torn writes inline.
|
|
106
|
+
* A document is torn when its expected end exceeds the actual file size:
|
|
107
|
+
* position + documentWriteSize(dataSize) > partition.size
|
|
108
|
+
*
|
|
109
|
+
* @private
|
|
110
|
+
* @returns {{ lastValidSequenceNumber: number, maxPartitionSequenceNumber: number }}
|
|
111
|
+
*/
|
|
112
|
+
findTornWriteBoundary() {
|
|
113
|
+
let lastValidSequenceNumber = Number.MAX_SAFE_INTEGER;
|
|
114
|
+
let maxPartitionSequenceNumber = -1;
|
|
115
|
+
this.forEachPartition(partition => {
|
|
116
|
+
partition.open();
|
|
117
|
+
const last = partition.readLast();
|
|
118
|
+
/* istanbul ignore if */
|
|
119
|
+
if (!last) return;
|
|
120
|
+
const { header: { sequenceNumber, dataSize }, position } = last;
|
|
121
|
+
if (position + partition.documentWriteSize(dataSize) > partition.size) {
|
|
122
|
+
// Torn write: the document extends beyond the end of the file.
|
|
123
|
+
lastValidSequenceNumber = Math.min(lastValidSequenceNumber, sequenceNumber);
|
|
124
|
+
} else {
|
|
125
|
+
maxPartitionSequenceNumber = Math.max(maxPartitionSequenceNumber, sequenceNumber);
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
return { lastValidSequenceNumber, maxPartitionSequenceNumber };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Check all partitions for torn writes, physically repair each partition, truncate all indexes
|
|
133
|
+
* to the torn-write boundary, and then reindex to rebuild any missing index entries.
|
|
134
|
+
*
|
|
135
|
+
* A document is torn when the partition file ends before the document's expected end position
|
|
136
|
+
* (i.e. position + documentWriteSize(dataSize) > partition.size). Detected inline in
|
|
137
|
+
* findTornWriteBoundary(), without any checkTornWrite() call.
|
|
138
|
+
*
|
|
139
|
+
* Repair flow:
|
|
140
|
+
* 1. findTornWriteBoundary() reads the last document of every partition and finds the global
|
|
141
|
+
* torn-write boundary (minimum torn sequence number across all partitions).
|
|
142
|
+
* 2. If torn writes were found, truncateAfterSequence() removes all documents at or beyond
|
|
143
|
+
* the boundary from each partition.
|
|
144
|
+
* 3. Truncate all indexes to the torn-write boundary, then reindex to fill any lagging entries.
|
|
145
|
+
* 4. If no torn writes were found but the index is lagging, reindex directly.
|
|
146
|
+
*/
|
|
147
|
+
checkTornWrites() {
|
|
148
|
+
const { lastValidSequenceNumber, maxPartitionSequenceNumber } = this.findTornWriteBoundary();
|
|
149
|
+
|
|
150
|
+
if (lastValidSequenceNumber < Number.MAX_SAFE_INTEGER) {
|
|
151
|
+
// Phase 2: remove all documents at or beyond the torn-write boundary from each partition.
|
|
152
|
+
this.forEachPartition(partition => {
|
|
153
|
+
partition.open();
|
|
154
|
+
partition.truncateAfterSequence(lastValidSequenceNumber - 1);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// Truncate all indexes to the torn-write boundary.
|
|
158
|
+
this.index.open();
|
|
159
|
+
this.index.truncate(lastValidSequenceNumber);
|
|
160
|
+
/* istanbul ignore next */
|
|
161
|
+
this.forEachWritableSecondaryIndex(index => {
|
|
162
|
+
index.truncate(index.find(lastValidSequenceNumber));
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
// Reindex to fill in any missing complete-document entries.
|
|
166
|
+
this.reindex(this.index.length);
|
|
167
|
+
} else if (maxPartitionSequenceNumber >= 0 && maxPartitionSequenceNumber + 1 > this.index.length) {
|
|
168
|
+
// No torn writes, but the index is lagging — repair it.
|
|
169
|
+
this.reindex(this.index.length);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
this.forEachPartition(partition => partition.close());
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Rebuild the primary index and all loaded secondary indexes starting from the given sequence
|
|
177
|
+
* number by scanning the partition data directly.
|
|
178
|
+
* This is the building block for both auto-repair (invoked automatically when the primary
|
|
179
|
+
* index is found to be lagging in checkTornWrites()) and for user-driven re-indexing after
|
|
180
|
+
* index corruption.
|
|
181
|
+
*
|
|
182
|
+
* @api
|
|
183
|
+
* @param {number} [fromSequenceNumber=0] The number of primary index entries to keep intact.
|
|
184
|
+
* All index entries beyond this position will be removed and rebuilt from partition data.
|
|
185
|
+
* Defaults to 0, which rebuilds all indexes from scratch.
|
|
186
|
+
*/
|
|
187
|
+
reindex(fromSequenceNumber = 0) {
|
|
188
|
+
this.index.truncate(fromSequenceNumber);
|
|
189
|
+
|
|
190
|
+
// Truncate all loaded secondary indexes to match the new primary length.
|
|
191
|
+
this.forEachWritableSecondaryIndex(index => {
|
|
192
|
+
// find(0) returns 0, so truncate(0) will remove all entries when fromSequenceNumber===0
|
|
193
|
+
index.truncate(fromSequenceNumber === 0 ? 0 : index.find(fromSequenceNumber));
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
// Scan partitions in sequence-number order and rebuild index entries.
|
|
197
|
+
// iterateDocumentsNoIndex opens any closed partitions automatically.
|
|
198
|
+
for (const { document, partition, position, size } of this.iterateDocumentsNoIndex(fromSequenceNumber, Number.MAX_SAFE_INTEGER)) {
|
|
199
|
+
const newEntry = new WritableIndex.Entry(this.index.length + 1, position, size, partition);
|
|
200
|
+
this.index.add(newEntry);
|
|
201
|
+
|
|
202
|
+
this.forEachWritableSecondaryIndex((secIndex) => {
|
|
203
|
+
secIndex.add(newEntry);
|
|
204
|
+
}, document);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
this.flush();
|
|
72
208
|
}
|
|
73
209
|
|
|
74
210
|
/**
|
|
@@ -81,7 +217,6 @@ class WritableStorage extends ReadableStorage {
|
|
|
81
217
|
if (this.locked) {
|
|
82
218
|
return false;
|
|
83
219
|
}
|
|
84
|
-
this.lockFile = path.resolve(this.dataDirectory, this.storageFile + '.lock');
|
|
85
220
|
try {
|
|
86
221
|
fs.mkdirSync(this.lockFile);
|
|
87
222
|
this.locked = true;
|
|
@@ -101,7 +236,12 @@ class WritableStorage extends ReadableStorage {
|
|
|
101
236
|
* Current implementation just deletes a lock file that is named like the storage.
|
|
102
237
|
*/
|
|
103
238
|
unlock() {
|
|
104
|
-
fs.
|
|
239
|
+
if (fs.existsSync(this.lockFile)) {
|
|
240
|
+
if (!this.locked) {
|
|
241
|
+
this.checkTornWrites();
|
|
242
|
+
}
|
|
243
|
+
fs.rmdirSync(this.lockFile);
|
|
244
|
+
}
|
|
105
245
|
this.locked = false;
|
|
106
246
|
}
|
|
107
247
|
|
|
@@ -147,6 +287,19 @@ class WritableStorage extends ReadableStorage {
|
|
|
147
287
|
return entry;
|
|
148
288
|
}
|
|
149
289
|
|
|
290
|
+
/**
|
|
291
|
+
* Register a handler that is called before a document is written to storage.
|
|
292
|
+
* The handler receives the document and the partition metadata and may throw to abort the write.
|
|
293
|
+
* Multiple handlers can be registered; all run on every write in registration order.
|
|
294
|
+
* Equivalent to `storage.on('preCommit', hook)`.
|
|
295
|
+
*
|
|
296
|
+
* @api
|
|
297
|
+
* @param {function(object, object): void} hook A function receiving (document, partitionMetadata).
|
|
298
|
+
*/
|
|
299
|
+
preCommit(hook) {
|
|
300
|
+
this.on('preCommit', hook);
|
|
301
|
+
}
|
|
302
|
+
|
|
150
303
|
/**
|
|
151
304
|
* Get a partition either by name or by id.
|
|
152
305
|
* If a partition with the given name does not exist, a new one will be created.
|
|
@@ -159,10 +312,14 @@ class WritableStorage extends ReadableStorage {
|
|
|
159
312
|
*/
|
|
160
313
|
getPartition(partitionIdentifier) {
|
|
161
314
|
if (typeof partitionIdentifier === 'string') {
|
|
315
|
+
const partitionShortName = partitionIdentifier;
|
|
162
316
|
const partitionName = this.storageFile + (partitionIdentifier.length ? '.' + partitionIdentifier : '');
|
|
163
317
|
partitionIdentifier = WritablePartition.idFor(partitionName);
|
|
164
318
|
if (!this.partitions[partitionIdentifier]) {
|
|
165
|
-
|
|
319
|
+
const partitionConfig = typeof this.partitionConfig.metadata === 'function'
|
|
320
|
+
? { ...this.partitionConfig, metadata: this.partitionConfig.metadata(partitionShortName) }
|
|
321
|
+
: this.partitionConfig;
|
|
322
|
+
this.partitions[partitionIdentifier] = this.createPartition(partitionName, partitionConfig);
|
|
166
323
|
this.emit('partition-created', partitionIdentifier);
|
|
167
324
|
}
|
|
168
325
|
this.partitions[partitionIdentifier].open();
|
|
@@ -183,6 +340,9 @@ class WritableStorage extends ReadableStorage {
|
|
|
183
340
|
|
|
184
341
|
const partitionName = this.partitioner(document, this.index.length + 1);
|
|
185
342
|
const partition = this.getPartition(partitionName);
|
|
343
|
+
if (this.listenerCount('preCommit') > 0) {
|
|
344
|
+
this.emit('preCommit', document, partition.metadata);
|
|
345
|
+
}
|
|
186
346
|
const position = partition.write(data, this.length, callback);
|
|
187
347
|
|
|
188
348
|
assert(position !== false, 'Error writing document.');
|
|
@@ -205,11 +365,14 @@ class WritableStorage extends ReadableStorage {
|
|
|
205
365
|
*
|
|
206
366
|
* @api
|
|
207
367
|
* @param {string} name The index name.
|
|
208
|
-
* @param {
|
|
368
|
+
* @param {Matcher} [matcher] An object that describes the document properties that need to match to add it this index or a function that receives a document and returns true if the document should be indexed.
|
|
209
369
|
* @returns {ReadableIndex} The index containing all documents that match the query.
|
|
210
370
|
* @throws {Error} if the index doesn't exist yet and no matcher was specified.
|
|
211
371
|
*/
|
|
212
372
|
ensureIndex(name, matcher) {
|
|
373
|
+
if (name === '_all') {
|
|
374
|
+
return this.index;
|
|
375
|
+
}
|
|
213
376
|
if (name in this.secondaryIndexes) {
|
|
214
377
|
return this.secondaryIndexes[name].index;
|
|
215
378
|
}
|
|
@@ -276,6 +439,10 @@ class WritableStorage extends ReadableStorage {
|
|
|
276
439
|
/**
|
|
277
440
|
* Truncate all partitions after the given (global) sequence number.
|
|
278
441
|
*
|
|
442
|
+
* Assumes the primary index is fully consistent with the partition data. Looks up the first
|
|
443
|
+
* index entry after `after` for each affected partition and truncates the partition file
|
|
444
|
+
* at that entry's byte position.
|
|
445
|
+
*
|
|
279
446
|
* @private
|
|
280
447
|
* @param {number} after The document sequence number to truncate after.
|
|
281
448
|
*/
|
|
@@ -309,31 +476,39 @@ class WritableStorage extends ReadableStorage {
|
|
|
309
476
|
if (!this.index.isOpen()) {
|
|
310
477
|
this.index.open();
|
|
311
478
|
}
|
|
479
|
+
if (after < 0) {
|
|
480
|
+
after += this.index.length;
|
|
481
|
+
}
|
|
312
482
|
|
|
313
483
|
this.truncatePartitions(after);
|
|
314
484
|
|
|
315
485
|
this.index.truncate(after);
|
|
316
|
-
this.
|
|
317
|
-
if (!(index instanceof WritableIndex)) {
|
|
318
|
-
return;
|
|
319
|
-
}
|
|
320
|
-
let closeIndex = false;
|
|
321
|
-
if (!index.isOpen()) {
|
|
322
|
-
index.open();
|
|
323
|
-
closeIndex = true;
|
|
324
|
-
}
|
|
486
|
+
this.forEachWritableSecondaryIndex(index => {
|
|
325
487
|
index.truncate(index.find(after));
|
|
326
|
-
if (closeIndex) {
|
|
327
|
-
index.close();
|
|
328
|
-
}
|
|
329
488
|
});
|
|
330
489
|
}
|
|
331
490
|
|
|
491
|
+
/**
|
|
492
|
+
* @inheritDoc
|
|
493
|
+
* Open an existing secondary index and repair any stale entries beyond the current primary
|
|
494
|
+
* index length. Stale entries can be present when checkTornWrites() truncated the primary
|
|
495
|
+
* index before this secondary index was loaded into memory.
|
|
496
|
+
*/
|
|
497
|
+
openIndex(name, matcher) {
|
|
498
|
+
const index = super.openIndex(name, matcher);
|
|
499
|
+
const lastEntry = index.lastEntry;
|
|
500
|
+
if (lastEntry !== false && lastEntry.number > this.index.length) {
|
|
501
|
+
// Secondary index is ahead of primary: truncate stale entries.
|
|
502
|
+
index.truncate(index.find(this.index.length));
|
|
503
|
+
}
|
|
504
|
+
return index;
|
|
505
|
+
}
|
|
506
|
+
|
|
332
507
|
/**
|
|
333
508
|
* @protected
|
|
334
509
|
* @param {string} name
|
|
335
510
|
* @param {object} [options]
|
|
336
|
-
* @returns {{ index: WritableIndex, matcher:
|
|
511
|
+
* @returns {{ index: WritableIndex, matcher: Matcher }}
|
|
337
512
|
*/
|
|
338
513
|
createIndex(name, options = {}) {
|
|
339
514
|
const index = new WritableIndex(name, options);
|
|
@@ -366,4 +541,7 @@ class WritableStorage extends ReadableStorage {
|
|
|
366
541
|
}
|
|
367
542
|
|
|
368
543
|
module.exports = WritableStorage;
|
|
369
|
-
module.exports.StorageLockedError = StorageLockedError;
|
|
544
|
+
module.exports.StorageLockedError = StorageLockedError;
|
|
545
|
+
module.exports.CorruptFileError = ReadableStorage.CorruptFileError;
|
|
546
|
+
module.exports.LOCK_THROW = LOCK_THROW;
|
|
547
|
+
module.exports.LOCK_RECLAIM = LOCK_RECLAIM;
|