fix(transport): robust failure-audit when rollback throws + doc clarifications

Address one Blocker and three Important findings from code review of
2c34f12 (BundleImporter.ApplyAsync):

- BLOCKER: wrap RollbackAsync in nested try/catch so a rollback fault
  does not swallow the BundleImportFailed audit row. Dispose the
  failed transaction before the audit-write so the new SaveChangesAsync
  uses a fresh implicit transaction instead of enlisting in the broken
  one. Surface the rollback exception's message on the failure row
  alongside the original cause, and swallow audit-write faults per the
  design's best-effort-audit invariant. Add regression integration
  test using a SQLite transaction interceptor that throws on rollback.

- Document re-entrancy assumption on IAuditCorrelationContext: scoped
  lifetime, single circuit, concurrent imports within a shared scope
  must serialize externally.

- Document repository audit responsibility on BundleImporter: repos
  are thin EF wrappers; ApplyAsync writes audit rows explicitly. If
  repos ever start emitting audit rows, the explicit calls here must
  be removed to avoid double-logging.

- Document BundleSessionStore thread-safety: ConcurrentDictionary
  primitives are safe under concurrent callers; BundleSession itself
  is not thread-safe.
This commit is contained in:
Joseph Doherty
2026-05-24 05:06:04 -04:00
parent 2c34f12a6f
commit cda80cf821
4 changed files with 397 additions and 16 deletions
@@ -4,6 +4,17 @@ namespace ScadaLink.Commons.Interfaces.Transport;
/// Scoped service the bundle importer sets to thread a BundleImportId through to
/// the audit log entries emitted by the audited repository methods invoked during
/// ApplyAsync. AuditService reads this and stamps every AuditLogEntry it writes.
/// <para>
/// Re-entrancy / thread-safety: mutating <see cref="BundleImportId"/> is NOT
/// thread-safe. The service is registered scoped, and the assumed usage is a
/// single Blazor Server circuit (or single API request) at a time — within that
/// scope <see cref="BundleImporter.ApplyAsync"/> is the sole writer, and the
/// audit service is the sole reader, in a strictly sequential await chain.
/// Callers that perform concurrent imports within a shared scope (e.g. two
/// <c>ApplyAsync</c> calls awaited via <c>Task.WhenAll</c> on the same circuit)
/// MUST serialize access externally — there is no internal lock and the last
/// writer wins, which would cross-contaminate audit rows between imports.
/// </para>
/// </summary>
public interface IAuditCorrelationContext
{