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: pricing & metadata caching #51

Merged
merged 4 commits into from
Jan 9, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 18 additions & 4 deletions apps/processing/src/services/sharedDependencies.service.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { CoreDependencies } from "@grants-stack-indexer/data-flow";
import { EnvioIndexerClient } from "@grants-stack-indexer/indexer-client";
import { IpfsProvider } from "@grants-stack-indexer/metadata";
import { PricingProviderFactory } from "@grants-stack-indexer/pricing";
import { CachingMetadataProvider, IpfsProvider } from "@grants-stack-indexer/metadata";
import { CachingPricingProvider, PricingProviderFactory } from "@grants-stack-indexer/pricing";
import {
createKyselyDatabase,
IEventRegistryRepository,
Expand All @@ -11,6 +11,8 @@ import {
KyselyApplicationRepository,
KyselyDonationRepository,
KyselyEventRegistryRepository,
KyselyMetadataCache,
KyselyPricingCache,
KyselyProjectRepository,
KyselyRoundRepository,
KyselyStrategyProcessingCheckpointRepository,
Expand Down Expand Up @@ -68,11 +70,23 @@ export class SharedDependenciesService {
kyselyDatabase,
env.DATABASE_SCHEMA,
);
const pricingRepository = new KyselyPricingCache(kyselyDatabase, env.DATABASE_SCHEMA);
const pricingProvider = PricingProviderFactory.create(env, {
logger,
});
const cachedPricingProvider = new CachingPricingProvider(
pricingProvider,
pricingRepository,
logger,
);

const metadataRepository = new KyselyMetadataCache(kyselyDatabase, env.DATABASE_SCHEMA);
const metadataProvider = new IpfsProvider(env.IPFS_GATEWAYS_URL, logger);
const cachedMetadataProvider = new CachingMetadataProvider(
metadataProvider,
metadataRepository,
logger,
);

const eventRegistryRepository = new KyselyEventRegistryRepository(
kyselyDatabase,
Expand Down Expand Up @@ -104,9 +118,9 @@ export class SharedDependenciesService {
projectRepository,
roundRepository,
applicationRepository,
pricingProvider,
pricingProvider: cachedPricingProvider,
donationRepository,
metadataProvider,
metadataProvider: cachedMetadataProvider,
applicationPayoutRepository,
transactionManager,
},
Expand Down
4 changes: 4 additions & 0 deletions apps/processing/test/unit/sharedDependencies.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,16 +45,20 @@ vi.mock("@grants-stack-indexer/repository", () => ({
KyselyEventRegistryRepository: vi.fn(),
KyselyStrategyProcessingCheckpointRepository: vi.fn(),
KyselyTransactionManager: vi.fn(),
KyselyPricingCache: vi.fn(),
KyselyMetadataCache: vi.fn(),
}));

vi.mock("@grants-stack-indexer/pricing", () => ({
PricingProviderFactory: {
create: vi.fn(),
},
CachingPricingProvider: vi.fn(),
}));

vi.mock("@grants-stack-indexer/metadata", () => ({
IpfsProvider: vi.fn(),
CachingMetadataProvider: vi.fn(),
}));

vi.mock("@grants-stack-indexer/indexer-client", () => ({
Expand Down
1 change: 1 addition & 0 deletions packages/metadata/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
"test:cov": "vitest run --config vitest.config.ts --coverage"
},
"dependencies": {
"@grants-stack-indexer/repository": "workspace:*",
"@grants-stack-indexer/shared": "workspace:*",
"axios": "1.7.7",
"zod": "3.23.8"
Expand Down
2 changes: 1 addition & 1 deletion packages/metadata/src/external.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export { IpfsProvider } from "./internal.js";
export { IpfsProvider, CachingMetadataProvider } from "./internal.js";

export {
EmptyGatewaysUrlsException,
Expand Down
53 changes: 53 additions & 0 deletions packages/metadata/src/providers/cachingProxy.provider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { z } from "zod";

import { ICache } from "@grants-stack-indexer/repository";
import { ILogger } from "@grants-stack-indexer/shared";

import { IMetadataProvider } from "../internal.js";

/**
* A metadata provider that caches metadata lookups from the underlying provider.
* When a metadata is requested, it first checks the cache. If found, returns the cached metadata.
* If not found in cache, fetches from the underlying provider and caches the result before returning.
* Cache failures (both reads and writes) are logged but do not prevent the provider from functioning.
*/
export class CachingMetadataProvider implements IMetadataProvider {
constructor(
private readonly provider: IMetadataProvider,
private readonly cache: ICache<string, unknown>,
private readonly logger: ILogger,
) {}

/** @inheritdoc */
async getMetadata<T>(
ipfsCid: string,
validateContent?: z.ZodSchema<T>,
): Promise<T | undefined> {
let cachedMetadata: T | undefined = undefined;
try {
cachedMetadata = (await this.cache.get(ipfsCid)) as T | undefined;
} catch (error) {
this.logger.debug(`Failed to get cached metadata for IPFS CID ${ipfsCid}`, {
error,
});
}

if (cachedMetadata) {
return cachedMetadata;
}

const metadata = await this.provider.getMetadata<T>(ipfsCid, validateContent);

if (metadata) {
try {
await this.cache.set(ipfsCid, metadata);
} catch (error) {
this.logger.debug(`Failed to cache metadata for IPFS CID ${ipfsCid}`, {
error,
});
}
}

return metadata;
}
}
1 change: 1 addition & 0 deletions packages/metadata/src/providers/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from "./ipfs.provider.js";
export * from "./cachingProxy.provider.js";
96 changes: 96 additions & 0 deletions packages/metadata/test/providers/cachingProxy.provider.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { z } from "zod";

import { ICache } from "@grants-stack-indexer/repository";
import { ILogger } from "@grants-stack-indexer/shared";

import { IMetadataProvider } from "../../src/internal.js";
import { CachingMetadataProvider } from "../../src/providers/cachingProxy.provider.js";

describe("CachingMetadataProvider", () => {
const mockProvider = {
getMetadata: vi.fn(),
} as unknown as IMetadataProvider;

const mockCache = {
get: vi.fn(),
set: vi.fn(),
} as unknown as ICache<string, unknown>;

const mockLogger = {
debug: vi.fn(),
} as unknown as ILogger;

let provider: CachingMetadataProvider;

beforeEach(() => {
vi.clearAllMocks();
provider = new CachingMetadataProvider(mockProvider, mockCache, mockLogger);
});

describe("getMetadata", () => {
const testCid = "QmTest123";
const testData = { foo: "bar" };
const testSchema = z.object({ foo: z.string() });

it("returns cached metadata when available", async () => {
vi.spyOn(mockCache, "get").mockResolvedValue(testData);

const result = await provider.getMetadata(testCid, testSchema);

expect(result).toEqual(testData);
expect(mockCache.get).toHaveBeenCalledWith(testCid);
expect(mockProvider.getMetadata).not.toHaveBeenCalled();
});

it("fetches and caches metadata when cache misses", async () => {
vi.spyOn(mockCache, "get").mockResolvedValue(undefined);
vi.spyOn(mockProvider, "getMetadata").mockResolvedValue(testData);

const result = await provider.getMetadata(testCid, testSchema);

expect(result).toEqual(testData);
expect(mockCache.get).toHaveBeenCalledWith(testCid);
expect(mockProvider.getMetadata).toHaveBeenCalledWith(testCid, testSchema);
expect(mockCache.set).toHaveBeenCalledWith(testCid, testData);
});

it("handles cache read failures gracefully", async () => {
vi.spyOn(mockCache, "get").mockRejectedValue(new Error("Cache read error"));
vi.spyOn(mockProvider, "getMetadata").mockResolvedValue(testData);

const result = await provider.getMetadata(testCid, testSchema);

expect(result).toEqual(testData);
expect(mockLogger.debug).toHaveBeenCalledWith(
`Failed to get cached metadata for IPFS CID ${testCid}`,
expect.any(Object),
);
expect(mockProvider.getMetadata).toHaveBeenCalledWith(testCid, testSchema);
});

it("handles cache write failures gracefully", async () => {
vi.spyOn(mockCache, "get").mockResolvedValue(undefined);
vi.spyOn(mockCache, "set").mockRejectedValue(new Error("Cache write error"));
vi.spyOn(mockProvider, "getMetadata").mockResolvedValue(testData);

const result = await provider.getMetadata(testCid, testSchema);

expect(result).toEqual(testData);
expect(mockLogger.debug).toHaveBeenCalledWith(
`Failed to cache metadata for IPFS CID ${testCid}`,
expect.any(Object),
);
});

it("returns undefined when metadata is not found", async () => {
vi.spyOn(mockCache, "get").mockResolvedValue(undefined);
vi.spyOn(mockProvider, "getMetadata").mockResolvedValue(undefined);

const result = await provider.getMetadata(testCid, testSchema);

expect(result).toBeUndefined();
expect(mockCache.set).not.toHaveBeenCalled();
});
});
});
1 change: 1 addition & 0 deletions packages/pricing/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
"test:cov": "vitest run --config vitest.config.ts --coverage"
},
"dependencies": {
"@grants-stack-indexer/repository": "workspace:*",
"@grants-stack-indexer/shared": "workspace:*",
"axios": "1.7.7"
},
Expand Down
2 changes: 1 addition & 1 deletion packages/pricing/src/external.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
export type { TokenPrice, IPricingProvider } from "./internal.js";

export { CoingeckoProvider, DummyPricingProvider } from "./internal.js";
export { CoingeckoProvider, DummyPricingProvider, CachingPricingProvider } from "./internal.js";

export { PricingProviderFactory } from "./internal.js";
export type {
Expand Down
67 changes: 67 additions & 0 deletions packages/pricing/src/providers/cachingProxy.provider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { ICache, PriceCacheKey } from "@grants-stack-indexer/repository";
import { ILogger, TokenCode } from "@grants-stack-indexer/shared";
Copy link
Collaborator

Choose a reason for hiding this comment

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

it doesn't have to be in this PR but probably we should rename tokencode to tokensymbol, they are different things (and I've never heard of token code used to describe a symbol like "ETH")

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

could be, TokenCode is just a way to identify token generically (ie. not tied to the ID on a pricing provider like Coingecko) or to the symbol (since many tokens can have the same symbol), but in this case we decided to pick the token symbol as TokenCode but could be anything else. what name do you suggest?

Copy link
Collaborator

Choose a reason for hiding this comment

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

Ahh gotcha, I think that name is good then


import { IPricingProvider, TokenPrice } from "../internal.js";

/**
* A pricing provider that caches token price lookups from the underlying provider.
* When a price is requested, it first checks the cache. If found, returns the cached price.
* If not found in cache, fetches from the underlying provider and caches the result before returning.
* Cache failures (both reads and writes) are logged but do not prevent the provider from functioning.
*/
export class CachingPricingProvider implements IPricingProvider {
Copy link
Collaborator

Choose a reason for hiding this comment

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

can this data ever become stale? do we need TTL/eviction logic?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

no, is always data from the past that isn't supposed to change

constructor(
private readonly provider: IPricingProvider,
private readonly cache: ICache<PriceCacheKey, TokenPrice>,
private readonly logger: ILogger,
) {}

/** @inheritdoc */
async getTokenPrice(
tokenCode: TokenCode,
startTimestampMs: number,
endTimestampMs?: number,
): Promise<TokenPrice | undefined> {
let cachedPrice: TokenPrice | undefined = undefined;
try {
cachedPrice = await this.cache.get({
tokenCode,
timestampMs: startTimestampMs,
});
} catch (error) {
this.logger.debug(
`Failed to get cached price for token ${tokenCode} at ${startTimestampMs}`,
{ error },
);
}

if (cachedPrice) {
return cachedPrice;
}

const price = await this.provider.getTokenPrice(
tokenCode,
startTimestampMs,
endTimestampMs,
);

if (price) {
try {
await this.cache.set(
{
tokenCode,
timestampMs: startTimestampMs,
},
price,
);
} catch (error) {
this.logger.debug(
`Failed to cache price for token ${tokenCode} at ${startTimestampMs}`,
{ error },
);
}
}

return price;
}
}
1 change: 1 addition & 0 deletions packages/pricing/src/providers/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from "./coingecko.provider.js";
export * from "./dummy.provider.js";
export * from "./cachingProxy.provider.js";
Loading
Loading