Skip to content

Commit 1a91536

Browse files
committed
fix: persist portal-cache watermark across getFinalizedStream calls
1 parent f0863da commit 1a91536

1 file changed

Lines changed: 46 additions & 23 deletions

File tree

src/polyfills/portal-cache.ts

Lines changed: 46 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,13 @@
2222
* PORTAL_CACHE_DIR default `.portal-cache`
2323
* PORTAL_CACHE_LOG_INTERVAL seconds; 0 disables (default 30)
2424
*/
25-
import { PortalClient, PortalStreamData } from '@subsquid/portal-client'
2625
import { createHash } from 'node:crypto'
2726
import { mkdirSync } from 'node:fs'
2827
import { promisify } from 'node:util'
2928
import zlib from 'node:zlib'
3029

30+
import { PortalClient, PortalStreamData } from '@subsquid/portal-client'
31+
3132
import { bigintJsonParse, bigintJsonStringify } from '../utils/bigintJson'
3233

3334
// zstd is faster + smaller than gzip; available in Node 22+. Fall back
@@ -61,13 +62,13 @@ interface PortalCacheDb {
6162
}
6263

6364
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
65+
hit_chunks: number // chunks served from disk (replay phase)
66+
hit_blocks: number // total blocks across hit chunks
67+
saved_chunks: number // new chunks written to disk (live phase)
68+
saved_blocks: number // finalized blocks across saved chunks
69+
saved_bytes: number // compressed bytes written
70+
live_batches: number // live batches yielded without producing a save
71+
live_blocks: number // blocks across live-only batches
7172
}
7273

7374
class PortalCacheStats {
@@ -136,9 +137,7 @@ function openCacheDb(path: string): PortalCacheDb {
136137
`)
137138
db.pragma('journal_mode = WAL')
138139
db.pragma('synchronous = NORMAL')
139-
const selectStmt = db.prepare(
140-
'SELECT block_to, value FROM portal_cache WHERE block_from = ? AND query_hash = ?',
141-
)
140+
const selectStmt = db.prepare('SELECT block_to, value FROM portal_cache WHERE block_from = ? AND query_hash = ?')
142141
const insertStmt = db.prepare(
143142
'INSERT OR IGNORE INTO portal_cache (block_from, block_to, query_hash, value) VALUES (?, ?, ?, ?)',
144143
)
@@ -161,18 +160,15 @@ function openCacheDb(path: string): PortalCacheDb {
161160
// Generic stream signature we operate against; intentionally looser
162161
// than PortalClient.prototype.getStream so we don't pin to the exact
163162
// block type emitted by the evm-processor branch.
164-
type AnyGetStream = (
165-
this: PortalClient,
166-
query: any,
167-
options?: any,
168-
) => AsyncIterable<PortalStreamData<BlockLike>>
163+
type AnyGetStream = (this: PortalClient, query: any, options?: any) => AsyncIterable<PortalStreamData<BlockLike>>
169164

170165
async function* cachedStream(
171166
client: PortalClient,
172167
innerGetStream: AnyGetStream,
173168
query: any,
174169
options: any,
175170
db: PortalCacheDb,
171+
watermarks: Map<string, number>,
176172
stats: PortalCacheStats,
177173
log: (msg: string) => void,
178174
): AsyncGenerator<PortalStreamData<BlockLike>, void, unknown> {
@@ -183,6 +179,29 @@ async function* cachedStream(
183179
}
184180
let chunksReplayed = 0
185181

182+
// Watermark is keyed by `queryHash` and persists across `getFinalizedStream`
183+
// calls. Subsquid's `getFinalizedBlocks` iterates over `requests` and calls
184+
// `getFinalizedStream` once per request, so a per-generator watermark would
185+
// reset between requests — while `state.height` in the runner does NOT
186+
// reset. When two requests share the same `queryHash` (which happens when
187+
// they differ only in fromBlock/toBlock — hashQuery strips those), the
188+
// second request can replay cached chunks whose blocks the runner has
189+
// already committed via the first. The shared watermark catches that.
190+
const filterAdvancing = <T extends PortalStreamData<BlockLike>>(batch: T): T | undefined => {
191+
if (batch.blocks.length === 0) return batch
192+
let mark = watermarks.get(queryHash) ?? -1
193+
const lastHeight = lastOf(batch.blocks).header.number
194+
if (lastHeight > mark) {
195+
watermarks.set(queryHash, lastHeight)
196+
return batch
197+
}
198+
const advanced = batch.blocks.filter((b) => b.header.number > mark)
199+
if (advanced.length === 0) return undefined
200+
mark = lastOf(advanced).header.number
201+
watermarks.set(queryHash, mark)
202+
return { ...batch, blocks: advanced }
203+
}
204+
186205
// 1. Replay contiguous chunks from cache.
187206
while (true) {
188207
const row = db.selectChunk(cursor.number, queryHash)
@@ -191,7 +210,8 @@ async function* cachedStream(
191210
const decoded = bigintJsonParse(buf.toString('utf8')) as PortalStreamData<BlockLike>
192211
stats.bump('hit_chunks')
193212
stats.bump('hit_blocks', decoded.blocks.length)
194-
yield decoded
213+
const filtered = filterAdvancing(decoded)
214+
if (filtered) yield filtered
195215
chunksReplayed++
196216
if (!decoded.blocks.length) break
197217
const lastBlock = lastOf(decoded.blocks)
@@ -224,9 +244,7 @@ async function* cachedStream(
224244
const finalizedBlocks = batch.blocks.filter((b: BlockLike) => b.header.number <= finalizedHead)
225245
if (finalizedBlocks.length > 0) {
226246
const last = lastOf(finalizedBlocks).header.number
227-
const payload = (await compressAsync(
228-
bigintJsonStringify({ ...batch, blocks: finalizedBlocks }),
229-
)) as Buffer
247+
const payload = (await compressAsync(bigintJsonStringify({ ...batch, blocks: finalizedBlocks }))) as Buffer
230248
db.insertChunk(chunkStart, last, queryHash, payload)
231249
stats.bump('saved_chunks')
232250
stats.bump('saved_blocks', finalizedBlocks.length)
@@ -238,7 +256,9 @@ async function* cachedStream(
238256
stats.bump('live_batches')
239257
stats.bump('live_blocks', batch.blocks.length)
240258
}
241-
yield batch
259+
const filtered = filterAdvancing(batch)
260+
if (filtered) yield filtered
261+
chunksReplayed++
242262
}
243263
}
244264

@@ -266,6 +286,9 @@ export function setupPortalCache(stateSchema: string): void {
266286
const dbPath = `${cacheDir}/${stateSchema}.sqlite`
267287
const db = openCacheDb(dbPath)
268288
const stats = new PortalCacheStats()
289+
// Highest block height yielded per queryHash, shared across every
290+
// `getFinalizedStream`/`getStream` invocation for the life of the process.
291+
const watermarks = new Map<string, number>()
269292
startStatsLogger(stateSchema, stats, logIntervalSec)
270293
const compressor = 'zstdCompress' in zlib ? 'zstd' : 'gzip'
271294
console.log(`[portal-cache ${stateSchema}] enabled at ${dbPath} (compression=${compressor})`)
@@ -277,9 +300,9 @@ export function setupPortalCache(stateSchema: string): void {
277300
const innerGetStream = PortalClient.prototype.getStream as unknown as AnyGetStream
278301
const innerGetFinalizedStream = PortalClient.prototype.getFinalizedStream as unknown as AnyGetStream
279302
PortalClient.prototype.getStream = function (this: PortalClient, query: any, options?: any): any {
280-
return cachedStream(this, innerGetStream, query, options, db, stats, log)
303+
return cachedStream(this, innerGetStream, query, options, db, watermarks, stats, log)
281304
}
282305
PortalClient.prototype.getFinalizedStream = function (this: PortalClient, query: any, options?: any): any {
283-
return cachedStream(this, innerGetFinalizedStream, query, options, db, stats, log)
306+
return cachedStream(this, innerGetFinalizedStream, query, options, db, watermarks, stats, log)
284307
}
285308
}

0 commit comments

Comments
 (0)