|
| 1 | +using Lucene.Net.Attributes; |
1 | 2 | using Lucene.Net.Documents; |
2 | 3 | using Lucene.Net.Index; |
3 | 4 | using Lucene.Net.Support.Threading; |
4 | 5 | using Lucene.Net.Util; |
5 | 6 | using NUnit.Framework; |
6 | 7 | using System; |
| 8 | +using System.Collections.Generic; |
| 9 | +using System.Threading; |
7 | 10 | using System.Threading.Tasks; |
8 | 11 |
|
9 | 12 | namespace Lucene.Net.Search |
@@ -160,5 +163,321 @@ public virtual void TestSearchAfterPassedMaxDoc() |
160 | 163 | IOUtils.Dispose(r, dir); |
161 | 164 | } |
162 | 165 | } |
| 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 | + } |
163 | 482 | } |
164 | 483 | } |
0 commit comments