From 7dd22e4b167007527b9c842ad73099325714d830 Mon Sep 17 00:00:00 2001 From: master <> Date: Mon, 6 Apr 2026 08:53:12 +0300 Subject: [PATCH] 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) --- .../StellaOps.AdvisoryAI.WebService/TASKS.md | 1 + .../PostgresAdvisoryChatAuditLogger.cs | 7 +- .../KnowledgeSearchDataSourceProvider.cs | 102 ++++++++++++++++++ .../KnowledgeSearch/KnowledgeSearchOptions.cs | 13 +++ ...wledgeSearchServiceCollectionExtensions.cs | 1 + .../PostgresKnowledgeSearchStore.cs | 35 ++---- src/AdvisoryAI/StellaOps.AdvisoryAI/TASKS.md | 2 + .../Analytics/SearchAnalyticsService.cs | 37 ++++--- .../Analytics/SearchQualityMonitor.cs | 40 ++++--- .../UnifiedSearch/EntityAliasService.cs | 23 ++-- .../UnifiedSearch/UnifiedSearchIndexer.cs | 14 +-- .../StellaOps.OpsMemory.WebService/Program.cs | 10 +- .../StellaOps.OpsMemory.WebService/TASKS.md | 1 + .../KnowledgeSearchDataSourceProviderTests.cs | 49 +++++++++ 14 files changed, 246 insertions(+), 89 deletions(-) create mode 100644 src/AdvisoryAI/StellaOps.AdvisoryAI/KnowledgeSearch/KnowledgeSearchDataSourceProvider.cs create mode 100644 src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/KnowledgeSearch/KnowledgeSearchDataSourceProviderTests.cs diff --git a/src/AdvisoryAI/StellaOps.AdvisoryAI.WebService/TASKS.md b/src/AdvisoryAI/StellaOps.AdvisoryAI.WebService/TASKS.md index 5d82a31af..90b34d436 100644 --- a/src/AdvisoryAI/StellaOps.AdvisoryAI.WebService/TASKS.md +++ b/src/AdvisoryAI/StellaOps.AdvisoryAI.WebService/TASKS.md @@ -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. | diff --git a/src/AdvisoryAI/StellaOps.AdvisoryAI/Chat/Services/PostgresAdvisoryChatAuditLogger.cs b/src/AdvisoryAI/StellaOps.AdvisoryAI/Chat/Services/PostgresAdvisoryChatAuditLogger.cs index 5b4a4045b..f240a56d1 100644 --- a/src/AdvisoryAI/StellaOps.AdvisoryAI/Chat/Services/PostgresAdvisoryChatAuditLogger.cs +++ b/src/AdvisoryAI/StellaOps.AdvisoryAI/Chat/Services/PostgresAdvisoryChatAuditLogger.cs @@ -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 ( diff --git a/src/AdvisoryAI/StellaOps.AdvisoryAI/KnowledgeSearch/KnowledgeSearchDataSourceProvider.cs b/src/AdvisoryAI/StellaOps.AdvisoryAI/KnowledgeSearch/KnowledgeSearchDataSourceProvider.cs new file mode 100644 index 000000000..1f62499e3 --- /dev/null +++ b/src/AdvisoryAI/StellaOps.AdvisoryAI/KnowledgeSearch/KnowledgeSearchDataSourceProvider.cs @@ -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 _dataSource; + + public KnowledgeSearchDataSourceProvider(IOptions options) + : this(options?.Value ?? new KnowledgeSearchOptions()) + { + } + + internal KnowledgeSearchDataSourceProvider(KnowledgeSearchOptions options) + { + _options = options ?? throw new ArgumentNullException(nameof(options)); + _dataSource = new Lazy(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 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(); + } +} diff --git a/src/AdvisoryAI/StellaOps.AdvisoryAI/KnowledgeSearch/KnowledgeSearchOptions.cs b/src/AdvisoryAI/StellaOps.AdvisoryAI/KnowledgeSearch/KnowledgeSearchOptions.cs index 60bf19b06..198115e3a 100644 --- a/src/AdvisoryAI/StellaOps.AdvisoryAI/KnowledgeSearch/KnowledgeSearchOptions.cs +++ b/src/AdvisoryAI/StellaOps.AdvisoryAI/KnowledgeSearch/KnowledgeSearchOptions.cs @@ -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"; diff --git a/src/AdvisoryAI/StellaOps.AdvisoryAI/KnowledgeSearch/KnowledgeSearchServiceCollectionExtensions.cs b/src/AdvisoryAI/StellaOps.AdvisoryAI/KnowledgeSearch/KnowledgeSearchServiceCollectionExtensions.cs index c702fbbab..7ec563ca7 100644 --- a/src/AdvisoryAI/StellaOps.AdvisoryAI/KnowledgeSearch/KnowledgeSearchServiceCollectionExtensions.cs +++ b/src/AdvisoryAI/StellaOps.AdvisoryAI/KnowledgeSearch/KnowledgeSearchServiceCollectionExtensions.cs @@ -17,6 +17,7 @@ public static class KnowledgeSearchServiceCollectionExtensions .Bind(configuration.GetSection(KnowledgeSearchOptions.SectionName)) .ValidateDataAnnotations(); + services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(); diff --git a/src/AdvisoryAI/StellaOps.AdvisoryAI/KnowledgeSearch/PostgresKnowledgeSearchStore.cs b/src/AdvisoryAI/StellaOps.AdvisoryAI/KnowledgeSearch/PostgresKnowledgeSearchStore.cs index a4a9b06ac..f6a2d7d24 100644 --- a/src/AdvisoryAI/StellaOps.AdvisoryAI/KnowledgeSearch/PostgresKnowledgeSearchStore.cs +++ b/src/AdvisoryAI/StellaOps.AdvisoryAI/KnowledgeSearch/PostgresKnowledgeSearchStore.cs @@ -14,18 +14,20 @@ internal sealed class PostgresKnowledgeSearchStore : IKnowledgeSearchStore, IKno private readonly KnowledgeSearchOptions _options; private readonly ILogger _logger; - private readonly Lazy _dataSource; + private readonly KnowledgeSearchDataSourceProvider _dataSourceProvider; + private readonly bool _ownsDataSourceProvider; private bool? _hasEmbeddingVectorColumn; public PostgresKnowledgeSearchStore( IOptions options, - ILogger logger) + ILogger logger, + KnowledgeSearchDataSourceProvider? dataSourceProvider = null) { ArgumentNullException.ThrowIfNull(options); _options = options.Value ?? new KnowledgeSearchOptions(); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - - _dataSource = new Lazy(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() diff --git a/src/AdvisoryAI/StellaOps.AdvisoryAI/TASKS.md b/src/AdvisoryAI/StellaOps.AdvisoryAI/TASKS.md index 42fb6477b..8ca0fc075 100644 --- a/src/AdvisoryAI/StellaOps.AdvisoryAI/TASKS.md +++ b/src/AdvisoryAI/StellaOps.AdvisoryAI/TASKS.md @@ -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`). | diff --git a/src/AdvisoryAI/StellaOps.AdvisoryAI/UnifiedSearch/Analytics/SearchAnalyticsService.cs b/src/AdvisoryAI/StellaOps.AdvisoryAI/UnifiedSearch/Analytics/SearchAnalyticsService.cs index 427f46e37..3d46d5582 100644 --- a/src/AdvisoryAI/StellaOps.AdvisoryAI/UnifiedSearch/Analytics/SearchAnalyticsService.cs +++ b/src/AdvisoryAI/StellaOps.AdvisoryAI/UnifiedSearch/Analytics/SearchAnalyticsService.cs @@ -9,16 +9,19 @@ internal sealed class SearchAnalyticsService { private readonly KnowledgeSearchOptions _options; private readonly ILogger _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 options, - ILogger logger) + ILogger 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 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)) diff --git a/src/AdvisoryAI/StellaOps.AdvisoryAI/UnifiedSearch/Analytics/SearchQualityMonitor.cs b/src/AdvisoryAI/StellaOps.AdvisoryAI/UnifiedSearch/Analytics/SearchQualityMonitor.cs index f29eb9ccb..7ae6bb3b2 100644 --- a/src/AdvisoryAI/StellaOps.AdvisoryAI/UnifiedSearch/Analytics/SearchQualityMonitor.cs +++ b/src/AdvisoryAI/StellaOps.AdvisoryAI/UnifiedSearch/Analytics/SearchQualityMonitor.cs @@ -25,6 +25,7 @@ internal sealed class SearchQualityMonitor private readonly KnowledgeSearchOptions _options; private readonly ILogger _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 _fallbackAlerts = []; @@ -32,12 +33,14 @@ internal sealed class SearchQualityMonitor public SearchQualityMonitor( IOptions options, ILogger 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.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 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 diff --git a/src/AdvisoryAI/StellaOps.AdvisoryAI/UnifiedSearch/EntityAliasService.cs b/src/AdvisoryAI/StellaOps.AdvisoryAI/UnifiedSearch/EntityAliasService.cs index e5ccb83dc..02b171403 100644 --- a/src/AdvisoryAI/StellaOps.AdvisoryAI/UnifiedSearch/EntityAliasService.cs +++ b/src/AdvisoryAI/StellaOps.AdvisoryAI/UnifiedSearch/EntityAliasService.cs @@ -9,31 +9,24 @@ internal sealed class EntityAliasService : IEntityAliasService { private readonly KnowledgeSearchOptions _options; private readonly ILogger _logger; - private readonly Lazy _dataSource; + private readonly KnowledgeSearchDataSourceProvider _dataSourceProvider; public EntityAliasService( IOptions options, - ILogger logger) + ILogger logger, + KnowledgeSearchDataSourceProvider? dataSourceProvider = null) { ArgumentNullException.ThrowIfNull(options); _options = options.Value ?? new KnowledgeSearchOptions(); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - _dataSource = new Lazy(() => - { - 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> 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()); diff --git a/src/AdvisoryAI/StellaOps.AdvisoryAI/UnifiedSearch/UnifiedSearchIndexer.cs b/src/AdvisoryAI/StellaOps.AdvisoryAI/UnifiedSearch/UnifiedSearchIndexer.cs index f5b69e055..d19c672b6 100644 --- a/src/AdvisoryAI/StellaOps.AdvisoryAI/UnifiedSearch/UnifiedSearchIndexer.cs +++ b/src/AdvisoryAI/StellaOps.AdvisoryAI/UnifiedSearch/UnifiedSearchIndexer.cs @@ -16,18 +16,21 @@ internal sealed class UnifiedSearchIndexer : IUnifiedSearchIndexer private readonly IKnowledgeSearchStore _store; private readonly IEnumerable _adapters; private readonly ILogger _logger; + private readonly KnowledgeSearchDataSourceProvider _dataSourceProvider; public UnifiedSearchIndexer( IOptions options, IKnowledgeSearchStore store, IEnumerable adapters, - ILogger logger) + ILogger 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 UpsertChunksAsync(IReadOnlyList 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 diff --git a/src/AdvisoryAI/StellaOps.OpsMemory.WebService/Program.cs b/src/AdvisoryAI/StellaOps.OpsMemory.WebService/Program.cs index 17389cc0b..2c861e90a 100644 --- a/src/AdvisoryAI/StellaOps.OpsMemory.WebService/Program.cs +++ b/src/AdvisoryAI/StellaOps.OpsMemory.WebService/Program.cs @@ -17,7 +17,15 @@ var builder = WebApplication.CreateBuilder(args); // Add PostgreSQL data source var connectionString = ResolveOpsMemoryConnectionString(builder); -builder.Services.AddSingleton(_ => NpgsqlDataSource.Create(connectionString)); +builder.Services.AddSingleton(_ => +{ + 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(); diff --git a/src/AdvisoryAI/StellaOps.OpsMemory.WebService/TASKS.md b/src/AdvisoryAI/StellaOps.OpsMemory.WebService/TASKS.md index 51e8f7a80..56bc2bdb6 100644 --- a/src/AdvisoryAI/StellaOps.OpsMemory.WebService/TASKS.md +++ b/src/AdvisoryAI/StellaOps.OpsMemory.WebService/TASKS.md @@ -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. | diff --git a/src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/KnowledgeSearch/KnowledgeSearchDataSourceProviderTests.cs b/src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/KnowledgeSearch/KnowledgeSearchDataSourceProviderTests.cs new file mode 100644 index 000000000..11528c21b --- /dev/null +++ b/src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/KnowledgeSearch/KnowledgeSearchDataSourceProviderTests.cs @@ -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); + } +}