Skip to content

Commit 0a55ac2

Browse files
committed
feat(NODE-6338): implement client bulk write error handling
1 parent d56e235 commit 0a55ac2

19 files changed

+852
-180
lines changed

Diff for: src/cmap/wire_protocol/responses.ts

+4
Original file line numberDiff line numberDiff line change
@@ -354,4 +354,8 @@ export class ClientBulkWriteCursorResponse extends CursorResponse {
354354
get deletedCount() {
355355
return this.get('nDeleted', BSONType.int, true);
356356
}
357+
358+
get writeConcernError() {
359+
return this.get('writeConcernError', BSONType.object, false);
360+
}
357361
}

Diff for: src/cursor/client_bulk_write_cursor.ts

+2-8
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import { type Document } from 'bson';
22

33
import { type ClientBulkWriteCursorResponse } from '../cmap/wire_protocol/responses';
4-
import { MongoClientBulkWriteCursorError } from '../error';
54
import type { MongoClient } from '../mongo_client';
65
import { ClientBulkWriteOperation } from '../operations/client_bulk_write/client_bulk_write';
76
import { type ClientBulkWriteCommandBuilder } from '../operations/client_bulk_write/command_builder';
@@ -48,16 +47,11 @@ export class ClientBulkWriteCursor extends AbstractCursor {
4847
* We need a way to get the top level cursor response fields for
4948
* generating the bulk write result, so we expose this here.
5049
*/
51-
get response(): ClientBulkWriteCursorResponse {
50+
get response(): ClientBulkWriteCursorResponse | null {
5251
if (this.cursorResponse) return this.cursorResponse;
53-
throw new MongoClientBulkWriteCursorError(
54-
'No client bulk write cursor response returned from the server.'
55-
);
52+
return null;
5653
}
5754

58-
/**
59-
* Get the last set of operations the cursor executed.
60-
*/
6155
get operations(): Document[] {
6256
return this.commandBuilder.lastOperations;
6357
}

Diff for: src/error.ts

+27
Original file line numberDiff line numberDiff line change
@@ -643,6 +643,33 @@ export class MongoClientBulkWriteCursorError extends MongoRuntimeError {
643643
}
644644
}
645645

646+
/**
647+
* An error indicating that an error occurred when generating a bulk write update.
648+
*
649+
* @public
650+
* @category Error
651+
*/
652+
export class MongoClientBulkWriteUpdateError extends MongoRuntimeError {
653+
/**
654+
* **Do not use this constructor!**
655+
*
656+
* Meant for internal use only.
657+
*
658+
* @remarks
659+
* This class is only meant to be constructed within the driver. This constructor is
660+
* not subject to semantic versioning compatibility guarantees and may change at any time.
661+
*
662+
* @public
663+
**/
664+
constructor(message: string) {
665+
super(message);
666+
}
667+
668+
override get name(): string {
669+
return 'MongoClientBulkWriteUpdateError';
670+
}
671+
}
672+
646673
/**
647674
* An error indicating that an error occurred on the client when executing a client bulk write.
648675
*

Diff for: src/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ export {
4747
MongoChangeStreamError,
4848
MongoClientBulkWriteCursorError,
4949
MongoClientBulkWriteExecutionError,
50+
MongoClientBulkWriteUpdateError,
5051
MongoCompatibilityError,
5152
MongoCursorExhaustedError,
5253
MongoCursorInUseError,

Diff for: src/mongo_client.ts

+5
Original file line numberDiff line numberDiff line change
@@ -493,6 +493,11 @@ export class MongoClient extends TypedEventEmitter<MongoClientEvents> implements
493493
models: AnyClientBulkWriteModel[],
494494
options?: ClientBulkWriteOptions
495495
): Promise<ClientBulkWriteResult | { ok: 1 }> {
496+
if (this.autoEncrypter) {
497+
throw new MongoInvalidArgumentError(
498+
'MongoClient bulkWrite does not currently support automatic encryption.'
499+
);
500+
}
496501
return await new ClientBulkWriteExecutor(this, models, options).execute();
497502
}
498503

Diff for: src/operations/client_bulk_write/client_bulk_write.ts

+10-4
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,8 @@ export class ClientBulkWriteOperation extends CommandOperation<ClientBulkWriteCu
4848
session.pin(connection);
4949
command = this.commandBuilder.buildBatch(
5050
connection.hello?.maxMessageSizeBytes,
51-
connection.hello?.maxWriteBatchSize
51+
connection.hello?.maxWriteBatchSize,
52+
connection.hello?.maxBsonObjectSize
5253
);
5354
} else {
5455
throw new MongoClientBulkWriteExecutionError(
@@ -59,14 +60,19 @@ export class ClientBulkWriteOperation extends CommandOperation<ClientBulkWriteCu
5960
// At this point we have a server and the auto connect code has already
6061
// run in executeOperation, so the server description will be populated.
6162
// We can use that to build the command.
62-
if (!server.description.maxWriteBatchSize || !server.description.maxMessageSizeBytes) {
63+
if (
64+
!server.description.maxWriteBatchSize ||
65+
!server.description.maxMessageSizeBytes ||
66+
!server.description.maxBsonObjectSize
67+
) {
6368
throw new MongoClientBulkWriteExecutionError(
64-
'In order to execute a client bulk write, both maxWriteBatchSize and maxMessageSizeBytes must be provided by the servers hello response.'
69+
'In order to execute a client bulk write, both maxWriteBatchSize, maxMessageSizeBytes and maxBsonObjectSize must be provided by the servers hello response.'
6570
);
6671
}
6772
command = this.commandBuilder.buildBatch(
6873
server.description.maxMessageSizeBytes,
69-
server.description.maxWriteBatchSize
74+
server.description.maxWriteBatchSize,
75+
server.description.maxBsonObjectSize
7076
);
7177
}
7278
return await super.executeCommand(server, session, command, ClientBulkWriteCursorResponse);

Diff for: src/operations/client_bulk_write/command_builder.ts

+90-4
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { BSON, type Document } from '../../bson';
22
import { DocumentSequence } from '../../cmap/commands';
3+
import { MongoClientBulkWriteUpdateError, MongoInvalidArgumentError } from '../../error';
34
import { type PkFactory } from '../../mongo_client';
45
import type { Filter, OptionalId, UpdateFilter, WithoutId } from '../../mongo_types';
56
import { DEFAULT_PK_FACTORY } from '../../utils';
@@ -82,7 +83,11 @@ export class ClientBulkWriteCommandBuilder {
8283
* @param maxWriteBatchSize - The max write batch size.
8384
* @returns The client bulk write command.
8485
*/
85-
buildBatch(maxMessageSizeBytes: number, maxWriteBatchSize: number): ClientBulkWriteCommand {
86+
buildBatch(
87+
maxMessageSizeBytes: number,
88+
maxWriteBatchSize: number,
89+
maxBsonObjectSize: number
90+
): ClientBulkWriteCommand {
8691
let commandLength = 0;
8792
let currentNamespaceIndex = 0;
8893
const command: ClientBulkWriteCommand = this.baseCommand();
@@ -96,7 +101,16 @@ export class ClientBulkWriteCommandBuilder {
96101
if (nsIndex != null) {
97102
// Build the operation and serialize it to get the bytes buffer.
98103
const operation = buildOperation(model, nsIndex, this.pkFactory);
99-
const operationBuffer = BSON.serialize(operation);
104+
let operationBuffer;
105+
try {
106+
operationBuffer = BSON.serialize(operation);
107+
} catch (error) {
108+
throw new MongoInvalidArgumentError(
109+
`Could not serialize operation to BSON: ${error.message}.`
110+
);
111+
}
112+
113+
validateBufferSize('ops', operationBuffer, maxBsonObjectSize, maxMessageSizeBytes);
100114

101115
// Check if the operation buffer can fit in the command. If it can,
102116
// then add the operation to the document sequence and increment the
@@ -119,9 +133,20 @@ export class ClientBulkWriteCommandBuilder {
119133
// construct our nsInfo and ops documents and buffers.
120134
namespaces.set(ns, currentNamespaceIndex);
121135
const nsInfo = { ns: ns };
122-
const nsInfoBuffer = BSON.serialize(nsInfo);
123136
const operation = buildOperation(model, currentNamespaceIndex, this.pkFactory);
124-
const operationBuffer = BSON.serialize(operation);
137+
let nsInfoBuffer;
138+
let operationBuffer;
139+
try {
140+
nsInfoBuffer = BSON.serialize(nsInfo);
141+
operationBuffer = BSON.serialize(operation);
142+
} catch (error) {
143+
throw new MongoInvalidArgumentError(
144+
`Could not serialize ns info and operation to BSON: ${error.message}.`
145+
);
146+
}
147+
148+
validateBufferSize('nsInfo', nsInfoBuffer, maxBsonObjectSize, maxMessageSizeBytes);
149+
validateBufferSize('ops', operationBuffer, maxBsonObjectSize, maxMessageSizeBytes);
125150

126151
// Check if the operation and nsInfo buffers can fit in the command. If they
127152
// can, then add the operation and nsInfo to their respective document
@@ -179,6 +204,25 @@ export class ClientBulkWriteCommandBuilder {
179204
}
180205
}
181206

207+
function validateBufferSize(
208+
name: string,
209+
buffer: Uint8Array,
210+
maxBsonObjectSize: number,
211+
maxMessageSizeBytes: number
212+
) {
213+
if (buffer.length > maxBsonObjectSize) {
214+
throw new MongoInvalidArgumentError(
215+
`Client bulk write operation ${name} of length ${buffer.length} exceeds the max bson object size of ${maxBsonObjectSize}`
216+
);
217+
}
218+
219+
if (buffer.length > maxMessageSizeBytes) {
220+
throw new MongoInvalidArgumentError(
221+
`Client bulk write operation ${name} of length ${buffer.length} exceeds the max message size size of ${maxMessageSizeBytes}`
222+
);
223+
}
224+
}
225+
182226
/** @internal */
183227
interface ClientInsertOperation {
184228
insert: number;
@@ -293,6 +337,22 @@ export const buildUpdateManyOperation = (
293337
return createUpdateOperation(model, index, true);
294338
};
295339

340+
/**
341+
* Validate the update document.
342+
* @param update - The update document.
343+
*/
344+
function validateUpdate(update: Document) {
345+
const keys = Object.keys(update);
346+
if (keys.length === 0) {
347+
throw new MongoClientBulkWriteUpdateError('Client bulk write update models may not be empty.');
348+
}
349+
if (!keys[0].startsWith('$')) {
350+
throw new MongoClientBulkWriteUpdateError(
351+
'Client bulk write update models must only contain atomic modifiers (start with $).'
352+
);
353+
}
354+
}
355+
296356
/**
297357
* Creates a delete operation based on the parameters.
298358
*/
@@ -301,6 +361,22 @@ function createUpdateOperation(
301361
index: number,
302362
multi: boolean
303363
): ClientUpdateOperation {
364+
// Update documents provided in UpdateOne and UpdateMany write models are
365+
// required only to contain atomic modifiers (i.e. keys that start with "$").
366+
// Drivers MUST throw an error if an update document is empty or if the
367+
// document's first key does not start with "$".
368+
if (Array.isArray(model.update)) {
369+
if (model.update.length === 0) {
370+
throw new MongoClientBulkWriteUpdateError(
371+
'Client bulk write update model pipelines may not be empty.'
372+
);
373+
}
374+
for (const update of model.update) {
375+
validateUpdate(update);
376+
}
377+
} else {
378+
validateUpdate(model.update);
379+
}
304380
const document: ClientUpdateOperation = {
305381
update: index,
306382
multi: multi,
@@ -343,6 +419,16 @@ export const buildReplaceOneOperation = (
343419
model: ClientReplaceOneModel,
344420
index: number
345421
): ClientReplaceOneOperation => {
422+
const keys = Object.keys(model.replacement);
423+
if (keys.length === 0) {
424+
throw new MongoClientBulkWriteUpdateError('Client bulk write replace models may not be empty.');
425+
}
426+
if (keys[0].startsWith('$')) {
427+
throw new MongoClientBulkWriteUpdateError(
428+
'Client bulk write replace models must not contain atomic modifiers (start with $).'
429+
);
430+
}
431+
346432
const document: ClientReplaceOneOperation = {
347433
update: index,
348434
multi: false,

Diff for: src/operations/client_bulk_write/common.ts

+50
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { type Document } from '../../bson';
2+
import { type ErrorDescription, type MongoRuntimeError, MongoServerError } from '../../error';
23
import type { Filter, OptionalId, UpdateFilter, WithoutId } from '../../mongo_types';
34
import type { CollationOptions, CommandOperationOptions } from '../../operations/command';
45
import type { Hint } from '../../operations/operation';
@@ -181,6 +182,55 @@ export interface ClientBulkWriteResult {
181182
deleteResults?: Map<number, ClientDeleteResult>;
182183
}
183184

185+
export interface ClientBulkWriteError {
186+
code: number;
187+
message: string;
188+
}
189+
190+
/**
191+
* An error indicating that an error occurred when executing the bulk write.
192+
*
193+
* @public
194+
* @category Error
195+
*/
196+
export class MongoClientBulkWriteError extends MongoServerError {
197+
/**
198+
* A top-level error that occurred when attempting to communicate with the server or execute
199+
* the bulk write. This value may not be populated if the exception was thrown due to errors
200+
* occurring on individual writes.
201+
*/
202+
error?: MongoRuntimeError;
203+
/**
204+
* Write concern errors that occurred while executing the bulk write. This list may have
205+
* multiple items if more than one server command was required to execute the bulk write.
206+
*/
207+
writeConcernErrors: Document[];
208+
/**
209+
* Errors that occurred during the execution of individual write operations. This map will
210+
* contain at most one entry if the bulk write was ordered.
211+
*/
212+
writeErrors: Map<number, ClientBulkWriteError>;
213+
/**
214+
* The results of any successful operations that were performed before the error was
215+
* encountered.
216+
*/
217+
partialResult?: ClientBulkWriteResult;
218+
219+
/**
220+
* Initialize the client bulk write error.
221+
* @param message - The error message.
222+
*/
223+
constructor(message: ErrorDescription) {
224+
super(message);
225+
this.writeConcernErrors = [];
226+
this.writeErrors = new Map();
227+
}
228+
229+
override get name(): string {
230+
return 'MongoClientBulkWriteError';
231+
}
232+
}
233+
184234
/** @public */
185235
export interface ClientInsertOneResult {
186236
/**

0 commit comments

Comments
 (0)