Yuki Takei 6 месяцев назад
Родитель
Сommit
224cab828a

+ 0 - 83
.serena/memories/git-bisect-memory-consumption-investigation-plan.md

@@ -1,83 +0,0 @@
-# git bisectによるメモリ消費量増加の原因特定調査計画
-
-## 調査目的
-2025/7/1以降、production buildしたサーバーのメモリ利用量(Heap Total)が約25%~33%増加した原因コミットを特定する。
-
-## 判定基準
-- **Good:** Heap Total ≒ 90MB
-- **Bad:** Heap Total ≒ 110MB
-
-## 調査範囲
-- 開始コミット: タグ `v7.2.9` (acdccb05538b72a593d690ce042922d6b71a4a63)
-- 終了コミット: master (db1d378da55ffa8c08b4f1a0cca3b6a2a3e2c219)
-
-## 実行手順
-1. 対象コミットをチェックアウト
-   ```bash
-   git checkout {target-commit}
-   ```
-2. ビルド
-   ```bash
-   cd /workspace/growi/apps/app
-   turbo run bootstrap
-   turbo run build
-   ```
-3. サーバー起動
-   ```bash
-   NODE_ENV=production node --inspect -r dotenv-flow/config dist/server/app.js
-   ```
-   サーバーはバックグラウンドで起動し、プロセスIDを /tmp/growi_server.pid に記録
-4. 10秒 sleep してからメモリ消費量計測
-   ```bash
-   sleep 10
-   cp /home/vscode/print-memory-consumption.ts tmp/
-   node --experimental-strip-types --experimental-transform-types --experimental-detect-module --no-warnings=ExperimentalWarning tmp/print-memory-consumption.ts
-   ```
-5. サーバー停止
-  ```bash
-  kill $(cat /tmp/growi_server.pid) && rm /tmp/growi_server.pid
-  ```
-6. Heap Total値でGood/Bad判定
-
-## 注意事項
-- サーバー起動直後の値で判定する(アクセスによるメモリリークの可能性もあるため、なるべくアクセス前に計測)。
-- 必要に応じて複数回計測し、安定した値を採用する。
-- bisectの自動化には、Heap Total値の判定をスクリプト化することで効率化可能。
-
----
-
-# git bisect 実施指示書
-
-1. bisect開始
-   ```bash
-   git bisect start
-   git bisect bad master
-   git bisect good v7.2.9
-   ```
-2. 各コミットで以下を実施
-   - 上記「実行手順」に従いビルド・起動・計測
-   - Heap Total値でGood/Bad判定
-   - 判定結果に応じて
-     ```bash
-     git bisect good
-     # または
-     git bisect bad
-     ```
-3. bisect終了後、原因コミットを記録
-   ```bash
-   git bisect reset
-   ```
-
----
-
-## 参考: 判定自動化例(bashスクリプト)
-
-```bash
-HEAP_TOTAL=$(node .../print-memory-consumption.ts | grep 'Heap Total' | awk '{print $3}')
-if (( $(echo "$HEAP_TOTAL < 100" | bc -l) )); then
-  exit 0  # good
-else
-  exit 1  # bad
-fi
-```
-bisect runで自動化する場合はこのスクリプトを利用してください。

+ 0 - 269
.serena/memories/import-service-memory-leak-analysis-report.md

@@ -1,269 +0,0 @@
-# インポート機能 メモリリーク分析レポート(更新版)
-
-## 概要
-`/workspace/growi/apps/app/src/server/service/import/import.ts` および関連ファイルにおけるメモリリークの可能性を詳細分析し、実際の修正実装とデグレリスク評価を行った結果です。
-
-## 🔴 高リスク:修正完了
-
-### 1. ストリームパイプライン処理での参照保持
-**場所**: `importCollection`メソッド(行 187-284)  
-**状況**: ✅ **修正完了**
-
-**修正前の問題**:
-```typescript
-// prepare functions invoked from custom streams
-const convertDocuments = this.convertDocuments.bind(this);
-const bulkOperate = this.bulkOperate.bind(this);
-const execUnorderedBulkOpSafely = this.execUnorderedBulkOpSafely.bind(this);
-const emitProgressEvent = this.emitProgressEvent.bind(this);
-```
-
-**修正後**:
-```typescript
-// Avoid closure references by passing direct method references
-const collection = mongoose.connection.collection(collectionName);
-
-// Transform stream内で直接参照
-transform(this: Transform, doc, encoding, callback) {
-  const converted = (importSettings as any).service.convertDocuments(collectionName, doc, overwriteParams);
-  // ...
-}
-
-// Writable stream内で直接参照  
-write: async(batch, encoding, callback) => {
-  batch.forEach((document) => {
-    this.bulkOperate(unorderedBulkOp, collectionName, document, importSettings);
-  });
-  // ...
-}
-```
-
-**効果**: `bind()`によるクロージャ参照を除去し、メモリリーク要因を解消
-
-### 2. Transform/Writableストリームでのドキュメント蓄積
-**場所**: `convertDocuments`メソッド(行 415-463)  
-**状況**: ✅ **修正完了 + デグレリスク分析済み**
-
-**修正前の問題**:
-```typescript
-const _document: D = structuredClone(document); // 常に深いコピー
-```
-
-**修正後**:
-```typescript
-// Use shallow copy instead of structuredClone() when sufficient
-const _document: D = (typeof document === 'object' && document !== null && !Array.isArray(document)) 
-  ? { ...document } : structuredClone(document);
-```
-
-**デグレリスク評価**: 🟢 **安全確認済み**
-- overwrite-params実装を全て確認
-- すべての変換関数は読み取り専用で新しい値を返すのみ
-- ネストオブジェクトの直接変更は皆無
-- 浅いコピーでも元のコードと同じ動作を保証
-
-**効果**: メモリ使用量大幅削減、動作保証維持
-
-### 3. MongoDB UnorderedBulkOperation での大量データ保持
-**場所**: `writeStream`内のバルク処理(行 240-254)  
-**状況**: ✅ **修正完了**
-
-**修正内容**:
-- エラーハンドリングの改善
-- バッチ処理の効率化
-- メモリ監視の追加
-
-**効果**: MongoDBネイティブレベルでのメモリ蓄積を最適化
-
-### 4. ファイルストリーム処理での不適切なクリーンアップ
-**場所**: `unzip`メソッド(行 347-376)  
-**状況**: 🔴 **重大なデグレリスク発見**
-
-**現在の問題コード**:
-```typescript
-parseStream.on('entry', (entry) => {
-  // ...
-  pipeline(entry, writeStream)
-    .then(() => files.push(jsonFile))  // ← 非同期でfiles配列に追加
-    .catch(err => logger.error('Failed to extract entry:', err));
-});
-await pipeline(readStream, parseStream);  // ← parseStreamの完了のみ待機
-return files;  // ← files配列が空の可能性
-```
-
-**問題**: 非同期処理の競合状態により、ファイル展開完了前に空の配列を返す可能性
-
-**影響度**: 🔴 **高リスク - 確実にデグレが存在**
-
-**必要な修正**: 全エントリ処理の完了を適切に待機する実装
-
-## 🟡 中リスク:部分的修正完了
-
-### 5. 手動ガベージコレクションの復活
-**場所**: `writeStream`の処理完了時(行 247-253)  
-**状況**: ✅ **修正完了**
-
-**修正内容**:
-```typescript
-// First aid to prevent unexplained memory leaks
-try {
-  logger.info('global.gc() invoked.');
-  gc();
-}
-catch (err) {
-  logger.error('fail garbage collection: ', err);
-}
-```
-
-**効果**: メモリリーク対策の一環として手動GCを復活
-
-### 6. ConvertMap とスキーマ情報のキャッシュ化
-**場所**: `convertDocuments`メソッド(行 415-463)  
-**状況**: ✅ **修正完了**
-
-**修正内容**:
-```typescript
-// Model and schema cache (optimization)
-if (!this.modelCache) {
-  this.modelCache = new Map();
-}
-
-let modelInfo = this.modelCache.get(collectionName);
-if (!modelInfo) {
-  const Model = getModelFromCollectionName(collectionName);
-  const schema = (Model != null) ? Model.schema : undefined;
-  modelInfo = { Model, schema };
-  this.modelCache.set(collectionName, modelInfo);
-}
-```
-
-**効果**: 重複するModel/schema取得処理を削減、パフォーマンス改善
-
-### 7. インポート後のキャッシュ解放
-**場所**: `import`メソッド(行 177-180)  
-**状況**: ✅ **修正完了**
-
-**修正内容**:
-```typescript
-// Release caches after import process
-this.modelCache.clear();
-this.convertMap = undefined;
-```
-
-**効果**: インポート完了後の明示的なキャッシュ解放
-
-### 8. コメントの英語化
-**場所**: ファイル全体  
-**状況**: ✅ **修正完了**
-
-**効果**: コードの国際化、保守性向上
-
-## 🔴 未修正の重大な問題
-
-### unzipメソッドの競合状態(最優先修正要)
-
-**現在の問題**:
-```typescript
-async unzip(zipFile: string): Promise<string[]> {
-  const files: string[] = [];
-  parseStream.on('entry', (entry) => {
-    // ...
-    pipeline(entry, writeStream)
-      .then(() => files.push(jsonFile))  // 非同期実行
-      .catch(err => logger.error('Failed to extract entry:', err));
-  });
-  await pipeline(readStream, parseStream);  // parseStreamの完了のみ待機
-  return files;  // エントリ処理完了前に返される可能性
-}
-```
-
-**推奨修正案**:
-```typescript
-async unzip(zipFile: string): Promise<string[]> {
-  const readStream = fs.createReadStream(zipFile);
-  const parseStream = unzipStream.Parse();
-  const files: string[] = [];
-  const entryPromises: Promise<string | null>[] = [];
-
-  parseStream.on('entry', (entry) => {
-    const fileName = entry.path;
-    if (fileName.match(/(\.\.\/|\.\.\\)/)) {
-      logger.error('File path is not appropriate.', fileName);
-      entry.autodrain();
-      return;
-    }
-
-    if (fileName === this.growiBridgeService.getMetaFileName()) {
-      entry.autodrain();
-    } else {
-      const entryPromise = new Promise<string | null>((resolve, reject) => {
-        const jsonFile = path.join(this.baseDir, fileName);
-        const writeStream = fs.createWriteStream(jsonFile, { 
-          encoding: this.growiBridgeService.getEncoding() 
-        });
-        
-        pipeline(entry, writeStream)
-          .then(() => resolve(jsonFile))
-          .catch(reject);
-      });
-      
-      entryPromises.push(entryPromise);
-    }
-  });
-
-  await pipeline(readStream, parseStream);
-  const results = await Promise.all(entryPromises);
-  
-  return results.filter((file): file is string => file !== null);
-}
-```
-
-## 🟢 低リスク:監視継続
-
-### 9-10. JSON解析とファイル管理
-**状況**: 現在の実装で十分
-
-## 📊 修正効果の評価
-
-### メモリ使用量改善
-- ✅ structuredClone → 浅いコピー: **大幅なメモリ削減**
-- ✅ bind()除去: **クロージャ参照によるリーク解消**  
-- ✅ モデルキャッシュ: **重複処理削減**
-- ✅ 明示的キャッシュ解放: **長期稼働時の蓄積防止**
-
-### デグレリスク対策
-- ✅ overwrite-params実装確認: **変換関数の安全性確認済み**
-- ✅ 浅いコピー影響分析: **実用上リスクなし**
-- 🔴 unzipメソッド: **確実にデグレ存在、修正必要**
-
-### TypeScript型安全性
-- ✅ 型エラー修正完了
-- ✅ 引数・戻り値型の明示化
-
-## 🎯 残存課題と対応優先度
-
-### 最優先(即座対応)
-1. **unzipメソッドの競合状態修正** - ZIPファイル展開の動作保証
-
-### 推奨(短期対応)  
-2. Transform streamでの型安全性向上(`as any`の除去)
-3. メモリ使用量の継続監視機能追加
-
-### 任意(長期検討)
-4. バッチサイズの動的調整機能
-5. メモリ閾値に基づく自動GC実行
-
-## 📈 成果サマリー
-
-**修正完了項目**: 8/10項目(80%)
-**メモリリーク対策**: 主要因子すべて対応済み
-**デグレリスク**: 1件の重大な問題を除き安全確認済み
-**型安全性**: 向上
-
-**総合評価**: メモリリーク問題は大幅に改善、unzipメソッドの修正により完全解決見込み
-
----
-**最終更新日**: 2025年9月19日  
-**対象ブランチ**: support/investigate-memory-leak-by-yuki  
-**修正状況**: 主要なメモリリーク対策完了、1件の重大デグレリスク要修正  
-**重要度**: 高(ZIPファイル展開機能の正常動作のため unzip修正が必須)

+ 22 - 7
apps/app/src/server/service/import/import.ts

@@ -348,7 +348,8 @@ export class ImportService {
   async unzip(zipFile: string): Promise<string[]> {
   async unzip(zipFile: string): Promise<string[]> {
     const readStream = fs.createReadStream(zipFile);
     const readStream = fs.createReadStream(zipFile);
     const parseStream = unzipStream.Parse();
     const parseStream = unzipStream.Parse();
-    const files: string[] = [];
+    const entryPromises: Promise<string | null>[] = [];
+
     parseStream.on('entry', (/** @type {Entry} */ entry) => {
     parseStream.on('entry', (/** @type {Entry} */ entry) => {
       const fileName = entry.path;
       const fileName = entry.path;
       // https://regex101.com/r/mD4eZs/6
       // https://regex101.com/r/mD4eZs/6
@@ -357,6 +358,7 @@ export class ImportService {
       // ../../src/server/example.html
       // ../../src/server/example.html
       if (fileName.match(/(\.\.\/|\.\.\\)/)) {
       if (fileName.match(/(\.\.\/|\.\.\\)/)) {
         logger.error('File path is not appropriate.', fileName);
         logger.error('File path is not appropriate.', fileName);
+        entry.autodrain();
         return;
         return;
       }
       }
 
 
@@ -365,15 +367,28 @@ export class ImportService {
         entry.autodrain();
         entry.autodrain();
       }
       }
       else {
       else {
-        const jsonFile = path.join(this.baseDir, fileName);
-        const writeStream = fs.createWriteStream(jsonFile, { encoding: this.growiBridgeService.getEncoding() });
-        pipeline(entry, writeStream)
-          .then(() => files.push(jsonFile))
-          .catch(err => logger.error('Failed to extract entry:', err));
+        const entryPromise = new Promise<string | null>((resolve) => {
+          const jsonFile = path.join(this.baseDir, fileName);
+          const writeStream = fs.createWriteStream(jsonFile, { encoding: this.growiBridgeService.getEncoding() });
+
+          pipeline(entry, writeStream)
+            .then(() => resolve(jsonFile))
+            .catch((err) => {
+              logger.error('Failed to extract entry:', err);
+              resolve(null); // Continue processing other entries
+            });
+        });
+
+        entryPromises.push(entryPromise);
       }
       }
     });
     });
+
     await pipeline(readStream, parseStream);
     await pipeline(readStream, parseStream);
-    return files;
+    const results = await Promise.allSettled(entryPromises);
+
+    return results
+      .filter((result): result is PromiseFulfilledResult<string> => result.status === 'fulfilled' && result.value !== null)
+      .map(result => result.value);
   }
   }
 
 
   /**
   /**