From b28a8961d3a2bf0711d220eccded17cbf3ceb286 Mon Sep 17 00:00:00 2001 From: gxkl Date: Tue, 18 Aug 2026 14:25:53 +0800 Subject: [PATCH] feat(tegg): support cooperative async module loading --- core/common-util/index.ts | 1 + core/common-util/src/EventLoopYieldUtil.ts | 24 +++ .../test/EventLoopYieldUtil.test.ts | 27 ++++ core/loader/src/LoaderFactory.ts | 70 ++++++--- core/loader/src/impl/ModuleLoader.ts | 45 +++++- core/loader/test/Loader.test.ts | 16 ++ .../module-before-yield/BlockEventLoop.ts | 15 ++ .../module-before-yield/ObserveYield.ts | 6 + .../modules/module-before-yield/package.json | 7 + core/types/metadata/model/Loader.ts | 10 ++ plugin/aop/app.ts | 5 +- .../apps/aop-app/config/config.default.js | 3 + plugin/config/lib/ModuleScanner.ts | 4 +- plugin/controller/app.ts | 4 +- .../config/config.default.js | 3 + plugin/langchain/app.ts | 3 +- plugin/tegg/README.md | 17 +++ plugin/tegg/lib/EggModuleLoader.ts | 57 ++++++- plugin/tegg/lib/ModuleHandler.ts | 5 + plugin/tegg/test/lib/EggModuleLoader.test.ts | 139 ++++++++++++++++++ plugin/tegg/typings/index.d.ts | 13 ++ 21 files changed, 439 insertions(+), 35 deletions(-) create mode 100644 core/common-util/src/EventLoopYieldUtil.ts create mode 100644 core/common-util/test/EventLoopYieldUtil.test.ts create mode 100644 core/loader/test/fixtures/modules/module-before-yield/BlockEventLoop.ts create mode 100644 core/loader/test/fixtures/modules/module-before-yield/ObserveYield.ts create mode 100644 core/loader/test/fixtures/modules/module-before-yield/package.json diff --git a/core/common-util/index.ts b/core/common-util/index.ts index 4229fb4b5..ebde2a5d8 100644 --- a/core/common-util/index.ts +++ b/core/common-util/index.ts @@ -9,4 +9,5 @@ export * from './src/ProxyUtil'; export * from './src/ModuleConfig'; export * from './src/ModuleConfigs'; export * from './src/TimerUtil'; +export * from './src/EventLoopYieldUtil'; export * from './src/StreamUtil'; diff --git a/core/common-util/src/EventLoopYieldUtil.ts b/core/common-util/src/EventLoopYieldUtil.ts new file mode 100644 index 000000000..3a08fee5c --- /dev/null +++ b/core/common-util/src/EventLoopYieldUtil.ts @@ -0,0 +1,24 @@ +import { performance } from 'node:perf_hooks'; + +export class EventLoopYieldUtil { + private lastYieldAt = performance.now(); + + constructor(private readonly yieldIntervalMs: number) { + if (!Number.isFinite(yieldIntervalMs) || yieldIntervalMs < 0) { + throw new RangeError('yieldIntervalMs must be a non-negative finite number'); + } + } + + yieldIfNeeded(): Promise | undefined { + if (performance.now() - this.lastYieldAt < this.yieldIntervalMs) { + return; + } + + return new Promise(resolve => { + setImmediate(() => { + this.lastYieldAt = performance.now(); + resolve(); + }); + }); + } +} diff --git a/core/common-util/test/EventLoopYieldUtil.test.ts b/core/common-util/test/EventLoopYieldUtil.test.ts new file mode 100644 index 000000000..1a014686d --- /dev/null +++ b/core/common-util/test/EventLoopYieldUtil.test.ts @@ -0,0 +1,27 @@ +import { strict as assert } from 'node:assert'; +import { EventLoopYieldUtil } from '..'; + +describe('test/EventLoopYieldUtil.test.ts', () => { + it('should yield to the event loop when the interval is reached', async () => { + let yielded = false; + setImmediate(() => { + yielded = true; + }); + + const eventLoopYield = new EventLoopYieldUtil(0); + const yieldTask = eventLoopYield.yieldIfNeeded(); + assert(yieldTask); + await yieldTask; + + assert.equal(yielded, true); + }); + + it('should return synchronously before the interval is reached', () => { + const eventLoopYield = new EventLoopYieldUtil(Number.MAX_SAFE_INTEGER); + assert.equal(eventLoopYield.yieldIfNeeded(), undefined); + }); + + it('should reject an invalid interval', () => { + assert.throws(() => new EventLoopYieldUtil(-1), /non-negative finite number/); + }); +}); diff --git a/core/loader/src/LoaderFactory.ts b/core/loader/src/LoaderFactory.ts index 1c0bb21b4..6d72e9fca 100644 --- a/core/loader/src/LoaderFactory.ts +++ b/core/loader/src/LoaderFactory.ts @@ -1,9 +1,18 @@ -import { EggLoadUnitType, EggLoadUnitTypeLike, EggProtoImplClass, Loader, ModuleReference } from '@eggjs/tegg-types'; +import { + EggLoadUnitType, + EggLoadUnitTypeLike, + EggProtoImplClass, + LoadAsyncOptions, + Loader, + ModuleReference, +} from '@eggjs/tegg-types'; import { ModuleDescriptor } from '@eggjs/tegg-metadata'; import { PrototypeUtil } from '@eggjs/core-decorator'; export type LoaderCreator = (unitPath: string) => Loader; +export const DEFAULT_ASYNC_LOAD_YIELD_INTERVAL_MS = 50; + export class LoaderFactory { private static loaderCreatorMap: Map = new Map(); @@ -19,28 +28,55 @@ export class LoaderFactory { this.loaderCreatorMap.set(type, creator); } + private static createModuleDescriptor( + moduleReference: ModuleReference, + multiInstanceClazzList: EggProtoImplClass[], + ): ModuleDescriptor { + return { + name: moduleReference.name, + unitPath: moduleReference.path, + clazzList: [], + protos: [], + multiInstanceClazzList, + optional: moduleReference.optional, + }; + } + + private static collectClazzList(clazzList: EggProtoImplClass[], descriptor: ModuleDescriptor) { + for (const clazz of clazzList) { + if (PrototypeUtil.isEggPrototype(clazz)) { + descriptor.clazzList.push(clazz); + } else if (PrototypeUtil.isEggMultiInstancePrototype(clazz)) { + descriptor.multiInstanceClazzList.push(clazz); + } + } + } + static loadApp(moduleReferences: readonly ModuleReference[]): ModuleDescriptor[] { const result: ModuleDescriptor[] = []; const multiInstanceClazzList: EggProtoImplClass[] = []; for (const moduleReference of moduleReferences) { const loader = LoaderFactory.createLoader(moduleReference.path, moduleReference.loaderType || EggLoadUnitType.MODULE); - const res: ModuleDescriptor = { - name: moduleReference.name, - unitPath: moduleReference.path, - clazzList: [], - protos: [], - multiInstanceClazzList, - optional: moduleReference.optional, - }; - result.push(res); + const descriptor = LoaderFactory.createModuleDescriptor(moduleReference, multiInstanceClazzList); + result.push(descriptor); const clazzList = loader.load(); - for (const clazz of clazzList) { - if (PrototypeUtil.isEggPrototype(clazz)) { - res.clazzList.push(clazz); - } else if (PrototypeUtil.isEggMultiInstancePrototype(clazz)) { - res.multiInstanceClazzList.push(clazz); - } - } + LoaderFactory.collectClazzList(clazzList, descriptor); + } + return result; + } + + static async loadAppAsync( + moduleReferences: readonly ModuleReference[], + options?: LoadAsyncOptions, + ): Promise { + const result: ModuleDescriptor[] = []; + const multiInstanceClazzList: EggProtoImplClass[] = []; + for (const moduleReference of moduleReferences) { + const loader = LoaderFactory.createLoader(moduleReference.path, moduleReference.loaderType || EggLoadUnitType.MODULE); + const descriptor = LoaderFactory.createModuleDescriptor(moduleReference, multiInstanceClazzList); + result.push(descriptor); + const clazzList = loader.loadAsync ? await loader.loadAsync(options) : loader.load(); + LoaderFactory.collectClazzList(clazzList, descriptor); } return result; } diff --git a/core/loader/src/impl/ModuleLoader.ts b/core/loader/src/impl/ModuleLoader.ts index af18b2506..53f944506 100644 --- a/core/loader/src/impl/ModuleLoader.ts +++ b/core/loader/src/impl/ModuleLoader.ts @@ -1,12 +1,14 @@ import globby from 'globby'; import path from 'node:path'; +import { EventLoopYieldUtil } from '@eggjs/tegg-common-util'; import { LoaderUtil } from '../LoaderUtil'; -import type { EggProtoImplClass, Loader } from '@eggjs/tegg-types'; -import { LoaderFactory } from '../LoaderFactory'; +import type { EggProtoImplClass, LoadAsyncOptions, Loader } from '@eggjs/tegg-types'; +import { DEFAULT_ASYNC_LOAD_YIELD_INTERVAL_MS, LoaderFactory } from '../LoaderFactory'; export class ModuleLoader implements Loader { private readonly moduleDir: string; private protoClazzList: EggProtoImplClass[]; + private loadPromise?: Promise; constructor(moduleDir: string) { this.moduleDir = moduleDir; @@ -32,6 +34,45 @@ export class ModuleLoader implements Loader { return this.protoClazzList; } + async loadAsync(options?: LoadAsyncOptions): Promise { + if (this.protoClazzList) { + return this.protoClazzList; + } + if (this.loadPromise) { + return await this.loadPromise; + } + + this.loadPromise = this.doLoadAsync(options); + try { + return await this.loadPromise; + } finally { + this.loadPromise = undefined; + } + } + + private async doLoadAsync(options?: LoadAsyncOptions): Promise { + const protoClassList: EggProtoImplClass[] = []; + const filePattern = LoaderUtil.filePattern(); + const files = await globby(filePattern, { cwd: this.moduleDir }); + const yieldIntervalMs = options?.yieldIntervalMs ?? DEFAULT_ASYNC_LOAD_YIELD_INTERVAL_MS; + const eventLoopYield = new EventLoopYieldUtil(yieldIntervalMs); + + for (const file of files) { + const realPath = path.join(this.moduleDir, file); + const fileClazzList = LoaderUtil.loadFile(realPath); + for (const clazz of fileClazzList) { + protoClassList.push(clazz); + } + const yieldTask = eventLoopYield.yieldIfNeeded(); + if (yieldTask) { + await yieldTask; + } + } + + this.protoClazzList = Array.from(new Set(protoClassList)); + return this.protoClazzList; + } + static createModuleLoader(path: string): ModuleLoader { return new ModuleLoader(path); } diff --git a/core/loader/test/Loader.test.ts b/core/loader/test/Loader.test.ts index 254a1e0d3..e0fab0ad7 100644 --- a/core/loader/test/Loader.test.ts +++ b/core/loader/test/Loader.test.ts @@ -34,6 +34,22 @@ describe('test/loader/Loader.test.ts', () => { const prototypes = loader.load(); assert(prototypes.length === 1); }); + + it('should yield to the event loop between top-level requires while loading asynchronously', async () => { + const moduleReferences = [{ + name: 'module-before-yield', + path: path.join(__dirname, './fixtures/modules/module-before-yield'), + }]; + + try { + const descriptors = await LoaderFactory.loadAppAsync(moduleReferences, { yieldIntervalMs: 10 }); + const observedProto = descriptors[0].clazzList.find(t => t.name === 'ObserveYield'); + assert(observedProto); + assert.equal((observedProto as any).eventLoopYielded, true); + } finally { + delete (global as any).__teggLoaderEventLoopYielded; + } + }); }); if (process.env.TS_NODE_TRANSPILE_ONLY !== 'true') { diff --git a/core/loader/test/fixtures/modules/module-before-yield/BlockEventLoop.ts b/core/loader/test/fixtures/modules/module-before-yield/BlockEventLoop.ts new file mode 100644 index 000000000..cbce8dbf7 --- /dev/null +++ b/core/loader/test/fixtures/modules/module-before-yield/BlockEventLoop.ts @@ -0,0 +1,15 @@ +import { Prototype } from '@eggjs/core-decorator'; + +(global as any).__teggLoaderEventLoopYielded = false; +setImmediate(() => { + (global as any).__teggLoaderEventLoopYielded = true; +}); + +const blockUntil = Date.now() + 20; +while (Date.now() < blockUntil) { + // Simulate expensive synchronous module evaluation. +} + +@Prototype() +export class BlockEventLoop { +} diff --git a/core/loader/test/fixtures/modules/module-before-yield/ObserveYield.ts b/core/loader/test/fixtures/modules/module-before-yield/ObserveYield.ts new file mode 100644 index 000000000..80144a417 --- /dev/null +++ b/core/loader/test/fixtures/modules/module-before-yield/ObserveYield.ts @@ -0,0 +1,6 @@ +import { Prototype } from '@eggjs/core-decorator'; + +@Prototype() +export class ObserveYield { + static eventLoopYielded = (global as any).__teggLoaderEventLoopYielded; +} diff --git a/core/loader/test/fixtures/modules/module-before-yield/package.json b/core/loader/test/fixtures/modules/module-before-yield/package.json new file mode 100644 index 000000000..8c084f198 --- /dev/null +++ b/core/loader/test/fixtures/modules/module-before-yield/package.json @@ -0,0 +1,7 @@ +{ + "name": "module-before-yield", + "eggModule": { + "name": "module-before-yield" + }, + "main": "index.js" +} diff --git a/core/types/metadata/model/Loader.ts b/core/types/metadata/model/Loader.ts index bb9d830f6..87b9eec78 100644 --- a/core/types/metadata/model/Loader.ts +++ b/core/types/metadata/model/Loader.ts @@ -1,10 +1,20 @@ import { EggProtoImplClass } from '../../core-decorator'; +export interface LoadAsyncOptions { + /** Maximum synchronous work between event-loop yields, in milliseconds. */ + yieldIntervalMs?: number; +} + /** * Loader to load class list in module */ export interface Loader { load(): EggProtoImplClass[]; + /** + * Load classes without monopolizing the event loop during application + * bootstrap. Loaders that do not need cooperative scheduling can omit it. + */ + loadAsync?(options?: LoadAsyncOptions): Promise; // TODO impl loadProto // loadProto(): ProtoDescriptor[]; } diff --git a/plugin/aop/app.ts b/plugin/aop/app.ts index 4062b0c86..815039679 100644 --- a/plugin/aop/app.ts +++ b/plugin/aop/app.ts @@ -8,7 +8,6 @@ import { pointCutGraphHook, } from '@eggjs/tegg-aop-runtime'; import { AopContextHook } from './lib/AopContextHook'; -import { GlobalGraph } from '@eggjs/tegg-metadata'; export default class AopAppHook { private readonly app: Application; @@ -31,8 +30,8 @@ export default class AopAppHook { this.app.eggPrototypeLifecycleUtil.registerLifecycle(this.eggPrototypeCrossCutHook); this.app.loadUnitLifecycleUtil.registerLifecycle(this.loadUnitAopHook); this.app.eggObjectLifecycleUtil.registerLifecycle(this.eggObjectAopHook); - GlobalGraph.instance!.registerBuildHook(crossCutGraphHook); - GlobalGraph.instance!.registerBuildHook(pointCutGraphHook); + this.app.moduleHandler.registerGlobalGraphBuildHook(crossCutGraphHook); + this.app.moduleHandler.registerGlobalGraphBuildHook(pointCutGraphHook); } async didLoad() { diff --git a/plugin/aop/test/fixtures/apps/aop-app/config/config.default.js b/plugin/aop/test/fixtures/apps/aop-app/config/config.default.js index 6d1b8de5b..a7312f3a8 100644 --- a/plugin/aop/test/fixtures/apps/aop-app/config/config.default.js +++ b/plugin/aop/test/fixtures/apps/aop-app/config/config.default.js @@ -5,6 +5,9 @@ const path = require('path'); module.exports = function(appInfo) { const config = { keys: 'test key', + tegg: { + asyncLoad: true, + }, customLogger: { xxLogger: { file: path.join(appInfo.root, 'logs/xx.log'), diff --git a/plugin/config/lib/ModuleScanner.ts b/plugin/config/lib/ModuleScanner.ts index aa6e986f6..04751ade8 100644 --- a/plugin/config/lib/ModuleScanner.ts +++ b/plugin/config/lib/ModuleScanner.ts @@ -3,9 +3,9 @@ import path from 'path'; export class ModuleScanner { private readonly baseDir: string; - private readonly readModuleOptions: ReadModuleReferenceOptions; + private readonly readModuleOptions?: ReadModuleReferenceOptions; - constructor(baseDir: string, readModuleOptions: ReadModuleReferenceOptions) { + constructor(baseDir: string, readModuleOptions?: ReadModuleReferenceOptions) { this.baseDir = baseDir; this.readModuleOptions = readModuleOptions; } diff --git a/plugin/controller/app.ts b/plugin/controller/app.ts index b22835ee0..d8fc69164 100644 --- a/plugin/controller/app.ts +++ b/plugin/controller/app.ts @@ -1,7 +1,7 @@ import { Application } from 'egg'; import { CONTROLLER_LOAD_UNIT, ControllerLoadUnit } from './lib/ControllerLoadUnit'; import { AppLoadUnitControllerHook } from './lib/AppLoadUnitControllerHook'; -import { GlobalGraph, LoadUnitLifecycleContext } from '@eggjs/tegg-metadata'; +import { LoadUnitLifecycleContext } from '@eggjs/tegg-metadata'; import { ControllerMetaBuilderFactory, ControllerType } from '@eggjs/tegg'; import { HTTPControllerRegister } from './lib/impl/http/HTTPControllerRegister'; import { WebSocketControllerRegister } from './lib/impl/websocket/WebSocketControllerRegister'; @@ -161,7 +161,7 @@ export default class ControllerAppBootHook { } configDidLoad() { - GlobalGraph.instance!.registerBuildHook(middlewareGraphHook); + this.app.moduleHandler.registerGlobalGraphBuildHook(middlewareGraphHook); } async willReady() { diff --git a/plugin/controller/test/fixtures/apps/middleware-graph-app/config/config.default.js b/plugin/controller/test/fixtures/apps/middleware-graph-app/config/config.default.js index 31ec8ab4f..2caa76212 100644 --- a/plugin/controller/test/fixtures/apps/middleware-graph-app/config/config.default.js +++ b/plugin/controller/test/fixtures/apps/middleware-graph-app/config/config.default.js @@ -3,6 +3,9 @@ module.exports = function() { const config = { keys: 'test key', + tegg: { + asyncLoad: true, + }, security: { csrf: { ignoreJSON: false, diff --git a/plugin/langchain/app.ts b/plugin/langchain/app.ts index 49cef982f..8e23f2d54 100644 --- a/plugin/langchain/app.ts +++ b/plugin/langchain/app.ts @@ -1,5 +1,4 @@ import type { Application, IBoot } from 'egg'; -import { GlobalGraph } from '@eggjs/tegg-metadata'; import { GraphObjectHook } from './lib/graph/GraphObjectHook'; import { GraphLoadUnitHook } from './lib/graph/GraphLoadUnitHook'; import { CompiledStateGraphProto } from './lib/graph/CompiledStateGraphProto'; @@ -32,7 +31,7 @@ export default class ModuleLangChainHook implements IBoot { } configDidLoad() { - GlobalGraph.instance!.registerBuildHook(GraphBuildHook); + this.#app.moduleHandler.registerGlobalGraphBuildHook(GraphBuildHook); } async beforeClose() { diff --git a/plugin/tegg/README.md b/plugin/tegg/README.md index 5a0573e1e..4f33bac0a 100644 --- a/plugin/tegg/README.md +++ b/plugin/tegg/README.md @@ -1,5 +1,22 @@ # `@eggjs/tegg-plugin` +## Configuration + +TEgg keeps synchronous module file loading and evaluation by default. +Applications can opt in to cooperative asynchronous loading during `didLoad`: + +```ts +config.tegg = { + asyncLoad: true, + asyncLoadYieldIntervalMs: 50, +}; +``` + +`asyncLoadYieldIntervalMs` controls how much time top-level file `require` calls +within each TEgg module may cumulatively consume before TEgg yields to the event +loop. The timer is reset for each module and defaults to 50ms. A single +`require`, including its transitive imports, cannot be interrupted midway. + ## Usage Please read [../../README.md](../..) diff --git a/plugin/tegg/lib/EggModuleLoader.ts b/plugin/tegg/lib/EggModuleLoader.ts index 702d42d74..dccf20dce 100644 --- a/plugin/tegg/lib/EggModuleLoader.ts +++ b/plugin/tegg/lib/EggModuleLoader.ts @@ -1,19 +1,33 @@ import { EggLoadUnitType, LoadUnitFactory, - GlobalGraph, ModuleDescriptorDumper, + GlobalGraph, GlobalGraphBuildHook, ModuleDescriptor, ModuleDescriptorDumper, } from '@eggjs/tegg-metadata'; -import { LoaderFactory } from '@eggjs/tegg-loader'; +import { DEFAULT_ASYNC_LOAD_YIELD_INTERVAL_MS, LoaderFactory } from '@eggjs/tegg-loader'; import { EggAppLoader } from './EggAppLoader'; import { Application } from 'egg'; export class EggModuleLoader { app: Application; globalGraph: GlobalGraph; + private readonly asyncLoad: boolean; + private pendingBuildHooks: GlobalGraphBuildHook[] = []; + private initGraphPromise?: Promise; constructor(app) { this.app = app; - GlobalGraph.instance = this.globalGraph = this.buildAppGraph(); + this.asyncLoad = this.app.config.tegg?.asyncLoad === true; + if (!this.asyncLoad) { + GlobalGraph.instance = this.globalGraph = this.buildAppGraph(); + } + } + + registerBuildHook(hook: GlobalGraphBuildHook) { + if (this.globalGraph) { + this.globalGraph.registerBuildHook(hook); + return; + } + this.pendingBuildHooks.push(hook); } private async loadApp() { @@ -22,7 +36,7 @@ export class EggModuleLoader { this.app.moduleHandler.loadUnits.push(loadUnit); } - private buildAppGraph() { + private prepareModuleReferences() { for (const plugin of Object.values(this.app.plugins)) { if (!plugin.enable) continue; const modulePlugin = this.app.moduleReferences.find(t => t.path === plugin.path); @@ -30,7 +44,9 @@ export class EggModuleLoader { modulePlugin.optional = false; } } - const moduleDescriptors = LoaderFactory.loadApp(this.app.moduleReferences); + } + + private createGraph(moduleDescriptors: ModuleDescriptor[]): GlobalGraph { for (const moduleDescriptor of moduleDescriptors) { ModuleDescriptorDumper.dump(moduleDescriptor, { dumpDir: this.app.baseDir, @@ -39,8 +55,18 @@ export class EggModuleLoader { this.app.logger.warn(e); }); } - const graph = GlobalGraph.create(moduleDescriptors); - return graph; + return GlobalGraph.create(moduleDescriptors); + } + + private buildAppGraph(): GlobalGraph { + this.prepareModuleReferences(); + return this.createGraph(LoaderFactory.loadApp(this.app.moduleReferences)); + } + + private async buildAppGraphAsync(): Promise { + this.prepareModuleReferences(); + const yieldIntervalMs = this.app.config.tegg?.asyncLoadYieldIntervalMs ?? DEFAULT_ASYNC_LOAD_YIELD_INTERVAL_MS; + return this.createGraph(await LoaderFactory.loadAppAsync(this.app.moduleReferences, { yieldIntervalMs })); } private async loadModule() { @@ -55,7 +81,24 @@ export class EggModuleLoader { } } + private async doInitGraph() { + GlobalGraph.instance = this.globalGraph = await this.buildAppGraphAsync(); + for (const hook of this.pendingBuildHooks) { + this.globalGraph.registerBuildHook(hook); + } + this.pendingBuildHooks = []; + } + + async initGraph() { + if (this.globalGraph) return; + if (!this.initGraphPromise) { + this.initGraphPromise = this.doInitGraph(); + } + await this.initGraphPromise; + } + async load() { + await this.initGraph(); await this.loadApp(); await this.loadModule(); } diff --git a/plugin/tegg/lib/ModuleHandler.ts b/plugin/tegg/lib/ModuleHandler.ts index e5661f02e..4147ee313 100644 --- a/plugin/tegg/lib/ModuleHandler.ts +++ b/plugin/tegg/lib/ModuleHandler.ts @@ -2,6 +2,7 @@ import Base from 'sdk-base'; import { Application, Context } from 'egg'; import { EggLoadUnitType, + GlobalGraphBuildHook, LoadUnit, LoadUnitFactory, } from '@eggjs/tegg-metadata'; @@ -23,6 +24,10 @@ export class ModuleHandler extends Base { this.loadUnitLoader = new EggModuleLoader(this.app); } + registerGlobalGraphBuildHook(hook: GlobalGraphBuildHook) { + this.loadUnitLoader.registerBuildHook(hook); + } + async init() { try { this.app.eggPrototypeCreatorFactory.registerPrototypeCreator( diff --git a/plugin/tegg/test/lib/EggModuleLoader.test.ts b/plugin/tegg/test/lib/EggModuleLoader.test.ts index 9e282fad6..4db3ae029 100644 --- a/plugin/tegg/test/lib/EggModuleLoader.test.ts +++ b/plugin/tegg/test/lib/EggModuleLoader.test.ts @@ -1,6 +1,18 @@ import mm from 'egg-mock'; import assert from 'assert'; import path from 'path'; +import { Prototype } from '@eggjs/core-decorator'; +import { + GlobalGraph, + ModuleDescriptor, + ModuleDescriptorDumper, +} from '@eggjs/tegg-metadata'; +import { LoaderFactory } from '@eggjs/tegg-loader'; +import { EggModuleLoader } from '../../lib/EggModuleLoader'; + +class DeferredProto { +} +Prototype()(DeferredProto); describe('test/lib/EggModuleLoader.test.ts', () => { beforeEach(() => { @@ -11,9 +23,136 @@ describe('test/lib/EggModuleLoader.test.ts', () => { }); afterEach(() => { + GlobalGraph.instance = undefined; mm.restore(); }); + it('should build graph synchronously by default', () => { + const moduleDescriptor: ModuleDescriptor = { + name: 'sync', + unitPath: '/tmp/sync', + clazzList: [ DeferredProto ], + protos: [], + multiInstanceClazzList: [], + optional: false, + }; + let asyncLoadCalled = false; + mm(LoaderFactory, 'loadApp', () => [ moduleDescriptor ]); + mm(LoaderFactory, 'loadAppAsync', async () => { + asyncLoadCalled = true; + return []; + }); + mm(ModuleDescriptorDumper, 'dump', async () => undefined); + + const loader = new EggModuleLoader({ + baseDir: '/tmp/sync-app', + config: {}, + logger: { warn: () => undefined }, + moduleReferences: [], + plugins: {}, + } as any); + + assert.equal(GlobalGraph.instance, loader.globalGraph); + assert.equal(loader.globalGraph.moduleGraph.nodes.size, 1); + assert.equal(asyncLoadCalled, false); + }); + + it('should buffer build hooks until asynchronous graph initialization completes', async () => { + let releaseLoad: () => void; + const waitForRelease = new Promise(resolve => { + releaseLoad = resolve; + }); + let loadCount = 0; + let yieldIntervalMs: number | undefined; + const moduleDescriptor: ModuleDescriptor = { + name: 'deferred', + unitPath: '/tmp/deferred', + clazzList: [ DeferredProto ], + protos: [], + multiInstanceClazzList: [], + optional: false, + }; + + mm(LoaderFactory, 'loadAppAsync', async (_moduleReferences, options) => { + loadCount++; + yieldIntervalMs = options?.yieldIntervalMs; + await waitForRelease; + return [ moduleDescriptor ]; + }); + mm(ModuleDescriptorDumper, 'dump', async () => undefined); + + const loader = new EggModuleLoader({ + baseDir: '/tmp/deferred-app', + config: { + tegg: { + asyncLoad: true, + }, + }, + logger: { warn: () => undefined }, + moduleReferences: [{ + name: moduleDescriptor.name, + path: moduleDescriptor.unitPath, + }], + plugins: {}, + } as any); + let hookCallCount = 0; + loader.registerBuildHook(() => { + hookCallCount++; + }); + + const initGraph = Promise.all([ loader.initGraph(), loader.initGraph() ]); + assert.equal(GlobalGraph.instance, undefined); + + releaseLoad!(); + await initGraph; + + const graph = loader.globalGraph; + assert.equal(loadCount, 1); + assert.equal(yieldIntervalMs, 50); + assert.equal(GlobalGraph.instance, graph); + assert.equal(graph.moduleGraph.nodes.size, 1); + assert.equal(hookCallCount, 0); + + graph.build(); + assert.equal(hookCallCount, 1); + }); + + it('should pass the configured asynchronous load yield interval', async () => { + let yieldIntervalMs: number | undefined; + const moduleDescriptor: ModuleDescriptor = { + name: 'custom-yield-interval', + unitPath: '/tmp/custom-yield-interval', + clazzList: [ DeferredProto ], + protos: [], + multiInstanceClazzList: [], + optional: false, + }; + mm(LoaderFactory, 'loadAppAsync', async (_moduleReferences, options) => { + yieldIntervalMs = options?.yieldIntervalMs; + return [ moduleDescriptor ]; + }); + mm(ModuleDescriptorDumper, 'dump', async () => undefined); + + const loader = new EggModuleLoader({ + baseDir: '/tmp/custom-yield-interval-app', + config: { + tegg: { + asyncLoad: true, + asyncLoadYieldIntervalMs: 17, + }, + }, + logger: { warn: () => undefined }, + moduleReferences: [{ + name: moduleDescriptor.name, + path: moduleDescriptor.unitPath, + }], + plugins: {}, + } as any); + + await loader.initGraph(); + assert.equal(yieldIntervalMs, 17); + }); + describe('has recursive dependency module', () => { it('should throw error', async () => { const app = mm.app({ diff --git a/plugin/tegg/typings/index.d.ts b/plugin/tegg/typings/index.d.ts index 2c19ae1e0..bb21fdde4 100644 --- a/plugin/tegg/typings/index.d.ts +++ b/plugin/tegg/typings/index.d.ts @@ -20,10 +20,23 @@ import { } from '@eggjs/tegg-runtime'; import { LoaderFactory } from '@eggjs/tegg-loader'; import { IdenticalUtil, EggProtoImplClass, QualifierInfo } from '@eggjs/tegg'; +import { ReadModuleReferenceOptions } from '@eggjs/tegg-common-util'; import { ModuleHandler } from '../lib/ModuleHandler'; import { EggContextHandler } from '../lib/EggContextHandler'; declare module 'egg' { + export interface TeggPluginConfig { + readModuleOptions?: ReadModuleReferenceOptions; + /** Load TEgg module files asynchronously during didLoad. Defaults to false. */ + asyncLoad?: boolean; + /** Maximum cumulative synchronous top-level require time within each module. Defaults to 50ms. */ + asyncLoadYieldIntervalMs?: number; + } + + export interface EggAppConfig { + tegg?: TeggPluginConfig; + } + export interface EggModule { }