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

Commit

Permalink
Merge pull request #40 from ar-io/PE-4612_promise_cache
Browse files Browse the repository at this point in the history
PE-4612: introduce promise caching for contract state retrieval
  • Loading branch information
dtfiedler authored Sep 15, 2023
2 parents 5a26fef + f23402f commit 64cb172
Show file tree
Hide file tree
Showing 4 changed files with 189 additions and 29 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
197 changes: 168 additions & 29 deletions src/api/warp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<any> & {

Check warning on line 21 in src/api/warp.ts

View workflow job for this annotation

GitHub Actions / build (lint:check)

Unexpected any. Specify a different type
evaluationOptions?: Partial<EvaluationOptions>;
};

export class EvaluationError extends Error {
constructor(message?: string) {
Expand All @@ -25,58 +31,172 @@ export class EvaluationError extends Error {
// cache duplicate requests on the same instance within a short period of time
const requestMap: Map<string, Promise<any> | undefined> = new Map();

Check warning on line 32 in src/api/warp.ts

View workflow job for this annotation

GitHub Actions / build (lint:check)

Unexpected any. Specify a different type

// Convenience class for read through caching
class ContractStateCacheKey {
constructor(
public readonly contractTxId: string,
public readonly evaluationOptions: Partial<EvaluationOptions>,
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

Check warning on line 84 in src/api/warp.ts

View workflow job for this annotation

GitHub Actions / build (lint:check)

Unexpected any. Specify a different type
> = new ReadThroughPromiseCache({
cacheParams: {
cacheCapacity: 1000,
cacheTTL: 1000 * 60 * 60 * 24 * 365, // 365 days - effectively permanent
},
readThroughFunction: (cacheKey) => readThroughToContractManifest(cacheKey),
});

function createQueryParamHash(evalOptions: Partial<EvaluationOptions>): string {
// Function to calculate the hash of a string
const hash = createHash('sha256');
hash.update(JSON.stringify(evalOptions));
return hash.digest('hex');
}

async function readThroughToContractState(
cacheKey: ContractStateCacheKey,
): Promise<EvaluatedContractState> {
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<EvaluationOptions>;
}): Promise<
EvalStateResult<any> & { evaluationOptions?: Partial<EvaluationOptions> }
> {
logger?: winston.Logger;
}): Promise<EvaluatedContractState> {
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 (
Expand All @@ -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<EvaluationManifest> {
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
Expand All @@ -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<EvaluationManifest> {
return contractManifestCache.get(
new ContractManifestCacheKey(contractTxId, arweave, logger),
);
}

export function tagsToObject(tags: Tag[]): {
[x: string]: string;
} {
Expand Down
1 change: 1 addition & 0 deletions src/routes/contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export async function contractHandler(ctx: KoaContext, next: Next) {
const { state, evaluationOptions } = await getContractState({
contractTxId,
warp,
logger,
});
ctx.body = {
contractTxId,
Expand Down
19 changes: 19 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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/[email protected]":
version "1.2.6"
resolved "https://registry.yarnpkg.com/@apollo/protobufjs/-/protobufjs-1.2.6.tgz#d601e65211e06ae1432bf5993a1a0105f2862f27"
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down

0 comments on commit 64cb172

Please sign in to comment.