Skip to content

Commit 44069a8

Browse files
authored
Merge pull request #1385 from assemblycom/OUT-3964-bound-subtask-template-fanout
OUT-3964 | Tasks App - Task(s) Created Using Template Not Saving
2 parents f0aa5bc + a44b384 commit 44069a8

5 files changed

Lines changed: 70 additions & 19 deletions

File tree

src/app/api/tasks/public/public.service.ts

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
import { subtaskTemplateBatchSize } from '@/constants/tasks'
12
import { MAX_FETCH_ASSIGNEE_COUNT } from '@/constants/users'
3+
import { runInBatches } from '@/utils/array'
24
import { deleteTaskNotifications, sendTaskCreateNotifications, sendTaskUpdateNotifications } from '@/jobs/notifications'
35
import { CreateTaskRequest, UpdateTaskRequest, Associations, AssociationsSchema } from '@/types/dto/tasks.dto'
46
import { DISPATCHABLE_EVENT } from '@/types/webhook'
@@ -270,13 +272,15 @@ export class PublicTasksService extends TasksSharedService {
270272
}
271273

272274
if (template.subTaskTemplates.length) {
273-
await Promise.all(
274-
template.subTaskTemplates.map(async (sub, index) => {
275-
const updatedSubTemplate = await templateService.getAppliedTemplateDescription(sub.id)
276-
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
277-
await this.createSubtasksFromTemplate(updatedSubTemplate, newTask, manualTimeStamp)
278-
}),
279-
)
275+
await runInBatches(template.subTaskTemplates, subtaskTemplateBatchSize, async (sub, index) => {
276+
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
277+
await this.createSubtasksFromTemplate({
278+
subTemplateId: sub.id,
279+
parentTask: newTask,
280+
manualTimestamp,
281+
templateService,
282+
})
283+
})
280284
}
281285
}
282286

src/app/api/tasks/tasks.service.ts

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import { ClientResponse, CompanyResponse, InternalUsers } from '@/types/common'
44
import { TaskWithWorkflowState } from '@/types/db'
55
import { AncestorTaskResponse, CreateTaskRequest, UpdateTaskRequest, Associations } from '@/types/dto/tasks.dto'
66
import { DISPATCHABLE_EVENT } from '@/types/webhook'
7+
import { runInBatches } from '@/utils/array'
8+
import { subtaskTemplateBatchSize } from '@/constants/tasks'
79
import { UserIdsType } from '@/utils/assignee'
810
import { isPastDateString } from '@/utils/dateHelper'
911
import { getIdsFromLtreePath } from '@/utils/ltree'
@@ -251,13 +253,15 @@ export class TasksService extends TasksSharedService {
251253
}
252254

253255
if (template.subTaskTemplates.length) {
254-
await Promise.all(
255-
template.subTaskTemplates.map(async (sub, index) => {
256-
const updatedSubTemplate = await templateService.getAppliedTemplateDescription(sub.id)
257-
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
258-
await this.createSubtasksFromTemplate(updatedSubTemplate, newTask, manualTimeStamp)
259-
}),
260-
)
256+
await runInBatches(template.subTaskTemplates, subtaskTemplateBatchSize, async (sub, index) => {
257+
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
258+
await this.createSubtasksFromTemplate({
259+
subTemplateId: sub.id,
260+
parentTask: newTask,
261+
manualTimestamp,
262+
templateService,
263+
})
264+
})
261265
}
262266
}
263267

src/app/api/tasks/tasksShared.service.ts

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,11 @@ import { SupabaseActions } from '@/utils/SupabaseActions'
1818
import APIError from '@api/core/exceptions/api'
1919
import { BaseService } from '@api/core/services/base.service'
2020
import { UserRole } from '@api/core/types/user'
21-
import { AssigneeType, Prisma, PrismaClient, StateType, Task, TaskTemplate } from '@prisma/client'
21+
import { AssigneeType, Prisma, PrismaClient, StateType, Task } from '@prisma/client'
2222
import httpStatus from 'http-status'
2323
import z from 'zod'
2424
import { AttachmentsService } from '@api/attachments/attachments.service'
25+
import type { TemplatesService } from '@api/tasks/templates/templates.service'
2526

2627
//Base class with shared permission logic and methods that both tasks.service.ts and public.service.ts could use
2728
export abstract class TasksSharedService extends BaseService {
@@ -589,11 +590,22 @@ export abstract class TasksSharedService extends BaseService {
589590
}
590591
}
591592

592-
protected async createSubtasksFromTemplate(data: TaskTemplate, parentTask: Task, manualTimestamp: Date) {
593-
const { workspaceId, title, body, workflowStateId } = data
593+
protected async createSubtasksFromTemplate({
594+
subTemplateId,
595+
parentTask,
596+
manualTimestamp,
597+
templateService,
598+
}: {
599+
subTemplateId: string
600+
parentTask: Task
601+
manualTimestamp: Date
602+
templateService: TemplatesService
603+
}) {
594604
const { id: parentId, internalUserId, clientId, companyId, associations, isShared } = parentTask
595605

596606
try {
607+
const { workspaceId, title, body, workflowStateId } =
608+
await templateService.getAppliedTemplateDescription(subTemplateId)
597609
const createTaskPayload = CreateTaskRequestSchema.parse({
598610
title: resolveDynamicFields(title),
599611
body: body ? resolveAutofillTags(body) : body,
@@ -608,8 +620,10 @@ export abstract class TasksSharedService extends BaseService {
608620
isShared,
609621
})
610622

611-
await this.createTask(createTaskPayload, { disableSubtaskTemplates: true, manualTimestamp: manualTimestamp })
623+
await this.createTask(createTaskPayload, { disableSubtaskTemplates: true, manualTimestamp })
612624
} catch (e) {
625+
// All-or-nothing: if any subtask fails to apply, roll back the parent task
626+
// rather than leaving a partially-applied template, and surface the error.
613627
const deleteTask = this.db.task.delete({ where: { id: parentId } })
614628
const deleteActivityLogs = this.db.activityLog.deleteMany({ where: { taskId: parentId } })
615629

@@ -620,7 +634,11 @@ export abstract class TasksSharedService extends BaseService {
620634
this.unsetTransaction()
621635
})
622636

623-
console.error('TasksService#createTask | Rolling back task creation', e)
637+
console.error('TasksSharedService#createSubtasksFromTemplate | Rolling back task creation', {
638+
parentId,
639+
subTemplateId,
640+
e,
641+
})
624642
throw new APIError(
625643
httpStatus.INTERNAL_SERVER_ERROR,
626644
'Failed to create subtask from template, new task was not created.',

src/constants/tasks.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,5 @@
11
export const maxSubTaskDepth = 1
2+
3+
// Bounds how many subtasks we create concurrently when applying a template, so a
4+
// template with many sub-templates can't exhaust the DB connection pool.
5+
export const subtaskTemplateBatchSize = 5

src/utils/array.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,3 +28,24 @@ export const groupBy = <T, K extends keyof T>(arr: T[], key: K): Grouped<T> => {
2828
return acc
2929
}, {} as Grouped<T>)
3030
}
31+
32+
export const chunk = <T>(arr: T[], size: number): T[][] =>
33+
Array.from({ length: Math.ceil(arr.length / size) }, (_, i) => arr.slice(i * size, i * size + size))
34+
35+
/**
36+
* Runs `handler` over `items` with bounded concurrency: at most `size` run at once,
37+
* one batch after another. Prevents unbounded fan-out from exhausting the DB connection
38+
* pool (and downstream rate limits) when the input can be large.
39+
*/
40+
export const runInBatches = async <T, R>(
41+
items: T[],
42+
size: number,
43+
handler: (item: T, index: number) => Promise<R>,
44+
): Promise<R[]> => {
45+
const indexed = items.map((item, index) => ({ item, index }))
46+
const results: R[] = []
47+
for (const batch of chunk(indexed, size)) {
48+
results.push(...(await Promise.all(batch.map(({ item, index }) => handler(item, index)))))
49+
}
50+
return results
51+
}

0 commit comments

Comments
 (0)