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:
master
2026-04-06 08:51:04 +03:00
parent 517fa0a92d
commit ccdfd41e4f
64 changed files with 625 additions and 178 deletions

View File

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

View File

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

View File

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

View File

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

View File

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