feat(server): improve calendar sync queue (#14783)

This commit is contained in:
DarkSky
2026-04-05 11:02:48 +08:00
parent b98ab495bb
commit 46e7e35357
17 changed files with 354 additions and 300 deletions
+18 -2
View File
@@ -62,6 +62,18 @@
"concurrency": 10 "concurrency": 10
} }
}, },
"queues.calendar": {
"type": "object",
"description": "The config for calendar job queue\n@default {\"concurrency\":4}",
"properties": {
"concurrency": {
"type": "number"
}
},
"default": {
"concurrency": 4
}
},
"queues.doc": { "queues.doc": {
"type": "object", "type": "object",
"description": "The config for doc job queue\n@default {\"concurrency\":1}", "description": "The config for doc job queue\n@default {\"concurrency\":1}",
@@ -843,7 +855,7 @@
"properties": { "properties": {
"google": { "google": {
"type": "object", "type": "object",
"description": "Google Calendar integration config\n@default {\"enabled\":false,\"clientId\":\"\",\"clientSecret\":\"\",\"externalWebhookUrl\":\"\",\"webhookVerificationToken\":\"\"}\n@link https://developers.google.com/calendar/api/guides/push", "description": "Google Calendar integration config\n@default {\"enabled\":false,\"clientId\":\"\",\"clientSecret\":\"\",\"externalWebhookUrl\":\"\",\"webhookVerificationToken\":\"\",\"requestTimeoutMs\":10000}\n@link https://developers.google.com/calendar/api/guides/push",
"properties": { "properties": {
"enabled": { "enabled": {
"type": "boolean" "type": "boolean"
@@ -859,6 +871,9 @@
}, },
"webhookVerificationToken": { "webhookVerificationToken": {
"type": "string" "type": "string"
},
"requestTimeoutMs": {
"type": "number"
} }
}, },
"default": { "default": {
@@ -866,7 +881,8 @@
"clientId": "", "clientId": "",
"clientSecret": "", "clientSecret": "",
"externalWebhookUrl": "", "externalWebhookUrl": "",
"webhookVerificationToken": "" "webhookVerificationToken": "",
"requestTimeoutMs": 10000
} }
}, },
"caldav": { "caldav": {
@@ -0,0 +1,27 @@
-- AlterTable
ALTER TABLE
"calendar_subscriptions"
ADD
COLUMN "next_sync_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
ADD
COLUMN "sync_retry_count" INTEGER NOT NULL DEFAULT 0;
UPDATE
"calendar_subscriptions" AS s
SET
"next_sync_at" = CASE
WHEN s."last_sync_at" IS NULL THEN CURRENT_TIMESTAMP
ELSE s."last_sync_at" + make_interval(
mins => COALESCE(a."refresh_interval_minutes", 30)
)
END
FROM
"calendar_accounts" AS a
WHERE
a."id" = s."account_id";
-- CreateIndex
CREATE INDEX "calendar_subscriptions_custom_channel_id_idx" ON "calendar_subscriptions"("custom_channel_id");
-- CreateIndex
CREATE INDEX "calendar_subscriptions_enabled_next_sync_at_idx" ON "calendar_subscriptions"("enabled", "next_sync_at");
+4
View File
@@ -1037,6 +1037,8 @@ model CalendarSubscription {
enabled Boolean @default(true) enabled Boolean @default(true)
syncToken String? @map("sync_token") @db.Text syncToken String? @map("sync_token") @db.Text
lastSyncAt DateTime? @map("last_sync_at") @db.Timestamptz(3) lastSyncAt DateTime? @map("last_sync_at") @db.Timestamptz(3)
nextSyncAt DateTime @default(now()) @map("next_sync_at") @db.Timestamptz(3)
syncRetryCount Int @default(0) @map("sync_retry_count")
customChannelId String? @map("custom_channel_id") @db.VarChar customChannelId String? @map("custom_channel_id") @db.VarChar
customResourceId String? @map("custom_resource_id") @db.VarChar customResourceId String? @map("custom_resource_id") @db.VarChar
channelExpiration DateTime? @map("channel_expiration") @db.Timestamptz(3) channelExpiration DateTime? @map("channel_expiration") @db.Timestamptz(3)
@@ -1050,6 +1052,8 @@ model CalendarSubscription {
@@unique([accountId, externalCalendarId]) @@unique([accountId, externalCalendarId])
@@index([accountId]) @@index([accountId])
@@index([provider, externalCalendarId]) @@index([provider, externalCalendarId])
@@index([customChannelId])
@@index([enabled, nextSyncAt])
@@map("calendar_subscriptions") @@map("calendar_subscriptions")
} }
@@ -55,6 +55,14 @@ defineModuleConfig('job', {
schema, schema,
}, },
'queues.calendar': {
desc: 'The config for calendar job queue',
default: {
concurrency: 4,
},
schema,
},
'queues.doc': { 'queues.doc': {
desc: 'The config for doc job queue', desc: 'The config for doc job queue',
default: { default: {
@@ -28,6 +28,7 @@ export enum Queue {
DOC = 'doc', DOC = 'doc',
COPILOT = 'copilot', COPILOT = 'copilot',
INDEXER = 'indexer', INDEXER = 'indexer',
CALENDAR = 'calendar',
} }
export const QUEUES = Object.values(Queue); export const QUEUES = Object.values(Queue);
@@ -92,7 +92,7 @@ export class CalendarAccountModel extends BaseModel {
scope: input.scope ?? null, scope: input.scope ?? null,
status: input.status ?? 'active', status: input.status ?? 'active',
lastError: input.lastError ?? null, lastError: input.lastError ?? null,
refreshIntervalMinutes: input.refreshIntervalMinutes ?? 60, refreshIntervalMinutes: input.refreshIntervalMinutes ?? 30,
}; };
const updateData: Prisma.CalendarAccountUncheckedUpdateInput = { const updateData: Prisma.CalendarAccountUncheckedUpdateInput = {
@@ -17,6 +17,8 @@ export interface UpsertCalendarSubscriptionInput {
export interface UpdateCalendarSubscriptionSyncInput { export interface UpdateCalendarSubscriptionSyncInput {
syncToken?: string | null; syncToken?: string | null;
lastSyncAt?: Date | null; lastSyncAt?: Date | null;
nextSyncAt?: Date;
syncRetryCount?: number;
} }
export interface UpdateCalendarSubscriptionChannelInput { export interface UpdateCalendarSubscriptionChannelInput {
@@ -81,13 +83,21 @@ export class CalendarSubscriptionModel extends BaseModel {
} }
async updateSync(id: string, input: UpdateCalendarSubscriptionSyncInput) { async updateSync(id: string, input: UpdateCalendarSubscriptionSyncInput) {
return await this.db.calendarSubscription.update({ const data: Prisma.CalendarSubscriptionUncheckedUpdateInput = {};
where: { id }, if (input.syncToken !== undefined) {
data: { data.syncToken = input.syncToken ?? null;
syncToken: input.syncToken ?? null, }
lastSyncAt: input.lastSyncAt ?? null, if (input.lastSyncAt !== undefined) {
}, data.lastSyncAt = input.lastSyncAt ?? null;
}); }
if (input.nextSyncAt !== undefined) {
data.nextSyncAt = input.nextSyncAt;
}
if (input.syncRetryCount !== undefined) {
data.syncRetryCount = input.syncRetryCount;
}
return await this.db.calendarSubscription.update({ where: { id }, data });
} }
async updateChannel( async updateChannel(
@@ -155,10 +165,16 @@ export class CalendarSubscriptionModel extends BaseModel {
}); });
} }
async listAllWithAccountForSync() { async listDueForSync(now: Date, limit: number) {
return await this.db.calendarSubscription.findMany({ return await this.db.calendarSubscription.findMany({
where: { enabled: true }, where: {
include: { account: true }, enabled: true,
nextSyncAt: { lte: now },
account: { status: 'active' },
},
select: { id: true },
orderBy: { nextSyncAt: 'asc' },
take: limit,
}); });
} }
@@ -169,13 +185,6 @@ export class CalendarSubscriptionModel extends BaseModel {
}); });
} }
async updateLastSyncAt(id: string, lastSyncAt: Date) {
return await this.db.calendarSubscription.update({
where: { id },
data: { lastSyncAt },
});
}
async clearSyncTokensByAccount(accountId: string) { async clearSyncTokensByAccount(accountId: string) {
return await this.db.calendarSubscription.updateMany({ return await this.db.calendarSubscription.updateMany({
where: { accountId }, where: { accountId },
@@ -200,6 +209,7 @@ export class CalendarSubscriptionModel extends BaseModel {
data: { data: {
enabled: false, enabled: false,
syncToken: null, syncToken: null,
syncRetryCount: 0,
customChannelId: null, customChannelId: null,
customResourceId: null, customResourceId: null,
channelExpiration: null, channelExpiration: null,
@@ -5,11 +5,7 @@ import test from 'ava';
import { createModule } from '../../../__tests__/create-module'; import { createModule } from '../../../__tests__/create-module';
import { Mockers } from '../../../__tests__/mocks'; import { Mockers } from '../../../__tests__/mocks';
import { import { CalendarProviderRequestError, CryptoHelper } from '../../../base';
CalendarProviderRequestError,
CryptoHelper,
Mutex,
} from '../../../base';
import { ConfigModule } from '../../../base/config'; import { ConfigModule } from '../../../base/config';
import { ServerConfigModule } from '../../../core/config'; import { ServerConfigModule } from '../../../core/config';
import type { import type {
@@ -93,7 +89,6 @@ const calendarService = module.get(CalendarService);
const calendarCronJobs = module.get(CalendarCronJobs); const calendarCronJobs = module.get(CalendarCronJobs);
const providerFactory = module.get(CalendarProviderFactory); const providerFactory = module.get(CalendarProviderFactory);
const models = module.get(Models); const models = module.get(Models);
const mutex = module.get(Mutex);
module.get(CryptoHelper).onConfigInit(); module.get(CryptoHelper).onConfigInit();
const createAccount = async ( const createAccount = async (
@@ -120,6 +115,8 @@ const createSubscription = async (
accountId: string, accountId: string,
overrides: Partial<UpsertCalendarSubscriptionInput> & { overrides: Partial<UpsertCalendarSubscriptionInput> & {
syncToken?: string | null; syncToken?: string | null;
nextSyncAt?: Date;
syncRetryCount?: number;
customChannelId?: string | null; customChannelId?: string | null;
customResourceId?: string | null; customResourceId?: string | null;
channelExpiration?: Date | null; channelExpiration?: Date | null;
@@ -141,6 +138,20 @@ const createSubscription = async (
}); });
} }
if (
overrides.nextSyncAt !== undefined ||
overrides.syncRetryCount !== undefined
) {
await models.calendarSubscription.updateSync(subscription.id, {
...(overrides.nextSyncAt !== undefined
? { nextSyncAt: overrides.nextSyncAt }
: {}),
...(overrides.syncRetryCount !== undefined
? { syncRetryCount: overrides.syncRetryCount }
: {}),
});
}
if ( if (
overrides.customChannelId !== undefined || overrides.customChannelId !== undefined ||
overrides.customResourceId !== undefined || overrides.customResourceId !== undefined ||
@@ -158,6 +169,8 @@ const createSubscription = async (
test.afterEach.always(() => { test.afterEach.always(() => {
mock.reset(); mock.reset();
module.queue.add.resetHistory();
module.queue.remove.resetHistory();
}); });
test.after.always(async () => { test.after.always(async () => {
@@ -259,6 +272,9 @@ test('syncSubscription resets invalid sync token and maps events', async t => {
const updated = await models.calendarSubscription.get(subscription.id); const updated = await models.calendarSubscription.get(subscription.id);
t.is(updated?.syncToken, 'next-token'); t.is(updated?.syncToken, 'next-token');
t.truthy(updated?.lastSyncAt); t.truthy(updated?.lastSyncAt);
t.is(updated?.syncRetryCount, 0);
t.truthy(updated?.nextSyncAt);
t.true(updated!.nextSyncAt.getTime() > updated!.lastSyncAt!.getTime());
const events = await models.calendarEvent.listBySubscriptionsInRange( const events = await models.calendarEvent.listBySubscriptionsInRange(
[subscription.id], [subscription.id],
@@ -500,51 +516,22 @@ test('syncSubscription applies exponential backoff for repeated failures', async
mock.method(Date, 'now', () => now); mock.method(Date, 'now', () => now);
await calendarService.syncSubscription(subscription.id); await calendarService.syncSubscription(subscription.id);
await calendarService.syncSubscription(subscription.id); let updated = await models.calendarSubscription.get(subscription.id);
t.is(listEventsMock.mock.callCount(), 1); t.is(listEventsMock.mock.callCount(), 1);
t.is(updated?.syncRetryCount, 1);
t.is(
updated?.nextSyncAt.toISOString(),
new Date(now + baseDelayMs).toISOString()
);
now += baseDelayMs + 1000;
await calendarService.syncSubscription(subscription.id); await calendarService.syncSubscription(subscription.id);
updated = await models.calendarSubscription.get(subscription.id);
t.is(listEventsMock.mock.callCount(), 2); t.is(listEventsMock.mock.callCount(), 2);
t.is(updated?.syncRetryCount, 2);
now += baseDelayMs + 1000; t.is(
await calendarService.syncSubscription(subscription.id); updated?.nextSyncAt.toISOString(),
t.is(listEventsMock.mock.callCount(), 2); new Date(now + baseDelayMs * 2).toISOString()
}); );
test('syncSubscription skips token refresh while in backoff window', async t => {
let now = new Date('2026-01-01T00:00:00.000Z').getTime();
mock.method(Date, 'now', () => now);
const user = await module.create(Mockers.User);
const account = await createAccount(user.id, {
accessToken: 'expired-access-token',
expiresAt: new Date(now - 5 * 60 * 1000),
});
const subscription = await createSubscription(account.id, {
syncToken: 'sync-token',
});
const provider = new MockCalendarProvider();
const refreshMock = mock.method(provider, 'refreshTokens', async () => ({
accessToken: `refreshed-${randomUUID()}`,
}));
const listEventsMock = mock.method(provider, 'listEvents', async () => {
throw new Error('upstream timeout');
});
mock.method(providerFactory, 'get', () => provider);
const baseDelayMs = 5 * 60 * 1000;
await calendarService.syncSubscription(subscription.id);
await calendarService.syncSubscription(subscription.id);
t.is(refreshMock.mock.callCount(), 1);
t.is(listEventsMock.mock.callCount(), 1);
now += baseDelayMs + 1000;
await calendarService.syncSubscription(subscription.id);
t.is(refreshMock.mock.callCount(), 2);
t.is(listEventsMock.mock.callCount(), 2);
}); });
test('syncSubscription renews webhook channel when expiring', async t => { test('syncSubscription renews webhook channel when expiring', async t => {
@@ -607,56 +594,72 @@ test('syncSubscription renews webhook channel when expiring', async t => {
t.truthy(updated?.channelExpiration); t.truthy(updated?.channelExpiration);
}); });
test('pollAccounts skips syncing when cluster lock is unavailable', async t => { test('syncSubscription keeps schedule moving when webhook renewal fails', async t => {
mock.method(mutex, 'acquire', async () => undefined); const now = new Date('2026-01-01T00:00:00.000Z').getTime();
mock.method( mock.method(Date, 'now', () => now);
models.calendarSubscription,
'listAllWithAccountForSync', const user = await module.create(Mockers.User);
async () => [] const account = await createAccount(user.id, {
); refreshIntervalMinutes: 60,
const syncAccountMock = mock.method(calendarService, 'syncAccount', async () => { });
return; const subscription = await createSubscription(account.id, {
syncToken: 'sync-token',
channelExpiration: new Date(Date.now() + 60 * 60 * 1000),
}); });
await calendarCronJobs.pollAccounts(); const provider = new MockCalendarProvider();
mock.method(provider, 'listEvents', async () => ({
t.is(syncAccountMock.mock.callCount(), 0); events: [],
}); nextSyncToken: 'next-sync',
test('pollAccounts only syncs due accounts', async t => {
mock.method(mutex, 'acquire', async () => ({
[Symbol.asyncDispose]: async () => {},
})); }));
mock.method( mock.method(provider, 'watchCalendar', async () => {
models.calendarSubscription, throw new Error('watch failed');
'listAllWithAccountForSync',
async () =>
[
{
accountId: 'due-account',
lastSyncAt: new Date(Date.now() - 31 * 60 * 1000),
account: {
refreshIntervalMinutes: 30,
},
},
{
accountId: 'fresh-account',
lastSyncAt: new Date(Date.now() - 5 * 60 * 1000),
account: {
refreshIntervalMinutes: 30,
},
},
] as any
);
const syncAccountMock = mock.method(calendarService, 'syncAccount', async () => {
return;
}); });
mock.method(providerFactory, 'get', () => provider);
await calendarService.syncSubscription(subscription.id);
const updated = await models.calendarSubscription.get(subscription.id);
t.truthy(updated?.lastSyncAt);
t.is(updated?.syncRetryCount, 0);
t.is(
updated?.nextSyncAt.toISOString(),
new Date(now + 15 * 60 * 1000).toISOString()
);
});
test('pollAccounts skips when nothing is due', async t => {
mock.method(models.calendarSubscription, 'listDueForSync', async () => []);
await calendarCronJobs.pollAccounts(); await calendarCronJobs.pollAccounts();
t.is(module.queue.count('calendar.syncSubscription'), 0);
});
test('pollAccounts enqueues due subscriptions only', async t => {
mock.method(models.calendarSubscription, 'listDueForSync', async () => [
{ id: 'due-subscription-a' },
{ id: 'due-subscription-b' },
]);
await calendarCronJobs.pollAccounts();
t.is(module.queue.count('calendar.syncSubscription'), 2);
t.deepEqual( t.deepEqual(
syncAccountMock.mock.calls.map(call => call.arguments[0]), module.queue.add
['due-account'] .getCalls()
.map(call => [call.args[0], call.args[1], call.args[2]]),
[
[
'calendar.syncSubscription',
{ subscriptionId: 'due-subscription-a', reason: 'polling' },
{ jobId: 'due-subscription-a' },
],
[
'calendar.syncSubscription',
{ subscriptionId: 'due-subscription-b', reason: 'polling' },
{ jobId: 'due-subscription-b' },
],
]
); );
}); });
@@ -8,6 +8,7 @@ export interface CalendarGoogleConfig {
clientSecret: string; clientSecret: string;
externalWebhookUrl?: string; externalWebhookUrl?: string;
webhookVerificationToken?: string; webhookVerificationToken?: string;
requestTimeoutMs?: number;
} }
export type CalendarCalDAVAuthType = 'auto' | 'basic' | 'digest'; export type CalendarCalDAVAuthType = 'auto' | 'basic' | 'digest';
@@ -49,6 +50,7 @@ const schema: JSONSchema = {
clientSecret: { type: 'string' }, clientSecret: { type: 'string' },
externalWebhookUrl: { type: 'string' }, externalWebhookUrl: { type: 'string' },
webhookVerificationToken: { type: 'string' }, webhookVerificationToken: { type: 'string' },
requestTimeoutMs: { type: 'number' },
}, },
}; };
@@ -88,6 +90,7 @@ defineModuleConfig('calendar', {
clientSecret: '', clientSecret: '',
externalWebhookUrl: '', externalWebhookUrl: '',
webhookVerificationToken: '', webhookVerificationToken: '',
requestTimeoutMs: 10_000,
}, },
schema, schema,
shape: z.object({ shape: z.object({
@@ -101,6 +104,7 @@ defineModuleConfig('calendar', {
.or(z.string().length(0)) .or(z.string().length(0))
.optional(), .optional(),
webhookVerificationToken: z.string().optional(), webhookVerificationToken: z.string().optional(),
requestTimeoutMs: z.number().int().positive().optional(),
}), }),
link: 'https://developers.google.com/calendar/api/guides/push', link: 'https://developers.google.com/calendar/api/guides/push',
}, },
@@ -1,72 +1,33 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule'; import { Cron, CronExpression } from '@nestjs/schedule';
import { chunk } from 'lodash-es';
import { Mutex } from '../../base'; import { JobQueue } from '../../base';
import { Models } from '../../models'; import { Models } from '../../models';
import { CalendarService } from './service';
const CALENDAR_POLL_LOCK_KEY = 'calendar:poll-accounts'; const CALENDAR_POLL_BATCH_SIZE = 200;
const CALENDAR_POLL_BATCH_SIZE = 10;
@Injectable() @Injectable()
export class CalendarCronJobs { export class CalendarCronJobs {
constructor( constructor(
private readonly models: Models, private readonly models: Models,
private readonly calendar: CalendarService, private readonly queue: JobQueue
private readonly mutex: Mutex
) {} ) {}
@Cron(CronExpression.EVERY_MINUTE) @Cron(CronExpression.EVERY_MINUTE)
async pollAccounts() { async pollAccounts() {
await using lock = await this.mutex.acquire(CALENDAR_POLL_LOCK_KEY); const subscriptions = await this.models.calendarSubscription.listDueForSync(
if (!lock) return; new Date(),
CALENDAR_POLL_BATCH_SIZE
);
const subscriptions = await Promise.allSettled(
await this.models.calendarSubscription.listAllWithAccountForSync(); subscriptions.map(({ id }) =>
this.queue.add(
const accountDueAt = new Map< 'calendar.syncSubscription',
string, { subscriptionId: id, reason: 'polling' },
{ refreshInterval: number; lastSyncAt: Date | null } { jobId: id }
>(); )
for (const subscription of subscriptions) {
const interval = subscription.account.refreshIntervalMinutes ?? 60;
const lastSyncAt = subscription.lastSyncAt ?? null;
const existing = accountDueAt.get(subscription.accountId);
if (!existing) {
accountDueAt.set(subscription.accountId, {
refreshInterval: interval,
lastSyncAt,
});
continue;
}
const earliest =
existing.lastSyncAt && lastSyncAt
? existing.lastSyncAt < lastSyncAt
? existing.lastSyncAt
: lastSyncAt
: (existing.lastSyncAt ?? lastSyncAt);
accountDueAt.set(subscription.accountId, {
refreshInterval: interval,
lastSyncAt: earliest,
});
}
const now = Date.now();
const dueAccountIds = Array.from(accountDueAt.entries())
.filter(
([, info]) =>
!info.lastSyncAt ||
now - info.lastSyncAt.getTime() >= info.refreshInterval * 60 * 1000
) )
.map(([accountId]) => accountId); );
for (const accountIds of chunk(dueAccountIds, CALENDAR_POLL_BATCH_SIZE)) {
await Promise.allSettled(
accountIds.map(accountId => this.calendar.syncAccount(accountId))
);
}
} }
} }
@@ -7,6 +7,7 @@ import { PermissionModule } from '../../core/permission';
import { WorkspaceModule } from '../../core/workspaces'; import { WorkspaceModule } from '../../core/workspaces';
import { CalendarController } from './controller'; import { CalendarController } from './controller';
import { CalendarCronJobs } from './cron'; import { CalendarCronJobs } from './cron';
import { CalendarJob } from './job';
import { CalendarOAuthService } from './oauth'; import { CalendarOAuthService } from './oauth';
import { CalendarProviderFactory, CalendarProviders } from './providers'; import { CalendarProviderFactory, CalendarProviders } from './providers';
import { import {
@@ -25,6 +26,7 @@ import { CalendarService } from './service';
...CalendarProviders, ...CalendarProviders,
CalendarProviderFactory, CalendarProviderFactory,
CalendarService, CalendarService,
CalendarJob,
CalendarOAuthService, CalendarOAuthService,
CalendarCronJobs, CalendarCronJobs,
CalendarServerConfigResolver, CalendarServerConfigResolver,
@@ -0,0 +1,26 @@
import { Injectable } from '@nestjs/common';
import { OnJob } from '../../base';
import { CalendarService } from './service';
declare global {
interface Jobs {
'calendar.syncSubscription': {
subscriptionId: string;
reason?: 'polling' | 'webhook' | 'on-demand';
};
}
}
@Injectable()
export class CalendarJob {
constructor(private readonly calendar: CalendarService) {}
@OnJob('calendar.syncSubscription')
async syncSubscription({
subscriptionId,
reason,
}: Jobs['calendar.syncSubscription']) {
await this.calendar.syncSubscription(subscriptionId, { reason });
}
}
@@ -152,10 +152,28 @@ export abstract class CalendarProvider {
} }
} }
protected get requestTimeoutMs() {
const timeout = (this.config as { requestTimeoutMs?: number } | undefined)
?.requestTimeoutMs;
return typeof timeout === 'number' && timeout > 0 ? timeout : undefined;
}
protected withTimeout(signal?: AbortSignal | null) {
const timeoutMs = this.requestTimeoutMs;
if (!timeoutMs) return signal;
const timeoutSignal = AbortSignal.timeout(timeoutMs);
if (!signal) return timeoutSignal;
return AbortSignal.any([signal, timeoutSignal]);
}
protected async fetchJson<T>(url: string, init?: RequestInit) { protected async fetchJson<T>(url: string, init?: RequestInit) {
const response = await fetch(url, { const response = await fetch(url, {
headers: { Accept: 'application/json', ...init?.headers }, headers: { Accept: 'application/json', ...init?.headers },
...init, ...init,
signal: this.withTimeout(init?.signal),
headers: { ...init?.headers, Accept: 'application/json' },
}); });
const body = await response.text(); const body = await response.text();
if (!response.ok) { if (!response.ok) {
@@ -329,6 +329,7 @@ export class GoogleCalendarProvider extends CalendarProvider {
private async fetchWithTokenHandling<T>(url: string, accessToken: string) { private async fetchWithTokenHandling<T>(url: string, accessToken: string) {
const response = await fetch(url, { const response = await fetch(url, {
signal: this.withTimeout(),
headers: { headers: {
Accept: 'application/json', Accept: 'application/json',
Authorization: `Bearer ${accessToken}`, Authorization: `Bearer ${accessToken}`,
@@ -4,16 +4,15 @@ import { Injectable, Logger } from '@nestjs/common';
import { Transactional } from '@nestjs-cls/transactional'; import { Transactional } from '@nestjs-cls/transactional';
import type { CalendarAccount, Prisma } from '@prisma/client'; import type { CalendarAccount, Prisma } from '@prisma/client';
import { addDays, subDays } from 'date-fns'; import { addDays, subDays } from 'date-fns';
import { chunk } from 'lodash-es';
import { import {
CalendarProviderRequestError, CalendarProviderRequestError,
Config, Config,
exponentialBackoffDelay,
GraphqlBadRequest, GraphqlBadRequest,
Mutex, JobQueue,
URLHelper, URLHelper,
} from '../../base'; } from '../../base';
import { SessionRedis } from '../../base/redis';
import { Models } from '../../models'; import { Models } from '../../models';
import type { CalendarCalDAVProviderPreset } from './config'; import type { CalendarCalDAVProviderPreset } from './config';
import { import {
@@ -29,11 +28,10 @@ import type { LinkCalDAVAccountInput } from './types';
const TOKEN_REFRESH_SKEW_MS = 60 * 1000; const TOKEN_REFRESH_SKEW_MS = 60 * 1000;
const DEFAULT_PAST_DAYS = 90; const DEFAULT_PAST_DAYS = 90;
const DEFAULT_FUTURE_DAYS = 180; const DEFAULT_FUTURE_DAYS = 180;
const SYNC_FAILURE_BACKOFF_KEY_PREFIX = 'calendar:sync:backoff:';
const SYNC_FAILURE_BACKOFF_BASE_MS = 5 * 60 * 1000; const SYNC_FAILURE_BACKOFF_BASE_MS = 5 * 60 * 1000;
const SYNC_FAILURE_BACKOFF_MAX_MS = 6 * 60 * 60 * 1000; const SYNC_FAILURE_BACKOFF_MAX_MS = 6 * 60 * 60 * 1000;
const SYNC_FAILURE_BACKOFF_TTL_SECONDS = 24 * 60 * 60; const DEFAULT_REFRESH_INTERVAL_MINUTES = 30;
const ACCOUNT_SYNC_BATCH_SIZE = 10; const CHANNEL_RENEW_RETRY_MS = 15 * 60 * 1000;
@Injectable() @Injectable()
export class CalendarService { export class CalendarService {
@@ -43,8 +41,7 @@ export class CalendarService {
constructor( constructor(
private readonly models: Models, private readonly models: Models,
private readonly providerFactory: CalendarProviderFactory<CalendarProvider>, private readonly providerFactory: CalendarProviderFactory<CalendarProvider>,
private readonly mutex: Mutex, private readonly queue: JobQueue,
private readonly redis: SessionRedis,
private readonly config: Config, private readonly config: Config,
private readonly url: URLHelper private readonly url: URLHelper
) {} ) {}
@@ -87,10 +84,24 @@ export class CalendarService {
return null; return null;
} }
return await this.models.calendarAccount.updateRefreshInterval( const updatedAccount =
accountId, await this.models.calendarAccount.updateRefreshInterval(
refreshIntervalMinutes accountId,
refreshIntervalMinutes
);
const subscriptions =
await this.models.calendarSubscription.listByAccountForSync(accountId);
await Promise.all(
subscriptions.map(subscription =>
this.models.calendarSubscription.updateSync(subscription.id, {
nextSyncAt: this.calculateNextSyncAt(
subscription.lastSyncAt ?? this.now(),
refreshIntervalMinutes
),
})
)
); );
return updatedAccount;
} }
async unlinkAccount(userId: string, accountId: string) { async unlinkAccount(userId: string, accountId: string) {
@@ -315,25 +326,6 @@ export class CalendarService {
return; return;
} }
const now = Date.now();
const backoff = await this.getSyncFailureBackoff(subscription.id);
if (backoff && now < backoff.nextRetryAt.getTime()) {
return;
}
await using lock = await this.mutex.acquire(
`calendar:subscription:${subscriptionId}`
);
if (!lock) {
return;
}
const lockedNow = Date.now();
const lockedBackoff = await this.getSyncFailureBackoff(subscription.id);
if (lockedBackoff && lockedNow < lockedBackoff.nextRetryAt.getTime()) {
return;
}
const provider = this.providerFactory.get( const provider = this.providerFactory.get(
account.provider as CalendarProviderName account.provider as CalendarProviderName
); );
@@ -417,30 +409,27 @@ export class CalendarService {
} }
if (synced) { if (synced) {
await this.clearSyncFailureBackoff(subscription.id); const syncedAt = this.now();
await this.ensureWebhookChannel(subscription, provider, accessToken); let nextSyncAt = this.calculateNextSyncAt(
} syncedAt,
account.refreshIntervalMinutes
await this.models.calendarSubscription.updateLastSyncAt(
subscription.id,
new Date()
);
}
async syncAccount(accountId: string) {
const account = await this.models.calendarAccount.get(accountId);
if (!account || account.status !== 'active') {
return;
}
const subscriptions =
await this.models.calendarSubscription.listByAccountForSync(accountId);
for (const batch of chunk(subscriptions, ACCOUNT_SYNC_BATCH_SIZE)) {
await Promise.allSettled(
batch.map(subscription =>
this.syncSubscription(subscription.id, { reason: 'polling' })
)
); );
try {
await this.ensureWebhookChannel(subscription, provider, accessToken);
} catch (error) {
nextSyncAt = this.calculateChannelRetryAt(nextSyncAt);
this.logger.warn(
`Failed to ensure webhook channel for subscription ${subscription.id}`,
this.toError(error)
);
}
await this.models.calendarSubscription.updateSync(subscription.id, {
lastSyncAt: syncedAt,
nextSyncAt,
syncRetryCount: 0,
});
} }
} }
@@ -459,9 +448,18 @@ export class CalendarService {
params.to params.to
); );
const subscriptions =
await this.models.calendarSubscription.listWithAccounts(subscriptionIds);
const staleSubscriptions = subscriptions.filter(
subscription =>
subscription.enabled &&
subscription.account.status === 'active' &&
subscription.nextSyncAt.getTime() <= this.nowMs()
);
Promise.allSettled( Promise.allSettled(
subscriptionIds.map(subscriptionId => staleSubscriptions.map(subscription =>
this.syncSubscription(subscriptionId, { reason: 'on-demand' }) this.enqueueSyncSubscription(subscription.id, 'on-demand')
) )
).catch(error => { ).catch(error => {
this.logger.warn('Calendar on-demand sync failed', error as Error); this.logger.warn('Calendar on-demand sync failed', error as Error);
@@ -517,7 +515,7 @@ export class CalendarService {
return; return;
} }
await this.syncSubscription(subscription.id, { reason: 'webhook' }); await this.enqueueSyncSubscription(subscription.id, 'webhook');
} }
getWebhookToken() { getWebhookToken() {
@@ -751,7 +749,7 @@ export class CalendarService {
} }
private getSyncWindow() { private getSyncWindow() {
const now = new Date(); const now = this.now();
return { return {
timeMin: subDays(now, DEFAULT_PAST_DAYS).toISOString(), timeMin: subDays(now, DEFAULT_PAST_DAYS).toISOString(),
timeMax: addDays(now, DEFAULT_FUTURE_DAYS).toISOString(), timeMax: addDays(now, DEFAULT_FUTURE_DAYS).toISOString(),
@@ -771,7 +769,7 @@ export class CalendarService {
if ( if (
accessToken && accessToken &&
account.expiresAt && account.expiresAt &&
account.expiresAt.getTime() > Date.now() + TOKEN_REFRESH_SKEW_MS account.expiresAt.getTime() > this.nowMs() + TOKEN_REFRESH_SKEW_MS
) { ) {
return { accessToken }; return { accessToken };
} }
@@ -835,7 +833,7 @@ export class CalendarService {
return; return;
} }
const renewThreshold = Date.now() + 24 * 60 * 60 * 1000; const renewThreshold = this.nowMs() + 24 * 60 * 60 * 1000;
if ( if (
subscription.channelExpiration && subscription.channelExpiration &&
subscription.channelExpiration.getTime() > renewThreshold subscription.channelExpiration.getTime() > renewThreshold
@@ -877,6 +875,7 @@ export class CalendarService {
subscription: { subscription: {
id: string; id: string;
externalCalendarId: string; externalCalendarId: string;
syncRetryCount: number;
customChannelId: string | null; customChannelId: string | null;
customResourceId: string | null; customResourceId: string | null;
}; };
@@ -899,7 +898,6 @@ export class CalendarService {
} }
if (this.isTokenInvalidError(params.error)) { if (this.isTokenInvalidError(params.error)) {
await this.clearSyncFailureBackoff(params.subscription.id);
await this.models.calendarAccount.invalidateAndPurge( await this.models.calendarAccount.invalidateAndPurge(
params.account.id, params.account.id,
this.formatSyncError(params.error) this.formatSyncError(params.error)
@@ -907,18 +905,14 @@ export class CalendarService {
return; return;
} }
const backoff = await this.bumpSyncFailureBackoff(params.subscription.id); const attempt = params.subscription.syncRetryCount + 1;
const interval = params.account.refreshIntervalMinutes ?? 60; const nextRetryAt = this.calculateFailureRetryAt(attempt);
const lastSyncAt = this.calculateLastSyncAtForRetry( await this.models.calendarSubscription.updateSync(params.subscription.id, {
backoff.nextRetryAt, nextSyncAt: nextRetryAt,
interval syncRetryCount: attempt,
); });
await this.models.calendarSubscription.updateLastSyncAt(
params.subscription.id,
lastSyncAt
);
this.logger.warn( this.logger.warn(
`Calendar sync failed for subscription ${params.subscription.id}, attempt ${backoff.attempt}, next retry at ${backoff.nextRetryAt.toISOString()}`, `Calendar sync failed for subscription ${params.subscription.id}, attempt ${attempt}, next retry at ${nextRetryAt.toISOString()}`,
this.toError(params.error) this.toError(params.error)
); );
} }
@@ -931,15 +925,6 @@ export class CalendarService {
return status === 404; return status === 404;
} }
private calculateLastSyncAtForRetry(
nextRetryAt: Date,
refreshIntervalMinutes: number
) {
// Cron schedules by `now - lastSyncAt >= refreshInterval`, so back-calculate
// a synthetic lastSyncAt to defer the next attempt to `nextRetryAt`.
return new Date(nextRetryAt.getTime() - refreshIntervalMinutes * 60 * 1000);
}
private async disableSubscription(params: { private async disableSubscription(params: {
subscriptionId: string; subscriptionId: string;
provider: CalendarProvider; provider: CalendarProvider;
@@ -970,68 +955,52 @@ export class CalendarService {
await this.models.calendarSubscription.disableAndPurge( await this.models.calendarSubscription.disableAndPurge(
params.subscriptionId params.subscriptionId
); );
await this.clearSyncFailureBackoff(params.subscriptionId);
} }
private getSyncFailureBackoffKey(subscriptionId: string) { async enqueueSyncSubscription(
return `${SYNC_FAILURE_BACKOFF_KEY_PREFIX}${subscriptionId}`; subscriptionId: string,
} reason: 'polling' | 'webhook' | 'on-demand'
) {
private async getSyncFailureBackoff(subscriptionId: string) { await this.queue.add(
const key = this.getSyncFailureBackoffKey(subscriptionId); 'calendar.syncSubscription',
const value = await this.redis.get(key); {
if (!value) { subscriptionId,
return null; reason,
} },
{
try { jobId: subscriptionId,
const parsed = JSON.parse(value) as {
attempt?: number;
nextRetryAt?: string;
};
if (!parsed.attempt || !parsed.nextRetryAt) {
return null;
} }
const nextRetryAt = new Date(parsed.nextRetryAt); );
if (Number.isNaN(nextRetryAt.getTime())) {
return null;
}
return {
attempt: parsed.attempt,
nextRetryAt,
};
} catch {
return null;
}
} }
private async bumpSyncFailureBackoff(subscriptionId: string) { private calculateNextSyncAt(base: Date, refreshIntervalMinutes?: number) {
const state = await this.getSyncFailureBackoff(subscriptionId); const intervalMinutes =
const attempt = (state?.attempt ?? 0) + 1; refreshIntervalMinutes ?? DEFAULT_REFRESH_INTERVAL_MINUTES;
const delay = Math.min( return new Date(base.getTime() + intervalMinutes * 60 * 1000);
SYNC_FAILURE_BACKOFF_BASE_MS * 2 ** (attempt - 1),
SYNC_FAILURE_BACKOFF_MAX_MS
);
const nextRetryAt = new Date(Date.now() + delay);
const key = this.getSyncFailureBackoffKey(subscriptionId);
await this.redis.set(
key,
JSON.stringify({
attempt,
nextRetryAt: nextRetryAt.toISOString(),
}),
'EX',
SYNC_FAILURE_BACKOFF_TTL_SECONDS
);
return {
attempt,
nextRetryAt,
};
} }
private async clearSyncFailureBackoff(subscriptionId: string) { private calculateChannelRetryAt(nextSyncAt: Date) {
const key = this.getSyncFailureBackoffKey(subscriptionId); return new Date(
await this.redis.del(key); Math.min(nextSyncAt.getTime(), this.nowMs() + CHANNEL_RENEW_RETRY_MS)
);
}
private calculateFailureRetryAt(attempt: number) {
return new Date(
this.nowMs() +
exponentialBackoffDelay(attempt - 1, {
baseDelayMs: SYNC_FAILURE_BACKOFF_BASE_MS,
maxDelayMs: SYNC_FAILURE_BACKOFF_MAX_MS,
})
);
}
private now() {
return new Date(this.nowMs());
}
private nowMs() {
return Date.now();
} }
private formatSyncError(error: unknown) { private formatSyncError(error: unknown) {
+4
View File
@@ -27,6 +27,10 @@
"type": "Object", "type": "Object",
"desc": "The config for copilot job queue" "desc": "The config for copilot job queue"
}, },
"queues.calendar": {
"type": "Object",
"desc": "The config for calendar job queue"
},
"queues.doc": { "queues.doc": {
"type": "Object", "type": "Object",
"desc": "The config for doc job queue" "desc": "The config for doc job queue"
@@ -18,7 +18,7 @@
"pl": 98, "pl": 98,
"pt-BR": 96, "pt-BR": 96,
"ru": 98, "ru": 98,
"sv-SE": 97, "sv-SE": 96,
"uk": 96, "uk": 96,
"ur": 2, "ur": 2,
"zh-Hans": 98, "zh-Hans": 98,