• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

albe / node-event-storage / 23403353980

22 Mar 2026 12:45PM UTC coverage: 97.583% (-0.3%) from 97.928%
23403353980

Pull #266

github

web-flow
Merge a2e8bbf39 into 131d43a8c
Pull Request #266: Allow reindexing a Storage: auto-repair lagging index and public reindex() API

699 of 739 branches covered (94.59%)

Branch coverage included in aggregate %.

30 of 34 new or added lines in 2 files covered. (88.24%)

1 existing line in 1 file now uncovered.

1562 of 1578 relevant lines covered (98.99%)

1324.23 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

94.58
/src/Storage/WritableStorage.js
1
const fs = require('fs');
4✔
2
const path = require('path');
4✔
3
const WritablePartition = require('../Partition/WritablePartition');
4✔
4
const WritableIndex = require('../Index/WritableIndex');
4✔
5
const ReadableStorage = require('./ReadableStorage');
4✔
6
const { assert, matches, buildMetadataForMatcher, buildMatcherFromMetadata, ensureDirectory } = require('../util');
4✔
7

8
const DEFAULT_WRITE_BUFFER_SIZE = 16 * 1024;
4✔
9

10
const LOCK_RECLAIM = 0x1;
4✔
11
const LOCK_THROW = 0x2;
4✔
12

13
class StorageLockedError extends Error {}
14

15
/**
16
 * @typedef {object|function(object):boolean} Matcher
17
 */
18

19
/**
20
 * An append-only storage with highly performant positional range scans.
21
 * It's highly optimized for an event-store and hence does not support compaction or data-rewrite, nor any querying
22
 */
23
class WritableStorage extends ReadableStorage {
24

25
    /**
26
     * @param {string} [storageName] The name of the storage.
27
     * @param {object} [config] An object with storage parameters.
28
     * @param {object} [config.serializer] A serializer object with methods serialize(document) and deserialize(data).
29
     * @param {function(object): string} config.serializer.serialize Default is JSON.stringify.
30
     * @param {function(string): object} config.serializer.deserialize Default is JSON.parse.
31
     * @param {string} [config.dataDirectory] The path where the storage data should reside. Default '.'.
32
     * @param {string} [config.indexDirectory] The path where the indexes should be stored. Defaults to dataDirectory.
33
     * @param {string} [config.indexFile] The name of the primary index. Default '{storageName}.index'.
34
     * @param {number} [config.readBufferSize] Size of the read buffer in bytes. Default 4096.
35
     * @param {number} [config.writeBufferSize] Size of the write buffer in bytes. Default 16384.
36
     * @param {number} [config.maxWriteBufferDocuments] How many documents to have in the write buffer at max. 0 means as much as possible. Default 0.
37
     * @param {boolean} [config.syncOnFlush] If fsync should be called on write buffer flush. Set this if you need strict durability. Defaults to false.
38
     * @param {boolean} [config.dirtyReads] If dirty reads should be allowed. This means that writes that are in write buffer but not yet flushed can be read. Defaults to true.
39
     * @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.
40
     * @param {object} [config.indexOptions] An options object that should be passed to all indexes on construction.
41
     * @param {string} [config.hmacSecret] A private key that is used to verify matchers retrieved from indexes.
42
     * @param {number} [config.lock] One of LOCK_* constants that defines how an existing lock should be handled.
43
     */
44
    constructor(storageName = 'storage', config = {}) {
524!
45
        if (typeof storageName !== 'string') {
896✔
46
            config = storageName;
524✔
47
            storageName = undefined;
524✔
48
        }
49
        const defaults = {
896✔
50
            partitioner: (document, number) => '',
1,688✔
51
            writeBufferSize: DEFAULT_WRITE_BUFFER_SIZE,
52
            maxWriteBufferDocuments: 0,
53
            syncOnFlush: false,
54
            dirtyReads: true,
55
            dataDirectory: '.'
56
        };
57
        config = Object.assign(defaults, config);
896✔
58
        config.indexOptions = Object.assign({ syncOnFlush: config.syncOnFlush }, config.indexOptions);
896✔
59
        ensureDirectory(config.dataDirectory);
896✔
60
        super(storageName, config);
896✔
61

62
        this.lockFile = path.resolve(this.dataDirectory, this.storageFile + '.lock');
896✔
63
        if (config.lock === LOCK_RECLAIM) {
896✔
64
            this.unlock();
12✔
65
        }
66
        this.partitioner = config.partitioner;
896✔
67
    }
68

69
    /**
70
     * @inheritDoc
71
     * @returns {boolean}
72
     * @throws {StorageLockedError} If this storage is locked by another process.
73
     */
74
    open() {
75
        if (!this.lock()) {
848✔
76
            return true;
4✔
77
        }
78
        const result = super.open();
840✔
79
        this.emit('ready');
840✔
80
        return result;
840✔
81
    }
82

83
    /**
84
     * Check all partitions torn writes and truncate the storage to the position before the first torn write.
85
     * This might delete correctly written events in partitions, if their sequence number is higher than the
86
     * torn write in another partition.
87
     * Also detects when the primary index is lagging behind the actual partition data and automatically
88
     * repairs it by invoking reindex().
89
     */
90
    checkTornWrites() {
91
        let lastValidSequenceNumber = Number.MAX_SAFE_INTEGER;
28✔
92
        let maxPartitionSequenceNumber = -1;
28✔
93
        this.forEachPartition(partition => {
28✔
94
            partition.open();
32✔
95
            const result = partition.checkTornWrite();
32✔
96
            if (result < 0) {
32✔
97
                // Torn write: result encodes -(tornSeqnum + 1), so torn seqnum = -result - 1.
98
                const tornSeqnum = -result - 1;
16✔
99
                lastValidSequenceNumber = Math.min(lastValidSequenceNumber, tornSeqnum);
16✔
100
                // Any complete documents before the torn one contribute to the lagging check.
101
                // Their last seqnum is tornSeqnum - 1 (if > 0; otherwise no complete docs).
102
                if (tornSeqnum > 0) {
16✔
103
                    maxPartitionSequenceNumber = Math.max(maxPartitionSequenceNumber, tornSeqnum - 1);
12✔
104
                }
105
                // Physically remove the torn document from this partition so that subsequent
106
                // partition reads (e.g. in reindex()) don't encounter the corrupt data.
107
                const tornPosition = partition.findDocumentPositionBefore(partition.size);
16✔
108
                if (tornPosition !== false && tornPosition >= 0) {
16!
109
                    partition.truncate(tornPosition);
16✔
110
                }
111
            } else if (result > 0) {
16!
112
                // No torn write: result encodes (lastCompleteSeqnum + 1), so seqnum = result - 1.
113
                maxPartitionSequenceNumber = Math.max(maxPartitionSequenceNumber, result - 1);
16✔
114
            }
115
            // result === 0: empty partition, no action needed.
116
        });
117
        if (lastValidSequenceNumber < Number.MAX_SAFE_INTEGER) {
28✔
118
            this.truncate(lastValidSequenceNumber);
16✔
119
            // After truncation, account for documents beyond the truncation point being removed.
120
            // truncate(N) keeps index entries 1..N, so the last kept partition seqnum is N-1.
121
            maxPartitionSequenceNumber = Math.min(maxPartitionSequenceNumber, lastValidSequenceNumber - 1);
16✔
122
        }
123
        // Ensure index is open so its length can be checked accurately.
124
        if (!this.index.isOpen()) {
28!
NEW
125
            this.index.open();
×
126
        }
127
        // A partition seqnum of N means the document was written when the index had N entries,
128
        // so the index should contain at least N+1 entries to be consistent.
129
        // Automatically repair a lagging index by reindexing from the current index length.
130
        if (maxPartitionSequenceNumber >= 0 && maxPartitionSequenceNumber + 1 > this.index.length) {
28✔
131
            this.reindex(this.index.length);
12✔
132
        }
133
        this.forEachPartition(partition => partition.close());
32✔
134
    }
135

136
    /**
137
     * Rebuild the primary index and all loaded secondary indexes starting from the given sequence
138
     * number by scanning the partition data directly.
139
     * This is the building block for both auto-repair (invoked automatically when the primary
140
     * index is found to be lagging in checkTornWrites()) and for user-driven re-indexing after
141
     * index corruption.
142
     *
143
     * @api
144
     * @param {number} [fromSequenceNumber=0] The number of primary index entries to keep intact.
145
     *   All index entries beyond this position will be removed and rebuilt from partition data.
146
     *   Defaults to 0, which rebuilds all indexes from scratch.
147
     */
148
    reindex(fromSequenceNumber = 0) {
×
149
        if (!this.index.isOpen()) {
32!
NEW
150
            this.index.open();
×
151
        }
152

153
        this.index.truncate(fromSequenceNumber);
32✔
154

155
        // Truncate all loaded secondary indexes to match the new primary length.
156
        this.forEachSecondaryIndex(index => {
32✔
157
            /* istanbul ignore if */
158
            if (!(index instanceof WritableIndex)) {
8✔
159
                return;
160
            }
161
            if (!index.isOpen()) {
8!
NEW
162
                index.open();
×
163
            }
164
            // find(0) returns 0, so truncate(0) will remove all entries when fromSequenceNumber===0
165
            index.truncate(fromSequenceNumber === 0 ? 0 : index.find(fromSequenceNumber));
8✔
166
        });
167

168
        // Ensure all partitions are open so iteratePartitionsBySequenceNumber can read them.
169
        this.forEachPartition(partition => {
32✔
170
            if (!partition.isOpen()) {
40!
NEW
171
                partition.open();
×
172
            }
173
        });
174

175
        // Scan partitions in sequence-number order and rebuild index entries.
176
        for (const { document, partitionId, position, size } of
32✔
177
            this.iteratePartitionsBySequenceNumber(fromSequenceNumber, Number.MAX_SAFE_INTEGER)) {
178
            const newEntry = new WritableIndex.Entry(this.index.length + 1, position, size, partitionId);
104✔
179
            this.index.add(newEntry);
104✔
180

181
            this.forEachSecondaryIndex((secIndex, name) => {
104✔
182
                /* istanbul ignore if */
183
                if (!(secIndex instanceof WritableIndex)) {
36✔
184
                    return;
185
                }
186
                /* istanbul ignore if */
187
                if (!secIndex.isOpen()) {
36✔
188
                    secIndex.open();
189
                }
190
                const { matcher } = this.secondaryIndexes[name];
36✔
191
                if (matches(document, matcher)) {
36✔
192
                    secIndex.add(newEntry);
20✔
193
                }
194
            });
195
        }
196

197
        this.flush();
32✔
198
    }
199

200
    /**
201
     * @returns {boolean} True if the lock was created or false if the lock is already in place.
202
     * @throws {StorageLockedError} If this storage is already locked by another process.
203
     * @throws {Error} If the lock could not be created.
204
     */
205
    lock() {
206
        if (this.locked) {
848✔
207
            return false;
4✔
208
        }
209
        try {
844✔
210
            fs.mkdirSync(this.lockFile);
844✔
211
            this.locked = true;
840✔
212
        } catch (e) {
213
            /* istanbul ignore if */
214
            if (e.code !== 'EEXIST') {
4✔
215
                throw new Error(`Error creating lock for storage ${this.storageFile}: ` + e.message);
216
            }
217
            throw new StorageLockedError(`Storage ${this.storageFile} is locked by another process`);
4✔
218
        }
219
        return true;
840✔
220
    }
221

222
    /**
223
     * Unlock this storage, no matter if it was previously locked by this writer.
224
     * Only use this if you are sure there is no other process still having a writer open.
225
     * Current implementation just deletes a lock file that is named like the storage.
226
     */
227
    unlock() {
228
        if (fs.existsSync(this.lockFile)) {
848✔
229
            if (!this.locked) {
840✔
230
                this.checkTornWrites();
12✔
231
            }
232
            fs.rmdirSync(this.lockFile);
840✔
233
        }
234
        this.locked = false;
848✔
235
    }
236

237
    /**
238
     * @inheritDoc
239
     */
240
    close() {
241
        if (this.locked) {
1,392✔
242
            this.unlock();
836✔
243
        }
244
        super.close();
1,392✔
245
    }
246

247
    /**
248
     * Add an index entry for the given document at the position and size.
249
     *
250
     * @private
251
     * @param {number} partitionId The partition where the document is stored.
252
     * @param {number} position The file offset where the document is stored.
253
     * @param {number} size The size of the stored document.
254
     * @param {object} document The document to add to the index.
255
     * @param {function} [callback] The callback to call when the index is written to disk.
256
     * @returns {EntryInterface} The index entry item.
257
     */
258
    addIndex(partitionId, position, size, document, callback) {
259
        if (!this.index.isOpen()) {
2,880✔
260
            this.index.open();
4✔
261
        }
262

263
        /*if (this.index.lastEntry.position + this.index.lastEntry.size !== position) {
264
         this.emit('index-corrupted');
265
         throw new Error('Corrupted index, needs to be rebuilt!');
266
         }*/
267

268
        const entry = new WritableIndex.Entry(this.index.length + 1, position, size, partitionId);
2,880✔
269
        this.index.add(entry, (indexPosition) => {
2,880✔
270
            this.emit('wrote', document, entry, indexPosition);
2,880✔
271
            /* istanbul ignore if  */
272
            if (typeof callback === 'function') {
2,880✔
273
                return callback(indexPosition);
274
            }
275
        });
276
        return entry;
2,880✔
277
    }
278

279
    /**
280
     * Register a handler that is called before a document is written to storage.
281
     * The handler receives the document and the partition metadata and may throw to abort the write.
282
     * Multiple handlers can be registered; all run on every write in registration order.
283
     * Equivalent to `storage.on('preCommit', hook)`.
284
     *
285
     * @api
286
     * @param {function(object, object): void} hook A function receiving (document, partitionMetadata).
287
     */
288
    preCommit(hook) {
289
        this.on('preCommit', hook);
16✔
290
    }
291

292
    /**
293
     * Get a partition either by name or by id.
294
     * If a partition with the given name does not exist, a new one will be created.
295
     * If a partition with the given id does not exist, an error is thrown.
296
     *
297
     * @protected
298
     * @param {string|number} partitionIdentifier The partition name or the partition Id
299
     * @returns {ReadablePartition}
300
     * @throws {Error} If an id is given and no such partition exists.
301
     */
302
    getPartition(partitionIdentifier) {
303
        if (typeof partitionIdentifier === 'string') {
7,020✔
304
            const partitionShortName = partitionIdentifier;
2,904✔
305
            const partitionName = this.storageFile + (partitionIdentifier.length ? '.' + partitionIdentifier : '');
2,904✔
306
            partitionIdentifier = WritablePartition.idFor(partitionName);
2,904✔
307
            if (!this.partitions[partitionIdentifier]) {
2,904✔
308
                const partitionConfig = typeof this.partitionConfig.metadata === 'function'
892✔
309
                    ? { ...this.partitionConfig, metadata: this.partitionConfig.metadata(partitionShortName) }
310
                    : this.partitionConfig;
311
                this.partitions[partitionIdentifier] = this.createPartition(partitionName, partitionConfig);
892✔
312
                this.emit('partition-created', partitionIdentifier);
892✔
313
            }
314
            this.partitions[partitionIdentifier].open();
2,904✔
315
            return this.partitions[partitionIdentifier];
2,904✔
316
        }
317
        return super.getPartition(partitionIdentifier);
4,116✔
318
    }
319

320
    /**
321
     * @api
322
     * @param {object} document The document to write to storage.
323
     * @param {function} [callback] A function that will be called when the document is written to disk.
324
     * @returns {number} The 1-based document sequence number in the storage.
325
     */
326
    write(document, callback) {
327
        const data = this.serializer.serialize(document).toString();
2,888✔
328
        const dataSize = Buffer.byteLength(data, 'utf8');
2,888✔
329

330
        const partitionName = this.partitioner(document, this.index.length + 1);
2,888✔
331
        const partition = this.getPartition(partitionName);
2,888✔
332
        if (this.listenerCount('preCommit') > 0) {
2,888✔
333
            this.emit('preCommit', document, partition.metadata);
56✔
334
        }
335
        const position = partition.write(data, this.length, callback);
2,880✔
336

337
        assert(position !== false, 'Error writing document.');
2,880✔
338

339
        const indexEntry = this.addIndex(partition.id, position, dataSize, document);
2,880✔
340
        this.forEachSecondaryIndex((index, name) => {
2,880✔
341
            if (!index.isOpen()) {
1,504✔
342
                index.open();
4✔
343
            }
344
            index.add(indexEntry);
1,504✔
345
            this.emit('index-add', name, index.length, document);
1,504✔
346
        }, document);
347

348
        return this.index.length;
2,880✔
349
    }
350

351
    /**
352
     * Ensure that an index with the given name and document matcher exists.
353
     * Will create the index if it doesn't exist, otherwise return the existing index.
354
     *
355
     * @api
356
     * @param {string} name The index name.
357
     * @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.
358
     * @returns {ReadableIndex} The index containing all documents that match the query.
359
     * @throws {Error} if the index doesn't exist yet and no matcher was specified.
360
     */
361
    ensureIndex(name, matcher) {
362
        if (name === '_all') {
868✔
363
            return this.index;
4✔
364
        }
365
        if (name in this.secondaryIndexes) {
864✔
366
            return this.secondaryIndexes[name].index;
4✔
367
        }
368

369
        const indexName = this.storageFile + '.' + name + '.index';
860✔
370
        if (fs.existsSync(path.join(this.indexDirectory, indexName))) {
860✔
371
            return this.openIndex(name, matcher);
16✔
372
        }
373

374
        assert((typeof matcher === 'object' || typeof matcher === 'function') && matcher !== null, 'Need to specify a matcher.');
844✔
375

376
        const metadata = buildMetadataForMatcher(matcher, this.hmac);
840✔
377
        const { index } = this.createIndex(indexName, Object.assign({}, this.indexOptions, { metadata }));
840✔
378
        try {
840✔
379
            this.forEachDocument((document, indexEntry) => {
840✔
380
                if (matches(document, matcher)) {
1,752✔
381
                    index.add(indexEntry);
16✔
382
                }
383
            });
384
        } catch (e) {
385
            index.destroy();
4✔
386
            throw e;
4✔
387
        }
388

389
        this.secondaryIndexes[name] = { index, matcher };
836✔
390
        this.emit('index-created', name);
836✔
391
        return index;
836✔
392
    }
393

394
    /**
395
     * Flush all write buffers to disk.
396
     * This is a sync method and will invoke all previously registered flush callbacks.
397
     *
398
     * @api
399
     * @returns {boolean} Returns true if a flush on any partition or the main index was executed.
400
     */
401
    flush() {
402
        let result = this.index.flush();
132✔
403
        this.forEachPartition(partition => result = result | partition.flush());
144✔
404
        this.forEachSecondaryIndex(index => index.flush());
132✔
405
        return result;
132✔
406
    }
407

408
    /**
409
     * Iterate all distinct partitions in which the given iterable list of entries are stored.
410
     * @param {Iterable<Index.Entry>} entries
411
     * @param {function(Index.Entry)} iterationHandler
412
     */
413
    forEachDistinctPartitionOf(entries, iterationHandler) {
414
        const partitions = [];
40✔
415
        const numPartitions = Object.keys(this.partitions).length;
40✔
416
        for (let entry of entries) {
40✔
417
            if (partitions.indexOf(entry.partition) >= 0) {
60✔
418
                continue;
16✔
419
            }
420
            partitions.push(entry.partition);
44✔
421
            iterationHandler(entry);
44✔
422
            if (partitions.length === numPartitions) {
44✔
423
                break;
24✔
424
            }
425
        }
426
    }
427

428
    /**
429
     * Truncate all partitions after the given (global) sequence number.
430
     *
431
     * @private
432
     * @param {number} after The document sequence number to truncate after.
433
     */
434
    truncatePartitions(after) {
435
        if (after === 0) {
60✔
436
            this.forEachPartition(partition => partition.truncate(0));
12✔
437
            return;
12✔
438
        }
439

440
        const entries = this.index.range(after + 1);  // We need the first entry that is cut off
48✔
441
        if (entries === false || entries.length === 0) {
48✔
442
            return;
8✔
443
        }
444

445
        this.forEachDistinctPartitionOf(entries, entry => this.getPartition(entry.partition).truncate(entry.position));
44✔
446
    }
447

448
    /**
449
     * Truncate the storage after the given sequence number.
450
     *
451
     * @param {number} after The document sequence number to truncate after.
452
     */
453
    truncate(after) {
454
        /*
455
         To truncate the store following steps need to be done:
456

457
         1) find all partition positions after which their files should be truncated
458
         2) truncate all partitions accordingly
459
         3) truncate/rewrite all indexes
460
         */
461
        if (!this.index.isOpen()) {
60✔
462
            this.index.open();
4✔
463
        }
464
        if (after < 0) {
60!
UNCOV
465
            after += this.index.length;
×
466
        }
467

468
        this.truncatePartitions(after);
60✔
469

470
        this.index.truncate(after);
60✔
471
        this.forEachSecondaryIndex(index => {
60✔
472
            /* istanbul ignore if */
473
            if (!(index instanceof WritableIndex)) {
32✔
474
                return;
475
            }
476
            let closeIndex = false;
32✔
477
            if (!index.isOpen()) {
32✔
478
                index.open();
4✔
479
                closeIndex = true;
4✔
480
            }
481
            index.truncate(index.find(after));
32✔
482
            if (closeIndex) {
32✔
483
                index.close();
4✔
484
            }
485
        });
486
    }
487

488
    /**
489
     * @inheritDoc
490
     * Open an existing secondary index and repair any stale entries beyond the current primary
491
     * index length. Stale entries can be present when checkTornWrites() truncated the primary
492
     * index before this secondary index was loaded into memory.
493
     */
494
    openIndex(name, matcher) {
495
        const index = super.openIndex(name, matcher);
704✔
496
        const lastEntry = index.lastEntry;
688✔
497
        if (lastEntry !== false && lastEntry.number > this.index.length) {
688✔
498
            // Secondary index is ahead of primary: truncate stale entries.
499
            index.truncate(index.find(this.index.length));
8✔
500
        }
501
        return index;
688✔
502
    }
503

504
    /**
505
     * @protected
506
     * @param {string} name
507
     * @param {object} [options]
508
     * @returns {{ index: WritableIndex, matcher: Matcher }}
509
     */
510
    createIndex(name, options = {}) {
×
511
        const index = new WritableIndex(name, options);
1,840✔
512
        let matcher;
513

514
        // If the index contains a matcher (possibly a serialized function) we check HMAC
515
        // to prevent evaluating unknown code.
516
        if (index.metadata.matcher) {
1,832✔
517
            try {
936✔
518
                matcher = buildMatcherFromMetadata(index.metadata, this.hmac);
936✔
519
            } catch (e) {
520
                index.destroy();
4✔
521
                throw e;
4✔
522
            }
523
        }
524

525
        return { index, matcher };
1,828✔
526
    }
527

528
    /**
529
     * @protected
530
     * @param {string} name
531
     * @param {object} [config]
532
     * @returns {WritablePartition}
533
     */
534
    createPartition(name, config = {}) {
×
535
        return new WritablePartition(name, config);
988✔
536
    }
537

538
}
539

540
module.exports = WritableStorage;
4✔
541
module.exports.StorageLockedError = StorageLockedError;
4✔
542
module.exports.CorruptFileError = ReadableStorage.CorruptFileError;
4✔
543
module.exports.LOCK_THROW = LOCK_THROW;
4✔
544
module.exports.LOCK_RECLAIM = LOCK_RECLAIM;
4✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc