Skip to content

Commit 53f9303

Browse files
committed
feat: opt-in in-process portal cache for development (PORTAL_CACHE=true)
1 parent c18f2a4 commit 53f9303

5 files changed

Lines changed: 292 additions & 0 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
/cache
88
/out
99
/.rpc-cache
10+
/.portal-cache
1011

1112
/**Versions.json
1213

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
"@subsquid/big-decimal": "^1.0.0",
4242
"@subsquid/evm-processor": "^1.30.1",
4343
"@subsquid/graphql-server": "^4.12.0",
44+
"@subsquid/portal-client": "0.3.2",
4445
"@subsquid/rpc-client": "4.15.0",
4546
"@subsquid/typeorm-migration": "^1.3.1",
4647
"@subsquid/typeorm-store": "^1.9.1",

pnpm-lock.yaml

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/polyfills/portal-cache.ts

Lines changed: 285 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,285 @@
1+
/**
2+
* In-process Portal cache for the Subsquid processor. Off by default;
3+
* opt-in via `PORTAL_CACHE=true`.
4+
*
5+
* Monkey-patches `PortalClient.prototype.getStream` to replay cached
6+
* finalized chunks from SQLite first, then fall through to the live
7+
* portal stream. Live batches' finalized portion is compressed and
8+
* stored keyed by (query_hash, block_from, block_to).
9+
*
10+
* Algorithm is a direct port of `@subsquid/pipes`' `portalSqliteCache`
11+
* (MIT). Two adaptations for portal-client@0.3.2 which our processor
12+
* uses:
13+
* - read `batch.finalizedHead?.number` instead of `batch.head.finalized?.number`
14+
* - track `requestedFromBlock` locally (0.3.2 doesn't emit it in meta)
15+
*
16+
* Storage is namespaced per processor `stateSchema` so concurrent
17+
* processors don't share or contend on cache files. Wire from
18+
* `initProcessorFromDump` with the processor's stateSchema.
19+
*
20+
* Env knobs:
21+
* PORTAL_CACHE truthy to enable (default off)
22+
* PORTAL_CACHE_DIR default `.portal-cache`
23+
* PORTAL_CACHE_LOG_INTERVAL seconds; 0 disables (default 30)
24+
*/
25+
import { PortalClient, PortalStreamData } from '@subsquid/portal-client'
26+
import { createHash } from 'node:crypto'
27+
import { mkdirSync } from 'node:fs'
28+
import { promisify } from 'node:util'
29+
import zlib from 'node:zlib'
30+
31+
import { bigintJsonParse, bigintJsonStringify } from '../utils/bigintJson'
32+
33+
// zstd is faster + smaller than gzip; available in Node 22+. Fall back
34+
// to gzip so this works on older Node too. Compression target is the
35+
// finalized batch payload, which can be tens of MB per chunk uncompressed.
36+
const compressAsync = promisify('zstdCompress' in zlib ? (zlib as any).zstdCompress : zlib.gzip)
37+
const decompressAsync = promisify('zstdDecompress' in zlib ? (zlib as any).zstdDecompress : zlib.gunzip)
38+
39+
interface BlockLike {
40+
header: { number: number; hash: string }
41+
}
42+
43+
/**
44+
* Stable hash that excludes cursor fields so the same query can be
45+
* resumed from any block range and still hit the same cache namespace.
46+
* Mirrors pipes' `hashQuery` — strip {fromBlock, toBlock, parentBlockHash},
47+
* sha256 the rest.
48+
*/
49+
function hashQuery(query: Record<string, unknown>): string {
50+
const { fromBlock: _f, toBlock: _t, parentBlockHash: _p, ...unique } = query
51+
return createHash('sha256').update(JSON.stringify(unique)).digest('hex')
52+
}
53+
54+
function lastOf<T>(arr: T[]): T {
55+
return arr[arr.length - 1]
56+
}
57+
58+
interface PortalCacheDb {
59+
selectChunk(blockFrom: number, queryHash: string): { block_to: number; value: Buffer } | undefined
60+
insertChunk(blockFrom: number, blockTo: number, queryHash: string, value: Buffer): void
61+
}
62+
63+
interface CacheCounters {
64+
hit_chunks: number // chunks served from disk (replay phase)
65+
hit_blocks: number // total blocks across hit chunks
66+
saved_chunks: number // new chunks written to disk (live phase)
67+
saved_blocks: number // finalized blocks across saved chunks
68+
saved_bytes: number // compressed bytes written
69+
live_batches: number // live batches yielded without producing a save
70+
live_blocks: number // blocks across live-only batches
71+
}
72+
73+
class PortalCacheStats {
74+
private counters: CacheCounters = newCounters()
75+
private since = Date.now()
76+
77+
bump<K extends keyof CacheCounters>(field: K, delta = 1): void {
78+
this.counters[field] += delta
79+
}
80+
81+
drain(): { interval_ms: number; counters: CacheCounters } {
82+
const now = Date.now()
83+
const interval_ms = now - this.since
84+
this.since = now
85+
const counters = this.counters
86+
this.counters = newCounters()
87+
return { interval_ms, counters }
88+
}
89+
}
90+
91+
function newCounters(): CacheCounters {
92+
return {
93+
hit_chunks: 0,
94+
hit_blocks: 0,
95+
saved_chunks: 0,
96+
saved_blocks: 0,
97+
saved_bytes: 0,
98+
live_batches: 0,
99+
live_blocks: 0,
100+
}
101+
}
102+
103+
function startStatsLogger(namespace: string, stats: PortalCacheStats, intervalSec: number): void {
104+
if (intervalSec <= 0) return
105+
setInterval(() => {
106+
const { interval_ms, counters } = stats.drain()
107+
const total = counters.hit_chunks + counters.saved_chunks + counters.live_batches
108+
if (total === 0) return
109+
const hitBlocks = counters.hit_blocks
110+
const liveBlocks = counters.saved_blocks + counters.live_blocks
111+
const totalBlocks = hitBlocks + liveBlocks
112+
const hitPct = totalBlocks > 0 ? ((hitBlocks / totalBlocks) * 100).toFixed(1) : '—'
113+
const savedKb = (counters.saved_bytes / 1024).toFixed(1)
114+
console.log(
115+
`[portal-cache ${namespace}] last ${(interval_ms / 1000).toFixed(1)}s: ` +
116+
`hit=${counters.hit_chunks} chunks/${counters.hit_blocks} blocks, ` +
117+
`saved=${counters.saved_chunks} chunks/${counters.saved_blocks} blocks/${savedKb}KB, ` +
118+
`live=${counters.live_batches} batches/${counters.live_blocks} blocks, ` +
119+
`hit_rate=${hitPct}%`,
120+
)
121+
}, intervalSec * 1000).unref()
122+
}
123+
124+
function openCacheDb(path: string): PortalCacheDb {
125+
// eslint-disable-next-line @typescript-eslint/no-require-imports
126+
const Database = require('better-sqlite3')
127+
const db = new Database(path)
128+
db.exec(`
129+
CREATE TABLE IF NOT EXISTS portal_cache (
130+
query_hash TEXT NOT NULL,
131+
block_from INTEGER NOT NULL,
132+
block_to INTEGER NOT NULL,
133+
value BLOB NOT NULL,
134+
PRIMARY KEY (block_from, block_to, query_hash)
135+
)
136+
`)
137+
db.pragma('journal_mode = WAL')
138+
db.pragma('synchronous = NORMAL')
139+
const selectStmt = db.prepare(
140+
'SELECT block_to, value FROM portal_cache WHERE block_from = ? AND query_hash = ?',
141+
)
142+
const insertStmt = db.prepare(
143+
'INSERT OR IGNORE INTO portal_cache (block_from, block_to, query_hash, value) VALUES (?, ?, ?, ?)',
144+
)
145+
return {
146+
selectChunk(blockFrom, queryHash) {
147+
return selectStmt.get(blockFrom, queryHash) as { block_to: number; value: Buffer } | undefined
148+
},
149+
insertChunk(blockFrom, blockTo, queryHash, value) {
150+
insertStmt.run(blockFrom, blockTo, queryHash, value)
151+
},
152+
}
153+
}
154+
155+
/**
156+
* Yield cached chunks until the cache no longer has a contiguous chunk
157+
* starting at `cursor.number`, then switch to the live portal stream.
158+
* Finalized portions of live batches are persisted before yielding.
159+
* Mirrors pipes' `PortalCacheNodeJs.getStream`.
160+
*/
161+
// Generic stream signature we operate against; intentionally looser
162+
// than PortalClient.prototype.getStream so we don't pin to the exact
163+
// block type emitted by the evm-processor branch.
164+
type AnyGetStream = (
165+
this: PortalClient,
166+
query: any,
167+
options?: any,
168+
) => AsyncIterable<PortalStreamData<BlockLike>>
169+
170+
async function* cachedStream(
171+
client: PortalClient,
172+
innerGetStream: AnyGetStream,
173+
query: any,
174+
options: any,
175+
db: PortalCacheDb,
176+
stats: PortalCacheStats,
177+
log: (msg: string) => void,
178+
): AsyncGenerator<PortalStreamData<BlockLike>, void, unknown> {
179+
const queryHash = hashQuery(query)
180+
let cursor: { number: number; hash: string | undefined } = {
181+
number: query.fromBlock ?? 0,
182+
hash: query.parentBlockHash,
183+
}
184+
let chunksReplayed = 0
185+
186+
// 1. Replay contiguous chunks from cache.
187+
while (true) {
188+
const row = db.selectChunk(cursor.number, queryHash)
189+
if (!row) break
190+
const buf = (await decompressAsync(row.value)) as Buffer
191+
const decoded = bigintJsonParse(buf.toString('utf8')) as PortalStreamData<BlockLike>
192+
stats.bump('hit_chunks')
193+
stats.bump('hit_blocks', decoded.blocks.length)
194+
yield decoded
195+
chunksReplayed++
196+
if (!decoded.blocks.length) break
197+
const lastBlock = lastOf(decoded.blocks)
198+
cursor = { number: row.block_to + 1, hash: lastBlock.header.hash }
199+
}
200+
if (chunksReplayed > 0) log(`replayed ${chunksReplayed} chunk(s) from cache; resuming live at block ${cursor.number}`)
201+
202+
if (query.toBlock !== undefined && cursor.number > query.toBlock) return
203+
204+
// 2. Live tail. Save finalized portion of every batch.
205+
//
206+
// `requestedFromBlock` mirrors portal-client's internal per-HTTP-request
207+
// cursor (which 0.3.2 doesn't expose in batch.meta). It must advance for
208+
// EVERY batch — even ones without a `finalizedHead` — because the next
209+
// HTTP request the portal makes uses (last block we received + 1). If we
210+
// only advance on save, we'd later save a chunk whose claimed range
211+
// [requestedFromBlock, last] is wider than its actual content, silently
212+
// hiding the un-saved blocks from future replays.
213+
let requestedFromBlock = cursor.number
214+
const liveQuery = { ...query, fromBlock: cursor.number, parentBlockHash: cursor.hash }
215+
const liveStream = innerGetStream.call(client, liveQuery, options)
216+
for await (const batch of liveStream) {
217+
const chunkStart = requestedFromBlock
218+
if (batch.blocks.length > 0) {
219+
requestedFromBlock = lastOf(batch.blocks).header.number + 1
220+
}
221+
const finalizedHead = batch.finalizedHead?.number
222+
let saved = false
223+
if (finalizedHead !== undefined) {
224+
const finalizedBlocks = batch.blocks.filter((b: BlockLike) => b.header.number <= finalizedHead)
225+
if (finalizedBlocks.length > 0) {
226+
const last = lastOf(finalizedBlocks).header.number
227+
const payload = (await compressAsync(
228+
bigintJsonStringify({ ...batch, blocks: finalizedBlocks }),
229+
)) as Buffer
230+
db.insertChunk(chunkStart, last, queryHash, payload)
231+
stats.bump('saved_chunks')
232+
stats.bump('saved_blocks', finalizedBlocks.length)
233+
stats.bump('saved_bytes', payload.length)
234+
saved = true
235+
}
236+
}
237+
if (!saved) {
238+
stats.bump('live_batches')
239+
stats.bump('live_blocks', batch.blocks.length)
240+
}
241+
yield batch
242+
}
243+
}
244+
245+
let initialized = false
246+
247+
/**
248+
* Install the cache wrapper on `PortalClient.prototype.getStream`.
249+
* Idempotent: first call wins; subsequent calls with a different
250+
* stateSchema are ignored with a warning so the file path stays stable
251+
* for the life of the process.
252+
*/
253+
export function setupPortalCache(stateSchema: string): void {
254+
if (!process.env.PORTAL_CACHE || process.env.PORTAL_CACHE === 'false' || process.env.PORTAL_CACHE === '0') {
255+
return
256+
}
257+
if (initialized) {
258+
console.warn(`[portal-cache] already initialized; ignoring setupPortalCache(${stateSchema})`)
259+
return
260+
}
261+
initialized = true
262+
263+
const cacheDir = process.env.PORTAL_CACHE_DIR ?? '.portal-cache'
264+
const logIntervalSec = Number(process.env.PORTAL_CACHE_LOG_INTERVAL ?? '30')
265+
mkdirSync(cacheDir, { recursive: true })
266+
const dbPath = `${cacheDir}/${stateSchema}.sqlite`
267+
const db = openCacheDb(dbPath)
268+
const stats = new PortalCacheStats()
269+
startStatsLogger(stateSchema, stats, logIntervalSec)
270+
const compressor = 'zstdCompress' in zlib ? 'zstd' : 'gzip'
271+
console.log(`[portal-cache ${stateSchema}] enabled at ${dbPath} (compression=${compressor})`)
272+
const log = (msg: string) => console.log(`[portal-cache ${stateSchema}] ${msg}`)
273+
274+
// evm-processor consumes `getFinalizedStream`; we wrap `getStream` too
275+
// since both go through identical streaming code paths (different portal
276+
// endpoint, same response shape).
277+
const innerGetStream = PortalClient.prototype.getStream as unknown as AnyGetStream
278+
const innerGetFinalizedStream = PortalClient.prototype.getFinalizedStream as unknown as AnyGetStream
279+
PortalClient.prototype.getStream = function (this: PortalClient, query: any, options?: any): any {
280+
return cachedStream(this, innerGetStream, query, options, db, stats, log)
281+
}
282+
PortalClient.prototype.getFinalizedStream = function (this: PortalClient, query: any, options?: any): any {
283+
return cachedStream(this, innerGetFinalizedStream, query, options, db, stats, log)
284+
}
285+
}

src/utils/dumps.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { SquidProcessor, run } from '@originprotocol/squid-utils'
22

3+
import { setupPortalCache } from '../polyfills/portal-cache'
34
import { setupRpcCache } from '../polyfills/rpc-cache'
45
import { DBDumpManager } from './db-dump-manager'
56

@@ -41,6 +42,7 @@ export async function checkAndRestoreDump(processorName: string) {
4142

4243
export async function initProcessorFromDump(processor: SquidProcessor) {
4344
setupRpcCache(processor.stateSchema)
45+
setupPortalCache(processor.stateSchema)
4446
if (process.env.NODE_ENV !== 'development' && !process.env.BLOCK_FROM && !process.env.BLOCK_TO) {
4547
const blockHeight = await checkAndRestoreDump(processor.stateSchema)
4648
if (blockHeight) {

0 commit comments

Comments
 (0)