Skip to content

fix: report search_cutoff and 408 correctly for union searches - #3052

Open
alnr wants to merge 4 commits into
typesense:v31from
ory-corp:alnr/search-cutoff-union
Open

alnr wants to merge 4 commits into
typesense:v31from
ory-corp:alnr/search-cutoff-union

Conversation

@alnr

@alnr alnr commented Sep 15, 2026

Copy link
Copy Markdown
Contributor
  • A union took search_cutoff from its last sub-search only, so an earlier sub-search running out of budget went unreported.
  • A union cut off with nothing to return answered 200 with an empty body instead of 408: CollectionManager::do_union returned result_op rather than the 408, and the handler's union 408 branch never finalized its response.

Each sub-search clears the thread-local search_cutoff before it runs, so the
flag do_union reads once at the end belongs to whichever sub-search happened to
go last. A union spends one time budget across all of them and reports one
flag, so a sub-search that ran out of it has to be visible in that flag however
the later ones fared — and a search that finds nothing its filter admits
returns before it reads the clock at all, so the last one is not a reliable
place to read it from.

The same flag gates the 408 a union returns when the budget left it nothing, so
a union could answer 200 with an empty result set and search_cutoff false after
timing out.
CollectionManager::do_union handled a 408 from Collection::do_union by
returning result_op, which is always ok at that point, so the multi_search
handler never saw the timeout and answered 200 with an empty body. It now
returns the 408.

Because of that, the handler's 408 branch for a union had never run, and it
neither marked the response final nor streamed it, unlike every other branch
of post_multi_search. It does both now, so the client gets the 408 rather
than no response at all.

SearchCutoffCoversEverySubSearch expected the empty body and now expects the
408. The new core API test checks the status, the message and that the
response is final.
Comment thread test/core_api_utils_test.cpp Outdated
}

std::shared_ptr<http_req> req = std::make_shared<http_req>();
std::shared_ptr<http_res> res = std::make_shared<http_res>(nullptr);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

this uses http_res(nullptr), so stream_response() returns immediately. the test would still pas if we removed the new streaming call. could we cover the actual response delivery too?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Restructured, and added an API-level test for this. Fixed some related bugs at the same time.

Comment thread test/union_test.cpp
// The first sub-search is cut off before it finds anything and the second
// has nothing its filter admits, so the union has nothing to return. It
// answers 408 only if the flag it reads says a sub-search was cut off.
ASSERT_FALSE(search_op.ok());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

could we also keep a pinned hit in the first search and assert search_cutoff: true when the last search matches nothing?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added, and restructured so the tests no longer depend on the last sub-search happening not to read the clock. Only the first sub-search has its budget spent (search_cutoff_ms: 0 in its own body); the second runs with the default budget and returns real hits. The union has to report search_cutoff: true with found exactly 1 pinned + 20 real hits.

Comment thread test/union_test.cpp Outdated
// Two collections for one union: the first holds documents the search has
// to work through, the second holds none that filter_by admits, so its
// search returns before it ever reads the clock.
void setupCutoffCollections() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

this only used once, in one test, so this can be inlined in the test itself

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

done

Comment thread src/collection.cpp Outdated

// Every sub-search clears search_cutoff before it runs, so the flag left
// behind belongs to the last one. The union spends one budget across all of
// them and reports one flag, which has to mean any of them ran out of it.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Super pedantic and could be ignored, but this could be rewritten as:

// each sub-search resets search_cutoff. preserve if any search was cut off.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

done

Add a union test where an earlier sub-search is cut off but a later one
still returns hits, so the cutoff has to be carried into the response,
check that a cut-off union's 408 is not served from the response cache,
and add an api_tests suite that checks the same union over HTTP,
including delivery of the 408. Also give 408 a reason phrase and answer
any failed union with its error rather than only a 408.
@alnr
alnr requested a review from tharropoulos September 17, 2026 11:21
@alnr

alnr commented Sep 22, 2026 •

Copy link
Copy Markdown
Contributor Author

@tharropoulos can I ask for a quick re-review?

Comment thread src/core_api.cpp Outdated
Option<bool> union_op = CollectionManager::do_union(req->params, req->embedded_params_vec, searches,
response, req->conn_ts, union_remove_duplicates);
if(!union_op.ok() && union_op.code() == 408) {
if(!union_op.ok()) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

could we keep the original 408 condition here? do_union only returns a failed option for 408 right now, so the broader check and nested if don’t change behavior.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

done

}

describe(Phases.SINGLE_FRESH, () => {
it("create the union cutoff collections", async () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

this could be changed for a beforeAll hook instead of a test

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

done

Also restore the 408-only check on a failed union in the handler, since
that is the only failure do_union propagates.
@alnr
alnr requested a review from tharropoulos September 22, 2026 19:22

@tharropoulos tharropoulos left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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