From c7d09555632826d9eff62197c2708d14b870c68f Mon Sep 17 00:00:00 2001 From: Denis Davidyuk Date: Sun, 25 Feb 2024 19:44:42 +0700 Subject: [PATCH] feat(chain): add `cache` option to `getHeight` Also, cache current height in transaction polling to avoid extra requests. --- src/AeSdkBase.ts | 26 +++++++++++--------- src/AeSdkMethods.ts | 42 +++++++++++++------------------- src/chain.ts | 33 +++++++++++++++++++++----- src/oracle.ts | 2 +- src/tx/validator.ts | 2 +- src/utils/other.ts | 24 +++++++++++++++++++ test/integration/chain.ts | 50 ++++++++++++++++++++++++++++++--------- test/utils.ts | 5 ++-- 8 files changed, 126 insertions(+), 58 deletions(-) diff --git a/src/AeSdkBase.ts b/src/AeSdkBase.ts index 507ace5f9a..869c767e1e 100644 --- a/src/AeSdkBase.ts +++ b/src/AeSdkBase.ts @@ -4,8 +4,9 @@ import { CompilerError, DuplicateNodeError, NodeNotFoundError, NotImplementedError, TypeError, } from './utils/errors'; import { Encoded } from './utils/encoder'; +import { wrapWithProxy } from './utils/other'; import CompilerBase from './contract/compiler/Base'; -import AeSdkMethods, { OnAccount, getValueOrErrorProxy, AeSdkMethodsOptions } from './AeSdkMethods'; +import AeSdkMethods, { OnAccount, AeSdkMethodsOptions, WrappedOptions } from './AeSdkMethods'; import { AensName } from './tx/builder/constants'; type NodeInfo = Awaited> & { name: string }; @@ -21,6 +22,8 @@ export default class AeSdkBase extends AeSdkMethods { selectedNodeName?: string; + readonly #wrappedOptions: WrappedOptions; + /** * @param options - Options * @param options.nodes - Array of nodes @@ -33,6 +36,12 @@ export default class AeSdkBase extends AeSdkMethods { super(options); nodes.forEach(({ name, instance }, i) => this.addNode(name, instance, i === 0)); + + this.#wrappedOptions = { + onNode: wrapWithProxy(() => this.api), + onCompiler: wrapWithProxy(() => this.compilerApi), + onAccount: wrapWithProxy(() => this._resolveAccount()), + }; } // TODO: consider dropping this getter, because: @@ -293,19 +302,14 @@ export default class AeSdkBase extends AeSdkMethods { * The same as AeSdkMethods:getContext, but it would resolve ak_-prefixed address in * `mergeWith.onAccount` to AccountBase. */ - override getContext(mergeWith: AeSdkMethodsOptions = {}): AeSdkMethodsOptions & { - onNode: Node; - onAccount: AccountBase; - onCompiler: CompilerBase; - } { + override getContext(mergeWith: AeSdkMethodsOptions = {}): AeSdkMethodsOptions & WrappedOptions { return { ...this._options, - onNode: getValueOrErrorProxy(() => this.api), - onCompiler: getValueOrErrorProxy(() => this.compilerApi), + ...this.#wrappedOptions, ...mergeWith, - onAccount: mergeWith.onAccount != null - ? this._resolveAccount(mergeWith.onAccount) - : getValueOrErrorProxy(() => this._resolveAccount()), + ...mergeWith.onAccount != null && { + onAccount: this._resolveAccount(mergeWith.onAccount), + }, }; } } diff --git a/src/AeSdkMethods.ts b/src/AeSdkMethods.ts index ae6beeda9e..64c5fdce64 100644 --- a/src/AeSdkMethods.ts +++ b/src/AeSdkMethods.ts @@ -6,7 +6,7 @@ import Contract, { ContractMethodsBase } from './contract/Contract'; import createDelegationSignature from './contract/delegation-signature'; import * as contractGaMethods from './contract/ga'; import { buildTxAsync } from './tx/builder'; -import { mapObject, UnionToIntersection } from './utils/other'; +import { mapObject, UnionToIntersection, wrapWithProxy } from './utils/other'; import Node from './Node'; import { TxParamsAsync } from './tx/builder/schema.generated'; import AccountBase from './account/Base'; @@ -15,25 +15,6 @@ import CompilerBase from './contract/compiler/Base'; export type OnAccount = Encoded.AccountAddress | AccountBase | undefined; -export function getValueOrErrorProxy( - valueCb: () => Value, -): NonNullable { - return new Proxy( - {}, - Object.fromEntries(([ - 'apply', 'construct', 'defineProperty', 'deleteProperty', 'getOwnPropertyDescriptor', - 'getPrototypeOf', 'isExtensible', 'ownKeys', 'preventExtensions', 'set', 'setPrototypeOf', - 'get', 'has', - ] as const).map((name) => [name, (t: {}, ...args: unknown[]) => { - const target = valueCb() as object; // to get a native exception in case it missed - const res = (Reflect[name] as any)(target, ...args); - return typeof res === 'function' && name === 'get' - ? res.bind(target) // otherwise it fails with attempted to get private field on non-instance - : res; - }])), - ) as NonNullable; -} - const { InvalidTxError: _2, ...chainMethodsOther } = chainMethods; const methods = { @@ -57,6 +38,12 @@ export interface AeSdkMethodsOptions extends Partial> { } +export interface WrappedOptions { + onAccount: AccountBase; + onCompiler: CompilerBase; + onNode: Node; +} + /** * AeSdkMethods is the composition of: * - chain methods @@ -73,11 +60,18 @@ export interface AeSdkMethodsOptions class AeSdkMethods { _options: AeSdkMethodsOptions = {}; + readonly #wrappedOptions: WrappedOptions; + /** * @param options - Options */ constructor(options: AeSdkMethodsOptions = {}) { Object.assign(this._options, options); + this.#wrappedOptions = { + onAccount: wrapWithProxy(() => this._options.onAccount), + onNode: wrapWithProxy(() => this._options.onNode), + onCompiler: wrapWithProxy(() => this._options.onCompiler), + }; } /** @@ -86,14 +80,10 @@ class AeSdkMethods { * @param mergeWith - Merge context with these extra options * @returns Context object */ - getContext( - mergeWith: AeSdkMethodsOptions = {}, - ): AeSdkMethodsOptions & { onAccount: AccountBase; onCompiler: CompilerBase; onNode: Node } { + getContext(mergeWith: AeSdkMethodsOptions = {}): AeSdkMethodsOptions & WrappedOptions { return { ...this._options, - onAccount: getValueOrErrorProxy(() => this._options.onAccount), - onNode: getValueOrErrorProxy(() => this._options.onNode), - onCompiler: getValueOrErrorProxy(() => this._options.onCompiler), + ...this.#wrappedOptions, ...mergeWith, }; } diff --git a/src/chain.ts b/src/chain.ts index ba053a2d4f..f9bedc5d3a 100644 --- a/src/chain.ts +++ b/src/chain.ts @@ -1,6 +1,8 @@ import { AE_AMOUNT_FORMATS, formatAmount } from './utils/amount-formatter'; import verifyTransaction, { ValidatorResult } from './tx/validator'; -import { ensureError, isAccountNotFoundError, pause } from './utils/other'; +import { + ensureError, isAccountNotFoundError, pause, unwrapProxy, +} from './utils/other'; import { isNameValid, produceNameId } from './tx/builder/helpers'; import { DRY_RUN_ACCOUNT } from './tx/builder/schema'; import { AensName } from './tx/builder/constants'; @@ -56,14 +58,33 @@ export class InvalidTxError extends TransactionError { } } +const heightCache: WeakMap = new WeakMap(); + /** * Obtain current height of the chain * @category chain * @param options - Options + * @param options.cached - Get height from the cache. The lag behind the actual height shouldn't + * be more than 1 block. Use if needed to reduce requests count, and approximate value can be used. + * For example, for timeout check in transaction status polling. * @returns Current chain height */ -export async function getHeight({ onNode }: { onNode: Node }): Promise { - return (await onNode.getCurrentKeyBlockHeight()).height; +export async function getHeight( + { cached = false, ...options }: { + onNode: Node; + cached?: boolean; + } & Parameters[1], +): Promise { + const onNode = unwrapProxy(options.onNode); + if (cached) { + const cache = heightCache.get(onNode); + if (cache?.time != null && cache.time > Date.now() - _getPollInterval('block', options)) { + return cache.height; + } + } + const { height } = await onNode.getCurrentKeyBlockHeight(); + heightCache.set(onNode, { height, time: Date.now() }); + return height; } /** @@ -84,12 +105,12 @@ export async function poll( { blocks?: number; interval?: number; onNode: Node } & Parameters[1], ): Promise> { interval ??= _getPollInterval('microblock', options); - const max = await getHeight({ onNode }) + blocks; + const max = await getHeight({ ...options, onNode, cached: true }) + blocks; do { const tx = await onNode.getTransactionByHash(th); if (tx.blockHeight !== -1) return tx; await pause(interval); - } while (await getHeight({ onNode }) < max); + } while (await getHeight({ ...options, onNode, cached: true }) < max); throw new TxTimedOutError(blocks, th); } @@ -111,7 +132,7 @@ export async function awaitHeight( let currentHeight; do { if (currentHeight != null) await pause(interval); - currentHeight = (await onNode.getCurrentKeyBlockHeight()).height; + currentHeight = await getHeight({ onNode }); } while (currentHeight < height); return currentHeight; } diff --git a/src/oracle.ts b/src/oracle.ts index 6d400ed1f6..ce94903544 100644 --- a/src/oracle.ts +++ b/src/oracle.ts @@ -87,7 +87,7 @@ export async function pollForQueryResponse( const responseBuffer = decode(response as Encoded.OracleResponse); if (responseBuffer.length > 0) return responseBuffer.toString(); await pause(interval); - height = await getHeight({ onNode }); + height = await getHeight({ ...options, onNode, cached: true }); } while (ttl >= height); throw new RequestTimedOutError(height); } diff --git a/src/tx/validator.ts b/src/tx/validator.ts index 2a5099320f..60170f0b0d 100644 --- a/src/tx/validator.ts +++ b/src/tx/validator.ts @@ -48,7 +48,7 @@ async function verifyTransactionInternal( }) // TODO: remove after fixing https://github.com/aeternity/aepp-sdk-js/issues/1537 .then((acc) => ({ ...acc, id: acc.id as Encoded.AccountAddress })), - node.getCurrentKeyBlockHeight(), + node.getCurrentKeyBlockHeight(), // TODO: don't request height on each validation, use caching node.getNodeInfo(), ]); diff --git a/src/utils/other.ts b/src/utils/other.ts index 2e137e0249..df68e6691a 100644 --- a/src/utils/other.ts +++ b/src/utils/other.ts @@ -27,6 +27,30 @@ export const concatBuffers = isWebpack4Buffer ) : Buffer.concat; +export function wrapWithProxy( + valueCb: () => Value, +): NonNullable { + return new Proxy( + {}, + Object.fromEntries(([ + 'apply', 'construct', 'defineProperty', 'deleteProperty', 'getOwnPropertyDescriptor', + 'getPrototypeOf', 'isExtensible', 'ownKeys', 'preventExtensions', 'set', 'setPrototypeOf', + 'get', 'has', + ] as const).map((name) => [name, (t: {}, ...args: unknown[]) => { + if (name === 'get' && args[0] === '_wrappedValue') return valueCb(); + const target = valueCb() as object; // to get a native exception in case it missed + const res = (Reflect[name] as any)(target, ...args); + return typeof res === 'function' && name === 'get' + ? res.bind(target) // otherwise it fails with attempted to get private field on non-instance + : res; + }])), + ) as NonNullable; +} + +export function unwrapProxy(value: Value): Value { + return (value as { _wrappedValue?: Value })._wrappedValue ?? value; +} + /** * Object key type guard * @param key - Maybe object key diff --git a/test/integration/chain.ts b/test/integration/chain.ts index 207b1fa9fd..ae0de7580d 100644 --- a/test/integration/chain.ts +++ b/test/integration/chain.ts @@ -2,7 +2,7 @@ import { describe, it, before } from 'mocha'; import { expect } from 'chai'; import { getSdk } from '.'; import { - generateKeyPair, AeSdk, Tag, MemoryAccount, Encoded, + generateKeyPair, AeSdk, Tag, MemoryAccount, Encoded, Node, } from '../../src'; import { assertNotNull, bindRequestCounter } from '../utils'; @@ -16,17 +16,45 @@ describe('Node Chain', () => { aeSdkWithoutAccount = await getSdk(0); }); - it('determines the height', async () => { - expect(await aeSdkWithoutAccount.getHeight()).to.be.a('number'); - }); + describe('getHeight', () => { + it('determines the height', async () => { + expect(await aeSdkWithoutAccount.getHeight()).to.be.a('number'); + }); - it('combines height queries', async () => { - const getCount = bindRequestCounter(aeSdk.api); - const heights = await Promise.all( - new Array(5).fill(undefined).map(async () => aeSdk.getHeight()), - ); - expect(heights).to.eql(heights.map(() => heights[0])); - expect(getCount()).to.be.equal(1); + it('combines height queries', async () => { + const getCount = bindRequestCounter(aeSdk.api); + const heights = await Promise.all( + new Array(5).fill(undefined).map(async () => aeSdk.getHeight()), + ); + expect(heights).to.eql(heights.map(() => heights[0])); + expect(getCount()).to.be.equal(1); + }); + + it('returns height from cache', async () => { + const height = await aeSdk.getHeight(); + const getCount = bindRequestCounter(aeSdk.api); + expect(await aeSdk.getHeight({ cached: true })).to.be.equal(height); + expect(getCount()).to.be.equal(0); + }); + + it('returns not cached height if network changed', async () => { + const height = await aeSdk.getHeight(); + aeSdk.addNode('test-2', new Node(`${aeSdk.api.$host}/`), true); + const getCount = bindRequestCounter(aeSdk.api); + expect(await aeSdk.getHeight({ cached: true })).to.be.equal(height); + expect(getCount()).to.be.equal(1); + aeSdk.selectNode('test'); + aeSdk.pool.delete('test-2'); + }); + + it('uses correct cache key if node changed while doing request', async () => { + const heightPromise = aeSdk.getHeight(); + aeSdk.addNode('test-2', new Node('http://example.com'), true); + await heightPromise; + await expect(aeSdk.getHeight({ cached: true })) + .to.be.rejectedWith('v3/status error: 404 status code'); + aeSdk.selectNode('test'); + }); }); it('waits for specified heights', async () => { diff --git a/test/utils.ts b/test/utils.ts index 8fe4d23395..a2260d262f 100644 --- a/test/utils.ts +++ b/test/utils.ts @@ -33,16 +33,17 @@ export type InputNumber = number | bigint | string | BigNumber; export function checkOnlyTypes(cb: Function): void {} export function bindRequestCounter(node: Node): () => number { + const name = `counter-${randomString(6)}`; let counter = 0; node.pipeline.addPolicy({ - name: 'counter', + name, async sendRequest(request, next) { counter += 1; return next(request); }, }, { phase: 'Deserialize' }); return () => { - node.pipeline.removePolicy({ name: 'counter' }); + node.pipeline.removePolicy({ name }); return counter; }; }