mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-08-22 04:21:40 +08:00
fix(server): avoid global rejection when event handler errors (#10467)
This commit is contained in:
@@ -4,7 +4,7 @@ import { CLS_ID, ClsServiceManager } from 'nestjs-cls';
|
|||||||
import Sinon from 'sinon';
|
import Sinon from 'sinon';
|
||||||
|
|
||||||
import { EventBus, metrics } from '../../base';
|
import { EventBus, metrics } from '../../base';
|
||||||
import { createTestingModule } from '../utils';
|
import { createTestingModule, sleep } from '../utils';
|
||||||
import { Listeners } from './provider';
|
import { Listeners } from './provider';
|
||||||
|
|
||||||
export const test = ava as TestFn<{
|
export const test = ava as TestFn<{
|
||||||
@@ -201,3 +201,55 @@ test('should continuously use the same request id', async t => {
|
|||||||
|
|
||||||
t.true(listeners.onRequestId.lastCall.returned('test-request-id'));
|
t.true(listeners.onRequestId.lastCall.returned('test-request-id'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('should throw when emitting async event with uncaught error', async t => {
|
||||||
|
const { eventbus } = t.context;
|
||||||
|
|
||||||
|
await t.throwsAsync(
|
||||||
|
() => eventbus.emitAsync('__test__.throw', { count: 0 }),
|
||||||
|
{
|
||||||
|
message: 'Error in event handler',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should suppress thrown error when emitting async event', async t => {
|
||||||
|
const { eventbus } = t.context;
|
||||||
|
const spy = Sinon.spy();
|
||||||
|
// @ts-expect-error internal event
|
||||||
|
const off = eventbus.on('error', spy);
|
||||||
|
|
||||||
|
const promise = eventbus.emitAsync('__test__.suppressThrow', {});
|
||||||
|
await t.notThrowsAsync(promise);
|
||||||
|
|
||||||
|
t.true(spy.calledOnce);
|
||||||
|
const args = spy.firstCall.args[0];
|
||||||
|
t.is(args.event, '__test__.suppressThrow');
|
||||||
|
t.deepEqual(args.payload, {});
|
||||||
|
t.is(args.error.message, 'Error in event handler');
|
||||||
|
|
||||||
|
const returns = await promise;
|
||||||
|
t.deepEqual(returns, [undefined]);
|
||||||
|
|
||||||
|
off();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should catch thrown error when emitting sync event', async t => {
|
||||||
|
const { eventbus } = t.context;
|
||||||
|
|
||||||
|
const spy = Sinon.spy();
|
||||||
|
// @ts-expect-error internal event
|
||||||
|
const off = eventbus.on('error', spy);
|
||||||
|
t.notThrows(() => eventbus.emit('__test__.throw', { count: 0 }));
|
||||||
|
|
||||||
|
// wait a tick
|
||||||
|
await sleep(1);
|
||||||
|
|
||||||
|
t.true(spy.calledOnce);
|
||||||
|
const args = spy.firstCall.args[0];
|
||||||
|
t.is(args.event, '__test__.throw');
|
||||||
|
t.deepEqual(args.payload, { count: 0 });
|
||||||
|
t.is(args.error.message, 'Error in event handler');
|
||||||
|
|
||||||
|
off();
|
||||||
|
});
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ declare global {
|
|||||||
'__test__.event': { count: number };
|
'__test__.event': { count: number };
|
||||||
'__test__.event2': { count: number };
|
'__test__.event2': { count: number };
|
||||||
'__test__.throw': { count: number };
|
'__test__.throw': { count: number };
|
||||||
|
'__test__.suppressThrow': {};
|
||||||
'__test__.requestId': {};
|
'__test__.requestId': {};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -32,6 +33,11 @@ export class Listeners {
|
|||||||
throw new Error('Error in event handler');
|
throw new Error('Error in event handler');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OnEvent('__test__.suppressThrow', { suppressError: true })
|
||||||
|
onSuppressThrow() {
|
||||||
|
throw new Error('Error in event handler');
|
||||||
|
}
|
||||||
|
|
||||||
@OnEvent('__test__.requestId')
|
@OnEvent('__test__.requestId')
|
||||||
onRequestId() {
|
onRequestId() {
|
||||||
const cls = ClsServiceManager.getClsService();
|
const cls = ClsServiceManager.getClsService();
|
||||||
|
|||||||
@@ -19,6 +19,12 @@ import { genRequestId } from '../utils';
|
|||||||
import { type EventName, type EventOptions } from './def';
|
import { type EventName, type EventOptions } from './def';
|
||||||
import { EventHandlerScanner } from './scanner';
|
import { EventHandlerScanner } from './scanner';
|
||||||
|
|
||||||
|
interface EventHandlerErrorPayload {
|
||||||
|
event: string;
|
||||||
|
payload: any;
|
||||||
|
error: Error;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* We use socket.io system to auto pub/sub on server to server broadcast events
|
* We use socket.io system to auto pub/sub on server to server broadcast events
|
||||||
*/
|
*/
|
||||||
@@ -50,6 +56,9 @@ export class EventBus
|
|||||||
|
|
||||||
async onModuleInit() {
|
async onModuleInit() {
|
||||||
this.bindEventHandlers();
|
this.bindEventHandlers();
|
||||||
|
this.emitter.on('error', ({ event, error }: EventHandlerErrorPayload) => {
|
||||||
|
this.logger.error(`Error happened when handling event ${event}`, error);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async onApplicationBootstrap() {
|
async onApplicationBootstrap() {
|
||||||
@@ -78,7 +87,16 @@ export class EventBus
|
|||||||
*/
|
*/
|
||||||
emit<T extends EventName>(event: T, payload: Events[T]) {
|
emit<T extends EventName>(event: T, payload: Events[T]) {
|
||||||
this.logger.log(`Dispatch event: ${event}`);
|
this.logger.log(`Dispatch event: ${event}`);
|
||||||
return this.emitter.emit(event, payload);
|
|
||||||
|
// NOTE(@forehalo):
|
||||||
|
// Because all event handlers are wrapped in promisified metrics and cls context, they will always run in standalone tick.
|
||||||
|
// In which way, if handler throws, an unhandled rejection will be triggered and end up with process exiting.
|
||||||
|
// So we catch it here with `emitAsync`
|
||||||
|
this.emitter.emitAsync(event, payload).catch(e => {
|
||||||
|
this.emitter.emit('error', { event, payload, error: e });
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -115,10 +133,11 @@ export class EventBus
|
|||||||
return await listener(payload);
|
return await listener(payload);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (suppressError) {
|
if (suppressError) {
|
||||||
this.logger.error(
|
this.emitter.emit('error', {
|
||||||
`Error happened when handling event ${signature}`,
|
event,
|
||||||
e
|
payload,
|
||||||
);
|
error: e,
|
||||||
|
} as EventHandlerErrorPayload);
|
||||||
} else {
|
} else {
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { DynamicModule } from '@nestjs/common';
|
|||||||
|
|
||||||
import { Config } from '../../config';
|
import { Config } from '../../config';
|
||||||
import { QueueRedis } from '../../redis';
|
import { QueueRedis } from '../../redis';
|
||||||
import { QUEUES } from './def';
|
import { Queue, QUEUES } from './def';
|
||||||
import { JobExecutor } from './executor';
|
import { JobExecutor } from './executor';
|
||||||
import { JobQueue } from './queue';
|
import { JobQueue } from './queue';
|
||||||
import { JobHandlerScanner } from './scanner';
|
import { JobHandlerScanner } from './scanner';
|
||||||
@@ -25,7 +25,15 @@ export class JobModule {
|
|||||||
},
|
},
|
||||||
inject: [Config, QueueRedis],
|
inject: [Config, QueueRedis],
|
||||||
}),
|
}),
|
||||||
BullModule.registerQueue(...QUEUES.map(name => ({ name }))),
|
BullModule.registerQueue(
|
||||||
|
...QUEUES.map(name => {
|
||||||
|
if (name === Queue.NIGHTLY_JOB) {
|
||||||
|
// avoid nightly jobs been run multiple times
|
||||||
|
return { name, removeOnComplete: { age: 1000 * 60 * 60 } };
|
||||||
|
}
|
||||||
|
return { name };
|
||||||
|
})
|
||||||
|
),
|
||||||
],
|
],
|
||||||
providers: [JobQueue, JobExecutor, JobHandlerScanner],
|
providers: [JobQueue, JobExecutor, JobHandlerScanner],
|
||||||
exports: [JobQueue],
|
exports: [JobQueue],
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common';
|
|||||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
import { EventBus, JobQueue, OnEvent, OnJob } from '../../base';
|
import { EventBus, JobQueue, OnJob } from '../../base';
|
||||||
import {
|
import {
|
||||||
SubscriptionPlan,
|
SubscriptionPlan,
|
||||||
SubscriptionRecurring,
|
SubscriptionRecurring,
|
||||||
@@ -126,6 +126,15 @@ export class SubscriptionCronJobs {
|
|||||||
});
|
});
|
||||||
|
|
||||||
for (const subscription of subscriptions) {
|
for (const subscription of subscriptions) {
|
||||||
|
await this.db.subscription.delete({
|
||||||
|
where: {
|
||||||
|
targetId_plan: {
|
||||||
|
targetId: subscription.targetId,
|
||||||
|
plan: subscription.plan,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
this.event.emit('user.subscription.canceled', {
|
this.event.emit('user.subscription.canceled', {
|
||||||
userId: subscription.targetId,
|
userId: subscription.targetId,
|
||||||
plan: subscription.plan as SubscriptionPlan,
|
plan: subscription.plan as SubscriptionPlan,
|
||||||
@@ -133,19 +142,4 @@ export class SubscriptionCronJobs {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OnEvent('user.subscription.canceled')
|
|
||||||
async handleUserSubscriptionCanceled({
|
|
||||||
userId,
|
|
||||||
plan,
|
|
||||||
}: Events['user.subscription.canceled']) {
|
|
||||||
await this.db.subscription.delete({
|
|
||||||
where: {
|
|
||||||
targetId_plan: {
|
|
||||||
targetId: userId,
|
|
||||||
plan,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user