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
package/README.md
CHANGED
|
@@ -1,430 +1,89 @@
|
|
|
1
|
-
|
|
1
|
+

|
|
2
|
+
|
|
3
|
+
[](https://github.com/albe/node-event-storage/actions/workflows/build.yml)
|
|
4
|
+
[](https://badge.fury.io/js/event-storage)
|
|
2
5
|
[](https://codeclimate.com/github/albe/node-event-storage)
|
|
3
|
-
[](https://coveralls.io/github/albe/node-event-storage?branch=main)
|
|
7
|
+
[](https://inch-ci.org/github/albe/node-event-storage)
|
|
5
8
|
|
|
6
9
|
# node-event-storage
|
|
7
10
|
|
|
8
11
|
An optimized embedded event store for modern node.js, written in ES6.
|
|
9
12
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
# Contents
|
|
13
|
+
๐ **[Full documentation on readthedocs.io](https://node-event-storage.readthedocs.io/en/latest/)**
|
|
13
14
|
|
|
14
|
-
|
|
15
|
-
- [Use cases](#use-cases)
|
|
16
|
-
- [Design goals](#design-goals)
|
|
17
|
-
- [Event storage specifics](#event-storage-and-its-specifics)
|
|
18
|
-
- [Installation](#installation)
|
|
19
|
-
- [Usage](#usage)
|
|
20
|
-
* [Creating additional streams](#creating-additional-streams)
|
|
21
|
-
* [Optimistic concurrency](#optimistic-concurrency)
|
|
22
|
-
- [Consumers](#consumers)
|
|
23
|
-
* [Exactly-once](#exactly-once-semantics)
|
|
24
|
-
- [Read-Only](#read-only)
|
|
25
|
-
- [Implementation details](#implementation-details)
|
|
26
|
-
* [ACID](#acid)
|
|
27
|
-
* [Global order](#global-order)
|
|
28
|
-
* [Event streams](#event-streams)
|
|
29
|
-
* [Partitioning](#partitioning)
|
|
30
|
-
* [Custom serialization](#custom-serialization)
|
|
31
|
-
* [Compression](#compression)
|
|
32
|
-
* [Security](#security)
|
|
15
|
+
---
|
|
33
16
|
|
|
34
17
|
## Why?
|
|
35
18
|
|
|
36
|
-
There is currently only a single embedded event store
|
|
37
|
-
|
|
38
|
-
It is a nice project, but has a few drawbacks though:
|
|
39
|
-
|
|
40
|
-
- its API is fully based around Event Streams, so in order to commit a new event the full existing Event Stream needs to be
|
|
41
|
-
retrieved first. This makes it unfit for client application scenarios that frequently restart the application.
|
|
42
|
-
- it has backends for quite a few existing databases (TingoDB, NeDB, MongoDB, ...), but none of them are optimized for event storage needs
|
|
43
|
-
- the embeddable storage backends (TingoDB, NeDB) do not persist indexes and hence are very slow on initial load
|
|
44
|
-
- it stores event publishing meta information in the events, so it does updates to event data
|
|
45
|
-
- events are fixed onto one stream and it's not possible to create multiple streams that partially contain
|
|
46
|
-
the same events. This makes creating projections hard and/or slow.
|
|
47
|
-
|
|
48
|
-
## Use cases
|
|
49
|
-
|
|
50
|
-
Event sourced client applications running on node.js (electron, node-webkit, etc.).
|
|
51
|
-
Small event sourced single-server applications that want to get near-optimal write performance.
|
|
52
|
-
Using it as queryable log storage.
|
|
53
|
-
|
|
54
|
-
## Design goals
|
|
19
|
+
There is currently only a single other embedded event store for node/javascript: [node-eventstore](https://github.com/adrai/node-eventstore). It has a few drawbacks:
|
|
55
20
|
|
|
56
|
-
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
* read performance should be optimized for sequential read-forward style reads starting at arbitrary position
|
|
60
|
-
* reads should be scalable to as many readers as necessary (but typically one reader per projection)
|
|
61
|
-
* it should be possible to create high number (thousands) of streams without high resource (memory,cpu) usage
|
|
62
|
-
* re-reading (replaying) an arbitrary stream should be optimized for and cost no more than visiting every document in that stream (no full database scan)
|
|
63
|
-
- consistency
|
|
64
|
-
* writes to a single stream need to be able to guarantee consistency (i.e. every write happens only as of the state immediately before that write)
|
|
65
|
-
* reads from a stream need to be consistent every time, i.e. repeatable read isolation (guaranteed order, read-committed for read-only but read-uncommitted/read your own writes for writers)
|
|
66
|
-
- simplicity
|
|
67
|
-
* the architecture and design should be straight-forward, not more complex than dictated by the goals
|
|
68
|
-
* creating new streams (from existing data) should be easily doable with language-level methods
|
|
21
|
+
- Its API requires loading a full Event Stream before committing, making it unfit for frequently-restarting client applications.
|
|
22
|
+
- Its embeddable backends (TingoDB, NeDB) do not persist indexes and are slow on initial load.
|
|
23
|
+
- Events are fixed to one stream โ creating overlapping projection streams is not possible.
|
|
69
24
|
|
|
70
|
-
|
|
25
|
+
**node-event-storage** is built from first principles for append-only workloads, giving you near-optimal write speed with no unnecessary overhead.
|
|
71
26
|
|
|
72
|
-
|
|
73
|
-
- therefore: no network API
|
|
74
|
-
- cross-stream transactions
|
|
75
|
-
- arbitrary querying capabilities - only range scans per stream
|
|
76
|
-
|
|
77
|
-
## Event-Storage and it's specifics
|
|
78
|
-
|
|
79
|
-
The thing that makes event storages stand out (and also makes them simpler and more performant), is that they
|
|
80
|
-
have no concept of overwriting or deleting data. They are purely append-only storages and the only querying is
|
|
81
|
-
sequential (range) reading (possibly with some filtering applied):
|
|
82
|
-
|
|
83
|
-
This means a couple of things:
|
|
84
|
-
|
|
85
|
-
- no write-ahead log or transaction log required - the storage itself is the transaction log!
|
|
86
|
-
- therefore writes are as fast as they can get, but you only can have a single writer (without implementing complex distributed log with RAFT or Paxos)
|
|
87
|
-
- durability comes for free (in complexity) if write caches are avoided
|
|
88
|
-
- reads and writes can happen lock-free, reads don't block writes and are always consistent (natural MVCC)
|
|
89
|
-
- indexes are append-only and hence gain the same benefits
|
|
90
|
-
- since only sequential reading is needed, indexes are simple file position lists - no fancy B+-Tree/fractal tree required
|
|
91
|
-
- indexes are therefore pretty cheap and can be created in high numbers
|
|
92
|
-
- creating backups is easily doable with rsync or by creating file copies on the fly
|
|
93
|
-
|
|
94
|
-
Using any SQL/NoSQL database for storing events therefore is sub-optimal, as those databases do a lot of work on
|
|
95
|
-
top which is simply not needed. Write and read performance suffer.
|
|
27
|
+
---
|
|
96
28
|
|
|
97
29
|
## Installation
|
|
98
30
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
`npm test`
|
|
31
|
+
```bash
|
|
32
|
+
npm install event-storage
|
|
33
|
+
```
|
|
104
34
|
|
|
105
|
-
##
|
|
35
|
+
## Quick Start
|
|
106
36
|
|
|
107
37
|
```javascript
|
|
108
38
|
const EventStore = require('event-storage');
|
|
109
39
|
|
|
110
40
|
const eventstore = new EventStore('my-event-store', { storageDirectory: './data' });
|
|
41
|
+
|
|
111
42
|
eventstore.on('ready', () => {
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
...
|
|
43
|
+
// Write events
|
|
44
|
+
eventstore.commit('my-stream', [{ type: 'SomethingHappened', value: 42 }], 0, () => {
|
|
45
|
+
console.log('Written!');
|
|
116
46
|
});
|
|
117
47
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
48
|
+
// Read events
|
|
49
|
+
const stream = eventstore.getEventStream('my-stream');
|
|
50
|
+
for (const event of stream) {
|
|
51
|
+
console.log(event);
|
|
121
52
|
}
|
|
122
53
|
});
|
|
123
54
|
```
|
|
124
55
|
|
|
125
|
-
|
|
126
|
-
potentially involves other commits to the same stream. See [Optimistic Concurrency](#optimistic-concurrency).
|
|
127
|
-
|
|
128
|
-
### Creating additional streams
|
|
129
|
-
|
|
130
|
-
Create additional streams that contain only part of another stream, or even a combination of events of other streams.
|
|
131
|
-
|
|
132
|
-
```javascript
|
|
133
|
-
...
|
|
134
|
-
let myProjectionStream = eventstore.createStream('my-projection-stream', (event) => ['FooHappened', 'BarHappened'].includes(event.type));
|
|
135
|
-
|
|
136
|
-
for (let event of myProjectionStream) {
|
|
137
|
-
...
|
|
138
|
-
}
|
|
139
|
-
```
|
|
140
|
-
|
|
141
|
-
### Optimistic concurrency
|
|
142
|
-
|
|
143
|
-
Optimistic concurrency is required when multiple sources generate events concurrently.
|
|
144
|
-
|
|
145
|
-
> Note that having the producer of events behind a HTTP interface automatically implies concurrent operation.
|
|
146
|
-
|
|
147
|
-
To handle those cases but still guarantee all those producers can have their own consistent view of the current state,
|
|
148
|
-
you need to track the last `streamVersion` the producer was at when he generated the event, then send that as `expectedVersion`
|
|
149
|
-
with the commit.
|
|
150
|
-
|
|
151
|
-
```javascript
|
|
152
|
-
const model = new MyConsistencyModel();
|
|
153
|
-
const stream = eventstore.getEventStream('my-stream');
|
|
154
|
-
stream.forEach((event, metadata) => {
|
|
155
|
-
model.apply(event);
|
|
156
|
-
});
|
|
157
|
-
const expectedVersion = stream.version;
|
|
158
|
-
// Provide model state and expectedVersion to some state change API or UI that returns a command
|
|
159
|
-
...
|
|
160
|
-
// generate new events from the current model, by applying an incoming command
|
|
161
|
-
const events = model.handle(command.payload);
|
|
162
|
-
try {
|
|
163
|
-
// The expectedVersion is supposed to be given back through the command
|
|
164
|
-
eventstore.commit('my-stream', events, command.expectedVersion, () => {
|
|
165
|
-
...
|
|
166
|
-
});
|
|
167
|
-
} catch (e) {
|
|
168
|
-
if (e instanceof EventStore.OptimisticConcurrencyError) {
|
|
169
|
-
...
|
|
170
|
-
// Reattempt command / resolve conflict
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
```
|
|
174
|
-
|
|
175
|
-
Where `expectedVersion` is either `EventStore.ExpectedVersion.Any` (no optimistic concurrency check, the default),
|
|
176
|
-
`EventStore.ExpectedVersion.EmptyStream` or any version number > 0 that the stream is expected to be at.
|
|
177
|
-
It will throw an OptimisticConcurrencyError if the given stream version does not match the expected.
|
|
178
|
-
In that case you should either signal that back to the upstream source, or replay state and reattempt application
|
|
179
|
-
of the command.
|
|
180
|
-
|
|
181
|
-
### Consumers
|
|
182
|
-
|
|
183
|
-
Consumers are durable event-driven listeners on event streams. They provide at-least-once delivery guarantees,
|
|
184
|
-
meaning that they receive each event in the stream at least once. An event can possibly be delivered twice if
|
|
185
|
-
the program crashed during the handling of an event, since the current position will only be persisted *afterwards*.
|
|
186
|
-
|
|
187
|
-
```javascript
|
|
188
|
-
let myConsumer = eventstore.getConsumer('my-stream', 'my-stream-consumer1');
|
|
189
|
-
myConsumer.on('data', event => {
|
|
190
|
-
// do something with event, but be sure to de-duplicate or have idempotent handling
|
|
191
|
-
});
|
|
192
|
-
```
|
|
193
|
-
|
|
194
|
-
Since a consumer is always bound to a specific stream, you need to create a stream for the specific consumer first,
|
|
195
|
-
if it needs to listen to events from different [write-streams](#event-streams).
|
|
196
|
-
|
|
197
|
-
**Note**
|
|
198
|
-
> The consuming of events will start as soon as a handler for the `data` event is registered and suspended
|
|
199
|
-
> when the last listener is removed.
|
|
200
|
-
|
|
201
|
-
As soon as the consumer has caught up the stream, it will emit a `caught-up` event.
|
|
202
|
-
|
|
203
|
-
#### Exactly-Once semantics
|
|
204
|
-
|
|
205
|
-
Since version 0.6 the consumers can persist their state (a simple JSON object), which allows for achieving
|
|
206
|
-
exactly-once processing semantics relatively easy. What this means is, that the state of the consumer will
|
|
207
|
-
always reflect the state of having each event processed exactly once, because if persisting the state fails,
|
|
208
|
-
the position is also not updated and vice versa.
|
|
209
|
-
|
|
210
|
-
```javascript
|
|
211
|
-
let myConsumer = eventstore.getConsumer('my-stream', 'my-stream-consumer1');
|
|
212
|
-
myConsumer.on('data', event => {
|
|
213
|
-
const newState = { ...myConsumer.state, projectedValue: myConsumer.state.projectedValue + event.someValue };
|
|
214
|
-
myConsumer.setState(newState);
|
|
215
|
-
});
|
|
216
|
-
```
|
|
217
|
-
|
|
218
|
-
This is very useful for projecting some data out of a stream with exactly-once processing without a lot of effort.
|
|
219
|
-
Whenever the state is persisted, the consumer will also emit a `persisted` event.
|
|
220
|
-
|
|
221
|
-
**Note**
|
|
222
|
-
> Never mutate the consumers `state` property directly and only use the `setState` method **inside** the `data` handler.
|
|
223
|
-
|
|
224
|
-
The reason why this works is, that conceptually the state update and the position update happens within a single
|
|
225
|
-
transaction. So anything you can wrap inside a transaction with storing the position yields exactly-once semantics.
|
|
226
|
-
However, for example sending an email exactly once for every event is not achievable with this, because you can't
|
|
227
|
-
wrap a transaction around sending an e-mail and persisting the consumer position in a local file easily.
|
|
228
|
-
|
|
229
|
-
### Read-Only
|
|
230
|
-
|
|
231
|
-
The `EventStore` can also be opened in read-only mode since 0.7, by specifying the constructor option `readOnly: true`.
|
|
232
|
-
In this mode, any writes to the store are prevented, while all reads and consumers work as normal. The read-only storage
|
|
233
|
-
will watch the files that back it and automatically update internal state on changes, so the reader is asynchronously fully
|
|
234
|
-
consistent to the writer state. You can open as many readers as needed and the main use case is to use it for consumers running
|
|
235
|
-
in a different process than the writer. This way, you can have different processes create projections from the events for
|
|
236
|
-
different use cases and serve their state out to other systems, e.g. through an HTTP interface or whatever deems useful.
|
|
237
|
-
|
|
238
|
-
```javascript
|
|
239
|
-
const EventStore = require('event-storage');
|
|
240
|
-
|
|
241
|
-
const eventstore = new EventStore('my-event-store', { storageDirectory: './data', readOnly: true });
|
|
242
|
-
eventstore.on('ready', () => {
|
|
243
|
-
let myConsumer = eventstore.getConsumer('my-stream', 'my-stream-consumer1');
|
|
244
|
-
myConsumer.on('data', event => {
|
|
245
|
-
const newState = { ...myConsumer.state, projectedValue: myConsumer.state.projectedValue + event.someValue };
|
|
246
|
-
myConsumer.setState(newState);
|
|
247
|
-
});
|
|
248
|
-
});
|
|
249
|
-
```
|
|
250
|
-
|
|
251
|
-
In theory, it would even be possible with this, to scale the storage to multiple machines, if they are all backed by a common
|
|
252
|
-
file system. The biggest issue preventing this is, that the nodejs file watcher needs to work on that filesystem.
|
|
253
|
-
See https://nodejs.org/api/fs.html#fs_availability for more information.
|
|
254
|
-
Also, you could rsync the files that back the storage to another machine and have a read-only instance running on that.
|
|
255
|
-
See https://linux.die.net/man/1/rsync and the `--append` option.
|
|
256
|
-
|
|
257
|
-
## Implementation details
|
|
258
|
-
|
|
259
|
-
### ACID
|
|
260
|
-
|
|
261
|
-
> Note: All following explanations talk about a single transaction boundary, which is a single write-stream, AKA a storage partition.
|
|
262
|
-
|
|
263
|
-
The storage engine is not strictly designed to follow ACID semantics. However, it has following properties:
|
|
264
|
-
|
|
265
|
-
#### Atomicity
|
|
266
|
-
|
|
267
|
-
A single document write is guaranteed to be atomic. Unless specifically configured, atomicity spreads to all subsequent
|
|
268
|
-
writes until the write buffer is flushed, which happens either if the current document doesn't fully fit into the write
|
|
269
|
-
buffer or on the next node event loop.
|
|
270
|
-
This can be (ab)used to create a reduced form of transactional behaviour: All writes that happen within a single event loop
|
|
271
|
-
and still fit into the write buffer will all happen together or not at all.
|
|
272
|
-
If strict atomicity for single documents is required, you can configure the option `maxWriteBufferDocuments` to 1, which
|
|
273
|
-
leads to every single document being flushed directly.
|
|
274
|
-
|
|
275
|
-
#### Consistency
|
|
276
|
-
|
|
277
|
-
Since the storage is append-only, consistency is automatically guaranteed.
|
|
278
|
-
|
|
279
|
-
#### Isolation
|
|
280
|
-
|
|
281
|
-
The storage is supposed to only work with a single writer, therefore writes do not influence each other obviously. The single
|
|
282
|
-
writer is only guaranteed with a simple lock-directory mechanic, which works on NFS. This is of course not a hard guarantee, just
|
|
283
|
-
a helper to prevent accidentally opening two writers.
|
|
284
|
-
Reads are guaranteed to be isolated due to the append-only nature and a read only ever seeing writes that have finished
|
|
285
|
-
(not necessarily flushed - i.e. Dirty Reads) at the point of the read. In a read-only instance, dirty reads are technically
|
|
286
|
-
impossible, because the reader has no access to the unfinished writes. Multiple reads can happen without blocking writes.
|
|
287
|
-
|
|
288
|
-
If Dirty Reads are not wanted, they can be disabled with the storage configuration option `dirtyReads` set to false. That
|
|
289
|
-
way you will only ever be able to read back documents that where flushed to disk, even on writers. Note though, that this should
|
|
290
|
-
only be done with in-memory models that keep their own (uncommitted) state, or else you might suffer from inconsistency.
|
|
291
|
-
|
|
292
|
-
There are no lost updates due to the append-only nature. Phantom reads can be prevented by specifying the `maxRevision` for
|
|
293
|
-
streams explicitly (MVCC). All reads are repeatable, as long as no manual truncation happens.
|
|
294
|
-
|
|
295
|
-
#### Durability
|
|
296
|
-
|
|
297
|
-
Durability is not strictly guaranteed due to the used write buffering and flushes not being synced to disk by default.
|
|
298
|
-
All writes happening within a single node event loop and fitting into the write buffer can be lost on application crash.
|
|
299
|
-
Even after flush, the OS and/or disk write buffers can still limit durability guarantees.
|
|
300
|
-
This is a trade-off made for increased write performance and can be more finely configured to needs.
|
|
301
|
-
The write buffer behaviour can be configured with the already mentioned `maxWriteBufferDocuments` and `writeBufferSize`
|
|
302
|
-
options. For strict durability, you can set the option `syncOnFlush` which will sync all flushes to disk before finishing,
|
|
303
|
-
but comes at a very high performance penalty of course.
|
|
56
|
+
## Key Features
|
|
304
57
|
|
|
305
|
-
|
|
58
|
+
| Feature | Summary |
|
|
59
|
+
|---------|---------|
|
|
60
|
+
| **Optimistic concurrency** | Pass `expectedVersion` to `commit()` to guarantee conflict-free writes. |
|
|
61
|
+
| **Flexible stream reading** | Range queries, reverse iteration, and a fluent builder API. |
|
|
62
|
+
| **Derived streams** | Filter or combine events into new read-only streams. |
|
|
63
|
+
| **Stream categories** | Name streams `<category>-<id>` and query the whole category at once. |
|
|
64
|
+
| **Durable consumers** | At-least-once (and exactly-once with `setState`) event delivery with automatic position tracking. |
|
|
65
|
+
| **Consistency guards** | Build aggregates that enforce business invariants with built-in snapshotting. |
|
|
66
|
+
| **Read-only mode** | Open the store from a second process to build projections without touching the writer. |
|
|
67
|
+
| **Crash safety** | Torn writes detected and truncated on startup; automatic index repair via `LOCK_RECLAIM`; bounded, predictable data loss validated by a dedicated stress test. |
|
|
68
|
+
| **Custom serialization** | Plug in msgpack, protobuf, or any other codec. |
|
|
69
|
+
| **Compression** | Apply LZ4, zstd, or any other compression via the `serializer` option. |
|
|
70
|
+
| **Access control hooks** | `preCommit` / `preRead` hooks with per-stream metadata for authorization. |
|
|
306
71
|
|
|
307
|
-
|
|
72
|
+
---
|
|
308
73
|
|
|
309
|
-
|
|
310
|
-
sure that streams that are made up of multiple write-streams will stay consistent when re-reading all events. This has some
|
|
311
|
-
issues though, like not being able to consistently reindex a storage, which is discussed in https://github.com/albe/node-event-storage/issues/24.
|
|
74
|
+
## Documentation
|
|
312
75
|
|
|
313
|
-
|
|
314
|
-
This way, a consistent global order can also be reconsituted without a global index. In a later version, the global index might
|
|
315
|
-
therefore be removed and reindexing a storage be possible, which allows to rebuild a consistent state after a destructive crash.
|
|
76
|
+
The full documentation is hosted at **<https://node-event-storage.readthedocs.io/en/latest/>** and covers:
|
|
316
77
|
|
|
317
|
-
|
|
78
|
+
- [Getting Started](https://node-event-storage.readthedocs.io/en/latest/getting-started/) โ installation, constructor options, basic usage.
|
|
79
|
+
- [Event Streams](https://node-event-storage.readthedocs.io/en/latest/streams/) โ writing, reading, optimistic concurrency, fluent API, joining streams, categories, and event metadata.
|
|
80
|
+
- [Consumers](https://node-event-storage.readthedocs.io/en/latest/consumers/) โ at-least-once and exactly-once delivery, consumer state, consistency guards, and read-only mode.
|
|
81
|
+
- [Advanced Topics](https://node-event-storage.readthedocs.io/en/latest/advanced/) โ ACID properties, reliability and crash-safety guarantees, storage configuration, partitioning, custom serialization, compression, security, and access control hooks.
|
|
318
82
|
|
|
319
|
-
|
|
83
|
+
---
|
|
320
84
|
|
|
321
|
-
|
|
322
|
-
a physical separation of the events that happens on write. An event written to a specific write stream can not be removed
|
|
323
|
-
from it, it can only be linked to from other additional (read) streams.
|
|
324
|
-
|
|
325
|
-
- A read stream is an ordered sequence in which specific events are iterated when reading. Every write stream automatically
|
|
326
|
-
creates a read stream that will iterate the events in the order they were written to that stream. Additional read streams
|
|
327
|
-
can be created that possibly even sequence events from multiple write streams. Such read streams can be deleted without
|
|
328
|
-
problem, since they will not actually delete the events, but just the specific iteration sequence.
|
|
329
|
-
|
|
330
|
-
An Event Stream is implemented as an iterator over an storage index. It is therefore limited to iterating the events at
|
|
331
|
-
the point the Event Stream was retrieved, but can be limited to a specific range of events, denoted by min/max revision.
|
|
332
|
-
It implements the node `ReadableStream` interface.
|
|
333
|
-
|
|
334
|
-
### Partitioning
|
|
335
|
-
|
|
336
|
-
By default, the Event Store is partitioned on (write) streams, so every unique stream name is written to a separate file.
|
|
337
|
-
This has several consequences:
|
|
338
|
-
|
|
339
|
-
- subsequent reads from a single write stream are faster, because the events share more locality
|
|
340
|
-
- every write stream has it's own write and read buffer, hence interleaved writes/reads will not trash the buffers
|
|
341
|
-
- since writes are buffered, only writes within a single write stream will be flushed together, hence "transactionality" is not spread over streams
|
|
342
|
-
- the amount of write streams is limited by the amount of files the filesystem can handle inside a single folder
|
|
343
|
-
- if hard disk is configured for file based RAID, this will most likely lead to unbalanced load
|
|
344
|
-
|
|
345
|
-
If required, the partitioning behaviour can be configured with the `partitioner` option, which is a method with following signature:
|
|
346
|
-
`(string:document, number:sequenceNumber) -> string:partitionName`
|
|
347
|
-
i.e. it maps a document and it's sequence number to a partition name. That way you could for example easily distribute all writes
|
|
348
|
-
equally among a fixed number of arbitrary partitions by doing `(document, sequenceNumber) => 'partition-' + (sequenceNumber % maxPartitions)`.
|
|
349
|
-
This is not recommended in the generic case though, since it contradicts the consistency boundary that a single stream should give.
|
|
350
|
-
Many databases partition the data into Chunks (striding) of a fixed size, which helps with disk performance especially in RAID setups.
|
|
351
|
-
However, since SSDs become more the standard, the benefit of chunking data is becoming more limited. It does help with incremental
|
|
352
|
-
backup strategies, or for use cases where old data needs to be archived or even deleted. For those cases, the partitioner could look
|
|
353
|
-
like `(document, sequenceNumber) -> 'partition' + (sequenceNumber / documentsPerChunk) >> 0`, which will write documents into an ever
|
|
354
|
-
increasing number of partitions. Or you partition by the document timestamp, which for an `EventStore` document could be taken from the `committedAt` field, which is a javascript timestamp. Optimally, you might want to make sure a commit is not spread among partitions though, so those partitioners are not fool-proof.
|
|
355
|
-
|
|
356
|
-
### Custom Serialization
|
|
357
|
-
|
|
358
|
-
By default, the serialization will be achieved through `JSON.stringify` and `JSON.parse`. Those are plenty fast on recent nodejs
|
|
359
|
-
versions, but JSON serialization takes more space than more optimized formats. You could use some other library, like `@msgpack/msgpack`
|
|
360
|
-
to have performant, but space-safing data format. In benchmarks, `@msgpack/msgpack` even turns out faster than `JSON.parse` for
|
|
361
|
-
deserialization and pretty much on par with `JSON.stringify` for serialization. The drawback is that the storage files are no longer
|
|
362
|
-
human readable.
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
```javascript
|
|
366
|
-
const { encode, decode } = require('@msgpack/msgpack');
|
|
367
|
-
const eventstore = new EventStore('my-event-store', {
|
|
368
|
-
storageDirectory: './data',
|
|
369
|
-
storageConfig: {
|
|
370
|
-
serializer: {
|
|
371
|
-
serialize: (doc) => {
|
|
372
|
-
const encoded = encode(doc);
|
|
373
|
-
return Buffer.from(encoded.buffer, encoded.byteOffset, encoded.byteLength).toString('binary');
|
|
374
|
-
},
|
|
375
|
-
deserialize: (string) => {
|
|
376
|
-
return decode(Buffer.from(string, 'binary'));
|
|
377
|
-
}
|
|
378
|
-
}
|
|
379
|
-
}
|
|
380
|
-
});
|
|
381
|
-
```
|
|
382
|
-
|
|
383
|
-
### Compression
|
|
384
|
-
|
|
385
|
-
To apply compression on the storage level, the `serializer` option of the Storage can be used.
|
|
386
|
-
|
|
387
|
-
For example to use LZ4:
|
|
85
|
+
## Run Tests
|
|
388
86
|
|
|
389
|
-
```
|
|
390
|
-
|
|
391
|
-
const eventstore = new EventStore('my-event-store', {
|
|
392
|
-
storageDirectory: './data',
|
|
393
|
-
storageConfig: {
|
|
394
|
-
serializer: {
|
|
395
|
-
serialize: (doc) => {
|
|
396
|
-
return lz4.encode(Buffer.from(JSON.stringify(doc))).toString('binary');
|
|
397
|
-
},
|
|
398
|
-
deserialize: (string) => {
|
|
399
|
-
return JSON.parse(lz4.decode(Buffer.from(string, 'binary')));
|
|
400
|
-
}
|
|
401
|
-
}
|
|
402
|
-
}
|
|
403
|
-
});
|
|
87
|
+
```bash
|
|
88
|
+
npm test
|
|
404
89
|
```
|
|
405
|
-
|
|
406
|
-
Since compression works on a per document level, compression efficiency is reduced. This is currently necessary
|
|
407
|
-
to allow fully random access of single documents without having to read a large block before.
|
|
408
|
-
If available, use a dictionary for the compression library and fill it with common words that describe
|
|
409
|
-
your event/document schema and the following terms:
|
|
410
|
-
|
|
411
|
-
- "metadata":{"commitId":
|
|
412
|
-
- ,"committedAt":
|
|
413
|
-
- ,"commitVersion":
|
|
414
|
-
- ,"commitSize":
|
|
415
|
-
- ,"streamVersion":
|
|
416
|
-
|
|
417
|
-
### Security
|
|
418
|
-
|
|
419
|
-
When specifying a matcher function for streams/indexes those matcher functions will be serialized into the index
|
|
420
|
-
file and be `eval`'d on later loading for convenience to not having to specify the matcher when reopening.
|
|
421
|
-
In order to prevent some malicious attacker from executing arbitrary code in your application by altering an index
|
|
422
|
-
file, the matcher function gets fingerprinted with an HMAC.
|
|
423
|
-
This HMAC is calculated with a secret that you should specify with the `hmacSecret` option of the storage
|
|
424
|
-
configuration.
|
|
425
|
-
|
|
426
|
-
Currently the `hmacSecret` is an optional parameter defaulting to an empty string, which is unsecure, so always
|
|
427
|
-
specify an own unique random secret for this in production.
|
|
428
|
-
|
|
429
|
-
Alternatively you should always explicitly specify your matchers when opening an existing index, since that will
|
|
430
|
-
check that the specified matcher matches the one in the index file.
|
package/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
module.exports =
|
|
1
|
+
module.exports = require('./src/EventStore');
|
|
2
|
+
module.exports.EventStore = module.exports;
|
|
2
3
|
module.exports.EventStream = require('./src/EventStream');
|
|
3
4
|
module.exports.Storage = require('./src/Storage');
|
|
4
5
|
module.exports.Index = require('./src/Index');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "event-storage",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.1",
|
|
4
4
|
"description": "An optimized embedded event store for node.js",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"event-storage",
|
|
@@ -15,31 +15,32 @@
|
|
|
15
15
|
"homepage": "https://github.com/albe/node-event-storage",
|
|
16
16
|
"repository": {
|
|
17
17
|
"type": "git",
|
|
18
|
-
"url": "https://github.com/albe/node-event-storage"
|
|
18
|
+
"url": "git+https://github.com/albe/node-event-storage.git"
|
|
19
19
|
},
|
|
20
20
|
"bugs": {
|
|
21
21
|
"url": "https://github.com/albe/node-event-storage/issues"
|
|
22
22
|
},
|
|
23
23
|
"scripts": {
|
|
24
|
-
"test": "nyc --reporter=
|
|
24
|
+
"test": "nyc --reporter=lcov mocha test/*.spec.js",
|
|
25
25
|
"coverage": "nyc report --reporter=text-lcov | coveralls"
|
|
26
26
|
},
|
|
27
27
|
"files": [
|
|
28
|
-
"
|
|
29
|
-
"
|
|
30
|
-
"
|
|
31
|
-
"
|
|
32
|
-
"
|
|
33
|
-
"
|
|
34
|
-
"
|
|
35
|
-
"
|
|
36
|
-
"
|
|
28
|
+
"src/Consumer*.js",
|
|
29
|
+
"src/EventStore*.js",
|
|
30
|
+
"src/EventStream*.js",
|
|
31
|
+
"src/Index*.js",
|
|
32
|
+
"src/IndexEntry*.js",
|
|
33
|
+
"src/JoinEventStream*.js",
|
|
34
|
+
"src/Partition*.js",
|
|
35
|
+
"src/Storage*.js",
|
|
36
|
+
"src/Watcher*.js",
|
|
37
|
+
"src/Clock*.js",
|
|
37
38
|
"src/Index/*.js",
|
|
38
39
|
"src/Partition/*.js",
|
|
39
40
|
"src/Storage/*.js",
|
|
40
|
-
"src/Clock.js",
|
|
41
41
|
"src/WatchesFile.js",
|
|
42
42
|
"src/util.js",
|
|
43
|
+
"src/metadataUtil.js",
|
|
43
44
|
"index.js"
|
|
44
45
|
],
|
|
45
46
|
"license": "MIT",
|
|
@@ -50,16 +51,24 @@
|
|
|
50
51
|
}
|
|
51
52
|
],
|
|
52
53
|
"engines": {
|
|
53
|
-
"node": ">=
|
|
54
|
+
"node": ">=18.0"
|
|
54
55
|
},
|
|
55
56
|
"dependencies": {
|
|
56
|
-
"mkdirp": "^
|
|
57
|
+
"mkdirp": "^3.0.1"
|
|
58
|
+
},
|
|
59
|
+
"nyc": {
|
|
60
|
+
"include": [
|
|
61
|
+
"src/**/*.js"
|
|
62
|
+
],
|
|
63
|
+
"exclude": [
|
|
64
|
+
"bench/**/*.js"
|
|
65
|
+
]
|
|
57
66
|
},
|
|
58
67
|
"devDependencies": {
|
|
59
|
-
"coveralls": "^
|
|
68
|
+
"coveralls-next": "^6.0.1",
|
|
60
69
|
"expect.js": "^0.3.1",
|
|
61
|
-
"fs-extra": "^
|
|
62
|
-
"mocha": "^7.
|
|
63
|
-
"nyc": "^
|
|
70
|
+
"fs-extra": "^11.3.4",
|
|
71
|
+
"mocha": "^11.7.5",
|
|
72
|
+
"nyc": "^18.0.0"
|
|
64
73
|
}
|
|
65
74
|
}
|
package/src/Clock.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
const TIME_BASE = process.hrtime();
|
|
2
|
-
const
|
|
3
|
-
const
|
|
1
|
+
const TIME_BASE = process.hrtime.bigint();
|
|
2
|
+
const DATE_FACTOR = 1000000n;
|
|
3
|
+
const DATE_BASE_NS = BigInt(Date.now() + 1) * DATE_FACTOR - 1n;
|
|
4
4
|
const CLOCK_ACCURACY_US = 1; // two process.hrtime() calls take roughly this long, so this is the accuracy we can measure time
|
|
5
5
|
|
|
6
6
|
/**
|
|
@@ -14,16 +14,28 @@ class Clock {
|
|
|
14
14
|
* @param {Date|number} epoch The epoch to base this clock on, either as a Date or a number of the amount of milliseconds since the unix epoch
|
|
15
15
|
*/
|
|
16
16
|
constructor(epoch) {
|
|
17
|
-
this.epoch =
|
|
17
|
+
this.epoch = BigInt(epoch instanceof Date ? epoch.getTime() : Number(epoch)) * DATE_FACTOR;
|
|
18
|
+
this.lastTime = 0;
|
|
18
19
|
}
|
|
19
20
|
|
|
20
21
|
/**
|
|
21
|
-
* @returns {number} The number of microseconds since the epoch given in the constructor.
|
|
22
|
-
* @note Needs to allow at least
|
|
22
|
+
* @returns {number} The number of microseconds since the epoch given in the constructor.
|
|
23
|
+
* @note Needs to allow at least tenths of ms accuracy, better hundredths of ms
|
|
23
24
|
*/
|
|
24
25
|
time() {
|
|
25
|
-
const delta = process.hrtime(TIME_BASE
|
|
26
|
-
|
|
26
|
+
const delta = process.hrtime.bigint() - TIME_BASE;
|
|
27
|
+
const timeSinceEpoch = Number((DATE_BASE_NS - this.epoch + delta) / 1000n);
|
|
28
|
+
return this.lastTime = Math.max(this.lastTime + 1, timeSinceEpoch);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Return the clock accuracy of the given timestamp. This is only useful for calculating a consistent ordering
|
|
33
|
+
* ala TrueTime for multi-writer scenarios.
|
|
34
|
+
* @param {number} time A timestamp measured by this clock.
|
|
35
|
+
* @returns {number} The amount of ยตs accuracy this timestamp has.
|
|
36
|
+
*/
|
|
37
|
+
accuracy(time) {
|
|
38
|
+
return CLOCK_ACCURACY_US;
|
|
27
39
|
}
|
|
28
40
|
|
|
29
41
|
}
|