Files
ScadaBridge/tests/ScadaLink.Commons.Tests/Interfaces/Services/ExternalCallResultTests.cs
T
Joseph Doherty 2ed5c6c379 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).
2026-05-28 07:29:41 -04:00

77 lines
3.1 KiB
C#

using ScadaLink.Commons.Interfaces.Services;
namespace ScadaLink.Commons.Tests.Interfaces.Services;
/// <summary>
/// Tests for <see cref="ExternalCallResult"/>, in particular the Commons-021
/// thread-safe lazy parse of <c>Response</c>. The pre-fix implementation used
/// two mutable fields (<c>_response</c>/<c>_responseParsed</c>) with no
/// synchronization, so concurrent readers could each construct a fresh
/// <c>DynamicJsonElement</c> and one would overwrite the other. The fix moves
/// the parse onto a <c>Lazy&lt;dynamic?&gt;</c> with
/// <c>LazyThreadSafetyMode.ExecutionAndPublication</c> (the default), which
/// guarantees one parse and one shared result for all readers.
/// </summary>
public class ExternalCallResultTests
{
[Fact]
public void Response_NullOrEmptyJson_ReturnsNull()
{
var withNull = new ExternalCallResult(Success: true, ResponseJson: null, ErrorMessage: null);
var withEmpty = new ExternalCallResult(Success: true, ResponseJson: string.Empty, ErrorMessage: null);
Assert.Null(withNull.Response);
Assert.Null(withEmpty.Response);
}
[Fact]
public void Response_ParsesJsonIntoDynamicElement()
{
var result = new ExternalCallResult(Success: true, ResponseJson: "{\"answer\": 42}", ErrorMessage: null);
// dynamic property access is the production usage pattern.
dynamic? response = result.Response;
Assert.NotNull(response);
int answer = (int)response!.answer;
Assert.Equal(42, answer);
}
/// <summary>
/// Commons-021: concurrent readers must observe the same parsed instance
/// (a `Lazy&lt;T&gt;` invariant). Under the pre-fix code two threads could
/// both produce a fresh `DynamicJsonElement` and one would win the race —
/// `ReferenceEquals` would then occasionally fail. With the fix every
/// reader observes the single Lazy-published value, so the assertion
/// holds for every pair of observers.
/// </summary>
[Fact]
public void Response_ConcurrentReads_ReturnSameInstance()
{
// A larger payload makes the parse window wider so the race, if
// present, is more likely to fire. The same property — single
// published instance — must hold for any payload, though.
var json = "{\"items\":[{\"name\":\"a\"},{\"name\":\"b\"},{\"name\":\"c\"}],\"count\":3}";
var result = new ExternalCallResult(Success: true, ResponseJson: json, ErrorMessage: null);
const int observerCount = 64;
var barrier = new Barrier(observerCount);
var observed = new object?[observerCount];
Parallel.For(0, observerCount, i =>
{
// Force all observers to call `Response` at the same instant so
// they collide on the lazy parse rather than each finding it
// already-published.
barrier.SignalAndWait();
observed[i] = result.Response;
});
var first = observed[0];
Assert.NotNull(first);
for (var i = 1; i < observerCount; i++)
{
Assert.Same(first, observed[i]);
}
}
}