Skip to content

Commit

Permalink
feat(chain): add cache option to getHeight
Browse files Browse the repository at this point in the history
Also, cache current height in transaction polling to avoid extra requests.
  • Loading branch information
davidyuk committed Feb 29, 2024
1 parent b009940 commit c7d0955
Show file tree
Hide file tree
Showing 8 changed files with 126 additions and 58 deletions.
26 changes: 15 additions & 11 deletions src/AeSdkBase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ReturnType<Node['getNodeInfo']>> & { name: string };
Expand All @@ -21,6 +22,8 @@ export default class AeSdkBase extends AeSdkMethods {

selectedNodeName?: string;

readonly #wrappedOptions: WrappedOptions;

/**
* @param options - Options
* @param options.nodes - Array of nodes
Expand All @@ -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:
Expand Down Expand Up @@ -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),
},
};
}
}
42 changes: 16 additions & 26 deletions src/AeSdkMethods.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -15,25 +15,6 @@ import CompilerBase from './contract/compiler/Base';

export type OnAccount = Encoded.AccountAddress | AccountBase | undefined;

export function getValueOrErrorProxy<Value extends object | undefined>(
valueCb: () => Value,
): NonNullable<Value> {
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<Value>;
}

const { InvalidTxError: _2, ...chainMethodsOther } = chainMethods;

const methods = {
Expand All @@ -57,6 +38,12 @@ export interface AeSdkMethodsOptions
extends Partial<UnionToIntersection<MethodsOptions[keyof MethodsOptions]>> {
}

export interface WrappedOptions {
onAccount: AccountBase;
onCompiler: CompilerBase;
onNode: Node;
}

/**
* AeSdkMethods is the composition of:
* - chain methods
Expand All @@ -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),
};
}

/**
Expand All @@ -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,
};
}
Expand Down
33 changes: 27 additions & 6 deletions src/chain.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -56,14 +58,33 @@ export class InvalidTxError extends TransactionError {
}
}

const heightCache: WeakMap<Node, { time: number; height: number }> = 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<number> {
return (await onNode.getCurrentKeyBlockHeight()).height;
export async function getHeight(
{ cached = false, ...options }: {
onNode: Node;
cached?: boolean;
} & Parameters<typeof _getPollInterval>[1],
): Promise<number> {
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;
}

/**
Expand All @@ -84,12 +105,12 @@ export async function poll(
{ blocks?: number; interval?: number; onNode: Node } & Parameters<typeof _getPollInterval>[1],
): Promise<TransformNodeType<SignedTx>> {
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);
}

Expand All @@ -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;
}
Expand Down
2 changes: 1 addition & 1 deletion src/oracle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
2 changes: 1 addition & 1 deletion src/tx/validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
]);

Expand Down
24 changes: 24 additions & 0 deletions src/utils/other.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,30 @@ export const concatBuffers = isWebpack4Buffer
)
: Buffer.concat;

export function wrapWithProxy<Value extends object | undefined>(
valueCb: () => Value,
): NonNullable<Value> {
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<Value>;
}

export function unwrapProxy<Value extends object>(value: Value): Value {
return (value as { _wrappedValue?: Value })._wrappedValue ?? value;
}

/**
* Object key type guard
* @param key - Maybe object key
Expand Down
50 changes: 39 additions & 11 deletions test/integration/chain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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 () => {
Expand Down
5 changes: 3 additions & 2 deletions test/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
}

0 comments on commit c7d0955

Please sign in to comment.