opusdev/vector-similarity-api
1
1import { Binary, type Document, Long, type Timestamp } from './bson';2import type { CommandOptions, Connection } from './cmap/connection';3import { ConnectionPoolMetrics } from './cmap/metrics';4import { type MongoDBResponse } from './cmap/wire_protocol/responses';5import { PINNED, UNPINNED } from './constants';6import type { AbstractCursor } from './cursor/abstract_cursor';7import {8 type AnyError,9 isRetryableWriteError,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 MongoWriteConcernError22} from './error';23import type { MongoClient, MongoOptions } from './mongo_client';24import { TypedEventEmitter } from './mongo_types';25import { executeOperation } from './operations/execute_operation';26import { RunCommandOperation } from './operations/run_command';27import { ReadConcernLevel } from './read_concern';28import { ReadPreference } from './read_preference';29import { type AsyncDisposable, configureResourceManagement } from './resource_management';30import { _advanceClusterTime, type ClusterTime, TopologyType } from './sdam/common';31import { TimeoutContext } from './timeout';32import {33 isTransactionCommand,34 Transaction,35 type TransactionOptions,36 TxnState37} from './transactions';38import {39 ByteUtils,40 calculateDurationInMs,41 commandSupportsReadConcern,42 isPromiseLike,43 List,44 MongoDBNamespace,45 noop,46 now,47 squashError,48 uuidV449} from './utils';50import { WriteConcern, type WriteConcernOptions, type WriteConcernSettings } from './write_concern';51 52/** @public */53export interface ClientSessionOptions {54 /** Whether causal consistency should be enabled on this session */55 causalConsistency?: boolean;56 /** Whether all read operations should be read from the same snapshot for this session (NOTE: not compatible with `causalConsistency=true`) */57 snapshot?: boolean;58 /** The default TransactionOptions to use for transactions started on this session. */59 defaultTransactionOptions?: TransactionOptions;60 /**61 * @public62 * @experimental63 * An overriding timeoutMS value to use for a client-side timeout.64 * If not provided the session uses the timeoutMS specified on the MongoClient.65 */66 defaultTimeoutMS?: number;67 68 /** @internal */69 owner?: symbol | AbstractCursor;70 /** @internal */71 explicit?: boolean;72 /** @internal */73 initialClusterTime?: ClusterTime;74}75 76/** @public */77export type WithTransactionCallback<T = any> = (session: ClientSession) => Promise<T>;78 79/** @public */80export type ClientSessionEvents = {81 ended(session: ClientSession): void;82};83 84/** @public */85export interface EndSessionOptions {86 /**87 * An optional error which caused the call to end this session88 * @internal89 */90 error?: AnyError;91 force?: boolean;92 forceClear?: boolean;93 94 /** Specifies the time an operation will run until it throws a timeout error */95 timeoutMS?: number;96}97 98/**99 * A class representing a client session on the server100 *101 * NOTE: not meant to be instantiated directly.102 * @public103 */104export class ClientSession105 extends TypedEventEmitter<ClientSessionEvents>106 implements AsyncDisposable107{108 /** @internal */109 client: MongoClient;110 /** @internal */111 sessionPool: ServerSessionPool;112 hasEnded: boolean;113 clientOptions: MongoOptions;114 supports: { causalConsistency: boolean };115 clusterTime?: ClusterTime;116 operationTime?: Timestamp;117 explicit: boolean;118 /** @internal */119 owner?: symbol | AbstractCursor;120 defaultTransactionOptions: TransactionOptions;121 /** @deprecated - Will be made internal in the next major release */122 transaction: Transaction;123 /**124 * @internal125 * Keeps track of whether or not the current transaction has attempted to be committed. Is126 * initially undefined. Gets set to false when startTransaction is called. When commitTransaction is sent to server, if the commitTransaction succeeds, it is then set to undefined, otherwise, set to true127 */128 private commitAttempted?: boolean;129 public readonly snapshotEnabled: boolean;130 131 /** @internal */132 private _serverSession: ServerSession | null;133 /** @internal */134 public snapshotTime?: Timestamp;135 /** @internal */136 public pinnedConnection?: Connection;137 /** @internal */138 public txnNumberIncrement: number;139 /**140 * @experimental141 * Specifies the time an operation in a given `ClientSession` will run until it throws a timeout error142 */143 timeoutMS?: number;144 145 /** @internal */146 public timeoutContext: TimeoutContext | null = null;147 148 /**149 * Create a client session.150 * @internal151 * @param client - The current client152 * @param sessionPool - The server session pool (Internal Class)153 * @param options - Optional settings154 * @param clientOptions - Optional settings provided when creating a MongoClient155 */156 constructor(157 client: MongoClient,158 sessionPool: ServerSessionPool,159 options: ClientSessionOptions,160 clientOptions: MongoOptions161 ) {162 super();163 this.on('error', noop);164 165 if (client == null) {166 // TODO(NODE-3483)167 throw new MongoRuntimeError('ClientSession requires a MongoClient');168 }169 170 if (sessionPool == null || !(sessionPool instanceof ServerSessionPool)) {171 // TODO(NODE-3483)172 throw new MongoRuntimeError('ClientSession requires a ServerSessionPool');173 }174 175 options = options ?? {};176 177 this.snapshotEnabled = options.snapshot === true;178 if (options.causalConsistency === true && this.snapshotEnabled) {179 throw new MongoInvalidArgumentError(180 'Properties "causalConsistency" and "snapshot" are mutually exclusive'181 );182 }183 184 this.client = client;185 this.sessionPool = sessionPool;186 this.hasEnded = false;187 this.clientOptions = clientOptions;188 this.timeoutMS = options.defaultTimeoutMS ?? client.s.options?.timeoutMS;189 190 this.explicit = !!options.explicit;191 this._serverSession = this.explicit ? this.sessionPool.acquire() : null;192 this.txnNumberIncrement = 0;193 194 const defaultCausalConsistencyValue = this.explicit && options.snapshot !== true;195 this.supports = {196 // if we can enable causal consistency, do so by default197 causalConsistency: options.causalConsistency ?? defaultCausalConsistencyValue198 };199 200 this.clusterTime = options.initialClusterTime;201 202 this.operationTime = undefined;203 this.owner = options.owner;204 this.defaultTransactionOptions = { ...options.defaultTransactionOptions };205 this.transaction = new Transaction();206 }207 208 /** The server id associated with this session */209 get id(): ServerSessionId | undefined {210 return this.serverSession?.id;211 }212 213 get serverSession(): ServerSession {214 let serverSession = this._serverSession;215 if (serverSession == null) {216 if (this.explicit) {217 throw new MongoRuntimeError('Unexpected null serverSession for an explicit session');218 }219 if (this.hasEnded) {220 throw new MongoRuntimeError('Unexpected null serverSession for an ended implicit session');221 }222 serverSession = this.sessionPool.acquire();223 this._serverSession = serverSession;224 }225 return serverSession;226 }227 228 get loadBalanced(): boolean {229 return this.client.topology?.description.type === TopologyType.LoadBalanced;230 }231 232 /** @internal */233 pin(conn: Connection): void {234 if (this.pinnedConnection) {235 throw TypeError('Cannot pin multiple connections to the same session');236 }237 238 this.pinnedConnection = conn;239 conn.emit(240 PINNED,241 this.inTransaction() ? ConnectionPoolMetrics.TXN : ConnectionPoolMetrics.CURSOR242 );243 }244 245 /** @internal */246 unpin(options?: { force?: boolean; forceClear?: boolean; error?: AnyError }): void {247 if (this.loadBalanced) {248 return maybeClearPinnedConnection(this, options);249 }250 251 this.transaction.unpinServer();252 }253 254 get isPinned(): boolean {255 return this.loadBalanced ? !!this.pinnedConnection : this.transaction.isPinned;256 }257 258 /**259 * Frees any client-side resources held by the current session. If a session is in a transaction,260 * the transaction is aborted.261 *262 * Does not end the session on the server.263 *264 * @param options - Optional settings. Currently reserved for future use265 */266 async endSession(options?: EndSessionOptions): Promise<void> {267 try {268 if (this.inTransaction()) {269 await this.abortTransaction({ ...options, throwTimeout: true });270 }271 } catch (error) {272 // spec indicates that we should ignore all errors for `endSessions`273 if (error.name === 'MongoOperationTimeoutError') throw error;274 squashError(error);275 } finally {276 if (!this.hasEnded) {277 const serverSession = this.serverSession;278 if (serverSession != null) {279 // release the server session back to the pool280 this.sessionPool.release(serverSession);281 // Store a clone of the server session for reference (debugging)282 this._serverSession = new ServerSession(serverSession);283 }284 // mark the session as ended, and emit a signal285 this.hasEnded = true;286 this.emit('ended', this);287 }288 maybeClearPinnedConnection(this, { force: true, ...options });289 }290 }291 /**292 * @beta293 * @experimental294 * An alias for {@link ClientSession.endSession|ClientSession.endSession()}.295 */296 declare [Symbol.asyncDispose]: () => Promise<void>;297 /** @internal */298 async asyncDispose() {299 await this.endSession({ force: true });300 }301 302 /**303 * Advances the operationTime for a ClientSession.304 *305 * @param operationTime - the `BSON.Timestamp` of the operation type it is desired to advance to306 */307 advanceOperationTime(operationTime: Timestamp): void {308 if (this.operationTime == null) {309 this.operationTime = operationTime;310 return;311 }312 313 if (operationTime.greaterThan(this.operationTime)) {314 this.operationTime = operationTime;315 }316 }317 318 /**319 * Advances the clusterTime for a ClientSession to the provided clusterTime of another ClientSession320 *321 * @param clusterTime - the $clusterTime returned by the server from another session in the form of a document containing the `BSON.Timestamp` clusterTime and signature322 */323 advanceClusterTime(clusterTime: ClusterTime): void {324 if (!clusterTime || typeof clusterTime !== 'object') {325 throw new MongoInvalidArgumentError('input cluster time must be an object');326 }327 if (!clusterTime.clusterTime || clusterTime.clusterTime._bsontype !== 'Timestamp') {328 throw new MongoInvalidArgumentError(329 'input cluster time "clusterTime" property must be a valid BSON Timestamp'330 );331 }332 if (333 !clusterTime.signature ||334 clusterTime.signature.hash?._bsontype !== 'Binary' ||335 (typeof clusterTime.signature.keyId !== 'bigint' &&336 typeof clusterTime.signature.keyId !== 'number' &&337 clusterTime.signature.keyId?._bsontype !== 'Long') // apparently we decode the key to number?338 ) {339 throw new MongoInvalidArgumentError(340 'input cluster time must have a valid "signature" property with BSON Binary hash and BSON Long keyId'341 );342 }343 344 _advanceClusterTime(this, clusterTime);345 }346 347 /**348 * Used to determine if this session equals another349 *350 * @param session - The session to compare to351 */352 equals(session: ClientSession): boolean {353 if (!(session instanceof ClientSession)) {354 return false;355 }356 357 if (this.id == null || session.id == null) {358 return false;359 }360 361 return ByteUtils.equals(this.id.id.buffer, session.id.id.buffer);362 }363 364 /**365 * Increment the transaction number on the internal ServerSession366 *367 * @privateRemarks368 * This helper increments a value stored on the client session that will be369 * added to the serverSession's txnNumber upon applying it to a command.370 * This is because the serverSession is lazily acquired after a connection is obtained371 */372 incrementTransactionNumber(): void {373 this.txnNumberIncrement += 1;374 }375 376 /** @returns whether this session is currently in a transaction or not */377 inTransaction(): boolean {378 return this.transaction.isActive;379 }380 381 /**382 * Starts a new transaction with the given options.383 *384 * @remarks385 * **IMPORTANT**: Running operations in parallel is not supported during a transaction. The use of `Promise.all`,386 * `Promise.allSettled`, `Promise.race`, etc to parallelize operations inside a transaction is387 * undefined behaviour.388 *389 * @param options - Options for the transaction390 */391 startTransaction(options?: TransactionOptions): void {392 if (this.snapshotEnabled) {393 throw new MongoCompatibilityError('Transactions are not supported in snapshot sessions');394 }395 396 if (this.inTransaction()) {397 throw new MongoTransactionError('Transaction already in progress');398 }399 400 if (this.isPinned && this.transaction.isCommitted) {401 this.unpin();402 }403 404 this.commitAttempted = false;405 // increment txnNumber406 this.incrementTransactionNumber();407 // create transaction state408 this.transaction = new Transaction({409 readConcern:410 options?.readConcern ??411 this.defaultTransactionOptions.readConcern ??412 this.clientOptions?.readConcern,413 writeConcern:414 options?.writeConcern ??415 this.defaultTransactionOptions.writeConcern ??416 this.clientOptions?.writeConcern,417 readPreference:418 options?.readPreference ??419 this.defaultTransactionOptions.readPreference ??420 this.clientOptions?.readPreference,421 maxCommitTimeMS: options?.maxCommitTimeMS ?? this.defaultTransactionOptions.maxCommitTimeMS422 });423 424 this.transaction.transition(TxnState.STARTING_TRANSACTION);425 }426 427 /**428 * Commits the currently active transaction in this session.429 *430 * @param options - Optional options, can be used to override `defaultTimeoutMS`.431 */432 async commitTransaction(options?: { timeoutMS?: number }): Promise<void> {433 if (this.transaction.state === TxnState.NO_TRANSACTION) {434 throw new MongoTransactionError('No transaction started');435 }436 437 if (438 this.transaction.state === TxnState.STARTING_TRANSACTION ||439 this.transaction.state === TxnState.TRANSACTION_COMMITTED_EMPTY440 ) {441 // the transaction was never started, we can safely exit here442 this.transaction.transition(TxnState.TRANSACTION_COMMITTED_EMPTY);443 return;444 }445 446 if (this.transaction.state === TxnState.TRANSACTION_ABORTED) {447 throw new MongoTransactionError(448 'Cannot call commitTransaction after calling abortTransaction'449 );450 }451 452 const command: {453 commitTransaction: 1;454 writeConcern?: WriteConcernSettings;455 recoveryToken?: Document;456 maxTimeMS?: number;457 } = { commitTransaction: 1 };458 459 const timeoutMS =460 typeof options?.timeoutMS === 'number'461 ? options.timeoutMS462 : typeof this.timeoutMS === 'number'463 ? this.timeoutMS464 : null;465 466 const wc = this.transaction.options.writeConcern ?? this.clientOptions?.writeConcern;467 if (wc != null) {468 if (timeoutMS == null && this.timeoutContext == null) {469 WriteConcern.apply(command, { wtimeoutMS: 10000, w: 'majority', ...wc });470 } else {471 const wcKeys = Object.keys(wc);472 if (wcKeys.length > 2 || (!wcKeys.includes('wtimeoutMS') && !wcKeys.includes('wTimeoutMS')))473 // if the write concern was specified with wTimeoutMS, then we set both wtimeoutMS and wTimeoutMS, guaranteeing at least two keys, so if we have more than two keys, then we can automatically assume that we should add the write concern to the command. If it has 2 or fewer keys, we need to check that those keys aren't the wtimeoutMS or wTimeoutMS options before we add the write concern to the command474 WriteConcern.apply(command, { ...wc, wtimeoutMS: undefined });475 }476 }477 478 if (this.transaction.state === TxnState.TRANSACTION_COMMITTED || this.commitAttempted) {479 if (timeoutMS == null && this.timeoutContext == null) {480 WriteConcern.apply(command, { wtimeoutMS: 10000, ...wc, w: 'majority' });481 } else {482 WriteConcern.apply(command, { w: 'majority', ...wc, wtimeoutMS: undefined });483 }484 }485 486 if (typeof this.transaction.options.maxTimeMS === 'number') {487 command.maxTimeMS = this.transaction.options.maxTimeMS;488 }489 490 if (this.transaction.recoveryToken) {491 command.recoveryToken = this.transaction.recoveryToken;492 }493 494 const operation = new RunCommandOperation(new MongoDBNamespace('admin'), command, {495 session: this,496 readPreference: ReadPreference.primary,497 bypassPinningCheck: true498 });499 500 const timeoutContext =501 this.timeoutContext ??502 (typeof timeoutMS === 'number'503 ? TimeoutContext.create({504 serverSelectionTimeoutMS: this.clientOptions.serverSelectionTimeoutMS,505 socketTimeoutMS: this.clientOptions.socketTimeoutMS,506 timeoutMS507 })508 : null);509 510 try {511 await executeOperation(this.client, operation, timeoutContext);512 this.commitAttempted = undefined;513 return;514 } catch (firstCommitError) {515 this.commitAttempted = true;516 if (firstCommitError instanceof MongoError && isRetryableWriteError(firstCommitError)) {517 // SPEC-1185: apply majority write concern when retrying commitTransaction518 WriteConcern.apply(command, { wtimeoutMS: 10000, ...wc, w: 'majority' });519 // per txns spec, must unpin session in this case520 this.unpin({ force: true });521 522 try {523 await executeOperation(524 this.client,525 new RunCommandOperation(new MongoDBNamespace('admin'), command, {526 session: this,527 readPreference: ReadPreference.primary,528 bypassPinningCheck: true529 }),530 timeoutContext531 );532 return;533 } catch (retryCommitError) {534 // If the retry failed, we process that error instead of the original535 if (shouldAddUnknownTransactionCommitResultLabel(retryCommitError)) {536 retryCommitError.addErrorLabel(MongoErrorLabel.UnknownTransactionCommitResult);537 }538 539 if (shouldUnpinAfterCommitError(retryCommitError)) {540 this.unpin({ error: retryCommitError });541 }542 543 throw retryCommitError;544 }545 }546 547 if (shouldAddUnknownTransactionCommitResultLabel(firstCommitError)) {548 firstCommitError.addErrorLabel(MongoErrorLabel.UnknownTransactionCommitResult);549 }550 551 if (shouldUnpinAfterCommitError(firstCommitError)) {552 this.unpin({ error: firstCommitError });553 }554 555 throw firstCommitError;556 } finally {557 this.transaction.transition(TxnState.TRANSACTION_COMMITTED);558 }559 }560 561 /**562 * Aborts the currently active transaction in this session.563 *564 * @param options - Optional options, can be used to override `defaultTimeoutMS`.565 */566 async abortTransaction(options?: { timeoutMS?: number }): Promise<void>;567 /** @internal */568 async abortTransaction(options?: { timeoutMS?: number; throwTimeout?: true }): Promise<void>;569 async abortTransaction(options?: { timeoutMS?: number; throwTimeout?: true }): Promise<void> {570 if (this.transaction.state === TxnState.NO_TRANSACTION) {571 throw new MongoTransactionError('No transaction started');572 }573 574 if (this.transaction.state === TxnState.STARTING_TRANSACTION) {575 // the transaction was never started, we can safely exit here576 this.transaction.transition(TxnState.TRANSACTION_ABORTED);577 return;578 }579 580 if (this.transaction.state === TxnState.TRANSACTION_ABORTED) {581 throw new MongoTransactionError('Cannot call abortTransaction twice');582 }583 584 if (585 this.transaction.state === TxnState.TRANSACTION_COMMITTED ||586 this.transaction.state === TxnState.TRANSACTION_COMMITTED_EMPTY587 ) {588 throw new MongoTransactionError(589 'Cannot call abortTransaction after calling commitTransaction'590 );591 }592 593 const command: {594 abortTransaction: 1;595 writeConcern?: WriteConcernOptions;596 recoveryToken?: Document;597 } = { abortTransaction: 1 };598 599 const timeoutMS =600 typeof options?.timeoutMS === 'number'601 ? options.timeoutMS602 : this.timeoutContext?.csotEnabled()603 ? this.timeoutContext.timeoutMS // refresh timeoutMS for abort operation604 : typeof this.timeoutMS === 'number'605 ? this.timeoutMS606 : null;607 608 const timeoutContext =609 timeoutMS != null610 ? TimeoutContext.create({611 timeoutMS,612 serverSelectionTimeoutMS: this.clientOptions.serverSelectionTimeoutMS,613 socketTimeoutMS: this.clientOptions.socketTimeoutMS614 })615 : null;616 617 const wc = this.transaction.options.writeConcern ?? this.clientOptions?.writeConcern;618 if (wc != null && timeoutMS == null) {619 WriteConcern.apply(command, { wtimeoutMS: 10000, w: 'majority', ...wc });620 }621 622 if (this.transaction.recoveryToken) {623 command.recoveryToken = this.transaction.recoveryToken;624 }625 626 const operation = new RunCommandOperation(new MongoDBNamespace('admin'), command, {627 session: this,628 readPreference: ReadPreference.primary,629 bypassPinningCheck: true630 });631 632 try {633 await executeOperation(this.client, operation, timeoutContext);634 this.unpin();635 return;636 } catch (firstAbortError) {637 this.unpin();638 639 if (firstAbortError.name === 'MongoRuntimeError') throw firstAbortError;640 if (options?.throwTimeout && firstAbortError.name === 'MongoOperationTimeoutError') {641 throw firstAbortError;642 }643 644 if (firstAbortError instanceof MongoError && isRetryableWriteError(firstAbortError)) {645 try {646 await executeOperation(this.client, operation, timeoutContext);647 return;648 } catch (secondAbortError) {649 if (secondAbortError.name === 'MongoRuntimeError') throw secondAbortError;650 if (options?.throwTimeout && secondAbortError.name === 'MongoOperationTimeoutError') {651 throw secondAbortError;652 }653 // we do not retry the retry654 }655 }656 657 // The spec indicates that if the operation times out or fails with a non-retryable error, we should ignore all errors on `abortTransaction`658 } finally {659 this.transaction.transition(TxnState.TRANSACTION_ABORTED);660 if (this.loadBalanced) {661 maybeClearPinnedConnection(this, { force: false });662 }663 }664 }665 666 /**667 * This is here to ensure that ClientSession is never serialized to BSON.668 */669 toBSON(): never {670 throw new MongoRuntimeError('ClientSession cannot be serialized to BSON.');671 }672 673 /**674 * Starts a transaction and runs a provided function, ensuring the commitTransaction is always attempted when all operations run in the function have completed.675 *676 * **IMPORTANT:** This method requires the function passed in to return a Promise. That promise must be made by `await`-ing all operations in such a way that rejections are propagated to the returned promise.677 *678 * **IMPORTANT:** Running operations in parallel is not supported during a transaction. The use of `Promise.all`,679 * `Promise.allSettled`, `Promise.race`, etc to parallelize operations inside a transaction is680 * undefined behaviour.681 *682 * **IMPORTANT:** When running an operation inside a `withTransaction` callback, if it is not683 * provided the explicit session in its options, it will not be part of the transaction and it will not respect timeoutMS.684 *685 *686 * @remarks687 * - If all operations successfully complete and the `commitTransaction` operation is successful, then the provided function will return the result of the provided function.688 * - If the transaction is unable to complete or an error is thrown from within the provided function, then the provided function will throw an error.689 * - If the transaction is manually aborted within the provided function it will not throw.690 * - If the driver needs to attempt to retry the operations, the provided function may be called multiple times.691 *692 * Checkout a descriptive example here:693 * @see https://www.mongodb.com/blog/post/quick-start-nodejs--mongodb--how-to-implement-transactions694 *695 * If a command inside withTransaction fails:696 * - It may cause the transaction on the server to be aborted.697 * - This situation is normally handled transparently by the driver.698 * - However, if the application catches such an error and does not rethrow it, the driver will not be able to determine whether the transaction was aborted or not.699 * - The driver will then retry the transaction indefinitely.700 *701 * To avoid this situation, the application must not silently handle errors within the provided function.702 * If the application needs to handle errors within, it must await all operations such that if an operation is rejected it becomes the rejection of the callback function passed into withTransaction.703 *704 * @param fn - callback to run within a transaction705 * @param options - optional settings for the transaction706 * @returns A raw command response or undefined707 */708 async withTransaction<T = any>(709 fn: WithTransactionCallback<T>,710 options?: TransactionOptions & {711 /**712 * Configures a timeoutMS expiry for the entire withTransactionCallback.713 *714 * @remarks715 * - The remaining timeout will not be applied to callback operations that do not use the ClientSession.716 * - Overriding timeoutMS for operations executed using the explicit session inside the provided callback will result in a client-side error.717 */718 timeoutMS?: number;719 }720 ): Promise<T> {721 const MAX_TIMEOUT = 120000;722 723 const timeoutMS = options?.timeoutMS ?? this.timeoutMS ?? null;724 this.timeoutContext =725 timeoutMS != null726 ? TimeoutContext.create({727 timeoutMS,728 serverSelectionTimeoutMS: this.clientOptions.serverSelectionTimeoutMS,729 socketTimeoutMS: this.clientOptions.socketTimeoutMS730 })731 : null;732 733 const startTime = this.timeoutContext?.csotEnabled() ? this.timeoutContext.start : now();734 735 let committed = false;736 let result: any;737 738 try {739 while (!committed) {740 this.startTransaction(options); // may throw on error741 742 try {743 const promise = fn(this);744 if (!isPromiseLike(promise)) {745 throw new MongoInvalidArgumentError(746 'Function provided to `withTransaction` must return a Promise'747 );748 }749 750 result = await promise;751 752 if (753 this.transaction.state === TxnState.NO_TRANSACTION ||754 this.transaction.state === TxnState.TRANSACTION_COMMITTED ||755 this.transaction.state === TxnState.TRANSACTION_ABORTED756 ) {757 // Assume callback intentionally ended the transaction758 return result;759 }760 } catch (fnError) {761 if (!(fnError instanceof MongoError) || fnError instanceof MongoInvalidArgumentError) {762 await this.abortTransaction();763 throw fnError;764 }765 766 if (767 this.transaction.state === TxnState.STARTING_TRANSACTION ||768 this.transaction.state === TxnState.TRANSACTION_IN_PROGRESS769 ) {770 await this.abortTransaction();771 }772 773 if (774 fnError.hasErrorLabel(MongoErrorLabel.TransientTransactionError) &&775 (this.timeoutContext != null || now() - startTime < MAX_TIMEOUT)776 ) {777 continue;778 }779 780 throw fnError;781 }782 783 while (!committed) {784 try {785 /*786 * We will rely on ClientSession.commitTransaction() to787 * apply a majority write concern if commitTransaction is788 * being retried (see: DRIVERS-601)789 */790 await this.commitTransaction();791 committed = true;792 } catch (commitError) {793 /*794 * Note: a maxTimeMS error will have the MaxTimeMSExpired795 * code (50) and can be reported as a top-level error or796 * inside writeConcernError, ex.797 * { ok:0, code: 50, codeName: 'MaxTimeMSExpired' }798 * { ok:1, writeConcernError: { code: 50, codeName: 'MaxTimeMSExpired' } }799 */800 if (801 !isMaxTimeMSExpiredError(commitError) &&802 commitError.hasErrorLabel(MongoErrorLabel.UnknownTransactionCommitResult) &&803 (this.timeoutContext != null || now() - startTime < MAX_TIMEOUT)804 ) {805 continue;806 }807 808 if (809 commitError.hasErrorLabel(MongoErrorLabel.TransientTransactionError) &&810 (this.timeoutContext != null || now() - startTime < MAX_TIMEOUT)811 ) {812 break;813 }814 815 throw commitError;816 }817 }818 }819 return result;820 } finally {821 this.timeoutContext = null;822 }823 }824}825 826configureResourceManagement(ClientSession.prototype);827 828const NON_DETERMINISTIC_WRITE_CONCERN_ERRORS = new Set([829 'CannotSatisfyWriteConcern',830 'UnknownReplWriteConcern',831 'UnsatisfiableWriteConcern'832]);833 834function shouldUnpinAfterCommitError(commitError: Error) {835 if (commitError instanceof MongoError) {836 if (837 isRetryableWriteError(commitError) ||838 commitError instanceof MongoWriteConcernError ||839 isMaxTimeMSExpiredError(commitError)840 ) {841 if (isUnknownTransactionCommitResult(commitError)) {842 // per txns spec, must unpin session in this case843 return true;844 }845 } else if (commitError.hasErrorLabel(MongoErrorLabel.TransientTransactionError)) {846 return true;847 }848 }849 return false;850}851 852function shouldAddUnknownTransactionCommitResultLabel(commitError: MongoError) {853 let ok = isRetryableWriteError(commitError);854 ok ||= commitError instanceof MongoWriteConcernError;855 ok ||= isMaxTimeMSExpiredError(commitError);856 ok &&= isUnknownTransactionCommitResult(commitError);857 return ok;858}859 860function isUnknownTransactionCommitResult(err: MongoError): err is MongoError {861 const isNonDeterministicWriteConcernError =862 err instanceof MongoServerError &&863 err.codeName &&864 NON_DETERMINISTIC_WRITE_CONCERN_ERRORS.has(err.codeName);865 866 return (867 isMaxTimeMSExpiredError(err) ||868 (!isNonDeterministicWriteConcernError &&869 err.code !== MONGODB_ERROR_CODES.UnsatisfiableWriteConcern &&870 err.code !== MONGODB_ERROR_CODES.UnknownReplWriteConcern)871 );872}873 874export function maybeClearPinnedConnection(875 session: ClientSession,876 options?: EndSessionOptions877): void {878 // unpin a connection if it has been pinned879 const conn = session.pinnedConnection;880 const error = options?.error;881 882 if (883 session.inTransaction() &&884 error &&885 error instanceof MongoError &&886 error.hasErrorLabel(MongoErrorLabel.TransientTransactionError)887 ) {888 return;889 }890 891 const topology = session.client.topology;892 // NOTE: the spec talks about what to do on a network error only, but the tests seem to893 // to validate that we don't unpin on _all_ errors?894 if (conn && topology != null) {895 const servers = Array.from(topology.s.servers.values());896 const loadBalancer = servers[0];897 898 if (options?.error == null || options?.force) {899 loadBalancer.pool.checkIn(conn);900 session.pinnedConnection = undefined;901 conn.emit(902 UNPINNED,903 session.transaction.state !== TxnState.NO_TRANSACTION904 ? ConnectionPoolMetrics.TXN905 : ConnectionPoolMetrics.CURSOR906 );907 908 if (options?.forceClear) {909 loadBalancer.pool.clear({ serviceId: conn.serviceId });910 }911 }912 }913}914 915function isMaxTimeMSExpiredError(err: MongoError): boolean {916 if (err == null || !(err instanceof MongoServerError)) {917 return false;918 }919 920 return (921 err.code === MONGODB_ERROR_CODES.MaxTimeMSExpired ||922 err.writeConcernError?.code === MONGODB_ERROR_CODES.MaxTimeMSExpired923 );924}925 926/** @public */927export type ServerSessionId = { id: Binary };928 929/**930 * Reflects the existence of a session on the server. Can be reused by the session pool.931 * WARNING: not meant to be instantiated directly. For internal use only.932 * @public933 */934export class ServerSession {935 id: ServerSessionId;936 lastUse: number;937 txnNumber: number;938 isDirty: boolean;939 940 /** @internal */941 constructor(cloned?: ServerSession | null) {942 if (cloned != null) {943 const idBytes = Buffer.allocUnsafe(16);944 idBytes.set(cloned.id.id.buffer);945 this.id = { id: new Binary(idBytes, cloned.id.id.sub_type) };946 this.lastUse = cloned.lastUse;947 this.txnNumber = cloned.txnNumber;948 this.isDirty = cloned.isDirty;949 return;950 }951 this.id = { id: new Binary(uuidV4(), Binary.SUBTYPE_UUID) };952 this.lastUse = now();953 this.txnNumber = 0;954 this.isDirty = false;955 }956 957 /**958 * Determines if the server session has timed out.959 *960 * @param sessionTimeoutMinutes - The server's "logicalSessionTimeoutMinutes"961 */962 hasTimedOut(sessionTimeoutMinutes: number): boolean {963 // Take the difference of the lastUse timestamp and now, which will result in a value in964 // milliseconds, and then convert milliseconds to minutes to compare to `sessionTimeoutMinutes`965 const idleTimeMinutes = Math.round(966 ((calculateDurationInMs(this.lastUse) % 86400000) % 3600000) / 60000967 );968 969 return idleTimeMinutes > sessionTimeoutMinutes - 1;970 }971}972 973/**974 * Maintains a pool of Server Sessions.975 * For internal use only976 * @internal977 */978export class ServerSessionPool {979 client: MongoClient;980 sessions: List<ServerSession>;981 982 constructor(client: MongoClient) {983 if (client == null) {984 throw new MongoRuntimeError('ServerSessionPool requires a MongoClient');985 }986 987 this.client = client;988 this.sessions = new List<ServerSession>();989 }990 991 /**992 * Acquire a Server Session from the pool.993 * Iterates through each session in the pool, removing any stale sessions994 * along the way. The first non-stale session found is removed from the995 * pool and returned. If no non-stale session is found, a new ServerSession is created.996 */997 acquire(): ServerSession {998 const sessionTimeoutMinutes = this.client.topology?.logicalSessionTimeoutMinutes ?? 10;999 1000 let session: ServerSession | null = null;1001 1002 // Try to obtain from session pool1003 while (this.sessions.length > 0) {1004 const potentialSession = this.sessions.shift();1005 if (1006 potentialSession != null &&1007 (!!this.client.topology?.loadBalanced ||1008 !potentialSession.hasTimedOut(sessionTimeoutMinutes))1009 ) {1010 session = potentialSession;1011 break;1012 }1013 }1014 1015 // If nothing valid came from the pool make a new one1016 if (session == null) {1017 session = new ServerSession();1018 }1019 1020 return session;1021 }1022 1023 /**1024 * Release a session to the session pool1025 * Adds the session back to the session pool if the session has not timed out yet.1026 * This method also removes any stale sessions from the pool.1027 *1028 * @param session - The session to release to the pool1029 */1030 release(session: ServerSession): void {1031 const sessionTimeoutMinutes = this.client.topology?.logicalSessionTimeoutMinutes ?? 10;1032 1033 if (this.client.topology?.loadBalanced && !sessionTimeoutMinutes) {1034 this.sessions.unshift(session);1035 }1036 1037 if (!sessionTimeoutMinutes) {1038 return;1039 }1040 1041 this.sessions.prune(session => session.hasTimedOut(sessionTimeoutMinutes));1042 1043 if (!session.hasTimedOut(sessionTimeoutMinutes)) {1044 if (session.isDirty) {1045 return;1046 }1047 1048 // otherwise, readd this session to the session pool1049 this.sessions.unshift(session);1050 }1051 }1052}1053 1054/**1055 * Optionally decorate a command with sessions specific keys1056 *1057 * @param session - the session tracking transaction state1058 * @param command - the command to decorate1059 * @param options - Optional settings passed to calling operation1060 *1061 * @internal1062 */1063export function applySession(1064 session: ClientSession,1065 command: Document,1066 options: CommandOptions1067): MongoDriverError | undefined {1068 if (session.hasEnded) {1069 return new MongoExpiredSessionError();1070 }1071 1072 // May acquire serverSession here1073 const serverSession = session.serverSession;1074 if (serverSession == null) {1075 return new MongoRuntimeError('Unable to acquire server session');1076 }1077 1078 if (options.writeConcern?.w === 0) {1079 if (session && session.explicit) {1080 // Error if user provided an explicit session to an unacknowledged write (SPEC-1019)1081 return new MongoAPIError('Cannot have explicit session with unacknowledged writes');1082 }1083 return;1084 }1085 1086 // mark the last use of this session, and apply the `lsid`1087 serverSession.lastUse = now();1088 command.lsid = serverSession.id;1089 1090 const inTxnOrTxnCommand = session.inTransaction() || isTransactionCommand(command);1091 const isRetryableWrite = !!options.willRetryWrite;1092 1093 if (isRetryableWrite || inTxnOrTxnCommand) {1094 serverSession.txnNumber += session.txnNumberIncrement;1095 session.txnNumberIncrement = 0;1096 // TODO(NODE-2674): Preserve int64 sent from MongoDB1097 command.txnNumber = Long.fromNumber(serverSession.txnNumber);1098 }1099 1100 if (!inTxnOrTxnCommand) {1101 if (session.transaction.state !== TxnState.NO_TRANSACTION) {1102 session.transaction.transition(TxnState.NO_TRANSACTION);1103 }1104 1105 if (1106 session.supports.causalConsistency &&1107 session.operationTime &&1108 commandSupportsReadConcern(command)1109 ) {1110 command.readConcern = command.readConcern || {};1111 Object.assign(command.readConcern, { afterClusterTime: session.operationTime });1112 } else if (session.snapshotEnabled) {1113 command.readConcern = command.readConcern || { level: ReadConcernLevel.snapshot };1114 if (session.snapshotTime != null) {1115 Object.assign(command.readConcern, { atClusterTime: session.snapshotTime });1116 }1117 }1118 1119 return;1120 }1121 1122 // now attempt to apply transaction-specific sessions data1123 1124 // `autocommit` must always be false to differentiate from retryable writes1125 command.autocommit = false;1126 1127 if (session.transaction.state === TxnState.STARTING_TRANSACTION) {1128 session.transaction.transition(TxnState.TRANSACTION_IN_PROGRESS);1129 command.startTransaction = true;1130 1131 const readConcern =1132 session.transaction.options.readConcern || session?.clientOptions?.readConcern;1133 if (readConcern) {1134 command.readConcern = readConcern;1135 }1136 1137 if (session.supports.causalConsistency && session.operationTime) {1138 command.readConcern = command.readConcern || {};1139 Object.assign(command.readConcern, { afterClusterTime: session.operationTime });1140 }1141 }1142 return;1143}1144 1145export function updateSessionFromResponse(session: ClientSession, document: MongoDBResponse): void {1146 if (document.$clusterTime) {1147 _advanceClusterTime(session, document.$clusterTime);1148 }1149 1150 if (document.operationTime && session && session.supports.causalConsistency) {1151 session.advanceOperationTime(document.operationTime);1152 }1153 1154 if (document.recoveryToken && session && session.inTransaction()) {1155 session.transaction._recoveryToken = document.recoveryToken;1156 }1157 1158 if (session?.snapshotEnabled && session.snapshotTime == null) {1159 // find and aggregate commands return atClusterTime on the cursor1160 // distinct includes it in the response body1161 const atClusterTime = document.atClusterTime;1162 if (atClusterTime) {1163 session.snapshotTime = atClusterTime;1164 }1165 }1166}1167 