Skip to content

Commit dddb0a7

Browse files
committed
Add unit tests for cancellation support
1 parent 411d333 commit dddb0a7

1 file changed

Lines changed: 319 additions & 0 deletions

File tree

src/Lucene.Net.Tests/Search/TestIndexSearcher.cs

Lines changed: 319 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
1+
using Lucene.Net.Attributes;
12
using Lucene.Net.Documents;
23
using Lucene.Net.Index;
34
using Lucene.Net.Support.Threading;
45
using Lucene.Net.Util;
56
using NUnit.Framework;
67
using System;
8+
using System.Collections.Generic;
9+
using System.Threading;
710
using System.Threading.Tasks;
811

912
namespace Lucene.Net.Search
@@ -160,5 +163,321 @@ public virtual void TestSearchAfterPassedMaxDoc()
160163
IOUtils.Dispose(r, dir);
161164
}
162165
}
166+
167+
// LUCENENET specific - tests for the CancellationToken support
168+
// added to IndexSearcher methods. See #922.
169+
170+
/// <summary>
171+
/// Builds a multi-segment index so cancellation at leaf boundaries is observable.
172+
/// </summary>
173+
private static IndexReader BuildMultiSegmentReader(Directory directory)
174+
{
175+
RandomIndexWriter iw = new RandomIndexWriter(Random, directory);
176+
for (int i = 0; i < 50; i++)
177+
{
178+
Document doc = new Document();
179+
doc.Add(NewStringField("field", Convert.ToString(i), Field.Store.NO));
180+
iw.AddDocument(doc);
181+
// Commit every few docs to force multiple segments (leaves).
182+
if (i % 5 == 0)
183+
{
184+
iw.Commit();
185+
}
186+
}
187+
IndexReader r = iw.GetReader();
188+
iw.Dispose();
189+
return r;
190+
}
191+
192+
[Test]
193+
[LuceneNetSpecific]
194+
public virtual void TestCancellation_SingleThreaded_PreCanceledToken_ThrowsOperationCanceledException()
195+
{
196+
// When the executor is null (single-threaded), an already-canceled token should
197+
// cause OperationCanceledException to be thrown on the first leaf.
198+
using CancellationTokenSource cts = new CancellationTokenSource();
199+
cts.Cancel();
200+
201+
IndexSearcher searcher = new IndexSearcher(reader);
202+
Query query = new MatchAllDocsQuery();
203+
204+
Assert.Throws<OperationCanceledException>(
205+
() => searcher.Search(query, 10, cts.Token));
206+
207+
Assert.Throws<OperationCanceledException>(
208+
() => searcher.Search(query, filter: null, 10, cts.Token));
209+
210+
Assert.Throws<OperationCanceledException>(
211+
() => searcher.SearchAfter(after: null, query, 10, cts.Token));
212+
213+
Assert.Throws<OperationCanceledException>(
214+
() => searcher.SearchAfter(after: null, query, filter: null, 10, cts.Token));
215+
216+
Sort sort = new Sort(new SortField("field", SortFieldType.STRING));
217+
Assert.Throws<OperationCanceledException>(
218+
() => searcher.Search(query, 10, sort, cts.Token));
219+
220+
Assert.Throws<OperationCanceledException>(
221+
() => searcher.Search(query, filter: null, 10, sort, cts.Token));
222+
223+
Assert.Throws<OperationCanceledException>(
224+
() => searcher.Search(query, filter: null, 10, sort, doDocScores: true, doMaxScore: true, cts.Token));
225+
226+
Assert.Throws<OperationCanceledException>(
227+
() => searcher.SearchAfter(after: null, query, filter: null, 10, sort, cts.Token));
228+
229+
Assert.Throws<OperationCanceledException>(
230+
() => searcher.SearchAfter(after: null, query, filter: null, 10, sort, doDocScores: true, doMaxScore: true, cts.Token));
231+
}
232+
233+
[Test]
234+
[LuceneNetSpecific]
235+
public virtual void TestCancellation_SingleThreaded_CollectorOverload_PreCanceledTokenThrows()
236+
{
237+
// The ICollector overloads also thread the CancellationToken through and
238+
// should throw on entry to the leaf iteration.
239+
using CancellationTokenSource cts = new CancellationTokenSource();
240+
cts.Cancel();
241+
242+
IndexSearcher searcher = new IndexSearcher(reader);
243+
Query query = new MatchAllDocsQuery();
244+
245+
TotalHitCountCollector collector = new TotalHitCountCollector();
246+
247+
Assert.Throws<OperationCanceledException>(
248+
() => searcher.Search(query, collector, cts.Token));
249+
250+
Assert.Throws<OperationCanceledException>(
251+
() => searcher.Search(query, filter: null, collector, cts.Token));
252+
}
253+
254+
[Test]
255+
[LuceneNetSpecific]
256+
public virtual void TestCancellation_SingleThreaded_CancelDuringSearch_StopsAtNextLeaf()
257+
{
258+
// Verify that cancellation requested during collection takes effect at the
259+
// next leaf boundary, and the partial work already performed does not prevent
260+
// the OperationCanceledException from being observed.
261+
using Directory directory = NewDirectory();
262+
IndexReader r = BuildMultiSegmentReader(directory);
263+
try
264+
{
265+
// Require at least 2 leaves for this assertion to be meaningful.
266+
Assume.That(r.Leaves.Count >= 2, "Test requires a multi-segment index");
267+
268+
IndexSearcher searcher = new IndexSearcher(r);
269+
using CancellationTokenSource cts = new CancellationTokenSource();
270+
271+
int leavesEntered = 0;
272+
ICollector collector = Collector.NewAnonymous(
273+
setScorer: _ => { },
274+
collect: _ => { },
275+
setNextReader: _ =>
276+
{
277+
// Trigger cancellation on the first leaf; the next leaf should
278+
// observe the cancellation at the loop's entry check.
279+
Interlocked.Increment(ref leavesEntered);
280+
cts.Cancel();
281+
},
282+
acceptsDocsOutOfOrder: () => true);
283+
284+
Assert.Throws<OperationCanceledException>(
285+
() => searcher.Search(new MatchAllDocsQuery(), collector, cts.Token));
286+
287+
// Only the first leaf should have been entered; subsequent leaves must have
288+
// been skipped due to the cancellation check.
289+
Assert.AreEqual(1, leavesEntered);
290+
}
291+
finally
292+
{
293+
r.Dispose();
294+
}
295+
}
296+
297+
[Test]
298+
[LuceneNetSpecific]
299+
public virtual void TestCancellation_SingleThreaded_DefaultToken_SearchCompletesNormally()
300+
{
301+
// Sanity check: a default (non-cancellable) token must not affect a normal search.
302+
IndexSearcher searcher = new IndexSearcher(reader);
303+
TopDocs docs = searcher.Search(new MatchAllDocsQuery(), 10, CancellationToken.None);
304+
Assert.AreEqual(100, docs.TotalHits);
305+
}
306+
307+
[Test]
308+
[LuceneNetSpecific]
309+
public virtual void TestCancellation_MultiThreaded_PreCanceledTokenThrows()
310+
{
311+
// When the executor is non-null, cancellation is propagated to the submitted
312+
// tasks. The awaiting call in ExecutionHelper.MoveNext catches any Wait
313+
// exception and re-throws it wrapped via RuntimeException.Create, so we
314+
// assert on the wrapped exception having a cancellation-derived inner.
315+
TaskScheduler service = new LimitedConcurrencyLevelTaskScheduler(4);
316+
317+
using Directory directory = NewDirectory();
318+
IndexReader r = BuildMultiSegmentReader(directory);
319+
try
320+
{
321+
IndexSearcher searcher = new IndexSearcher(r, service);
322+
Query query = new MatchAllDocsQuery();
323+
324+
using CancellationTokenSource cts = new CancellationTokenSource();
325+
cts.Cancel();
326+
327+
Exception ex = Assert.Catch(() => searcher.Search(query, 10, cts.Token));
328+
AssertCancellationInChain(ex);
329+
330+
ex = Assert.Catch(() => searcher.SearchAfter(after: null, query, 10, cts.Token));
331+
AssertCancellationInChain(ex);
332+
333+
Sort sort = new Sort(new SortField("field", SortFieldType.STRING));
334+
ex = Assert.Catch(() => searcher.Search(query, 10, sort, cts.Token));
335+
AssertCancellationInChain(ex);
336+
337+
ex = Assert.Catch(() => searcher.Search(query, filter: null, 10, sort, doDocScores: true, doMaxScore: true, cts.Token));
338+
AssertCancellationInChain(ex);
339+
340+
ex = Assert.Catch(() => searcher.SearchAfter(after: null, query, filter: null, 10, sort, cts.Token));
341+
AssertCancellationInChain(ex);
342+
}
343+
finally
344+
{
345+
r.Dispose();
346+
}
347+
}
348+
349+
[Test]
350+
[LuceneNetSpecific]
351+
public virtual void TestCancellation_MultiThreaded_CancelDuringSearch_Throws()
352+
{
353+
// Verify that cancellation requested while tasks are in-flight causes the
354+
// multi-threaded search to observe the cancellation. We override the leaf-level
355+
// Search to cancel the token after entering the first leaf, which is deterministic
356+
// regardless of scheduling.
357+
using Directory directory = NewDirectory();
358+
IndexReader r = BuildMultiSegmentReader(directory);
359+
try
360+
{
361+
Assume.That(r.Leaves.Count >= 2, "Test requires a multi-segment index");
362+
363+
using CancellationTokenSource cts = new CancellationTokenSource();
364+
TaskScheduler service = new LimitedConcurrencyLevelTaskScheduler(4);
365+
IndexSearcher searcher = new CancelAfterFirstLeafSearcher(r, service, cts);
366+
367+
Exception ex = Assert.Catch(() => searcher.Search(new MatchAllDocsQuery(), 10, cts.Token));
368+
AssertCancellationInChain(ex);
369+
}
370+
finally
371+
{
372+
r.Dispose();
373+
}
374+
}
375+
376+
[Test]
377+
[LuceneNetSpecific]
378+
public virtual void TestCancellation_MultiThreaded_DefaultToken_SearchCompletesNormally()
379+
{
380+
// Sanity check: a default (non-cancellable) token must not affect a normal
381+
// multi-threaded search.
382+
TaskScheduler service = new LimitedConcurrencyLevelTaskScheduler(4);
383+
384+
using Directory directory = NewDirectory();
385+
IndexReader r = BuildMultiSegmentReader(directory);
386+
try
387+
{
388+
IndexSearcher searcher = new IndexSearcher(r, service);
389+
TopDocs docs = searcher.Search(new MatchAllDocsQuery(), 10, CancellationToken.None);
390+
Assert.AreEqual(50, docs.TotalHits);
391+
}
392+
finally
393+
{
394+
r.Dispose();
395+
}
396+
}
397+
398+
/// <summary>
399+
/// Walks the exception chain (exception + inner exceptions, including the inner
400+
/// exceptions of any <see cref="AggregateException"/> encountered) looking for an
401+
/// <see cref="OperationCanceledException"/>. The multi-threaded search path wraps
402+
/// cancellation exceptions via <c>RuntimeException.Create</c>, so the cancellation
403+
/// is not the top-level exception but should always be present in the chain.
404+
/// </summary>
405+
private static void AssertCancellationInChain(Exception ex)
406+
{
407+
Assert.IsNotNull(ex, "Expected an exception, but none was thrown.");
408+
Exception current = ex;
409+
while (current != null)
410+
{
411+
if (current is OperationCanceledException)
412+
{
413+
return;
414+
}
415+
if (current is AggregateException agg)
416+
{
417+
foreach (Exception inner in agg.InnerExceptions)
418+
{
419+
if (ContainsOperationCanceled(inner))
420+
{
421+
return;
422+
}
423+
}
424+
}
425+
current = current.InnerException;
426+
}
427+
Assert.Fail("Expected OperationCanceledException in the exception chain, but got: " + ex);
428+
}
429+
430+
private static bool ContainsOperationCanceled(Exception ex)
431+
{
432+
Exception current = ex;
433+
while (current != null)
434+
{
435+
if (current is OperationCanceledException)
436+
{
437+
return true;
438+
}
439+
if (current is AggregateException agg)
440+
{
441+
foreach (Exception inner in agg.InnerExceptions)
442+
{
443+
if (ContainsOperationCanceled(inner))
444+
{
445+
return true;
446+
}
447+
}
448+
}
449+
current = current.InnerException;
450+
}
451+
return false;
452+
}
453+
454+
/// <summary>
455+
/// An <see cref="IndexSearcher"/> subclass that cancels the given
456+
/// <see cref="CancellationTokenSource"/> after the first leaf context is entered
457+
/// in the leaf-level Search method. This makes cancellation during multi-threaded
458+
/// search deterministic — the first slice triggers cancellation, and subsequent
459+
/// slices (or leaves within the same slice) observe it.
460+
/// </summary>
461+
private sealed class CancelAfterFirstLeafSearcher : IndexSearcher
462+
{
463+
private readonly CancellationTokenSource cts;
464+
private int leafEntered; // 0 = not yet, 1 = already canceled
465+
466+
public CancelAfterFirstLeafSearcher(IndexReader r, TaskScheduler executor, CancellationTokenSource cts)
467+
: base(r, executor)
468+
{
469+
this.cts = cts;
470+
}
471+
472+
protected override void Search(IList<AtomicReaderContext> leaves, Weight weight, ICollector collector, CancellationToken cancellationToken = default)
473+
{
474+
// Cancel after the very first leaf has been entered across all slices.
475+
if (Interlocked.Exchange(ref leafEntered, 1) == 0)
476+
{
477+
cts.Cancel();
478+
}
479+
base.Search(leaves, weight, collector, cancellationToken);
480+
}
481+
}
163482
}
164483
}

0 commit comments

Comments
 (0)