Skip to content

Commit

Permalink
Merge branch 'staging' of https://github.com/3drepo/3drepo.io into IS…
Browse files Browse the repository at this point in the history
…SUE_5173
  • Loading branch information
Amantini1997 committed Sep 27, 2024
2 parents 5d498f2 + 29e37d3 commit 92ae050
Show file tree
Hide file tree
Showing 10 changed files with 379 additions and 31 deletions.
100 changes: 100 additions & 0 deletions backend/src/scripts/utility/modelProcessing/detectZombieProcessing.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
/**
* Copyright (C) 2024 3D Repo Ltd
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/

/**
* This script is used to check the processing status of models/drawings.
* Processing status should be 'ok' or 'failed'.
* The utility script `resetProcessingFlag` can be used to reset zombie statuses for models.
*/

const { v5Path } = require('../../../interop');

const { logger } = require(`${v5Path}/utils/logger`);
const { getTeamspaceList } = require('../../utils');

const { find } = require(`${v5Path}/handler/db`);
const { SETTINGS_COL, processStatuses } = require(`${v5Path}/models/modelSettings.constants`);
const { DRAWINGS_HISTORY_COL } = require(`${v5Path}/models/revisions.constants`);
const { sendSystemEmail } = require(`${v5Path}/services/mailer`);
const { templates: emailTemplates } = require(`${v5Path}/services/mailer/mailer.constants`);
const { UUIDToString } = require(`${v5Path}/utils/helper/uuids`);
const Path = require('path');

let TIME_LIMIT = 24 * 60 * 60 * 1000; // hours * 1 hour in ms

const processTeamspace = async (teamspace) => {
const expiredTimestamp = new Date(new Date() - TIME_LIMIT);
const zombieQuery = {
status: { $exists: true, $not: { $regex: `(${processStatuses.OK})|(${processStatuses.FAILED})` } },
timestamp: { $lt: expiredTimestamp },
};

logger.logInfo(`\t-${teamspace}`);

const zombieModels = await find(teamspace, SETTINGS_COL, zombieQuery, { status: 1, timestamp: 1 });
const zombieDrawings = await find(teamspace, DRAWINGS_HISTORY_COL, zombieQuery, { status: 1, timestamp: 1 });

return [
...zombieModels.map(({ _id, status, timestamp }) => `${teamspace}, model, ${_id}, ${status}, ${timestamp}`),
...zombieDrawings.map(({ _id, status, timestamp }) => `${teamspace}, drawing, ${UUIDToString(_id)}, ${status}, ${timestamp}`),
];
};

const run = async (teamspace, limit, notify) => {
logger.logInfo(`Check processing flag(s) in ${teamspace ?? 'all teamspaces'}`);

if (limit) {
TIME_LIMIT = limit * 60 * 60 * 1000;
}

const teamspaces = teamspace ? [teamspace] : await getTeamspaceList();
const results = (await Promise.all(teamspaces.map((ts) => processTeamspace(ts)))).flat();

if (notify && results.length > 0) {
logger.logInfo(`Zombie processing statuses found: ${results.length}`);
const data = {
script: Path.basename(__filename, Path.extname(__filename)),
title: 'Zombie processing statuses found',
message: `${results.length} zombie processing statuses found`,
logExcerpt: JSON.stringify(results),
};
await sendSystemEmail(emailTemplates.ZOMBIE_PROCESSING_STATUSES.name, data);
}
};

const genYargs = /* istanbul ignore next */(yargs) => {
const commandName = Path.basename(__filename, Path.extname(__filename));
const argsSpec = (subYargs) => subYargs.option('teamspace', {
describe: 'Target a specific teamspace (if unspecified, all teamspaces will be targetted)',
type: 'string',
}).option('limit', {
describe: 'Time limit (hours, default: 24) where models/drawings may still be processing',
type: 'number',
}).option('notify', {
describe: 'Send e-mail notification if results are found (default: false)',
type: 'boolean',
});
return yargs.command(commandName,
'Checks the processing status of models/drawings.',
argsSpec,
(argv) => run(argv.teamspace, argv.limit, argv.notify));
};

module.exports = {
run,
genYargs,
};
2 changes: 2 additions & 0 deletions backend/src/v5/services/mailer/mailer.constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ const forgotPasswordSso = require('./templates/forgotPasswordSSO');
const modelImportError = require('./templates/modelImportError');
const { toConstantCase } = require('../../utils/helper/strings');
const verifyUser = require('./templates/verifyUser');
const zombieProcessingStatuses = require('./templates/zombieProcessingStatuses');

const MailerConstants = {};

Expand All @@ -30,6 +31,7 @@ const templates = {
forgotPasswordSso,
errorNotification,
modelImportError,
zombieProcessingStatuses,
};

MailerConstants.templates = {};
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<p><%= message %> on <%= domain %><p>
<p><b>Log:</b></p>
<p>
<%-logExcerpt-%>
</p>

Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/**
* Copyright (C) 2024 3D Repo Ltd
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/

const Yup = require('yup');
const config = require('../../../utils/config');
const { generateTemplateFn } = require('./common');

const TEMPLATE_PATH = `${__dirname}/html/zombieProcessingStatuses.html`;

const dataSchema = Yup.object({
script: Yup.string().default(''),
title: Yup.string().default('Zombie Processing Statuses'),
domain: Yup.string().default(() => config.getBaseURL()),
logExcerpt: Yup.string().default('No logs found.').transform(
(val) => val.replaceAll('<', '&lt;').replaceAll('>', '&gt;').replace(/(\r\n|\n|\r)/gm, '<br>')),

}).required(true);

const ZombieProcessingStatusesTemplate = {};
ZombieProcessingStatusesTemplate.subject = (data) => {
const { domain, title, script } = dataSchema.cast(data);
return `[${domain}][${script}] ${title}`;
};

ZombieProcessingStatusesTemplate.html = generateTemplateFn(dataSchema, TEMPLATE_PATH);

module.exports = ZombieProcessingStatusesTemplate;
2 changes: 1 addition & 1 deletion backend/src/v5/services/modelProcessing.js
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ const queueDrawingUpload = async (teamspace, project, model, revId, data, fileBu
database: teamspace,
project: model,
revId,
file,
file: `${SHARED_SPACE_TAG}/${revId}/${revId}${data.format}`,
};

await mkdir(pathToRevFolder);
Expand Down
5 changes: 3 additions & 2 deletions backend/src/v5/utils/helper/units.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,13 @@ const UNITS_CONVERSION_FACTORS_TO_METRES = {
UnitsHelper.convertArrayUnits = (array, fromUnit, toUnit) => {
const fromFactor = UNITS_CONVERSION_FACTORS_TO_METRES[fromUnit];
const toFactor = UNITS_CONVERSION_FACTORS_TO_METRES[toUnit];
const scale = toFactor / fromFactor;

if (!array.every(isNumber) || !fromFactor || !toFactor) {
return null;
return array;
}

return array.map((n) => (n / fromFactor) * toFactor);
return array.map((n) => n * scale);
};

module.exports = UnitsHelper;
5 changes: 3 additions & 2 deletions backend/tests/v5/helper/services.js
Original file line number Diff line number Diff line change
Expand Up @@ -458,16 +458,17 @@ ServiceHelper.generateRandomModel = ({ modelType = modelTypes.CONTAINER, viewers
};
};

ServiceHelper.generateRevisionEntry = (isVoid = false, hasFile = true, modelType) => {
ServiceHelper.generateRevisionEntry = (isVoid = false, hasFile = true, modelType, timestamp, status) => {
const _id = ServiceHelper.generateUUIDString();
const entry = deleteIfUndefined({
_id,
tag: modelType === modelTypes.DRAWING ? undefined : ServiceHelper.generateRandomString(),
status,
statusCode: modelType === modelTypes.DRAWING ? statusCodes[0].code : undefined,
revCode: modelType === modelTypes.DRAWING ? ServiceHelper.generateRandomString(10) : undefined,
format: modelType === modelTypes.DRAWING ? '.pdf' : undefined,
author: ServiceHelper.generateRandomString(),
timestamp: ServiceHelper.generateRandomDate(),
timestamp: timestamp || ServiceHelper.generateRandomDate(),
desc: ServiceHelper.generateRandomString(),
void: !!isVoid,
});
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
/**
* Copyright (C) 2024 3D Repo Ltd
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/

const {
determineTestGroup,
db: { reset: resetDB, createModel, createRevision },
generateRandomString,
generateRandomModel,
generateRevisionEntry,
} = require('../../helper/services');
const { times } = require('lodash');
const { utilScripts, src } = require('../../helper/path');

const { modelTypes, processStatuses } = require(`${src}/models/modelSettings.constants`);
const { deleteIfUndefined } = require(`${src}/utils/helper/objects`);
const { disconnect } = require(`${src}/handler/db`);
const { templates: emailTemplates } = require(`${src}/services/mailer/mailer.constants`);

jest.mock('../../../../src/v5/services/mailer');
const Mailer = require(`${src}/services/mailer`);

const DetectZombieProcessing = require(`${utilScripts}/modelProcessing/detectZombieProcessing`);
const Path = require('path');

const modelStates = Object.values(processStatuses);

const recentDate = new Date((new Date()) - 36 * 60 * 60 * 1000);

const setupData = () => {
const modelProms = times(2, async () => {
const teamspace = generateRandomString();
const models = await Promise.all(times(modelStates.length, async (n) => {
const { _id, name, properties } = generateRandomModel({
properties: deleteIfUndefined({ status: modelStates[n], timestamp: recentDate }),
});
await createModel(teamspace, _id, name, properties);
return { model: _id, status: modelStates[n] };
}));
const drawings = await Promise.all(times(modelStates.length, async (n) => {
const project = generateRandomString();
const revision = generateRevisionEntry(false, false, modelTypes.DRAWING, recentDate, modelStates[n]);
await createRevision(teamspace, project, revision._id, revision, modelTypes.DRAWING);
return { drawing: revision._id, status: modelStates[n] };
}));

return { teamspace, models, drawings };
});
return Promise.all(modelProms);
};

const checkMail = (data, filteredTeamspace) => {
const expectedLogExcerpt = data.map(({ teamspace, models, drawings }) => {
if (!filteredTeamspace || teamspace === filteredTeamspace) {
const expectedModels = models.map(({ model, status }) => (
(status !== processStatuses.OK && status !== processStatuses.FAILED)
? `${teamspace}, model, ${model}, ${status}, ${recentDate}` : ''));
const expectedDrawings = drawings.map(({ drawing, status }) => (
(status !== processStatuses.OK && status !== processStatuses.FAILED)
? `${teamspace}, drawing, ${drawing}, ${status}, ${recentDate}` : ''));
return [...expectedModels, ...expectedDrawings];
}
return undefined;
}).flat().filter(Boolean);
const expectedData = {
script: Path.basename(__filename, Path.extname(__filename)).replace(/\.test/, ''),
title: 'Zombie processing statuses found',
message: `${expectedLogExcerpt.length} zombie processing statuses found`,
};
expect(Mailer.sendSystemEmail).toHaveBeenCalledWith(
emailTemplates.ZOMBIE_PROCESSING_STATUSES.name,
expect.objectContaining(expectedData),
);
const actualLogExcerpt = JSON.parse(Mailer.sendSystemEmail.mock.calls[0][1].logExcerpt);
expect(actualLogExcerpt).toEqual(expect.arrayContaining(expectedLogExcerpt));
};

const runTest = () => {
describe('Detect zombie processing', () => {
let data;
beforeEach(async () => {
await resetDB();
data = await setupData();
});

test('Should do nothing if notify is not set', async () => {
await DetectZombieProcessing.run();
expect(Mailer.sendSystemEmail).toHaveBeenCalledTimes(0);
});

test('Should send system mail if notify is set', async () => {
await DetectZombieProcessing.run(undefined, undefined, true);
expect(Mailer.sendSystemEmail).toHaveBeenCalledTimes(1);
checkMail(data);
});

test('Should send system mail if the predefined teamspace exists', async () => {
await DetectZombieProcessing.run(data[0].teamspace, undefined, true);
expect(Mailer.sendSystemEmail).toHaveBeenCalledTimes(1);
checkMail(data, data[0].teamspace);
});

test('Should do nothing if teamspace is not found', async () => {
await DetectZombieProcessing.run(generateRandomString(), undefined, true);
expect(Mailer.sendSystemEmail).toHaveBeenCalledTimes(0);
});

test('Should do nothing if time limit is extended', async () => {
await DetectZombieProcessing.run(undefined, 48 * 60 * 60 * 1000, true);
expect(Mailer.sendSystemEmail).toHaveBeenCalledTimes(0);
});
});
};

describe(determineTestGroup(__filename), () => {
runTest();
afterAll(disconnect);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/**
* Copyright (C) 2024 3D Repo Ltd
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/

const { src } = require('../../../../helper/path');
const { determineTestGroup, generateRandomString } = require('../../../../helper/services');
const isHtml = require('is-html-content');

const ZombieProcessingStatuses = require(`${src}/services/mailer/templates/zombieProcessingStatuses`);

const testHtml = () => {
describe('get template html', () => {
test('should get zombieProcessingStatuses template html', async () => {
const res = await ZombieProcessingStatuses.html({
message: generateRandomString(),
domain: generateRandomString(),
logExcerpt: generateRandomString(),
});
expect(isHtml(res)).toEqual(true);
});
});
};

const testSubject = () => {
describe.each([
['data object is empty', {}],
['data object is not empty', { domain: generateRandomString(), title: generateRandomString(), script: generateRandomString() }],
])('get subject', (desc, data) => {
test(`should succeed if ${desc}`, () => {
expect(ZombieProcessingStatuses.subject(data).length).not.toEqual(0);
});
});
};

describe(determineTestGroup(__filename), () => {
testHtml();
testSubject();
});
Loading

0 comments on commit 92ae050

Please sign in to comment.