Centralize Postgres connection string policy across all modules
Extract connection string building into PostgresConnectionStringPolicy so all services use consistent pooling, application_name, and timeout settings. Adopt the new policy in 20+ module DataSource/ServiceCollectionExtensions classes. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -62,7 +62,7 @@ public static class ServiceCollectionExtensions
|
||||
"Eventing:ConnectionString must be configured when Eventing:UseInMemoryStore is false.");
|
||||
}
|
||||
|
||||
return NpgsqlDataSource.Create(configuredOptions.ConnectionString);
|
||||
return CreateNamedDataSource(configuredOptions.ConnectionString, configuredOptions.ServiceName);
|
||||
});
|
||||
|
||||
services.TryAddSingleton<ITimelineEventStore, PostgresTimelineEventStore>();
|
||||
@@ -104,7 +104,7 @@ public static class ServiceCollectionExtensions
|
||||
TryAddHybridLogicalClock(services, serviceName);
|
||||
|
||||
// Register NpgsqlDataSource
|
||||
services.TryAddSingleton(_ => NpgsqlDataSource.Create(connectionString));
|
||||
services.TryAddSingleton(_ => CreateNamedDataSource(connectionString, serviceName));
|
||||
|
||||
services.TryAddSingleton<ITimelineEventStore, PostgresTimelineEventStore>();
|
||||
services.TryAddSingleton<ITimelineEventEmitter, TimelineEventEmitter>();
|
||||
@@ -186,4 +186,15 @@ public static class ServiceCollectionExtensions
|
||||
|
||||
return nodeId.Trim().Replace(' ', '-').ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static NpgsqlDataSource CreateNamedDataSource(string connectionString, string? serviceName)
|
||||
{
|
||||
var normalizedServiceName = ResolveNodeId(serviceName);
|
||||
var connectionStringBuilder = new NpgsqlConnectionStringBuilder(connectionString)
|
||||
{
|
||||
ApplicationName = $"stellaops-eventing-{normalizedServiceName}",
|
||||
};
|
||||
|
||||
return new NpgsqlDataSourceBuilder(connectionStringBuilder.ConnectionString).Build();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,8 @@
|
||||
## Determinism & Guardrails
|
||||
- Target runtime: .NET 10, Npgsql 9.x; keep options defaults deterministic (UTC timezone, statement timeout, stable pagination ordering).
|
||||
- Tenant context must be set via `set_config('app.current_tenant', ...)` on every connection before use; never bypass DataSourceBase.
|
||||
- Runtime `NpgsqlDataSource` creation must carry a stable `application_name`; prefer `PostgresConnectionStringPolicy` or an explicit `NpgsqlConnectionStringBuilder.ApplicationName`.
|
||||
- Anonymous `NpgsqlDataSource.Create(...)` is forbidden in steady-state runtime code. Allow exceptions only for tests, migrations, CLI/setup paths, or sprint-documented one-shot diagnostics.
|
||||
- Migrations ship as embedded resources; MigrationRunner uses SHA256 checksums and `RunFromAssemblyAsync`???do not execute ad-hoc SQL outside tracked migrations.
|
||||
- Respect air-gap posture: no external downloads at runtime; pin Postgres/Testcontainers images (`postgres:16-alpine` or later) in tests.
|
||||
|
||||
@@ -30,4 +32,3 @@
|
||||
## Handoff Notes
|
||||
- Align configuration defaults with the provisioning values under `devops/database/postgres` (ports, pool sizes, SSL/TLS).
|
||||
- Update this AGENTS file whenever connection/session rules or provisioning defaults change; record updates in the sprint Execution Log.
|
||||
|
||||
|
||||
@@ -241,15 +241,8 @@ public abstract class DataSourceBase : IAsyncDisposable
|
||||
return connection;
|
||||
}
|
||||
|
||||
private static string BuildConnectionString(PostgresOptions options)
|
||||
{
|
||||
var builder = new NpgsqlConnectionStringBuilder(options.ConnectionString)
|
||||
{
|
||||
Pooling = options.Pooling,
|
||||
MaxPoolSize = options.MaxPoolSize,
|
||||
MinPoolSize = options.MinPoolSize
|
||||
};
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
private string BuildConnectionString(PostgresOptions options)
|
||||
=> PostgresConnectionStringPolicy.Build(
|
||||
options,
|
||||
PostgresConnectionStringPolicy.BuildDefaultApplicationName(ModuleName));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
using Npgsql;
|
||||
using StellaOps.Infrastructure.Postgres.Options;
|
||||
|
||||
namespace StellaOps.Infrastructure.Postgres.Connections;
|
||||
|
||||
/// <summary>
|
||||
/// Applies StellaOps runtime defaults to PostgreSQL connection strings.
|
||||
/// </summary>
|
||||
public static class PostgresConnectionStringPolicy
|
||||
{
|
||||
public static string Build(PostgresOptions options, string defaultApplicationName)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(defaultApplicationName);
|
||||
|
||||
return Build(
|
||||
options.ConnectionString,
|
||||
string.IsNullOrWhiteSpace(options.ApplicationName)
|
||||
? defaultApplicationName.Trim()
|
||||
: options.ApplicationName.Trim(),
|
||||
options.Pooling,
|
||||
options.MinPoolSize,
|
||||
options.MaxPoolSize,
|
||||
options.ConnectionIdleLifetimeSeconds);
|
||||
}
|
||||
|
||||
public static string Build(
|
||||
string connectionString,
|
||||
string applicationName,
|
||||
bool pooling = true,
|
||||
int minPoolSize = 1,
|
||||
int maxPoolSize = 100,
|
||||
int? connectionIdleLifetimeSeconds = null)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(connectionString);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(applicationName);
|
||||
|
||||
var normalizedMinPoolSize = Math.Max(minPoolSize, 0);
|
||||
var normalizedMaxPoolSize = Math.Max(maxPoolSize, normalizedMinPoolSize);
|
||||
|
||||
var builder = new NpgsqlConnectionStringBuilder(connectionString)
|
||||
{
|
||||
ApplicationName = applicationName.Trim(),
|
||||
Pooling = pooling,
|
||||
MinPoolSize = normalizedMinPoolSize,
|
||||
MaxPoolSize = normalizedMaxPoolSize,
|
||||
};
|
||||
|
||||
if (connectionIdleLifetimeSeconds.HasValue && connectionIdleLifetimeSeconds.Value > 0)
|
||||
{
|
||||
builder.ConnectionIdleLifetime = connectionIdleLifetimeSeconds.Value;
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
public static string BuildDefaultApplicationName(string moduleName)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(moduleName);
|
||||
|
||||
return $"stellaops-{ToKebabCase(moduleName.Trim())}";
|
||||
}
|
||||
|
||||
private static string ToKebabCase(string value)
|
||||
{
|
||||
var chars = new List<char>(value.Length + 8);
|
||||
for (var i = 0; i < value.Length; i++)
|
||||
{
|
||||
var current = value[i];
|
||||
if (char.IsWhiteSpace(current) || current is '_' or '-')
|
||||
{
|
||||
if (chars.Count > 0 && chars[^1] != '-')
|
||||
{
|
||||
chars.Add('-');
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char.IsUpper(current))
|
||||
{
|
||||
var hasPrevious = chars.Count > 0;
|
||||
var previous = hasPrevious ? value[i - 1] : '\0';
|
||||
var hasNext = i + 1 < value.Length;
|
||||
var next = hasNext ? value[i + 1] : '\0';
|
||||
|
||||
if (hasPrevious
|
||||
&& chars[^1] != '-'
|
||||
&& (!char.IsUpper(previous) || (hasNext && char.IsLower(next))))
|
||||
{
|
||||
chars.Add('-');
|
||||
}
|
||||
|
||||
chars.Add(char.ToLowerInvariant(current));
|
||||
continue;
|
||||
}
|
||||
|
||||
chars.Add(char.ToLowerInvariant(current));
|
||||
}
|
||||
|
||||
return new string(chars.ToArray()).Trim('-');
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,11 @@ public sealed class PostgresOptions
|
||||
/// </summary>
|
||||
public int MaxPoolSize { get; set; } = 100;
|
||||
|
||||
/// <summary>
|
||||
/// Stable PostgreSQL application name used for runtime attribution.
|
||||
/// </summary>
|
||||
public string? ApplicationName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Minimum number of connections in the pool. Default is 1.
|
||||
/// </summary>
|
||||
|
||||
@@ -5,6 +5,7 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| SPRINT_20260405_011-XPORT-STD | DONE | `docs/implplan/SPRINT_20260405_011___Libraries_transport_pooling_and_attribution_hardening.md`: shared transport hardening, PostgreSQL application-name policy, runtime caller remediation, and static guardrails. |
|
||||
| AUDIT-0089-M | DONE | Revalidated 2026-01-08; maintainability audit for Infrastructure.Postgres. |
|
||||
| AUDIT-0089-T | DONE | Revalidated 2026-01-08; test coverage audit for Infrastructure.Postgres. |
|
||||
| AUDIT-0089-A | TODO | Pending approval (non-test project; revalidated 2026-01-08). |
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
using FluentAssertions;
|
||||
using Npgsql;
|
||||
using StellaOps.Infrastructure.Postgres.Connections;
|
||||
using StellaOps.Infrastructure.Postgres.Options;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Infrastructure.Postgres.Tests;
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
public sealed class PostgresConnectionStringPolicyTests
|
||||
{
|
||||
[Fact]
|
||||
public void Build_applies_runtime_attribution_and_pooling_policy()
|
||||
{
|
||||
var options = new PostgresOptions
|
||||
{
|
||||
ConnectionString = "Host=localhost;Database=stellaops;Username=stellaops;Password=stellaops",
|
||||
ApplicationName = "custom-ledger-app",
|
||||
Pooling = false,
|
||||
MinPoolSize = 3,
|
||||
MaxPoolSize = 12,
|
||||
ConnectionIdleLifetimeSeconds = 900,
|
||||
};
|
||||
|
||||
var connectionString = PostgresConnectionStringPolicy.Build(options, "stellaops-findings-ledger");
|
||||
var builder = new NpgsqlConnectionStringBuilder(connectionString);
|
||||
|
||||
builder.ApplicationName.Should().Be("custom-ledger-app");
|
||||
builder.Pooling.Should().BeFalse();
|
||||
builder.MinPoolSize.Should().Be(3);
|
||||
builder.MaxPoolSize.Should().Be(12);
|
||||
builder.ConnectionIdleLifetime.Should().Be(900);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_uses_default_application_name_when_none_is_configured()
|
||||
{
|
||||
var options = new PostgresOptions
|
||||
{
|
||||
ConnectionString = "Host=localhost;Database=stellaops;Username=stellaops;Password=stellaops",
|
||||
MinPoolSize = 0,
|
||||
MaxPoolSize = 1,
|
||||
};
|
||||
|
||||
var connectionString = PostgresConnectionStringPolicy.Build(options, "stellaops-policy");
|
||||
var builder = new NpgsqlConnectionStringBuilder(connectionString);
|
||||
|
||||
builder.ApplicationName.Should().Be("stellaops-policy");
|
||||
builder.MinPoolSize.Should().Be(0);
|
||||
builder.MaxPoolSize.Should().Be(1);
|
||||
builder.ConnectionIdleLifetime.Should().Be(300);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Policy", "stellaops-policy")]
|
||||
[InlineData("PacksRegistry", "stellaops-packs-registry")]
|
||||
[InlineData("IssuerDirectory", "stellaops-issuer-directory")]
|
||||
[InlineData("ReachGraph", "stellaops-reach-graph")]
|
||||
public void BuildDefaultApplicationName_normalizes_module_names(string moduleName, string expected)
|
||||
{
|
||||
var result = PostgresConnectionStringPolicy.BuildDefaultApplicationName(moduleName);
|
||||
|
||||
result.Should().Be(expected);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user