Skip to content
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

ci: merge staging to master #38

Merged
merged 39 commits into from
Jul 14, 2022
Merged
Changes from 1 commit
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
946a01e
Preparing for native leveldb build
CMCDragonkai May 31, 2022
a972390
Added leveldb native dependencies
CMCDragonkai May 31, 2022
e7229bf
Direct to C++ leveldb works
CMCDragonkai Jun 7, 2022
da767cc
Switching over to rocksdb
CMCDragonkai Jun 10, 2022
e42f01e
Removed StringOrBufferLength check from rocksdb.cpp
CMCDragonkai Jun 8, 2022
dc4db39
Modularized rocksdb.cpp into a rocksdb/napi directory
CMCDragonkai Jun 8, 2022
ba968b0
Added `infoLogLevel` to `RocksDBDatabaseOptions`
CMCDragonkai Jun 10, 2022
f521ab0
Added in basic `transaction_*` methods, and modularised `src/rocksdb/…
CMCDragonkai Jun 10, 2022
92edd7f
Native addon exported functions now match naming convention standard …
CMCDragonkai Jun 10, 2022
7b55563
Fixed Transaction* workers in C++ to instantiate the `Transaction`
CMCDragonkai Jun 10, 2022
6862983
Lint fixing native C/C++ code
CMCDragonkai Jun 11, 2022
dd1cec1
Tested write-skew anomaly and solved with transactionGetForUpdate
CMCDragonkai Jun 11, 2022
8e1135c
First class snapshots
CMCDragonkai Jun 11, 2022
0dc0b96
Transactions support iterators and refactoring C++ object lifecycle
CMCDragonkai Jun 14, 2022
c909c02
GC finalizers may run after `env_cleanup_hook`, so `Detach` methods can
CMCDragonkai Jun 17, 2022
04c5448
Fix database->isClosing_ and Transaction::DecrementPendingWork should…
CMCDragonkai Jun 17, 2022
434ef4a
Testing iterator consistency
CMCDragonkai Jun 17, 2022
23d3405
Adding transactionClear
CMCDragonkai Jun 17, 2022
f9cecbc
Adding transactionMultiGet and transactionMultiGetForUpdate
CMCDragonkai Jun 18, 2022
b8a6083
Re-enabled DBTransaction and integrated it with rocksdb's optimistic …
CMCDragonkai Jun 7, 2022
58a62f2
Added DBTransaction.getForUpdate to address write skew, and added tes…
CMCDragonkai Jun 26, 2022
a06ccdf
Introducing PCC locking for `DBTransaction`
CMCDragonkai Jun 27, 2022
cb25e1f
Export `RocksDB` and `RocksDBP` interfaces
CMCDragonkai Jun 30, 2022
567526b
Updated docs
CMCDragonkai Jun 30, 2022
879c5e2
Updated benchmarks
CMCDragonkai Jun 30, 2022
8fd0c9a
Merge pull request #19 from MatrixAI/feature-control
CMCDragonkai Jun 30, 2022
6d37873
5.0.0-alpha.0
CMCDragonkai Jun 30, 2022
fd749a1
ci: use `.npmrc` to specify `npm_config_prefix` and `npm_config_jobs`
CMCDragonkai Jul 12, 2022
7ba21bb
rocksdb: changed back to using `#include <node_api.h>` because the de…
CMCDragonkai Jul 12, 2022
0bf62e3
nix: updated to 22.05 revision
CMCDragonkai Jul 12, 2022
df7bf85
nix: acquire package name with `npm pkg get name`
CMCDragonkai Jul 12, 2022
ff8f525
ci: explicitly installing node headers and static libraries into `./t…
CMCDragonkai Jul 12, 2022
6411c8d
ci: lint native code in `check:lint` job
CMCDragonkai Jul 12, 2022
6bcb953
ci: always preserve the cache even for failing jobs
CMCDragonkai Jul 12, 2022
37978ac
ci: moved `GIT_SUBMODULE_STRATEGY at the top of `variables`
CMCDragonkai Jul 12, 2022
5b43d96
ci: mkdir `./tmp` during all jobs, and override for windows
CMCDragonkai Jul 12, 2022
31d71b4
windows: fixed native build for windows
CMCDragonkai Jul 12, 2022
1f6299d
chore: lintfixed scripts/prebuild.js
CMCDragonkai Jul 13, 2022
886f7cb
binding.gyp: missing comma typo for macos builds
CMCDragonkai Jul 14, 2022
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
Introducing PCC locking for DBTransaction
CMCDragonkai committed Jun 30, 2022
commit a06ccdf079688c13a7c9760081bd558eee89c449
46 changes: 34 additions & 12 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -28,9 +28,9 @@
},
"dependencies": {
"@matrixai/async-init": "^1.8.1",
"@matrixai/async-locks": "^2.3.1",
"@matrixai/async-locks": "^3.0.0",
"@matrixai/errors": "^1.1.2",
"@matrixai/logger": "^2.2.2",
"@matrixai/logger": "^2.3.0",
"@matrixai/resources": "^1.1.3",
"@matrixai/workers": "^1.3.3",
"node-gyp-build": "4.4.0",
8 changes: 8 additions & 0 deletions src/DB.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { ResourceAcquire } from '@matrixai/resources';
import type { RWLockWriter } from '@matrixai/async-locks';
import type {
KeyPath,
LevelPath,
@@ -20,6 +21,7 @@ import {
CreateDestroyStartStop,
ready,
} from '@matrixai/async-init/dist/CreateDestroyStartStop';
import { LockBox } from '@matrixai/async-locks';
import DBIterator from './DBIterator';
import DBTransaction from './DBTransaction';
import { rocksdbP } from './rocksdb';
@@ -69,6 +71,7 @@ class DB {
protected fs: FileSystem;
protected logger: Logger;
protected workerManager?: DBWorkerManagerInterface;
protected _lockBox: LockBox<RWLockWriter> = new LockBox();
protected _db: RocksDBDatabase;
/**
* References to iterators
@@ -97,6 +100,10 @@ class DB {
return this._transactionRefs;
}

get lockBox(): Readonly<LockBox<RWLockWriter>> {
return this._lockBox;
}

constructor({
dbPath,
crypto,
@@ -193,6 +200,7 @@ class DB {
return async () => {
const tran = new DBTransaction({
db: this,
lockBox: this._lockBox,
logger: this.logger,
});
return [
176 changes: 145 additions & 31 deletions src/DBTransaction.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,17 @@
import type { ResourceRelease } from '@matrixai/resources';
import type {
LockBox,
MultiLockRequest as AsyncLocksMultiLockRequest,
} from '@matrixai/async-locks';
import type DB from './DB';
import type {
ToString,
KeyPath,
LevelPath,
DBIteratorOptions,
DBClearOptions,
DBCountOptions,
MultiLockRequest,
} from './types';
import type {
RocksDBTransaction,
@@ -13,6 +20,7 @@ import type {
} from './rocksdb/types';
import Logger from '@matrixai/logger';
import { CreateDestroy, ready } from '@matrixai/async-init/dist/CreateDestroy';
import { RWLockWriter } from '@matrixai/async-locks';
import DBIterator from './DBIterator';
import { rocksdbP } from './rocksdb';
import * as utils from './utils';
@@ -21,37 +29,44 @@ import * as errors from './errors';
interface DBTransaction extends CreateDestroy {}
@CreateDestroy()
class DBTransaction {
public readonly id: number;

protected _db: DB;
protected logger: Logger;

protected lockBox: LockBox<RWLockWriter>;
protected _locks: Map<
string,
{
lock: RWLockWriter;
type: 'read' | 'write';
release: ResourceRelease;
}
> = new Map();
protected _options: RocksDBTransactionOptions;
protected _transaction: RocksDBTransaction;
protected _id: number;
protected _snapshot: RocksDBTransactionSnapshot;

protected _iteratorRefs: Set<DBIterator<any, any>> = new Set();
protected _callbacksSuccess: Array<() => any> = [];
protected _callbacksFailure: Array<(e?: Error) => any> = [];
protected _callbacksFinally: Array<(e?: Error) => any> = [];
protected _committed: boolean = false;
protected _rollbacked: boolean = false;

/**
* References to iterators
*/
protected _iteratorRefs: Set<DBIterator<any, any>> = new Set();

public constructor({
db,
lockBox,
logger,
...options
}: {
db: DB;
lockBox: LockBox<RWLockWriter>;
logger?: Logger;
} & RocksDBTransactionOptions) {
logger = logger ?? new Logger(this.constructor.name);
logger.debug(`Constructing ${this.constructor.name}`);
this.logger = logger;
this._db = db;
this.lockBox = lockBox;
const options_ = {
...options,
// Transactions should be synchronous
@@ -61,21 +76,24 @@ class DBTransaction {
this._options = options_;
this._transaction = rocksdbP.transactionInit(db.db, options_);
db.transactionRefs.add(this);
this._id = rocksdbP.transactionId(this._transaction);
logger.debug(`Constructed ${this.constructor.name} ${this._id}`);
this.id = rocksdbP.transactionId(this._transaction);
logger.debug(`Constructed ${this.constructor.name} ${this.id}`);
}

/**
* Destroy the transaction
* This cannot be called until the transaction is committed or rollbacked
*/
public async destroy() {
this.logger.debug(`Destroying ${this.constructor.name} ${this._id}`);
this._db.transactionRefs.delete(this);
this.logger.debug(`Destroying ${this.constructor.name} ${this.id}`);
if (!this._committed && !this._rollbacked) {
throw new errors.ErrorDBTransactionNotCommittedNorRollbacked();
}
this.logger.debug(`Destroyed ${this.constructor.name} ${this._id}`);
this._db.transactionRefs.delete(this);
// Unlock all locked keys in reverse
const lockedKeys = [...this._locks.keys()].reverse();
await this.unlock(...lockedKeys);
this.logger.debug(`Destroyed ${this.constructor.name} ${this.id}`);
}

get db(): Readonly<DB> {
@@ -86,17 +104,6 @@ class DBTransaction {
return this._transaction;
}

get id(): number {
return this._id;
}

/**
* @internal
*/
get iteratorRefs(): Readonly<Set<DBIterator<any, any>>> {
return this._iteratorRefs;
}

get callbacksSuccess(): Readonly<Array<() => any>> {
return this._callbacksSuccess;
}
@@ -117,6 +124,98 @@ class DBTransaction {
return this._rollbacked;
}

get locks(): ReadonlyMap<
string,
{
lock: RWLockWriter;
type: 'read' | 'write';
release: ResourceRelease;
}
> {
return this._locks;
}

/**
* @internal
*/
get iteratorRefs(): Readonly<Set<DBIterator<any, any>>> {
return this._iteratorRefs;
}

/**
* Lock a sequence of lock requests
* If the lock request doesn't specify, it
* defaults to using `RWLockWriter` with `write` type
* Keys are locked in string sorted order
* Even though keys can be arbitrary strings, by convention, you should use
* keys that correspond to keys in the database
* Locking with the same key is idempotent therefore lock re-entrancy is enabled
* Keys are automatically unlocked in reverse sorted order
* when the transaction is destroyed
* There is no support for lock upgrading or downgrading
* There is no deadlock detection
*/
public async lock(
...requests: Array<MultiLockRequest | string>
): Promise<void> {
const requests_: Array<AsyncLocksMultiLockRequest<RWLockWriter>> = [];
for (const request of requests) {
if (Array.isArray(request)) {
const [key, ...lockingParams] = request;
const key_ = key.toString();
const lock = this._locks.get(key_);
// Default the lock type to `write`
const lockType = (lockingParams[0] = lockingParams[0] ?? 'write');
if (lock == null) {
requests_.push([key_, RWLockWriter, ...lockingParams]);
} else if (lock.type !== lockType) {
throw new errors.ErrorDBTransactionLockType();
}
} else {
const key_ = request.toString();
const lock = this._locks.get(key_);
if (lock == null) {
// Default to using `RWLockWriter` write lock for just string keys
requests_.push([key_, RWLockWriter, 'write']);
} else if (lock.type !== 'write') {
throw new errors.ErrorDBTransactionLockType();
}
}
}
if (requests_.length > 0) {
// Duplicates are eliminated, and the returned acquisitions are sorted
const lockAcquires = this.lockBox.lockMulti(...requests_);
for (const [key, lockAcquire, ...lockingParams] of lockAcquires) {
const [lockRelease, lock] = await lockAcquire();
// The `Map` will maintain insertion order
// these must be unlocked in reverse order
// when the transaction is destroyed
this._locks.set(key as string, {
lock: lock!,
type: lockingParams[0]!, // The `type` is defaulted to `write`
release: lockRelease,
});
}
}
}

/**
* Unlock a sequence of lock keys
* Unlocking will be done in the order of the keys
* A transaction instance is only allowed to unlock keys that it previously
* locked, all keys that are not part of the `this._locks` is ignored
* Unlocking the same keys is idempotent
*/
public async unlock(...keys: Array<ToString>): Promise<void> {
for (const key of keys) {
const key_ = key.toString();
const lock = this._locks.get(key_);
if (lock == null) continue;
this._locks.delete(key_);
await lock.release();
}
}

public async get<T>(
keyPath: KeyPath | string | Buffer,
raw?: false,
@@ -344,7 +443,7 @@ class DBTransaction {
if (this._committed) {
return;
}
this.logger.debug(`Committing ${this.constructor.name} ${this._id}`);
this.logger.debug(`Committing ${this.constructor.name} ${this.id}`);
for (const iterator of this._iteratorRefs) {
await iterator.destroy();
}
@@ -357,12 +456,14 @@ class DBTransaction {
} catch (e) {
if (e.code === 'TRANSACTION_CONFLICT') {
this.logger.debug(
`Failed Committing ${this.constructor.name} ${this._id} due to ${errors.ErrorDBTransactionConflict.name}`,
`Failed Committing ${this.constructor.name} ${this.id} due to ${errors.ErrorDBTransactionConflict.name}`,
);
throw new errors.ErrorDBTransactionConflict(undefined, { cause: e });
throw new errors.ErrorDBTransactionConflict(undefined, {
cause: e,
});
} else {
this.logger.debug(
`Failed Committing ${this.constructor.name} ${this._id} due to ${e.message}`,
`Failed Committing ${this.constructor.name} ${this.id} due to ${e.message}`,
);
throw e;
}
@@ -376,7 +477,7 @@ class DBTransaction {
}
}
await this.destroy();
this.logger.debug(`Committed ${this.constructor.name} ${this._id}`);
this.logger.debug(`Committed ${this.constructor.name} ${this.id}`);
}

@ready(new errors.ErrorDBTransactionDestroyed())
@@ -387,7 +488,7 @@ class DBTransaction {
if (this._rollbacked) {
return;
}
this.logger.debug(`Rollbacking ${this.constructor.name} ${this._id}`);
this.logger.debug(`Rollbacking ${this.constructor.name} ${this.id}`);
for (const iterator of this._iteratorRefs) {
await iterator.destroy();
}
@@ -405,7 +506,20 @@ class DBTransaction {
}
}
await this.destroy();
this.logger.debug(`Rollbacked ${this.constructor.name} ${this._id}`);
this.logger.debug(`Rollbacked ${this.constructor.name} ${this.id}`);
}

/**
* Set the snapshot manually
* This ensures that consistent reads and writes start
* after this method is executed
* This is idempotent
* Note that normally snapshots are set lazily upon the first
* transaction db operation
*/
@ready(new errors.ErrorDBTransactionDestroyed())
public setSnapshot(): void {
this.setupSnapshot();
}

/**
6 changes: 6 additions & 0 deletions src/errors.ts
Original file line number Diff line number Diff line change
@@ -82,6 +82,11 @@ class ErrorDBTransactionConflict<T> extends ErrorDBTransaction<T> {
static description = 'DBTransaction cannot commit due to conflicting writes';
}

class ErrorDBTransactionLockType<T> extends ErrorDBTransaction<T> {
static description =
'DBTransaction does not support upgrading or downgrading the lock type';
}

export {
ErrorDB,
ErrorDBRunning,
@@ -103,4 +108,5 @@ export {
ErrorDBTransactionRollbacked,
ErrorDBTransactionNotCommittedNorRollbacked,
ErrorDBTransactionConflict,
ErrorDBTransactionLockType,
};
19 changes: 17 additions & 2 deletions src/types.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import type fs from 'fs';
import type { RWLockWriter } from '@matrixai/async-locks';
import type { WorkerManagerInterface } from '@matrixai/workers';
import type {
RocksDBDatabaseOptions,
RocksDBIteratorOptions,
@@ -8,14 +11,19 @@ import type {
RocksDBSnapshot,
RocksDBTransactionSnapshot,
} from './rocksdb/types';
import type fs from 'fs';
import type { WorkerManagerInterface } from '@matrixai/workers';

/**
* Plain data dictionary
*/
type POJO = { [key: string]: any };

/**
* Any type that can be turned into a string
*/
interface ToString {
toString(): string;
}

/**
* Opaque types are wrappers of existing types
* that require smart constructors
@@ -151,8 +159,14 @@ type DBOp =

type DBOps = Array<DBOp>;

type MultiLockRequest = [
key: ToString,
...lockingParams: Parameters<RWLockWriter['lock']>,
];

export type {
POJO,
ToString,
Opaque,
Callback,
Merge,
@@ -168,4 +182,5 @@ export type {
DBBatch,
DBOp,
DBOps,
MultiLockRequest,
};
187 changes: 163 additions & 24 deletions tests/DBTransaction.test.ts
Original file line number Diff line number Diff line change
@@ -4,7 +4,7 @@ import path from 'path';
import fs from 'fs';
import Logger, { LogLevel, StreamHandler } from '@matrixai/logger';
import { withF } from '@matrixai/resources';
import { Lock } from '@matrixai/async-locks';
import { errors as locksErrors } from '@matrixai/async-locks';
import DB from '@/DB';
import DBTransaction from '@/DBTransaction';
import * as errors from '@/errors';
@@ -297,7 +297,7 @@ describe(DBTransaction.name, () => {
}),
).toBe(true);
});
test('PCC locking to prevent thrashing for racing counters', async () => {
test('locking to prevent thrashing for racing counters', async () => {
await db.put('counter', '0');
let t1 = withF([db.transaction()], async ([tran]) => {
// Can also use `getForUpdate`, but a conflict exists even for `get`
@@ -326,33 +326,25 @@ describe(DBTransaction.name, () => {
// in race thrashing where only 1 request succeeds, and all other requests
// keep failing. The only way to prevent this thrashing is to use PCC locking
await db.put('counter', '0');
const l = new Lock();
t1 = l.withF(async () => {
await withF([db.transaction()], async ([tran]) => {
// Can also use `get`, no difference here
let counter = parseInt((await tran.getForUpdate('counter'))!);
counter++;
await tran.put('counter', counter.toString());
});
t1 = withF([db.transaction()], async ([tran]) => {
// Enforces mutual exclusion
await tran.lock('counter');
// Can also use `get`, no difference here
let counter = parseInt((await tran.getForUpdate('counter'))!);
counter++;
await tran.put('counter', counter.toString());
});
t2 = l.withF(async () => {
await withF([db.transaction()], async ([tran]) => {
// Can also use `get`, no difference here
let counter = parseInt((await tran.getForUpdate('counter'))!);
counter++;
await tran.put('counter', counter.toString());
});
t2 = withF([db.transaction()], async ([tran]) => {
// Enforces mutual exclusion
await tran.lock('counter');
// Can also use `get`, no difference here
let counter = parseInt((await tran.getForUpdate('counter'))!);
counter++;
await tran.put('counter', counter.toString());
});
results = await Promise.allSettled([t1, t2]);
expect(results.every((result) => result.status === 'fulfilled'));
expect(await db.get('counter')).toBe('2');
// The PCC locks must be done outside of transaction creation
// This is because the PCC locks enforce mutual exclusion between commit operations
// If the locks were done inside the transaction, it's possible for the commit operations
// to be delayed after all mutually exclusive callbacks are executed
// resulting in a DBTransactionConflict
// When this library gains native locking, it must deal with this problem
// by only releasing the locks when the transaction is committed or rollbacked
});
test('iterator get after delete consistency', async () => {
await db.put('hello', 'world');
@@ -992,4 +984,151 @@ describe(DBTransaction.name, () => {
expect(await db.get('1')).toBe('a');
expect(await db.get('2')).toBe('b');
});
test('locking and unlocking', async () => {
await db.withTransactionF(async (tran) => {
await tran.lock('foo');
await tran.unlock('foo');
expect(tran.locks.size).toBe(0);
});
await db.withTransactionF(async (tran) => {
await tran.lock('foo', 'bar');
await tran.unlock('bar');
expect(tran.locks.size).toBe(1);
expect(tran.locks.has('foo')).toBe(true);
});
await db.withTransactionF(async (tran) => {
await tran.lock('foo', 'bar');
await tran.unlock('bar', 'foo');
expect(tran.locks.size).toBe(0);
});
await db.withTransactionF(async (tran) => {
await tran.lock('bar', 'foo');
await tran.unlock('bar', 'foo');
expect(tran.locks.size).toBe(0);
});
// Duplicates are eliminated
await db.withTransactionF(async (tran) => {
await tran.lock('foo', 'foo');
expect(tran.locks.size).toBe(1);
await tran.unlock('foo', 'foo');
expect(tran.locks.size).toBe(0);
});
});
test('read and write locking', async () => {
await db.withTransactionF(async (tran1) => {
await tran1.lock(['foo', 'read']);
await tran1.lock(['bar', 'write']);
// There is no automatic lock upgrade or downgrade
await expect(tran1.lock(['foo', 'write'])).rejects.toThrow(
errors.ErrorDBTransactionLockType,
);
await expect(tran1.lock(['bar', 'read'])).rejects.toThrow(
errors.ErrorDBTransactionLockType,
);
await db.withTransactionF(async (tran2) => {
await tran2.lock(['foo', 'read']);
await expect(tran2.lock(['bar', 'write', 0])).rejects.toThrow(
locksErrors.ErrorAsyncLocksTimeout,
);
expect(tran1.locks.size).toBe(2);
expect(tran1.locks.has('foo')).toBe(true);
expect(tran1.locks.get('foo')!.type).toBe('read');
expect(tran2.locks.size).toBe(1);
expect(tran2.locks.has('foo')).toBe(true);
expect(tran2.locks.get('foo')!.type).toBe('read');
});
expect(tran1.locks.size).toBe(2);
await tran1.unlock('bar');
await db.withTransactionF(async (tran2) => {
await tran2.lock(['foo', 'read']);
await tran2.lock(['bar', 'write']);
expect(tran1.locks.size).toBe(1);
expect(tran1.locks.has('foo')).toBe(true);
expect(tran1.locks.get('foo')!.type).toBe('read');
expect(tran2.locks.size).toBe(2);
expect(tran2.locks.has('foo')).toBe(true);
expect(tran2.locks.get('foo')!.type).toBe('read');
expect(tran2.locks.has('bar')).toBe(true);
expect(tran2.locks.get('bar')!.type).toBe('write');
});
});
});
test('locks are unlocked in reverse order', async () => {
const order: Array<string> = [];
let p1, p2;
await db.withTransactionF(async (tran) => {
// '1' and '2' are in sort order
await tran.lock('1');
await tran.lock('2');
p1 = db.withTransactionF(async (tran) => {
await tran.lock('1');
order.push('1');
});
p2 = db.withTransactionF(async (tran) => {
await tran.lock('2');
order.push('2');
});
});
await Promise.all([p2, p1]);
expect(order).toStrictEqual(['2', '1']);
});
test('lock re-entrancy', async () => {
await db.withTransactionF(async (tran) => {
// Locking with the same keys is idempotent
await tran.lock('key1', 'key2');
await tran.lock('key1', 'key2');
await tran.lock('key1');
await tran.lock('key2');
});
});
test('locks are isolated per transaction', async () => {
await db.withTransactionF(async (tran1) => {
await tran1.lock('key1', 'key2');
expect(tran1.locks.size).toBe(2);
await db.withTransactionF(async (tran2) => {
// This is a noop, because `tran1` owns `key1` and `key2`
await tran2.unlock('key1', 'key2');
// This fails because `key1` is still locked by `tran1`
await expect(tran2.lock(['key1', 'write', 0])).rejects.toThrow(
locksErrors.ErrorAsyncLocksTimeout,
);
await tran1.unlock('key1');
expect(tran1.locks.size).toBe(1);
// This succeeds because `key1` is now unlocked
await tran2.lock('key1');
expect(tran2.locks.size).toBe(1);
// This is a noop, because `tran2` owns `key1`
await tran1.unlock('key1');
expect(tran2.locks.has('key1')).toBe(true);
expect(tran1.locks.has('key1')).toBe(false);
await expect(tran1.lock(['key1', 'write', 0])).rejects.toThrow(
locksErrors.ErrorAsyncLocksTimeout,
);
});
await tran1.lock('key1');
expect(tran1.locks.has('key1')).toBe(true);
expect(tran1.locks.has('key2')).toBe(true);
});
});
test('deadlock', async () => {
await db.withTransactionF(async (tran1) => {
await db.withTransactionF(async (tran2) => {
await tran1.lock('foo');
await tran2.lock('bar');
// Currently a deadlock can happen, and the only way to avoid is to use timeouts
// In the future, we want to have `DBTransaction` detect deadlocks
// and automatically give us `ErrorDBTransactionDeadlock` exception
const p1 = tran1.lock(['bar', 'write', 50]);
const p2 = tran2.lock(['foo', 'write', 50]);
const results = await Promise.allSettled([p1, p2]);
expect(
results.every(
(r) =>
r.status === 'rejected' &&
r.reason instanceof locksErrors.ErrorAsyncLocksTimeout,
),
).toBe(true);
});
});
});
});