diff --git a/packages/peer-collections/package.json b/packages/peer-collections/package.json index e25fae06d3..4eca33e60a 100644 --- a/packages/peer-collections/package.json +++ b/packages/peer-collections/package.json @@ -58,6 +58,9 @@ }, "devDependencies": { "@libp2p/peer-id-factory": "^4.0.0", - "aegir": "^41.0.2" + "@types/sinon": "^17.0.2", + "aegir": "^41.0.2", + "sinon": "^17.0.1", + "sinon-ts": "^2.0.0" } } diff --git a/packages/peer-collections/src/index.ts b/packages/peer-collections/src/index.ts index 4ac5b21c77..d62c5ddbde 100644 --- a/packages/peer-collections/src/index.ts +++ b/packages/peer-collections/src/index.ts @@ -5,6 +5,8 @@ * * PeerIds cache stringified versions of themselves so this should be a cheap operation. * + * Tracked versions are also available which report their current size to the libp2p Metrics collector. + * * @example Peer lists * * ```JavaScript @@ -14,6 +16,18 @@ * list.push(peerId) * ``` * + * @example Tracked peer lists + * + * * ```Typescript + * import { trackedPeerList } from '@libp2p/peer-collections' + * import { createLibp2p } from 'libp2p' + * + * const libp2p = await createLibp2p() + * + * const list = trackedPeerList({ name: 'my_metric_name', metrics: libp2p.metrics }) + * list.push(peerId) + * ``` + * * @example Peer maps * * ```JavaScript @@ -23,6 +37,18 @@ * map.set(peerId, 'value') * ``` * + * @example Tracked peer maps + * + * * ```Typescript + * import { trackedPeerMap } from '@libp2p/peer-collections' + * import { createLibp2p } from 'libp2p' + * + * const libp2p = await createLibp2p() + * + * const list = trackedPeerMap({ name: 'my_metric_name', metrics: libp2p.metrics }) + * map.set(peerId, 'value') + * ``` + * * @example Peer sets * * ```JavaScript @@ -31,8 +57,24 @@ * const set = peerSet() * set.add(peerId) * ``` + * + * @example Tracked peer sets + * + * * ```Typescript + * import { trackedPeerSet } from '@libp2p/peer-collections' + * import { createLibp2p } from 'libp2p' + * + * const libp2p = await createLibp2p() + * + * const list = trackedPeerSet({ name: 'my_metric_name', metrics: libp2p.metrics }) + * map.add(peerId) + * ``` */ -export { PeerMap } from './map.js' -export { PeerSet } from './set.js' -export { PeerList } from './list.js' +export { PeerMap, peerMap } from './map.js' +export { PeerSet, peerSet } from './set.js' +export { PeerList, peerList } from './list.js' + +export { trackedPeerMap } from './tracked-map.js' +export { trackedPeerSet } from './tracked-set.js' +export { trackedPeerList } from './tracked-list.js' diff --git a/packages/peer-collections/src/list.ts b/packages/peer-collections/src/list.ts index f384b870c2..1fc4505439 100644 --- a/packages/peer-collections/src/list.ts +++ b/packages/peer-collections/src/list.ts @@ -20,7 +20,7 @@ import type { PeerId } from '@libp2p/interface' * ``` */ export class PeerList { - private readonly list: string[] + private list: string[] constructor (list?: PeerList | Iterable) { this.list = [] @@ -148,7 +148,15 @@ export class PeerList { return len } + clear (): void { + this.list = [] + } + get length (): number { return this.list.length } } + +export function peerList (): PeerList { + return new PeerList() +} diff --git a/packages/peer-collections/src/map.ts b/packages/peer-collections/src/map.ts index 324f884fa0..78ce069579 100644 --- a/packages/peer-collections/src/map.ts +++ b/packages/peer-collections/src/map.ts @@ -40,8 +40,8 @@ export class PeerMap { this.map.clear() } - delete (peer: PeerId): void { - this.map.delete(peer.toString()) + delete (peer: PeerId): boolean { + return this.map.delete(peer.toString()) } entries (): IterableIterator<[PeerId, T]> { @@ -88,3 +88,7 @@ export class PeerMap { return this.map.size } } + +export function peerMap (): PeerMap { + return new PeerMap() +} diff --git a/packages/peer-collections/src/set.ts b/packages/peer-collections/src/set.ts index 538ec56d27..02e9e9b1d4 100644 --- a/packages/peer-collections/src/set.ts +++ b/packages/peer-collections/src/set.ts @@ -122,3 +122,7 @@ export class PeerSet { return output } } + +export function peerSet (): PeerSet { + return new PeerSet() +} diff --git a/packages/peer-collections/src/tracked-list.ts b/packages/peer-collections/src/tracked-list.ts new file mode 100644 index 0000000000..73ba96e49c --- /dev/null +++ b/packages/peer-collections/src/tracked-list.ts @@ -0,0 +1,92 @@ +import { PeerList } from './list.js' +import type { Metric, Metrics, PeerId } from '@libp2p/interface' + +export interface TrackedPeerListInit { + name: string + metrics: Metrics +} + +class TrackedPeerList extends PeerList { + private readonly metric: Metric + + constructor (init: TrackedPeerListInit) { + super() + + const { name, metrics } = init + + this.metric = metrics.registerMetric(name) + this.updateComponentMetric() + } + + pop (): PeerId | undefined { + const peerId = super.pop() + this.updateComponentMetric() + return peerId + } + + push (...peerIds: PeerId[]): void { + super.push(...peerIds) + this.updateComponentMetric() + } + + shift (): PeerId | undefined { + const peerId = super.shift() + this.updateComponentMetric() + return peerId + } + + unshift (...peerIds: PeerId[]): number { + const result = super.unshift(...peerIds) + this.updateComponentMetric() + return result + } + + clear (): void { + super.clear() + this.updateComponentMetric() + } + + private updateComponentMetric (): void { + this.metric.update(this.length) + } +} + +export interface CreateTrackedPeerListInit { + /** + * The metric name to use + */ + name: string + + /** + * A metrics implementation + */ + metrics?: Metrics +} + +/** + * Creates a PeerList that reports it's size to the libp2p Metrics service + * + * @example + * + * * ```Typescript + * import { trackedPeerList } from '@libp2p/peer-collections' + * import { createLibp2p } from 'libp2p' + * + * const libp2p = await createLibp2p() + * + * const list = trackedPeerList({ name: 'my_metric_name', metrics: libp2p.metrics }) + * list.push(peerId) + * ``` + */ +export function trackedPeerList (config: CreateTrackedPeerListInit): PeerList { + const { name, metrics } = config + let map: PeerList + + if (metrics != null) { + map = new TrackedPeerList({ name, metrics }) + } else { + map = new PeerList() + } + + return map +} diff --git a/packages/peer-collections/src/tracked-map.ts b/packages/peer-collections/src/tracked-map.ts new file mode 100644 index 0000000000..5c3836c04a --- /dev/null +++ b/packages/peer-collections/src/tracked-map.ts @@ -0,0 +1,81 @@ +import { PeerMap } from './map.js' +import type { Metric, Metrics, PeerId } from '@libp2p/interface' + +export interface TrackedPeerMapInit { + name: string + metrics: Metrics +} + +class TrackedPeerMap extends PeerMap { + private readonly metric: Metric + + constructor (init: TrackedPeerMapInit) { + super() + + const { name, metrics } = init + + this.metric = metrics.registerMetric(name) + this.updateComponentMetric() + } + + set (key: PeerId, value: V): this { + super.set(key, value) + this.updateComponentMetric() + return this + } + + delete (key: PeerId): boolean { + const deleted = super.delete(key) + this.updateComponentMetric() + return deleted + } + + clear (): void { + super.clear() + this.updateComponentMetric() + } + + private updateComponentMetric (): void { + this.metric.update(this.size) + } +} + +export interface CreateTrackedPeerMapInit { + /** + * The metric name to use + */ + name: string + + /** + * A metrics implementation + */ + metrics?: Metrics +} + +/** + * Creates a PeerMap that reports it's size to the libp2p Metrics service + * + * @example + * + * * ```Typescript + * import { trackedPeerMap } from '@libp2p/peer-collections' + * import { createLibp2p } from 'libp2p' + * + * const libp2p = await createLibp2p() + * + * const list = trackedPeerMap({ name: 'my_metric_name', metrics: libp2p.metrics }) + * map.set(peerId, 'value') + * ``` + */ +export function trackedPeerMap (config: CreateTrackedPeerMapInit): PeerMap { + const { name, metrics } = config + let map: PeerMap + + if (metrics != null) { + map = new TrackedPeerMap({ name, metrics }) + } else { + map = new PeerMap() + } + + return map +} diff --git a/packages/peer-collections/src/tracked-set.ts b/packages/peer-collections/src/tracked-set.ts new file mode 100644 index 0000000000..26ccb6e275 --- /dev/null +++ b/packages/peer-collections/src/tracked-set.ts @@ -0,0 +1,79 @@ +import { PeerSet } from './set.js' +import type { Metric, Metrics, PeerId } from '@libp2p/interface' + +export interface TrackedPeerSetInit { + name: string + metrics: Metrics +} + +class TrackedPeerSet extends PeerSet { + private readonly metric: Metric + + constructor (init: TrackedPeerSetInit) { + super() + + const { name, metrics } = init + + this.metric = metrics.registerMetric(name) + this.updateComponentMetric() + } + + add (peer: PeerId): void { + super.add(peer) + this.updateComponentMetric() + } + + delete (peer: PeerId): void { + super.delete(peer) + this.updateComponentMetric() + } + + clear (): void { + super.clear() + this.updateComponentMetric() + } + + private updateComponentMetric (): void { + this.metric.update(this.size) + } +} + +export interface CreateTrackedPeerSetInit { + /** + * The metric name to use + */ + name: string + + /** + * A metrics implementation + */ + metrics?: Metrics +} + +/** + * Creates a PeerSet that reports it's size to the libp2p Metrics service + * + * @example Tracked peer sets + * + * * ```Typescript + * import { trackedPeerSet } from '@libp2p/peer-collections' + * import { createLibp2p } from 'libp2p' + * + * const libp2p = await createLibp2p() + * + * const list = trackedPeerSet({ name: 'my_metric_name', metrics: libp2p.metrics }) + * map.add(peerId) + * ``` + */ +export function trackedPeerSet (config: CreateTrackedPeerSetInit): PeerSet { + const { name, metrics } = config + let map: PeerSet + + if (metrics != null) { + map = new TrackedPeerSet({ name, metrics }) + } else { + map = new PeerSet() + } + + return map +} diff --git a/packages/peer-collections/test/tracked-list.spec.ts b/packages/peer-collections/test/tracked-list.spec.ts new file mode 100644 index 0000000000..6efde67060 --- /dev/null +++ b/packages/peer-collections/test/tracked-list.spec.ts @@ -0,0 +1,94 @@ +import { createEd25519PeerId } from '@libp2p/peer-id-factory' +import { expect } from 'aegir/chai' +import { stubInterface } from 'sinon-ts' +import { PeerList } from '../src/list.js' +import { trackedPeerList } from '../src/tracked-list.js' +import type { Metric, Metrics, PeerId } from '@libp2p/interface' +import type { SinonStubbedInstance } from 'sinon' + +describe('tracked-peer-list', () => { + let metrics: SinonStubbedInstance + let peer1: PeerId + let peer2: PeerId + + beforeEach(async () => { + metrics = stubInterface() + peer1 = await createEd25519PeerId() + peer2 = await createEd25519PeerId() + }) + + it('should return a map with metrics', () => { + const name = 'system_component_metric' + const metric = stubInterface() + // @ts-expect-error the wrong overload is selected + metrics.registerMetric.withArgs(name).returns(metric) + + const list = trackedPeerList({ + name, + metrics + }) + + expect(list).to.be.an.instanceOf(PeerList) + expect(metrics.registerMetric.calledWith(name)).to.be.true() + }) + + it('should return a list without metrics', () => { + const name = 'system_component_metric' + const metric = stubInterface() + // @ts-expect-error the wrong overload is selected + metrics.registerMetric.withArgs(name).returns(metric) + + const list = trackedPeerList({ + name + }) + + expect(list).to.be.an.instanceOf(PeerList) + expect(metrics.registerMetric.called).to.be.false() + }) + + it('should track metrics', () => { + const name = 'system_component_metric' + let value = 0 + let callCount = 0 + + const metric = stubInterface() + // @ts-expect-error the wrong overload is selected + metrics.registerMetric.withArgs(name).returns(metric) + + metric.update.callsFake((v) => { + if (typeof v === 'number') { + value = v + } + + callCount++ + }) + + const list = trackedPeerList({ + name, + metrics + }) + + expect(list).to.be.an.instanceOf(PeerList) + expect(callCount).to.equal(1) + + list.push(peer1) + + expect(value).to.equal(1) + expect(callCount).to.equal(2) + + list.push(peer2) + + expect(value).to.equal(2) + expect(callCount).to.equal(3) + + list.pop() + + expect(value).to.equal(1) + expect(callCount).to.equal(4) + + list.clear() + + expect(value).to.equal(0) + expect(callCount).to.equal(5) + }) +}) diff --git a/packages/peer-collections/test/tracked-map.spec.ts b/packages/peer-collections/test/tracked-map.spec.ts new file mode 100644 index 0000000000..d63dd9fe52 --- /dev/null +++ b/packages/peer-collections/test/tracked-map.spec.ts @@ -0,0 +1,99 @@ +import { createEd25519PeerId } from '@libp2p/peer-id-factory' +import { expect } from 'aegir/chai' +import { stubInterface } from 'sinon-ts' +import { PeerMap } from '../src/map.js' +import { trackedPeerMap } from '../src/tracked-map.js' +import type { Metric, Metrics, PeerId } from '@libp2p/interface' +import type { SinonStubbedInstance } from 'sinon' + +describe('tracked-peer-map', () => { + let metrics: SinonStubbedInstance + let peer1: PeerId + let peer2: PeerId + + beforeEach(async () => { + metrics = stubInterface() + peer1 = await createEd25519PeerId() + peer2 = await createEd25519PeerId() + }) + + it('should return a map with metrics', () => { + const name = 'system_component_metric' + const metric = stubInterface() + // @ts-expect-error the wrong overload is selected + metrics.registerMetric.withArgs(name).returns(metric) + + const map = trackedPeerMap({ + name, + metrics + }) + + expect(map).to.be.an.instanceOf(PeerMap) + expect(metrics.registerMetric.calledWith(name)).to.be.true() + }) + + it('should return a map without metrics', () => { + const name = 'system_component_metric' + const metric = stubInterface() + // @ts-expect-error the wrong overload is selected + metrics.registerMetric.withArgs(name).returns(metric) + + const map = trackedPeerMap({ + name + }) + + expect(map).to.be.an.instanceOf(PeerMap) + expect(metrics.registerMetric.called).to.be.false() + }) + + it('should track metrics', () => { + const name = 'system_component_metric' + let value = 0 + let callCount = 0 + + const metric = stubInterface() + // @ts-expect-error the wrong overload is selected + metrics.registerMetric.withArgs(name).returns(metric) + + metric.update.callsFake((v) => { + if (typeof v === 'number') { + value = v + } + + callCount++ + }) + + const map = trackedPeerMap({ + name, + metrics + }) + + expect(map).to.be.an.instanceOf(PeerMap) + expect(callCount).to.equal(1) + + map.set(peer1, 'value1') + + expect(value).to.equal(1) + expect(callCount).to.equal(2) + + map.set(peer1, 'value2') + + expect(value).to.equal(1) + expect(callCount).to.equal(3) + + map.set(peer2, 'value3') + + expect(value).to.equal(2) + expect(callCount).to.equal(4) + + map.delete(peer2) + + expect(value).to.equal(1) + expect(callCount).to.equal(5) + + map.clear() + + expect(value).to.equal(0) + expect(callCount).to.equal(6) + }) +}) diff --git a/packages/peer-collections/test/tracked-set.spec.ts b/packages/peer-collections/test/tracked-set.spec.ts new file mode 100644 index 0000000000..f2ffdff3d6 --- /dev/null +++ b/packages/peer-collections/test/tracked-set.spec.ts @@ -0,0 +1,94 @@ +import { createEd25519PeerId } from '@libp2p/peer-id-factory' +import { expect } from 'aegir/chai' +import { stubInterface } from 'sinon-ts' +import { PeerSet } from '../src/set.js' +import { trackedPeerSet } from '../src/tracked-set.js' +import type { Metric, Metrics, PeerId } from '@libp2p/interface' +import type { SinonStubbedInstance } from 'sinon' + +describe('tracked-peer-set', () => { + let metrics: SinonStubbedInstance + let peer1: PeerId + let peer2: PeerId + + beforeEach(async () => { + metrics = stubInterface() + peer1 = await createEd25519PeerId() + peer2 = await createEd25519PeerId() + }) + + it('should return a map with metrics', () => { + const name = 'system_component_metric' + const metric = stubInterface() + // @ts-expect-error the wrong overload is selected + metrics.registerMetric.withArgs(name).returns(metric) + + const set = trackedPeerSet({ + name, + metrics + }) + + expect(set).to.be.an.instanceOf(PeerSet) + expect(metrics.registerMetric.calledWith(name)).to.be.true() + }) + + it('should return a list without metrics', () => { + const name = 'system_component_metric' + const metric = stubInterface() + // @ts-expect-error the wrong overload is selected + metrics.registerMetric.withArgs(name).returns(metric) + + const list = trackedPeerSet({ + name + }) + + expect(list).to.be.an.instanceOf(PeerSet) + expect(metrics.registerMetric.called).to.be.false() + }) + + it('should track metrics', () => { + const name = 'system_component_metric' + let value = 0 + let callCount = 0 + + const metric = stubInterface() + // @ts-expect-error the wrong overload is selected + metrics.registerMetric.withArgs(name).returns(metric) + + metric.update.callsFake((v) => { + if (typeof v === 'number') { + value = v + } + + callCount++ + }) + + const list = trackedPeerSet({ + name, + metrics + }) + + expect(list).to.be.an.instanceOf(PeerSet) + expect(callCount).to.equal(1) + + list.add(peer1) + + expect(value).to.equal(1) + expect(callCount).to.equal(2) + + list.add(peer2) + + expect(value).to.equal(2) + expect(callCount).to.equal(3) + + list.delete(peer1) + + expect(value).to.equal(1) + expect(callCount).to.equal(4) + + list.clear() + + expect(value).to.equal(0) + expect(callCount).to.equal(5) + }) +})