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 <morgan@cal.com>

* fix: reduce batch size to 50 and use skipDuplicates for workflow linkage

Co-Authored-By: morgan@cal.com <morgan@cal.com>

* test: add comprehensive tests for batch execution and retry behavior

Co-Authored-By: keith@cal.com <keithwillcode@gmail.com>

* fix: use logger instead of console.log and fix type error

Co-Authored-By: morgan@cal.com <morgan@cal.com>

* test: update test file to mock logger instead of console.log

Co-Authored-By: morgan@cal.com <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 <keithwillcode@gmail.com>
This commit is contained in:
Morgan
2026-01-19 20:37:21 +04:00
committed by GitHub
co-authored by morgan@cal.com <morgan@cal.com> morgan@cal.com <morgan@cal.com> keith@cal.com <keithwillcode@gmail.com> morgan@cal.com <morgan@cal.com> morgan@cal.com <morgan@cal.com> Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> keith@cal.com <keithwillcode@gmail.com>
parent f319b662c5
commit a4f8571f59
2 changed files with 389 additions and 66 deletions
@@ -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<number, 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;
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 },
});
});
});
@@ -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,
});
}
});