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

@@ -146,7 +146,14 @@ public static class ServiceCollectionExtensions
new CachedCallTelemetryForwarder(
sp.GetRequiredService<IAuditWriter>(),
sp.GetService<ScadaLink.Commons.Interfaces.IOperationTrackingStore>(),
sp.GetRequiredService<ILogger<CachedCallTelemetryForwarder>>()));
sp.GetRequiredService<ILogger<CachedCallTelemetryForwarder>>(),
// SourceNode-stamping (Task 14): the local node identity is
// threaded through so RecordEnqueueAsync can stamp the
// tracking row's SourceNode column. GetService — central
// composition roots may not register the provider, in which
// case the forwarder degrades to a null SourceNode rather
// than failing the DI resolution.
sp.GetService<INodeIdentityProvider>()));
// M3 Bundle F: bridge the store-and-forward retry-loop observer hook
// to the cached-call forwarder so per-attempt + terminal telemetry
@@ -154,7 +161,17 @@ public static class ServiceCollectionExtensions
// as the script-thread CachedSubmit row. Registered as a singleton
// and also bound to ICachedCallLifecycleObserver so AddStoreAndForward
// can resolve it through DI (Bundle F StoreAndForward wiring change).
services.AddSingleton<CachedCallLifecycleBridge>();
// SourceNode-stamping (Task 14): factory-resolved so the
// INodeIdentityProvider singleton can be threaded through — the
// bridge stamps SiteCallOperational.SourceNode from
// INodeIdentityProvider.NodeName on every cached-call lifecycle row.
// GetService (not GetRequiredService) — central composition roots may
// not register the provider, in which case the bridge degrades to a
// null SourceNode rather than failing the DI resolution.
services.AddSingleton<CachedCallLifecycleBridge>(sp => new CachedCallLifecycleBridge(
sp.GetRequiredService<ICachedCallTelemetryForwarder>(),
sp.GetRequiredService<ILogger<CachedCallLifecycleBridge>>(),
sp.GetService<INodeIdentityProvider>()));
services.AddSingleton<ICachedCallLifecycleObserver>(
sp => sp.GetRequiredService<CachedCallLifecycleBridge>());

View File

@@ -39,12 +39,23 @@ public sealed class CachedCallLifecycleBridge : ICachedCallLifecycleObserver
private readonly ICachedCallTelemetryForwarder _forwarder;
private readonly ILogger<CachedCallLifecycleBridge> _logger;
/// <summary>
/// SourceNode-stamping (Task 14): the local node identity provider used to
/// stamp <c>SiteCallOperational.SourceNode</c> on every cached-call
/// lifecycle row this bridge emits. Optional — when null (legacy hosts /
/// tests that don't register the provider) SourceNode stays null and
/// central persists the <c>SiteCalls</c> row with SourceNode NULL.
/// </summary>
private readonly INodeIdentityProvider? _nodeIdentity;
public CachedCallLifecycleBridge(
ICachedCallTelemetryForwarder forwarder,
ILogger<CachedCallLifecycleBridge> logger)
ILogger<CachedCallLifecycleBridge> logger,
INodeIdentityProvider? nodeIdentity = null)
{
_forwarder = forwarder ?? throw new ArgumentNullException(nameof(forwarder));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_nodeIdentity = nodeIdentity;
}
/// <inheritdoc/>
@@ -114,7 +125,7 @@ public sealed class CachedCallLifecycleBridge : ICachedCallLifecycleObserver
await _forwarder.ForwardAsync(packet, ct).ConfigureAwait(false);
}
private static CachedCallTelemetry BuildPacket(
private CachedCallTelemetry BuildPacket(
CachedCallAttemptContext context,
AuditKind kind,
AuditStatus status,
@@ -162,9 +173,11 @@ public sealed class CachedCallLifecycleBridge : ICachedCallLifecycleObserver
Channel: context.Channel,
Target: context.Target,
SourceSite: context.SourceSite,
// SourceNode: stamped by Task 14 once the bridge gets an
// INodeIdentityProvider; null until then.
SourceNode: null,
// SourceNode-stamping (Task 14): the local cluster node name
// (node-a/node-b on a site). Stamped from the injected
// INodeIdentityProvider; null when no provider was wired so
// central persists SiteCalls.SourceNode as NULL.
SourceNode: _nodeIdentity?.NodeName,
Status: operationalStatus,
RetryCount: context.RetryCount,
LastError: lastError,

View File

@@ -53,6 +53,14 @@ public sealed class CachedCallTelemetryForwarder : ICachedCallTelemetryForwarder
private readonly IOperationTrackingStore? _trackingStore;
private readonly ILogger<CachedCallTelemetryForwarder> _logger;
/// <summary>
/// SourceNode-stamping (Task 14): local node identity provider used to
/// stamp the tracking-store row's <c>SourceNode</c> column on
/// <c>RecordEnqueueAsync</c>. Optional — when null (legacy / test hosts)
/// the column stays NULL on the tracking row.
/// </summary>
private readonly INodeIdentityProvider? _nodeIdentity;
/// <summary>
/// Construct the forwarder. <paramref name="trackingStore"/> is optional —
/// when null only the audit half of the packet is emitted, which matches
@@ -65,11 +73,13 @@ public sealed class CachedCallTelemetryForwarder : ICachedCallTelemetryForwarder
public CachedCallTelemetryForwarder(
IAuditWriter auditWriter,
IOperationTrackingStore? trackingStore,
ILogger<CachedCallTelemetryForwarder> logger)
ILogger<CachedCallTelemetryForwarder> logger,
INodeIdentityProvider? nodeIdentity = null)
{
_auditWriter = auditWriter ?? throw new ArgumentNullException(nameof(auditWriter));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_trackingStore = trackingStore;
_nodeIdentity = nodeIdentity;
}
/// <summary>
@@ -128,16 +138,17 @@ public sealed class CachedCallTelemetryForwarder : ICachedCallTelemetryForwarder
// Enqueue — insert-if-not-exists with the operational
// channel as the kind discriminator. RetryCount is fixed
// at 0 by the tracking store's INSERT contract.
// sourceNode plumbed through but left null here; stamping
// is wired in a later task (Task 14) once the
// INodeIdentityProvider is threaded into the forwarder.
// SourceNode-stamping (Task 14): stamp the local node
// name (node-a/node-b) from the injected
// INodeIdentityProvider; null when no provider was wired
// so the tracking row's SourceNode column stays NULL.
await _trackingStore.RecordEnqueueAsync(
telemetry.Operational.TrackedOperationId,
telemetry.Operational.Channel,
telemetry.Operational.Target,
telemetry.Audit.SourceInstanceId,
telemetry.Audit.SourceScript,
sourceNode: null,
sourceNode: _nodeIdentity?.NodeName,
ct).ConfigureAwait(false);
break;

View File

@@ -69,15 +69,20 @@ public class SiteCallAuditRepository : ISiteCallAuditRepository
// Step 1: insert-if-not-exists. Like AuditLogRepository.InsertIfNotExistsAsync
// this is check-then-act so a duplicate-key violation may surface under
// concurrent inserts on the same id — caught + logged at Debug.
//
// SourceNode-stamping (Task 14): the column is included in the INSERT
// column list / VALUES so a fresh row carries the originating node
// name (node-a/node-b for site rows). A null SourceNode (legacy hosts
// / unstamped reconciled rows) writes NULL straight through.
try
{
await _context.Database.ExecuteSqlInterpolatedAsync(
$@"IF NOT EXISTS (SELECT 1 FROM dbo.SiteCalls WHERE TrackedOperationId = {idText})
INSERT INTO dbo.SiteCalls
(TrackedOperationId, Channel, Target, SourceSite, Status, RetryCount,
(TrackedOperationId, Channel, Target, SourceSite, SourceNode, Status, RetryCount,
LastError, HttpStatus, CreatedAtUtc, UpdatedAtUtc, TerminalAtUtc, IngestedAtUtc)
VALUES
({idText}, {siteCall.Channel}, {siteCall.Target}, {siteCall.SourceSite}, {siteCall.Status}, {siteCall.RetryCount},
({idText}, {siteCall.Channel}, {siteCall.Target}, {siteCall.SourceSite}, {siteCall.SourceNode}, {siteCall.Status}, {siteCall.RetryCount},
{siteCall.LastError}, {siteCall.HttpStatus}, {siteCall.CreatedAtUtc}, {siteCall.UpdatedAtUtc}, {siteCall.TerminalAtUtc}, {siteCall.IngestedAtUtc});",
ct);
}
@@ -96,6 +101,21 @@ VALUES
// string to the same rank table the caller uses; we only mutate if the
// incoming rank is strictly greater. Same-rank (including
// terminal-over-terminal) is a no-op — first-write-wins at each rank.
//
// SourceNode-stamping (Task 14): SourceNode is updated via
// COALESCE(@SourceNode, SourceNode). The operator returns @SourceNode
// when it is non-null, otherwise the stored value — so the column
// behaves protectively: a later packet that carries a null
// SourceNode (e.g. a reconciliation pull from an unstamped node)
// NEVER blanks out a value the first stamping packet set. A later
// packet that DOES carry a non-null SourceNode replaces the previous
// value — combined with the monotonic-rank guard this is
// "last-non-null-wins on rank advance", which lets a missing
// SourceNode be filled in later if Submit happened to be unstamped
// and an Attempt/Resolve carries the node identity. Within one
// lifecycle every packet should carry the same SourceNode value (one
// execution, one node) so the "overwrite" path is in practice
// idempotent.
await _context.Database.ExecuteSqlInterpolatedAsync(
$@"UPDATE dbo.SiteCalls
SET Status = {siteCall.Status},
@@ -104,7 +124,8 @@ SET Status = {siteCall.Status},
HttpStatus = {siteCall.HttpStatus},
UpdatedAtUtc = {siteCall.UpdatedAtUtc},
TerminalAtUtc = {siteCall.TerminalAtUtc},
IngestedAtUtc = {siteCall.IngestedAtUtc}
IngestedAtUtc = {siteCall.IngestedAtUtc},
SourceNode = COALESCE({siteCall.SourceNode}, SourceNode)
WHERE TrackedOperationId = {idText}
AND {incomingRank} > (CASE Status
WHEN 'Submitted' THEN 0

View File

@@ -310,7 +310,11 @@ public class ScriptRuntimeContext
_cachedForwarder,
// Audit Log #23 (ParentExecutionId): the spawning execution's id,
// threaded alongside _executionId. Null for non-routed runs.
_parentExecutionId);
_parentExecutionId,
// SourceNode-stamping (Task 14): the local node name (node-a/node-b),
// threaded so the cached-call telemetry construction sites can stamp
// it onto SiteCallOperational.SourceNode.
_sourceNode);
/// <summary>
/// WP-13: Provides access to database operations.
@@ -334,7 +338,11 @@ public class ScriptRuntimeContext
_cachedForwarder,
// Audit Log #23 (ParentExecutionId): the spawning execution's id,
// threaded alongside _executionId. Null for non-routed runs.
_parentExecutionId);
_parentExecutionId,
// SourceNode-stamping (Task 14): the local node name (node-a/node-b),
// threaded so Database.CachedWrite's CachedSubmit telemetry can
// stamp it onto SiteCallOperational.SourceNode.
_sourceNode);
/// <summary>
/// Provides access to the Notification Outbox API.
@@ -453,6 +461,16 @@ public class ScriptRuntimeContext
private readonly string? _sourceScript;
private readonly ICachedCallTelemetryForwarder? _cachedForwarder;
/// <summary>
/// SourceNode-stamping (Task 14): the local cluster node name on
/// which this script is executing (<c>node-a</c>/<c>node-b</c>).
/// Stamped onto <c>SiteCallOperational.SourceNode</c> on the three
/// cached-call telemetry construction sites (CachedSubmit + the two
/// immediate-completion rows) so central can persist it on the
/// <c>SiteCalls</c> row.
/// </summary>
private readonly string? _sourceNode;
// Internal constructor for tests living in ScadaLink.SiteRuntime.Tests
// (via InternalsVisibleTo). Production sites resolve the helper through
// ScriptRuntimeContext.ExternalSystem.
@@ -474,7 +492,8 @@ public class ScriptRuntimeContext
string siteId = "",
string? sourceScript = null,
ICachedCallTelemetryForwarder? cachedForwarder = null,
Guid? parentExecutionId = null)
Guid? parentExecutionId = null,
string? sourceNode = null)
{
_client = client;
_instanceName = instanceName;
@@ -485,6 +504,7 @@ public class ScriptRuntimeContext
_sourceScript = sourceScript;
_cachedForwarder = cachedForwarder;
_parentExecutionId = parentExecutionId;
_sourceNode = sourceNode;
}
public async Task<ExternalCallResult> Call(
@@ -670,9 +690,11 @@ public class ScriptRuntimeContext
Channel: "ApiOutbound",
Target: target,
SourceSite: _siteId,
// SourceNode: stamped by Task 14 once the script context
// gets an INodeIdentityProvider; null until then.
SourceNode: null,
// SourceNode-stamping (Task 14): the local node name
// (node-a/node-b) — threaded through INodeIdentityProvider
// at the ScriptExecutionActor; null when no provider was
// wired so central persists SiteCalls.SourceNode as NULL.
SourceNode: _sourceNode,
Status: "Submitted",
RetryCount: 0,
LastError: null,
@@ -791,9 +813,11 @@ public class ScriptRuntimeContext
Channel: "ApiOutbound",
Target: target,
SourceSite: _siteId,
// SourceNode: stamped by Task 14 once the script context
// gets an INodeIdentityProvider; null until then.
SourceNode: null,
// SourceNode-stamping (Task 14): the local node name
// (node-a/node-b) — threaded through INodeIdentityProvider
// at the ScriptExecutionActor; null when no provider was
// wired so central persists SiteCalls.SourceNode as NULL.
SourceNode: _sourceNode,
Status: "Attempted",
// RetryCount stays 0 — the operation never reached the
// S&F retry sweep, so no retries were performed.
@@ -861,9 +885,11 @@ public class ScriptRuntimeContext
Channel: "ApiOutbound",
Target: target,
SourceSite: _siteId,
// SourceNode: stamped by Task 14 once the script context
// gets an INodeIdentityProvider; null until then.
SourceNode: null,
// SourceNode-stamping (Task 14): the local node name
// (node-a/node-b) — threaded through INodeIdentityProvider
// at the ScriptExecutionActor; null when no provider was
// wired so central persists SiteCalls.SourceNode as NULL.
SourceNode: _sourceNode,
Status: operationalTerminalStatus,
RetryCount: 0,
LastError: result.Success ? null : result.ErrorMessage,
@@ -1120,6 +1146,15 @@ public class ScriptRuntimeContext
/// </summary>
private readonly IAuditWriter? _auditWriter;
/// <summary>
/// SourceNode-stamping (Task 14): the local cluster node name on
/// which this script is executing (<c>node-a</c>/<c>node-b</c>).
/// Stamped onto <c>SiteCallOperational.SourceNode</c> at the
/// <c>Database.CachedWrite</c> CachedSubmit telemetry construction
/// site so central can persist it on the <c>SiteCalls</c> row.
/// </summary>
private readonly string? _sourceNode;
// Parameter ordering: executionId sits immediately after the
// ILogger — see the note on ExternalSystemHelper's ctor for why the
// post-logger slot is the one consistent position across all four
@@ -1133,7 +1168,8 @@ public class ScriptRuntimeContext
string siteId = "",
string? sourceScript = null,
ICachedCallTelemetryForwarder? cachedForwarder = null,
Guid? parentExecutionId = null)
Guid? parentExecutionId = null,
string? sourceNode = null)
{
_gateway = gateway;
_instanceName = instanceName;
@@ -1144,6 +1180,7 @@ public class ScriptRuntimeContext
_sourceScript = sourceScript;
_cachedForwarder = cachedForwarder;
_parentExecutionId = parentExecutionId;
_sourceNode = sourceNode;
}
public async Task<System.Data.Common.DbConnection> Connection(
@@ -1274,9 +1311,11 @@ public class ScriptRuntimeContext
Channel: "DbOutbound",
Target: target,
SourceSite: _siteId,
// SourceNode: stamped by Task 14 once the script context
// gets an INodeIdentityProvider; null until then.
SourceNode: null,
// SourceNode-stamping (Task 14): the local node name
// (node-a/node-b) — threaded through INodeIdentityProvider
// at the ScriptExecutionActor; null when no provider was
// wired so central persists SiteCalls.SourceNode as NULL.
SourceNode: _sourceNode,
Status: "Submitted",
RetryCount: 0,
LastError: null,