pixl-server-storage 4.1.0 → 4.2.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.
- package/README.md +35 -4
- package/docs/API.md +58 -0
- package/docs/Indexer.md +62 -0
- package/docs/Transactions.md +102 -60
- package/engines/Filesystem.js +59 -27
- package/engines/Hybrid.js +104 -2
- package/engines/Postgres.js +291 -23
- package/engines/Redis.js +262 -28
- package/engines/RedisCluster.js +152 -28
- package/engines/S3.js +53 -25
- package/engines/SQLite.js +253 -21
- package/package.json +1 -1
- package/storage.js +63 -1
- package/test/config.json +4 -0
- package/test/data/backups/backup-2026-06-14-15-49-56.db.gz +0 -0
- package/test/data/backups/backup-2026-06-14-15-58-00.db.gz +0 -0
- package/test/data/backups/backup-2026-06-14-16-29-30.db.gz +0 -0
- package/test/data/backups/backup-2026-06-14-17-29-36.db.gz +0 -0
- package/test/data/backups/backup-2026-06-14-17-31-49.db.gz +0 -0
- package/test/data/backups/backup-2026-06-14-18-02-10.db.gz +0 -0
- package/test/data/backups/backup-2026-06-14-18-02-55.db.gz +0 -0
- package/test/test-main.js +17 -0
- package/test/test.js +59 -38
- package/transaction.js +105 -3
- package/test/data/backups/backup-2026-02-15-18-10-33.db.gz +0 -0
- package/test/data/backups/backup-2026-02-15-18-10-45.db.gz +0 -0
- package/test/data/backups/backup-2026-02-15-18-10-58.db.gz +0 -0
- package/test/data/backups/backup-2026-02-15-18-11-10.db.gz +0 -0
- package/test/data/backups/backup-2026-02-15-18-11-23.db.gz +0 -0
- package/test/data/backups/backup-2026-02-15-18-11-35.db.gz +0 -0
- package/test/data/backups/backup-2026-04-05-20-23-56.db.gz +0 -0
package/docs/Transactions.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
A transaction is an isolated compound operation with atomic commit and rollback. Using transactions you can execute a series of operations without affecting other views of the database until you commit, and then it all happens at once. Commits are an "all or nothing" affair, providing atomicity. They also provide a safe way to automatically rollback to a known good state in case of an error or crash.
|
|
4
4
|
|
|
5
|
-
The transaction system works with any storage engine,
|
|
5
|
+
The transaction system works with any storage engine, including SQLite, S3-compatible object storage, Redis, Postgres and the local filesystem engine.
|
|
6
6
|
|
|
7
7
|
The code examples all assume you have your preloaded `Storage` component instance in a local variable named `storage`. The component instance can be retrieved from a running server like this:
|
|
8
8
|
|
|
@@ -26,6 +26,7 @@ let storage = server.Storage;
|
|
|
26
26
|
* [Temporary Directory](#temporary-directory)
|
|
27
27
|
* [Branch Process](#branch-process)
|
|
28
28
|
* [Commit Process](#commit-process)
|
|
29
|
+
* [Native Engine Commits](#native-engine-commits)
|
|
29
30
|
* [Rollback Process](#rollback-process)
|
|
30
31
|
|
|
31
32
|
## Caveats
|
|
@@ -34,7 +35,7 @@ Transaction locking is advisory. Meaning, transactions will all play nice with
|
|
|
34
35
|
|
|
35
36
|
The transaction system is designed for JSON records only. Binary records are not included as part of a transaction, and operations are silently passed straight through. Meaning, any calls accessing binary records inside of a transaction will result in the original record being changed. This behavior may be changed in a future version.
|
|
36
37
|
|
|
37
|
-
Please note that transactions aren't 100% [ACID compliant](https://en.wikipedia.org/wiki/ACID), but they do follow *most* of the rules.
|
|
38
|
+
Please note that transactions aren't 100% [ACID compliant](https://en.wikipedia.org/wiki/ACID), but they do follow *most* of the rules. The main thing to understand is durability, as in what happens in the event of a sudden power loss, process crash, storage outage or network failure during a commit. When a transaction is committed, by design it may involve changing a large number of records in your configured storage engine. Engines that support native transactions, currently Postgres, Redis and SQLite, can perform the commit using the engine's own atomic transaction system. Other engines use the built-in rollback log system. Both approaches are designed to make commits "all or nothing", but the real world still has teeth. A rollback log may be incomplete or corrupted, or a remote database may lose the connection right as a native commit is being decided. When the system cannot safely determine or restore a known good state, it raises a fatal error rather than continuing with possibly inconsistent storage.
|
|
38
39
|
|
|
39
40
|
I guess the bottom line is, always keep backups of your data!
|
|
40
41
|
|
|
@@ -45,20 +46,25 @@ The transaction system is configured by a few additional properties in the `Stor
|
|
|
45
46
|
| Property Name | Type | Description |
|
|
46
47
|
|---------------|------|-------------|
|
|
47
48
|
| `transactions` | Boolean | Set to `true` to enable transactions (defaults to `false`). |
|
|
48
|
-
| `trans_dir` | String | Path to
|
|
49
|
+
| `trans_dir` | String | Path to local directory for transaction rollback logs and recovery cleanup (see below). |
|
|
49
50
|
| `trans_auto_recover` | Boolean | Automatically recover from fatal errors on startup (see [Recovery](#recovery) below). |
|
|
50
51
|
|
|
51
|
-
Choose your `trans_dir` carefully.
|
|
52
|
+
Choose your `trans_dir` carefully. For engines using the built-in commit path, this directory is used for rollback logs, and those logs are what make crash recovery possible. Transaction record changes are kept in memory until commit time, but rollback logs are written to local disk and synced before the main storage engine is modified. Engines using native commits do not write rollback logs for successful commits, but the transaction directory is still created for consistency and recovery bookkeeping. In modern deployments, the best choice is usually a fast, reliable local path on the same machine as your Node.js process:
|
|
52
53
|
|
|
53
|
-
| Storage
|
|
54
|
-
|
|
55
|
-
|
|
|
56
|
-
|
|
|
57
|
-
|
|
|
54
|
+
| Storage Setup | Recommendation |
|
|
55
|
+
|---------------|----------------|
|
|
56
|
+
| Postgres | Uses native PostgreSQL transactions during commit, so rollback logs are not used for successful commits. You can still set `trans_dir` explicitly for consistency with other engines and future recovery behavior. |
|
|
57
|
+
| SQLite | Uses native SQLite transactions during commit, so rollback logs are not used for successful commits. The default is usually fine, because SQLite exposes a `base_dir`, and transactions will default to a `_transactions` directory inside it. |
|
|
58
|
+
| Redis | Uses native Redis `MULTI` / `EXEC` transactions during commit, so rollback logs are not used for successful commits. You can still set `trans_dir` explicitly for consistency with other engines and future recovery behavior. |
|
|
59
|
+
| RedisCluster | Uses the built-in rollback log system. Set `trans_dir` explicitly to a fast local disk path. |
|
|
60
|
+
| S3 or S3-Compatible | Set `trans_dir` explicitly to a fast local disk path. Do not place rollback logs in object storage. |
|
|
61
|
+
| Local Filesystem | The default is a `_transactions` subdirectory inside your local storage `base_dir`, which is usually a good choice. |
|
|
62
|
+
| Network Filesystem | Do not put `trans_dir` on the network mount. Point it at a local fast SSD mount instead. |
|
|
63
|
+
| Other Remote Engines | Prefer a local disk path on the app server, rather than a remote or shared filesystem. |
|
|
58
64
|
|
|
59
|
-
|
|
65
|
+
If your storage engine exposes a `baseDir` internally, the default `trans_dir` is a `_transactions` subdirectory under that base path. Otherwise, it defaults to a `transactions` directory under the current working directory. For remote engines like S3-compatible storage, it is usually best to set this explicitly, so recovery-critical rollback logs are kept somewhere predictable, local and fast.
|
|
60
66
|
|
|
61
|
-
**Pro-Tip:** When using transactions with the Amazon S3 engine, consider setting the `maxAttempts` property in your `S3` configuration object, so the AWS client library makes multiple attempts before failing an operation. Network hiccups can happen.
|
|
67
|
+
**Pro-Tip:** When using transactions with the Amazon S3 engine or an S3-compatible service, consider setting the `maxAttempts` property in your `S3` configuration object, so the AWS client library makes multiple attempts before failing an operation. Network hiccups can happen.
|
|
62
68
|
|
|
63
69
|
## Basic Use
|
|
64
70
|
|
|
@@ -178,9 +184,11 @@ storage.begin( 'some_path', function(err, trans) {
|
|
|
178
184
|
});
|
|
179
185
|
```
|
|
180
186
|
|
|
181
|
-
It should be noted that if you receive an error from a [commit()](API.md#commit) call, it is *vital* that you call [abort()](API.md#abort) to undo whatever operations may have already executed. A commit error is typically very bad, and your storage system
|
|
187
|
+
It should be noted that if you receive an error from a [commit()](API.md#commit) call, it is *vital* that you call [abort()](API.md#abort) to undo whatever operations may have already executed. A commit error is typically very bad, and your storage system may be in an unknown state. Only by calling [abort()](API.md#abort) can you restore it to before the transaction started.
|
|
182
188
|
|
|
183
|
-
If the [abort()](API.md#abort)
|
|
189
|
+
For engines that support native transactions, currently Postgres, Redis and SQLite, the engine commit path replaces the rollback log system entirely. If the engine can safely reject or roll back a failed native commit, the error is returned to your application. You should still call [abort()](API.md#abort), because it cleans up the outer transaction metadata and releases locks. If the native engine detects an unsafe outcome, such as a connection failure while the commit decision is in progress or a failed database rollback, the storage system raises a fatal error immediately instead of returning a normal commit error.
|
|
190
|
+
|
|
191
|
+
If the [abort()](API.md#abort) also fails, then the database raises a fatal error and exits immediately. See [Emergency Shutdown](#emergency-shutdown) below for details about what this means, and [Recovery](#recovery) for how to get back up and running. Examples of fatal errors include your disk running completely out of space, a SQLite write failure, or a major network failure when using a remote storage engine such as S3-compatible object storage.
|
|
184
192
|
|
|
185
193
|
## Automatic API Wrappers
|
|
186
194
|
|
|
@@ -215,7 +223,11 @@ You can still use these methods inside of your own transaction, and they will "j
|
|
|
215
223
|
|
|
216
224
|
## Emergency Shutdown
|
|
217
225
|
|
|
218
|
-
If a fatal storage error is encountered in the middle of an abort (rollback) operation, the database immediately shuts itself down. This is because your data will be in an undefined state, stuck in the middle of partial transaction. This should be a very rare event, only occurring when the underlying storage completely fails to write records. Examples include a disk running out of space
|
|
226
|
+
If a fatal storage error is encountered in the middle of an abort (rollback) operation, the database immediately shuts itself down. This is because your data will be in an undefined state, stuck in the middle of partial transaction. This should be a very rare event, only occurring when the underlying storage completely fails to write records. Examples include a disk running out of space, a local database write failure, or a hard network failure with a remote storage engine.
|
|
227
|
+
|
|
228
|
+
A fatal error can also be raised by a native engine commit path. For example, Postgres can report a failure while the client is waiting for `COMMIT`, or it can fail while attempting to roll back a database transaction after an earlier commit error. In these cases the application may not be able to prove whether the database committed or rolled back, so the safest response is to stop immediately and let an operator inspect the situation.
|
|
229
|
+
|
|
230
|
+
When this happens, the Node.js process will exit (by default), and may need to be recovered or investigated before restart (see [Recovery](#recovery) below).
|
|
219
231
|
|
|
220
232
|
Upon fatal error, your application can hook the event and provide its own emergency shutdown procedure. Simply add an event listener on the storage object for the `fatal` event. It will be passed an `Error` object describing the actual error that occurred. It is then up to your code to call `process.exit()`. Example:
|
|
221
233
|
|
|
@@ -239,7 +251,7 @@ Please start it in recovery mode by issuing this command:
|
|
|
239
251
|
/path/to/your/app/start/cmd.js --recover
|
|
240
252
|
```
|
|
241
253
|
|
|
242
|
-
Adding the `--recover` command-line flag allows it to continue starting up, and then abort (rollback) any transactions that were active when it died. It switches into "debug" mode during recovery (i.e. skips the background daemon fork), and echoes the main event log to the console, so you can see exactly what is happening. When it is complete, another message will be printed to the console, and it will exit again:
|
|
254
|
+
Adding the `--recover` command-line flag allows it to continue starting up, and then abort (rollback) any transactions that were active when it died. For the built-in rollback-log commit path, this means locating leftover rollback logs and replaying them. For a native engine transaction that failed before a durable commit decision, the database engine should already have rejected or rolled back the transaction, and startup recovery may have no rollback log to process. It switches into "debug" mode during recovery (i.e. skips the background daemon fork), and echoes the main event log to the console, so you can see exactly what is happening. When it is complete, another message will be printed to the console, and it will exit again:
|
|
243
255
|
|
|
244
256
|
```
|
|
245
257
|
Database recovery is complete. Please see logs/recovery.log for full details.
|
|
@@ -267,19 +279,19 @@ if (this.server.Storage.recovery_count) {
|
|
|
267
279
|
|
|
268
280
|
## Transaction Internals
|
|
269
281
|
|
|
270
|
-
The transaction system is implemented by temporarily "branching" the storage system into an isolated object
|
|
282
|
+
The transaction system is implemented by temporarily "branching" the storage system into an isolated object. Any JSON writes performed on the branch are stored in memory, and any JSON deletes are tracked in memory as well. Nothing is persisted to the main storage engine until the transaction is committed. At commit time the branch is "merged" back into main storage. Depending on the storage engine, the merge is protected either by pixl-server-storage's rollback log system, or by the engine's own native transaction system.
|
|
271
283
|
|
|
272
284
|
### Temporary Directory
|
|
273
285
|
|
|
274
|
-
A local
|
|
286
|
+
A local transaction directory on disk is created regardless of the storage engine. Modified JSON records live in memory until commit time. For engines using the built-in commit path, the directory is mainly used for rollback logs, which are written and synced before any changes are applied to primary storage. Engines using native commit support do not write rollback logs during successful commits. You can control where this directory lives by setting the `trans_dir` configuration property.
|
|
275
287
|
|
|
276
|
-
Inside the
|
|
288
|
+
Inside the transaction directory, two subdirectories are created: `data` and `logs`. The `logs` directory is the important one for engines using rollback logs. When a transaction is committed through that path, the original state of the mutated records is written to a rollback log, so it can be applied in reverse during a recovery event. The `data` directory is created for maintenance and recovery cleanup, but current JSON transaction writes do not use it. Engines that provide native transaction commits, currently Postgres, Redis and SQLite, do not write rollback logs for their normal commit path.
|
|
277
289
|
|
|
278
290
|
### Branch Process
|
|
279
291
|
|
|
280
|
-
When a transaction is first started, the only thing that happens is a unique ID is assigned, and a lock is obtained.
|
|
292
|
+
When a transaction is first started, the only thing that happens is a unique sequential ID is assigned, and a lock is obtained. The lock is based on the path that is passed into [begin()](API.md#begin). Nothing is written to disk at this point.
|
|
281
293
|
|
|
282
|
-
Each record that is mutated (created, updated or deleted) inside a transaction is
|
|
294
|
+
Each JSON record that is mutated (created, updated or deleted) inside a transaction is tracked in memory. The transaction keeps a `keys` ledger, which maps each touched key to a "state", representing a written or deleted record. Written records also have their full JSON value stored in a `values` hash in memory. Deleted records are marked in the ledger only.
|
|
283
295
|
|
|
284
296
|
For example, consider a transaction that begins with path `test1`...
|
|
285
297
|
|
|
@@ -289,7 +301,7 @@ storage.begin( 'test1', function(err, trans) {
|
|
|
289
301
|
} );
|
|
290
302
|
```
|
|
291
303
|
|
|
292
|
-
The transaction ID will be
|
|
304
|
+
The transaction ID will be a sequential number such as `1`. Now, let's say inside our transaction we store a record with key `test1/record1`...
|
|
293
305
|
|
|
294
306
|
```js
|
|
295
307
|
trans.put( 'test1/record1', { foo: 12345 }, function(err) {
|
|
@@ -297,34 +309,36 @@ trans.put( 'test1/record1', { foo: 12345 }, function(err) {
|
|
|
297
309
|
} );
|
|
298
310
|
```
|
|
299
311
|
|
|
300
|
-
So at this point we wrote the `test1/record1` record using our `trans` object, but nothing has happened in the outer storage system (i.e. nothing was written to the primary storage engine). Instead, the
|
|
301
|
-
|
|
302
|
-
```
|
|
303
|
-
/tmp/db/data/5a105e8b9d40e1329780d62ea2265d8a-0d1454fd1bcdc024acedcbe5cfff4ffd.json
|
|
304
|
-
```
|
|
305
|
-
|
|
306
|
-
The temp filename is made up of the transaction ID (`5a105e8b9d40e1329780d62ea2265d8a`) and the MD5 hash of the record key (`0d1454fd1bcdc024acedcbe5cfff4ffd`). This is to insure it will not collide with any other records in any other concurrent transactions, doesn't require any further subdirectories, and we can easily retrieve it at commit time if we know the plain key.
|
|
312
|
+
So at this point we wrote the `test1/record1` record using our `trans` object, but nothing has happened in the outer storage system (i.e. nothing was written to the primary storage engine). Instead, the JSON value is kept in memory as part of the active transaction, along with its modification time and byte length.
|
|
307
313
|
|
|
308
314
|
It should be noted that in these examples our record keys are *under* the `test1` base transaction path, e.g. `test1/record1`. This is not required, as any record anywhere in storage can be read, written or deleted inside a transaction. However, it keeps things much cleaner if you design your storage key layout in this way, especially with locking being completely advisory. You want to ensure that your own application keeps its transaction-enabled records separate from non-transaction records (if any). You can do this with a base key path like `test1/...` or some other method -- the storage system cares not.
|
|
309
315
|
|
|
310
|
-
The fact that we wrote to `test1/record1` is also noted in memory, in the transactions hash.
|
|
316
|
+
The fact that we wrote to `test1/record1` is also noted in memory, in the transactions hash. Active transactions are keyed by the transaction base path, and each transaction has both a `keys` ledger and a `values` hash for written records. Here is a simplified internal representation:
|
|
311
317
|
|
|
312
318
|
```js
|
|
313
319
|
"transactions": {
|
|
314
|
-
"
|
|
315
|
-
"id": "
|
|
316
|
-
"path": "test1",
|
|
317
|
-
"
|
|
320
|
+
"test1": {
|
|
321
|
+
"id": "1",
|
|
322
|
+
"path": "test1",
|
|
323
|
+
"log": "/tmp/db/logs/9343-1.log",
|
|
324
|
+
"date": 1514260380.343,
|
|
318
325
|
"pid": 9343,
|
|
319
326
|
"keys": {
|
|
320
327
|
"test1/record1": "W"
|
|
321
328
|
},
|
|
329
|
+
"values": {
|
|
330
|
+
"test1/record1": {
|
|
331
|
+
"mod": 1514260380.456,
|
|
332
|
+
"len": 13,
|
|
333
|
+
"data": { "foo": 12345 }
|
|
334
|
+
}
|
|
335
|
+
},
|
|
322
336
|
"queue": []
|
|
323
337
|
}
|
|
324
338
|
}
|
|
325
339
|
```
|
|
326
340
|
|
|
327
|
-
In this case the state of the record is `W`, indicating "written". If we deleted the record inside the transaction, the state would be `D`. This ledger keeps track of all mutations, and is used to perform the actual commit.
|
|
341
|
+
In this case the state of the record is `W`, indicating "written". If we deleted the record inside the transaction, the state would be `D`. This ledger keeps track of all mutations, and is used to perform the actual commit. The `values` hash holds a copy of the JSON data for all records currently in the `W` state.
|
|
328
342
|
|
|
329
343
|
Let's also delete a record just to see the internal representation. Assuming `test1/deleteme` already existed before we started the transaction, we can do this:
|
|
330
344
|
|
|
@@ -334,7 +348,7 @@ trans.delete( 'test1/deleteme', function(err) {
|
|
|
334
348
|
} );
|
|
335
349
|
```
|
|
336
350
|
|
|
337
|
-
This
|
|
351
|
+
This does not write anything to disk, but we do have to make a note in the transactions hash about the state of the deleted record:
|
|
338
352
|
|
|
339
353
|
```js
|
|
340
354
|
"keys": {
|
|
@@ -343,9 +357,9 @@ This will not result in any temp file created on disk (although it will delete o
|
|
|
343
357
|
},
|
|
344
358
|
```
|
|
345
359
|
|
|
346
|
-
So now our transaction contains two mutations (operations). One record written
|
|
360
|
+
So now our transaction contains two mutations (operations). One record written in memory, and one record deleted in memory. Still, outside the transaction nothing has happened. The `test1/deleteme` record still exists.
|
|
347
361
|
|
|
348
|
-
It is important to note that
|
|
362
|
+
It is important to note that active transaction writes are kept in RAM until commit time. If you plan to issue large transactions, make sure your Node.js process has enough memory allocated to hold all pending writes until commit. For the built-in commit path, the rollback log is written to disk during commit, and none of the in-memory transaction metadata is required for crash recovery once the rollback log exists. For native engine commits, the in-memory transaction metadata is passed to the engine at commit time, and the engine is responsible for applying the final set of changes atomically.
|
|
349
363
|
|
|
350
364
|
Now let's see what happens if we fetch our `test1/record1` key, still inside the transaction and using the `trans` object...
|
|
351
365
|
|
|
@@ -356,7 +370,7 @@ trans.get( 'test1/record1', function(err, data) {
|
|
|
356
370
|
} );
|
|
357
371
|
```
|
|
358
372
|
|
|
359
|
-
Internally, the [get()](API.md#get) method is overridden, and the transaction layer intercepts the call. First, we check the ledger, to see if the `test1/record1` key was branched, and in this case it was. So instead of hitting primary storage, we
|
|
373
|
+
Internally, the [get()](API.md#get) method is overridden, and the transaction layer intercepts the call. First, we check the ledger, to see if the `test1/record1` key was branched, and in this case it was. So instead of hitting primary storage, we return a copy of the in-memory value from the transaction, since the state is `W`. If the state is `D` (deleted inside transaction), then a `NoSuchKey` error is returned, just as if the record didn't exist in primary storage. Alternatively, if `test1/record1` simply isn't in the transaction ledger, the operation falls back to normal storage.
|
|
360
374
|
|
|
361
375
|
Similarly, if we try to fetch `test1/deleteme` using `trans`, we get an error:
|
|
362
376
|
|
|
@@ -369,29 +383,34 @@ trans.get( 'test1/deleteme', function(err, data) {
|
|
|
369
383
|
|
|
370
384
|
Even though the real `test1/deleteme` record still exists, our transaction "simulates" a deleted record by returning an error for the overridden [get()](API.md#get) method.
|
|
371
385
|
|
|
372
|
-
In this way, any records mutated inside the transaction are "branched"
|
|
386
|
+
In this way, any records mutated inside the transaction are "branched" in memory and kept isolated until the transaction needs to be "merged" (committed), or possibly just discarded (transaction aborted before commit).
|
|
373
387
|
|
|
374
388
|
### Commit Process
|
|
375
389
|
|
|
376
|
-
The commit process basically replays all the transaction operations on primary storage, effectively "merging the branch".
|
|
390
|
+
The commit process basically replays all the transaction operations on primary storage, effectively "merging the branch". The outer transaction system always owns the branch, the advisory locks, the in-memory ledger, and the public `commitStart` / `commitEnd` events. The actual storage commit can happen in one of two ways:
|
|
391
|
+
|
|
392
|
+
- If the engine provides a native `commitTransaction()` method, pixl-server-storage hands the full transaction object to the engine and lets it perform the durable commit atomically.
|
|
393
|
+
- Otherwise, pixl-server-storage uses its built-in rollback log system, described below.
|
|
394
|
+
|
|
395
|
+
The built-in commit path must be able to survive a sudden crash or power loss at *any point* and still allow for full recovery, i.e. a clean rollback to the previous state. To do this, we rely on a local rollback log, and of course [fsync](http://man7.org/linux/man-pages/man2/fsync.2.html), to insure the log is actually written to physical disk (and not in the OS cache).
|
|
377
396
|
|
|
378
|
-
The log will contain the original JSON contents of all the records that will be mutated by the transaction. It is basically a "snapshot" of the previous state, before we start the commit. The log goes into the `logs` subdirectory under our `trans_dir`
|
|
397
|
+
The log will contain the original JSON contents of all the records that will be mutated by the transaction. It is basically a "snapshot" of the previous state, before we start the commit. The log goes into the `logs` subdirectory under our `trans_dir` transaction directory, and is named using the process ID and transaction ID:
|
|
379
398
|
|
|
380
399
|
```
|
|
381
|
-
/tmp/db/logs/
|
|
400
|
+
/tmp/db/logs/9343-1.log
|
|
382
401
|
```
|
|
383
402
|
|
|
384
403
|
The log file format is plain text with line-delimited JSON for the records. The first line is a header record that describes the transaction. Here is an example log:
|
|
385
404
|
|
|
386
405
|
```
|
|
387
|
-
{"id":"
|
|
388
|
-
{"key":"test1/record1","value":
|
|
389
|
-
{"key":"test1/deleteme","value":
|
|
406
|
+
{"id":"1","path":"test1","log":"/tmp/db/logs/9343-1.log","date":1514260380.343,"pid":9343}
|
|
407
|
+
{"key":"test1/record1","value":0}
|
|
408
|
+
{"key":"test1/deleteme","value":{"old":true}}
|
|
390
409
|
```
|
|
391
410
|
|
|
392
|
-
The header record is just a copy of the in-memory transaction metadata, minus the verbose key map and queue. It's
|
|
411
|
+
The header record is just a copy of the in-memory transaction metadata, minus the verbose key map, in-memory values and queue. It's used to identify the transaction during recovery, and for debugging purposes. The rest of the lines are for all the mutated records. Each record is wrapped in an outer JSON with a `key` and `value` property. The `value` is the original record JSON contents from before the commit began. If the record did not exist before the transaction, then the value is set to `0`, as shown for `test1/record1` above. If a record is being deleted, and it existed before the transaction, then its original JSON value is written to the log so rollback can restore it.
|
|
393
412
|
|
|
394
|
-
So here is the commit process, step by step:
|
|
413
|
+
So here is the built-in rollback-log commit process, step by step:
|
|
395
414
|
|
|
396
415
|
- Write rollback log to local disk (see above).
|
|
397
416
|
- Call [fsync](http://man7.org/linux/man-pages/man2/fsync.2.html) on the rollback log file, to flush it to physical disk.
|
|
@@ -399,36 +418,59 @@ So here is the commit process, step by step:
|
|
|
399
418
|
- See the [fsync manpage](http://man7.org/linux/man-pages/man2/fsync.2.html) for details.
|
|
400
419
|
- Apply all transaction record changes to primary storage, as fast as possible.
|
|
401
420
|
- This uses the storage [concurrency](../README.md#concurrency) configuration setting for parallelization.
|
|
402
|
-
-
|
|
403
|
-
-
|
|
404
|
-
|
|
405
|
-
- This safety mechanism probably only has a real effect on local ext3/ext4 filesystems.
|
|
421
|
+
- Written records are applied from the transaction's in-memory `values` hash.
|
|
422
|
+
- Deleted records are deleted directly from primary storage.
|
|
423
|
+
- If the storage engine provides a `sync()` method, enqueue post-commit sync calls for all written keys.
|
|
406
424
|
- Delete the rollback log.
|
|
407
425
|
- Remove transaction metadata from memory.
|
|
408
426
|
- Release the lock on the transaction base path.
|
|
409
427
|
|
|
410
|
-
The idea here is that no matter where a crash or sudden power loss occurs during
|
|
428
|
+
The idea here is that no matter where a crash or sudden power loss occurs during this built-in commit process, a full rollback can be attempted. The actual changes on the main storage system are only made once the rollback log is fully written and flushed to disk, and the log itself is only deleted when the record changes are also made and synced where possible.
|
|
411
429
|
|
|
412
|
-
This is not a 100% guarantee of data durability, as corruption can happen during a crash or power loss. The rollback log may only be partially written, or corrupted in some way where a replay is not possible. This can happen because fsync calls are not guaranteed on all operating systems, filesystems or disk media (e.g. certain SSDs), and
|
|
430
|
+
This is not a 100% guarantee of data durability, as corruption can happen during a crash or power loss. The rollback log may only be partially written, or corrupted in some way where a replay is not possible. This can happen because fsync calls are not guaranteed on all operating systems, filesystems or disk media (e.g. certain SSDs), and remote storage engines may fail independently of the local process. For example, an S3-compatible service may accept some writes and fail others during a network or service outage.
|
|
413
431
|
|
|
414
432
|
Basically it's all just a big crap shoot, but we're trying to cover as many error cases as possible.
|
|
415
433
|
|
|
434
|
+
### Native Engine Commits
|
|
435
|
+
|
|
436
|
+
Some storage engines can provide their own atomic commit support. When this is available, the engine's native transaction system replaces the built-in rollback-log commit path entirely. The outer pixl-server-storage transaction still works the same way while the transaction is active: writes and deletes are kept in memory, reads see the branched view, and nothing is sent to primary storage until commit time.
|
|
437
|
+
|
|
438
|
+
At commit time, the outer transaction layer detects the engine hook and calls the engine's `commitTransaction()` method, passing the full transaction object. No rollback log is written, no rollback log directory is synced, and no local disk commit log is used for this path. The engine is responsible for applying the final set of record writes and deletes atomically.
|
|
439
|
+
|
|
440
|
+
The following engines currently support native commits:
|
|
441
|
+
|
|
442
|
+
- **Postgres** checks out one client from the `pg.Pool`, starts a PostgreSQL transaction with `BEGIN`, applies all final deletes and writes using set-based SQL statements, and then issues `COMMIT`. The single checked-out client is important, because PostgreSQL transactions are connection-scoped. The bulk SQL statements are important too, because they avoid one network round trip per record during commit.
|
|
443
|
+
- **Redis** queues all final writes and deletes into an ioredis `MULTI` / `EXEC` transaction. Redis executes the queued commands atomically as a single transaction on the Redis server, and pixl-server-storage updates its own caches only after `EXEC` succeeds.
|
|
444
|
+
- **SQLite** uses the `better-sqlite3` native transaction wrapper. The engine replays all final writes and deletes inside one SQLite transaction, so local multi-record commits are protected by SQLite's own transaction machinery.
|
|
445
|
+
|
|
446
|
+
The `Hybrid` engine can use native commits too, but only through its configured `docEngine`. This works because pixl-server-storage transactions only include JSON records. Binary keys are passed straight through outside the transaction system, so the `binaryEngine` is not part of the commit. If the `docEngine` supports native commits, `Hybrid` exposes the native commit hook automatically. If the `docEngine` does not support native commits, `Hybrid` uses the built-in rollback log path.
|
|
447
|
+
|
|
448
|
+
RedisCluster intentionally still uses the built-in rollback log path. Redis Cluster transactions are only practical when all involved keys land in the same hash slot and on the same node. pixl-server-storage transactions can touch many keys across a whole logical data structure, and those keys may be distributed across the cluster. The rollback log path keeps RedisCluster behavior general and predictable without requiring special key-slot constraints.
|
|
449
|
+
|
|
450
|
+
If a native engine statement fails before the final commit decision, the engine returns an error to the outer transaction system if it can safely reject or roll back the native transaction. Your application should still call [abort()](API.md#abort) on the transaction object, so pixl-server-storage can discard the in-memory branch and release its locks.
|
|
451
|
+
|
|
452
|
+
If a native engine reports an error while the final commit decision is in progress, or if a database rollback itself fails, the engine marks the error as fatal. This is because the application may not be able to prove whether the database committed or rolled back, and there is no pixl-server-storage rollback log for this path. The outer transaction system responds by raising a fatal storage error immediately.
|
|
453
|
+
|
|
454
|
+
After a successful native commit, pixl-server-storage updates its own in-process RAM cache, emits the usual per-record `put` / `delete` events, writes the normal transaction log entries, removes the transaction metadata from memory, and releases the transaction locks. This keeps application-facing behavior consistent with engines that use the built-in rollback-log path.
|
|
455
|
+
|
|
416
456
|
### Rollback Process
|
|
417
457
|
|
|
418
458
|
There are two types of aborted transaction: one that happens before the commit (easy & safe), and one that happens as a result of an error *during* the commit (difficult & dangerous). Let's take the easy one first.
|
|
419
459
|
|
|
420
|
-
When a transaction is aborted *before* commit, there is really nothing to roll back. There is no rollback log, and nothing has touched primary storage yet. Instead, we simply need to clean things up.
|
|
460
|
+
When a transaction is aborted *before* commit, there is really nothing to roll back. There is no rollback log, and nothing has touched primary storage yet. Instead, we simply need to clean things up. The in-memory key ledger, values hash and queue are discarded, and the lock is released. This kind of abort is typically user-driven, by calling [abort()](API.md#abort) in your application code. It is typically quite safe, low risk and non-destructive to primary storage.
|
|
421
461
|
|
|
422
|
-
The other type of abort -- one that occurs as a result of an error, crash or power loss *during* a commit -- is more involved. Here we have to basically "replay" the rollback log, and restore records to their original state.
|
|
462
|
+
The other type of abort -- one that occurs as a result of an error, crash or power loss *during* a built-in rollback-log commit -- is more involved. Here we have to basically "replay" the rollback log, and restore records to their original state.
|
|
423
463
|
|
|
424
|
-
The first step is to locate the rollback log. It is named using the transaction ID
|
|
464
|
+
The first step is to locate the rollback log. It is named using the process ID and transaction ID, so we can easily find it if we are rolling back an active transaction in the same process (i.e. non-crash rollback). However, for a full crash recovery, any and all leftover rollback logs are globbed and processed in reverse order.
|
|
425
465
|
|
|
426
|
-
The rollback log is basically line-delimited JSON records, so we just have to open the file, and iterate over it, line by line. Generally we skip over the first line (the file header), which is just metadata about the transaction. This is only needed during a full crash recovery (see below). Next, we process each line, which is a record to be restored to the specified JSON state, or deleted.
|
|
466
|
+
The rollback log is basically line-delimited JSON records, so we just have to open the file, and iterate over it, line by line. Generally we skip over the first line (the file header), which is just metadata about the transaction. This is only needed during a full crash recovery (see below). Next, we process each line, which is a record to be restored to the specified JSON state, or deleted. Rollback uses the log contents directly, because all the JSON record data is contained within the rollback log itself (this is why only JSON records are supported for transactions, and not binary records).
|
|
427
467
|
|
|
428
468
|
As noted above, during a full crash recovery we have no transaction metadata in memory (with the transaction ID, log file path, etc.). To restore this internal state, we use the rollback log header (i.e. the first line). This special JSON record is used to restore the internal metadata, so we have an active transaction that we are recovering.
|
|
429
469
|
|
|
430
|
-
The second phase of the rollback is cleanup.
|
|
470
|
+
The second phase of the rollback is cleanup. The rollback log itself is deleted, the in-memory transaction metadata is discarded, and the original lock is released (if applicable). During startup recovery, the system also clears the transaction `data` directory.
|
|
471
|
+
|
|
472
|
+
At this point storage has been restored to the state just before the commit, and everything should be happy.
|
|
431
473
|
|
|
432
|
-
|
|
474
|
+
For a native engine commit, rollback is handled differently. If the engine can prove that its native transaction was rejected or rolled back, then pixl-server-storage only needs to discard its in-memory transaction branch and release locks. If the engine cannot prove this, such as a connection failure during the final commit decision or a failed database rollback, the engine marks the error as fatal and pixl-server-storage shuts down immediately.
|
|
433
475
|
|
|
434
|
-
Note that if a transaction abort operation fails due to a storage write failure, this is considered fatal. The database immediately shuts down and issues a `fatal` error event. We have to give up here because storage will be in an undefined state, and we should not attempt any further operations before a user addresses the underlying issue. This is typically a disk that ran out of space,
|
|
476
|
+
Note that if a transaction abort operation fails due to a storage write failure, this is considered fatal. The database immediately shuts down and issues a `fatal` error event. We have to give up here because storage will be in an undefined state, and we should not attempt any further operations before a user addresses the underlying issue. This is typically a disk that ran out of space, a local database write failure, or a persistent remote storage error.
|
package/engines/Filesystem.js
CHANGED
|
@@ -215,7 +215,7 @@ module.exports = Class.create({
|
|
|
215
215
|
}
|
|
216
216
|
else {
|
|
217
217
|
this.logDebug(9, "Storing JSON Object: " + key, this.debugLevel(10) ? value : file);
|
|
218
|
-
value = this.pretty ? JSON.stringify( value, null, "\t" ) : JSON.stringify( value );
|
|
218
|
+
value = Buffer.from( this.pretty ? JSON.stringify( value, null, "\t" ) : JSON.stringify( value ) );
|
|
219
219
|
}
|
|
220
220
|
|
|
221
221
|
var dir = path.dirname( file );
|
|
@@ -378,13 +378,22 @@ module.exports = Class.create({
|
|
|
378
378
|
if (this.cache && this.cache.has(key)) {
|
|
379
379
|
var item = this.cache.getMeta(key);
|
|
380
380
|
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
381
|
+
if (item.value.length == 0) {
|
|
382
|
+
process.nextTick( function() {
|
|
383
|
+
var err = new Error("Failed to head key: " + key + ": File not found");
|
|
384
|
+
err.code = "NoSuchKey";
|
|
385
|
+
callback( err );
|
|
386
386
|
} );
|
|
387
|
-
}
|
|
387
|
+
}
|
|
388
|
+
else {
|
|
389
|
+
process.nextTick( function() {
|
|
390
|
+
self.logDebug(9, "Cached head complete: " + key);
|
|
391
|
+
callback( null, {
|
|
392
|
+
mod: item.date,
|
|
393
|
+
len: item.value.length
|
|
394
|
+
} );
|
|
395
|
+
} );
|
|
396
|
+
}
|
|
388
397
|
return;
|
|
389
398
|
} // cache
|
|
390
399
|
|
|
@@ -393,6 +402,11 @@ module.exports = Class.create({
|
|
|
393
402
|
if (err.message.match(/ENOENT/)) {
|
|
394
403
|
err.message = "File not found";
|
|
395
404
|
err.code = "NoSuchKey";
|
|
405
|
+
|
|
406
|
+
if (self.cache && !self.storage.isBinaryKey(key)) {
|
|
407
|
+
// store 'empty' stub in cache
|
|
408
|
+
self.cache.set( key, Buffer.alloc(0), { date: 0 } );
|
|
409
|
+
}
|
|
396
410
|
}
|
|
397
411
|
else {
|
|
398
412
|
// log fs errors that aren't simple missing files (i.e. I/O errors)
|
|
@@ -421,30 +435,42 @@ module.exports = Class.create({
|
|
|
421
435
|
|
|
422
436
|
// check cache first
|
|
423
437
|
if (this.cache && !is_binary && this.cache.has(key)) {
|
|
424
|
-
var
|
|
438
|
+
var item = this.cache.getMeta(key);
|
|
425
439
|
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
callback(
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
440
|
+
if (item.value.length == 0) {
|
|
441
|
+
process.nextTick( function() {
|
|
442
|
+
var err = new Error("Failed to fetch key: " + key + ": File not found");
|
|
443
|
+
err.code = "NoSuchKey";
|
|
444
|
+
callback( err );
|
|
445
|
+
} );
|
|
446
|
+
}
|
|
447
|
+
else {
|
|
448
|
+
process.nextTick( function() {
|
|
449
|
+
var data = null;
|
|
450
|
+
try { data = JSON.parse( item.value.toString() ); }
|
|
451
|
+
catch (e) {
|
|
452
|
+
self.logError('file', "Failed to parse JSON record: " + key + ": " + e);
|
|
453
|
+
callback( e, null );
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
self.logDebug(9, "Cached JSON fetch complete: " + key, self.debugLevel(10) ? data : null);
|
|
457
|
+
|
|
458
|
+
callback( null, data );
|
|
459
|
+
} );
|
|
460
|
+
}
|
|
437
461
|
return;
|
|
438
462
|
} // cache
|
|
439
463
|
|
|
440
|
-
|
|
441
|
-
if (!this.storage.isBinaryKey(key)) opts = { encoding: 'utf8' };
|
|
442
|
-
|
|
443
|
-
fs.readFile(file, opts, function (err, data) {
|
|
464
|
+
fs.readFile(file, function (err, data) {
|
|
444
465
|
if (err) {
|
|
445
466
|
if (err.message.match(/ENOENT/)) {
|
|
446
467
|
err.message = "File not found";
|
|
447
468
|
err.code = "NoSuchKey";
|
|
469
|
+
|
|
470
|
+
if (self.cache && !is_binary) {
|
|
471
|
+
// store 'empty' stub in cache
|
|
472
|
+
self.cache.set( key, Buffer.alloc(0), { date: 0 } );
|
|
473
|
+
}
|
|
448
474
|
}
|
|
449
475
|
else {
|
|
450
476
|
// log fs errors that aren't simple missing files (i.e. I/O errors)
|
|
@@ -461,7 +487,7 @@ module.exports = Class.create({
|
|
|
461
487
|
}
|
|
462
488
|
|
|
463
489
|
if (!is_binary) {
|
|
464
|
-
try { data = JSON.parse( data ); }
|
|
490
|
+
try { data = JSON.parse( data.toString() ); }
|
|
465
491
|
catch (e) {
|
|
466
492
|
self.logError('file', "Failed to parse JSON record: " + key + ": " + e);
|
|
467
493
|
callback( e, null );
|
|
@@ -595,6 +621,11 @@ module.exports = Class.create({
|
|
|
595
621
|
if (err.message.match(/ENOENT/)) {
|
|
596
622
|
err.message = "File not found";
|
|
597
623
|
err.code = "NoSuchKey";
|
|
624
|
+
|
|
625
|
+
if (self.cache && !self.storage.isBinaryKey(key)) {
|
|
626
|
+
// store 'empty' stub in cache
|
|
627
|
+
self.cache.set( key, Buffer.alloc(0), { date: 0 } );
|
|
628
|
+
}
|
|
598
629
|
}
|
|
599
630
|
|
|
600
631
|
self.logError('file', "Failed to delete file: " + key + ": " + file + ": " + err.message);
|
|
@@ -605,9 +636,10 @@ module.exports = Class.create({
|
|
|
605
636
|
else {
|
|
606
637
|
self.logDebug(9, "Delete complete: " + key);
|
|
607
638
|
|
|
608
|
-
// possibly delete from LRU cache as well
|
|
609
|
-
if (self.cache && self.
|
|
610
|
-
|
|
639
|
+
// possibly "delete" from LRU cache as well
|
|
640
|
+
if (self.cache && !self.storage.isBinaryKey(key)) {
|
|
641
|
+
// store 'empty' stub in cache
|
|
642
|
+
self.cache.set( key, Buffer.alloc(0), { date: 0 } );
|
|
611
643
|
}
|
|
612
644
|
|
|
613
645
|
// cleanup parent dirs if empty
|