diff --git a/packages/unleash-api/lib/client-metrics/list.js b/packages/unleash-api/lib/client-metrics/list.js new file mode 100644 index 0000000000..676b70b054 --- /dev/null +++ b/packages/unleash-api/lib/client-metrics/list.js @@ -0,0 +1,132 @@ +'use strict'; + +const { EventEmitter } = require('events'); + +class Node { + constructor (value) { + this.value = value; + this.next = null; + } + + link (next) { + this.next = next; + next.prev = this; + } +} + + +/* + * linked list + * ranged list, assumes start to end(tail) is a known order + * remove() is only implemented in reverse order for the usecase + * emits events on eviction +*/ +module.exports = class List extends EventEmitter { + constructor () { + super(); + this.start = null; + this.tail = null; + } + + add (obj) { + const node = new Node(obj); + if (this.tail) { + this.tail.link(node); + this.tail = node; + } else { + this.start = node; + this.tail = node; + } + return node; + } + + iterate (fn) { + if (!this.start) { + return; + } + let cursor = this.start; + while (cursor) { + const result = fn(cursor); + if (result === false) { + cursor = null; + } else { + cursor = cursor.next; + } + } + } + + iterateReverse (fn) { + if (!this.tail) { + return; + } + let cursor = this.tail; + while (cursor) { + const result = fn(cursor); + if (result === false) { + cursor = null; + } else { + cursor = cursor.prev; + } + } + } + + reverseRemoveUntilTrue (fn) { + if (!this.tail) { + return; + } + + let cursor = this.tail; + while (cursor) { + const result = fn(cursor); + if (result === false && cursor === this.start) { + // whole list is removed + this.emit('evicted', cursor.value); + this.start = null; + this.tail = null; + // stop iteration + cursor = null; + } else if (result === true) { + // when TRUE, set match as new tail + if (cursor !== this.tail) { + this.tail = cursor; + cursor.next = null; + } + // stop iteration + cursor = null; + } else { + // evicted + this.emit('evicted', cursor.value); + // iterate to next + cursor = cursor.prev; + } + } + } + + toArray () { + const result = []; + + if (this.start) { + let cursor = this.start; + while (cursor) { + result.push(cursor.value); + cursor = cursor.next; + } + } + + return result; + } + + toArrayReverse () { + const result = []; + + if (this.tail) { + let cursor = this.tail; + while (cursor) { + result.push(cursor.value); + cursor = cursor.prev; + } + } + + return result; + } +}; diff --git a/packages/unleash-api/lib/client-metrics/list.test.js b/packages/unleash-api/lib/client-metrics/list.test.js new file mode 100644 index 0000000000..4de293914f --- /dev/null +++ b/packages/unleash-api/lib/client-metrics/list.test.js @@ -0,0 +1,107 @@ +'use strict'; + +const test = require('ava'); +const List = require('./list'); + +function getList () { + const list = new List(); + list.add(1); + list.add(2); + list.add(3); + list.add(4); + list.add(5); + list.add(6); + list.add(7); + return list; +} + +test('should emit "evicted" events for objects leaving list', (t) => { + const list = getList(); + const evictedList = []; + list.on('evicted', (value) => { + evictedList.push(value); + }); + + t.true(evictedList.length === 0); + + list.reverseRemoveUntilTrue(({ value }) => { + if (value === 4) { + return true; + } + return false; + }); + + t.true(evictedList.length === 3); + + list.reverseRemoveUntilTrue(() => false); + + t.true(evictedList.length === 7); + + list.add(1); + list.reverseRemoveUntilTrue(() => false); + + t.true(evictedList.length === 8); +}); + +test('list should be able remove until given value', (t) => { + const list = getList(); + + t.true(list.toArray().length === 7); + + list.reverseRemoveUntilTrue(({ value }) => value === 4); + t.true(list.toArray().length === 4); + + list.reverseRemoveUntilTrue(({ value }) => value === 3); + t.true(list.toArray().length === 3); + + list.reverseRemoveUntilTrue(({ value }) => value === 3); + t.true(list.toArray().length === 3); +}); + +test('list can be cleared and re-add entries', (t) => { + const list = getList(); + + list.add(8); + list.add(9); + + t.true(list.toArray().length === 9); + + list.reverseRemoveUntilTrue(() => false); + + t.true(list.toArray().length === 0); + + list.add(1); + list.add(2); + list.add(3); + + t.true(list.toArray().length === 3); +}); + + +test('should iterate', (t) => { + const list = getList(); + + let iterateCount = 0; + list.iterate(({ value }) => { + iterateCount++; + if (value === 4) { + return false; + } + return true; + }); + t.true(iterateCount === 4); +}); + +test('should reverse iterate', (t) => { + const list = getList(); + + let iterateCount = 0; + list.iterateReverse(({ value }) => { + iterateCount++; + if (value === 3) { + return false; + } + return true; + }); + t.true(iterateCount === 5); +}); diff --git a/packages/unleash-api/lib/client-metrics/ttl-list.js b/packages/unleash-api/lib/client-metrics/ttl-list.js index 8b046847df..2d4d9d0dd3 100644 --- a/packages/unleash-api/lib/client-metrics/ttl-list.js +++ b/packages/unleash-api/lib/client-metrics/ttl-list.js @@ -1,47 +1,44 @@ 'use strict'; const { EventEmitter } = require('events'); -const yallist = require('yallist'); +const List = require('./list'); const moment = require('moment'); -// this list must have entires with sorted ttl range -module.exports = class TTLList extends EventEmitter { - constructor () { +// this list must have entries with sorted ttl range +module.exports = class FIFOTTLList extends EventEmitter { + constructor ({ + interval = 1000, + expireAmount = 1, + expireType = 'hours', + } = {}) { super(); - this.cache = yallist.create(); - setInterval(() => { + this.expireAmount = expireAmount; + this.expireType = expireType; + + this.list = new List(); + + this.list.on('evicted', ({ value, ttl }) => { + this.emit('expire', value, ttl); + }); + + this.timer = setInterval(() => { this.timedCheck(); - }, 1000); + }, interval); } - expire (entry) { - this.emit('expire', entry.value); - } - - add (value, timestamp) { - const ttl = moment(timestamp).add(1, 'hour'); - this.cache.push({ ttl, value }); + add (value, timestamp = new Date()) { + const ttl = moment(timestamp).add(this.expireAmount, this.expireType); + this.list.add({ ttl, value }); } timedCheck () { const now = moment(new Date()); - // find index to remove - let done = false; - // TODO: might use internal linkedlist - this.cache.forEachReverse((entry, index) => { - if (done) { - return; - } else if (now.isBefore(entry.ttl)) { - // When we hit a valid ttl, remove next items in list (iteration is reversed) - this.cache = this.cache.slice(0, index + 1); - done = true; - } else if (index === 0) { - this.expire(entry); - // if rest of list has timed out, let it DIE! - this.cache = yallist.create(); // empty= - } else { - this.expire(entry); - } - }); + this.list.reverseRemoveUntilTrue(({ value }) => now.isBefore(value.ttl)); + } + + destroy () { + clearTimeout(this.timer); + delete this.timer; + this.list = null; } }; diff --git a/packages/unleash-api/lib/client-metrics/ttl-list.test.js b/packages/unleash-api/lib/client-metrics/ttl-list.test.js new file mode 100644 index 0000000000..0a7b7dd3ab --- /dev/null +++ b/packages/unleash-api/lib/client-metrics/ttl-list.test.js @@ -0,0 +1,63 @@ +'use strict'; + +const test = require('ava'); +const TTLList = require('./ttl-list'); +const moment = require('moment'); + +test.cb('should emit expire', (t) => { + const list = new TTLList({ + interval: 20, + expireAmount: 10, + expireType: 'milliseconds', + }); + + list.on('expire', (entry) => { + list.destroy(); + t.true(entry.n === 1); + t.end(); + }); + + list.add({ n: 1 }); +}); + +test.cb('should slice off list', (t) => { + const list = new TTLList({ + interval: 10, + expireAmount: 10, + expireType: 'milliseconds', + }); + + // console.time('4'); + list.add({ n: '4' }, moment().add(300, 'milliseconds')); + + // console.time('3'); + list.add({ n: '3' }, moment().add(200, 'milliseconds')); + + // console.time('2'); + list.add({ n: '2' }, moment().add(50, 'milliseconds')); + + // console.time('1'); + list.add({ n: '1' }, moment().add(1, 'milliseconds')); + + const expired = []; + + list.on('expire', (entry) => { + // console.timeEnd(entry.n); + expired.push(entry); + }); + + setTimeout(() => { + t.true(expired.length === 1); + }, 30); + setTimeout(() => { + t.true(expired.length === 2); + }, 71); + setTimeout(() => { + t.true(expired.length === 3); + }, 221); + setTimeout(() => { + t.true(expired.length === 4); + list.destroy(); + t.end(); + }, 330); +}); diff --git a/packages/unleash-api/package.json b/packages/unleash-api/package.json index 9f08389594..e56a902627 100644 --- a/packages/unleash-api/package.json +++ b/packages/unleash-api/package.json @@ -65,6 +65,7 @@ }, "devDependencies": { "bluebird": "^3.4.6", + "ava": "^0.16.0", "coveralls": "^2.11.12", "istanbul": "^0.4.5", "mocha": "^3.0.2",