Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

perf(NODE-6246): Use buffer pool for ObjectId to significantly improve memory usage #707

Open
wants to merge 7 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
152 changes: 111 additions & 41 deletions src/objectid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,25 @@ import { type InspectFn, defaultInspect } from './parser/utils';
import { ByteUtils } from './utils/byte_utils';
import { NumberUtils } from './utils/number_utils';

const defaultPoolSize = 1000; // Hold 1000 ObjectId buffers in a pool
let pool: Uint8Array;
SeanReece marked this conversation as resolved.
Show resolved Hide resolved
let poolOffset = 0;

/** Internal pool accessors for objectId instance */
/** @internal private instance pool accessor */
export const _pool = Symbol('pool');
SeanReece marked this conversation as resolved.
Show resolved Hide resolved
/** @internal private instance offset accessor */
export const _offset = Symbol('offset');

/**
* Create a new ObjectId buffer pool and reset the pool offset
* @internal
*/
function createPool(): void {
pool = ByteUtils.allocateUnsafe(ObjectId.poolSize * 12);
poolOffset = 0;
}

// Regular expression that checks for hex value
const checkForHexRegExp = new RegExp('^[0-9a-fA-F]{24}$');

Expand Down Expand Up @@ -37,8 +56,16 @@ export class ObjectId extends BSONValue {

static cacheHexString: boolean;

/** ObjectId Bytes @internal */
private buffer!: Uint8Array;
/**
* The size of the buffer pool for ObjectId.
*/
static poolSize: number = defaultPoolSize;
SeanReece marked this conversation as resolved.
Show resolved Hide resolved

/** ObjectId buffer pool pointer @internal */
private [_pool]: Uint8Array;
/** Buffer pool offset @internal */
private [_offset]: number;

/** ObjectId hexString cache @internal */
private __id?: string;

Expand Down Expand Up @@ -72,7 +99,7 @@ export class ObjectId extends BSONValue {
*
* @param inputId - A 12 byte binary Buffer.
*/
SeanReece marked this conversation as resolved.
Show resolved Hide resolved
constructor(inputId: Uint8Array);
constructor(inputId: Uint8Array, inputIndex?: number);
/** To generate a new ObjectId, use ObjectId() with no argument. */
constructor();
/**
Expand All @@ -86,7 +113,10 @@ export class ObjectId extends BSONValue {
*
* @param inputId - An input value to create a new ObjectId from.
*/
constructor(inputId?: string | number | ObjectId | ObjectIdLike | Uint8Array) {
constructor(
inputId?: string | number | ObjectId | ObjectIdLike | Uint8Array,
inputIndex?: number
) {
super();
// workingId is set based on type of input and whether valid id exists for the input
let workingId;
Expand All @@ -103,17 +133,38 @@ export class ObjectId extends BSONValue {
workingId = inputId;
}

// If we have reached the end of the pool then create a new pool
if (!pool || poolOffset + 12 > pool.byteLength) {
createPool();
}
this[_pool] = pool;
this[_offset] = poolOffset;
poolOffset += 12;
SeanReece marked this conversation as resolved.
Show resolved Hide resolved

// The following cases use workingId to construct an ObjectId
if (workingId == null || typeof workingId === 'number') {
// The most common use case (blank id, new objectId instance)
// Generate a new id
this.buffer = ObjectId.generate(typeof workingId === 'number' ? workingId : undefined);
} else if (ArrayBuffer.isView(workingId) && workingId.byteLength === 12) {
// If intstanceof matches we can escape calling ensure buffer in Node.js environments
this.buffer = ByteUtils.toLocalBufferType(workingId);
ObjectId.generate(
typeof workingId === 'number' ? workingId : undefined,
this[_pool],
this[_offset]
);
} else if (ArrayBuffer.isView(workingId)) {
if (workingId.byteLength !== 12 && typeof inputIndex !== 'number') {
throw new BSONError('Buffer length must be 12 or offset must be specified');
}
if (
inputIndex &&
(typeof inputIndex !== 'number' || inputIndex < 0 || workingId.byteLength < inputIndex + 12)
nbbeeken marked this conversation as resolved.
Show resolved Hide resolved
) {
throw new BSONError('Buffer offset must be a non-negative number less than buffer length');
}
inputIndex ??= 0;
for (let i = 0; i < 12; i++) this[_pool][this[_offset] + i] = workingId[inputIndex + i];
} else if (typeof workingId === 'string') {
if (workingId.length === 24 && checkForHexRegExp.test(workingId)) {
this.buffer = ByteUtils.fromHex(workingId);
this[_pool].set(ByteUtils.fromHex(workingId), this[_offset]);
} else {
throw new BSONError(
'input must be a 24 character hex string, 12 byte Uint8Array, or an integer'
Expand All @@ -124,20 +175,28 @@ export class ObjectId extends BSONValue {
}
// If we are caching the hex string
if (ObjectId.cacheHexString) {
this.__id = ByteUtils.toHex(this.id);
this.__id = ByteUtils.toHex(this[_pool], this[_offset], this[_offset] + 12);
}
}

/** ObjectId bytes @internal */
get buffer(): Uint8Array {
return this.id;
}

/**
* The ObjectId bytes
* @readonly
*/
get id(): Uint8Array {
return this.buffer;
return this[_pool].subarray(this[_offset], this[_offset] + 12);
}

set id(value: Uint8Array) {
this.buffer = value;
if (value.byteLength !== 12) {
throw new BSONError('input must be a 12 byte Uint8Array');
}
this[_pool].set(value, this[_offset]);
if (ObjectId.cacheHexString) {
this.__id = ByteUtils.toHex(value);
}
Expand All @@ -149,7 +208,7 @@ export class ObjectId extends BSONValue {
return this.__id;
}

const hexString = ByteUtils.toHex(this.id);
const hexString = ByteUtils.toHex(this[_pool], this[_offset], this[_offset] + 12);

if (ObjectId.cacheHexString && !this.__id) {
this.__id = hexString;
Expand All @@ -170,34 +229,38 @@ export class ObjectId extends BSONValue {
* Generate a 12 byte id buffer used in ObjectId's
*
* @param time - pass in a second based timestamp.
* @param buffer - Optionally pass in a buffer instance.
* @param offset - Optionally pass in a buffer offset.
*/
static generate(time?: number): Uint8Array {
static generate(time?: number, buffer?: Uint8Array, offset: number = 0): Uint8Array {
SeanReece marked this conversation as resolved.
Show resolved Hide resolved
if ('number' !== typeof time) {
time = Math.floor(Date.now() / 1000);
}

const inc = ObjectId.getInc();
const buffer = ByteUtils.allocateUnsafe(12);
if (!buffer) {
buffer = ByteUtils.allocateUnsafe(12);
}

// 4-byte timestamp
NumberUtils.setInt32BE(buffer, 0, time);
NumberUtils.setInt32BE(buffer, offset, time);

// set PROCESS_UNIQUE if yet not initialized
if (PROCESS_UNIQUE === null) {
PROCESS_UNIQUE = ByteUtils.randomBytes(5);
}

// 5-byte process unique
buffer[4] = PROCESS_UNIQUE[0];
buffer[5] = PROCESS_UNIQUE[1];
buffer[6] = PROCESS_UNIQUE[2];
buffer[7] = PROCESS_UNIQUE[3];
buffer[8] = PROCESS_UNIQUE[4];
buffer[offset + 4] = PROCESS_UNIQUE[0];
buffer[offset + 5] = PROCESS_UNIQUE[1];
buffer[offset + 6] = PROCESS_UNIQUE[2];
buffer[offset + 7] = PROCESS_UNIQUE[3];
buffer[offset + 8] = PROCESS_UNIQUE[4];

// 3-byte counter
buffer[11] = inc & 0xff;
buffer[10] = (inc >> 8) & 0xff;
buffer[9] = (inc >> 16) & 0xff;
buffer[offset + 11] = inc & 0xff;
buffer[offset + 10] = (inc >> 8) & 0xff;
buffer[offset + 9] = (inc >> 16) & 0xff;

return buffer;
}
Expand Down Expand Up @@ -239,9 +302,16 @@ export class ObjectId extends BSONValue {
}

if (ObjectId.is(otherId)) {
return (
this.buffer[11] === otherId.buffer[11] && ByteUtils.equals(this.buffer, otherId.buffer)
);
if (otherId[_pool] && typeof otherId[_offset] === 'number') {
for (let i = 11; i >= 0; i--) {
if (this[_pool][this[_offset] + i] !== otherId[_pool][otherId[_offset] + i]) {
return false;
}
}
return true;
}
// If otherId does not have pool and offset, fallback to buffer comparison for compatibility
return ByteUtils.equals(this.buffer, otherId.buffer);
}

if (typeof otherId === 'string') {
Expand All @@ -260,7 +330,7 @@ export class ObjectId extends BSONValue {
/** Returns the generation date (accurate up to the second) that this ID was generated. */
getTimestamp(): Date {
const timestamp = new Date();
const time = NumberUtils.getUint32BE(this.buffer, 0);
const time = NumberUtils.getUint32BE(this[_pool], this[_offset]);
timestamp.setTime(Math.floor(time) * 1000);
return timestamp;
}
Expand All @@ -272,18 +342,18 @@ export class ObjectId extends BSONValue {

/** @internal */
serializeInto(uint8array: Uint8Array, index: number): 12 {
uint8array[index] = this.buffer[0];
uint8array[index + 1] = this.buffer[1];
uint8array[index + 2] = this.buffer[2];
uint8array[index + 3] = this.buffer[3];
uint8array[index + 4] = this.buffer[4];
uint8array[index + 5] = this.buffer[5];
uint8array[index + 6] = this.buffer[6];
uint8array[index + 7] = this.buffer[7];
uint8array[index + 8] = this.buffer[8];
uint8array[index + 9] = this.buffer[9];
uint8array[index + 10] = this.buffer[10];
uint8array[index + 11] = this.buffer[11];
uint8array[index] = this[_pool][this[_offset]];
uint8array[index + 1] = this[_pool][this[_offset] + 1];
uint8array[index + 2] = this[_pool][this[_offset] + 2];
uint8array[index + 3] = this[_pool][this[_offset] + 3];
uint8array[index + 4] = this[_pool][this[_offset] + 4];
uint8array[index + 5] = this[_pool][this[_offset] + 5];
uint8array[index + 6] = this[_pool][this[_offset] + 6];
uint8array[index + 7] = this[_pool][this[_offset] + 7];
uint8array[index + 8] = this[_pool][this[_offset] + 8];
uint8array[index + 9] = this[_pool][this[_offset] + 9];
uint8array[index + 10] = this[_pool][this[_offset] + 10];
uint8array[index + 11] = this[_pool][this[_offset] + 11];
SeanReece marked this conversation as resolved.
Show resolved Hide resolved
return 12;
}

Expand All @@ -293,7 +363,7 @@ export class ObjectId extends BSONValue {
* @param time - an integer number representing a number of seconds.
*/
static createFromTime(time: number): ObjectId {
const buffer = ByteUtils.allocate(12);
const buffer = ByteUtils.allocateUnsafe(12);
for (let i = 11; i >= 4; i--) buffer[i] = 0;
// Encode time into first 4 bytes
NumberUtils.setInt32BE(buffer, 0, time);
Expand Down
4 changes: 1 addition & 3 deletions src/parser/deserializer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -263,9 +263,7 @@ function deserializeObject(
value = ByteUtils.toUTF8(buffer, index, index + stringSize - 1, shouldValidateKey);
index = index + stringSize;
} else if (elementType === constants.BSON_DATA_OID) {
const oid = ByteUtils.allocateUnsafe(12);
for (let i = 0; i < 12; i++) oid[i] = buffer[index + i];
value = new ObjectId(oid);
value = new ObjectId(buffer, index);
index = index + 12;
} else if (elementType === constants.BSON_DATA_INT && promoteValues === false) {
value = new Int32(NumberUtils.getInt32LE(buffer, index));
Expand Down
2 changes: 1 addition & 1 deletion src/utils/byte_utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export type ByteUtils = {
/** Create a Uint8Array from a hex string */
fromHex: (hex: string) => Uint8Array;
/** Create a lowercase hex string from bytes */
toHex: (buffer: Uint8Array) => string;
toHex: (buffer: Uint8Array, start?: number, end?: number) => string;
/** Create a string from utf8 code units, fatal=true will throw an error if UTF-8 bytes are invalid, fatal=false will insert replacement characters */
toUTF8: (buffer: Uint8Array, start: number, end: number, fatal: boolean) => string;
/** Get the utf8 code unit count from a string if it were to be transformed to utf8 */
Expand Down
6 changes: 3 additions & 3 deletions src/utils/node_byte_utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ type NodeJsEncoding = 'base64' | 'hex' | 'utf8' | 'binary';
type NodeJsBuffer = ArrayBufferView &
Uint8Array & {
write(string: string, offset: number, length: undefined, encoding: 'utf8'): number;
copy(target: Uint8Array, targetStart: number, sourceStart: number, sourceEnd: number): number;
copy(target: Uint8Array, targetStart: number, sourceStart?: number, sourceEnd?: number): number;
toString: (this: Uint8Array, encoding: NodeJsEncoding, start?: number, end?: number) => string;
SeanReece marked this conversation as resolved.
Show resolved Hide resolved
equals: (this: Uint8Array, other: Uint8Array) => boolean;
};
Expand Down Expand Up @@ -124,8 +124,8 @@ export const nodeJsByteUtils = {
return Buffer.from(hex, 'hex');
},

toHex(buffer: Uint8Array): string {
return nodeJsByteUtils.toLocalBufferType(buffer).toString('hex');
toHex(buffer: NodeJsBuffer, start?: number, end?: number): string {
SeanReece marked this conversation as resolved.
Show resolved Hide resolved
return nodeJsByteUtils.toLocalBufferType(buffer).toString('hex', start, end);
},

toUTF8(buffer: Uint8Array, start: number, end: number, fatal: boolean): string {
Expand Down
6 changes: 4 additions & 2 deletions src/utils/web_byte_utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,8 +170,10 @@ export const webByteUtils = {
return Uint8Array.from(buffer);
},

toHex(uint8array: Uint8Array): string {
return Array.from(uint8array, byte => byte.toString(16).padStart(2, '0')).join('');
toHex(uint8array: Uint8Array, start?: number, end?: number): string {
return Array.from(uint8array.subarray(start, end), byte =>
byte.toString(16).padStart(2, '0')
).join('');
},

toUTF8(uint8array: Uint8Array, start: number, end: number, fatal: boolean): string {
Expand Down
Loading