@subsquid/batch-processor 1.0.0-portal-api.a3f844 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/run.ts CHANGED
@@ -1,27 +1,18 @@
1
1
  import {createLogger} from '@subsquid/logger'
2
2
  import {last, maybeLast, runProgram, Throttler} from '@subsquid/util-internal'
3
- import {createPrometheusServer} from '@subsquid/util-internal-prometheus-server'
4
- import * as prom from 'prom-client'
5
- import {HashAndHeight, Database, HotDatabaseState} from './database'
6
- import {Metrics} from './metrics'
7
- import {DataSource, isForkException, BlockRef} from '@subsquid/util-internal-data-source'
3
+ import {Database, DatabaseTransactResult, FinalDatabaseState, HashAndHeight, HotDatabaseState} from './database'
4
+ import {DataSource, isForkException, BlockRef, type BlockBatch} from '@subsquid/util-internal-data-source'
8
5
  import assert from 'assert'
6
+ import {PrometheusServer, RunnerMetrics} from '@subsquid/util-internal-processor-tools'
9
7
  import {formatHead, getItemsCount} from './util'
10
8
 
9
+ export {PrometheusServer}
10
+
11
11
  const log = createLogger('sqd:batch-processor')
12
12
 
13
13
  export interface DataHandlerContext<Block, Store> {
14
- /**
15
- * Storage interface provided by the database
16
- */
17
14
  store: Store
18
- /**
19
- * List of blocks to map and process
20
- */
21
15
  blocks: Block[]
22
- /**
23
- * Signals, that the processor is near the head of the chain.
24
- */
25
16
  isHead: boolean
26
17
  }
27
18
 
@@ -29,6 +20,10 @@ export interface BlockBase {
29
20
  header: BlockRef
30
21
  }
31
22
 
23
+ export interface RunOptions {
24
+ prometheus?: PrometheusServer
25
+ }
26
+
32
27
  /**
33
28
  * Run data processing.
34
29
  *
@@ -46,183 +41,227 @@ export interface BlockBase {
46
41
  export function run<Block extends BlockBase, Store>(
47
42
  src: DataSource<Block>,
48
43
  db: Database<Store>,
49
- dataHandler: (ctx: DataHandlerContext<Block, Store>) => Promise<void>
44
+ dataHandler: (ctx: DataHandlerContext<Block, Store>) => Promise<DatabaseTransactResult | void>,
45
+ opts?: RunOptions,
50
46
  ): void {
51
47
  runProgram(
52
48
  () => {
53
- return new Processor(src, db, dataHandler).run()
49
+ return new Processor(src, db, dataHandler, opts).run()
54
50
  },
55
51
  (err) => {
56
52
  log.fatal(err)
57
- }
53
+ },
58
54
  )
59
55
  }
60
56
 
61
- class Processor<B extends BlockBase, S> {
62
- private metrics = new Metrics()
63
- private chainHeight: Throttler<number>
57
+ interface ProcessorStateInit {
58
+ finalizedHead?: BlockRef
59
+ unfinalizedHeads?: BlockRef[]
60
+ }
61
+
62
+ class ProcessorState {
63
+ finalizedHead: BlockRef | undefined = undefined
64
+ unfinalizedHeads: BlockRef[] = []
65
+
66
+ get head(): BlockRef | undefined {
67
+ return maybeLast(this.unfinalizedHeads) ?? this.finalizedHead
68
+ }
69
+
70
+ init(state: ProcessorStateInit): void {
71
+ this.finalizedHead = state.finalizedHead
72
+ this.unfinalizedHeads = state.unfinalizedHeads ?? []
73
+ }
74
+
75
+ handleFork(previousBlocks: BlockRef[]): void {
76
+ let chain = this.finalizedHead ? [this.finalizedHead, ...this.unfinalizedHeads] : this.unfinalizedHeads
77
+ let rollbackIndex = findRollbackIndex(chain, previousBlocks)
78
+ if (rollbackIndex === -1) {
79
+ if (this.finalizedHead != null) throw new Error('Unable to process fork')
80
+ this.unfinalizedHeads = []
81
+ } else {
82
+ let rollbackHead = chain[rollbackIndex]
83
+ log.info(`navigating a fork on a common base ${formatHead(rollbackHead)}`)
84
+ this.unfinalizedHeads = chain.slice(this.finalizedHead ? 1 : 0, rollbackIndex + 1)
85
+ }
86
+ }
87
+ }
88
+
89
+ export class Processor<B extends BlockBase, S> {
90
+ private metrics: RunnerMetrics
64
91
  private statusReportTimer?: any
65
92
  private hasStatusNews = false
93
+ private state = new ProcessorState()
66
94
 
67
95
  constructor(
68
96
  private src: DataSource<B>,
69
97
  private db: Database<S>,
70
- private handler: (ctx: DataHandlerContext<B, S>) => Promise<void>
98
+ private handler: (ctx: DataHandlerContext<B, S>) => Promise<DatabaseTransactResult | void>,
99
+ private readonly opts?: RunOptions,
71
100
  ) {
72
- this.chainHeight = new Throttler(() => this.src.getFinalizedHead()?.then((r) => r?.number ?? -1), 30_000)
101
+ this.metrics = new RunnerMetrics(
102
+ src.getBlocksCountInRange?.bind(src) ?? ((range) => Math.max(0, range.to - range.from + 1)),
103
+ )
73
104
  }
74
105
 
75
106
  async run(): Promise<void> {
76
- let finalizedHead: HashAndHeight
77
- let head: HashAndHeight
78
- if (this.db.supportsHotBlocks) {
79
- let state = await this.db.connect()
80
- finalizedHead = state
81
- head = last([state, ...state.top])
82
- } else {
83
- finalizedHead = head = await this.db.connect()
84
- }
107
+ let getHead = this.db.supportsHotBlocks
108
+ ? this.src.getHead.bind(this.src)
109
+ : this.src.getFinalizedHead.bind(this.src)
110
+ let chainHeight = new Throttler(() => getHead().then((r) => r.number), 10_000)
85
111
 
86
- if (head.height >= 0) {
87
- log.info(`last processed block was ${head.height}`)
88
- await this.assertWeAreOnTheSameChain(finalizedHead)
112
+ let dbState = await this.db.connect()
113
+ this.state.init(toProcessorStateInit(dbState))
114
+
115
+ let head = this.state.head
116
+ if (head != null) {
117
+ log.info(`last processed block was ${head.number}`)
89
118
  }
119
+ await this.initMetrics(head?.number ?? -1, await chainHeight.get())
90
120
 
91
- await this.initMetrics(head.height)
121
+ let getStream = this.db.supportsHotBlocks
122
+ ? this.src.getStream.bind(this.src)
123
+ : this.src.getFinalizedStream.bind(this.src)
92
124
 
93
125
  while (true) {
94
126
  try {
95
- let prevBlockNumber = head.height
96
- let prevBlockHash = head.height < 0 ? undefined : head.hash
97
-
98
- let stream = this.db.supportsHotBlocks
99
- ? this.src.getStream({range: {from: prevBlockNumber + 1}, parentHash: prevBlockHash})
100
- : this.src.getFinalizedStream({range: {from: prevBlockNumber + 1}, parentHash: prevBlockHash})
101
-
102
- for await (let data of stream) {
103
- let finalizedHead: HashAndHeight =
104
- data.finalizedHead == null
105
- ? {height: -1, hash: '0x'}
106
- : {
107
- height: data.finalizedHead.number,
108
- hash: data.finalizedHead.hash,
109
- }
110
-
111
- head = await this.processBatch(head, finalizedHead, data.blocks)
127
+ for await (let data of getStream({
128
+ from: (this.state.head?.number ?? -1) + 1,
129
+ parentHash: this.state.head?.hash,
130
+ })) {
131
+ await this.processBatch(
132
+ data,
133
+ await chainHeight.get(),
134
+ async (store: S, sliceBlocks: B[], isOnTop: boolean) => {
135
+ return this.handler({store, blocks: sliceBlocks, isHead: isOnTop})
136
+ },
137
+ )
112
138
  }
113
-
114
139
  break
115
140
  } catch (e) {
116
- if (isForkException(e) && this.db.supportsHotBlocks) {
117
- let state = await this.db.getState()
118
- let forkBase = await computeForkBase(state, e.previousBlocks)
119
- if (forkBase == null) {
120
- // rollback all blocks
121
- head = {height: -1, hash: '0x'}
122
- } else {
123
- head = forkBase
124
- }
125
- log.info(`navigating a fork on a common base ${formatHead(head)}`)
126
- } else {
127
- throw e
128
- }
141
+ if (!isForkException(e) || !this.db.supportsHotBlocks) throw e
142
+ this.state.handleFork(e.previousBlocks)
129
143
  }
130
144
  }
131
145
 
132
146
  this.reportFinalStatus()
133
147
  }
134
148
 
135
- private async assertWeAreOnTheSameChain(state: HashAndHeight): Promise<void> {
136
- // if (state.height < 0) return
137
- // let hash = await this.src.getBlockHash(state.number)
138
- // if (state.hash === hash) return
139
- // throw new Error(
140
- // `already indexed block ${formatHead(state)} was not found on chain`
141
- // )
142
- }
143
-
144
- private async initMetrics(state: number): Promise<void> {
145
- this.updateProgressMetrics(await this.chainHeight.get(), state)
149
+ private async initMetrics(state: number, chainHeight: number): Promise<void> {
150
+ this.updateProgressMetrics(chainHeight, state)
146
151
  let port = process.env.PROCESSOR_PROMETHEUS_PORT || process.env.PROMETHEUS_PORT
147
- if (port == null) return
148
- prom.collectDefaultMetrics()
149
- this.metrics.install()
150
- let server = await createPrometheusServer(prom.register, port)
151
- log.info(`prometheus metrics are served on port ${server.port}`)
152
+
153
+ let prometheusServer: PrometheusServer | undefined
154
+ if (this.opts?.prometheus != null) {
155
+ prometheusServer = this.opts.prometheus
156
+ } else if (port != null) {
157
+ prometheusServer = new PrometheusServer()
158
+ prometheusServer.setPort(port)
159
+ }
160
+ if (prometheusServer == null) return
161
+
162
+ prometheusServer.addRunnerMetrics(this.metrics)
163
+ let listening = await prometheusServer.serve()
164
+ log.info(`prometheus metrics are served on port ${listening.port}`)
152
165
  }
153
166
 
154
167
  private updateProgressMetrics(chainHeight: number, indexerHeight: number, time?: bigint): void {
155
168
  this.metrics.setChainHeight(chainHeight)
156
169
  this.metrics.setLastProcessedBlock(indexerHeight)
157
- let left: number
158
- let processed: number
159
- left = this.metrics.getChainHeight() - this.metrics.getLastProcessedBlock()
160
- processed = this.metrics.getLastProcessedBlock()
161
- this.metrics.updateProgress(processed, left, time)
170
+ this.metrics.updateProgress(time)
162
171
  }
163
172
 
164
173
  private async processBatch(
165
- prevHead: HashAndHeight,
166
- finalizedHead: HashAndHeight,
167
- blocks: B[]
168
- ): Promise<HashAndHeight> {
169
- let chainHeight = await this.chainHeight.get()
170
-
171
- let lastBlock = maybeLast(blocks)
172
- if (lastBlock == null) return prevHead
173
-
174
- let nextHead = {
175
- hash: lastBlock.header.hash,
176
- height: lastBlock.header.number,
174
+ data: BlockBatch<B>,
175
+ chainHeight: number,
176
+ map: (store: S, blocks: B[], isOnTop: boolean) => Promise<DatabaseTransactResult | void>,
177
+ ): Promise<void> {
178
+ let {blocks, finalizedHead: finalizedHeadData} = data
179
+ let hasBlocks = blocks.length > 0
180
+
181
+ if (!hasBlocks && finalizedHeadData == null) return
182
+
183
+ let prevHead = this.state.head
184
+
185
+ if (!hasBlocks && !this.db.supportsHotBlocks) return
186
+
187
+ assertBlocksContinuity(prevHead, blocks)
188
+
189
+ if (
190
+ finalizedHeadData != null &&
191
+ this.state.finalizedHead != null &&
192
+ finalizedHeadData.number <= this.state.finalizedHead.number
193
+ ) {
194
+ finalizedHeadData = this.state.finalizedHead
177
195
  }
196
+ finalizedHeadData = maxBlockRef(finalizedHeadData, this.state.finalizedHead)
197
+
198
+ let unfinalizedIndex =
199
+ finalizedHeadData == null ? 0 : blocks.findIndex((b) => b.header.number > finalizedHeadData.number)
200
+ unfinalizedIndex = unfinalizedIndex < 0 ? blocks.length : unfinalizedIndex
178
201
 
179
- let isOnTop = nextHead.height >= chainHeight
202
+ let nextHead = maybeLast(blocks)?.header ?? prevHead ?? finalizedHeadData
203
+ if (nextHead == null) return
204
+
205
+ let isOnTop = nextHead.number >= chainHeight
180
206
 
181
207
  let mappingStartTime = process.hrtime.bigint()
182
208
 
183
209
  if (this.db.supportsHotBlocks) {
210
+ let finalizedHead: BlockRef | undefined
211
+ if (!hasBlocks || unfinalizedIndex === 0) {
212
+ finalizedHead = finalizedHeadData
213
+ } else if (unfinalizedIndex === blocks.length) {
214
+ finalizedHead = last(blocks).header
215
+ } else {
216
+ finalizedHead = blocks[unfinalizedIndex - 1]?.header
217
+ }
218
+ let unfinalizedSliceHeads: BlockRef[] = []
184
219
  await this.db.transactHot2(
185
220
  {
186
- finalizedHead,
187
- baseHead: prevHead,
188
- newBlocks: blocks.map((b) => ({
189
- hash: b.header.hash,
190
- height: b.header.number,
191
- })),
221
+ finalizedHead: toHashAndHeight(finalizedHead),
222
+ baseHead: toHashAndHeight(prevHead),
223
+ newBlocks: blocks.map((b) => toHashAndHeight(b.header)),
224
+ },
225
+ async (store, start, end) => {
226
+ let sliceBlocks = start === 0 && end === blocks.length ? blocks : blocks.slice(start, end)
227
+ if (sliceBlocks.length === 0) return
228
+ if (end > unfinalizedIndex) {
229
+ unfinalizedSliceHeads.push(last(sliceBlocks).header)
230
+ }
231
+ return map(store, sliceBlocks, isOnTop)
192
232
  },
193
- (store, start, end) => {
194
- return this.handler({
195
- store,
196
- blocks: blocks.slice(start, end),
197
- isHead: isOnTop,
198
- })
199
- }
200
233
  )
234
+
235
+ let newFinalizedHead = finalizedHead ?? this.state.finalizedHead
236
+ let unfinalizedHeads = this.state.unfinalizedHeads
237
+ if (newFinalizedHead) {
238
+ let idx = unfinalizedHeads.findIndex((h) => h.number > newFinalizedHead.number)
239
+ unfinalizedHeads = idx < 0 ? [] : unfinalizedHeads.slice(idx)
240
+ }
241
+ this.state.finalizedHead = newFinalizedHead
242
+ this.state.unfinalizedHeads = unfinalizedHeads.concat(unfinalizedSliceHeads)
201
243
  } else {
244
+ assert(unfinalizedIndex === blocks.length, 'non-hot database received unfinalized blocks')
245
+
202
246
  await this.db.transact(
203
247
  {
204
- prevHead,
205
- nextHead,
248
+ prevHead: toHashAndHeight(prevHead),
249
+ nextHead: toHashAndHeight(nextHead),
206
250
  isOnTop,
207
251
  },
208
- (store) => {
209
- return this.handler({
210
- store,
211
- blocks,
212
- isHead: isOnTop,
213
- })
214
- }
252
+ (store) => map(store, blocks, isOnTop),
215
253
  )
254
+
255
+ this.state.finalizedHead = nextHead
256
+ this.state.unfinalizedHeads = []
216
257
  }
217
258
 
218
259
  let mappingEndTime = process.hrtime.bigint()
219
260
 
220
- this.updateProgressMetrics(chainHeight, nextHead.height, mappingEndTime)
261
+ this.updateProgressMetrics(chainHeight, nextHead.number, mappingEndTime)
221
262
  this.metrics.registerBatch(blocks.length, getItemsCount(blocks), mappingStartTime, mappingEndTime)
222
263
 
223
264
  this.reportStatus()
224
-
225
- return nextHead
226
265
  }
227
266
 
228
267
  private reportStatus(): void {
@@ -234,7 +273,7 @@ class Processor<B extends BlockBase, S> {
234
273
  this.hasStatusNews = false
235
274
  this.reportStatus()
236
275
  }
237
- }, 5000)
276
+ }, 5_000)
238
277
  } else {
239
278
  this.hasStatusNews = true
240
279
  }
@@ -251,38 +290,66 @@ class Processor<B extends BlockBase, S> {
251
290
  }
252
291
  }
253
292
 
254
- async function computeForkBase(
255
- state: HotDatabaseState,
256
- forked: BlockRef[],
257
- finalizedHead?: BlockRef
258
- ): Promise<HashAndHeight | undefined> {
259
- assert(forked?.length)
260
- let tail = forked.slice()
261
-
262
- let commited = state.top
263
- if (commited.length > 0) {
264
- for (let i = commited.length - 1; i >= 0; i--) {
265
- let h = commited[i]
293
+ export function findRollbackIndex(currentChain: BlockRef[], forkChain: BlockRef[]): number {
294
+ let currentIndex = 0
295
+ let forkIndex = 0
296
+ let lastCommonIndex = -1
266
297
 
267
- while (tail.length > 0 && last(tail).number > h.height) {
268
- tail.pop()
269
- }
298
+ while (currentIndex < currentChain.length && forkIndex < forkChain.length) {
299
+ const currentBlock = currentChain[currentIndex]
300
+ const forkBlock = forkChain[forkIndex]
270
301
 
271
- if (tail.length == 0) return h
302
+ if (currentBlock.number > forkBlock.number) {
303
+ forkIndex++
304
+ continue
305
+ }
272
306
 
273
- let t = last(tail)
274
- if (t.number == h.height && t.hash == h.hash) return h
307
+ if (currentBlock.number < forkBlock.number) {
308
+ currentIndex++
309
+ continue
275
310
  }
276
- } else {
277
- if (forked[0].number > state.height) return state
278
311
 
279
- let headOnChain = forked.find((b) => b.number == state.height)
280
- if (headOnChain == null || headOnChain.hash !== state.hash) {
281
- if (finalizedHead && finalizedHead.number >= state.height) {
282
- throw new Error(`finalized block ${formatHead(state)} was not found on chain`)
283
- }
312
+ if (currentBlock.hash !== forkBlock.hash) {
313
+ return lastCommonIndex
284
314
  }
285
315
 
286
- return state
316
+ lastCommonIndex = currentIndex
317
+ currentIndex++
318
+ forkIndex++
319
+ }
320
+
321
+ return lastCommonIndex
322
+ }
323
+
324
+ function toHashAndHeight(ref: BlockRef | undefined): HashAndHeight {
325
+ if (ref == null) return {height: -1, hash: '0x'}
326
+ return {height: ref.number, hash: ref.hash}
327
+ }
328
+
329
+ function toBlockRef(hh: HashAndHeight): BlockRef {
330
+ return {number: hh.height, hash: hh.hash}
331
+ }
332
+
333
+ function toProcessorStateInit(dbState: FinalDatabaseState | HotDatabaseState): ProcessorStateInit {
334
+ let top = 'top' in dbState ? dbState.top : undefined
335
+ return {
336
+ finalizedHead: dbState.height < 0 ? undefined : toBlockRef(dbState),
337
+ unfinalizedHeads: top?.map((b) => toBlockRef(b)),
338
+ }
339
+ }
340
+
341
+ function maxBlockRef(a: BlockRef | undefined, b: BlockRef | undefined): BlockRef | undefined {
342
+ if (a == null) return b
343
+ if (b == null) return a
344
+ return a.number >= b.number ? a : b
345
+ }
346
+
347
+ function assertBlocksContinuity<B extends BlockBase>(prevHead: BlockRef | undefined, blocks: B[]): void {
348
+ let prev = prevHead
349
+ for (let block of blocks) {
350
+ if (prev && prev.number >= block.header.number) {
351
+ throw new Error('Data is not continuous')
352
+ }
353
+ prev = block.header
287
354
  }
288
355
  }