diff --git a/src/health-monitor.js b/src/health-monitor.js new file mode 100644 index 0000000..865d61e --- /dev/null +++ b/src/health-monitor.js @@ -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; diff --git a/src/index.js b/src/index.js index e66dd23..6d6412a 100644 --- a/src/index.js +++ b/src/index.js @@ -4,6 +4,7 @@ * Module dependencies. */ +const HealthMonitor = require('./health-monitor'); const log = require('debugnyan')('process-manager'); const utils = require('./utils'); @@ -26,6 +27,7 @@ class ProcessManager { constructor() { this.errors = []; this.forceShutdown = utils.deferred(); + this.healthMonitor = new HealthMonitor(); this.hooks = []; this.running = []; this.terminating = false; @@ -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)); diff --git a/test/src/health-monitor.test.js b/test/src/health-monitor.test.js new file mode 100644 index 0000000..af61183 --- /dev/null +++ b/test/src/health-monitor.test.js @@ -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 + }); + }); + }); + }); +}); diff --git a/test/index.test.js b/test/src/index.test.js similarity index 99% rename from test/index.test.js rename to test/src/index.test.js index 2c11c68..9ed38c7 100644 --- a/test/index.test.js +++ b/test/src/index.test.js @@ -13,7 +13,7 @@ describe('ProcessManager', () => { jest.spyOn(process, 'on').mockImplementation(() => {}); jest.spyOn(console, 'error').mockImplementation(() => {}); - processManager = require('../src'); + processManager = require('../../src'); }); describe('constructor()', () => {