refactor(server): config system (#11081)

This commit is contained in:
forehalo
2025-03-27 12:32:28 +00:00
parent 7091111f85
commit 0ea38680fa
274 changed files with 7583 additions and 5841 deletions
@@ -52,13 +52,15 @@ class JobHandlers {
test.before(async () => {
module = await createTestingModule({
imports: [
ConfigModule.forRoot({
ConfigModule.override({
job: {
worker: {
// NOTE(@forehalo):
// bullmq will hold the connection to check stalled jobs,
// which will keep the test process alive to timeout.
stalledInterval: 100,
defaultWorkerOptions: {
// NOTE(@forehalo):
// bullmq will hold the connection to check stalled jobs,
// which will keep the test process alive to timeout.
stalledInterval: 100,
},
},
queue: {
defaultJobOptions: { delay: 1000 },
@@ -82,7 +84,7 @@ test.afterEach(async () => {
// @ts-expect-error private api
const inner = queue.getQueue('nightly');
await inner.obliterate({ force: true });
inner.resume();
await inner.resume();
});
test.after.always(async () => {
@@ -132,7 +134,7 @@ test('should remove job from queue', async t => {
// #region executor
test('should start workers', async t => {
// @ts-expect-error private api
const worker = executor.workers['nightly'];
const worker = executor.workers.get('nightly')!;
t.truthy(worker);
t.true(worker.isRunning());
@@ -1,61 +1,86 @@
import { QueueOptions, WorkerOptions } from 'bullmq';
import {
defineRuntimeConfig,
defineStartupConfig,
ModuleConfig,
} from '../../config';
import { defineModuleConfig, JSONSchema } from '../../config';
import { Queue } from './def';
declare module '../../config' {
interface AppConfig {
job: ModuleConfig<
{
queue: Omit<QueueOptions, 'connection'>;
worker: Omit<WorkerOptions, 'connection'>;
},
{
queues: {
[key in Queue]: {
concurrency: number;
};
};
}
>;
declare global {
interface AppConfigSchema {
job: {
queue: ConfigItem<Omit<QueueOptions, 'connection' | 'telemetry'>>;
worker: ConfigItem<{
defaultWorkerOptions: Omit<WorkerOptions, 'connection' | 'telemetry'>;
}>;
queues: {
[key in Queue]: ConfigItem<{
concurrency: number;
}>;
};
};
}
}
defineStartupConfig('job', {
const schema: JSONSchema = {
type: 'object',
properties: {
concurrency: { type: 'number' },
},
};
defineModuleConfig('job', {
queue: {
prefix: AFFiNE.node.test ? 'affine_job_test' : 'affine_job',
defaultJobOptions: {
attempts: 5,
// should remove job after it's completed, because we will add a new job with the same job id
removeOnComplete: true,
removeOnFail: {
age: 24 * 3600 /* 1 day */,
count: 500,
desc: 'The config for job queues',
default: {
prefix: env.testing ? 'affine_job_test' : 'affine_job',
defaultJobOptions: {
attempts: 5,
// should remove job after it's completed, because we will add a new job with the same job id
removeOnComplete: true,
removeOnFail: {
age: 24 * 3600 /* 1 day */,
count: 500,
},
},
},
link: 'https://api.docs.bullmq.io/interfaces/v5.QueueOptions.html',
},
worker: {},
});
defineRuntimeConfig('job', {
'queues.nightly.concurrency': {
default: 1,
desc: 'Concurrency of worker consuming of nightly checking job queue',
worker: {
desc: 'The config for job workers',
default: {
defaultWorkerOptions: {},
},
link: 'https://api.docs.bullmq.io/interfaces/v5.WorkerOptions.html',
},
'queues.notification.concurrency': {
default: 10,
desc: 'Concurrency of worker consuming of notification job queue',
'queues.copilot': {
desc: 'The config for copilot job queue',
default: {
concurrency: 1,
},
schema,
},
'queues.doc.concurrency': {
default: 1,
desc: 'Concurrency of worker consuming of doc job queue',
'queues.doc': {
desc: 'The config for doc job queue',
default: {
concurrency: 1,
},
schema,
},
'queues.copilot.concurrency': {
default: 1,
desc: 'Concurrency of worker consuming of copilot job queue',
'queues.notification': {
desc: 'The config for notification job queue',
default: {
concurrency: 10,
},
schema,
},
'queues.nightly': {
desc: 'The config for nightly job queue',
default: {
concurrency: 1,
},
schema,
},
});
@@ -49,7 +49,7 @@ export const OnJob = (job: JobName) => {
if (!QUEUES.includes(ns as Queue)) {
throw new Error(
`Invalid job queue: ${ns}, must be one of [${QUEUES.join(', ')}].
If you want to introduce new job queue, please modify the Queue enum first in ${join(AFFiNE.projectRoot, 'src/base/job/queue/def.ts')}`
If you want to introduce new job queue, please modify the Queue enum first in ${join(env.projectRoot, 'src/base/job/queue/def.ts')}`
);
}
@@ -1,49 +1,51 @@
import {
Injectable,
Logger,
OnApplicationBootstrap,
OnApplicationShutdown,
} from '@nestjs/common';
import { Injectable, Logger, OnModuleDestroy } from '@nestjs/common';
import { Worker } from 'bullmq';
import { difference } from 'lodash-es';
import { difference, merge } from 'lodash-es';
import { CLS_ID, ClsServiceManager } from 'nestjs-cls';
import { Config } from '../../config';
import { OnEvent } from '../../event';
import { metrics, wrapCallMetric } from '../../metrics';
import { QueueRedis } from '../../redis';
import { Runtime } from '../../runtime';
import { genRequestId } from '../../utils';
import { JOB_SIGNAL, namespace, Queue, QUEUES } from './def';
import { JobHandlerScanner } from './scanner';
@Injectable()
export class JobExecutor
implements OnApplicationBootstrap, OnApplicationShutdown
{
export class JobExecutor implements OnModuleDestroy {
private readonly logger = new Logger('job');
private readonly workers: Record<string, Worker> = {};
private readonly workers: Map<Queue, Worker> = new Map();
constructor(
private readonly config: Config,
private readonly redis: QueueRedis,
private readonly scanner: JobHandlerScanner,
private readonly runtime: Runtime
private readonly scanner: JobHandlerScanner
) {}
async onApplicationBootstrap() {
const queues = this.config.flavor.graphql
? difference(QUEUES, [Queue.DOC])
: [];
@OnEvent('config.init')
async onConfigInit() {
const queues = env.flavors.graphql ? difference(QUEUES, [Queue.DOC]) : [];
// NOTE(@forehalo): only enable doc queue in doc service
if (this.config.flavor.doc) {
if (env.flavors.doc) {
queues.push(Queue.DOC);
}
await this.startWorkers(queues);
}
async onApplicationShutdown() {
@OnEvent('config.changed')
async onConfigChanged({ updates }: Events['config.changed']) {
if (updates.job?.queues) {
Object.entries(updates.job.queues).forEach(([queue, options]) => {
if (options.concurrency) {
this.setConcurrency(queue as Queue, options.concurrency);
}
});
}
}
async onModuleDestroy() {
await this.stopWorkers();
}
@@ -98,38 +100,35 @@ export class JobExecutor
}
}
private async startWorkers(queues: Queue[]) {
const configs =
(await this.runtime.fetchAll(
queues.reduce(
(ret, queue) => {
ret[`job/queues.${queue}.concurrency`] = true;
return ret;
},
{} as {
[key in `job/queues.${Queue}.concurrency`]: true;
}
)
// TODO(@forehalo): fix the override by [payment/service.spec.ts]
)) ?? {};
setConcurrency(queue: Queue, concurrency: number) {
const worker = this.workers.get(queue);
if (!worker) {
throw new Error(`Worker for [${queue}] not found.`);
}
worker.concurrency = concurrency;
}
private async startWorkers(queues: Queue[]) {
for (const queue of queues) {
const concurrency =
(configs[`job/queues.${queue}.concurrency`] as number) ??
this.config.job.worker.concurrency ??
1;
const queueOptions = this.config.job.queues[queue];
const concurrency = queueOptions.concurrency ?? 1;
const worker = new Worker(
queue,
async job => {
await this.run(job.name as JobName, job.data);
},
{
...this.config.job.queue,
...this.config.job.worker,
connection: this.redis,
concurrency,
}
merge(
{},
this.config.job.queue,
this.config.job.worker.defaultWorkerOptions,
queueOptions,
{
concurrency,
connection: this.redis,
}
)
);
worker.on('error', error => {
@@ -140,13 +139,13 @@ export class JobExecutor
`Queue Worker [${queue}] started; concurrency=${concurrency};`
);
this.workers[queue] = worker;
this.workers.set(queue, worker);
}
}
private async stopWorkers() {
await Promise.all(
Object.values(this.workers).map(async worker => {
Array.from(this.workers.values()).map(async worker => {
await worker.close(true);
})
);