Add calendar subscription error better management (#27019)

This commit is contained in:
Volnei Munhoz authored and GitHub committed 2026-01-20 18:47:03 +02:00
1 parent 6c3b10672c
commit 5ddb4dce3f
12 files changed
+209 -26

No files matched your search

@@ -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,
@@ -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,
@@ -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,
@@ -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);
}
@@ -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);
@@ -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,
@@ -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,
};
}
@@ -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<SelectedCalendar[]>;
@@ -62,6 +64,8 @@ export interface ISelectedCalendarRepository {
| "channelKind"
| "channelExpiration"
| "syncSubscribedAt"
| "syncSubscribedErrorAt"
| "syncSubscribedErrorCount"
>
): Promise<SelectedCalendar>;
}
@@ -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" } } },
],
@@ -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<Prisma.SelectedCalendarWhereInput>((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({
@@ -0,0 +1,4 @@
-- AlterTable
ALTER TABLE "public"."SelectedCalendar"
ADD COLUMN "syncSubscribedErrorAt" TIMESTAMP(3),
ADD COLUMN "syncSubscribedErrorCount" INTEGER NOT NULL DEFAULT 0;
+7 -5
View File
@@ -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?