Просмотр исходного кода

setup PageBulkExportJobCronService

Futa Arai 1 год назад
Родитель
Сommit
16a02348d6

+ 1 - 0
apps/app/public/static/locales/en_US/translation.json

@@ -642,6 +642,7 @@
     "choose_export_format": "Select export format",
     "bulk_export_started": "Please wait a moment...",
     "bulk_export_download_expired": "Download period has expired",
+    "bulk_export_job_expired": "Export process was canceled because it took too long",
     "duplicate_bulk_export_job_error": "Export for the same page and its children is in progress"
   },
   "message": {

+ 1 - 0
apps/app/public/static/locales/fr_FR/translation.json

@@ -636,6 +636,7 @@
     "choose_export_format": "Sélectionnez le format d'exportation",
     "bulk_export_started": "Patientez s'il-vous-plait...",
     "bulk_export_download_expired": "La période de téléchargement a expiré",
+    "bulk_export_job_expired": "Le traitement a été interrompu car le temps d'exportation était trop long",
     "duplicate_bulk_export_job_error": "L'export pour la même page et ses enfants est en cours"
   },
   "message": {

+ 1 - 0
apps/app/public/static/locales/ja_JP/translation.json

@@ -675,6 +675,7 @@
     "choose_export_format": "エクスポート形式を選択してください",
     "bulk_export_started": "ただいま準備中です...",
     "bulk_export_download_expired": "ダウンロード期限が切れました",
+    "bulk_export_job_expired": "エクスポート時間が長すぎるため、処理が中断されました",
     "duplicate_bulk_export_job_error": "既に同じページとその配下のエクスポートが進行中です"
   },
   "message": {

+ 1 - 0
apps/app/public/static/locales/zh_CN/translation.json

@@ -645,6 +645,7 @@
     "choose_export_format": "选择导出格式",
     "bulk_export_started": "目前我们正在准备...",
     "bulk_export_download_expired": "下载期限已过",
+    "bulk_export_job_expired": "由于导出时间太长,处理被中断",
     "duplicate_bulk_export_job_error": "正在导出同一页面及其子页面"
   },
   "message": {

+ 10 - 3
apps/app/src/client/components/InAppNotification/ModelNotification/PageBulkExportJobModelNotification.tsx

@@ -33,8 +33,15 @@ export const usePageBulkExportJobModelNotification = (notification: IInAppNotifi
 
   notification.parsedSnapshot = pageBulkExportJobSerializers.parseSnapshot(notification.snapshot);
 
-  const subMsg = (notification.action === SupportedAction.ACTION_PAGE_BULK_EXPORT_COMPLETED && notification.target == null)
-    ? <div className="text-danger"><small>{t('page_export.bulk_export_download_expired')}</small></div> : <></>;
+  const getSubMsg = (): React.ReactElement => {
+    if (notification.action === SupportedAction.ACTION_PAGE_BULK_EXPORT_COMPLETED && notification.target == null) {
+      return <div className="text-danger"><small>{t('page_export.bulk_export_download_expired')}</small></div>;
+    }
+    if (notification.action === SupportedAction.ACTION_PAGE_BULK_EXPORT_JOB_EXPIRED) {
+      return <div className="text-danger"><small>{t('page_export.bulk_export_job_expired')}</small></div>;
+    }
+    return <></>;
+  };
 
   const Notification = () => {
     return (
@@ -44,7 +51,7 @@ export const usePageBulkExportJobModelNotification = (notification: IInAppNotifi
         actionIcon={actionIcon}
         actionUsers={actionUsers}
         hideActionUsers
-        subMsg={subMsg}
+        subMsg={getSubMsg()}
       />
     );
   };

+ 1 - 0
apps/app/src/client/components/InAppNotification/ModelNotification/useActionAndMsg.ts

@@ -75,6 +75,7 @@ export const useActionMsgAndIconForModelNotification = (notification: IInAppNoti
       actionIcon = 'download';
       break;
     case SupportedAction.ACTION_PAGE_BULK_EXPORT_FAILED:
+    case SupportedAction.ACTION_PAGE_BULK_EXPORT_JOB_EXPIRED:
       actionMsg = 'export failed for';
       actionIcon = 'error';
       break;

+ 85 - 0
apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-cron.ts

@@ -0,0 +1,85 @@
+import { SupportedAction } from '~/interfaces/activity';
+import { configManager } from '~/server/service/config-manager';
+import CronService from '~/server/service/cron';
+import loggerFactory from '~/utils/logger';
+
+import { PageBulkExportJobStatus } from '../../interfaces/page-bulk-export';
+import type { PageBulkExportJobDocument } from '../models/page-bulk-export-job';
+import PageBulkExportJob from '../models/page-bulk-export-job';
+
+import { pageBulkExportService } from './page-bulk-export';
+
+const logger = loggerFactory('growi:service:cron');
+
+class PageBulkExportJobCronService extends CronService {
+
+  crowi: any;
+
+  constructor(crowi) {
+    super();
+    this.crowi = crowi;
+  }
+
+  override async executeJob(): Promise<void> {
+    await this.deleteExpiredExportJobs();
+    await this.deleteDownloadExpiredJobs();
+    await this.deleteFailedExportJobs();
+  }
+
+  async deleteExpiredExportJobs() {
+    const exportJobExpirationSeconds = configManager.getConfig('crowi', 'app:bulkExportJobExpirationSeconds');
+    const expiredExportJobs = await PageBulkExportJob.find({
+      status: PageBulkExportJobStatus.initializing,
+      createdAt: { $lt: new Date(Date.now() - exportJobExpirationSeconds * 1000) },
+    });
+    for (const expiredExportJob of expiredExportJobs) {
+      try {
+        // eslint-disable-next-line no-await-in-loop
+        await pageBulkExportService?.notifyExportResult(expiredExportJob, SupportedAction.ACTION_PAGE_BULK_EXPORT_JOB_EXPIRED);
+      }
+      catch (err) {
+        logger.error(err);
+      }
+      // eslint-disable-next-line no-await-in-loop
+      await this.cleanUpAndDeleteBulkExportJob(expiredExportJob);
+    }
+  }
+
+  async deleteDownloadExpiredJobs() {
+    const downloadExpirationSeconds = configManager.getConfig('crowi', 'app:bulkExportDownloadExpirationSeconds');
+    const downloadExpiredExportJobs = await PageBulkExportJob.find({
+      status: PageBulkExportJobStatus.completed,
+      completedAt: { $lt: new Date(Date.now() - downloadExpirationSeconds * 1000) },
+    });
+    for (const downloadExpiredExportJob of downloadExpiredExportJobs) {
+      try {
+        this.crowi.attachmentService?.removeAttachment(downloadExpiredExportJob.attachment);
+      }
+      catch (err) {
+        logger.error(err);
+      }
+      // eslint-disable-next-line no-await-in-loop
+      await this.cleanUpAndDeleteBulkExportJob(downloadExpiredExportJob);
+    }
+  }
+
+  async deleteFailedExportJobs() {
+    const failedExportJobs = await PageBulkExportJob.find({ status: PageBulkExportJobStatus.failed });
+    for (const failedExportJob of failedExportJobs) {
+      // eslint-disable-next-line no-await-in-loop
+      await this.cleanUpAndDeleteBulkExportJob(failedExportJob);
+    }
+  }
+
+  async cleanUpAndDeleteBulkExportJob(pageBulkExportJob: PageBulkExportJobDocument) {
+    await pageBulkExportService?.cleanUpExportJobResources(pageBulkExportJob);
+    await pageBulkExportJob.delete();
+  }
+
+}
+
+// eslint-disable-next-line import/no-mutable-exports
+export let pageBulkExportJobCronService: PageBulkExportJobCronService | undefined; // singleton instance
+export default function instanciate(crowi): void {
+  pageBulkExportJobCronService = new PageBulkExportJobCronService(crowi);
+}

+ 38 - 16
apps/app/src/features/page-bulk-export/server/service/page-bulk-export.ts

@@ -133,11 +133,9 @@ class PageBulkExportService {
   }
 
   /**
-   * Do the following in parallel:
-   * - notify user of the export result
-   * - update pageBulkExportJob status
-   * - delete page snapshots
-   * - remove the temporal output directory
+   * Notify the user of the export result, and cleanup the resources used in the export process
+   * @param succeeded whether the export was successful
+   * @param pageBulkExportJob the page bulk export job
    */
   private async notifyExportResultAndCleanUp(
       succeeded: boolean,
@@ -145,15 +143,16 @@ class PageBulkExportService {
   ): Promise<void> {
     const action = succeeded ? SupportedAction.ACTION_PAGE_BULK_EXPORT_COMPLETED : SupportedAction.ACTION_PAGE_BULK_EXPORT_FAILED;
     pageBulkExportJob.status = succeeded ? PageBulkExportJobStatus.completed : PageBulkExportJobStatus.failed;
-    const results = await Promise.allSettled([
-      this.notifyExportResult(pageBulkExportJob, action),
-      PageBulkExportPageSnapshot.deleteMany({ pageBulkExportJob }),
-      fs.promises.rm(this.getTmpOutputDir(pageBulkExportJob), { recursive: true, force: true }),
-      pageBulkExportJob.save(),
-    ]);
-    results.forEach((result) => {
-      if (result.status === 'rejected') logger.error(result.reason);
-    });
+
+    try {
+      await pageBulkExportJob.save();
+      await this.notifyExportResult(pageBulkExportJob, action);
+    }
+    catch (err) {
+      logger.error(err);
+    }
+    // execute independently of notif process resolve/reject
+    await this.cleanUpExportJobResources(pageBulkExportJob);
   }
 
   /**
@@ -265,7 +264,7 @@ class PageBulkExportService {
     const fileUploadService: FileUploader = this.crowi.fileUploadService;
     // if the process of uploading was interrupted, delete and start from the start
     if (pageBulkExportJob.uploadKey != null && pageBulkExportJob.uploadId != null) {
-      await fileUploadService.abortExistingMultipartUpload(pageBulkExportJob.uploadKey, pageBulkExportJob.uploadId);
+      await fileUploadService.abortPreviousMultipartUpload(pageBulkExportJob.uploadKey, pageBulkExportJob.uploadId);
     }
 
     // init multipart upload
@@ -349,7 +348,7 @@ class PageBulkExportService {
     return `${this.tmpOutputRootDir}/${pageBulkExportJob._id}`;
   }
 
-  private async notifyExportResult(
+  async notifyExportResult(
       pageBulkExportJob: PageBulkExportJobDocument, action: SupportedActionType,
   ) {
     const activity = await this.crowi.activityService.createActivity({
@@ -366,6 +365,29 @@ class PageBulkExportService {
     this.activityEvent.emit('updated', activity, pageBulkExportJob, preNotify);
   }
 
+  /**
+   * Do the following in parallel:
+   * - delete page snapshots
+   * - remove the temporal output directory
+   * - abort multipart upload
+   */
+  async cleanUpExportJobResources(pageBulkExportJob: PageBulkExportJobDocument) {
+    const promises = [
+      PageBulkExportPageSnapshot.deleteMany({ pageBulkExportJob }),
+      fs.promises.rm(this.getTmpOutputDir(pageBulkExportJob), { recursive: true, force: true }),
+    ];
+
+    const fileUploadService: FileUploader = this.crowi.fileUploadService;
+    if (pageBulkExportJob.uploadKey != null && pageBulkExportJob.uploadId != null) {
+      promises.push(fileUploadService.abortPreviousMultipartUpload(pageBulkExportJob.uploadKey, pageBulkExportJob.uploadId));
+    }
+
+    const results = await Promise.allSettled(promises);
+    results.forEach((result) => {
+      if (result.status === 'rejected') logger.error(result.reason);
+    });
+  }
+
 }
 
 // eslint-disable-next-line import/no-mutable-exports

+ 3 - 0
apps/app/src/interfaces/activity.ts

@@ -54,6 +54,7 @@ const ACTION_PAGE_UNSUBSCRIBE = 'PAGE_UNSUBSCRIBE';
 const ACTION_PAGE_EXPORT = 'PAGE_EXPORT';
 const ACTION_PAGE_BULK_EXPORT_COMPLETED = 'PAGE_BULK_EXPORT_COMPLETED';
 const ACTION_PAGE_BULK_EXPORT_FAILED = 'PAGE_BULK_EXPORT_FAILED';
+const ACTION_PAGE_BULK_EXPORT_JOB_EXPIRED = 'PAGE_BULK_EXPORT_JOB_EXPIRED';
 const ACTION_TAG_UPDATE = 'TAG_UPDATE';
 const ACTION_IN_APP_NOTIFICATION_ALL_STATUSES_OPEN = 'IN_APP_NOTIFICATION_ALL_STATUSES_OPEN';
 const ACTION_COMMENT_CREATE = 'COMMENT_CREATE';
@@ -346,6 +347,7 @@ export const SupportedAction = {
   ACTION_ADMIN_SEARCH_INDICES_REBUILD,
   ACTION_PAGE_BULK_EXPORT_COMPLETED,
   ACTION_PAGE_BULK_EXPORT_FAILED,
+  ACTION_PAGE_BULK_EXPORT_JOB_EXPIRED,
 } as const;
 
 // Action required for notification
@@ -366,6 +368,7 @@ export const EssentialActionGroup = {
   ACTION_USER_REGISTRATION_APPROVAL_REQUEST,
   ACTION_PAGE_BULK_EXPORT_COMPLETED,
   ACTION_PAGE_BULK_EXPORT_FAILED,
+  ACTION_PAGE_BULK_EXPORT_JOB_EXPIRED,
 } as const;
 
 export const ActionGroupSize = {

+ 5 - 0
apps/app/src/server/crowi/index.js

@@ -15,6 +15,7 @@ import { LdapUserGroupSyncService } from '~/features/external-user-group/server/
 import { PageBulkExportJobStatus } from '~/features/page-bulk-export/interfaces/page-bulk-export';
 import PageBulkExportJob from '~/features/page-bulk-export/server/models/page-bulk-export-job';
 import instanciatePageBulkExportService, { pageBulkExportService } from '~/features/page-bulk-export/server/service/page-bulk-export';
+import instanciatePageBulkExportJobCronService, { pageBulkExportJobCronService } from '~/features/page-bulk-export/server/service/page-bulk-export-job-cron';
 import QuestionnaireService from '~/features/questionnaire/server/service/questionnaire';
 import questionnaireCronService from '~/features/questionnaire/server/service/questionnaire-cron';
 import loggerFactory from '~/utils/logger';
@@ -330,6 +331,10 @@ Crowi.prototype.setupModels = async function() {
 Crowi.prototype.setupCron = function() {
   const questionnaireCronSchedule = this.crowi.configManager?.getConfig('crowi', 'app:questionnaireCronSchedule');
   questionnaireCronService.startCron(questionnaireCronSchedule);
+
+  instanciatePageBulkExportJobCronService(this);
+  const pageBulkExportJobCronSchedule = this.crowi.configManager?.getConfig('crowi', 'app:pageBulkExportJobCronSchedule');
+  pageBulkExportJobCronService.startCron(pageBulkExportJobCronSchedule);
 };
 
 Crowi.prototype.setupQuestionnaireService = function() {

+ 18 - 0
apps/app/src/server/service/config-loader.ts

@@ -736,6 +736,24 @@ const ENV_VAR_NAME_TO_CONFIG_INFO = {
     type: ValueType.NUMBER,
     default: 172800, // 2 days
   },
+  BULK_EXPORT_JOB_EXPIRATION_SECONDS: {
+    ns: 'crowi',
+    key: 'app:bulkExportJobExpirationSeconds',
+    type: ValueType.NUMBER,
+    default: 86400, // 1 day
+  },
+  BULK_EXPORT_DOWNLOAD_EXPIRATION_SECONDS: {
+    ns: 'crowi',
+    key: 'app:bulkExportDownloadExpirationSeconds',
+    type: ValueType.NUMBER,
+    default: 86400, // 1 day
+  },
+  BULK_EXPORT_JOB_CRON_SCHEDULE: {
+    ns: 'crowi',
+    key: 'app:pageBulkExportJobCronSchedule',
+    type: ValueType.STRING,
+    default: '0 * * * *',
+  },
 };
 
 

+ 14 - 6
apps/app/src/server/service/file-uploader/aws/index.ts

@@ -237,12 +237,20 @@ class AwsFileUploader extends AbstractFileUploader {
     return new AwsMultipartUploader(s3, getS3Bucket(), uploadKey, maxPartSize);
   }
 
-  override async abortExistingMultipartUpload(uploadKey: string, uploadId: string) {
-    await S3Factory().send(new AbortMultipartUploadCommand({
-      Bucket: getS3Bucket(),
-      Key: uploadKey,
-      UploadId: uploadId,
-    }));
+  override async abortPreviousMultipartUpload(uploadKey: string, uploadId: string) {
+    try {
+      await S3Factory().send(new AbortMultipartUploadCommand({
+        Bucket: getS3Bucket(),
+        Key: uploadKey,
+        UploadId: uploadId,
+      }));
+    }
+    catch (e) {
+      // allow duplicate abort requests to ensure abortion
+      if (e.response?.status !== 404) {
+        throw e;
+      }
+    }
   }
 
 }

+ 2 - 2
apps/app/src/server/service/file-uploader/file-uploader.ts

@@ -42,7 +42,7 @@ export interface FileUploader {
   findDeliveryFile(attachment: IAttachmentDocument): Promise<NodeJS.ReadableStream>,
   generateTemporaryUrl(attachment: IAttachmentDocument, opts?: RespondOptions): Promise<TemporaryUrl>,
   createMultipartUploader: (uploadKey: string, maxPartSize: number) => MultipartUploader,
-  abortExistingMultipartUpload: (uploadKey: string, uploadId: string) => Promise<void>
+  abortPreviousMultipartUpload: (uploadKey: string, uploadId: string) => Promise<void>
 }
 
 export abstract class AbstractFileUploader implements FileUploader {
@@ -165,7 +165,7 @@ export abstract class AbstractFileUploader implements FileUploader {
   /**
    * Abort an existing multipart upload without creating a MultipartUploader instance
    */
-  abortExistingMultipartUpload(uploadKey: string, uploadId: string): Promise<void> {
+  abortPreviousMultipartUpload(uploadKey: string, uploadId: string): Promise<void> {
     throw new Error('Multipart upload not available for file upload type');
   }
 

+ 5 - 2
apps/app/src/server/service/file-uploader/gcs/index.ts

@@ -177,12 +177,15 @@ class GcsFileUploader extends AbstractFileUploader {
     return new GcsMultipartUploader(myBucket, uploadKey, maxPartSize);
   }
 
-  override async abortExistingMultipartUpload(uploadKey: string, uploadId: string) {
+  override async abortPreviousMultipartUpload(uploadKey: string, uploadId: string) {
     try {
       await axios.delete(uploadId);
     }
     catch (e) {
-      if (e.response?.status !== 499) {
+      // allow 404: allow duplicate abort requests to ensure abortion
+      // allow 499: it is the success response code for canceling upload
+      // ref: https://cloud.google.com/storage/docs/performing-resumable-uploads#cancel-upload
+      if (e.response?.status !== 404 && e.response?.status !== 499) {
         throw e;
       }
     }

+ 2 - 0
apps/app/src/server/service/file-uploader/gcs/multipart-uploader.ts

@@ -77,6 +77,8 @@ export class GcsMultipartUploader extends MultipartUploader implements IGcsMulti
       await axios.delete(this.uploadId);
     }
     catch (e) {
+      // 499 is successful response code for canceling upload
+      // ref: https://cloud.google.com/storage/docs/performing-resumable-uploads#cancel-upload
       if (e.response?.status !== 499) {
         throw e;
       }