Skip to content

Commit e8229b1

Browse files
authored
feat(schema): run schema analysis with secondaryPreferred when read pref not set COMPASS-8854 (#6665)
1 parent 88c7bfe commit e8229b1

File tree

5 files changed

+160
-42
lines changed

5 files changed

+160
-42
lines changed

packages/compass-schema/src/modules/schema-analysis.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ export const analyzeSchema = async (
6464
},
6565
{
6666
abortSignal,
67+
fallbackReadPreference: 'secondaryPreferred',
6768
}
6869
);
6970
const schemaData = await mongodbSchema(docs);

packages/compass-schema/src/stores/reducer.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { Schema } from 'mongodb-schema';
22
import type { Action, AnyAction, Reducer } from 'redux';
3+
import type { AggregateOptions } from 'mongodb';
34
import { type AnalysisState } from '../constants/analysis-states';
45
import {
56
ANALYSIS_STATE_ANALYZING,
@@ -250,7 +251,7 @@ export const startAnalysis = (): SchemaThunkAction<
250251
fields: query.project ?? undefined,
251252
};
252253

253-
const driverOptions = {
254+
const driverOptions: AggregateOptions = {
254255
maxTimeMS: capMaxTimeMSAtPreferenceLimit(preferences, query.maxTimeMS),
255256
};
256257

packages/compass-schema/src/stores/store.spec.ts

Lines changed: 86 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { activateSchemaPlugin, type SchemaStore } from './store';
1+
import { activateSchemaPlugin } from './store';
2+
import type { SchemaStore, SchemaPluginServices } from './store';
23
import AppRegistry, { createActivateHelpers } from 'hadron-app-registry';
34
import { expect } from 'chai';
45

@@ -7,7 +8,6 @@ import { createSandboxFromDefaultPreferences } from 'compass-preferences-model';
78
import { createNoopLogger } from '@mongodb-js/compass-logging/provider';
89
import type { FieldStoreService } from '@mongodb-js/compass-field-store';
910
import { createNoopTrack } from '@mongodb-js/compass-telemetry/provider';
10-
import type { ConnectionInfoRef } from '@mongodb-js/compass-connections/provider';
1111
import { startAnalysis, stopAnalysis } from './reducer';
1212
import Sinon from 'sinon';
1313

@@ -26,46 +26,61 @@ const mockQueryBar = {
2626
};
2727

2828
describe('Schema Store', function () {
29-
describe('#configureStore', function () {
30-
let store: SchemaStore;
31-
let deactivate: () => void;
32-
let sandbox: Sinon.SinonSandbox;
33-
const localAppRegistry = new AppRegistry();
34-
const globalAppRegistry = new AppRegistry();
35-
const namespace = 'db.coll';
29+
let store: SchemaStore;
30+
let deactivate: () => void;
31+
let sandbox: Sinon.SinonSandbox;
32+
const localAppRegistry = new AppRegistry();
33+
const globalAppRegistry = new AppRegistry();
34+
const namespace = 'db.coll';
35+
let sampleStub: Sinon.SinonStub;
36+
let isCancelErrorStub: Sinon.SinonStub;
37+
38+
beforeEach(function () {
39+
sandbox = Sinon.createSandbox();
40+
sampleStub = sandbox.stub();
41+
isCancelErrorStub = sandbox.stub();
42+
});
43+
44+
async function createStore(services: Partial<SchemaPluginServices> = {}) {
45+
const dataService = {
46+
sample: sampleStub,
47+
isCancelError: isCancelErrorStub,
48+
};
3649
const connectionInfoRef = {
37-
current: {},
38-
} as ConnectionInfoRef;
39-
let sampleStub: Sinon.SinonStub;
40-
let isCancelErrorStub: Sinon.SinonStub;
50+
current: {
51+
connectionOptions: {
52+
connectionString: 'mongodb://localhost:27017',
53+
},
54+
title: 'test',
55+
id: 'test',
56+
},
57+
};
58+
59+
const plugin = activateSchemaPlugin(
60+
{
61+
namespace: namespace,
62+
},
63+
{
64+
localAppRegistry: localAppRegistry,
65+
globalAppRegistry: globalAppRegistry,
66+
dataService,
67+
logger: dummyLogger,
68+
track: dummyTrack,
69+
preferences: await createSandboxFromDefaultPreferences(),
70+
fieldStoreService: mockFieldStoreService,
71+
queryBar: mockQueryBar as any,
72+
connectionInfoRef,
73+
...services,
74+
},
75+
createActivateHelpers()
76+
);
77+
store = plugin.store;
78+
deactivate = () => plugin.deactivate();
79+
}
4180

81+
describe('#configureStore', function () {
4282
beforeEach(async function () {
43-
sandbox = Sinon.createSandbox();
44-
sampleStub = sandbox.stub();
45-
isCancelErrorStub = sandbox.stub();
46-
const dataService = {
47-
sample: sampleStub,
48-
isCancelError: isCancelErrorStub,
49-
};
50-
const plugin = activateSchemaPlugin(
51-
{
52-
namespace: namespace,
53-
},
54-
{
55-
localAppRegistry: localAppRegistry,
56-
globalAppRegistry: globalAppRegistry,
57-
dataService: dataService as any,
58-
logger: dummyLogger,
59-
track: dummyTrack,
60-
preferences: await createSandboxFromDefaultPreferences(),
61-
fieldStoreService: mockFieldStoreService,
62-
queryBar: mockQueryBar as any,
63-
connectionInfoRef,
64-
},
65-
createActivateHelpers()
66-
);
67-
store = plugin.store;
68-
deactivate = () => plugin.deactivate();
83+
await createStore();
6984
});
7085

7186
afterEach(function () {
@@ -107,5 +122,37 @@ describe('Schema Store', function () {
107122
await analysisPromise;
108123
expect(store.getState().analysisState).to.equal('initial');
109124
});
125+
126+
it('runs the analysis with fallback read pref secondaryPreferred', async function () {
127+
sampleStub.resolves([{ name: 'Hans' }, { name: 'Greta' }]);
128+
await store.dispatch(startAnalysis());
129+
expect(sampleStub.getCall(0).args[3])
130+
.property('fallbackReadPreference')
131+
.to.equal('secondaryPreferred');
132+
});
133+
});
134+
135+
describe('with a connection string with explicit read preference set', function () {
136+
beforeEach(async function () {
137+
await createStore({
138+
connectionInfoRef: {
139+
current: {
140+
connectionOptions: {
141+
connectionString:
142+
'mongodb://localhost:27017/?readPreference=primary',
143+
},
144+
title: 'test',
145+
id: 'test',
146+
},
147+
},
148+
});
149+
});
150+
151+
it('does not set read preference to secondaryPreferred', async function () {
152+
await store.dispatch(startAnalysis());
153+
expect(sampleStub.getCall(0).args[2]).not.to.have.property(
154+
'readPreference'
155+
);
156+
});
110157
});
111158
});

packages/data-service/src/data-service.spec.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { Collection, MongoServerError } from 'mongodb';
77
import { MongoClient } from 'mongodb';
88
import { Int32, UUID } from 'bson';
99
import sinon from 'sinon';
10+
import ConnectionStringUrl from 'mongodb-connection-string-url';
1011
import type { DataService } from './data-service';
1112
import { DataServiceImpl } from './data-service';
1213
import type {
@@ -1044,6 +1045,54 @@ describe('DataService', function () {
10441045
{ allowDiskUse: false }
10451046
);
10461047
});
1048+
1049+
it('allows to pass fallbackReadPreference and sets the read preference when unset', function () {
1050+
sandbox.spy(dataService, 'aggregate');
1051+
void dataService.sample(
1052+
'db.coll',
1053+
{},
1054+
{},
1055+
{
1056+
fallbackReadPreference: 'secondaryPreferred',
1057+
}
1058+
);
1059+
1060+
// eslint-disable-next-line @typescript-eslint/unbound-method
1061+
expect(dataService.aggregate).to.have.been.calledWith(
1062+
'db.coll',
1063+
[{ $sample: { size: 1000 } }],
1064+
{ allowDiskUse: true, readPreference: 'secondaryPreferred' }
1065+
);
1066+
});
1067+
1068+
it('allows to pass fallbackReadPreference and does not set the read preference when it is already set', function () {
1069+
sandbox.spy(dataService, 'aggregate');
1070+
const connectionStringReplacement = new ConnectionStringUrl(
1071+
cluster().connectionString
1072+
);
1073+
connectionStringReplacement.searchParams.set(
1074+
'readPreference',
1075+
'primary'
1076+
);
1077+
sandbox.replace(dataService as any, '_connectionOptions', {
1078+
connectionString: connectionStringReplacement.toString(),
1079+
});
1080+
void dataService.sample(
1081+
'db.coll',
1082+
{},
1083+
{},
1084+
{
1085+
fallbackReadPreference: 'secondaryPreferred',
1086+
}
1087+
);
1088+
1089+
// eslint-disable-next-line @typescript-eslint/unbound-method
1090+
expect(dataService.aggregate).to.have.been.calledWith(
1091+
'db.coll',
1092+
[{ $sample: { size: 1000 } }],
1093+
{ allowDiskUse: true }
1094+
);
1095+
});
10471096
});
10481097

10491098
describe('#getLastSeenTopology', function () {

packages/data-service/src/data-service.ts

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ import type {
5151
ClientEncryptionDataKeyProvider,
5252
ClientEncryptionCreateDataKeyProviderOptions,
5353
SearchIndexDescription,
54+
ReadPreferenceMode,
5455
} from 'mongodb';
5556
import ConnectionStringUrl from 'mongodb-connection-string-url';
5657
import parseNamespace from 'mongodb-ns';
@@ -126,6 +127,12 @@ function isEmptyObject(obj: Record<string, unknown>) {
126127
return Object.keys(obj).length === 0;
127128
}
128129

130+
function isReadPreferenceSet(connectionString: string): boolean {
131+
return !!new ConnectionStringUrl(connectionString).searchParams.get(
132+
'readPreference'
133+
);
134+
}
135+
129136
let id = 0;
130137

131138
type ClientType = 'CRUD' | 'META';
@@ -665,7 +672,9 @@ export interface DataService {
665672
ns: string,
666673
args?: { query?: Filter<Document>; size?: number; fields?: Document },
667674
options?: AggregateOptions,
668-
executionOptions?: ExecutionOptions
675+
executionOptions?: ExecutionOptions & {
676+
fallbackReadPreference?: ReadPreferenceMode;
677+
}
669678
): Promise<Document[]>;
670679

671680
/*** Insert ***/
@@ -2219,7 +2228,9 @@ class DataServiceImpl extends WithLogContext implements DataService {
22192228
fields,
22202229
}: { query?: Filter<Document>; size?: number; fields?: Document } = {},
22212230
options: AggregateOptions = {},
2222-
executionOptions?: ExecutionOptions
2231+
executionOptions?: ExecutionOptions & {
2232+
fallbackReadPreference?: ReadPreferenceMode;
2233+
}
22232234
): Promise<Document[]> {
22242235
const pipeline = [];
22252236
if (query && Object.keys(query).length > 0) {
@@ -2246,6 +2257,15 @@ class DataServiceImpl extends WithLogContext implements DataService {
22462257
pipeline,
22472258
{
22482259
allowDiskUse: true,
2260+
// When the read preference isn't set in the connection string explicitly,
2261+
// then we allow consumers to default to a read preference, for instance
2262+
// secondaryPreferred to avoid using the primary for analyzing documents.
2263+
...(executionOptions?.fallbackReadPreference &&
2264+
!isReadPreferenceSet(this._connectionOptions.connectionString)
2265+
? {
2266+
readPreference: executionOptions?.fallbackReadPreference,
2267+
}
2268+
: {}),
22492269
...options,
22502270
},
22512271
executionOptions

0 commit comments

Comments
 (0)