-
Notifications
You must be signed in to change notification settings - Fork 887
feat(evmrpc): bound eth_getLogs peak memory with a matched-log cap #3759
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -8,6 +8,7 @@ import ( | |
| "fmt" | ||
| "sort" | ||
| "sync" | ||
| "sync/atomic" | ||
| "time" | ||
|
|
||
| "github.com/ethereum/go-ethereum/common" | ||
|
|
@@ -802,23 +803,31 @@ func (f *LogFetcher) GetLogsByFilters(ctx context.Context, crit filters.FilterCr | |
| return nil, 0, fmt.Errorf("block range too large (%d), maximum allowed is %d blocks", blockRange, f.filterConfig.maxBlock) | ||
| } | ||
|
|
||
| // maxLog caps the number of matching logs a single query may return, for | ||
| // both bounded and open-ended queries. Exceeding it is an error (not | ||
| // silent truncation), so peak memory stays bounded to the cap. NewFilterAPI | ||
| // coerces a non-positive value to DefaultMaxLogLimit, so limit is always > 0 | ||
| // here; downstream helpers still treat limit <= 0 as uncapped. | ||
| limit := f.filterConfig.maxLog | ||
|
|
||
| // blockHash queries must use the hash-aware block fetch path below. | ||
| // Range-query receipt stores only constrain by numeric block range and | ||
| // do not enforce crit.BlockHash. | ||
| if crit.BlockHash == nil { | ||
| // Try efficient range query first | ||
| // #nosec G115 -- begin and end are validated to be positive block heights above | ||
| if logs, rangeErr := f.tryFilterLogsRange(ctx, uint64(begin), uint64(end), crit); rangeErr == nil { | ||
| if logs, rangeErr := f.tryFilterLogsRange(ctx, uint64(begin), uint64(end), crit, limit); rangeErr == nil { | ||
| return logs, end, nil | ||
| } else if !errors.Is(rangeErr, receipt.ErrRangeQueryNotSupported) { | ||
| // If it's a real error (not just unsupported), return it | ||
| // A real error (including receipt.ErrTooManyLogs) short-circuits; | ||
| // only "unsupported" falls through to the block-by-block path. | ||
| return nil, 0, rangeErr | ||
| } | ||
| } | ||
| // Fall back to block-by-block querying for backends that don't support range queries | ||
|
|
||
| bloomIndexes := EncodeFilters(crit.Addresses, crit.Topics) | ||
| blocks, end, applyOpenEndedLogLimit, err := f.fetchBlocksByCrit(ctx, crit, lastToHeight, bloomIndexes) | ||
| blocks, end, err := f.fetchBlocksByCrit(ctx, crit, lastToHeight, bloomIndexes) | ||
| if err != nil { | ||
| return nil, 0, err | ||
| } | ||
|
|
@@ -828,14 +837,27 @@ func (f *LogFetcher) GetLogsByFilters(ctx context.Context, crit filters.FilterCr | |
| sortedBatches := make([][]*ethtypes.Log, 0) | ||
| var wg sync.WaitGroup | ||
| var submitError error | ||
| // collected tracks matched logs across workers so the fan-out can stop | ||
| // queueing batches once the cap is exceeded. | ||
| var collected int64 | ||
| capExceeded := func() bool { | ||
| return limit > 0 && atomic.LoadInt64(&collected) > limit | ||
| } | ||
|
|
||
| processBatch := func(batch []*coretypes.ResultBlock) { | ||
| defer wg.Done() | ||
| // Each worker gets a clean slice from the pool | ||
| localLogs := f.globalLogSlicePool.Get() | ||
|
|
||
| for _, block := range batch { | ||
| // Cooperative early-abort: once any worker has pushed the matched | ||
| // count past the cap, stop materializing further blocks. | ||
| if capExceeded() { | ||
| break | ||
| } | ||
| before := len(localLogs) | ||
| f.GetLogsForBlockPooled(block, crit, &localLogs) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [nit] Nit: the cap is checked only between blocks, so |
||
| atomic.AddInt64(&collected, int64(len(localLogs)-before)) | ||
| } | ||
|
|
||
| // Sort the local batch | ||
|
|
@@ -855,6 +877,11 @@ func (f *LogFetcher) GetLogsByFilters(ctx context.Context, crit filters.FilterCr | |
| // Batch process with fail-fast | ||
| blockBatch := make([]*coretypes.ResultBlock, 0, evmrpcconfig.WorkerBatchSize) | ||
| for block := range blocks { | ||
| // Stop queueing once the cap is exceeded; in-flight batches finish and | ||
| // the overflow is reported after wg.Wait. | ||
| if capExceeded() { | ||
| break | ||
| } | ||
| blockBatch = append(blockBatch, block) | ||
|
|
||
| if len(blockBatch) >= evmrpcconfig.WorkerBatchSize { | ||
|
|
@@ -874,8 +901,8 @@ func (f *LogFetcher) GetLogsByFilters(ctx context.Context, crit filters.FilterCr | |
| return nil, 0, submitError | ||
| } | ||
|
|
||
| // Process remaining blocks | ||
| if len(blockBatch) > 0 { | ||
| // Process remaining blocks, unless the cap is already exceeded | ||
| if len(blockBatch) > 0 && !capExceeded() { | ||
| wg.Add(1) | ||
| if err := runner.SubmitWithMetrics(func() { processBatch(blockBatch) }); err != nil { | ||
| wg.Done() | ||
|
|
@@ -893,13 +920,15 @@ func (f *LogFetcher) GetLogsByFilters(ctx context.Context, crit filters.FilterCr | |
| } | ||
| }() | ||
|
|
||
| // Push the cap into the merge so it stops popping once the limit is reached, | ||
| // bounding the merged result allocation to O(maxLog) instead of O(total | ||
| // matching logs). A limit of 0 means no cap. | ||
| var limit int64 | ||
| if applyOpenEndedLogLimit { | ||
| limit = f.filterConfig.maxLog | ||
| // Drain the blocks channel so its producer goroutines are not left blocked | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [nit] The rationale in this comment is slightly inaccurate. |
||
| // on a full buffer when we broke out of the loop above on overflow. | ||
| for range blocks { | ||
| } | ||
|
|
||
| if limit > 0 && collected > limit { | ||
| return nil, 0, receipt.NewTooManyLogsError(limit) | ||
| } | ||
|
|
||
| res = f.mergeSortedLogs(sortedBatches, limit) | ||
|
|
||
| // Ensure we never return nil, always return an array (even if empty) | ||
|
|
@@ -974,7 +1003,9 @@ func (f *LogFetcher) earliestHeight(ctx context.Context) (int64, error) { | |
|
|
||
| // tryFilterLogsRange attempts to use the efficient range query if supported by the backend. | ||
| // Returns ErrRangeQueryNotSupported if the backend doesn't support range queries. | ||
| func (f *LogFetcher) tryFilterLogsRange(ctx context.Context, fromBlock, toBlock uint64, crit filters.FilterCriteria) ([]*ethtypes.Log, error) { | ||
| // When limit > 0 the backend aborts with receipt.ErrTooManyLogs once more than | ||
| // limit logs match, bounding peak memory; limit <= 0 means no cap. | ||
| func (f *LogFetcher) tryFilterLogsRange(ctx context.Context, fromBlock, toBlock uint64, crit filters.FilterCriteria, limit int64) ([]*ethtypes.Log, error) { | ||
| store := f.k.ReceiptStore() | ||
| if store == nil { | ||
| return nil, receipt.ErrRangeQueryNotSupported | ||
|
|
@@ -984,7 +1015,7 @@ func (f *LogFetcher) tryFilterLogsRange(ctx context.Context, fromBlock, toBlock | |
| // #nosec G115 -- toBlock is a block height which fits in int64 | ||
| sdkCtx := f.ctxProvider(int64(toBlock)) | ||
|
|
||
| logs, err := store.FilterLogs(sdkCtx, fromBlock, toBlock, crit) | ||
| logs, err := store.FilterLogs(sdkCtx, fromBlock, toBlock, crit, limit) | ||
|
cursor[bot] marked this conversation as resolved.
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [suggestion] The range path applies the cap against the store's pre-normalization count. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [suggestion] The cap is enforced by the store on the raw tag-index match count, which includes non-EVM-visible / synthetic logs, before |
||
| if err != nil { | ||
| return nil, err | ||
| } | ||
|
|
@@ -993,7 +1024,18 @@ func (f *LogFetcher) tryFilterLogsRange(ctx context.Context, fromBlock, toBlock | |
| return []*ethtypes.Log{}, nil | ||
| } | ||
|
|
||
| return f.normalizeRangeQueryLogs(ctx, logs, crit) | ||
| normalized, err := f.normalizeRangeQueryLogs(ctx, logs, crit) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
|
|
||
| // Re-check the cap against the normalized (EVM-visible) count, which the | ||
| // store's tag-index count does not match. | ||
| if limit > 0 && int64(len(normalized)) > limit { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [nit] This post-normalization re-check appears to be dead code: it only fires when |
||
| return nil, receipt.NewTooManyLogsError(limit) | ||
| } | ||
|
|
||
| return normalized, nil | ||
| } | ||
|
|
||
| // normalizeRangeQueryLogs corrects BlockHash, TxIndex, and LogIndex on logs | ||
|
|
@@ -1170,57 +1212,61 @@ func MatchesCriteria(log *ethtypes.Log, crit filters.FilterCriteria) bool { | |
| } | ||
|
|
||
| // Optimized fetchBlocksByCrit with batch processing | ||
| func (f *LogFetcher) fetchBlocksByCrit(ctx context.Context, crit filters.FilterCriteria, lastToHeight int64, bloomIndexes [][]BloomIndexes) (chan *coretypes.ResultBlock, int64, bool, error) { | ||
| func (f *LogFetcher) fetchBlocksByCrit(ctx context.Context, crit filters.FilterCriteria, lastToHeight int64, bloomIndexes [][]BloomIndexes) (chan *coretypes.ResultBlock, int64, error) { | ||
| if crit.BlockHash != nil { | ||
| // Check for invalid zero hash | ||
| zeroHash := common.Hash{} | ||
| if *crit.BlockHash == zeroHash { | ||
| // For invalid hash, return empty channel instead of error | ||
| res := make(chan *coretypes.ResultBlock) | ||
| close(res) | ||
| return res, 0, false, nil | ||
| return res, 0, nil | ||
| } | ||
|
|
||
| block, err := blockByHashRespectingWatermarks(ctx, f.tmClient, f.watermarks, crit.BlockHash[:], 1) | ||
| if err != nil { | ||
| // For non-existent blocks, return empty channel instead of error | ||
| res := make(chan *coretypes.ResultBlock) | ||
| close(res) | ||
| return res, 0, false, nil | ||
| return res, 0, nil | ||
| } | ||
| res := make(chan *coretypes.ResultBlock, 1) | ||
| res <- block | ||
| close(res) | ||
| return res, 0, false, nil | ||
| return res, 0, nil | ||
| } | ||
|
|
||
| applyOpenEndedLogLimit := f.filterConfig.maxLog > 0 && (crit.FromBlock == nil || crit.ToBlock == nil) | ||
| // Open-ended queries (missing fromBlock or toBlock) have their block range | ||
| // windowed down to the most recent maxBlock blocks rather than erroring; a | ||
| // bounded query whose range is too large still errors. The matched-log cap | ||
| // (maxLog) is enforced separately by the caller. | ||
| applyOpenEndedBlockWindow := f.filterConfig.maxLog > 0 && (crit.FromBlock == nil || crit.ToBlock == nil) | ||
| latest, err := f.watermarks.LatestHeight(ctx) | ||
| if err != nil { | ||
| return nil, 0, false, err | ||
| return nil, 0, err | ||
| } | ||
| earliest, err := f.watermarks.EarliestHeight(ctx) | ||
| if err != nil { | ||
| earliest = 0 | ||
| } | ||
| begin, end, err := ComputeBlockBounds(latest, earliest, lastToHeight, crit) | ||
| if err != nil { | ||
| return nil, 0, false, err | ||
| return nil, 0, err | ||
| } | ||
|
|
||
| blockRange := end - begin + 1 | ||
| if applyOpenEndedLogLimit && blockRange > f.filterConfig.maxBlock { | ||
| if applyOpenEndedBlockWindow && blockRange > f.filterConfig.maxBlock { | ||
| begin = end - f.filterConfig.maxBlock + 1 | ||
| if begin < earliest { | ||
| begin = earliest | ||
| } | ||
| } else if !applyOpenEndedLogLimit && f.filterConfig.maxBlock > 0 && blockRange > f.filterConfig.maxBlock { | ||
| } else if !applyOpenEndedBlockWindow && f.filterConfig.maxBlock > 0 && blockRange > f.filterConfig.maxBlock { | ||
| // Use consistent error message format | ||
| return nil, 0, false, fmt.Errorf("block range too large (%d), maximum allowed is %d blocks", blockRange, f.filterConfig.maxBlock) | ||
| return nil, 0, fmt.Errorf("block range too large (%d), maximum allowed is %d blocks", blockRange, f.filterConfig.maxBlock) | ||
| } | ||
|
|
||
| if begin > end { | ||
| return nil, 0, false, fmt.Errorf("fromBlock %d is after toBlock %d", begin, end) | ||
| return nil, 0, fmt.Errorf("fromBlock %d is after toBlock %d", begin, end) | ||
| } | ||
|
|
||
| res := make(chan *coretypes.ResultBlock, end-begin+1) | ||
|
|
@@ -1243,7 +1289,7 @@ func (f *LogFetcher) fetchBlocksByCrit(ctx context.Context, crit filters.FilterC | |
| } | ||
| }(batchStart, batchEnd)); err != nil { | ||
| wg.Done() | ||
| return nil, 0, false, fmt.Errorf("system overloaded, please reduce request frequency: %w", err) | ||
| return nil, 0, fmt.Errorf("system overloaded, please reduce request frequency: %w", err) | ||
| } | ||
| } | ||
|
|
||
|
|
@@ -1262,10 +1308,10 @@ func (f *LogFetcher) fetchBlocksByCrit(ctx context.Context, crit filters.FilterC | |
| } | ||
|
|
||
| if firstErr != nil { | ||
| return nil, 0, false, firstErr | ||
| return nil, 0, firstErr | ||
| } | ||
|
|
||
| return res, end, applyOpenEndedLogLimit, nil | ||
| return res, end, nil | ||
| } | ||
|
|
||
| // Batch processing function for blocks | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[suggestion]
limitis sourced fromMaxLogNoBlock, whose config doc reads "max number of logs returned if block range is open-ended." Onmain, bounded queries were uncapped on both the range path (tryFilterLogsRangepassed no limit) and the block-by-block path (limit only applied whenapplyOpenEndedLogLimit). Applyinglimitunconditionally here silently repurposes an open-ended-only setting into a hard error threshold for bounded queries: a boundedeth_getLogsmatching more thanMaxLogNoBlock(default 10000) logs now returnsErrTooManyLogswhere it previously succeeded. If this broader cap is intended, update themax_log_no_blockdoc comment (config.go:103 and the config template) and the changelog; otherwise consider preserving the open-ended-only semantics or introducing a distinct general-cap setting.