Skip to content

fix(rank-fusion): stop waiting without limit for a searcher that does not answer - #3453

Merged
marevol merged 1 commit into
mainfrom
fix/rank-fusion-searcher-timeout
Sep 15, 2026
Merged

marevol merged 1 commit into
mainfrom
fix/rank-fusion-searcher-timeout

Conversation

@marevol

@marevol marevol commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

Problem

RankFusionProcessor#searchWithMultipleSearchers collects every searcher's result with Future#get() and no timeout (RankFusionProcessor.java:404). With semantic search enabled, SemanticChunkSearcher embeds the query before it searches, so the fused search can only be as fast as the embedding provider. A provider that accepts the connection and never answers holds every search for as long as its client keeps waiting and retrying. With fess-llm-ollama defaults that is 3 × 60 s, and the search came back after about 186 s. The response then contained the keyword results only, and nothing in it said that part of the search was missing.

The main searcher is collected through the same pool. Under load, a search can also wait for a pool thread held by semantic searches that are stuck on the provider.

Found while verifying 15.9.0; 15.8.0 has the same wait.

What changed

  • New setting rank.fusion.timeout (milliseconds, default 10000, the same as query.timeout). It bounds how long searchWithMultipleSearchers waits for the searchers other than the main one, counted from when they are submitted. 0 or less keeps the previous unbounded wait.
  • A searcher that has not answered in time is dropped from that search. Its future is cancelled, and a WARN names the searcher and the setting.
  • The response reports it. It is flagged partialResults and timedOut, so the results page shows labels.process_time_is_exceeded and /api/v2/search returns "partial": true, "timed_out": true. Keyword results that are missing their semantic half no longer look complete.
  • The main searcher runs on the calling thread. The new timeout does not apply to it; the search engine's own timeouts (query.timeout, index.search.timeout) do. It also no longer waits for a pool thread, so stuck semantic searches cannot delay the keyword results. Exception handling is the same as before: InvalidQueryException, ResultOffsetExceededException and InvalidAccessTokenException are rethrown, and anything else is logged and treated as an empty result.

FessConfig gains getRankFusionTimeout() / getRankFusionTimeoutAsInteger() and a default-map entry, in the generated form.

Compatibility and scope

  • A semantic leg that regularly takes longer than 10 s is now dropped from the search where it used to be waited for. Such a response is flagged and logged, and raising rank.fusion.timeout restores the old behaviour.
  • Only the path where Fess fuses the results itself changes. With rank.fusion.engine.enabled=true, searchWithEngineFusion still builds each sub-query, and so embeds the query, on the calling thread before it sends the single fused request. That path is not bounded by this setting yet.
  • A timed-out searcher's pool thread stays busy until the provider's own timeout ends the call. Cancelling interrupts backoff sleeps, but not a blocking socket read. codelibs/fess-llm-ollama has a separate change that stops retrying read timeouts, which keeps that time to one timeout. Neither change requires the other.

Verification

  • New RankFusionProcessorTimeoutTest:
    • test_searcherThatDoesNotAnswer_isLeftOutAfterTimeout: a searcher blocks for 5 s with the timeout at 200 ms. The main searcher's 10 hits come back well under 3 s, flagged partial and timed out. Against the previous code the search took 5003 ms.
    • test_searcherWithinTimeout_isFused, test_mainSearcherIsNotCutByTimeout and test_nonPositiveTimeout_waitsForEverySearcher guard the rest of the contract. They passed before as well.
  • The 143 tests in the rank-fusion package pass unchanged, including the concurrency, edge-case and error-handling test classes.
  • mvn test: 7491 tests, 0 failures.

End-to-end check: a Fess ZIP built from main (before) and from this branch (after), run with fess-llm-ollama from main and semantic search enabled. A local stub server's /api/embed answered late, never answered, reset the connection, or refused it. The index was empty, so the numbers measure the wait, not the ranking.

/api/embed Before After
answers at once 0.03 s 0.03 s
answers after 3 s 3.0 s, not flagged 3.0 s, not flagged
answers after 15 s 15.0 s, not flagged 10.0 s, partial + timed_out
never answers 186.9 s, not flagged 10.0 s, partial + timed_out
resets the connection 6.2 s, not flagged 5.5 s, not flagged
connection refused 5.8 s, not flagged 6.1 s, not flagged

Each timed-out search logged semantic_chunk did not return results within 10000ms (rank.fusion.timeout); it is left out of this search. A reset or refused endpoint fails inside the searcher before the timeout, and the searcher already falls back to keyword results for that, so those searches stay unflagged, as before.

In the "never answers" run, cancelling the searcher interrupted the plugin's retry backoff (Retry interrupted), which freed its pool thread at the next sleep. With the fess-llm-ollama change applied as well, the search again returned in 10.0 s, and the pool thread was released after a single 60 s read timeout.

… not answer

RankFusionProcessor#searchWithMultipleSearchers collected every searcher's
result with Future#get() and no timeout. With semantic search enabled, the
semantic chunk searcher embeds the query before it searches, so an embedding
provider that accepts the connection and never answers held every search for
as long as the provider's client kept waiting and retrying, and the search then
returned the keyword results with nothing to say that part of it was missing.
The main searcher was collected through the same pool, so under load it could
also queue for a thread behind searchers that were stuck that way.

The searchers other than the main one are now waited for up to
rank.fusion.timeout milliseconds (default 10000, the same as query.timeout),
counted from their submission. One that has not answered by then is cancelled
and left out of that search with a WARN that names it and the setting, and the
response is flagged partial and timed out, so the search screen shows
labels.process_time_is_exceeded and the search APIs return partial and
timed_out. The main searcher now runs on the calling thread: the timeout does
not apply to it, since the search engine's own timeouts do, and it no longer
needs a pool thread. 0 or less keeps the previous unbounded wait.

Only the path where Fess fuses the results itself changes. With
rank.fusion.engine.enabled=true the sub-queries are still built on the calling
thread before the single fused request is sent.
@marevol marevol added this to the 15.9.0 milestone Sep 15, 2026
@marevol marevol self-assigned this Sep 15, 2026
@marevol
marevol merged commit 1e586dc into main Sep 15, 2026
2 checks passed
@marevol
marevol deleted the fix/rank-fusion-searcher-timeout branch September 16, 2026 13:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant