Skip to content

Commit b52e9c1

Browse files
authored
Improving Implement buffered document importing for improved performance (#137)
2 parents 0b94c0b + 1508dfd commit b52e9c1

4 files changed

Lines changed: 404 additions & 56 deletions

File tree

l10n/bundle.l10n.json

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -215,8 +215,8 @@
215215
"I want to connect using a connection string.": "I want to connect using a connection string.",
216216
"Ignoring the following files that do not match the \"*.json\" file name pattern:": "Ignoring the following files that do not match the \"*.json\" file name pattern:",
217217
"Import": "Import",
218+
"Import completed with errors.": "Import completed with errors.",
218219
"Import From JSON…": "Import From JSON…",
219-
"Import has accomplished with errors.": "Import has accomplished with errors.",
220220
"Import successful.": "Import successful.",
221221
"IMPORTANT: Please be sure to remove any private information before submitting.": "IMPORTANT: Please be sure to remove any private information before submitting.",
222222
"Importing document {num} of {countDocuments}": "Importing document {num} of {countDocuments}",
@@ -378,8 +378,6 @@
378378
"The document with the _id \"{0}\" has been saved.": "The document with the _id \"{0}\" has been saved.",
379379
"The entered value does not match the original.": "The entered value does not match the original.",
380380
"The export operation was canceled.": "The export operation was canceled.",
381-
"The insertion failed. The operation was not acknowledged by the database.": "The insertion failed. The operation was not acknowledged by the database.",
382-
"The insertion of document {number} failed with error: {error}": "The insertion of document {number} failed with error: {error}",
383381
"The issue text was copied to the clipboard. Please paste it into this window.": "The issue text was copied to the clipboard. Please paste it into this window.",
384382
"The local instance is using a self-signed certificate. To connect, you must import the appropriate TLS/SSL certificate. See {link} for tips.": "The local instance is using a self-signed certificate. To connect, you must import the appropriate TLS/SSL certificate. See {link} for tips.",
385383
"The location where resources will be deployed.": "The location where resources will be deployed.",
@@ -450,6 +448,7 @@
450448
"Where to save the exported documents?": "Where to save the exported documents?",
451449
"with Popover": "with Popover",
452450
"Working…": "Working…",
451+
"Write error: {0}": "Write error: {0}",
453452
"Yes": "Yes",
454453
"Yes, save my credentials": "Yes, save my credentials",
455454
"You are not signed in to an Azure account. Please sign in.": "You are not signed in to an Azure account. Please sign in.",

src/commands/importDocuments/importDocuments.ts

Lines changed: 104 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,20 @@
33
* Licensed under the MIT License. See License.txt in the project root for license information.
44
*--------------------------------------------------------------------------------------------*/
55

6-
import { parseError, type IActionContext } from '@microsoft/vscode-azext-utils';
6+
import { nonNullProp, parseError, type IActionContext } from '@microsoft/vscode-azext-utils';
77
import * as l10n from '@vscode/l10n';
88
import { EJSON, type Document } from 'bson';
99
import * as fse from 'fs-extra';
1010
import * as vscode from 'vscode';
1111
import { ClustersClient } from '../../documentdb/ClustersClient';
12+
import {
13+
AzureDomains,
14+
getHostsFromConnectionString,
15+
hasDomainSuffix,
16+
} from '../../documentdb/utils/connectionStringHelpers';
1217
import { ext } from '../../extensionVariables';
1318
import { CollectionItem } from '../../tree/documentdb/CollectionItem';
19+
import { BufferErrorCode, createMongoDbBuffer, type DocumentBuffer } from '../../utils/documentBuffer';
1420
import { getRootPath } from '../../utils/workspacUtils';
1521

1622
export async function importDocuments(
@@ -51,6 +57,10 @@ export async function importDocuments(
5157
return undefined;
5258
}
5359

60+
if (!(selectedItem instanceof CollectionItem)) {
61+
throw new Error('Selected item must be a CollectionItem');
62+
}
63+
5464
context.telemetry.properties.experience = selectedItem.experience.api;
5565

5666
await ext.state.runWithTemporaryDescription(selectedItem.id, l10n.t('Importing…'), async () => {
@@ -70,18 +80,21 @@ export async function importDocumentsWithProgress(selectedItem: CollectionItem,
7080
progress.report({ increment: 0, message: l10n.t('Loading documents…') });
7181

7282
const countUri = uris.length;
73-
const incrementUri = 50 / (countUri || 1);
83+
const incrementUri = 25 / (countUri || 1);
7484
const documents: unknown[] = [];
7585
let hasErrors = false;
7686

77-
for (let i = 0, percent = 0; i < countUri; i++, percent += incrementUri) {
87+
for (let i = 0; i < countUri; i++) {
88+
const increment = (i + 1) * incrementUri;
7889
progress.report({
79-
increment: Math.floor(percent),
90+
increment: Math.floor(increment),
8091
message: l10n.t('Loading document {num} of {countUri}', { num: i + 1, countUri }),
8192
});
8293

8394
const result = await parseAndValidateFile(selectedItem, uris[i]);
8495

96+
// Note to future maintainers: the validation can return 0 valid documents and still have errors.
97+
8598
if (result.errors && result.errors.length) {
8699
ext.outputChannel.appendLog(
87100
l10n.t('Errors found in document {path}. Please fix these.', { path: uris[i].path }),
@@ -91,47 +104,62 @@ export async function importDocumentsWithProgress(selectedItem: CollectionItem,
91104
hasErrors = true;
92105
}
93106

94-
if (result.documents && result.documents.length) {
107+
if (result.documents && result.documents.length > 0) {
95108
documents.push(...result.documents);
96109
}
97110
}
98111

99112
const countDocuments = documents.length;
100-
const incrementDocuments = 50 / (countDocuments || 1);
113+
const incrementDocuments = 75 / (countDocuments || 1);
101114
let count = 0;
115+
let buffer: DocumentBuffer<unknown> | undefined;
116+
if (selectedItem instanceof CollectionItem) {
117+
const hosts = getHostsFromConnectionString(nonNullProp(selectedItem.cluster, 'connectionString'));
118+
const isRuResource = hasDomainSuffix(AzureDomains.RU, ...hosts);
119+
120+
if (isRuResource) {
121+
// For Azure MongoDB RU, we use a buffer with maxDocumentCount = 1
122+
buffer = createMongoDbBuffer<unknown>({
123+
maxDocumentCount: 1,
124+
});
125+
} else {
126+
buffer = createMongoDbBuffer<unknown>();
127+
}
128+
}
102129

103-
for (let i = 0, percent = 0; i < countDocuments; i++, percent += incrementDocuments) {
130+
for (let i = 0; i < countDocuments; i++) {
104131
progress.report({
105-
increment: Math.floor(percent),
132+
increment: incrementDocuments,
106133
message: l10n.t('Importing document {num} of {countDocuments}', {
107134
num: i + 1,
108135
countDocuments,
109136
}),
110137
});
111138

112-
const result = await insertDocument(selectedItem, documents[i]);
139+
const result = await insertDocument(selectedItem, documents[i], buffer);
113140

114-
if (result.error) {
115-
ext.outputChannel.appendLog(
116-
l10n.t('The insertion of document {number} failed with error: {error}', {
117-
number: i + 1,
118-
error: result.error,
119-
}),
120-
);
121-
ext.outputChannel.show();
122-
hasErrors = true;
123-
} else {
124-
count++;
125-
}
141+
// 'count' in result means that the result is from the buffer
142+
count += result.count;
143+
// check if error occurred as partial failure would happen in bulk insertion
144+
hasErrors = hasErrors || result.errorOccurred;
145+
}
146+
147+
// Do insertion for the last batch for bulk insertion
148+
if (buffer && buffer.getStats().documentCount > 0) {
149+
const lastBatchFlushResult = await insertDocument(selectedItem, undefined, buffer);
150+
151+
count += lastBatchFlushResult.count;
152+
hasErrors = hasErrors || lastBatchFlushResult.errorOccurred;
126153
}
127154

128-
progress.report({ increment: 50, message: l10n.t('Finished importing') });
155+
// let's make sure we reach 100% progress, useful in case of errors etc.
156+
progress.report({ increment: 100, message: l10n.t('Finished importing') });
129157

130-
return hasErrors
131-
? l10n.t('Import has accomplished with errors.')
132-
: l10n.t('Import successful.') +
133-
' ' +
134-
l10n.t('Inserted {0} document(s). See output for more details.', count);
158+
return (
159+
(hasErrors ? l10n.t('Import completed with errors.') : l10n.t('Import successful.')) +
160+
' ' +
161+
l10n.t('Inserted {0} document(s). See output for more details.', count)
162+
);
135163
},
136164
);
137165

@@ -205,32 +233,62 @@ async function parseAndValidateFileForMongo(uri: vscode.Uri): Promise<{ document
205233
return { documents, errors };
206234
}
207235

208-
async function insertDocument(node: CollectionItem, document: unknown): Promise<{ document: unknown; error: string }> {
236+
async function insertDocument(
237+
node: CollectionItem,
238+
document: unknown,
239+
buffer: DocumentBuffer<unknown> | undefined,
240+
): Promise<{ count: number; errorOccurred: boolean }> {
209241
try {
242+
// Check for valid buffer
243+
if (!buffer) {
244+
return { count: 0, errorOccurred: true };
245+
}
246+
247+
// Route to appropriate handler based on node type
210248
if (node instanceof CollectionItem) {
211-
// await needs to catch the error here, otherwise it will be thrown to the caller
212-
return await insertDocumentIntoCluster(node, document as Document);
249+
return await insertDocumentWithBufferIntoCluster(node, buffer, document as Document);
213250
}
214-
} catch (e) {
215-
return { document, error: parseError(e).message };
216-
}
217251

218-
return { document, error: l10n.t('Unknown error') };
252+
// Should only reach here if node is neither CollectionItem nor CosmosDBContainerResourceItem
253+
return { count: 0, errorOccurred: true };
254+
} catch {
255+
return { count: 0, errorOccurred: true };
256+
}
219257
}
220258

221-
async function insertDocumentIntoCluster(
259+
async function insertDocumentWithBufferIntoCluster(
222260
node: CollectionItem,
223-
document: Document,
224-
): Promise<{ document: Document; error: string }> {
225-
const client = await ClustersClient.getClient(node.cluster.id);
226-
const response = await client.insertDocuments(node.databaseInfo.name, node.collectionInfo.name, [document]);
261+
buffer: DocumentBuffer<unknown>,
262+
document?: Document,
263+
// If document is undefined, it means that we are flushing the buffer
264+
// It is used for the last batch, and not recommended to be used for normal batches
265+
): Promise<{ count: number; errorOccurred: boolean }> {
266+
const databaseName = node.databaseInfo.name;
267+
const collectionName = node.collectionInfo.name;
268+
// Try to add document to buffer
269+
const insertOrFlushToBufferResult = buffer.insertOrFlush(document);
270+
// If successful, no immediate action needed
271+
if (insertOrFlushToBufferResult.success) {
272+
return { count: 0, errorOccurred: false };
273+
}
227274

228-
if (response?.acknowledged) {
229-
return { document, error: '' };
230-
} else {
231-
return {
232-
document,
233-
error: l10n.t('The insertion failed. The operation was not acknowledged by the database.'),
234-
};
275+
let documentsToProcess = insertOrFlushToBufferResult.documentsToProcess;
276+
if (insertOrFlushToBufferResult.errorCode === BufferErrorCode.BufferFull) {
277+
// The buffer has been flushed by the insertOrFlush method.
278+
// Reinserting the current document into the buffer ensures it is processed after the flush.
279+
// This is safe because the document has already been validated (e.g., it is not too large and not undefined).
280+
buffer.insert(document);
281+
} else if (insertOrFlushToBufferResult.errorCode === BufferErrorCode.EmptyDocument) {
282+
documentsToProcess = buffer.flush();
235283
}
284+
285+
// Documents to process could be the current document (if too large)
286+
// or the contents of the buffer (if it was full)
287+
const client = await ClustersClient.getClient(node.cluster.id);
288+
const insertResult = await client.insertDocuments(databaseName, collectionName, documentsToProcess as Document[]);
289+
290+
return {
291+
count: insertResult.insertedCount,
292+
errorOccurred: insertResult.insertedCount < (documentsToProcess?.length || 0),
293+
};
236294
}

src/documentdb/ClustersClient.ts

Lines changed: 45 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { appendExtensionUserAgent, callWithTelemetryAndErrorHandling, parseError
1313
import * as l10n from '@vscode/l10n';
1414
import { EJSON } from 'bson';
1515
import {
16+
MongoBulkWriteError,
1617
MongoClient,
1718
ObjectId,
1819
type Collection,
@@ -24,8 +25,10 @@ import {
2425
type MongoClientOptions,
2526
type WithId,
2627
type WithoutId,
28+
type WriteError,
2729
} from 'mongodb';
2830
import { Links } from '../constants';
31+
import { ext } from '../extensionVariables';
2932
import { type EmulatorConfiguration } from '../utils/emulatorConfiguration';
3033
import { CredentialCache } from './CredentialCache';
3134
import { getHostsFromConnectionString, hasAzureDomain } from './utils/connectionStringHelpers';
@@ -54,9 +57,9 @@ export interface IndexItemModel {
5457
version?: number;
5558
}
5659

60+
// Currently we only return insertedCount, but we can add more fields in the future if needed
61+
// Keep the type definition here for future extensibility
5762
export type InsertDocumentsResult = {
58-
/** Indicates whether this write result was acknowledged. If not, then all other members of this result will be undefined */
59-
acknowledged: boolean;
6063
/** The number of inserted documents for this operations */
6164
insertedCount: number;
6265
};
@@ -457,13 +460,48 @@ export class ClustersClient {
457460
collectionName: string,
458461
documents: Document[],
459462
): Promise<InsertDocumentsResult> {
463+
if (documents.length === 0) {
464+
return { insertedCount: 0 };
465+
}
460466
const collection = this._mongoClient.db(databaseName).collection(collectionName);
461467

462-
const insertManyResults = await collection.insertMany(documents, { forceServerObjectId: true });
468+
try {
469+
const insertManyResults = await collection.insertMany(documents, {
470+
forceServerObjectId: true,
471+
472+
// Setting `ordered` to be false allows MongoDB to continue inserting remaining documents even if previous fails.
473+
// More details: https://www.mongodb.com/docs/manual/reference/method/db.collection.insertMany/#syntax
474+
ordered: false,
475+
});
476+
return {
477+
insertedCount: insertManyResults.insertedCount,
478+
};
479+
} catch (error) {
480+
// print error messages to the console
481+
if (error instanceof MongoBulkWriteError) {
482+
const writeErrors: WriteError[] = Array.isArray(error.writeErrors)
483+
? (error.writeErrors as WriteError[])
484+
: [error.writeErrors as WriteError];
485+
486+
for (const writeError of writeErrors) {
487+
const generalErrorMessage = parseError(writeError).message;
488+
const descriptiveErrorMessage = writeError.err?.errmsg;
463489

464-
return {
465-
acknowledged: insertManyResults.acknowledged,
466-
insertedCount: insertManyResults.insertedCount,
467-
};
490+
const fullErrorMessage = descriptiveErrorMessage
491+
? `${generalErrorMessage} - ${descriptiveErrorMessage}`
492+
: generalErrorMessage;
493+
494+
ext.outputChannel.appendLog(l10n.t('Write error: {0}', fullErrorMessage));
495+
}
496+
ext.outputChannel.show();
497+
} else if (error instanceof Error) {
498+
ext.outputChannel.appendLog(l10n.t('Error: {0}', error.message));
499+
ext.outputChannel.show();
500+
}
501+
502+
return {
503+
insertedCount: error instanceof MongoBulkWriteError ? error.insertedCount || 0 : 0,
504+
};
505+
}
468506
}
469507
}

0 commit comments

Comments
 (0)