From 2fea233d3a9701ffeabc5bf353cd13df39837888 Mon Sep 17 00:00:00 2001 From: Lance Ball Date: Fri, 20 Jul 2018 16:04:15 -0400 Subject: [PATCH] feat: add options.allowWarmUp as a creation option This feature allows the user to let the circuit warm up before opening, even if every request is a failure or timeout. The warmup duration is the value provided for options.rollingCountTimeout. Fixes: https://github.com/bucharest-gold/opossum/issues/217 --- index.js | 24 +++++++++++++++++ lib/circuit.js | 25 ++++++++++++++++-- lib/status.js | 3 +-- test/warmup-test.js | 64 +++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 112 insertions(+), 4 deletions(-) create mode 100644 test/warmup-test.js diff --git a/index.js b/index.js index 7f64f7af..aa572caa 100644 --- a/index.js +++ b/index.js @@ -22,6 +22,30 @@ const defaults = { * opening. Default 10. * @param options.resetTimeout The time in milliseconds to wait before setting * the breaker to `halfOpen` state, and trying the action again. + * @param options.rollingCountTimeout Sets the duration of the statistical rolling + * window, in milliseconds. This is how long Opossum keeps metrics for the circuit + * breaker to use and for publishing. Default: 10000 + * @param options.rollingCountBuckets Sets the number of buckets the rolling + * statistical window is divided into. So, if options.rollingCountTimeout is + * 10000, and options.rollingCountBuckets is 10, then the statistical window will + * be 1000 1 second snapshots in the statistical window. Default: 10 + * @param options.name the circuit name to use when reporting stats + * @param options.rollingPercentilesEnabled This property indicates whether + * execution latencies should be tracked and calculated as percentiles. If they + * are disabled, all summary statistics (mean, percentiles) are returned as -1. + * Default: false + * @param options.capacity the number of concurrent requests allowed. If the number + * currently executing function calls is equal to options.capacity, further calls + * to `fire()` are rejected until at least one of the current requests completes. + * @param options.errorThresholdPercentage the error percentage at which to open the + * circuit and start short-circuiting requests to fallback. + * @param options.enabled whether this circuit is enabled upon construction. Default: true + * @param options.allowWarmUp {boolean} determines whether to allow failures + * without opening the circuit during a brief warmup period (this is the + * `rollingCountDuration` property). Default: false + * allow before enabling the circuit. This can help in situations where no matter + * what your `errorThresholdPercentage` is, if the first execution times out or + * fails, the circuit immediately opens. Default: 0 * @return a {@link CircuitBreaker} instance */ function circuitBreaker (action, options) { diff --git a/lib/circuit.js b/lib/circuit.js index 5ecdbb5a..af356f55 100644 --- a/lib/circuit.js +++ b/lib/circuit.js @@ -17,6 +17,7 @@ const GROUP = Symbol('group'); const HYSTRIX_STATS = Symbol('hystrix-stats'); const CACHE = new WeakMap(); const ENABLED = Symbol('Enabled'); +const WARMING_UP = Symbol('warming-up'); const deprecation = `options.maxFailures is deprecated. \ Please use options.errorThresholdPercentage`; @@ -58,6 +59,12 @@ Please use options.errorThresholdPercentage`; * which to open the circuit and start short-circuiting requests to fallback. * @param options.enabled {boolean} whether this circuit is enabled upon * construction. Default: true + * @param options.allowWarmUp {boolean} determines whether to allow failures + * without opening the circuit during a brief warmup period (this is the + * `rollingCountDuration` property). Default: false + * allow before enabling the circuit. This can help in situations where no matter + * what your `errorThresholdPercentage` is, if the first execution times out or + * fails, the circuit immediately opens. Default: 0 */ class CircuitBreaker extends EventEmitter { constructor (action, options) { @@ -67,10 +74,11 @@ class CircuitBreaker extends EventEmitter { this.options.rollingCountBuckets = options.rollingCountBuckets || 10; this.options.rollingPercentilesEnabled = options.rollingPercentilesEnabled !== false; - this.options.capacity = - typeof options.capacity === 'number' ? options.capacity : 10; + this.options.capacity = Number.isInteger(options.capacity) ? options.capacity : 10; + this.semaphore = new Semaphore(this.options.capacity); + this[WARMING_UP] = options.allowWarmUp === true; this[STATUS] = new Status(this.options); this[STATE] = CLOSED; this[FALLBACK_FUNCTION] = null; @@ -79,6 +87,14 @@ class CircuitBreaker extends EventEmitter { this[GROUP] = options.group || this[NAME]; this[ENABLED] = options.enabled !== false; + if (this[WARMING_UP]) { + const timer = setTimeout(_ => (this[WARMING_UP] = false), + this.options.rollingCountTimeout); + if (typeof timer.unref === 'function') { + timer.unref(); + } + } + if (typeof action !== 'function') { this.action = _ => Promise.resolve(action); } else this.action = action; @@ -226,6 +242,10 @@ class CircuitBreaker extends EventEmitter { return this[ENABLED]; } + get warmUp () { + return this[WARMING_UP]; + } + /** * Provide a fallback function for this {@link CircuitBreaker}. This * function will be executed when the circuit is `fire`d and fails. @@ -487,6 +507,7 @@ function fail (circuit, err, args, latency) { * @event CircuitBreaker#failure */ circuit.emit('failure', err, latency); + if (circuit.warmUp) return; // check stats to see if the circuit should be opened const stats = circuit.stats; diff --git a/lib/status.js b/lib/status.js index 89417553..7c6c991d 100644 --- a/lib/status.js +++ b/lib/status.js @@ -87,8 +87,7 @@ class Status extends EventEmitter { */ get stats () { const totals = this[WINDOW].reduce((acc, val) => { - // the window starts with all but one bucket undefined - if (!val) return acc; + if (!val) { return acc; } Object.keys(acc).forEach(key => { if (key !== 'latencyTimes' && key !== 'percentiles') { (acc[key] += val[key] || 0); diff --git a/test/warmup-test.js b/test/warmup-test.js new file mode 100644 index 00000000..55dcbc84 --- /dev/null +++ b/test/warmup-test.js @@ -0,0 +1,64 @@ +'use strict'; + +const test = require('tape'); +const opossum = require('../'); +const { passFail } = require('./common'); + +test('By default does not allow for warmup', t => { + t.plan(3); + const options = { + errorThresholdPercentage: 1, + resetTimeout: 100 + }; + + const breaker = opossum(passFail, options); + breaker.fire(-1) + .catch(e => t.equals(e, 'Error: -1 is < 0')) + .then(() => { + t.ok(breaker.opened, 'should be open after initial fire'); + t.notOk(breaker.pendingClose, + 'should not be pending close after initial fire'); + }); +}); + +test('Allows for warmup when option is provided', t => { + t.plan(3); + const options = { + errorThresholdPercentage: 1, + resetTimeout: 100, + allowWarmUp: true + }; + + const breaker = opossum(passFail, options); + breaker.fire(-1) + .catch(e => t.equals(e, 'Error: -1 is < 0')) + .then(() => { + t.notOk(breaker.opened, 'should not be open after initial fire'); + t.notOk(breaker.pendingClose, + 'should not be pending close after initial fire'); + }); +}); + +test('Only warms up for rollingCountTimeout', t => { + t.plan(4); + const options = { + errorThresholdPercentage: 1, + resetTimeout: 100, + allowWarmUp: true, + rollingCountTimeout: 500 + }; + + const breaker = opossum(passFail, options); + breaker.fire(-1) + .catch(e => t.equals(e, 'Error: -1 is < 0')) + .then(() => { + t.notOk(breaker.opened, 'should not be open after initial fire'); + t.notOk(breaker.pendingClose, + 'should not be pending close after initial fire'); + }) + .then(() => { + setTimeout(_ => { + t.notOk(breaker.warmUp, 'Warmup should end after rollingCountTimeout'); + }, 500); + }); +});