Keep product migrate from crashing the API on large image copies.

PM2 was restarting at 512MB while Sharp cached buffers, which returned 502 to the migrate UI after categories had already succeeded.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-08-30 15:02:40 +03:30
co-authored by Cursor
parent a5f2f8d63b
commit 73588db1c6
2 changed files with 144 additions and 123 deletions
+1 -1
View File
@@ -9,7 +9,7 @@ module.exports = {
env: {
NODE_ENV: 'production',
},
max_memory_restart: '512M',
max_memory_restart: '2G',
time: true,
},
],
+143 -122
View File
@@ -19,6 +19,11 @@ import { StorageService } from '../storage/storage.service';
import { PrismaService } from '../prisma/prisma.service';
import { MigrateFromOldEntity } from './dto/migrate-from-old.dto';
// Legacy product images are large; Sharp's default cache plus PM2's 512MB cap
// was restarting the API mid-migrate (nginx 502, UI "Unable to migrate").
sharp.cache(false);
sharp.concurrency(1);
export type MigrateEntityCounts = {
created: number;
skipped: number;
@@ -2482,144 +2487,160 @@ export class LegacyMigrateService {
continue;
}
const titleEn =
(row.name_en ?? '').trim() || (row.name ?? '').trim() || 'Product';
const nameFa = (row.name ?? '').trim() || null;
const baseSlug = slugify(titleEn);
const slug = await this.ensureUniqueProductSlug(
businessId,
baseSlug,
row.id,
);
try {
const titleEn =
(row.name_en ?? '').trim() || (row.name ?? '').trim() || 'Product';
const nameFa = (row.name ?? '').trim() || null;
const baseSlug = slugify(titleEn);
const slug = await this.ensureUniqueProductSlug(
businessId,
baseSlug,
row.id,
);
// Re-check in case slug already exists from a prior partial run.
const existingBySlug = await this.prisma.product.findFirst({
where: { businessId, slug },
select: { id: true },
});
if (existingBySlug) {
existingByOldId.set(row.id, existingBySlug.id);
skipped += 1;
continue;
}
const productMedia = mediaByProduct.get(row.id) ?? [];
let featuredMediaId: bigint | null = null;
const galleryMediaIds: bigint[] = [];
for (const media of productMedia) {
const size = Number(media.size) || 0;
if (size <= 0) {
imagesFailed += 1;
// Re-check in case slug already exists from a prior partial run.
const existingBySlug = await this.prisma.product.findFirst({
where: { businessId, slug },
select: { id: true },
});
if (existingBySlug) {
existingByOldId.set(row.id, existingBySlug.id);
skipped += 1;
continue;
}
let newMediaId = mediaOldToNew.get(media.id) ?? null;
if (!newMediaId) {
try {
const copied = await this.copyLegacyMedia({
businessId,
oldMedia: media,
storagePrefix,
});
newMediaId = copied.id;
mediaOldToNew.set(media.id, newMediaId);
imagesCopied += 1;
if (copied.resized) {
imagesResized += 1;
}
} catch {
const productMedia = mediaByProduct.get(row.id) ?? [];
let featuredMediaId: bigint | null = null;
const galleryMediaIds: bigint[] = [];
for (const media of productMedia) {
const size = Number(media.size) || 0;
if (size <= 0) {
imagesFailed += 1;
continue;
}
let newMediaId = mediaOldToNew.get(media.id) ?? null;
if (!newMediaId) {
try {
const copied = await this.copyLegacyMedia({
businessId,
oldMedia: media,
storagePrefix,
});
newMediaId = copied.id;
mediaOldToNew.set(media.id, newMediaId);
imagesCopied += 1;
if (copied.resized) {
imagesResized += 1;
}
} catch {
imagesFailed += 1;
continue;
}
}
if (media.collection_name === 'main_image' && !featuredMediaId) {
featuredMediaId = newMediaId;
} else if (media.collection_name === 'images') {
galleryMediaIds.push(newMediaId);
} else if (
media.collection_name === 'main_image' &&
featuredMediaId &&
featuredMediaId !== newMediaId
) {
galleryMediaIds.push(newMediaId);
}
}
if (media.collection_name === 'main_image' && !featuredMediaId) {
featuredMediaId = newMediaId;
} else if (media.collection_name === 'images') {
galleryMediaIds.push(newMediaId);
} else if (
media.collection_name === 'main_image' &&
featuredMediaId &&
featuredMediaId !== newMediaId
) {
galleryMediaIds.push(newMediaId);
}
}
const createdAt = row.created_at ? new Date(row.created_at) : new Date();
const updatedAt = row.updated_at ? new Date(row.updated_at) : createdAt;
const sku =
row.product_code != null && String(row.product_code).trim()
? String(row.product_code).trim()
: null;
const createdAt = row.created_at ? new Date(row.created_at) : new Date();
const updatedAt = row.updated_at ? new Date(row.updated_at) : createdAt;
const sku =
row.product_code != null && String(row.product_code).trim()
? String(row.product_code).trim()
: null;
const product = await this.prisma.product.create({
data: {
businessId,
title: titleEn,
slug,
description: null,
content: {
nameFa,
html: row.description ?? '',
} as Prisma.InputJsonValue,
sku,
price: null,
stockQuantity: null,
status: ContentStatus.published,
featuredMediaId,
sortOrder: index,
publishedAt: createdAt,
metadata: {
migratedFromOldId: row.id,
legacyBrandId: row.brand_id ?? null,
} as Prisma.InputJsonValue,
createdAt,
updatedAt,
},
});
existingByOldId.set(row.id, product.id);
const oldCategoryIds = new Set<number>(
categoriesByProduct.get(row.id) ?? [],
);
if (row.product_category_id != null) {
oldCategoryIds.add(row.product_category_id);
}
for (const oldCategoryId of oldCategoryIds) {
const newCategoryId = categoryMap.get(oldCategoryId);
if (!newCategoryId) continue;
try {
await this.prisma.categoryAssignment.create({
data: {
businessId,
categoryId: newCategoryId,
entityType: MediaEntityType.product,
entityId: product.id,
},
});
} catch {
// ignore duplicate assignment
}
}
for (const [galleryIndex, mediaId] of galleryMediaIds.entries()) {
await this.prisma.mediaAttachment.create({
const product = await this.prisma.product.create({
data: {
businessId,
mediaId,
entityType: MediaEntityType.product,
entityId: product.id,
sortOrder: galleryIndex,
isFeatured: false,
title: titleEn,
slug,
description: null,
content: {
nameFa,
html: row.description ?? '',
} as Prisma.InputJsonValue,
sku,
price: null,
stockQuantity: null,
status: ContentStatus.published,
featuredMediaId,
sortOrder: index,
publishedAt: createdAt,
metadata: {
migratedFromOldId: row.id,
legacyBrandId: row.brand_id ?? null,
} as Prisma.InputJsonValue,
createdAt,
updatedAt,
},
});
existingByOldId.set(row.id, product.id);
const oldCategoryIds = new Set<number>(
categoriesByProduct.get(row.id) ?? [],
);
if (row.product_category_id != null) {
oldCategoryIds.add(row.product_category_id);
}
for (const oldCategoryId of oldCategoryIds) {
const newCategoryId = categoryMap.get(oldCategoryId);
if (!newCategoryId) continue;
try {
await this.prisma.categoryAssignment.create({
data: {
businessId,
categoryId: newCategoryId,
entityType: MediaEntityType.product,
entityId: product.id,
},
});
} catch {
// ignore duplicate assignment
}
}
for (const [galleryIndex, mediaId] of galleryMediaIds.entries()) {
await this.prisma.mediaAttachment.create({
data: {
businessId,
mediaId,
entityType: MediaEntityType.product,
entityId: product.id,
sortOrder: galleryIndex,
isFeatured: false,
},
});
}
created += 1;
} catch (err) {
console.error(
`Legacy migrate: product old id ${row.id} failed`,
err instanceof Error ? err.message : err,
);
skipped += 1;
}
created += 1;
if ((index + 1) % 25 === 0) {
const rssMb = Math.round(process.memoryUsage().rss / 1024 / 1024);
console.log(
`Legacy migrate: products ${index + 1}/${products.length} created=${created} skipped=${skipped} rss=${rssMb}MB`,
);
await new Promise((resolve) => setImmediate(resolve));
}
}
return {