From a4f8571f59b02aa22620b0ff3dabcfcda8e5aaa9 Mon Sep 17 00:00:00 2001 From: Morgan <33722304+ThyMinimalDev@users.noreply.github.com> Date: Mon, 19 Jan 2026 18:37:21 +0200 Subject: [PATCH] fix: update transaction timeout for large managed event-types (#25944) * fix: update transaction timeout for large managed event-types * test: add findMany mock for batch update optimization in handleChildrenEventTypes tests Co-Authored-By: morgan@cal.com * fix: reduce batch size to 50 and use skipDuplicates for workflow linkage Co-Authored-By: morgan@cal.com * test: add comprehensive tests for batch execution and retry behavior Co-Authored-By: keith@cal.com * fix: use logger instead of console.log and fix type error Co-Authored-By: morgan@cal.com * test: update test file to mock logger instead of console.log Co-Authored-By: morgan@cal.com * fix: type issues * fixup! fix: type issues --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: keith@cal.com --- .../test/lib/handleChildrenEventTypes.test.ts | 335 +++++++++++++++++- .../lib/handleChildrenEventTypes.ts | 120 ++++--- 2 files changed, 389 insertions(+), 66 deletions(-) diff --git a/apps/web/test/lib/handleChildrenEventTypes.test.ts b/apps/web/test/lib/handleChildrenEventTypes.test.ts index 8b4200380b..0b96e82b4f 100644 --- a/apps/web/test/lib/handleChildrenEventTypes.test.ts +++ b/apps/web/test/lib/handleChildrenEventTypes.test.ts @@ -1,18 +1,24 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ import prismaMock from "@calcom/testing/lib/__mocks__/prismaMock"; - +import updateChildrenEventTypes from "@calcom/features/ee/managed-event-types/lib/handleChildrenEventTypes"; +import logger from "@calcom/lib/logger"; +import { buildEventType } from "@calcom/lib/test/builder"; +import type { EventType, Prisma, User, WorkflowsOnEventTypes } from "@calcom/prisma/client"; +import { SchedulingType } from "@calcom/prisma/enums"; import { describe, expect, it, vi } from "vitest"; -import updateChildrenEventTypes from "@calcom/features/ee/managed-event-types/lib/handleChildrenEventTypes"; -import { buildEventType } from "@calcom/lib/test/builder"; -import type { EventType, User, WorkflowsOnEventTypes } from "@calcom/prisma/client"; -import type { Prisma } from "@calcom/prisma/client"; -import { SchedulingType } from "@calcom/prisma/enums"; +// Mock the logger module +vi.mock("@calcom/lib/logger", () => ({ + default: { + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), + }, +})); // Helper to setup transaction mock that executes the callback with the prisma mock const setupTransactionMock = () => { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore prismaMock.$transaction.mockImplementation(async (callback) => { if (typeof callback === "function") { return await callback(prismaMock); @@ -98,7 +104,7 @@ describe("handleChildrenEventTypes", () => { it("Returns message 'Missing event type'", async () => { // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore + // @ts-expect-error prismaMock.eventType.findFirst.mockImplementation(() => { return new Promise((resolve) => { resolve(null); @@ -124,8 +130,6 @@ describe("handleChildrenEventTypes", () => { describe("Happy paths", () => { it("Adds new users", async () => { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore const { schedulingType, id, @@ -209,8 +213,6 @@ describe("handleChildrenEventTypes", () => { }); it("Updates old users", async () => { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore const { schedulingType, id, @@ -233,6 +235,11 @@ describe("handleChildrenEventTypes", () => { metadata: { managedEventConfig: {} }, locations: [], }); + + // Mock findMany for existing records lookup (new batch update optimization) + // @ts-expect-error - partial mock for test purposes + prismaMock.eventType.findMany.mockResolvedValue([{ userId: 4, metadata: {} }]); + const result = await updateChildrenEventTypes({ eventTypeId: 1, oldEventType: { children: [{ userId: 4 }], team: { name: "" } }, @@ -301,6 +308,11 @@ describe("handleChildrenEventTypes", () => { metadata: { managedEventConfig: {} }, locations: [], }); + + // Mock findMany for existing records lookup (new batch update optimization) + // @ts-expect-error - partial mock for test purposes + prismaMock.eventType.findMany.mockResolvedValue([{ userId: 4, metadata: {} }]); + const result = await updateChildrenEventTypes({ eventTypeId: 1, oldEventType: { children: [{ userId: 4 }, { userId: 1 }], team: { name: "" } }, @@ -324,8 +336,6 @@ describe("handleChildrenEventTypes", () => { describe("Slug conflicts", () => { it("Deletes existent event types for new users added", async () => { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore const { schedulingType, id, @@ -412,8 +422,6 @@ describe("handleChildrenEventTypes", () => { expect(result.deletedExistentEventTypes).toEqual([123]); }); it("Deletes existent event types for old users updated", async () => { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore const { schedulingType, id, @@ -437,6 +445,11 @@ describe("handleChildrenEventTypes", () => { metadata: { managedEventConfig: {} }, locations: [], }); + + // Mock findMany for existing records lookup (new batch update optimization) + // @ts-expect-error - partial mock for test purposes + prismaMock.eventType.findMany.mockResolvedValue([{ userId: 4, metadata: {} }]); + prismaMock.eventType.deleteMany.mockResolvedValue([123] as unknown as Prisma.BatchPayload); const result = await updateChildrenEventTypes({ eventTypeId: 1, @@ -535,6 +548,9 @@ describe("handleChildrenEventTypes", () => { }; prismaMock.eventType.createManyAndReturn.mockResolvedValue([createdEventType]); + // Mock findMany for existing records lookup (new batch update optimization) + prismaMock.eventType.findMany.mockResolvedValue([{ userId: 4, metadata: {} } as EventType]); + // Mock the event type that will be returned for existing users const mockUpdatedEventType = { id: 2, @@ -657,7 +673,290 @@ describe("handleChildrenEventTypes", () => { // Since findMany returned empty array, all workflow-eventType pairs should be created expect(prismaMock.workflowsOnEventTypes.createMany).toHaveBeenCalledWith({ data: [{ eventTypeId: 2, workflowId: 11 }], - skipDuplicates: false, + skipDuplicates: true, + }); + }); + }); + + describe("Batch execution and retry behavior", () => { + it("processes updates in batches of 50", async () => { + // Create 120 old users to test batching (should result in 3 batches: 50, 50, 20) + const oldUserIds = Array.from({ length: 120 }, (_, i) => i + 1); + const children = oldUserIds.map((id) => ({ + hidden: false, + owner: { id, name: `User ${id}`, email: `user${id}@test.com`, eventTypeSlugs: [] }, + })); + + mockFindFirstEventType({ + metadata: { managedEventConfig: {} }, + locations: [], + }); + + // Mock findMany for existing records lookup + // @ts-expect-error - partial mock for test purposes + prismaMock.eventType.findMany.mockResolvedValue(oldUserIds.map((userId) => ({ userId, metadata: {} }))); + + // Track update calls to verify batching + let updateCallCount = 0; + // @ts-expect-error - partial mock for test purposes + prismaMock.eventType.update.mockImplementation(async () => { + updateCallCount++; + return { + id: updateCallCount, + userId: updateCallCount, + title: "Test", + slug: "test", + length: 30, + hidden: false, + position: 0, + teamId: null, + schedulingType: SchedulingType.MANAGED, + scheduleId: null, + price: 0, + currency: "usd", + slotInterval: null, + metadata: {}, + successRedirectUrl: null, + bookingFields: [], + createdAt: new Date(), + updatedAt: new Date(), + }; + }); + + const result = await updateChildrenEventTypes({ + eventTypeId: 1, + oldEventType: { children: oldUserIds.map((userId) => ({ userId })), team: { name: "" } }, + children, + updatedEventType: { schedulingType: "MANAGED", slug: "something" }, + currentUserId: 1, + prisma: prismaMock, + profileId: null, + updatedValues: {}, + }); + + // Verify all 120 users were processed + expect(result.oldUserIds).toHaveLength(120); + expect(updateCallCount).toBe(120); + // Verify findMany was called once for bulk fetch optimization + expect(prismaMock.eventType.findMany).toHaveBeenCalledTimes(1); + }); + + it("retries failed updates once", async () => { + // Reset logger mocks before test + vi.mocked(logger.info).mockClear(); + vi.mocked(logger.error).mockClear(); + + mockFindFirstEventType({ + metadata: { managedEventConfig: {} }, + locations: [], + }); + + prismaMock.eventType.findMany.mockResolvedValue([ + { userId: 1, metadata: {} } as EventType, + { userId: 2, metadata: {} } as EventType, + { userId: 3, metadata: {} } as EventType, + ]); + + // Track calls per userId to simulate failure on first attempt, success on retry + const callCountByUser: Record = {}; + + // @ts-expect-error - partial mock for test purposes + prismaMock.eventType.update.mockImplementation(async (args) => { + const userId = (args.where as { userId_parentId: { userId: number } }).userId_parentId.userId; + callCountByUser[userId] = (callCountByUser[userId] || 0) + 1; + + // User 2 fails on first attempt, succeeds on retry + if (userId === 2 && callCountByUser[userId] === 1) { + throw new Error("Simulated database error"); + } + + return { + id: userId, + userId, + title: "Test", + slug: "test", + length: 30, + hidden: false, + position: 0, + teamId: null, + schedulingType: SchedulingType.MANAGED, + scheduleId: null, + price: 0, + currency: "usd", + slotInterval: null, + metadata: {}, + successRedirectUrl: null, + bookingFields: [], + createdAt: new Date(), + updatedAt: new Date(), + } as unknown as EventType; + }); + + const result = await updateChildrenEventTypes({ + eventTypeId: 1, + oldEventType: { children: [{ userId: 1 }, { userId: 2 }, { userId: 3 }], team: { name: "" } }, + children: [ + { hidden: false, owner: { id: 1, name: "", email: "", eventTypeSlugs: [] } }, + { hidden: false, owner: { id: 2, name: "", email: "", eventTypeSlugs: [] } }, + { hidden: false, owner: { id: 3, name: "", email: "", eventTypeSlugs: [] } }, + ], + updatedEventType: { schedulingType: "MANAGED", slug: "something" }, + currentUserId: 1, + prisma: prismaMock, + profileId: null, + updatedValues: {}, + }); + + // All 3 users should be in oldUserIds (successful after retry) + expect(result.oldUserIds).toEqual([1, 2, 3]); + // User 2 should have been called twice (initial + retry) + expect(callCountByUser[2]).toBe(2); + // Verify retry log was called + expect(logger.info).toHaveBeenCalledWith("Retrying 1 failed updates..."); + }); + + it("continues processing when some updates fail permanently", async () => { + // Reset logger mocks before test + vi.mocked(logger.info).mockClear(); + vi.mocked(logger.error).mockClear(); + + mockFindFirstEventType({ + metadata: { managedEventConfig: {} }, + locations: [], + }); + + // Mock findMany for existing records lookup + prismaMock.eventType.findMany.mockResolvedValue([ + { userId: 1, metadata: {} } as EventType, + { userId: 2, metadata: {} } as EventType, + { userId: 3, metadata: {} } as EventType, + ]); + + // @ts-expect-error - partial mock for test purposes + prismaMock.eventType.update.mockImplementation(async (args: Prisma.EventTypeUpdateArgs) => { + const userId = (args.where as { userId_parentId: { userId: number } }).userId_parentId.userId; + + // User 2 always fails (permanent failure) + if (userId === 2) { + throw new Error("Permanent database error"); + } + + return { + id: userId, + userId, + title: "Test", + slug: "test", + length: 30, + hidden: false, + position: 0, + teamId: null, + schedulingType: SchedulingType.MANAGED, + scheduleId: null, + price: 0, + currency: "usd", + slotInterval: null, + metadata: {}, + successRedirectUrl: null, + bookingFields: [], + createdAt: new Date(), + updatedAt: new Date(), + } as unknown as EventType; + }); + + const result = await updateChildrenEventTypes({ + eventTypeId: 1, + oldEventType: { children: [{ userId: 1 }, { userId: 2 }, { userId: 3 }], team: { name: "" } }, + children: [ + { hidden: false, owner: { id: 1, name: "", email: "", eventTypeSlugs: [] } }, + { hidden: false, owner: { id: 2, name: "", email: "", eventTypeSlugs: [] } }, + { hidden: false, owner: { id: 3, name: "", email: "", eventTypeSlugs: [] } }, + ], + updatedEventType: { schedulingType: "MANAGED", slug: "something" }, + currentUserId: 1, + prisma: prismaMock, + profileId: null, + updatedValues: {}, + }); + + // oldUserIds should still contain all 3 (the function returns the input, not successful ones) + expect(result.oldUserIds).toEqual([1, 2, 3]); + // Verify permanent failure was logged + expect(logger.error).toHaveBeenCalledWith( + "handleChildrenEventType - Could not update managed event-type", + { + parentId: 1, + userIds: [2], + } + ); + }); + + it("handles large scale scenarios with 100+ users efficiently", async () => { + // Create 150 old users to test large scale handling + const oldUserIds = Array.from({ length: 150 }, (_, i) => i + 1); + const children = oldUserIds.map((id) => ({ + hidden: false, + owner: { id, name: `User ${id}`, email: `user${id}@test.com`, eventTypeSlugs: [] }, + })); + + mockFindFirstEventType({ + metadata: { managedEventConfig: {} }, + locations: [], + }); + + // Mock findMany for existing records lookup - should be called ONCE (bulk optimization) + // @ts-expect-error - partial mock for test purposes + prismaMock.eventType.findMany.mockResolvedValue(oldUserIds.map((userId) => ({ userId, metadata: {} }))); + + // Track timing to ensure batching doesn't cause sequential delays + const updateResults: number[] = []; + // @ts-expect-error - partial mock for test purposes + prismaMock.eventType.update.mockImplementation(async (args) => { + const userId = (args.where as { userId_parentId: { userId: number } }).userId_parentId.userId; + updateResults.push(userId); + + return { + id: userId, + userId, + title: "Test", + slug: "test", + length: 30, + hidden: false, + position: 0, + teamId: null, + schedulingType: SchedulingType.MANAGED, + scheduleId: null, + price: 0, + currency: "usd", + slotInterval: null, + metadata: {}, + successRedirectUrl: null, + bookingFields: [], + createdAt: new Date(), + updatedAt: new Date(), + } as unknown as EventType; + }); + + const result = await updateChildrenEventTypes({ + eventTypeId: 1, + oldEventType: { children: oldUserIds.map((userId) => ({ userId })), team: { name: "" } }, + children, + updatedEventType: { schedulingType: "MANAGED", slug: "something" }, + currentUserId: 1, + prisma: prismaMock, + profileId: null, + updatedValues: {}, + }); + + // Verify all 150 users were processed + expect(result.oldUserIds).toHaveLength(150); + expect(updateResults).toHaveLength(150); + + // Verify bulk fetch optimization: findMany should be called only ONCE + // (not 150 individual findUnique calls as in the old implementation) + expect(prismaMock.eventType.findMany).toHaveBeenCalledTimes(1); + expect(prismaMock.eventType.findMany).toHaveBeenCalledWith({ + where: { parentId: 1, userId: { in: oldUserIds } }, + select: { userId: true, metadata: true }, }); }); }); diff --git a/packages/features/ee/managed-event-types/lib/handleChildrenEventTypes.ts b/packages/features/ee/managed-event-types/lib/handleChildrenEventTypes.ts index 888725e834..ac633b6b75 100644 --- a/packages/features/ee/managed-event-types/lib/handleChildrenEventTypes.ts +++ b/packages/features/ee/managed-event-types/lib/handleChildrenEventTypes.ts @@ -2,9 +2,10 @@ import type { DeepMockProxy } from "vitest-mock-extended"; import { eventTypeMetaDataSchemaWithTypedApps } from "@calcom/app-store/zod-utils"; import { sendSlugReplacementEmail } from "@calcom/emails/integration-email-service"; +import logger from "@calcom/lib/logger"; import { getTranslation } from "@calcom/lib/server/i18n"; import type { PrismaClient } from "@calcom/prisma"; -import type { Prisma } from "@calcom/prisma/client"; +import type { EventType, Prisma } from "@calcom/prisma/client"; import { SchedulingType } from "@calcom/prisma/enums"; import { allManagedEventTypeProps, @@ -273,7 +274,7 @@ export default async function handleChildrenEventTypes({ // Old users updated if (oldUserIds?.length) { - // Check if there are children with existent homonym event types to send notifications + // 1. Initial Checks and Setup deletedExistentEventTypes = await checkExistentEventTypes({ updatedEventType, children, @@ -283,50 +284,42 @@ export default async function handleChildrenEventTypes({ }); const { unlockedFields } = managedEventTypeValues.metadata?.managedEventConfig ?? {}; - const unlockedFieldProps = !unlockedFields - ? {} - : Object.keys(unlockedFields).reduce((acc, key) => { - const filteredKey = - key === "afterBufferTime" - ? "afterEventBuffer" - : key === "beforeBufferTime" - ? "beforeEventBuffer" - : key; - // @ts-expect-error Element implicitly has any type - acc[filteredKey] = true; - return acc; - }, {}); - // Add to payload all eventType values that belong to locked fields, changed or unchanged - // Ignore from payload any eventType values that belong to unlocked fields + // Transform unlocked fields into a lookup object with mapped keys + const unlockedFieldProps = Object.keys(unlockedFields ?? {}).reduce((acc, key) => { + const mappedKey = + key === "afterBufferTime" + ? "afterEventBuffer" + : key === "beforeBufferTime" + ? "beforeEventBuffer" + : key; + // @ts-expect-error Element implicitly has any type + acc[mappedKey] = true; + return acc; + }, {}); + + // Prepare payload: Omit unlocked fields and the "children" property const updatePayload = allManagedEventTypePropsZod.omit(unlockedFieldProps).parse(eventType); const updatePayloadFiltered = Object.entries(updatePayload) .filter(([key, _]) => key !== "children") .reduce((newObj, [key, value]) => ({ ...newObj, [key]: value }), {}); - // Update event types for old users - const oldEventTypes = await Promise.all( - oldUserIds.map(async (userId) => { - const existingEventType = await prisma.eventType.findUnique({ - where: { - userId_parentId: { - userId, - parentId, - }, - }, - select: { - metadata: true, - }, - }); - const metadata = eventTypeMetaDataSchemaWithTypedApps.parse(existingEventType?.metadata || {}); + // 2. Data Fetching Optimization + const existingRecords = await prisma.eventType.findMany({ + where: { parentId, userId: { in: oldUserIds } }, + select: { userId: true, metadata: true }, + }); + + const metadataMap = new Map(existingRecords.map((rec) => [rec.userId, rec.metadata])); + + // 3. Define Reusable Update Logic + const performUpdate = async (userId: number) => { + try { + const rawMetadata = metadataMap.get(userId); + const metadata = eventTypeMetaDataSchemaWithTypedApps.parse(rawMetadata || {}); return await prisma.eventType.update({ - where: { - userId_parentId: { - userId, - parentId, - }, - }, + where: { userId_parentId: { userId, parentId } }, data: { ...updatePayloadFiltered, rrHostSubsetEnabled: false, @@ -334,12 +327,7 @@ export default async function handleChildrenEventTypes({ ...("schedule" in unlockedFieldProps ? {} : { scheduleId: eventType.scheduleId || null }), restrictionScheduleId: null, useBookerTimezone: false, - hashedLink: - "multiplePrivateLinks" in unlockedFieldProps - ? undefined - : { - deleteMany: {}, - }, + hashedLink: "multiplePrivateLinks" in unlockedFieldProps ? undefined : { deleteMany: {} }, allowReschedulingCancelledBookings: managedEventTypeValues.allowReschedulingCancelledBookings ?? false, metadata: { @@ -351,10 +339,46 @@ export default async function handleChildrenEventTypes({ }, }, }); - }) - ); + } catch (error) { + throw { userId, error }; + } + }; - // Link workflows with old users' event types if new workflows were added + // 4. Batch Execution Handler + const executeBatch = async (ids: number[]) => { + const successes: EventType[] = []; + const failures: { userId: number; error: Error }[] = []; + const BATCH_SIZE = 50; + + for (let i = 0; i < ids.length; i += BATCH_SIZE) { + const batch = ids.slice(i, i + BATCH_SIZE); + const results = await Promise.allSettled(batch.map(performUpdate)); + + results.forEach((res) => { + if (res.status === "fulfilled") successes.push(res.value); + else failures.push(res.reason); + }); + } + return { successes, failures }; + }; + + // 5. Run Updates & Retries + const { successes: oldEventTypes, failures: failedUpdates } = await executeBatch(oldUserIds); + + if (failedUpdates.length > 0) { + logger.info(`Retrying ${failedUpdates.length} failed updates...`); + const retry = await executeBatch(failedUpdates.map((f) => f.userId)); + oldEventTypes.push(...retry.successes); + // Any remaining failures in retry.failures are permanent + if (retry.failures.length > 0) { + logger.error("handleChildrenEventType - Could not update managed event-type", { + parentId, + userIds: retry.failures.map((failure) => failure.userId), + }); + } + } + + // 6. Workflow Linkage if (currentWorkflowIds?.length && oldEventTypes.length) { await prisma.$transaction(async (tx) => { const eventTypeIds = oldEventTypes.map((e) => e.id); @@ -393,7 +417,7 @@ export default async function handleChildrenEventTypes({ if (newRelationshipsToCreate.length > 0) { await tx.workflowsOnEventTypes.createMany({ data: newRelationshipsToCreate, - skipDuplicates: false, + skipDuplicates: true, }); } });