diff --git a/packages/core/server/services/bulk-generate.ts b/packages/core/server/services/bulk-generate.ts index a3aa3a3a..09231b9f 100644 --- a/packages/core/server/services/bulk-generate.ts +++ b/packages/core/server/services/bulk-generate.ts @@ -3,6 +3,53 @@ import { UID } from '@strapi/strapi'; import { getPluginService } from '../util/getPluginService'; import { GenerationType } from '../types'; +// Helper function to safely extract error message +function getErrorMessage(err: unknown): string { + if (typeof err === 'object' && err && 'message' in err) { + const { message } = err as { message?: unknown }; + if (typeof message === 'string') { + return message.toLowerCase(); + } + } + return ''; +} + +// Deadlock/locking retry helper for all major DBs +async function withDbLockRetry( + fn: () => Promise, + retries = 3, + delay = 100, +): Promise { + for (let i = 0; i < retries; i++) { + try { + // eslint-disable-next-line no-await-in-loop + const result = await fn(); + return result; + } catch (err: unknown) { + // Safe type guard + const msg: string = getErrorMessage(err); + // Covers MySQL/MariaDB/Postgres/SQLite + if ( + ( + msg.includes('deadlock') || // MySQL, MariaDB, Postgres + msg.includes('could not serialize access') || // Postgres + msg.includes('lock wait timeout') || // MySQL, MariaDB + msg.includes('database is locked') // SQLite + ) && i < retries - 1 + ) { + // eslint-disable-next-line no-await-in-loop + await new Promise((resolve) => { setTimeout(resolve, delay); }); + // continue is intentional and allowed here + // eslint-disable-next-line no-continue + continue; + } + throw err; + } + } + // If all retries exhausted, throw an error (this satisfies consistent-return) + throw new Error('withDbLockRetry: operation failed after max retries'); +} + export interface GenerateParams { types: UID.ContentType[], generationType: GenerationType, @@ -18,30 +65,35 @@ const generateUrlAliases = async (params: GenerateParams): Promise => { const { types, generationType } = params; let generatedCount = 0; - // Map over all the types sent in the request. - await Promise.all(types.map(async (type) => { + // No concurrency: process each type one by one + // eslint-disable-next-line no-restricted-syntax + for (const type of types) { + // Lock-safe delete operations if (generationType === 'all') { // Delete all the URL aliases for the given type. - await strapi.db.query('plugin::webtools.url-alias').deleteMany({ + // eslint-disable-next-line no-await-in-loop + await withDbLockRetry(() => strapi.db.query('plugin::webtools.url-alias').deleteMany({ where: { contenttype: type }, - }); + })); } if (generationType === 'only_generated') { // Delete all the auto generated URL aliases of the given type. - await strapi.db.query('plugin::webtools.url-alias').deleteMany({ + // eslint-disable-next-line no-await-in-loop + await withDbLockRetry(() => strapi.db.query('plugin::webtools.url-alias').deleteMany({ where: { contenttype: type, generated: true }, - }); + })); } let relations: string[] = []; - let languages: string[] = [undefined]; + let languages: string[] = []; - languages = []; + // eslint-disable-next-line no-await-in-loop const locales = await strapi.documents('plugin::i18n.locale').findMany({}); languages = locales.map((locale) => locale.code); // Get all relations for the type + // eslint-disable-next-line no-await-in-loop await Promise.all(languages.map(async (lang) => { const urlPatterns = await getPluginService('url-pattern').findByUid(type, lang); urlPatterns.forEach((urlPattern) => { @@ -51,6 +103,7 @@ const generateUrlAliases = async (params: GenerateParams): Promise => { })); // Query all the entities of the type that do not have a corresponding URL alias. + // eslint-disable-next-line no-await-in-loop const entities = await strapi.documents(type as 'api::test.test').findMany({ filters: { url_alias: null }, populate: { @@ -77,29 +130,30 @@ const generateUrlAliases = async (params: GenerateParams): Promise => { const resolvedPath = getPluginService('url-pattern').resolvePattern(type, entity, urlPatterns[0]); // eslint-disable-next-line no-await-in-loop - const newUrlAlias = await strapi.documents('plugin::webtools.url-alias').create({ + const newUrlAlias = await withDbLockRetry(() => strapi.documents('plugin::webtools.url-alias').create({ data: { url_path: resolvedPath, generated: true, contenttype: type, locale: entity.locale, }, - }); + })); // eslint-disable-next-line no-await-in-loop - await strapi.documents(type as 'api::test.test').update({ + await withDbLockRetry(() => strapi.documents(type as 'api::test.test').update({ documentId: entity.documentId, data: { url_alias: [newUrlAlias.documentId], }, - }); + })); + // Safe parallel localizations—OK for a handful of locales // eslint-disable-next-line no-await-in-loop await Promise.all(entity.localizations.map(async (loc) => { const patterns = await getPluginService('url-pattern').findByUid(type, loc.locale); const path = getPluginService('url-pattern').resolvePattern(type, loc, patterns[0]); - const alias = await strapi.documents('plugin::webtools.url-alias').update({ + const alias = await withDbLockRetry(() => strapi.documents('plugin::webtools.url-alias').update({ documentId: newUrlAlias.documentId, locale: loc.locale, data: { @@ -108,20 +162,20 @@ const generateUrlAliases = async (params: GenerateParams): Promise => { contenttype: type, locale: entity.locale, }, - }); + })); - await strapi.documents(type as 'api::test.test').update({ + await withDbLockRetry(() => strapi.documents(type as 'api::test.test').update({ documentId: entity.documentId, locale: loc.locale, data: { url_alias: [alias.documentId], }, - }); + })); })); generatedCount += 1; } - })); + } return generatedCount; };