Skip to content

Commit

Permalink
Add health monitor
Browse files Browse the repository at this point in the history
  • Loading branch information
Americas committed Apr 14, 2022
1 parent e7f0e76 commit b199cd3
Show file tree
Hide file tree
Showing 4 changed files with 341 additions and 1 deletion.
106 changes: 106 additions & 0 deletions src/health-monitor.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
'use strict';

/**
* Module dependencies.
*/

const log = require('debugnyan')('process-manager:health-monitor');

/**
* `HealthMonitor`.
*/

class HealthMonitor {
/**
* Constructor.
*/

constructor() {
this.checks = [];
this.globalState = HealthMonitor.states.UNKNOWN;
this.states = {};
}

/**
* Add health check.
*/

addCheck({ handler, id, interval = 5000 }) {
if (this.states[id]) {
throw new Error('Cannot add handler since it would overwrite an existing one');
}

this.states[id] = HealthMonitor.states.UNKNOWN;

const check = setInterval(async () => {
let state;

try {
state = await handler() ? HealthMonitor.states.HEALTHY : HealthMonitor.states.UNHEALTHY;
} catch (e) {
state = HealthMonitor.states.UNHEALTHY;
}

this.updateState({ id, state });
}, interval);

this.checks.push(check);
}

/**
* Cleanup health monitor by clearing all timers and resetting the internal state.
*/

cleanup() {
this.checks.forEach(clearInterval);

this.checks = [];
this.globalState = HealthMonitor.states.UNKNOWN;
this.states = {};
}

/**
* Handles state changes.
*/

updateState({ id, state }) {
if (this.states[id] === state) {
return;
}

log.info({ id, newState: state, oldState: this.states[id] }, 'Component health status has changed');

this.states[id] = state;

// The sorted states array makes it so that the state at the end of the array is the relevant one.
// The global state is:
// - UNKNOWN if one exists.
// - UNHEALTHY if one exists and there are no UNKNOWN states.
// - HEALTHY if there are no UNKNOWN and UNHEALTHY states.
const globalState = Object.values(this.states).sort().at(-1);

if (this.globalState === globalState) {
return;
}

log.info({ newState: globalState, oldState: this.globalState }, 'Global health status has changed');

this.globalState = globalState;
}
}

/**
* Health states.
*/

HealthMonitor.states = {
HEALTHY: 'healthy',
UNHEALTHY: 'unhealthy',
UNKNOWN: 'unknown'
};

/**
* Export `HealthMonitor` class.
*/

module.exports = HealthMonitor;
3 changes: 3 additions & 0 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
* Module dependencies.
*/

const HealthMonitor = require('./health-monitor');
const log = require('debugnyan')('process-manager');
const utils = require('./utils');

Expand All @@ -26,6 +27,7 @@ class ProcessManager {
constructor() {
this.errors = [];
this.forceShutdown = utils.deferred();
this.healthMonitor = new HealthMonitor();
this.hooks = [];
this.running = [];
this.terminating = false;
Expand Down Expand Up @@ -183,6 +185,7 @@ class ProcessManager {
.then(() => log.info('All running instances have stopped'))
.then(() => this.hook('drain'))
.then(() => log.info(`${this.hooks.filter(hook => hook.type === 'drain').length} server(s) drained`))
.then(() => this.healthMonitor.cleanup())
.then(() => this.hook('disconnect'))
.then(() => log.info(`${this.hooks.filter(hook => hook.type === 'disconnect').length} service(s) disconnected`))
.then(() => this.hook('exit', this.errors));
Expand Down
231 changes: 231 additions & 0 deletions test/src/health-monitor.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
'use strict';

/**
* Module Dependencies.
*/

const HealthMonitor = require('../../src/health-monitor');
const log = require('debugnyan')('process-manager:health-monitor');

/**
* Instances.
*/

const healthMonitor = new HealthMonitor();

/**
* Test `HealthMonitor`.
*/

describe('HealthMonitor', () => {
afterEach(() => {
healthMonitor.cleanup();
});

describe('constructor()', () => {
it('should set up the default values', () => {
const healthMonitor = new HealthMonitor();

expect(healthMonitor.checks).toEqual([]);
expect(healthMonitor.globalState).toBe(HealthMonitor.states.UNKNOWN);
expect(healthMonitor.states).toEqual({});
});
});

describe('addCheck()', () => {
it('should throw an error if the `id` is already in use', () => {
healthMonitor.addCheck({ handler: () => {}, id: 'foo' });

try {
healthMonitor.addCheck({ handler: () => {}, id: 'foo' });

fail();
} catch (e) {
expect(e).toBeInstanceOf(Error);
expect(e.message).toBe('Cannot add handler since it would overwrite an existing one');
}
});

it('should add the check and setup the state', () => {
healthMonitor.addCheck({ handler: () => {}, id: 'foo' });

expect(healthMonitor.checks).toHaveLength(1);
expect(healthMonitor.states.foo).toBe(HealthMonitor.states.UNKNOWN);
});

describe('when running the check', () => {
it('should call `healthMonitor.updateState()` with `UNHEALTHY` status if the check throws an error', done => {
const handler = jest.fn(() => {
throw new Error();
});

jest.spyOn(healthMonitor, 'updateState').mockImplementation(({ id, state }) => {
expect(handler).toHaveBeenCalledTimes(1);
expect(id).toBe('foo');
expect(state).toBe(HealthMonitor.states.UNHEALTHY);
done();
});

healthMonitor.addCheck({ handler, id: 'foo', interval: 0 });
});

it('should call `healthMonitor.updateState()` with `UNHEALTHY` status if the check returns a falsy value', done => {
const handler = jest.fn(() => {});

jest.spyOn(healthMonitor, 'updateState').mockImplementation(({ id, state }) => {
expect(handler).toHaveBeenCalledTimes(1);
expect(id).toBe('foo');
expect(state).toBe(HealthMonitor.states.UNHEALTHY);
done();
});

healthMonitor.addCheck({ handler, id: 'foo', interval: 0 });
});

it('should call `healthMonitor.updateState()` with `HEALTHY` status if the check returns a truthy value', done => {
const handler = jest.fn(() => true);

jest.spyOn(healthMonitor, 'updateState').mockImplementation(({ id, state }) => {
expect(handler).toHaveBeenCalledTimes(1);
expect(id).toBe('foo');
expect(state).toBe(HealthMonitor.states.HEALTHY);
done();
});

healthMonitor.addCheck({ handler, id: 'foo', interval: 0 });
});

it('should call handle asynchronous checks', done => {
const handler = jest.fn(() => Promise.resolve(true));

jest.spyOn(healthMonitor, 'updateState').mockImplementation(({ id, state }) => {
expect(handler).toHaveBeenCalledTimes(1);
expect(id).toBe('foo');
expect(state).toBe(HealthMonitor.states.HEALTHY);
done();
});

healthMonitor.addCheck({ handler, id: 'foo', interval: 0 });
});
});
});

describe('cleanup()', () => {
it('should clear all checks and states currently running', async () => {
healthMonitor.checks.push(setInterval(() => {}, 5000));
healthMonitor.states.foo = HealthMonitor.states.HEALTHY;
healthMonitor.globalState = HealthMonitor.states.HEALTHY;

healthMonitor.cleanup();

expect(healthMonitor.checks).toHaveLength(0);
expect(healthMonitor.states).toEqual({});
expect(healthMonitor.globalState).toBe(HealthMonitor.states.UNKNOWN);
});
});

describe('updateState()', () => {
it('should not update the component state if it has not changed', () => {
healthMonitor.states.foo = HealthMonitor.states.HEALTHY;

jest.spyOn(log, 'info');

healthMonitor.updateState({ id: 'foo', state: HealthMonitor.states.HEALTHY });

expect(log.info).not.toHaveBeenCalled();

expect(healthMonitor.states.foo).toBe(HealthMonitor.states.HEALTHY);
});

it('should update the component state if it has changed', () => {
healthMonitor.states.foo = HealthMonitor.states.HEALTHY;
healthMonitor.globalState = HealthMonitor.states.UNHEALTHY;

jest.spyOn(log, 'info');

healthMonitor.updateState({ id: 'foo', state: HealthMonitor.states.UNHEALTHY });

expect(log.info).toHaveBeenCalledTimes(1);
expect(log.info).toHaveBeenCalledWith({
id: 'foo',
newState: HealthMonitor.states.UNHEALTHY,
oldState: HealthMonitor.states.HEALTHY
}, 'Component health status has changed');

expect(healthMonitor.states.foo).toBe(HealthMonitor.states.UNHEALTHY);
});

it('should not update the global state if it has not changed', () => {
healthMonitor.states.foo = HealthMonitor.states.UNHEALTHY;
healthMonitor.globalState = HealthMonitor.states.HEALTHY;

jest.spyOn(log, 'info');

healthMonitor.updateState({ id: 'foo', state: HealthMonitor.states.HEALTHY });

expect(log.info).toHaveBeenCalledTimes(1);

expect(healthMonitor.states.foo).toBe(HealthMonitor.states.HEALTHY);
expect(healthMonitor.globalState).toBe(HealthMonitor.states.HEALTHY);
});

it('should update the global state if it has changed', () => {
healthMonitor.states.foo = HealthMonitor.states.HEALTHY;
healthMonitor.globalState = HealthMonitor.states.HEALTHY;

jest.spyOn(log, 'info');

healthMonitor.updateState({ id: 'foo', state: HealthMonitor.states.UNHEALTHY });

expect(log.info).toHaveBeenCalledTimes(2);
expect(log.info).toHaveBeenLastCalledWith({
newState: HealthMonitor.states.UNHEALTHY,
oldState: HealthMonitor.states.HEALTHY
}, 'Global health status has changed');

expect(healthMonitor.states.foo).toBe(HealthMonitor.states.UNHEALTHY);
expect(healthMonitor.globalState).toBe(HealthMonitor.states.UNHEALTHY);
});

describe('global state', () => {
it('should be UNKNOWN if at least one of the components is in the UNKNOWN state', () => {
healthMonitor.updateState({ id: 'foo', state: HealthMonitor.states.HEALTHY });
healthMonitor.updateState({ id: 'bar', state: HealthMonitor.states.UNHEALTHY });
healthMonitor.updateState({ id: 'biz', state: HealthMonitor.states.UNKNOWN });

expect(healthMonitor.globalState).toBe(HealthMonitor.states.UNKNOWN);
expect(healthMonitor.states).toEqual({
bar: HealthMonitor.states.UNHEALTHY,
biz: HealthMonitor.states.UNKNOWN,
foo: HealthMonitor.states.HEALTHY
});
});

it('should be UNHEALTHY if no component is in the UNKNOWN state and at least one of the components is in the UNHEALTHY state', () => {
healthMonitor.updateState({ id: 'foo', state: HealthMonitor.states.HEALTHY });
healthMonitor.updateState({ id: 'bar', state: HealthMonitor.states.UNHEALTHY });
healthMonitor.updateState({ id: 'biz', state: HealthMonitor.states.HEALTHY });

expect(healthMonitor.globalState).toBe(HealthMonitor.states.UNHEALTHY);
expect(healthMonitor.states).toEqual({
bar: HealthMonitor.states.UNHEALTHY,
biz: HealthMonitor.states.HEALTHY,
foo: HealthMonitor.states.HEALTHY
});
});

it('should be HEALTHY if all components are in the HEALTHY state', () => {
healthMonitor.updateState({ id: 'foo', state: HealthMonitor.states.HEALTHY });
healthMonitor.updateState({ id: 'bar', state: HealthMonitor.states.HEALTHY });
healthMonitor.updateState({ id: 'biz', state: HealthMonitor.states.HEALTHY });

expect(healthMonitor.globalState).toBe(HealthMonitor.states.HEALTHY);
expect(healthMonitor.states).toEqual({
bar: HealthMonitor.states.HEALTHY,
biz: HealthMonitor.states.HEALTHY,
foo: HealthMonitor.states.HEALTHY
});
});
});
});
});
2 changes: 1 addition & 1 deletion test/index.test.js → test/src/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ describe('ProcessManager', () => {
jest.spyOn(process, 'on').mockImplementation(() => {});
jest.spyOn(console, 'error').mockImplementation(() => {});

processManager = require('../src');
processManager = require('../../src');
});

describe('constructor()', () => {
Expand Down

0 comments on commit b199cd3

Please sign in to comment.