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

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

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

View File

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