-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
4 changed files
with
341 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
}); | ||
}); | ||
}); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters