Browse Source

Merge pull request #5896 from weseek/feat/prevent-page-operation-from-being-processed-many-times-at-once

feat: Prevent the same PageOperation from being processed by multiple process at the same time
Yuki Takei 3 years ago
parent
commit
4525f45306

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

@@ -1,4 +1,5 @@
 import { getOrCreateModel } from '@growi/core';
 import { getOrCreateModel } from '@growi/core';
+import { addSeconds } from 'date-fns';
 import mongoose, {
 import mongoose, {
   Schema, Model, Document, QueryOptions, FilterQuery,
   Schema, Model, Document, QueryOptions, FilterQuery,
 } from 'mongoose';
 } from 'mongoose';
@@ -10,6 +11,8 @@ import {
 import loggerFactory from '../../utils/logger';
 import loggerFactory from '../../utils/logger';
 import { ObjectIdLike } from '../interfaces/mongoose-utils';
 import { ObjectIdLike } from '../interfaces/mongoose-utils';
 
 
+const TIME_TO_ADD_SEC = 10;
+
 const logger = loggerFactory('growi:models:page-operation');
 const logger = loggerFactory('growi:models:page-operation');
 
 
 type IObjectId = mongoose.Types.ObjectId;
 type IObjectId = mongoose.Types.ObjectId;
@@ -43,6 +46,7 @@ export interface IPageOperation {
   user: IUserForResuming,
   user: IUserForResuming,
   options?: IOptionsForResuming,
   options?: IOptionsForResuming,
   incForUpdatingDescendantCount?: number,
   incForUpdatingDescendantCount?: number,
+  unprocessableExpiryDate: Date,
 }
 }
 
 
 export interface PageOperationDocument extends IPageOperation, Document {}
 export interface PageOperationDocument extends IPageOperation, Document {}
@@ -53,6 +57,8 @@ export interface PageOperationModel extends Model<PageOperationDocument> {
   findByIdAndUpdatePageActionStage(pageOpId: ObjectIdLike, stage: PageActionStage): Promise<PageOperationDocumentHasId | null>
   findByIdAndUpdatePageActionStage(pageOpId: ObjectIdLike, stage: PageActionStage): Promise<PageOperationDocumentHasId | null>
   findMainOps(filter?: FilterQuery<PageOperationDocument>, projection?: any, options?: QueryOptions): Promise<PageOperationDocumentHasId[]>
   findMainOps(filter?: FilterQuery<PageOperationDocument>, projection?: any, options?: QueryOptions): Promise<PageOperationDocumentHasId[]>
   deleteByActionTypes(deleteTypeList: PageActionType[]): Promise<void>
   deleteByActionTypes(deleteTypeList: PageActionType[]): Promise<void>
+  isProcessable(pageOp: PageOperationDocument): boolean
+  extendExpiryDate(operationId: ObjectIdLike): Promise<void>
 }
 }
 
 
 const pageSchemaForResuming = new Schema<IPageForResuming>({
 const pageSchemaForResuming = new Schema<IPageForResuming>({
@@ -99,6 +105,7 @@ const schema = new Schema<PageOperationDocument, PageOperationModel>({
   user: { type: userSchemaForResuming, required: true },
   user: { type: userSchemaForResuming, required: true },
   options: { type: optionsSchemaForResuming },
   options: { type: optionsSchemaForResuming },
   incForUpdatingDescendantCount: { type: Number },
   incForUpdatingDescendantCount: { type: Number },
+  unprocessableExpiryDate: { type: Date, default: addSeconds(new Date(), 10) },
 });
 });
 
 
 schema.statics.findByIdAndUpdatePageActionStage = async function(
 schema.statics.findByIdAndUpdatePageActionStage = async function(
@@ -129,4 +136,17 @@ schema.statics.deleteByActionTypes = async function(
   logger.info(`Deleted all PageOperation documents with actionType: [${actionTypes}]`);
   logger.info(`Deleted all PageOperation documents with actionType: [${actionTypes}]`);
 };
 };
 
 
+schema.statics.isProcessable = function(pageOp: PageOperationDocument): boolean {
+  const { unprocessableExpiryDate } = pageOp;
+  return unprocessableExpiryDate == null || (unprocessableExpiryDate != null && new Date() > unprocessableExpiryDate);
+};
+
+/**
+ * add TIME_TO_ADD_SEC to current time and update unprocessableExpiryDate with it
+ */
+schema.statics.extendExpiryDate = async function(operationId: ObjectIdLike): Promise<void> {
+  const date = addSeconds(new Date(), TIME_TO_ADD_SEC);
+  await this.findByIdAndUpdate(operationId, { unprocessableExpiryDate: date });
+};
+
 export default getOrCreateModel<PageOperationDocument, PageOperationModel>('PageOperation', schema);
 export default getOrCreateModel<PageOperationDocument, PageOperationModel>('PageOperation', schema);

+ 19 - 0
packages/app/src/server/service/page-operation.ts

@@ -2,7 +2,10 @@ import { pagePathUtils } from '@growi/core';
 
 
 import PageOperation, { PageActionType } from '~/server/models/page-operation';
 import PageOperation, { PageActionType } from '~/server/models/page-operation';
 
 
+import { ObjectIdLike } from '../interfaces/mongoose-utils';
+
 const { isEitherOfPathAreaOverlap, isPathAreaOverlap, isTrashPage } = pagePathUtils;
 const { isEitherOfPathAreaOverlap, isPathAreaOverlap, isTrashPage } = pagePathUtils;
+const AUTO_UPDATE_INTERVAL_SEC = 5;
 
 
 class PageOperationService {
 class PageOperationService {
 
 
@@ -77,6 +80,22 @@ class PageOperationService {
     return true;
     return true;
   }
   }
 
 
+  /**
+   * Set interval to update unprocessableExpiryDate every AUTO_UPDATE_INTERVAL_SEC seconds.
+   * This is used to prevent the same page operation from being processed multiple times at once
+   */
+  autoUpdateExpiryDate(operationId: ObjectIdLike): NodeJS.Timeout {
+    // https://github.com/Microsoft/TypeScript/issues/30128#issuecomment-651877225
+    const timerObj = global.setInterval(async() => {
+      await PageOperation.extendExpiryDate(operationId);
+    }, AUTO_UPDATE_INTERVAL_SEC * 1000);
+    return timerObj;
+  }
+
+  clearAutoUpdateInterval(timerObj: NodeJS.Timeout): void {
+    clearInterval(timerObj);
+  }
+
 }
 }
 
 
 export default PageOperationService;
 export default PageOperationService;

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

@@ -564,8 +564,18 @@ class PageService {
 
 
     const exParentId = page.parent;
     const exParentId = page.parent;
 
 
+    const timerObj = this.crowi.pageOperationService.autoUpdateExpiryDate(pageOpId);
+    try {
     // update descendants first
     // update descendants first
-    await this.renameDescendantsWithStream(page, newPagePath, user, options, false);
+      await this.renameDescendantsWithStream(page, newPagePath, user, options, false);
+    }
+    catch (err) {
+      logger.warn(err);
+      throw Error(err);
+    }
+    finally {
+      this.crowi.pageOperationService.clearAutoUpdateInterval(timerObj);
+    }
 
 
     // reduce ancestore's descendantCount
     // reduce ancestore's descendantCount
     const nToReduce = -1 * ((page.isEmpty ? 0 : 1) + page.descendantCount);
     const nToReduce = -1 * ((page.isEmpty ? 0 : 1) + page.descendantCount);
@@ -592,6 +602,10 @@ class PageService {
     if (pageOp == null) {
     if (pageOp == null) {
       throw Error('There is nothing to be processed right now');
       throw Error('There is nothing to be processed right now');
     }
     }
+    const isProcessable = await PageOperation.isProcessable(pageOp);
+    if (!isProcessable) {
+      throw Error('This page operation is currently being processed');
+    }
 
 
     const {
     const {
       page, toPath, options, user,
       page, toPath, options, user,