fix: 降级BangDream启动健康检查
This commit is contained in:
parent
2a30049600
commit
d6135a2012
@ -843,13 +843,42 @@ export class QqbotPluginPlatformService
|
|||||||
persistedInstallation,
|
persistedInstallation,
|
||||||
);
|
);
|
||||||
|
|
||||||
await this.startWorker(installation, version);
|
try {
|
||||||
startedCount += 1;
|
await this.startWorker(installation, version);
|
||||||
|
startedCount += 1;
|
||||||
|
} catch (error) {
|
||||||
|
await this.recordBuiltinWorkerStartFailure(installation, error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return startedCount;
|
return startedCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records one failed built-in worker boot without blocking other built-in plugin packages.
|
||||||
|
* @param installation - Persisted installation whose worker failed during module startup.
|
||||||
|
* @param error - Startup error from worker load, activate, or health checks.
|
||||||
|
*/
|
||||||
|
private async recordBuiltinWorkerStartFailure(
|
||||||
|
installation: QqbotPluginInstallation,
|
||||||
|
error: unknown,
|
||||||
|
) {
|
||||||
|
const message = error instanceof Error ? error.message : `${error}`;
|
||||||
|
await this.updateInstallationRuntime(
|
||||||
|
installation,
|
||||||
|
'enabled',
|
||||||
|
'unhealthy',
|
||||||
|
);
|
||||||
|
await this.recordRuntimeEvent(
|
||||||
|
installation,
|
||||||
|
'builtin-start-failed',
|
||||||
|
'error',
|
||||||
|
{
|
||||||
|
message,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ensures descriptor-discovered built-in package state is persisted.
|
* Ensures descriptor-discovered built-in package state is persisted.
|
||||||
* @param descriptor - Manifest descriptor discovered from a controlled plugin package root.
|
* @param descriptor - Manifest descriptor discovered from a controlled plugin package root.
|
||||||
|
|||||||
@ -137,6 +137,28 @@ function buildBangDreamRuntimePlugin(
|
|||||||
(error instanceof Error ? error.message : `${error}`) ||
|
(error instanceof Error ? error.message : `${error}`) ||
|
||||||
'BangDream 命令执行失败');
|
'BangDream 命令执行失败');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks BangDream runtime health without turning external catalog outages into worker startup failures.
|
||||||
|
* @returns Health payload consumed by the plugin platform and Admin health view.
|
||||||
|
*/
|
||||||
|
const checkBangDreamHealth = async () => {
|
||||||
|
const checkedAt = formatBangDreamCheckedAt(new Date());
|
||||||
|
try {
|
||||||
|
await context.checkHealth();
|
||||||
|
return {
|
||||||
|
checkedAt,
|
||||||
|
message: 'BangDream 插件可用',
|
||||||
|
status: 'healthy',
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
checkedAt,
|
||||||
|
message: normalizeError(error) || 'BangDream 插件不可用',
|
||||||
|
status: 'degraded',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 执行 BangDream 插件局部步骤。
|
* 执行 BangDream 插件局部步骤。
|
||||||
* @param operationKey - operationKey 输入;影响 executeOperation 的返回值。
|
* @param operationKey - operationKey 输入;影响 executeOperation 的返回值。
|
||||||
@ -176,27 +198,11 @@ function buildBangDreamRuntimePlugin(
|
|||||||
/**
|
/**
|
||||||
* 执行 BangDream回调。
|
* 执行 BangDream回调。
|
||||||
*/
|
*/
|
||||||
health: () => context.checkHealth(),
|
health: checkBangDreamHealth,
|
||||||
/**
|
/**
|
||||||
* 执行 BangDream回调。
|
* 执行 BangDream回调。
|
||||||
*/
|
*/
|
||||||
healthCheck: async () => {
|
healthCheck: checkBangDreamHealth,
|
||||||
const checkedAt = formatBangDreamCheckedAt(new Date());
|
|
||||||
try {
|
|
||||||
await context.checkHealth();
|
|
||||||
return {
|
|
||||||
checkedAt,
|
|
||||||
message: 'BangDream 插件可用',
|
|
||||||
status: 'healthy',
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
return {
|
|
||||||
checkedAt,
|
|
||||||
message: normalizeError(error) || 'BangDream 插件不可用',
|
|
||||||
status: 'degraded',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
},
|
|
||||||
key: options.pluginKey || 'bangdream',
|
key: options.pluginKey || 'bangdream',
|
||||||
legacyKeys: options.legacyAliases,
|
legacyKeys: options.legacyAliases,
|
||||||
name: options.name || 'BangDream 查询',
|
name: options.name || 'BangDream 查询',
|
||||||
|
|||||||
@ -851,6 +851,176 @@ describe('QQBot plugin platform lifecycle runtime contract', () => {
|
|||||||
expect(worker.executeOperation).not.toHaveBeenCalled();
|
expect(worker.executeOperation).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('continues starting other built-in workers when one worker health check fails', async () => {
|
||||||
|
/**
|
||||||
|
* 创建测试 manifest。
|
||||||
|
* @param pluginKey - 插件 key;用于区分当前被发现的内置插件包。
|
||||||
|
* @returns 可被插件平台解析的内置插件 manifest。
|
||||||
|
*/
|
||||||
|
const createManifest = (pluginKey: string) => ({
|
||||||
|
assets: [],
|
||||||
|
configSchema: {},
|
||||||
|
entry: 'src/index.ts',
|
||||||
|
events: [],
|
||||||
|
legacyAliases: [],
|
||||||
|
migrations: [],
|
||||||
|
minApiSdkVersion: '1.0.0',
|
||||||
|
name: pluginKey,
|
||||||
|
operations: [
|
||||||
|
{
|
||||||
|
handlerName: 'echo',
|
||||||
|
key: `${pluginKey}.echo`,
|
||||||
|
name: 'Echo',
|
||||||
|
permissions: [],
|
||||||
|
timeoutMs: 123,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
permissions: [],
|
||||||
|
pluginKey,
|
||||||
|
runtime: {
|
||||||
|
maxConcurrency: 1,
|
||||||
|
memoryMb: 128,
|
||||||
|
timeoutMs: 456,
|
||||||
|
workerType: 'node-worker',
|
||||||
|
},
|
||||||
|
version: '0.1.0',
|
||||||
|
});
|
||||||
|
const failingManifest = createManifest('failing-plugin');
|
||||||
|
const healthyManifest = createManifest('healthy-plugin');
|
||||||
|
const savedPlugins: any[] = [];
|
||||||
|
const savedVersions: any[] = [];
|
||||||
|
const savedInstallations: any[] = [];
|
||||||
|
const runtimeEvents: any[] = [];
|
||||||
|
const pluginRepository = {
|
||||||
|
find: jest.fn(async () => savedPlugins),
|
||||||
|
findAndCount: jest.fn(async () => [savedPlugins, savedPlugins.length]),
|
||||||
|
findOne: jest.fn(async ({ where }: any) =>
|
||||||
|
savedPlugins.find((plugin) => plugin.id === where?.id),
|
||||||
|
),
|
||||||
|
save: jest.fn(async (value) => {
|
||||||
|
const row = { id: `plugin-${value.pluginKey}`, ...value };
|
||||||
|
savedPlugins.push(row);
|
||||||
|
return row;
|
||||||
|
}),
|
||||||
|
update: jest.fn(async () => ({ affected: 1 })),
|
||||||
|
};
|
||||||
|
const versionRepository = {
|
||||||
|
findOne: jest.fn(async ({ where }: any) =>
|
||||||
|
savedVersions.find((version) => version.id === where?.id),
|
||||||
|
),
|
||||||
|
save: jest.fn(async (value) => {
|
||||||
|
const row = { id: `version-${value.pluginId}`, ...value };
|
||||||
|
savedVersions.push(row);
|
||||||
|
return row;
|
||||||
|
}),
|
||||||
|
update: jest.fn(async () => ({ affected: 1 })),
|
||||||
|
};
|
||||||
|
const installationRepository = {
|
||||||
|
find: jest.fn(async () => savedInstallations),
|
||||||
|
findAndCount: jest.fn(async () => [
|
||||||
|
savedInstallations,
|
||||||
|
savedInstallations.length,
|
||||||
|
]),
|
||||||
|
findOne: jest.fn(async ({ where }: any) =>
|
||||||
|
savedInstallations.find(
|
||||||
|
(installation) => installation.id === where?.id,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
save: jest.fn(async (value) => {
|
||||||
|
const row = { id: `installation-${value.pluginId}`, ...value };
|
||||||
|
savedInstallations.push(row);
|
||||||
|
return row;
|
||||||
|
}),
|
||||||
|
update: jest.fn(async () => ({ affected: 1 })),
|
||||||
|
};
|
||||||
|
const runtimeEventRepository = {
|
||||||
|
save: jest.fn(async (value) => {
|
||||||
|
runtimeEvents.push(value);
|
||||||
|
return value;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const failingWorker = {
|
||||||
|
activate: jest.fn(async () => ({ ok: true })),
|
||||||
|
deactivate: jest.fn(async () => ({ ok: true })),
|
||||||
|
dispose: jest.fn(async () => undefined),
|
||||||
|
drainRuntimeEvents: jest.fn(() => []),
|
||||||
|
executeOperation: jest.fn(),
|
||||||
|
handleEvent: jest.fn(),
|
||||||
|
health: jest.fn(async () => {
|
||||||
|
throw new Error('Bestdori unavailable');
|
||||||
|
}),
|
||||||
|
load: jest.fn(async () => ({ ok: true })),
|
||||||
|
};
|
||||||
|
const healthyWorker = {
|
||||||
|
activate: jest.fn(async () => ({ ok: true })),
|
||||||
|
deactivate: jest.fn(async () => ({ ok: true })),
|
||||||
|
dispose: jest.fn(async () => undefined),
|
||||||
|
drainRuntimeEvents: jest.fn(() => []),
|
||||||
|
executeOperation: jest.fn(async () => ({ replyText: 'ok' })),
|
||||||
|
handleEvent: jest.fn(async () => true),
|
||||||
|
health: jest.fn(async () => ({ ok: true })),
|
||||||
|
load: jest.fn(async () => ({ ok: true })),
|
||||||
|
};
|
||||||
|
const runtimeFactory = {
|
||||||
|
create: jest.fn((_installation, version) =>
|
||||||
|
version.manifestJson.pluginKey === 'failing-plugin'
|
||||||
|
? failingWorker
|
||||||
|
: healthyWorker,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
const packageSource = {
|
||||||
|
discoverPackages: jest.fn(async () =>
|
||||||
|
[failingManifest, healthyManifest].map((manifest) => ({
|
||||||
|
entry: manifest.entry,
|
||||||
|
entryFile: `D:/plugins/${manifest.pluginKey}/src/index.ts`,
|
||||||
|
manifest,
|
||||||
|
packageRoot: `D:/plugins/${manifest.pluginKey}`,
|
||||||
|
pluginKey: manifest.pluginKey,
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
const service = new (QqbotPluginPlatformService as any)(
|
||||||
|
pluginRepository,
|
||||||
|
versionRepository,
|
||||||
|
installationRepository,
|
||||||
|
{ update: jest.fn(async () => ({ affected: 1 })) },
|
||||||
|
{ update: jest.fn(async () => ({ affected: 1 })) },
|
||||||
|
{ find: jest.fn(async () => []) },
|
||||||
|
{},
|
||||||
|
{},
|
||||||
|
runtimeEventRepository,
|
||||||
|
undefined,
|
||||||
|
runtimeFactory,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
packageSource,
|
||||||
|
) as QqbotPluginPlatformService;
|
||||||
|
|
||||||
|
await expect(service.onModuleInit()).resolves.toBeUndefined();
|
||||||
|
|
||||||
|
expect(failingWorker.dispose).toHaveBeenCalled();
|
||||||
|
expect(healthyWorker.health).toHaveBeenCalled();
|
||||||
|
await expect(
|
||||||
|
service.executeOperation({
|
||||||
|
input: {},
|
||||||
|
operationKey: 'healthy-plugin.echo',
|
||||||
|
pluginKey: 'healthy-plugin',
|
||||||
|
}),
|
||||||
|
).resolves.toEqual({ replyText: 'ok' });
|
||||||
|
expect(runtimeEvents).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
eventType: 'builtin-start-failed',
|
||||||
|
level: 'error',
|
||||||
|
safeSummary: expect.objectContaining({
|
||||||
|
message: 'Bestdori unavailable',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('persists built-in installations before syncing manifest tasks', async () => {
|
it('persists built-in installations before syncing manifest tasks', async () => {
|
||||||
const manifest = {
|
const manifest = {
|
||||||
assets: [],
|
assets: [],
|
||||||
|
|||||||
@ -214,7 +214,10 @@ describe('BangDream package entry', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await plugin.activate();
|
await plugin.activate();
|
||||||
await expect(plugin.health()).resolves.toBe(true);
|
await expect(plugin.health()).resolves.toMatchObject({
|
||||||
|
message: 'BangDream 插件可用',
|
||||||
|
status: 'healthy',
|
||||||
|
});
|
||||||
await expect(
|
await expect(
|
||||||
plugin.executeOperation('bangdream.song.search', { text: '夏祭り' }),
|
plugin.executeOperation('bangdream.song.search', { text: '夏祭り' }),
|
||||||
).resolves.toMatchObject({
|
).resolves.toMatchObject({
|
||||||
@ -236,6 +239,20 @@ describe('BangDream package entry', () => {
|
|||||||
expect(manifestOperations).toHaveLength(15);
|
expect(manifestOperations).toHaveLength(15);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('reports degraded health when catalog health fails instead of throwing', async () => {
|
||||||
|
mockContext.checkHealth.mockRejectedValueOnce(
|
||||||
|
new Error('Bestdori unavailable'),
|
||||||
|
);
|
||||||
|
const plugin = createPlugin({
|
||||||
|
operations: manifestOperations,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(plugin.health()).resolves.toMatchObject({
|
||||||
|
message: 'Bestdori unavailable',
|
||||||
|
status: 'degraded',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('normalizes operation errors without an application-service wrapper', async () => {
|
it('normalizes operation errors without an application-service wrapper', async () => {
|
||||||
const plugin = createPlugin({
|
const plugin = createPlugin({
|
||||||
/**
|
/**
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user