perf(adminui): pooled DbContext factory; role-grants reads per-call, not per-circuit
This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user