Skip to content

Commit 6428f1b

Browse files
committed
feat: add createGetCheckpointsRange
This function can be used to create getCheckpointsRange function for provider with out-of-the-box cache.
1 parent ba90c3c commit 6428f1b

File tree

5 files changed

+289
-12
lines changed

5 files changed

+289
-12
lines changed
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import { CheckpointRecord } from '../../stores/checkpoints';
2+
3+
type CacheEntry = {
4+
/**
5+
* Block range for which cache record is valid. This is inclusive on both ends.
6+
*/
7+
range: [number, number];
8+
/**
9+
* Checkpoint records for the source in the given range.
10+
*/
11+
records: CheckpointRecord[];
12+
};
13+
14+
/**
15+
* Options for createGetCheckpointsRange.
16+
*/
17+
type Options<T> = {
18+
/**
19+
* Function to retrieve sources based on the block number.
20+
*
21+
* @param blockNumber Block number.
22+
* @returns Array of sources.
23+
*/
24+
sourcesFn: (blockNumber: number) => T[];
25+
/**
26+
* Function to extract a key from the source. This key is used for cache.
27+
*
28+
* @param source Source.
29+
* @returns Key.
30+
*/
31+
keyFn: (source: T) => string;
32+
/**
33+
* Function to query checkpoint records for a source within given block range.
34+
*
35+
* @param fromBlock Starting block number.
36+
* @param toBlock Ending block number.
37+
* @param source The source to query.
38+
* @returns Promise resolving to an array of CheckpointRecords.
39+
*/
40+
querySourceFn: (fromBlock: number, toBlock: number, source: T) => Promise<CheckpointRecord[]>;
41+
};
42+
43+
/**
44+
* Creates a getCheckpointsRange function.
45+
*
46+
* This function has a cache to avoid querying the same source for the same block range multiple times.
47+
* This cache automatically evicts entries outside of the last queried range.
48+
*
49+
* @param options
50+
* @returns A function that retrieves checkpoint records for a given block range.
51+
*/
52+
export function createGetCheckpointsRange<T>(options: Options<T>) {
53+
const cache = new Map<string, CacheEntry>();
54+
55+
return async (fromBlock: number, toBlock: number): Promise<CheckpointRecord[]> => {
56+
const sources = options.sourcesFn(fromBlock);
57+
58+
let events: CheckpointRecord[] = [];
59+
for (const source of sources) {
60+
let sourceEvents: CheckpointRecord[] = [];
61+
62+
const key = options.keyFn(source);
63+
64+
const cacheEntry = cache.get(key);
65+
if (!cacheEntry) {
66+
const events = await options.querySourceFn(fromBlock, toBlock, source);
67+
sourceEvents = sourceEvents.concat(events);
68+
} else {
69+
const [cacheStart, cacheEnd] = cacheEntry.range;
70+
71+
const cacheEntries = cacheEntry.records.filter(
72+
({ blockNumber }) => blockNumber >= fromBlock && blockNumber <= toBlock
73+
);
74+
75+
sourceEvents = sourceEvents.concat(cacheEntries);
76+
77+
const bottomHalfStart = fromBlock;
78+
const bottomHalfEnd = Math.min(toBlock, cacheStart - 1);
79+
80+
const topHalfStart = Math.max(fromBlock, cacheEnd + 1);
81+
const topHalfEnd = toBlock;
82+
83+
if (bottomHalfStart <= bottomHalfEnd) {
84+
const events = await options.querySourceFn(bottomHalfStart, bottomHalfEnd, source);
85+
sourceEvents = sourceEvents.concat(events);
86+
}
87+
88+
if (topHalfStart <= topHalfEnd) {
89+
const events = await options.querySourceFn(topHalfStart, topHalfEnd, source);
90+
sourceEvents = sourceEvents.concat(events);
91+
}
92+
}
93+
94+
sourceEvents.sort((a, b) => a.blockNumber - b.blockNumber);
95+
96+
cache.set(key, {
97+
range: [fromBlock, toBlock],
98+
records: sourceEvents
99+
});
100+
101+
events = events.concat(sourceEvents);
102+
}
103+
104+
return events;
105+
};
106+
}

src/providers/evm/provider.ts

Lines changed: 8 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,10 @@ import { Log, Provider, StaticJsonRpcProvider } from '@ethersproject/providers';
44
import { Interface, LogDescription } from '@ethersproject/abi';
55
import { keccak256 } from '@ethersproject/keccak256';
66
import { toUtf8Bytes } from '@ethersproject/strings';
7-
import { CheckpointRecord } from '../../stores/checkpoints';
87
import { Writer } from './types';
98
import { ContractSourceConfig } from '../../types';
109
import { sleep } from '../../utils/helpers';
10+
import { createGetCheckpointsRange } from '../core/createGetCheckpointsRange';
1111

1212
type BlockWithTransactions = Awaited<ReturnType<Provider['getBlockWithTransactions']>>;
1313
type Transaction = BlockWithTransactions['transactions'][number];
@@ -32,6 +32,13 @@ export class EvmProvider extends BaseProvider {
3232

3333
this.provider = new StaticJsonRpcProvider(this.instance.config.network_node_url);
3434
this.writers = writers;
35+
36+
this.getCheckpointsRange = createGetCheckpointsRange({
37+
sourcesFn: blockNumber => this.instance.getCurrentSources(blockNumber),
38+
keyFn: source => source.contract,
39+
querySourceFn: async (fromBlock, toBlock, source) =>
40+
this.getLogs(fromBlock, toBlock, source.contract)
41+
});
3542
}
3643

3744
formatAddresses(addresses: string[]): string[] {
@@ -274,17 +281,6 @@ export class EvmProvider extends BaseProvider {
274281
}));
275282
}
276283

277-
async getCheckpointsRange(fromBlock: number, toBlock: number): Promise<CheckpointRecord[]> {
278-
let events: CheckpointRecord[] = [];
279-
280-
for (const source of this.instance.getCurrentSources(fromBlock)) {
281-
const addressEvents = await this.getLogs(fromBlock, toBlock, source.contract);
282-
events = events.concat(addressEvents);
283-
}
284-
285-
return events;
286-
}
287-
288284
getEventHash(eventName: string) {
289285
if (!this.sourceHashes.has(eventName)) {
290286
this.sourceHashes.set(eventName, keccak256(toUtf8Bytes(eventName)));

src/providers/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
export * from './base';
22
export * as starknet from './starknet';
33
export * as evm from './evm';
4+
export * from './core/createGetCheckpointsRange';

src/providers/starknet/provider.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
} from './types';
1717
import { ContractSourceConfig } from '../../types';
1818
import { sleep } from '../../utils/helpers';
19+
import { createGetCheckpointsRange } from '../core/createGetCheckpointsRange';
1920

2021
export class StarknetProvider extends BaseProvider {
2122
private readonly provider: RpcProvider;
@@ -39,6 +40,18 @@ export class StarknetProvider extends BaseProvider {
3940
nodeUrl: this.instance.config.network_node_url
4041
});
4142
this.writers = writers;
43+
44+
this.getCheckpointsRangeForAddress = createGetCheckpointsRange({
45+
sourcesFn: blockNumber => this.instance.getCurrentSources(blockNumber),
46+
keyFn: source => source.contract,
47+
querySourceFn: async (fromBlock, toBlock, source) =>
48+
this.getCheckpointsRangeForAddress(
49+
fromBlock,
50+
toBlock,
51+
source.contract,
52+
source.events.map(event => event.name)
53+
)
54+
});
4255
}
4356

4457
public async init() {
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
import { createGetCheckpointsRange } from '../../../../src/providers/core/createGetCheckpointsRange';
2+
3+
it('should call querySourceFn for every source', async () => {
4+
const sources = ['a', 'b', 'c'];
5+
6+
const mockFunction = jest.fn().mockReturnValue([]);
7+
8+
const getCheckpointsRange = createGetCheckpointsRange({
9+
sourcesFn: () => sources,
10+
keyFn: source => source,
11+
querySourceFn: mockFunction
12+
});
13+
14+
await getCheckpointsRange(10, 20);
15+
expect(mockFunction).toHaveBeenCalledTimes(sources.length);
16+
for (const source of sources) {
17+
expect(mockFunction).toHaveBeenCalledWith(10, 20, source);
18+
}
19+
});
20+
21+
it('should return value for requested source', async () => {
22+
let sources = ['a', 'b'];
23+
24+
const mockFunction = jest.fn().mockImplementation((fromBlock, toBlock, source) => {
25+
return {
26+
blockNumber: 14,
27+
contractAddress: source
28+
};
29+
});
30+
31+
const getCheckpointsRange = createGetCheckpointsRange({
32+
sourcesFn: () => sources,
33+
keyFn: source => source,
34+
querySourceFn: mockFunction
35+
});
36+
37+
let result = await getCheckpointsRange(10, 20);
38+
expect(result).toEqual([
39+
{
40+
blockNumber: 14,
41+
contractAddress: 'a'
42+
},
43+
{
44+
blockNumber: 14,
45+
contractAddress: 'b'
46+
}
47+
]);
48+
49+
sources = ['b'];
50+
result = await getCheckpointsRange(10, 20);
51+
expect(result).toEqual([
52+
{
53+
blockNumber: 14,
54+
contractAddress: 'b'
55+
}
56+
]);
57+
});
58+
59+
describe('cache', () => {
60+
const mockFunction = jest.fn().mockResolvedValue([]);
61+
62+
function getCheckpointQuery() {
63+
return createGetCheckpointsRange({
64+
sourcesFn: () => ['a'],
65+
keyFn: source => source,
66+
querySourceFn: mockFunction
67+
});
68+
}
69+
70+
beforeEach(() => {
71+
mockFunction.mockClear();
72+
});
73+
74+
// Case 1:
75+
// Cache exists and we are fetching the same range again
76+
// This triggers no queryFn calls
77+
it('exact cache match', async () => {
78+
const getCheckpointsRange = getCheckpointQuery();
79+
80+
await getCheckpointsRange(10, 20);
81+
expect(mockFunction).toHaveBeenCalledTimes(1);
82+
83+
mockFunction.mockClear();
84+
85+
await getCheckpointsRange(10, 20);
86+
expect(mockFunction).toHaveBeenCalledTimes(0);
87+
});
88+
89+
// Case 2:
90+
// Cache exists but we are fetching blocks outside of cache range
91+
// This triggers single queryFn call
92+
test('cache outside of the range', async () => {
93+
const getCheckpointsRange = getCheckpointQuery();
94+
95+
await getCheckpointsRange(10, 20);
96+
expect(mockFunction).toHaveBeenCalledTimes(1);
97+
98+
// Case 2a: Cache exists but we are fetching blocks further than the cache
99+
mockFunction.mockClear();
100+
101+
await getCheckpointsRange(21, 31);
102+
expect(mockFunction).toHaveBeenCalledTimes(1);
103+
expect(mockFunction).toHaveBeenCalledWith(21, 31, 'a');
104+
105+
// Case 2b: Cache exists but we are fetching blocks before the cache
106+
mockFunction.mockClear();
107+
108+
await getCheckpointsRange(0, 9);
109+
expect(mockFunction).toHaveBeenCalledTimes(1);
110+
expect(mockFunction).toHaveBeenCalledWith(0, 9, 'a');
111+
});
112+
113+
// Case 3:
114+
// Part of the range is cached and part of the range is not cached
115+
// This triggers two queryFn calls (one to fetch block before cache and one to fetch block after cache)
116+
test('cache is fully inside the range', async () => {
117+
const getCheckpointsRange = getCheckpointQuery();
118+
119+
await getCheckpointsRange(10, 20);
120+
expect(mockFunction).toHaveBeenCalledTimes(1);
121+
122+
mockFunction.mockClear();
123+
124+
await getCheckpointsRange(5, 25);
125+
expect(mockFunction).toHaveBeenCalledTimes(2);
126+
expect(mockFunction).toHaveBeenCalledWith(5, 9, 'a');
127+
expect(mockFunction).toHaveBeenCalledWith(21, 25, 'a');
128+
});
129+
130+
// Case 4:
131+
// Cache covers bottom part of the range
132+
// This triggers single queryFn call to fetch the top part of the range
133+
test('cache covers bottom part of range', async () => {
134+
const getCheckpointsRange = getCheckpointQuery();
135+
136+
await getCheckpointsRange(10, 20);
137+
expect(mockFunction).toHaveBeenCalledTimes(1);
138+
139+
mockFunction.mockClear();
140+
141+
await getCheckpointsRange(15, 25);
142+
expect(mockFunction).toHaveBeenCalledTimes(1);
143+
expect(mockFunction).toHaveBeenCalledWith(21, 25, 'a');
144+
});
145+
146+
// Case 5:
147+
// Cache covers top part of the range
148+
// This triggers single queryFn call to fetch the bottom part of the range
149+
test('cache covers top part of range', async () => {
150+
const getCheckpointsRange = getCheckpointQuery();
151+
152+
await getCheckpointsRange(10, 20);
153+
expect(mockFunction).toHaveBeenCalledTimes(1);
154+
155+
mockFunction.mockClear();
156+
157+
await getCheckpointsRange(0, 15);
158+
expect(mockFunction).toHaveBeenCalledTimes(1);
159+
expect(mockFunction).toHaveBeenCalledWith(0, 9, 'a');
160+
});
161+
});

0 commit comments

Comments
 (0)