feat(core): Add cluster check reconciliation cycle (no-changelog) (#28936)

This commit is contained in:
Andreas Fitzek
2026-04-29 12:06:31 +00:00
committed by GitHub
parent 9576ab907c
commit ec2e2f11dc
9 changed files with 913 additions and 11 deletions
@@ -42,9 +42,32 @@ const ClusterVersionMismatchSchema = z.object({
// Version mismatch type (for REST API response)
export type ClusterVersionMismatch = z.infer<typeof ClusterVersionMismatchSchema>;
const ClusterCheckWarningSchema = z.object({
check: z.string(),
code: z.string(),
message: z.string(),
severity: z.enum(['info', 'warning', 'error']).optional(),
context: z.record(z.string(), z.unknown()).optional(),
});
const ClusterCheckResultSchema = z.object({
check: z.string(),
executedAt: z.number(),
warnings: z.array(ClusterCheckWarningSchema),
status: z.enum(['succeeded', 'failed']),
});
export type ClusterCheckResult = z.infer<typeof ClusterCheckResultSchema>;
// Single warning raised by a cluster check (for REST API response)
export type ClusterCheckWarning = z.infer<typeof ClusterCheckWarningSchema>;
const ClusterCheckSummarySchema = z.record(z.string(), ClusterCheckResultSchema);
export type ClusterCheckSummary = z.infer<typeof ClusterCheckSummarySchema>;
const ClusterInfoResponseSchema = z.object({
instances: z.array(instanceRegistrationSchema),
versionMismatch: ClusterVersionMismatchSchema.nullable(),
checks: ClusterCheckSummarySchema,
});
// REST API response type
@@ -0,0 +1,340 @@
import type { InstanceRegistration } from '@n8n/api-types';
import type { Logger } from '@n8n/backend-common';
import type {
ClusterCheckContext,
ClusterCheckMetadata,
ClusterCheckResult,
IClusterCheck,
} from '@n8n/decorators';
import { Container } from '@n8n/di';
import { mock } from 'jest-mock-extended';
import type { InstanceSettings } from 'n8n-core';
import type { MessageEventBus } from '@/eventbus/message-event-bus/message-event-bus';
import type { Push } from '@/push';
import { CheckService, computeDiff } from '../checks/check.service';
import type { InstanceRegistryService } from '../instance-registry.service';
import { REGISTRY_CONSTANTS } from '../instance-registry.types';
const makeLogger = () => {
const logger = mock<Logger>();
logger.scoped.mockReturnValue(logger);
return logger;
};
const makeInstance = (override: Partial<InstanceRegistration> = {}): InstanceRegistration => ({
schemaVersion: 1 as const,
instanceKey: 'key',
hostId: 'host',
instanceType: 'main',
instanceRole: 'follower',
version: '1.0.0',
registeredAt: 0,
lastSeen: 0,
...override,
});
const namedClass = (name: string) => {
class Anon {}
Object.defineProperty(Anon, 'name', { value: name });
return Anon as unknown as new () => IClusterCheck;
};
describe('CheckService', () => {
let logger: ReturnType<typeof makeLogger>;
let instanceSettings: InstanceSettings;
let registryService: jest.Mocked<InstanceRegistryService>;
let clusterCheckMetadata: jest.Mocked<ClusterCheckMetadata>;
let messageEventBus: jest.Mocked<MessageEventBus>;
let push: jest.Mocked<Push>;
let containerGet: jest.SpyInstance;
let service: CheckService | undefined;
const buildService = () =>
new CheckService(
logger,
instanceSettings,
registryService,
clusterCheckMetadata,
messageEventBus,
push,
);
beforeEach(() => {
jest.useFakeTimers();
logger = makeLogger();
instanceSettings = mock<InstanceSettings>({ isLeader: false });
registryService = mock<InstanceRegistryService>();
clusterCheckMetadata = mock<ClusterCheckMetadata>();
messageEventBus = mock<MessageEventBus>();
push = mock<Push>();
clusterCheckMetadata.getClasses.mockReturnValue([]);
registryService.getAllInstances.mockResolvedValue([]);
registryService.getLastKnownState.mockResolvedValue(new Map());
registryService.saveLastKnownState.mockResolvedValue();
messageEventBus.sendAuditEvent.mockResolvedValue(undefined);
containerGet = jest.spyOn(Container, 'get');
});
afterEach(() => {
service?.shutdown();
service = undefined;
containerGet.mockRestore();
jest.useRealTimers();
});
it('discovers checks via metadata and DI, skipping failures', () => {
const WorkingCheck = namedClass('WorkingCheck');
const FailingCheck = namedClass('FailingCheck');
const workingInstance: IClusterCheck = {
checkDescription: { name: 'cluster.working' },
async run() {
return {};
},
};
clusterCheckMetadata.getClasses.mockReturnValue([FailingCheck, WorkingCheck]);
containerGet.mockImplementation((cls: unknown) => {
if (cls === WorkingCheck) return workingInstance;
throw new Error('not registered');
});
service = buildService();
service.init();
expect(logger.error).toHaveBeenCalledWith(
'Failed to instantiate cluster check "FailingCheck"',
expect.objectContaining({ error: expect.any(Error) }),
);
expect(logger.info).toHaveBeenCalledWith('Discovered 1 cluster checks', {
names: ['cluster.working'],
});
});
it('runs reconcile immediately on takeover, again every 180s, and stops on stepdown', async () => {
const TickCheck = namedClass('TickCheck');
const runMock = jest
.fn<Promise<ClusterCheckResult>, [ClusterCheckContext]>()
.mockResolvedValue({});
const tickInstance: IClusterCheck = {
checkDescription: { name: 'cluster.tick' },
run: runMock,
};
clusterCheckMetadata.getClasses.mockReturnValue([TickCheck]);
containerGet.mockReturnValue(tickInstance);
service = buildService();
service.init();
expect(runMock).not.toHaveBeenCalled();
service.startReconciliation();
await jest.advanceTimersByTimeAsync(0);
expect(runMock).toHaveBeenCalledTimes(1);
await jest.advanceTimersByTimeAsync(REGISTRY_CONSTANTS.RECONCILIATION_INTERVAL_MS);
expect(runMock).toHaveBeenCalledTimes(2);
service.stopReconciliation();
await jest.advanceTimersByTimeAsync(REGISTRY_CONSTANTS.RECONCILIATION_INTERVAL_MS * 2);
expect(runMock).toHaveBeenCalledTimes(2);
});
it('does not reconcile when not leader, nor after shutdown', async () => {
const NoOp = namedClass('NoOp');
const runMock = jest
.fn<Promise<ClusterCheckResult>, [ClusterCheckContext]>()
.mockResolvedValue({});
clusterCheckMetadata.getClasses.mockReturnValue([NoOp]);
containerGet.mockReturnValue({
checkDescription: { name: 'cluster.noop' },
run: runMock,
});
Object.assign(instanceSettings, { isLeader: false });
service = buildService();
service.init();
await jest.advanceTimersByTimeAsync(REGISTRY_CONSTANTS.RECONCILIATION_INTERVAL_MS * 2);
expect(runMock).not.toHaveBeenCalled();
service.shutdown();
service.startReconciliation();
await jest.advanceTimersByTimeAsync(REGISTRY_CONSTANTS.RECONCILIATION_INTERVAL_MS * 2);
expect(runMock).not.toHaveBeenCalled();
});
it('reconcile forwards warnings/audit/push from runChecks and saves current state', async () => {
const WorkingCheck = namedClass('WorkingCheck');
const workingRun = jest
.fn<Promise<ClusterCheckResult>, [ClusterCheckContext]>()
.mockResolvedValue({
warnings: [{ code: 'cluster.w', message: 'warn msg', severity: 'warning' }],
auditEvents: [{ eventName: 'n8n.audit.cluster.foo', payload: { a: 1 } }],
pushNotifications: [{ type: 'cluster-foo', data: { b: 2 } }],
});
clusterCheckMetadata.getClasses.mockReturnValue([WorkingCheck]);
containerGet.mockReturnValue({
checkDescription: { name: 'cluster.work' },
run: workingRun,
});
const inst = makeInstance({ instanceKey: 'k1' });
registryService.getAllInstances.mockResolvedValue([inst]);
service = buildService();
service.init();
service.startReconciliation();
await jest.advanceTimersByTimeAsync(0);
expect(logger.warn).toHaveBeenCalledWith(
'Cluster check warning',
expect.objectContaining({ check: 'cluster.work', code: 'cluster.w' }),
);
expect(messageEventBus.sendAuditEvent).toHaveBeenCalledWith({
eventName: 'n8n.audit.cluster.foo',
payload: { a: 1 },
});
expect(push.broadcast).toHaveBeenCalledWith({ type: 'cluster-foo', data: { b: 2 } });
expect(registryService.saveLastKnownState).toHaveBeenCalledWith(new Map([['k1', inst]]));
});
describe('runChecks', () => {
it('returns results for succeeded checks, failed markers for thrown checks, and no side effects', async () => {
const WorkingCheck = namedClass('WorkingCheck');
const FailingCheck = namedClass('FailingCheck');
const workingInstance: IClusterCheck = {
checkDescription: { name: 'cluster.work', displayName: 'Work Check' },
run: jest.fn<Promise<ClusterCheckResult>, [ClusterCheckContext]>().mockResolvedValue({
warnings: [{ code: 'cluster.w', message: 'warn msg', severity: 'warning' }],
auditEvents: [{ eventName: 'n8n.audit.cluster.foo', payload: { a: 1 } }],
pushNotifications: [{ type: 'cluster-foo', data: { b: 2 } }],
}),
};
const failingInstance: IClusterCheck = {
checkDescription: { name: 'cluster.fail', displayName: 'Fail Check' },
run: jest.fn().mockRejectedValue(new Error('boom')),
};
clusterCheckMetadata.getClasses.mockReturnValue([WorkingCheck, FailingCheck]);
containerGet.mockImplementation((cls: unknown) => {
if (cls === WorkingCheck) return workingInstance;
if (cls === FailingCheck) return failingInstance;
throw new Error('unexpected class');
});
const inst = makeInstance({ instanceKey: 'k1' });
registryService.getAllInstances.mockResolvedValue([inst]);
service = buildService();
service.init();
const { currentState, results } = await service.runChecks();
expect(currentState).toEqual(new Map([['k1', inst]]));
expect(results).toEqual([
{
checkName: 'cluster.work',
checkDisplayName: 'Work Check',
result: {
warnings: [{ code: 'cluster.w', message: 'warn msg', severity: 'warning' }],
auditEvents: [{ eventName: 'n8n.audit.cluster.foo', payload: { a: 1 } }],
pushNotifications: [{ type: 'cluster-foo', data: { b: 2 } }],
},
},
{
checkName: 'cluster.fail',
checkDisplayName: 'Fail Check',
failed: true,
},
]);
expect(logger.error).toHaveBeenCalledWith(
'Cluster check failed',
expect.objectContaining({ checkName: 'cluster.fail', error: expect.any(Error) }),
);
expect(messageEventBus.sendAuditEvent).not.toHaveBeenCalled();
expect(push.broadcast).not.toHaveBeenCalled();
expect(registryService.saveLastKnownState).not.toHaveBeenCalled();
});
it('passes diff context to checks; short-circuits without I/O when no checks are registered', async () => {
const DiffCheck = namedClass('DiffCheck');
const runMock = jest
.fn<Promise<ClusterCheckResult>, [ClusterCheckContext]>()
.mockResolvedValue({});
clusterCheckMetadata.getClasses.mockReturnValue([DiffCheck]);
containerGet.mockReturnValue({
checkDescription: { name: 'cluster.diff' },
run: runMock,
});
const prev = makeInstance({ instanceKey: 'old' });
const curr = makeInstance({ instanceKey: 'new' });
registryService.getLastKnownState.mockResolvedValue(new Map([['old', prev]]));
registryService.getAllInstances.mockResolvedValue([curr]);
service = buildService();
service.init();
await service.runChecks();
const context = runMock.mock.calls[0][0];
expect(context.currentState).toEqual(new Map([['new', curr]]));
expect(context.previousState).toEqual(new Map([['old', prev]]));
expect(context.diff.added.map((x) => x.instanceKey)).toEqual(['new']);
expect(context.diff.removed.map((x) => x.instanceKey)).toEqual(['old']);
service.shutdown();
service = undefined;
registryService.getAllInstances.mockClear();
registryService.getLastKnownState.mockClear();
clusterCheckMetadata.getClasses.mockReturnValue([]);
service = buildService();
service.init();
const empty = await service.runChecks();
expect(empty).toEqual({ currentState: new Map(), results: [] });
expect(registryService.getAllInstances).not.toHaveBeenCalled();
expect(registryService.getLastKnownState).not.toHaveBeenCalled();
});
});
});
describe('computeDiff', () => {
const i = (key: string, extra: Partial<InstanceRegistration> = {}): InstanceRegistration =>
makeInstance({ instanceKey: key, ...extra });
it.each([
{
name: 'reports added when a key appears only in current',
previous: new Map<string, InstanceRegistration>(),
current: new Map([['a', i('a')]]),
expected: { added: ['a'], removed: [], changed: [] },
},
{
name: 'reports removed when a key is missing from current',
previous: new Map([['a', i('a')]]),
current: new Map<string, InstanceRegistration>(),
expected: { added: [], removed: ['a'], changed: [] },
},
{
name: 'reports changed on meaningful diff; lastSeen-only drift is ignored',
previous: new Map([
['a', i('a', { version: '1.0.0', lastSeen: 0 })],
['b', i('b', { lastSeen: 0 })],
]),
current: new Map([
['a', i('a', { version: '1.1.0', lastSeen: 1 })],
['b', i('b', { lastSeen: 9_999 })],
]),
expected: { added: [], removed: [], changed: ['a'] },
},
])('$name', ({ previous, current, expected }) => {
const diff = computeDiff(previous, current);
expect(diff.added.map((x) => x.instanceKey)).toEqual(expected.added);
expect(diff.removed.map((x) => x.instanceKey)).toEqual(expected.removed);
expect(diff.changed.map((x) => x.current.instanceKey)).toEqual(expected.changed);
});
});
@@ -1,6 +1,7 @@
import type { InstanceRegistration } from '@n8n/api-types';
import { mock } from 'jest-mock-extended';
import type { CheckService } from '../checks/check.service';
import { InstanceRegistryController } from '../instance-registry.controller';
import type { InstanceRegistryService } from '../instance-registry.service';
@@ -19,14 +20,17 @@ const makeRegistration = (overrides: Partial<InstanceRegistration> = {}): Instan
describe('InstanceRegistryController', () => {
let controller: InstanceRegistryController;
let service: jest.Mocked<InstanceRegistryService>;
let checkService: jest.Mocked<CheckService>;
beforeEach(() => {
service = mock<InstanceRegistryService>();
controller = new InstanceRegistryController(service);
checkService = mock<CheckService>();
checkService.runChecks.mockResolvedValue({ currentState: new Map(), results: [] });
controller = new InstanceRegistryController(service, checkService);
});
describe('getClusterInfo', () => {
it('should return instances from the service', async () => {
it('returns instances and an empty check summary when no checks produced results', async () => {
const instances = [
makeRegistration({ instanceKey: 'key-1', hostId: 'host-1' }),
makeRegistration({ instanceKey: 'key-2', hostId: 'host-2', instanceType: 'worker' }),
@@ -36,16 +40,72 @@ describe('InstanceRegistryController', () => {
const result = await controller.getClusterInfo();
expect(result.instances).toEqual(instances);
expect(result.versionMismatch).toBeNull();
expect(result.checks).toEqual({});
});
it('should return empty instances when no instances are registered', async () => {
it('builds a per-check summary from runChecks results (succeeded, warned, and execution-failed)', async () => {
service.getAllInstances.mockResolvedValue([]);
checkService.runChecks.mockResolvedValue({
currentState: new Map(),
results: [
{
checkName: 'cluster.versionMismatch',
result: {
warnings: [
{
code: 'cluster.versionMismatch',
message: 'Detected 2 versions',
severity: 'warning',
context: { versions: ['1.0.0', '1.1.0'] },
},
],
},
},
{
checkName: 'cluster.quiet',
result: {},
},
{
checkName: 'cluster.broken',
failed: true,
},
],
});
const result = await controller.getClusterInfo();
expect(result.instances).toEqual([]);
expect(result.versionMismatch).toBeNull();
expect(result.checks['cluster.versionMismatch']).toMatchObject({
check: 'cluster.versionMismatch',
status: 'failed',
warnings: [
{
check: 'cluster.versionMismatch',
code: 'cluster.versionMismatch',
message: 'Detected 2 versions',
severity: 'warning',
context: { versions: ['1.0.0', '1.1.0'] },
},
],
});
expect(typeof result.checks['cluster.versionMismatch'].executedAt).toBe('number');
expect(result.checks['cluster.quiet']).toMatchObject({
check: 'cluster.quiet',
status: 'succeeded',
warnings: [],
});
expect(result.checks['cluster.broken']).toMatchObject({
check: 'cluster.broken',
status: 'failed',
warnings: [
{
check: 'cluster.broken',
code: 'cluster.check-execution-failed',
severity: 'warning',
},
],
});
});
});
});
@@ -0,0 +1,78 @@
import type { InstanceRegistration } from '@n8n/api-types';
import type { ClusterCheckContext, ClusterStateDiff } from '@n8n/decorators';
import { VersionMismatchCheck } from '../checks/version-mismatch.check';
const makeInstance = (override: Partial<InstanceRegistration> = {}): InstanceRegistration => ({
schemaVersion: 1 as const,
instanceKey: 'key',
hostId: 'host',
instanceType: 'main',
instanceRole: 'follower',
version: '1.0.0',
registeredAt: 0,
lastSeen: 0,
...override,
});
const emptyDiff: ClusterStateDiff = {
added: [],
removed: [],
changed: [],
};
const makeContext = (currentState: Map<string, InstanceRegistration>): ClusterCheckContext => ({
currentState,
previousState: new Map(),
diff: emptyDiff,
});
describe('VersionMismatchCheck', () => {
const check = new VersionMismatchCheck();
it('returns no warnings when currentState is empty', async () => {
const result = await check.run(makeContext(new Map()));
expect(result).toEqual({});
});
it('returns no warnings when a single instance is present', async () => {
const result = await check.run(
makeContext(new Map([['k1', makeInstance({ instanceKey: 'k1', version: '1.0.0' })]])),
);
expect(result).toEqual({});
});
it('returns no warnings when multiple instances share a version', async () => {
const result = await check.run(
makeContext(
new Map([
['k1', makeInstance({ instanceKey: 'k1', version: '1.0.0' })],
['k2', makeInstance({ instanceKey: 'k2', version: '1.0.0' })],
['k3', makeInstance({ instanceKey: 'k3', version: '1.0.0' })],
]),
),
);
expect(result).toEqual({});
});
it('emits a version-mismatch warning when distinct versions are present', async () => {
const result = await check.run(
makeContext(
new Map([
['k1', makeInstance({ instanceKey: 'k1', version: '1.0.0' })],
['k2', makeInstance({ instanceKey: 'k2', version: '1.1.0' })],
['k3', makeInstance({ instanceKey: 'k3', version: '1.0.0' })],
]),
),
);
expect(result.warnings).toHaveLength(1);
const [warning] = result.warnings!;
expect(warning.code).toBe('cluster.version-mismatch');
expect(warning.severity).toBe('error');
expect(warning.context).toEqual({ versions: ['1.0.0', '1.1.0'] });
});
});
@@ -0,0 +1,310 @@
import type { InstanceRegistration, PushMessage } from '@n8n/api-types';
import { Logger } from '@n8n/backend-common';
import type {
ClusterCheckAuditEvent,
ClusterCheckContext,
ClusterCheckPushNotification,
ClusterCheckResult,
ClusterCheckWarning,
ClusterStateDiff,
IClusterCheck,
} from '@n8n/decorators';
import {
ClusterCheckMetadata,
OnLeaderStepdown,
OnLeaderTakeover,
OnShutdown,
} from '@n8n/decorators';
import { Container, Service } from '@n8n/di';
import { InstanceSettings } from 'n8n-core';
import type { EventNamesAuditType } from '@/eventbus/event-message-classes';
import type { EventPayloadAudit } from '@/eventbus/event-message-classes/event-message-audit';
import { MessageEventBus } from '@/eventbus/message-event-bus/message-event-bus';
import { Push } from '@/push';
import { InstanceRegistryService } from '../instance-registry.service';
import { REGISTRY_CONSTANTS } from '../instance-registry.types';
/**
* Leader-only service that reconciles cluster state and runs health checks
* on a fixed interval. Discovers checks via `ClusterCheckMetadata`, fans them
* out with error isolation, and forwards each result into logs, the audit
* event bus, and the push channel.
*/
@Service()
export class CheckService {
private reconcileController?: AbortController;
private reconcileTimer: NodeJS.Timeout | undefined;
private isShuttingDown = false;
private readonly checks: IClusterCheck[] = [];
private readonly logger: Logger;
constructor(
logger: Logger,
private readonly instanceSettings: InstanceSettings,
private readonly instanceRegistryService: InstanceRegistryService,
private readonly clusterCheckMetadata: ClusterCheckMetadata,
private readonly messageEventBus: MessageEventBus,
private readonly push: Push,
) {
this.logger = logger.scoped('instance-registry');
}
init() {
this.discoverChecks();
if (this.instanceSettings.isLeader) this.startReconciliation();
}
@OnLeaderTakeover()
startReconciliation() {
if (this.isShuttingDown || this.reconcileController) return;
this.reconcileController = new AbortController();
const { signal } = this.reconcileController;
void this.runReconcileSafely(signal);
this.scheduleNextReconcile(signal);
this.logger.debug('Cluster check reconciliation scheduled');
}
@OnLeaderStepdown()
stopReconciliation() {
this.reconcileController?.abort();
this.reconcileController = undefined;
clearTimeout(this.reconcileTimer);
this.reconcileTimer = undefined;
}
@OnShutdown()
shutdown() {
this.isShuttingDown = true;
this.stopReconciliation();
}
private scheduleNextReconcile(signal: AbortSignal) {
if (signal.aborted) return;
this.reconcileTimer = setTimeout(async () => {
await this.runReconcileSafely(signal);
this.scheduleNextReconcile(signal);
}, REGISTRY_CONSTANTS.RECONCILIATION_INTERVAL_MS);
}
private discoverChecks() {
const checkClasses = this.clusterCheckMetadata.getClasses();
for (const CheckClass of checkClasses) {
try {
const check = Container.get(CheckClass);
this.checks.push(check);
} catch (error) {
this.logger.error(`Failed to instantiate cluster check "${CheckClass.name}"`, {
error,
});
}
}
this.logger.info(`Discovered ${this.checks.length} cluster checks`, {
names: this.checks.map((c) => c.checkDescription.name),
});
}
private async runReconcileSafely(signal: AbortSignal) {
try {
await this.reconcile(signal);
} catch (error) {
this.logger.warn('Reconciliation cycle failed', { error });
}
}
/**
* Evaluates all registered cluster checks against the current cluster state
* and returns their aggregated results. Side-effect free: does not dispatch
* warnings/audit events/push notifications and does not persist state.
*
* Safe to call from any instance (leader or follower), e.g. from the REST
* controller serving the cluster overview UI. The leader's scheduled
* reconciliation loop is the single writer of `lastKnownState`; this method
* only reads it to build the diff context for checks.
*/
async runChecks(): Promise<{
currentState: Map<string, InstanceRegistration>;
results: Array<{
checkName: string;
checkDisplayName?: string;
result?: ClusterCheckResult;
failed?: true;
}>;
}> {
if (this.checks.length === 0) {
return { currentState: new Map(), results: [] };
}
const instances = await this.instanceRegistryService.getAllInstances();
const currentState = new Map<string, InstanceRegistration>(
instances.map((i) => [i.instanceKey, i]),
);
const previousState = await this.instanceRegistryService.getLastKnownState();
const diff = computeDiff(previousState, currentState);
const context: ClusterCheckContext = { currentState, previousState, diff };
const settled = await Promise.allSettled(
this.checks.map(async (check) => await check.run(context)),
);
const results: Array<{
checkName: string;
checkDisplayName?: string;
result?: ClusterCheckResult;
failed?: true;
}> = [];
for (let i = 0; i < settled.length; i++) {
const outcome = settled[i];
const check = this.checks[i];
const checkResult: {
checkName: string;
checkDisplayName?: string;
result?: ClusterCheckResult;
failed?: true;
} = {
checkName: check.checkDescription.name,
checkDisplayName: check.checkDescription.displayName,
};
if (outcome.status === 'fulfilled') {
checkResult.result = outcome.value;
} else {
this.logger.error('Cluster check failed', {
...checkResult,
error: outcome.reason,
});
checkResult.failed = true;
}
results.push(checkResult);
}
return { currentState, results };
}
private async reconcile(signal: AbortSignal) {
if (this.checks.length === 0) return;
if (signal.aborted) return;
const { currentState, results } = await this.runChecks();
if (signal.aborted) return;
for (const { checkName, result } of results) {
this.processResult(checkName, result);
}
try {
if (signal.aborted) return;
await this.instanceRegistryService.saveLastKnownState(currentState);
} catch (error) {
this.logger.warn('Failed to persist last known cluster state', { error });
}
}
private processResult(checkName: string, result?: ClusterCheckResult) {
for (const warning of result?.warnings ?? []) {
this.logWarning(checkName, warning);
}
for (const event of result?.auditEvents ?? []) {
this.emitAuditEvent(checkName, event);
}
for (const notification of result?.pushNotifications ?? []) {
this.broadcastPush(checkName, notification);
}
}
private logWarning(checkName: string, warning: ClusterCheckWarning) {
const severity = warning.severity ?? 'warning';
const method = severity === 'info' ? 'info' : severity === 'error' ? 'error' : 'warn';
this.logger[method]('Cluster check warning', {
check: checkName,
code: warning.code,
message: warning.message,
context: warning.context,
});
}
private emitAuditEvent(checkName: string, event: ClusterCheckAuditEvent) {
void this.messageEventBus
.sendAuditEvent({
eventName: event.eventName as EventNamesAuditType,
payload: event.payload as unknown as EventPayloadAudit,
})
.catch((error: unknown) => {
this.logger.warn('Failed to emit cluster check audit event', {
check: checkName,
eventName: event.eventName,
error,
});
});
}
private broadcastPush(checkName: string, notification: ClusterCheckPushNotification) {
try {
this.push.broadcast({
type: notification.type,
data: notification.data,
} as PushMessage);
} catch (error) {
this.logger.warn('Failed to broadcast cluster check push notification', {
check: checkName,
type: notification.type,
error,
});
}
}
}
/**
* Computes the structured diff between two cluster state snapshots keyed by
* `instanceKey`. Exported for direct unit testing.
*
* Equality for the `changed` bucket ignores `lastSeen` — heartbeats refresh it
* every 30s, which would otherwise flag every instance on every cycle.
*/
export function computeDiff(
previousState: ReadonlyMap<string, InstanceRegistration>,
currentState: ReadonlyMap<string, InstanceRegistration>,
): ClusterStateDiff {
const added: InstanceRegistration[] = [];
const removed: InstanceRegistration[] = [];
const changed: Array<{ previous: InstanceRegistration; current: InstanceRegistration }> = [];
for (const [key, current] of currentState) {
const previous = previousState.get(key);
if (!previous) {
added.push(current);
} else if (!isEquivalent(previous, current)) {
changed.push({ previous, current });
}
}
for (const [key, previous] of previousState) {
if (!currentState.has(key)) removed.push(previous);
}
return { added, removed, changed };
}
function isEquivalent(a: InstanceRegistration, b: InstanceRegistration): boolean {
return (
a.schemaVersion === b.schemaVersion &&
a.instanceKey === b.instanceKey &&
a.hostId === b.hostId &&
a.instanceType === b.instanceType &&
a.instanceRole === b.instanceRole &&
a.version === b.version
);
}
@@ -0,0 +1 @@
import './version-mismatch.check';
@@ -0,0 +1,39 @@
import {
ClusterCheck,
ClusterCheckContext,
ClusterCheckResult,
IClusterCheck,
} from '@n8n/decorators';
@ClusterCheck()
export class VersionMismatchCheck implements IClusterCheck {
constructor() {}
checkDescription = {
name: 'version-mismatch',
displayName: 'Version mismatch',
};
async run(context: ClusterCheckContext): Promise<ClusterCheckResult> {
const allInstanceVersions = [...context.currentState.values()].map((i) => i.version);
const versions = [...new Set<string>(allInstanceVersions)];
if (versions.length <= 1) {
// Zero instances or a single version — no mismatch.
return {};
}
return {
warnings: [
{
code: 'cluster.version-mismatch',
message: `Detected multiple N8N versions in the cluster!: ${versions.join(', ')}`,
severity: 'error',
context: {
versions,
},
},
],
};
}
}
@@ -1,20 +1,67 @@
import type { ClusterInfoResponse } from '@n8n/api-types';
import type { ClusterCheckSummary, ClusterInfoResponse } from '@n8n/api-types';
import { Get, GlobalScope, RestController } from '@n8n/decorators';
import { CheckService } from './checks/check.service';
import { InstanceRegistryService } from './instance-registry.service';
@RestController('/instance-registry')
export class InstanceRegistryController {
constructor(private readonly instanceRegistryService: InstanceRegistryService) {}
constructor(
private readonly instanceRegistryService: InstanceRegistryService,
private readonly checkService: CheckService,
) {}
@Get('/')
@GlobalScope('orchestration:read')
async getClusterInfo(): Promise<ClusterInfoResponse> {
const instances = await this.instanceRegistryService.getAllInstances();
const [instances, { results }] = await Promise.all([
this.instanceRegistryService.getAllInstances(),
this.checkService.runChecks(),
]);
const now = Date.now();
const checks = results.reduce((acc, cur) => {
const { checkName, result, failed } = cur;
if (!acc[checkName]) {
acc[checkName] = {
status: 'succeeded',
check: cur.checkName,
executedAt: now,
warnings: [],
};
}
const entry = acc[checkName];
const newWarnings =
result?.warnings?.map((w) => ({
check: checkName,
...w, // Assuming the warning object structure matches
})) ?? [];
if (newWarnings.length > 0) {
entry.status = 'failed';
entry.warnings.push(...newWarnings);
}
if (failed) {
entry.status = 'failed';
entry.warnings.push({
check: cur.checkName,
code: 'cluster.check-execution-failed',
message: 'Failed to execute cluster check, please check error logs for details',
severity: 'warning',
});
}
return acc;
}, {} as ClusterCheckSummary);
return {
instances,
versionMismatch: null,
checks,
};
}
}
@@ -34,6 +34,10 @@ export class InstanceRegistryModule implements ModuleInterface {
const { StaleMemberCleanupService } = await import('./stale-member-cleanup.service');
Container.get(StaleMemberCleanupService).init();
await import('./checks');
const { CheckService } = await import('./checks/check.service');
Container.get(CheckService).init();
}
@OnShutdown()