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:
co-authored by
Cursor
parent
a5f2f8d63b
commit
73588db1c6
+1
-1
@@ -9,7 +9,7 @@ module.exports = {
|
||||
env: {
|
||||
NODE_ENV: 'production',
|
||||
},
|
||||
max_memory_restart: '512M',
|
||||
max_memory_restart: '2G',
|
||||
time: true,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user