From 0e3d6ead735ed067bd044c8d0c9c307d970f1986 Mon Sep 17 00:00:00 2001 From: Warren James Date: Wed, 3 Apr 2024 17:09:26 -0400 Subject: [PATCH] feat(NODE-5825): add `minRoundTripTime` to `ServerDescription` and change `roundTripTime` to a moving average (#4059) --- src/index.ts | 1 + src/sdam/monitor.ts | 237 ++++++++++++++++++++-------- src/sdam/server.ts | 12 +- src/sdam/server_description.ts | 7 +- src/sdam/server_selection.ts | 5 +- test/unit/sdam/monitor.test.ts | 277 ++++++++++++++++++++++++++++----- 6 files changed, 424 insertions(+), 115 deletions(-) diff --git a/src/index.ts b/src/index.ts index 9cd58ec0ac..60ebd96067 100644 --- a/src/index.ts +++ b/src/index.ts @@ -510,6 +510,7 @@ export type { MonitorPrivate, RTTPinger, RTTPingerOptions, + RTTSampler, ServerMonitoringMode } from './sdam/monitor'; export type { Server, ServerEvents, ServerOptions, ServerPrivate } from './sdam/server'; diff --git a/src/sdam/monitor.ts b/src/sdam/monitor.ts index bec8cda1cf..769c41d16d 100644 --- a/src/sdam/monitor.ts +++ b/src/sdam/monitor.ts @@ -8,8 +8,14 @@ import { LEGACY_HELLO_COMMAND } from '../constants'; import { MongoError, MongoErrorLabel, MongoNetworkTimeoutError } from '../error'; import { MongoLoggableComponent } from '../mongo_logger'; import { CancellationToken, TypedEventEmitter } from '../mongo_types'; -import type { Callback, EventEmitterWithState } from '../utils'; -import { calculateDurationInMs, makeStateMachine, now, ns } from '../utils'; +import { + calculateDurationInMs, + type Callback, + type EventEmitterWithState, + makeStateMachine, + now, + ns +} from '../utils'; import { ServerType, STATE_CLOSED, STATE_CLOSING } from './common'; import { ServerHeartbeatFailedEvent, @@ -25,8 +31,6 @@ const kServer = Symbol('server'); const kMonitorId = Symbol('monitorId'); /** @internal */ const kCancellationToken = Symbol('cancellationToken'); -/** @internal */ -const kRoundTripTime = Symbol('roundTripTime'); const STATE_IDLE = 'idle'; const STATE_MONITORING = 'monitoring'; @@ -100,6 +104,8 @@ export class Monitor extends TypedEventEmitter { rttPinger?: RTTPinger; /** @internal */ override component = MongoLoggableComponent.TOPOLOGY; + /** @internal */ + private rttSampler: RTTSampler; constructor(server: Server, options: MonitorOptions) { super(); @@ -121,6 +127,7 @@ export class Monitor extends TypedEventEmitter { }); this.isRunningInFaasEnv = getFAASEnv() != null; this.mongoLogger = this[kServer].topology.client?.mongoLogger; + this.rttSampler = new RTTSampler(10); const cancellationToken = this[kCancellationToken]; // TODO: refactor this to pull it directly from the pool, requires new ConnectionPool integration @@ -203,6 +210,26 @@ export class Monitor extends TypedEventEmitter { this.emit('close'); stateTransition(this, STATE_CLOSED); } + + get roundTripTime(): number { + return this.rttSampler.average(); + } + + get minRoundTripTime(): number { + return this.rttSampler.min(); + } + + get latestRtt(): number { + return this.rttSampler.last ?? 0; // FIXME: Check if this is acceptable + } + + addRttSample(rtt: number) { + this.rttSampler.addSample(rtt); + } + + clearRttSamples() { + this.rttSampler.clear(); + } } function resetMonitorState(monitor: Monitor) { @@ -216,6 +243,8 @@ function resetMonitorState(monitor: Monitor) { monitor.connection?.destroy(); monitor.connection = null; + + monitor.clearRttSamples(); } function useStreamingProtocol(monitor: Monitor, topologyVersion: TopologyVersion | null): boolean { @@ -249,7 +278,6 @@ function checkServer(monitor: Monitor, callback: Callback) { function onHeartbeatFailed(err: Error) { monitor.connection?.destroy(); monitor.connection = null; - monitor.emitAndLogHeartbeat( Server.SERVER_HEARTBEAT_FAILED, monitor[kServer].topology.s.id, @@ -275,11 +303,15 @@ function checkServer(monitor: Monitor, callback: Callback) { hello.isWritablePrimary = hello[LEGACY_HELLO_COMMAND]; } + // NOTE: here we use the latestRtt as this measurement corresponds with the value + // obtained for this successful heartbeat const duration = isAwaitable && monitor.rttPinger - ? monitor.rttPinger.roundTripTime + ? monitor.rttPinger.latestRtt ?? calculateDurationInMs(start) : calculateDurationInMs(start); + monitor.addRttSample(duration); + monitor.emitAndLogHeartbeat( Server.SERVER_HEARTBEAT_SUCCEEDED, monitor[kServer].topology.s.id, @@ -328,13 +360,7 @@ function checkServer(monitor: Monitor, callback: Callback) { : { socketTimeoutMS: connectTimeoutMS }; if (isAwaitable && monitor.rttPinger == null) { - monitor.rttPinger = new RTTPinger( - monitor[kCancellationToken], - Object.assign( - { heartbeatFrequencyMS: monitor.options.heartbeatFrequencyMS }, - monitor.connectOptions - ) - ); + monitor.rttPinger = new RTTPinger(monitor); } // Record new start time before sending handshake @@ -377,6 +403,8 @@ function checkServer(monitor: Monitor, callback: Callback) { connection.destroy(); return; } + const duration = calculateDurationInMs(start); + monitor.addRttSample(duration); monitor.connection = connection; monitor.emitAndLogHeartbeat( @@ -385,7 +413,7 @@ function checkServer(monitor: Monitor, callback: Callback) { connection.hello?.connectionId, new ServerHeartbeatSucceededEvent( monitor.address, - calculateDurationInMs(start), + duration, connection.hello, useStreamingProtocol(monitor, connection.hello?.topologyVersion) ) @@ -458,23 +486,30 @@ export class RTTPinger { /** @internal */ [kCancellationToken]: CancellationToken; /** @internal */ - [kRoundTripTime]: number; - /** @internal */ [kMonitorId]: NodeJS.Timeout; + /** @internal */ + monitor: Monitor; closed: boolean; + /** @internal */ + latestRtt?: number; - constructor(cancellationToken: CancellationToken, options: RTTPingerOptions) { + constructor(monitor: Monitor) { this.connection = undefined; - this[kCancellationToken] = cancellationToken; - this[kRoundTripTime] = 0; + this[kCancellationToken] = monitor[kCancellationToken]; this.closed = false; + this.monitor = monitor; + this.latestRtt = monitor.latestRtt; - const heartbeatFrequencyMS = options.heartbeatFrequencyMS; - this[kMonitorId] = setTimeout(() => measureRoundTripTime(this, options), heartbeatFrequencyMS); + const heartbeatFrequencyMS = monitor.options.heartbeatFrequencyMS; + this[kMonitorId] = setTimeout(() => this.measureRoundTripTime(), heartbeatFrequencyMS); } get roundTripTime(): number { - return this[kRoundTripTime]; + return this.monitor.roundTripTime; + } + + get minRoundTripTime(): number { + return this.monitor.minRoundTripTime; } close(): void { @@ -484,61 +519,60 @@ export class RTTPinger { this.connection?.destroy(); this.connection = undefined; } -} - -function measureRoundTripTime(rttPinger: RTTPinger, options: RTTPingerOptions) { - const start = now(); - options.cancellationToken = rttPinger[kCancellationToken]; - const heartbeatFrequencyMS = options.heartbeatFrequencyMS; - - if (rttPinger.closed) { - return; - } - function measureAndReschedule(conn?: Connection) { - if (rttPinger.closed) { + private measureAndReschedule(start?: number, conn?: Connection) { + if (start == null) { + start = now(); + } + if (this.closed) { conn?.destroy(); return; } - if (rttPinger.connection == null) { - rttPinger.connection = conn; + if (this.connection == null) { + this.connection = conn; } - rttPinger[kRoundTripTime] = calculateDurationInMs(start); - rttPinger[kMonitorId] = setTimeout( - () => measureRoundTripTime(rttPinger, options), - heartbeatFrequencyMS + this.latestRtt = calculateDurationInMs(start); + this[kMonitorId] = setTimeout( + () => this.measureRoundTripTime(), + this.monitor.options.heartbeatFrequencyMS ); } - const connection = rttPinger.connection; - if (connection == null) { + private measureRoundTripTime() { + const start = now(); + + if (this.closed) { + return; + } + + const connection = this.connection; + if (connection == null) { + // eslint-disable-next-line github/no-then + connect(this.monitor.connectOptions).then( + connection => { + this.measureAndReschedule(start, connection); + }, + () => { + this.connection = undefined; + } + ); + return; + } + + const commandName = + connection.serverApi?.version || connection.helloOk ? 'hello' : LEGACY_HELLO_COMMAND; // eslint-disable-next-line github/no-then - connect(options).then( - connection => { - measureAndReschedule(connection); - }, + connection.command(ns('admin.$cmd'), { [commandName]: 1 }, undefined).then( + () => this.measureAndReschedule(), () => { - rttPinger.connection = undefined; - rttPinger[kRoundTripTime] = 0; + this.connection?.destroy(); + this.connection = undefined; + return; } ); - return; } - - const commandName = - connection.serverApi?.version || connection.helloOk ? 'hello' : LEGACY_HELLO_COMMAND; - // eslint-disable-next-line github/no-then - connection.command(ns('admin.$cmd'), { [commandName]: 1 }, undefined).then( - () => measureAndReschedule(), - () => { - rttPinger.connection?.destroy(); - rttPinger.connection = undefined; - rttPinger[kRoundTripTime] = 0; - return; - } - ); } /** @@ -666,3 +700,82 @@ export class MonitorInterval { }); }; } + +/** @internal + * This class implements the RTT sampling logic specified for [CSOT](https://github.com/mongodb/specifications/blob/bbb335e60cd7ea1e0f7cd9a9443cb95fc9d3b64d/source/client-side-operations-timeout/client-side-operations-timeout.md#drivers-use-minimum-rtt-to-short-circuit-operations) + * + * This is implemented as a [circular buffer](https://en.wikipedia.org/wiki/Circular_buffer) keeping + * the most recent `windowSize` samples + * */ +export class RTTSampler { + /** Index of the next slot to be overwritten */ + private writeIndex: number; + private length: number; + private rttSamples: Float64Array; + + constructor(windowSize = 10) { + this.rttSamples = new Float64Array(windowSize); + this.length = 0; + this.writeIndex = 0; + } + + /** + * Adds an rtt sample to the end of the circular buffer + * When `windowSize` samples have been collected, `addSample` overwrites the least recently added + * sample + */ + addSample(sample: number) { + this.rttSamples[this.writeIndex++] = sample; + if (this.length < this.rttSamples.length) { + this.length++; + } + + this.writeIndex %= this.rttSamples.length; + } + + /** + * When \< 2 samples have been collected, returns 0 + * Otherwise computes the minimum value samples contained in the buffer + */ + min(): number { + if (this.length < 2) return 0; + let min = this.rttSamples[0]; + for (let i = 1; i < this.length; i++) { + if (this.rttSamples[i] < min) min = this.rttSamples[i]; + } + + return min; + } + + /** + * Returns mean of samples contained in the buffer + */ + average(): number { + if (this.length === 0) return 0; + let sum = 0; + for (let i = 0; i < this.length; i++) { + sum += this.rttSamples[i]; + } + + return sum / this.length; + } + + /** + * Returns most recently inserted element in the buffer + * Returns null if the buffer is empty + * */ + get last(): number | null { + if (this.length === 0) return null; + return this.rttSamples[this.writeIndex === 0 ? this.length - 1 : this.writeIndex - 1]; + } + + /** + * Clear the buffer + * NOTE: this does not overwrite the data held in the internal array, just the pointers into + * this array + */ + clear() { + this.length = 0; + this.writeIndex = 0; + } +} diff --git a/src/sdam/server.ts b/src/sdam/server.ts index 89f154eaac..6dbc31df7d 100644 --- a/src/sdam/server.ts +++ b/src/sdam/server.ts @@ -175,7 +175,8 @@ export class Server extends TypedEventEmitter { this.emit( Server.DESCRIPTION_RECEIVED, new ServerDescription(this.description.hostAddress, event.reply, { - roundTripTime: calculateRoundTripTime(this.description.roundTripTime, event.duration) + roundTripTime: this.monitor?.roundTripTime, + minRoundTripTime: this.monitor?.minRoundTripTime }) ); @@ -467,15 +468,6 @@ export class Server extends TypedEventEmitter { } } -function calculateRoundTripTime(oldRtt: number, duration: number): number { - if (oldRtt === -1) { - return duration; - } - - const alpha = 0.2; - return alpha * duration + (1 - alpha) * oldRtt; -} - function markServerUnknown(server: Server, error?: MongoError) { // Load balancer servers can never be marked unknown. if (server.loadBalanced) { diff --git a/src/sdam/server_description.ts b/src/sdam/server_description.ts index ec9b1939db..5068931b6a 100644 --- a/src/sdam/server_description.ts +++ b/src/sdam/server_description.ts @@ -33,8 +33,10 @@ export interface ServerDescriptionOptions { /** An Error used for better reporting debugging */ error?: MongoError; - /** The round trip time to ping this server (in ms) */ + /** The average round trip time to ping this server (in ms) */ roundTripTime?: number; + /** The minimum round trip time to ping this server over the past 10 samples(in ms) */ + minRoundTripTime?: number; /** If the client is in load balancing mode. */ loadBalanced?: boolean; @@ -58,6 +60,8 @@ export class ServerDescription { minWireVersion: number; maxWireVersion: number; roundTripTime: number; + /** The minimum measurement of the last 10 measurements of roundTripTime that have been collected */ + minRoundTripTime: number; lastUpdateTime: number; lastWriteDate: number; me: string | null; @@ -98,6 +102,7 @@ export class ServerDescription { this.minWireVersion = hello?.minWireVersion ?? 0; this.maxWireVersion = hello?.maxWireVersion ?? 0; this.roundTripTime = options?.roundTripTime ?? -1; + this.minRoundTripTime = options?.minRoundTripTime ?? 0; this.lastUpdateTime = now(); this.lastWriteDate = hello?.lastWrite?.lastWriteDate ?? 0; this.error = options.error ?? null; diff --git a/src/sdam/server_selection.ts b/src/sdam/server_selection.ts index 8c92f08b62..bb262efa33 100644 --- a/src/sdam/server_selection.ts +++ b/src/sdam/server_selection.ts @@ -223,9 +223,8 @@ function latencyWindowReducer( servers: ServerDescription[] ): ServerDescription[] { const low = servers.reduce( - (min: number, server: ServerDescription) => - min === -1 ? server.roundTripTime : Math.min(server.roundTripTime, min), - -1 + (min: number, server: ServerDescription) => Math.min(server.roundTripTime, min), + Infinity ); const high = low + topologyDescription.localThresholdMS; diff --git a/test/unit/sdam/monitor.test.ts b/test/unit/sdam/monitor.test.ts index 3c6e02a134..408a2b2665 100644 --- a/test/unit/sdam/monitor.test.ts +++ b/test/unit/sdam/monitor.test.ts @@ -1,11 +1,19 @@ +import { once } from 'node:events'; import * as net from 'node:net'; import { expect } from 'chai'; import { coerce } from 'semver'; import * as sinon from 'sinon'; import { setTimeout } from 'timers'; +import { setTimeout as setTimeoutPromise } from 'timers/promises'; -import { MongoClient } from '../../mongodb'; +import { + Long, + MongoClient, + ObjectId, + RTTSampler, + ServerHeartbeatSucceededEvent +} from '../../mongodb'; import { isHello, LEGACY_HELLO_COMMAND, @@ -55,7 +63,8 @@ describe('monitoring', function () { 'should ignore attempts to connect when not already closed', 'should not initiate another check if one is in progress', 'should not close the monitor on a failed heartbeat', - 'should upgrade to hello from legacy hello when initial handshake contains helloOk' + 'should upgrade to hello from legacy hello when initial handshake contains helloOk', + 'correctly returns the mean of the heartbeat durations' ]; test.skipReason = (major === 18 || major === 20) && failingTests.includes(test.title) @@ -141,7 +150,7 @@ describe('monitoring', function () { }).skipReason = 'TODO(NODE-3600): Unskip flaky tests'; describe('Monitor', function () { - let monitor; + let monitor: Monitor | null; beforeEach(() => { monitor = null; @@ -153,7 +162,7 @@ describe('monitoring', function () { } }); - it('should connect and issue an initial server check', function (done) { + it('should connect and issue an initial server check', async function () { mockServer.setMessageHandler(request => { const doc = request.document; if (isHello(doc)) { @@ -164,15 +173,17 @@ describe('monitoring', function () { const server = new MockServer(mockServer.address()); monitor = new Monitor(server as any, {} as any); - monitor.on('serverHeartbeatFailed', () => done(new Error('unexpected heartbeat failure'))); - monitor.on('serverHeartbeatSucceeded', () => { - expect(monitor.connection).to.have.property('id', ''); - done(); - }); + const heartbeatFailed = once(monitor, 'serverHeartbeatFailed'); + const heartbeatSucceeded = once(monitor, 'serverHeartbeatSucceeded'); monitor.connect(); + + const res = await Promise.race([heartbeatFailed, heartbeatSucceeded]); + + expect(res[0]).to.be.instanceOf(ServerHeartbeatSucceededEvent); + expect(monitor.connection).to.have.property('id', ''); }); - it('should ignore attempts to connect when not already closed', function (done) { + it('should ignore attempts to connect when not already closed', async function () { mockServer.setMessageHandler(request => { const doc = request.document; if (isHello(doc)) { @@ -183,13 +194,17 @@ describe('monitoring', function () { const server = new MockServer(mockServer.address()); monitor = new Monitor(server as any, {} as any); - monitor.on('serverHeartbeatFailed', () => done(new Error('unexpected heartbeat failure'))); - monitor.on('serverHeartbeatSucceeded', () => done()); + const heartbeatFailed = once(monitor, 'serverHeartbeatFailed'); + const heartbeatSucceeded = once(monitor, 'serverHeartbeatSucceeded'); monitor.connect(); + + const res = await Promise.race([heartbeatFailed, heartbeatSucceeded]); + + expect(res[0]).to.be.instanceOf(ServerHeartbeatSucceededEvent); monitor.connect(); }); - it('should not initiate another check if one is in progress', function (done) { + it('should not initiate another check if one is in progress', async function () { mockServer.setMessageHandler(request => { const doc = request.document; if (isHello(doc)) { @@ -202,32 +217,27 @@ describe('monitoring', function () { const startedEvents: ServerHeartbeatStartedEvent[] = []; monitor.on('serverHeartbeatStarted', event => startedEvents.push(event)); - monitor.on('close', () => { - expect(startedEvents).to.have.length(2); - done(); - }); + const monitorClose = once(monitor, 'close'); monitor.connect(); - monitor.once('serverHeartbeatSucceeded', () => { - monitor.requestCheck(); - monitor.requestCheck(); - monitor.requestCheck(); - monitor.requestCheck(); - monitor.requestCheck(); + await once(monitor, 'serverHeartbeatSucceeded'); + monitor.requestCheck(); + monitor.requestCheck(); + monitor.requestCheck(); + monitor.requestCheck(); + monitor.requestCheck(); - const minHeartbeatFrequencyMS = 500; - setTimeout(() => { - // wait for minHeartbeatFrequencyMS, then request a check and verify another check occurred - monitor.once('serverHeartbeatSucceeded', () => { - monitor.close(); - }); + const minHeartbeatFrequencyMS = 500; + await setTimeoutPromise(minHeartbeatFrequencyMS); - monitor.requestCheck(); - }, minHeartbeatFrequencyMS); - }); + await once(monitor, 'serverHeartbeatSucceeded'); + monitor.close(); + + await monitorClose; + expect(startedEvents).to.have.length(2); }); - it('should not close the monitor on a failed heartbeat', function (done) { + it('should not close the monitor on a failed heartbeat', async function () { let helloCount = 0; mockServer.setMessageHandler(request => { const doc = request.document; @@ -263,16 +273,13 @@ describe('monitoring', function () { let successCount = 0; monitor.on('serverHeartbeatSucceeded', () => { if (successCount++ === 2) { - monitor.close(); + monitor?.close(); } }); - monitor.on('close', () => { - expect(events).to.have.length(2); - done(); - }); - monitor.connect(); + await once(monitor, 'close'); + expect(events).to.have.length(2); }); it('should upgrade to hello from legacy hello when initial handshake contains helloOk', function (done) { @@ -306,6 +313,52 @@ describe('monitoring', function () { }, minHeartbeatFrequencyMS); }); }); + + describe('roundTripTime', function () { + const table = [ + { + serverMonitoringMode: 'stream', + topologyVersion: { + processId: new ObjectId(), + counter: new Long(0, 0) + } + }, + { serverMonitoringMode: 'poll', topologyVersion: undefined } + ]; + for (const { serverMonitoringMode, topologyVersion } of table) { + context(`when serverMonitoringMode = ${serverMonitoringMode}`, () => { + context('when more than one heartbeatSucceededEvent has been captured', () => { + let heartbeatDurationMS = 100; + it('correctly returns the mean of the heartbeat durations', async () => { + mockServer.setMessageHandler(request => { + setTimeout( + () => request.reply(Object.assign({ helloOk: true }, mock.HELLO)), + heartbeatDurationMS + ); + heartbeatDurationMS += 100; + }); + const server = new MockServer(mockServer.address()); + if (topologyVersion) server.description.topologyVersion = topologyVersion; + monitor = new Monitor(server as any, { serverMonitoringMode } as any); + monitor.connect(); + + for (let i = 0; i < 5; i++) { + await once(monitor, 'serverHeartbeatSucceeded'); + monitor.requestCheck(); + } + + const avgRtt = monitor.roundTripTime; + // expected avgRtt = (100 + 200 + 300 + 400 + 500)/5 = 300ms + // avgRtt will strictly be greater than 300ms since setTimeout sets a minimum + // delay from the time of scheduling to the time of callback execution + expect(avgRtt).to.be.within(300, 350); + + monitor.close(); + }); + }); + }); + } + }); }); describe('class MonitorInterval', function () { @@ -618,4 +671,150 @@ describe('monitoring', function () { expect(serverHeartbeatFailed).to.have.property('duration').that.is.lessThan(20); // way less than 80ms }); }); + + describe('class RTTSampler', () => { + describe('constructor', () => { + it('Constructs a Float64 array of length windowSize', () => { + const sampler = new RTTSampler(10); + // @ts-expect-error Accessing internal state + expect(sampler.rttSamples).to.have.length(10); + }); + }); + + describe('addSample', () => { + context('when length < windowSize', () => { + it('increments the length', () => { + const sampler = new RTTSampler(10); + expect(sampler).to.have.property('length', 0); + + sampler.addSample(1); + + expect(sampler).to.have.property('length', 1); + }); + }); + context('when length === windowSize', () => { + let sampler: RTTSampler; + const size = 10; + + beforeEach(() => { + sampler = new RTTSampler(size); + for (let i = 1; i <= size; i++) { + sampler.addSample(i); + } + }); + + it('does not increment the length', () => { + sampler.addSample(size + 1); + expect(sampler).to.have.property('length', size); + }); + + it('overwrites the oldest element', () => { + sampler.addSample(size + 1); + // @ts-expect-error Accessing internal state + for (const el of sampler.rttSamples) { + if (el === 1) expect.fail('Did not overwrite oldest element'); + } + }); + + it('appends the new element to the end of the window', () => { + sampler.addSample(size + 1); + expect(sampler.last).to.equal(size + 1); + }); + }); + }); + + describe('min()', () => { + context('when length < 2', () => { + it('returns 0', () => { + const sampler = new RTTSampler(10); + // length 0 + expect(sampler.min()).to.equal(0); + + sampler.addSample(1); + // length 1 + expect(sampler.min()).to.equal(0); + }); + }); + + context('when 2 <= length < windowSize', () => { + let sampler: RTTSampler; + beforeEach(() => { + sampler = new RTTSampler(10); + for (let i = 1; i <= 3; i++) { + sampler.addSample(i); + } + }); + + it('correctly computes the minimum', () => { + expect(sampler.min()).to.equal(1); + }); + }); + + context('when length == windowSize', () => { + let sampler: RTTSampler; + const size = 10; + + beforeEach(() => { + sampler = new RTTSampler(size); + for (let i = 1; i <= size * 2; i++) { + sampler.addSample(i); + } + }); + + it('correctly computes the minimum', () => { + expect(sampler.min()).to.equal(size + 1); + }); + }); + }); + + describe('average()', () => { + it('correctly computes the mean', () => { + const sampler = new RTTSampler(10); + let sum = 0; + + for (let i = 1; i <= 10; i++) { + sum += i; + sampler.addSample(i); + } + + expect(sampler.average()).to.equal(sum / 10); + }); + }); + + describe('last', () => { + context('when length == 0', () => { + it('returns null', () => { + const sampler = new RTTSampler(10); + expect(sampler.last).to.be.null; + }); + }); + + context('when length > 0', () => { + it('returns the most recently inserted element', () => { + const sampler = new RTTSampler(10); + for (let i = 0; i < 11; i++) { + sampler.addSample(i); + } + expect(sampler.last).to.equal(10); + }); + }); + }); + + describe('clear', () => { + let sampler: RTTSampler; + + beforeEach(() => { + sampler = new RTTSampler(10); + for (let i = 0; i < 20; i++) { + sampler.addSample(i); + } + expect(sampler).to.have.property('length', 10); + }); + + it('sets length to 0', () => { + sampler.clear(); + expect(sampler).to.have.property('length', 0); + }); + }); + }); });