event-storage 0.9.1 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,149 @@
1
+ /**
2
+ * A fixed-capacity registry of partitions with LRU eviction of open file handles.
3
+ *
4
+ * All partitions are stored by their numeric id and may be queried at any time.
5
+ * The pool additionally tracks which partitions currently have an open file descriptor
6
+ * in LRU (least-recently-used) order. When the pool is asked to open a partition and
7
+ * doing so would exceed the configured cap, the least-recently-used open partition is
8
+ * closed first to stay within the limit.
9
+ *
10
+ * Setting the cap to 0 disables eviction: all partitions are allowed to remain open
11
+ * simultaneously, which matches the uncapped behaviour of the original implementation.
12
+ */
13
+ class PartitionPool {
14
+
15
+ /**
16
+ * @param {number} [maxOpen=0] Maximum number of simultaneously open partition file
17
+ * handles. 0 disables the limit (no eviction).
18
+ */
19
+ constructor(maxOpen = 0) {
20
+ this.maxOpen = maxOpen;
21
+ /** Registry of all known partitions keyed by id. */
22
+ this.registry = Object.create(null);
23
+ /**
24
+ * Insertion-order map used for LRU tracking of open file handles.
25
+ * Key = partition id, value = true.
26
+ * Oldest (least-recently-used) entry is first; newest (most-recently-used) is last.
27
+ */
28
+ this.handles = new Map();
29
+ }
30
+
31
+ /**
32
+ * Register a partition under the given id.
33
+ *
34
+ * @param {number|string} id
35
+ * @param {object} partition
36
+ */
37
+ add(id, partition) {
38
+ this.registry[id] = partition;
39
+ }
40
+
41
+ /**
42
+ * Retrieve a registered partition without opening it.
43
+ *
44
+ * @param {number|string} id
45
+ * @returns {object|undefined}
46
+ */
47
+ get(id) {
48
+ return this.registry[id];
49
+ }
50
+
51
+ /**
52
+ * Check whether a partition with the given id is registered in the pool.
53
+ *
54
+ * @param {number|string} id
55
+ * @returns {boolean}
56
+ */
57
+ has(id) {
58
+ return id in this.registry;
59
+ }
60
+
61
+ /**
62
+ * Open the partition with the given id, applying LRU eviction if necessary.
63
+ *
64
+ * If the partition is not yet open and adding it would exceed `maxOpen`, the
65
+ * least-recently-used open partition is closed first. Stale entries (partitions
66
+ * that were closed externally) are discarded from the LRU map as they are
67
+ * encountered; if all tracked entries turn out to be stale the loop exits without
68
+ * closing any partition — the handle count stays temporarily inflated (bounded by
69
+ * the number of external closes since the last `open()` call) but correctness is
70
+ * preserved.
71
+ *
72
+ * @param {number|string} id
73
+ * @returns {object} The opened partition.
74
+ */
75
+ open(id) {
76
+ const partition = this.registry[id];
77
+
78
+ if (this.maxOpen > 0) {
79
+ // Remove id first — this may already bring the handle count below the cap.
80
+ this.handles.delete(id);
81
+ if (this.handles.size >= this.maxOpen) {
82
+ for (const [lruId] of this.handles) {
83
+ this.handles.delete(lruId);
84
+ const lruPartition = this.registry[lruId];
85
+ if (lruPartition && lruPartition.isOpen()) {
86
+ lruPartition.close();
87
+ break;
88
+ }
89
+ }
90
+ }
91
+ // (Re-)add id at the MRU end of the map.
92
+ this.handles.set(id, true);
93
+ }
94
+
95
+ partition.open();
96
+ return partition;
97
+ }
98
+
99
+ /**
100
+ * Invoke `callback` for every registered partition.
101
+ *
102
+ * @param {function(object): void} callback
103
+ */
104
+ forEach(callback) {
105
+ for (const id of Object.keys(this.registry)) {
106
+ callback(this.registry[id]);
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Yield every registered partition object.
112
+ *
113
+ * @returns {Generator<object>}
114
+ */
115
+ *values() {
116
+ for (const id of Object.keys(this.registry)) {
117
+ yield this.registry[id];
118
+ }
119
+ }
120
+
121
+ /**
122
+ * The total number of registered partitions.
123
+ * @returns {number}
124
+ */
125
+ get count() {
126
+ return Object.keys(this.registry).length;
127
+ }
128
+
129
+ /**
130
+ * The number of open partition file handles currently tracked by the pool.
131
+ * @returns {number}
132
+ */
133
+ get openCount() {
134
+ return this.handles.size;
135
+ }
136
+
137
+ /**
138
+ * Reset the open-handle tracking without closing any partitions.
139
+ *
140
+ * Call this after externally closing all partitions (e.g. after
141
+ * `checkTornWrites`) to keep the pool's LRU state consistent with reality.
142
+ */
143
+ clearOpenHandles() {
144
+ this.handles.clear();
145
+ }
146
+
147
+ }
148
+
149
+ export default PartitionPool;
@@ -1,6 +1,6 @@
1
- const ReadableStorage = require('./ReadableStorage');
2
- const ReadablePartition = require('../Partition/ReadablePartition');
3
- const Watcher = require('../Watcher');
1
+ import ReadableStorage from './ReadableStorage.js';
2
+ import ReadablePartition from '../Partition/ReadablePartition.js';
3
+ import Watcher from '../Watcher.js';
4
4
 
5
5
  /**
6
6
  * An append-only storage with highly performant positional range scans.
@@ -54,9 +54,9 @@ class ReadOnlyStorage extends ReadableStorage {
54
54
  }
55
55
 
56
56
  const partitionId = ReadablePartition.idFor(filename);
57
- if (!this.partitions[partitionId]) {
57
+ if (!this.partitions.has(partitionId)) {
58
58
  const partition = this.createPartition(filename, this.partitionConfig);
59
- this.partitions[partition.id] = partition;
59
+ this.partitions.add(partition.id, partition);
60
60
  this.emit('partition-created', partition.id);
61
61
  }
62
62
  }
@@ -113,4 +113,4 @@ class ReadOnlyStorage extends ReadableStorage {
113
113
  }
114
114
  }
115
115
 
116
- module.exports = ReadOnlyStorage;
116
+ export default ReadOnlyStorage;
@@ -1,13 +1,31 @@
1
- const fs = require('fs');
2
- const path = require('path');
3
- const events = require('events');
4
- const Partition = require('../Partition');
5
- const Index = require('../Index');
6
- const { assert, wrapAndCheck, kWayMerge } = require('../util');
7
- const { createHmac, matches, buildMetadataForMatcher } = require('../metadataUtil');
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import events from 'events';
4
+ import Partition, { ReadOnly as ReadOnlyPartition } from '../Partition.js';
5
+ import Index, { ReadOnly as ReadOnlyIndex } from '../Index.js';
6
+ import { assert, wrapAndCheck, kWayMerge } from '../util.js';
7
+ import { scanForFiles } from '../fsUtil.js';
8
+ import { createHmac, matches, buildMetadataForMatcher } from '../metadataUtil.js';
9
+ import IndexMatcher from '../IndexMatcher.js';
10
+ import PartitionPool from '../PartitionPool.js';
8
11
 
9
12
  const DEFAULT_READ_BUFFER_SIZE = 4 * 1024;
10
13
 
14
+ /**
15
+ * Default ordered list of document property paths used as discriminant keys when
16
+ * classifying object matchers into the fast-lookup table. Each path may use
17
+ * dot-notation for nested access (e.g. `'payload.type'`). The first path that
18
+ * resolves to a scalar value in a given matcher wins; remaining paths are not
19
+ * examined for that matcher.
20
+ */
21
+ const DEFAULT_MATCHER_PROPERTIES = ['stream', 'payload.type'];
22
+
23
+ /**
24
+ * Default maximum number of partition file descriptors kept open simultaneously.
25
+ * Partitions beyond this limit are evicted using LRU order. 0 disables the limit.
26
+ */
27
+ const DEFAULT_MAX_OPEN_PARTITIONS = 1024;
28
+
11
29
  /**
12
30
  * Reverses the items of an iterable
13
31
  * @param {Generator|Iterable} iterator
@@ -43,6 +61,13 @@ class ReadableStorage extends events.EventEmitter {
43
61
  * @param {object} [config.indexOptions] An options object that should be passed to all indexes on construction.
44
62
  * @param {string} [config.hmacSecret] A private key that is used to verify matchers retrieved from indexes.
45
63
  * @param {object} [config.metadata] A metadata object to be stored in all partitions belonging to this storage.
64
+ * @param {string[]} [config.matcherProperties] Ordered list of document property paths (dot-notation) used as
65
+ * discriminant keys for the fast secondary-index lookup table. Only the first property that resolves to a scalar
66
+ * value inside a given object matcher is used; the rest are checked via the full `matches()` fallback.
67
+ * Default: `['stream', 'payload.type']`.
68
+ * @param {number} [config.maxOpenPartitions] Maximum number of partition file descriptors kept open at one time.
69
+ * When the limit is reached the least-recently-used partition is closed to make room. 0 disables the limit.
70
+ * Default: 1024.
46
71
  */
47
72
  constructor(storageName = 'storage', config = {}) {
48
73
  super();
@@ -58,7 +83,9 @@ class ReadableStorage extends events.EventEmitter {
58
83
  indexFile: this.storageFile + '.index',
59
84
  indexOptions: {},
60
85
  hmacSecret: '',
61
- metadata: {}
86
+ metadata: {},
87
+ matcherProperties: DEFAULT_MATCHER_PROPERTIES,
88
+ maxOpenPartitions: DEFAULT_MAX_OPEN_PARTITIONS
62
89
  };
63
90
  config = Object.assign(defaults, config);
64
91
  this.serializer = config.serializer;
@@ -67,7 +94,13 @@ class ReadableStorage extends events.EventEmitter {
67
94
 
68
95
  this.dataDirectory = path.resolve(config.dataDirectory);
69
96
 
70
- this.scanPartitions(config);
97
+ const partitionDefaults = { readBufferSize: DEFAULT_READ_BUFFER_SIZE };
98
+ this.partitionConfig = Object.assign(partitionDefaults, config);
99
+ this.partitions = new PartitionPool(config.maxOpenPartitions);
100
+
101
+ // initialized: null = not started (or scan cancelled), false = in progress, true = done
102
+ this.initialized = null;
103
+
71
104
  this.initializeIndexes(config);
72
105
  }
73
106
 
@@ -79,7 +112,7 @@ class ReadableStorage extends events.EventEmitter {
79
112
  */
80
113
  createIndex(name, options = {}) {
81
114
  /** @type ReadableIndex */
82
- const index = new Index.ReadOnly(name, options);
115
+ const index = new ReadOnlyIndex(name, options);
83
116
  return { index };
84
117
  }
85
118
 
@@ -90,7 +123,7 @@ class ReadableStorage extends events.EventEmitter {
90
123
  * @returns {ReadablePartition}
91
124
  */
92
125
  createPartition(name, options = {}) {
93
- return new Partition.ReadOnly(name, options);
126
+ return new ReadOnlyPartition(name, options);
94
127
  }
95
128
 
96
129
  /**
@@ -111,6 +144,9 @@ class ReadableStorage extends events.EventEmitter {
111
144
  this.index = index;
112
145
  this.secondaryIndexes = {};
113
146
  this.readonlyIndexes = {};
147
+
148
+ /** Fast secondary-index lookup — classifies matchers for O(1) candidate resolution on write. */
149
+ this.indexMatcher = new IndexMatcher(config.matcherProperties);
114
150
  }
115
151
 
116
152
  /**
@@ -122,56 +158,91 @@ class ReadableStorage extends events.EventEmitter {
122
158
  }
123
159
 
124
160
  /**
125
- * Scan the data directory for all existing partitions.
126
- * Every file beginning with the storageFile name is considered a partition.
127
- *
128
- * @private
129
- * @param {object} config The configuration object containing options for the partitions.
130
- * @returns void
161
+ * Scan partitions and secondary index files; emit 'index-created' for each found index.
162
+ * @param {function} done Called when both scans finish.
131
163
  */
132
- scanPartitions(config) {
133
- const defaults = {
134
- readBufferSize: DEFAULT_READ_BUFFER_SIZE
135
- };
136
- this.partitionConfig = Object.assign(defaults, config);
137
- this.partitions = Object.create(null);
164
+ scanFiles(done) {
165
+ const escaped = this.storageFile.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
166
+ const partitionPattern = new RegExp(`^(${escaped}.*)$`);
167
+ scanForFiles(this.dataDirectory, partitionPattern, (file) => {
168
+ if (file.endsWith('.index') || file.endsWith('.branch') || file.endsWith('.lock')) return;
169
+ const partition = this.createPartition(file, this.partitionConfig);
170
+ this.partitions.add(partition.id, partition);
171
+ }, (partErr) => {
172
+ /* istanbul ignore if */
173
+ if (partErr) throw partErr;
138
174
 
139
- const files = fs.readdirSync(this.dataDirectory);
140
- for (let file of files) {
141
- if (file.substr(-6) === '.index') continue;
142
- if (file.substr(-7) === '.branch') continue;
143
- if (file.substr(-5) === '.lock') continue;
144
- if (file.substr(0, this.storageFile.length) !== this.storageFile) continue;
175
+ // Scan was cancelled by close() between the two scan phases.
176
+ if (this.initialized === null) return;
145
177
 
146
- const partition = this.createPartition(file, this.partitionConfig);
147
- this.partitions[partition.id] = partition;
148
- }
178
+ // No secondary indexes exist yet — nothing to scan.
179
+ if (!fs.existsSync(this.indexDirectory)) {
180
+ return done();
181
+ }
182
+ const indexPattern = new RegExp(`^${escaped}\\.(.+)\\.index$`);
183
+ scanForFiles(this.indexDirectory, indexPattern, (name) => {
184
+ this.emit('index-created', name);
185
+ }, (indexErr) => {
186
+ // The directory could disappear between existsSync and readdir (e.g. test cleanup).
187
+ /* istanbul ignore if */
188
+ if (indexErr && indexErr.code !== 'ENOENT') throw indexErr;
189
+ done();
190
+ });
191
+ });
149
192
  }
150
193
 
151
194
  /**
152
- * Open the storage and indexes and create read and write buffers eagerly.
153
- * Will emit an 'opened' event if finished.
195
+ * Only the primary index is opened eagerly; secondary indexes open on demand.
154
196
  *
155
- * @api
156
- * @returns {boolean}
197
+ * @protected
157
198
  */
158
- open() {
199
+ openIndexes() {
159
200
  this.index.open();
201
+ }
160
202
 
161
- this.forEachSecondaryIndex(index => index.open());
162
-
163
- this.emit('opened');
203
+ /**
204
+ * Open the storage; scans existing partitions and indexes asynchronously on first open.
205
+ * Re-opens after `close()` are synchronous.
206
+ * Will emit an `'opened'` event when finished.
207
+ *
208
+ * @api
209
+ * @param {function(): void} [callback] Called after indexes open, before `'opened'` is emitted.
210
+ * Can be used as a synchronous alternative to listening to the `'opened'` event.
211
+ * @returns {boolean}
212
+ */
213
+ open(callback) {
214
+ if (this.initialized === true) {
215
+ this.openIndexes();
216
+ callback?.();
217
+ this.emit('opened');
218
+ return true;
219
+ }
220
+ if (this.initialized === false) {
221
+ return true;
222
+ }
223
+ this.initialized = false;
224
+ this.scanFiles(() => {
225
+ // Guard: close() while scanning resets initialized to null.
226
+ if (this.initialized === null) return;
227
+ this.initialized = true;
228
+ this.openIndexes();
229
+ callback?.();
230
+ this.emit('opened');
231
+ });
164
232
  return true;
165
233
  }
166
234
 
167
235
  /**
168
- * Close the storage and frees up all resources.
236
+ * Close the storage and free up all resources.
169
237
  * Will emit a 'closed' event when finished.
170
238
  *
171
239
  * @api
172
- * @returns void
173
240
  */
174
241
  close() {
242
+ // Cancel in-progress scan so the callback does not re-open after an explicit close.
243
+ if (this.initialized === false) {
244
+ this.initialized = null;
245
+ }
175
246
  this.index.close();
176
247
  this.forEachSecondaryIndex(index => index.close());
177
248
  for (let index of Object.values(this.readonlyIndexes)) {
@@ -182,20 +253,17 @@ class ReadableStorage extends events.EventEmitter {
182
253
  }
183
254
 
184
255
  /**
185
- * Get a partition either by name or by id.
186
- * If a partition with the given name does not exist, a new one will be created.
256
+ * Get a partition by its id.
187
257
  * If a partition with the given id does not exist, an error is thrown.
188
258
  *
189
259
  * @protected
190
- * @param {string|number} partitionIdentifier The partition name or the partition Id
260
+ * @param {number|string} partitionIdentifier The partition Id
191
261
  * @returns {ReadablePartition}
192
- * @throws {Error} If an id is given and no such partition exists.
262
+ * @throws {Error} If no such partition exists.
193
263
  */
194
264
  getPartition(partitionIdentifier) {
195
- assert(partitionIdentifier in this.partitions, `Partition #${partitionIdentifier} does not exist.`);
196
-
197
- this.partitions[partitionIdentifier].open();
198
- return this.partitions[partitionIdentifier];
265
+ assert(this.partitions.has(partitionIdentifier), `Partition #${partitionIdentifier} does not exist.`);
266
+ return this.partitions.open(partitionIdentifier);
199
267
  }
200
268
 
201
269
  /**
@@ -359,10 +427,27 @@ class ReadableStorage extends events.EventEmitter {
359
427
  const metadata = buildMetadataForMatcher(matcher, this.hmac);
360
428
  let { index } = this.secondaryIndexes[name] = this.createIndex(indexName, Object.assign({}, this.indexOptions, { metadata }));
361
429
 
430
+ // Register the actual stored matcher (may have been reconstructed from metadata by WritableStorage.createIndex).
431
+ this.indexMatcher.add(name, this.secondaryIndexes[name].matcher);
432
+
362
433
  index.open();
363
434
  return index;
364
435
  }
365
436
 
437
+ /**
438
+ * Remove a secondary index from the write path and the matcher lookup table.
439
+ *
440
+ * @api
441
+ * @param {string} name The secondary index name to remove.
442
+ */
443
+ removeSecondaryIndex(name) {
444
+ const entry = this.secondaryIndexes[name];
445
+ if (entry) {
446
+ this.indexMatcher.remove(name);
447
+ delete this.secondaryIndexes[name];
448
+ }
449
+ }
450
+
366
451
  /**
367
452
  * Iterate documents across all partitions in sequenceNumber order using a k-way merge.
368
453
  * Opens any closed partition automatically.
@@ -448,6 +533,9 @@ class ReadableStorage extends events.EventEmitter {
448
533
  /**
449
534
  * Helper method to iterate over all secondary indexes.
450
535
  *
536
+ * When `matchDocument` is provided, `this.indexMatcher.forEachMatch()` is used to
537
+ * efficiently find only the matching indexes via the discriminant lookup table.
538
+ *
451
539
  * @protected
452
540
  * @param {function(ReadableIndex, string)} iterationHandler
453
541
  * @param {object} [matchDocument] If supplied, only indexes the document matches on will be iterated.
@@ -458,11 +546,17 @@ class ReadableStorage extends events.EventEmitter {
458
546
  return;
459
547
  }
460
548
 
461
- for (let indexName of Object.keys(this.secondaryIndexes)) {
462
- if (!matchDocument || matches(matchDocument, this.secondaryIndexes[indexName].matcher)) {
549
+ if (!matchDocument) {
550
+ // No document filter: iterate all secondary indexes unconditionally.
551
+ for (const indexName of Object.keys(this.secondaryIndexes)) {
463
552
  iterationHandler(this.secondaryIndexes[indexName].index, indexName);
464
553
  }
554
+ return;
465
555
  }
556
+
557
+ this.indexMatcher.forEachMatch(matchDocument, indexName => {
558
+ iterationHandler(this.secondaryIndexes[indexName].index, indexName);
559
+ });
466
560
  }
467
561
 
468
562
  /**
@@ -477,13 +571,10 @@ class ReadableStorage extends events.EventEmitter {
477
571
  return;
478
572
  }
479
573
 
480
- for (let partition of Object.keys(this.partitions)) {
481
- iterationHandler(this.partitions[partition]);
482
- }
574
+ this.partitions.forEach(iterationHandler);
483
575
  }
484
576
 
485
577
  }
486
578
 
487
- module.exports = ReadableStorage;
488
- module.exports.matches = matches;
489
- module.exports.CorruptFileError = Partition.CorruptFileError;
579
+ export default ReadableStorage;
580
+ export { matches };