diff --git a/src/app/api/tasks/public/public.service.ts b/src/app/api/tasks/public/public.service.ts index 4988d7c55..6e43f4573 100644 --- a/src/app/api/tasks/public/public.service.ts +++ b/src/app/api/tasks/public/public.service.ts @@ -1,4 +1,6 @@ +import { subtaskTemplateBatchSize } from '@/constants/tasks' import { MAX_FETCH_ASSIGNEE_COUNT } from '@/constants/users' +import { runInBatches } from '@/utils/array' import { deleteTaskNotifications, sendTaskCreateNotifications, sendTaskUpdateNotifications } from '@/jobs/notifications' import { CreateTaskRequest, UpdateTaskRequest, Associations, AssociationsSchema } from '@/types/dto/tasks.dto' import { DISPATCHABLE_EVENT } from '@/types/webhook' @@ -270,13 +272,15 @@ export class PublicTasksService extends TasksSharedService { } if (template.subTaskTemplates.length) { - await Promise.all( - template.subTaskTemplates.map(async (sub, index) => { - const updatedSubTemplate = await templateService.getAppliedTemplateDescription(sub.id) - const manualTimeStamp = new Date(template.createdAt.getTime() + (template.subTaskTemplates.length - index) * 10) //maintain the order of subtasks in tasks with respect to subtasks in templates - await this.createSubtasksFromTemplate(updatedSubTemplate, newTask, manualTimeStamp) - }), - ) + await runInBatches(template.subTaskTemplates, subtaskTemplateBatchSize, async (sub, index) => { + const manualTimestamp = new Date(template.createdAt.getTime() + (template.subTaskTemplates.length - index) * 10) //maintain the order of subtasks in tasks with respect to subtasks in templates + await this.createSubtasksFromTemplate({ + subTemplateId: sub.id, + parentTask: newTask, + manualTimestamp, + templateService, + }) + }) } } diff --git a/src/app/api/tasks/tasks.service.ts b/src/app/api/tasks/tasks.service.ts index 9d04d3252..319b9f53b 100644 --- a/src/app/api/tasks/tasks.service.ts +++ b/src/app/api/tasks/tasks.service.ts @@ -4,6 +4,8 @@ import { ClientResponse, CompanyResponse, InternalUsers } from '@/types/common' import { TaskWithWorkflowState } from '@/types/db' import { AncestorTaskResponse, CreateTaskRequest, UpdateTaskRequest, Associations } from '@/types/dto/tasks.dto' import { DISPATCHABLE_EVENT } from '@/types/webhook' +import { runInBatches } from '@/utils/array' +import { subtaskTemplateBatchSize } from '@/constants/tasks' import { UserIdsType } from '@/utils/assignee' import { isPastDateString } from '@/utils/dateHelper' import { getIdsFromLtreePath } from '@/utils/ltree' @@ -251,13 +253,15 @@ export class TasksService extends TasksSharedService { } if (template.subTaskTemplates.length) { - await Promise.all( - template.subTaskTemplates.map(async (sub, index) => { - const updatedSubTemplate = await templateService.getAppliedTemplateDescription(sub.id) - const manualTimeStamp = new Date(template.createdAt.getTime() + (template.subTaskTemplates.length - index) * 10) //maintain the order of subtasks in tasks with respect to subtasks in templates - await this.createSubtasksFromTemplate(updatedSubTemplate, newTask, manualTimeStamp) - }), - ) + await runInBatches(template.subTaskTemplates, subtaskTemplateBatchSize, async (sub, index) => { + const manualTimestamp = new Date(template.createdAt.getTime() + (template.subTaskTemplates.length - index) * 10) //maintain the order of subtasks in tasks with respect to subtasks in templates + await this.createSubtasksFromTemplate({ + subTemplateId: sub.id, + parentTask: newTask, + manualTimestamp, + templateService, + }) + }) } } diff --git a/src/app/api/tasks/tasksShared.service.ts b/src/app/api/tasks/tasksShared.service.ts index 8740d3888..de50099f1 100644 --- a/src/app/api/tasks/tasksShared.service.ts +++ b/src/app/api/tasks/tasksShared.service.ts @@ -18,10 +18,11 @@ import { SupabaseActions } from '@/utils/SupabaseActions' import APIError from '@api/core/exceptions/api' import { BaseService } from '@api/core/services/base.service' import { UserRole } from '@api/core/types/user' -import { AssigneeType, Prisma, PrismaClient, StateType, Task, TaskTemplate } from '@prisma/client' +import { AssigneeType, Prisma, PrismaClient, StateType, Task } from '@prisma/client' import httpStatus from 'http-status' import z from 'zod' import { AttachmentsService } from '@api/attachments/attachments.service' +import type { TemplatesService } from '@api/tasks/templates/templates.service' //Base class with shared permission logic and methods that both tasks.service.ts and public.service.ts could use export abstract class TasksSharedService extends BaseService { @@ -589,11 +590,22 @@ export abstract class TasksSharedService extends BaseService { } } - protected async createSubtasksFromTemplate(data: TaskTemplate, parentTask: Task, manualTimestamp: Date) { - const { workspaceId, title, body, workflowStateId } = data + protected async createSubtasksFromTemplate({ + subTemplateId, + parentTask, + manualTimestamp, + templateService, + }: { + subTemplateId: string + parentTask: Task + manualTimestamp: Date + templateService: TemplatesService + }) { const { id: parentId, internalUserId, clientId, companyId, associations, isShared } = parentTask try { + const { workspaceId, title, body, workflowStateId } = + await templateService.getAppliedTemplateDescription(subTemplateId) const createTaskPayload = CreateTaskRequestSchema.parse({ title: resolveDynamicFields(title), body: body ? resolveAutofillTags(body) : body, @@ -608,8 +620,10 @@ export abstract class TasksSharedService extends BaseService { isShared, }) - await this.createTask(createTaskPayload, { disableSubtaskTemplates: true, manualTimestamp: manualTimestamp }) + await this.createTask(createTaskPayload, { disableSubtaskTemplates: true, manualTimestamp }) } catch (e) { + // All-or-nothing: if any subtask fails to apply, roll back the parent task + // rather than leaving a partially-applied template, and surface the error. const deleteTask = this.db.task.delete({ where: { id: parentId } }) const deleteActivityLogs = this.db.activityLog.deleteMany({ where: { taskId: parentId } }) @@ -620,7 +634,11 @@ export abstract class TasksSharedService extends BaseService { this.unsetTransaction() }) - console.error('TasksService#createTask | Rolling back task creation', e) + console.error('TasksSharedService#createSubtasksFromTemplate | Rolling back task creation', { + parentId, + subTemplateId, + e, + }) throw new APIError( httpStatus.INTERNAL_SERVER_ERROR, 'Failed to create subtask from template, new task was not created.', diff --git a/src/constants/tasks.ts b/src/constants/tasks.ts index df55376cd..f511f1f6b 100644 --- a/src/constants/tasks.ts +++ b/src/constants/tasks.ts @@ -1 +1,5 @@ export const maxSubTaskDepth = 1 + +// Bounds how many subtasks we create concurrently when applying a template, so a +// template with many sub-templates can't exhaust the DB connection pool. +export const subtaskTemplateBatchSize = 5 diff --git a/src/utils/array.ts b/src/utils/array.ts index 71c1c9495..e633dff53 100644 --- a/src/utils/array.ts +++ b/src/utils/array.ts @@ -28,3 +28,24 @@ export const groupBy = (arr: T[], key: K): Grouped => { return acc }, {} as Grouped) } + +export const chunk = (arr: T[], size: number): T[][] => + Array.from({ length: Math.ceil(arr.length / size) }, (_, i) => arr.slice(i * size, i * size + size)) + +/** + * Runs `handler` over `items` with bounded concurrency: at most `size` run at once, + * one batch after another. Prevents unbounded fan-out from exhausting the DB connection + * pool (and downstream rate limits) when the input can be large. + */ +export const runInBatches = async ( + items: T[], + size: number, + handler: (item: T, index: number) => Promise, +): Promise => { + const indexed = items.map((item, index) => ({ item, index })) + const results: R[] = [] + for (const batch of chunk(indexed, size)) { + results.push(...(await Promise.all(batch.map(({ item, index }) => handler(item, index))))) + } + return results +}