divan/RMuseum/Utils/SemanticSearch/LazySemanticSearchResources.cs
2026-09-09 14:07:00 +03:30

112 lines
5.6 KiB
C#

using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using System;
namespace RMuseum.Utils.SemanticSearch
{
/// <summary>
/// Wraps EmbeddingIndex + QueryEmbedder loading so a failure (missing files, wrong paths,
/// corrupt data) can NEVER take down anything else in the app.
///
/// The original design registered EmbeddingIndex/QueryEmbedder as singletons whose DI
/// factories called EmbeddingIndex.Load(...)/`new QueryEmbedder(...)` directly — both of
/// which throw on failure. Because GanjoorController's constructor (indirectly, through
/// ISemanticSearchService) depended on them, a load failure meant the controller itself
/// couldn't be constructed — taking down EVERY endpoint under /api/ganjoor, not just semantic
/// search, with a 503. That's exactly what happened in production. This class exists so that
/// can't happen again: the actual load is deferred to first real use (not app/controller
/// construction), attempted at most once, and a failure is caught, logged, and remembered —
/// SearchAsync() then reports "search unavailable" as an ordinary result, not an exception
/// that propagates into breaking anything else.
///
/// Also worth knowing if this server runs multiple IIS worker processes for the same app
/// pool: each process gets its own instance of this (and everything it loads) — "singleton"
/// only means one instance per process, not per server. See the migration notes for the
/// memory math this implies at scale.
/// </summary>
public class LazySemanticSearchResources
{
private readonly object _lock = new object();
private EmbeddingIndex _embeddingIndex;
private QueryEmbedder _queryEmbedder;
private string _loadError;
private bool _attempted;
private readonly string _embeddingsDirectory;
private readonly string _modelPath;
private readonly string _vocabPath;
private readonly string _mergesPath;
private readonly int _dimension;
private readonly ILogger<LazySemanticSearchResources> _logger;
public LazySemanticSearchResources(IConfiguration configuration, ILogger<LazySemanticSearchResources> logger)
{
_embeddingsDirectory = configuration["SemanticSearch:EmbeddingsDirectory"];
_modelPath = configuration["SemanticSearch:ModelPath"];
_vocabPath = configuration["SemanticSearch:VocabPath"];
_mergesPath = configuration["SemanticSearch:MergesPath"];
_dimension = int.TryParse(configuration["SemanticSearch:Dimension"], out var d) ? d : 1024;
_logger = logger;
}
/// <summary>
/// Attempts to load the resources on first call (subsequent calls reuse the same result,
/// success or failure — this never retries automatically; a fresh app start is required
/// to try again after a config/file fix, which is the expected deploy-and-restart flow
/// anyway). Returns true and populates both out parameters if available; returns false
/// and populates <paramref name="error"/> otherwise. NEVER THROWS — that guarantee is the
/// entire point of this class.
/// </summary>
public bool TryGetResources(out EmbeddingIndex embeddingIndex, out QueryEmbedder queryEmbedder, out string error)
{
if (!_attempted)
{
lock (_lock)
{
if (!_attempted)
{
try
{
_embeddingIndex = EmbeddingIndex.Load(_embeddingsDirectory);
_queryEmbedder = new QueryEmbedder(_modelPath, _vocabPath, _mergesPath, _dimension);
_logger.LogInformation(
"Semantic search resources loaded: {Count} poems, dimension {Dimension}.",
_embeddingIndex.Metadata.Count, _embeddingIndex.Metadata.Dimension);
}
catch (Exception exp)
{
_loadError = exp.Message;
_embeddingIndex = null;
_queryEmbedder = null;
_logger.LogError(exp,
"Semantic search resources failed to load from '{EmbeddingsDirectory}' / '{ModelPath}' " +
"— semantic search will report unavailable, but this must not affect anything else.",
_embeddingsDirectory, _modelPath);
}
finally
{
_attempted = true;
}
}
}
}
embeddingIndex = _embeddingIndex;
queryEmbedder = _queryEmbedder;
error = _loadError;
return _embeddingIndex != null && _queryEmbedder != null;
}
}
/// <summary>
/// Thrown by SemanticSearchService when the underlying resources aren't available — the
/// controller catches this specifically and returns HTTP 503 with the message, distinct from
/// a plain 400/500, so a client (or a person checking logs) can tell "this feature isn't
/// configured/loaded right now" apart from "the query itself was bad" or "something crashed".
/// </summary>
public class SemanticSearchUnavailableException : Exception
{
public SemanticSearchUnavailableException(string message) : base(message) { }
}
}