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:
master
2026-04-06 08:53:12 +03:00
parent 31fac84cab
commit 7dd22e4b16
14 changed files with 246 additions and 89 deletions

View File

@@ -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. |

View File

@@ -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 (

View File

@@ -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();
}
}

View File

@@ -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";

View File

@@ -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>();

View File

@@ -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()

View File

@@ -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`). |

View File

@@ -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))

View File

@@ -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

View File

@@ -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());

View File

@@ -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

View File

@@ -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();

View File

@@ -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. |

View File

@@ -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);
}
}