Skip to content

Commit 523e05c

Browse files
authored
feat(NODE-3392): enable snapshot reads on secondaries (#2897)
1 parent 5a8842a commit 523e05c

19 files changed

+2878
-50
lines changed

src/operations/execute_operation.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,8 @@ export function executeOperation<
8686
session = topology.startSession({ owner, explicit: false });
8787
} else if (session.hasEnded) {
8888
return cb(new MongoDriverError('Use of expired sessions is not permitted'));
89+
} else if (session.snapshotEnabled && !topology.capabilities.supportsSnapshotReads) {
90+
return cb(new MongoDriverError('Snapshot reads require MongoDB 5.0 or later'));
8991
}
9092
} else if (session) {
9193
// If the user passed an explicit session and we are still, after server selection,

src/sdam/topology.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -379,7 +379,7 @@ export class Topology extends TypedEventEmitter<TopologyEvents> {
379379
return this.s.description;
380380
}
381381

382-
capabilities(): ServerCapabilities {
382+
get capabilities(): ServerCapabilities {
383383
return new ServerCapabilities(this.lastIsMaster());
384384
}
385385

@@ -1064,6 +1064,10 @@ export class ServerCapabilities {
10641064
return this.maxWireVersion >= 3;
10651065
}
10661066

1067+
get supportsSnapshotReads(): boolean {
1068+
return this.maxWireVersion >= 13;
1069+
}
1070+
10671071
get commandsTakeWriteConcern(): boolean {
10681072
return this.maxWireVersion >= 5;
10691073
}

src/sessions.ts

Lines changed: 56 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import type { AbstractCursor } from './cursor/abstract_cursor';
3030
import type { CommandOptions } from './cmap/connection';
3131
import type { WriteConcern } from './write_concern';
3232
import { TypedEventEmitter } from './mongo_types';
33+
import { ReadConcernLevel } from './read_concern';
3334

3435
const minWireVersionForShardedTransactions = 8;
3536

@@ -51,6 +52,8 @@ function assertAlive(session: ClientSession, callback?: Callback): boolean {
5152
export interface ClientSessionOptions {
5253
/** Whether causal consistency should be enabled on this session */
5354
causalConsistency?: boolean;
55+
/** Whether all read operations should be read from the same snapshot for this session (NOTE: not compatible with `causalConsistency=true`) */
56+
snapshot?: boolean;
5457
/** The default TransactionOptions to use for transactions started on this session. */
5558
defaultTransactionOptions?: TransactionOptions;
5659

@@ -72,14 +75,18 @@ export type ClientSessionEvents = {
7275

7376
/** @internal */
7477
const kServerSession = Symbol('serverSession');
78+
/** @internal */
79+
const kSnapshotTime = Symbol('snapshotTime');
80+
/** @internal */
81+
const kSnapshotEnabled = Symbol('snapshotEnabled');
7582

7683
/**
7784
* A class representing a client session on the server
7885
*
7986
* NOTE: not meant to be instantiated directly.
8087
* @public
8188
*/
82-
class ClientSession extends TypedEventEmitter<ClientSessionEvents> {
89+
export class ClientSession extends TypedEventEmitter<ClientSessionEvents> {
8390
/** @internal */
8491
topology: Topology;
8592
/** @internal */
@@ -96,6 +103,10 @@ class ClientSession extends TypedEventEmitter<ClientSessionEvents> {
96103
transaction: Transaction;
97104
/** @internal */
98105
[kServerSession]?: ServerSession;
106+
/** @internal */
107+
[kSnapshotTime]?: Timestamp;
108+
/** @internal */
109+
[kSnapshotEnabled] = false;
99110

100111
/**
101112
* Create a client session.
@@ -123,15 +134,23 @@ class ClientSession extends TypedEventEmitter<ClientSessionEvents> {
123134

124135
options = options ?? {};
125136

137+
if (options.snapshot === true) {
138+
this[kSnapshotEnabled] = true;
139+
if (options.causalConsistency === true) {
140+
throw new MongoDriverError(
141+
'Properties "causalConsistency" and "snapshot" are mutually exclusive'
142+
);
143+
}
144+
}
145+
126146
this.topology = topology;
127147
this.sessionPool = sessionPool;
128148
this.hasEnded = false;
129149
this.clientOptions = clientOptions;
130150
this[kServerSession] = undefined;
131151

132152
this.supports = {
133-
causalConsistency:
134-
typeof options.causalConsistency === 'boolean' ? options.causalConsistency : true
153+
causalConsistency: options.snapshot !== true && options.causalConsistency !== false
135154
};
136155

137156
this.clusterTime = options.initialClusterTime;
@@ -157,6 +176,11 @@ class ClientSession extends TypedEventEmitter<ClientSessionEvents> {
157176
return this[kServerSession]!;
158177
}
159178

179+
/** Whether or not this session is configured for snapshot reads */
180+
get snapshotEnabled(): boolean {
181+
return this[kSnapshotEnabled];
182+
}
183+
160184
/**
161185
* Ends this session on the server
162186
*
@@ -257,6 +281,10 @@ class ClientSession extends TypedEventEmitter<ClientSessionEvents> {
257281
* @param options - Options for the transaction
258282
*/
259283
startTransaction(options?: TransactionOptions): void {
284+
if (this[kSnapshotEnabled]) {
285+
throw new MongoDriverError('Transactions are not allowed with snapshot sessions');
286+
}
287+
260288
assertAlive(this);
261289
if (this.inTransaction()) {
262290
throw new MongoDriverError('Transaction already in progress');
@@ -623,7 +651,7 @@ export type ServerSessionId = { id: Binary };
623651
* WARNING: not meant to be instantiated directly. For internal use only.
624652
* @public
625653
*/
626-
class ServerSession {
654+
export class ServerSession {
627655
id: ServerSessionId;
628656
lastUse: number;
629657
txnNumber: number;
@@ -658,7 +686,7 @@ class ServerSession {
658686
* For internal use only
659687
* @internal
660688
*/
661-
class ServerSessionPool {
689+
export class ServerSessionPool {
662690
topology: Topology;
663691
sessions: ServerSession[];
664692

@@ -746,7 +774,7 @@ class ServerSessionPool {
746774

747775
// TODO: this should be codified in command construction
748776
// @see https://github.com/mongodb/specifications/blob/master/source/read-write-concern/read-write-concern.rst#read-concern
749-
function commandSupportsReadConcern(command: Document, options?: Document): boolean {
777+
export function commandSupportsReadConcern(command: Document, options?: Document): boolean {
750778
if (command.aggregate || command.count || command.distinct || command.find || command.geoNear) {
751779
return true;
752780
}
@@ -770,7 +798,7 @@ function commandSupportsReadConcern(command: Document, options?: Document): bool
770798
* @param command - the command to decorate
771799
* @param options - Optional settings passed to calling operation
772800
*/
773-
function applySession(
801+
export function applySession(
774802
session: ClientSession,
775803
command: Document,
776804
options?: CommandOptions
@@ -801,28 +829,35 @@ function applySession(
801829
// first apply non-transaction-specific sessions data
802830
const inTransaction = session.inTransaction() || isTransactionCommand(command);
803831
const isRetryableWrite = options?.willRetryWrite || false;
804-
const shouldApplyReadConcern = commandSupportsReadConcern(command, options);
805832

806833
if (serverSession.txnNumber && (isRetryableWrite || inTransaction)) {
807834
command.txnNumber = Long.fromNumber(serverSession.txnNumber);
808835
}
809836

810-
// now attempt to apply transaction-specific sessions data
811837
if (!inTransaction) {
812838
if (session.transaction.state !== TxnState.NO_TRANSACTION) {
813839
session.transaction.transition(TxnState.NO_TRANSACTION);
814840
}
815841

816-
// TODO: the following should only be applied to read operation per spec.
817-
// for causal consistency
818-
if (session.supports.causalConsistency && session.operationTime && shouldApplyReadConcern) {
842+
if (
843+
session.supports.causalConsistency &&
844+
session.operationTime &&
845+
commandSupportsReadConcern(command, options)
846+
) {
819847
command.readConcern = command.readConcern || {};
820848
Object.assign(command.readConcern, { afterClusterTime: session.operationTime });
849+
} else if (session[kSnapshotEnabled]) {
850+
command.readConcern = command.readConcern || { level: ReadConcernLevel.snapshot };
851+
if (session[kSnapshotTime] !== undefined) {
852+
Object.assign(command.readConcern, { atClusterTime: session[kSnapshotTime] });
853+
}
821854
}
822855

823856
return;
824857
}
825858

859+
// now attempt to apply transaction-specific sessions data
860+
826861
// `autocommit` must always be false to differentiate from retryable writes
827862
command.autocommit = false;
828863

@@ -843,7 +878,7 @@ function applySession(
843878
}
844879
}
845880

846-
function updateSessionFromResponse(session: ClientSession, document: Document): void {
881+
export function updateSessionFromResponse(session: ClientSession, document: Document): void {
847882
if (document.$clusterTime) {
848883
resolveClusterTime(session, document.$clusterTime);
849884
}
@@ -855,14 +890,12 @@ function updateSessionFromResponse(session: ClientSession, document: Document):
855890
if (document.recoveryToken && session && session.inTransaction()) {
856891
session.transaction._recoveryToken = document.recoveryToken;
857892
}
858-
}
859893

860-
export {
861-
ClientSession,
862-
ServerSession,
863-
ServerSessionPool,
864-
TxnState,
865-
applySession,
866-
updateSessionFromResponse,
867-
commandSupportsReadConcern
868-
};
894+
if (
895+
document.cursor?.atClusterTime &&
896+
session?.[kSnapshotEnabled] &&
897+
session[kSnapshotTime] === undefined
898+
) {
899+
session[kSnapshotTime] = document.cursor.atClusterTime;
900+
}
901+
}

src/utils.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -394,7 +394,7 @@ export function decorateWithCollation(
394394
target: MongoClient | Db | Collection,
395395
options: AnyOptions
396396
): void {
397-
const capabilities = getTopology(target).capabilities();
397+
const capabilities = getTopology(target).capabilities;
398398
if (options.collation && typeof options.collation === 'object') {
399399
if (capabilities && capabilities.commandsTakeCollation) {
400400
command.collation = options.collation;

test/functional/sessions.test.js

Lines changed: 44 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
'use strict';
22

3+
const path = require('path');
34
const expect = require('chai').expect;
4-
const setupDatabase = require('./shared').setupDatabase;
5-
const withMonitoredClient = require('./shared').withMonitoredClient;
6-
const TestRunnerContext = require('./spec-runner').TestRunnerContext;
7-
const generateTopologyTests = require('./spec-runner').generateTopologyTests;
8-
const loadSpecTests = require('../spec').loadSpecTests;
5+
const { setupDatabase, withMonitoredClient } = require('./shared');
6+
const { TestRunnerContext, generateTopologyTests } = require('./spec-runner');
7+
const { loadSpecTests } = require('../spec');
8+
const { runUnifiedTest } = require('./unified-spec-runner/runner');
99

1010
const ignoredCommands = ['ismaster'];
1111
const test = {
@@ -148,7 +148,7 @@ describe('Sessions - functional', function () {
148148
}
149149
});
150150

151-
describe('spec tests', function () {
151+
describe('legacy spec tests', function () {
152152
class SessionSpecTestContext extends TestRunnerContext {
153153
assertSessionNotDirty(options) {
154154
const session = options.session;
@@ -176,7 +176,7 @@ describe('Sessions - functional', function () {
176176
}
177177

178178
const testContext = new SessionSpecTestContext();
179-
const testSuites = loadSpecTests('sessions');
179+
const testSuites = loadSpecTests(path.join('sessions', 'legacy'));
180180

181181
after(() => testContext.teardown());
182182
before(function () {
@@ -196,6 +196,43 @@ describe('Sessions - functional', function () {
196196
generateTopologyTests(testSuites, testContext, testFilter);
197197
});
198198

199+
describe('unified spec tests', function () {
200+
for (const sessionTests of loadSpecTests(path.join('sessions', 'unified'))) {
201+
expect(sessionTests).to.be.an('object');
202+
context(String(sessionTests.description), function () {
203+
// TODO: NODE-3393 fix test runner to apply session to all operations
204+
const skipTestMap = {
205+
'snapshot-sessions': [
206+
'countDocuments operation with snapshot',
207+
'Distinct operation with snapshot',
208+
'Mixed operation with snapshot'
209+
],
210+
'snapshot-sessions-not-supported-client-error': [
211+
'Client error on distinct with snapshot'
212+
],
213+
'snapshot-sessions-not-supported-server-error': [
214+
'Server returns an error on distinct with snapshot'
215+
],
216+
'snapshot-sessions-unsupported-ops': [
217+
'Server returns an error on listCollections with snapshot',
218+
'Server returns an error on listDatabases with snapshot',
219+
'Server returns an error on listIndexes with snapshot',
220+
'Server returns an error on runCommand with snapshot'
221+
]
222+
};
223+
const testsToSkip = skipTestMap[sessionTests.description] || [];
224+
for (const test of sessionTests.tests) {
225+
it(String(test.description), {
226+
metadata: { sessions: { skipLeakTests: true } },
227+
test: async function () {
228+
await runUnifiedTest(this, sessionTests, test, testsToSkip);
229+
}
230+
});
231+
}
232+
});
233+
}
234+
});
235+
199236
context('unacknowledged writes', () => {
200237
it('should not include session for unacknowledged writes', {
201238
metadata: { requires: { topology: 'single', mongodb: '>=3.6.0' } },

test/functional/unified-spec-runner/entities.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,10 @@ export class EntitiesMap<E = Entity> extends Map<string, E> {
250250
options.causalConsistency = entity.session.sessionOptions?.causalConsistency;
251251
}
252252

253+
if (entity.session.sessionOptions?.snapshot) {
254+
options.snapshot = entity.session.sessionOptions.snapshot;
255+
}
256+
253257
if (entity.session.sessionOptions?.defaultTransactionOptions) {
254258
options.defaultTransactionOptions = Object.create(null);
255259
const defaultOptions = entity.session.sessionOptions.defaultTransactionOptions;

test/spec/read-write-concern/README.rst

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
=======================
2-
Connection String Tests
3-
=======================
1+
============================
2+
Read and Write Concern Tests
3+
============================
44

55
The YAML and JSON files in this directory tree are platform-independent tests
66
that drivers can use to prove their conformance to the Read and Write Concern

test/spec/sessions/README.rst

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,11 @@ Driver Session Tests
99
Introduction
1010
============
1111

12-
The YAML and JSON files in this directory are platform-independent tests that
13-
drivers can use to prove their conformance to the Driver Sessions Spec. They are
12+
The YAML and JSON files in the ``legacy`` and ``unified`` sub-directories are platform-independent tests
13+
that drivers can use to prove their conformance to the Driver Sessions Spec. They are
1414
designed with the intention of sharing most test-runner code with the
15-
Transactions spec tests.
15+
`Transactions Spec tests <../../transactions/tests/README.rst#test-format>`_.. Tests in the
16+
``unified`` directory are written using the `Unified Test Format <../../unified-test-format/unified-test-format.rst>`_.
1617

1718
Several prose tests, which are not easily expressed in YAML, are also presented
1819
in the Driver Sessions Spec. Those tests will need to be manually implemented
@@ -78,7 +79,26 @@ the given session is *not* marked dirty::
7879
arguments:
7980
session: session0
8081

82+
Snapshot session tests
83+
======================
84+
Snapshot sessions tests require server of version 5.0 or higher and
85+
replica set or a sharded cluster deployment.
86+
Default snapshot history window on the server is 5 minutes. Running the test in debug mode, or in any other slow configuration
87+
may lead to `SnapshotTooOld` errors. Drivers can work around this issue by increasing the server's `minSnapshotHistoryWindowInSeconds` parameter, for example:
88+
89+
.. code:: python
90+
91+
client.admin.command('setParameter', 1, minSnapshotHistoryWindowInSeconds=60)
92+
93+
Prose tests
94+
```````````
95+
- Setting both ``snapshot`` and ``causalConsistency`` is not allowed
96+
97+
* ``client.startSession(snapshot = true, causalConsistency = true)``
98+
* Assert that an error was raised by driver
99+
81100
Changelog
82101
=========
83102

84103
:2019-05-15: Initial version.
104+
:2021-06-15: Added snapshot-session tests. Introduced legacy and unified folders.

0 commit comments

Comments
 (0)