From a3d3ef2812442a93bf284603b02a99a01208a5f7 Mon Sep 17 00:00:00 2001 From: Daniel Green <757038+danielgreen@users.noreply.github.com> Date: Wed, 21 Oct 2026 14:56:25 +0100 Subject: [PATCH 1/2] Answer queries on an index with no documents Searching, counting or fetching a document from an index that had no documents written yet failed with a 500 (DirectoryNotFoundException), because the Lucene directory does not exist until the first write. Azure answers with an empty result, a count of 0 and a 404 for the key. When the directory holds no Lucene index, the reader factory now returns an empty reader. It is not cached, so the first write is seen by the next read. This also covers indexes created before this change. The unexpected-failure integration test relied on that 500, so it now uses a full Lucene query that does not parse. --- .../EmulatorIntegrationTests.cs | 27 ++++ .../ErrorResponseIntegrationTests.cs | 22 +-- .../EmptyIndexTests.cs | 153 ++++++++++++++++++ .../LuceneDirectoryReaderFactory.cs | 7 + .../Searching/LuceneNetIndexSearcher.cs | 12 +- 5 files changed, 197 insertions(+), 24 deletions(-) create mode 100644 AzureSearchEmulator.UnitTests/EmptyIndexTests.cs diff --git a/AzureSearchEmulator.IntegrationTests/EmulatorIntegrationTests.cs b/AzureSearchEmulator.IntegrationTests/EmulatorIntegrationTests.cs index f831630..3241f37 100644 --- a/AzureSearchEmulator.IntegrationTests/EmulatorIntegrationTests.cs +++ b/AzureSearchEmulator.IntegrationTests/EmulatorIntegrationTests.cs @@ -89,6 +89,33 @@ public async Task GetDocumentById_ShouldReturnDocument() await indexClient.DeleteIndexAsync(indexName, TestContext.Current.CancellationToken); } + [Fact] + public async Task EmptyIndex_ShouldAnswerReadsWithoutDocuments() + { + const string indexName = "test-empty-index"; + var indexClient = factory.CreateSearchIndexClient(); + var searchClient = factory.CreateSearchClient(indexName); + + // Arrange - Create the index and write nothing to it + await CreateIndexAsync(indexClient, indexName); + + // Act + var results = await searchClient.SearchAsync("*", new SearchOptions { IncludeTotalCount = true }, TestContext.Current.CancellationToken); + var items = await results.Value.GetResultsAsync().ToListAsync(TestContext.Current.CancellationToken); + var count = await searchClient.GetDocumentCountAsync(TestContext.Current.CancellationToken); + var missing = await Assert.ThrowsAsync( + () => searchClient.GetDocumentAsync("1", cancellationToken: TestContext.Current.CancellationToken)); + + // Assert + Assert.Empty(items); + Assert.Equal(0, results.Value.TotalCount); + Assert.Equal(0, count.Value); + Assert.Equal(404, missing.Status); + + // Cleanup + await indexClient.DeleteIndexAsync(indexName, TestContext.Current.CancellationToken); + } + [Fact] public async Task SearchDocuments_SimpleQuery_ShouldReturnResults() { diff --git a/AzureSearchEmulator.IntegrationTests/ErrorResponseIntegrationTests.cs b/AzureSearchEmulator.IntegrationTests/ErrorResponseIntegrationTests.cs index ba49908..d85f5a5 100644 --- a/AzureSearchEmulator.IntegrationTests/ErrorResponseIntegrationTests.cs +++ b/AzureSearchEmulator.IntegrationTests/ErrorResponseIntegrationTests.cs @@ -48,11 +48,6 @@ private static SearchIndex BuildIndex(string indexName) => /// /// Creates the index and indexes a document. /// - /// - /// The document matters: with an empty index the Lucene directory does not exist yet, so a - /// query fails on the missing directory before it ever reaches the filter parser, and the - /// test would assert on the wrong error. - /// private async Task SetUpAsync(string indexName) { var indexClient = factory.CreateSearchIndexClient(); @@ -180,15 +175,14 @@ public async Task DocumentLookupWithUnknownSelectField_IsRejectedAsBadRequest() /// An unexpected fault still answers in the envelope, and without leaking internals. /// /// - /// Searching an index that has never had a document written to it currently trips a Lucene - /// DirectoryNotFoundException, because the segment directory is only created on first write. - /// That 500 is arguably its own bug — the real service answers an empty result set — so this - /// test deliberately asserts only on the SHAPE of an unexpected failure, not on this query - /// being one. Should the empty-index case be fixed to return results, the assertions below - /// stop applying and the test should be pointed at another unexpected fault rather than - /// being made to keep this 500 alive. + /// A full Lucene query that does not parse currently escapes as a Lucene parse exception, + /// which is not mapped to a status, so it answers 500 where the real service answers 400. + /// That is arguably its own bug, so this test deliberately asserts only on the SHAPE of an + /// unexpected failure, not on this query being one. Should malformed query syntax be + /// fixed to answer 400, the assertions below stop applying and the test should be pointed + /// at another unexpected fault rather than being made to keep this 500 alive. /// - /// The generic message is the point: the underlying exception names the emulator's own + /// The generic message is the point: the underlying exception can name the emulator's own /// filesystem paths, and those belong in the log rather than the response body. /// [Fact] @@ -200,7 +194,7 @@ public async Task UnexpectedFailure_ReturnsEnvelopeWithoutInternalDetail() var http = factory.CreateHttpClient(); var response = await http.GetAsync( - $"/indexes/{indexName}/docs?search=*", + $"/indexes/{indexName}/docs?search=%28&queryType=full", TestContext.Current.CancellationToken); var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); diff --git a/AzureSearchEmulator.UnitTests/EmptyIndexTests.cs b/AzureSearchEmulator.UnitTests/EmptyIndexTests.cs new file mode 100644 index 0000000..4c697df --- /dev/null +++ b/AzureSearchEmulator.UnitTests/EmptyIndexTests.cs @@ -0,0 +1,153 @@ +using System.Text.Json.Nodes; +using AzureSearchEmulator.Indexing; +using AzureSearchEmulator.Models; +using AzureSearchEmulator.SearchData; +using AzureSearchEmulator.Searching; +using Lucene.Net.Store; +using Xunit; +using Directory = Lucene.Net.Store.Directory; + +namespace AzureSearchEmulator.UnitTests; + +/// +/// Covers reading an index that exists but has never had a document written to it, which +/// Azure answers with an empty result rather than an error. +/// +/// +/// Uses the real over an on-disk directory, because +/// the failure comes from opening a reader on a directory Lucene has never written. An +/// in-memory directory is always readable, so a test built on one would pass without the fix. +/// +public sealed class EmptyIndexTests : IDisposable +{ + private readonly string _path = Path.Combine(Path.GetTempPath(), "azs-empty-index-" + Guid.NewGuid().ToString("N")); + private readonly SearchIndex _index = LuceneTestHelper.CreateSuggesterProductIndex(); + private readonly LuceneDirectoryReaderFactory _readerFactory; + private readonly LuceneNetIndexWriterFactory _writerFactory; + private readonly LuceneNetSearchIndexer _indexer; + private readonly LuceneNetIndexSearcher _searcher; + + public EmptyIndexTests() + { + var directoryFactory = new FixedPathDirectoryFactory(_path); + + _readerFactory = new LuceneDirectoryReaderFactory(directoryFactory); + _writerFactory = new LuceneNetIndexWriterFactory(directoryFactory); + _indexer = new LuceneNetSearchIndexer(_writerFactory, _readerFactory); + _searcher = new LuceneNetIndexSearcher(_readerFactory); + } + + public void Dispose() + { + // Reader and writer before the folder: both hold open handles. + _readerFactory.ClearCachedReader(_index.Name); + _writerFactory.Dispose(); + + if (System.IO.Directory.Exists(_path)) + { + System.IO.Directory.Delete(_path, recursive: true); + } + } + + [Fact] + public async Task Search_OnAnIndexWithNoDocuments_ReturnsNoResults() + { + var response = await _searcher.Search(_index, new SearchRequest { Search = "*", Count = true }); + + Assert.Empty(response.Results); + Assert.Equal(0, response.Count); + } + + [Fact] + public async Task Search_ForText_OnAnIndexWithNoDocuments_ReturnsNoResults() + { + var response = await _searcher.Search(_index, new SearchRequest { Search = "laptop", Count = true }); + + Assert.Empty(response.Results); + Assert.Equal(0, response.Count); + } + + [Fact] + public async Task GetDocCount_OnAnIndexWithNoDocuments_ReturnsZero() + { + Assert.Equal(0, await _searcher.GetDocCount(_index)); + } + + [Fact] + public async Task GetDoc_OnAnIndexWithNoDocuments_ReturnsNull() + { + Assert.Null(await _searcher.GetDoc(_index, "1")); + } + + [Fact] + public async Task Suggest_OnAnIndexWithNoDocuments_ReturnsNoResults() + { + var response = await _searcher.Suggest(_index, new SuggestRequest { Search = "lap", SuggesterName = "sg" }); + + Assert.Empty(response.Results); + } + + [Fact] + public async Task Autocomplete_OnAnIndexWithNoDocuments_ReturnsNoResults() + { + var response = await _searcher.Autocomplete(_index, + new AutocompleteRequest { Search = "lap", SuggesterName = "sg" }); + + Assert.Empty(response.Results); + } + + /// + /// A volume can hold a folder that Lucene created but never committed to. + /// + [Fact] + public async Task Search_WhenTheIndexFolderExistsButIsEmpty_ReturnsNoResults() + { + System.IO.Directory.CreateDirectory(_path); + + var response = await _searcher.Search(_index, new SearchRequest { Search = "*", Count = true }); + + Assert.Empty(response.Results); + Assert.Equal(0, response.Count); + } + + /// + /// The empty answer must not be kept: once a document is written, reads see it. + /// + [Fact] + public async Task Reads_AfterTheFirstDocumentIsWritten_SeeIt() + { + // Read first, so the empty answer is the one a stale cache would keep serving. + Assert.Empty((await _searcher.Search(_index, new SearchRequest { Search = "*" })).Results); + Assert.Equal(0, await _searcher.GetDocCount(_index)); + + _indexer.IndexDocuments(_index, + [ + new UploadIndexDocumentAction(new JsonObject + { + ["Id"] = "1", + ["Name"] = "Laptop Pro 15", + ["Description"] = "High-performance laptop", + }) + ]); + + var response = await _searcher.Search(_index, new SearchRequest { Search = "laptop", Count = true }); + + Assert.Equal(1, response.Count); + Assert.Equal("1", Assert.Single(response.Results)["Id"]!.GetValue()); + Assert.Equal(1, await _searcher.GetDocCount(_index)); + Assert.NotNull(await _searcher.GetDoc(_index, "1")); + } + + private sealed class FixedPathDirectoryFactory(string path) : ILuceneDirectoryFactory + { + private Directory? _directory; + + public Directory GetDirectory(string indexName) => _directory ??= new SimpleFSDirectory(path); + + public void ClearCachedDirectory(string indexName) + { + _directory?.Dispose(); + _directory = null; + } + } +} diff --git a/AzureSearchEmulator/SearchData/LuceneDirectoryReaderFactory.cs b/AzureSearchEmulator/SearchData/LuceneDirectoryReaderFactory.cs index 38cabb2..8726127 100644 --- a/AzureSearchEmulator/SearchData/LuceneDirectoryReaderFactory.cs +++ b/AzureSearchEmulator/SearchData/LuceneDirectoryReaderFactory.cs @@ -25,6 +25,13 @@ public IndexReader RefreshReader(string indexName) { var directory = luceneDirectoryFactory.GetDirectory(indexName); + // An index with no documents written yet has no Lucene files, and may not have a + // folder. It is not cached, so the next call looks again and sees the first write. + if (!DirectoryReader.IndexExists(directory)) + { + return new MultiReader(); + } + var reader = DirectoryReader.Open(directory); _indexReaders[indexName] = reader; diff --git a/AzureSearchEmulator/Searching/LuceneNetIndexSearcher.cs b/AzureSearchEmulator/Searching/LuceneNetIndexSearcher.cs index 53e7249..7c9f42f 100644 --- a/AzureSearchEmulator/Searching/LuceneNetIndexSearcher.cs +++ b/AzureSearchEmulator/Searching/LuceneNetIndexSearcher.cs @@ -48,17 +48,9 @@ public class LuceneNetIndexSearcher( public Task GetDocCount(SearchIndex index) { - try - { - var reader = indexReaderFactory.GetIndexReader(index.Name); + var reader = indexReaderFactory.GetIndexReader(index.Name); - return Task.FromResult(reader.NumDocs); - } - catch (DirectoryNotFoundException) - { - // If we've come this far, the index exists, but has no documents, so it's okay to return 0 - return Task.FromResult(0); - } + return Task.FromResult(reader.NumDocs); } public async Task Search(SearchIndex index, SearchRequest request) From 1017fa28cedb906f9f32a91bbf10ba8fddfb559b Mon Sep 17 00:00:00 2001 From: Daniel Green <757038+danielgreen@users.noreply.github.com> Date: Wed, 21 Oct 2026 14:56:25 +0100 Subject: [PATCH 2/2] Add an empty-index search check to DebugClient --- DebugClient/Program.cs | 42 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 40 insertions(+), 2 deletions(-) diff --git a/DebugClient/Program.cs b/DebugClient/Program.cs index 188013a..ae07c79 100644 --- a/DebugClient/Program.cs +++ b/DebugClient/Program.cs @@ -242,8 +242,46 @@ Console.WriteLine($" ✗ Error: {ex.GetType().Name}: {ex.Message}\n"); } - // Test 10: Delete Index - Console.WriteLine("10. Deleting index..."); + // Test 10: Search an index that has no documents + Console.WriteLine("10. Searching an empty index..."); + const string emptyIndexName = "empty-test-index"; + try + { + await indexClient.CreateIndexAsync(new SearchIndex(emptyIndexName) + { + Fields = + [ + new SearchField("id", SearchFieldDataType.String) { IsKey = true }, + new SearchField("name", SearchFieldDataType.String) { IsSearchable = true } + ] + }); + + var emptySearchClient = new SearchClient(new Uri(endpoint), emptyIndexName, credential, options); + + // Azure answers a search on an index with no documents with an empty result, not an error. + var results = await emptySearchClient.SearchAsync("*", new SearchOptions { IncludeTotalCount = true }); + var items = await results.Value.GetResultsAsync().ToListAsync(); + + Console.WriteLine($" ✓ Found {items.Count} results, total count {results.Value.TotalCount}\n"); + } + catch (Exception ex) + { + Console.WriteLine($" ✗ Error: {ex.GetType().Name}: {ex.Message}\n"); + } + finally + { + try + { + await indexClient.DeleteIndexAsync(emptyIndexName); + } + catch (Exception ex) + { + Console.WriteLine($" ✗ Error deleting {emptyIndexName}: {ex.GetType().Name}: {ex.Message}\n"); + } + } + + // Test 11: Delete Index + Console.WriteLine("11. Deleting index..."); try { await indexClient.DeleteIndexAsync(indexName);