Skip to content
This repository has been archived by the owner on Feb 26, 2024. It is now read-only.

Add in-memory cache for forking requests #1248

Merged
merged 9 commits into from
Sep 27, 2021
31 changes: 27 additions & 4 deletions src/chains/ethereum/ethereum/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions src/chains/ethereum/ethereum/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@
"leveldown": "5.6.0",
"levelup": "4.4.0",
"lodash.clonedeep": "4.5.0",
"lru-cache": "6.0.0",
"mcl-wasm": "0.7.8",
"merkle-patricia-tree": "4.2.0",
"scrypt-js": "3.0.1",
Expand All @@ -91,6 +92,7 @@
"@types/fs-extra": "9.0.2",
"@types/keccak": "3.0.1",
"@types/lodash.clonedeep": "4.5.6",
"@types/lru-cache": "5.1.1",
"@types/mocha": "8.2.2",
"@types/secp256k1": "4.0.1",
"@types/seedrandom": "^3.0.1",
Expand Down
21 changes: 14 additions & 7 deletions src/chains/ethereum/ethereum/src/data-managers/block-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,13 +115,22 @@ export default class BlockManager extends Manager<Block> {
}

fromFallback = async (
tagOrBlockNumber: string | Buffer | Tag
tagOrBlockNumber: string | Quantity
): Promise<Buffer> => {
const fallback = this.#blockchain.fallback;
let blockNumber: string;
if (typeof tagOrBlockNumber === "string") {
blockNumber = tagOrBlockNumber;
} else if (tagOrBlockNumber.toBigInt() > fallback.blockNumber.toBigInt()) {
// don't get the block if the requested block is _after_ our fallback's
// blocknumber because it doesn't exist in our local chain.
return null;
} else {
blockNumber = tagOrBlockNumber.toString();
}

Comment on lines +120 to +130
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

previously, requesting blocks that exist on the original chain, but not locally, would always just return our lcoal block's "latest" instead of null. this fixes it -- probably. :-)

const json = await fallback.request<any>("eth_getBlockByNumber", [
typeof tagOrBlockNumber === "string"
? tagOrBlockNumber
: Quantity.from(tagOrBlockNumber).toString(),
blockNumber,
true
]);
return json == null ? null : BlockManager.rawFromJSON(json, this.#common);
Expand Down Expand Up @@ -199,9 +208,7 @@ export default class BlockManager extends Manager<Block> {
const numBuf = blockNumber.toBuffer();
return this.getRaw(numBuf).then(block => {
if (block == null && fallback) {
return this.fromFallback(
fallback.selectValidForkBlockNumber(blockNumber).toBuffer()
);
return this.fromFallback(blockNumber);
}
return block;
});
Expand Down
67 changes: 17 additions & 50 deletions src/chains/ethereum/ethereum/src/forking/fork.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,31 +10,32 @@ import { Block } from "@ganache/ethereum-block";
import { Address } from "@ganache/ethereum-address";
import { Account } from "@ganache/ethereum-utils";
import BlockManager from "../data-managers/block-manager";
import { ProviderHandler } from "./handlers/provider-handler";

function fetchChainId(fork: Fork) {
return fork
.request<string>("eth_chainId", [])
.then(chainIdHex => parseInt(chainIdHex, 16));
async function fetchChainId(fork: Fork) {
const chainIdHex = await fork.request<string>("eth_chainId", []);
return parseInt(chainIdHex, 16);
}
function fetchNetworkId(fork: Fork) {
return fork
.request<string>("net_version", [])
.then(networkIdStr => parseInt(networkIdStr, 10));
async function fetchNetworkId(fork: Fork) {
const networkIdStr = await fork.request<string>("net_version", []);
return parseInt(networkIdStr, 10);
}
function fetchBlockNumber(fork: Fork) {
return fork.request<string>("eth_blockNumber", []);
}
function fetchBlock(fork: Fork, blockNumber: Quantity | Tag.LATEST) {
return fork.request<any>("eth_getBlockByNumber", [blockNumber, true]);
}
function fetchNonce(
async function fetchNonce(
fork: Fork,
address: Address,
blockNumber: Quantity | Tag.LATEST
) {
return fork
.request<string>("eth_getTransactionCount", [address, blockNumber])
.then(nonce => Quantity.from(nonce));
const nonce = await fork.request<string>("eth_getTransactionCount", [
address,
blockNumber
]);
return Quantity.from(nonce);
Comment on lines +13 to +38
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

vscode suggested it could convert all these to async`, so I clicked the button, and this is what it made. I like it.

}

export class Fork {
Expand Down Expand Up @@ -75,44 +76,10 @@ export class Fork {
}
}
} else if (forkingOptions.provider) {
let id = 0;
this.#handler = {
request: <T>(method: string, params: any[]) => {
// format params via JSON stringification because the params might
// be Quantity or Data, which aren't valid as `params` themselves,
// but when JSON stringified they are
const paramCopy = JSON.parse(JSON.stringify(params));
if (forkingOptions.provider.request) {
return forkingOptions.provider.request({
method,
params: paramCopy
}) as Promise<T>;
} else if ((forkingOptions.provider as any).send) {
// TODO: remove support for legacy providers
// legacy `.send`
console.warn(
"WARNING: Ganache forking only supports EIP-1193-compliant providers. Legacy support for send is currently enabled, but will be removed in a future version _without_ a breaking change. To remove this warning, switch to an EIP-1193 provider. This error is probably caused by an old version of Web3's HttpProvider (or ganache < v7)"
);
return new Promise<T>((resolve, reject) => {
(forkingOptions.provider as any).send(
{
id: id++,
jsonrpc: "2.0",
method,
params: paramCopy
},
(err: Error, response: { result: T }) => {
if (err) return void reject(err);
resolve(response.result);
}
);
});
} else {
throw new Error("Forking `provider` must be EIP-1193 compatible");
}
},
close: () => Promise.resolve()
};
this.#handler = new ProviderHandler(
options,
this.#abortController.signal
);
}
}

Expand Down
96 changes: 74 additions & 22 deletions src/chains/ethereum/ethereum/src/forking/handlers/base-handler.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
import { EthereumInternalOptions } from "@ganache/ethereum-options";
import { JsonRpcError, JsonRpcResponse } from "@ganache/utils";
import { hasOwn, JsonRpcError } from "@ganache/utils";
import { AbortSignal } from "abort-controller";
import { OutgoingHttpHeaders } from "http";
import RateLimiter from "../rate-limiter/rate-limiter";
import LRU from "lru-cache";
import { AbortError, CodedError } from "@ganache/ethereum-utils";

const INVALID_RESPONSE = "Invalid response from fork provider: ";

type Headers = OutgoingHttpHeaders & { authorization?: string };

Expand All @@ -11,12 +15,11 @@ const INVALID_AUTH_ERROR =
const WINDOW_SECONDS = 30;

export class BaseHandler {
static JSONRPC_PREFIX = '{"jsonrpc":"2.0","id":';
static JSONRPC_PREFIX = '{"jsonrpc":"2.0","id":' as const;
protected id: number = 1;
protected requestCache = new Map<
string,
Promise<JsonRpcError | JsonRpcResponse>
>();
protected requestCache = new Map<string, Promise<unknown>>();
protected valueCache: LRU<string, string | Buffer>;

protected limiter: RateLimiter;
protected headers: Headers;
protected abortSignal: AbortSignal;
Expand All @@ -33,24 +36,31 @@ export class BaseHandler {
abortSignal
);

const headers: Headers = {
"user-agent": userAgent
};
if (origin) {
headers["origin"] = origin;
}
this.valueCache = new LRU({
max: 1_073_741_824, // 1 gigabyte
length: (value, key) => {
return value.length + key.length;
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To measure the cache size we measure the string length of the key and string length of the value. it's technically not perfect, but should be close enough for these purposes.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Your comment should be part of the code

}
});

// we set our own Authentication headers, so username and password must be
// removed from the url. (The values have already been copied to the options)
url.password = url.username = "";
// we don't header-related things if we are using a provider instead of a url
if (url) {
const headers: Headers = {
"user-agent": userAgent
};
if (origin) {
headers["origin"] = origin;
}

BaseHandler.setAuthHeaders(forkingOptions, headers);
BaseHandler.setUserHeaders(
forkingOptions,
headers,
!url.host.endsWith(".infura.io")
);
this.headers = headers;
// we set our own Authentication headers, so username and password must be
// removed from the url. (The values have already been copied to the options)
url.password = url.username = "";
const isInfura = url.host.endsWith(".infura.io");

BaseHandler.setAuthHeaders(forkingOptions, headers);
BaseHandler.setUserHeaders(forkingOptions, headers, !isInfura);
this.headers = headers;
}
}

/**
Expand Down Expand Up @@ -122,4 +132,46 @@ export class BaseHandler {
}
}
}

getFromCache<T>(key: string) {
const cachedRequest = this.requestCache.get(key);
if (cachedRequest !== undefined) return cachedRequest as Promise<T>;

const cachedValue = this.valueCache.get(key);
if (cachedValue !== undefined) return JSON.parse(cachedValue).result as T;
}

async queueRequest<T>(
key: string,
send: (
...args: unknown[]
) => Promise<{
response: { result: any } | { error: { message: string; code: number } };
raw: string | Buffer;
}>
): Promise<T> {
const cached = this.getFromCache<T>(key);
if (cached !== undefined) return cached;

const promise = this.limiter.handle(send).then(({ response, raw }) => {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The send function actually does the sending, the limiter just schedules when to call that funciton. The send function returns a {response, raw} object here, raw is just used for the cache, and is a string or Buffer.

I did this because we can't measure the size of a json object for the cache, but we can for a string and Buffer.

if (this.abortSignal.aborted) return Promise.reject(new AbortError());

// check for null/undefined, as we can't trust that network responses will
// be well-formed
if (typeof response == "object") {
if (hasOwn(response, "result")) {
// cache non-error responses only
this.valueCache.set(key, raw);

return response.result as T;
} else if (hasOwn(response, "error") && response.error != null) {
const { error } = response as JsonRpcError;
throw new CodedError(error.message, error.code);
}
}
throw new Error(`${INVALID_RESPONSE}\`${JSON.stringify(response)}\``);
});
this.requestCache.set(key, promise);
return await promise;
}
}
34 changes: 13 additions & 21 deletions src/chains/ethereum/ethereum/src/forking/handlers/http-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,13 +85,8 @@ export class HttpHandler extends BaseHandler implements Handler {
});
}

public async request(method: string, params: unknown[]) {
const data = JSON.stringify({ method, params });
if (this.requestCache.has(data)) {
//console.log("cache hit: " + data);
return this.requestCache.get(data);
}

public async request<T>(method: string, params: unknown[]) {
const key = JSON.stringify({ method, params });
const { protocol, hostname: host, port, pathname, search } = this.url;
const requestOptions = {
protocol,
Expand All @@ -108,9 +103,11 @@ export class HttpHandler extends BaseHandler implements Handler {
const send = () => {
if (this.abortSignal.aborted) return Promise.reject(new AbortError());

//console.log("sending request: " + data);
const deferred = Deferred<JsonRpcResponse | JsonRpcError>();
const postData = `${JSONRPC_PREFIX}${this.id++},${data.slice(1)}`;
const deferred = Deferred<{
response: JsonRpcResponse | JsonRpcError;
raw: Buffer;
}>();
const postData = `${JSONRPC_PREFIX}${this.id++},${key.slice(1)}`;
this.headers["content-length"] = postData.length;

const req = this._request(requestOptions);
Expand All @@ -135,7 +132,10 @@ export class HttpHandler extends BaseHandler implements Handler {
// TODO: handle invalid JSON (throws on parse)?
buffer.then(buffer => {
try {
deferred.resolve(JSON.parse(buffer));
deferred.resolve({
response: JSON.parse(buffer),
raw: buffer
});
} catch {
const resStr = buffer.toString();
let shortStr: string;
Expand Down Expand Up @@ -165,18 +165,10 @@ export class HttpHandler extends BaseHandler implements Handler {
req.write(postData);
req.end();

return deferred.promise.finally(() => this.requestCache.delete(data));
return deferred.promise.finally(() => this.requestCache.delete(key));
};

const promise = this.limiter.handle(send).then(result => {
if ("result" in result) {
return result.result;
} else if ("error" in result) {
throw new CodedError(result.error.message, result.error.code);
}
});
this.requestCache.set(data, promise);
return promise;
return await this.queueRequest<T>(key, send);
}

public close() {
Expand Down
Loading