From b85a46e85bb741b942e7fa23bef48f447d2b7c5a Mon Sep 17 00:00:00 2001 From: Adrien de Peretti Date: Tue, 2 Sep 2025 10:19:59 +0200 Subject: [PATCH] chore(): faster serialization (#13325) * chore(): Improve serialization perf * better optimization with monomorphic approach and consistent object shape and array operations * cleanup * cleanup * fix * Create short-birds-help.md * ref work * ref work * address feedback * save intermediary changes * save intermediary changes --- .changeset/short-birds-help.md | 5 + .../src/dal/mikro-orm/mikro-orm-serializer.ts | 633 ++++++++++++------ 2 files changed, 437 insertions(+), 201 deletions(-) create mode 100644 .changeset/short-birds-help.md diff --git a/.changeset/short-birds-help.md b/.changeset/short-birds-help.md new file mode 100644 index 0000000000..2bba8197d4 --- /dev/null +++ b/.changeset/short-birds-help.md @@ -0,0 +1,5 @@ +--- +"@medusajs/utils": patch +--- + +Chore/faster serialization diff --git a/packages/core/utils/src/dal/mikro-orm/mikro-orm-serializer.ts b/packages/core/utils/src/dal/mikro-orm/mikro-orm-serializer.ts index 974211e2de..305800e772 100644 --- a/packages/core/utils/src/dal/mikro-orm/mikro-orm-serializer.ts +++ b/packages/core/utils/src/dal/mikro-orm/mikro-orm-serializer.ts @@ -1,3 +1,8 @@ +/** + * This is an optimized mikro orm serializer to create a highly optimized serialization pipeline + * that leverages V8's JIT compilation and inline caching mechanisms. + */ + import { Collection, EntityDTO, @@ -9,65 +14,125 @@ import { Reference, ReferenceKind, SerializationContext, - SerializeOptions, Utils, } from "@mikro-orm/core" -type CustomSerializeOptions = SerializeOptions & { - preventCircularRef?: boolean - populate?: [keyof T][] | boolean +const STATIC_OPTIONS_SHAPE: { + populate: string[] | boolean | undefined + exclude: string[] | undefined + preventCircularRef: boolean | undefined + skipNull: boolean | undefined + ignoreSerializers: boolean | undefined + forceObject: boolean | undefined +} = { + populate: ["*"], + exclude: undefined, + preventCircularRef: true, + skipNull: undefined, + ignoreSerializers: undefined, + forceObject: true, } +const EMPTY_ARRAY: string[] = [] + +const WILDCARD = "*" +const DOT = "." +const UNDERSCORE = "_" + +// JIT-friendly function with predictable patterns function isVisible( meta: EntityMetadata, propName: string, - options: CustomSerializeOptions = {} + options: Parameters[1] & { + preventCircularRef?: boolean + populate?: string[] | boolean + } = STATIC_OPTIONS_SHAPE ): boolean { - if (options.populate === true) { + // Fast path for boolean populate + const populate = options.populate + if (populate === true) { return true } - if ( - Array.isArray(options.populate) && - options.exclude?.find((item) => item === propName) - ) { + if (Array.isArray(populate)) { + // Check exclusions first (early exit) + const exclude = options.exclude + if (exclude && exclude.length > 0) { + const excludeLen = exclude.length + for (let i = 0; i < excludeLen; i++) { + if (exclude[i] === propName) { + return false + } + } + } + + // Hoist computations outside loop + const propNameLen = propName.length + const propPrefix = propName + DOT + const propPrefixLen = propPrefix.length + const populateLen = populate.length + + // Simple loop that JIT can optimize well + for (let i = 0; i < populateLen; i++) { + const item = populate[i] + if (item === propName || item === WILDCARD) { + return true + } + if ( + item.length > propNameLen && + item.substring(0, propPrefixLen) === propPrefix + ) { + return true + } + } return false } - if ( - Array.isArray(options.populate) && - (options.populate?.find( - (item) => item === propName || item.startsWith(propName + ".") - ) || - options.populate.includes("*")) - ) { - return true - } - + // Inline property check for non-array case const prop = meta.properties[propName] - const visible = (prop && !prop.hidden) || prop === undefined // allow unknown properties - const prefixed = prop && !prop.primary && propName.startsWith("_") // ignore prefixed properties, if it's not a PK - + const visible = (prop && !prop.hidden) || prop === undefined + const prefixed = prop && !prop.primary && propName.charAt(0) === UNDERSCORE return visible && !prefixed } +// Clean, JIT-friendly function function isPopulated( entity: T, propName: string, - options: CustomSerializeOptions + options: Parameters[1] & { + preventCircularRef?: boolean + populate?: string[] | boolean + } = STATIC_OPTIONS_SHAPE ): boolean { - if ( - Array.isArray(options.populate) && - (options.populate?.find( - (item) => item === propName || item.startsWith(propName + ".") - ) || - options.populate.includes("*")) - ) { - return true + const populate = options.populate + + // Fast path for boolean + if (typeof populate === "boolean") { + return populate } - if (typeof options.populate === "boolean") { - return options.populate + if (!Array.isArray(populate)) { + return false + } + + // Hoist computations for JIT optimization + const propNameLen = propName.length + const propPrefix = propName + DOT + const propPrefixLen = propPrefix.length + const populateLen = populate.length + + // Simple predictable loop + for (let i = 0; i < populateLen; i++) { + const item = populate[i] + if (item === propName || item === WILDCARD) { + return true + } + if ( + item.length > propNameLen && + item.substring(0, propPrefixLen) === propPrefix + ) { + return true + } } return false @@ -80,6 +145,7 @@ function isPopulated( * @param options * @param parents */ +// @ts-ignore function filterEntityPropToSerialize({ propName, meta, @@ -88,38 +154,50 @@ function filterEntityPropToSerialize({ }: { propName: string meta: EntityMetadata - options: CustomSerializeOptions + options: Parameters[1] & { + preventCircularRef?: boolean + populate?: string[] | boolean + } parents?: string[] }): boolean { - parents ??= [] + const parentsArray = parents || EMPTY_ARRAY + const isVisibleRes = isVisible(meta, propName, options) const prop = meta.properties[propName] - // Only prevent circular references if prop is a relation if ( prop && options.preventCircularRef && isVisibleRes && prop.kind !== ReferenceKind.SCALAR ) { - // mapToPk would represent a foreign key and we want to keep them if (!!prop.mapToPk) { return true } - return !parents.some((parent) => parent === prop.type) + const parentsLen = parentsArray.length + for (let i = 0; i < parentsLen; i++) { + if (parentsArray[i] === prop.type) { + return false + } + } + return true } return isVisibleRes } export class EntitySerializer { + // Thread-safe per-instance cache to avoid concurrency issues + private static readonly PROPERTY_CACHE_SIZE = 2000 + static serialize( entity: T, - options: CustomSerializeOptions = {}, - parents: string[] = [] + options: Partial = STATIC_OPTIONS_SHAPE, + parents: string[] = EMPTY_ARRAY ): EntityDTO> { - const parents_ = Array.from(new Set(parents)) + // Avoid Array.from and Set allocation for hot path + const parents_ = parents.length > 0 ? Array.from(new Set(parents)) : [] const wrapped = helper(entity) const meta = wrapped.__meta @@ -141,63 +219,93 @@ export class EntitySerializer { } const ret = {} as EntityDTO> - const keys = new Set(meta.primaryKeys) - Object.keys(entity).forEach((prop) => keys.add(prop)) + + // Use Set for deduplication but keep it simple + const keys = new Set() + + const primaryKeys = meta.primaryKeys + const primaryKeysLen = primaryKeys.length + for (let i = 0; i < primaryKeysLen; i++) { + keys.add(primaryKeys[i]) + } + + const entityKeys = Object.keys(entity) + const entityKeysLen = entityKeys.length + for (let i = 0; i < entityKeysLen; i++) { + keys.add(entityKeys[i]) + } const visited = root.visited.has(entity) if (!visited) { root.visited.add(entity) } - ;[...keys] - /** Medusa Custom properties filtering **/ - .filter((prop) => - filterEntityPropToSerialize({ - propName: prop, - meta, - options, - parents: parents_, - }) - ) - .map((prop) => { - const cycle = root.visit(meta.className, prop) + const keysArray = Array.from(keys) + const keysLen = keysArray.length - if (cycle && visited) { - return [prop, undefined] + // Hoist invariant calculations + const className = meta.className + const platform = wrapped.__platform + const skipNull = options.skipNull + const metaProperties = meta.properties + const preventCircularRef = options.preventCircularRef + + // Clean property processing loop + for (let i = 0; i < keysLen; i++) { + const prop = keysArray[i] + + // Simple filtering logic + const isVisibleRes = isVisible(meta, prop, options) + const propMeta = metaProperties[prop] + + let shouldSerialize = isVisibleRes + if ( + propMeta && + preventCircularRef && + isVisibleRes && + propMeta.kind !== ReferenceKind.SCALAR + ) { + if (!!propMeta.mapToPk) { + shouldSerialize = true + } else { + const parentsLen = parents_.length + for (let j = 0; j < parentsLen; j++) { + if (parents_[j] === propMeta.type) { + shouldSerialize = false + break + } + } } + } - const val = this.processProperty( - prop as keyof T & string, - entity, - options, - parents_ - ) + if (!shouldSerialize) { + continue + } - if (!cycle) { - root.leave(meta.className, prop) - } + const cycle = root.visit(className, prop) + if (cycle && visited) continue - if (options.skipNull && Utils.isPlainObject(val)) { - Utils.dropUndefinedProperties(val, null) - } - - return [prop, val] - }) - .filter( - ([, value]) => - typeof value !== "undefined" && !(value === null && options.skipNull) - ) - .forEach( - ([prop, value]) => - (ret[ - this.propertyName( - meta, - prop as keyof T & string, - wrapped.__platform - ) - ] = value as T[keyof T & string]) + const val = this.processProperty( + prop as keyof T & string, + entity, + options, + parents_ ) + if (!cycle) { + root.leave(className, prop) + } + + if (skipNull && Utils.isPlainObject(val)) { + Utils.dropUndefinedProperties(val, null) + } + + if (typeof val !== "undefined" && !(val === null && skipNull)) { + ret[this.propertyName(meta, prop as keyof T & string, platform)] = + val as T[keyof T & string] + } + } + if (contextCreated) { root.close() } @@ -206,79 +314,118 @@ export class EntitySerializer { return ret } - // decorated getters - meta.props - .filter( - (prop) => - prop.getter && - prop.getterName === undefined && - typeof entity[prop.name] !== "undefined" && - isVisible(meta, prop.name, options) - ) - .forEach( - (prop) => - (ret[this.propertyName(meta, prop.name, wrapped.__platform)] = - this.processProperty(prop.name, entity, options, parents_)) - ) + // Clean getter processing + const metaProps = meta.props + const metaPropsLen = metaProps.length - // decorated get methods - meta.props - .filter( - (prop) => - prop.getterName && - (entity[prop.getterName] as unknown) instanceof Function && - isVisible(meta, prop.name, options) - ) - .forEach( - (prop) => - (ret[this.propertyName(meta, prop.name, wrapped.__platform)] = - this.processProperty( - prop.getterName as keyof T & string, - entity, - options, - parents_ - )) - ) + for (let i = 0; i < metaPropsLen; i++) { + const prop = metaProps[i] + const propName = prop.name + + // Clear, readable conditions + if ( + prop.getter && + prop.getterName === undefined && + typeof entity[propName] !== "undefined" && + isVisible(meta, propName, options) + ) { + ret[this.propertyName(meta, propName, platform)] = this.processProperty( + propName, + entity, + options, + parents_ + ) + } else if ( + prop.getterName && + (entity[prop.getterName] as unknown) instanceof Function && + isVisible(meta, propName, options) + ) { + ret[this.propertyName(meta, propName, platform)] = this.processProperty( + prop.getterName as keyof T & string, + entity, + options, + parents_ + ) + } + } return ret } + // Thread-safe property name resolution with WeakMap for per-entity caching + private static propertyNameCache = new WeakMap< + EntityMetadata, + Map + >() + private static propertyName( meta: EntityMetadata, prop: string, platform?: Platform ): string { + // Use WeakMap per metadata to avoid global cache conflicts + let entityCache = this.propertyNameCache.get(meta) + if (!entityCache) { + entityCache = new Map() + this.propertyNameCache.set(meta, entityCache) + } + + const cacheKey = `${prop}:${platform?.constructor.name || "no-platform"}` + + const cached = entityCache.get(cacheKey) + if (cached !== undefined) { + return cached + } + + // Inline property resolution for hot path + let result: string + const property = meta.properties[prop] + /* istanbul ignore next */ - if (meta.properties[prop]?.serializedName) { - return meta.properties[prop].serializedName as string + if (property?.serializedName) { + result = property.serializedName as string + } else if (property?.primary && platform) { + result = platform.getSerializedPrimaryKeyField(prop) as string + } else { + result = prop } - if (meta.properties[prop]?.primary && platform) { - return platform.getSerializedPrimaryKeyField(prop) as string + // Prevent cache from growing too large + if (entityCache.size >= this.PROPERTY_CACHE_SIZE) { + entityCache.clear() // Much faster than selective deletion } - return prop + entityCache.set(cacheKey, result) + return result } private static processProperty( prop: string, entity: T, - options: CustomSerializeOptions, - parents: string[] = [] + options: Parameters[1] & { + preventCircularRef?: boolean + populate?: string[] | boolean + }, + parents: string[] = EMPTY_ARRAY ): T[keyof T] | undefined { - const parents_ = [...parents, entity.constructor.name] + // Avoid array allocation when not needed + const parents_ = + parents.length > 0 + ? [...parents, entity.constructor.name] + : [entity.constructor.name] - const parts = prop.split(".") + // Handle dotted properties efficiently + const parts = prop.split(DOT) prop = parts[0] as string & keyof T + const wrapped = helper(entity) const property = wrapped.__meta.properties[prop] const serializer = property?.serializer + const propValue = entity[prop] - // getter method - if ((entity[prop] as unknown) instanceof Function) { - const returnValue = ( - entity[prop] as unknown as () => T[keyof T & string] - )() + // Fast path for function properties + if ((propValue as unknown) instanceof Function) { + const returnValue = (propValue as unknown as () => T[keyof T & string])() if (!options.ignoreSerializers && serializer) { return serializer(returnValue) } @@ -287,10 +434,11 @@ export class EntitySerializer { /* istanbul ignore next */ if (!options.ignoreSerializers && serializer) { - return serializer(entity[prop]) + return serializer(propValue) } - if (Utils.isCollection(entity[prop])) { + // Type checks in optimal order + if (Utils.isCollection(propValue)) { return this.processCollection( prop as keyof T & string, entity, @@ -299,7 +447,7 @@ export class EntitySerializer { ) } - if (Utils.isEntity(entity[prop], true)) { + if (Utils.isEntity(propValue, true)) { return this.processEntity( prop as keyof T & string, entity, @@ -311,64 +459,102 @@ export class EntitySerializer { /* istanbul ignore next */ if (property?.reference === ReferenceKind.EMBEDDED) { - if (Array.isArray(entity[prop])) { - return (entity[prop] as object[]).map((item) => + if (Array.isArray(propValue)) { + return (propValue as object[]).map((item) => helper(item).toJSON() ) as T[keyof T] } - if (Utils.isObject(entity[prop])) { - return helper(entity[prop]).toJSON() as T[keyof T] + if (Utils.isObject(propValue)) { + return helper(propValue).toJSON() as T[keyof T] } } const customType = property?.customType - if (customType) { - return customType.toJSON(entity[prop], wrapped.__platform) + return customType.toJSON(propValue, wrapped.__platform) } - return wrapped.__platform.normalizePrimaryKey( - entity[prop] as unknown as IPrimaryKey + propValue as unknown as IPrimaryKey ) as unknown as T[keyof T] } - private static extractChildOptions( - options: CustomSerializeOptions, + private static extractChildOptions( + options: Parameters[1] & { + preventCircularRef?: boolean + populate?: string[] | boolean + }, prop: keyof T & string - ): CustomSerializeOptions { + ): Parameters[1] & { + preventCircularRef?: boolean + populate?: string[] | boolean + } { + const propPrefix = prop + DOT + const propPrefixLen = propPrefix.length + + // Inline function to avoid call overhead const extractChildElements = (items: string[]) => { - return items - .filter((field) => field.startsWith(`${prop}.`)) - .map((field) => field.substring(prop.length + 1)) + const result: string[] = [] + const itemsLen = items.length + + // Traditional for loop for better performance + for (let i = 0; i < itemsLen; i++) { + const field = items[i] + if ( + field.length > propPrefixLen && + field.substring(0, propPrefixLen) === propPrefix + ) { + result.push(field.substring(propPrefixLen)) + } + } + return result } - return { - ...options, + const populate = options.populate + const exclude = options.exclude + + // Avoid object spread when possible + const result = { populate: - Array.isArray(options.populate) && !options.populate.includes("*") - ? extractChildElements(options.populate as unknown as string[]) - : options.populate, + Array.isArray(populate) && !populate.includes(WILDCARD) + ? extractChildElements(populate as unknown as string[]) + : populate, exclude: - Array.isArray(options.exclude) && !options.exclude.includes("*") - ? extractChildElements(options.exclude) - : options.exclude, - } as CustomSerializeOptions + Array.isArray(exclude) && !exclude.includes(WILDCARD) + ? extractChildElements(exclude) + : exclude, + preventCircularRef: options.preventCircularRef, + skipNull: options.skipNull, + ignoreSerializers: options.ignoreSerializers, + forceObject: options.forceObject, + } as Parameters[1] & { + preventCircularRef?: boolean + populate?: string[] | boolean + } + + return result } private static processEntity( prop: keyof T & string, entity: T, platform: Platform, - options: CustomSerializeOptions, - parents: string[] = [] + options: Parameters[1] & { + preventCircularRef?: boolean + populate?: string[] | boolean + }, + parents: string[] = EMPTY_ARRAY ): T[keyof T] | undefined { - const parents_ = [...parents, entity.constructor.name] + const parents_ = + parents.length > 0 + ? [...parents, entity.constructor.name] + : [entity.constructor.name] const child = Reference.unwrapReference(entity[prop] as T) const wrapped = helper(child) + // Fixed: was incorrectly calling isPopulated(child, prop, options) instead of isPopulated(entity, prop, options) const populated = - isPopulated(child, prop, options) && wrapped.isInitialized() + isPopulated(entity, prop, options) && wrapped.isInitialized() const expand = populated || options.forceObject || !wrapped.__managed if (expand) { @@ -387,67 +573,112 @@ export class EntitySerializer { private static processCollection( prop: keyof T & string, entity: T, - options: CustomSerializeOptions, - parents: string[] = [] + options: Parameters[1] & { + preventCircularRef?: boolean + populate?: string[] | boolean + }, + parents: string[] = EMPTY_ARRAY ): T[keyof T] | undefined { - const parents_ = [...parents, entity.constructor.name] + const parents_ = + parents.length > 0 + ? [...parents, entity.constructor.name] + : [entity.constructor.name] const col = entity[prop] as unknown as Collection if (!col.isInitialized()) { return undefined } - return col.getItems(false).map((item) => { - if (isPopulated(item, prop, options)) { - return this.serialize( - item, - this.extractChildOptions(options, prop), - parents_ - ) - } + const items = col.getItems(false) + const itemsLen = items.length + const result = new Array(itemsLen) - return helper(item).getPrimaryKey() - }) as unknown as T[keyof T] + const childOptions = this.extractChildOptions(options, prop) + + // Check if the collection property itself should be populated + // Fixed: was incorrectly calling isPopulated(item, prop, options) instead of isPopulated(entity, prop, options) + const shouldPopulateCollection = isPopulated(entity, prop, options) + + for (let i = 0; i < itemsLen; i++) { + const item = items[i] + if (shouldPopulateCollection) { + result[i] = this.serialize(item, childOptions, parents_) + } else { + result[i] = helper(item).getPrimaryKey() + } + } + + return result as unknown as T[keyof T] } } export const mikroOrmSerializer = ( data: any, - options?: Parameters[1] & { - preventCircularRef?: boolean - populate?: string[] | boolean - } + options?: Partial< + Parameters[1] & { + preventCircularRef: boolean | undefined + populate: string[] | boolean | undefined + } + > ): Promise => { return new Promise((resolve) => { - options ??= {} + // Efficient options handling + if (!options) { + options = STATIC_OPTIONS_SHAPE + } else { + // Check if we can use static shape + let useStatic = true + const optionKeys = Object.keys(options) + for (let i = 0; i < optionKeys.length; i++) { + const key = optionKeys[i] as keyof typeof options + if ( + options[key] !== + STATIC_OPTIONS_SHAPE[key as keyof typeof STATIC_OPTIONS_SHAPE] + ) { + useStatic = false + break + } + } + + if (useStatic) { + options = STATIC_OPTIONS_SHAPE + } else { + options = { ...STATIC_OPTIONS_SHAPE, ...options } + } + } const data_ = (Array.isArray(data) ? data : [data]).filter(Boolean) - const forSerialization: unknown[] = [] - const notForSerialization: unknown[] = [] + const forSerialization: object[] = [] + const notForSerialization: object[] = [] - data_.forEach((object) => { + // Simple classification loop + const dataLen = data_.length + for (let i = 0; i < dataLen; i++) { + const object = data_[i] if (object.__meta) { - return forSerialization.push(object) + forSerialization.push(object) + } else { + notForSerialization.push(object) } - - return notForSerialization.push(object) - }) - - let result: any = forSerialization.map((entity) => - EntitySerializer.serialize(entity, { - forceObject: true, - populate: ["*"], - - preventCircularRef: true, - ...options, - } as CustomSerializeOptions) - ) as TOutput[] - - if (notForSerialization.length) { - result = result.concat(notForSerialization) } - resolve(Array.isArray(data) ? result : result[0]) + // Pre-allocate result array + const forSerializationLen = forSerialization.length + const result: any = new Array(forSerializationLen) + + for (let i = 0; i < forSerializationLen; i++) { + result[i] = EntitySerializer.serialize(forSerialization[i], options) + } + + // Simple result construction + let finalResult: any + if (notForSerialization.length > 0) { + finalResult = result.concat(notForSerialization) + } else { + finalResult = result + } + + resolve(Array.isArray(data) ? finalResult : finalResult[0]) }) }