diff --git a/apps/api/v1/test/lib/selected-calendars/_post.test.ts b/apps/api/v1/test/lib/selected-calendars/_post.test.ts index 597221baa4..ce4fde5a20 100644 --- a/apps/api/v1/test/lib/selected-calendars/_post.test.ts +++ b/apps/api/v1/test/lib/selected-calendars/_post.test.ts @@ -102,6 +102,8 @@ describe("POST /api/selected-calendars", () => { channelResourceUri: null, channelExpiration: null, syncSubscribedAt: null, + syncSubscribedErrorAt: null, + syncSubscribedErrorCount: 0, syncToken: null, syncedAt: null, syncErrorAt: null, @@ -157,6 +159,8 @@ describe("POST /api/selected-calendars", () => { channelResourceUri: null, channelExpiration: null, syncSubscribedAt: null, + syncSubscribedErrorAt: null, + syncSubscribedErrorCount: 0, syncToken: null, syncedAt: null, syncErrorAt: null, diff --git a/packages/features/calendar-subscription/adapters/__tests__/GoogleCalendarSubscriptionAdapter.test.ts b/packages/features/calendar-subscription/adapters/__tests__/GoogleCalendarSubscriptionAdapter.test.ts index 99c9b4912a..5bf687ce04 100644 --- a/packages/features/calendar-subscription/adapters/__tests__/GoogleCalendarSubscriptionAdapter.test.ts +++ b/packages/features/calendar-subscription/adapters/__tests__/GoogleCalendarSubscriptionAdapter.test.ts @@ -52,6 +52,8 @@ const mockSelectedCalendar: SelectedCalendar = { channelResourceUri: "test-resource-uri", channelExpiration: channelExpirationDate, syncSubscribedAt: today.toDate(), + syncSubscribedErrorAt: null, + syncSubscribedErrorCount: 0, syncToken: "test-sync-token", syncedAt: today.toDate(), syncErrorAt: null, diff --git a/packages/features/calendar-subscription/adapters/__tests__/Office365CalendarSubscriptionAdapter.test.ts b/packages/features/calendar-subscription/adapters/__tests__/Office365CalendarSubscriptionAdapter.test.ts index e109056a45..eec286e9be 100644 --- a/packages/features/calendar-subscription/adapters/__tests__/Office365CalendarSubscriptionAdapter.test.ts +++ b/packages/features/calendar-subscription/adapters/__tests__/Office365CalendarSubscriptionAdapter.test.ts @@ -29,6 +29,8 @@ const _mockSelectedCalendar: SelectedCalendar = { channelResourceUri: "test-resource-uri", channelExpiration: new Date(Date.now() + 86400000), syncSubscribedAt: new Date(), + syncSubscribedErrorAt: null, + syncSubscribedErrorCount: 0, syncToken: "test-sync-token", syncedAt: new Date(), syncErrorAt: null, diff --git a/packages/features/calendar-subscription/lib/CalendarSubscriptionService.ts b/packages/features/calendar-subscription/lib/CalendarSubscriptionService.ts index 99f95831e4..3c74208401 100644 --- a/packages/features/calendar-subscription/lib/CalendarSubscriptionService.ts +++ b/packages/features/calendar-subscription/lib/CalendarSubscriptionService.ts @@ -21,6 +21,7 @@ const log = logger.getSubLogger({ prefix: ["CalendarSubscriptionService"] }); export class CalendarSubscriptionService { static CALENDAR_SUBSCRIPTION_CACHE_FEATURE = "calendar-subscription-cache" as const; static CALENDAR_SUBSCRIPTION_SYNC_FEATURE = "calendar-subscription-sync" as const; + static MAX_SUBSCRIBE_ERRORS = 3; constructor( private deps: { @@ -54,19 +55,41 @@ export class CalendarSubscriptionService { return; } + const subscribeErrorCount = selectedCalendar.syncSubscribedErrorCount ?? 0; + if (subscribeErrorCount >= CalendarSubscriptionService.MAX_SUBSCRIBE_ERRORS) { + log.debug("Selected calendar exceeded subscribe error threshold", { selectedCalendarId }); + return; + } + const calendarSubscriptionAdapter = this.deps.adapterFactory.get( selectedCalendar.integration as CalendarSubscriptionProvider ); - const res = await calendarSubscriptionAdapter.subscribe(selectedCalendar, credential); - await this.deps.selectedCalendarRepository.updateSubscription(selectedCalendarId, { - channelId: res?.id, - channelResourceId: res?.resourceId, - channelResourceUri: res?.resourceUri, - channelKind: res?.provider, - channelExpiration: res?.expiration, - syncSubscribedAt: new Date(), - }); + try { + const res = await calendarSubscriptionAdapter.subscribe(selectedCalendar, credential); + await this.deps.selectedCalendarRepository.updateSubscription(selectedCalendarId, { + channelId: res?.id, + channelResourceId: res?.resourceId, + channelResourceUri: res?.resourceUri, + channelKind: res?.provider, + channelExpiration: res?.expiration, + syncSubscribedAt: new Date(), + syncSubscribedErrorAt: null, + syncSubscribedErrorCount: 0, + }); + } catch (error: unknown) { + const nextErrorCount = Math.min( + CalendarSubscriptionService.MAX_SUBSCRIBE_ERRORS, + (selectedCalendar.syncSubscribedErrorCount ?? 0) + 1 + ); + await this.deps.selectedCalendarRepository.updateSubscription(selectedCalendarId, { + // don't need to cleanup other fields as they will only be used if syncSubscribedAt is not null + syncSubscribedAt: null, + syncSubscribedErrorAt: new Date(), + syncSubscribedErrorCount: nextErrorCount, + }); + throw error; + } await this.processEvents(selectedCalendar); } diff --git a/packages/features/calendar-subscription/lib/__tests__/CalendarSubscriptionService.test.ts b/packages/features/calendar-subscription/lib/__tests__/CalendarSubscriptionService.test.ts index cd3096bc52..b8ea2d559d 100644 --- a/packages/features/calendar-subscription/lib/__tests__/CalendarSubscriptionService.test.ts +++ b/packages/features/calendar-subscription/lib/__tests__/CalendarSubscriptionService.test.ts @@ -45,6 +45,8 @@ const mockSelectedCalendar: SelectedCalendar = { channelResourceUri: "test-resource-uri", channelExpiration: new Date(Date.now() + 86400000), syncSubscribedAt: new Date(), + syncSubscribedErrorAt: null, + syncSubscribedErrorCount: 0, syncToken: "test-sync-token", syncedAt: new Date(), syncErrorAt: null, @@ -160,6 +162,8 @@ describe("CalendarSubscriptionService", () => { calendarSyncService: mockCalendarSyncService, }); + mockSelectedCalendar.syncSubscribedErrorCount = 0; + const { getCredentialForSelectedCalendar } = await import("../__mocks__/delegationCredential"); getCredentialForSelectedCalendar.mockResolvedValue(mockCredential); }); @@ -178,9 +182,36 @@ describe("CalendarSubscriptionService", () => { channelKind: "google_calendar", channelExpiration: mockSubscriptionResult.expiration, syncSubscribedAt: expect.any(Date), + syncSubscribedErrorAt: null, + syncSubscribedErrorCount: 0, }); }); + test("should record subscribe errors and retry later", async () => { + const subscribeError = new Error("subscribe failed"); + mockAdapter.subscribe.mockRejectedValue(subscribeError); + + await expect(service.subscribe("test-calendar-id")).rejects.toThrow(subscribeError); + + expect(mockSelectedCalendarRepository.updateSubscription).toHaveBeenCalledWith("test-calendar-id", { + syncSubscribedAt: null, + syncSubscribedErrorAt: expect.any(Date), + syncSubscribedErrorCount: 1, + }); + }); + + test("should skip subscription attempts after hitting error threshold", async () => { + mockSelectedCalendarRepository.findById.mockResolvedValue({ + ...mockSelectedCalendar, + syncSubscribedErrorCount: CalendarSubscriptionService.MAX_SUBSCRIBE_ERRORS, + }); + + await service.subscribe("test-calendar-id"); + + expect(mockAdapter.subscribe).not.toHaveBeenCalled(); + expect(mockSelectedCalendarRepository.updateSubscription).not.toHaveBeenCalled(); + }); + test("should return early if selected calendar not found", async () => { mockSelectedCalendarRepository.findById.mockResolvedValue(null); diff --git a/packages/features/calendar-subscription/lib/cache/__tests__/CalendarCacheEventService.test.ts b/packages/features/calendar-subscription/lib/cache/__tests__/CalendarCacheEventService.test.ts index a6727757d7..2a6f789147 100644 --- a/packages/features/calendar-subscription/lib/cache/__tests__/CalendarCacheEventService.test.ts +++ b/packages/features/calendar-subscription/lib/cache/__tests__/CalendarCacheEventService.test.ts @@ -33,6 +33,8 @@ const mockSelectedCalendar: SelectedCalendar = { channelResourceUri: "test-resource-uri", channelExpiration: new Date(Date.now() + 86400000), syncSubscribedAt: new Date(), + syncSubscribedErrorAt: null, + syncSubscribedErrorCount: 0, syncToken: "test-sync-token", syncedAt: new Date(), syncErrorAt: null, diff --git a/packages/features/calendars/lib/getCalendarsEvents.test.ts b/packages/features/calendars/lib/getCalendarsEvents.test.ts index 8bddfcdc4d..915425a001 100644 --- a/packages/features/calendars/lib/getCalendarsEvents.test.ts +++ b/packages/features/calendars/lib/getCalendarsEvents.test.ts @@ -99,6 +99,18 @@ function buildSelectedCalendar(credential: { watchAttempts: 0, unwatchAttempts: 0, maxAttempts: 3, + channelId: null, + channelKind: null, + channelResourceId: null, + channelResourceUri: null, + channelExpiration: null, + syncSubscribedAt: null, + syncSubscribedErrorAt: null, + syncSubscribedErrorCount: 0, + syncToken: null, + syncedAt: null, + syncErrorAt: null, + syncErrorCount: 0, ...credential, }; } diff --git a/packages/features/selectedCalendar/repositories/SelectedCalendarRepository.interface.ts b/packages/features/selectedCalendar/repositories/SelectedCalendarRepository.interface.ts index d4090c30d5..1449073d86 100644 --- a/packages/features/selectedCalendar/repositories/SelectedCalendarRepository.interface.ts +++ b/packages/features/selectedCalendar/repositories/SelectedCalendarRepository.interface.ts @@ -18,6 +18,8 @@ export interface ISelectedCalendarRepository { /** * Find next batch of selected calendars * Will check if syncSubscribedAt is null or channelExpiration is greater than current date + * Calendars with recent subscription errors (last 24h) are skipped + * Calendars with 3+ subscription errors are skipped entirely * * @param take the number of calendars to take * @param integrations the list of integrations @@ -31,7 +33,7 @@ export interface ISelectedCalendarRepository { }: { take: number; teamIds: number[]; - integrations?: string[]; + integrations: string[]; genericCalendarSuffixes?: string[]; }): Promise; @@ -62,6 +64,8 @@ export interface ISelectedCalendarRepository { | "channelKind" | "channelExpiration" | "syncSubscribedAt" + | "syncSubscribedErrorAt" + | "syncSubscribedErrorCount" > ): Promise; } diff --git a/packages/features/selectedCalendar/repositories/SelectedCalendarRepository.test.ts b/packages/features/selectedCalendar/repositories/SelectedCalendarRepository.test.ts index 141e4e74ce..408707b441 100644 --- a/packages/features/selectedCalendar/repositories/SelectedCalendarRepository.test.ts +++ b/packages/features/selectedCalendar/repositories/SelectedCalendarRepository.test.ts @@ -40,6 +40,8 @@ const mockSelectedCalendar: SelectedCalendar = { channelResourceUri: "test-resource-uri", channelExpiration: new Date(Date.now() + 86400000), syncSubscribedAt: new Date(), + syncSubscribedErrorAt: null, + syncSubscribedErrorCount: 0, syncToken: "test-sync-token", syncedAt: new Date(), syncErrorAt: null, @@ -122,7 +124,6 @@ describe("SelectedCalendarRepository", () => { expect(mockPrismaClient.selectedCalendar.findMany).toHaveBeenCalledWith({ where: { integration: { in: ["google_calendar", "office365_calendar"] }, - OR: [{ syncSubscribedAt: null }, { channelExpiration: { lte: expect.any(Date) } }], user: { teams: { some: { @@ -131,7 +132,24 @@ describe("SelectedCalendarRepository", () => { }, }, }, - AND: undefined, + AND: [ + { + OR: [ + { syncSubscribedAt: null }, + { channelExpiration: null }, + { channelExpiration: { lte: expect.any(Date) } }, + ], + }, + { + OR: [ + { syncSubscribedErrorAt: null }, + { syncSubscribedErrorAt: { lt: expect.any(Date) } }, + ], + }, + { + syncSubscribedErrorCount: { lt: 3 }, + }, + ], }, take: 10, }); @@ -152,7 +170,6 @@ describe("SelectedCalendarRepository", () => { expect(mockPrismaClient.selectedCalendar.findMany).toHaveBeenCalledWith({ where: { integration: { in: [] }, - OR: [{ syncSubscribedAt: null }, { channelExpiration: { lte: expect.any(Date) } }], user: { teams: { some: { @@ -161,7 +178,24 @@ describe("SelectedCalendarRepository", () => { }, }, }, - AND: undefined, + AND: [ + { + OR: [ + { syncSubscribedAt: null }, + { channelExpiration: null }, + { channelExpiration: { lte: expect.any(Date) } }, + ], + }, + { + OR: [ + { syncSubscribedErrorAt: null }, + { syncSubscribedErrorAt: { lt: expect.any(Date) } }, + ], + }, + { + syncSubscribedErrorCount: { lt: 3 }, + }, + ], }, take: 5, }); @@ -182,7 +216,6 @@ describe("SelectedCalendarRepository", () => { expect(mockPrismaClient.selectedCalendar.findMany).toHaveBeenCalledWith({ where: { integration: { in: ["google_calendar"] }, - OR: [{ syncSubscribedAt: null }, { channelExpiration: { lte: expect.any(Date) } }], user: { teams: { some: { @@ -191,7 +224,24 @@ describe("SelectedCalendarRepository", () => { }, }, }, - AND: undefined, + AND: [ + { + OR: [ + { syncSubscribedAt: null }, + { channelExpiration: null }, + { channelExpiration: { lte: expect.any(Date) } }, + ], + }, + { + OR: [ + { syncSubscribedErrorAt: null }, + { syncSubscribedErrorAt: { lt: expect.any(Date) } }, + ], + }, + { + syncSubscribedErrorCount: { lt: 3 }, + }, + ], }, take: 10, }); @@ -215,7 +265,6 @@ describe("SelectedCalendarRepository", () => { expect(mockPrismaClient.selectedCalendar.findMany).toHaveBeenCalledWith({ where: { integration: { in: ["google_calendar"] }, - OR: [{ syncSubscribedAt: null }, { channelExpiration: { lte: expect.any(Date) } }], user: { teams: { some: { @@ -225,6 +274,22 @@ describe("SelectedCalendarRepository", () => { }, }, AND: [ + { + OR: [ + { syncSubscribedAt: null }, + { channelExpiration: null }, + { channelExpiration: { lte: expect.any(Date) } }, + ], + }, + { + OR: [ + { syncSubscribedErrorAt: null }, + { syncSubscribedErrorAt: { lt: expect.any(Date) } }, + ], + }, + { + syncSubscribedErrorCount: { lt: 3 }, + }, { NOT: { externalId: { endsWith: "@group.v.calendar.google.com" } } }, { NOT: { externalId: { endsWith: "@group.calendar.google.com" } } }, ], diff --git a/packages/features/selectedCalendar/repositories/SelectedCalendarRepository.ts b/packages/features/selectedCalendar/repositories/SelectedCalendarRepository.ts index e4f50fd899..fd5d2e92fa 100644 --- a/packages/features/selectedCalendar/repositories/SelectedCalendarRepository.ts +++ b/packages/features/selectedCalendar/repositories/SelectedCalendarRepository.ts @@ -2,6 +2,8 @@ import type { ISelectedCalendarRepository } from "@calcom/features/selectedCalen import type { PrismaClient } from "@calcom/prisma"; import type { Prisma } from "@calcom/prisma/client"; +const MAX_SUBSCRIBE_ERRORS = 3; + export class SelectedCalendarRepository implements ISelectedCalendarRepository { constructor(private prismaClient: PrismaClient) {} @@ -26,10 +28,40 @@ export class SelectedCalendarRepository implements ISelectedCalendarRepository { integrations: string[]; genericCalendarSuffixes?: string[]; }) { + const now = new Date(); + const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000); + + const needsSubscriptionFilter: Prisma.SelectedCalendarWhereInput = { + OR: [ + { syncSubscribedAt: null }, + { channelExpiration: null }, + { channelExpiration: { lte: now } }, + ], + }; + + const retryableWindowFilter: Prisma.SelectedCalendarWhereInput = { + OR: [{ syncSubscribedErrorAt: null }, { syncSubscribedErrorAt: { lt: oneDayAgo } }], + }; + + const retryableErrorCountFilter: Prisma.SelectedCalendarWhereInput = { + syncSubscribedErrorCount: { lt: MAX_SUBSCRIBE_ERRORS }, + }; + + const suffixFilters = + genericCalendarSuffixes?.map((suffix) => ({ + NOT: { externalId: { endsWith: suffix } }, + })) ?? []; + + const andFilters = [ + needsSubscriptionFilter, + retryableWindowFilter, + retryableErrorCountFilter, + ...suffixFilters, + ]; + return this.prismaClient.selectedCalendar.findMany({ where: { integration: { in: integrations }, - OR: [{ syncSubscribedAt: null }, { channelExpiration: { lte: new Date() } }], user: { teams: { some: { @@ -38,9 +70,7 @@ export class SelectedCalendarRepository implements ISelectedCalendarRepository { }, }, }, - AND: genericCalendarSuffixes?.map((suffix) => ({ - NOT: { externalId: { endsWith: suffix } }, - })), + AND: andFilters.length ? andFilters : undefined, }, take, }); @@ -69,6 +99,8 @@ export class SelectedCalendarRepository implements ISelectedCalendarRepository { | "channelKind" | "channelExpiration" | "syncSubscribedAt" + | "syncSubscribedErrorAt" + | "syncSubscribedErrorCount" > ) { return this.prismaClient.selectedCalendar.update({ diff --git a/packages/prisma/migrations/20260119184420_add_calendar_subscription_error/migration.sql b/packages/prisma/migrations/20260119184420_add_calendar_subscription_error/migration.sql new file mode 100644 index 0000000000..eb81b06162 --- /dev/null +++ b/packages/prisma/migrations/20260119184420_add_calendar_subscription_error/migration.sql @@ -0,0 +1,4 @@ +-- AlterTable +ALTER TABLE "public"."SelectedCalendar" + ADD COLUMN "syncSubscribedErrorAt" TIMESTAMP(3), + ADD COLUMN "syncSubscribedErrorCount" INTEGER NOT NULL DEFAULT 0; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 72eb7d8156..d85e2eab70 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -991,11 +991,13 @@ model SelectedCalendar { channelExpiration DateTime? @db.Timestamp(3) // Used to calendar cache and sync - syncSubscribedAt DateTime? @db.Timestamp(3) - syncToken String? - syncedAt DateTime? - syncErrorAt DateTime? - syncErrorCount Int? @default(0) + syncSubscribedAt DateTime? @db.Timestamp(3) + syncSubscribedErrorAt DateTime? @db.Timestamp(3) + syncSubscribedErrorCount Int @default(0) + syncToken String? + syncedAt DateTime? + syncErrorAt DateTime? + syncErrorCount Int? @default(0) delegationCredential DelegationCredential? @relation(fields: [delegationCredentialId], references: [id], onDelete: Cascade) delegationCredentialId String?