sessions.ts 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050
  1. import { promisify } from 'util';
  2. import { Binary, type Document, Long, type Timestamp } from './bson';
  3. import type { CommandOptions, Connection } from './cmap/connection';
  4. import { ConnectionPoolMetrics } from './cmap/metrics';
  5. import { isSharded } from './cmap/wire_protocol/shared';
  6. import { PINNED, UNPINNED } from './constants';
  7. import type { AbstractCursor } from './cursor/abstract_cursor';
  8. import {
  9. type AnyError,
  10. MongoAPIError,
  11. MongoCompatibilityError,
  12. MONGODB_ERROR_CODES,
  13. type MongoDriverError,
  14. MongoError,
  15. MongoErrorLabel,
  16. MongoExpiredSessionError,
  17. MongoInvalidArgumentError,
  18. MongoRuntimeError,
  19. MongoServerError,
  20. MongoTransactionError,
  21. MongoWriteConcernError
  22. } from './error';
  23. import type { MongoClient, MongoOptions } from './mongo_client';
  24. import { TypedEventEmitter } from './mongo_types';
  25. import { executeOperation } from './operations/execute_operation';
  26. import { RunAdminCommandOperation } from './operations/run_command';
  27. import { ReadConcernLevel } from './read_concern';
  28. import { ReadPreference } from './read_preference';
  29. import { _advanceClusterTime, type ClusterTime, TopologyType } from './sdam/common';
  30. import {
  31. isTransactionCommand,
  32. Transaction,
  33. type TransactionOptions,
  34. TxnState
  35. } from './transactions';
  36. import {
  37. ByteUtils,
  38. calculateDurationInMs,
  39. type Callback,
  40. commandSupportsReadConcern,
  41. isPromiseLike,
  42. List,
  43. maxWireVersion,
  44. now,
  45. uuidV4
  46. } from './utils';
  47. import { WriteConcern } from './write_concern';
  48. const minWireVersionForShardedTransactions = 8;
  49. /** @public */
  50. export interface ClientSessionOptions {
  51. /** Whether causal consistency should be enabled on this session */
  52. causalConsistency?: boolean;
  53. /** Whether all read operations should be read from the same snapshot for this session (NOTE: not compatible with `causalConsistency=true`) */
  54. snapshot?: boolean;
  55. /** The default TransactionOptions to use for transactions started on this session. */
  56. defaultTransactionOptions?: TransactionOptions;
  57. /** @internal */
  58. owner?: symbol | AbstractCursor;
  59. /** @internal */
  60. explicit?: boolean;
  61. /** @internal */
  62. initialClusterTime?: ClusterTime;
  63. }
  64. /** @public */
  65. export type WithTransactionCallback<T = void> = (session: ClientSession) => Promise<T>;
  66. /** @public */
  67. export type ClientSessionEvents = {
  68. ended(session: ClientSession): void;
  69. };
  70. /** @internal */
  71. const kServerSession = Symbol('serverSession');
  72. /** @internal */
  73. const kSnapshotTime = Symbol('snapshotTime');
  74. /** @internal */
  75. const kSnapshotEnabled = Symbol('snapshotEnabled');
  76. /** @internal */
  77. const kPinnedConnection = Symbol('pinnedConnection');
  78. /** @internal Accumulates total number of increments to add to txnNumber when applying session to command */
  79. const kTxnNumberIncrement = Symbol('txnNumberIncrement');
  80. /** @public */
  81. export interface EndSessionOptions {
  82. /**
  83. * An optional error which caused the call to end this session
  84. * @internal
  85. */
  86. error?: AnyError;
  87. force?: boolean;
  88. forceClear?: boolean;
  89. }
  90. /**
  91. * A class representing a client session on the server
  92. *
  93. * NOTE: not meant to be instantiated directly.
  94. * @public
  95. */
  96. export class ClientSession extends TypedEventEmitter<ClientSessionEvents> {
  97. /** @internal */
  98. client: MongoClient;
  99. /** @internal */
  100. sessionPool: ServerSessionPool;
  101. hasEnded: boolean;
  102. clientOptions?: MongoOptions;
  103. supports: { causalConsistency: boolean };
  104. clusterTime?: ClusterTime;
  105. operationTime?: Timestamp;
  106. explicit: boolean;
  107. /** @internal */
  108. owner?: symbol | AbstractCursor;
  109. defaultTransactionOptions: TransactionOptions;
  110. transaction: Transaction;
  111. /** @internal */
  112. [kServerSession]: ServerSession | null;
  113. /** @internal */
  114. [kSnapshotTime]?: Timestamp;
  115. /** @internal */
  116. [kSnapshotEnabled] = false;
  117. /** @internal */
  118. [kPinnedConnection]?: Connection;
  119. /** @internal */
  120. [kTxnNumberIncrement]: number;
  121. /**
  122. * Create a client session.
  123. * @internal
  124. * @param client - The current client
  125. * @param sessionPool - The server session pool (Internal Class)
  126. * @param options - Optional settings
  127. * @param clientOptions - Optional settings provided when creating a MongoClient
  128. */
  129. constructor(
  130. client: MongoClient,
  131. sessionPool: ServerSessionPool,
  132. options: ClientSessionOptions,
  133. clientOptions?: MongoOptions
  134. ) {
  135. super();
  136. if (client == null) {
  137. // TODO(NODE-3483)
  138. throw new MongoRuntimeError('ClientSession requires a MongoClient');
  139. }
  140. if (sessionPool == null || !(sessionPool instanceof ServerSessionPool)) {
  141. // TODO(NODE-3483)
  142. throw new MongoRuntimeError('ClientSession requires a ServerSessionPool');
  143. }
  144. options = options ?? {};
  145. if (options.snapshot === true) {
  146. this[kSnapshotEnabled] = true;
  147. if (options.causalConsistency === true) {
  148. throw new MongoInvalidArgumentError(
  149. 'Properties "causalConsistency" and "snapshot" are mutually exclusive'
  150. );
  151. }
  152. }
  153. this.client = client;
  154. this.sessionPool = sessionPool;
  155. this.hasEnded = false;
  156. this.clientOptions = clientOptions;
  157. this.explicit = !!options.explicit;
  158. this[kServerSession] = this.explicit ? this.sessionPool.acquire() : null;
  159. this[kTxnNumberIncrement] = 0;
  160. const defaultCausalConsistencyValue = this.explicit && options.snapshot !== true;
  161. this.supports = {
  162. // if we can enable causal consistency, do so by default
  163. causalConsistency: options.causalConsistency ?? defaultCausalConsistencyValue
  164. };
  165. this.clusterTime = options.initialClusterTime;
  166. this.operationTime = undefined;
  167. this.owner = options.owner;
  168. this.defaultTransactionOptions = Object.assign({}, options.defaultTransactionOptions);
  169. this.transaction = new Transaction();
  170. }
  171. /** The server id associated with this session */
  172. get id(): ServerSessionId | undefined {
  173. return this[kServerSession]?.id;
  174. }
  175. get serverSession(): ServerSession {
  176. let serverSession = this[kServerSession];
  177. if (serverSession == null) {
  178. if (this.explicit) {
  179. throw new MongoRuntimeError('Unexpected null serverSession for an explicit session');
  180. }
  181. if (this.hasEnded) {
  182. throw new MongoRuntimeError('Unexpected null serverSession for an ended implicit session');
  183. }
  184. serverSession = this.sessionPool.acquire();
  185. this[kServerSession] = serverSession;
  186. }
  187. return serverSession;
  188. }
  189. /** Whether or not this session is configured for snapshot reads */
  190. get snapshotEnabled(): boolean {
  191. return this[kSnapshotEnabled];
  192. }
  193. get loadBalanced(): boolean {
  194. return this.client.topology?.description.type === TopologyType.LoadBalanced;
  195. }
  196. /** @internal */
  197. get pinnedConnection(): Connection | undefined {
  198. return this[kPinnedConnection];
  199. }
  200. /** @internal */
  201. pin(conn: Connection): void {
  202. if (this[kPinnedConnection]) {
  203. throw TypeError('Cannot pin multiple connections to the same session');
  204. }
  205. this[kPinnedConnection] = conn;
  206. conn.emit(
  207. PINNED,
  208. this.inTransaction() ? ConnectionPoolMetrics.TXN : ConnectionPoolMetrics.CURSOR
  209. );
  210. }
  211. /** @internal */
  212. unpin(options?: { force?: boolean; forceClear?: boolean; error?: AnyError }): void {
  213. if (this.loadBalanced) {
  214. return maybeClearPinnedConnection(this, options);
  215. }
  216. this.transaction.unpinServer();
  217. }
  218. get isPinned(): boolean {
  219. return this.loadBalanced ? !!this[kPinnedConnection] : this.transaction.isPinned;
  220. }
  221. /**
  222. * Ends this session on the server
  223. *
  224. * @param options - Optional settings. Currently reserved for future use
  225. */
  226. async endSession(options?: EndSessionOptions): Promise<void> {
  227. try {
  228. if (this.inTransaction()) {
  229. await this.abortTransaction();
  230. }
  231. if (!this.hasEnded) {
  232. const serverSession = this[kServerSession];
  233. if (serverSession != null) {
  234. // release the server session back to the pool
  235. this.sessionPool.release(serverSession);
  236. // Make sure a new serverSession never makes it onto this ClientSession
  237. Object.defineProperty(this, kServerSession, {
  238. value: ServerSession.clone(serverSession),
  239. writable: false
  240. });
  241. }
  242. // mark the session as ended, and emit a signal
  243. this.hasEnded = true;
  244. this.emit('ended', this);
  245. }
  246. } catch {
  247. // spec indicates that we should ignore all errors for `endSessions`
  248. } finally {
  249. maybeClearPinnedConnection(this, { force: true, ...options });
  250. }
  251. }
  252. /**
  253. * Advances the operationTime for a ClientSession.
  254. *
  255. * @param operationTime - the `BSON.Timestamp` of the operation type it is desired to advance to
  256. */
  257. advanceOperationTime(operationTime: Timestamp): void {
  258. if (this.operationTime == null) {
  259. this.operationTime = operationTime;
  260. return;
  261. }
  262. if (operationTime.greaterThan(this.operationTime)) {
  263. this.operationTime = operationTime;
  264. }
  265. }
  266. /**
  267. * Advances the clusterTime for a ClientSession to the provided clusterTime of another ClientSession
  268. *
  269. * @param clusterTime - the $clusterTime returned by the server from another session in the form of a document containing the `BSON.Timestamp` clusterTime and signature
  270. */
  271. advanceClusterTime(clusterTime: ClusterTime): void {
  272. if (!clusterTime || typeof clusterTime !== 'object') {
  273. throw new MongoInvalidArgumentError('input cluster time must be an object');
  274. }
  275. if (!clusterTime.clusterTime || clusterTime.clusterTime._bsontype !== 'Timestamp') {
  276. throw new MongoInvalidArgumentError(
  277. 'input cluster time "clusterTime" property must be a valid BSON Timestamp'
  278. );
  279. }
  280. if (
  281. !clusterTime.signature ||
  282. clusterTime.signature.hash?._bsontype !== 'Binary' ||
  283. (typeof clusterTime.signature.keyId !== 'bigint' &&
  284. typeof clusterTime.signature.keyId !== 'number' &&
  285. clusterTime.signature.keyId?._bsontype !== 'Long') // apparently we decode the key to number?
  286. ) {
  287. throw new MongoInvalidArgumentError(
  288. 'input cluster time must have a valid "signature" property with BSON Binary hash and BSON Long keyId'
  289. );
  290. }
  291. _advanceClusterTime(this, clusterTime);
  292. }
  293. /**
  294. * Used to determine if this session equals another
  295. *
  296. * @param session - The session to compare to
  297. */
  298. equals(session: ClientSession): boolean {
  299. if (!(session instanceof ClientSession)) {
  300. return false;
  301. }
  302. if (this.id == null || session.id == null) {
  303. return false;
  304. }
  305. return ByteUtils.equals(this.id.id.buffer, session.id.id.buffer);
  306. }
  307. /**
  308. * Increment the transaction number on the internal ServerSession
  309. *
  310. * @privateRemarks
  311. * This helper increments a value stored on the client session that will be
  312. * added to the serverSession's txnNumber upon applying it to a command.
  313. * This is because the serverSession is lazily acquired after a connection is obtained
  314. */
  315. incrementTransactionNumber(): void {
  316. this[kTxnNumberIncrement] += 1;
  317. }
  318. /** @returns whether this session is currently in a transaction or not */
  319. inTransaction(): boolean {
  320. return this.transaction.isActive;
  321. }
  322. /**
  323. * Starts a new transaction with the given options.
  324. *
  325. * @param options - Options for the transaction
  326. */
  327. startTransaction(options?: TransactionOptions): void {
  328. if (this[kSnapshotEnabled]) {
  329. throw new MongoCompatibilityError('Transactions are not supported in snapshot sessions');
  330. }
  331. if (this.inTransaction()) {
  332. throw new MongoTransactionError('Transaction already in progress');
  333. }
  334. if (this.isPinned && this.transaction.isCommitted) {
  335. this.unpin();
  336. }
  337. const topologyMaxWireVersion = maxWireVersion(this.client.topology);
  338. if (
  339. isSharded(this.client.topology) &&
  340. topologyMaxWireVersion != null &&
  341. topologyMaxWireVersion < minWireVersionForShardedTransactions
  342. ) {
  343. throw new MongoCompatibilityError(
  344. 'Transactions are not supported on sharded clusters in MongoDB < 4.2.'
  345. );
  346. }
  347. // increment txnNumber
  348. this.incrementTransactionNumber();
  349. // create transaction state
  350. this.transaction = new Transaction({
  351. readConcern:
  352. options?.readConcern ??
  353. this.defaultTransactionOptions.readConcern ??
  354. this.clientOptions?.readConcern,
  355. writeConcern:
  356. options?.writeConcern ??
  357. this.defaultTransactionOptions.writeConcern ??
  358. this.clientOptions?.writeConcern,
  359. readPreference:
  360. options?.readPreference ??
  361. this.defaultTransactionOptions.readPreference ??
  362. this.clientOptions?.readPreference,
  363. maxCommitTimeMS: options?.maxCommitTimeMS ?? this.defaultTransactionOptions.maxCommitTimeMS
  364. });
  365. this.transaction.transition(TxnState.STARTING_TRANSACTION);
  366. }
  367. /**
  368. * Commits the currently active transaction in this session.
  369. */
  370. async commitTransaction(): Promise<Document> {
  371. return endTransactionAsync(this, 'commitTransaction');
  372. }
  373. /**
  374. * Aborts the currently active transaction in this session.
  375. */
  376. async abortTransaction(): Promise<Document> {
  377. return endTransactionAsync(this, 'abortTransaction');
  378. }
  379. /**
  380. * This is here to ensure that ClientSession is never serialized to BSON.
  381. */
  382. toBSON(): never {
  383. throw new MongoRuntimeError('ClientSession cannot be serialized to BSON.');
  384. }
  385. /**
  386. * Runs a provided callback within a transaction, retrying either the commitTransaction operation
  387. * or entire transaction as needed (and when the error permits) to better ensure that
  388. * the transaction can complete successfully.
  389. *
  390. * **IMPORTANT:** This method requires the user to return a Promise, and `await` all operations.
  391. * Any callbacks that do not return a Promise will result in undefined behavior.
  392. *
  393. * @remarks
  394. * This function:
  395. * - Will return the command response from the final commitTransaction if every operation is successful (can be used as a truthy object)
  396. * - Will return `undefined` if the transaction is explicitly aborted with `await session.abortTransaction()`
  397. * - Will throw if one of the operations throws or `throw` statement is used inside the `withTransaction` callback
  398. *
  399. * Checkout a descriptive example here:
  400. * @see https://www.mongodb.com/developer/quickstart/node-transactions/
  401. *
  402. * @param fn - callback to run within a transaction
  403. * @param options - optional settings for the transaction
  404. * @returns A raw command response or undefined
  405. */
  406. async withTransaction<T = void>(
  407. fn: WithTransactionCallback<T>,
  408. options?: TransactionOptions
  409. ): Promise<Document | undefined> {
  410. const startTime = now();
  411. return attemptTransaction(this, startTime, fn, options);
  412. }
  413. }
  414. const MAX_WITH_TRANSACTION_TIMEOUT = 120000;
  415. const NON_DETERMINISTIC_WRITE_CONCERN_ERRORS = new Set([
  416. 'CannotSatisfyWriteConcern',
  417. 'UnknownReplWriteConcern',
  418. 'UnsatisfiableWriteConcern'
  419. ]);
  420. function hasNotTimedOut(startTime: number, max: number) {
  421. return calculateDurationInMs(startTime) < max;
  422. }
  423. function isUnknownTransactionCommitResult(err: MongoError) {
  424. const isNonDeterministicWriteConcernError =
  425. err instanceof MongoServerError &&
  426. err.codeName &&
  427. NON_DETERMINISTIC_WRITE_CONCERN_ERRORS.has(err.codeName);
  428. return (
  429. isMaxTimeMSExpiredError(err) ||
  430. (!isNonDeterministicWriteConcernError &&
  431. err.code !== MONGODB_ERROR_CODES.UnsatisfiableWriteConcern &&
  432. err.code !== MONGODB_ERROR_CODES.UnknownReplWriteConcern)
  433. );
  434. }
  435. export function maybeClearPinnedConnection(
  436. session: ClientSession,
  437. options?: EndSessionOptions
  438. ): void {
  439. // unpin a connection if it has been pinned
  440. const conn = session[kPinnedConnection];
  441. const error = options?.error;
  442. if (
  443. session.inTransaction() &&
  444. error &&
  445. error instanceof MongoError &&
  446. error.hasErrorLabel(MongoErrorLabel.TransientTransactionError)
  447. ) {
  448. return;
  449. }
  450. const topology = session.client.topology;
  451. // NOTE: the spec talks about what to do on a network error only, but the tests seem to
  452. // to validate that we don't unpin on _all_ errors?
  453. if (conn && topology != null) {
  454. const servers = Array.from(topology.s.servers.values());
  455. const loadBalancer = servers[0];
  456. if (options?.error == null || options?.force) {
  457. loadBalancer.pool.checkIn(conn);
  458. conn.emit(
  459. UNPINNED,
  460. session.transaction.state !== TxnState.NO_TRANSACTION
  461. ? ConnectionPoolMetrics.TXN
  462. : ConnectionPoolMetrics.CURSOR
  463. );
  464. if (options?.forceClear) {
  465. loadBalancer.pool.clear({ serviceId: conn.serviceId });
  466. }
  467. }
  468. session[kPinnedConnection] = undefined;
  469. }
  470. }
  471. function isMaxTimeMSExpiredError(err: MongoError) {
  472. if (err == null || !(err instanceof MongoServerError)) {
  473. return false;
  474. }
  475. return (
  476. err.code === MONGODB_ERROR_CODES.MaxTimeMSExpired ||
  477. (err.writeConcernError && err.writeConcernError.code === MONGODB_ERROR_CODES.MaxTimeMSExpired)
  478. );
  479. }
  480. function attemptTransactionCommit<T>(
  481. session: ClientSession,
  482. startTime: number,
  483. fn: WithTransactionCallback<T>,
  484. options?: TransactionOptions
  485. ): Promise<T> {
  486. return session.commitTransaction().catch((err: MongoError) => {
  487. if (
  488. err instanceof MongoError &&
  489. hasNotTimedOut(startTime, MAX_WITH_TRANSACTION_TIMEOUT) &&
  490. !isMaxTimeMSExpiredError(err)
  491. ) {
  492. if (err.hasErrorLabel(MongoErrorLabel.UnknownTransactionCommitResult)) {
  493. return attemptTransactionCommit(session, startTime, fn, options);
  494. }
  495. if (err.hasErrorLabel(MongoErrorLabel.TransientTransactionError)) {
  496. return attemptTransaction(session, startTime, fn, options);
  497. }
  498. }
  499. throw err;
  500. });
  501. }
  502. const USER_EXPLICIT_TXN_END_STATES = new Set<TxnState>([
  503. TxnState.NO_TRANSACTION,
  504. TxnState.TRANSACTION_COMMITTED,
  505. TxnState.TRANSACTION_ABORTED
  506. ]);
  507. function userExplicitlyEndedTransaction(session: ClientSession) {
  508. return USER_EXPLICIT_TXN_END_STATES.has(session.transaction.state);
  509. }
  510. function attemptTransaction<TSchema>(
  511. session: ClientSession,
  512. startTime: number,
  513. fn: WithTransactionCallback<TSchema>,
  514. options?: TransactionOptions
  515. ): Promise<any> {
  516. session.startTransaction(options);
  517. let promise;
  518. try {
  519. promise = fn(session);
  520. } catch (err) {
  521. promise = Promise.reject(err);
  522. }
  523. if (!isPromiseLike(promise)) {
  524. session.abortTransaction().catch(() => null);
  525. throw new MongoInvalidArgumentError(
  526. 'Function provided to `withTransaction` must return a Promise'
  527. );
  528. }
  529. return promise.then(
  530. () => {
  531. if (userExplicitlyEndedTransaction(session)) {
  532. return;
  533. }
  534. return attemptTransactionCommit(session, startTime, fn, options);
  535. },
  536. err => {
  537. function maybeRetryOrThrow(err: MongoError): Promise<any> {
  538. if (
  539. err instanceof MongoError &&
  540. err.hasErrorLabel(MongoErrorLabel.TransientTransactionError) &&
  541. hasNotTimedOut(startTime, MAX_WITH_TRANSACTION_TIMEOUT)
  542. ) {
  543. return attemptTransaction(session, startTime, fn, options);
  544. }
  545. if (isMaxTimeMSExpiredError(err)) {
  546. err.addErrorLabel(MongoErrorLabel.UnknownTransactionCommitResult);
  547. }
  548. throw err;
  549. }
  550. if (session.inTransaction()) {
  551. return session.abortTransaction().then(() => maybeRetryOrThrow(err));
  552. }
  553. return maybeRetryOrThrow(err);
  554. }
  555. );
  556. }
  557. const endTransactionAsync = promisify(
  558. endTransaction as (
  559. session: ClientSession,
  560. commandName: 'abortTransaction' | 'commitTransaction',
  561. callback: (error: Error, result: Document) => void
  562. ) => void
  563. );
  564. function endTransaction(
  565. session: ClientSession,
  566. commandName: 'abortTransaction' | 'commitTransaction',
  567. callback: Callback<Document>
  568. ) {
  569. // handle any initial problematic cases
  570. const txnState = session.transaction.state;
  571. if (txnState === TxnState.NO_TRANSACTION) {
  572. callback(new MongoTransactionError('No transaction started'));
  573. return;
  574. }
  575. if (commandName === 'commitTransaction') {
  576. if (
  577. txnState === TxnState.STARTING_TRANSACTION ||
  578. txnState === TxnState.TRANSACTION_COMMITTED_EMPTY
  579. ) {
  580. // the transaction was never started, we can safely exit here
  581. session.transaction.transition(TxnState.TRANSACTION_COMMITTED_EMPTY);
  582. callback();
  583. return;
  584. }
  585. if (txnState === TxnState.TRANSACTION_ABORTED) {
  586. callback(
  587. new MongoTransactionError('Cannot call commitTransaction after calling abortTransaction')
  588. );
  589. return;
  590. }
  591. } else {
  592. if (txnState === TxnState.STARTING_TRANSACTION) {
  593. // the transaction was never started, we can safely exit here
  594. session.transaction.transition(TxnState.TRANSACTION_ABORTED);
  595. callback();
  596. return;
  597. }
  598. if (txnState === TxnState.TRANSACTION_ABORTED) {
  599. callback(new MongoTransactionError('Cannot call abortTransaction twice'));
  600. return;
  601. }
  602. if (
  603. txnState === TxnState.TRANSACTION_COMMITTED ||
  604. txnState === TxnState.TRANSACTION_COMMITTED_EMPTY
  605. ) {
  606. callback(
  607. new MongoTransactionError('Cannot call abortTransaction after calling commitTransaction')
  608. );
  609. return;
  610. }
  611. }
  612. // construct and send the command
  613. const command: Document = { [commandName]: 1 };
  614. // apply a writeConcern if specified
  615. let writeConcern;
  616. if (session.transaction.options.writeConcern) {
  617. writeConcern = Object.assign({}, session.transaction.options.writeConcern);
  618. } else if (session.clientOptions && session.clientOptions.writeConcern) {
  619. writeConcern = { w: session.clientOptions.writeConcern.w };
  620. }
  621. if (txnState === TxnState.TRANSACTION_COMMITTED) {
  622. writeConcern = Object.assign({ wtimeoutMS: 10000 }, writeConcern, { w: 'majority' });
  623. }
  624. if (writeConcern) {
  625. WriteConcern.apply(command, writeConcern);
  626. }
  627. if (commandName === 'commitTransaction' && session.transaction.options.maxTimeMS) {
  628. Object.assign(command, { maxTimeMS: session.transaction.options.maxTimeMS });
  629. }
  630. function commandHandler(error?: Error, result?: Document) {
  631. if (commandName !== 'commitTransaction') {
  632. session.transaction.transition(TxnState.TRANSACTION_ABORTED);
  633. if (session.loadBalanced) {
  634. maybeClearPinnedConnection(session, { force: false });
  635. }
  636. // The spec indicates that we should ignore all errors on `abortTransaction`
  637. return callback();
  638. }
  639. session.transaction.transition(TxnState.TRANSACTION_COMMITTED);
  640. if (error instanceof MongoError) {
  641. if (
  642. error.hasErrorLabel(MongoErrorLabel.RetryableWriteError) ||
  643. error instanceof MongoWriteConcernError ||
  644. isMaxTimeMSExpiredError(error)
  645. ) {
  646. if (isUnknownTransactionCommitResult(error)) {
  647. error.addErrorLabel(MongoErrorLabel.UnknownTransactionCommitResult);
  648. // per txns spec, must unpin session in this case
  649. session.unpin({ error });
  650. }
  651. } else if (error.hasErrorLabel(MongoErrorLabel.TransientTransactionError)) {
  652. session.unpin({ error });
  653. }
  654. }
  655. callback(error, result);
  656. }
  657. if (session.transaction.recoveryToken) {
  658. command.recoveryToken = session.transaction.recoveryToken;
  659. }
  660. // send the command
  661. executeOperation(
  662. session.client,
  663. new RunAdminCommandOperation(undefined, command, {
  664. session,
  665. readPreference: ReadPreference.primary,
  666. bypassPinningCheck: true
  667. }),
  668. (error, result) => {
  669. if (command.abortTransaction) {
  670. // always unpin on abort regardless of command outcome
  671. session.unpin();
  672. }
  673. if (error instanceof MongoError && error.hasErrorLabel(MongoErrorLabel.RetryableWriteError)) {
  674. // SPEC-1185: apply majority write concern when retrying commitTransaction
  675. if (command.commitTransaction) {
  676. // per txns spec, must unpin session in this case
  677. session.unpin({ force: true });
  678. command.writeConcern = Object.assign({ wtimeout: 10000 }, command.writeConcern, {
  679. w: 'majority'
  680. });
  681. }
  682. return executeOperation(
  683. session.client,
  684. new RunAdminCommandOperation(undefined, command, {
  685. session,
  686. readPreference: ReadPreference.primary,
  687. bypassPinningCheck: true
  688. }),
  689. commandHandler
  690. );
  691. }
  692. commandHandler(error, result);
  693. }
  694. );
  695. }
  696. /** @public */
  697. export type ServerSessionId = { id: Binary };
  698. /**
  699. * Reflects the existence of a session on the server. Can be reused by the session pool.
  700. * WARNING: not meant to be instantiated directly. For internal use only.
  701. * @public
  702. */
  703. export class ServerSession {
  704. id: ServerSessionId;
  705. lastUse: number;
  706. txnNumber: number;
  707. isDirty: boolean;
  708. /** @internal */
  709. constructor() {
  710. this.id = { id: new Binary(uuidV4(), Binary.SUBTYPE_UUID) };
  711. this.lastUse = now();
  712. this.txnNumber = 0;
  713. this.isDirty = false;
  714. }
  715. /**
  716. * Determines if the server session has timed out.
  717. *
  718. * @param sessionTimeoutMinutes - The server's "logicalSessionTimeoutMinutes"
  719. */
  720. hasTimedOut(sessionTimeoutMinutes: number): boolean {
  721. // Take the difference of the lastUse timestamp and now, which will result in a value in
  722. // milliseconds, and then convert milliseconds to minutes to compare to `sessionTimeoutMinutes`
  723. const idleTimeMinutes = Math.round(
  724. ((calculateDurationInMs(this.lastUse) % 86400000) % 3600000) / 60000
  725. );
  726. return idleTimeMinutes > sessionTimeoutMinutes - 1;
  727. }
  728. /**
  729. * @internal
  730. * Cloning meant to keep a readable reference to the server session data
  731. * after ClientSession has ended
  732. */
  733. static clone(serverSession: ServerSession): Readonly<ServerSession> {
  734. const arrayBuffer = new ArrayBuffer(16);
  735. const idBytes = Buffer.from(arrayBuffer);
  736. idBytes.set(serverSession.id.id.buffer);
  737. const id = new Binary(idBytes, serverSession.id.id.sub_type);
  738. // Manual prototype construction to avoid modifying the constructor of this class
  739. return Object.setPrototypeOf(
  740. {
  741. id: { id },
  742. lastUse: serverSession.lastUse,
  743. txnNumber: serverSession.txnNumber,
  744. isDirty: serverSession.isDirty
  745. },
  746. ServerSession.prototype
  747. );
  748. }
  749. }
  750. /**
  751. * Maintains a pool of Server Sessions.
  752. * For internal use only
  753. * @internal
  754. */
  755. export class ServerSessionPool {
  756. client: MongoClient;
  757. sessions: List<ServerSession>;
  758. constructor(client: MongoClient) {
  759. if (client == null) {
  760. throw new MongoRuntimeError('ServerSessionPool requires a MongoClient');
  761. }
  762. this.client = client;
  763. this.sessions = new List<ServerSession>();
  764. }
  765. /**
  766. * Acquire a Server Session from the pool.
  767. * Iterates through each session in the pool, removing any stale sessions
  768. * along the way. The first non-stale session found is removed from the
  769. * pool and returned. If no non-stale session is found, a new ServerSession is created.
  770. */
  771. acquire(): ServerSession {
  772. const sessionTimeoutMinutes = this.client.topology?.logicalSessionTimeoutMinutes ?? 10;
  773. let session: ServerSession | null = null;
  774. // Try to obtain from session pool
  775. while (this.sessions.length > 0) {
  776. const potentialSession = this.sessions.shift();
  777. if (
  778. potentialSession != null &&
  779. (!!this.client.topology?.loadBalanced ||
  780. !potentialSession.hasTimedOut(sessionTimeoutMinutes))
  781. ) {
  782. session = potentialSession;
  783. break;
  784. }
  785. }
  786. // If nothing valid came from the pool make a new one
  787. if (session == null) {
  788. session = new ServerSession();
  789. }
  790. return session;
  791. }
  792. /**
  793. * Release a session to the session pool
  794. * Adds the session back to the session pool if the session has not timed out yet.
  795. * This method also removes any stale sessions from the pool.
  796. *
  797. * @param session - The session to release to the pool
  798. */
  799. release(session: ServerSession): void {
  800. const sessionTimeoutMinutes = this.client.topology?.logicalSessionTimeoutMinutes ?? 10;
  801. if (this.client.topology?.loadBalanced && !sessionTimeoutMinutes) {
  802. this.sessions.unshift(session);
  803. }
  804. if (!sessionTimeoutMinutes) {
  805. return;
  806. }
  807. this.sessions.prune(session => session.hasTimedOut(sessionTimeoutMinutes));
  808. if (!session.hasTimedOut(sessionTimeoutMinutes)) {
  809. if (session.isDirty) {
  810. return;
  811. }
  812. // otherwise, readd this session to the session pool
  813. this.sessions.unshift(session);
  814. }
  815. }
  816. }
  817. /**
  818. * Optionally decorate a command with sessions specific keys
  819. *
  820. * @param session - the session tracking transaction state
  821. * @param command - the command to decorate
  822. * @param options - Optional settings passed to calling operation
  823. *
  824. * @internal
  825. */
  826. export function applySession(
  827. session: ClientSession,
  828. command: Document,
  829. options: CommandOptions
  830. ): MongoDriverError | undefined {
  831. if (session.hasEnded) {
  832. return new MongoExpiredSessionError();
  833. }
  834. // May acquire serverSession here
  835. const serverSession = session.serverSession;
  836. if (serverSession == null) {
  837. return new MongoRuntimeError('Unable to acquire server session');
  838. }
  839. if (options.writeConcern?.w === 0) {
  840. if (session && session.explicit) {
  841. // Error if user provided an explicit session to an unacknowledged write (SPEC-1019)
  842. return new MongoAPIError('Cannot have explicit session with unacknowledged writes');
  843. }
  844. return;
  845. }
  846. // mark the last use of this session, and apply the `lsid`
  847. serverSession.lastUse = now();
  848. command.lsid = serverSession.id;
  849. const inTxnOrTxnCommand = session.inTransaction() || isTransactionCommand(command);
  850. const isRetryableWrite = !!options.willRetryWrite;
  851. if (isRetryableWrite || inTxnOrTxnCommand) {
  852. serverSession.txnNumber += session[kTxnNumberIncrement];
  853. session[kTxnNumberIncrement] = 0;
  854. // TODO(NODE-2674): Preserve int64 sent from MongoDB
  855. command.txnNumber = Long.fromNumber(serverSession.txnNumber);
  856. }
  857. if (!inTxnOrTxnCommand) {
  858. if (session.transaction.state !== TxnState.NO_TRANSACTION) {
  859. session.transaction.transition(TxnState.NO_TRANSACTION);
  860. }
  861. if (
  862. session.supports.causalConsistency &&
  863. session.operationTime &&
  864. commandSupportsReadConcern(command, options)
  865. ) {
  866. command.readConcern = command.readConcern || {};
  867. Object.assign(command.readConcern, { afterClusterTime: session.operationTime });
  868. } else if (session[kSnapshotEnabled]) {
  869. command.readConcern = command.readConcern || { level: ReadConcernLevel.snapshot };
  870. if (session[kSnapshotTime] != null) {
  871. Object.assign(command.readConcern, { atClusterTime: session[kSnapshotTime] });
  872. }
  873. }
  874. return;
  875. }
  876. // now attempt to apply transaction-specific sessions data
  877. // `autocommit` must always be false to differentiate from retryable writes
  878. command.autocommit = false;
  879. if (session.transaction.state === TxnState.STARTING_TRANSACTION) {
  880. session.transaction.transition(TxnState.TRANSACTION_IN_PROGRESS);
  881. command.startTransaction = true;
  882. const readConcern =
  883. session.transaction.options.readConcern || session?.clientOptions?.readConcern;
  884. if (readConcern) {
  885. command.readConcern = readConcern;
  886. }
  887. if (session.supports.causalConsistency && session.operationTime) {
  888. command.readConcern = command.readConcern || {};
  889. Object.assign(command.readConcern, { afterClusterTime: session.operationTime });
  890. }
  891. }
  892. return;
  893. }
  894. export function updateSessionFromResponse(session: ClientSession, document: Document): void {
  895. if (document.$clusterTime) {
  896. _advanceClusterTime(session, document.$clusterTime);
  897. }
  898. if (document.operationTime && session && session.supports.causalConsistency) {
  899. session.advanceOperationTime(document.operationTime);
  900. }
  901. if (document.recoveryToken && session && session.inTransaction()) {
  902. session.transaction._recoveryToken = document.recoveryToken;
  903. }
  904. if (session?.[kSnapshotEnabled] && session[kSnapshotTime] == null) {
  905. // find and aggregate commands return atClusterTime on the cursor
  906. // distinct includes it in the response body
  907. const atClusterTime = document.cursor?.atClusterTime || document.atClusterTime;
  908. if (atClusterTime) {
  909. session[kSnapshotTime] = atClusterTime;
  910. }
  911. }
  912. }