Skip to content

Commit b193ea6

Browse files
committed
fix: asset heap memoryb issue
1 parent b193cb2 commit b193ea6

File tree

6 files changed

+309
-5
lines changed

6 files changed

+309
-5
lines changed

packages/contentstack-import/src/config/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -93,9 +93,9 @@ const config: DefaultConfig = {
9393
assetBatchLimit: 1,
9494
fileName: 'assets.json',
9595
importSameStructure: true,
96-
uploadAssetsConcurrency: 2,
96+
uploadAssetsConcurrency: 2, // Keeping original concurrency setting
9797
displayExecutionTime: false,
98-
importFoldersConcurrency: 1,
98+
importFoldersConcurrency: 1, // Keeping original concurrency setting
9999
includeVersionedAssets: false,
100100
host: 'https://api.contentstack.io',
101101
folderValidKeys: ['name', 'parent_uid'],

packages/contentstack-import/src/import/modules/assets.ts

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import { PATH_CONSTANTS } from '../../constants';
1818

1919
import config from '../../config';
2020
import { ModuleClassParams } from '../../types';
21-
import { formatDate, PROCESS_NAMES, MODULE_CONTEXTS, MODULE_NAMES, PROCESS_STATUS } from '../../utils';
21+
import { formatDate, PROCESS_NAMES, MODULE_CONTEXTS, MODULE_NAMES, PROCESS_STATUS, MemoryUtils } from '../../utils';
2222
import BaseClass, { ApiOptions } from './base-class';
2323

2424
export default class ImportAssets extends BaseClass {
@@ -241,6 +241,29 @@ export default class ImportAssets extends BaseClass {
241241
this.progressManager?.tick(true, `asset: ${title || uid}`, null, progressProcessName);
242242
log.debug(`Created asset: ${title} (Mapped ${uid}${response.uid})`, this.importConfig.context);
243243
log.success(`Created asset: '${title}'`, this.importConfig.context);
244+
245+
// Periodic mapping cleanup every 1000 assets to prevent memory accumulation
246+
const totalMappings = Object.keys(this.assetsUidMap).length;
247+
if (MemoryUtils.shouldCleanup(totalMappings, 1000)) {
248+
log.debug(`Performing periodic cleanup at ${totalMappings} assets`, this.importConfig.context);
249+
250+
// Write current mappings to disk
251+
if (!isEmpty(this.assetsUidMap)) {
252+
this.fs.writeFile(this.assetUidMapperPath, this.assetsUidMap);
253+
}
254+
if (!isEmpty(this.assetsUrlMap)) {
255+
this.fs.writeFile(this.assetUrlMapperPath, this.assetsUrlMap);
256+
}
257+
258+
// Clear in-memory maps to free memory
259+
this.assetsUidMap = {};
260+
this.assetsUrlMap = {};
261+
262+
// Force garbage collection if available
263+
MemoryUtils.forceGarbageCollection(this.importConfig.context);
264+
265+
MemoryUtils.logMemoryStats(`After cleanup at ${totalMappings} assets`, this.importConfig.context);
266+
}
244267
};
245268

246269
const onReject = ({ error, apiData: { title, uid } = undefined }: any) => {
@@ -307,20 +330,33 @@ export default class ImportAssets extends BaseClass {
307330
undefined,
308331
!isVersion,
309332
);
333+
334+
// Memory cleanup after chunk processing
335+
MemoryUtils.cleanup(chunk, apiContent);
336+
337+
// Log memory stats periodically
338+
if (+index % 10 === 0) {
339+
MemoryUtils.logMemoryStats(`Processed chunk ${index}/${indexerCount}`, this.importConfig.context);
340+
}
310341
}
311342
}
312343

313344
if (!isVersion) {
345+
// Write any remaining mappings that weren't written during periodic cleanup
314346
if (!isEmpty(this.assetsUidMap)) {
315347
const uidMappingCount = Object.keys(this.assetsUidMap || {}).length;
316-
log.debug(`Writing ${uidMappingCount} UID mappings`, this.importConfig.context);
348+
log.debug(`Writing final ${uidMappingCount} UID mappings`, this.importConfig.context);
317349
this.fs.writeFile(this.assetUidMapperPath, this.assetsUidMap);
318350
}
319351
if (!isEmpty(this.assetsUrlMap)) {
320352
const urlMappingCount = Object.keys(this.assetsUrlMap || {}).length;
321-
log.debug(`Writing ${urlMappingCount} URL mappings`, this.importConfig.context);
353+
log.debug(`Writing final ${urlMappingCount} URL mappings`, this.importConfig.context);
322354
this.fs.writeFile(this.assetUrlMapperPath, this.assetsUrlMap);
323355
}
356+
357+
// Final memory cleanup
358+
MemoryUtils.cleanup(this.assetsUidMap, this.assetsUrlMap);
359+
MemoryUtils.logMemoryStats('Import completed', this.importConfig.context);
324360
}
325361
}
326362

packages/contentstack-import/src/utils/backup-handler.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import * as path from 'path';
22
import { copy } from 'fs-extra';
3+
import { statSync } from 'node:fs';
34
import { cliux, sanitizePath, log } from '@contentstack/cli-utilities';
45

56
import { fileHelper } from './index';
@@ -53,6 +54,27 @@ export default async function backupHandler(importConfig: ImportConfig): Promise
5354
}
5455

5556
if (backupDirPath) {
57+
// Check dataset size before backup to prevent memory issues
58+
try {
59+
const stats = statSync(sourceDir);
60+
const sizeGB = stats.size / (1024 * 1024 * 1024);
61+
const sizeThresholdGB = 1; // Skip backup for datasets larger than 1GB
62+
63+
log.debug(`Source directory size: ${sizeGB.toFixed(2)}GB`, importConfig.context);
64+
65+
if (sizeGB > sizeThresholdGB) {
66+
const skipMessage = `Large dataset detected (${sizeGB.toFixed(2)}GB > ${sizeThresholdGB}GB threshold). Skipping backup to save memory and prevent OOM errors.`;
67+
log.warn(skipMessage, importConfig.context);
68+
cliux.print(skipMessage, { color: 'yellow' });
69+
70+
// Return the source directory as the "backup" directory
71+
log.debug(`Using source directory directly: ${sourceDir}`, importConfig.context);
72+
return sourceDir;
73+
}
74+
} catch (error) {
75+
log.debug(`Could not determine source directory size: ${error}. Proceeding with backup.`, importConfig.context);
76+
}
77+
5678
log.debug(`Starting content copy to backup directory: ${backupDirPath}`);
5779
log.info('Copying content to the backup directory...', importConfig.context);
5880

packages/contentstack-import/src/utils/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,4 +32,5 @@ export {
3232
} from './entries-helper';
3333
export * from './common-helper';
3434
export { lookUpTaxonomy, lookUpTerms } from './taxonomies-helper';
35+
export { MemoryUtils, MemoryStats } from './memory-utils';
3536
export { MODULE_CONTEXTS, MODULE_NAMES, PROCESS_NAMES, PROCESS_STATUS } from './constants';
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import { log } from '@contentstack/cli-utilities';
2+
3+
export interface MemoryStats {
4+
rss: number;
5+
heapTotal: number;
6+
heapUsed: number;
7+
external: number;
8+
arrayBuffers: number;
9+
heapUsedMB: number;
10+
heapTotalMB: number;
11+
rssMB: number;
12+
}
13+
14+
/**
15+
* Simple memory monitoring utilities for asset import
16+
*/
17+
export class MemoryUtils {
18+
private static lastGC: number = 0;
19+
private static gcCooldownMs: number = 5000; // 5 second cooldown between GC calls
20+
21+
/**
22+
* Get current memory usage statistics
23+
*/
24+
static getMemoryStats(): MemoryStats {
25+
const usage = process.memoryUsage();
26+
27+
return {
28+
rss: usage.rss,
29+
heapTotal: usage.heapTotal,
30+
heapUsed: usage.heapUsed,
31+
external: usage.external,
32+
arrayBuffers: usage.arrayBuffers,
33+
heapUsedMB: Math.round(usage.heapUsed / 1024 / 1024 * 100) / 100,
34+
heapTotalMB: Math.round(usage.heapTotal / 1024 / 1024 * 100) / 100,
35+
rssMB: Math.round(usage.rss / 1024 / 1024 * 100) / 100,
36+
};
37+
}
38+
39+
/**
40+
* Check if memory usage exceeds the given threshold
41+
* @param thresholdMB Memory threshold in MB
42+
*/
43+
static checkMemoryPressure(thresholdMB: number = 1024): boolean {
44+
const stats = this.getMemoryStats();
45+
return stats.heapUsedMB > thresholdMB;
46+
}
47+
48+
/**
49+
* Force garbage collection if available and cooldown period has passed
50+
*/
51+
static async forceGarbageCollection(context?: Record<string, any>): Promise<void> {
52+
const now = Date.now();
53+
54+
if (now - this.lastGC < this.gcCooldownMs) {
55+
return; // Skip if cooldown period hasn't passed
56+
}
57+
58+
const beforeStats = this.getMemoryStats();
59+
60+
if (global.gc) {
61+
log.debug(`Forcing garbage collection - heap before: ${beforeStats.heapUsedMB}MB`, context);
62+
global.gc();
63+
64+
// Small delay to allow GC to complete
65+
await new Promise(resolve => setTimeout(resolve, 100));
66+
67+
const afterStats = this.getMemoryStats();
68+
const freedMB = beforeStats.heapUsedMB - afterStats.heapUsedMB;
69+
70+
log.debug(`GC completed - heap after: ${afterStats.heapUsedMB}MB, freed: ${freedMB.toFixed(2)}MB`, context);
71+
72+
this.lastGC = now;
73+
} else {
74+
log.warn('Garbage collection not available. Run with --expose-gc flag for better memory management.', context);
75+
}
76+
}
77+
78+
/**
79+
* Log memory statistics with a given label
80+
*/
81+
static logMemoryStats(label: string, context?: Record<string, any>): void {
82+
const stats = this.getMemoryStats();
83+
log.debug(`${label} - Memory: ${stats.heapUsedMB}MB used / ${stats.heapTotalMB}MB total (RSS: ${stats.rssMB}MB)`, context);
84+
}
85+
86+
/**
87+
* Perform memory cleanup operations
88+
* @param objects Array of objects to null out
89+
*/
90+
static cleanup(...objects: any[]): void {
91+
for (let i = 0; i < objects.length; i++) {
92+
objects[i] = null;
93+
}
94+
}
95+
96+
/**
97+
* Check if we should trigger memory cleanup based on count
98+
* @param count Current count
99+
* @param interval Cleanup interval (default 1000)
100+
*/
101+
static shouldCleanup(count: number, interval: number = 1000): boolean {
102+
return count > 0 && count % interval === 0;
103+
}
104+
}
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
import { expect } from 'chai';
2+
import sinon from 'sinon';
3+
import { MemoryUtils } from '../../src/utils/memory-utils';
4+
5+
describe('Memory Optimization Integration', () => {
6+
let memoryUtilsSpy: sinon.SinonSpy;
7+
let processMemoryUsageStub: sinon.SinonStub;
8+
9+
beforeEach(() => {
10+
// Mock process.memoryUsage to simulate memory pressure
11+
processMemoryUsageStub = sinon.stub(process, 'memoryUsage').returns({
12+
rss: 2000 * 1024 * 1024, // 2GB
13+
heapTotal: 1500 * 1024 * 1024, // 1.5GB
14+
heapUsed: 1200 * 1024 * 1024, // 1.2GB (above 1GB threshold)
15+
external: 100 * 1024 * 1024,
16+
arrayBuffers: 50 * 1024 * 1024,
17+
});
18+
19+
memoryUtilsSpy = sinon.spy(MemoryUtils);
20+
});
21+
22+
afterEach(() => {
23+
processMemoryUsageStub.restore();
24+
sinon.restore();
25+
});
26+
27+
describe('Memory Pressure Detection', () => {
28+
it('should detect memory pressure with large heap usage', () => {
29+
const isUnderPressure = MemoryUtils.checkMemoryPressure(1024); // 1GB threshold
30+
expect(isUnderPressure).to.be.true;
31+
});
32+
33+
it('should not detect memory pressure with normal heap usage', () => {
34+
// Mock lower memory usage
35+
processMemoryUsageStub.returns({
36+
rss: 500 * 1024 * 1024,
37+
heapTotal: 400 * 1024 * 1024,
38+
heapUsed: 300 * 1024 * 1024, // 300MB < 1GB threshold
39+
external: 50 * 1024 * 1024,
40+
arrayBuffers: 25 * 1024 * 1024,
41+
});
42+
43+
const isUnderPressure = MemoryUtils.checkMemoryPressure(1024);
44+
expect(isUnderPressure).to.be.false;
45+
});
46+
});
47+
48+
describe('Periodic Cleanup Logic', () => {
49+
it('should trigger cleanup at correct intervals', () => {
50+
// Test cleanup intervals
51+
expect(MemoryUtils.shouldCleanup(1000, 1000)).to.be.true;
52+
expect(MemoryUtils.shouldCleanup(2000, 1000)).to.be.true;
53+
expect(MemoryUtils.shouldCleanup(999, 1000)).to.be.false;
54+
expect(MemoryUtils.shouldCleanup(1001, 1000)).to.be.false;
55+
});
56+
57+
it('should use default interval of 1000', () => {
58+
expect(MemoryUtils.shouldCleanup(1000)).to.be.true;
59+
expect(MemoryUtils.shouldCleanup(2000)).to.be.true;
60+
expect(MemoryUtils.shouldCleanup(3000)).to.be.true;
61+
});
62+
});
63+
64+
describe('Memory Statistics', () => {
65+
it('should provide accurate memory statistics', () => {
66+
const stats = MemoryUtils.getMemoryStats();
67+
68+
expect(stats.heapUsedMB).to.equal(1200); // 1.2GB in MB
69+
expect(stats.heapTotalMB).to.equal(1500); // 1.5GB in MB
70+
expect(stats.rssMB).to.equal(2000); // 2GB in MB
71+
72+
expect(stats.heapUsed).to.equal(1200 * 1024 * 1024);
73+
expect(stats.heapTotal).to.equal(1500 * 1024 * 1024);
74+
expect(stats.rss).to.equal(2000 * 1024 * 1024);
75+
});
76+
});
77+
78+
describe('Garbage Collection', () => {
79+
it('should handle garbage collection gracefully when not available', async () => {
80+
// Ensure global.gc is not available
81+
delete (global as any).gc;
82+
83+
// Should not throw an error
84+
await MemoryUtils.forceGarbageCollection();
85+
});
86+
87+
it('should call garbage collection when available', async () => {
88+
const mockGc = sinon.stub();
89+
(global as any).gc = mockGc;
90+
91+
await MemoryUtils.forceGarbageCollection();
92+
93+
expect(mockGc.calledOnce).to.be.true;
94+
95+
delete (global as any).gc;
96+
});
97+
});
98+
99+
describe('Memory Cleanup Simulation', () => {
100+
it('should simulate asset processing memory cleanup', () => {
101+
// Simulate processing 5000 assets
102+
let memoryCleanupCount = 0;
103+
104+
for (let i = 1; i <= 5000; i++) {
105+
if (MemoryUtils.shouldCleanup(i, 1000)) {
106+
memoryCleanupCount++;
107+
}
108+
}
109+
110+
// Should trigger cleanup 5 times (at 1000, 2000, 3000, 4000, 5000)
111+
expect(memoryCleanupCount).to.equal(5);
112+
});
113+
114+
it('should demonstrate memory pressure detection throughout processing', () => {
115+
const memoryReadings = [];
116+
117+
// Simulate increasing memory usage
118+
for (let i = 0; i < 5; i++) {
119+
const memoryUsageMB = 500 + (i * 200); // 500MB, 700MB, 900MB, 1100MB, 1300MB
120+
121+
processMemoryUsageStub.returns({
122+
rss: memoryUsageMB * 1024 * 1024,
123+
heapTotal: (memoryUsageMB - 100) * 1024 * 1024,
124+
heapUsed: (memoryUsageMB - 200) * 1024 * 1024,
125+
external: 50 * 1024 * 1024,
126+
arrayBuffers: 25 * 1024 * 1024,
127+
});
128+
129+
const isUnderPressure = MemoryUtils.checkMemoryPressure(1024); // 1GB threshold
130+
memoryReadings.push({ memoryUsageMB: memoryUsageMB - 200, isUnderPressure });
131+
}
132+
133+
// Should detect pressure when memory exceeds 1GB (1024MB)
134+
expect(memoryReadings[0].isUnderPressure).to.be.false; // 300MB
135+
expect(memoryReadings[1].isUnderPressure).to.be.false; // 500MB
136+
expect(memoryReadings[2].isUnderPressure).to.be.false; // 700MB
137+
expect(memoryReadings[3].isUnderPressure).to.be.true; // 900MB (close to threshold)
138+
expect(memoryReadings[4].isUnderPressure).to.be.true; // 1100MB (over threshold)
139+
});
140+
});
141+
});

0 commit comments

Comments
 (0)