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
@@ -1,4 +1,5 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
@@ -8,21 +9,9 @@ using ZB.MOM.WW.OtOpcUa.Configuration.Services;
namespace ZB.MOM.WW.OtOpcUa.Configuration.Tests;
[Trait("Category", "Unit")]
public sealed class LdapGroupRoleMappingServiceTests : IDisposable
public sealed class LdapGroupRoleMappingServiceTests
{
private readonly OtOpcUaConfigDbContext _db;
/// <summary>Initializes a new instance of the LdapGroupRoleMappingServiceTests class.</summary>
public LdapGroupRoleMappingServiceTests()
{
var options = new DbContextOptionsBuilder<OtOpcUaConfigDbContext>()
.UseInMemoryDatabase($"ldap-grm-{Guid.NewGuid():N}")
.Options;
_db = new OtOpcUaConfigDbContext(options);
}
/// <summary>Disposes the database context.</summary>
public void Dispose() => _db.Dispose();
private readonly RecordingContextFactory _db = new();
private LdapGroupRoleMapping Make(string group, AdminRole role, string? clusterId = null, bool? isSystemWide = null) =>
new()
@@ -165,4 +154,48 @@ public sealed class LdapGroupRoleMappingServiceTests : IDisposable
var byGroup = await svc.GetByGroupsAsync(["cn=sysadmins,dc=x"], CancellationToken.None);
byGroup.ShouldContain(r => r.Id == saved.Id);
}
/// <summary>
/// The service must take its context from the factory PER CALL and dispose it before returning —
/// it must never pin one context (and therefore one pooled connection) for its own lifetime. That
/// is what makes it safe on the RoleGrants Blazor page, where the DI scope is the circuit and two
/// renders can overlap: a pinned context would throw
/// "A second operation was started on this context instance" the first time they did.
/// </summary>
[Fact]
public async Task Every_call_creates_and_disposes_its_own_context()
{
var svc = new LdapGroupRoleMappingService(_db);
await svc.CreateAsync(Make("cn=fleet,dc=x", AdminRole.Administrator), CancellationToken.None);
await svc.ListAllAsync(CancellationToken.None);
await svc.GetByGroupsAsync(["cn=fleet,dc=x"], CancellationToken.None);
// One context per call, and no leftovers: a service holding its own context would show 0.
_db.Created.Count.ShouldBe(3);
foreach (var ctx in _db.Created)
Should.Throw<ObjectDisposedException>(() => ctx.LdapGroupRoleMappings.ToList());
}
/// <summary>
/// Hands out a fresh <see cref="OtOpcUaConfigDbContext"/> per call over one shared in-memory store
/// (an explicit <see cref="InMemoryDatabaseRoot"/>, so every context genuinely sees the same rows),
/// and records what it created so a test can assert the per-call create/dispose discipline.
/// </summary>
private sealed class RecordingContextFactory : IDbContextFactory<OtOpcUaConfigDbContext>
{
private readonly DbContextOptions<OtOpcUaConfigDbContext> _options =
new DbContextOptionsBuilder<OtOpcUaConfigDbContext>()
.UseInMemoryDatabase($"ldap-grm-{Guid.NewGuid():N}", new InMemoryDatabaseRoot())
.Options;
public List<OtOpcUaConfigDbContext> Created { get; } = [];
public OtOpcUaConfigDbContext CreateDbContext()
{
var ctx = new OtOpcUaConfigDbContext(_options);
Created.Add(ctx);
return ctx;
}
}
}