Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Introduce AccountFetcher to common-sdk #45

Merged
merged 14 commits into from
Jun 23, 2023
64 changes: 64 additions & 0 deletions packages/common-sdk/src/web3/network/cache/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { Account, Mint } from "@solana/spl-token";
import { ParsableEntity } from "..";
import { Address } from "../../address-util";

export * from "./simple-cache-impl";

export type BasicSupportedTypes = Account | Mint;

/**
* Options when fetching the accounts
*/
export type AccountFetchOpts = {
// Accepted Time to live in milliseconds for a cache entry for this account request.
ttl?: number;
};

/**
* Interface for fetching and caching on-chain accounts
*/
export interface AccountCache<T> {
/**
* Fetch an account from the cache or from the network
* @param address The account address to fetch from cache or network
* @param parser The parser to used for theses accounts
* @param opts Options when fetching the accounts
* @returns
*/
getAccount: (
address: Address,
parser: ParsableEntity<T>,
opts?: AccountFetchOpts
) => Promise<T | null>;

/**
* Fetch multiple accounts from the cache or from the network
* @param address A list of account addresses to fetch from cache or network
* @param parser The parser to used for theses accounts
* @param opts Options when fetching the accounts
* @returns a Map of addresses to accounts. The ordering of the Map iteration is the same as the ordering of the input addresses.
*/
getAccounts: (
address: Address[],
parser: ParsableEntity<T>,
opts?: AccountFetchOpts
) => Promise<ReadonlyMap<string, T | null>>;

/**
* Fetch multiple accounts from the cache or from the network and return as an array
* @param address A list of account addresses to fetch from cache or network
* @param parser The parser to used for theses accounts
* @param opts Options when fetching the accounts
* @returns an array of accounts. The ordering of the array is the same as the ordering of the input addresses.
*/
getAccountsAsArray: (
address: Address[],
parser: ParsableEntity<T>,
opts?: AccountFetchOpts
) => Promise<ReadonlyArray<T | null>>;

/**
* Refresh all accounts in the cache
*/
refreshAll: () => Promise<void>;
}
132 changes: 132 additions & 0 deletions packages/common-sdk/src/web3/network/cache/simple-cache-impl.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import { Connection } from "@solana/web3.js";
import { AccountCache, AccountFetchOpts } from ".";
import { Address, AddressUtil } from "../../address-util";
import { getMultipleAccountsInMap } from "../account-requests";
import { ParsableEntity } from "../parsing";

type CachedContent<T> = {
parser: ParsableEntity<T>;
fetchedAt: number;
value: T | null;
};

export type RetentionPolicy<T> = ReadonlyMap<ParsableEntity<T>, number>;

// SimpleAccountCache is a simple implementation of AccountCache that stores the fetched
// accounts in memory. If TTL is not provided, it will use TTL defined in the the retention policy
// for the parser. If that is also not provided, the request will always prefer the cache value.
export class SimpleAccountCache<T> implements AccountCache<T> {
cache: Map<string, CachedContent<T>> = new Map();
constructor(readonly connection: Connection, readonly retentionPolicy: RetentionPolicy<T>) {
this.cache = new Map<string, CachedContent<T>>();
}

async getAccount<U extends T>(
address: Address,
parser: ParsableEntity<U>,
opts?: AccountFetchOpts | undefined,
now: number = Date.now()
): Promise<U | null> {
const addressKey = AddressUtil.toPubKey(address);
const addressStr = AddressUtil.toString(address);

const cached = this.cache.get(addressStr);
const ttl = opts?.ttl ?? this.retentionPolicy.get(parser) ?? Number.POSITIVE_INFINITY;
const elapsed = !!cached ? now - (cached?.fetchedAt ?? 0) : Number.NEGATIVE_INFINITY;
const expired = elapsed > ttl;

if (!!cached && !expired) {
return cached.value as U | null;
}

try {
const accountInfo = await this.connection.getAccountInfo(addressKey);
const value = parser.parse(addressKey, accountInfo);
this.cache.set(addressStr, { parser, value, fetchedAt: now });
return value;
} catch (e) {
this.cache.set(addressStr, { parser, value: null, fetchedAt: now });
return null;
}
}

async getAccounts<U extends T>(
addresses: Address[],
parser: ParsableEntity<U>,
opts?: AccountFetchOpts | undefined,
now: number = Date.now()
): Promise<ReadonlyMap<string, U | null>> {
const addressStrs = AddressUtil.toStrings(addresses);
await this.populateCache(addressStrs, parser, opts, now);

// Build a map of the results, insert by the order of the addresses parameter
const result = new Map<string, U | null>();
addressStrs.forEach((addressStr) => {
const cached = this.cache.get(addressStr);
const value = cached?.value as U | null;
result.set(addressStr, value);
});

// invariant(result.size === addresses.length, "not enough results fetched");
return result;
}

async getAccountsAsArray<U extends T>(
addresses: Address[],
parser: ParsableEntity<U>,
opts?: AccountFetchOpts | undefined,
now: number = Date.now()
): Promise<ReadonlyArray<U | null>> {
const addressStrs = AddressUtil.toStrings(addresses);
await this.populateCache(addressStrs, parser, opts, now);

// Rebuild an array containing the results, insert by the order of the addresses parameter
const result = new Array<U | null>();
addressStrs.forEach((addressStr) => {
const cached = this.cache.get(addressStr);
const value = cached?.value as U | null;
result.push(value);
});

return result;
}

async refreshAll(now: number = Date.now()) {
const addresses = Array.from(this.cache.keys());
const fetchedAccountsMap = await getMultipleAccountsInMap(this.connection, addresses);

for (const [key, cachedContent] of this.cache.entries()) {
const parser = cachedContent.parser;
const fetchedEntry = fetchedAccountsMap.get(key);
const value = parser.parse(AddressUtil.toPubKey(key), fetchedEntry);
this.cache.set(key, { parser, value, fetchedAt: now });
}
}
private async populateCache<U extends T>(
addresses: Address[],
parser: ParsableEntity<U>,
opts?: AccountFetchOpts | undefined,
now: number = Date.now()
) {
const addressStrs = AddressUtil.toStrings(addresses);
const ttl = opts?.ttl ?? this.retentionPolicy.get(parser) ?? Number.POSITIVE_INFINITY;

// Filter out all unexpired accounts to get the accounts to fetch
const undefinedAccounts = addressStrs.filter((addressStr) => {
const cached = this.cache.get(addressStr);
const elapsed = cached ? now - (cached?.fetchedAt ?? 0) : Number.NEGATIVE_INFINITY;
const expired = elapsed > ttl;
return !cached || expired;
});

// Fetch all undefined accounts and place in cache
if (undefinedAccounts.length > 0) {
const fetchedAccountsMap = await getMultipleAccountsInMap(this.connection, undefinedAccounts);
undefinedAccounts.forEach((key) => {
const fetchedEntry = fetchedAccountsMap.get(key);
const value = parser.parse(AddressUtil.toPubKey(key), fetchedEntry);
this.cache.set(key, { parser, value, fetchedAt: now });
});
}
}
}
1 change: 1 addition & 0 deletions packages/common-sdk/src/web3/network/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from "./account-requests";
export * from "./cache";
export * from "./parsing";
9 changes: 9 additions & 0 deletions packages/common-sdk/tests/utils/expectations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { Mint } from "@solana/spl-token";

export function expectMintEquals(actual: Mint, expected: Mint) {
expect(actual.decimals).toEqual(expected.decimals);
expect(actual.isInitialized).toEqual(expected.isInitialized);
expect(actual.mintAuthority!.equals(expected.mintAuthority!)).toBeTruthy();
expect(actual.freezeAuthority!.equals(expected.freezeAuthority!)).toBeTruthy();
expect(actual.supply === expected.supply).toBeTruthy();
}
11 changes: 2 additions & 9 deletions packages/common-sdk/tests/web3/network/account-requests.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { getMint, Mint } from "@solana/spl-token";
import { getMint } from "@solana/spl-token";
import { Keypair } from "@solana/web3.js";
import { getMultipleParsedAccounts, getParsedAccount, ParsableMintInfo } from "../../../src/web3";
import {
Expand All @@ -7,6 +7,7 @@ import {
createTestContext,
requestAirdrop,
} from "../../test-context";
import { expectMintEquals } from "../../utils/expectations";

jest.setTimeout(100 * 1000 /* ms */);

Expand Down Expand Up @@ -67,11 +68,3 @@ describe("account-requests", () => {
});
});
});

function expectMintEquals(actual: Mint, expected: Mint) {
expect(actual.decimals).toEqual(expected.decimals);
expect(actual.isInitialized).toEqual(expected.isInitialized);
expect(actual.mintAuthority!.equals(expected.mintAuthority!)).toBeTruthy();
expect(actual.freezeAuthority!.equals(expected.freezeAuthority!)).toBeTruthy();
expect(actual.supply === expected.supply).toBeTruthy();
}
Loading