using DataLayer; using LibationSearchEngine; using Serilog; using System; using System.Collections.Generic; using System.IO; using System.Linq; namespace ApplicationServices; public static class SearchEngineCommands { /// Serializes all search index access so only one reader/writer is active at a time, avoiding write.lock contention. private static readonly object IndexLock = new(); #region Search public static SearchResultSet Search(string searchString) => performSafeQuery(e => e.Search(searchString) ); private static T performSafeQuery(Func func) { lock (IndexLock) { var engine = new SearchEngine(); repairShortIndex(engine); try { return func(engine); } catch (FileNotFoundException) { fullReIndex(engine); return func(engine); } catch (Exception ex) when (SearchEngine.IsRecoverableCorruptIndexException(ex)) { Log.Warning(ex, "Search index unreadable or corrupt; rebuilding and retrying query."); fullReIndex(engine); return func(engine); } } } /// Set once the index has been measured against the library, so the check costs one query per run. private static bool indexCounted; /// /// Rebuilds an index that holds fewer books than the library does. /// /// A book the index never received is not merely unsearchable. Filtering intersects the grid with the /// query's hits, so a positive term such as Absent cannot return it, while the negated -Absent /// resolves to "every document in the index" and therefore drops it from the grid - which looks exactly /// like the negated filter working correctly. Reported as issue #1989. /// /// /// Nothing else notices: the index is only ever written as a whole, and /// deliberately swallows a rebuild that fails so a bad index cannot fail a good scan. A short index /// therefore stays short until something happens to change the library again. /// /// private static void repairShortIndex(SearchEngine engine) { if (indexCounted) return; // before the work, not after: a check that throws must not run on every query for the rest of the session indexCounted = true; try { var indexed = engine.GetIndexedBookCount(); // no index yet, or one too damaged to read. Both already have their own recovery. if (indexed < 0) return; var expected = DbContexts.GetIndexableBookCount(); // more documents than books is stale rather than harmful: an extra document matches no grid row. if (indexed >= expected) return; Log.Warning("The search index holds {Indexed} of {Expected} books. Rebuilding it: search cannot find the rest, and a negated filter hides them.", indexed, expected); var failures = fullReIndex(engine); if (failures > 0) Log.Error("{Failures} book(s) could not be added to the search index. Search cannot find them, and a negated filter hides them.", failures); } catch (Exception ex) { // Searching with a short index still beats not searching at all. Log.Error(ex, "Could not check the search index against the library."); } } #endregion public static event EventHandler? SearchEngineUpdated; /// /// Occurs when the index could not be updated even after automatic repair, so it needs the user's help. /// public static event EventHandler? UpdateFailed; #region Update private static bool isUpdating; /// Updates the index after books were added to or removed from the library. public static void OnLibrarySizeChanged(List libraryBooks) => tryUpdate(() => FullReIndex(libraryBooks)); /// Updates the index after book details, tags or statuses were committed. public static void OnBookUserDefinedItemCommitted(IEnumerable books) => tryUpdate(() => UpdateBooks(books)); /// /// The database change that triggers an update is committed before the update runs, and this index is derived /// from that database, so a failure here is reported instead of propagated. Letting it escape reported a /// successful scan as "Error importing library", and, since this is the first subscriber to those events, /// stopped the handlers that refresh the grid and the backup counts from running at all. /// private static void tryUpdate(Action update) { try { update(); } catch (Exception ex) { Log.Error(ex, "Failed to update the search index. Library changes are saved; search and filter results may be stale until the next update succeeds."); UpdateFailed?.Invoke(null, ex); } } public static void UpdateBooks(IEnumerable books) { // Semi-arbitrary. At some point it's more worth it to do a full re-index than to do one offs. // I did not benchmark before choosing the number here if (books.Count() > 15) FullReIndex(); else { foreach (var book in books) UpdateUserDefinedItems(book); } } public static void FullReIndex() => performSafeCommand(e => fullReIndex(e)); public static void FullReIndex(List libraryBooks) => performSafeCommand(se => fullReIndex(se, libraryBooks.WithoutParents())); internal static void UpdateUserDefinedItems(LibraryBook book) => performSafeCommand(e => { e.UpdateLiberatedStatus(book); e.UpdateTags(book.Book.AudibleProductId, book.Book.UserDefinedItem.Tags); e.UpdateUserRatings(book); } ); private static void performSafeCommand(Action action) { try { update(action); } catch (FileNotFoundException) { fullReIndex(new SearchEngine()); update(action); } catch (Exception ex) when (SearchEngine.IsRecoverableCorruptIndexException(ex)) { Log.Warning(ex, "Search index unreadable or corrupt; rebuilding and retrying."); fullReIndex(new SearchEngine()); update(action); } } private static void update(Action action) { if (action is null) return; lock (IndexLock) { // support nesting incl recursion var prevIsUpdating = isUpdating; try { isUpdating = true; action(new SearchEngine()); if (!prevIsUpdating) SearchEngineUpdated?.Invoke(null, EventArgs.Empty); } finally { isUpdating = prevIsUpdating; } } } /// How many books could not be indexed. private static int fullReIndex(SearchEngine engine) { var library = DbContexts.GetLibrary_Flat_NoTracking(); return fullReIndex(engine, library); } /// How many books could not be indexed. private static int fullReIndex(SearchEngine engine, IEnumerable libraryBooks) => engine.CreateNewIndex(libraryBooks); #endregion }