perf(adminui): pooled DbContext factory; role-grants reads per-call, not per-circuit

This commit is contained in:
Joseph Doherty
2026-08-16 04:05:34 -04:00
parent 34f836e03c
commit 140bf0c131
11 changed files with 346 additions and 50 deletions
@@ -22,15 +22,29 @@ public static class ServiceCollectionExtensions
?? throw new InvalidOperationException(
$"Connection string '{ConnectionStringName}' is required. Add it to appsettings.json or the OTOPCUA_CONFIG_CONNECTION env var.");
services.AddDbContextFactory<OtOpcUaConfigDbContext>(opt => opt.UseSqlServer(connectionString));
// POOLED, deliberately: every consumer in this repo takes the IDbContextFactory<> and creates
// a short-lived context per call/query, so context construction (and its model/service-provider
// lookup) is on the hot path of every AdminUI render. Pooling makes each CreateDbContext a rent
// from a reset instance instead of a fresh build. OtOpcUaConfigDbContext qualifies: one public
// ctor taking DbContextOptions<>, no injected scoped services, no instance state beyond EF's own.
services.AddPooledDbContextFactory<OtOpcUaConfigDbContext>(opt => opt.UseSqlServer(connectionString));
// AddDbContextFactory registers only the IDbContextFactory<> — it does NOT also register
// a scoped OtOpcUaConfigDbContext. Config services that take the context directly (e.g.
// LdapGroupRoleMappingService) need a scoped instance, so bridge one off the factory.
// AddPooledDbContextFactory registers only the IDbContextFactory<> — it does NOT also register
// a scoped OtOpcUaConfigDbContext. This bridge exists for ONE consumer that cannot be moved to
// the factory because it is not ours: ASP.NET Core Data Protection's EF Core key repository
// (Security's AddDataProtection().PersistKeysToDbContext<OtOpcUaConfigDbContext>()), which
// resolves TContext with CreateScope() + GetRequiredService<TContext>() and has no factory
// support. Deleting this bridge breaks cookie-key persistence — i.e. AdminUI login — at runtime,
// not at build time. Nothing else should take OtOpcUaConfigDbContext from DI directly; take the
// factory and own the context's lifetime per call, as LdapGroupRoleMappingService does. Disposing
// a pooled context returns it to the pool, so the scope end is a release, not a teardown.
services.AddScoped(sp => sp.GetRequiredService<IDbContextFactory<OtOpcUaConfigDbContext>>().CreateDbContext());
// Config-DB services consumed by both the AdminUI (RoleGrants page) and the auth/login
// host (AuthEndpoints.LoginAsync). Scoped to match the request/render scope of both callers.
// host (AuthEndpoints.LoginAsync). Scoped is a registration-shape choice, not a data-access
// one: the driver-role block TryAdds NullLdapGroupRoleMappingService at the same lifetime, and
// the service itself is stateless — it creates and disposes a context per call off the pooled
// factory rather than pinning one for the scope (a Blazor circuit is a single long scope).
services.AddScoped<ILdapGroupRoleMappingService, LdapGroupRoleMappingService>();
return services;
@@ -8,7 +8,18 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Services;
/// "exactly one of (ClusterId, IsSystemWide)" invariant at the write surface so a
/// malformed row can't land in the DB.
/// </summary>
public sealed class LdapGroupRoleMappingService(OtOpcUaConfigDbContext db) : ILdapGroupRoleMappingService
/// <remarks>
/// Takes the <see cref="IDbContextFactory{TContext}"/> and creates + disposes one context PER CALL
/// rather than holding an injected context for its lifetime. Its consumers are the RoleGrants Blazor
/// page (InteractiveServer, so its DI scope is the whole circuit) and <c>AuthEndpoints.LoginAsync</c>;
/// an injected context would pin one context — and one pooled connection — for the life of a circuit,
/// and would fault outright ("a second operation was started on this context instance") the moment two
/// of that page's renders overlapped. The factory is pooled (see
/// <c>ServiceCollectionExtensions.AddOtOpcUaConfigDb</c>), so the per-call create is a pool rent, not
/// a fresh context build.
/// </remarks>
public sealed class LdapGroupRoleMappingService(IDbContextFactory<OtOpcUaConfigDbContext> dbFactory)
: ILdapGroupRoleMappingService
{
/// <inheritdoc />
public async Task<IReadOnlyList<LdapGroupRoleMapping>> GetByGroupsAsync(
@@ -18,6 +29,7 @@ public sealed class LdapGroupRoleMappingService(OtOpcUaConfigDbContext db) : ILd
var groupSet = ldapGroups.ToList();
if (groupSet.Count == 0) return [];
await using var db = await dbFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
return await db.LdapGroupRoleMappings
.AsNoTracking()
.Where(m => groupSet.Contains(m.LdapGroup))
@@ -27,12 +39,15 @@ public sealed class LdapGroupRoleMappingService(OtOpcUaConfigDbContext db) : ILd
/// <inheritdoc />
public async Task<IReadOnlyList<LdapGroupRoleMapping>> ListAllAsync(CancellationToken cancellationToken)
=> await db.LdapGroupRoleMappings
{
await using var db = await dbFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
return await db.LdapGroupRoleMappings
.AsNoTracking()
.OrderBy(m => m.LdapGroup)
.ThenBy(m => m.ClusterId)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<LdapGroupRoleMapping> CreateAsync(LdapGroupRoleMapping row, CancellationToken cancellationToken)
@@ -43,6 +58,7 @@ public sealed class LdapGroupRoleMappingService(OtOpcUaConfigDbContext db) : ILd
if (row.Id == Guid.Empty) row.Id = Guid.NewGuid();
if (row.CreatedAtUtc == default) row.CreatedAtUtc = DateTime.UtcNow;
await using var db = await dbFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
db.LdapGroupRoleMappings.Add(row);
await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
return row;
@@ -51,6 +67,7 @@ public sealed class LdapGroupRoleMappingService(OtOpcUaConfigDbContext db) : ILd
/// <inheritdoc />
public async Task DeleteAsync(Guid id, CancellationToken cancellationToken)
{
await using var db = await dbFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
var existing = await db.LdapGroupRoleMappings.FindAsync([id], cancellationToken).ConfigureAwait(false);
if (existing is null) return;
db.LdapGroupRoleMappings.Remove(existing);