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

feat: price in memory caching #31

Merged
merged 11 commits into from
Aug 1, 2024
10 changes: 9 additions & 1 deletion libs/pricing/src/pricing.module.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,19 @@
import { CacheModule } from "@nestjs/cache-manager";
import { Module } from "@nestjs/common";

import { LoggerModule } from "@zkchainhub/shared";
import { TOKEN_CACHE_TTL_IN_SEC } from "@zkchainhub/shared/constants/";

import { CoingeckoService } from "./services";

@Module({
imports: [LoggerModule],
imports: [
LoggerModule,
CacheModule.register({
store: "memory",
ttl: TOKEN_CACHE_TTL_IN_SEC,
Copy link
Collaborator

Choose a reason for hiding this comment

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

sweet

}),
],
providers: [CoingeckoService],
exports: [CoingeckoService],
})
Expand Down
36 changes: 27 additions & 9 deletions libs/pricing/src/services/coingecko.service.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { createMock } from "@golevelup/ts-jest";
import { Cache, CACHE_MANAGER } from "@nestjs/cache-manager";
import { Logger } from "@nestjs/common";
import { Test, TestingModule } from "@nestjs/testing";
import { AxiosError, AxiosInstance } from "axios";
import { AxiosInstance } from "axios";
import MockAdapter from "axios-mock-adapter";
import { WINSTON_MODULE_PROVIDER } from "nest-winston";

Expand All @@ -20,6 +22,7 @@ describe("CoingeckoService", () => {
let service: CoingeckoService;
let axios: AxiosInstance;
let mockAxios: MockAdapter;
let cache: Cache;
const apiKey = "COINGECKO_API_KEY";
const apiBaseUrl = "https://api.coingecko.com/api/v3/";

Expand All @@ -29,21 +32,31 @@ describe("CoingeckoService", () => {
CoingeckoService,
{
provide: CoingeckoService,
useFactory: (logger: Logger) => {
return new CoingeckoService(apiKey, apiBaseUrl, logger);
useFactory: (logger: Logger, cache: Cache) => {
return new CoingeckoService(apiKey, apiBaseUrl, logger, cache);
},
inject: [WINSTON_MODULE_PROVIDER],
inject: [WINSTON_MODULE_PROVIDER, CACHE_MANAGER],
},
{
provide: WINSTON_MODULE_PROVIDER,
useValue: mockLogger,
},
{
provide: CACHE_MANAGER,
useValue: createMock<Cache>({
Copy link
Collaborator

Choose a reason for hiding this comment

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

Pretty neat solution the createMock utility function, didn't know it existed, nice

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

its from some guys that made utilities and helpers for NestJs actually jajaja

store: createMock<Cache["store"]>({
mget: jest.fn(),
mset: jest.fn(),
}),
}),
},
],
}).compile();

service = module.get<CoingeckoService>(CoingeckoService);
axios = service["axios"];
mockAxios = new MockAdapter(axios);
cache = module.get<Cache>(CACHE_MANAGER);
});

afterEach(() => {
Expand All @@ -67,14 +80,15 @@ describe("CoingeckoService", () => {
});

describe("getTokenPrices", () => {
it("return token prices", async () => {
it("all token prices are fetched from Coingecko", async () => {
Copy link
Collaborator

Choose a reason for hiding this comment

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

what ? 🤣

0xnigir1 marked this conversation as resolved.
Show resolved Hide resolved
const tokenIds = ["token1", "token2"];
const currency = "usd";
const expectedResponse: TokenPrices = {
token1: { usd: 1.23 },
token2: { usd: 4.56 },
};

jest.spyOn(cache.store, "mget").mockResolvedValueOnce([null, null]);
jest.spyOn(axios, "get").mockResolvedValueOnce({
data: expectedResponse,
});
Expand All @@ -92,12 +106,18 @@ describe("CoingeckoService", () => {
precision: service["DECIMALS_PRECISION"].toString(),
},
});
expect(cache.store.mget).toHaveBeenCalledWith("token1.usd", "token2.usd");
expect(cache.store.mset).toHaveBeenCalledWith([
["token1.usd", 1.23],
["token2.usd", 4.56],
]);
});

it("throw ApiNotAvailable when Coingecko returns a 500 family exception", async () => {
const tokenIds = ["token1", "token2"];
const currency = "usd";

jest.spyOn(cache.store, "mget").mockResolvedValueOnce([null, null]);
mockAxios.onGet().replyOnce(503, {
data: {},
status: 503,
Expand All @@ -113,6 +133,7 @@ describe("CoingeckoService", () => {
const tokenIds = ["token1", "token2"];
const currency = "usd";

jest.spyOn(cache.store, "mget").mockResolvedValueOnce([null, null]);
mockAxios.onGet().replyOnce(429, {
data: {},
status: 429,
Expand All @@ -128,10 +149,7 @@ describe("CoingeckoService", () => {
const tokenIds = ["invalidTokenId", "token2"];
const currency = "usd";

jest.spyOn(axios, "get").mockRejectedValueOnce(
new AxiosError("Invalid token ID", "400"),
);

jest.spyOn(cache.store, "mget").mockResolvedValueOnce([null, null]);
mockAxios.onGet().replyOnce(400, {
data: {
message: "Invalid token ID",
Expand Down
58 changes: 58 additions & 0 deletions libs/pricing/src/services/coingecko.service.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { Cache, CACHE_MANAGER } from "@nestjs/cache-manager";
import { Inject, Injectable, LoggerService } from "@nestjs/common";
import axios, { AxiosInstance, isAxiosError } from "axios";
import { WINSTON_MODULE_NEST_PROVIDER } from "nest-winston";
Expand All @@ -24,6 +25,7 @@ export class CoingeckoService implements IPricingService {
private readonly apiKey: string,
private readonly apiBaseUrl: string = "https://api.coingecko.com/api/v3/",
@Inject(WINSTON_MODULE_NEST_PROVIDER) private readonly logger: LoggerService,
@Inject(CACHE_MANAGER) private readonly cacheManager: Cache,
) {
this.axios = axios.create({
baseURL: apiBaseUrl,
Expand All @@ -50,6 +52,62 @@ export class CoingeckoService implements IPricingService {
): Promise<Record<string, number>> {
const { currency } = config;

const cacheKeys = tokenIds.map((tokenId) => this.formatTokenCacheKey(tokenId, currency));
Copy link
Collaborator

Choose a reason for hiding this comment

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

is it posible to have sth like this on the Cache ?

{ < currency > : { < tokenId > : < amount > } }

iam not a huge fan of formatting keys

Copy link
Collaborator

Choose a reason for hiding this comment

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

@0xyaco would love to hear your opinion here ser

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

storing an object/map as value?
the thing is that we use only tokenId as key we will get a hit but we will have to analyze the response map for the currency

a simpler solution is an only USD service

Copy link
Collaborator

Choose a reason for hiding this comment

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

yea, i think we are not gona be using other currency as baseCurrency , lets keep it like it is for now

const cachedTokenPrices = await this.getTokenPricesFromCache(cacheKeys);
const cachedMap = cachedTokenPrices.reduce(
(result, price, index) => {
if (price !== null) result[tokenIds.at(index) as string] = price;
return result;
},
{} as Record<string, number>,
);

const missingTokenIds = tokenIds.filter((_, index) => !cachedTokenPrices[index]);
Copy link
Collaborator

Choose a reason for hiding this comment

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

this filter could be done inside the reduce i think

const missingTokenPrices = await this.fetchTokenPrices(missingTokenIds, currency);

await this.saveTokenPricesToCache(missingTokenPrices, currency);

return { ...cachedMap, ...missingTokenPrices };
}

private formatTokenCacheKey(tokenId: string, currency: string) {
return `${tokenId}.${currency}`;
}

/**
* Retrieves multiple token prices from the cache at once.
* @param keys - An array of cache keys.
* @returns A promise that resolves to an array of token prices (number or null).
*/
private async getTokenPricesFromCache(keys: string[]): Promise<(number | null)[]> {
return this.cacheManager.store.mget(...keys) as Promise<(number | null)[]>;
}

/**
* Saves multiple token prices to the cache at once.
*
* @param prices - The token prices to be saved.
* @param currency - The currency in which the prices are denominated.
*/
private async saveTokenPricesToCache(prices: Record<string, number>, currency: string) {
if (Object.keys(prices).length === 0) return;

this.cacheManager.store.mset(
Object.entries(prices).map(([key, value]) => [
this.formatTokenCacheKey(key, currency),
value,
]),
);
}

private async fetchTokenPrices(
tokenIds: string[],
currency: string,
): Promise<Record<string, number>> {
if (tokenIds.length === 0) {
return {};
}

return this.axios
.get<TokenPrices>("simple/price", {
params: {
Expand Down
Empty file removed libs/shared/src/constants.ts
Empty file.
1 change: 1 addition & 0 deletions libs/shared/src/constants/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const TOKEN_CACHE_TTL_IN_SEC = 60;
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
},
"dependencies": {
"@nestjs/axios": "3.0.2",
"@nestjs/cache-manager": "2.2.2",
"@nestjs/common": "10.0.0",
"@nestjs/core": "10.0.0",
"@nestjs/platform-express": "10.0.0",
Expand All @@ -32,6 +33,7 @@
"axios": "1.7.2",
"axios-mock-adapter": "1.22.0",
"nest-winston": "1.9.7",
"cache-manager": "5.7.4",
"reflect-metadata": "0.1.13",
"rxjs": "7.8.1",
"viem": "2.17.5",
Expand Down
43 changes: 43 additions & 0 deletions pnpm-lock.yaml

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