Skip to content

Commit

Permalink
feat: Add the ability to prime a breaker with previous stats (#568)
Browse files Browse the repository at this point in the history
* This adds a new static method on the `CircuitBreaker` object called `newStatus` that takes some stats and will return a `Status` object, which can then be passed to the `CircuitBreaker` constructor to pre-populate the Status stats


Co-authored-by: Lance Ball <[email protected]>
  • Loading branch information
lholmquist and lance authored May 6, 2021
1 parent 4c79b2e commit be26d74
Show file tree
Hide file tree
Showing 5 changed files with 252 additions and 5 deletions.
5 changes: 4 additions & 1 deletion .eslintrc
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@
"es6": true,
"node": true
},
"parserOptions": {
"ecmaVersion": 2020
},
"rules": {
"standard/no-callback-literal": "off",
"arrow-spacing": "error",
Expand All @@ -23,4 +26,4 @@
"quotes": ["error", "single", { "allowTemplateLiterals": true }]
}

}
}
50 changes: 50 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,56 @@ const breaker = new CircuitBreaker(delay);
breaker.fire(20000, 1, 2, 3);
breaker.fallback((delay, a, b, c) => `Sorry, out of service right now. But your parameters are: ${delay}, ${a}, ${b} and ${c}`);
```
### Status Initialization

There may be times where you will need to pre-populate the stats of the Circuit Breaker Status Object. Primary use cases for this are in a serverless environment such as Knative or AWS Lambda, or any container based platform, where the container being deployed is ephemeral.

Getting the existing cumalative stats for a breaker can be done like this:

```
const stats = breaker.stats;
```

`stats` will be an object that might look similar to this:

```
{
failures: 11,
fallbacks: 0,
successes: 5,
rejects: 0,
fires: 16,
timeouts: 0,
cacheHits: 0,
cacheMisses: 0,
semaphoreRejections: 0,
percentiles: {
'0': 0,
'1': 0,
'0.25': 0,
'0.5': 0,
'0.75': 0,
'0.9': 0,
'0.95': 0,
'0.99': 0,
'0.995': 0
},
latencyTimes: [ 0 ],
latencyMean: 0
}
```

To then re-import those stats, first create a new `Status` object with the previous stats and then pass that as an option to the CircuitBreaker constructor:

```
const statusOptions = {
stats: {....}
};
const newStatus = CircuitBreaker.newStatus(statusOptions);
const breaker = new CircuitBreaker({status: newStatus});
```

### Browser

Expand Down
31 changes: 30 additions & 1 deletion lib/circuit.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ Please use options.errorThresholdPercentage`;
* @extends EventEmitter
* @param {Function} action The action to fire for this {@link CircuitBreaker}
* @param {Object} options Options for the {@link CircuitBreaker}
* @param {Status} options.status A {@link Status} object that might
* have pre-prime stats
* @param {Number} options.timeout The time in milliseconds that action should
* be allowed to execute before timing out. Timeout can be disabled by setting
* this to `false`. Default 10000 (10 seconds)
Expand Down Expand Up @@ -110,6 +112,20 @@ class CircuitBreaker extends EventEmitter {
return !!error[OUR_ERROR];
}

/**
* Create a new Status object,
* helpful when you need to prime a breaker with stats
* @param {Object} options -
* @param {Number} options.rollingCountBuckets number of buckets in the window
* @param {Number} options.rollingCountTimeout the duration of the window
* @param {Boolean} options.rollingPercentilesEnabled whether to calculate
* @param {Object} options.stats user supplied stats
* @returns {Status} a new {@link Status} object
*/
static newStatus(options) {
return new Status(options);
}

constructor (action, options = {}) {
super();
this.options = options;
Expand Down Expand Up @@ -138,7 +154,20 @@ class CircuitBreaker extends EventEmitter {
this[VOLUME_THRESHOLD] = Number.isInteger(options.volumeThreshold)
? options.volumeThreshold : 0;
this[WARMING_UP] = options.allowWarmUp === true;
this[STATUS] = new Status(this.options);

// The user can pass in a Status object to inialize the Status/stats
if (this.options.status) {
// Do a check that this is a Status Object,
if (this.options.status instanceof Status) {
this[STATUS] = this.options.status;
} else {
// should it error if it is not a Status Object
throw new TypeError('Not a Status Object. The status option must be an instance of Status');
}
} else {
this[STATUS] = new Status(this.options);
}

this[STATE] = CLOSED;
this[FALLBACK_FUNCTION] = null;
this[PENDING_CLOSE] = false;
Expand Down
12 changes: 9 additions & 3 deletions lib/status.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ const EventEmitter = require('events').EventEmitter;
* @param {Number} options.rollingCountTimeout the duration of the window
* @param {Boolean} options.rollingPercentilesEnabled whether to calculate
* percentiles
* @param {Object} options.stats object of previous stats
* @example
* // Creates a 1 second window consisting of ten time slices,
* // each 100ms long.
Expand All @@ -51,13 +52,14 @@ class Status extends EventEmitter {
super();

// Set up our statistical rolling window
this[BUCKETS] = options.rollingCountBuckets;
this[TIMEOUT] = options.rollingCountTimeout;
this[BUCKETS] = options.rollingCountBuckets || 10;
this[TIMEOUT] = options.rollingCountTimeout || 10000;
this[WINDOW] = new Array(this[BUCKETS]);
this[PERCENTILES] = [0.0, 0.25, 0.5, 0.75, 0.9, 0.95, 0.99, 0.995, 1];

// Default this value to true
this.rollingPercentilesEnabled = options.rollingPercentilesEnabled;
this.rollingPercentilesEnabled
= options.rollingPercentilesEnabled !== false;

// prime the window with buckets
for (let i = 0; i < this[BUCKETS]; i++) this[WINDOW][i] = bucket();
Expand All @@ -84,6 +86,10 @@ class Status extends EventEmitter {
if (typeof this[SNAPSHOT_INTERVAL].unref === 'function') {
this[SNAPSHOT_INTERVAL].unref();
}

if (options.stats) {
this[WINDOW][0] = { ...bucket(), ...options.stats };
}
}

/**
Expand Down
159 changes: 159 additions & 0 deletions test/status-test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
'use strict';

const test = require('tape');
const CircuitBreaker = require('../');
const Status = require('../lib/status.js');
const { passFail } = require('./common');

test('CircuitBreaker status - new Status static method', t => {
t.plan(9);

const prevStats = {
failures: 1,
fallbacks: 1,
successes: 1,
rejects: 1,
fires: 1,
timeouts: 1,
cacheHits: 1,
cacheMisses: 1,
semaphoreRejections: 1,
percentiles: {},
latencyTimes: []
};

const status = CircuitBreaker.newStatus({stats: prevStats});
t.equal(status instanceof Status, true, 'returns a new Status instance');

const stats = status.stats;
t.equal(stats.failures, 1, 'status reports 1 failure');
t.equal(stats.rejects, 1, 'status reports 1 reject');
t.equal(stats.fires, 1, 'status reports 1 fires');
t.equal(stats.fallbacks, 1, 'status reports 1 fallback');
t.equal(stats.successes, 1, 'status reports 1 successes');
t.equal(stats.timeouts, 1, 'status reports 1 timeouts');
t.equal(stats.cacheHits, 1, 'status reports 1 cacheHits');
t.equal(stats.semaphoreRejections, 1, 'status reports 1 semaphoreRejections');

t.end();
});

test('CircuitBreaker status - import stats', t => {
t.plan(12);

const prevStats = {
failures: 1,
fallbacks: 1,
successes: 1,
rejects: 1,
fires: 1,
timeouts: 1,
cacheHits: 1,
cacheMisses: 1,
semaphoreRejections: 1,
percentiles: {},
latencyTimes: []
};

const status = CircuitBreaker.newStatus({stats: prevStats});

const breaker = new CircuitBreaker(passFail, {
errorThresholdPercentage: 1,
status: status
});

const deepEqual = (t, expected) =>
actual => t.deepEqual(actual, expected, 'expected status values');

Promise.all([
breaker.fire(10).then(deepEqual(t, 10)),
breaker.fire(20).then(deepEqual(t, 20)),
breaker.fire(30).then(deepEqual(t, 30))
])
.then(() => t.deepEqual(breaker.status.stats.fires, 4,
'breaker fired 4 times'))
.catch(t.fail)
.then(() => {
breaker.fire(-10)
.then(t.fail)
.catch(value => {
const stats = breaker.status.stats;
t.equal(value, 'Error: -10 is < 0',
'fails with correct error message');
t.equal(stats.failures, 2, 'status reports 2 failures');
t.equal(stats.fires, 5, 'status reports 5 fires');
})
.then(() => {
breaker.fallback(() => 'Fallback called');
return breaker.fire(-20)
.then(result => {
const stats = breaker.status.stats;
t.equal(result, 'Fallback called', 'fallback is invoked');
t.equal(stats.failures, 2, 'status reports 2 failure');
t.equal(stats.rejects, 2, 'status reports 2 reject');
t.equal(stats.fires, 6, 'status reports 6 fires');
t.equal(stats.fallbacks, 2, 'status reports 2 fallback');
})
.catch(t.fail);
})
.then(_ => breaker.shutdown())
.catch(t.fail)
.then(t.end);
});
});

test('CircuitBreaker status - import stats, but leave some out', t => {
t.plan(3);

const prevStats = {
rejects: 1,
fires: 1,
timeouts: 1,
cacheHits: 1,
cacheMisses: 1,
semaphoreRejections: 1,
percentiles: {},
latencyTimes: []
};

const status = CircuitBreaker.newStatus({stats: prevStats});

const breaker = new CircuitBreaker(passFail, {
errorThresholdPercentage: 1,
status: status
});

t.equal(breaker.status.stats.failures, 0, 'failures was initialized');
t.equal(breaker.status.stats.fallbacks, 0, 'fallbacks was initialized');
t.equal(breaker.status.stats.successes, 0, 'successes was initialized');

breaker.shutdown();
t.end();
});

test('CircuitBreaker status - import stats,but not a status object', t => {
t.plan(1);

const prevStats = {
rejects: 1,
fires: 1,
timeouts: 1,
cacheHits: 1,
cacheMisses: 1,
semaphoreRejections: 1,
percentiles: {},
latencyTimes: []
};

try {
// eslint-disable-next-line
const _ = new CircuitBreaker(passFail, {
errorThresholdPercentage: 1,
status: prevStats
});
t.fail();
} catch (err) {
if (err instanceof TypeError) t.pass();
t.end();
}
});

0 comments on commit be26d74

Please sign in to comment.