fix(concurrency/lifetime): close Theme 5 — 10 concurrency / DI / scope findings
Concurrency hazards, DI lifetime hygiene, and one verify-only confirmation
across 8 modules. Highlights:
Concurrency:
- CentralUI-030: SandboxConsoleCapture writes routed through WriteSynchronized
locking on the captured StringWriter — intra-script Task fan-out can no
longer corrupt the per-call buffer.
- Commons-021: ExternalCallResult.Response now backed by Lazy<dynamic?>
(ExecutionAndPublication) — no more benign double-parse race.
- CD-017: DeploymentManagerRepository.DeleteDeploymentRecordAsync now takes
an expected RowVersion and seeds entry.OriginalValues so EF emits
DELETE ... WHERE Id=@id AND RowVersion=@prior; stale RowVersion now
throws DbUpdateConcurrencyException instead of silent overwrite.
- Transport-009: AuditCorrelationContext.BundleImportId backed by
AsyncLocal<Guid?> so concurrent imports get per-logical-call isolation
(was a scoped instance shared via AuditService across runs).
DI / lifetime:
- AuditLog-003: All 3 AuditLog actor handlers switched to CreateAsyncScope
+ await using — async EF disposal no longer swallowed.
- AuditLog-007: INodeIdentityProvider resolution standardised on
GetRequiredService<>() (was mixed with GetService<>()).
- AuditLog-011: AddAuditLogHealthMetricsBridge guarded by sentinel
descriptor check — calling twice no longer double-registers the hosted
service.
Shutdown / supervision:
- SiteCallAudit-002: AkkaHostedService adds a CoordinatedShutdown
cluster-leave task (drain-site-call-audit-singleton) that issues a
bounded GracefulStop(10s) so failover waits for in-flight upserts.
Registration safety:
- NS-020: AkkaHostedService now guards NotificationForwarder S&F
registration with _notificationDeliveryHandlerRegistered + throws
InvalidOperationException on double-register to make the regression loud.
VERIFY-only closures:
- NotifOutbox-005: Confirmed already closed by CD-015 fix (ac96b83) —
NotificationOutboxRepository.InsertIfNotExistsAsync uses the same
raw-SQL IF NOT EXISTS + 2601/2627 swallow pattern; race eliminated.
5+ new regression tests (CentralUI sandbox WhenAll, ExternalCallResult
64-reader Barrier, AuditLog DI idempotency, RowVersion stale-throw,
SiteCallAudit-002 shutdown drain). Build clean; affected suites all green.
README regenerated: 65 open (was 75).
This commit is contained in:
@@ -81,17 +81,30 @@ public class DeploymentManagerRepository : IDeploymentManagerRepository
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task DeleteDeploymentRecordAsync(int id, CancellationToken cancellationToken = default)
|
||||
public Task DeleteDeploymentRecordAsync(int id, byte[] expectedRowVersion, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(expectedRowVersion);
|
||||
|
||||
// CD-017: DeploymentRecord carries a SQL Server rowversion concurrency token.
|
||||
// The stub-attach delete path must seed EF's OriginalValues["RowVersion"] with
|
||||
// the caller's last-observed value so the generated SQL becomes
|
||||
// `DELETE ... WHERE Id = @id AND RowVersion = @prior`. Without this seeding a
|
||||
// concurrent edit is silently overwritten; with it, EF raises
|
||||
// DbUpdateConcurrencyException on SaveChangesAsync — the documented
|
||||
// optimistic-concurrency contract on deployment status records.
|
||||
var record = _dbContext.DeploymentRecords.Local.FirstOrDefault(d => d.Id == id);
|
||||
if (record != null)
|
||||
{
|
||||
var entry = _dbContext.Entry(record);
|
||||
entry.OriginalValues["RowVersion"] = expectedRowVersion;
|
||||
_dbContext.DeploymentRecords.Remove(record);
|
||||
}
|
||||
else
|
||||
{
|
||||
var stub = new DeploymentRecord("stub", "stub") { Id = id };
|
||||
_dbContext.DeploymentRecords.Attach(stub);
|
||||
var entry = _dbContext.Entry(stub);
|
||||
entry.OriginalValues["RowVersion"] = expectedRowVersion;
|
||||
_dbContext.DeploymentRecords.Remove(stub);
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
|
||||
@@ -3,13 +3,34 @@ using ScadaLink.Commons.Interfaces.Transport;
|
||||
namespace ScadaLink.ConfigurationDatabase.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Per-scope mutable holder for the active bundle import id. AuditService reads it
|
||||
/// while writing AuditLogEntry rows. Registered as Scoped so each Blazor circuit /
|
||||
/// request gets its own value; ApplyAsync explicitly creates a service scope and
|
||||
/// sets the id at the top of the transaction.
|
||||
/// Holder for the active bundle import id, backed by an <see cref="AsyncLocal{T}"/>
|
||||
/// so each logical asynchronous call chain observes its own value. AuditService
|
||||
/// reads it while writing AuditLogEntry rows.
|
||||
/// <para>
|
||||
/// Thread-safety / concurrency contract (Transport-009): the previous Scoped
|
||||
/// instance with a plain auto-property mutated by <c>BundleImporter.ApplyAsync</c>
|
||||
/// was vulnerable to cross-contamination if two imports ran concurrently inside
|
||||
/// a shared DI scope — either via <c>Task.WhenAll</c> on a single Blazor circuit
|
||||
/// or via a misconfigured singleton registration. Backing the property with
|
||||
/// <see cref="AsyncLocal{T}"/> means every fresh logical-call-context — every
|
||||
/// distinct <c>ApplyAsync</c> invocation, even ones sharing the same DI scope —
|
||||
/// gets its own independent value, and the value flows naturally through every
|
||||
/// <c>await</c> in the chain. Concurrent imports no longer leak BundleImportIds
|
||||
/// across audit rows.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The class is still registered as Scoped so injection works with the existing
|
||||
/// DI graph, but its in-memory state is per-call-context regardless of lifetime.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class AuditCorrelationContext : IAuditCorrelationContext
|
||||
{
|
||||
private static readonly AsyncLocal<Guid?> _bundleImportId = new();
|
||||
|
||||
/// <inheritdoc />
|
||||
public Guid? BundleImportId { get; set; }
|
||||
public Guid? BundleImportId
|
||||
{
|
||||
get => _bundleImportId.Value;
|
||||
set => _bundleImportId.Value = value;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user