@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/lib/database.d.ts +25 -8
- package/lib/database.d.ts.map +1 -1
- package/lib/run.d.ts +25 -11
- package/lib/run.d.ts.map +1 -1
- package/lib/run.js +183 -149
- package/lib/run.js.map +1 -1
- package/lib/util.d.ts +2 -2
- package/lib/util.d.ts.map +1 -1
- package/lib/util.js +1 -1
- package/lib/util.js.map +1 -1
- package/package.json +12 -9
- package/src/database.ts +28 -13
- package/src/find-rollback-index.test.ts +85 -0
- package/src/run.ts +219 -152
- package/src/test/processor.test.ts +651 -0
- package/src/util.ts +3 -3
- package/lib/metrics.d.ts +0 -21
- package/lib/metrics.d.ts.map +0 -1
- package/lib/metrics.js +0 -92
- package/lib/metrics.js.map +0 -1
- package/src/metrics.ts +0 -111
|
@@ -0,0 +1,651 @@
|
|
|
1
|
+
import {buildChain, forkAt, joinFork, type MockBlock} from '@subsquid/util-internal-testing'
|
|
2
|
+
import {
|
|
3
|
+
ForkException,
|
|
4
|
+
type BlockBatch,
|
|
5
|
+
type BlockRef,
|
|
6
|
+
type BlockStream,
|
|
7
|
+
type DataSource,
|
|
8
|
+
type StreamRequest,
|
|
9
|
+
} from '@subsquid/util-internal-data-source'
|
|
10
|
+
import {describe, expect, it} from 'vitest'
|
|
11
|
+
import type {
|
|
12
|
+
Database,
|
|
13
|
+
FinalDatabase,
|
|
14
|
+
FinalDatabaseState,
|
|
15
|
+
FinalTxInfo,
|
|
16
|
+
HashAndHeight,
|
|
17
|
+
HotDatabase,
|
|
18
|
+
HotDatabaseState,
|
|
19
|
+
HotTxInfo,
|
|
20
|
+
} from '../database'
|
|
21
|
+
import {Processor, type DataHandlerContext} from '../run'
|
|
22
|
+
|
|
23
|
+
// Block shape the Processor consumes — just a {header: BlockRef} envelope.
|
|
24
|
+
interface TestBlock {
|
|
25
|
+
header: BlockRef
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function wrap(headers: MockBlock[]): TestBlock[] {
|
|
29
|
+
return headers.map((h) => ({header: {number: h.height, hash: h.hash}}))
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// In-process DataSource that serves a single pre-built chain. Only
|
|
33
|
+
// getFinalizedHead and getFinalizedStream are wired — good enough for the
|
|
34
|
+
// "final database, linear progress" case the Processor needs to handle.
|
|
35
|
+
class InProcessSource implements DataSource<TestBlock> {
|
|
36
|
+
constructor(private readonly chain: TestBlock[]) {}
|
|
37
|
+
|
|
38
|
+
async getHead(): Promise<BlockRef> {
|
|
39
|
+
return this.tipRef()
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async getFinalizedHead(): Promise<BlockRef> {
|
|
43
|
+
return this.tipRef()
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
getFinalizedStream(req: StreamRequest): BlockStream<TestBlock> {
|
|
47
|
+
return this.makeStream(req)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
getStream(req: StreamRequest): BlockStream<TestBlock> {
|
|
51
|
+
return this.makeStream(req)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
private tipRef(): BlockRef {
|
|
55
|
+
if (this.chain.length === 0) return {number: -1, hash: '0x'}
|
|
56
|
+
const tip = this.chain[this.chain.length - 1].header
|
|
57
|
+
return {number: tip.number, hash: tip.hash}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
private async *makeStream(req: StreamRequest): BlockStream<TestBlock> {
|
|
61
|
+
const blocks = this.chain.filter(
|
|
62
|
+
(b) => b.header.number >= req.from && (req.to == null || b.header.number <= req.to),
|
|
63
|
+
)
|
|
64
|
+
if (blocks.length === 0) return
|
|
65
|
+
const finalizedHead = this.tipRef()
|
|
66
|
+
const batch: BlockBatch<TestBlock> = {blocks, finalizedHead}
|
|
67
|
+
yield batch
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
interface RecorderStore {
|
|
72
|
+
blocks: TestBlock[]
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
interface RecordedTransact {
|
|
76
|
+
prevHead: HashAndHeight
|
|
77
|
+
nextHead: HashAndHeight
|
|
78
|
+
isOnTop: boolean
|
|
79
|
+
blocks: TestBlock[]
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
class RecordingFinalDatabase implements FinalDatabase<RecorderStore> {
|
|
83
|
+
supportsHotBlocks: false = false
|
|
84
|
+
state: FinalDatabaseState = {height: -1, hash: '0x'}
|
|
85
|
+
transacts: RecordedTransact[] = []
|
|
86
|
+
|
|
87
|
+
async connect(): Promise<FinalDatabaseState> {
|
|
88
|
+
return this.state
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async transact(info: FinalTxInfo, cb: (store: RecorderStore) => Promise<unknown>): Promise<void> {
|
|
92
|
+
const store: RecorderStore = {blocks: []}
|
|
93
|
+
await cb(store)
|
|
94
|
+
this.transacts.push({
|
|
95
|
+
prevHead: info.prevHead,
|
|
96
|
+
nextHead: info.nextHead,
|
|
97
|
+
isOnTop: info.isOnTop,
|
|
98
|
+
blocks: store.blocks,
|
|
99
|
+
})
|
|
100
|
+
this.state = {height: info.nextHead.height, hash: info.nextHead.hash}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
interface RecordedHotTransact {
|
|
105
|
+
info: HotTxInfo
|
|
106
|
+
blocks: TestBlock[]
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
class RecordingHotDatabase implements HotDatabase<RecorderStore> {
|
|
110
|
+
supportsHotBlocks: true = true
|
|
111
|
+
state: HotDatabaseState = {height: -1, hash: '0x', top: []}
|
|
112
|
+
transacts: RecordedTransact[] = []
|
|
113
|
+
hotTransacts: RecordedHotTransact[] = []
|
|
114
|
+
|
|
115
|
+
constructor(private readonly hotGroupSize = Number.POSITIVE_INFINITY) {}
|
|
116
|
+
|
|
117
|
+
async connect(): Promise<HotDatabaseState> {
|
|
118
|
+
return this.state
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async transact(info: FinalTxInfo, cb: (store: RecorderStore) => Promise<unknown>): Promise<void> {
|
|
122
|
+
const store: RecorderStore = {blocks: []}
|
|
123
|
+
await cb(store)
|
|
124
|
+
this.transacts.push({
|
|
125
|
+
prevHead: info.prevHead,
|
|
126
|
+
nextHead: info.nextHead,
|
|
127
|
+
isOnTop: info.isOnTop,
|
|
128
|
+
blocks: store.blocks,
|
|
129
|
+
})
|
|
130
|
+
this.state = {height: info.nextHead.height, hash: info.nextHead.hash, top: []}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async transactHot2(
|
|
134
|
+
info: HotTxInfo,
|
|
135
|
+
cb: (store: RecorderStore, sliceBeg: number, sliceEnd: number) => Promise<unknown>,
|
|
136
|
+
): Promise<void> {
|
|
137
|
+
const store: RecorderStore = {blocks: []}
|
|
138
|
+
for (let sliceBeg = 0; sliceBeg < info.newBlocks.length; sliceBeg += this.hotGroupSize) {
|
|
139
|
+
await cb(store, sliceBeg, Math.min(sliceBeg + this.hotGroupSize, info.newBlocks.length))
|
|
140
|
+
}
|
|
141
|
+
this.hotTransacts.push({info, blocks: store.blocks})
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Stateful DataSource used for fork scenarios. `plan` is a list of "steps",
|
|
146
|
+
// each consumed by one getStream invocation. A step can either yield a batch
|
|
147
|
+
// or throw a ForkException; the source cycles through them in order and
|
|
148
|
+
// terminates cleanly once the plan is exhausted.
|
|
149
|
+
type StreamStep = {kind: 'yield'; batch: BlockBatch<TestBlock>} | {kind: 'fork'; fork: ForkException}
|
|
150
|
+
|
|
151
|
+
class ProgrammableSource implements DataSource<TestBlock> {
|
|
152
|
+
private readonly plan: StreamStep[][] = []
|
|
153
|
+
|
|
154
|
+
constructor(private readonly chain: TestBlock[]) {}
|
|
155
|
+
|
|
156
|
+
// Each entry is what ONE getStream call should do, in order.
|
|
157
|
+
addStreamCall(steps: StreamStep[]): this {
|
|
158
|
+
this.plan.push(steps)
|
|
159
|
+
return this
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async getHead(): Promise<BlockRef> {
|
|
163
|
+
return this.tipRef()
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async getFinalizedHead(): Promise<BlockRef> {
|
|
167
|
+
return {number: -1, hash: '0x'}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
getFinalizedStream(req: StreamRequest): BlockStream<TestBlock> {
|
|
171
|
+
return this.nextStream(req)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
getStream(_req: StreamRequest): BlockStream<TestBlock> {
|
|
175
|
+
return this.nextStream(_req)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
private tipRef(): BlockRef {
|
|
179
|
+
if (this.chain.length === 0) return {number: -1, hash: '0x'}
|
|
180
|
+
const tip = this.chain[this.chain.length - 1].header
|
|
181
|
+
return {number: tip.number, hash: tip.hash}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
private async *nextStream(_req: StreamRequest): BlockStream<TestBlock> {
|
|
185
|
+
const steps = this.plan.shift()
|
|
186
|
+
if (!steps) return
|
|
187
|
+
for (const step of steps) {
|
|
188
|
+
if (step.kind === 'yield') {
|
|
189
|
+
yield step.batch
|
|
190
|
+
} else {
|
|
191
|
+
throw step.fork
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
describe('Processor', () => {
|
|
198
|
+
it('drives linear progress from DataSource to FinalDatabase in one transact call', async () => {
|
|
199
|
+
const headers = buildChain({from: 0, to: 4}) // 5 blocks
|
|
200
|
+
const chain = wrap(headers)
|
|
201
|
+
|
|
202
|
+
const src = new InProcessSource(chain)
|
|
203
|
+
const db: Database<RecorderStore> = new RecordingFinalDatabase()
|
|
204
|
+
|
|
205
|
+
const processor = new Processor<TestBlock, RecorderStore>(
|
|
206
|
+
src,
|
|
207
|
+
db,
|
|
208
|
+
async (ctx: DataHandlerContext<TestBlock, RecorderStore>) => {
|
|
209
|
+
for (const block of ctx.blocks) {
|
|
210
|
+
ctx.store.blocks.push(block)
|
|
211
|
+
}
|
|
212
|
+
},
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
await processor.run()
|
|
216
|
+
|
|
217
|
+
const recorder = db as RecordingFinalDatabase
|
|
218
|
+
expect(recorder.transacts).toHaveLength(1)
|
|
219
|
+
const only = recorder.transacts[0]
|
|
220
|
+
expect(only.prevHead).toMatchObject({height: -1, hash: '0x'})
|
|
221
|
+
expect(only.nextHead).toMatchObject({height: 4, hash: '0x4'})
|
|
222
|
+
expect(only.isOnTop).toBe(true)
|
|
223
|
+
expect(only.blocks.map((b) => b.header.number)).toEqual([0, 1, 2, 3, 4])
|
|
224
|
+
expect(recorder.state).toMatchObject({height: 4, hash: '0x4'})
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
it('rejects non-continuous block order inside a batch', async () => {
|
|
228
|
+
const chain: TestBlock[] = [
|
|
229
|
+
{header: {number: 0, hash: '0x0'}},
|
|
230
|
+
{header: {number: 2, hash: '0x2'}},
|
|
231
|
+
{header: {number: 1, hash: '0x1'}},
|
|
232
|
+
]
|
|
233
|
+
|
|
234
|
+
const src = new InProcessSource(chain)
|
|
235
|
+
const db = new RecordingFinalDatabase()
|
|
236
|
+
const processor = new Processor<TestBlock, RecorderStore>(src, db, async () => {})
|
|
237
|
+
|
|
238
|
+
await expect(processor.run()).rejects.toThrow(/Data is not continuous/)
|
|
239
|
+
expect(db.transacts).toHaveLength(0)
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
it('drives linear progress through a HotDatabase via transactHot2', async () => {
|
|
243
|
+
// Same five blocks, but fed through a HotDatabase. BlockBatch carries
|
|
244
|
+
// no finalizedHead, so every block is treated as unfinalized and
|
|
245
|
+
// routed through transactHot2.
|
|
246
|
+
const chain = wrap(buildChain({from: 0, to: 4}))
|
|
247
|
+
|
|
248
|
+
const src = new ProgrammableSource(chain).addStreamCall([
|
|
249
|
+
{kind: 'yield', batch: {blocks: chain, finalizedHead: undefined}},
|
|
250
|
+
])
|
|
251
|
+
const db = new RecordingHotDatabase()
|
|
252
|
+
|
|
253
|
+
const processor = new Processor<TestBlock, RecorderStore>(src, db, async (ctx) => {
|
|
254
|
+
for (const block of ctx.blocks) {
|
|
255
|
+
ctx.store.blocks.push(block)
|
|
256
|
+
}
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
await processor.run()
|
|
260
|
+
|
|
261
|
+
expect(db.hotTransacts).toHaveLength(1)
|
|
262
|
+
const only = db.hotTransacts[0]
|
|
263
|
+
expect(only.info.baseHead).toMatchObject({height: -1, hash: '0x'})
|
|
264
|
+
expect(only.info.finalizedHead).toMatchObject({height: -1, hash: '0x'})
|
|
265
|
+
expect(only.info.newBlocks.map((b) => ({height: b.height, hash: b.hash}))).toEqual([
|
|
266
|
+
{height: 0, hash: '0x0'},
|
|
267
|
+
{height: 1, hash: '0x1'},
|
|
268
|
+
{height: 2, hash: '0x2'},
|
|
269
|
+
{height: 3, hash: '0x3'},
|
|
270
|
+
{height: 4, hash: '0x4'},
|
|
271
|
+
])
|
|
272
|
+
expect(only.blocks.map((b) => b.header.number)).toEqual([0, 1, 2, 3, 4])
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
it('builds hot state from transactHot2 slice tips only', async () => {
|
|
276
|
+
const chain = wrap(buildChain({from: 0, to: 4}))
|
|
277
|
+
|
|
278
|
+
const src = new ProgrammableSource(chain).addStreamCall([
|
|
279
|
+
{kind: 'yield', batch: {blocks: chain, finalizedHead: undefined}},
|
|
280
|
+
])
|
|
281
|
+
const db = new RecordingHotDatabase(2)
|
|
282
|
+
|
|
283
|
+
const processor = new Processor<TestBlock, RecorderStore>(src, db, async (ctx) => {
|
|
284
|
+
for (const block of ctx.blocks) {
|
|
285
|
+
ctx.store.blocks.push(block)
|
|
286
|
+
}
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
await processor.run()
|
|
290
|
+
|
|
291
|
+
expect(db.hotTransacts).toHaveLength(1)
|
|
292
|
+
expect(db.hotTransacts[0].blocks.map((b) => b.header.number)).toEqual([0, 1, 2, 3, 4])
|
|
293
|
+
expect(
|
|
294
|
+
(processor as unknown as {state: {unfinalizedHeads: BlockRef[]}}).state.unfinalizedHeads
|
|
295
|
+
).toEqual([
|
|
296
|
+
{number: 1, hash: '0x1'},
|
|
297
|
+
{number: 3, hash: '0x3'},
|
|
298
|
+
{number: 4, hash: '0x4'},
|
|
299
|
+
])
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
it('rolls back to the nearest known sparse head', async () => {
|
|
303
|
+
// Current sparse state is [1, 2, 6]. The fork view reaches block 4
|
|
304
|
+
// through the same block 2, so Processor should rewind
|
|
305
|
+
// state.unfinalizedHeads to the nearest known common point [1, 2]
|
|
306
|
+
// and re-request the stream from parentHash = 0x2.
|
|
307
|
+
const forkException = new ForkException(4, '0x4-fork', [
|
|
308
|
+
{number: 1, hash: '0x1'},
|
|
309
|
+
{number: 2, hash: '0x2'},
|
|
310
|
+
{number: 4, hash: '0x4-fork'},
|
|
311
|
+
])
|
|
312
|
+
const forkBlocks: TestBlock[] = [
|
|
313
|
+
{header: {number: 3, hash: '0x3-fork'}},
|
|
314
|
+
{header: {number: 4, hash: '0x4-fork'}},
|
|
315
|
+
]
|
|
316
|
+
|
|
317
|
+
const src = new ProgrammableSource(forkBlocks)
|
|
318
|
+
.addStreamCall([{kind: 'fork', fork: forkException}])
|
|
319
|
+
.addStreamCall([{kind: 'yield', batch: {blocks: forkBlocks, finalizedHead: undefined}}])
|
|
320
|
+
|
|
321
|
+
const db = new RecordingHotDatabase()
|
|
322
|
+
db.state = {
|
|
323
|
+
height: -1,
|
|
324
|
+
hash: '0x',
|
|
325
|
+
top: [
|
|
326
|
+
{height: 1, hash: '0x1'},
|
|
327
|
+
{height: 2, hash: '0x2'},
|
|
328
|
+
{height: 6, hash: '0x6'},
|
|
329
|
+
],
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const processor = new Processor<TestBlock, RecorderStore>(src, db, async () => {})
|
|
333
|
+
|
|
334
|
+
await processor.run()
|
|
335
|
+
|
|
336
|
+
expect(db.hotTransacts).toHaveLength(1)
|
|
337
|
+
// baseHead at the nearest known sparse common block (height 2, hash 0x2).
|
|
338
|
+
expect(db.hotTransacts[0].info.baseHead).toMatchObject({height: 2, hash: '0x2'})
|
|
339
|
+
expect(db.hotTransacts[0].info.newBlocks.map(b => b.hash)).toEqual(['0x3-fork', '0x4-fork'])
|
|
340
|
+
})
|
|
341
|
+
|
|
342
|
+
it('handles a deep reorg (>10 blocks) via ForkException', async () => {
|
|
343
|
+
// Main 0..12 (13 blocks). Fork at height 3, length 9 → replaces
|
|
344
|
+
// blocks 4..12 with 4a..12a. ForkException.previousBlocks carries
|
|
345
|
+
// the source's new view so findRollbackIndex can locate the common
|
|
346
|
+
// prefix when sparse hot state has it; otherwise the processor is
|
|
347
|
+
// allowed to roll back deeper.
|
|
348
|
+
const mainHeaders = buildChain({from: 0, to: 12})
|
|
349
|
+
const main = wrap(mainHeaders)
|
|
350
|
+
const forkHeaders = forkAt(mainHeaders, {at: 3, length: 9, suffix: 'a'})
|
|
351
|
+
const replay = wrap(joinFork(mainHeaders, forkHeaders))
|
|
352
|
+
|
|
353
|
+
// previousBlocks tells Processor what the source now considers the
|
|
354
|
+
// already-served chain. Hash divergence at height 4 triggers rollback.
|
|
355
|
+
const forkException = new ForkException(13, '0x12a', [
|
|
356
|
+
{number: 0, hash: '0x0'},
|
|
357
|
+
{number: 1, hash: '0x1'},
|
|
358
|
+
{number: 2, hash: '0x2'},
|
|
359
|
+
{number: 3, hash: '0x3'},
|
|
360
|
+
{number: 4, hash: '0x4a'},
|
|
361
|
+
{number: 5, hash: '0x5a'},
|
|
362
|
+
{number: 6, hash: '0x6a'},
|
|
363
|
+
{number: 7, hash: '0x7a'},
|
|
364
|
+
{number: 8, hash: '0x8a'},
|
|
365
|
+
{number: 9, hash: '0x9a'},
|
|
366
|
+
{number: 10, hash: '0x10a'},
|
|
367
|
+
{number: 11, hash: '0x11a'},
|
|
368
|
+
{number: 12, hash: '0x12a'},
|
|
369
|
+
])
|
|
370
|
+
|
|
371
|
+
const src = new ProgrammableSource(main)
|
|
372
|
+
.addStreamCall([
|
|
373
|
+
{kind: 'yield', batch: {blocks: main, finalizedHead: undefined}},
|
|
374
|
+
{kind: 'fork', fork: forkException},
|
|
375
|
+
])
|
|
376
|
+
.addStreamCall([{kind: 'yield', batch: {blocks: replay, finalizedHead: undefined}}])
|
|
377
|
+
|
|
378
|
+
const db = new RecordingHotDatabase()
|
|
379
|
+
|
|
380
|
+
const processor = new Processor<TestBlock, RecorderStore>(src, db, async (ctx) => {
|
|
381
|
+
for (const block of ctx.blocks) {
|
|
382
|
+
ctx.store.blocks.push(block)
|
|
383
|
+
}
|
|
384
|
+
})
|
|
385
|
+
|
|
386
|
+
await processor.run()
|
|
387
|
+
|
|
388
|
+
expect(db.hotTransacts).toHaveLength(2)
|
|
389
|
+
const reorg = db.hotTransacts[1]
|
|
390
|
+
expect(reorg.info.baseHead).toMatchObject({height: -1, hash: '0x'})
|
|
391
|
+
expect(reorg.info.newBlocks.map((b) => b.hash)).toEqual(replay.map((b) => b.header.hash))
|
|
392
|
+
})
|
|
393
|
+
|
|
394
|
+
it('handles cascading reorgs: two sequential ForkExceptions', async () => {
|
|
395
|
+
// main: 0 — 1 — 2 — 3 — 4
|
|
396
|
+
// fork A: 3 — 4a — 5a (common ancestor at height 3)
|
|
397
|
+
// fork B: 1 — 2 — 3b — 4b — 5b — 6b — 7b (deeper — common at 1)
|
|
398
|
+
//
|
|
399
|
+
// Three getStream calls, three transactHot2 commits. With dense hot
|
|
400
|
+
// state each baseHead would sit progressively deeper than the previous;
|
|
401
|
+
// with sparse state, rollback can land on an earlier known slice head.
|
|
402
|
+
const mainHeaders = buildChain({from: 0, to: 4})
|
|
403
|
+
const main = wrap(mainHeaders)
|
|
404
|
+
|
|
405
|
+
const branchAHeaders = forkAt(mainHeaders, {at: 3, length: 2, suffix: 'a'}) // 4a, 5a
|
|
406
|
+
const afterAHeaders = joinFork(mainHeaders, branchAHeaders)
|
|
407
|
+
const replayA = wrap(afterAHeaders).slice(1)
|
|
408
|
+
|
|
409
|
+
const branchBHeaders = forkAt(afterAHeaders, {at: 1, length: 6, suffix: 'b'}) // 2b..7b
|
|
410
|
+
const replayB = wrap(joinFork(afterAHeaders, branchBHeaders)).slice(1)
|
|
411
|
+
|
|
412
|
+
// First ForkException: source realises its chain at heights 4..5 (or
|
|
413
|
+
// beyond) differs from what it served. The fork view here retains
|
|
414
|
+
// 0..3 common, then diverges at 4.
|
|
415
|
+
const forkA = new ForkException(6, '0x5a', [
|
|
416
|
+
{number: 0, hash: '0x0'},
|
|
417
|
+
{number: 1, hash: '0x1'},
|
|
418
|
+
{number: 2, hash: '0x2'},
|
|
419
|
+
{number: 3, hash: '0x3'},
|
|
420
|
+
{number: 4, hash: '0x4a'},
|
|
421
|
+
{number: 5, hash: '0x5a'},
|
|
422
|
+
])
|
|
423
|
+
|
|
424
|
+
// Second ForkException: now the source's view has shifted deeper —
|
|
425
|
+
// diverges from the Processor's committed chain. With sparse hot
|
|
426
|
+
// state, previousBlocks can prove the finalized genesis base, but not
|
|
427
|
+
// every intermediate hot block.
|
|
428
|
+
const forkB = new ForkException(8, '0x7b', [
|
|
429
|
+
{number: 0, hash: '0x0'},
|
|
430
|
+
{number: 1, hash: '0x1'},
|
|
431
|
+
{number: 2, hash: '0x2b'},
|
|
432
|
+
{number: 3, hash: '0x3b'},
|
|
433
|
+
{number: 4, hash: '0x4b'},
|
|
434
|
+
{number: 5, hash: '0x5b'},
|
|
435
|
+
{number: 6, hash: '0x6b'},
|
|
436
|
+
{number: 7, hash: '0x7b'},
|
|
437
|
+
])
|
|
438
|
+
|
|
439
|
+
// Pin finalizedHead at genesis in every batch so that ProcessorState's
|
|
440
|
+
// chain = [finalizedHead, ...unfinalizedHeads] always has a stable
|
|
441
|
+
// chain[0] to drop in chain.slice(1, ...). Without this anchor, the
|
|
442
|
+
// slice drops the first unfinalized block and state drifts after
|
|
443
|
+
// cascading reorgs.
|
|
444
|
+
const anchoredFinalized = {number: 0, hash: '0x0'}
|
|
445
|
+
const src = new ProgrammableSource(main)
|
|
446
|
+
.addStreamCall([
|
|
447
|
+
{kind: 'yield', batch: {blocks: main, finalizedHead: anchoredFinalized}},
|
|
448
|
+
{kind: 'fork', fork: forkA},
|
|
449
|
+
])
|
|
450
|
+
.addStreamCall([
|
|
451
|
+
{kind: 'yield', batch: {blocks: replayA, finalizedHead: anchoredFinalized}},
|
|
452
|
+
{kind: 'fork', fork: forkB},
|
|
453
|
+
])
|
|
454
|
+
.addStreamCall([{kind: 'yield', batch: {blocks: replayB, finalizedHead: anchoredFinalized}}])
|
|
455
|
+
|
|
456
|
+
const db = new RecordingHotDatabase()
|
|
457
|
+
|
|
458
|
+
const processor = new Processor<TestBlock, RecorderStore>(src, db, async () => {})
|
|
459
|
+
|
|
460
|
+
await processor.run()
|
|
461
|
+
|
|
462
|
+
expect(db.hotTransacts).toHaveLength(3)
|
|
463
|
+
|
|
464
|
+
// First commit: main chain from genesis.
|
|
465
|
+
expect(db.hotTransacts[0].info.baseHead).toMatchObject({height: -1, hash: '0x'})
|
|
466
|
+
expect(db.hotTransacts[0].info.newBlocks.map((b) => b.hash)).toEqual(['0x0', '0x1', '0x2', '0x3', '0x4'])
|
|
467
|
+
|
|
468
|
+
// Second commit: the real common ancestor is height 3, but sparse
|
|
469
|
+
// state only proves finalized genesis, so replay starts from block 1.
|
|
470
|
+
expect(db.hotTransacts[1].info.baseHead).toMatchObject({height: 0, hash: '0x0'})
|
|
471
|
+
expect(db.hotTransacts[1].info.newBlocks.map((b) => b.hash)).toEqual(replayA.map((b) => b.header.hash))
|
|
472
|
+
|
|
473
|
+
// Third commit: the real common ancestor is height 1, but the
|
|
474
|
+
// cascading sparse rollback again starts from finalized genesis.
|
|
475
|
+
expect(db.hotTransacts[2].info.baseHead).toMatchObject({height: 0, hash: '0x0'})
|
|
476
|
+
expect(db.hotTransacts[2].info.newBlocks.map((b) => b.hash)).toEqual(replayB.map((b) => b.header.hash))
|
|
477
|
+
})
|
|
478
|
+
|
|
479
|
+
it('forwards BlockBatch.finalizedHead into transactHot2.info.finalizedHead', async () => {
|
|
480
|
+
// Mixed finalized+hot batch: the source hands over blocks 0..4 with
|
|
481
|
+
// finalizedHead = {3, '0x3'}. With a HotDatabase, Processor passes
|
|
482
|
+
// the entire batch through transactHot2 and expects info.finalizedHead
|
|
483
|
+
// to reflect the source's finality view — persistence layer is the
|
|
484
|
+
// one that splits the writes at the boundary.
|
|
485
|
+
const chain = wrap(buildChain({from: 0, to: 4}))
|
|
486
|
+
|
|
487
|
+
const src = new ProgrammableSource(chain).addStreamCall([
|
|
488
|
+
{
|
|
489
|
+
kind: 'yield',
|
|
490
|
+
batch: {
|
|
491
|
+
blocks: chain,
|
|
492
|
+
finalizedHead: {number: 3, hash: '0x3'},
|
|
493
|
+
},
|
|
494
|
+
},
|
|
495
|
+
])
|
|
496
|
+
|
|
497
|
+
const db = new RecordingHotDatabase()
|
|
498
|
+
|
|
499
|
+
const processor = new Processor<TestBlock, RecorderStore>(src, db, async () => {})
|
|
500
|
+
|
|
501
|
+
await processor.run()
|
|
502
|
+
|
|
503
|
+
expect(db.hotTransacts).toHaveLength(1)
|
|
504
|
+
const only = db.hotTransacts[0]
|
|
505
|
+
expect(only.info.finalizedHead).toMatchObject({height: 3, hash: '0x3'})
|
|
506
|
+
expect(only.info.baseHead).toMatchObject({height: -1, hash: '0x'})
|
|
507
|
+
expect(only.info.newBlocks.map((b) => b.hash)).toEqual(['0x0', '0x1', '0x2', '0x3', '0x4'])
|
|
508
|
+
})
|
|
509
|
+
|
|
510
|
+
it('propagates non-ForkException errors thrown by the handler', async () => {
|
|
511
|
+
// If the user handler throws, the error must surface out of
|
|
512
|
+
// Processor.run() rather than being swallowed. Processor's outer
|
|
513
|
+
// try/catch only re-enters the while(true) loop for ForkException;
|
|
514
|
+
// everything else propagates.
|
|
515
|
+
const chain = wrap(buildChain({from: 0, to: 2}))
|
|
516
|
+
const src = new ProgrammableSource(chain).addStreamCall([
|
|
517
|
+
{kind: 'yield', batch: {blocks: chain, finalizedHead: undefined}},
|
|
518
|
+
])
|
|
519
|
+
|
|
520
|
+
const db = new RecordingHotDatabase()
|
|
521
|
+
|
|
522
|
+
const processor = new Processor<TestBlock, RecorderStore>(src, db, async () => {
|
|
523
|
+
throw new Error('simulated handler crash')
|
|
524
|
+
})
|
|
525
|
+
|
|
526
|
+
await expect(processor.run()).rejects.toThrow(/simulated handler crash/)
|
|
527
|
+
})
|
|
528
|
+
|
|
529
|
+
it('processes a finality-only batch (empty blocks, advanced finalizedHead)', async () => {
|
|
530
|
+
const chain = wrap(buildChain({from: 0, to: 2}))
|
|
531
|
+
const src = new ProgrammableSource(chain).addStreamCall([
|
|
532
|
+
// Initial batch with three blocks and no finality.
|
|
533
|
+
{kind: 'yield', batch: {blocks: chain, finalizedHead: undefined}},
|
|
534
|
+
// Finality-only update: no new blocks, finalizedHead moves forward.
|
|
535
|
+
{kind: 'yield', batch: {blocks: [], finalizedHead: {number: 2, hash: '0x2'}}},
|
|
536
|
+
])
|
|
537
|
+
|
|
538
|
+
const db = new RecordingHotDatabase()
|
|
539
|
+
|
|
540
|
+
const processor = new Processor<TestBlock, RecorderStore>(src, db, async () => {})
|
|
541
|
+
|
|
542
|
+
await processor.run()
|
|
543
|
+
|
|
544
|
+
// The finality-only batch must still reach the database so hot rows
|
|
545
|
+
// can be promoted even when no new best block arrived.
|
|
546
|
+
expect(db.hotTransacts).toHaveLength(2)
|
|
547
|
+
expect(db.hotTransacts[1].info.finalizedHead).toMatchObject({height: 2, hash: '0x2'})
|
|
548
|
+
expect(db.hotTransacts[1].info.newBlocks).toEqual([])
|
|
549
|
+
})
|
|
550
|
+
|
|
551
|
+
it('raises "Unable to process fork" when ForkException.previousBlocks is disjoint from state', async () => {
|
|
552
|
+
// If the data source's ForkException carries previousBlocks that share
|
|
553
|
+
// no common prefix with the Processor's committed chain AND the
|
|
554
|
+
// Processor has any finalized state, handleFork throws rather than
|
|
555
|
+
// silently wiping everything. The guard lives in ProcessorState:
|
|
556
|
+
// if (rollbackIndex === -1) {
|
|
557
|
+
// if (this.finalizedHead != null) throw new Error('Unable to process fork')
|
|
558
|
+
// this.unfinalizedHeads = []
|
|
559
|
+
// }
|
|
560
|
+
//
|
|
561
|
+
// This test pins finalizedHead to genesis on the first batch so the
|
|
562
|
+
// "finalized state present" branch fires, then fires a ForkException
|
|
563
|
+
// whose previousBlocks don't overlap with the committed chain at all.
|
|
564
|
+
const mainHeaders = buildChain({from: 0, to: 4})
|
|
565
|
+
const main = wrap(mainHeaders)
|
|
566
|
+
|
|
567
|
+
// Disjoint: different hashes at the SAME heights as committed state.
|
|
568
|
+
const disjointFork = new ForkException(5, '0xdisjoint5', [
|
|
569
|
+
{number: 0, hash: '0xZERO'},
|
|
570
|
+
{number: 1, hash: '0xONE'},
|
|
571
|
+
{number: 2, hash: '0xTWO'},
|
|
572
|
+
{number: 3, hash: '0xTHREE'},
|
|
573
|
+
{number: 4, hash: '0xFOUR'},
|
|
574
|
+
])
|
|
575
|
+
|
|
576
|
+
const src = new ProgrammableSource(main).addStreamCall([
|
|
577
|
+
{
|
|
578
|
+
kind: 'yield',
|
|
579
|
+
batch: {blocks: main, finalizedHead: {number: 0, hash: '0x0'}},
|
|
580
|
+
},
|
|
581
|
+
{kind: 'fork', fork: disjointFork},
|
|
582
|
+
])
|
|
583
|
+
|
|
584
|
+
const db = new RecordingHotDatabase()
|
|
585
|
+
const processor = new Processor<TestBlock, RecorderStore>(src, db, async () => {})
|
|
586
|
+
|
|
587
|
+
await expect(processor.run()).rejects.toThrow(/Unable to process fork/)
|
|
588
|
+
|
|
589
|
+
// The first batch still committed before the fork arrived.
|
|
590
|
+
expect(db.hotTransacts).toHaveLength(1)
|
|
591
|
+
})
|
|
592
|
+
|
|
593
|
+
it('accepts a data source whose getFinalizedHead never advances past genesis', async () => {
|
|
594
|
+
// No X-Sqd-Finalized-Head-* signal anywhere in the stream. Every
|
|
595
|
+
// block reaches the database as unfinalized via transactHot2;
|
|
596
|
+
// Processor runs to completion without waiting for finality to
|
|
597
|
+
// advance.
|
|
598
|
+
const main = wrap(buildChain({from: 0, to: 3}))
|
|
599
|
+
|
|
600
|
+
const src = new ProgrammableSource(main).addStreamCall([
|
|
601
|
+
{kind: 'yield', batch: {blocks: main, finalizedHead: undefined}},
|
|
602
|
+
])
|
|
603
|
+
|
|
604
|
+
const db = new RecordingHotDatabase()
|
|
605
|
+
const processor = new Processor<TestBlock, RecorderStore>(src, db, async () => {})
|
|
606
|
+
|
|
607
|
+
await processor.run()
|
|
608
|
+
|
|
609
|
+
expect(db.hotTransacts).toHaveLength(1)
|
|
610
|
+
// info.finalizedHead falls back to the batch tip when no finality is
|
|
611
|
+
// reported (see run.ts:209-213 — unfinalizedIndex<0 branch).
|
|
612
|
+
// All four blocks routed through transactHot2.
|
|
613
|
+
expect(db.hotTransacts[0].info.newBlocks.map((b) => b.height)).toEqual([0, 1, 2, 3])
|
|
614
|
+
})
|
|
615
|
+
|
|
616
|
+
// Remaining Stage 5 scenarios from the roadmap. Subsumed by other tests,
|
|
617
|
+
// belonging to a different stage, or requiring a real data-source
|
|
618
|
+
// integration beyond the in-process harness. Explicit todos so the gaps
|
|
619
|
+
// stay visible until addressed.
|
|
620
|
+
|
|
621
|
+
// — groupSize > 1 mid-group baseHead. Processor is oblivious to
|
|
622
|
+
// persistence-layer grouping; it just hands info to transactHot2. The
|
|
623
|
+
// groupSize bug lives in typeorm-store and is covered by
|
|
624
|
+
// fork-deep.test.ts.
|
|
625
|
+
it.todo('groupSize > 1 mid-group baseHead')
|
|
626
|
+
|
|
627
|
+
// Deep EVM reorg end-to-end is still unverified on this layer. The
|
|
628
|
+
// deep-reorg test above feeds a synthetic ForkException with a complete
|
|
629
|
+
// previousBlocks history, so it exercises findRollbackIndex in isolation
|
|
630
|
+
// but not the real chain-specific datasource behavior: ensureContinuity
|
|
631
|
+
// in EvmRpcDataSource decides what previousBlocks payload to actually
|
|
632
|
+
// throw, and any N-block reorg will fire one ForkException per fork-
|
|
633
|
+
// point mismatch. A true end-to-end test would wire a mock EVM RPC (not
|
|
634
|
+
// a ProgrammableSource) through EvmRpcDataSource and into Processor +
|
|
635
|
+
// TypeormDatabase, then assert the final state is correct and the
|
|
636
|
+
// processor survives the full reorg cycle without hitting the
|
|
637
|
+
// 'Unable to process fork' guard for cases where the fork-point IS in
|
|
638
|
+
// our chain. Needs the full integration harness — deferred.
|
|
639
|
+
it.todo('deep EVM reorg end-to-end: EvmRpcDataSource + Processor + TypeormDatabase')
|
|
640
|
+
|
|
641
|
+
// coldIngest over-fetch + empty split. These test the internals of
|
|
642
|
+
// util-internal-ingest-tools/cold.ts, below the Processor boundary.
|
|
643
|
+
it.todo('coldIngest over-fetch at stopOnHead boundary')
|
|
644
|
+
it.todo('coldIngest empty split + isHead moving-top boundary')
|
|
645
|
+
|
|
646
|
+
// Gapped top[] from typeorm-store → HotProcessor bridge. Needs
|
|
647
|
+
// a real HotProcessor wired on top of a real TypeormDatabase returning
|
|
648
|
+
// a gap-structured top[]; the in-process Processor mocks here don't
|
|
649
|
+
// exercise the bridge.
|
|
650
|
+
it.todo('gapped top[] from DB → HotProcessor initialisation')
|
|
651
|
+
})
|
package/src/util.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type {BlockRef} from '@subsquid/util-internal-data-source'
|
|
2
2
|
|
|
3
3
|
|
|
4
4
|
export function timeInterval(seconds: number): string {
|
|
@@ -29,8 +29,8 @@ export function getItemsCount(blocks: any[]): number {
|
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
|
|
32
|
-
export function formatHead(head:
|
|
33
|
-
return `${head.
|
|
32
|
+
export function formatHead(head: BlockRef): string {
|
|
33
|
+
return `${head.number}#${shortHash(head.hash)}`
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
|
package/lib/metrics.d.ts
DELETED
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
export declare class Metrics {
|
|
2
|
-
private chainHeight;
|
|
3
|
-
private lastBlock;
|
|
4
|
-
private mappingSpeed;
|
|
5
|
-
private mappingItemSpeed;
|
|
6
|
-
private blockProgress;
|
|
7
|
-
setChainHeight(height: number): void;
|
|
8
|
-
setLastProcessedBlock(height: number): void;
|
|
9
|
-
updateProgress(processed: number, left: number, time?: bigint): void;
|
|
10
|
-
registerBatch(batchSize: number, batchItemSize: number, batchMappingStartTime: bigint, batchMappingEndTime: bigint): void;
|
|
11
|
-
getChainHeight(): number;
|
|
12
|
-
getLastProcessedBlock(): number;
|
|
13
|
-
getSyncSpeed(): number;
|
|
14
|
-
getSyncEtaSeconds(): number;
|
|
15
|
-
getSyncRatio(): number;
|
|
16
|
-
getMappingSpeed(): number;
|
|
17
|
-
getMappingItemSpeed(): number;
|
|
18
|
-
getStatusLine(): string;
|
|
19
|
-
install(): void;
|
|
20
|
-
}
|
|
21
|
-
//# sourceMappingURL=metrics.d.ts.map
|