Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions core/common-util/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
24 changes: 24 additions & 0 deletions core/common-util/src/EventLoopYieldUtil.ts
Original file line number Diff line number Diff line change
@@ -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<void> | undefined {
if (performance.now() - this.lastYieldAt < this.yieldIntervalMs) {
return;
}

return new Promise<void>(resolve => {
setImmediate(() => {
this.lastYieldAt = performance.now();
resolve();
});
});
}
}
27 changes: 27 additions & 0 deletions core/common-util/test/EventLoopYieldUtil.test.ts
Original file line number Diff line number Diff line change
@@ -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/);
});
});
70 changes: 53 additions & 17 deletions core/loader/src/LoaderFactory.ts
Original file line number Diff line number Diff line change
@@ -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<EggLoadUnitTypeLike, LoaderCreator> = new Map();

Expand All @@ -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<ModuleDescriptor[]> {
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;
}
Expand Down
45 changes: 43 additions & 2 deletions core/loader/src/impl/ModuleLoader.ts
Original file line number Diff line number Diff line change
@@ -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<EggProtoImplClass[]>;

constructor(moduleDir: string) {
this.moduleDir = moduleDir;
Expand All @@ -32,6 +34,45 @@ export class ModuleLoader implements Loader {
return this.protoClazzList;
}

async loadAsync(options?: LoadAsyncOptions): Promise<EggProtoImplClass[]> {
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<EggProtoImplClass[]> {
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);
}
Expand Down
16 changes: 16 additions & 0 deletions core/loader/test/Loader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
Expand Down
Original file line number Diff line number Diff line change
@@ -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 {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { Prototype } from '@eggjs/core-decorator';

@Prototype()
export class ObserveYield {
static eventLoopYielded = (global as any).__teggLoaderEventLoopYielded;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"name": "module-before-yield",
"eggModule": {
"name": "module-before-yield"
},
"main": "index.js"
}
10 changes: 10 additions & 0 deletions core/types/metadata/model/Loader.ts
Original file line number Diff line number Diff line change
@@ -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<EggProtoImplClass[]>;
// TODO impl loadProto
// loadProto(): ProtoDescriptor[];
}
5 changes: 2 additions & 3 deletions plugin/aop/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down
4 changes: 2 additions & 2 deletions plugin/config/lib/ModuleScanner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
4 changes: 2 additions & 2 deletions plugin/controller/app.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -161,7 +161,7 @@ export default class ControllerAppBootHook {
}

configDidLoad() {
GlobalGraph.instance!.registerBuildHook(middlewareGraphHook);
this.app.moduleHandler.registerGlobalGraphBuildHook(middlewareGraphHook);
}

async willReady() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
module.exports = function() {
const config = {
keys: 'test key',
tegg: {
asyncLoad: true,
},
security: {
csrf: {
ignoreJSON: false,
Expand Down
3 changes: 1 addition & 2 deletions plugin/langchain/app.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -32,7 +31,7 @@ export default class ModuleLangChainHook implements IBoot {
}

configDidLoad() {
GlobalGraph.instance!.registerBuildHook(GraphBuildHook);
this.#app.moduleHandler.registerGlobalGraphBuildHook(GraphBuildHook);
}

async beforeClose() {
Expand Down
17 changes: 17 additions & 0 deletions plugin/tegg/README.md
Original file line number Diff line number Diff line change
@@ -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](../..)
Loading
Loading