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'
2625import { createHash } from 'node:crypto'
2726import { mkdirSync } from 'node:fs'
2827import { promisify } from 'node:util'
2928import zlib from 'node:zlib'
3029
30+ import { PortalClient , PortalStreamData } from '@subsquid/portal-client'
31+
3132import { 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
6364interface 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
7374class 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
170165async 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