diff --git a/package.json b/package.json index fad5b2d..1708ab1 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "prepare": "husky install" }, "dependencies": { + "@ardrive/ardrive-promise-cache": "^1.1.3", "@commitlint/config-conventional": "^17.7.0", "@koa/cors": "^4.0.0", "@koa/router": "^12.0.0", diff --git a/src/api/warp.ts b/src/api/warp.ts index 50c8344..1f436e1 100644 --- a/src/api/warp.ts +++ b/src/api/warp.ts @@ -15,6 +15,12 @@ import { EvaluationTimeoutError } from '../errors'; import { createHash } from 'crypto'; import Arweave from 'arweave'; import { Tag } from 'arweave/node/lib/transaction'; +import { ReadThroughPromiseCache } from '@ardrive/ardrive-promise-cache'; +import winston from 'winston'; + +export type EvaluatedContractState = EvalStateResult & { + evaluationOptions?: Partial; +}; export class EvaluationError extends Error { constructor(message?: string) { @@ -25,6 +31,65 @@ export class EvaluationError extends Error { // cache duplicate requests on the same instance within a short period of time const requestMap: Map | undefined> = new Map(); +// Convenience class for read through caching +class ContractStateCacheKey { + constructor( + public readonly contractTxId: string, + public readonly evaluationOptions: Partial, + public readonly warp: Warp, + public readonly logger?: winston.Logger, + ) {} + + toString(): string { + return `${this.contractTxId}-${createQueryParamHash( + this.evaluationOptions, + )}`; + } + + // Facilitate ReadThroughPromiseCache key derivation + toJSON() { + return { cacheKey: this.toString() }; + } +} + +// Cache contract states for 30 seconds since block time is around 2 minutes +const contractStateCache: ReadThroughPromiseCache< + ContractStateCacheKey, + EvaluatedContractState +> = new ReadThroughPromiseCache({ + cacheParams: { + cacheCapacity: 100, + cacheTTL: 1000 * 30, // 30 seconds + }, + readThroughFunction: (cacheKey) => readThroughToContractState(cacheKey), +}); + +// Convenience class for read through caching +class ContractManifestCacheKey { + constructor( + public readonly contractTxId: string, + public readonly arweave: Arweave, + public readonly logger?: winston.Logger, + ) {} + + // Facilitate ReadThroughPromiseCache key derivation + toJSON() { + return { contractTxId: this.contractTxId }; + } +} + +// Aggressively cache contract manifests since they're permanent on chain +const contractManifestCache: ReadThroughPromiseCache< + ContractManifestCacheKey, + any +> = new ReadThroughPromiseCache({ + cacheParams: { + cacheCapacity: 1000, + cacheTTL: 1000 * 60 * 60 * 24 * 365, // 365 days - effectively permanent + }, + readThroughFunction: (cacheKey) => readThroughToContractManifest(cacheKey), +}); + function createQueryParamHash(evalOptions: Partial): string { // Function to calculate the hash of a string const hash = createHash('sha256'); @@ -32,51 +97,106 @@ function createQueryParamHash(evalOptions: Partial): string { return hash.digest('hex'); } +async function readThroughToContractState( + cacheKey: ContractStateCacheKey, +): Promise { + const { contractTxId, evaluationOptions, warp, logger } = cacheKey; + logger?.debug('Reading through to contract state...', { + contractTxId, + cacheKey: cacheKey.toString(), + }); + const cacheId = cacheKey.toString(); + + // Prevent multiple in-flight requests for the same contract state + // This could be needed if the read through cache gets overwhelmed + const inFlightRequest = requestMap.get(cacheId); + if (inFlightRequest) { + logger?.debug('Deduplicating in flight requests for contract state...', { + contractTxId, + cacheKey: cacheKey.toString(), + }); + const { cachedValue } = await inFlightRequest; + return { + ...cachedValue, + evaluationOptions, + }; + } else { + logger?.debug('Evaluating contract state...', { + contractTxId, + cacheKey: cacheKey.toString(), + }); + } + + // use the combined evaluation options + const contract = warp + .contract(contractTxId) + .setEvaluationOptions(evaluationOptions); + + // set cached value for multiple requests during initial promise + const readStatePromise = contract.readState(); + requestMap.set(cacheId, readStatePromise); + + readStatePromise + .catch((error) => { + logger?.debug('Failed to evaluate contract state!', { + contractTxId, + cacheKey: cacheKey.toString(), + error, + }); + }) + .finally(() => { + // remove the cached request whether it completes or fails + requestMap.delete(cacheId); + }); + + // await the response + const { cachedValue } = await requestMap.get(cacheId); + logger?.debug('Successfully evaluated contract state.', { + contractTxId, + cacheKey: cacheKey.toString(), + }); + + return { + ...cachedValue, + evaluationOptions, + }; +} + // TODO: we can put this in a interface/class and update the resolved type export async function getContractState({ contractTxId, warp, evaluationOptionOverrides = DEFAULT_EVALUATION_OPTIONS, + logger, }: { contractTxId: string; warp: Warp; evaluationOptionOverrides?: Partial; -}): Promise< - EvalStateResult & { evaluationOptions?: Partial } -> { + logger?: winston.Logger; +}): Promise { try { // get the contract manifest eval options by default const { evaluationOptions: contractDefinedEvalOptions } = - await getContractManifest({ contractTxId, arweave: warp.arweave }); + await getContractManifest({ + contractTxId, + arweave: warp.arweave, + logger, + }); // override any contract manifest eval options with eval options provided const combinedEvalOptions = { ...contractDefinedEvalOptions, ...evaluationOptionOverrides, }; - const evaluationOptionsHash = createQueryParamHash(combinedEvalOptions); - const cacheId = `${contractTxId}-${evaluationOptionsHash}`; - // validate request is new, if not return the existing promise (e.g. barrier synchronization) - if (requestMap.get(cacheId)) { - const { cachedValue } = await requestMap.get(cacheId); - return { - ...cachedValue, - evaluationOptions: combinedEvalOptions, - }; - } - // use the combined evaluation options - const contract = warp - .contract(contractTxId) - .setEvaluationOptions(combinedEvalOptions); - // set cached value for multiple requests during initial promise - requestMap.set(cacheId, contract.readState()); - // await the response - const { cachedValue } = await requestMap.get(cacheId); - // remove the cached value once it's been retrieved - requestMap.delete(cacheId); - return { - ...cachedValue, - evaluationOptions: combinedEvalOptions, - }; + + // Awaiting here so that promise rejection can be caught below, wrapped, and propagated + return await contractStateCache.get( + new ContractStateCacheKey( + contractTxId, + combinedEvalOptions, + warp, + logger, + ), + ); } catch (error) { // throw an eval here so we can properly return correct status code if ( @@ -91,13 +211,18 @@ export async function getContractState({ } } -export async function getContractManifest({ +async function readThroughToContractManifest({ contractTxId, arweave, + logger, }: { contractTxId: string; arweave: Arweave; + logger?: winston.Logger; }): Promise { + logger?.debug('Reading through to contract manifest...', { + contractTxId, + }); const { tags: encodedTags } = await arweave.transactions.get(contractTxId); const decodedTags = tagsToObject(encodedTags); // this may not exist, so provided empty json object string as default @@ -106,6 +231,20 @@ export async function getContractManifest({ return contractManifest; } +export async function getContractManifest({ + contractTxId, + arweave, + logger, +}: { + contractTxId: string; + arweave: Arweave; + logger?: winston.Logger; +}): Promise { + return contractManifestCache.get( + new ContractManifestCacheKey(contractTxId, arweave, logger), + ); +} + export function tagsToObject(tags: Tag[]): { [x: string]: string; } { diff --git a/src/routes/contract.ts b/src/routes/contract.ts index b30d2de..c5f9ea2 100644 --- a/src/routes/contract.ts +++ b/src/routes/contract.ts @@ -18,6 +18,7 @@ export async function contractHandler(ctx: KoaContext, next: Next) { const { state, evaluationOptions } = await getContractState({ contractTxId, warp, + logger, }); ctx.body = { contractTxId, diff --git a/yarn.lock b/yarn.lock index fa73ec9..c1ff476 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2,6 +2,13 @@ # yarn lockfile v1 +"@alexsasharegan/simple-cache@^3.3.3": + version "3.3.3" + resolved "https://registry.yarnpkg.com/@alexsasharegan/simple-cache/-/simple-cache-3.3.3.tgz#6e17ea1caee06e62857c704bc14630735cf0dc16" + integrity sha512-SWLaNj75VckhB7tFmfD4vqORVKmwqCYiZTfdTLU87aMF9mkpHp1UeSNKM2axZZDTIDqx45msonjBYeMvhE+amQ== + dependencies: + safe-types "^4.17.0" + "@apollo/protobufjs@1.2.6": version "1.2.6" resolved "https://registry.yarnpkg.com/@apollo/protobufjs/-/protobufjs-1.2.6.tgz#d601e65211e06ae1432bf5993a1a0105f2862f27" @@ -110,6 +117,13 @@ dependencies: xss "^1.0.8" +"@ardrive/ardrive-promise-cache@^1.1.3": + version "1.1.3" + resolved "https://registry.yarnpkg.com/@ardrive/ardrive-promise-cache/-/ardrive-promise-cache-1.1.3.tgz#93dc04f54bcccfc90b97d9aeb1095bb6374eacc8" + integrity sha512-jPFlnVUlHcW+LEptyUyXl2oZ6JZeuWTycZ/tfOWxXL9go+0icUHUWibIvJAuqeR4EjBFsT953BOibplPsZ/b1w== + dependencies: + "@alexsasharegan/simple-cache" "^3.3.3" + "@babel/code-frame@^7.0.0": version "7.22.10" resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.22.10.tgz#1c20e612b768fefa75f6e90d6ecb86329247f0a3" @@ -6067,6 +6081,11 @@ safe-stable-stringify@^2.3.1: resolved "https://registry.yarnpkg.com/safe-stable-stringify/-/safe-stable-stringify-2.4.3.tgz#138c84b6f6edb3db5f8ef3ef7115b8f55ccbf886" integrity sha512-e2bDA2WJT0wxseVd4lsDP4+3ONX6HpMXQa1ZhFQ7SU+GjvORCmShbCMltrtIDfkYhVHrOcPtj+KhmDBdPdZD1g== +safe-types@^4.17.0: + version "4.27.0" + resolved "https://registry.yarnpkg.com/safe-types/-/safe-types-4.27.0.tgz#6016efdf32ddc29b052f77d15ce3fa4c734b8841" + integrity sha512-6FY9SBSfWIaLKIi+wHKwdKHQy1rJMUdlsZVleXbMX35bSC2j5Jul4o59Sl7S+argcFCQXLurS0DC+CDFKA9cIw== + "safer-buffer@>= 2.1.2 < 3", "safer-buffer@>= 2.1.2 < 3.0.0", safer-buffer@^2.1.0: version "2.1.2" resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a"