Skip to content

Commit 5b3ca4b

Browse files
authored
feat(indexes): add an option to create indexes through rolling build COMPASS-8216 (#6309)
* feat(indexes): add an option to create indexes through rolling build * chore(atlas-service): map non-api cloud backend errors * chore(indexes): fix checkbox types * chore: fix linting issues * chore(index): adjust rolling indexes fetching logic * fix(indexes): bind correct instance
1 parent 9ec077e commit 5b3ca4b

File tree

8 files changed

+171
-69
lines changed

8 files changed

+171
-69
lines changed

packages/atlas-service/src/atlas-service.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,11 @@ export type AtlasServiceOptions = {
1414
defaultHeaders?: Record<string, string>;
1515
};
1616

17+
function normalizePath(path?: string) {
18+
path = path ? (path.startsWith('/') ? path : `/${path}`) : '';
19+
return encodeURI(path);
20+
}
21+
1722
export class AtlasService {
1823
private config: AtlasServiceConfig;
1924
constructor(
@@ -25,16 +30,14 @@ export class AtlasService {
2530
this.config = getAtlasConfig(preferences);
2631
}
2732
adminApiEndpoint(path?: string, requestId?: string): string {
28-
const uri = encodeURI(
29-
`${this.config.atlasApiBaseUrl}${path ? `/${path}` : ''}`
30-
);
33+
const uri = `${this.config.atlasApiBaseUrl}${normalizePath(path)}`;
3134
const query = requestId
3235
? `?request_id=${encodeURIComponent(requestId)}`
3336
: '';
3437
return `${uri}${query}`;
3538
}
3639
cloudEndpoint(path?: string): string {
37-
return encodeURI(`${this.config.cloudBaseUrl}${path ? `/${path}` : ''}`);
40+
return `${this.config.cloudBaseUrl}${normalizePath(path)}`;
3841
}
3942
regionalizedCloudEndpoint(
4043
_atlasMetadata: Pick<AtlasClusterMetadata, 'regionalBaseUrl'>,
@@ -45,7 +48,7 @@ export class AtlasService {
4548
return this.cloudEndpoint(path);
4649
}
4750
driverProxyEndpoint(path?: string): string {
48-
return encodeURI(`${this.config.wsBaseUrl}${path ? `/${path}` : ''}`);
51+
return `${this.config.wsBaseUrl}${normalizePath(path)}`;
4952
}
5053
async fetch(url: RequestInfo | URL, init?: RequestInit): Promise<Response> {
5154
throwIfNetworkTrafficDisabled(this.preferences);

packages/atlas-service/src/util.ts

Lines changed: 31 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -46,10 +46,21 @@ export function throwIfNetworkTrafficDisabled(
4646
/**
4747
* https://www.mongodb.com/docs/atlas/api/atlas-admin-api-ref/#errors
4848
*/
49-
export function isServerError(
49+
function isAtlasAPIError(
5050
err: any
5151
): err is { error: number; errorCode: string; detail: string } {
52-
return Boolean(err.error && err.errorCode && err.detail);
52+
return Boolean(err && err.error && err.errorCode && err.detail);
53+
}
54+
55+
function isCloudBackendError(err: any): err is {
56+
errorCode: string;
57+
message: string;
58+
version: string;
59+
status: string;
60+
} {
61+
return Boolean(
62+
err && err.errorCode && err.message && err.version && err.status
63+
);
5364
}
5465

5566
export async function throwIfNotOk(
@@ -60,21 +71,25 @@ export async function throwIfNotOk(
6071
}
6172

6273
const messageJSON = await res.json().catch(() => undefined);
63-
if (messageJSON && isServerError(messageJSON)) {
64-
throw new AtlasServiceError(
65-
'ServerError',
66-
res.status,
67-
messageJSON.detail ?? 'Internal server error',
68-
messageJSON.errorCode ?? 'INTERNAL_SERVER_ERROR'
69-
);
70-
} else {
71-
throw new AtlasServiceError(
72-
'NetworkError',
73-
res.status,
74-
res.statusText,
75-
`${res.status}`
76-
);
74+
75+
const status = res.status;
76+
let statusText = res.statusText;
77+
let errorCode = `${res.status}`;
78+
let errorName: 'NetworkError' | 'ServerError' = 'NetworkError';
79+
80+
if (isAtlasAPIError(messageJSON)) {
81+
errorName = 'ServerError';
82+
statusText = messageJSON.detail;
83+
errorCode = messageJSON.errorCode;
84+
}
85+
86+
if (isCloudBackendError(messageJSON)) {
87+
errorName = 'ServerError';
88+
statusText = messageJSON.message;
89+
errorCode = messageJSON.errorCode;
7790
}
91+
92+
throw new AtlasServiceError(errorName, status, statusText, errorCode);
7893
}
7994

8095
export type AtlasServiceConfig = {

packages/compass-indexes/src/components/create-index-form/checkbox-input.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@ import { OPTIONS, optionChanged } from '../../modules/create-index';
1111

1212
type CheckboxInputProps = {
1313
name: CheckboxOptions;
14-
label: string;
15-
description: string;
14+
label: React.ReactNode;
15+
description: React.ReactNode;
1616
disabled?: boolean;
1717
checked: boolean;
1818
onChange(name: CheckboxOptions, newVal: boolean): void;
@@ -37,6 +37,9 @@ export const CheckboxInput: React.FunctionComponent<CheckboxInputProps> = ({
3737
onChange(name, event.target.checked);
3838
}}
3939
label={<Label htmlFor={labelId}>{label}</Label>}
40+
// @ts-expect-error leafygreen types only allow strings here, but can
41+
// render a ReactNode too (and we use that to render links inside
42+
// descriptions)
4043
description={description}
4144
disabled={disabled}
4245
/>

packages/compass-indexes/src/components/create-index-form/create-index-form.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ import { CreateIndexFields } from '../create-index-fields';
66
import { hasColumnstoreIndexesSupport } from '../../utils/columnstore-indexes';
77
import CheckboxInput from './checkbox-input';
88
import CollapsibleInput from './collapsible-input';
9+
import {
10+
useConnectionInfo,
11+
useConnectionSupports,
12+
} from '@mongodb-js/compass-connections/provider';
913

1014
const createIndexModalFieldsStyles = css({
1115
margin: `${spacing[4]}px 0 ${spacing[5]}px 0`,
@@ -38,6 +42,11 @@ function CreateIndexForm({
3842
onAddFieldClick,
3943
onRemoveFieldClick,
4044
}: CreateIndexFormProps) {
45+
const { id: connectionId } = useConnectionInfo();
46+
const supportsRollingIndexes = useConnectionSupports(
47+
connectionId,
48+
'rollingIndexCreation'
49+
);
4150
const schemaFields = useAutocompleteFields(namespace);
4251
const schemaFieldNames = useMemo(() => {
4352
return schemaFields
@@ -86,6 +95,9 @@ function CreateIndexForm({
8695
<CollapsibleInput name="columnstoreProjection"></CollapsibleInput>
8796
)}
8897
<CheckboxInput name="sparse"></CheckboxInput>
98+
{supportsRollingIndexes && (
99+
<CheckboxInput name="buildInRollingProcess"></CheckboxInput>
100+
)}
89101
</div>
90102
</Accordion>
91103
</>

packages/compass-indexes/src/modules/create-index.tsx

Lines changed: 47 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import { EJSON, ObjectId } from 'bson';
2-
import type { CreateIndexesOptions } from 'mongodb';
2+
import type { CreateIndexesOptions, IndexDirection } from 'mongodb';
33
import { isCollationValid } from 'mongodb-query-parser';
44
import React from 'react';
55
import type { Action, Reducer, Dispatch } from 'redux';
6-
import { Badge } from '@mongodb-js/compass-components';
6+
import { Badge, Link } from '@mongodb-js/compass-components';
77
import { isAction } from '../utils/is-action';
88
import type { IndexesThunkAction } from '.';
99
import type { RootState } from '.';
@@ -191,6 +191,20 @@ export const OPTIONS = {
191191
description:
192192
'Sparse indexes only contain entries for documents that have the indexed field, even if the index field contains a null value. The index skips over any document that is missing the indexed field.',
193193
},
194+
buildInRollingProcess: {
195+
type: 'checkbox',
196+
label: 'Build in rolling process',
197+
description: (
198+
<>
199+
Building indexes in a rolling fashion can minimize the performance
200+
impact of index builds. We only recommend using rolling index builds
201+
when regular index builds do not meet your needs.{' '}
202+
<Link href="https://www.mongodb.com/docs/manual/core/index-creation/">
203+
Learn More
204+
</Link>
205+
</>
206+
),
207+
},
194208
} as const;
195209

196210
type OptionNames = keyof typeof OPTIONS;
@@ -317,13 +331,24 @@ function isEmptyValue(value: unknown) {
317331
return false;
318332
}
319333

334+
function fieldTypeToIndexDirection(type: string): IndexDirection {
335+
if (type === '1 (asc)') {
336+
return 1;
337+
}
338+
if (type === '-1 (desc)') {
339+
return -1;
340+
}
341+
if (type === 'text' || type === '2dsphere') {
342+
return type;
343+
}
344+
throw new Error(`Unsupported field type: ${type}`);
345+
}
346+
320347
export const createIndexFormSubmitted = (): IndexesThunkAction<
321348
void,
322349
ErrorEncounteredAction | CreateIndexFormSubmittedAction
323350
> => {
324351
return (dispatch, getState) => {
325-
const spec = {} as CreateIndexSpec;
326-
327352
// Check for field errors.
328353
if (
329354
getState().createIndex.fields.some(
@@ -336,12 +361,18 @@ export const createIndexFormSubmitted = (): IndexesThunkAction<
336361

337362
const formIndexOptions = getState().createIndex.options;
338363

339-
getState().createIndex.fields.forEach((field: Field) => {
340-
let type: string | number = field.type;
341-
if (field.type === '1 (asc)') type = 1;
342-
if (field.type === '-1 (desc)') type = -1;
343-
spec[field.name] = type;
344-
});
364+
let spec: Record<string, IndexDirection>;
365+
366+
try {
367+
spec = Object.fromEntries(
368+
getState().createIndex.fields.map((field) => {
369+
return [field.name, fieldTypeToIndexDirection(field.type)];
370+
})
371+
);
372+
} catch (e) {
373+
dispatch(errorEncountered((e as any).message));
374+
return;
375+
}
345376

346377
const options: CreateIndexesOptions = {};
347378

@@ -437,7 +468,12 @@ export const createIndexFormSubmitted = (): IndexesThunkAction<
437468

438469
dispatch({ type: ActionTypes.CreateIndexFormSubmitted });
439470
void dispatch(
440-
createRegularIndex(getState().createIndex.indexId, spec, options)
471+
createRegularIndex(
472+
getState().createIndex.indexId,
473+
spec,
474+
options,
475+
!!formIndexOptions.buildInRollingProcess.value
476+
)
441477
);
442478
};
443479
};

packages/compass-indexes/src/modules/regular-indexes.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import {
1717
hideModalDescription,
1818
unhideModalDescription,
1919
} from '../utils/modal-descriptions';
20-
import type { IndexSpecification, CreateIndexesOptions } from 'mongodb';
20+
import type { CreateIndexesOptions, IndexDirection } from 'mongodb';
2121
import { hasColumnstoreIndex } from '../utils/columnstore-indexes';
2222
import type { AtlasIndexStats } from './rolling-indexes-service';
2323
import { connectionSupports } from '@mongodb-js/compass-connections';
@@ -458,8 +458,9 @@ const indexCreationFailed = (
458458

459459
export function createRegularIndex(
460460
inProgressIndexId: string,
461-
spec: CreateIndexSpec,
462-
options: CreateIndexesOptions
461+
spec: Record<string, IndexDirection>,
462+
options: CreateIndexesOptions,
463+
isRollingIndexBuild: boolean
463464
): IndexesThunkAction<
464465
Promise<void>,
465466
| IndexCreationStartedAction
@@ -469,7 +470,7 @@ export function createRegularIndex(
469470
return async (
470471
dispatch,
471472
getState,
472-
{ track, dataService, connectionInfoRef }
473+
{ track, dataService, rollingIndexesService, connectionInfoRef }
473474
) => {
474475
const ns = getState().namespace;
475476
const inProgressIndex = prepareInProgressIndex(inProgressIndexId, {
@@ -501,7 +502,10 @@ export function createRegularIndex(
501502
};
502503

503504
try {
504-
await dataService.createIndex(ns, spec as IndexSpecification, options);
505+
const createFn = isRollingIndexBuild
506+
? rollingIndexesService.createRollingIndex.bind(rollingIndexesService)
507+
: dataService.createIndex.bind(dataService);
508+
await createFn(ns, spec, options);
505509
dispatch(indexCreationSucceeded(inProgressIndexId));
506510
track('Index Created', trackEvent, connectionInfoRef.current);
507511

packages/compass-indexes/src/modules/rolling-indexes-service.spec.ts

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ describe('RollingIndexesService', function () {
66
const atlasServiceStub = {
77
automationAgentRequest: Sinon.stub(),
88
automationAgentAwait: Sinon.stub(),
9+
authenticatedFetch: Sinon.stub(),
10+
cloudEndpoint: Sinon.stub().callsFake((str) => str),
911
};
1012
let service: RollingIndexesService;
1113

@@ -55,15 +57,19 @@ describe('RollingIndexesService', function () {
5557
});
5658

5759
describe('createRollingIndex', function () {
58-
it('should fail if automation agent returned unexpected result', async function () {
59-
atlasServiceStub.automationAgentRequest.resolves({ _id: '_id' });
60+
it('should send the request to the kinda automation agent endpoint with the matching body and path params', async function () {
61+
await service.createRollingIndex('db.coll', {}, {});
6062

61-
try {
62-
await service.createRollingIndex('db.coll', {}, {});
63-
expect.fail('expected createRollingIndex to throw');
64-
} catch (err) {
65-
expect(err).not.to.be.null;
66-
}
63+
expect(atlasServiceStub.authenticatedFetch).to.have.been.calledOnce;
64+
65+
const { args } = atlasServiceStub.authenticatedFetch.getCall(0);
66+
67+
expect(args[0]).to.eq('/explorer/v1/groups/abc/clusters/123/index');
68+
expect(args[1]).to.have.property('method', 'POST');
69+
expect(args[1]).to.have.property(
70+
'body',
71+
'{"clusterId":"123","db":"db","collection":"coll","keys":"{}","options":"","collationOptions":""}'
72+
);
6773
});
6874
});
6975
});

0 commit comments

Comments
 (0)