Skip to content

Commit 352831e

Browse files
authored
feat: Atlas get performance advisor integration tests (#589)
1 parent 6ee1274 commit 352831e

File tree

8 files changed

+436
-99
lines changed

8 files changed

+436
-99
lines changed

src/common/atlas/cluster.ts

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,37 @@
11
import type { ClusterDescription20240805, FlexClusterDescription20241113 } from "./openapi.js";
22
import type { ApiClient } from "./apiClient.js";
33
import { LogId } from "../logger.js";
4+
import { ConnectionString } from "mongodb-connection-string-url";
45

5-
const DEFAULT_PORT = "27017";
6+
type AtlasProcessId = `${string}:${number}`;
7+
8+
function extractProcessIds(connectionString: string): Array<AtlasProcessId> {
9+
if (!connectionString) {
10+
return [];
11+
}
12+
const connectionStringUrl = new ConnectionString(connectionString);
13+
return connectionStringUrl.hosts as Array<AtlasProcessId>;
14+
}
615
export interface Cluster {
716
name?: string;
817
instanceType: "FREE" | "DEDICATED" | "FLEX";
918
instanceSize?: string;
1019
state?: "IDLE" | "CREATING" | "UPDATING" | "DELETING" | "REPAIRING";
1120
mongoDBVersion?: string;
1221
connectionString?: string;
22+
processIds?: Array<string>;
1323
}
1424

1525
export function formatFlexCluster(cluster: FlexClusterDescription20241113): Cluster {
26+
const connectionString = cluster.connectionStrings?.standardSrv || cluster.connectionStrings?.standard;
1627
return {
1728
name: cluster.name,
1829
instanceType: "FLEX",
1930
instanceSize: undefined,
2031
state: cluster.stateName,
2132
mongoDBVersion: cluster.mongoDBVersion,
22-
connectionString: cluster.connectionStrings?.standardSrv || cluster.connectionStrings?.standard,
33+
connectionString,
34+
processIds: extractProcessIds(cluster.connectionStrings?.standard ?? ""),
2335
};
2436
}
2537

@@ -53,14 +65,16 @@ export function formatCluster(cluster: ClusterDescription20240805): Cluster {
5365

5466
const instanceSize = regionConfigs[0]?.instanceSize ?? "UNKNOWN";
5567
const clusterInstanceType = instanceSize === "M0" ? "FREE" : "DEDICATED";
68+
const connectionString = cluster.connectionStrings?.standardSrv || cluster.connectionStrings?.standard;
5669

5770
return {
5871
name: cluster.name,
5972
instanceType: clusterInstanceType,
6073
instanceSize: clusterInstanceType === "DEDICATED" ? instanceSize : undefined,
6174
state: cluster.stateName,
6275
mongoDBVersion: cluster.mongoDBVersion,
63-
connectionString: cluster.connectionStrings?.standardSrv || cluster.connectionStrings?.standard,
76+
connectionString,
77+
processIds: extractProcessIds(cluster.connectionStrings?.standard ?? ""),
6478
};
6579
}
6680

@@ -98,21 +112,17 @@ export async function inspectCluster(apiClient: ApiClient, projectId: string, cl
98112
}
99113
}
100114

101-
export async function getProcessIdFromCluster(
115+
export async function getProcessIdsFromCluster(
102116
apiClient: ApiClient,
103117
projectId: string,
104118
clusterName: string
105-
): Promise<string> {
119+
): Promise<Array<string>> {
106120
try {
107121
const cluster = await inspectCluster(apiClient, projectId, clusterName);
108-
if (!cluster.connectionString) {
109-
throw new Error("No connection string available for cluster");
110-
}
111-
const url = new URL(cluster.connectionString);
112-
return `${url.hostname}:${url.port || DEFAULT_PORT}`;
122+
return cluster.processIds || [];
113123
} catch (error) {
114124
throw new Error(
115-
`Failed to get processId from cluster: ${error instanceof Error ? error.message : String(error)}`
125+
`Failed to get processIds from cluster: ${error instanceof Error ? error.message : String(error)}`
116126
);
117127
}
118128
}

src/common/atlas/performanceAdvisorUtils.ts

Lines changed: 29 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
import { LogId } from "../logger.js";
22
import type { ApiClient } from "./apiClient.js";
3-
import { getProcessIdFromCluster } from "./cluster.js";
3+
import { getProcessIdsFromCluster } from "./cluster.js";
44
import type { components } from "./openapi.js";
55

66
export type SuggestedIndex = components["schemas"]["PerformanceAdvisorIndex"];
77
export type DropIndexSuggestion = components["schemas"]["DropIndexSuggestionsIndex"];
88
export type SlowQueryLogMetrics = components["schemas"]["PerformanceAdvisorSlowQueryMetrics"];
99
export type SlowQueryLog = components["schemas"]["PerformanceAdvisorSlowQuery"];
1010

11+
export const DEFAULT_SLOW_QUERY_LOGS_LIMIT = 50;
12+
1113
interface SuggestedIndexesResponse {
1214
content: components["schemas"]["PerformanceAdvisorResponse"];
1315
}
@@ -112,22 +114,35 @@ export async function getSlowQueries(
112114
namespaces?: Array<string>
113115
): Promise<{ slowQueryLogs: Array<SlowQueryLog> }> {
114116
try {
115-
const processId = await getProcessIdFromCluster(apiClient, projectId, clusterName);
117+
const processIds = await getProcessIdsFromCluster(apiClient, projectId, clusterName);
116118

117-
const response = await apiClient.listSlowQueries({
118-
params: {
119-
path: {
120-
groupId: projectId,
121-
processId,
122-
},
123-
query: {
124-
...(since && { since: since.getTime() }),
125-
...(namespaces && { namespaces: namespaces }),
119+
if (processIds.length === 0) {
120+
return { slowQueryLogs: [] };
121+
}
122+
123+
const slowQueryPromises = processIds.map((processId) =>
124+
apiClient.listSlowQueries({
125+
params: {
126+
path: {
127+
groupId: projectId,
128+
processId,
129+
},
130+
query: {
131+
...(since && { since: since.getTime() }),
132+
...(namespaces && { namespaces: namespaces }),
133+
nLogs: DEFAULT_SLOW_QUERY_LOGS_LIMIT,
134+
},
126135
},
127-
},
128-
});
136+
})
137+
);
138+
139+
const responses = await Promise.allSettled(slowQueryPromises);
140+
141+
const allSlowQueryLogs = responses.reduce((acc, response) => {
142+
return acc.concat(response.status === "fulfilled" ? (response.value.slowQueries ?? []) : []);
143+
}, [] as Array<SlowQueryLog>);
129144

130-
return { slowQueryLogs: response.slowQueries ?? [] };
145+
return { slowQueryLogs: allSlowQueryLogs };
131146
} catch (err) {
132147
apiClient.logger.debug({
133148
id: LogId.atlasPaSlowQueryLogsFailure,

src/tools/atlas/atlasTool.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,14 +38,29 @@ For more information on setting up API keys, visit: https://www.mongodb.com/docs
3838
};
3939
}
4040

41+
if (statusCode === 402) {
42+
return {
43+
content: [
44+
{
45+
type: "text",
46+
text: `Received a Payment Required API Error: ${error.message}
47+
48+
Payment setup is required to perform this action in MongoDB Atlas.
49+
Please ensure that your payment method for your organization has been set up and is active.
50+
For more information on setting up payment, visit: https://www.mongodb.com/docs/atlas/billing/`,
51+
},
52+
],
53+
};
54+
}
55+
4156
if (statusCode === 403) {
4257
return {
4358
content: [
4459
{
4560
type: "text",
4661
text: `Received a Forbidden API Error: ${error.message}
4762
48-
You don't have sufficient permissions to perform this action in MongoDB Atlas
63+
You don't have sufficient permissions to perform this action in MongoDB Atlas.
4964
Please ensure your API key has the necessary roles assigned.
5065
For more information on Atlas API access roles, visit: https://www.mongodb.com/docs/atlas/api/service-accounts-overview/`,
5166
},

src/tools/atlas/read/getPerformanceAdvisor.ts

Lines changed: 45 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
getDropIndexSuggestions,
99
getSchemaAdvice,
1010
getSlowQueries,
11+
DEFAULT_SLOW_QUERY_LOGS_LIMIT,
1112
} from "../../../common/atlas/performanceAdvisorUtils.js";
1213
import { AtlasArgs } from "../../args.js";
1314

@@ -20,8 +21,7 @@ const PerformanceAdvisorOperationType = z.enum([
2021

2122
export class GetPerformanceAdvisorTool extends AtlasToolBase {
2223
public name = "atlas-get-performance-advisor";
23-
protected description =
24-
"Get MongoDB Atlas performance advisor recommendations, which includes the operations: suggested indexes, drop index suggestions, slow query logs, and schema suggestions";
24+
protected description = `Get MongoDB Atlas performance advisor recommendations, which includes the operations: suggested indexes, drop index suggestions, schema suggestions, and a sample of the most recent (max ${DEFAULT_SLOW_QUERY_LOGS_LIMIT}) slow query logs`;
2525
public operationType: OperationType = "read";
2626
protected argsShape = {
2727
projectId: AtlasArgs.projectId().describe("Atlas project ID to get performance advisor recommendations"),
@@ -31,8 +31,11 @@ export class GetPerformanceAdvisorTool extends AtlasToolBase {
3131
.default(PerformanceAdvisorOperationType.options)
3232
.describe("Operations to get performance advisor recommendations"),
3333
since: z
34-
.date()
35-
.describe("Date to get slow query logs since. Only relevant for the slowQueryLogs operation.")
34+
.string()
35+
.datetime()
36+
.describe(
37+
"Date to get slow query logs since. Must be a string in ISO 8601 format. Only relevant for the slowQueryLogs operation."
38+
)
3639
.optional(),
3740
namespaces: z
3841
.array(z.string())
@@ -49,34 +52,58 @@ export class GetPerformanceAdvisorTool extends AtlasToolBase {
4952
}: ToolArgs<typeof this.argsShape>): Promise<CallToolResult> {
5053
try {
5154
const [suggestedIndexesResult, dropIndexSuggestionsResult, slowQueryLogsResult, schemaSuggestionsResult] =
52-
await Promise.all([
55+
await Promise.allSettled([
5356
operations.includes("suggestedIndexes")
5457
? getSuggestedIndexes(this.session.apiClient, projectId, clusterName)
5558
: Promise.resolve(undefined),
5659
operations.includes("dropIndexSuggestions")
5760
? getDropIndexSuggestions(this.session.apiClient, projectId, clusterName)
5861
: Promise.resolve(undefined),
5962
operations.includes("slowQueryLogs")
60-
? getSlowQueries(this.session.apiClient, projectId, clusterName, since, namespaces)
63+
? getSlowQueries(
64+
this.session.apiClient,
65+
projectId,
66+
clusterName,
67+
since ? new Date(since) : undefined,
68+
namespaces
69+
)
6170
: Promise.resolve(undefined),
6271
operations.includes("schemaSuggestions")
6372
? getSchemaAdvice(this.session.apiClient, projectId, clusterName)
6473
: Promise.resolve(undefined),
6574
]);
6675

76+
const hasSuggestedIndexes =
77+
suggestedIndexesResult.status === "fulfilled" &&
78+
suggestedIndexesResult.value?.suggestedIndexes &&
79+
suggestedIndexesResult.value.suggestedIndexes.length > 0;
80+
const hasDropIndexSuggestions =
81+
dropIndexSuggestionsResult.status === "fulfilled" &&
82+
dropIndexSuggestionsResult.value?.hiddenIndexes &&
83+
dropIndexSuggestionsResult.value?.redundantIndexes &&
84+
dropIndexSuggestionsResult.value?.unusedIndexes &&
85+
dropIndexSuggestionsResult.value.hiddenIndexes.length > 0 &&
86+
dropIndexSuggestionsResult.value.redundantIndexes.length > 0 &&
87+
dropIndexSuggestionsResult.value.unusedIndexes.length > 0;
88+
const hasSlowQueryLogs =
89+
slowQueryLogsResult.status === "fulfilled" &&
90+
slowQueryLogsResult.value?.slowQueryLogs &&
91+
slowQueryLogsResult.value.slowQueryLogs.length > 0;
92+
const hasSchemaSuggestions =
93+
schemaSuggestionsResult.status === "fulfilled" &&
94+
schemaSuggestionsResult.value?.recommendations &&
95+
schemaSuggestionsResult.value.recommendations.length > 0;
96+
97+
// Inserts the performance advisor data with the relevant section header if it exists
6798
const performanceAdvisorData = [
68-
suggestedIndexesResult && suggestedIndexesResult?.suggestedIndexes?.length > 0
69-
? `## Suggested Indexes\n${JSON.stringify(suggestedIndexesResult.suggestedIndexes)}`
70-
: "No suggested indexes found.",
71-
dropIndexSuggestionsResult
72-
? `## Drop Index Suggestions\n${JSON.stringify(dropIndexSuggestionsResult)}`
73-
: "No drop index suggestions found.",
74-
slowQueryLogsResult && slowQueryLogsResult?.slowQueryLogs?.length > 0
75-
? `## Slow Query Logs\n${JSON.stringify(slowQueryLogsResult.slowQueryLogs)}`
76-
: "No slow query logs found.",
77-
schemaSuggestionsResult && schemaSuggestionsResult?.recommendations?.length > 0
78-
? `## Schema Suggestions\n${JSON.stringify(schemaSuggestionsResult.recommendations)}`
79-
: "No schema suggestions found.",
99+
`## Suggested Indexes\n${
100+
hasSuggestedIndexes
101+
? `Note: The "Weight" field is measured in bytes, and represents the estimated number of bytes saved in disk reads per executed read query that would be saved by implementing an index suggestion. Please convert this to MB or GB for easier readability.\n${JSON.stringify(suggestedIndexesResult.value?.suggestedIndexes)}`
102+
: "No suggested indexes found."
103+
}`,
104+
`## Drop Index Suggestions\n${hasDropIndexSuggestions ? JSON.stringify(dropIndexSuggestionsResult.value) : "No drop index suggestions found."}`,
105+
`## Slow Query Logs\n${hasSlowQueryLogs ? JSON.stringify(slowQueryLogsResult.value?.slowQueryLogs) : "No slow query logs found."}`,
106+
`## Schema Suggestions\n${hasSchemaSuggestions ? JSON.stringify(schemaSuggestionsResult.value?.recommendations) : "No schema suggestions found."}`,
80107
];
81108

82109
if (performanceAdvisorData.length === 0) {

tests/integration/helpers.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,9 @@ export const defaultDriverOptions: DriverOptions = {
4141
...driverOptions,
4242
};
4343

44+
// Timeout in milliseconds for long running tests: defaults to 20 minutes
45+
export const DEFAULT_LONG_RUNNING_TEST_WAIT_TIMEOUT_MS = 1_200_000;
46+
4447
export function setupIntegrationTest(
4548
getUserConfig: () => UserConfig,
4649
getDriverOptions: () => DriverOptions,

tests/integration/tools/atlas/atlasHelpers.ts

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import { ObjectId } from "mongodb";
2-
import type { Group } from "../../../../src/common/atlas/openapi.js";
2+
import type { ClusterDescription20240805, Group } from "../../../../src/common/atlas/openapi.js";
33
import type { ApiClient } from "../../../../src/common/atlas/apiClient.js";
44
import type { IntegrationTest } from "../../helpers.js";
55
import { setupIntegrationTest, defaultTestConfig, defaultDriverOptions } from "../../helpers.js";
66
import type { SuiteCollector } from "vitest";
77
import { afterAll, beforeAll, describe } from "vitest";
8+
import type { Session } from "../../../../src/common/session.js";
89

910
export type IntegrationTestFunction = (integration: IntegrationTest) => void;
1011

@@ -143,3 +144,83 @@ async function createProject(apiClient: ApiClient): Promise<Group & Required<Pic
143144

144145
return group as Group & Required<Pick<Group, "id">>;
145146
}
147+
148+
export function sleep(ms: number): Promise<void> {
149+
return new Promise((resolve) => setTimeout(resolve, ms));
150+
}
151+
152+
export async function assertClusterIsAvailable(
153+
session: Session,
154+
projectId: string,
155+
clusterName: string
156+
): Promise<boolean> {
157+
try {
158+
await session.apiClient.getCluster({
159+
params: {
160+
path: {
161+
groupId: projectId,
162+
clusterName,
163+
},
164+
},
165+
});
166+
return true;
167+
} catch {
168+
return false;
169+
}
170+
}
171+
172+
export async function deleteAndWaitCluster(
173+
session: Session,
174+
projectId: string,
175+
clusterName: string,
176+
pollingInterval: number = 1000,
177+
maxPollingIterations: number = 300
178+
): Promise<void> {
179+
await session.apiClient.deleteCluster({
180+
params: {
181+
path: {
182+
groupId: projectId,
183+
clusterName,
184+
},
185+
},
186+
});
187+
188+
for (let i = 0; i < maxPollingIterations; i++) {
189+
const isAvailable = await assertClusterIsAvailable(session, projectId, clusterName);
190+
if (!isAvailable) {
191+
return;
192+
}
193+
await sleep(pollingInterval);
194+
}
195+
throw new Error(
196+
`Cluster deletion timeout: ${clusterName} did not delete within ${maxPollingIterations} iterations`
197+
);
198+
}
199+
200+
export async function waitCluster(
201+
session: Session,
202+
projectId: string,
203+
clusterName: string,
204+
check: (cluster: ClusterDescription20240805) => boolean | Promise<boolean>,
205+
pollingInterval: number = 1000,
206+
maxPollingIterations: number = 300
207+
): Promise<void> {
208+
for (let i = 0; i < maxPollingIterations; i++) {
209+
const cluster = await session.apiClient.getCluster({
210+
params: {
211+
path: {
212+
groupId: projectId,
213+
clusterName,
214+
},
215+
},
216+
});
217+
if (await check(cluster)) {
218+
return;
219+
}
220+
await sleep(pollingInterval);
221+
}
222+
223+
throw new Error(
224+
`Cluster wait timeout: ${clusterName} did not meet condition within ${maxPollingIterations} iterations`
225+
);
226+
}

0 commit comments

Comments
 (0)