mirror of
https://github.com/Permissionless-Software-Foundation/psf-memo-indexer.git
synced 2026-09-21 16:52:02 -07:00
Paralell processing of TXs within a block
This commit is contained in:
@@ -8,3 +8,5 @@ TX_REST_API_PORT=5455
|
|||||||
TX_REST_API_IP=localhost
|
TX_REST_API_IP=localhost
|
||||||
START_BLOCK_HEIGHT=525000
|
START_BLOCK_HEIGHT=525000
|
||||||
SEEN_TX_MAX=100000
|
SEEN_TX_MAX=100000
|
||||||
|
FILTER_CONCURRENCY=20
|
||||||
|
MEMO_TX_CONCURRENCY=20
|
||||||
|
|||||||
@@ -61,6 +61,8 @@ See `.env-example`. Key variables:
|
|||||||
| `RPC_IP` / `RPC_PORT` | `172.17.0.1` / `8332` | Full node RPC |
|
| `RPC_IP` / `RPC_PORT` | `172.17.0.1` / `8332` | Full node RPC |
|
||||||
| `ZMQ_PORT` | `28332` | Full node ZMQ |
|
| `ZMQ_PORT` | `28332` | Full node ZMQ |
|
||||||
| `TX_REST_API_PORT` | `5455` | TX indexer control API |
|
| `TX_REST_API_PORT` | `5455` | TX indexer control API |
|
||||||
|
| `FILTER_CONCURRENCY` | `20` | Parallel Memo tx detection per block |
|
||||||
|
| `MEMO_TX_CONCURRENCY` | `20` | Parallel Memo tx processing per block |
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,13 @@ export default {
|
|||||||
zmqBlockQueueMax: process.env.ZMQ_BLOCK_QUEUE_MAX ? parseInt(process.env.ZMQ_BLOCK_QUEUE_MAX) : 1000,
|
zmqBlockQueueMax: process.env.ZMQ_BLOCK_QUEUE_MAX ? parseInt(process.env.ZMQ_BLOCK_QUEUE_MAX) : 1000,
|
||||||
txCacheMax: process.env.TX_CACHE_MAX ? parseInt(process.env.TX_CACHE_MAX) : 100000,
|
txCacheMax: process.env.TX_CACHE_MAX ? parseInt(process.env.TX_CACHE_MAX) : 100000,
|
||||||
|
|
||||||
|
filterConcurrency: process.env.FILTER_CONCURRENCY
|
||||||
|
? parseInt(process.env.FILTER_CONCURRENCY)
|
||||||
|
: 20,
|
||||||
|
memoTxConcurrency: process.env.MEMO_TX_CONCURRENCY
|
||||||
|
? parseInt(process.env.MEMO_TX_CONCURRENCY)
|
||||||
|
: 20,
|
||||||
|
|
||||||
startBlockHeight: process.env.START_BLOCK_HEIGHT
|
startBlockHeight: process.env.START_BLOCK_HEIGHT
|
||||||
? parseInt(process.env.START_BLOCK_HEIGHT)
|
? parseInt(process.env.START_BLOCK_HEIGHT)
|
||||||
: 525000,
|
: 525000,
|
||||||
|
|||||||
@@ -60,8 +60,8 @@ Controllers
|
|||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
Use cases
|
Use cases
|
||||||
index-blocks.js — processBlock, processMemoTx
|
index-blocks.js — processBlock, processMemoTx (parallel per block)
|
||||||
filter-block.js — filterMemoTxs (parallel pre-check)
|
filter-block.js — filterMemoTxs (parallel pre-check, block order preserved)
|
||||||
state.js — synced block height
|
state.js — synced block height
|
||||||
action-types/*.js — per Memo action handlers
|
action-types/*.js — per Memo action handlers
|
||||||
utils.js
|
utils.js
|
||||||
@@ -157,7 +157,7 @@ Each store is a separate LevelDB with JSON values. Keys are txids, addresses, or
|
|||||||
| `zeromq` | Indexer | Subscribe to full node |
|
| `zeromq` | Indexer | Subscribe to full node |
|
||||||
| `@chris.troutner/retry-queue` | Indexer | Retry RPC on transient failure |
|
| `@chris.troutner/retry-queue` | Indexer | Retry RPC on transient failure |
|
||||||
| `axios` | Indexer | psf-memo-db REST client |
|
| `axios` | Indexer | psf-memo-db REST client |
|
||||||
| `p-queue` / `p-retry` | Indexer | Parallel block filter with retries |
|
| `p-queue` / `p-retry` | Indexer | Parallel block filter and parallel per-tx processing within a block |
|
||||||
| `express` | Indexer | TX indexer control API |
|
| `express` | Indexer | TX indexer control API |
|
||||||
| `level` | psf-memo-db | Embedded JSON LevelDB |
|
| `level` | psf-memo-db | Embedded JSON LevelDB |
|
||||||
| `koa` + `koa-router` | psf-memo-db | REST server |
|
| `koa` + `koa-router` | psf-memo-db | REST server |
|
||||||
|
|||||||
@@ -51,15 +51,21 @@ This document records **why** the Memo indexer stack looks the way it does, incl
|
|||||||
|
|
||||||
**Risk:** Indexer state diverges from memo.sv for excluded actions until handlers are added.
|
**Risk:** Indexer state diverges from memo.sv for excluded actions until handlers are added.
|
||||||
|
|
||||||
## 4. No DAG sort within blocks
|
## 4. Parallel processing within blocks (no DAG sort)
|
||||||
|
|
||||||
**Decision:** Process filtered Memo txids in block order, sequentially.
|
**Decision:** Filter and process Memo txs concurrently within a block using `p-queue`. Default concurrency is 20 for both phases (`FILTER_CONCURRENCY`, `MEMO_TX_CONCURRENCY`). Filter results are reordered to match block tx order before processing starts.
|
||||||
|
|
||||||
**SLP context:** SEND transactions may spend token outputs created earlier in the same block; DAG sort orders them correctly.
|
**SLP context:** SEND transactions may spend token outputs created earlier in the same block; DAG sort orders them correctly and writes must stay serial when UTXO state overlaps.
|
||||||
|
|
||||||
**Memo context:** Social actions do not consume each other’s UTXOs in a token graph. Replies reference parent txids by hash but do not require reordering spends within the block.
|
**Memo context:** Social actions do not consume each other’s UTXOs in a token graph. Most handler writes are keyed by `txid` and are independent across transactions. Parallel `processMemoTx` is safe for typical blocks.
|
||||||
|
|
||||||
**Tradeoff:** If future Memo actions introduce intra-block dependencies, DAG or topological sort may be needed—unlikely for current social ops.
|
**Soft ordering:** Likes that reference a post in the same block may compute `tip = 0` if the post handler has not finished yet. Replies still persist parent/child links and post bodies regardless of completion order. Profile/follow handlers keyed by address can race if the same signer emits multiple updates in one block (rare).
|
||||||
|
|
||||||
|
**Within a single tx:** Multiple Memo `OP_RETURN` outputs in one transaction are still dispatched sequentially inside `processMemoTx`.
|
||||||
|
|
||||||
|
**Tradeoff:** Higher throughput vs. HTTP contention on `psf-memo-db`. Lower `MEMO_TX_CONCURRENCY` if the DB service becomes the bottleneck.
|
||||||
|
|
||||||
|
**Alternative rejected:** Full serial processing—simple but leaves RPC/DB latency on the table during IBD when blocks contain many unrelated Memo txs.
|
||||||
|
|
||||||
## 5. Scan all transaction outputs for OP_RETURN
|
## 5. Scan all transaction outputs for OP_RETURN
|
||||||
|
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ sequenceDiagram
|
|||||||
loop each candidate txid
|
loop each candidate txid
|
||||||
FB->>TX: isMemoTx(txid)
|
FB->>TX: isMemoTx(txid)
|
||||||
end
|
end
|
||||||
loop each memo txid
|
loop each memo txid (parallel, up to MEMO_TX_CONCURRENCY)
|
||||||
BI->>BI: processMemoTx(txid)
|
BI->>BI: processMemoTx(txid)
|
||||||
BI->>DB: reads/writes via handlers
|
BI->>DB: reads/writes via handlers
|
||||||
end
|
end
|
||||||
@@ -71,11 +71,11 @@ sequenceDiagram
|
|||||||
|
|
||||||
**Step 1 — Fetch block:** `getblock` returns the list of transaction ids in the block (verbosity 1).
|
**Step 1 — Fetch block:** `getblock` returns the list of transaction ids in the block (verbosity 1).
|
||||||
|
|
||||||
**Step 2 — Filter:** `filterMemoTxs` runs up to 20 concurrent `isMemoTx` checks (`p-queue`). Each check loads the transaction (with in-memory cache in `transaction.js`) and scans **all outputs** for a Memo `OP_RETURN`.
|
**Step 2 — Filter:** `filterMemoTxs` runs up to `FILTER_CONCURRENCY` (default 20) concurrent `isMemoTx` checks (`p-queue`). Each check loads the transaction (with in-memory cache in `transaction.js`) and scans **all outputs** for a Memo `OP_RETURN`. Results are returned in **block tx order** (parallel checks use a set, then the original block list is filtered).
|
||||||
|
|
||||||
**Why scan all outputs?** Unlike SLP, which conventionally places token data in `vout[0]`, Memo actions may appear in any output’s `scriptPubKey`. The Go reference iterates every `TxOut`.
|
**Why scan all outputs?** Unlike SLP, which conventionally places token data in `vout[0]`, Memo actions may appear in any output’s `scriptPubKey`. The Go reference iterates every `TxOut`.
|
||||||
|
|
||||||
**Step 3 — Process:** For each Memo txid, `processMemoTx` runs sequentially within the block (no DAG sort—Memo social actions do not form token-like dependency chains within a block).
|
**Step 3 — Process:** `processMemoTxs` runs up to `MEMO_TX_CONCURRENCY` (default 20) concurrent `processMemoTx` calls via `p-queue`. There is no DAG sort—Memo social actions do not form token-like dependency chains within a block. Multiple OP_RETURN outputs in a **single** transaction are still handled sequentially inside `processMemoTx`.
|
||||||
|
|
||||||
## Phase 3: Processing a single Memo transaction
|
## Phase 3: Processing a single Memo transaction
|
||||||
|
|
||||||
|
|||||||
@@ -14,3 +14,5 @@ TX_REST_API_IP=172.17.0.1
|
|||||||
|
|
||||||
START_BLOCK_HEIGHT=525000
|
START_BLOCK_HEIGHT=525000
|
||||||
EXIT_ON_MISSING_BACKUP=false
|
EXIT_ON_MISSING_BACKUP=false
|
||||||
|
FILTER_CONCURRENCY=20
|
||||||
|
MEMO_TX_CONCURRENCY=20
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ async function start () {
|
|||||||
console.log('TX indexer started.')
|
console.log('TX indexer started.')
|
||||||
|
|
||||||
let loopCnt = 0
|
let loopCnt = 0
|
||||||
let liveStatus = status
|
const liveStatus = status
|
||||||
do {
|
do {
|
||||||
let blockHeight = await queue.addToQueue(adapters.rpc.getBlockCount, {})
|
let blockHeight = await queue.addToQueue(adapters.rpc.getBlockCount, {})
|
||||||
const block = adapters.zmq.getBlock()
|
const block = adapters.zmq.getBlock()
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
import PQueue from 'p-queue'
|
import PQueue from 'p-queue'
|
||||||
import pRetry from 'p-retry'
|
import pRetry from 'p-retry'
|
||||||
|
import config from '../../config/index.js'
|
||||||
|
|
||||||
class FilterBlock {
|
class FilterBlock {
|
||||||
constructor (localConfig = {}) {
|
constructor (localConfig = {}) {
|
||||||
@@ -27,17 +28,17 @@ class FilterBlock {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async filterMemoTxs (txids) {
|
async filterMemoTxs (txids) {
|
||||||
const memoTxs = []
|
const memoTxSet = new Set()
|
||||||
const tasks = txids.map((txid) => async () => {
|
const tasks = txids.map((txid) => async () => {
|
||||||
const isMemo = await this.retryWrapper(
|
const isMemo = await this.retryWrapper(
|
||||||
this.adapters.transaction.isMemoTx.bind(this.adapters.transaction),
|
this.adapters.transaction.isMemoTx.bind(this.adapters.transaction),
|
||||||
txid
|
txid
|
||||||
)
|
)
|
||||||
if (isMemo) memoTxs.push(txid)
|
if (isMemo) memoTxSet.add(txid)
|
||||||
})
|
})
|
||||||
|
|
||||||
await this.pQueue.addAll(tasks)
|
await this.pQueue.addAll(tasks)
|
||||||
return memoTxs
|
return txids.filter((txid) => memoTxSet.has(txid))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import RetryQueue from '@chris.troutner/retry-queue'
|
import RetryQueue from '@chris.troutner/retry-queue'
|
||||||
|
import PQueue from 'p-queue'
|
||||||
|
import config from '../../config/index.js'
|
||||||
import FilterBlock from './filter-block.js'
|
import FilterBlock from './filter-block.js'
|
||||||
import { findMemoOutputs, getSignerAddress } from '../lib/memo-parser.js'
|
import { findMemoOutputs, getSignerAddress } from '../lib/memo-parser.js'
|
||||||
import { dispatchMemoAction } from './action-types/index.js'
|
import { dispatchMemoAction } from './action-types/index.js'
|
||||||
@@ -15,6 +17,7 @@ class IndexBlocks {
|
|||||||
this.adapters = localConfig.adapters
|
this.adapters = localConfig.adapters
|
||||||
this.filterBlock = new FilterBlock({ adapters: this.adapters })
|
this.filterBlock = new FilterBlock({ adapters: this.adapters })
|
||||||
this.retryQueue = new RetryQueue()
|
this.retryQueue = new RetryQueue()
|
||||||
|
this.pQueue = new PQueue({ concurrency: config.memoTxConcurrency })
|
||||||
this.processBlock = this.processBlock.bind(this)
|
this.processBlock = this.processBlock.bind(this)
|
||||||
this.processMemoTx = this.processMemoTx.bind(this)
|
this.processMemoTx = this.processMemoTx.bind(this)
|
||||||
this.processMemoTxs = this.processMemoTxs.bind(this)
|
this.processMemoTxs = this.processMemoTxs.bind(this)
|
||||||
@@ -63,9 +66,10 @@ class IndexBlocks {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async processMemoTxs (txids, blockHeight) {
|
async processMemoTxs (txids, blockHeight) {
|
||||||
for (let i = 0; i < txids.length; i++) {
|
const tasks = txids.map((txid) => async () => {
|
||||||
await this.processMemoTx(txids[i], blockHeight)
|
await this.processMemoTx(txid, blockHeight)
|
||||||
}
|
})
|
||||||
|
await this.pQueue.addAll(tasks)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,10 +5,11 @@ import FilterBlock from '../../../src/use-cases/filter-block.js'
|
|||||||
describe('#FilterBlock', () => {
|
describe('#FilterBlock', () => {
|
||||||
let uut
|
let uut
|
||||||
let sandbox
|
let sandbox
|
||||||
|
let adapters
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
sandbox = sinon.createSandbox()
|
sandbox = sinon.createSandbox()
|
||||||
const adapters = {
|
adapters = {
|
||||||
transaction: {
|
transaction: {
|
||||||
isMemoTx: sandbox.stub()
|
isMemoTx: sandbox.stub()
|
||||||
}
|
}
|
||||||
@@ -26,4 +27,15 @@ describe('#FilterBlock', () => {
|
|||||||
const result = await uut.filterMemoTxs(['tx1', 'tx2', 'tx3'])
|
const result = await uut.filterMemoTxs(['tx1', 'tx2', 'tx3'])
|
||||||
assert.deepEqual(result, ['tx1', 'tx3'])
|
assert.deepEqual(result, ['tx1', 'tx3'])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should preserve block tx order when filtering in parallel', async () => {
|
||||||
|
adapters.transaction.isMemoTx.reset()
|
||||||
|
adapters.transaction.isMemoTx.callsFake(async (txid) => {
|
||||||
|
return txid === 'tx-a' || txid === 'tx-c' || txid === 'tx-e'
|
||||||
|
})
|
||||||
|
|
||||||
|
const blockOrder = ['tx-a', 'tx-b', 'tx-c', 'tx-d', 'tx-e']
|
||||||
|
const result = await uut.filterMemoTxs(blockOrder)
|
||||||
|
assert.deepEqual(result, ['tx-a', 'tx-c', 'tx-e'])
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { assert } from 'chai'
|
||||||
|
import sinon from 'sinon'
|
||||||
|
import IndexBlocks from '../../../src/use-cases/index-blocks.js'
|
||||||
|
|
||||||
|
describe('#IndexBlocks', () => {
|
||||||
|
let uut
|
||||||
|
let sandbox
|
||||||
|
let adapters
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
sandbox = sinon.createSandbox()
|
||||||
|
adapters = {
|
||||||
|
ptxDb: {
|
||||||
|
get: sandbox.stub().rejects(new Error('not found')),
|
||||||
|
create: sandbox.stub().resolves({ success: true })
|
||||||
|
},
|
||||||
|
processErrorDb: { create: sandbox.stub().resolves({ success: true }) },
|
||||||
|
transaction: {
|
||||||
|
get: sandbox.stub().resolves({
|
||||||
|
txid: 'tx1',
|
||||||
|
vin: [{ txid: 'prev', vout: 0, scriptSig: { hex: '471044...' } }],
|
||||||
|
vout: [{
|
||||||
|
scriptPubKey: {
|
||||||
|
hex: '6a046d020102',
|
||||||
|
addresses: ['bitcoincash:qptest']
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
uut = new IndexBlocks({ adapters })
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => sandbox.restore())
|
||||||
|
|
||||||
|
describe('#processMemoTxs', () => {
|
||||||
|
it('should process all memo txs in parallel', async () => {
|
||||||
|
const processMemoTx = sandbox.stub(uut, 'processMemoTx').resolves(true)
|
||||||
|
|
||||||
|
await uut.processMemoTxs(['tx-a', 'tx-b', 'tx-c'], 600000)
|
||||||
|
|
||||||
|
assert.equal(processMemoTx.callCount, 3)
|
||||||
|
assert.deepEqual(
|
||||||
|
processMemoTx.getCalls().map((call) => call.args[0]).sort(),
|
||||||
|
['tx-a', 'tx-b', 'tx-c']
|
||||||
|
)
|
||||||
|
processMemoTx.getCalls().forEach((call) => {
|
||||||
|
assert.equal(call.args[1], 600000)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should fail the block if any memo tx fails', async () => {
|
||||||
|
sandbox.stub(uut, 'processMemoTx')
|
||||||
|
.onFirstCall().resolves(true)
|
||||||
|
.onSecondCall().rejects(new Error('db error'))
|
||||||
|
.onThirdCall().resolves(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await uut.processMemoTxs(['tx-a', 'tx-b', 'tx-c'], 600000)
|
||||||
|
assert.fail('Expected processMemoTxs to throw')
|
||||||
|
} catch (err) {
|
||||||
|
assert.include(err.message, 'db error')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user