fix: 修复QQBot插件worker超时恢复

This commit is contained in:
sunlei 2026-06-17 12:31:37 +08:00
parent e53db705aa
commit eb255a0ac6
4 changed files with 103 additions and 11 deletions

View File

@ -236,21 +236,19 @@ export class QqbotPluginWorkerRuntime {
error instanceof QqbotPluginWorkerExpiredRequestError || error instanceof QqbotPluginWorkerExpiredRequestError ||
isNamedError(error, 'QqbotPluginWorkerExpiredRequestError') isNamedError(error, 'QqbotPluginWorkerExpiredRequestError')
) { ) {
const safeSummary = {
correlationId: message.correlationId,
operationId: message.operationId,
timeoutMs,
type,
};
const runtimeError = new QqbotPluginRuntimeError( const runtimeError = new QqbotPluginRuntimeError(
'PLUGIN_WORKER_TIMEOUT', 'PLUGIN_WORKER_TIMEOUT',
this.options.pluginKey, this.options.pluginKey,
'QQBot plugin worker queue request expired.', 'QQBot plugin worker queue request expired.',
{ safeSummary,
correlationId: message.correlationId,
operationId: message.operationId,
timeoutMs,
type,
},
);
this.recordRuntimeEvent(
'worker-request-expired',
runtimeError.safeSummary,
); );
await this.markWorkerFailed('worker-request-expired', safeSummary);
throw runtimeError; throw runtimeError;
} }
if (error instanceof QqbotPluginWorkerResponseError) { if (error instanceof QqbotPluginWorkerResponseError) {

View File

@ -90,7 +90,7 @@
"plugin.config.read", "plugin.config.read",
"plugin.storage.read" "plugin.storage.read"
], ],
"timeoutMs": 30000 "timeoutMs": 120000
}, },
{ {
"key": "bangdream.card.search", "key": "bangdream.card.search",

View File

@ -2,6 +2,7 @@ import type { ConfigService } from '@nestjs/config';
import { import {
createQqbotBullmqWorkerQueueOptions, createQqbotBullmqWorkerQueueOptions,
QqbotPluginRuntimeError, QqbotPluginRuntimeError,
QqbotPluginWorkerExpiredRequestError,
QqbotPluginWorkerResponseError, QqbotPluginWorkerResponseError,
QqbotPluginWorkerRuntime, QqbotPluginWorkerRuntime,
resolveQqbotPluginQueueConnection, resolveQqbotPluginQueueConnection,
@ -111,6 +112,28 @@ class TimeoutAwareRecordingRequestQueue extends RecordingRequestQueue {
readonly queueWaitTimeoutMs = 50; readonly queueWaitTimeoutMs = 50;
} }
class ExpiringOnceRequestQueue implements QqbotPluginWorkerRequestQueue {
constructor(private readonly driver: QqbotPluginWorkerDriver) {}
async close() {
await this.driver.dispose();
}
async request(message: QqbotPluginWorkerRequest): Promise<unknown> {
if (message.operationId === 'op-expire') {
await this.reset();
throw new QqbotPluginWorkerExpiredRequestError(
'worker-request-execution-timeout',
);
}
return this.driver.request(message);
}
async reset() {
await this.driver.dispose();
}
}
const createRuntime = (driver = new RecordingDriver()) => { const createRuntime = (driver = new RecordingDriver()) => {
const runtime = new QqbotPluginWorkerRuntime(new RecordingRequestQueue(driver), { const runtime = new QqbotPluginWorkerRuntime(new RecordingRequestQueue(driver), {
defaultTimeoutMs: 50, defaultTimeoutMs: 50,
@ -349,6 +372,66 @@ describe('QQBot plugin worker runtime', () => {
expect(runtime.status).toBe('active'); expect(runtime.status).toBe('active');
}); });
it('recovers after the queue expires and disposes a slow worker request', async () => {
const requestTypes: string[] = [];
const driver: QqbotPluginWorkerDriver = {
dispose: jest.fn(async () => undefined),
request: jest.fn(async (message) => {
requestTypes.push(message.operationId || message.type);
return {
ok: true,
operationId: message.operationId,
type: message.type,
};
}),
};
const runtime = new QqbotPluginWorkerRuntime(
new ExpiringOnceRequestQueue(driver),
{
defaultTimeoutMs: 50,
installationId: 'install-expired-recover',
pluginKey: 'demo-plugin',
},
);
await runtime.load({
entry: 'src/index.ts',
pluginKey: 'demo-plugin',
version: '0.1.0',
});
await runtime.activate();
await expect(
runtime.executeOperation({
input: { text: 'slow' },
operationId: 'op-expire',
operationKey: 'demo-plugin.echo',
}),
).rejects.toMatchObject({
code: 'PLUGIN_WORKER_TIMEOUT',
});
expect(runtime.status).toBe('failed');
await expect(
runtime.executeOperation({
input: { text: 'after expired' },
operationId: 'op-after-expire',
operationKey: 'demo-plugin.echo',
}),
).resolves.toMatchObject({
operationId: 'op-after-expire',
});
expect(requestTypes).toEqual([
'load',
'activate',
'load',
'activate',
'op-after-expire',
]);
expect(runtime.status).toBe('active');
});
it('sends lifecycle and execution RPC messages with correlation IDs and safe input summaries', async () => { it('sends lifecycle and execution RPC messages with correlation IDs and safe input summaries', async () => {
const { driver, runtime } = createRuntime(); const { driver, runtime } = createRuntime();
driver.responses.set('executeOperation', { replyText: 'ok' }); driver.responses.set('executeOperation', { replyText: 'ok' });

View File

@ -52,4 +52,15 @@ describe('BangDream operation manifest', () => {
}); });
expect(byKey.get('bangdream.unknown')).toBeUndefined(); expect(byKey.get('bangdream.unknown')).toBeUndefined();
}); });
it('keeps heavyweight list renderers on an extended timeout budget', () => {
const manifest = readManifest();
const byKey = new Map(
manifest.operations.map((operation) => [operation.key, operation]),
);
expect(byKey.get('bangdream.song.meta')?.timeoutMs).toBeGreaterThanOrEqual(
120000,
);
});
}); });