Skip to content

Commit b9cbd23

Browse files
Added fail-fast on leg query error
Signed-off-by: Martin Gaievski <gaievski@amazon.com>
1 parent 457d8f6 commit b9cbd23

2 files changed

Lines changed: 59 additions & 80 deletions

File tree

src/main/java/org/opensearch/neuralsearch/query/HybridFusionOrchestrator.java

Lines changed: 35 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@
1313
import java.util.Map;
1414
import java.util.Objects;
1515

16-
import lombok.extern.log4j.Log4j2;
1716
import org.opensearch.action.search.MultiSearchRequest;
1817
import org.opensearch.action.search.MultiSearchResponse;
1918
import org.opensearch.action.search.SearchRequest;
@@ -43,7 +42,6 @@
4342
* normalization + {@code arithmetic_mean} combination (the caller rejects other techniques at rewrite for now).
4443
*/
4544
@NoArgsConstructor(access = AccessLevel.PRIVATE)
46-
@Log4j2
4745
final class HybridFusionOrchestrator {
4846

4947
private static final ScoreCombinationFactory SCORE_COMBINATION_FACTORY = new ScoreCombinationFactory();
@@ -57,6 +55,11 @@ final class HybridFusionOrchestrator {
5755
* request/response processors once per leg (redundant, and incorrect for processors like {@code rerank} that expect
5856
* request context absent from an id-only leg). The outer fused request still carries the pipeline, so top-level
5957
* processors run exactly once.
58+
*
59+
* <p>Legs disable partial results ({@code allowPartialSearchResults(false)}): fused relevance is computed across a
60+
* leg's full window, so a leg silently truncated by a down shard would be as corrupting as a fully failed leg.
61+
* Failing the leg instead lets {@link #groupLegHits} fail the whole request fast, rather than fusing over incomplete
62+
* data — this is the leg-level half of the fail-fast contract.
6063
*/
6164
static MultiSearchRequest buildLegMultiSearch(SearchRequest request, List<QueryBuilder> legs, int windowSize) {
6265
MultiSearchRequest multiSearchRequest = new MultiSearchRequest();
@@ -70,6 +73,7 @@ static MultiSearchRequest buildLegMultiSearch(SearchRequest request, List<QueryB
7073
new SearchRequest(request.indices()).indicesOptions(request.indicesOptions())
7174
.source(legSource)
7275
.pipeline(SearchPipelineService.NOOP_PIPELINE_ID)
76+
.allowPartialSearchResults(false)
7377
);
7478
}
7579
return multiSearchRequest;
@@ -109,13 +113,15 @@ static QueryBuilder buildFusedQuery(
109113
} else {
110114
topOnly = true; // track_total_hits:false -> plain top-K, no Tail
111115
}
112-
List<QueryBuilder> tail = topOnly ? List.of() : survivingLegQueries(legs, legHits);
116+
List<QueryBuilder> tail = topOnly ? List.of() : legQueriesForTail(legs, legHits);
113117
return new HybridFusionQuery(ranked.ids(), ranked.scores(), tail);
114118
}
115119

116120
/**
117-
* Reduce the raw MultiSearch items into a per-leg array of hits (one item per leg). Graceful per-leg failure: a
118-
* failed sub-search sets its slot to null and is skipped by fusion; only when ALL legs failed do we throw.
121+
* Reduce the raw MultiSearch items into a per-leg array of hits (one item per leg). Fail fast on ANY leg failure:
122+
* fused relevance is computed across all legs (min_max normalization + combination), so a dropped leg would silently
123+
* change the ranking function — a partial fused result is semantically different, not just smaller. So a single
124+
* failed sub-search fails the whole request rather than degrading to the surviving legs.
119125
*/
120126
private static SearchHit[][] groupLegHits(MultiSearchResponse.Item[] items, int legCount) {
121127
if (items.length != legCount) {
@@ -124,50 +130,31 @@ private static SearchHit[][] groupLegHits(MultiSearchResponse.Item[] items, int
124130
);
125131
}
126132
SearchHit[][] legHits = new SearchHit[legCount][];
127-
int survivingLegs = 0;
128133
for (int leg = 0; leg < legCount; leg++) {
129134
MultiSearchResponse.Item item = items[leg];
130135
if (item.isFailure()) {
131-
log.warn("[hybrid] fused-mode sub-query {} dropped: {}", leg, item.getFailureMessage());
132-
legHits[leg] = null;
133-
} else {
134-
legHits[leg] = item.getResponse().getHits().getHits();
135-
survivingLegs++;
136+
throw new IllegalStateException(
137+
String.format(Locale.ROOT, "[hybrid] fused-mode sub-query %d failed: %s", leg, item.getFailureMessage()),
138+
item.getFailure()
139+
);
136140
}
137-
}
138-
if (survivingLegs == 0) {
139-
MultiSearchResponse.Item firstFailure = firstFailure(items);
140-
throw new IllegalStateException(
141-
"[hybrid] all fused-mode sub-queries failed"
142-
+ (Objects.isNull(firstFailure) ? "" : ": " + firstFailure.getFailureMessage()),
143-
Objects.isNull(firstFailure) ? null : firstFailure.getFailure()
144-
);
141+
legHits[leg] = item.getResponse().getHits().getHits();
145142
}
146143
return legHits;
147144
}
148145

149-
private static MultiSearchResponse.Item firstFailure(MultiSearchResponse.Item[] items) {
150-
for (MultiSearchResponse.Item item : items) {
151-
if (item.isFailure()) {
152-
return item;
153-
}
154-
}
155-
return null;
156-
}
157-
158146
/**
159147
* Fuse via the shared {@link CoordinatorScoreFusion} core (min_max + arithmetic_mean), then rank by fused score and
160148
* cut to the window. Converts the coordinator's {@code SearchHit[][]} view into the {@code _id}-keyed per-leg maps
161-
* the shared core consumes; a dropped (null) leg contributes an empty map.
149+
* the shared core consumes; a leg that matched nothing contributes an empty map (groupLegHits fails fast on failures,
150+
* so every slot is non-null).
162151
*/
163152
private static RankedDocs computeRankedDocs(SearchHit[][] legHits, FusionSpec fusion, int windowSize) {
164153
List<Map<String, Float>> legRawScores = new ArrayList<>(legHits.length);
165154
for (SearchHit[] hits : legHits) {
166155
Map<String, Float> byId = new LinkedHashMap<>();
167-
if (Objects.nonNull(hits)) {
168-
for (SearchHit hit : hits) {
169-
byId.put(hit.getId(), hit.getScore());
170-
}
156+
for (SearchHit hit : hits) {
157+
byId.put(hit.getId(), hit.getScore());
171158
}
172159
legRawScores.add(byId);
173160
}
@@ -231,28 +218,25 @@ private static RankedDocs toRankedDocs(Map<String, Float> scoresById, int window
231218
return new RankedDocs(ids, scores);
232219
}
233220

234-
/** The sub-query legs restricted to those that survived (non-null hits slot); used for the Tail so a failed leg is
235-
* not re-executed in the self-erased query (graceful degradation). */
236-
private static List<QueryBuilder> survivingLegQueries(List<QueryBuilder> legs, SearchHit[][] legHits) {
237-
// groupLegHits guarantees legHits.length == legs.size(), so a surviving leg is exactly a non-null hits slot.
238-
List<QueryBuilder> surviving = new ArrayList<>(legs.size());
221+
/** The sub-query legs in their Tail form. groupLegHits fails fast on any leg failure, so every leg is present here
222+
* (legHits.length == legs.size(), no null slots). A kNN/neural leg's match set IS its returned top-k, so it is
223+
* materialized as an {@link IdsQueryBuilder} of its already-retrieved ids rather than re-walking the HNSW graph in
224+
* the Tail purely to count; other legs are used as-is. */
225+
private static List<QueryBuilder> legQueriesForTail(List<QueryBuilder> legs, SearchHit[][] legHits) {
226+
List<QueryBuilder> tail = new ArrayList<>(legs.size());
239227
for (int legIndex = 0; legIndex < legs.size(); legIndex++) {
240-
if (Objects.nonNull(legHits[legIndex])) {
241-
QueryBuilder leg = legs.get(legIndex);
242-
// A kNN/neural leg's match set IS its returned top-k — re-running it in the Tail would walk the HNSW
243-
// graph again purely to count. Materialize such legs as their already-retrieved ids instead.
244-
if (isMaterializableLeg(leg)) {
245-
IdsQueryBuilder ids = new IdsQueryBuilder();
246-
for (SearchHit hit : legHits[legIndex]) {
247-
ids.addIds(hit.getId());
248-
}
249-
surviving.add(ids);
250-
} else {
251-
surviving.add(leg);
228+
QueryBuilder leg = legs.get(legIndex);
229+
if (isMaterializableLeg(leg)) {
230+
IdsQueryBuilder ids = new IdsQueryBuilder();
231+
for (SearchHit hit : legHits[legIndex]) {
232+
ids.addIds(hit.getId());
252233
}
234+
tail.add(ids);
235+
} else {
236+
tail.add(leg);
253237
}
254238
}
255-
return surviving;
239+
return tail;
256240
}
257241

258242
/** Legs whose Lucene match set is their own top-k (re-running them in the Tail = a redundant ANN pass). */

src/test/java/org/opensearch/neuralsearch/query/HybridFusionOrchestratorTests.java

Lines changed: 24 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,8 @@ public void testBuildLegMultiSearch_perLegSourceShape() {
7676
assertEquals(0, source.from());
7777
assertFalse(source.fetchSource().fetchSource());
7878
assertEquals(SearchPipelineService.NOOP_PIPELINE_ID, leg.pipeline());
79+
// Legs are strict: a leg truncated by a down shard must fail (so fail-fast catches it), not return partial.
80+
assertEquals(Boolean.FALSE, leg.allowPartialSearchResults());
7981
}
8082
}
8183

@@ -149,41 +151,48 @@ public void testBuildFusedQuery_windowCapsRankedDocs() {
149151
assertEquals("window=2 caps the Top to 2 docs", 2, self.should().size());
150152
}
151153

152-
// ---- graceful leg failure ----
154+
// ---- fail-fast on leg failure ----
153155

154-
public void testBuildFusedQuery_oneLegFailed_survivesOnRemaining() {
156+
public void testBuildFusedQuery_whenAnyLegFailed_thenFailsFast() {
157+
// A single failed leg fails the whole request: partial fusion would silently change the ranking function, so we
158+
// do not degrade to the surviving legs. The failing leg's index is reported and its exception chained as cause.
155159
List<QueryBuilder> legs = List.of(new MatchQueryBuilder("text", "hello"), new TermQueryBuilder("text", "place"));
156160
MultiSearchResponse ms = multiSearch(legItem(Map.of("1", 0.9f, "2", 0.5f)), failedItem());
157161

158-
QueryBuilder fused = HybridFusionOrchestrator.buildFusedQuery(
159-
new SearchSourceBuilder().trackTotalHits(false),
160-
ms,
161-
legs,
162-
minMaxArithmetic(),
163-
10,
164-
true
162+
IllegalStateException e = expectThrows(
163+
IllegalStateException.class,
164+
() -> HybridFusionOrchestrator.buildFusedQuery(
165+
new SearchSourceBuilder().trackTotalHits(false),
166+
ms,
167+
legs,
168+
minMaxArithmetic(),
169+
10,
170+
true
171+
)
165172
);
166-
167-
BoolQueryBuilder self = ((HybridFusionQuery) fused).buildSelfErasedQuery();
168-
assertEquals("fuses over the surviving leg", 2, self.should().size());
173+
assertTrue("reports the failing leg index", e.getMessage().contains("fused-mode sub-query 1 failed"));
174+
assertNotNull("chains the leg failure as cause", e.getCause());
175+
assertTrue(e.getCause().getMessage().contains("leg boom"));
169176
}
170177

171-
public void testBuildFusedQuery_allLegsFailed_throws() {
178+
public void testBuildFusedQuery_whenAllLegsFailed_thenFailsFast() {
179+
// All legs failing also fails fast — on the first failed leg (index 0).
172180
List<QueryBuilder> legs = List.of(new MatchQueryBuilder("text", "hello"), new TermQueryBuilder("text", "place"));
173181
MultiSearchResponse ms = multiSearch(failedItem(), failedItem());
174182

175183
IllegalStateException e = expectThrows(
176184
IllegalStateException.class,
177185
() -> HybridFusionOrchestrator.buildFusedQuery(new SearchSourceBuilder(), ms, legs, minMaxArithmetic(), 10, true)
178186
);
179-
assertTrue(e.getMessage().contains("all fused-mode sub-queries failed"));
187+
assertTrue(e.getMessage().contains("fused-mode sub-query 0 failed"));
188+
assertNotNull(e.getCause());
180189
}
181190

182191
// ---- knn/neural leg materialized as Ids in the Tail (no second ANN walk) ----
183192

184193
public void testBuildFusedQuery_knnLeg_materializedAsIdsInTail() {
185194
// A leg whose writeable name is a materializable one ("knn") — its Lucene match set IS its returned top-k, so
186-
// survivingLegQueries rewrites it to an IdsQuery in the Tail rather than re-walking the ANN graph. Using a
195+
// legQueriesForTail rewrites it to an IdsQuery in the Tail rather than re-walking the ANN graph. Using a
187196
// minimal MatchQuery wrapper reporting name "knn" keeps the test off KNN-internal construction/validation.
188197
QueryBuilder knnLeg = new MatchQueryBuilder("vec", "q") {
189198
@Override
@@ -284,18 +293,4 @@ public String getWriteableName() {
284293
long idsClauses = tail.should().stream().filter(q -> q instanceof IdsQueryBuilder).count();
285294
assertEquals("neural leg materialized as IdsQuery", 1, idsClauses);
286295
}
287-
288-
public void testBuildFusedQuery_failedLegExcludedFromTail() {
289-
// A failed leg (null hits slot) is dropped from the Tail — only the surviving leg's real query remains.
290-
List<QueryBuilder> legs = List.of(new MatchQueryBuilder("text", "hello"), new TermQueryBuilder("text", "place"));
291-
MultiSearchResponse ms = multiSearch(legItem(Map.of("1", 0.9f, "2", 0.5f)), failedItem());
292-
SearchSourceBuilder source = new SearchSourceBuilder().aggregation(
293-
org.opensearch.search.aggregations.AggregationBuilders.terms("t").field("f")
294-
);
295-
296-
QueryBuilder fused = HybridFusionOrchestrator.buildFusedQuery(source, ms, legs, minMaxArithmetic(), 10, true);
297-
298-
BoolQueryBuilder tail = (BoolQueryBuilder) ((HybridFusionQuery) fused).buildSelfErasedQuery().filter().get(0);
299-
assertEquals("only the surviving leg is in the Tail", 1, tail.should().size());
300-
}
301296
}

0 commit comments

Comments
 (0)