Searcher manager
SearcherManager keeps a current IndexSearcher open and swaps in a fresh one when a new commit lands. Share one searcher across many concurrent queries.
Setup
using Rowles.LeanCorpus.Search.Searcher;
using Rowles.LeanCorpus.Store;
using var dir = new MMapDirectory("./index");
using var manager = new SearcherManager(dir, new SearcherManagerConfig
{
RefreshInterval = TimeSpan.FromSeconds(1),
SearcherConfig = new IndexSearcherConfig { EnableQueryCache = true },
});
A background loop polls at RefreshInterval.
sequenceDiagram
participant Timer
participant Manager as SearcherManager
participant Store as Index directory
participant Caller
Timer->>Manager: Refresh poll
Manager->>Store: Read latest commit generation
alt No newer commit
Manager->>Manager: Keep current searcher
else Newer commit
Manager->>Store: Open replacement searcher
Manager->>Manager: Swap current searcher
Caller->>Manager: AcquireLease()
Manager-->>Caller: Lease on current searcher
Caller->>Manager: Dispose lease
Manager->>Manager: Dispose old searcher after final lease
end
Acquire and release
using var lease = manager.AcquireLease();
var hits = lease.Searcher.Search(query, 10);
Or the convenience method:
var hits = manager.UsingSearcher(s => s.Search(query, 10));
Force refresh
bool refreshed = manager.MaybeRefresh();
bool refreshedAsync = await manager.MaybeRefreshAsync();
Returns true when a newer commit was loaded.
The manager opens and validates the replacement before publication. A failed refresh leaves the previous healthy searcher available.
Query cache across refresh
When query caching is enabled, the manager owns one shared QueryCache. Refresh invalidates its generation so old document IDs cannot be returned, while hit and miss counters continue across replacement searchers.
Refresh failures
Errors are captured instead of crashing the background loop:
manager.RefreshFailed += (_, e) =>
logger.LogWarning(e.Error, "Searcher refresh failed {Count} time(s)", e.ConsecutiveFailures);
if (manager.LastRefreshError is not null)
Console.Error.WriteLine(manager.LastRefreshError.Message);
Generic reader lifecycle
Use ReaderManager<TReader> when the retained near-real-time reader is not an
IndexSearcher. It provides the same immutable swap and lease behaviour for any
IDisposable reader:
using var readers = new ReaderManager<MyReader>(
openFactory: OpenCurrent,
refreshFactory: current => TryOpenNewer(current),
refreshInterval: TimeSpan.FromSeconds(1));
using var lease = readers.AcquireLease();
var result = lease.Reader.Read(request);
The old reader is retired after publication and disposed after its final lease is
released. GetDiagnostics() reports active readers, leases, refreshes, failures,
and disposed readers. SearcherManager is implemented on this lifecycle so the
searcher-specific API remains compatible.
Composing directory snapshots
MultiReader opens one immutable IndexSearcher per directory and assigns global
document IDs in input order. A later commit does not change an existing composition;
create a new MultiReader when all component snapshots should advance together.
using var reader = new MultiReader([firstDirectory, secondDirectory]);
var hits = reader.Search(query, 20, SortField.String("category"));
var nextPage = reader.SearchAfter(hits.ScoreDocs[^1], query, 20, SortField.String("category"));
GetOrdinalMap(fieldName, sortedSet: true) returns stable term-order ordinals across
the component snapshots. This is also used when federated facet counts are merged.