Skip to content

fix(routing/http): cap records after filtering#1157

Open
lidel wants to merge 4 commits into
mainfrom
fix/post-filter-records-limit
Open

fix(routing/http): cap records after filtering#1157
lidel wants to merge 4 commits into
mainfrom
fix/post-filter-records-limit

Conversation

@lidel
Copy link
Copy Markdown
Member

@lidel lidel commented May 19, 2026

Problem

The Delegated Routing server applied the records limit before filter-addrs/filter-protocols ran, so records the filters dropped shrank /routing/v1/providers and /routing/v1/peers responses below the configured limit.

This Fix

The server now calls the delegate FindProviders/FindPeers with a limit of 0 (unbounded), reads the result iterator lazily, and applies the cap itself after filtering. A delegate that used the limit to end its walk early will now end it on Close instead.

See it in action in:

Changes

  • routing/http/types/iter: add Limit, an iterator that caps another iterator at a fixed number of values
  • routing/http/server: apply the records limit post-filter; pass 0 to the delegate; update tests and DelegatedRouter docs

The Delegated Routing server applied the records limit before
filter-addrs/filter-protocols ran, so records the filters dropped
shrank /routing/v1/providers and /routing/v1/peers responses below
the configured limit.

The server now calls the delegate FindProviders/FindPeers with a
limit of 0 (unbounded), reads the result iterator lazily, and
applies the cap itself after filtering. A delegate that used the
limit to end its walk early will now end it on Close instead.

- routing/http/types/iter: add Limit, an iterator that caps
  another iterator at a fixed number of values
- routing/http/server: apply the records limit post-filter; pass
  0 to the delegate; update tests and DelegatedRouter docs
@codecov
Copy link
Copy Markdown

codecov Bot commented May 19, 2026

Codecov Report

❌ Patch coverage is 95.45455% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 63.21%. Comparing base (4ea1540) to head (a4885f6).

Files with missing lines Patch % Lines
routing/http/types/iter/filter.go 80.00% 1 Missing and 1 partial ⚠️

Impacted file tree graph

@@            Coverage Diff             @@
##             main    #1157      +/-   ##
==========================================
+ Coverage   63.15%   63.21%   +0.05%     
==========================================
  Files         267      268       +1     
  Lines       26867    26884      +17     
==========================================
+ Hits        16969    16995      +26     
+ Misses       8173     8166       -7     
+ Partials     1725     1723       -2     
Files with missing lines Coverage Δ
routing/http/server/server.go 77.83% <100.00%> (+1.67%) ⬆️
routing/http/types/iter/limit.go 100.00% <100.00%> (ø)
routing/http/types/iter/filter.go 88.23% <80.00%> (ø)

... and 12 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@lidel lidel marked this pull request as ready for review May 19, 2026 22:33
@lidel lidel requested a review from a team as a code owner May 19, 2026 22:33
Copy link
Copy Markdown
Contributor

@guillaumemichel guillaumemichel left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems like a better approach than ipfs/someguy#149 👍🏻

Would it make sense to use FindProvidersAsync() instead of FindProviders()? It is the more standard way to find provider (go-libp2p interface), and gives caller more control.

FindProvidersAsync(ctx, cid, 0) would allow to cancel the request (cancel context) after we are happy with the results (e.g enough providers with addresses), so that we don't solicit the content router more than strictly needed.

@lidel
Copy link
Copy Markdown
Member Author

lidel commented May 20, 2026

Hm.. the goal here (cancel once we have enough, don't over-ask the router) is right, and it's already what this PR does.

@guillaumemichel lmk if i misunderstood, but the DelegatedRouter.FindProviders is slightly different from go-libp2p interface:

  • go-libp2p ContentDiscovery.FindProvidersAsync returns <-chan peer.AddrInfo a value channel, no error channel
  • boxo's DelegatedRouter is a separate interface, returning iter.ResultIter[types.Record] (richer type than peer.AddrInfo, and is lazy: the server pulls record by record, stops once iter.Limit is satisfied, and the deferred Close() cancels the rest

Is the idea to lift FindProvidersAsync to DelegatedRouter? If so, it wouldn't add control we lack, and it has costs:

  • <-chan peer.AddrInfo has no slot for per-item errors; iter.Result does.
  • a channel forces a goroutine even for synchronous delegates. boxo's own HTTP-delegated client is a goroutine-free streaming decoder today; a channel interface would spawn a pump goroutine per request.
  • ApplyFiltersToIter, Limit, and the ndjson writer are all iter-based, so a channel would be converted right back into an iterator.

So I'd keep FindProviders as-is. I did expand the DelegatedRouter doc comment to spell out the lazy + Close() contract, so implementers know to stream results and stop work on Close.

Copy link
Copy Markdown
Contributor

@guillaumemichel guillaumemichel left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Alright, I was missing a piece of the puzzle in someguy.

Everything looks good, just 2 nits inline

Comment thread routing/http/server/server.go Outdated
Comment thread routing/http/server/server.go Outdated
lidel added 3 commits May 24, 2026 16:11
Close limitedIter right after iter.ReadAllResults so the upstream
router request is canceled before JSON marshaling and the HTTP
write, instead of being held open until the handler returns.
Iterate on rejected values instead of recursing so long reject
runs (now reachable since the server pulls unbounded results
from the delegate) don't grow the goroutine stack proportionally
to the rejected count.
lidel added a commit to ipfs/someguy that referenced this pull request May 24, 2026
@lidel
Copy link
Copy Markdown
Member Author

lidel commented May 24, 2026

Thanks @guillaumemichel. Applied your eager-close suggestion in 1d14daa. Also pushed a peers-side regression test (13c6e41) and a loop fix for FilterIter.Next (a4885f6).

On the FilterIter one: every filtered-out record used to add a frame to the goroutine stack via return f.Next(). Not catastrophic since Go grows the stack as needed, but depth is bounded only by what the delegate returns, and this PR removes the upstream cap. We've seen IPNI return ~15k graphsync records in a single request that filter-protocols then drops. Unlikely we'll hit >50k any time soon, but felt safer to switch to a loop now than wait for it to actually stack-overflow.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants