112 lines
5.6 KiB
C#
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) { }
|
|
}
|
|
}
|