Files
Libation/Source/ApplicationServices/DbContexts.cs
T
Cursor Agentandrmcrackan a0ef5c3d95 fix: repair a search index that is missing books
A book the index does not hold cannot be found by any positive filter, and
every negated filter - which resolves to every document in the index - drops
it from the grid instead. That is why issue #1989 reads as half a working
filter: Absent found nothing while -Absent removed exactly the absent books.

The index is only ever written as a whole, and a rebuild that fails is
deliberately swallowed so a bad index cannot fail a good scan, so a short
index stayed short until something else changed the library. Count the
index against the library once per run and rebuild when it is short.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-24 18:00:52 +00:00

158 lines
5.3 KiB
C#

using DataLayer;
using LibationFileManager;
using Serilog;
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading;
namespace ApplicationServices;
public static class DbContexts
{
private static bool _sqliteDbValidated;
private static readonly object _initialDatabaseStatisticsCaptureLock = new();
/// <summary>
/// True after initial DB statistics were read and either written to Serilog or stored for <see cref="TryEmitPendingInitialDatabaseStatistics"/>.
/// False if capture has not run yet or the last attempt threw (a later <see cref="GetContext"/> may retry).
/// </summary>
private static bool _initialDatabaseStatisticsCaptured;
/// <summary>Shape of the initial DB statistics log event; edit here only when changing what is logged.</summary>
private sealed class InitialDatabaseStatistics
{
public required int LibraryBooksNotInTrash { get; init; }
public required int LibraryBooksInTrash { get; init; }
public required int BookRecords { get; init; }
}
private static InitialDatabaseStatistics? _pendingInitialDbStats;
/// <summary>Use for fully functional context, incl. SaveChanges(). For query-only, use the other method</summary>
public static LibationContext GetContext()
{
var context = !string.IsNullOrEmpty(Configuration.Instance.PostgresqlConnectionString)
? LibationContextFactory.CreatePostgres(Configuration.Instance.PostgresqlConnectionString)
: LibationContextFactory.CreateSqlite(SqliteStorage.ConnectionString);
try
{
LibationContextFactory.ApplyMigrations(
context,
string.IsNullOrEmpty(Configuration.Instance.PostgresqlConnectionString) ? SqliteStorage.DatabasePath : null);
}
catch (InvalidOperationException ex) when (ex.Message.Contains("Libation cannot write its SQLite database", StringComparison.Ordinal))
{
Log.Error(
ex,
"SQLite migrations failed (read-only or blocked). LibationFiles={LibationFiles} DatabasePath={DatabasePath} AppsettingsJson={AppsettingsJson}",
Configuration.Instance.LibationFiles.Location,
SqliteStorage.DatabasePath,
Configuration.Instance.LibationFiles.AppsettingsJsonFile ?? "(null, LIBATION_FILES_DIR may be set)");
throw;
}
// Validate SQLite DB file was created and is accessible (once per process; OS may delay availability)
if (!_sqliteDbValidated && string.IsNullOrEmpty(Configuration.Instance.PostgresqlConnectionString))
{
EssentialFileValidator.ValidateCreatedAndReport(SqliteStorage.DatabasePath);
_sqliteDbValidated = true;
}
TryCaptureInitialDatabaseStatistics(context);
return context;
}
private static void TryCaptureInitialDatabaseStatistics(LibationContext context)
{
lock (_initialDatabaseStatisticsCaptureLock)
{
if (_initialDatabaseStatisticsCaptured)
return;
try
{
var (notInTrash, inTrash) = context.GetLibraryBookCountsByTrashFlag();
var bookRecords = context.GetBookCount();
var stats = new InitialDatabaseStatistics
{
LibraryBooksNotInTrash = notInTrash,
LibraryBooksInTrash = inTrash,
BookRecords = bookRecords,
};
if (Configuration.Instance.SerilogInitialized)
LogInitialDatabaseStatistics(stats);
else
_pendingInitialDbStats = stats;
_initialDatabaseStatisticsCaptured = true;
}
catch (Exception ex)
{
if (Configuration.Instance.SerilogInitialized)
Log.Warning(ex, "Could not capture initial database statistics");
}
}
}
/// <summary>
/// Writes initial DB statistics that were captured before Serilog was configured (e.g. WinForms early library load).
/// Call once after <see cref="Configuration.ConfigureLogging"/>.
/// </summary>
public static void TryEmitPendingInitialDatabaseStatistics()
{
var pending = Interlocked.Exchange(ref _pendingInitialDbStats, null);
if (pending is not null)
LogInitialDatabaseStatistics(pending);
}
private static void LogInitialDatabaseStatistics(InitialDatabaseStatistics stats) =>
Log.Logger.Information("Initial database statistics. {@DbStats}", stats);
/// <summary>Use for full library querying. No lazy loading</summary>
public static List<LibraryBook> GetLibrary_Flat_NoTracking(bool includeParents = false)
{
using var context = GetContext();
return context.GetLibrary_Flat_NoTracking(includeParents);
}
public static List<LibraryBook> GetUnliberated_Flat_NoTracking()
{
using var context = GetContext();
return context.GetUnLiberated_Flat_NoTracking();
}
public static List<LibraryBook> GetDeletedLibraryBooks()
{
using var context = GetContext();
return context.GetDeletedLibraryBooks();
}
/// <summary>How many books belong in the search index. A row count only; no entities are loaded.</summary>
public static int GetIndexableBookCount()
{
using var context = GetContext();
return context.GetIndexableBookCount();
}
/// <summary>
/// How many <see cref="LibraryBook"/> rows are in the trash. A row count only; no entities are loaded,
/// so this is cheap enough to refresh whenever the library changes.
/// </summary>
public static int GetTrashedBookCount()
{
using var context = GetContext();
return context.GetLibraryBookCountsByTrashFlag().InTrash;
}
public static LibraryBook? GetLibraryBook_Flat_NoTracking(string productId, bool caseSensative = true, string? account = null)
{
using var context = GetContext();
return context.GetLibraryBook_Flat_NoTracking(productId, caseSensative, account);
}
}