Skip to content

Commit 8f7c72a

Browse files
committed
feat(NODE-3697): reduce serverSession allocation
1 parent ff26b12 commit 8f7c72a

File tree

8 files changed

+247
-50
lines changed

8 files changed

+247
-50
lines changed

src/cmap/connection.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@ export interface CommandOptions extends BSONSerializeOptions {
114114
// Applying a session to a command should happen as part of command construction,
115115
// most likely in the CommandOperation#executeCommand method, where we have access to
116116
// the details we need to determine if a txnNum should also be applied.
117-
willRetryWrite?: true;
117+
willRetryWrite?: boolean;
118118

119119
writeConcern?: WriteConcern;
120120
}

src/operations/operation.ts

+2-3
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ export interface OperationConstructor extends Function {
2525
export interface OperationOptions extends BSONSerializeOptions {
2626
/** Specify ClientSession for this command */
2727
session?: ClientSession;
28-
willRetryWrites?: boolean;
28+
willRetryWrite?: boolean;
2929

3030
/** The preferred read preference (ReadPreference.primary, ReadPreference.primary_preferred, ReadPreference.secondary, ReadPreference.secondary_preferred, ReadPreference.nearest). */
3131
readPreference?: ReadPreferenceLike;
@@ -56,8 +56,7 @@ export abstract class AbstractOperation<TResult = any> {
5656
// BSON serialization options
5757
bsonOptions?: BSONSerializeOptions;
5858

59-
// TODO: Each operation defines its own options, there should be better typing here
60-
options: Document;
59+
options: OperationOptions;
6160

6261
[kSession]: ClientSession | undefined;
6362

src/sessions.ts

+63-43
Original file line numberDiff line numberDiff line change
@@ -42,20 +42,6 @@ import {
4242

4343
const minWireVersionForShardedTransactions = 8;
4444

45-
function assertAlive(session: ClientSession, callback?: Callback): boolean {
46-
if (session.serverSession == null) {
47-
const error = new MongoExpiredSessionError();
48-
if (typeof callback === 'function') {
49-
callback(error);
50-
return false;
51-
}
52-
53-
throw error;
54-
}
55-
56-
return true;
57-
}
58-
5945
/** @public */
6046
export interface ClientSessionOptions {
6147
/** Whether causal consistency should be enabled on this session */
@@ -89,6 +75,8 @@ const kSnapshotTime = Symbol('snapshotTime');
8975
const kSnapshotEnabled = Symbol('snapshotEnabled');
9076
/** @internal */
9177
const kPinnedConnection = Symbol('pinnedConnection');
78+
/** @internal Accumulates total number of increments to perform to txnNumber */
79+
const kTxnNumberIncrement = Symbol('txnNumberIncrement');
9280

9381
/** @public */
9482
export interface EndSessionOptions {
@@ -130,6 +118,8 @@ export class ClientSession extends TypedEventEmitter<ClientSessionEvents> {
130118
[kSnapshotEnabled] = false;
131119
/** @internal */
132120
[kPinnedConnection]?: Connection;
121+
/** @internal Accumulates total number of increments to perform to txnNumber */
122+
[kTxnNumberIncrement]: number;
133123

134124
/**
135125
* Create a client session.
@@ -172,7 +162,10 @@ export class ClientSession extends TypedEventEmitter<ClientSessionEvents> {
172162
this.sessionPool = sessionPool;
173163
this.hasEnded = false;
174164
this.clientOptions = clientOptions;
175-
this[kServerSession] = undefined;
165+
166+
this.explicit = Boolean(options.explicit);
167+
this[kServerSession] = this.explicit ? this.sessionPool.acquire() : undefined;
168+
this[kTxnNumberIncrement] = 0;
176169

177170
this.supports = {
178171
causalConsistency: options.snapshot !== true && options.causalConsistency !== false
@@ -181,24 +174,27 @@ export class ClientSession extends TypedEventEmitter<ClientSessionEvents> {
181174
this.clusterTime = options.initialClusterTime;
182175

183176
this.operationTime = undefined;
184-
this.explicit = !!options.explicit;
185177
this.owner = options.owner;
186178
this.defaultTransactionOptions = Object.assign({}, options.defaultTransactionOptions);
187179
this.transaction = new Transaction();
188180
}
189181

190182
/** The server id associated with this session */
191183
get id(): ServerSessionId | undefined {
192-
return this.serverSession?.id;
184+
const serverSession = this[kServerSession];
185+
if (serverSession == null) {
186+
return undefined;
187+
}
188+
return serverSession.id;
193189
}
194190

195191
get serverSession(): ServerSession {
196-
if (this[kServerSession] == null) {
197-
this[kServerSession] = this.sessionPool.acquire();
192+
let serverSession = this[kServerSession];
193+
if (serverSession == null) {
194+
serverSession = this.sessionPool.acquire();
195+
this[kServerSession] = serverSession;
198196
}
199-
200-
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
201-
return this[kServerSession]!;
197+
return serverSession;
202198
}
203199

204200
/** Whether or not this session is configured for snapshot reads */
@@ -267,9 +263,15 @@ export class ClientSession extends TypedEventEmitter<ClientSessionEvents> {
267263
const completeEndSession = () => {
268264
maybeClearPinnedConnection(this, finalOptions);
269265

270-
// release the server session back to the pool
271-
this.sessionPool.release(this.serverSession);
272-
this[kServerSession] = undefined;
266+
const serverSession = this[kServerSession];
267+
if (serverSession != null) {
268+
// release the server session back to the pool
269+
this.sessionPool.release(serverSession);
270+
// Make sure a new serverSession never makes it on to the ClientSession
271+
Object.defineProperty(this, kServerSession, {
272+
value: ServerSession.clone(serverSession)
273+
});
274+
}
273275

274276
// mark the session as ended, and emit a signal
275277
this.hasEnded = true;
@@ -279,7 +281,9 @@ export class ClientSession extends TypedEventEmitter<ClientSessionEvents> {
279281
done();
280282
};
281283

282-
if (this.serverSession && this.inTransaction()) {
284+
if (this.inTransaction()) {
285+
// If we've reached endSession and the transaction is still active
286+
// by default we abort it
283287
this.abortTransaction(err => {
284288
if (err) return done(err);
285289
completeEndSession();
@@ -355,10 +359,7 @@ export class ClientSession extends TypedEventEmitter<ClientSessionEvents> {
355359

356360
/** Increment the transaction number on the internal ServerSession */
357361
incrementTransactionNumber(): void {
358-
if (this.serverSession) {
359-
this.serverSession.txnNumber =
360-
typeof this.serverSession.txnNumber === 'number' ? this.serverSession.txnNumber + 1 : 0;
361-
}
362+
this[kTxnNumberIncrement] += 1;
362363
}
363364

364365
/** @returns whether this session is currently in a transaction or not */
@@ -376,7 +377,6 @@ export class ClientSession extends TypedEventEmitter<ClientSessionEvents> {
376377
throw new MongoCompatibilityError('Transactions are not allowed with snapshot sessions');
377378
}
378379

379-
assertAlive(this);
380380
if (this.inTransaction()) {
381381
throw new MongoTransactionError('Transaction already in progress');
382382
}
@@ -627,7 +627,7 @@ function attemptTransaction<TSchema>(
627627
throw err;
628628
}
629629

630-
if (session.transaction.isActive) {
630+
if (session.inTransaction()) {
631631
return session.abortTransaction().then(() => maybeRetryOrThrow(err));
632632
}
633633

@@ -641,11 +641,6 @@ function endTransaction(
641641
commandName: 'abortTransaction' | 'commitTransaction',
642642
callback: Callback<Document>
643643
) {
644-
if (!assertAlive(session, callback)) {
645-
// checking result in case callback was called
646-
return;
647-
}
648-
649644
// handle any initial problematic cases
650645
const txnState = session.transaction.state;
651646

@@ -750,7 +745,6 @@ function endTransaction(
750745
callback(error, result);
751746
}
752747

753-
// Assumption here that commandName is "commitTransaction" or "abortTransaction"
754748
if (session.transaction.recoveryToken) {
755749
command.recoveryToken = session.transaction.recoveryToken;
756750
}
@@ -832,6 +826,30 @@ export class ServerSession {
832826

833827
return idleTimeMinutes > sessionTimeoutMinutes - 1;
834828
}
829+
830+
/**
831+
* @internal
832+
* Cloning meant to keep a readable reference to the server session data
833+
* after ClientSession has ended
834+
*/
835+
static clone(serverSession: ServerSession): Readonly<ServerSession> {
836+
const arrayBuffer = new ArrayBuffer(16);
837+
const idBytes = Buffer.from(arrayBuffer);
838+
idBytes.set(serverSession.id.id.buffer);
839+
840+
const id = new Binary(idBytes, serverSession.id.id.sub_type);
841+
842+
// Manual prototype construction to avoid modifying the constructor of this class
843+
return Object.setPrototypeOf(
844+
{
845+
id: { id },
846+
lastUse: serverSession.lastUse,
847+
txnNumber: serverSession.txnNumber,
848+
isDirty: serverSession.isDirty
849+
},
850+
ServerSession.prototype
851+
);
852+
}
835853
}
836854

837855
/**
@@ -944,11 +962,11 @@ export function applySession(
944962
command: Document,
945963
options: CommandOptions
946964
): MongoDriverError | undefined {
947-
// TODO: merge this with `assertAlive`, did not want to throw a try/catch here
948965
if (session.hasEnded) {
949966
return new MongoExpiredSessionError();
950967
}
951968

969+
// May acquire serverSession here
952970
const serverSession = session.serverSession;
953971
if (serverSession == null) {
954972
return new MongoRuntimeError('Unable to acquire server session');
@@ -967,14 +985,16 @@ export function applySession(
967985
command.lsid = serverSession.id;
968986

969987
// first apply non-transaction-specific sessions data
970-
const inTransaction = session.inTransaction() || isTransactionCommand(command);
971-
const isRetryableWrite = options?.willRetryWrite || false;
988+
const inTxnOrTxnCommand = session.inTransaction() || isTransactionCommand(command);
989+
const isRetryableWrite = Boolean(options.willRetryWrite);
972990

973-
if (serverSession.txnNumber && (isRetryableWrite || inTransaction)) {
991+
if (isRetryableWrite || inTxnOrTxnCommand) {
992+
serverSession.txnNumber += session[kTxnNumberIncrement];
993+
session[kTxnNumberIncrement] = 0;
974994
command.txnNumber = Long.fromNumber(serverSession.txnNumber);
975995
}
976996

977-
if (!inTransaction) {
997+
if (!inTxnOrTxnCommand) {
978998
if (session.transaction.state !== TxnState.NO_TRANSACTION) {
979999
session.transaction.transition(TxnState.NO_TRANSACTION);
9801000
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { expect } from 'chai';
2+
3+
import { Collection } from '../../../src/index';
4+
5+
describe('ServerSession', () => {
6+
let client;
7+
let testCollection: Collection<{ _id: number; a?: number }>;
8+
beforeEach(async function () {
9+
const configuration = this.configuration;
10+
client = await configuration.newClient({ maxPoolSize: 1, monitorCommands: true }).connect();
11+
12+
// reset test collection
13+
testCollection = client.db('test').collection('too.many.sessions');
14+
await testCollection.drop().catch(() => null);
15+
});
16+
17+
afterEach(async () => {
18+
await client?.close(true);
19+
});
20+
21+
/**
22+
* TODO(DRIVERS-2218): Refactor tests to align exactly with spec wording. Preliminarily implements:
23+
* Drivers MAY assert that exactly one session is used for all the concurrent operations listed in the test, however this is a race condition if the session isn't released before checkIn (which SHOULD NOT be attempted)
24+
* Drivers SHOULD assert that after repeated runs they are able to achieve the use of exactly one session, this will statistically prove we've reduced the allocation amount
25+
* Drivers MUST assert that the number of allocated sessions never exceeds the number of concurrent operations executing
26+
*/
27+
28+
it('13. may reuse one server session for many operations', async () => {
29+
const events = [];
30+
client.on('commandStarted', ev => events.push(ev));
31+
32+
const operations = [
33+
testCollection.insertOne({ _id: 1 }),
34+
testCollection.deleteOne({ _id: 2 }),
35+
testCollection.updateOne({ _id: 3 }, { $set: { a: 1 } }),
36+
testCollection.bulkWrite([{ updateOne: { filter: { _id: 4 }, update: { $set: { a: 1 } } } }]),
37+
testCollection.findOneAndDelete({ _id: 5 }),
38+
testCollection.findOneAndUpdate({ _id: 6 }, { $set: { a: 1 } }),
39+
testCollection.findOneAndReplace({ _id: 7 }, { a: 8 }),
40+
testCollection.find().toArray()
41+
];
42+
43+
const allResults = await Promise.all(operations);
44+
45+
expect(allResults).to.have.lengthOf(operations.length);
46+
expect(events).to.have.lengthOf(operations.length);
47+
48+
expect(new Set(events.map(ev => ev.command.lsid.id.toString('hex'))).size).to.equal(1); // This is a guarantee in node
49+
});
50+
});

test/integration/sessions/sessions.test.ts

+33
Original file line numberDiff line numberDiff line change
@@ -367,4 +367,37 @@ describe('Sessions Spec', function () {
367367
});
368368
});
369369
});
370+
371+
describe('Session allocation', () => {
372+
let client;
373+
let testCollection;
374+
375+
beforeEach(async function () {
376+
client = await this.configuration
377+
.newClient({ maxPoolSize: 1, monitorCommands: true })
378+
.connect();
379+
// reset test collection
380+
testCollection = client.db('test').collection('too.many.sessions');
381+
await testCollection.drop().catch(() => null);
382+
});
383+
384+
afterEach(async () => {
385+
await client?.close();
386+
});
387+
388+
it('should only use one session for many operations when maxPoolSize is 1', async () => {
389+
const documents = new Array(50).fill(null).map((_, idx) => ({ _id: idx }));
390+
391+
const events = [];
392+
client.on('commandStarted', ev => events.push(ev));
393+
const allResults = await Promise.all(
394+
documents.map(async doc => testCollection.insertOne(doc))
395+
);
396+
397+
expect(allResults).to.have.lengthOf(documents.length);
398+
expect(events).to.have.lengthOf(documents.length);
399+
400+
expect(new Set(events.map(ev => ev.command.lsid.id.toString('hex'))).size).to.equal(1);
401+
});
402+
});
370403
});

test/tools/cluster_setup.sh

+1-1
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ if [[ $1 == "replica_set" ]]; then
1717
echo "mongodb://bob:pwd123@localhost:31000,localhost:31001,localhost:31002/?replicaSet=rs"
1818
elif [[ $1 == "sharded_cluster" ]]; then
1919
mkdir -p $SHARDED_DIR
20-
mlaunch init --dir $SHARDED_DIR --auth --username "bob" --password "pwd123" --replicaset --nodes 3 --arbiter --name rs --port 51000 --enableMajorityReadConcern --setParameter enableTestCommands=1 --sharded 1 --mongos 2
20+
mlaunch init --dir $SHARDED_DIR --auth --username "bob" --password "pwd123" --replicaset --nodes 3 --name rs --port 51000 --enableMajorityReadConcern --setParameter enableTestCommands=1 --sharded 1 --mongos 2
2121
echo "mongodb://bob:pwd123@localhost:51000,localhost:51001"
2222
elif [[ $1 == "server" ]]; then
2323
mkdir -p $SINGLE_DIR

test/tools/spec-runner/index.js

+2
Original file line numberDiff line numberDiff line change
@@ -459,6 +459,8 @@ function validateExpectations(commandEvents, spec, savedSessionData) {
459459
const rawExpectedEvents = spec.expectations.map(x => x.command_started_event);
460460
const expectedEvents = normalizeCommandShapes(rawExpectedEvents);
461461

462+
expect(actualEvents).to.have.lengthOf(expectedEvents.length);
463+
462464
for (const [idx, expectedEvent] of expectedEvents.entries()) {
463465
const actualEvent = actualEvents[idx];
464466

0 commit comments

Comments
 (0)