Improve AdvisoryAI knowledge search pooling and unified search analytics
Add KnowledgeSearchDataSourceProvider for connection policy adoption, update PostgresKnowledgeSearchStore and chat audit logger, refine SearchAnalyticsService and SearchQualityMonitor queries. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +4,7 @@ Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_sol
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| SPRINT_20260405_010-AIAI-PG | DONE | `docs/implplan/SPRINT_20260405_010_AdvisoryAI_pg_pooling_and_gitea_spike_followup.md`: advisory-ai-web PostgreSQL attribution/pooling follow-up and Gitea spike capture. |
|
||||
| SPRINT_20260222_051-AKS-API | DONE | Extended AKS search/open-action endpoint contract and added header-based authentication wiring (`AddAuthentication` + `AddAuthorization` + `UseAuthorization`) so `RequireAuthorization()` endpoints execute without runtime middleware errors. |
|
||||
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/AdvisoryAI/StellaOps.AdvisoryAI.WebService/StellaOps.AdvisoryAI.WebService.md. |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
|
||||
@@ -46,7 +46,12 @@ internal sealed class PostgresAdvisoryChatAuditLogger : IAdvisoryChatAuditLogger
|
||||
|
||||
_includeEvidenceBundle = audit.IncludeEvidenceBundle;
|
||||
_schema = NormalizeSchemaName(audit.SchemaName);
|
||||
_dataSource = new NpgsqlDataSourceBuilder(audit.ConnectionString).Build();
|
||||
|
||||
var connectionStringBuilder = new NpgsqlConnectionStringBuilder(audit.ConnectionString)
|
||||
{
|
||||
ApplicationName = "stellaops-advisoryai-chat-audit",
|
||||
};
|
||||
_dataSource = new NpgsqlDataSourceBuilder(connectionStringBuilder.ConnectionString).Build();
|
||||
|
||||
_insertSessionSql = $"""
|
||||
INSERT INTO {_schema}.chat_sessions (
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using Npgsql;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.KnowledgeSearch;
|
||||
|
||||
internal sealed class KnowledgeSearchDataSourceProvider : IAsyncDisposable
|
||||
{
|
||||
private readonly KnowledgeSearchOptions _options;
|
||||
private readonly Lazy<NpgsqlDataSource?> _dataSource;
|
||||
|
||||
public KnowledgeSearchDataSourceProvider(IOptions<KnowledgeSearchOptions> options)
|
||||
: this(options?.Value ?? new KnowledgeSearchOptions())
|
||||
{
|
||||
}
|
||||
|
||||
internal KnowledgeSearchDataSourceProvider(KnowledgeSearchOptions options)
|
||||
{
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_dataSource = new Lazy<NpgsqlDataSource?>(CreateDataSource, isThreadSafe: true);
|
||||
}
|
||||
|
||||
public bool IsConfigured => IsConfiguredOptions(_options);
|
||||
|
||||
public NpgsqlDataSource? TryGetDataSource()
|
||||
{
|
||||
return _dataSource.Value;
|
||||
}
|
||||
|
||||
public NpgsqlDataSource GetRequiredDataSource()
|
||||
{
|
||||
if (_dataSource.Value is null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"AdvisoryAI knowledge search is not configured. Set AdvisoryAI:KnowledgeSearch:ConnectionString.");
|
||||
}
|
||||
|
||||
return _dataSource.Value;
|
||||
}
|
||||
|
||||
public ValueTask<NpgsqlConnection> OpenConnectionAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return GetRequiredDataSource().OpenConnectionAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_dataSource.IsValueCreated && _dataSource.Value is not null)
|
||||
{
|
||||
await _dataSource.Value.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
internal static bool IsConfiguredOptions(KnowledgeSearchOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
return options.Enabled && !string.IsNullOrWhiteSpace(options.ConnectionString);
|
||||
}
|
||||
|
||||
internal static string BuildConnectionString(KnowledgeSearchOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
var builder = new NpgsqlConnectionStringBuilder(options.ConnectionString);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options.DatabaseApplicationName))
|
||||
{
|
||||
builder.ApplicationName = options.DatabaseApplicationName.Trim();
|
||||
}
|
||||
|
||||
if (options.DatabasePoolingEnabled.HasValue)
|
||||
{
|
||||
builder.Pooling = options.DatabasePoolingEnabled.Value;
|
||||
}
|
||||
|
||||
if (options.DatabaseMinPoolSize.HasValue)
|
||||
{
|
||||
builder.MinPoolSize = options.DatabaseMinPoolSize.Value;
|
||||
}
|
||||
|
||||
if (options.DatabaseMaxPoolSize.HasValue)
|
||||
{
|
||||
builder.MaxPoolSize = options.DatabaseMaxPoolSize.Value;
|
||||
}
|
||||
|
||||
if (options.DatabaseConnectionIdleLifetimeSeconds.HasValue)
|
||||
{
|
||||
builder.ConnectionIdleLifetime = options.DatabaseConnectionIdleLifetimeSeconds.Value;
|
||||
}
|
||||
|
||||
return builder.ConnectionString;
|
||||
}
|
||||
|
||||
private NpgsqlDataSource? CreateDataSource()
|
||||
{
|
||||
if (!IsConfigured)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var builder = new NpgsqlDataSourceBuilder(BuildConnectionString(_options));
|
||||
return builder.Build();
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,19 @@ public sealed class KnowledgeSearchOptions
|
||||
|
||||
public string ConnectionString { get; set; } = string.Empty;
|
||||
|
||||
public string DatabaseApplicationName { get; set; } = "stellaops-advisory-ai-web/knowledge-search";
|
||||
|
||||
public bool? DatabasePoolingEnabled { get; set; }
|
||||
|
||||
[Range(0, 4096)]
|
||||
public int? DatabaseMinPoolSize { get; set; }
|
||||
|
||||
[Range(1, 4096)]
|
||||
public int? DatabaseMaxPoolSize { get; set; }
|
||||
|
||||
[Range(0, 86400)]
|
||||
public int? DatabaseConnectionIdleLifetimeSeconds { get; set; } = 900;
|
||||
|
||||
public string Product { get; set; } = "stella-ops";
|
||||
|
||||
public string Version { get; set; } = "local";
|
||||
|
||||
@@ -17,6 +17,7 @@ public static class KnowledgeSearchServiceCollectionExtensions
|
||||
.Bind(configuration.GetSection(KnowledgeSearchOptions.SectionName))
|
||||
.ValidateDataAnnotations();
|
||||
|
||||
services.TryAddSingleton<KnowledgeSearchDataSourceProvider>();
|
||||
services.TryAddSingleton<IKnowledgeSearchStore, PostgresKnowledgeSearchStore>();
|
||||
services.TryAddSingleton<IKnowledgeIndexer, KnowledgeIndexer>();
|
||||
services.TryAddSingleton<IKnowledgeSearchService, KnowledgeSearchService>();
|
||||
|
||||
@@ -14,18 +14,20 @@ internal sealed class PostgresKnowledgeSearchStore : IKnowledgeSearchStore, IKno
|
||||
|
||||
private readonly KnowledgeSearchOptions _options;
|
||||
private readonly ILogger<PostgresKnowledgeSearchStore> _logger;
|
||||
private readonly Lazy<NpgsqlDataSource?> _dataSource;
|
||||
private readonly KnowledgeSearchDataSourceProvider _dataSourceProvider;
|
||||
private readonly bool _ownsDataSourceProvider;
|
||||
private bool? _hasEmbeddingVectorColumn;
|
||||
|
||||
public PostgresKnowledgeSearchStore(
|
||||
IOptions<KnowledgeSearchOptions> options,
|
||||
ILogger<PostgresKnowledgeSearchStore> logger)
|
||||
ILogger<PostgresKnowledgeSearchStore> logger,
|
||||
KnowledgeSearchDataSourceProvider? dataSourceProvider = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
_options = options.Value ?? new KnowledgeSearchOptions();
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
|
||||
_dataSource = new Lazy<NpgsqlDataSource?>(CreateDataSource, isThreadSafe: true);
|
||||
_ownsDataSourceProvider = dataSourceProvider is null;
|
||||
_dataSourceProvider = dataSourceProvider ?? new KnowledgeSearchDataSourceProvider(_options);
|
||||
}
|
||||
|
||||
public async Task EnsureSchemaAsync(CancellationToken cancellationToken)
|
||||
@@ -473,9 +475,9 @@ internal sealed class PostgresKnowledgeSearchStore : IKnowledgeSearchStore, IKno
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_dataSource.IsValueCreated && _dataSource.Value is not null)
|
||||
if (_ownsDataSourceProvider)
|
||||
{
|
||||
await _dataSource.Value.DisposeAsync().ConfigureAwait(false);
|
||||
await _dataSourceProvider.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1158,29 +1160,12 @@ internal sealed class PostgresKnowledgeSearchStore : IKnowledgeSearchStore, IKno
|
||||
|
||||
private bool IsConfigured()
|
||||
{
|
||||
return _options.Enabled && !string.IsNullOrWhiteSpace(_options.ConnectionString);
|
||||
return _dataSourceProvider.IsConfigured;
|
||||
}
|
||||
|
||||
private NpgsqlDataSource GetDataSource()
|
||||
{
|
||||
if (_dataSource.Value is null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"AdvisoryAI knowledge search is not configured. Set AdvisoryAI:KnowledgeSearch:ConnectionString.");
|
||||
}
|
||||
|
||||
return _dataSource.Value;
|
||||
}
|
||||
|
||||
private NpgsqlDataSource? CreateDataSource()
|
||||
{
|
||||
if (!IsConfigured())
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var builder = new NpgsqlDataSourceBuilder(_options.ConnectionString);
|
||||
return builder.Build();
|
||||
return _dataSourceProvider.GetRequiredDataSource();
|
||||
}
|
||||
|
||||
private static IReadOnlyList<(string Name, string Sql)> LoadMigrationScripts()
|
||||
|
||||
@@ -5,6 +5,8 @@ Source of truth: `docs/implplan/SPRINT_20260113_005_ADVISORYAI_controlled_conver
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| SPRINT_20260405_011-XPORT | DONE | `docs/implplan/SPRINT_20260405_011___Libraries_transport_pooling_and_attribution_hardening.md`: named advisory chat audit PostgreSQL sessions and aligned the first repo-wide transport hardening wave. |
|
||||
| SPRINT_20260405_010-AIAI-PG | DONE | `docs/implplan/SPRINT_20260405_010_AdvisoryAI_pg_pooling_and_gitea_spike_followup.md`: AdvisoryAI PostgreSQL application-name/pooling follow-up and Gitea spike capture. |
|
||||
| SPRINT_20260223_100-USRCH-POL-005 | DONE | Security hardening closure: tenant-scoped adapter identities, backend+frontend snippet sanitization, and threat-model docs. Evidence: `UnifiedSearchLiveAdapterIntegrationTests` (11/11), `UnifiedSearchSprintIntegrationTests` (109/109), targeted snippet test (1/1). |
|
||||
| SPRINT_20260223_100-USRCH-POL-006 | DONE | Deprecation timeline documented in `docs/modules/advisory-ai/CHANGELOG.md`; platform/unified migration criteria closed for sprint 100 task 006. |
|
||||
| SPRINT_20260224_102-G1-005 | DONE | ONNX missing-model fallback integration evidence added (`G1_OnnxEncoderSelection_MissingModelPath_FallsBackToDeterministicHashEncoder`). |
|
||||
|
||||
@@ -9,16 +9,19 @@ internal sealed class SearchAnalyticsService
|
||||
{
|
||||
private readonly KnowledgeSearchOptions _options;
|
||||
private readonly ILogger<SearchAnalyticsService> _logger;
|
||||
private readonly KnowledgeSearchDataSourceProvider _dataSourceProvider;
|
||||
private readonly object _fallbackLock = new();
|
||||
private readonly List<(SearchAnalyticsEvent Event, DateTimeOffset RecordedAt)> _fallbackEvents = [];
|
||||
private readonly Dictionary<(string TenantId, string UserKey, string Query), SearchHistoryEntry> _fallbackHistory = new();
|
||||
|
||||
public SearchAnalyticsService(
|
||||
IOptions<KnowledgeSearchOptions> options,
|
||||
ILogger<SearchAnalyticsService> logger)
|
||||
ILogger<SearchAnalyticsService> logger,
|
||||
KnowledgeSearchDataSourceProvider? dataSourceProvider = null)
|
||||
{
|
||||
_options = options.Value;
|
||||
_logger = logger;
|
||||
_dataSourceProvider = dataSourceProvider ?? new KnowledgeSearchDataSourceProvider(_options);
|
||||
}
|
||||
|
||||
public async Task RecordEventAsync(SearchAnalyticsEvent evt, CancellationToken ct = default)
|
||||
@@ -38,8 +41,7 @@ internal sealed class SearchAnalyticsService
|
||||
|
||||
try
|
||||
{
|
||||
await using var conn = new NpgsqlConnection(_options.ConnectionString);
|
||||
await conn.OpenAsync(ct).ConfigureAwait(false);
|
||||
await using var conn = await OpenConnectionAsync(ct).ConfigureAwait(false);
|
||||
|
||||
await using var cmd = new NpgsqlCommand(@"
|
||||
INSERT INTO advisoryai.search_events
|
||||
@@ -90,8 +92,7 @@ internal sealed class SearchAnalyticsService
|
||||
|
||||
try
|
||||
{
|
||||
await using var conn = new NpgsqlConnection(_options.ConnectionString);
|
||||
await conn.OpenAsync(ct).ConfigureAwait(false);
|
||||
await using var conn = await OpenConnectionAsync(ct).ConfigureAwait(false);
|
||||
|
||||
foreach (var evt in events)
|
||||
{
|
||||
@@ -146,8 +147,7 @@ internal sealed class SearchAnalyticsService
|
||||
|
||||
try
|
||||
{
|
||||
await using var conn = new NpgsqlConnection(_options.ConnectionString);
|
||||
await conn.OpenAsync(ct).ConfigureAwait(false);
|
||||
await using var conn = await OpenConnectionAsync(ct).ConfigureAwait(false);
|
||||
|
||||
await using var cmd = new NpgsqlCommand(@"
|
||||
SELECT entity_key, COUNT(*) as click_count
|
||||
@@ -201,8 +201,7 @@ internal sealed class SearchAnalyticsService
|
||||
|
||||
try
|
||||
{
|
||||
await using var conn = new NpgsqlConnection(_options.ConnectionString);
|
||||
await conn.OpenAsync(ct).ConfigureAwait(false);
|
||||
await using var conn = await OpenConnectionAsync(ct).ConfigureAwait(false);
|
||||
|
||||
await using var cmd = new NpgsqlCommand(@"
|
||||
INSERT INTO advisoryai.search_history (tenant_id, user_id, query, result_count)
|
||||
@@ -256,8 +255,7 @@ internal sealed class SearchAnalyticsService
|
||||
|
||||
try
|
||||
{
|
||||
await using var conn = new NpgsqlConnection(_options.ConnectionString);
|
||||
await conn.OpenAsync(ct).ConfigureAwait(false);
|
||||
await using var conn = await OpenConnectionAsync(ct).ConfigureAwait(false);
|
||||
|
||||
await using var cmd = new NpgsqlCommand(@"
|
||||
SELECT history_id, query, result_count, searched_at
|
||||
@@ -305,8 +303,7 @@ internal sealed class SearchAnalyticsService
|
||||
|
||||
try
|
||||
{
|
||||
await using var conn = new NpgsqlConnection(_options.ConnectionString);
|
||||
await conn.OpenAsync(ct).ConfigureAwait(false);
|
||||
await using var conn = await OpenConnectionAsync(ct).ConfigureAwait(false);
|
||||
|
||||
await using var cmd = new NpgsqlCommand(@"
|
||||
DELETE FROM advisoryai.search_history
|
||||
@@ -345,8 +342,7 @@ internal sealed class SearchAnalyticsService
|
||||
|
||||
try
|
||||
{
|
||||
await using var conn = new NpgsqlConnection(_options.ConnectionString);
|
||||
await conn.OpenAsync(ct).ConfigureAwait(false);
|
||||
await using var conn = await OpenConnectionAsync(ct).ConfigureAwait(false);
|
||||
|
||||
await using var cmd = new NpgsqlCommand(@"
|
||||
SELECT candidate_query
|
||||
@@ -403,8 +399,7 @@ internal sealed class SearchAnalyticsService
|
||||
|
||||
try
|
||||
{
|
||||
await using var conn = new NpgsqlConnection(_options.ConnectionString);
|
||||
await conn.OpenAsync(ct).ConfigureAwait(false);
|
||||
await using var conn = await OpenConnectionAsync(ct).ConfigureAwait(false);
|
||||
|
||||
await using var cmd = new NpgsqlCommand(@"
|
||||
DELETE FROM advisoryai.search_history
|
||||
@@ -436,8 +431,7 @@ internal sealed class SearchAnalyticsService
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var conn = new NpgsqlConnection(_options.ConnectionString);
|
||||
await conn.OpenAsync(ct).ConfigureAwait(false);
|
||||
await using var conn = await OpenConnectionAsync(ct).ConfigureAwait(false);
|
||||
|
||||
await using (var eventsCmd = new NpgsqlCommand(@"
|
||||
DELETE FROM advisoryai.search_events
|
||||
@@ -559,6 +553,11 @@ internal sealed class SearchAnalyticsService
|
||||
}
|
||||
}
|
||||
|
||||
private ValueTask<NpgsqlConnection> OpenConnectionAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return _dataSourceProvider.OpenConnectionAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private void RecordFallbackHistory(string tenantId, string userKey, string query, int resultCount, DateTimeOffset recordedAt)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId) || string.IsNullOrWhiteSpace(userKey) || string.IsNullOrWhiteSpace(query))
|
||||
|
||||
@@ -25,6 +25,7 @@ internal sealed class SearchQualityMonitor
|
||||
private readonly KnowledgeSearchOptions _options;
|
||||
private readonly ILogger<SearchQualityMonitor> _logger;
|
||||
private readonly SearchAnalyticsService _analyticsService;
|
||||
private readonly KnowledgeSearchDataSourceProvider _dataSourceProvider;
|
||||
private readonly object _fallbackLock = new();
|
||||
private readonly List<(SearchFeedbackEntry Entry, DateTimeOffset CreatedAt)> _fallbackFeedback = [];
|
||||
private readonly List<SearchQualityAlertEntry> _fallbackAlerts = [];
|
||||
@@ -32,12 +33,14 @@ internal sealed class SearchQualityMonitor
|
||||
public SearchQualityMonitor(
|
||||
IOptions<KnowledgeSearchOptions> options,
|
||||
ILogger<SearchQualityMonitor> logger,
|
||||
SearchAnalyticsService? analyticsService = null)
|
||||
SearchAnalyticsService? analyticsService = null,
|
||||
KnowledgeSearchDataSourceProvider? dataSourceProvider = null)
|
||||
{
|
||||
_options = options.Value;
|
||||
_logger = logger;
|
||||
_analyticsService = analyticsService ??
|
||||
new SearchAnalyticsService(options, Microsoft.Extensions.Logging.Abstractions.NullLogger<SearchAnalyticsService>.Instance);
|
||||
_dataSourceProvider = dataSourceProvider ?? new KnowledgeSearchDataSourceProvider(_options);
|
||||
}
|
||||
|
||||
// ----- Feedback CRUD -----
|
||||
@@ -59,8 +62,7 @@ internal sealed class SearchQualityMonitor
|
||||
|
||||
try
|
||||
{
|
||||
await using var conn = new NpgsqlConnection(_options.ConnectionString);
|
||||
await conn.OpenAsync(ct).ConfigureAwait(false);
|
||||
await using var conn = await OpenConnectionAsync(ct).ConfigureAwait(false);
|
||||
|
||||
await using var cmd = new NpgsqlCommand(@"
|
||||
INSERT INTO advisoryai.search_feedback
|
||||
@@ -115,8 +117,7 @@ internal sealed class SearchQualityMonitor
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var conn = new NpgsqlConnection(_options.ConnectionString);
|
||||
await conn.OpenAsync(ct).ConfigureAwait(false);
|
||||
await using var conn = await OpenConnectionAsync(ct).ConfigureAwait(false);
|
||||
|
||||
await using var cmd = new NpgsqlCommand(@"
|
||||
SELECT DISTINCT tenant_id FROM advisoryai.search_events
|
||||
@@ -239,8 +240,7 @@ internal sealed class SearchQualityMonitor
|
||||
|
||||
try
|
||||
{
|
||||
await using var conn = new NpgsqlConnection(_options.ConnectionString);
|
||||
await conn.OpenAsync(ct).ConfigureAwait(false);
|
||||
await using var conn = await OpenConnectionAsync(ct).ConfigureAwait(false);
|
||||
|
||||
var sql = @"
|
||||
SELECT alert_id, tenant_id, alert_type, query, occurrence_count,
|
||||
@@ -342,8 +342,7 @@ internal sealed class SearchQualityMonitor
|
||||
|
||||
try
|
||||
{
|
||||
await using var conn = new NpgsqlConnection(_options.ConnectionString);
|
||||
await conn.OpenAsync(ct).ConfigureAwait(false);
|
||||
await using var conn = await OpenConnectionAsync(ct).ConfigureAwait(false);
|
||||
|
||||
await using var cmd = new NpgsqlCommand(@"
|
||||
UPDATE advisoryai.search_quality_alerts
|
||||
@@ -408,8 +407,7 @@ internal sealed class SearchQualityMonitor
|
||||
|
||||
try
|
||||
{
|
||||
await using var conn = new NpgsqlConnection(_options.ConnectionString);
|
||||
await conn.OpenAsync(ct).ConfigureAwait(false);
|
||||
await using var conn = await OpenConnectionAsync(ct).ConfigureAwait(false);
|
||||
|
||||
// Total searches and zero-result rate from search_events
|
||||
await using var searchCmd = new NpgsqlCommand(@"
|
||||
@@ -812,8 +810,7 @@ internal sealed class SearchQualityMonitor
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var conn = new NpgsqlConnection(_options.ConnectionString);
|
||||
await conn.OpenAsync(ct).ConfigureAwait(false);
|
||||
await using var conn = await OpenConnectionAsync(ct).ConfigureAwait(false);
|
||||
return await LoadSelfServeSignalEventsAsync(conn, tenantId, days, ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -1039,8 +1036,7 @@ internal sealed class SearchQualityMonitor
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var conn = new NpgsqlConnection(_options.ConnectionString);
|
||||
await conn.OpenAsync(ct).ConfigureAwait(false);
|
||||
await using var conn = await OpenConnectionAsync(ct).ConfigureAwait(false);
|
||||
|
||||
await using var cmd = new NpgsqlCommand(@"
|
||||
SELECT query, COUNT(*)::int AS occurrence_count, MIN(created_at), MAX(created_at)
|
||||
@@ -1102,8 +1098,7 @@ internal sealed class SearchQualityMonitor
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var conn = new NpgsqlConnection(_options.ConnectionString);
|
||||
await conn.OpenAsync(ct).ConfigureAwait(false);
|
||||
await using var conn = await OpenConnectionAsync(ct).ConfigureAwait(false);
|
||||
|
||||
await using var cmd = new NpgsqlCommand(@"
|
||||
SELECT query, COUNT(*)::int AS occurrence_count, MIN(created_at), MAX(created_at)
|
||||
@@ -1164,8 +1159,7 @@ internal sealed class SearchQualityMonitor
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var conn = new NpgsqlConnection(_options.ConnectionString);
|
||||
await conn.OpenAsync(ct).ConfigureAwait(false);
|
||||
await using var conn = await OpenConnectionAsync(ct).ConfigureAwait(false);
|
||||
|
||||
await using var findCmd = new NpgsqlCommand(@"
|
||||
SELECT alert_id
|
||||
@@ -1294,6 +1288,11 @@ internal sealed class SearchQualityMonitor
|
||||
}
|
||||
}
|
||||
|
||||
private ValueTask<NpgsqlConnection> OpenConnectionAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return _dataSourceProvider.OpenConnectionAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private IReadOnlyList<(SearchFeedbackEntry Entry, DateTimeOffset CreatedAt)> GetFallbackFeedback(string tenantId, TimeSpan window)
|
||||
{
|
||||
var cutoff = DateTimeOffset.UtcNow - window;
|
||||
@@ -1343,8 +1342,7 @@ internal sealed class SearchQualityMonitor
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var conn = new NpgsqlConnection(_options.ConnectionString);
|
||||
await conn.OpenAsync(ct).ConfigureAwait(false);
|
||||
await using var conn = await OpenConnectionAsync(ct).ConfigureAwait(false);
|
||||
|
||||
await using (var feedbackCmd = new NpgsqlCommand(@"
|
||||
DELETE FROM advisoryai.search_feedback
|
||||
|
||||
@@ -9,31 +9,24 @@ internal sealed class EntityAliasService : IEntityAliasService
|
||||
{
|
||||
private readonly KnowledgeSearchOptions _options;
|
||||
private readonly ILogger<EntityAliasService> _logger;
|
||||
private readonly Lazy<NpgsqlDataSource?> _dataSource;
|
||||
private readonly KnowledgeSearchDataSourceProvider _dataSourceProvider;
|
||||
|
||||
public EntityAliasService(
|
||||
IOptions<KnowledgeSearchOptions> options,
|
||||
ILogger<EntityAliasService> logger)
|
||||
ILogger<EntityAliasService> logger,
|
||||
KnowledgeSearchDataSourceProvider? dataSourceProvider = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
_options = options.Value ?? new KnowledgeSearchOptions();
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_dataSource = new Lazy<NpgsqlDataSource?>(() =>
|
||||
{
|
||||
if (!_options.Enabled || string.IsNullOrWhiteSpace(_options.ConnectionString))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new NpgsqlDataSourceBuilder(_options.ConnectionString).Build();
|
||||
}, isThreadSafe: true);
|
||||
_dataSourceProvider = dataSourceProvider ?? new KnowledgeSearchDataSourceProvider(_options);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<(string EntityKey, string EntityType)>> ResolveAliasesAsync(
|
||||
string alias,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(alias) || _dataSource.Value is null)
|
||||
if (string.IsNullOrWhiteSpace(alias) || !_dataSourceProvider.IsConfigured)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
@@ -45,7 +38,7 @@ internal sealed class EntityAliasService : IEntityAliasService
|
||||
ORDER BY entity_key, entity_type;
|
||||
""";
|
||||
|
||||
await using var command = _dataSource.Value.CreateCommand(sql);
|
||||
await using var command = _dataSourceProvider.GetRequiredDataSource().CreateCommand(sql);
|
||||
command.CommandTimeout = 10;
|
||||
command.Parameters.AddWithValue("alias", alias.Trim());
|
||||
|
||||
@@ -69,7 +62,7 @@ internal sealed class EntityAliasService : IEntityAliasService
|
||||
if (string.IsNullOrWhiteSpace(entityKey) ||
|
||||
string.IsNullOrWhiteSpace(entityType) ||
|
||||
string.IsNullOrWhiteSpace(alias) ||
|
||||
_dataSource.Value is null)
|
||||
!_dataSourceProvider.IsConfigured)
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -82,7 +75,7 @@ internal sealed class EntityAliasService : IEntityAliasService
|
||||
source = EXCLUDED.source;
|
||||
""";
|
||||
|
||||
await using var command = _dataSource.Value.CreateCommand(sql);
|
||||
await using var command = _dataSourceProvider.GetRequiredDataSource().CreateCommand(sql);
|
||||
command.CommandTimeout = 10;
|
||||
command.Parameters.AddWithValue("alias", alias.Trim());
|
||||
command.Parameters.AddWithValue("entity_key", entityKey.Trim());
|
||||
|
||||
@@ -16,18 +16,21 @@ internal sealed class UnifiedSearchIndexer : IUnifiedSearchIndexer
|
||||
private readonly IKnowledgeSearchStore _store;
|
||||
private readonly IEnumerable<ISearchIngestionAdapter> _adapters;
|
||||
private readonly ILogger<UnifiedSearchIndexer> _logger;
|
||||
private readonly KnowledgeSearchDataSourceProvider _dataSourceProvider;
|
||||
|
||||
public UnifiedSearchIndexer(
|
||||
IOptions<KnowledgeSearchOptions> options,
|
||||
IKnowledgeSearchStore store,
|
||||
IEnumerable<ISearchIngestionAdapter> adapters,
|
||||
ILogger<UnifiedSearchIndexer> logger)
|
||||
ILogger<UnifiedSearchIndexer> logger,
|
||||
KnowledgeSearchDataSourceProvider? dataSourceProvider = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
_options = options.Value ?? new KnowledgeSearchOptions();
|
||||
_store = store ?? throw new ArgumentNullException(nameof(store));
|
||||
_adapters = adapters ?? throw new ArgumentNullException(nameof(adapters));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_dataSourceProvider = dataSourceProvider ?? new KnowledgeSearchDataSourceProvider(_options);
|
||||
}
|
||||
|
||||
public async Task IndexAllAsync(CancellationToken cancellationToken)
|
||||
@@ -208,9 +211,8 @@ internal sealed class UnifiedSearchIndexer : IUnifiedSearchIndexer
|
||||
return;
|
||||
}
|
||||
|
||||
await using var dataSource = new NpgsqlDataSourceBuilder(_options.ConnectionString).Build();
|
||||
const string sql = "DELETE FROM advisoryai.kb_chunk WHERE domain = @domain;";
|
||||
await using var command = dataSource.CreateCommand(sql);
|
||||
await using var command = _dataSourceProvider.GetRequiredDataSource().CreateCommand(sql);
|
||||
command.CommandTimeout = 60;
|
||||
command.Parameters.AddWithValue("domain", domain);
|
||||
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
@@ -226,8 +228,7 @@ internal sealed class UnifiedSearchIndexer : IUnifiedSearchIndexer
|
||||
return 0;
|
||||
}
|
||||
|
||||
await using var dataSource = new NpgsqlDataSourceBuilder(_options.ConnectionString).Build();
|
||||
await using var command = dataSource.CreateCommand();
|
||||
await using var command = _dataSourceProvider.GetRequiredDataSource().CreateCommand();
|
||||
command.CommandTimeout = 90;
|
||||
command.Parameters.AddWithValue("domain", domain);
|
||||
|
||||
@@ -253,8 +254,7 @@ internal sealed class UnifiedSearchIndexer : IUnifiedSearchIndexer
|
||||
|
||||
private async Task<int> UpsertChunksAsync(IReadOnlyList<UnifiedChunk> chunks, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var dataSource = new NpgsqlDataSourceBuilder(_options.ConnectionString).Build();
|
||||
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var connection = await _dataSourceProvider.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
var hasEmbeddingVectorColumn = await HasEmbeddingVectorColumnAsync(connection, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Ensure parent documents exist for each unique DocId
|
||||
|
||||
@@ -17,7 +17,15 @@ var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// Add PostgreSQL data source
|
||||
var connectionString = ResolveOpsMemoryConnectionString(builder);
|
||||
builder.Services.AddSingleton<NpgsqlDataSource>(_ => NpgsqlDataSource.Create(connectionString));
|
||||
builder.Services.AddSingleton<NpgsqlDataSource>(_ =>
|
||||
{
|
||||
var connectionStringBuilder = new NpgsqlConnectionStringBuilder(connectionString)
|
||||
{
|
||||
ApplicationName = "stellaops-opsmemory",
|
||||
};
|
||||
|
||||
return new NpgsqlDataSourceBuilder(connectionStringBuilder.ConnectionString).Build();
|
||||
});
|
||||
|
||||
// Add determinism abstractions (TimeProvider + IGuidProvider for endpoint parameter binding)
|
||||
builder.Services.AddDeterminismDefaults();
|
||||
|
||||
@@ -4,6 +4,7 @@ Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_sol
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| SPRINT_20260405_011-XPORT | DONE | `docs/implplan/SPRINT_20260405_011___Libraries_transport_pooling_and_attribution_hardening.md`: named the OpsMemory PostgreSQL datasource for runtime attribution. |
|
||||
| S312-OPSMEMORY-CONNECTION | DONE | Sprint `docs/implplan/SPRINT_20260305_312_DOCS_storage_policy_postgres_rustfs_alignment.md` TASK-312-007: aligned connection resolution with compose defaults (`ConnectionStrings:Default` fallback) and added fail-fast behavior for non-development when DB config is missing. |
|
||||
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/OpsMemory/StellaOps.OpsMemory.WebService/StellaOps.OpsMemory.WebService.md. |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
using FluentAssertions;
|
||||
using Npgsql;
|
||||
using StellaOps.AdvisoryAI.KnowledgeSearch;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Tests.KnowledgeSearch;
|
||||
|
||||
public sealed class KnowledgeSearchDataSourceProviderTests
|
||||
{
|
||||
[Fact]
|
||||
public void BuildConnectionString_applies_stable_application_name_and_idle_lifetime_defaults()
|
||||
{
|
||||
var options = new KnowledgeSearchOptions
|
||||
{
|
||||
ConnectionString = "Host=db.stella-ops.local;Port=5432;Database=stellaops_platform;Username=stellaops;Password=stellaops;Maximum Pool Size=50"
|
||||
};
|
||||
|
||||
var builder = new NpgsqlConnectionStringBuilder(
|
||||
KnowledgeSearchDataSourceProvider.BuildConnectionString(options));
|
||||
|
||||
builder.ApplicationName.Should().Be("stellaops-advisory-ai-web/knowledge-search");
|
||||
builder.ConnectionIdleLifetime.Should().Be(900);
|
||||
builder.MaxPoolSize.Should().Be(50);
|
||||
builder.Host.Should().Be("db.stella-ops.local");
|
||||
builder.Database.Should().Be("stellaops_platform");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildConnectionString_honors_explicit_pooling_overrides()
|
||||
{
|
||||
var options = new KnowledgeSearchOptions
|
||||
{
|
||||
ConnectionString = "Host=db.stella-ops.local;Port=5432;Database=stellaops_platform;Username=stellaops;Password=stellaops;Maximum Pool Size=50",
|
||||
DatabaseApplicationName = "custom-aiai-search",
|
||||
DatabasePoolingEnabled = true,
|
||||
DatabaseMinPoolSize = 3,
|
||||
DatabaseMaxPoolSize = 12,
|
||||
DatabaseConnectionIdleLifetimeSeconds = 120
|
||||
};
|
||||
|
||||
var builder = new NpgsqlConnectionStringBuilder(
|
||||
KnowledgeSearchDataSourceProvider.BuildConnectionString(options));
|
||||
|
||||
builder.ApplicationName.Should().Be("custom-aiai-search");
|
||||
builder.Pooling.Should().BeTrue();
|
||||
builder.MinPoolSize.Should().Be(3);
|
||||
builder.MaxPoolSize.Should().Be(12);
|
||||
builder.ConnectionIdleLifetime.Should().Be(120);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user