Add calendar subscription error better management (#27019)
This commit is contained in:
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,
|
||||
|
||||
+2
@@ -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,
|
||||
|
||||
+2
@@ -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);
|
||||
}
|
||||
|
||||
+31
@@ -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);
|
||||
|
||||
|
||||
Vendored
+2
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
+5
-1
@@ -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({
|
||||
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."SelectedCalendar"
|
||||
ADD COLUMN "syncSubscribedErrorAt" TIMESTAMP(3),
|
||||
ADD COLUMN "syncSubscribedErrorCount" INTEGER NOT NULL DEFAULT 0;
|
||||
@@ -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?
|
||||
|
||||
Reference in new issue
Block a user