diff --git a/bchain/coins/tron/tronhttp.go b/bchain/coins/tron/tronhttp.go index 5172e37ce5..91d7258f82 100644 --- a/bchain/coins/tron/tronhttp.go +++ b/bchain/coins/tron/tronhttp.go @@ -5,10 +5,16 @@ import ( "context" "encoding/json" "fmt" + "io" "net/http" "time" ) +const ( + tronMaxIdleConnsPerHost = 64 + tronMaxIdleConns = 100 +) + type TronHTTP interface { Request(ctx context.Context, path string, reqBody interface{}, respBody interface{}) error } @@ -19,10 +25,15 @@ type TronHTTPClient struct { } func NewTronHTTPClient(baseURL string, timeout time.Duration) *TronHTTPClient { + transport := http.DefaultTransport.(*http.Transport).Clone() + transport.MaxIdleConns = tronMaxIdleConns + transport.MaxIdleConnsPerHost = tronMaxIdleConnsPerHost + return &TronHTTPClient{ baseURL: baseURL, httpClient: &http.Client{ - Timeout: timeout, + Timeout: timeout, + Transport: transport, }, } } @@ -43,7 +54,14 @@ func (c *TronHTTPClient) Request(ctx context.Context, path string, reqBody inter if err != nil { return fmt.Errorf("HTTP error calling Tron API %s: %w", path, err) } - defer resp.Body.Close() + // Drain the body before closing so net/http can return the connection to the + // idle pool for keep-alive reuse. Without this, the >=300 early return (which + // reads nothing) and any bytes the JSON decoder leaves unconsumed would force + // the connection closed, defeating the MaxIdleConns* pooling configured above. + defer func() { + io.Copy(io.Discard, resp.Body) + resp.Body.Close() + }() if resp.StatusCode >= 300 { return fmt.Errorf("Tron API returned status %d at path: %s %s", resp.StatusCode, c.baseURL, path) diff --git a/bchain/coins/tron/tronhttp_endpoints.go b/bchain/coins/tron/tronhttp_endpoints.go index 0e117033fe..983939225d 100644 --- a/bchain/coins/tron/tronhttp_endpoints.go +++ b/bchain/coins/tron/tronhttp_endpoints.go @@ -68,6 +68,7 @@ type tronGetBlockResponse struct { } type tronGetBlockHeaderResponse struct { + BlockID string `json:"blockID"` BlockHeader struct { RawData struct { Number *uint64 `json:"number"` @@ -467,6 +468,32 @@ func (b *TronRPC) requestBlockByID(ctx context.Context, blockHash string, isSoli return &resp, nil } +// requestBlockHashByNum resolves a block hash by height via the solidity node's +// /walletsolidity/getblock endpoint. It is only called for already-solidified +// heights (see GetBlockHash), so the lookup always targets the solidity node. +func (b *TronRPC) requestBlockHashByNum(ctx context.Context, blockNum uint32) (string, error) { + req := map[string]any{ + "id_or_num": strconv.FormatUint(uint64(blockNum), 10), + "detail": false, + } + http := b.getLookupHTTPClient(true) + var resp tronGetBlockHeaderResponse + if err := http.Request(ctx, "/walletsolidity/getblock", req, &resp); err != nil { + return "", err + } + if resp.BlockHeader.RawData.Number == nil { + return "", errors.Errorf("Tron getblock returned missing block_header.raw_data.number for height %d", blockNum) + } + if *resp.BlockHeader.RawData.Number != uint64(blockNum) { + return "", errors.Errorf("Tron getblock returned height %d, want %d", *resp.BlockHeader.RawData.Number, blockNum) + } + hash := strip0xPrefix(resp.BlockID) + if hash == "" { + return "", errors.Errorf("Tron getblock returned empty blockID for height %d", blockNum) + } + return hash, nil +} + func (b *TronRPC) requestLatestSolidifiedBlockHeight(ctx context.Context) (uint64, error) { http := b.solidityNodeHTTP if http == nil { diff --git a/bchain/coins/tron/tronrpc.go b/bchain/coins/tron/tronrpc.go index 0fc653ffdf..cd9299a208 100644 --- a/bchain/coins/tron/tronrpc.go +++ b/bchain/coins/tron/tronrpc.go @@ -287,6 +287,16 @@ func (b *TronRPC) GetBestBlockHash() (string, error) { // GetBlockHash returns block hash in Tron API format (without 0x prefix). func (b *TronRPC) GetBlockHash(height uint32) (string, error) { + if b.isBlockSolidified(uint64(height)) && b.solidityNodeHTTP != nil { + ctx, cancel := context.WithTimeout(b.requestContext(), b.Timeout) + defer cancel() + hash, err := b.requestBlockHashByNum(ctx, height) + if err == nil { + return hash, nil + } + glog.V(1).Infof("Tron native getblock hash lookup failed for height %d, falling back to JSON-RPC: %v", height, err) + } + hash, err := b.EthereumRPC.GetBlockHash(height) if err != nil { return "", err @@ -347,21 +357,6 @@ func (b *TronRPC) GetBlockInfo(hash string) (*bchain.BlockInfo, error) { } func (b *TronRPC) getBestHeader() (bchain.EVMHeader, error) { - // During initial sync (before ZeroMQ is initialized) there is no push-based - // tip refresh, so always read the latest header from the backend. - if b.mq == nil { - _, err := b.refreshBestHeaderFromChain() - if err != nil { - return nil, err - } - b.bestHeaderLock.Lock() - defer b.bestHeaderLock.Unlock() - if b.bestHeader == nil || b.bestHeader.Number() == nil { - return nil, errors.New("best header is nil") - } - return b.bestHeader, nil - } - b.bestHeaderLock.Lock() cachedHeader := b.bestHeader cachedAt := b.bestHeaderTime @@ -384,22 +379,6 @@ func (b *TronRPC) getBestHeader() (bchain.EVMHeader, error) { return b.bestHeader, nil } -// setBestHeader stores h as the cached tip and reports whether it changed. -// -// Unlike EthereumRPC.setBestHeader this is intentionally NON-monotonic: a lower -// height is accepted. Tron's tip is never taken from the feed header (the ZeroMQ -// notification carries none) — it is always an HTTP re-query (refreshBestHeaderFromChain), -// refreshed on every notification and on a tronBestHeaderMaxAge timer, so the cache -// is meant to track whatever the backend currently reports. Accepting a lower height -// is what lets a genuine rollback surface immediately to resyncIndex, so Tron is not -// subject to the frozen-tip masking that the EVM monotonic guard introduces (and which -// EVM has to undo with a watchdog regress). -// -// Tradeoff: with a load-balanced Tron RPC, a single lagging node answering a re-query -// could regress the tip and trip a spurious fork in resyncIndex (the case the EVM -// monotonic guard exists to prevent). That is acceptable for the common single-node -// java-tron backend; if Tron is ever fronted by a load balancer, port the EVM pattern -// here (monotonic hot path + on-advance liveness + allowRegress watchdog poll). func (b *TronRPC) setBestHeader(h bchain.EVMHeader) bool { if h == nil || h.Number() == nil { return false @@ -697,11 +676,11 @@ func (b *TronRPC) computeBlockConfirmations(blockNumber uint64) (uint32, error) return uint32(bestHeight - blockNumber + 1), nil } -func (b *TronRPC) buildTxFromHTTPData(txByID *tronGetTransactionByIDResponse, txInfo *tronGetTransactionInfoByIDResponse, blockTime int64, confirmations uint32, internalData *bchain.EthereumInternalData, isSolidified bool) (*bchain.Tx, error) { +func (b *TronRPC) buildTxFromHTTPData(txByID *tronGetTransactionByIDResponse, txInfo *tronGetTransactionInfoByIDResponse, blockTime int64, confirmations uint32, internalData *bchain.EthereumInternalData, keepReceipt bool) (*bchain.Tx, error) { csd := tronBuildEthereumSpecificData(txByID, txInfo) csd.InternalData = internalData - if !isSolidified { + if !keepReceipt { csd.Receipt = nil // set to nil so it can be considered as pending } @@ -948,7 +927,9 @@ func (b *TronRPC) GetBlock(hash string, height uint32) (*bchain.Block, error) { txInternalData = &internalData[i] } - rebuiltTx, err := b.buildTxFromHTTPData(txByID, txInfo, bbh.Time, confirmations, txInternalData, isSolidified) + // Keep receipts for block indexing even before solidity. If logs are dropped + // here, token-transfer participants never reach the address/contract indexes. + rebuiltTx, err := b.buildTxFromHTTPData(txByID, txInfo, bbh.Time, confirmations, txInternalData, true) if err != nil { return nil, err } diff --git a/bchain/coins/tron/txextra_test.go b/bchain/coins/tron/txextra_test.go index f7ed4de45a..64eca6721f 100644 --- a/bchain/coins/tron/txextra_test.go +++ b/bchain/coins/tron/txextra_test.go @@ -512,3 +512,60 @@ func TestTronBuildTxFromHTTPData_WithSynthesizedGenesisData(t *testing.T) { require.NotNil(t, receipt) require.Equal(t, "0x1", receipt.Status) } + +func TestTronBuildTxFromHTTPData_KeepReceiptControlsTokenLogs(t *testing.T) { + txByID := &tronGetTransactionByIDResponse{ + TxID: "b7a97862b0c719b714f0cf7e250ebc2dcdf1e5f05c54ddb21ff3a748c1aa45e4", + } + txByID.RawData.Contract = []tronTxContract{{ + Type: "TriggerSmartContract", + }} + txByID.RawData.Contract[0].Parameter.Value.OwnerAddress = "410746a05c314538e3e21faae3d702cc7939efc07a" + txByID.RawData.Contract[0].Parameter.Value.ContractAddress = "4139dd12a54e2bab7c82aa14a1e158b34263d2d510" + txByID.RawData.Contract[0].Parameter.Value.Data = "6f21b898" + + txInfo := &tronGetTransactionInfoByIDResponse{ + ID: txByID.TxID, + Log: []*bchain.RpcLog{ + { + Address: "a614f803b6fd780986a42c78ec9c7f77e6ded13c", + Data: "000000000000000000000000000000000000000000000000000000002d4cae00", + Topics: []string{ + "ddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", + "0000000000000000000000000746a05c314538e3e21faae3d702cc7939efc07a", + "000000000000000000000000f14cca91e8d5fff29cbcd42b26a19a7395b1aa84", + }, + }, + }, + } + txInfo.Receipt.Result = "SUCCESS" + + parser := NewTronParser(1, false) + tronRPC := &TronRPC{ + Parser: parser, + } + + pendingTx, err := tronRPC.buildTxFromHTTPData(txByID, txInfo, 0, 0, nil, false) + require.NoError(t, err) + pendingSpecific, ok := pendingTx.CoinSpecificData.(bchain.EthereumSpecificData) + require.True(t, ok) + require.Nil(t, pendingSpecific.Receipt) + pendingTransfers, err := parser.EthereumTypeGetTokenTransfersFromTx(pendingTx) + require.NoError(t, err) + require.Empty(t, pendingTransfers) + + indexedTx, err := tronRPC.buildTxFromHTTPData(txByID, txInfo, 0, 1, nil, true) + require.NoError(t, err) + indexedSpecific, ok := indexedTx.CoinSpecificData.(bchain.EthereumSpecificData) + require.True(t, ok) + require.NotNil(t, indexedSpecific.Receipt) + require.Len(t, indexedSpecific.Receipt.Logs, 1) + + indexedTransfers, err := parser.EthereumTypeGetTokenTransfersFromTx(indexedTx) + require.NoError(t, err) + require.Len(t, indexedTransfers, 1) + require.Equal(t, "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", indexedTransfers[0].Contract) + require.Equal(t, "TAdgLcNFZjeF1AspMobkz2bbxs5yDXpwYx", indexedTransfers[0].From) + require.Equal(t, "TXy5qqpAJNykdqsMU2dZM7B7mZbVF2izAN", indexedTransfers[0].To) + require.Equal(t, "760000000", indexedTransfers[0].Value.String()) +} diff --git a/docs/tron-config.md b/docs/tron-config.md index 8cfd24546c..107abf2634 100644 --- a/docs/tron-config.md +++ b/docs/tron-config.md @@ -61,7 +61,12 @@ Set explicit `tron_fullnode_http_url_template` and `tron_solidity_http_url_templ * the scheme differs from the JSON-RPC endpoint * the deployment uses non-standard ports +## Sync Performance Tuning + +For initial-sync tuning notes, see [Tron Sync Tuning](/docs/tron-sync-tuning.md). + ## Related Docs * [Config](/docs/config.md) +* [Tron Sync Tuning](/docs/tron-sync-tuning.md) * [Testing](/docs/testing.md) diff --git a/docs/tron-sync-tuning.md b/docs/tron-sync-tuning.md new file mode 100644 index 0000000000..33d3104f49 --- /dev/null +++ b/docs/tron-sync-tuning.md @@ -0,0 +1,78 @@ +# Tron Sync Tuning + +Fast Tron initial sync needs tuning on both sides of the RPC boundary. In the +observed setup, the bottleneck was backend block-hash/block lookup latency, not +Blockbook Go GC or RocksDB writes. + +## java-tron + +Keep the backend on LevelDB, keep transaction history enabled, enable the LevelDB read tuning and raise the +HTTP connection limit enough for Blockbook's worker count: + +```hocon +storage { + db.engine = "LEVELDB" + db.sync = false + transHistory.switch = "on" + + # LevelDB read tuning from Tron TIP-343. Raises fd usage. + default = { + maxOpenFiles = 100 + } + defaultM = { + maxOpenFiles = 500 + } + defaultL = { + maxOpenFiles = 1000 + } +} + +node { + # With Blockbook -workers=32, start around 200. + maxHttpConnectNumber = 200 +} +``` + +Run java-tron with enough heap, but leave RAM for the OS page cache. On large +hosts, `-Xms32g -Xmx32g` with G1GC is a good starting point. Validate with GC +logs and disk latency; a larger heap is not automatically faster. + +Keep event subscription minimal for Blockbook sync: + +```hocon +event.subscribe { + topics = [ + { triggerName = "block", enable = true, topic = "block", solidified = false }, + { triggerName = "solidity", enable = true, topic = "solidity" } + ] +} +``` + +Do not enable `transaction`, `contractevent`, or `contractlog` topics just for +Blockbook initial sync. + +## Blockbook + +Run initial sync with enough workers to keep java-tron busy: + +```ini +ExecStart=.../blockbook ... -sync -workers=32 ... +``` + +Pair this with a sufficiently large Tron HTTP connection pool in Blockbook and +`node.maxHttpConnectNumber` in java-tron. Raising workers without raising the +backend connection limit just moves the queue. + +## Verify + +Useful indicators while syncing: + +* Blockbook log `elapsed` per 1000 blocks +* `blockbook_rpc_latency{method="GetBlockHash"}` and `method="GetBlock"`, increased latency means the bottleneck is on the Java-Tron side +* java-tron GC pauses and heap after GC +* disk utilization and await, for example `iostat -xz 1` +* open file descriptors for both services +* `blockbook_index_block_not_found_retries` + +With the tuning above and native hash lookup, observed throughput reached about +7.5-9.5 seconds per 1000 historical blocks on the tested setup.