diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c43ec2c0d..a94fa4cc4d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ Changes: - Add Polkadot 9122 upgrade block - Cleanup Polkadot/Kusama/Wetsend/Rococo known types (>= v14 metadata) - Internal `decorateMethod{Promise, Rx}` renamed to `to{Promise, Rx}Method` +- Add short term provider-level caching for historic requests ## 6.7.2 Nov 9, 2021 diff --git a/packages/rpc-core/src/bundle.ts b/packages/rpc-core/src/bundle.ts index 447c74f735..6816a0e496 100644 --- a/packages/rpc-core/src/bundle.ts +++ b/packages/rpc-core/src/bundle.ts @@ -204,7 +204,7 @@ export class RpcCore { ? await this.#getBlockRegistry(u8aToU8a(blockHash)) : { registry: this.#registryDefault }; const params = this._formatInputs(registry, null, def, values); - const result = await this.provider.send(rpcName, params.map((p) => p.toJSON())); + const result = await this.provider.send(rpcName, params.map((p) => p.toJSON()), !!blockHash); return this._formatResult(isScale, registry, blockHash, method, def, params, result); }; diff --git a/packages/rpc-core/src/methodSend.spec.ts b/packages/rpc-core/src/methodSend.spec.ts index 4142e5ed87..df8be1e5b7 100644 --- a/packages/rpc-core/src/methodSend.spec.ts +++ b/packages/rpc-core/src/methodSend.spec.ts @@ -62,7 +62,7 @@ describe('methodSend', (): void => { // eslint-disable-next-line @typescript-eslint/no-unsafe-call,@typescript-eslint/no-unsafe-member-access method(new Uint8Array([2 << 2, 0x12, 0x34])).subscribe((): void => { // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - expect(provider.send).toHaveBeenCalledWith('test_blah', ['0x1234']); + expect(provider.send).toHaveBeenCalledWith('test_blah', ['0x1234'], false); done(); }); }); diff --git a/packages/rpc-provider/src/http/index.ts b/packages/rpc-provider/src/http/index.ts index af5ac3dd3c..871d9b3ccb 100644 --- a/packages/rpc-provider/src/http/index.ts +++ b/packages/rpc-provider/src/http/index.ts @@ -8,6 +8,7 @@ import { fetch } from '@polkadot/x-fetch'; import { RpcCoder } from '../coder'; import defaults from '../defaults'; +import { LRUCache } from '../lru'; const ERROR_SUBSCRIBE = 'HTTP Provider does not have subscriptions, use WebSockets instead'; @@ -34,6 +35,8 @@ const l = logger('api-http'); * @see [[WsProvider]] */ export class HttpProvider implements ProviderInterface { + readonly #callCache = new LRUCache(); + readonly #coder: RpcCoder; readonly #endpoint: string; @@ -103,8 +106,24 @@ export class HttpProvider implements ProviderInterface { /** * @summary Send HTTP POST Request with Body to configured HTTP Endpoint. */ - public async send (method: string, params: unknown[]): Promise { + public async send (method: string, params: unknown[], isCacheable?: boolean): Promise { const body = this.#coder.encodeJson(method, params); + let resultPromise: Promise | null = isCacheable + ? this.#callCache.get(body) as Promise + : null; + + if (!resultPromise) { + resultPromise = this.#send(body); + + if (isCacheable) { + this.#callCache.set(body, resultPromise); + } + } + + return resultPromise; + } + + async #send (body: string): Promise { const response = await fetch(this.#endpoint, { body, headers: { diff --git a/packages/rpc-provider/src/lru.spec.ts b/packages/rpc-provider/src/lru.spec.ts new file mode 100644 index 0000000000..6d675884fa --- /dev/null +++ b/packages/rpc-provider/src/lru.spec.ts @@ -0,0 +1,49 @@ +// Copyright 2017-2021 @polkadot/rpc-provider authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import { LRUCache } from './lru'; + +describe('LRUCache', (): void => { + it('allows getting of items below capacity', (): void => { + const keys = ['1', '2', '3', '4']; + const lru = new LRUCache(4); + + keys.forEach((k) => lru.set(k, `${k}${k}${k}`)); + + expect(lru.keys().join(', ')).toEqual(keys.reverse().join(', ')); + + keys.forEach((k) => expect(lru.get(k)).toEqual(`${k}${k}${k}`)); + }); + + it('drops items when at capacity', (): void => { + const keys = ['1', '2', '3', '4', '5', '6']; + const lru = new LRUCache(4); + + keys.forEach((k) => lru.set(k, `${k}${k}${k}`)); + + expect(lru.keys().join(', ')).toEqual(keys.slice(2).reverse().join(', ')); + + keys.slice(2).forEach((k) => expect(lru.get(k)).toEqual(`${k}${k}${k}`)); + }); + + it('adjusts the order as they are used', (): void => { + const keys = ['1', '2', '3', '4', '5']; + const lru = new LRUCache(4); + + keys.forEach((k) => lru.set(k, `${k}${k}${k}`)); + + expect(lru.entries()).toEqual([['5', '555'], ['4', '444'], ['3', '333'], ['2', '222']]); + + lru.get('3'); + + expect(lru.entries()).toEqual([['3', '333'], ['5', '555'], ['4', '444'], ['2', '222']]); + + lru.set('4', '4433'); + + expect(lru.entries()).toEqual([['4', '4433'], ['3', '333'], ['5', '555'], ['2', '222']]); + + lru.set('6', '666'); + + expect(lru.entries()).toEqual([['6', '666'], ['4', '4433'], ['3', '333'], ['5', '555']]); + }); +}); diff --git a/packages/rpc-provider/src/lru.ts b/packages/rpc-provider/src/lru.ts new file mode 100644 index 0000000000..3a1935e2f7 --- /dev/null +++ b/packages/rpc-provider/src/lru.ts @@ -0,0 +1,120 @@ +// Copyright 2017-2021 @polkadot/rpc-provider authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +const DEFAULT_CAPACITY = 2048; + +class LRUNode { + public readonly key: string; + + public next: LRUNode; + public prev: LRUNode; + + constructor (key: string) { + this.key = key; + this.next = this.prev = this; + } +} + +// https://en.wikipedia.org/wiki/Cache_replacement_policies#LRU +export class LRUCache { + readonly capacity: number; + readonly #data: Map = new Map(); + readonly #refs: Map = new Map(); + + #length = 0; + #head: LRUNode = new LRUNode('head'); + #tail: LRUNode = new LRUNode('tail'); + + constructor (capacity = DEFAULT_CAPACITY) { + this.capacity = capacity; + } + + get length (): number { + return this.#length; + } + + entries (): [string, unknown][] { + const keys = this.keys(); + const entries = new Array<[string, unknown]>(keys.length); + + for (let i = 0; i < keys.length; i++) { + const key = keys[i]; + + entries[i] = [key, this.#data.get(key)]; + } + + return entries; + } + + keys (): string[] { + const keys: string[] = []; + + if (this.length) { + let curr = this.#head; + + while (curr !== this.#tail) { + keys.push(curr.key); + curr = curr.next; + } + + keys.push(curr.key); + } + + return keys; + } + + get (key: string): T | null { + const data = this.#data.get(key); + + if (data) { + this.#toFront(key); + + return data as T; + } + + return null; + } + + set (key: string, value: T): void { + if (this.#data.has(key)) { + this.#toFront(key); + } else { + const node = new LRUNode(key); + + this.#refs.set(node.key, node); + + if (this.length === 0) { + this.#head = this.#tail = node; + } else { + this.#head.prev = node; + node.next = this.#head; + this.#head = node; + } + + if (this.#length === this.capacity) { + this.#data.delete(this.#tail.key); + this.#refs.delete(this.#tail.key); + + this.#tail = this.#tail.prev; + this.#tail.next = this.#head; + } else { + this.#length += 1; + } + } + + this.#data.set(key, value); + } + + #toFront (key: string): void { + const ref = this.#refs.get(key); + + if (ref && ref !== this.#head) { + ref.prev.next = ref.next; + ref.next.prev = ref.prev; + ref.next = this.#head; + + this.#head.prev = ref; + this.#head = ref; + } + } +} diff --git a/packages/rpc-provider/src/types.ts b/packages/rpc-provider/src/types.ts index 2e57d7cdb9..96d465a37f 100644 --- a/packages/rpc-provider/src/types.ts +++ b/packages/rpc-provider/src/types.ts @@ -49,7 +49,7 @@ export interface ProviderInterface { connect (): Promise; disconnect (): Promise; on (type: ProviderInterfaceEmitted, sub: ProviderInterfaceEmitCb): () => void; - send (method: string, params: unknown[]): Promise; + send (method: string, params: unknown[], isCacheable?: boolean): Promise; subscribe (type: string, method: string, params: unknown[], cb: ProviderInterfaceCallback): Promise; unsubscribe (type: string, method: string, id: number | string): Promise; } diff --git a/packages/rpc-provider/src/ws/index.ts b/packages/rpc-provider/src/ws/index.ts index 6b64ee4d08..a22d3b1bd9 100644 --- a/packages/rpc-provider/src/ws/index.ts +++ b/packages/rpc-provider/src/ws/index.ts @@ -7,12 +7,13 @@ import type { JsonRpcResponse, ProviderInterface, ProviderInterfaceCallback, Pro import EventEmitter from 'eventemitter3'; -import { assert, isChildClass, isNull, isUndefined, logger } from '@polkadot/util'; +import { assert, isChildClass, isNull, isUndefined, logger, objectSpread } from '@polkadot/util'; import { xglobal } from '@polkadot/x-global'; import { WebSocket } from '@polkadot/x-ws'; import { RpcCoder } from '../coder'; import defaults from '../defaults'; +import { LRUCache } from '../lru'; import { getWSErrorString } from './errors'; interface SubscriptionHandler { @@ -75,6 +76,8 @@ function eraseRecord (record: Record, cb?: (item: T) => void): voi * @see [[HttpProvider]] */ export class WsProvider implements ProviderInterface { + readonly #callCache = new LRUCache(); + readonly #coder: RpcCoder; readonly #endpoints: string[]; @@ -260,12 +263,28 @@ export class WsProvider implements ProviderInterface { * @param params Encoded parameters as applicable for the method * @param subscription Subscription details (internally used) */ - public send (method: string, params: unknown[], subscription?: SubscriptionHandler): Promise { + public send (method: string, params: unknown[], isCacheable?: boolean, subscription?: SubscriptionHandler): Promise { + const body = this.#coder.encodeJson(method, params); + let resultPromise: Promise | null = isCacheable + ? this.#callCache.get(body) as Promise + : null; + + if (!resultPromise) { + resultPromise = this.#send(body, method, params, subscription); + + if (isCacheable) { + this.#callCache.set(body, resultPromise); + } + } + + return resultPromise; + } + + async #send (json: string, method: string, params: unknown[], subscription?: SubscriptionHandler): Promise { return new Promise((resolve, reject): void => { try { assert(this.isConnected && !isNull(this.#websocket), 'WebSocket is not connected'); - const json = this.#coder.encodeJson(method, params); const id = this.#coder.getId(); const callback = (error?: Error | null, result?: T): void => { @@ -309,7 +328,7 @@ export class WsProvider implements ProviderInterface { * ``` */ public subscribe (type: string, method: string, params: unknown[], callback: ProviderInterfaceCallback): Promise { - return this.send(method, params, { callback, type }); + return this.send(method, params, false, { callback, type }); } /** @@ -410,11 +429,10 @@ export class WsProvider implements ProviderInterface { if (subscription) { const subId = `${subscription.type}::${result}`; - this.#subscriptions[subId] = { - ...subscription, + this.#subscriptions[subId] = objectSpread({}, subscription, { method, params - }; + }); // if we have a result waiting for this subscription already if (this.#waitingForId[subId]) {