-
Notifications
You must be signed in to change notification settings - Fork 0
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Introduce Snapshot Isolation OCC to DBTransaction #18
Conversation
@emmacasolin @tegefaulkes watch this PR! |
Now I have an interesting test to demonstrate the snapshot's capabilities. import { withF, ResourceRelease } from '@matrixai/resources';
import { DB } from './src';
import * as testsUtils from './tests/utils';
async function main () {
const key = await testsUtils.generateKey(256);
const db = await DB.createDB({
dbPath: './tmp/db',
crypto: {
key,
ops: { encrypt: testsUtils.encrypt, decrypt: testsUtils.decrypt },
},
fresh: true
});
await db.put('key', 'value');
const t1 = withF([db.transaction()], async ([tran]) => {
console.log('T1 START');
await testsUtils.sleep(100);
console.log('T1: LOADING KEY2', await tran.get('key2'));
console.log('T1 FINISH');
});
// T2 starts after T1 starts
const t2 = withF([db.transaction()], async ([tran]) => {
console.log('T2 START');
await tran.put('key2', 'value2');
console.log('T2 FINISH');
});
await t2;
await t1;
const t3 = withF([db.transaction()], async ([tran]) => {
console.log('T2 START');
console.log('T3: LOADING KEY2', await tran.get('key2'));
console.log('T3 FINISH');
});
await t3;
await db.stop();
}
main(); In master the output would be:
With the new snapshot iterator, the result is:
With SI, because T1 and T2 are started at the same time, they get the original state of the database, and T2 commits before T1 reads the key, but because T1 still has the original snapshot, it continues to read |
To allow future debugging, I've added debug logging statements for |
I just realised it's not possible to get a derived iterator for That will mean, that until we can have access to raw iterators (Level/leveldown#486 (comment)), if users want to have a consistent iterators with respect to the transactional snapshot, the iterators must be created at the beginning of the transaction. Any subsequent creation would take the snapshot of the DB state at that point taking into account any changes to the DB state by other committed transactions. I'm not fully sure if this is the case because We can do a synchronous constructor call instead if not. Anyway this should mean that consistent iteration can only be done with: withF([db.transaction()], async (tran) => {
const i1 = tran.iterator();
const i2 = tran.iterator();
// do work afterwards
}); |
I've found a scenario where creating an iterator up front is still not consistent. This may mean we need to change the way we construct our transaction (to be more synchronous). import { withF } from '@matrixai/resources';
import { DB } from './src';
import * as testsUtils from './tests/utils';
async function main () {
const key = await testsUtils.generateKey(256);
const db = await DB.createDB({
dbPath: './tmp/db',
crypto: {
key,
ops: { encrypt: testsUtils.encrypt, decrypt: testsUtils.decrypt },
},
fresh: true
});
await db.put('key', 'value');
const p = db.put('key', 'value3');
const t1 = withF([db.transaction()], async ([tran]) => {
const i1 = tran.iterator({ keyAsBuffer: false, valueAsBuffer: false });
console.log('CREATED ITERATOR');
i1.seek('key')
const v1 = (await i1.next())![1];
await i1.end();
const v2 = await tran.get('key');
console.log(v1, v2, v1 === v2);
});
await p;
await t1;
await db.stop();
}
main(); Here we see that the newly created iterator gets |
There's an old fork of leveldown that did expose snapshots to the interface: https://github.com/frankboehmer/leveldown/commits/master |
Ok by making const tran = new DBTransaction({
db: this,
transactionId,
logger: this.logger,
}); This can happen as long as you do A different foolproof trick would be delay the main snapshot iterator creation until the first But it also means that snapshot iterator lifecycle is not tied directly to transaction lifecycle. It also makes snapshot creation lazy, in that if a transaction only does Instead one can trigger snapshot creation as soon as get, put, del, or iterator method is called. That way, it is at least observable that only when the tran is actually interacted with does the consistent lifecycle start. The best solution is for leveldb to expose snapshots directly and for that we need to work in C++ and fork the leveldb for js-db. |
Expecting iterators to be created up front is not going to be good for usability. Consider that in PK our transactions may be created high the call graph, and the context may be passed down to many domain methods. It is therefore important that downstream users can expect consistency when creating their iterators. Maybe it's time to start the C++ journey! |
From https://github.com/google/leveldb/blob/main/doc/index.md#snapshots. In C++, snapshots are used like this: leveldb::ReadOptions options;
options.snapshot = db->GetSnapshot();
// ... apply some updates to db ...
leveldb::Iterator* iter = db->NewIterator(options);
// ... read using iter to view the state when the snapshot was created ...
delete iter;
db->ReleaseSnapshot(options.snapshot); Here we can see that even after mutating the db, just simply creating the iterator with This basically means that read operations using This means the final API might like this: // Internally inside `DBTransaction`
const snapshot = db.snapshot();
const iterator = db.iterator({ snapshot }, levelPath);
// Externally at the end-user
await withF([this.db.transaction()], async ([tran]) => {
const i1 = tran.iterator();
const i2 = tran.iterator();
// we can expect that `i1` and `i2` are consistent with the transactional snapshot iterator
}); The |
The forked implementation of snapshots frankboehmer/leveldown@a78ddc1 done in 2018 used the NAN mechanism. This was an older way of creating native addons: https://stackoverflow.com/a/59440434/582917. The newer way uses NAPI, so we will have to study both and compare what will be needed. |
The |
The
It would be interesting to explore this in the future, since that would indicate alot of work, and we still have to compare with potentially using rocksdb instead. |
Updated dev dependencies in package.json and synchronised .eslintrc Updated typedoc generation Canary option was never used Updated jest.config.js to use ts-jest instead of ts-jest/utils Enabled keyAsBuffer and valueAsBuffer options for iterators, still defaults to Buffer WIP Typo for ErrorDBTransactionCommited to ErrorDBTransactionCommitted WIP keyAsBuffer and valueAsBuffer WIP dump V types WIP snapshot is the get of last resort for tran WIP WIP WIP WIP
@emmacasolin I had a look at this current branch after I rebased on top of master. I see that the You should make sure that Also I think you removed a necessary devDependency being of Can you create another feature branch, enter |
Closed in favour of #19. That's now the new feature branch. I'll be bringing in the changes from TypeScript-Demo-Lib there too. |
Description
This implements the snapshot isolation DB transaction.
This means DBTransaction will be automatically snapshot isolated, which means most locking will be unnecessary.
Instead when performing a transaction, there's a chance for a
ErrorDBTransactionConflict
exception which means there was a write conflict with another transaction.Users can then decide on their discretion to retry the operation if they need to (assuming any non-DB side-effects are idempotent, noops or can be compensated). This should reduce the amount of locking overhead we need to do in Polykey. We may bubble up the conflict exception to the user, so the user can re-run their command, or in some cases, in-code we will automatically perform a retry. The user in this case can be the PK client, or the another PK agent or the PK GUI.
There is still one situation where user/application locks are needed, and that's where there may be a write-skew. See snapshot isolation https://en.wikipedia.org/wiki/Snapshot_isolation for more details and also https://www.cockroachlabs.com/blog/what-write-skew-looks-like/.
In the future we may upgrade to SSI (serializable snapshot isolation) which will eliminate this write-skew possibility.
Additionally this PR will also enable the
keyAsBuffer
andvalueAsBuffer
options on the iterators, enabling easier usage of the iterators without having to usedbUtils.deserialize<T>(value)
where it can be configured ahead of time.Issues Fixed
Tasks
keyAsBuffer
andvalueAsBuffer
DBTransaction
lifecyclesnapshotLock
to ensure mutual exclusion when using the snapshot iteratorgetSnapshot
as the last-resort getter forDBTransaction.get
DBTransaction.iterator
to use the snapshot iteratorkeyAsBuffer
andvalueAsBuffer
usage on the iterator, expectstring
andV
types. Existing tests should still work. Since by default iterator returns buffers. This is different fromget
which by default does not return raw buffers.ErrorDBTransactionConflict
Final checklist