Skip to content

Commit 3a8e40d

Browse files
devin-ai-integration[bot]hariom@cal.comhariombalharazomars
authored
feat(calendar): add error tracking with attempts to SelectedCalendar (calcom#21326)
Co-authored-by: hariom@cal.com <hariom@cal.com> Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: Hariom Balhara <hariombalhara@gmail.com> Co-authored-by: Omar López <zomars@me.com>
1 parent bca4622 commit 3a8e40d

7 files changed

Lines changed: 146 additions & 32 deletions

File tree

apps/api/v1/lib/validations/selected-calendar.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,11 @@ export const schemaSelectedCalendarBodyParams = schemaSelectedCalendarBaseBodyPa
1818
id: true,
1919
// No eventTypeId support in API v1
2020
eventTypeId: true,
21+
22+
/** No watch related fields support in API v1 */
23+
watchAttempts: true,
24+
unwatchAttempts: true,
25+
maxAttempts: true,
2126
});
2227

2328
export const schemaSelectedCalendarUpdateBodyParams = schemaSelectedCalendarBaseBodyParams

apps/api/v1/test/lib/selected-calendars/_post.test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,10 @@ describe("POST /api/selected-calendars", () => {
8989
googleChannelResourceUri: null,
9090
googleChannelExpiration: null,
9191
error: null,
92+
lastErrorAt: null,
93+
watchAttempts: 0,
94+
maxAttempts: 3,
95+
unwatchAttempts: 0,
9296
});
9397

9498
await handler(req, res);
@@ -128,6 +132,10 @@ describe("POST /api/selected-calendars", () => {
128132
domainWideDelegationCredentialId: null,
129133
eventTypeId: null,
130134
error: null,
135+
lastErrorAt: null,
136+
watchAttempts: 0,
137+
maxAttempts: 3,
138+
unwatchAttempts: 0,
131139
});
132140

133141
await handler(req, res);

packages/app-store/googlecalendar/lib/CalendarService.ts

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -974,15 +974,13 @@ export default class GoogleCalendarService implements Calendar {
974974
expiration: otherCalendarsWithSameSubscription[0].googleChannelExpiration,
975975
}
976976
: {};
977-
let error: string | null = null;
978977

979978
if (!otherCalendarsWithSameSubscription.length) {
980979
try {
981980
googleChannelProps = await this.startWatchingCalendarsInGoogle({ calendarId });
982-
} catch (_error) {
983-
this.log.error(`Failed to watch calendar ${calendarId}`, _error);
984-
// We set error to prevent attempting to watch on next cron run
985-
error = _error instanceof Error ? _error.message : "Unknown error";
981+
} catch (error) {
982+
this.log.error(`Failed to watch calendar ${calendarId}`, safeStringify(error));
983+
throw error;
986984
}
987985
} else {
988986
logger.info(
@@ -1000,7 +998,6 @@ export default class GoogleCalendarService implements Calendar {
1000998
googleChannelResourceId: googleChannelProps.resourceId,
1001999
googleChannelResourceUri: googleChannelProps.resourceUri,
10021000
googleChannelExpiration: googleChannelProps.expiration,
1003-
error,
10041001
},
10051002
eventTypeIds
10061003
);

packages/features/calendar-cache/api/cron.ts

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,8 @@ const handleCalendarsToUnwatch = async () => {
6868
// So we don't retry on next cron run
6969

7070
// FIXME: There could actually be multiple calendars with the same externalId and thus we need to technically update error for all of them
71-
await SelectedCalendarRepository.updateById(id, {
71+
await SelectedCalendarRepository.setErrorInUnwatching({
72+
id,
7273
error: "Missing credentialId",
7374
});
7475
log.error("no credentialId for SelectedCalendar: ", id);
@@ -78,26 +79,30 @@ const handleCalendarsToUnwatch = async () => {
7879
try {
7980
const cc = await CalendarCache.initFromCredentialId(credentialId);
8081
await cc.unwatchCalendar({ calendarId: externalId, eventTypeIds });
82+
await SelectedCalendarRepository.removeUnwatchingError({ id });
8183
} catch (error) {
8284
let errorMessage = "Unknown error";
8385
if (error instanceof Error) {
8486
errorMessage = error.message;
8587
}
8688
log.error(
87-
"Error unwatching calendar: ",
89+
`Error unwatching calendar ${externalId}`,
8890
safeStringify({
8991
selectedCalendarId: id,
9092
error: errorMessage,
9193
})
9294
);
93-
await SelectedCalendarRepository.updateById(id, {
94-
error: `Error unwatching calendar: ${errorMessage}`,
95+
await SelectedCalendarRepository.setErrorInUnwatching({
96+
id,
97+
error: `${errorMessage}`,
9598
});
9699
}
97100
}
98101
)
99102
);
100103

104+
log.info(`Processed ${result.length} calendars for unwatching`);
105+
101106
result.forEach(logRejected);
102107
return result;
103108
};
@@ -110,35 +115,36 @@ const handleCalendarsToWatch = async () => {
110115
async ([externalId, { credentialId, eventTypeIds, id }]) => {
111116
if (!credentialId) {
112117
// So we don't retry on next cron run
113-
await SelectedCalendarRepository.updateById(id, {
114-
error: "Missing credentialId",
115-
});
118+
await SelectedCalendarRepository.setErrorInWatching({ id, error: "Missing credentialId" });
116119
log.error("no credentialId for SelectedCalendar: ", id);
117120
return;
118121
}
119122

120123
try {
121124
const cc = await CalendarCache.initFromCredentialId(credentialId);
122125
await cc.watchCalendar({ calendarId: externalId, eventTypeIds });
126+
await SelectedCalendarRepository.removeWatchingError({ id });
123127
} catch (error) {
124128
let errorMessage = "Unknown error";
125129
if (error instanceof Error) {
126130
errorMessage = error.message;
127131
}
128132
log.error(
129-
"Error watching calendar: ",
133+
`Error watching calendar ${externalId}`,
130134
safeStringify({
131135
selectedCalendarId: id,
132136
error: errorMessage,
133137
})
134138
);
135-
await SelectedCalendarRepository.updateById(id, {
136-
error: `Error watching calendar: ${errorMessage}`,
139+
await SelectedCalendarRepository.setErrorInWatching({
140+
id,
141+
error: `${errorMessage}`,
137142
});
138143
}
139144
}
140145
)
141146
);
147+
log.info(`Processed ${result.length} calendars for watching`);
142148
result.forEach(logRejected);
143149
return result;
144150
};

packages/lib/server/repository/selectedCalendar.ts

Lines changed: 93 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -167,13 +167,35 @@ export class SelectedCalendarRepository {
167167
},
168168
// RN we only support google calendar subscriptions for now
169169
integration: "google_calendar",
170-
// We skip retrying calendars that have errored
171-
error: null,
172-
OR: [
173-
// Either is a calendar pending to be watched
174-
{ googleChannelExpiration: null },
175-
// Or is a calendar that is about to expire
176-
{ googleChannelExpiration: { lt: tomorrowTimestamp } },
170+
AND: [
171+
{
172+
OR: [
173+
// Either is a calendar that has not errored
174+
{ error: null },
175+
// Or is a calendar that has errored but has not reached max attempts
176+
{
177+
error: { not: null },
178+
watchAttempts: {
179+
lt: {
180+
// Using ts-ignore instead of ts-expect-error because I am seeing conflicting errors in CI. In one case ts-expect-error fails with `Unused '@ts-expect-error' directive.`
181+
// Removing ts-expect-error fails in another case that _ref isn't defined
182+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
183+
// @ts-ignore
184+
_ref: "maxAttempts",
185+
_container: "SelectedCalendar",
186+
},
187+
},
188+
},
189+
],
190+
},
191+
{
192+
OR: [
193+
// Either is a calendar pending to be watched
194+
{ googleChannelExpiration: null },
195+
// Or is a calendar that is about to expire
196+
{ googleChannelExpiration: { lt: tomorrowTimestamp } },
197+
],
198+
},
177199
],
178200
},
179201
});
@@ -188,19 +210,43 @@ export class SelectedCalendarRepository {
188210
// RN we only support google calendar subscriptions for now
189211
integration: "google_calendar",
190212
googleChannelExpiration: { not: null },
191-
user: {
192-
teams: {
193-
every: {
194-
team: {
195-
features: {
196-
none: {
197-
featureId: "calendar-cache",
213+
AND: [
214+
{
215+
OR: [
216+
// Either is a calendar that has not errored during unwatch
217+
{ error: null },
218+
// Or is a calendar that has errored during unwatch but has not reached max attempts
219+
{
220+
error: { not: null },
221+
unwatchAttempts: {
222+
lt: {
223+
// Using ts-ignore instead of ts-expect-error because I am seeing conflicting errors in CI. In one case ts-expect-error fails with `Unused '@ts-expect-error' directive.`
224+
// Removing ts-expect-error fails in another case that _ref isn't defined
225+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
226+
// @ts-ignore
227+
_ref: "maxAttempts",
228+
_container: "SelectedCalendar",
229+
},
230+
},
231+
},
232+
],
233+
},
234+
{
235+
user: {
236+
teams: {
237+
every: {
238+
team: {
239+
features: {
240+
none: {
241+
featureId: "calendar-cache",
242+
},
243+
},
198244
},
199245
},
200246
},
201247
},
202248
},
203-
},
249+
],
204250
};
205251
// If calendar cache is disabled globally, we skip team features and unwatch all subscriptions
206252
const nextBatch = await prisma.selectedCalendar.findMany({
@@ -351,4 +397,36 @@ export class SelectedCalendarRepository {
351397
data,
352398
});
353399
}
400+
401+
static async setErrorInWatching({ id, error }: { id: string; error: string }) {
402+
await SelectedCalendarRepository.updateById(id, {
403+
error,
404+
lastErrorAt: new Date(),
405+
watchAttempts: { increment: 1 },
406+
});
407+
}
408+
409+
static async setErrorInUnwatching({ id, error }: { id: string; error: string }) {
410+
await SelectedCalendarRepository.updateById(id, {
411+
error,
412+
lastErrorAt: new Date(),
413+
unwatchAttempts: { increment: 1 },
414+
});
415+
}
416+
417+
static async removeWatchingError({ id }: { id: string }) {
418+
await SelectedCalendarRepository.updateById(id, {
419+
error: null,
420+
lastErrorAt: null,
421+
watchAttempts: 0,
422+
});
423+
}
424+
425+
static async removeUnwatchingError({ id }: { id: string }) {
426+
await SelectedCalendarRepository.updateById(id, {
427+
error: null,
428+
lastErrorAt: null,
429+
unwatchAttempts: 0,
430+
});
431+
}
354432
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
-- DropIndex
2+
DROP INDEX "SelectedCalendar_integration_idx";
3+
4+
-- AlterTable
5+
ALTER TABLE "SelectedCalendar" ADD COLUMN "lastErrorAt" TIMESTAMP(3),
6+
ADD COLUMN "maxAttempts" INTEGER NOT NULL DEFAULT 3,
7+
ADD COLUMN "unwatchAttempts" INTEGER NOT NULL DEFAULT 0,
8+
ADD COLUMN "watchAttempts" INTEGER NOT NULL DEFAULT 0;
9+
10+
-- CreateIndex
11+
CREATE INDEX "SelectedCalendar_watch_idx" ON "SelectedCalendar"("integration", "googleChannelExpiration", "error", "watchAttempts", "maxAttempts");
12+
13+
-- CreateIndex
14+
CREATE INDEX "SelectedCalendar_unwatch_idx" ON "SelectedCalendar"("integration", "googleChannelExpiration", "error", "unwatchAttempts", "maxAttempts");

packages/prisma/schema.prisma

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -831,6 +831,10 @@ model SelectedCalendar {
831831
domainWideDelegationCredential DomainWideDelegation? @relation(fields: [domainWideDelegationCredentialId], references: [id], onDelete: Cascade)
832832
domainWideDelegationCredentialId String?
833833
error String?
834+
lastErrorAt DateTime?
835+
watchAttempts Int @default(0)
836+
unwatchAttempts Int @default(0)
837+
maxAttempts Int @default(3)
834838
835839
eventTypeId Int?
836840
eventType EventType? @relation(fields: [eventTypeId], references: [id])
@@ -841,10 +845,12 @@ model SelectedCalendar {
841845
@@unique([userId, integration, externalId, eventTypeId])
842846
@@unique([googleChannelId, eventTypeId])
843847
@@index([userId])
844-
@@index([integration])
845848
@@index([externalId])
846849
@@index([eventTypeId])
847850
@@index([credentialId])
851+
// Composite indices to optimize calendar-cache queries
852+
@@index([integration, googleChannelExpiration, error, watchAttempts, maxAttempts], name: "SelectedCalendar_watch_idx")
853+
@@index([integration, googleChannelExpiration, error, unwatchAttempts, maxAttempts], name: "SelectedCalendar_unwatch_idx")
848854
}
849855

850856
enum EventTypeCustomInputType {

0 commit comments

Comments
 (0)