feat(server): job system (#10134)

This commit is contained in:
forehalo
2025-02-18 05:41:56 +00:00
parent f6a86c10fe
commit cb895d4cb0
26 changed files with 1045 additions and 131 deletions

View File

@@ -1,3 +1,7 @@
import { OnOptions } from 'eventemitter2';
import { PushMetadata, sliceMetadata } from '../nestjs';
declare global {
/**
* Event definitions can be extended by
@@ -16,3 +20,29 @@ declare global {
}
export type EventName = keyof Events;
export const EVENT_LISTENER_METADATA = Symbol('event_listener');
interface EventHandlerMetadata {
namespace: string;
event: EventName;
opts?: OnOptions;
}
export interface EventOptions extends OnOptions {
prepend?: boolean;
name?: string;
suppressError?: boolean;
}
export const OnEvent = (event: EventName, opts?: EventOptions) => {
const namespace = event.split('.')[0];
return PushMetadata<EventHandlerMetadata>(EVENT_LISTENER_METADATA, {
namespace,
event,
opts,
});
};
export function getEventHandlerMetadata(target: any): EventHandlerMetadata[] {
return sliceMetadata<EventHandlerMetadata>(EVENT_LISTENER_METADATA, target);
}

View File

@@ -4,42 +4,20 @@ import {
OnApplicationBootstrap,
OnModuleInit,
} from '@nestjs/common';
import { DiscoveryService, MetadataScanner } from '@nestjs/core';
import {
OnGatewayConnection,
WebSocketGateway,
WebSocketServer,
} from '@nestjs/websockets';
import EventEmitter2, { type OnOptions } from 'eventemitter2';
import EventEmitter2 from 'eventemitter2';
import { once } from 'lodash-es';
import { CLS_ID, ClsService, ClsServiceManager } from 'nestjs-cls';
import type { Server, Socket } from 'socket.io';
import { wrapCallMetric } from '../metrics';
import { PushMetadata, sliceMetadata } from '../nestjs';
import { genRequestId } from '../utils';
import type { EventName } from './def';
const EVENT_LISTENER_METADATA = Symbol('event_listener');
interface EventHandlerMetadata {
namespace: string;
event: EventName;
opts?: OnOptions;
}
interface EventOptions extends OnOptions {
prepend?: boolean;
name?: string;
suppressError?: boolean;
}
export const OnEvent = (event: EventName, opts?: EventOptions) => {
const namespace = event.split('.')[0];
return PushMetadata<EventHandlerMetadata>(EVENT_LISTENER_METADATA, {
namespace,
event,
opts,
});
};
import { type EventName, type EventOptions } from './def';
import { EventHandlerScanner } from './scanner';
/**
* We use socket.io system to auto pub/sub on server to server broadcast events
@@ -59,8 +37,7 @@ export class EventBus
constructor(
private readonly emitter: EventEmitter2,
private readonly cls: ClsService,
private readonly discovery: DiscoveryService,
private readonly scanner: MetadataScanner
private readonly scanner: EventHandlerScanner
) {}
handleConnection(client: Socket) {
@@ -119,13 +96,14 @@ export class EventBus
) {
const namespace = event.split('.')[0];
const { name, prepend, suppressError } = opts;
let signature = name ?? listener.name ?? 'anonymous fn';
const handlerName = name ?? listener.name ?? 'anonymous fn';
let signature = `[${event}] (${handlerName})`;
const add = prepend ? this.emitter.prependListener : this.emitter.on;
const handler = wrapCallMetric(
async (payload: any) => {
this.logger.verbose(`Handle event [${event}] (${signature})`);
this.logger.verbose(`Handle event ${signature}`);
const cls = ClsServiceManager.getClsService();
return await cls.run({ ifNested: 'reuse' }, async () => {
@@ -138,7 +116,7 @@ export class EventBus
} catch (e) {
if (suppressError) {
this.logger.error(
`Error happened when handling event [${event}] (${signature})`,
`Error happened when handling event ${signature}`,
e
);
} else {
@@ -152,15 +130,13 @@ export class EventBus
{
event,
namespace,
handler: signature,
handler: handlerName,
}
);
add.call(this.emitter, event, handler as any, opts);
this.logger.verbose(
`Event handler for [${event}] registered ${name ? `in [${name}]` : ''}`
);
this.logger.verbose(`Event handler registered ${signature}`);
return () => {
this.emitter.off(event, handler as any);
@@ -171,60 +147,9 @@ export class EventBus
return this.emitter.waitFor(name, timeout);
}
private bindEventHandlers() {
// make sure all our job handlers defined in [Providers] to make the code organization clean.
// const providers = [...this.discovery.getProviders(), this.discovery.getControllers()]
const providers = this.discovery.getProviders();
providers.forEach(wrapper => {
const { instance, name } = wrapper;
if (!instance || wrapper.isAlias) {
return;
}
const proto = Object.getPrototypeOf(instance);
const methods = this.scanner.getAllMethodNames(proto);
methods.forEach(method => {
const fn = instance[method];
let defs = sliceMetadata<EventHandlerMetadata>(
EVENT_LISTENER_METADATA,
fn
);
if (defs.length === 0) {
return;
}
const signature = `${name}.${method}`;
if (typeof fn !== 'function') {
throw new Error(`Event handler [${signature}] is not a function.`);
}
if (!wrapper.isDependencyTreeStatic()) {
throw new Error(
`Provider [${name}] could not be RequestScoped or TransientScoped injectable if it contains event handlers.`
);
}
defs.forEach(({ event, opts }) => {
this.on(
event,
(payload: any) => {
// NOTE(@forehalo):
// we might create spies on the event handlers when testing,
// avoid reusing `fn` variable to fail the spies or stubs
return instance[method](payload);
},
{
...opts,
name: signature,
}
);
});
});
private readonly bindEventHandlers = once(() => {
this.scanner.scan().forEach(({ event, handler, opts }) => {
this.on(event, handler, opts);
});
}
});
}

View File

@@ -1,20 +1,20 @@
import { Global, Module } from '@nestjs/common';
import { DiscoveryModule } from '@nestjs/core';
import EventEmitter2 from 'eventemitter2';
import { EventBus, OnEvent } from './eventbus';
import { EventBus } from './eventbus';
import { EventHandlerScanner } from './scanner';
const EmitProvider = {
provide: EventEmitter2,
useValue: new EventEmitter2(),
useFactory: () => new EventEmitter2(),
};
@Global()
@Module({
imports: [DiscoveryModule],
providers: [EventBus, EmitProvider],
providers: [EventBus, EventHandlerScanner, EmitProvider],
exports: [EventBus],
})
export class EventModule {}
export { EventBus, OnEvent };
export { EventBus };
export { OnEvent } from './def';

View File

@@ -0,0 +1,71 @@
import { Injectable } from '@nestjs/common';
import { once } from 'lodash-es';
import { ModuleScanner } from '../nestjs';
import {
type EventName,
type EventOptions,
getEventHandlerMetadata,
} from './def';
@Injectable()
export class EventHandlerScanner {
constructor(private readonly scanner: ModuleScanner) {}
scan = once(() => {
const handlers: Array<{
event: EventName;
handler: (payload: any) => any;
opts?: EventOptions;
}> = [];
const providers = this.scanner.getAtInjectables();
providers.forEach(wrapper => {
const { instance, name } = wrapper;
if (!instance || wrapper.isAlias) {
return;
}
const methods = this.scanner.getAllMethodNames(instance);
methods.forEach(method => {
const fn = instance[method];
let defs = getEventHandlerMetadata(instance[method]);
if (defs.length === 0) {
return;
}
const signature = `${name}.${method}`;
if (typeof fn !== 'function') {
throw new Error(`Event handler [${signature}] is not a function.`);
}
if (!wrapper.isDependencyTreeStatic()) {
throw new Error(
`Provider [${name}] could not be RequestScoped or TransientScoped injectable if it contains event handlers.`
);
}
defs.forEach(({ event, opts }) => {
handlers.push({
event,
handler: (payload: any) => {
// NOTE(@forehalo):
// we might create spies on the event handlers when testing,
// avoid reusing `fn` variable to fail the spies or stubs
return instance[method].bind(instance)(payload);
},
opts: {
name: signature,
...opts,
},
});
});
});
});
return handlers;
});
}