diff --git a/AzureSearchEmulator/Indexing/LuceneNetSearchIndexer.cs b/AzureSearchEmulator/Indexing/LuceneNetSearchIndexer.cs index 24c515e..b1d0259 100644 --- a/AzureSearchEmulator/Indexing/LuceneNetSearchIndexer.cs +++ b/AzureSearchEmulator/Indexing/LuceneNetSearchIndexer.cs @@ -1,4 +1,5 @@ -using AzureSearchEmulator.Models; +using System.Collections.Concurrent; +using AzureSearchEmulator.Models; using AzureSearchEmulator.SearchData; using Lucene.Net.Index; using Lucene.Net.Util; @@ -10,41 +11,62 @@ public class LuceneNetSearchIndexer( ILuceneIndexReaderFactory luceneIndexReaderFactory) : ISearchIndexer { + // One writer gate per index. Lucene allows only a single IndexWriter per + // directory; this class creates a fresh writer per request, so two + // concurrent indexing requests against the same index would race — the + // loser either fails with LockObtainFailedException or, worse, observes + // a torn commit and permanently corrupts the segment files + // (FileNotFoundException: .../_N.si on every subsequent write until the + // index directory is deleted). Serializing IndexDocuments per index name + // removes both failure modes; requests against different indexes still + // run in parallel. + private static readonly ConcurrentDictionary IndexWriteGates = new(); + public IndexDocumentsResult IndexDocuments(SearchIndex index, IList actions) { - var analyzer = AnalyzerHelper.GetPerFieldIndexAnalyzer(index.Fields); + var gate = IndexWriteGates.GetOrAdd(index.Name, static _ => new SemaphoreSlim(1, 1)); + gate.Wait(); - var config = new IndexWriterConfig(LuceneVersion.LUCENE_48, analyzer); + try + { + var analyzer = AnalyzerHelper.GetPerFieldIndexAnalyzer(index.Fields); - var directory = luceneDirectoryFactory.GetDirectory(index.Name); - using var writer = new IndexWriter(directory, config); + var config = new IndexWriterConfig(LuceneVersion.LUCENE_48, analyzer); - var key = index.GetKeyField(); + var directory = luceneDirectoryFactory.GetDirectory(index.Name); + using var writer = new IndexWriter(directory, config); - var results = new IndexDocumentsResult(); + var key = index.GetKeyField(); - // ReSharper disable once AccessToDisposedClosure - var readerLazy = new Lazy(() => writer.GetReader(true)); + var results = new IndexDocumentsResult(); - var context = new IndexingContext(index, key, writer, readerLazy); + // ReSharper disable once AccessToDisposedClosure + var readerLazy = new Lazy(() => writer.GetReader(true)); - foreach (var action in actions) - { - var result = action.PerformIndexingAsync(context); - results.Value.Add(result); - } + var context = new IndexingContext(index, key, writer, readerLazy); - if (readerLazy.IsValueCreated) - { - var reader = readerLazy.Value; - reader.Dispose(); - } + foreach (var action in actions) + { + var result = action.PerformIndexingAsync(context); + results.Value.Add(result); + } + + if (readerLazy.IsValueCreated) + { + var reader = readerLazy.Value; + reader.Dispose(); + } - writer.Commit(); - writer.Flush(true, true); + writer.Commit(); + writer.Flush(true, true); - luceneIndexReaderFactory.RefreshReader(index.Name); + luceneIndexReaderFactory.RefreshReader(index.Name); - return results; + return results; + } + finally + { + gate.Release(); + } } }