From 1eabc70fdb068438e5589cab820be1d1c806a3a9 Mon Sep 17 00:00:00 2001 From: vietddude Date: Wed, 2 Sep 2026 23:15:53 +0700 Subject: [PATCH 01/11] perf(solana): use json encoding for getBlock instead of jsonParsed jsonParsed is the heaviest getBlock encoding (~35-40% larger payload and higher server-side cost). Benchmarks against public mainnet RPC show json cuts both latency and bandwidth substantially, improving real-time indexing throughput so Solana keeps up with slot production instead of falling behind into catchup. Switching encoding requires two adjustments the RPC used to do for us: - AccountKey now unmarshals from both a bare pubkey string (json) and the { pubkey, signer, writable } object (jsonParsed). - Versioned (v0) transactions deliver Address Lookup Table accounts in meta.loadedAddresses rather than merged into message.accountKeys. Index resolution now appends them as static keys + loaded writable + loaded readonly, matching the order the RPC produces under jsonParsed. --- internal/indexer/solana.go | 24 +++++++++++++++++++++- internal/indexer/solana_test.go | 30 ++++++++++++++++++++++++++++ internal/rpc/solana/client.go | 6 +++++- internal/rpc/solana/types.go | 35 +++++++++++++++++++++++++++++++++ 4 files changed, 93 insertions(+), 2 deletions(-) diff --git a/internal/indexer/solana.go b/internal/indexer/solana.go index 979d572..cc36c7e 100644 --- a/internal/indexer/solana.go +++ b/internal/indexer/solana.go @@ -464,6 +464,28 @@ func solanaParseTokenTransfer(ix solana.Instruction, accountKeys []solana.Accoun } } +// solanaEffectiveAccountKeys returns the full ordered account list used to +// resolve instruction/token-balance indices. With encoding=json, a versioned +// (v0) transaction's Address Lookup Table accounts are delivered separately in +// meta.loadedAddresses rather than in message.accountKeys, and instruction and +// token-balance indices point into static keys followed by the loaded writable +// then loaded readonly accounts. With encoding=jsonParsed loaded is empty +// (already merged), so the static keys are returned unchanged. +func solanaEffectiveAccountKeys(static []solana.AccountKey, loaded *solana.LoadedAddresses) []solana.AccountKey { + if loaded == nil || (len(loaded.Writable) == 0 && len(loaded.Readonly) == 0) { + return static + } + out := make([]solana.AccountKey, 0, len(static)+len(loaded.Writable)+len(loaded.Readonly)) + out = append(out, static...) + for _, pk := range loaded.Writable { + out = append(out, solana.AccountKey{Pubkey: pk, Writable: true}) + } + for _, pk := range loaded.Readonly { + out = append(out, solana.AccountKey{Pubkey: pk}) + } + return out +} + func (s *SolanaIndexer) extractSolanaTransfers(networkID string, slot uint64, ts uint64, b *solana.GetBlockResult) []types.Transaction { out := make([]types.Transaction, 0) for txIdx, tx := range b.Transactions { @@ -478,7 +500,7 @@ func (s *SolanaIndexer) extractSolanaTransfers(networkID string, slot uint64, ts } txHash := tx.Transaction.Signatures[0] fee := decimal.NewFromInt(int64(tx.Meta.Fee)) - accountKeys := tx.Transaction.Message.AccountKeys + accountKeys := solanaEffectiveAccountKeys(tx.Transaction.Message.AccountKeys, tx.Meta.LoadedAddresses) // Build token-account -> (owner, mint) lookup from token balance metadata. // This isn't used to infer transfers; only to map SPL token accounts to owners/mints. diff --git a/internal/indexer/solana_test.go b/internal/indexer/solana_test.go index 91c455c..13d1f6a 100644 --- a/internal/indexer/solana_test.go +++ b/internal/indexer/solana_test.go @@ -370,3 +370,33 @@ func TestParseSquadsMultisigTransfer(t *testing.T) { tokenTransfer.FromAddress, tokenTransfer.ToAddress, tokenTransfer.Amount, tokenTransfer.AssetAddress) } + +// TestSolanaEffectiveAccountKeys verifies that with encoding=json a versioned +// (v0) transaction's Address Lookup Table accounts are appended in the correct +// order: static keys, then loaded writable, then loaded readonly. This ordering +// matches the account list the RPC merges into accountKeys under jsonParsed and +// is what instruction / token-balance indices point into. +func TestSolanaEffectiveAccountKeys(t *testing.T) { + static := []solana.AccountKey{{Pubkey: "S0"}, {Pubkey: "S1"}} + + // No loaded addresses: returns the static slice unchanged (jsonParsed path). + assert.Equal(t, static, solanaEffectiveAccountKeys(static, nil)) + assert.Equal(t, static, solanaEffectiveAccountKeys(static, &solana.LoadedAddresses{})) + + loaded := &solana.LoadedAddresses{ + Writable: []string{"W0", "W1"}, + Readonly: []string{"R0"}, + } + got := solanaEffectiveAccountKeys(static, loaded) + require.Len(t, got, 5) + + pubkeys := make([]string, len(got)) + for i, k := range got { + pubkeys[i] = k.Pubkey + } + assert.Equal(t, []string{"S0", "S1", "W0", "W1", "R0"}, pubkeys, + "order must be static + loaded writable + loaded readonly") + assert.True(t, got[2].Writable, "loaded writable accounts must be marked writable") + assert.True(t, got[3].Writable) + assert.False(t, got[4].Writable, "loaded readonly accounts must not be writable") +} diff --git a/internal/rpc/solana/client.go b/internal/rpc/solana/client.go index e919d2c..9808bc5 100644 --- a/internal/rpc/solana/client.go +++ b/internal/rpc/solana/client.go @@ -74,8 +74,12 @@ func (c *Client) GetTransaction(ctx context.Context, signature string) (*GetTran } func (c *Client) GetBlock(ctx context.Context, slot uint64) (*GetBlockResult, error) { + // encoding=json is ~35-40% smaller/faster than jsonParsed for full blocks. + // The transfer parser resolves instructions from account indices + base58 + // instruction data (see extractSolanaTransfers), and appends meta.loadedAddresses + // so versioned (v0) transactions still resolve correctly. cfg := GetBlockConfig{ - Encoding: "jsonParsed", + Encoding: "json", TransactionDetails: "full", Rewards: false, MaxSupportedTransactionVersion: 0, diff --git a/internal/rpc/solana/types.go b/internal/rpc/solana/types.go index 8db5c51..aa43c4d 100644 --- a/internal/rpc/solana/types.go +++ b/internal/rpc/solana/types.go @@ -1,5 +1,7 @@ package solana +import "encoding/json" + // Minimal JSON-RPC types for Solana getBlock / getSlot. type jsonRPCRequest struct { @@ -52,6 +54,18 @@ type TxnMeta struct { PreTokenBalances []TokenBalance `json:"preTokenBalances"` PostTokenBalances []TokenBalance `json:"postTokenBalances"` InnerInstructions []InnerInstruction `json:"innerInstructions"` + // LoadedAddresses carries the accounts a versioned (v0) transaction pulls in + // via Address Lookup Tables. With encoding=json these are NOT included in + // message.accountKeys, so the full account list used for index resolution is + // static accountKeys + Writable + Readonly (in that order). With + // encoding=jsonParsed the RPC already merges them into accountKeys and this + // field is empty. + LoadedAddresses *LoadedAddresses `json:"loadedAddresses"` +} + +type LoadedAddresses struct { + Writable []string `json:"writable"` + Readonly []string `json:"readonly"` } type InnerInstruction struct { @@ -90,6 +104,27 @@ type AccountKey struct { Writable bool `json:"writable"` } +// UnmarshalJSON accepts both encodings of message.accountKeys: +// - encoding=json: a bare base58 pubkey string +// - encoding=jsonParsed: an object { pubkey, signer, source, writable } +func (a *AccountKey) UnmarshalJSON(data []byte) error { + if len(data) > 0 && data[0] == '"' { + var pubkey string + if err := json.Unmarshal(data, &pubkey); err != nil { + return err + } + a.Pubkey = pubkey + return nil + } + type alias AccountKey + var v alias + if err := json.Unmarshal(data, &v); err != nil { + return err + } + *a = AccountKey(v) + return nil +} + type Instruction struct { ProgramIdIndex uint64 `json:"programIdIndex"` Accounts any `json:"accounts"` From 041835645a48b1506c14acaec2c8fd5964432063 Mon Sep 17 00:00:00 2001 From: vietddude Date: Wed, 2 Sep 2026 23:21:34 +0700 Subject: [PATCH 02/11] fix(solana): treat skipped slots as advanced-past in the regular worker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Solana produces skipped slots that will never have a block. The catchup and rescanner paths already recognise these, but the real-time regular path ran them through handleBlockResult, which persisted each skipped slot as a failed block and pushed it to the rescanner — costing an extra getBlock per skip just to confirm the skip, exactly when block production is fastest. Detect ErrorTypeBlockNotFound on Solana in the non-reorg batch loop, advance currentBlock past the slot, and notify the observer as not-found instead of failed. --- internal/worker/regular.go | 18 +++++++++++++++ internal/worker/regular_test.go | 40 +++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/internal/worker/regular.go b/internal/worker/regular.go index ec93d59..4edaa12 100644 --- a/internal/worker/regular.go +++ b/internal/worker/regular.go @@ -165,6 +165,16 @@ func (rw *RegularWorker) processBatch( } for _, res := range results { + // Solana produces skipped slots that will never have a block. Treat them + // as advanced-past rather than failed so we don't persist them as failed + // blocks and waste an extra getBlock in the rescanner confirming the skip. + if rw.isSolanaSkippedSlot(res) { + rw.notifyObserver(res.Number, BlockStatusNotFound) + if res.Number > lastSuccess { + lastSuccess = res.Number + } + continue + } if rw.handleBlockResult(res) { lastSuccess = res.Number lastSuccessHash = res.Block.Hash @@ -173,6 +183,14 @@ func (rw *RegularWorker) processBatch( return lastSuccess, lastSuccessHash, false, nil } +// isSolanaSkippedSlot reports whether a block result is a Solana skipped slot, +// which is normal on Solana and must not be treated as a failed block. +func (rw *RegularWorker) isSolanaSkippedSlot(res indexer.BlockResult) bool { + return res.Error != nil && + res.Error.ErrorType == indexer.ErrorTypeBlockNotFound && + rw.chain.GetNetworkType() == enum.NetworkTypeSol +} + // commitProgress advances currentBlock past the last indexed block, persisting // the checkpoint and its hash. Returns the indexing timestamp, or zero if no new // block was indexed. diff --git a/internal/worker/regular_test.go b/internal/worker/regular_test.go index 8852084..222c587 100644 --- a/internal/worker/regular_test.go +++ b/internal/worker/regular_test.go @@ -119,6 +119,46 @@ func TestRegularWorkerProcessRegularBlocksMarksUnresolvedGapFailed(t *testing.T) require.Equal(t, []uint64{100, 100}, chain.getBlockCalls) } +func TestRegularWorkerProcessRegularBlocksSkipsSolanaSkippedSlot(t *testing.T) { + t.Parallel() + + chain := &stubIndexer{ + name: "solana", + internalCode: "sol", + networkType: enum.NetworkTypeSol, + latest: 102, + getBlocksFunc: func(context.Context, uint64, uint64, bool) ([]indexer.BlockResult, error) { + return []indexer.BlockResult{ + { + Number: 100, + Block: &types.Block{Number: 100, Hash: "h100", ParentHash: "h099"}, + }, + { + // Skipped slot: normal on Solana, must not be marked failed. + Number: 101, + Error: &indexer.Error{ErrorType: indexer.ErrorTypeBlockNotFound, Message: "block not found (skipped slot?)"}, + }, + { + Number: 102, + Block: &types.Block{Number: 102, Hash: "h102", ParentHash: "h101"}, + }, + }, nil + }, + } + store := &stubBlockStore{} + rw := newTestRegularWorker(chain, store, 100, 3) + + err := rw.processRegularBlocks() + require.NoError(t, err) + // currentBlock advances past the skipped slot to the next unindexed slot. + require.Equal(t, uint64(103), rw.currentBlock) + require.Equal(t, []uint64{102}, store.savedLatest) + // The skipped slot must NOT be persisted as a failed block. + require.Empty(t, store.failedBlocks) + // No single-block recovery is attempted for a skipped slot. + require.Empty(t, chain.getBlockCalls) +} + func TestBaseWorkerExecuteRecoverableConvertsPanicToError(t *testing.T) { t.Parallel() From 7e0a47be7ec0b9ef775e96d9ee429f76d6ff55f2 Mon Sep 17 00:00:00 2001 From: vietddude Date: Wed, 2 Sep 2026 23:21:34 +0700 Subject: [PATCH 03/11] feat(rpc): rotate away from slow-but-successful providers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The failover only penalised latency on the error path (analyzeError's >3s check). A provider that returns successfully but slowly — an overloaded free RPC responding in seconds without ever erroring — was never rotated away, so it kept dragging down real-time throughput. Add a success-path latency check in executeCore: a call slower than SlowResponseThreshold blacklists the provider for SlowResponseCooldown so the next call prefers a faster one. It never drops the available pool below MinActiveProviders, so when every provider is slow we keep using them. Both thresholds are config fields (defaulting to the previous hard-coded 3s / 2m), and analyzeError now reads the same values. --- internal/rpc/failover.go | 74 +++++++++++++++++++++++++++++++---- internal/rpc/failover_test.go | 72 ++++++++++++++++++++++++++++++++++ 2 files changed, 138 insertions(+), 8 deletions(-) diff --git a/internal/rpc/failover.go b/internal/rpc/failover.go index 2904c72..6844e3a 100644 --- a/internal/rpc/failover.go +++ b/internal/rpc/failover.go @@ -21,16 +21,26 @@ type FailoverConfig struct { ErrorThreshold int ForceRotateThreshold int DefaultTimeout time.Duration + // SlowResponseThreshold is the latency above which a call is considered slow. + // It rotates away a provider that is slow even when it returns successfully + // (e.g. an overloaded free RPC that never errors but responds in seconds). + // The same threshold classifies a slow error response. + SlowResponseThreshold time.Duration + // SlowResponseCooldown is how long a provider stays blacklisted after being + // flagged slow, before it is retried. + SlowResponseCooldown time.Duration } func DefaultFailoverConfig() FailoverConfig { return FailoverConfig{ - HealthCheckInterval: 30 * time.Second, - EnableBlacklisting: true, - MinActiveProviders: 2, - ErrorThreshold: 5, - ForceRotateThreshold: 3, - DefaultTimeout: 10 * time.Second, + HealthCheckInterval: 30 * time.Second, + EnableBlacklisting: true, + MinActiveProviders: 2, + ErrorThreshold: 5, + ForceRotateThreshold: 3, + DefaultTimeout: 10 * time.Second, + SlowResponseThreshold: 3 * time.Second, + SlowResponseCooldown: 2 * time.Minute, } } @@ -202,6 +212,12 @@ func NewFailover[T NetworkClient](config *FailoverConfig) *Failover[T] { if config.ForceRotateThreshold <= 0 { config.ForceRotateThreshold = DefaultFailoverConfig().ForceRotateThreshold } + if config.SlowResponseThreshold <= 0 { + config.SlowResponseThreshold = DefaultFailoverConfig().SlowResponseThreshold + } + if config.SlowResponseCooldown <= 0 { + config.SlowResponseCooldown = DefaultFailoverConfig().SlowResponseCooldown + } return &Failover[T]{ providers: make([]*Provider, 0), currentIndex: -1, @@ -414,9 +430,51 @@ func (f *Failover[T]) executeCore(ctx context.Context, provider *Provider, fn fu f.metrics.IncrementSuccess() provider.Success(elapsed) + f.evaluateSlowSuccess(provider, elapsed) return nil } +// evaluateSlowSuccess rotates away from a provider that returns successfully but +// too slowly, so the next call prefers a faster one. This covers overloaded free +// RPCs that respond in seconds without ever erroring — a case the error-path +// analysis never sees. It never drops the available pool below MinActiveProviders, +// so when every provider is slow we keep using them rather than starving. +func (f *Failover[T]) evaluateSlowSuccess(provider *Provider, elapsed time.Duration) { + if !f.config.EnableBlacklisting || f.config.SlowResponseThreshold <= 0 { + return + } + if elapsed <= f.config.SlowResponseThreshold { + return + } + if len(f.GetAvailableProviders()) <= f.config.MinActiveProviders { + if f.logThrottler.ShouldLog(fmt.Sprintf("slow_success_min_%s", provider.Name)) { + logger.Warn("Provider slow but kept to preserve minimum active providers", + "provider", provider.Name, + "latency_ms", elapsed.Milliseconds(), + "threshold_ms", f.config.SlowResponseThreshold.Milliseconds(), + "min_active", f.config.MinActiveProviders, + ) + } + return + } + + if f.logThrottler.ShouldLog(fmt.Sprintf("slow_success_%s", provider.Name)) { + provider.mu.RLock() + providerURL := provider.URL + provider.mu.RUnlock() + logger.Warn("Blacklisting slow provider on successful-but-slow response", + "provider", provider.Name, + "url", providerURL, + "latency_ms", elapsed.Milliseconds(), + "threshold_ms", f.config.SlowResponseThreshold.Milliseconds(), + "cooldown", f.config.SlowResponseCooldown, + ) + } + provider.Blacklist(f.config.SlowResponseCooldown) + f.metrics.IncrementBlacklist() + f.metrics.IncrementErrorType("slow_response") +} + // handleUnhealthyProvider marks provider as unhealthy and blacklists it func (f *Failover[T]) handleUnhealthyProvider(provider *Provider, issue ProviderIssue) { logKey := fmt.Sprintf("switch_%s", provider.Name) @@ -692,9 +750,9 @@ func (f *Failover[T]) analyzeError(err error, elapsed time.Duration) ProviderIss } // Check for slow response - if elapsed > 3*time.Second { + if f.config.SlowResponseThreshold > 0 && elapsed > f.config.SlowResponseThreshold { issue.Reason = "slow_response" - issue.Cooldown = 2 * time.Minute + issue.Cooldown = f.config.SlowResponseCooldown issue.MarkUnhealthy = true } diff --git a/internal/rpc/failover_test.go b/internal/rpc/failover_test.go index 0774374..422b5bd 100644 --- a/internal/rpc/failover_test.go +++ b/internal/rpc/failover_test.go @@ -228,6 +228,78 @@ func TestExecuteCore_GenericErrorsForceRotateToHealthySibling(t *testing.T) { assert.Equal(t, int64(1), metrics["provider_switches"]) } +func TestExecuteCore_SlowSuccessBlacklistsProvider(t *testing.T) { + cfg := DefaultFailoverConfig() + cfg.SlowResponseThreshold = 10 * time.Millisecond + cfg.SlowResponseCooldown = time.Minute + cfg.MinActiveProviders = 2 + f := NewFailover[NetworkClient](&cfg) + + // Three providers so blacklisting one still leaves >= MinActiveProviders. + first := newTestProvider("first") + require.NoError(t, f.AddProvider(first)) + require.NoError(t, f.AddProvider(newTestProvider("second"))) + require.NoError(t, f.AddProvider(newTestProvider("third"))) + + err := f.executeCore(context.Background(), first, func(NetworkClient) error { + time.Sleep(30 * time.Millisecond) // exceeds SlowResponseThreshold + return nil + }) + require.NoError(t, err) + + assert.False(t, first.IsAvailable(), "slow-but-successful provider should be blacklisted") + + metrics := f.GetMetrics() + assert.Equal(t, int64(1), metrics["blacklist_events"]) + errorsByType := metrics["errors_by_type"].(map[string]int64) + assert.Equal(t, int64(1), errorsByType["slow_response"]) + + got, err := f.GetBestProvider() + require.NoError(t, err) + assert.NotEqual(t, first.Name, got.Name, "should rotate off the slow provider") +} + +func TestExecuteCore_SlowSuccessKeepsProviderWhenPoolAtMinimum(t *testing.T) { + cfg := DefaultFailoverConfig() + cfg.SlowResponseThreshold = 10 * time.Millisecond + cfg.SlowResponseCooldown = time.Minute + cfg.MinActiveProviders = 2 + f := NewFailover[NetworkClient](&cfg) + + // Only MinActiveProviders providers: blacklisting would starve the pool. + first := newTestProvider("first") + require.NoError(t, f.AddProvider(first)) + require.NoError(t, f.AddProvider(newTestProvider("second"))) + + err := f.executeCore(context.Background(), first, func(NetworkClient) error { + time.Sleep(30 * time.Millisecond) + return nil + }) + require.NoError(t, err) + + assert.True(t, first.IsAvailable(), "must keep slow provider to preserve minimum active pool") + assert.Equal(t, int64(0), f.GetMetrics()["blacklist_events"]) +} + +func TestExecuteCore_FastSuccessDoesNotBlacklist(t *testing.T) { + cfg := DefaultFailoverConfig() + cfg.SlowResponseThreshold = 500 * time.Millisecond + f := NewFailover[NetworkClient](&cfg) + + first := newTestProvider("first") + require.NoError(t, f.AddProvider(first)) + require.NoError(t, f.AddProvider(newTestProvider("second"))) + require.NoError(t, f.AddProvider(newTestProvider("third"))) + + err := f.executeCore(context.Background(), first, func(NetworkClient) error { + return nil // fast + }) + require.NoError(t, err) + + assert.True(t, first.IsAvailable()) + assert.Equal(t, int64(0), f.GetMetrics()["blacklist_events"]) +} + func TestExecuteCore_TransientGenericErrorsDoNotForceRotate(t *testing.T) { cfg := DefaultFailoverConfig() cfg.ForceRotateThreshold = 3 From 5bdbb5bc9a816d6f99404176a48403e2f3c6de1d Mon Sep 17 00:00:00 2001 From: vietddude Date: Wed, 2 Sep 2026 23:33:57 +0700 Subject: [PATCH 04/11] refactor(config): move failover config out of yaml into code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The defaults.failover yaml block was dead configuration: the Failover field lived only on the Defaults struct, was never merged into ChainConfig, and every NewFailover call passed nil — so rpc.DefaultFailoverConfig() was always the effective source. Remove the unused field and the yaml block so failover tuning lives in one place (code) instead of misleading knobs operators can set with no effect. --- pkg/common/config/types.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/pkg/common/config/types.go b/pkg/common/config/types.go index fd41a7c..1d6ff0f 100644 --- a/pkg/common/config/types.go +++ b/pkg/common/config/types.go @@ -3,7 +3,6 @@ package config import ( "time" - "github.com/fystack/multichain-indexer/internal/rpc" "github.com/fystack/multichain-indexer/pkg/common/enum" ) @@ -36,7 +35,6 @@ type Defaults struct { Status StatusConfig `yaml:"status"` Client ClientConfig `yaml:"client"` Throttle Throttle `yaml:"throttle"` - Failover rpc.FailoverConfig `yaml:"failover"` } type Chains map[string]ChainConfig From ce238750c4dfad7bc072e02d048b39838184c443 Mon Sep 17 00:00:00 2001 From: vietddude Date: Wed, 2 Sep 2026 23:59:07 +0700 Subject: [PATCH 05/11] feat(solana): adaptive getBlock concurrency (AIMD congestion control) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the static per-batch semaphore in GetBlocksByNumbers with a shared AIMD concurrency limiter. The configured throttle.concurrency becomes a ceiling: the limiter multiplicatively backs off when getBlock calls get slow (>2.5s) or fail, and additively recovers toward the ceiling once calls settle (<1.2s). It never exceeds the ceiling, so it can only match or beat the previous static behaviour. Because the Solana indexer is built once and shared across all worker modes, the limiter is a single global congestion controller for the chain's getBlock load — replacing the previous uncoordinated per-call semaphores (regular + catchup + rescanner each had their own). This adapts call pressure to real RPC capacity instead of a fixed number, avoiding the rate-limit death spiral on overloaded free nodes while still using full concurrency when they are healthy. New pkg/adaptive holds the reusable limiter with full unit + race coverage. --- internal/indexer/solana.go | 45 ++++++--- internal/indexer/solana_test.go | 2 + pkg/adaptive/limiter.go | 165 ++++++++++++++++++++++++++++++++ pkg/adaptive/limiter_test.go | 163 +++++++++++++++++++++++++++++++ 4 files changed, 363 insertions(+), 12 deletions(-) create mode 100644 pkg/adaptive/limiter.go create mode 100644 pkg/adaptive/limiter_test.go diff --git a/internal/indexer/solana.go b/internal/indexer/solana.go index cc36c7e..e06478b 100644 --- a/internal/indexer/solana.go +++ b/internal/indexer/solana.go @@ -10,6 +10,7 @@ import ( "github.com/fystack/multichain-indexer/internal/rpc" "github.com/fystack/multichain-indexer/internal/rpc/solana" + "github.com/fystack/multichain-indexer/pkg/adaptive" "github.com/fystack/multichain-indexer/pkg/common/config" "github.com/fystack/multichain-indexer/pkg/common/constant" "github.com/fystack/multichain-indexer/pkg/common/enum" @@ -25,6 +26,10 @@ type SolanaIndexer struct { config config.ChainConfig failover *rpc.Failover[solana.SolanaAPI] pubkeyStore PubkeyStore + // limiter adapts getBlock concurrency to observed RPC latency/errors. It is + // shared across all worker modes for this chain (the indexer is built once), + // so it is a single global congestion controller for the chain's getBlock load. + limiter *adaptive.Limiter } func NewSolanaIndexer( @@ -33,7 +38,27 @@ func NewSolanaIndexer( failover *rpc.Failover[solana.SolanaAPI], pubkeyStore PubkeyStore, ) *SolanaIndexer { - return &SolanaIndexer{chainName: chainName, config: cfg, failover: failover, pubkeyStore: pubkeyStore} + maxConc := cfg.Throttle.Concurrency + if maxConc <= 0 { + maxConc = 1 + } + limiter := adaptive.New(adaptive.Config{ + Max: maxConc, + Min: 1, + // Healthy Solana getBlock (json, full) is ~0.6-1s; treat >2.5s as + // congestion and only grow back when calls settle under ~1.2s. + LowLatency: 1200 * time.Millisecond, + HighLatency: 2500 * time.Millisecond, + AdjustInterval: time.Second, + GrowStreak: 10, + }) + return &SolanaIndexer{ + chainName: chainName, + config: cfg, + failover: failover, + pubkeyStore: pubkeyStore, + limiter: limiter, + } } func (s *SolanaIndexer) GetName() string { return strings.ToUpper(s.chainName) } @@ -150,36 +175,32 @@ func (s *SolanaIndexer) GetBlocksByNumbers(ctx context.Context, blockNumbers []u return []BlockResult{}, nil } - maxConc := s.config.Throttle.Concurrency - if maxConc <= 0 { - maxConc = 1 - } - results := make([]BlockResult, len(blockNumbers)) eg, egCtx := errgroup.WithContext(ctx) - sem := make(chan struct{}, maxConc) for i, slot := range blockNumbers { i := i slot := slot eg.Go(func() error { - select { - case sem <- struct{}{}: - defer func() { <-sem }() - case <-egCtx.Done(): - return egCtx.Err() + if err := s.limiter.Acquire(egCtx); err != nil { + return err } + defer s.limiter.Release() var ( b *solana.GetBlockResult berr error ) + fetchStart := time.Now() berr = s.failover.ExecuteWithRetry(egCtx, func(c solana.SolanaAPI) error { blk, err := c.GetBlock(egCtx, slot) b = blk return err }) + // Feed latency/outcome back into the concurrency controller so it + // backs off when the RPC is slow or failing and recovers when fast. + s.limiter.Observe(time.Since(fetchStart), berr == nil) if berr != nil { results[i] = BlockResult{Number: slot, Error: &Error{ErrorType: ErrorTypeUnknown, Message: berr.Error()}} diff --git a/internal/indexer/solana_test.go b/internal/indexer/solana_test.go index 13d1f6a..945b70d 100644 --- a/internal/indexer/solana_test.go +++ b/internal/indexer/solana_test.go @@ -6,6 +6,7 @@ import ( "time" "github.com/fystack/multichain-indexer/internal/rpc/solana" + "github.com/fystack/multichain-indexer/pkg/adaptive" "github.com/fystack/multichain-indexer/pkg/common/config" "github.com/fystack/multichain-indexer/pkg/common/constant" "github.com/fystack/multichain-indexer/pkg/common/types" @@ -24,6 +25,7 @@ func newTestSolanaIndexer() *SolanaIndexer { chainName: "solana", config: config.ChainConfig{NetworkId: "solana-mainnet"}, pubkeyStore: nil, // no filtering + limiter: adaptive.New(adaptive.Config{Max: 4}), } } diff --git a/pkg/adaptive/limiter.go b/pkg/adaptive/limiter.go new file mode 100644 index 0000000..8e20b5d --- /dev/null +++ b/pkg/adaptive/limiter.go @@ -0,0 +1,165 @@ +// Package adaptive provides an AIMD (additive-increase / multiplicative-decrease) +// concurrency limiter. It adapts how many operations may run in parallel based on +// observed latency and errors — a congestion-control loop for RPC calls. +// +// The configured concurrency acts as a ceiling: the limiter multiplicatively +// backs off when calls get slow or fail (an overloaded / rate-limited RPC), and +// additively recovers back toward the ceiling once calls are fast again. It never +// exceeds the ceiling, so it can only ever do the same or better than a static +// semaphore of the same size. +package adaptive + +import ( + "context" + "sync" + "time" +) + +// Config tunes the limiter. Zero values are replaced with sane defaults. +type Config struct { + Min int // floor for concurrency (clamped to >= 1) + Max int // ceiling for concurrency (the configured concurrency) + Start int // initial limit (defaults to Max) + HighLatency time.Duration // a success at/above this latency counts as congestion + LowLatency time.Duration // a success at/below this latency is eligible to grow + AdjustInterval time.Duration // minimum time between limit changes (damping) + GrowStreak int // consecutive good samples required before +1 +} + +func (c *Config) withDefaults() { + if c.Max < 1 { + c.Max = 1 + } + if c.Min < 1 { + c.Min = 1 + } + if c.Min > c.Max { + c.Min = c.Max + } + if c.Start <= 0 || c.Start > c.Max { + c.Start = c.Max + } + if c.Start < c.Min { + c.Start = c.Min + } + if c.HighLatency <= 0 { + c.HighLatency = 2500 * time.Millisecond + } + if c.LowLatency <= 0 || c.LowLatency >= c.HighLatency { + c.LowLatency = c.HighLatency / 2 + } + if c.AdjustInterval <= 0 { + c.AdjustInterval = time.Second + } + if c.GrowStreak <= 0 { + c.GrowStreak = 10 + } +} + +// Limiter is a concurrency limiter whose active limit moves between [Min, Max]. +type Limiter struct { + cfg Config + mu sync.Mutex + cond *sync.Cond + + limit int + inflight int + goodStreak int + lastAdjust time.Time +} + +// New returns a limiter with the given config (defaults applied). +func New(cfg Config) *Limiter { + cfg.withDefaults() + l := &Limiter{cfg: cfg, limit: cfg.Start} + l.cond = sync.NewCond(&l.mu) + return l +} + +// Acquire blocks until a slot is free under the current limit, or ctx is done. +// A nil return means a slot was acquired and the caller must call Release. +func (l *Limiter) Acquire(ctx context.Context) error { + l.mu.Lock() + defer l.mu.Unlock() + + for { + // Context wins over an available slot, and is re-checked after every wake. + if err := ctx.Err(); err != nil { + return err + } + if l.inflight < l.limit { + l.inflight++ + return nil + } + // sync.Cond.Wait does not observe ctx, so register a wake on + // cancellation: AfterFunc broadcasts to re-evaluate the loop, and stop() + // unregisters it once this waiter proceeds normally. + stop := context.AfterFunc(ctx, func() { + l.mu.Lock() + l.cond.Broadcast() + l.mu.Unlock() + }) + l.cond.Wait() + stop() + } +} + +// Release returns a slot. Must be called exactly once per successful Acquire. +func (l *Limiter) Release() { + l.mu.Lock() + if l.inflight > 0 { + l.inflight-- + } + l.mu.Unlock() + // A slot freed up: wake one waiter. + l.cond.Signal() +} + +// Observe feeds the outcome of one call back into the controller: its latency and +// whether it succeeded. Errors and high latency shrink the limit (multiplicative +// decrease); sustained fast successes grow it (additive increase). Changes are +// rate-limited by AdjustInterval to avoid thrashing on a burst of samples. +func (l *Limiter) Observe(latency time.Duration, ok bool) { + l.mu.Lock() + defer l.mu.Unlock() + + congested := !ok || latency >= l.cfg.HighLatency + if congested { + l.goodStreak = 0 + if l.limit > l.cfg.Min && time.Since(l.lastAdjust) >= l.cfg.AdjustInterval { + l.limit = maxInt(l.cfg.Min, l.limit/2) + l.lastAdjust = time.Now() + } + return + } + + if latency > l.cfg.LowLatency { + // Healthy but not fast enough to justify growing; hold steady. + return + } + + l.goodStreak++ + if l.goodStreak >= l.cfg.GrowStreak && + l.limit < l.cfg.Max && + time.Since(l.lastAdjust) >= l.cfg.AdjustInterval { + l.limit++ + l.goodStreak = 0 + l.lastAdjust = time.Now() + // A new slot became available. + l.cond.Signal() + } +} + +// Limit returns the current concurrency limit (for logging / tests). +func (l *Limiter) Limit() int { + l.mu.Lock() + defer l.mu.Unlock() + return l.limit +} + +func maxInt(a, b int) int { + if a > b { + return a + } + return b +} diff --git a/pkg/adaptive/limiter_test.go b/pkg/adaptive/limiter_test.go new file mode 100644 index 0000000..78cb1cc --- /dev/null +++ b/pkg/adaptive/limiter_test.go @@ -0,0 +1,163 @@ +package adaptive + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestConfigDefaults(t *testing.T) { + l := New(Config{Max: 16}) + assert.Equal(t, 16, l.Limit(), "start defaults to max") + assert.Equal(t, 1, l.cfg.Min) + assert.Equal(t, 2500*time.Millisecond, l.cfg.HighLatency) + assert.Equal(t, 1250*time.Millisecond, l.cfg.LowLatency) +} + +func TestObserveMultiplicativeDecreaseOnError(t *testing.T) { + // A short AdjustInterval lets each spaced-out failure halve the limit. + l := New(Config{Max: 16, Min: 1, AdjustInterval: time.Millisecond}) + for _, want := range []int{8, 4, 2, 1, 1} { + l.Observe(time.Second, false) + assert.Equal(t, want, l.Limit()) + time.Sleep(2 * time.Millisecond) // pass the adjust interval + } +} + +func TestObserveDecreaseOnHighLatency(t *testing.T) { + l := New(Config{Max: 10, Min: 2, HighLatency: 2 * time.Second, AdjustInterval: 0}) + l.Observe(3*time.Second, true) // success but slow -> congestion + assert.Equal(t, 5, l.Limit()) +} + +func TestObserveAdditiveIncreaseOnFastSuccess(t *testing.T) { + l := New(Config{ + Max: 16, Min: 1, Start: 4, + LowLatency: time.Second, HighLatency: 2 * time.Second, + AdjustInterval: 0, GrowStreak: 3, + }) + // Fewer than GrowStreak good samples: no growth yet. + l.Observe(100*time.Millisecond, true) + l.Observe(100*time.Millisecond, true) + assert.Equal(t, 4, l.Limit()) + // Third good sample crosses the streak threshold -> +1. + l.Observe(100*time.Millisecond, true) + assert.Equal(t, 5, l.Limit()) +} + +func TestObserveNeverExceedsMax(t *testing.T) { + l := New(Config{Max: 3, Min: 1, Start: 3, LowLatency: time.Second, AdjustInterval: 0, GrowStreak: 1}) + for i := 0; i < 20; i++ { + l.Observe(10*time.Millisecond, true) + } + assert.Equal(t, 3, l.Limit(), "cannot grow past Max") +} + +func TestAdjustIntervalDampsBurst(t *testing.T) { + // A burst of failures within one interval collapses the limit only once. + l := New(Config{Max: 16, Min: 1, AdjustInterval: time.Hour}) + for i := 0; i < 10; i++ { + l.Observe(time.Second, false) + } + assert.Equal(t, 8, l.Limit(), "only one decrease per AdjustInterval") +} + +func TestAcquireRespectsLimitAndReleases(t *testing.T) { + l := New(Config{Max: 2, Min: 1, Start: 2}) + ctx := context.Background() + require.NoError(t, l.Acquire(ctx)) + require.NoError(t, l.Acquire(ctx)) + + // Third acquire must block until a Release happens. + acquired := make(chan struct{}) + go func() { + _ = l.Acquire(ctx) + close(acquired) + }() + + select { + case <-acquired: + t.Fatal("acquire should block when at limit") + case <-time.After(50 * time.Millisecond): + } + + l.Release() + select { + case <-acquired: + case <-time.After(time.Second): + t.Fatal("acquire should proceed after release") + } +} + +func TestAcquireCancelledContext(t *testing.T) { + l := New(Config{Max: 1, Min: 1, Start: 1}) + require.NoError(t, l.Acquire(context.Background())) // fill the only slot + + ctx, cancel := context.WithCancel(context.Background()) + errc := make(chan error, 1) + go func() { errc <- l.Acquire(ctx) }() + + time.Sleep(20 * time.Millisecond) + cancel() // cancellation alone must wake the waiter — no Release needed + + select { + case err := <-errc: + assert.ErrorIs(t, err, context.Canceled) + case <-time.After(time.Second): + t.Fatal("cancelled acquire should return") + } +} + +// TestAcquireWakesAllWaitersOnCancel covers the shutdown case: many goroutines +// blocked in Acquire must all return when ctx is cancelled, even though no slot +// is ever released (sync.Cond.Wait does not observe ctx on its own). +func TestAcquireWakesAllWaitersOnCancel(t *testing.T) { + l := New(Config{Max: 2, Min: 1, Start: 2}) + require.NoError(t, l.Acquire(context.Background())) + require.NoError(t, l.Acquire(context.Background())) // both slots held, never released + + ctx, cancel := context.WithCancel(context.Background()) + const waiters = 20 + done := make(chan error, waiters) + for i := 0; i < waiters; i++ { + go func() { done <- l.Acquire(ctx) }() + } + + time.Sleep(30 * time.Millisecond) // let them all park in Wait + cancel() + + timeout := time.After(2 * time.Second) + for i := 0; i < waiters; i++ { + select { + case err := <-done: + assert.ErrorIs(t, err, context.Canceled) + case <-timeout: + t.Fatalf("waiter %d did not wake on cancel", i) + } + } +} + +func TestConcurrentAcquireReleaseNoLeak(t *testing.T) { + l := New(Config{Max: 4, Min: 1, Start: 4}) + ctx := context.Background() + var wg sync.WaitGroup + for i := 0; i < 100; i++ { + wg.Add(1) + go func() { + defer wg.Done() + if l.Acquire(ctx) == nil { + time.Sleep(time.Millisecond) + l.Release() + } + }() + } + wg.Wait() + l.mu.Lock() + inflight := l.inflight + l.mu.Unlock() + assert.Equal(t, 0, inflight, "all slots released") +} From ddd1a84fbfdff199fafcacfffa6748e20b7bc021 Mon Sep 17 00:00:00 2001 From: vietddude Date: Thu, 3 Sep 2026 10:13:50 +0700 Subject: [PATCH 06/11] feat(rpc): blacklist nodes that do not serve the chain Some free RPCs (e.g. drpc) reject a chain outright with 'not available on free plan' / code 35. That was classified as a generic error, so the provider only degraded after ForceRotateThreshold wasted attempts every pass. Classify it as chain_unavailable and blacklist for 24h, since the condition is permanent for that node. --- internal/rpc/failover.go | 9 +++++++++ internal/rpc/failover_test.go | 15 +++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/internal/rpc/failover.go b/internal/rpc/failover.go index 6844e3a..9aa4b47 100644 --- a/internal/rpc/failover.go +++ b/internal/rpc/failover.go @@ -698,6 +698,15 @@ func (f *Failover[T]) analyzeError(err error, elapsed time.Duration) ProviderIss cooldown: 24 * time.Hour, markUnhealthy: true, }, + { + // Node does not serve this chain at all (e.g. drpc free plan). This is + // permanent for the node, so blacklist it long instead of churning + // through ForceRotateThreshold generic errors every pass. + patterns: []string{"not available on free plan", "upgrade to paid plan", "\"code\":35", "\"code\": 35"}, + reason: "chain_unavailable", + cooldown: 24 * time.Hour, + markUnhealthy: true, + }, { patterns: []string{ "-32701", diff --git a/internal/rpc/failover_test.go b/internal/rpc/failover_test.go index 422b5bd..f056e24 100644 --- a/internal/rpc/failover_test.go +++ b/internal/rpc/failover_test.go @@ -118,6 +118,21 @@ func TestAnalyzeAndHandleError_RestrictedQuery(t *testing.T) { assert.Equal(t, int64(1), errorsByType["restricted_query"]) } +func TestAnalyzeAndHandleError_ChainUnavailable(t *testing.T) { + f, p := newTestFailover() + + err := fmt.Errorf(`getBlock failed: HTTP 400 Bad Request: {"error":{"message":"chain is not available on free plan, please upgrade to paid plan","code":35}}`) + f.AnalyzeAndHandleError(p, err, 100*time.Millisecond) + + assert.False(t, p.IsAvailable(), "a node that doesn't serve the chain should be blacklisted immediately") + assert.Equal(t, StateBlacklisted, p.State) + // Long cooldown (24h) — the condition is permanent for that node. + assert.True(t, time.Now().Add(23*time.Hour).Before(p.BlacklistedUntil)) + + errorsByType := f.GetMetrics()["errors_by_type"].(map[string]int64) + assert.Equal(t, int64(1), errorsByType["chain_unavailable"]) +} + func TestAnalyzeAndHandleError_ConnectionError(t *testing.T) { f, p := newTestFailover() From 1a4f04a09d2890c3d774d2325b0c304a089cdd5e Mon Sep 17 00:00:00 2001 From: vietddude Date: Thu, 3 Sep 2026 10:24:36 +0700 Subject: [PATCH 07/11] feat(rpc): back off emergency recovery when the whole pool is blacklisted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When every provider is rate-limited/blacklisted, many concurrent callers each triggered emergency recovery, un-blacklisting a provider only to have it 429 again immediately — a hot recover->fail->recover loop that spammed logs and hammered dead nodes. Space emergency recoveries by EmergencyRecoveryInterval (default 2s): within the window, callers get errAllProvidersBackoff and retry instead of churning. Also refresh the example Solana config: drop drpc (does not serve Solana free), add keyed-node placeholders (Helius/Ankr/Chainstack) since free pools cannot sustain getBlock at slot rate. --- configs/config.example.yaml | 18 ++++++++++++++---- internal/rpc/failover.go | 27 +++++++++++++++++++++++++-- internal/rpc/failover_test.go | 33 +++++++++++++++++++++++++++++++++ 3 files changed, 72 insertions(+), 6 deletions(-) diff --git a/configs/config.example.yaml b/configs/config.example.yaml index a334d9f..9f23e51 100644 --- a/configs/config.example.yaml +++ b/configs/config.example.yaml @@ -163,14 +163,19 @@ chains: start_block: 0 poll_interval: "2s" nodes: + # STRONGLY RECOMMENDED: put at least one keyed node first. Free public + # nodes cannot sustain getBlock at Solana's slot rate — expect heavy 429s + # and lag without a paid/free-tier-with-key endpoint. + # - url: "https://mainnet.helius-rpc.com/?api-key=${HELIUS_KEY}" # recommended + # - url: "https://solana-mainnet.g.alchemy.com/v2/${ALCHEMY_KEY}" # optional + # - url: "https://solana-mainnet.g.quicknode.pro/${QUICKNODE_KEY}/" # optional - url: "https://solana-rpc.publicnode.com" - url: "https://api.mainnet.solana.com" - - url: "https://solana.drpc.org" - url: "https://solana.leorpc.com/?api_key=FREE" + - url: "https://solana-mainnet.gateway.tatum.io" - url: "https://solana.api.pocket.network" - url: "https://public.rpc.solanavibestation.com" - # - url: "https://solana-mainnet.g.alchemy.com/v2/${ALCHEMY_KEY}" # optional - # - url: "https://solana-mainnet.g.quicknode.pro/${QUICKNODE_KEY}/" # optional + # NOTE: solana.drpc.org does NOT serve Solana on the free plan — removed. client: timeout: "15s" max_retries: 2 @@ -189,8 +194,13 @@ chains: start_block: 0 poll_interval: "2s" nodes: - - url: "https://api.devnet.solana.com" + # Keyed nodes (Ankr/Chainstack free tiers with a key) lead — verified to + # serve getBlock reliably, unlike the unkeyed public devnet endpoints. + # - url: "https://rpc.ankr.com/solana_devnet/${ANKR_KEY}" + # - url: "https://solana-devnet.core.chainstack.com/${CHAINSTACK_KEY}" - url: "https://solana-devnet.api.onfinality.io/public" + - url: "https://solana-devnet.gateway.tatum.io/" + - url: "https://api.devnet.solana.com" client: timeout: "12s" max_retries: 2 diff --git a/internal/rpc/failover.go b/internal/rpc/failover.go index 9aa4b47..3626f7e 100644 --- a/internal/rpc/failover.go +++ b/internal/rpc/failover.go @@ -2,6 +2,7 @@ package rpc import ( "context" + "errors" "fmt" "math/rand" "sort" @@ -29,6 +30,10 @@ type FailoverConfig struct { // SlowResponseCooldown is how long a provider stays blacklisted after being // flagged slow, before it is retried. SlowResponseCooldown time.Duration + // EmergencyRecoveryInterval is the minimum spacing between emergency + // recoveries when the whole pool is blacklisted. It stops many concurrent + // callers from hot-spinning through recover→fail→recover. + EmergencyRecoveryInterval time.Duration } func DefaultFailoverConfig() FailoverConfig { @@ -39,8 +44,9 @@ func DefaultFailoverConfig() FailoverConfig { ErrorThreshold: 5, ForceRotateThreshold: 3, DefaultTimeout: 10 * time.Second, - SlowResponseThreshold: 3 * time.Second, - SlowResponseCooldown: 2 * time.Minute, + SlowResponseThreshold: 3 * time.Second, + SlowResponseCooldown: 2 * time.Minute, + EmergencyRecoveryInterval: 2 * time.Second, } } @@ -196,10 +202,16 @@ type Failover[T NetworkClient] struct { currentIndex int config FailoverConfig lastHealthCheck time.Time + lastEmergency time.Time metrics *FailoverMetrics logThrottler *LogThrottler } +// errAllProvidersBackoff is returned when the whole pool is blacklisted and an +// emergency recovery happened too recently. Callers back off (via retry) instead +// of hot-spinning through recover→fail→recover across goroutines. +var errAllProvidersBackoff = errors.New("all providers unavailable, backing off") + // NewFailover creates a new type-safe Failover[T] func NewFailover[T NetworkClient](config *FailoverConfig) *Failover[T] { if config == nil { @@ -218,6 +230,9 @@ func NewFailover[T NetworkClient](config *FailoverConfig) *Failover[T] { if config.SlowResponseCooldown <= 0 { config.SlowResponseCooldown = DefaultFailoverConfig().SlowResponseCooldown } + if config.EmergencyRecoveryInterval <= 0 { + config.EmergencyRecoveryInterval = DefaultFailoverConfig().EmergencyRecoveryInterval + } return &Failover[T]{ providers: make([]*Provider, 0), currentIndex: -1, @@ -370,6 +385,13 @@ func (f *Failover[T]) performEmergencyRecoveryLocked() (*Provider, error) { return nil, fmt.Errorf("no available providers") } + // Space out emergency recoveries: if we un-blacklisted a provider very + // recently, make callers back off rather than recover→fail→recover in a hot + // loop while the whole pool is rate-limited. + if !f.lastEmergency.IsZero() && time.Since(f.lastEmergency) < f.config.EmergencyRecoveryInterval { + return nil, errAllProvidersBackoff + } + var blacklisted []*Provider for _, p := range f.providers { if p.State == StateBlacklisted { @@ -389,6 +411,7 @@ func (f *Failover[T]) performEmergencyRecoveryLocked() (*Provider, error) { first := blacklisted[0] first.Recover() f.currentIndex = 0 + f.lastEmergency = time.Now() f.metrics.IncrementEmergencyRecovery() logger.Info("Emergency recovery", "name", first.Name) diff --git a/internal/rpc/failover_test.go b/internal/rpc/failover_test.go index f056e24..d075def 100644 --- a/internal/rpc/failover_test.go +++ b/internal/rpc/failover_test.go @@ -243,6 +243,39 @@ func TestExecuteCore_GenericErrorsForceRotateToHealthySibling(t *testing.T) { assert.Equal(t, int64(1), metrics["provider_switches"]) } +func TestEmergencyRecovery_SpacedByInterval(t *testing.T) { + cfg := DefaultFailoverConfig() + cfg.EmergencyRecoveryInterval = 100 * time.Millisecond + f := NewFailover[NetworkClient](&cfg) + + a := newTestProvider("a") + b := newTestProvider("b") + require.NoError(t, f.AddProvider(a)) + require.NoError(t, f.AddProvider(b)) + + // Whole pool down (long blacklist so it doesn't expire mid-test). + a.Blacklist(time.Hour) + b.Blacklist(time.Hour) + + // First call recovers one provider. + p, err := f.GetBestProvider() + require.NoError(t, err) + require.NotNil(t, p) + + // Knock the recovered one back out so the pool is fully down again. + p.Blacklist(time.Hour) + + // Immediately: within the interval -> caller is told to back off, no churn. + _, err = f.GetBestProvider() + require.ErrorIs(t, err, errAllProvidersBackoff) + + // After the interval passes, emergency recovery is allowed again. + time.Sleep(120 * time.Millisecond) + p2, err := f.GetBestProvider() + require.NoError(t, err) + require.NotNil(t, p2) +} + func TestExecuteCore_SlowSuccessBlacklistsProvider(t *testing.T) { cfg := DefaultFailoverConfig() cfg.SlowResponseThreshold = 10 * time.Millisecond From ef8e0b7f61481ce06d7e04e27c3a7060b9c9bdad Mon Sep 17 00:00:00 2001 From: vietddude Date: Thu, 3 Sep 2026 10:31:50 +0700 Subject: [PATCH 08/11] chore(solana): trim verbose comments on the branch changes --- internal/indexer/solana.go | 21 ++++----------- internal/indexer/solana_test.go | 6 +---- internal/rpc/failover.go | 31 +++++---------------- internal/rpc/solana/client.go | 6 ++--- internal/rpc/solana/types.go | 11 ++------ internal/worker/regular.go | 6 +---- pkg/adaptive/limiter.go | 48 +++++++++------------------------ pkg/adaptive/limiter_test.go | 4 +-- 8 files changed, 31 insertions(+), 102 deletions(-) diff --git a/internal/indexer/solana.go b/internal/indexer/solana.go index e06478b..c14236c 100644 --- a/internal/indexer/solana.go +++ b/internal/indexer/solana.go @@ -26,9 +26,7 @@ type SolanaIndexer struct { config config.ChainConfig failover *rpc.Failover[solana.SolanaAPI] pubkeyStore PubkeyStore - // limiter adapts getBlock concurrency to observed RPC latency/errors. It is - // shared across all worker modes for this chain (the indexer is built once), - // so it is a single global congestion controller for the chain's getBlock load. + // shared across worker modes: one congestion controller per chain. limiter *adaptive.Limiter } @@ -43,10 +41,8 @@ func NewSolanaIndexer( maxConc = 1 } limiter := adaptive.New(adaptive.Config{ - Max: maxConc, - Min: 1, - // Healthy Solana getBlock (json, full) is ~0.6-1s; treat >2.5s as - // congestion and only grow back when calls settle under ~1.2s. + Max: maxConc, + Min: 1, LowLatency: 1200 * time.Millisecond, HighLatency: 2500 * time.Millisecond, AdjustInterval: time.Second, @@ -198,8 +194,6 @@ func (s *SolanaIndexer) GetBlocksByNumbers(ctx context.Context, blockNumbers []u b = blk return err }) - // Feed latency/outcome back into the concurrency controller so it - // backs off when the RPC is slow or failing and recovers when fast. s.limiter.Observe(time.Since(fetchStart), berr == nil) if berr != nil { @@ -485,13 +479,8 @@ func solanaParseTokenTransfer(ix solana.Instruction, accountKeys []solana.Accoun } } -// solanaEffectiveAccountKeys returns the full ordered account list used to -// resolve instruction/token-balance indices. With encoding=json, a versioned -// (v0) transaction's Address Lookup Table accounts are delivered separately in -// meta.loadedAddresses rather than in message.accountKeys, and instruction and -// token-balance indices point into static keys followed by the loaded writable -// then loaded readonly accounts. With encoding=jsonParsed loaded is empty -// (already merged), so the static keys are returned unchanged. +// solanaEffectiveAccountKeys appends v0 ALT accounts (static + writable + readonly) +// so instruction/token-balance indices resolve under encoding=json. func solanaEffectiveAccountKeys(static []solana.AccountKey, loaded *solana.LoadedAddresses) []solana.AccountKey { if loaded == nil || (len(loaded.Writable) == 0 && len(loaded.Readonly) == 0) { return static diff --git a/internal/indexer/solana_test.go b/internal/indexer/solana_test.go index 945b70d..fe13de5 100644 --- a/internal/indexer/solana_test.go +++ b/internal/indexer/solana_test.go @@ -373,11 +373,7 @@ func TestParseSquadsMultisigTransfer(t *testing.T) { tokenTransfer.Amount, tokenTransfer.AssetAddress) } -// TestSolanaEffectiveAccountKeys verifies that with encoding=json a versioned -// (v0) transaction's Address Lookup Table accounts are appended in the correct -// order: static keys, then loaded writable, then loaded readonly. This ordering -// matches the account list the RPC merges into accountKeys under jsonParsed and -// is what instruction / token-balance indices point into. +// TestSolanaEffectiveAccountKeys: v0 ALT accounts append as static+writable+readonly. func TestSolanaEffectiveAccountKeys(t *testing.T) { static := []solana.AccountKey{{Pubkey: "S0"}, {Pubkey: "S1"}} diff --git a/internal/rpc/failover.go b/internal/rpc/failover.go index 3626f7e..b2fdf4a 100644 --- a/internal/rpc/failover.go +++ b/internal/rpc/failover.go @@ -22,17 +22,11 @@ type FailoverConfig struct { ErrorThreshold int ForceRotateThreshold int DefaultTimeout time.Duration - // SlowResponseThreshold is the latency above which a call is considered slow. - // It rotates away a provider that is slow even when it returns successfully - // (e.g. an overloaded free RPC that never errors but responds in seconds). - // The same threshold classifies a slow error response. + // SlowResponseThreshold rotates away a provider that is slow even on success. SlowResponseThreshold time.Duration - // SlowResponseCooldown is how long a provider stays blacklisted after being - // flagged slow, before it is retried. - SlowResponseCooldown time.Duration - // EmergencyRecoveryInterval is the minimum spacing between emergency - // recoveries when the whole pool is blacklisted. It stops many concurrent - // callers from hot-spinning through recover→fail→recover. + SlowResponseCooldown time.Duration + // EmergencyRecoveryInterval spaces emergency recoveries when the whole pool + // is blacklisted, to avoid hot recover→fail→recover across callers. EmergencyRecoveryInterval time.Duration } @@ -207,9 +201,6 @@ type Failover[T NetworkClient] struct { logThrottler *LogThrottler } -// errAllProvidersBackoff is returned when the whole pool is blacklisted and an -// emergency recovery happened too recently. Callers back off (via retry) instead -// of hot-spinning through recover→fail→recover across goroutines. var errAllProvidersBackoff = errors.New("all providers unavailable, backing off") // NewFailover creates a new type-safe Failover[T] @@ -385,9 +376,6 @@ func (f *Failover[T]) performEmergencyRecoveryLocked() (*Provider, error) { return nil, fmt.Errorf("no available providers") } - // Space out emergency recoveries: if we un-blacklisted a provider very - // recently, make callers back off rather than recover→fail→recover in a hot - // loop while the whole pool is rate-limited. if !f.lastEmergency.IsZero() && time.Since(f.lastEmergency) < f.config.EmergencyRecoveryInterval { return nil, errAllProvidersBackoff } @@ -457,11 +445,8 @@ func (f *Failover[T]) executeCore(ctx context.Context, provider *Provider, fn fu return nil } -// evaluateSlowSuccess rotates away from a provider that returns successfully but -// too slowly, so the next call prefers a faster one. This covers overloaded free -// RPCs that respond in seconds without ever erroring — a case the error-path -// analysis never sees. It never drops the available pool below MinActiveProviders, -// so when every provider is slow we keep using them rather than starving. +// evaluateSlowSuccess blacklists a slow-but-successful provider, unless that +// would drop the available pool below MinActiveProviders. func (f *Failover[T]) evaluateSlowSuccess(provider *Provider, elapsed time.Duration) { if !f.config.EnableBlacklisting || f.config.SlowResponseThreshold <= 0 { return @@ -722,9 +707,7 @@ func (f *Failover[T]) analyzeError(err error, elapsed time.Duration) ProviderIss markUnhealthy: true, }, { - // Node does not serve this chain at all (e.g. drpc free plan). This is - // permanent for the node, so blacklist it long instead of churning - // through ForceRotateThreshold generic errors every pass. + // Node does not serve this chain (e.g. drpc free plan) — permanent. patterns: []string{"not available on free plan", "upgrade to paid plan", "\"code\":35", "\"code\": 35"}, reason: "chain_unavailable", cooldown: 24 * time.Hour, diff --git a/internal/rpc/solana/client.go b/internal/rpc/solana/client.go index 9808bc5..f19aef8 100644 --- a/internal/rpc/solana/client.go +++ b/internal/rpc/solana/client.go @@ -74,10 +74,8 @@ func (c *Client) GetTransaction(ctx context.Context, signature string) (*GetTran } func (c *Client) GetBlock(ctx context.Context, slot uint64) (*GetBlockResult, error) { - // encoding=json is ~35-40% smaller/faster than jsonParsed for full blocks. - // The transfer parser resolves instructions from account indices + base58 - // instruction data (see extractSolanaTransfers), and appends meta.loadedAddresses - // so versioned (v0) transactions still resolve correctly. + // json is smaller/faster than jsonParsed; parser handles it via account + // indices + base58 data + meta.loadedAddresses (see extractSolanaTransfers). cfg := GetBlockConfig{ Encoding: "json", TransactionDetails: "full", diff --git a/internal/rpc/solana/types.go b/internal/rpc/solana/types.go index aa43c4d..43801fb 100644 --- a/internal/rpc/solana/types.go +++ b/internal/rpc/solana/types.go @@ -54,12 +54,7 @@ type TxnMeta struct { PreTokenBalances []TokenBalance `json:"preTokenBalances"` PostTokenBalances []TokenBalance `json:"postTokenBalances"` InnerInstructions []InnerInstruction `json:"innerInstructions"` - // LoadedAddresses carries the accounts a versioned (v0) transaction pulls in - // via Address Lookup Tables. With encoding=json these are NOT included in - // message.accountKeys, so the full account list used for index resolution is - // static accountKeys + Writable + Readonly (in that order). With - // encoding=jsonParsed the RPC already merges them into accountKeys and this - // field is empty. + // v0 ALT accounts; present only under encoding=json (jsonParsed pre-merges them). LoadedAddresses *LoadedAddresses `json:"loadedAddresses"` } @@ -104,9 +99,7 @@ type AccountKey struct { Writable bool `json:"writable"` } -// UnmarshalJSON accepts both encodings of message.accountKeys: -// - encoding=json: a bare base58 pubkey string -// - encoding=jsonParsed: an object { pubkey, signer, source, writable } +// UnmarshalJSON accepts a bare pubkey string (json) or an object (jsonParsed). func (a *AccountKey) UnmarshalJSON(data []byte) error { if len(data) > 0 && data[0] == '"' { var pubkey string diff --git a/internal/worker/regular.go b/internal/worker/regular.go index 4edaa12..4d0687c 100644 --- a/internal/worker/regular.go +++ b/internal/worker/regular.go @@ -165,9 +165,7 @@ func (rw *RegularWorker) processBatch( } for _, res := range results { - // Solana produces skipped slots that will never have a block. Treat them - // as advanced-past rather than failed so we don't persist them as failed - // blocks and waste an extra getBlock in the rescanner confirming the skip. + // Skipped slots are normal on Solana: advance past them, don't fail them. if rw.isSolanaSkippedSlot(res) { rw.notifyObserver(res.Number, BlockStatusNotFound) if res.Number > lastSuccess { @@ -183,8 +181,6 @@ func (rw *RegularWorker) processBatch( return lastSuccess, lastSuccessHash, false, nil } -// isSolanaSkippedSlot reports whether a block result is a Solana skipped slot, -// which is normal on Solana and must not be treated as a failed block. func (rw *RegularWorker) isSolanaSkippedSlot(res indexer.BlockResult) bool { return res.Error != nil && res.Error.ErrorType == indexer.ErrorTypeBlockNotFound && diff --git a/pkg/adaptive/limiter.go b/pkg/adaptive/limiter.go index 8e20b5d..0d211bf 100644 --- a/pkg/adaptive/limiter.go +++ b/pkg/adaptive/limiter.go @@ -1,12 +1,6 @@ -// Package adaptive provides an AIMD (additive-increase / multiplicative-decrease) -// concurrency limiter. It adapts how many operations may run in parallel based on -// observed latency and errors — a congestion-control loop for RPC calls. -// -// The configured concurrency acts as a ceiling: the limiter multiplicatively -// backs off when calls get slow or fail (an overloaded / rate-limited RPC), and -// additively recovers back toward the ceiling once calls are fast again. It never -// exceeds the ceiling, so it can only ever do the same or better than a static -// semaphore of the same size. +// Package adaptive provides an AIMD concurrency limiter: the configured +// concurrency is a ceiling the limiter backs off from under latency/errors and +// recovers toward when calls are fast. package adaptive import ( @@ -15,15 +9,14 @@ import ( "time" ) -// Config tunes the limiter. Zero values are replaced with sane defaults. type Config struct { - Min int // floor for concurrency (clamped to >= 1) - Max int // ceiling for concurrency (the configured concurrency) - Start int // initial limit (defaults to Max) - HighLatency time.Duration // a success at/above this latency counts as congestion - LowLatency time.Duration // a success at/below this latency is eligible to grow - AdjustInterval time.Duration // minimum time between limit changes (damping) - GrowStreak int // consecutive good samples required before +1 + Min int + Max int + Start int + HighLatency time.Duration + LowLatency time.Duration + AdjustInterval time.Duration + GrowStreak int } func (c *Config) withDefaults() { @@ -56,7 +49,6 @@ func (c *Config) withDefaults() { } } -// Limiter is a concurrency limiter whose active limit moves between [Min, Max]. type Limiter struct { cfg Config mu sync.Mutex @@ -68,7 +60,6 @@ type Limiter struct { lastAdjust time.Time } -// New returns a limiter with the given config (defaults applied). func New(cfg Config) *Limiter { cfg.withDefaults() l := &Limiter{cfg: cfg, limit: cfg.Start} @@ -76,14 +67,11 @@ func New(cfg Config) *Limiter { return l } -// Acquire blocks until a slot is free under the current limit, or ctx is done. -// A nil return means a slot was acquired and the caller must call Release. func (l *Limiter) Acquire(ctx context.Context) error { l.mu.Lock() defer l.mu.Unlock() for { - // Context wins over an available slot, and is re-checked after every wake. if err := ctx.Err(); err != nil { return err } @@ -91,9 +79,7 @@ func (l *Limiter) Acquire(ctx context.Context) error { l.inflight++ return nil } - // sync.Cond.Wait does not observe ctx, so register a wake on - // cancellation: AfterFunc broadcasts to re-evaluate the loop, and stop() - // unregisters it once this waiter proceeds normally. + // sync.Cond.Wait ignores ctx; broadcast on cancel to re-evaluate. stop := context.AfterFunc(ctx, func() { l.mu.Lock() l.cond.Broadcast() @@ -104,27 +90,20 @@ func (l *Limiter) Acquire(ctx context.Context) error { } } -// Release returns a slot. Must be called exactly once per successful Acquire. func (l *Limiter) Release() { l.mu.Lock() if l.inflight > 0 { l.inflight-- } l.mu.Unlock() - // A slot freed up: wake one waiter. l.cond.Signal() } -// Observe feeds the outcome of one call back into the controller: its latency and -// whether it succeeded. Errors and high latency shrink the limit (multiplicative -// decrease); sustained fast successes grow it (additive increase). Changes are -// rate-limited by AdjustInterval to avoid thrashing on a burst of samples. func (l *Limiter) Observe(latency time.Duration, ok bool) { l.mu.Lock() defer l.mu.Unlock() - congested := !ok || latency >= l.cfg.HighLatency - if congested { + if !ok || latency >= l.cfg.HighLatency { l.goodStreak = 0 if l.limit > l.cfg.Min && time.Since(l.lastAdjust) >= l.cfg.AdjustInterval { l.limit = maxInt(l.cfg.Min, l.limit/2) @@ -134,7 +113,6 @@ func (l *Limiter) Observe(latency time.Duration, ok bool) { } if latency > l.cfg.LowLatency { - // Healthy but not fast enough to justify growing; hold steady. return } @@ -145,12 +123,10 @@ func (l *Limiter) Observe(latency time.Duration, ok bool) { l.limit++ l.goodStreak = 0 l.lastAdjust = time.Now() - // A new slot became available. l.cond.Signal() } } -// Limit returns the current concurrency limit (for logging / tests). func (l *Limiter) Limit() int { l.mu.Lock() defer l.mu.Unlock() diff --git a/pkg/adaptive/limiter_test.go b/pkg/adaptive/limiter_test.go index 78cb1cc..1e64576 100644 --- a/pkg/adaptive/limiter_test.go +++ b/pkg/adaptive/limiter_test.go @@ -112,9 +112,7 @@ func TestAcquireCancelledContext(t *testing.T) { } } -// TestAcquireWakesAllWaitersOnCancel covers the shutdown case: many goroutines -// blocked in Acquire must all return when ctx is cancelled, even though no slot -// is ever released (sync.Cond.Wait does not observe ctx on its own). +// TestAcquireWakesAllWaitersOnCancel: blocked waiters all return on ctx cancel. func TestAcquireWakesAllWaitersOnCancel(t *testing.T) { l := New(Config{Max: 2, Min: 1, Start: 2}) require.NoError(t, l.Acquire(context.Background())) From 281f928c5cd4d468b63cb4ffb8090758900747d5 Mon Sep 17 00:00:00 2001 From: vietddude Date: Thu, 3 Sep 2026 10:34:16 +0700 Subject: [PATCH 09/11] docs: add CLAUDE.md guide for AI agents --- CLAUDE.md | 51 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..e84f834 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,51 @@ +# CLAUDE.md + +Guidance for AI agents working in this repo. Keep changes small, tested, and consistent with existing patterns. + +## What this is + +Multi-chain blockchain transaction indexer (Go 1.25). Watches configured chains, fetches blocks, extracts transfers for monitored addresses, and emits events. Supported chains live in `internal/indexer/` (EVM, Solana, Bitcoin, Tron, Sui, Cosmos, Aptos, TON, XRP, Stellar). + +## Commands + +```bash +make build # -> ./indexer +./indexer index --chain= --catchup --debug # run one chain (name = config key, e.g. solana_mainnet) +./indexer index --catchup # run all enabled chains +make stop # pkill the running indexer +go test ./... # all tests (some hit live RPC / need network) +go test ./pkg/adaptive/ -race # a single package, with race detector +go fmt ./... # format before committing +``` + +- Config path defaults to `configs/config.yaml`. **`configs/config.yaml` is gitignored** (real keys/endpoints live there, local only). The tracked template is `configs/config.example.yaml` — update it with **placeholders** (`${HELIUS_KEY}`), never real keys. +- Running needs NATS + Redis reachable (`nats:` / `redis:` blocks in config). Without them the indexer fails at startup. + +## Architecture + +- `internal/indexer/` — per-chain `Indexer` implementations (`indexer.go` defines the interface: `GetBlock`, `GetBlocks`, `GetBlocksByNumbers`, `GetLatestBlockNumber`, `IsHealthy`). Each parses raw RPC blocks into `types.Transaction` transfers. +- `internal/worker/` — worker modes over an indexer, built once per chain in `factory.go` and shared across modes (`types.go`): `regular` (real-time head), `catchup` (backfill ranges), `rescanner` (retry failed blocks), `manual`, `mempool`. `base.go` holds shared block-handling/emit logic. +- `internal/rpc/` — `Failover[T]` provider pool: health tracking, blacklisting, latency-based rotation, error classification (`analyzeError`). `internal/rpc//` has the concrete RPC clients. +- `pkg/adaptive/` — AIMD concurrency limiter used to pace RPC calls to observed latency/errors. +- `pkg/store/`, `pkg/kvstore/`, `pkg/repository/` — persistence (latest block, failed blocks, catchup ranges). `pkg/events/` — emission. `pkg/ratelimiter/` — shared per-chain RPS limiter. `pkg/common/config/` — config types. + +## Conventions + +- **Write minimal comments.** Prefer self-explanatory code (clear names, small functions) over comments. Only comment non-obvious rationale — a gotcha, a "why", something that would bite the next reader. Never restate what the code plainly does. No doc-comment boilerplate on every function. +- **Adding/changing a chain:** implement `indexer.Indexer` in `internal/indexer/.go`, wire an RPC client in `internal/rpc//`, add a `buildIndexer` in `internal/worker/factory.go`, and a config block. +- **Failover tuning lives in code**, not yaml — `rpc.DefaultFailoverConfig()`. Every `NewFailover` call passes `nil` and gets those defaults. Don't reintroduce a yaml `failover:` block. +- **Solana specifics:** `getBlock` uses `encoding=json` (not `jsonParsed`) — cheaper. The parser resolves accounts by index + base58 data and must append `meta.loadedAddresses` (v0 ALT accounts: static + writable + readonly) via `solanaEffectiveAccountKeys`. Skipped slots are normal (`ErrorTypeBlockNotFound`); never treat them as failed blocks. +- **Free public RPCs cannot sustain Solana getBlock** at slot rate — expect 429s/lag without a keyed node. This is capacity, not code. +- **Errors:** classify recoverable RPC failures in `analyzeError` (rate_limit, timeout, chain_unavailable, ...) so blacklist/cooldown policy is consistent. + +## Testing + +- Unit tests are deterministic and offline; prefer them. Some indexer tests hit live mainnet RPC (network-dependent — a DNS/429 failure there is environmental, not your change). +- Use `-race` for anything with goroutines/locks (e.g. `pkg/adaptive`). +- After edits: `go build ./...` then run the affected package's tests. + +## Git + +- Branch off `main`; never commit directly to it. +- Conventional commits (`feat(scope):`, `fix(scope):`, `refactor:`, `chore:`). +- Stage only the files you changed — do not `git add -A` (unrelated WIP files may be dirty in the tree). From fd05349c187a9c3f08b0461dd94ded69513b50cd Mon Sep 17 00:00:00 2001 From: vietddude Date: Thu, 3 Sep 2026 10:44:21 +0700 Subject: [PATCH 10/11] fix(worker): drop duplicate chain field in worker logs The worker logger already binds chain (and mode) via logger.With, so passing "chain" again in each call printed it twice (chain=X chain=X). Remove the redundant explicit chain args; the value now comes only from logger context. --- internal/worker/base.go | 8 +------- internal/worker/catchup.go | 18 +----------------- internal/worker/manual.go | 16 ++++++---------- internal/worker/mempool.go | 5 ++--- internal/worker/regular.go | 16 +++------------- internal/worker/rescanner.go | 8 ++------ 6 files changed, 15 insertions(+), 56 deletions(-) diff --git a/internal/worker/base.go b/internal/worker/base.go index a36fb8a..cb01b5a 100644 --- a/internal/worker/base.go +++ b/internal/worker/base.go @@ -54,7 +54,7 @@ type BaseWorker struct { // Stop stops the worker and cleans up internal resources func (bw *BaseWorker) Stop() { bw.cancel() - bw.logger.Info("Worker stopped", "chain", bw.chain.GetName()) + bw.logger.Info("Worker stopped") } // newWorkerWithMode constructs a BaseWorker with the given mode and logger. @@ -161,7 +161,6 @@ func (bw *BaseWorker) handleBlockResult(result indexer.BlockResult) bool { } bw.logger.Error("Failed to process block", - "chain", bw.chain.GetName(), "block", result.Number, "err", result.Error.Message, ) @@ -176,7 +175,6 @@ func (bw *BaseWorker) handleBlockResult(result indexer.BlockResult) bool { if result.Block == nil { bw.logger.Error("Nil block result", - "chain", bw.chain.GetName(), "block", result.Number, ) bw.notifyObserver(result.Number, BlockStatusFailed) @@ -187,7 +185,6 @@ func (bw *BaseWorker) handleBlockResult(result indexer.BlockResult) bool { bw.emitBlock(result.Block) bw.logger.Info("Processed block successfully", - "chain", bw.chain.GetName(), "block", result.Block.Number, ) registry.ClearFailedBlocks(bw.chain.GetName(), []uint64{result.Number}) @@ -224,7 +221,6 @@ func (bw *BaseWorker) emitBlock(block *types.Block) { "direction", types.DirectionIn, "from", inTx.FromAddress, "to", inTx.ToAddress, - "chain", bw.chain.GetName(), "type", inTx.Type, "txhash", inTx.TxHash, "status", inTx.Status, @@ -241,7 +237,6 @@ func (bw *BaseWorker) emitBlock(block *types.Block) { "direction", types.DirectionOut, "from", outTx.FromAddress, "to", outTx.ToAddress, - "chain", bw.chain.GetName(), "type", outTx.Type, "txhash", outTx.TxHash, "status", outTx.Status, @@ -324,7 +319,6 @@ func (bw *BaseWorker) emitUTXOs(block *types.Block) { event.Spent = filteredSpent bw.logger.Info("Emitting UTXO event", - "chain", bw.chain.GetName(), "txhash", event.TxHash, "created", len(event.Created), "spent", len(event.Spent), diff --git a/internal/worker/catchup.go b/internal/worker/catchup.go index fe3f6ac..910ddd0 100644 --- a/internal/worker/catchup.go +++ b/internal/worker/catchup.go @@ -72,7 +72,6 @@ func (cw *CatchupWorker) Start() { } cw.logger.Info("Starting optimized catchup worker", - "chain", cw.chain.GetName(), "ranges", len(cw.blockRanges), "total_blocks", totalBlocks, "parallel_workers", CATCHUP_WORKERS, @@ -109,9 +108,7 @@ func (cw *CatchupWorker) runCatchup() { // No ranges left: stay alive and re-check the store for ranges queued // later, instead of exiting permanently. if len(cw.blockRanges) == 0 { - cw.logger.Debug("No catchup ranges, waiting for new work", - "chain", cw.chain.GetName(), - ) + cw.logger.Debug("No catchup ranges, waiting for new work") select { case <-cw.ctx.Done(): return @@ -130,14 +127,12 @@ func (cw *CatchupWorker) reloadStoredRanges() []blockstore.CatchupRange { progress, err := cw.blockStore.GetCatchupProgress(cw.chain.GetNetworkInternalCode()) if err != nil { cw.logger.Warn("Failed to reload catchup progress while idle", - "chain", cw.chain.GetName(), "error", err, ) return nil } if len(progress) > 0 { cw.logger.Info("Picked up newly queued catchup ranges", - "chain", cw.chain.GetName(), "ranges", len(progress), ) status.EnsureStatusRegistry(cw.statusRegistry).SetCatchupRanges(cw.chain.GetName(), progress) @@ -159,14 +154,12 @@ func (cw *CatchupWorker) loadCatchupProgress() []blockstore.CatchupRange { // Load existing catchup ranges from database (they're already split when saved) if progress, err := cw.blockStore.GetCatchupProgress(cw.chain.GetNetworkInternalCode()); err == nil { cw.logger.Info("Loading existing catchup progress", - "chain", cw.chain.GetName(), "progress_ranges", len(progress), ) ranges = progress registry.SetCatchupRanges(cw.chain.GetName(), progress) } else { cw.logger.Warn("Failed to load catchup progress, will create new range", - "chain", cw.chain.GetName(), "error", err, ) } @@ -181,7 +174,6 @@ func (cw *CatchupWorker) loadCatchupProgress() []blockstore.CatchupRange { } start, end := latest+1, head cw.logger.Info("Creating new catchup range", - "chain", cw.chain.GetName(), "latest_block", latest, "head_block", head, "catchup_start", start, "catchup_end", end, @@ -199,7 +191,6 @@ func (cw *CatchupWorker) loadCatchupProgress() []blockstore.CatchupRange { newRanges, ); err != nil { cw.logger.Error("Failed to batch save catchup ranges", - "chain", cw.chain.GetName(), "count", len(newRanges), "error", err, ) @@ -220,7 +211,6 @@ func (cw *CatchupWorker) splitLargeRange(r blockstore.CatchupRange) []blockstore if len(subRanges) > 1 { cw.logger.Info("Split large catchup range", - "chain", cw.chain.GetName(), "original_range", fmt.Sprintf("%d-%d", r.Start, r.End), "original_size", r.End-r.Start+1, "sub_ranges", len(subRanges), @@ -396,14 +386,12 @@ func (cw *CatchupWorker) saveProgress(r blockstore.CatchupRange, current uint64) defer cw.progressMu.Unlock() registry := status.EnsureStatusRegistry(cw.statusRegistry) cw.logger.Debug("Saving catchup progress", - "chain", cw.chain.GetName(), "range", fmt.Sprintf("%d-%d", r.Start, r.End), "current", current, ) current = min(current, r.End) if err := cw.blockStore.SaveCatchupProgress(cw.chain.GetNetworkInternalCode(), r.Start, r.End, current); err != nil { cw.logger.Warn("Failed to save catchup progress", - "chain", cw.chain.GetName(), "range", fmt.Sprintf("%d-%d", r.Start, r.End), "current", current, "error", err, @@ -429,13 +417,11 @@ func (cw *CatchupWorker) completeRange(r blockstore.CatchupRange) error { registry := status.EnsureStatusRegistry(cw.statusRegistry) cw.logger.Info("Completing catchup range", - "chain", cw.chain.GetName(), "range", fmt.Sprintf("%d-%d", r.Start, r.End), ) if err := cw.blockStore.DeleteCatchupRange(cw.chain.GetNetworkInternalCode(), r.Start, r.End); err != nil { cw.logger.Warn("Failed to delete catchup range", - "chain", cw.chain.GetName(), "range", fmt.Sprintf("%d-%d", r.Start, r.End), "error", err, ) @@ -456,7 +442,6 @@ func (cw *CatchupWorker) completeRange(r blockstore.CatchupRange) error { func (cw *CatchupWorker) Close() error { cw.logger.Info("Closing catchup worker, saving progress...", - "chain", cw.chain.GetName(), "ranges", len(cw.blockRanges), ) @@ -483,7 +468,6 @@ func (cw *CatchupWorker) Close() error { if err := cw.blockStore.SaveCatchupRanges(cw.chain.GetNetworkInternalCode(), rangesToSave); err != nil { cw.logger.Error("Failed to batch save progress on close", - "chain", cw.chain.GetName(), "ranges", len(rangesToSave), "error", err, ) diff --git a/internal/worker/manual.go b/internal/worker/manual.go index 64ebe62..d3a0450 100644 --- a/internal/worker/manual.go +++ b/internal/worker/manual.go @@ -63,7 +63,7 @@ func NewManualWorker( } func (mw *ManualWorker) Start() { - mw.logger.Info("Starting manual worker", "chain", mw.chain.GetName()) + mw.logger.Info("Starting manual worker") // Periodic metrics mw.executeWithRecovery("manual metrics", func() { @@ -89,14 +89,14 @@ func (mw *ManualWorker) loop() { for { select { case <-ctx.Done(): - mw.logger.Info("Manual worker stopped", "chain", mw.chain.GetName()) + mw.logger.Info("Manual worker stopped") return default: } start, end, err := mw.mbs.GetNextRange(ctx, mw.chain.GetNetworkInternalCode()) if err != nil { - mw.logger.Error("GetNextRange failed", "err", err, "chain", mw.chain.GetName()) + mw.logger.Error("GetNextRange failed", "err", err) time.Sleep(time.Second) continue } @@ -105,7 +105,6 @@ func (mw *ManualWorker) loop() { count, _ := mw.mbs.CountRanges(ctx, mw.chain.GetNetworkInternalCode()) if emptyAttempts >= mw.config.MaxEmptyAttempts { mw.logger.Info("No ranges to process, sleeping", - "chain", mw.chain.GetName(), "sleep", mw.config.EmptySleep, "queued_ranges", count, ) @@ -127,14 +126,13 @@ func (mw *ManualWorker) loop() { func (mw *ManualWorker) handleRange(ctx context.Context, start, end uint64) { mw.logger.Info("Processing range", - "chain", mw.chain.GetName(), "start", start, "end", end, ) results, err := mw.chain.GetBlocks(ctx, start, end, false) if err != nil { - mw.logger.Error("GetBlocks failed", "err", err, "chain", mw.chain.GetName()) + mw.logger.Error("GetBlocks failed", "err", err) time.Sleep(time.Second) return } @@ -147,7 +145,6 @@ func (mw *ManualWorker) handleRange(ctx context.Context, start, end uint64) { } mw.logger.Info("Finished processing", - "chain", mw.chain.GetName(), "start", start, "end", end, "lastSuccess", lastSuccess, @@ -158,7 +155,7 @@ func (mw *ManualWorker) handleRange(ctx context.Context, start, end uint64) { } if lastSuccess >= end { if err := mw.mbs.RemoveRange(ctx, mw.chain.GetNetworkInternalCode(), start, end); err != nil { - mw.logger.Error("RemoveRange failed", "err", err, "chain", mw.chain.GetName()) + mw.logger.Error("RemoveRange failed", "err", err) } } } @@ -166,7 +163,7 @@ func (mw *ManualWorker) handleRange(ctx context.Context, start, end uint64) { func (mw *ManualWorker) logMissingRangesMetric() { ranges, err := mw.mbs.ListRanges(mw.ctx, mw.chain.GetNetworkInternalCode()) if err != nil { - mw.logger.Warn("ListRanges failed", "chain", mw.chain.GetName(), "err", err) + mw.logger.Warn("ListRanges failed", "err", err) return } @@ -180,7 +177,6 @@ func (mw *ManualWorker) logMissingRangesMetric() { } mw.logger.Info("Missing block ranges status", - "chain", mw.chain.GetName(), "status", status, "missing_count", rangeCount, ) diff --git a/internal/worker/mempool.go b/internal/worker/mempool.go index 9bf9f75..c393e26 100644 --- a/internal/worker/mempool.go +++ b/internal/worker/mempool.go @@ -72,7 +72,6 @@ func NewMempoolWorker( // Start begins the mempool polling loop func (mw *MempoolWorker) Start() { mw.logger.Info("Starting mempool worker", - "chain", mw.chain.GetName(), "poll_interval", mw.pollInterval, ) go mw.run(mw.processMempool) @@ -80,13 +79,13 @@ func (mw *MempoolWorker) Start() { // Stop stops the mempool worker func (mw *MempoolWorker) Stop() { - mw.logger.Info("Stopping mempool worker", "chain", mw.chain.GetName()) + mw.logger.Info("Stopping mempool worker") mw.BaseWorker.Stop() } // processMempool polls the mempool for new transactions func (mw *MempoolWorker) processMempool() error { - mw.logger.Debug("Polling mempool", "chain", mw.chain.GetName()) + mw.logger.Debug("Polling mempool") transactions, utxoEvents, err := mw.btcIndexer.GetMempoolTransactions(mw.ctx) if err != nil { diff --git a/internal/worker/regular.go b/internal/worker/regular.go index 4d0687c..afaf2e8 100644 --- a/internal/worker/regular.go +++ b/internal/worker/regular.go @@ -71,7 +71,6 @@ func NewRegularWorker( func (rw *RegularWorker) Start() { rw.logger.Info("Starting regular worker", - "chain", rw.chain.GetName(), "start_block", rw.currentBlock, ) rw.persistTicker = time.NewTicker(blockHashPersistInterval) @@ -122,7 +121,6 @@ func (rw *RegularWorker) processRegularBlocks() error { end := min(start+uint64(rw.config.Throttle.BatchSize)-1, latest) startTime := time.Now() rw.logger.Info("Processing range", - "chain", rw.chain.GetName(), "start", start, "end", end, "size", end-start+1, ) @@ -140,7 +138,6 @@ func (rw *RegularWorker) processRegularBlocks() error { rw.updateHeadStatus(latest, indexedAt) rw.logger.Info("Processed latest blocks", - "chain", rw.chain.GetName(), "start", start, "end", end, "elapsed", time.Since(startTime), "last_success", lastSuccess, @@ -214,13 +211,12 @@ func (rw *RegularWorker) determineStartingBlock() uint64 { chainLatest, chainErr := rw.getLatestBlockWithRetry() if chainErr != nil { rw.logger.Warn("Chain RPC failed, resuming from KV latest", - "chain", rw.chain.GetName(), "kvLatest", kvLatest) + "kvLatest", kvLatest) return kvLatest } if chainLatest > kvLatest { ranges := rw.queueCatchupRanges(kvLatest+1, chainLatest) rw.logger.Info("Queued catchup ranges", - "chain", rw.chain.GetName(), "gap", fmt.Sprintf("%d-%d", kvLatest+1, chainLatest), "ranges_created", len(ranges), ) @@ -230,7 +226,7 @@ func (rw *RegularWorker) determineStartingBlock() uint64 { if kvErr != nil { rw.logger.Error("Block store unavailable, starting from chain head", - "chain", rw.chain.GetName(), "error", kvErr) + "error", kvErr) } return rw.waitForChainHead() } @@ -244,7 +240,6 @@ func (rw *RegularWorker) queueCatchupRanges(start, end uint64) []blockstore.Catc if err := rw.blockStore.SaveCatchupRanges(rw.chain.GetNetworkInternalCode(), ranges); err != nil { rw.logger.Error("Failed to save catchup ranges", - "chain", rw.chain.GetName(), "count", len(ranges), "error", err, ) @@ -277,7 +272,7 @@ func (rw *RegularWorker) waitForChainHead() uint64 { return latest } rw.logger.Warn("Waiting for chain head before starting", - "chain", rw.chain.GetName(), "error", err) + "error", err) select { case <-rw.ctx.Done(): return 0 @@ -306,7 +301,6 @@ func (rw *RegularWorker) detectAndHandleReorg(res *indexer.BlockResult) (bool, e reorgStart = prevNum - rollbackWindow } rw.logger.Warn("Reorg detected; rolling back", - "chain", rw.chain.GetName(), "at_block", prevNum, "expected_parent", storedHash, "actual_parent", res.Block.ParentHash, @@ -373,7 +367,6 @@ func (rw *RegularWorker) loadBlockHashes() { } rw.blockHashes = hashes rw.logger.Info("Loaded persisted block hashes", - "chain", rw.chain.GetName(), "count", len(hashes), ) } @@ -397,7 +390,6 @@ func (rw *RegularWorker) flushBlockHashes() { } if err := rw.blockStore.SaveBlockHashes(rw.chain.GetNetworkInternalCode(), rw.blockHashes); err != nil { rw.logger.Error("Failed to persist block hashes", - "chain", rw.chain.GetName(), "error", err, ) return @@ -421,7 +413,6 @@ func (rw *RegularWorker) skipAheadIfLagging(latest uint64) bool { skipEnd := latest - 1 rw.logger.Warn("Lag threshold exceeded, skipping ahead to chain head", - "chain", rw.chain.GetName(), "current_block", rw.currentBlock, "chain_head", latest, "lag", latest-rw.currentBlock, @@ -436,7 +427,6 @@ func (rw *RegularWorker) skipAheadIfLagging(latest uint64) bool { rw.clearBlockHashes() rw.logger.Info("Skip-ahead complete, queued catchup ranges", - "chain", rw.chain.GetName(), "new_current", rw.currentBlock, "catchup_ranges", len(ranges), ) diff --git a/internal/worker/rescanner.go b/internal/worker/rescanner.go index 72ff67e..612a104 100644 --- a/internal/worker/rescanner.go +++ b/internal/worker/rescanner.go @@ -71,7 +71,6 @@ func NewRescannerWorker( func (rw *RescannerWorker) Start() { rw.logger.Info("Starting rescanner worker", - "chain", rw.chain.GetName(), "interval", rw.interval, "maxRetries", rw.maxRetries, ) @@ -157,7 +156,7 @@ func (rw *RescannerWorker) incrementRetry(block uint64) { delete(rw.failedBlocks, block) rw.addRemove(block) rw.logger.Error("Max retries reached; giving up", - "chain", rw.chain.GetName(), "block", block) + "block", block) } else { rw.failedBlocks[block] = count + 1 rw.addSave(block) @@ -184,7 +183,7 @@ func (rw *RescannerWorker) processRescan() error { time.Sleep(rw.interval) return nil } - rw.logger.Info("Got blocks for rescan", "chain", rw.chain.GetName(), "blocks", len(blocks)) + rw.logger.Info("Got blocks for rescan", "blocks", len(blocks)) return rw.processBatch(blocks) } @@ -220,7 +219,6 @@ func (rw *RescannerWorker) processBatch(blocks []uint64) error { } rw.logger.Info("Rescanner pass", - "chain", rw.chain.GetName(), "retried", len(blocks), "success", success, "remaining", len(rw.failedBlocks), @@ -257,14 +255,12 @@ func (rw *RescannerWorker) flushUnsafe() { if len(rw.pendingSaves) > 0 { _ = rw.blockStore.SaveFailedBlocks(rw.chain.GetNetworkInternalCode(), rw.pendingSaves) rw.logger.Debug("Batch saved failed blocks", - "chain", rw.chain.GetName(), "count", len(rw.pendingSaves)) rw.pendingSaves = rw.pendingSaves[:0] } if len(rw.pendingRemoves) > 0 { _ = rw.blockStore.RemoveFailedBlocks(rw.chain.GetNetworkInternalCode(), rw.pendingRemoves) rw.logger.Debug("Batch removed failed blocks", - "chain", rw.chain.GetName(), "count", len(rw.pendingRemoves)) rw.pendingRemoves = rw.pendingRemoves[:0] } From 5f2a7cec5e4f038d2666f36aff54a302c1dccbe0 Mon Sep 17 00:00:00 2001 From: vietddude Date: Thu, 3 Sep 2026 10:57:24 +0700 Subject: [PATCH 11/11] fix(bloom): tolerate address types missing from the DB enum Bloom init/sync iterate all network types, but the DB's address_type enum may lack some (e.g. 'apt'), so filtering wallet_addresses by that type fails with 22P02 and aborts init. Treat 22P02 (invalid enum value) as no matching rows in both the repository (WrapError) and the raw loader query, so unknown types are simply skipped. --- internal/worker/default_db_loader.go | 7 +++++++ pkg/repository/repository.go | 10 ++++++++++ 2 files changed, 17 insertions(+) diff --git a/internal/worker/default_db_loader.go b/internal/worker/default_db_loader.go index c93a387..1af060f 100644 --- a/internal/worker/default_db_loader.go +++ b/internal/worker/default_db_loader.go @@ -2,8 +2,10 @@ package worker import ( "context" + "errors" "github.com/fystack/multichain-indexer/pkg/model" + "github.com/jackc/pgx/v5/pgconn" "gorm.io/gorm" ) @@ -28,6 +30,11 @@ func (l *DefaultDBLoader) LoadAddresses(ctx context.Context, params AddressLoade Limit(params.Limit). Find(&rows).Error if err != nil { + // Filtering by an enum value the DB type does not define (22P02): no rows. + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) && pgErr.Code == "22P02" { + return nil, nil + } return nil, err } diff --git a/pkg/repository/repository.go b/pkg/repository/repository.go index 308aad8..56b869e 100644 --- a/pkg/repository/repository.go +++ b/pkg/repository/repository.go @@ -27,6 +27,9 @@ var ( // https://github.com/jackc/pgerrcode/blob/master/errcode.go UniqueViolation = "23505" ForeignKeyViolation = "23503" + // InvalidTextRepresentation (22P02) e.g. filtering by an enum value the DB + // type does not define — treated as no matching rows. + InvalidTextRepresentation = "22P02" ) type Repository[T any] interface { @@ -82,6 +85,13 @@ func (r *repository[T]) WrapError(ctx context.Context, err error) error { return nil } + // Filtering by an enum value the DB type does not define (22P02): no such + // rows exist, so treat it as an empty result rather than a hard error. + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) && pgErr.Code == InvalidTextRepresentation { + return nil + } + // otherwise, return original error // this is usually an unidentified internal error if err != nil {