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 standalone/standalone/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ export * from './src/main';
export * from './src/StandaloneInnerObjectProto';
export * from './src/StandaloneContext';
export * from './src/StandaloneInnerObject';
export * from './src/StandaloneAopContextHook';
11 changes: 11 additions & 0 deletions standalone/standalone/src/Runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
ContextHandler,
EggContainerFactory,
EggContext,
EggContextLifecycleUtil,
EggObjectLifecycleUtil,
LoadUnitInstance,
LoadUnitInstanceFactory,
Expand Down Expand Up @@ -38,6 +39,7 @@ import { InnerObject, StandaloneLoadUnit, StandaloneLoadUnitType } from './Stand
import { StandaloneContext } from './StandaloneContext';
import { StandaloneContextHandler } from './StandaloneContextHandler';
import { ConfigSourceLoadUnitHook } from './ConfigSourceLoadUnitHook';
import { StandaloneAopContextHook } from './StandaloneAopContextHook';
import { DalTableEggPrototypeHook } from '@eggjs/tegg-dal-plugin/lib/DalTableEggPrototypeHook';
import { DalModuleLoadUnitHook } from '@eggjs/tegg-dal-plugin/lib/DalModuleLoadUnitHook';
import { MysqlDataSourceManager } from '@eggjs/tegg-dal-plugin';
Expand Down Expand Up @@ -80,6 +82,7 @@ export class Runner {
private readonly loadUnitAopHook: LoadUnitAopHook;
private readonly eggPrototypeCrossCutHook: EggPrototypeCrossCutHook;
private readonly eggObjectAopHook: EggObjectAopHook;
private standaloneAopContextHook: StandaloneAopContextHook;

loadUnits: LoadUnit[] = [];
loadUnitInstances: LoadUnitInstance[] = [];
Expand Down Expand Up @@ -224,6 +227,11 @@ export class Runner {
instances.push(instance);
}
this.loadUnitInstances = instances;

// Register AOP context hook to pre-create ContextProto advice objects
this.standaloneAopContextHook = new StandaloneAopContextHook(this.loadUnitInstances);
EggContextLifecycleUtil.registerLifecycle(this.standaloneAopContextHook);

const runnerClass = StandaloneUtil.getMainRunner();
if (!runnerClass) {
throw new Error('not found runner class. Do you add @Runner decorator?');
Expand Down Expand Up @@ -281,6 +289,9 @@ export class Runner {
if (this.eggObjectAopHook) {
EggObjectLifecycleUtil.deleteLifecycle(this.eggObjectAopHook);
}
if (this.standaloneAopContextHook) {
EggContextLifecycleUtil.deleteLifecycle(this.standaloneAopContextHook);
}

if (this.loadUnitMultiInstanceProtoHook) {
LoadUnitLifecycleUtil.deleteLifecycle(this.loadUnitMultiInstanceProtoHook);
Expand Down
55 changes: 55 additions & 0 deletions standalone/standalone/src/StandaloneAopContextHook.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import type { EggContext, EggContextLifecycleContext, LoadUnitInstance } from '@eggjs/tegg-runtime';
import type { EggProtoImplClass, LifecycleHook } from '@eggjs/tegg';
import { PrototypeUtil, ObjectInitType } from '@eggjs/tegg';
import { AspectInfoUtil } from '@eggjs/tegg/aop';
import { EggPrototype, TeggError } from '@eggjs/tegg-metadata';

export interface EggPrototypeWithClazz extends EggPrototype {
clazz?: EggProtoImplClass;
}

export interface ProtoToCreate {
name: string;
proto: EggPrototype;
}
Comment on lines +7 to +14
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The interfaces EggPrototypeWithClazz and ProtoToCreate appear to be internal implementation details for StandaloneAopContextHook. Since index.ts uses export * from './src/StandaloneAopContextHook', these interfaces are exposed as part of the package's public API. To maintain a clean public API and signal that these are for internal use, it's better to define them without exporting.

Suggested change
export interface EggPrototypeWithClazz extends EggPrototype {
clazz?: EggProtoImplClass;
}
export interface ProtoToCreate {
name: string;
proto: EggPrototype;
}
interface EggPrototypeWithClazz extends EggPrototype {
clazz?: EggProtoImplClass;
}
interface ProtoToCreate {
name: string;
proto: EggPrototype;
}


/**
* AopContextHook for standalone mode.
* Pre-creates ContextProto advice objects when a new context is initialized.
* This ensures that advice objects are available when AOP methods are called.
*/
export class StandaloneAopContextHook implements LifecycleHook<EggContextLifecycleContext, EggContext> {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

這個放在 aop runtime 裏是不是更加合適?

private requestProtoList: Array<ProtoToCreate> = [];

constructor(loadUnitInstances: LoadUnitInstance[]) {
for (const loadUnitInstance of loadUnitInstances) {
const iterator = loadUnitInstance.loadUnit.iterateEggPrototype();
for (const proto of iterator) {
const protoWithClazz = proto as EggPrototypeWithClazz;
const clazz = protoWithClazz.clazz;
if (!clazz) continue;
const aspects = AspectInfoUtil.getAspectList(clazz);
for (const aspect of aspects) {
for (const advice of aspect.adviceList) {
const adviceProto = PrototypeUtil.getClazzProto(advice.clazz) as EggPrototype | undefined;
if (!adviceProto) {
throw TeggError.create(`Aop Advice(${advice.clazz.name}) not found in loadUnits`, 'advice_not_found');
}
if (adviceProto.initType === ObjectInitType.CONTEXT) {
this.requestProtoList.push({
name: advice.name,
proto: adviceProto,
});
}
}
}
}
}
}

async preCreate(_: EggContextLifecycleContext, ctx: EggContext): Promise<void> {
for (const proto of this.requestProtoList) {
ctx.addProtoToCreate(proto.name, proto.proto);
}
}
}
87 changes: 87 additions & 0 deletions standalone/standalone/test/fixtures/singleton-aop-module/Tool.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { SingletonProto, Inject } from '@eggjs/tegg';
import {
Advice,
AdviceContext,
IAdvice,
Pointcut,
} from '@eggjs/tegg/aop';

/**
* This is a ContextProto Advice (default for @Advice decorator)
* This simulates the user's CachedToolCallAdvice scenario
*/
@Advice()
export class ToolCallAdvice implements IAdvice<Tool> {
private callCount = 0;

async beforeCall(ctx: AdviceContext<Tool>): Promise<void> {
this.callCount++;
ctx.args[0] = `[advised:${this.callCount}] ${ctx.args[0]}`;
}
}

/**
* This is a SingletonProto that uses a ContextProto Advice
* This is the exact scenario that causes "EggObject not found" error
*/
@SingletonProto()
export class Tool {
@Pointcut(ToolCallAdvice)
async execute(input: string): Promise<string> {
return `Tool executed: ${input}`;
}
}

/**
* A SingletonProto that uses another SingletonProto Tool
* IMPORTANT: Both are Singletons, so in subsequent requests:
* - QueryNode is not re-created
* - Tool is not re-fetched via getOrCreateEggObject
* - ContextInitiator.init(Tool) is NOT called
* - Tool's ContextProto Advice is NOT created
* - AOP fails with "EggObject not found"
*/
@SingletonProto()
export class QueryNode {
@Inject()
tool: Tool;

async run(input: string): Promise<string> {
// This calls tool.execute() which has AOP
return await this.tool.execute(input);
}
}

/**
* A singleton that stores bound functions (simulates langchain graph)
* This is the key scenario that causes the bug:
* 1. Graph is created in request A, stores bound function nodeObj.execute.bind(nodeObj)
* 2. Request A ends, ContextProto Advice is destroyed
* 3. Request B uses the Graph, calls the stored bound function
* 4. The bound function's `this` (nodeObj) has a Tool that uses ContextProto Advice
* 5. AOP tries to access Advice, but it doesn't exist in request B's context
*/
@SingletonProto()
export class Graph {
private boundExecute: ((input: string) => Promise<string>) | null = null;
private _initialized = false;

get initialized(): boolean {
return this._initialized;
}

// Simulate langchain's addNode - stores bound function ONLY ONCE
setBoundExecute(fn: (input: string) => Promise<string>) {
if (!this._initialized) {
this.boundExecute = fn;
this._initialized = true;
}
}

async execute(input: string): Promise<string> {
if (!this.boundExecute) {
throw new Error('boundExecute not set');
}
return await this.boundExecute(input);
}
}
32 changes: 32 additions & 0 deletions standalone/standalone/test/fixtures/singleton-aop-module/main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { ContextProto, Inject } from '@eggjs/tegg';
import { Runner, MainRunner } from '@eggjs/tegg/standalone';
import { Graph, QueryNode } from './Tool';
import { EggContainerFactory } from '@eggjs/tegg-runtime';

@Runner()
@ContextProto()
export class Main implements MainRunner<string> {
@Inject()
graph: Graph;

async main(): Promise<string> {
// First request: set up the graph with bound execute function
// Key: We get QueryNode ONLY on first request, not on subsequent requests
if (!this.graph.initialized) {
// Get QueryNode only during graph initialization (first request)
const queryNodeObj = await EggContainerFactory.getOrCreateEggObjectFromClazz(QueryNode);
const queryNode = queryNodeObj.obj as QueryNode;
this.graph.setBoundExecute(queryNode.run.bind(queryNode));
}

// Execute through the graph
// On subsequent requests:
// - Graph is reused (Singleton)
// - boundExecute is already set, points to QueryNode from first request
// - QueryNode.run() calls Tool.execute() which has AOP
// - AOP needs ToolCallAdvice (ContextProto)
// - Without the fix: ToolCallAdvice doesn't exist in current context -> ERROR
// - With the fix: StandaloneAopContextHook pre-creates ToolCallAdvice -> SUCCESS
return await this.graph.execute('test-input');
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"name": "singleton-aop-module",
"eggModule": {
"name": "singletonAopModule"
}
}
38 changes: 38 additions & 0 deletions standalone/standalone/test/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,44 @@ describe('standalone/standalone/test/index.test.ts', () => {
});
});

describe('aop runtime with SingletonProto + ContextProto Advice', () => {
const fixturePath = path.join(__dirname, './fixtures/singleton-aop-module');

it('should work when SingletonProto uses ContextProto Advice', async () => {
// This test case reproduces the "EggObject xxx not found" error
// when a SingletonProto uses a ContextProto Advice (default for @Advice)
const msg = await main(fixturePath);
assert.equal(msg, 'Tool executed: [advised:1] test-input');
});

it('should work across multiple requests (Singleton reused)', async () => {
// This is the critical test case that reproduces the user's issue:
// 1. First request: Singleton with AOP is created, ContextProto Advice is created
// 2. First request ends, ContextProto Advice is destroyed
// 3. Second request: Singleton is reused, but Advice doesn't exist yet
// 4. AOP tries to access Advice -> "EggObject not found" error
//
// Without the fix (StandaloneAopContextHook), this test would fail on the second request.
const runner = new Runner(fixturePath);
await runner.init();

// First request - Singleton is created, Advice is created in this context
const result1 = await runner.run();
assert.equal(result1, 'Tool executed: [advised:1] test-input');

// Second request - Singleton is reused, Advice must be re-created
// This would fail without StandaloneAopContextHook!
const result2 = await runner.run();
assert.equal(result2, 'Tool executed: [advised:1] test-input');

// Third request - verify it continues to work
const result3 = await runner.run();
assert.equal(result3, 'Tool executed: [advised:1] test-input');

await runner.destroy();
});
});

describe('load', () => {
let runner: Runner;
afterEach(async () => {
Expand Down
Loading