diff --git a/backend/src/scripts/utility/modelProcessing/detectZombieProcessing.js b/backend/src/scripts/utility/modelProcessing/detectZombieProcessing.js
new file mode 100644
index 00000000000..fa085cd5945
--- /dev/null
+++ b/backend/src/scripts/utility/modelProcessing/detectZombieProcessing.js
@@ -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 .
+ */
+
+/**
+ * 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,
+};
diff --git a/backend/src/v5/services/mailer/mailer.constants.js b/backend/src/v5/services/mailer/mailer.constants.js
index 910b3ed6d74..aae01705397 100644
--- a/backend/src/v5/services/mailer/mailer.constants.js
+++ b/backend/src/v5/services/mailer/mailer.constants.js
@@ -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 = {};
@@ -30,6 +31,7 @@ const templates = {
forgotPasswordSso,
errorNotification,
modelImportError,
+ zombieProcessingStatuses,
};
MailerConstants.templates = {};
diff --git a/backend/src/v5/services/mailer/templates/html/zombieProcessingStatuses.html b/backend/src/v5/services/mailer/templates/html/zombieProcessingStatuses.html
new file mode 100644
index 00000000000..b7f74cb677e
--- /dev/null
+++ b/backend/src/v5/services/mailer/templates/html/zombieProcessingStatuses.html
@@ -0,0 +1,6 @@
+
<%= message %> on <%= domain %>
+
Log:
+
+ <%-logExcerpt-%>
+
+
diff --git a/backend/src/v5/services/mailer/templates/zombieProcessingStatuses.js b/backend/src/v5/services/mailer/templates/zombieProcessingStatuses.js
new file mode 100644
index 00000000000..00f413a49e0
--- /dev/null
+++ b/backend/src/v5/services/mailer/templates/zombieProcessingStatuses.js
@@ -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 .
+ */
+
+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('<', '<').replaceAll('>', '>').replace(/(\r\n|\n|\r)/gm, '
')),
+
+}).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;
diff --git a/backend/src/v5/services/modelProcessing.js b/backend/src/v5/services/modelProcessing.js
index 8ef211a004c..807cccbaf6f 100644
--- a/backend/src/v5/services/modelProcessing.js
+++ b/backend/src/v5/services/modelProcessing.js
@@ -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);
diff --git a/backend/src/v5/utils/helper/units.js b/backend/src/v5/utils/helper/units.js
index 277303f626c..a9e284205aa 100644
--- a/backend/src/v5/utils/helper/units.js
+++ b/backend/src/v5/utils/helper/units.js
@@ -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;
diff --git a/backend/tests/v5/helper/services.js b/backend/tests/v5/helper/services.js
index a45e5831394..8879f9a80bd 100644
--- a/backend/tests/v5/helper/services.js
+++ b/backend/tests/v5/helper/services.js
@@ -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,
});
diff --git a/backend/tests/v5/scripts/modelProcessing/detectZombieProcessing.test.js b/backend/tests/v5/scripts/modelProcessing/detectZombieProcessing.test.js
new file mode 100644
index 00000000000..3aae102e81d
--- /dev/null
+++ b/backend/tests/v5/scripts/modelProcessing/detectZombieProcessing.test.js
@@ -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 .
+ */
+
+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);
+});
diff --git a/backend/tests/v5/unit/services/mailer/templates/zombieProcessingStatuses.test.js b/backend/tests/v5/unit/services/mailer/templates/zombieProcessingStatuses.test.js
new file mode 100644
index 00000000000..9abc3524b8e
--- /dev/null
+++ b/backend/tests/v5/unit/services/mailer/templates/zombieProcessingStatuses.test.js
@@ -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 .
+ */
+
+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();
+});
diff --git a/backend/tests/v5/unit/utils/helper/units.test.js b/backend/tests/v5/unit/utils/helper/units.test.js
index a05c09457ef..a050c6cdd95 100644
--- a/backend/tests/v5/unit/utils/helper/units.test.js
+++ b/backend/tests/v5/unit/utils/helper/units.test.js
@@ -20,36 +20,51 @@ const { generateRandomString } = require('../../../helper/services');
const UnitsHelper = require(`${src}/utils/helper/units`);
+const UNITS_CONVERSION_FACTORS_TO_METRES = {
+ m: 1,
+ dm: 10,
+ cm: 100,
+ mm: 1000,
+ ft: 3.28084,
+};
+
+const getScaleFactor = (fromUnit, toUnit) => {
+ const fromFactor = UNITS_CONVERSION_FACTORS_TO_METRES[fromUnit];
+ const toFactor = UNITS_CONVERSION_FACTORS_TO_METRES[toUnit];
+ return toFactor / fromFactor;
+};
+
const testConvertArrayUnits = () => {
describe.each([
- ['invalid fromUnit', [1, 5], generateRandomString(), 'm'],
- ['invalid toUnit', [1, 5], 'm', generateRandomString()],
- ['array with non numbers', [generateRandomString(), 5], 'm', 'mm'],
- ['m to dm', [1, 5], 'm', 'dm', [10, 50]],
- ['m to cm', [1, 5], 'm', 'cm', [100, 500]],
- ['m to mm', [1, 5], 'm', 'mm', [1000, 5000]],
- ['m to ft', [1, 5], 'm', 'ft', [3.281, 16.404]],
- ['dm to m', [1, 5], 'dm', 'm', [0.1, 0.5]],
- ['dm to cm', [1, 5], 'dm', 'cm', [10, 50]],
- ['dm to mm', [1, 5], 'dm', 'mm', [100, 500]],
- ['dm to ft', [1, 5], 'dm', 'ft', [0.328, 1.640]],
- ['mm to dm', [1, 5], 'mm', 'dm', [0.01, 0.05]],
- ['mm to cm', [1, 5], 'mm', 'cm', [0.1, 0.5]],
- ['mm to m', [1, 5], 'mm', 'm', [0.001, 0.005]],
- ['mm to ft', [1, 5], 'mm', 'ft', [0.003, 0.016]],
- ['ft to dm', [1, 5], 'ft', 'dm', [3.048, 15.24]],
- ['ft to cm', [1, 5], 'ft', 'cm', [30.48, 152.4]],
- ['ft to m', [1, 5], 'ft', 'm', [0.305, 1.524]],
- ['ft to mm', [1, 5], 'ft', 'mm', [304.8, 1524]],
- ])('Convert array units', (description, array, fromUnit, toUnit, result = null) => {
- test(`with ${description} should return ${result}`, () => {
- let res = UnitsHelper.convertArrayUnits(array, fromUnit, toUnit);
+ ['invalid fromUnit', [1, 5], generateRandomString(), 'm', true],
+ ['invalid toUnit', [1, 5], 'm', generateRandomString(), true],
+ ['array with non numbers', [generateRandomString(), 5], 'm', 'mm', true],
+ ['m to dm', [1, 5], 'm', 'dm'],
+ ['m to cm', [1, 5], 'm', 'cm'],
+ ['m to mm', [1, 5], 'm', 'mm'],
+ ['m to ft', [1, 5], 'm', 'ft'],
+ ['dm to m', [1, 5], 'dm', 'm'],
+ ['dm to cm', [1, 5], 'dm', 'cm'],
+ ['dm to mm', [1, 5], 'dm', 'mm'],
+ ['dm to ft', [1, 5], 'dm', 'ft'],
+ ['mm to dm', [1, 5], 'mm', 'dm'],
+ ['mm to cm', [1, 5], 'mm', 'cm'],
+ ['mm to m', [1, 5], 'mm', 'm'],
+ ['mm to ft', [1, 5], 'mm', 'ft'],
+ ['ft to dm', [1, 5], 'ft', 'dm'],
+ ['ft to cm', [1, 5], 'ft', 'cm'],
+ ['ft to m', [1, 5], 'ft', 'm'],
+ ['ft to mm', [1, 5], 'ft', 'mm'],
+ ])('Convert array units', (description, array, fromUnit, toUnit, invalidInput) => {
+ test(`with ${description} should return ${invalidInput ? 'the same array' : 'the converted array'}`, () => {
+ const res = UnitsHelper.convertArrayUnits(array, fromUnit, toUnit);
- if (res) {
- res = res.map((r) => Math.round(r * 1000) / 1000);
+ if (invalidInput) {
+ expect(res).toEqual(array);
+ } else {
+ const scaleFactor = getScaleFactor(fromUnit, toUnit);
+ expect(res).toEqual(array.map((n) => n * scaleFactor));
}
-
- expect(res).toEqual(result);
});
});
};