|
| 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 | +} |
0 commit comments