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

implement restart page renaming operation

yohei0125 4 лет назад
Родитель
Сommit
72babe2c3a

+ 2 - 2
packages/app/src/server/models/page-operation.ts

@@ -40,7 +40,7 @@ export interface IPageOperation {
   user: IUserForResuming,
   options?: IOptionsForResuming,
   incForUpdatingDescendantCount?: number,
-  isFailure: boolean,
+  unprocessableExpiryDate?: Date,
 }
 
 export interface PageOperationDocument extends IPageOperation, Document {}
@@ -96,7 +96,7 @@ const schema = new Schema<PageOperationDocument, PageOperationModel>({
   user: { type: userSchemaForResuming, required: true },
   options: { type: optionsSchemaForResuming },
   incForUpdatingDescendantCount: { type: Number },
-  isFailure: { type: Boolean, default: false, required: true },
+  unprocessableExpiryDate: { type: Date, default: null },
 });
 
 schema.statics.findByIdAndUpdatePageActionStage = async function(

+ 90 - 15
packages/app/src/server/service/page.ts

@@ -2,6 +2,7 @@ import pathlib from 'path';
 import { Readable, Writable } from 'stream';
 
 import { pagePathUtils, pathUtils } from '@growi/core';
+import { addSeconds } from 'date-fns';
 import escapeStringRegexp from 'escape-string-regexp';
 import mongoose, { ObjectId, QueryCursor } from 'mongoose';
 import streamToPromise from 'stream-to-promise';
@@ -44,6 +45,10 @@ const { addTrailingSlash } = pathUtils;
 const BULK_REINDEX_SIZE = 100;
 const LIMIT_FOR_MULTIPLE_PAGE_OP = 20;
 
+// https://www.designcise.com/web/tutorial/what-is-the-correct-typescript-return-type-for-javascripts-settimeout-function#inferring-type-using-return-type
+type TSetInterval = ReturnType<typeof setInterval>
+type TSetTimeout = ReturnType<typeof setTimeout>
+
 // TODO: improve type
 class PageCursorsForDescendantsFactory {
 
@@ -391,6 +396,42 @@ class PageService {
       .cursor({ batchSize: BULK_REINDEX_SIZE });
   }
 
+  /**
+   * set interval to keep updating the property unprocessableExpiryDate until the renaming process is complete
+   * to avoid the same PageOperation being processed by multiple processes.
+   * -Until the time of unprocessableExpiryDate, the PageOperation is considered being processed thus new process for it cannot be made-
+   * @param pageOperationId
+   * @param extendTimeSec time to add on current time every intervalSec
+   * @param intervalSec interval time in sec
+   * @param selfStopSec time to self-stop in case setInterval kept alive as a zombie process
+   * @returns tiemr id
+   */
+  async setIntervalUpdatePageOperationExpireDate(
+      pageOperationId:ObjectIdLike, extendTimeSec: number, intervalSec: number, selfStopSec: number,
+  ): Promise<TSetInterval> {
+    const pageOp = await PageOperation.findByIdAndUpdate(pageOperationId, { unprocessableExpiryDate: addSeconds(new Date(), extendTimeSec) }, { new: true });
+    if (pageOp == null) throw Error('setinterval cannot be set as page operation is not found');
+
+    const timerId = setInterval(async(): Promise<void> => {
+      pageOp.unprocessableExpiryDate = addSeconds(new Date(), extendTimeSec);
+      await pageOp.save();
+      logger.info(`property unprocessableExpiryDate of page operation(${pageOperationId}) is updated`);
+    }, intervalSec * 1000);
+
+    this.setTimeoutToStopSetInterval(timerId, selfStopSec);
+
+    logger.info(`autoUpdateInterval(${timerId}) for page operation process is set.`);
+
+    return timerId;
+  }
+
+  setTimeoutToStopSetInterval(timerId:TSetInterval, selfStopSec:number): TSetTimeout {
+    return setTimeout(() => {
+      clearInterval(timerId);
+      logger.warn('executed self-stop setInterval in case it keeps alive as a zombie process');
+    }, selfStopSec * 1000);
+  }
+
   async renamePage(page, newPagePath, user, options) {
     /*
      * Common Operation
@@ -446,12 +487,18 @@ class PageService {
       logger.error('Failed to create PageOperation document.', err);
       throw err;
     }
-    const renamedPage = await this.renameMainOperation(page, newPagePath, user, options, pageOp._id);
+    const extendTimeSec = 10;
+    const intervalSec = 5;
+    const selfStopSec = 15;
+    // Keep updating the property unprocessableExpiryDate until the renaming process is complete
+    // to avoid PageOperation being processed by multiple processes.
+    const autoUpdateIntervalTimerId = await this.setIntervalUpdatePageOperationExpireDate(pageOp._id, extendTimeSec, intervalSec, selfStopSec);
+    const renamedPage = await this.renameMainOperation(page, newPagePath, user, options, pageOp._id, autoUpdateIntervalTimerId);
 
     return renamedPage;
   }
 
-  async renameMainOperation(page, newPagePath: string, user, options, pageOpId: ObjectIdLike) {
+  async renameMainOperation(page, newPagePath: string, user, options, pageOpId: ObjectIdLike, autoUpdateIntervalTimerId: ReturnType<typeof setInterval>) {
     const Page = mongoose.model('Page') as unknown as PageModel;
 
     const updateMetadata = options.updateMetadata || false;
@@ -531,18 +578,33 @@ class PageService {
     /*
      * Sub Operation
      */
-    this.renameSubOperation(page, newPagePath, user, options, renamedPage, pageOp._id);
+    this.renameSubOperation(page, newPagePath, user, options, renamedPage, pageOp._id, autoUpdateIntervalTimerId);
 
     return renamedPage;
   }
 
-  async renameSubOperation(page, newPagePath: string, user, options, renamedPage, pageOpId: ObjectIdLike): Promise<void> {
+
+  async renameSubOperation(
+      page, newPagePath: string, user, options, renamedPage, pageOpId: ObjectIdLike, autoUpdateIntervalTimerId: ReturnType<typeof setInterval>,
+  ): Promise<void> {
     const Page = mongoose.model('Page') as unknown as PageModel;
 
     const exParentId = page.parent;
 
+    try {
     // update descendants first
-    await this.renameDescendantsWithStream(page, newPagePath, user, options, false);
+      await this.renameDescendantsWithStream(page, newPagePath, user, options, false);
+    }
+    catch (err) {
+      // clear interval if failed
+      clearInterval(autoUpdateIntervalTimerId);
+      logger.error('renameDescendantsWithStream Failed:', err);
+      throw Error(err);
+    }
+
+    // clear interval
+    clearInterval(autoUpdateIntervalTimerId);
+    logger.info(`autoUpdateInterval(${autoUpdateIntervalTimerId}) is now cleared.`);
 
     // reduce ancestore's descendantCount
     const nToReduce = -1 * ((page.isEmpty ? 0 : 1) + page.descendantCount);
@@ -554,7 +616,7 @@ class PageService {
 
     // Remove leaf empty pages if not moving to under the ex-target position
     if (!this.isRenamingToUnderTarget(page.path, newPagePath)) {
-      // remove empty pages at leaf position
+    // remove empty pages at leaf position
       await Page.removeLeafEmptyPagesRecursively(page.parent);
     }
 
@@ -562,20 +624,33 @@ class PageService {
   }
 
   async restartPageRenameOperation(pageId: ObjectIdLike): Promise<void> {
-    const filter = { 'page.id': pageId, actionType: PageActionType.Rename, isFailure: true };
-    const update = { isFailure: false, actionStage: PageActionStage.Main }; // as it restarts from the beginning
-    // find one, update it, and return the updated document
-    const pageOp = await PageOperation.findOneAndUpdate(filter, update, { new: true }); // set isFailure to false before rename operation
+    if (pageId == null) {
+      throw Error('it did not restart rename operation because pageId is missing.');
+    }
+
+    const pageOperation = await PageOperation.findOne({ 'page._id': pageId, actionType: PageActionType.Rename });
+
+    if (pageOperation == null || pageOperation.toPath == null) {
+      throw Error('it did not restart rename operation because page operation to be processed was not found');
+    }
 
-    if (pageOp == null || pageOp.toPath == null) {
-      throw Error('PageRenameOperation cannot be restarted as PageOperation to be processed is not found');
+    const { unprocessableExpiryDate } = pageOperation;
+    if (unprocessableExpiryDate != null && unprocessableExpiryDate > new Date()) {
+      throw Error('it did not restart rename operation because it is currently being processed');
     }
 
     const {
-      page, toPath, user, options, _id,
-    } = pageOp;
+      page, toPath, user, options,
+    } = pageOperation;
+
+    const Page = mongoose.model('Page') as unknown as PageModel;
+    const renamedPage = await Page.findOne({ _id: page._id }); // sub operation needs updated page
 
-    await this.renameMainOperation(page, toPath, user, options, _id);
+    const extendTimeSec = 10;
+    const intervalSec = 5;
+    const selfStopSec = 15;
+    const autoUpdateIntervalTimerId = await this.setIntervalUpdatePageOperationExpireDate(pageOperation._id, extendTimeSec, intervalSec, selfStopSec);
+    await this.renameSubOperation(page, toPath, user, options, renamedPage, pageOperation._id, autoUpdateIntervalTimerId);
   }
 
   private isRenamingToUnderTarget(fromPath: string, toPath: string): boolean {