Browse Source

Merge pull request #8974 from weseek/feat/78039-135789-delete-unnecessary-page-bulk-export-jobs

Feat/78039 135789 delete unnecessary page bulk export jobs
Yuki Takei 1 year ago
parent
commit
6201af7c4a

+ 1 - 0
apps/app/package.json

@@ -236,6 +236,7 @@
     "@types/archiver": "^6.0.2",
     "@types/archiver": "^6.0.2",
     "@types/express": "^4.17.21",
     "@types/express": "^4.17.21",
     "@types/jest": "^29.5.2",
     "@types/jest": "^29.5.2",
+    "@types/node-cron": "^3.0.2",
     "@types/react-input-autosize": "^2.2.4",
     "@types/react-input-autosize": "^2.2.4",
     "@types/react-scroll": "^1.8.4",
     "@types/react-scroll": "^1.8.4",
     "@types/react-stickynode": "^4.0.3",
     "@types/react-stickynode": "^4.0.3",

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

@@ -641,6 +641,7 @@
     "choose_export_format": "Select export format",
     "choose_export_format": "Select export format",
     "bulk_export_started": "Please wait a moment...",
     "bulk_export_started": "Please wait a moment...",
     "bulk_export_download_expired": "Download period has expired",
     "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"
     "duplicate_bulk_export_job_error": "Export for the same page and its children is in progress"
   },
   },
   "message": {
   "message": {

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

@@ -635,6 +635,7 @@
     "choose_export_format": "Sélectionnez le format d'exportation",
     "choose_export_format": "Sélectionnez le format d'exportation",
     "bulk_export_started": "Patientez s'il-vous-plait...",
     "bulk_export_started": "Patientez s'il-vous-plait...",
     "bulk_export_download_expired": "La période de téléchargement a expiré",
     "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"
     "duplicate_bulk_export_job_error": "L'export pour la même page et ses enfants est en cours"
   },
   },
   "message": {
   "message": {

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

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

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

@@ -644,6 +644,7 @@
     "choose_export_format": "选择导出格式",
     "choose_export_format": "选择导出格式",
     "bulk_export_started": "目前我们正在准备...",
     "bulk_export_started": "目前我们正在准备...",
     "bulk_export_download_expired": "下载期限已过",
     "bulk_export_download_expired": "下载期限已过",
+    "bulk_export_job_expired": "由于导出时间太长,处理被中断",
     "duplicate_bulk_export_job_error": "正在导出同一页面及其子页面"
     "duplicate_bulk_export_job_error": "正在导出同一页面及其子页面"
   },
   },
   "message": {
   "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);
   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 = () => {
   const Notification = () => {
     return (
     return (
@@ -44,7 +51,7 @@ export const usePageBulkExportJobModelNotification = (notification: IInAppNotifi
         actionIcon={actionIcon}
         actionIcon={actionIcon}
         actionUsers={actionUsers}
         actionUsers={actionUsers}
         hideActionUsers
         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';
       actionIcon = 'download';
       break;
       break;
     case SupportedAction.ACTION_PAGE_BULK_EXPORT_FAILED:
     case SupportedAction.ACTION_PAGE_BULK_EXPORT_FAILED:
+    case SupportedAction.ACTION_PAGE_BULK_EXPORT_JOB_EXPIRED:
       actionMsg = 'export failed for';
       actionMsg = 'export failed for';
       actionIcon = 'error';
       actionIcon = 'error';
       break;
       break;

+ 5 - 1
apps/app/src/features/page-bulk-export/interfaces/page-bulk-export.ts

@@ -10,10 +10,14 @@ export const PageBulkExportFormat = {
 
 
 export type PageBulkExportFormat = typeof PageBulkExportFormat[keyof typeof PageBulkExportFormat]
 export type PageBulkExportFormat = typeof PageBulkExportFormat[keyof typeof PageBulkExportFormat]
 
 
-export const PageBulkExportJobStatus = {
+export const PageBulkExportJobInProgressStatus = {
   initializing: 'initializing', // preparing for export
   initializing: 'initializing', // preparing for export
   exporting: 'exporting', // exporting to fs
   exporting: 'exporting', // exporting to fs
   uploading: 'uploading', // uploading to cloud storage
   uploading: 'uploading', // uploading to cloud storage
+} as const;
+
+export const PageBulkExportJobStatus = {
+  ...PageBulkExportJobInProgressStatus,
   completed: 'completed',
   completed: 'completed',
   failed: 'failed',
   failed: 'failed',
 } as const;
 } as const;

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

@@ -0,0 +1,109 @@
+import type { HydratedDocument } from 'mongoose';
+
+import { SupportedAction } from '~/interfaces/activity';
+import { configManager } from '~/server/service/config-manager';
+import CronService from '~/server/service/cron';
+import loggerFactory from '~/utils/logger';
+
+import { PageBulkExportJobInProgressStatus, 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');
+
+/**
+ * Manages cronjob which deletes unnecessary bulk export jobs
+ */
+class PageBulkExportJobCronService extends CronService {
+
+  crowi: any;
+
+  constructor(crowi) {
+    super();
+    this.crowi = crowi;
+  }
+
+  override getCronSchedule(): string {
+    return configManager.getConfig('crowi', 'app:pageBulkExportJobCronSchedule');
+  }
+
+  override async executeJob(): Promise<void> {
+    await this.deleteExpiredExportJobs();
+    await this.deleteDownloadExpiredExportJobs();
+    await this.deleteFailedExportJobs();
+  }
+
+  /**
+   * Delete bulk export jobs which are on-going and has passed the limit time for execution
+   */
+  async deleteExpiredExportJobs() {
+    const exportJobExpirationSeconds = configManager.getConfig('crowi', 'app:bulkExportJobExpirationSeconds');
+    const expiredExportJobs = await PageBulkExportJob.find({
+      $or: Object.values(PageBulkExportJobInProgressStatus).map(status => ({ status })),
+      createdAt: { $lt: new Date(Date.now() - exportJobExpirationSeconds * 1000) },
+    });
+
+    const cleanup = async(job: PageBulkExportJobDocument) => {
+      await pageBulkExportService?.cleanUpExportJobResources(job);
+      await pageBulkExportService?.notifyExportResult(job, SupportedAction.ACTION_PAGE_BULK_EXPORT_JOB_EXPIRED);
+    };
+
+    await this.cleanUpAndDeleteBulkExportJobs(expiredExportJobs, cleanup);
+  }
+
+  /**
+   * Delete bulk export jobs which have completed but the due time for downloading has passed
+   */
+  async deleteDownloadExpiredExportJobs() {
+    const downloadExpirationSeconds = configManager.getConfig('crowi', 'app:bulkExportDownloadExpirationSeconds');
+    const downloadExpiredExportJobs = await PageBulkExportJob.find({
+      status: PageBulkExportJobStatus.completed,
+      completedAt: { $lt: new Date(Date.now() - downloadExpirationSeconds * 1000) },
+    });
+
+    const cleanup = async(job: PageBulkExportJobDocument) => {
+      await pageBulkExportService?.cleanUpExportJobResources(job);
+      await this.crowi.attachmentService?.removeAttachment(job.attachment);
+    };
+
+    await this.cleanUpAndDeleteBulkExportJobs(downloadExpiredExportJobs, cleanup);
+  }
+
+  /**
+   * Delete bulk export jobs which have failed
+   */
+  async deleteFailedExportJobs() {
+    const failedExportJobs = await PageBulkExportJob.find({ status: PageBulkExportJobStatus.failed });
+
+    if (pageBulkExportService != null) {
+      await this.cleanUpAndDeleteBulkExportJobs(failedExportJobs, pageBulkExportService.cleanUpExportJobResources);
+    }
+  }
+
+  async cleanUpAndDeleteBulkExportJobs(
+      pageBulkExportJobs: HydratedDocument<PageBulkExportJobDocument>[],
+      cleanup: (job: PageBulkExportJobDocument) => Promise<void>,
+  ): Promise<void> {
+    const results = await Promise.allSettled(pageBulkExportJobs.map(job => cleanup(job)));
+    results.forEach((result) => {
+      if (result.status === 'rejected') logger.error(result.reason);
+    });
+
+    // Only batch delete jobs which have been successfully cleaned up
+    // Cleanup failed jobs will be retried in the next cron execution
+    const cleanedUpJobs = pageBulkExportJobs.filter((_, index) => results[index].status === 'fulfilled');
+    if (cleanedUpJobs.length > 0) {
+      const cleanedUpJobIds = cleanedUpJobs.map(job => job._id);
+      await PageBulkExportJob.deleteMany({ _id: { $in: cleanedUpJobIds } });
+    }
+  }
+
+}
+
+// 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);
+}

+ 42 - 21
apps/app/src/features/page-bulk-export/server/service/page-bulk-export.ts

@@ -27,7 +27,7 @@ import { preNotifyService } from '~/server/service/pre-notify';
 import { getBufferToFixedSizeTransform } from '~/server/util/stream';
 import { getBufferToFixedSizeTransform } from '~/server/util/stream';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
-import { PageBulkExportFormat, PageBulkExportJobStatus } from '../../interfaces/page-bulk-export';
+import { PageBulkExportFormat, PageBulkExportJobInProgressStatus, PageBulkExportJobStatus } from '../../interfaces/page-bulk-export';
 import type { PageBulkExportJobDocument } from '../models/page-bulk-export-job';
 import type { PageBulkExportJobDocument } from '../models/page-bulk-export-job';
 import PageBulkExportJob from '../models/page-bulk-export-job';
 import PageBulkExportJob from '../models/page-bulk-export-job';
 import type { PageBulkExportPageSnapshotDocument } from '../models/page-bulk-export-page-snapshot';
 import type { PageBulkExportPageSnapshotDocument } from '../models/page-bulk-export-page-snapshot';
@@ -89,9 +89,7 @@ class PageBulkExportService {
       user: currentUser,
       user: currentUser,
       page: basePage,
       page: basePage,
       format,
       format,
-      $or: [
-        { status: PageBulkExportJobStatus.initializing }, { status: PageBulkExportJobStatus.exporting }, { status: PageBulkExportJobStatus.uploading },
-      ],
+      $or: Object.values(PageBulkExportJobInProgressStatus).map(status => ({ status })),
     });
     });
     if (duplicatePageBulkExportJobInProgress != null) {
     if (duplicatePageBulkExportJobInProgress != null) {
       throw new DuplicateBulkExportJobError();
       throw new DuplicateBulkExportJobError();
@@ -137,11 +135,10 @@ 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
+   * @param activityParameters parameters to record user activity
    */
    */
   private async notifyExportResultAndCleanUp(
   private async notifyExportResultAndCleanUp(
       succeeded: boolean,
       succeeded: boolean,
@@ -150,15 +147,16 @@ class PageBulkExportService {
   ): Promise<void> {
   ): Promise<void> {
     const action = succeeded ? SupportedAction.ACTION_PAGE_BULK_EXPORT_COMPLETED : SupportedAction.ACTION_PAGE_BULK_EXPORT_FAILED;
     const action = succeeded ? SupportedAction.ACTION_PAGE_BULK_EXPORT_COMPLETED : SupportedAction.ACTION_PAGE_BULK_EXPORT_FAILED;
     pageBulkExportJob.status = succeeded ? PageBulkExportJobStatus.completed : PageBulkExportJobStatus.failed;
     pageBulkExportJob.status = succeeded ? PageBulkExportJobStatus.completed : PageBulkExportJobStatus.failed;
-    const results = await Promise.allSettled([
-      this.notifyExportResult(pageBulkExportJob, action, activityParameters),
-      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, activityParameters);
+    }
+    catch (err) {
+      logger.error(err);
+    }
+    // execute independently of notif process resolve/reject
+    await this.cleanUpExportJobResources(pageBulkExportJob);
   }
   }
 
 
   /**
   /**
@@ -270,7 +268,7 @@ class PageBulkExportService {
     const fileUploadService: FileUploader = this.crowi.fileUploadService;
     const fileUploadService: FileUploader = this.crowi.fileUploadService;
     // if the process of uploading was interrupted, delete and start from the start
     // if the process of uploading was interrupted, delete and start from the start
     if (pageBulkExportJob.uploadKey != null && pageBulkExportJob.uploadId != null) {
     if (pageBulkExportJob.uploadKey != null && pageBulkExportJob.uploadId != null) {
-      await fileUploadService.abortExistingMultipartUpload(pageBulkExportJob.uploadKey, pageBulkExportJob.uploadId);
+      await fileUploadService.abortPreviousMultipartUpload(pageBulkExportJob.uploadKey, pageBulkExportJob.uploadId);
     }
     }
 
 
     // init multipart upload
     // init multipart upload
@@ -354,7 +352,7 @@ class PageBulkExportService {
     return `${this.tmpOutputRootDir}/${pageBulkExportJob._id}`;
     return `${this.tmpOutputRootDir}/${pageBulkExportJob._id}`;
   }
   }
 
 
-  private async notifyExportResult(
+  async notifyExportResult(
       pageBulkExportJob: PageBulkExportJobDocument, action: SupportedActionType, activityParameters?: ActivityParameters,
       pageBulkExportJob: PageBulkExportJobDocument, action: SupportedActionType, activityParameters?: ActivityParameters,
   ) {
   ) {
     const activity = await this.crowi.activityService.createActivity({
     const activity = await this.crowi.activityService.createActivity({
@@ -367,11 +365,34 @@ class PageBulkExportService {
         username: isPopulated(pageBulkExportJob.user) ? pageBulkExportJob.user.username : '',
         username: isPopulated(pageBulkExportJob.user) ? pageBulkExportJob.user.username : '',
       },
       },
     });
     });
-    const getAdditionalTargetUsers = (activity: ActivityDocument) => [activity.user];
+    const getAdditionalTargetUsers = async(activity: ActivityDocument) => [activity.user];
     const preNotify = preNotifyService.generatePreNotify(activity, getAdditionalTargetUsers);
     const preNotify = preNotifyService.generatePreNotify(activity, getAdditionalTargetUsers);
     this.activityEvent.emit('updated', activity, pageBulkExportJob, preNotify);
     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
 // eslint-disable-next-line import/no-mutable-exports

+ 28 - 26
apps/app/test/integration/service/questionnaire-cron.test.ts → apps/app/src/features/questionnaire/server/service/questionnaire-cron.integ.ts

@@ -2,28 +2,30 @@
 import axios from 'axios';
 import axios from 'axios';
 import mongoose from 'mongoose';
 import mongoose from 'mongoose';
 
 
-import { IProactiveQuestionnaireAnswer } from '../../../src/features/questionnaire/interfaces/proactive-questionnaire-answer';
-import { IQuestionnaireAnswer } from '../../../src/features/questionnaire/interfaces/questionnaire-answer';
-import { StatusType } from '../../../src/features/questionnaire/interfaces/questionnaire-answer-status';
-import ProactiveQuestionnaireAnswer from '../../../src/features/questionnaire/server/models/proactive-questionnaire-answer';
-import QuestionnaireAnswer from '../../../src/features/questionnaire/server/models/questionnaire-answer';
-import QuestionnaireAnswerStatus from '../../../src/features/questionnaire/server/models/questionnaire-answer-status';
-import QuestionnaireOrder from '../../../src/features/questionnaire/server/models/questionnaire-order';
-import { getInstance } from '../setup-crowi';
+import { configManager } from '~/server/service/config-manager';
 
 
-const spyAxiosGet = jest.spyOn<typeof axios, 'get'>(
-  axios,
-  'get',
-);
+import type { IProactiveQuestionnaireAnswer } from '../../interfaces/proactive-questionnaire-answer';
+import type { IQuestionnaireAnswer } from '../../interfaces/questionnaire-answer';
+import { StatusType } from '../../interfaces/questionnaire-answer-status';
+import ProactiveQuestionnaireAnswer from '../models/proactive-questionnaire-answer';
+import QuestionnaireAnswer from '../models/questionnaire-answer';
+import QuestionnaireAnswerStatus from '../models/questionnaire-answer-status';
+import QuestionnaireOrder from '../models/questionnaire-order';
 
 
-const spyAxiosPost = jest.spyOn<typeof axios, 'post'>(
-  axios,
-  'post',
-);
+import questionnaireCronService from './questionnaire-cron';
 
 
-describe('QuestionnaireCronService', () => {
-  let crowi;
+// TODO: use actual user model after ~/server/models/user.js becomes importable in vitest
+// ref: https://github.com/vitest-dev/vitest/issues/846
+const userSchema = new mongoose.Schema({
+  name: { type: String },
+  username: { type: String, required: true, unique: true },
+  email: { type: String, unique: true, sparse: true },
+}, {
+  timestamps: true,
+});
+const User = mongoose.model('User', userSchema);
 
 
+describe('QuestionnaireCronService', () => {
   const mockResponse = {
   const mockResponse = {
     data: {
     data: {
       questionnaireOrders: [
       questionnaireOrders: [
@@ -137,13 +139,13 @@ describe('QuestionnaireCronService', () => {
   };
   };
 
 
   beforeAll(async() => {
   beforeAll(async() => {
-    crowi = await getInstance();
-    const User = crowi.model('User');
+    await configManager.loadConfigs();
+    await configManager.updateConfigsInTheSameNamespace('crowi', { 'app:questionnaireCronMaxHoursUntilRequest': 0 });
+
     await User.create({
     await User.create({
       name: 'Example for Questionnaire Service Test',
       name: 'Example for Questionnaire Service Test',
       username: 'questionnaire cron test user',
       username: 'questionnaire cron test user',
       email: 'questionnaireCronTestUser@example.com',
       email: 'questionnaireCronTestUser@example.com',
-      password: 'usertestpass',
       createdAt: '2020-01-01',
       createdAt: '2020-01-01',
     });
     });
   });
   });
@@ -325,19 +327,19 @@ describe('QuestionnaireCronService', () => {
       validProactiveQuestionnaireAnswer,
       validProactiveQuestionnaireAnswer,
     ]);
     ]);
 
 
-    crowi.setupCron();
+    questionnaireCronService.startCron();
 
 
-    spyAxiosGet.mockResolvedValue(mockResponse);
-    spyAxiosPost.mockResolvedValue({ data: { result: 'success' } });
+    vi.spyOn(axios, 'get').mockResolvedValue(mockResponse);
+    vi.spyOn(axios, 'post').mockResolvedValue({ data: { result: 'success' } });
   });
   });
 
 
   afterAll(() => {
   afterAll(() => {
-    crowi.questionnaireCronService.stopCron(); // jest will not finish until cronjob stops
+    questionnaireCronService.stopCron(); // vitest will not finish until cronjob stops
   });
   });
 
 
   test('Job execution should save(update) quesionnaire orders, delete outdated ones, update skipped answer statuses, and delete resent answers', async() => {
   test('Job execution should save(update) quesionnaire orders, delete outdated ones, update skipped answer statuses, and delete resent answers', async() => {
     // testing the cronjob from schedule has untrivial overhead, so test job execution in place
     // testing the cronjob from schedule has untrivial overhead, so test job execution in place
-    await crowi.questionnaireCronService.executeJob();
+    await questionnaireCronService.executeJob();
 
 
     const savedOrders = await QuestionnaireOrder.find()
     const savedOrders = await QuestionnaireOrder.find()
       .select('-condition._id -questions._id -questions.createdAt -questions.updatedAt')
       .select('-condition._id -questions._id -questions.createdAt -questions.updatedAt')

+ 19 - 44
apps/app/src/features/questionnaire/server/service/questionnaire-cron.ts

@@ -1,59 +1,40 @@
 import axiosRetry from 'axios-retry';
 import axiosRetry from 'axios-retry';
 
 
-import loggerFactory from '~/utils/logger';
+import { configManager } from '~/server/service/config-manager';
+import CronService from '~/server/service/cron';
 import { getRandomIntInRange } from '~/utils/rand';
 import { getRandomIntInRange } from '~/utils/rand';
 
 
 import { StatusType } from '../../interfaces/questionnaire-answer-status';
 import { StatusType } from '../../interfaces/questionnaire-answer-status';
-import { IQuestionnaireOrder } from '../../interfaces/questionnaire-order';
+import type { IQuestionnaireOrder } from '../../interfaces/questionnaire-order';
 import ProactiveQuestionnaireAnswer from '../models/proactive-questionnaire-answer';
 import ProactiveQuestionnaireAnswer from '../models/proactive-questionnaire-answer';
 import QuestionnaireAnswer from '../models/questionnaire-answer';
 import QuestionnaireAnswer from '../models/questionnaire-answer';
 import QuestionnaireAnswerStatus from '../models/questionnaire-answer-status';
 import QuestionnaireAnswerStatus from '../models/questionnaire-answer-status';
 import QuestionnaireOrder from '../models/questionnaire-order';
 import QuestionnaireOrder from '../models/questionnaire-order';
 
 
-const logger = loggerFactory('growi:service:questionnaire-cron');
-
 const axios = require('axios').default;
 const axios = require('axios').default;
-const nodeCron = require('node-cron');
 
 
 axiosRetry(axios, { retries: 3 });
 axiosRetry(axios, { retries: 3 });
 
 
 /**
 /**
- * manage cronjob which
+ * Manages cronjob which
  *  1. fetches QuestionnaireOrders from questionnaire server
  *  1. fetches QuestionnaireOrders from questionnaire server
  *  2. updates QuestionnaireOrder collection to contain only the ones that exist in the fetched list and is not finished (doesn't have to be started)
  *  2. updates QuestionnaireOrder collection to contain only the ones that exist in the fetched list and is not finished (doesn't have to be started)
  *  3. changes QuestionnaireAnswerStatuses which are 'skipped' to 'not_answered'
  *  3. changes QuestionnaireAnswerStatuses which are 'skipped' to 'not_answered'
  *  4. resend QuestionnaireAnswers & ProactiveQuestionnaireAnswers which failed to reach questionnaire server
  *  4. resend QuestionnaireAnswers & ProactiveQuestionnaireAnswers which failed to reach questionnaire server
  */
  */
-class QuestionnaireCronService {
-
-  crowi: any;
-
-  cronJob: any;
-
-  // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
-  constructor(crowi) {
-    this.crowi = crowi;
-  }
+class QuestionnaireCronService extends CronService {
 
 
   sleep = (msec: number): Promise<void> => new Promise(resolve => setTimeout(resolve, msec));
   sleep = (msec: number): Promise<void> => new Promise(resolve => setTimeout(resolve, msec));
 
 
-  startCron(): void {
-    const cronSchedule = this.crowi.configManager?.getConfig('crowi', 'app:questionnaireCronSchedule');
-    const maxHoursUntilRequest = this.crowi.configManager?.getConfig('crowi', 'app:questionnaireCronMaxHoursUntilRequest');
-
-    const maxSecondsUntilRequest = maxHoursUntilRequest * 60 * 60;
-
-    this.cronJob?.stop();
-    this.cronJob = this.generateCronJob(cronSchedule, maxSecondsUntilRequest);
-    this.cronJob.start();
+  override getCronSchedule(): string {
+    return configManager.getConfig('crowi', 'app:questionnaireCronSchedule');
   }
   }
 
 
-  stopCron(): void {
-    this.cronJob.stop();
-  }
+  override async executeJob(): Promise<void> {
+    // sleep for a random amount to scatter request time from GROWI apps to questionnaire server
+    await this.sleepBeforeJob();
 
 
-  async executeJob(): Promise<void> {
-    const questionnaireServerOrigin = this.crowi.configManager?.getConfig('crowi', 'app:questionnaireServerOrigin');
+    const questionnaireServerOrigin = configManager.getConfig('crowi', 'app:questionnaireServerOrigin');
 
 
     const fetchQuestionnaireOrders = async(): Promise<IQuestionnaireOrder[]> => {
     const fetchQuestionnaireOrders = async(): Promise<IQuestionnaireOrder[]> => {
       const response = await axios.get(`${questionnaireServerOrigin}/questionnaire-order/index`);
       const response = await axios.get(`${questionnaireServerOrigin}/questionnaire-order/index`);
@@ -100,22 +81,16 @@ class QuestionnaireCronService {
     await changeSkippedAnswerStatusToNotAnswered();
     await changeSkippedAnswerStatusToNotAnswered();
   }
   }
 
 
-  private generateCronJob(cronSchedule: string, maxSecondsUntilRequest: number) {
-    return nodeCron.schedule(cronSchedule, async() => {
-      // sleep for a random amount to scatter request time from GROWI apps to questionnaire server
-      const secToSleep = getRandomIntInRange(0, maxSecondsUntilRequest);
-      await this.sleep(secToSleep * 1000);
-
-      try {
-        this.executeJob();
-      }
-      catch (e) {
-        logger.error(e);
-      }
+  private async sleepBeforeJob() {
+    const maxHoursUntilRequest = configManager.getConfig('crowi', 'app:questionnaireCronMaxHoursUntilRequest');
+    const maxSecondsUntilRequest = maxHoursUntilRequest * 60 * 60;
 
 
-    });
+    const secToSleep = getRandomIntInRange(0, maxSecondsUntilRequest);
+    await this.sleep(secToSleep * 1000);
   }
   }
 
 
 }
 }
 
 
-export default QuestionnaireCronService;
+const questionnaireCronService = new QuestionnaireCronService();
+
+export default questionnaireCronService;

+ 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_EXPORT = 'PAGE_EXPORT';
 const ACTION_PAGE_BULK_EXPORT_COMPLETED = 'PAGE_BULK_EXPORT_COMPLETED';
 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_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_TAG_UPDATE = 'TAG_UPDATE';
 const ACTION_IN_APP_NOTIFICATION_ALL_STATUSES_OPEN = 'IN_APP_NOTIFICATION_ALL_STATUSES_OPEN';
 const ACTION_IN_APP_NOTIFICATION_ALL_STATUSES_OPEN = 'IN_APP_NOTIFICATION_ALL_STATUSES_OPEN';
 const ACTION_COMMENT_CREATE = 'COMMENT_CREATE';
 const ACTION_COMMENT_CREATE = 'COMMENT_CREATE';
@@ -346,6 +347,7 @@ export const SupportedAction = {
   ACTION_ADMIN_SEARCH_INDICES_REBUILD,
   ACTION_ADMIN_SEARCH_INDICES_REBUILD,
   ACTION_PAGE_BULK_EXPORT_COMPLETED,
   ACTION_PAGE_BULK_EXPORT_COMPLETED,
   ACTION_PAGE_BULK_EXPORT_FAILED,
   ACTION_PAGE_BULK_EXPORT_FAILED,
+  ACTION_PAGE_BULK_EXPORT_JOB_EXPIRED,
 } as const;
 } as const;
 
 
 // Action required for notification
 // Action required for notification
@@ -366,6 +368,7 @@ export const EssentialActionGroup = {
   ACTION_USER_REGISTRATION_APPROVAL_REQUEST,
   ACTION_USER_REGISTRATION_APPROVAL_REQUEST,
   ACTION_PAGE_BULK_EXPORT_COMPLETED,
   ACTION_PAGE_BULK_EXPORT_COMPLETED,
   ACTION_PAGE_BULK_EXPORT_FAILED,
   ACTION_PAGE_BULK_EXPORT_FAILED,
+  ACTION_PAGE_BULK_EXPORT_JOB_EXPIRED,
 } as const;
 } as const;
 
 
 export const ActionGroupSize = {
 export const ActionGroupSize = {

+ 8 - 8
apps/app/src/server/crowi/index.js

@@ -12,11 +12,12 @@ import pkg from '^/package.json';
 
 
 import { KeycloakUserGroupSyncService } from '~/features/external-user-group/server/service/keycloak-user-group-sync';
 import { KeycloakUserGroupSyncService } from '~/features/external-user-group/server/service/keycloak-user-group-sync';
 import { LdapUserGroupSyncService } from '~/features/external-user-group/server/service/ldap-user-group-sync';
 import { LdapUserGroupSyncService } from '~/features/external-user-group/server/service/ldap-user-group-sync';
-import { PageBulkExportJobStatus } from '~/features/page-bulk-export/interfaces/page-bulk-export';
+import { PageBulkExportJobInProgressStatus, PageBulkExportJobStatus } from '~/features/page-bulk-export/interfaces/page-bulk-export';
 import PageBulkExportJob from '~/features/page-bulk-export/server/models/page-bulk-export-job';
 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 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 QuestionnaireService from '~/features/questionnaire/server/service/questionnaire';
-import QuestionnaireCronService from '~/features/questionnaire/server/service/questionnaire-cron';
+import questionnaireCronService from '~/features/questionnaire/server/service/questionnaire-cron';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 import { projectRoot } from '~/utils/project-dir-utils';
 import { projectRoot } from '~/utils/project-dir-utils';
 
 
@@ -105,7 +106,6 @@ class Crowi {
     this.activityService = null;
     this.activityService = null;
     this.commentService = null;
     this.commentService = null;
     this.questionnaireService = null;
     this.questionnaireService = null;
-    this.questionnaireCronService = null;
 
 
     this.tokens = null;
     this.tokens = null;
 
 
@@ -317,8 +317,10 @@ Crowi.prototype.setupSocketIoService = async function() {
 };
 };
 
 
 Crowi.prototype.setupCron = function() {
 Crowi.prototype.setupCron = function() {
-  this.questionnaireCronService = new QuestionnaireCronService(this);
-  this.questionnaireCronService.startCron();
+  questionnaireCronService.startCron();
+
+  instanciatePageBulkExportJobCronService(this);
+  pageBulkExportJobCronService.startCron();
 };
 };
 
 
 Crowi.prototype.setupQuestionnaireService = function() {
 Crowi.prototype.setupQuestionnaireService = function() {
@@ -783,9 +785,7 @@ Crowi.prototype.setupExternalUserGroupSyncService = function() {
 // TODO: Limit the number of jobs to execute in parallel (https://redmine.weseek.co.jp/issues/143599)
 // TODO: Limit the number of jobs to execute in parallel (https://redmine.weseek.co.jp/issues/143599)
 Crowi.prototype.resumeIncompletePageBulkExportJobs = async function() {
 Crowi.prototype.resumeIncompletePageBulkExportJobs = async function() {
   const jobs = await PageBulkExportJob.find({
   const jobs = await PageBulkExportJob.find({
-    $or: [
-      { status: PageBulkExportJobStatus.initializing }, { status: PageBulkExportJobStatus.exporting }, { status: PageBulkExportJobStatus.uploading },
-    ],
+    $or: Object.values(PageBulkExportJobInProgressStatus).map(status => ({ status })),
   });
   });
   Promise.all(jobs.map(job => pageBulkExportService.executePageBulkExportJob(job)));
   Promise.all(jobs.map(job => pageBulkExportService.executePageBulkExportJob(job)));
 };
 };

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

@@ -736,6 +736,24 @@ const ENV_VAR_NAME_TO_CONFIG_INFO = {
     type: ValueType.NUMBER,
     type: ValueType.NUMBER,
     default: 172800, // 2 days
     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: '*/10 * * * *', // every 10 minutes
+  },
 };
 };
 
 
 
 

+ 60 - 0
apps/app/src/server/service/cron.ts

@@ -0,0 +1,60 @@
+import type { ScheduledTask } from 'node-cron';
+import nodeCron from 'node-cron';
+
+import loggerFactory from '~/utils/logger';
+
+const logger = loggerFactory('growi:service:cron');
+
+/**
+ * Base class for services that manage a cronjob
+ */
+abstract class CronService {
+
+  // The current cronjob to manage
+  cronJob: ScheduledTask;
+
+  /**
+   * Create and start a new cronjob
+   */
+  startCron(): void {
+    this.cronJob?.stop();
+    this.cronJob = this.generateCronJob(this.getCronSchedule());
+    this.cronJob.start();
+  }
+
+  /**
+   * Stop the current cronjob
+   */
+  stopCron(): void {
+    this.cronJob.stop();
+  }
+
+  /**
+   * Get the cron schedule
+   * e.g. '0 1 * * *'
+   */
+  abstract getCronSchedule(): string;
+
+  /**
+   * Execute the job. Define the job process in the subclass.
+   */
+  abstract executeJob(): Promise<void>;
+
+  /**
+   * Create a new cronjob
+   * @param cronSchedule e.g. '0 1 * * *'
+   */
+  protected generateCronJob(cronSchedule: string): ScheduledTask {
+    return nodeCron.schedule(cronSchedule, async() => {
+      try {
+        await this.executeJob();
+      }
+      catch (e) {
+        logger.error(e);
+      }
+    });
+  }
+
+}
+
+export default CronService;

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

@@ -265,12 +265,20 @@ class AwsFileUploader extends AbstractFileUploader {
     return new AwsMultipartUploader(s3, getS3Bucket(), uploadKey, maxPartSize);
     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

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

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

@@ -201,12 +201,15 @@ class GcsFileUploader extends AbstractFileUploader {
     return new GcsMultipartUploader(myBucket, uploadKey, maxPartSize);
     return new GcsMultipartUploader(myBucket, uploadKey, maxPartSize);
   }
   }
 
 
-  override async abortExistingMultipartUpload(uploadKey: string, uploadId: string) {
+  override async abortPreviousMultipartUpload(uploadKey: string, uploadId: string) {
     try {
     try {
       await axios.delete(uploadId);
       await axios.delete(uploadId);
     }
     }
     catch (e) {
     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;
         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);
       await axios.delete(this.uploadId);
     }
     }
     catch (e) {
     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) {
       if (e.response?.status !== 499) {
         throw e;
         throw e;
       }
       }

+ 15 - 35
yarn.lock

@@ -4190,13 +4190,6 @@
   dependencies:
   dependencies:
     tslib "2.1.0"
     tslib "2.1.0"
 
 
-"@types/archiver@^6.0.2":
-  version "6.0.2"
-  resolved "https://registry.yarnpkg.com/@types/archiver/-/archiver-6.0.2.tgz#0daf8c83359cbde69de1e4b33dcade6a48a929e2"
-  integrity sha512-KmROQqbQzKGuaAbmK+ZcytkJ51+YqDa7NmbXjmtC5YBLSyQYo21YaUnQ3HbaPFKL1ooo6RQ6OPYPIDyxfpDDXw==
-  dependencies:
-    "@types/readdir-glob" "*"
-
 "@tybys/wasm-util@^0.9.0":
 "@tybys/wasm-util@^0.9.0":
   version "0.9.0"
   version "0.9.0"
   resolved "https://registry.yarnpkg.com/@tybys/wasm-util/-/wasm-util-0.9.0.tgz#3e75eb00604c8d6db470bf18c37b7d984a0e3355"
   resolved "https://registry.yarnpkg.com/@tybys/wasm-util/-/wasm-util-0.9.0.tgz#3e75eb00604c8d6db470bf18c37b7d984a0e3355"
@@ -4204,6 +4197,13 @@
   dependencies:
   dependencies:
     tslib "^2.4.0"
     tslib "^2.4.0"
 
 
+"@types/archiver@^6.0.2":
+  version "6.0.2"
+  resolved "https://registry.yarnpkg.com/@types/archiver/-/archiver-6.0.2.tgz#0daf8c83359cbde69de1e4b33dcade6a48a929e2"
+  integrity sha512-KmROQqbQzKGuaAbmK+ZcytkJ51+YqDa7NmbXjmtC5YBLSyQYo21YaUnQ3HbaPFKL1ooo6RQ6OPYPIDyxfpDDXw==
+  dependencies:
+    "@types/readdir-glob" "*"
+
 "@types/argparse@1.0.38":
 "@types/argparse@1.0.38":
   version "1.0.38"
   version "1.0.38"
   resolved "https://registry.yarnpkg.com/@types/argparse/-/argparse-1.0.38.tgz#a81fd8606d481f873a3800c6ebae4f1d768a56a9"
   resolved "https://registry.yarnpkg.com/@types/argparse/-/argparse-1.0.38.tgz#a81fd8606d481f873a3800c6ebae4f1d768a56a9"
@@ -4536,6 +4536,11 @@
   resolved "https://registry.yarnpkg.com/@types/ms/-/ms-0.7.31.tgz#31b7ca6407128a3d2bbc27fe2d21b345397f6197"
   resolved "https://registry.yarnpkg.com/@types/ms/-/ms-0.7.31.tgz#31b7ca6407128a3d2bbc27fe2d21b345397f6197"
   integrity sha512-iiUgKzV9AuaEkZqkOLDIvlQiL6ltuZd9tGcW3gwpnX8JbuiuhFlEGmmFXEXkN50Cvq7Os88IY2v0dkDqXYWVgA==
   integrity sha512-iiUgKzV9AuaEkZqkOLDIvlQiL6ltuZd9tGcW3gwpnX8JbuiuhFlEGmmFXEXkN50Cvq7Os88IY2v0dkDqXYWVgA==
 
 
+"@types/node-cron@^3.0.2":
+  version "3.0.11"
+  resolved "https://registry.yarnpkg.com/@types/node-cron/-/node-cron-3.0.11.tgz#70b7131f65038ae63cfe841354c8aba363632344"
+  integrity sha512-0ikrnug3/IyneSHqCBeslAhlK2aBfYek1fGo4bP4QnZPmiqSGRK+Oy7ZMisLWkesffJvQ1cqAcBnJC+8+nxIAg==
+
 "@types/node-fetch@^2.5.0":
 "@types/node-fetch@^2.5.0":
   version "2.6.8"
   version "2.6.8"
   resolved "https://registry.yarnpkg.com/@types/node-fetch/-/node-fetch-2.6.8.tgz#9a2993583975849c2e1f360b6ca2f11755b2c504"
   resolved "https://registry.yarnpkg.com/@types/node-fetch/-/node-fetch-2.6.8.tgz#9a2993583975849c2e1f360b6ca2f11755b2c504"
@@ -17271,7 +17276,7 @@ string-template@>=1.0.0:
   resolved "https://registry.yarnpkg.com/string-template/-/string-template-1.0.0.tgz#9e9f2233dc00f218718ec379a28a5673ecca8b96"
   resolved "https://registry.yarnpkg.com/string-template/-/string-template-1.0.0.tgz#9e9f2233dc00f218718ec379a28a5673ecca8b96"
   integrity sha1-np8iM9wA8hhxjsN5oopWc+zKi5Y=
   integrity sha1-np8iM9wA8hhxjsN5oopWc+zKi5Y=
 
 
-"string-width-cjs@npm:string-width@^4.2.0":
+"string-width-cjs@npm:string-width@^4.2.0", "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
   version "4.2.3"
   version "4.2.3"
   resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
   resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
   integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
   integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
@@ -17289,15 +17294,6 @@ string-width@=4.2.2:
     is-fullwidth-code-point "^3.0.0"
     is-fullwidth-code-point "^3.0.0"
     strip-ansi "^6.0.0"
     strip-ansi "^6.0.0"
 
 
-"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
-  version "4.2.3"
-  resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
-  integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
-  dependencies:
-    emoji-regex "^8.0.0"
-    is-fullwidth-code-point "^3.0.0"
-    strip-ansi "^6.0.1"
-
 string-width@^5.0.1, string-width@^5.1.2:
 string-width@^5.0.1, string-width@^5.1.2:
   version "5.1.2"
   version "5.1.2"
   resolved "https://registry.yarnpkg.com/string-width/-/string-width-5.1.2.tgz#14f8daec6d81e7221d2a357e668cab73bdbca794"
   resolved "https://registry.yarnpkg.com/string-width/-/string-width-5.1.2.tgz#14f8daec6d81e7221d2a357e668cab73bdbca794"
@@ -17381,7 +17377,7 @@ stringify-entities@^4.0.0:
     character-entities-html4 "^2.0.0"
     character-entities-html4 "^2.0.0"
     character-entities-legacy "^3.0.0"
     character-entities-legacy "^3.0.0"
 
 
-"strip-ansi-cjs@npm:strip-ansi@^6.0.1":
+"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1:
   version "6.0.1"
   version "6.0.1"
   resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
   resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
   integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
   integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
@@ -17395,13 +17391,6 @@ strip-ansi@^3.0.0:
   dependencies:
   dependencies:
     ansi-regex "^2.0.0"
     ansi-regex "^2.0.0"
 
 
-strip-ansi@^6.0.0, strip-ansi@^6.0.1:
-  version "6.0.1"
-  resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
-  integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
-  dependencies:
-    ansi-regex "^5.0.1"
-
 strip-ansi@^7.0.1, strip-ansi@^7.1.0:
 strip-ansi@^7.0.1, strip-ansi@^7.1.0:
   version "7.1.0"
   version "7.1.0"
   resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45"
   resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45"
@@ -19223,7 +19212,7 @@ word-wrap@^1.2.3:
   resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.3.tgz#610636f6b1f703891bd34771ccb17fb93b47079c"
   resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.3.tgz#610636f6b1f703891bd34771ccb17fb93b47079c"
   integrity sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==
   integrity sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==
 
 
-"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0":
+"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0:
   version "7.0.0"
   version "7.0.0"
   resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
   resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
   integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
   integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
@@ -19241,15 +19230,6 @@ wrap-ansi@^6.2.0:
     string-width "^4.1.0"
     string-width "^4.1.0"
     strip-ansi "^6.0.0"
     strip-ansi "^6.0.0"
 
 
-wrap-ansi@^7.0.0:
-  version "7.0.0"
-  resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
-  integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
-  dependencies:
-    ansi-styles "^4.0.0"
-    string-width "^4.1.0"
-    strip-ansi "^6.0.0"
-
 wrap-ansi@^8.1.0:
 wrap-ansi@^8.1.0:
   version "8.1.0"
   version "8.1.0"
   resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"
   resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"