feat(sitecall-audit): carry + persist SourceNode end-to-end via cached telemetry

Site: site emitters of SiteCallOperational (ExternalSystemClient, the script-API
cached call path in ScriptRuntimeContext, CachedCallLifecycleBridge) inject
INodeIdentityProvider and stamp SourceNode = NodeName at construction.

OperationTrackingStore call site in CachedCallTelemetryForwarder now stamps
SourceNode too.

Central: SiteCallAuditRepository.UpsertAsync INSERT includes SourceNode in the
column list; conditional monotonic UPDATE uses
COALESCE(@SourceNode, SourceNode) so later packets cannot blank a previously-
stamped value. After this commit every SiteCalls row carries node-a/node-b in
SourceNode (subject to monotonic preservation).
This commit is contained in:
Joseph Doherty
2026-05-23 17:41:22 -04:00
parent d1fcab490c
commit 06ed0acead
10 changed files with 539 additions and 34 deletions

View File

@@ -520,7 +520,8 @@ public class SiteCallAuditRepositoryTests : IClassFixture<MsSqlMigrationFixture>
DateTime? createdAtUtc = null,
DateTime? updatedAtUtc = null,
bool terminal = false,
DateTime? terminalAtUtc = null)
DateTime? terminalAtUtc = null,
string? sourceNode = null)
{
var created = createdAtUtc ?? DateTime.UtcNow;
var updated = updatedAtUtc ?? created;
@@ -534,6 +535,7 @@ public class SiteCallAuditRepositoryTests : IClassFixture<MsSqlMigrationFixture>
Channel = "ApiOutbound",
Target = "ERP.GetOrder",
SourceSite = sourceSite ?? NewSiteId(),
SourceNode = sourceNode,
Status = status,
RetryCount = retryCount,
LastError = lastError,
@@ -544,4 +546,159 @@ public class SiteCallAuditRepositoryTests : IClassFixture<MsSqlMigrationFixture>
IngestedAtUtc = DateTime.UtcNow,
};
}
// --- SourceNode-stamping (Task 14) --------------------------------------
[SkippableFact]
public async Task UpsertAsync_PersistsSourceNode_OnFreshInsert()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
// SourceNode-stamping (Task 14): a fresh INSERT must persist the
// SourceNode column verbatim — the central row carries the originating
// site node name end-to-end.
var id = TrackedOperationId.New();
await using var context = CreateContext();
var repo = new SiteCallAuditRepository(context);
await repo.UpsertAsync(NewRow(id, status: "Submitted", sourceNode: "node-a"));
var loaded = await repo.GetAsync(id);
Assert.NotNull(loaded);
Assert.Equal("node-a", loaded!.SourceNode);
}
[SkippableFact]
public async Task UpsertAsync_PreservesSourceNode_WhenLaterPacketCarriesNull()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
// SourceNode-stamping (Task 14): the UPDATE uses
// COALESCE(@SourceNode, SourceNode) so a subsequent packet that does
// NOT carry a SourceNode (legacy / reconciliation pull from an
// unstamped node) MUST NOT blank out the value the first packet set.
// Combined with the monotonic-rank guard the Status advances but the
// SourceNode survives.
//
// Each step uses a fresh DbContext — raw-SQL UPDATEs bypass the
// change tracker, so reusing a single context whose entity is already
// tracked masks the post-UPDATE state on a follow-up FindAsync.
var id = TrackedOperationId.New();
await using (var context = CreateContext())
{
var repo = new SiteCallAuditRepository(context);
// First packet: stamped Submit from node-a.
await repo.UpsertAsync(NewRow(id, status: "Submitted", sourceNode: "node-a"));
}
await using (var context = CreateContext())
{
var repo = new SiteCallAuditRepository(context);
// Later packet: rank-advancing Attempted with null SourceNode.
await repo.UpsertAsync(NewRow(id, status: "Attempted", retryCount: 1, sourceNode: null));
}
await using (var readContext = CreateContext())
{
var readRepo = new SiteCallAuditRepository(readContext);
var loaded = await readRepo.GetAsync(id);
Assert.NotNull(loaded);
// SourceNode preserved despite the null on the later packet.
Assert.Equal("node-a", loaded!.SourceNode);
// Status advanced — proves the UPDATE branch actually ran.
Assert.Equal("Attempted", loaded.Status);
Assert.Equal(1, loaded.RetryCount);
}
}
[SkippableFact]
public async Task UpsertAsync_NonNullIncomingSourceNode_OverwritesPreviousValueOnRankAdvance()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
// SourceNode-stamping (Task 14): per the COALESCE(@SourceNode,
// SourceNode) semantics the column protects against a *null*
// incoming value blanking a previously-stamped one, but a non-null
// incoming value DOES replace the existing value on a rank-advancing
// packet. This is the "last-non-null-wins on advance" behaviour the
// SQL operator literally implements — see the comment in
// SiteCallAuditRepository.UpsertAsync.
//
// In practice both stamps within a single lifecycle SHOULD carry the
// same value (same node, same execution); a divergence would imply a
// mid-lifecycle node change (e.g. failover handing off to node-b) and
// letting the latest stamp through is arguably the right call. This
// test pins the actual behaviour so we notice if the SQL gets
// inverted (to a true first-write-wins COALESCE(SourceNode,
// @SourceNode)) inadvertently.
var id = TrackedOperationId.New();
await using (var context = CreateContext())
{
var repo = new SiteCallAuditRepository(context);
await repo.UpsertAsync(NewRow(id, status: "Submitted", sourceNode: "node-a"));
}
await using (var context = CreateContext())
{
var repo = new SiteCallAuditRepository(context);
await repo.UpsertAsync(NewRow(id, status: "Attempted", retryCount: 1, sourceNode: "node-b"));
}
await using (var readContext = CreateContext())
{
var readRepo = new SiteCallAuditRepository(readContext);
var loaded = await readRepo.GetAsync(id);
Assert.NotNull(loaded);
// Incoming non-null wins — node-b replaces node-a on rank advance.
Assert.Equal("node-b", loaded!.SourceNode);
// Other monotonic fields advanced too — proves the UPDATE ran.
Assert.Equal("Attempted", loaded.Status);
}
}
[SkippableFact]
public async Task UpsertAsync_FillsSourceNode_WhenInsertWasNullAndLaterPacketCarriesValue()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
// SourceNode-stamping (Task 14): when the column was left NULL by an
// earlier unstamped packet, a later rank-advancing packet with a
// non-null SourceNode fills it — the COALESCE(@SourceNode, SourceNode)
// SQL operator returns @SourceNode when @SourceNode is non-null, so
// the incoming value wins over the existing NULL. This is the
// recovery path for an initially-unstamped lifecycle whose later
// packets carry the node identity.
//
// The intermediate verification and final read use FRESH contexts —
// FindAsync hits the change tracker first, so a cached entity from
// an earlier read in the same context can mask a raw-SQL UPDATE.
var id = TrackedOperationId.New();
await using (var context = CreateContext())
{
var repo = new SiteCallAuditRepository(context);
await repo.UpsertAsync(NewRow(id, status: "Submitted", sourceNode: null));
}
// Verify the INSERT left SourceNode NULL via a fresh context.
await using (var verifyContext = CreateContext())
{
var verifyRepo = new SiteCallAuditRepository(verifyContext);
var afterInsert = await verifyRepo.GetAsync(id);
Assert.NotNull(afterInsert);
Assert.Null(afterInsert!.SourceNode);
}
await using (var context = CreateContext())
{
var repo = new SiteCallAuditRepository(context);
await repo.UpsertAsync(NewRow(id, status: "Attempted", retryCount: 1, sourceNode: "node-a"));
}
await using (var readContext = CreateContext())
{
var readRepo = new SiteCallAuditRepository(readContext);
var loaded = await readRepo.GetAsync(id);
Assert.NotNull(loaded);
Assert.Equal("node-a", loaded!.SourceNode);
Assert.Equal("Attempted", loaded.Status);
}
}
}