fix: 降级BangDream启动健康检查

This commit is contained in:
sunlei 2026-07-04 10:23:40 +08:00
parent 2a30049600
commit d6135a2012
4 changed files with 243 additions and 21 deletions

View File

@ -843,13 +843,42 @@ export class QqbotPluginPlatformService
persistedInstallation,
);
await this.startWorker(installation, version);
startedCount += 1;
try {
await this.startWorker(installation, version);
startedCount += 1;
} catch (error) {
await this.recordBuiltinWorkerStartFailure(installation, error);
}
}
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.
* @param descriptor - Manifest descriptor discovered from a controlled plugin package root.

View File

@ -137,6 +137,28 @@ function buildBangDreamRuntimePlugin(
(error instanceof Error ? error.message : `${error}`) ||
'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
* @param operationKey - operationKey executeOperation
@ -176,27 +198,11 @@ function buildBangDreamRuntimePlugin(
/**
* BangDream回调
*/
health: () => context.checkHealth(),
health: checkBangDreamHealth,
/**
* BangDream回调
*/
healthCheck: 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',
};
}
},
healthCheck: checkBangDreamHealth,
key: options.pluginKey || 'bangdream',
legacyKeys: options.legacyAliases,
name: options.name || 'BangDream 查询',

View File

@ -851,6 +851,176 @@ describe('QQBot plugin platform lifecycle runtime contract', () => {
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 () => {
const manifest = {
assets: [],

View File

@ -214,7 +214,10 @@ describe('BangDream package entry', () => {
});
await plugin.activate();
await expect(plugin.health()).resolves.toBe(true);
await expect(plugin.health()).resolves.toMatchObject({
message: 'BangDream 插件可用',
status: 'healthy',
});
await expect(
plugin.executeOperation('bangdream.song.search', { text: '夏祭り' }),
).resolves.toMatchObject({
@ -236,6 +239,20 @@ describe('BangDream package entry', () => {
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 () => {
const plugin = createPlugin({
/**