Merge branch 'feature/audit-log-m3-cached-operations': Audit Log #23 M3 Cached Operations + Dual-Write

M3 ships the cached-call lifecycle: ExternalSystem.CachedCall and
Database.CachedWrite each produce 3-5 audit rows + 1 SiteCalls row
sharing the same TrackedOperationId. Site emits the combined packet
(AuditEvent + SiteCallOperational); central writes both rows in one
MS SQL transaction.

Inlines the minimum-viable Site Call Audit (#22) surface:
SiteCalls table + ISiteCallAuditRepository + SiteCallAuditActor.
Reconciliation, KPIs, central->site Retry/Discard relay deferred.

Shipped (23 commits, ~120 net new tests, 24/24 test projects green):
- TrackedOperationId strong type + OperationTrackingStore site-local
  SQLite + Tracking.Status script API.
- CachedCallTelemetry combined operational+audit packet (additive per
  Commons REQ-COM-5a — never renamed CachedOperationTelemetry).
- SiteCalls MS SQL table + monotonic upsert repository (operational
  state, no partitioning) + migration.
- ScadaLink.SiteCallAudit new project + SiteCallAuditActor cluster
  singleton.
- sitestream.proto extended with IngestCachedTelemetry RPC +
  SiteCallOperationalDto + CachedTelemetryPacket/Batch.
- AuditLogIngestActor combined-telemetry handler with per-entry
  BeginTransactionAsync; rollback on either-throw; per-entry try/catch
  isolates failures; central singleton stays alive (Resume).
- ScriptRuntimeContext.ExternalSystem.CachedCall + Database.CachedWrite
  wrappers emit CachedSubmit on enqueue + handle immediate-success path
  (no S&F retry) with direct Attempted+CachedResolve emission.
- StoreAndForward observer hook (ICachedCallLifecycleObserver) +
  CachedCallLifecycleBridge translates S&F outcomes to combined
  telemetry; per-attempt rows carry Kind=ApiCallCached/DbWriteCached,
  Status=Attempted (HttpStatus/ErrorMessage capture success/failure);
  terminal carries Kind=CachedResolve, Status=Delivered/Failed/Parked/
  Discarded.
- Component-level e2e via TestKit + MsSqlMigrationFixture +
  DirectActorSiteStreamAuditClient extracted to shared Integration/
  Infrastructure/ + CombinedTelemetryHarness/Dispatcher helpers.
- Health metric SiteAuditWriteFailures still wired (M2). Bridge from
  ICachedCallTelemetryForwarder to AuditWriter chain.

Invariants honored: append-only AuditLog (writer role DENY UPDATE/DELETE
from M1); audit-failure-never-aborts-script (three-layer fail-safe
preserved); central singleton supervisor=Resume; idempotent at central
on EventId (M2 race-fix from Bundle A) + monotonic at central on
TrackedOperationId. infra/* never touched on any branch commit
(verified empty via 'git log main..feature/audit-log-m3-cached-operations -- infra/').

Site->central gRPC client still NoOpSiteStreamAuditClient in production
until M6; cached telemetry rows accumulate at site as Pending in
production.
This commit is contained in:
Joseph Doherty
2026-05-20 15:39:41 -04:00
80 changed files with 10947 additions and 106 deletions

View File

@@ -12,6 +12,7 @@
<Project Path="src/ScadaLink.ExternalSystemGateway/ScadaLink.ExternalSystemGateway.csproj" />
<Project Path="src/ScadaLink.NotificationService/ScadaLink.NotificationService.csproj" />
<Project Path="src/ScadaLink.NotificationOutbox/ScadaLink.NotificationOutbox.csproj" />
<Project Path="src/ScadaLink.SiteCallAudit/ScadaLink.SiteCallAudit.csproj" />
<Project Path="src/ScadaLink.CentralUI/ScadaLink.CentralUI.csproj" />
<Project Path="src/ScadaLink.Security/ScadaLink.Security.csproj" />
<Project Path="src/ScadaLink.HealthMonitoring/ScadaLink.HealthMonitoring.csproj" />
@@ -35,6 +36,7 @@
<Project Path="tests/ScadaLink.ExternalSystemGateway.Tests/ScadaLink.ExternalSystemGateway.Tests.csproj" />
<Project Path="tests/ScadaLink.NotificationService.Tests/ScadaLink.NotificationService.Tests.csproj" />
<Project Path="tests/ScadaLink.NotificationOutbox.Tests/ScadaLink.NotificationOutbox.Tests.csproj" />
<Project Path="tests/ScadaLink.SiteCallAudit.Tests/ScadaLink.SiteCallAudit.Tests.csproj" />
<Project Path="tests/ScadaLink.CentralUI.Tests/ScadaLink.CentralUI.Tests.csproj" />
<Project Path="tests/ScadaLink.Security.Tests/ScadaLink.Security.Tests.csproj" />
<Project Path="tests/ScadaLink.HealthMonitoring.Tests/ScadaLink.HealthMonitoring.Tests.csproj" />

View File

@@ -0,0 +1,212 @@
# Audit Log #23 — M3 Cached Operations + Dual-Write Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:subagent-driven-development (bundled cadence per `feedback_subagent_cadence`).
**Goal:** Cached external calls (`ExternalSystem.CachedCall`) and cached DB writes (`Database.CachedWrite`) each produce 4+ audit rows per operation (`CachedSubmit``ApiCallCached`/`DbWriteCached` × N attempts with statuses `Forwarded` then `Attempted` then `Delivered`/`Failed``CachedResolve` terminal) AND a `SiteCalls` row at central. Combined telemetry: site emits one packet per lifecycle event carrying both the AuditEvent and the SiteCalls upsert; central writes both in one MS SQL transaction. Audit-write failure never aborts the script.
**Recommended-defaults applied:**
- Telemetry proto: **new top-level RPC `IngestCachedTelemetry(CachedTelemetryBatch) returns (IngestAck)`** (sitestream.proto), separate from the M2 `IngestAuditEvents` to keep payload shapes distinct.
- Forwarder: **separate `CachedCallTelemetryForwarder`** actor (or static dispatcher hooking into the existing `SiteAuditTelemetryActor`'s SQLite queue) — write the audit row + tracking row in one SQLite transaction, then let the existing telemetry actor drain both via the new RPC. Reuse the M2 Channel/SQLite hot-path infrastructure; do NOT introduce a parallel writer.
- Provenance: mirror M2's `ScriptRuntimeContext` wrapper pattern — ScriptRuntimeContext's cached-call helpers capture instance/script/site and feed the combined packet.
- IntegrationTests E2E: same component-level pattern as M2 Bundle H (`DirectActorSiteStreamAuditClient`), but extracted into `tests/ScadaLink.AuditLog.Tests/Integration/Infrastructure/` for reuse.
**M2 realities baked in (from roadmap line 446-459):**
- Use M1 vocabulary: `AuditKind.CachedSubmit` (enqueue), `AuditKind.ApiCallCached` / `AuditKind.DbWriteCached` (each attempt + post-forward), `AuditKind.CachedResolve` (terminal). `AuditStatus.Submitted``Forwarded``Attempted` × N → `Delivered`/`Failed`/`Parked`/`Discarded`. NO `CachedEnqueued`/`CachedAttempt`/`CachedTerminal` strings appear in code (those are pre-M1 spec wording the roadmap text still mentions; honor the enum vocabulary).
- NoOpSiteStreamAuditClient still in production until M6; E2E tests use the M2 Bundle H pattern.
- AuditEventMapper duplication note from M2: M3 should move the mapper into Commons (or document the gRPC inline duplication) since M3 adds a SECOND gRPC handler with the same DTO→entity translation work.
- CachedCallTelemetry message creates from scratch (additive per Commons REQ-COM-5a) — NOT renamed to CachedOperationTelemetry.
---
## Bundles
- **Bundle A — Commons types + tracking store** (T1, T2, T3, T4): TrackedOperationId, OperationTrackingStore, Tracking.Status API, CachedCallTelemetry message.
- **Bundle B — SiteCalls table EF + migration + repo** (T5, T6, T7).
- **Bundle C — SiteCallAudit project + actor** (T8).
- **Bundle D — Proto + central dual-write transaction** (T9, T10).
- **Bundle E — ESG / DB-gateway / S&F emissions** (T11, T12, T13, T14).
- **Bundle F — Host registration** (T15).
- **Bundle G — Integration tests** (T16, T17, T18).
Final cross-bundle reviewer + merge to main.
---
## Bundle A — Commons types + tracking store
### Task A1: TrackedOperationId strong-typed ID
File: `src/ScadaLink.Commons/Types/TrackedOperationId.cs``public readonly record struct TrackedOperationId(Guid Value)`. Static `New()`, `Parse(string)`, `ToString()` returns Value.ToString("D"). Implicit conversion from Guid via `From(Guid)` (no operator implicit because record struct doesn't allow). Tests in `tests/ScadaLink.Commons.Tests/Types/TrackedOperationIdTests.cs`. Commit: `feat(commons): TrackedOperationId strong type (#23 M3)`.
### Task A2: OperationTrackingStore (site-local SQLite)
File: `src/ScadaLink.Commons/Interfaces/IOperationTrackingStore.cs``RecordEnqueueAsync`, `RecordAttemptAsync`, `RecordTerminalAsync`, `GetStatusAsync(TrackedOperationId)`, `PurgeTerminalAsync(olderThanUtc)`.
File: `src/ScadaLink.SiteRuntime/Tracking/OperationTrackingStore.cs` — SQLite-backed, mirror SqliteAuditWriter pattern: Channel<T> + background writer Task + write-lock. Schema:
```sql
CREATE TABLE IF NOT EXISTS OperationTracking (
TrackedOperationId TEXT NOT NULL PRIMARY KEY,
Kind TEXT NOT NULL,
TargetSummary TEXT NULL,
Status TEXT NOT NULL,
RetryCount INTEGER NOT NULL DEFAULT 0,
LastError TEXT NULL,
HttpStatus INTEGER NULL,
CreatedAtUtc TEXT NOT NULL,
UpdatedAtUtc TEXT NOT NULL,
TerminalAtUtc TEXT NULL,
SourceInstanceId TEXT NULL,
SourceScript TEXT NULL);
CREATE INDEX IF NOT EXISTS IX_OperationTracking_Status_Updated ON OperationTracking(Status, UpdatedAtUtc);
```
Tests: schema, insert+update sequence, terminal purge (only terminal rows older than threshold). Commit: `feat(siteruntime): OperationTrackingStore site-local SQLite (#23 M3)`.
### Task A3: Tracking.Status script API
File: `src/ScadaLink.SiteRuntime/Scripts/ScriptRuntimeContext.cs` — add a `Tracking` accessor exposing `Status(TrackedOperationId)` reading via `IOperationTrackingStore.GetStatusAsync`. Returns a `TrackingStatusSnapshot` record (Commons/Types) with `Status`, `RetryCount`, `LastError`, `CreatedAtUtc`, `UpdatedAtUtc`, `TerminalAtUtc`. Returns null for unknown IDs.
Tests: known, unknown, terminal IDs. Commit: `feat(siteruntime): Tracking.Status script API (#23 M3)`.
### Task A4: CachedCallTelemetry Commons message
File: `src/ScadaLink.Commons/Messages/Integration/CachedCallTelemetry.cs``public sealed record CachedCallTelemetry(TrackedOperationId TrackedOperationId, AuditEvent Audit, SiteCallOperational Operational)` plus `SiteCallOperational` record (TrackedOperationId, Channel, Target, SourceSite, Status, RetryCount, LastError, HttpStatus, CreatedAtUtc, UpdatedAtUtc, TerminalAtUtc?).
Tests: round-trip; lifecycle-specific construction (Submit/Attempted/Resolve). Commit: `feat(commons): CachedCallTelemetry combined operational+audit packet (#23 M3)`.
---
## Bundle B — SiteCalls EF + migration + repo
### Task B1: SiteCall entity + EF mapping
File: `src/ScadaLink.Commons/Entities/Audit/SiteCall.cs``public sealed record SiteCall` with fields per `SiteCallOperational` plus `IngestedAtUtc`.
File: `src/ScadaLink.ConfigurationDatabase/Configurations/SiteCallEntityTypeConfiguration.cs` — table `SiteCalls`, PK on `TrackedOperationId`, indexes `IX_SiteCalls_Source_Created` on (SourceSite, CreatedAtUtc), `IX_SiteCalls_Status_Updated` on (Status, UpdatedAtUtc).
Modify: `ScadaLinkDbContext.cs``public DbSet<SiteCall> SiteCalls => Set<SiteCall>();`.
Tests as M1 pattern. Commit: `feat(configdb): map SiteCall to SiteCalls table (#23 M3)`.
### Task B2: AddSiteCallsTable migration
Generate via `dotnet ef migrations add AddSiteCallsTable --project src/ScadaLink.ConfigurationDatabase --startup-project src/ScadaLink.Host`. No partitioning (operational state, not audit). Use MsSqlMigrationFixture for integration test. Commit: `feat(configdb): add SiteCalls migration (#23 M3)`.
### Task B3: ISiteCallAuditRepository + EF impl
File: `src/ScadaLink.Commons/Interfaces/Repositories/ISiteCallAuditRepository.cs``UpsertAsync(SiteCall)` with **monotonic status progression** (later status wins; earlier status is no-op), `GetAsync(TrackedOperationId)`, `QueryAsync(filter, paging)`, `PurgeTerminalAsync(olderThanUtc)`.
File: `src/ScadaLink.ConfigurationDatabase/Repositories/SiteCallAuditRepository.cs` — implement via `MERGE` or `INSERT ... WHERE NOT EXISTS` + `UPDATE WHERE TerminalAtUtc IS NULL AND <monotonic order check>`. Tests use MsSqlMigrationFixture. Commit: `feat(configdb): ISiteCallAuditRepository + EF impl (#23 M3)`.
---
## Bundle C — SiteCallAudit project + actor
### Task C1: ScadaLink.SiteCallAudit project + actor
Create: `src/ScadaLink.SiteCallAudit/ScadaLink.SiteCallAudit.csproj` (mirrors ScadaLink.AuditLog csproj style — net10.0, references Commons + ConfigurationDatabase).
Create: `src/ScadaLink.SiteCallAudit/SiteCallAuditActor.cs` — central singleton actor handling `UpsertSiteCallCommand(SiteCall siteCall)` by calling `ISiteCallAuditRepository.UpsertAsync` (scope-per-message via IServiceProvider, mirror AuditLogIngestActor). Idempotent via repo's monotonic upsert.
Create: `src/ScadaLink.SiteCallAudit/ServiceCollectionExtensions.cs``AddSiteCallAudit()` registering actor props factory.
Create: `tests/ScadaLink.SiteCallAudit.Tests/` project.
Modify: `ScadaLink.slnx` — add src + tests entries.
Commit: `feat(scaudit): SiteCallAuditActor minimum surface (#22, #23 M3)`.
---
## Bundle D — Proto + central dual-write transaction
### Task D1: Extend sitestream.proto with IngestCachedTelemetry RPC
Follow the documented protobuf regen procedure from M2 Bundle C (temporarily uncomment ItemGroup, build, copy back, recomment). Add:
```proto
message SiteCallOperationalDto {
string tracked_operation_id = 1;
string channel = 2;
string target = 3;
string source_site = 4;
string status = 5;
int32 retry_count = 6;
string last_error = 7;
google.protobuf.Int32Value http_status = 8;
google.protobuf.Timestamp created_at_utc = 9;
google.protobuf.Timestamp updated_at_utc = 10;
google.protobuf.Timestamp terminal_at_utc = 11; // null when active
}
message CachedTelemetryPacket {
AuditEventDto audit_event = 1;
SiteCallOperationalDto operational = 2;
}
message CachedTelemetryBatch { repeated CachedTelemetryPacket packets = 1; }
service SiteStreamService {
rpc IngestCachedTelemetry(CachedTelemetryBatch) returns (IngestAck);
}
```
Test round-trips. Commit: `feat(comms): IngestCachedTelemetry RPC + CachedTelemetryPacket proto (#23 M3)`.
### Task D2: Dual-write transaction in AuditLogIngestActor
File: `src/ScadaLink.AuditLog/Central/AuditLogIngestActor.cs` (extend) — add `IngestCachedTelemetryCommand` handler. Inside one `DbContext.Database.BeginTransactionAsync()`:
1. Call `IAuditLogRepository.InsertIfNotExistsAsync(auditEvent)` (idempotent already from M2 Bundle A).
2. Call `ISiteCallAuditRepository.UpsertAsync(siteCallOperational)` (monotonic).
3. Commit on both-success; rollback on either-throw (the central singleton SUPERVISES — actor doesn't crash).
4. Reply `IngestAck(acceptedIds)`.
Modify: `src/ScadaLink.Communication/SiteStreamGrpc/SiteStreamGrpcServer.cs` — implement `IngestCachedTelemetry` gRPC handler routing to actor. Same inline FromDto pattern as M2 (move to mapper if time permits per M2 reviewer recommendation).
Add: `src/ScadaLink.Commons/Messages/Audit/IngestCachedTelemetryCommand.cs` and `IngestCachedTelemetryReply.cs` (Akka messages).
Tests:
- Single packet → 1 AuditLog + 1 SiteCalls row.
- Duplicate `EventId` + same status → AuditLog no-op, SiteCalls no-op (monotonic), no error.
- Duplicate `EventId` + ADVANCED status → AuditLog no-op, SiteCalls updates.
- SiteCalls upsert throws → AuditLog rolled back (no orphan).
- AuditLog throws (non-duplicate) → SiteCalls rolled back.
Commit: `feat(auditlog): combined telemetry dual-write transaction (#23 M3)`.
---
## Bundle E — ESG / DB / S&F lifecycle emissions
### Task E1: ScriptRuntimeContext.ExternalSystem.CachedCall wrapper
Mirror M2 Bundle F's `Call` wrapper. Differences:
- Emit on enqueue: AuditEvent(Kind=CachedSubmit, Status=Submitted) + SiteCallOperational(Status=Submitted, RetryCount=0).
- Calls `_externalSystemClient.CachedCallAsync` (resolves what S&F existing API surface looks like — discover by reading ExternalSystemClient).
- Returns a `TrackedOperationId` immediately (a TrackedOperationId tracking handle).
- Hands the operation to the existing StoreAndForward retry loop.
For the per-attempt + terminal emissions, hook into the S&F dispatch loop (Bundle E2/E3).
### Task E2: S&F retry-loop emission
Find the S&F retry-attempt callback site in `src/ScadaLink.StoreAndForward/`. On each attempt (success/transient/permanent):
- Build AuditEvent(Kind=ApiCallCached or DbWriteCached, Status=Attempted).
- Build SiteCallOperational(Status=Attempted, RetryCount=N, LastError, HttpStatus).
- Hand to `CachedCallTelemetryForwarder` which writes both to SQLite (AuditLog + OperationTracking tables, in one SQLite transaction) and lets SiteAuditTelemetryActor's drain loop push them.
### Task E3: S&F terminal-state emission
On final state transition (Delivered / Failed / Parked / Discarded):
- Build AuditEvent(Kind=CachedResolve, Status={final state}).
- Build SiteCallOperational(Status={final state}, TerminalAtUtc=DateTime.UtcNow).
- Forward.
### Task E4: Database.CachedWrite mirror
Same three-event pattern but Channel=DbOutbound, Kind=DbWriteCached for attempts, Kind=CachedSubmit for enqueue, Kind=CachedResolve for terminal.
Tests in ExternalSystemGateway.Tests + StoreAndForward.Tests.
Commit (bundle-level): one commit per task, descriptive messages following M2 style.
---
## Bundle F — Host registration
### Task F1: Register SiteCallAuditActor central singleton
File: `src/ScadaLink.Host/Actors/AkkaHostedService.cs` — register `SiteCallAuditActor` central singleton + proxy alongside `AuditLogIngestActor`. Hand the proxy to `SiteStreamGrpcServer.SetSiteCallAuditActor(proxy)` (mirroring `SetAuditIngestActor`).
File: `src/ScadaLink.Host/Program.cs` — call `.AddSiteCallAudit()` on the central role's services.
Tests in `tests/ScadaLink.Host.Tests/AkkaHostedServiceAuditWiringTests.cs` (extend).
Commit: `feat(host): register SiteCallAuditActor central singleton (#22, #23 M3)`.
---
## Bundle G — Integration tests
### Task G1: Extract DirectActorSiteStreamAuditClient to shared infrastructure
Move from `tests/ScadaLink.AuditLog.Tests/Integration/SyncCallEmissionEndToEndTests.cs` private inner class into `tests/ScadaLink.AuditLog.Tests/Integration/Infrastructure/DirectActorSiteStreamAuditClient.cs`. Extend to also implement the new `IngestCachedTelemetryAsync` method (mirror pattern).
### Task G2: Cached call E2E test
File: `tests/ScadaLink.AuditLog.Tests/Integration/CachedCallCombinedTelemetryTests.cs` (use AuditLog.Tests, not IntegrationTests, because the existing IntegrationTests harness disables Akka per M2 reality). Test: cached call that fails twice then succeeds produces 5 AuditLog rows (1 Submit + 1 Forwarded + 2 Attempted + 1 Resolve) + 1 SiteCalls row (Status=Delivered) + Tracking.Status reports Delivered.
### Task G3: Cached DB write E2E test
File: `tests/ScadaLink.AuditLog.Tests/Integration/CachedWriteCombinedTelemetryTests.cs`. Mirror G2 for DB.
### Task G4: Idempotency test
File: `tests/ScadaLink.AuditLog.Tests/Integration/CombinedTelemetryIdempotencyTests.cs`. Send the same packet twice; assert exactly 1 AuditLog row + 1 SiteCalls row.
---
## Final cross-bundle review + merge
Same template as M1/M2.

View File

@@ -4,6 +4,7 @@ using Microsoft.Extensions.Logging;
using ScadaLink.Commons.Entities.Audit;
using ScadaLink.Commons.Interfaces.Repositories;
using ScadaLink.Commons.Messages.Audit;
using ScadaLink.ConfigurationDatabase;
namespace ScadaLink.AuditLog.Central;
@@ -61,6 +62,11 @@ public class AuditLogIngestActor : ReceiveActor
_logger = logger;
ReceiveAsync<IngestAuditEventsCommand>(OnIngestAsync);
// The single-repository test ctor cannot service the M3 dual-write —
// it has no SiteCalls repo and no DbContext. The handler still
// registers (so callers don't dead-letter) but replies empty so the
// test surface stays explicit about what this ctor supports.
ReceiveAsync<IngestCachedTelemetryCommand>(OnCachedTelemetryWithoutDualWriteAsync);
}
/// <summary>
@@ -81,6 +87,7 @@ public class AuditLogIngestActor : ReceiveActor
_logger = logger;
ReceiveAsync<IngestAuditEventsCommand>(OnIngestAsync);
ReceiveAsync<IngestCachedTelemetryCommand>(OnCachedTelemetryAsync);
}
/// <summary>
@@ -150,4 +157,98 @@ public class AuditLogIngestActor : ReceiveActor
replyTo.Tell(new IngestAuditEventsReply(accepted));
}
/// <summary>
/// M3 dual-write handler. For every <see cref="CachedTelemetryEntry"/> the
/// actor opens a fresh MS SQL transaction, inserts the AuditLog row
/// idempotently AND upserts the SiteCalls row monotonically. Both succeed
/// or both roll back, so the audit and operational mirrors never drift
/// mid-row. The IngestedAtUtc stamp is unified between the two rows so a
/// downstream join lines up cleanly.
/// </summary>
/// <remarks>
/// Per-entry isolation — one entry's failed transaction does NOT abort
/// other entries in the batch (each gets its own
/// <see cref="Microsoft.EntityFrameworkCore.RelationalDatabaseFacadeExtensions.BeginTransactionAsync"/>
/// scope and a try/catch around it). Audit-write failure NEVER aborts the
/// user-facing action — the site keeps the row Pending and retries on the
/// next drain.
/// </remarks>
private async Task OnCachedTelemetryAsync(IngestCachedTelemetryCommand cmd)
{
var replyTo = Sender;
var accepted = new List<Guid>(cmd.Entries.Count);
try
{
await using var scope = _serviceProvider!.CreateAsyncScope();
var auditRepo = scope.ServiceProvider.GetRequiredService<IAuditLogRepository>();
var siteCallRepo = scope.ServiceProvider.GetRequiredService<ISiteCallAuditRepository>();
var dbContext = scope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
foreach (var entry in cmd.Entries)
{
try
{
await using var tx = await dbContext.Database
.BeginTransactionAsync()
.ConfigureAwait(false);
// Stamp IngestedAtUtc on both rows from a single
// central-side instant so a join on the two tables sees
// matching timestamps (debugging convenience, not a
// correctness invariant).
var ingestedAt = DateTime.UtcNow;
var auditStamped = entry.Audit with { IngestedAtUtc = ingestedAt };
var siteCallStamped = entry.SiteCall with { IngestedAtUtc = ingestedAt };
await auditRepo.InsertIfNotExistsAsync(auditStamped)
.ConfigureAwait(false);
await siteCallRepo.UpsertAsync(siteCallStamped)
.ConfigureAwait(false);
await tx.CommitAsync().ConfigureAwait(false);
accepted.Add(entry.Audit.EventId);
}
catch (Exception ex)
{
// Both rows rolled back via the disposing transaction. The
// EventId is NOT added to `accepted` so the site keeps its
// row Pending and retries on the next drain. Other entries
// in the batch continue with their own transactions.
_logger.LogError(
ex,
"Combined telemetry dual-write failed for AuditEvent {EventId} / TrackedOperationId {TrackedOpId}; rolled back.",
entry.Audit.EventId,
entry.SiteCall.TrackedOperationId);
}
}
}
catch (Exception ex)
{
// Resolving the scope itself threw (e.g. DI mis-wiring). Log and
// reply with whatever we managed to accept (likely empty) — the
// central singleton MUST stay alive.
_logger.LogError(
ex,
"Combined telemetry batch ingest failed before per-entry processing.");
}
replyTo.Tell(new IngestCachedTelemetryReply(accepted));
}
/// <summary>
/// Fallback handler installed on the single-repository test ctor — that
/// ctor has no DbContext and no <see cref="ISiteCallAuditRepository"/>, so
/// it cannot service the dual-write. Logs a warning and replies with an
/// empty ack so callers fall through to their retry path.
/// </summary>
private Task OnCachedTelemetryWithoutDualWriteAsync(IngestCachedTelemetryCommand cmd)
{
_logger.LogWarning(
"AuditLogIngestActor received IngestCachedTelemetryCommand on the single-repository ctor; dual-write requires the IServiceProvider ctor. Replying with empty ack ({Count} entries).",
cmd.Entries.Count);
Sender.Tell(new IngestCachedTelemetryReply(Array.Empty<Guid>()));
return Task.CompletedTask;
}
}

View File

@@ -102,6 +102,33 @@ public static class ServiceCollectionExtensions
// SiteAuditTelemetryActor's Props.Create call.
services.AddSingleton<ISiteStreamAuditClient, NoOpSiteStreamAuditClient>();
// M3 Bundle F: site-side dual emitter for cached-call lifecycle
// telemetry. ScriptRuntimeContext.ExternalSystem.CachedCall /
// Database.CachedWrite resolves this through DI and pushes one combined
// packet per lifecycle event; the forwarder writes the audit half
// through IAuditWriter and the operational half through the
// IOperationTrackingStore. The audit writer is always wired (the M2
// chain above); the operational tracking store is SITE-ONLY (registered
// by ScadaLink.SiteRuntime). On a Central composition root the tracking
// store has no registration, so the factory resolves it with GetService
// (returning null) — the forwarder degrades to "audit-only" emission,
// mirroring the lazy IAuditWriter chain established in M2.
services.AddSingleton<ICachedCallTelemetryForwarder>(sp =>
new CachedCallTelemetryForwarder(
sp.GetRequiredService<IAuditWriter>(),
sp.GetService<ScadaLink.Commons.Interfaces.IOperationTrackingStore>(),
sp.GetRequiredService<ILogger<CachedCallTelemetryForwarder>>()));
// M3 Bundle F: bridge the store-and-forward retry-loop observer hook
// to the cached-call forwarder so per-attempt + terminal telemetry
// emitted from the S&F retry sweep lands on the same SQLite hot-path
// 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>();
services.AddSingleton<ICachedCallLifecycleObserver>(
sp => sp.GetRequiredService<CachedCallLifecycleBridge>());
return services;
}

View File

@@ -0,0 +1,202 @@
using Microsoft.Extensions.Logging;
using ScadaLink.Commons.Entities.Audit;
using ScadaLink.Commons.Interfaces.Services;
using ScadaLink.Commons.Messages.Integration;
using ScadaLink.Commons.Types;
using ScadaLink.Commons.Types.Enums;
namespace ScadaLink.AuditLog.Site.Telemetry;
/// <summary>
/// Audit Log #23 (M3 Bundle E — Tasks E4/E5): translates per-attempt
/// notifications from the store-and-forward retry loop into one (or two)
/// <see cref="CachedCallTelemetry"/> packets and pushes them through
/// <see cref="ICachedCallTelemetryForwarder"/>.
/// </summary>
/// <remarks>
/// <para>
/// The S&amp;F loop's <see cref="ICachedCallLifecycleObserver"/> reports a
/// single coarse outcome per attempt; the audit pipeline however models the
/// lifecycle as TWO rows on terminal outcomes — an <c>Attempted</c>
/// (<see cref="AuditKind.ApiCallCached"/> / <see cref="AuditKind.DbWriteCached"/>)
/// row capturing the per-attempt mechanics, plus a <see cref="AuditKind.CachedResolve"/>
/// row marking the terminal state for downstream consumers. The bridge fans
/// out per outcome:
/// </para>
/// <list type="bullet">
/// <item><description><c>TransientFailure</c> -> one Attempted(Failed) row.</description></item>
/// <item><description><c>Delivered</c> -> Attempted(Delivered) + CachedResolve(Delivered).</description></item>
/// <item><description><c>PermanentFailure</c> -> Attempted(Failed) + CachedResolve(Parked).</description></item>
/// <item><description><c>ParkedMaxRetries</c> -> Attempted(Failed) + CachedResolve(Parked).</description></item>
/// </list>
/// <para>
/// <b>Best-effort emission (alog.md §7):</b> the bridge itself never throws;
/// the underlying forwarder swallows + logs its own failures.
/// </para>
/// </remarks>
public sealed class CachedCallLifecycleBridge : ICachedCallLifecycleObserver
{
private readonly ICachedCallTelemetryForwarder _forwarder;
private readonly ILogger<CachedCallLifecycleBridge> _logger;
public CachedCallLifecycleBridge(
ICachedCallTelemetryForwarder forwarder,
ILogger<CachedCallLifecycleBridge> logger)
{
_forwarder = forwarder ?? throw new ArgumentNullException(nameof(forwarder));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <inheritdoc/>
public async Task OnAttemptCompletedAsync(
CachedCallAttemptContext context, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(context);
try
{
await EmitAttemptedAsync(context, ct).ConfigureAwait(false);
if (IsTerminal(context.Outcome))
{
await EmitResolveAsync(context, ct).ConfigureAwait(false);
}
}
catch (Exception ex)
{
// Defensive — both EmitX paths call the forwarder which is itself
// best-effort. A throw here is unexpected, but the alog.md §7
// contract requires we never propagate.
_logger.LogWarning(ex,
"CachedCallLifecycleBridge: unexpected throw for {TrackedOperationId} (Outcome {Outcome})",
context.TrackedOperationId, context.Outcome);
}
}
private async Task EmitAttemptedAsync(CachedCallAttemptContext context, CancellationToken ct)
{
// Per-attempt row: kind discriminates channel; status is always
// Attempted regardless of outcome (success vs. failure is captured
// by the companion HttpStatus / ErrorMessage fields, NOT by flipping
// the status — CachedResolve carries the terminal Status). Per the
// M3 brief and alog.md §4.
var kind = ChannelToAttemptKind(context.Channel);
var status = AuditStatus.Attempted;
var packet = BuildPacket(
context,
kind: kind,
status: status,
// Operational status mirror — for the per-attempt row the
// operational state is the running status; the bridge always
// writes "Attempted" so reconciliation can't roll back.
operationalStatus: "Attempted",
terminalAtUtc: null,
lastError: context.LastError,
httpStatus: context.HttpStatus);
await _forwarder.ForwardAsync(packet, ct).ConfigureAwait(false);
}
private async Task EmitResolveAsync(CachedCallAttemptContext context, CancellationToken ct)
{
var (auditStatus, operationalStatus) = TerminalOutcomeToStatuses(context.Outcome);
var packet = BuildPacket(
context,
kind: AuditKind.CachedResolve,
status: auditStatus,
operationalStatus: operationalStatus,
terminalAtUtc: context.OccurredAtUtc,
lastError: context.LastError,
httpStatus: context.HttpStatus);
await _forwarder.ForwardAsync(packet, ct).ConfigureAwait(false);
}
private static CachedCallTelemetry BuildPacket(
CachedCallAttemptContext context,
AuditKind kind,
AuditStatus status,
string operationalStatus,
DateTime? terminalAtUtc,
string? lastError,
int? httpStatus)
{
var channel = ChannelStringToEnum(context.Channel);
return new CachedCallTelemetry(
Audit: new AuditEvent
{
EventId = Guid.NewGuid(),
OccurredAtUtc = DateTime.SpecifyKind(context.OccurredAtUtc, DateTimeKind.Utc),
Channel = channel,
Kind = kind,
CorrelationId = context.TrackedOperationId.Value,
SourceSiteId = string.IsNullOrEmpty(context.SourceSite) ? null : context.SourceSite,
SourceInstanceId = context.SourceInstanceId,
SourceScript = null, // Not threaded through S&F; left null on retry-loop rows.
Target = context.Target,
Status = status,
HttpStatus = httpStatus,
DurationMs = context.DurationMs,
ErrorMessage = lastError,
ForwardState = AuditForwardState.Pending,
},
Operational: new SiteCallOperational(
TrackedOperationId: context.TrackedOperationId,
Channel: context.Channel,
Target: context.Target,
SourceSite: context.SourceSite,
Status: operationalStatus,
RetryCount: context.RetryCount,
LastError: lastError,
HttpStatus: httpStatus,
CreatedAtUtc: DateTime.SpecifyKind(context.CreatedAtUtc, DateTimeKind.Utc),
UpdatedAtUtc: DateTime.SpecifyKind(context.OccurredAtUtc, DateTimeKind.Utc),
TerminalAtUtc: terminalAtUtc is null
? null
: DateTime.SpecifyKind(terminalAtUtc.Value, DateTimeKind.Utc)));
}
private static AuditKind ChannelToAttemptKind(string channel) => channel switch
{
"ApiOutbound" => AuditKind.ApiCallCached,
"DbOutbound" => AuditKind.DbWriteCached,
// Defensive default — the S&F observer is filtered to cached-call
// categories so this branch shouldn't fire in practice.
_ => AuditKind.ApiCallCached,
};
private static AuditChannel ChannelStringToEnum(string channel) => channel switch
{
"ApiOutbound" => AuditChannel.ApiOutbound,
"DbOutbound" => AuditChannel.DbOutbound,
_ => AuditChannel.ApiOutbound,
};
private static (AuditStatus auditStatus, string operationalStatus) TerminalOutcomeToStatuses(
CachedCallAttemptOutcome outcome) => outcome switch
{
CachedCallAttemptOutcome.Delivered =>
(AuditStatus.Delivered, "Delivered"),
CachedCallAttemptOutcome.PermanentFailure =>
(AuditStatus.Parked, "Parked"),
CachedCallAttemptOutcome.ParkedMaxRetries =>
(AuditStatus.Parked, "Parked"),
// TransientFailure isn't terminal — see IsTerminal — but the switch
// is exhaustive so we route it through Failed for safety.
CachedCallAttemptOutcome.TransientFailure =>
(AuditStatus.Failed, "Failed"),
_ => (AuditStatus.Failed, "Failed"),
};
private static bool IsTerminal(CachedCallAttemptOutcome outcome) => outcome switch
{
CachedCallAttemptOutcome.Delivered => true,
CachedCallAttemptOutcome.PermanentFailure => true,
CachedCallAttemptOutcome.ParkedMaxRetries => true,
CachedCallAttemptOutcome.TransientFailure => false,
_ => false,
};
}

View File

@@ -0,0 +1,179 @@
using Microsoft.Extensions.Logging;
using ScadaLink.Commons.Entities.Audit;
using ScadaLink.Commons.Interfaces;
using ScadaLink.Commons.Interfaces.Services;
using ScadaLink.Commons.Messages.Integration;
using ScadaLink.Commons.Types;
using ScadaLink.Commons.Types.Enums;
namespace ScadaLink.AuditLog.Site.Telemetry;
/// <summary>
/// Site-side dual emitter for cached-call lifecycle telemetry (Audit Log #23 /
/// M3). Sister to <see cref="SiteAuditTelemetryActor"/>: where the M2 actor
/// drains audit-only events, this forwarder takes a combined
/// <see cref="CachedCallTelemetry"/> packet and fans it out to the two
/// site-local stores in a single call:
/// <list type="bullet">
/// <item><description>The <see cref="AuditEvent"/> row is written via
/// <see cref="IAuditWriter"/> (the site <c>FallbackAuditWriter</c> +
/// <c>SqliteAuditWriter</c> chain established in M2).</description></item>
/// <item><description>The operational <see cref="SiteCallOperational"/> half
/// updates the site-local <c>OperationTracking</c> SQLite store via
/// <see cref="IOperationTrackingStore"/>, with the per-lifecycle method
/// (<c>Enqueue</c> / <c>Attempt</c> / <c>Terminal</c>) selected from the
/// audit row's <see cref="AuditKind"/>.</description></item>
/// </list>
/// </summary>
/// <remarks>
/// <para>
/// <b>Best-effort contract (alog.md §7):</b> a thrown writer OR a thrown
/// tracking store must never propagate to the calling script. Both emission
/// halves are wrapped in independent try/catch blocks so a SQLite outage on
/// one side cannot starve the other — the failure is logged and the call
/// returns normally.
/// </para>
/// <para>
/// <b>Wire push deferred to M6.</b> M3 keeps this forwarder synchronous
/// against the local stores: there is no site→central gRPC channel yet, so
/// the <see cref="ISiteStreamAuditClient.IngestCachedTelemetryAsync"/> RPC
/// is registered on the interface (Bundle E1) but the production binding
/// remains <c>NoOpSiteStreamAuditClient</c>. Once M6 wires a real client the
/// drain pattern from <c>SiteAuditTelemetryActor</c> can be reused — the
/// <c>AuditEvent</c> rows already live in SQLite tagged
/// <see cref="AuditForwardState.Pending"/>, so a single drain loop sweeps
/// both M2 and M3 emissions.
/// </para>
/// </remarks>
public sealed class CachedCallTelemetryForwarder : ICachedCallTelemetryForwarder
{
private readonly IAuditWriter _auditWriter;
private readonly IOperationTrackingStore? _trackingStore;
private readonly ILogger<CachedCallTelemetryForwarder> _logger;
/// <summary>
/// Construct the forwarder. <paramref name="trackingStore"/> is optional —
/// when null only the audit half of the packet is emitted, which matches
/// the M3 Bundle F composition-root contract on Central nodes: the
/// AuditLog DI surface registers the forwarder unconditionally (mirroring
/// the IAuditWriter chain) but the site-only tracking store has no central
/// registration. Production site nodes wire both — the central lazy
/// resolution is a no-op path kept symmetric with the M2 writer chain.
/// </summary>
public CachedCallTelemetryForwarder(
IAuditWriter auditWriter,
IOperationTrackingStore? trackingStore,
ILogger<CachedCallTelemetryForwarder> logger)
{
_auditWriter = auditWriter ?? throw new ArgumentNullException(nameof(auditWriter));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_trackingStore = trackingStore;
}
/// <summary>
/// Fan out one combined-telemetry packet to the audit writer and the
/// tracking store. Returns once both halves have been attempted (success
/// OR logged failure). NEVER throws — exceptions are caught per-half and
/// logged at warning level so the calling script's outbound action is not
/// disturbed.
/// </summary>
public async Task ForwardAsync(CachedCallTelemetry telemetry, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(telemetry);
// Independent try/catch — a thrown audit writer must not prevent the
// tracking-store update from running (and vice-versa). Both halves
// are best-effort.
await TryEmitAuditAsync(telemetry, ct).ConfigureAwait(false);
await TryEmitTrackingAsync(telemetry, ct).ConfigureAwait(false);
}
private async Task TryEmitAuditAsync(CachedCallTelemetry telemetry, CancellationToken ct)
{
try
{
await _auditWriter.WriteAsync(telemetry.Audit, ct).ConfigureAwait(false);
}
catch (Exception ex)
{
// alog.md §7 best-effort contract — log and swallow. The audit
// pipeline's own retry/recovery (RingBufferFallback in the
// FallbackAuditWriter) handles transient writer failures upstream;
// a throw bubbling up here means the writer's own swallow contract
// failed, which is itself best-effort-handled.
_logger.LogWarning(ex,
"CachedCallTelemetryForwarder: audit emission threw for EventId {EventId} (Kind {Kind}, Status {Status})",
telemetry.Audit.EventId, telemetry.Audit.Kind, telemetry.Audit.Status);
}
}
private async Task TryEmitTrackingAsync(CachedCallTelemetry telemetry, CancellationToken ct)
{
if (_trackingStore is null)
{
// No site-local tracking store wired — Central composition root or
// an integration-test host that skipped AddSiteRuntime. Emitting
// through the audit half is still meaningful; the tracking half
// is a no-op rather than an error.
return;
}
try
{
switch (telemetry.Audit.Kind)
{
case AuditKind.CachedSubmit:
// 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.
await _trackingStore.RecordEnqueueAsync(
telemetry.Operational.TrackedOperationId,
telemetry.Operational.Channel,
telemetry.Operational.Target,
telemetry.Audit.SourceInstanceId,
telemetry.Audit.SourceScript,
ct).ConfigureAwait(false);
break;
case AuditKind.ApiCallCached:
case AuditKind.DbWriteCached:
// Attempt — advance retry counter + last-error/HTTP-status.
// Terminal rows are guarded by the store's WHERE clause.
await _trackingStore.RecordAttemptAsync(
telemetry.Operational.TrackedOperationId,
telemetry.Operational.Status,
telemetry.Operational.RetryCount,
telemetry.Operational.LastError,
telemetry.Operational.HttpStatus,
ct).ConfigureAwait(false);
break;
case AuditKind.CachedResolve:
// Terminal — first-write-wins on the resolve flip.
await _trackingStore.RecordTerminalAsync(
telemetry.Operational.TrackedOperationId,
telemetry.Operational.Status,
telemetry.Operational.LastError,
telemetry.Operational.HttpStatus,
ct).ConfigureAwait(false);
break;
default:
// Defensive — only the four cached-lifecycle kinds are
// expected on this path. Anything else is logged so a
// mis-routed packet is visible but never crashes the
// forwarder.
_logger.LogWarning(
"CachedCallTelemetryForwarder: unexpected audit kind {Kind} on tracking emission for EventId {EventId}",
telemetry.Audit.Kind, telemetry.Audit.EventId);
break;
}
}
catch (Exception ex)
{
_logger.LogWarning(ex,
"CachedCallTelemetryForwarder: tracking-store emission threw for TrackedOperationId {Id} (Status {Status})",
telemetry.Operational.TrackedOperationId, telemetry.Operational.Status);
}
}
}

View File

@@ -20,4 +20,23 @@ public interface ISiteStreamAuditClient
/// in the site SQLite queue.
/// </summary>
Task<IngestAck> IngestAuditEventsAsync(AuditEventBatch batch, CancellationToken ct);
/// <summary>
/// Pushes the combined <see cref="CachedTelemetryBatch"/> (Audit Log #23 / M3)
/// to the central <c>IngestCachedTelemetry</c> RPC. Each packet carries both
/// the audit row and the operational <c>SiteCalls</c> upsert; central writes
/// both in a single MS SQL transaction. Returns the same
/// <see cref="IngestAck"/> shape as <see cref="IngestAuditEventsAsync"/> so
/// the M3 site-side forwarder can flip the underlying audit rows to
/// <see cref="ScadaLink.Commons.Types.Enums.AuditForwardState.Forwarded"/>
/// once central has acknowledged them.
/// </summary>
/// <remarks>
/// The production gRPC-backed implementation lands in M6 (no site→central
/// gRPC channel exists today); until then the default
/// <see cref="NoOpSiteStreamAuditClient"/> binding returns an empty ack and
/// integration tests substitute a direct-actor client that routes the batch
/// straight into the in-process <c>AuditLogIngestActor</c>.
/// </remarks>
Task<IngestAck> IngestCachedTelemetryAsync(CachedTelemetryBatch batch, CancellationToken ct);
}

View File

@@ -38,4 +38,16 @@ public sealed class NoOpSiteStreamAuditClient : ISiteStreamAuditClient
// Pending until M6's real client (or a Bundle H test stub) takes over.
return Task.FromResult(EmptyAck);
}
/// <inheritdoc/>
public Task<IngestAck> IngestCachedTelemetryAsync(CachedTelemetryBatch batch, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(batch);
// Empty ack — same rationale as IngestAuditEventsAsync. The M3
// CachedCallTelemetryForwarder still writes the audit + tracking rows to
// the site SQLite stores authoritatively; central-side state only
// materialises once M6's real gRPC client (or a Bundle G test stub) is
// wired in.
return Task.FromResult(EmptyAck);
}
}

View File

@@ -0,0 +1,60 @@
using ScadaLink.Commons.Types;
namespace ScadaLink.Commons.Entities.Audit;
/// <summary>
/// Central operational state row for a cached call (Site Call Audit #22, Audit Log #23 M3).
/// One row per <see cref="TrackedOperationId"/> in the <c>SiteCalls</c> table — append-once
/// then monotonic status update. Status transitions are forward-only
/// (<c>Submitted → Forwarded → Attempted → Delivered|Failed|Parked|Discarded</c>); an
/// out-of-order or duplicate upsert is a silent no-op so duplicate gRPC packets and
/// reconciliation pulls can both feed the same writer without rolling state back.
/// </summary>
/// <remarks>
/// Sites remain the source of truth — this row is the eventually-consistent mirror the
/// Central UI's Site Calls page reads. Unlike the partitioned <c>AuditLog</c> table this
/// entity backs operational (mutable) state on a standard non-partitioned table on
/// <c>[PRIMARY]</c>; no DB-role restriction applies.
/// </remarks>
public sealed record SiteCall
{
/// <summary>Strong-typed idempotency key shared with every audit row for the operation.</summary>
public required TrackedOperationId TrackedOperationId { get; init; }
/// <summary>Trust-boundary channel — <c>"ApiOutbound"</c> or <c>"DbOutbound"</c>.</summary>
public required string Channel { get; init; }
/// <summary>Human-readable target (e.g. <c>"ERP.GetOrder"</c>).</summary>
public required string Target { get; init; }
/// <summary>Site id that submitted the cached call.</summary>
public required string SourceSite { get; init; }
/// <summary>
/// Lifecycle status — string form of
/// <see cref="ScadaLink.Commons.Types.Enums.AuditStatus"/>. Monotonic: later rank
/// wins, earlier rank is a no-op.
/// </summary>
public required string Status { get; init; }
/// <summary>Number of dispatch attempts so far; 0 prior to first attempt.</summary>
public required int RetryCount { get; init; }
/// <summary>Most recent error message; null when no failures have occurred.</summary>
public string? LastError { get; init; }
/// <summary>Most recent HTTP status code (API calls only); null otherwise.</summary>
public int? HttpStatus { get; init; }
/// <summary>UTC timestamp the cached call was first submitted at the site.</summary>
public required DateTime CreatedAtUtc { get; init; }
/// <summary>UTC timestamp of the latest status mutation at the site.</summary>
public required DateTime UpdatedAtUtc { get; init; }
/// <summary>UTC timestamp the row reached a terminal status; null while still active.</summary>
public DateTime? TerminalAtUtc { get; init; }
/// <summary>UTC timestamp central ingested (or last refreshed) this row.</summary>
public required DateTime IngestedAtUtc { get; init; }
}

View File

@@ -0,0 +1,87 @@
using ScadaLink.Commons.Types;
namespace ScadaLink.Commons.Interfaces;
/// <summary>
/// Site-local source of truth for cached-operation tracking
/// (<c>ExternalSystem.CachedCall</c> / <c>Database.CachedWrite</c>) — alongside the
/// Store-and-Forward buffer, this is the row that <c>Tracking.Status(id)</c>
/// reads (Audit Log #23 / M3). One row per <see cref="TrackedOperationId"/>;
/// terminal rows are purged after a configurable retention window
/// (default 7 days).
/// </summary>
/// <remarks>
/// <para>
/// The store is intentionally a thin write-API on top of SQLite — not a
/// dispatcher. Status transitions follow
/// <c>Submitted → Retrying → Delivered / Parked / Failed / Discarded</c>; rows
/// in a terminal state never roll back. Implementations must:
/// <list type="bullet">
/// <item><description><see cref="RecordEnqueueAsync"/> is insert-if-not-exists
/// (caller-supplied id is the idempotency key — duplicate enqueues are no-ops).</description></item>
/// <item><description><see cref="RecordAttemptAsync"/> only updates non-terminal rows.</description></item>
/// <item><description><see cref="RecordTerminalAsync"/> only flips a non-terminal row to terminal.</description></item>
/// <item><description><see cref="PurgeTerminalAsync"/> deletes terminal rows whose
/// <c>TerminalAtUtc</c> is strictly older than the supplied threshold.</description></item>
/// </list>
/// </para>
/// </remarks>
public interface IOperationTrackingStore
{
/// <summary>
/// Insert a new tracking row in <c>Submitted</c> state with <c>RetryCount = 0</c>.
/// Idempotent — a duplicate id is silently ignored (the existing row is left
/// untouched), matching the at-least-once semantics of the calling site
/// store-and-forward path.
/// </summary>
Task RecordEnqueueAsync(
TrackedOperationId id,
string kind,
string? targetSummary,
string? sourceInstanceId,
string? sourceScript,
CancellationToken ct = default);
/// <summary>
/// Advance an in-flight tracking row's status, retry counter, and most-
/// recent error/HTTP-status. Terminal rows (<see cref="RecordTerminalAsync"/>
/// already applied) are NOT mutated — the operation has reached its final
/// outcome and any late-arriving attempt telemetry is dropped on the floor.
/// </summary>
Task RecordAttemptAsync(
TrackedOperationId id,
string status,
int retryCount,
string? lastError,
int? httpStatus,
CancellationToken ct = default);
/// <summary>
/// Flip a non-terminal tracking row to terminal — sets
/// <c>TerminalAtUtc = now</c> and writes the final status / error. A row
/// already in terminal state is left untouched (first-write-wins).
/// </summary>
Task RecordTerminalAsync(
TrackedOperationId id,
string status,
string? lastError,
int? httpStatus,
CancellationToken ct = default);
/// <summary>
/// Return the latest snapshot for the supplied id, or <c>null</c> when no
/// tracking row exists (purged or never recorded).
/// </summary>
Task<TrackingStatusSnapshot?> GetStatusAsync(
TrackedOperationId id,
CancellationToken ct = default);
/// <summary>
/// Delete terminal rows whose <c>TerminalAtUtc</c> is strictly older than
/// <paramref name="olderThanUtc"/>. Non-terminal rows are kept regardless
/// of age (the operation is still in flight).
/// </summary>
Task PurgeTerminalAsync(
DateTime olderThanUtc,
CancellationToken ct = default);
}

View File

@@ -0,0 +1,66 @@
using ScadaLink.Commons.Entities.Audit;
using ScadaLink.Commons.Types;
using ScadaLink.Commons.Types.Audit;
namespace ScadaLink.Commons.Interfaces.Repositories;
/// <summary>
/// Operational-state data access for the central <c>SiteCalls</c> table
/// (Site Call Audit #22, Audit Log #23 M3 Bundle B). One row per
/// <see cref="TrackedOperationId"/>; sites remain the source of truth and this
/// table is an eventually-consistent mirror fed by best-effort gRPC telemetry
/// plus periodic reconciliation pulls.
/// </summary>
/// <remarks>
/// <para>
/// Unlike the partitioned append-only <c>AuditLog</c> (M1), this table holds
/// mutable operational state. <see cref="UpsertAsync"/> is insert-if-not-exists
/// then monotonic update — a status update with rank less than or equal to the
/// stored status is a silent no-op so out-of-order telemetry, duplicate gRPC
/// packets, and reconciliation pulls can all feed the same writer without
/// rolling state backward.
/// </para>
/// <para>
/// Status rank for monotonic comparison (lower wins): <c>Submitted=0,
/// Forwarded=1, Attempted=2, Skipped=2, Delivered=3, Failed=3, Parked=3,
/// Discarded=3</c>. Terminal statuses share rank 3 and are mutually exclusive
/// — an attempt to upsert e.g. <c>Delivered</c> over an existing <c>Parked</c>
/// row is a no-op.
/// </para>
/// </remarks>
public interface ISiteCallAuditRepository
{
/// <summary>
/// Inserts <paramref name="siteCall"/> if no row with the same
/// <see cref="SiteCall.TrackedOperationId"/> exists; otherwise updates the
/// existing row IF AND ONLY IF the incoming status' rank strictly exceeds
/// the stored status' rank. Out-of-order / duplicate updates are silently
/// dropped (monotonic forward-only progression).
/// </summary>
Task UpsertAsync(SiteCall siteCall, CancellationToken ct = default);
/// <summary>
/// Returns the row for the given id, or <c>null</c> if none exists.
/// </summary>
Task<SiteCall?> GetAsync(TrackedOperationId id, CancellationToken ct = default);
/// <summary>
/// Returns up to <see cref="SiteCallPaging.PageSize"/> rows matching
/// <paramref name="filter"/>, ordered by <c>(CreatedAtUtc DESC,
/// TrackedOperationId DESC)</c>. Use keyset paging via
/// <see cref="SiteCallPaging.AfterCreatedAtUtc"/> + <see cref="SiteCallPaging.AfterId"/>
/// to fetch subsequent pages.
/// </summary>
Task<IReadOnlyList<SiteCall>> QueryAsync(
SiteCallQueryFilter filter,
SiteCallPaging paging,
CancellationToken ct = default);
/// <summary>
/// Deletes terminal rows whose <see cref="SiteCall.TerminalAtUtc"/> is
/// strictly older than <paramref name="olderThanUtc"/>. Non-terminal rows
/// (TerminalAtUtc IS NULL) are NEVER purged. Returns the number of rows
/// deleted.
/// </summary>
Task<int> PurgeTerminalAsync(DateTime olderThanUtc, CancellationToken ct = default);
}

View File

@@ -0,0 +1,93 @@
using ScadaLink.Commons.Types;
namespace ScadaLink.Commons.Interfaces.Services;
/// <summary>
/// Audit Log #23 (M3 Bundle E — Tasks E4/E5): site-side hook the
/// store-and-forward retry loop invokes after every cached-call attempt and
/// at terminal-state transitions, so the audit pipeline can emit
/// <c>ApiCallCached</c>/<c>DbWriteCached</c> per-attempt rows and the
/// <c>CachedResolve</c> terminal row under the original
/// <see cref="TrackedOperationId"/>.
/// </summary>
/// <remarks>
/// <para>
/// The interface deliberately uses <see cref="CachedCallAttemptOutcome"/>
/// rather than <see cref="ScadaLink.Commons.Types.Enums.AuditStatus"/> so the
/// S&amp;F project does not need to depend on the audit vocabulary — the
/// bridge living in <c>ScadaLink.AuditLog</c> maps the outcome to the right
/// audit kind + status when materialising the <c>CachedCallTelemetry</c>
/// packet.
/// </para>
/// <para>
/// <b>Best-effort contract (alog.md §7):</b> implementations MUST swallow
/// internal failures rather than propagating to the S&amp;F service — a
/// thrown observer must not be misclassified as a transient delivery
/// failure and must not corrupt the retry-count bookkeeping.
/// </para>
/// </remarks>
public interface ICachedCallLifecycleObserver
{
/// <summary>
/// Called by the store-and-forward retry loop after every cached-call
/// delivery attempt. Receives the message's TrackedOperationId-bearing id,
/// the per-category channel discriminator, retry-count + last-error
/// context, and whether the outcome reached a terminal state.
/// </summary>
Task OnAttemptCompletedAsync(CachedCallAttemptContext context, CancellationToken ct = default);
}
/// <summary>
/// Per-attempt context handed to <see cref="ICachedCallLifecycleObserver"/>.
/// </summary>
/// <param name="TrackedOperationId">
/// Tracking id parsed from the underlying <c>StoreAndForwardMessage.Id</c>.
/// </param>
/// <param name="Channel">
/// Trust-boundary channel string — <c>"ApiOutbound"</c> for ExternalSystem
/// cached calls, <c>"DbOutbound"</c> for cached DB writes.
/// </param>
/// <param name="Target">Human-readable target (system name / DB connection).</param>
/// <param name="SourceSite">Site id that submitted the cached call.</param>
/// <param name="Outcome">Per-attempt outcome.</param>
/// <param name="RetryCount">Number of retries performed so far (S&amp;F bookkeeping).</param>
/// <param name="LastError">Most recent error message (null on success).</param>
/// <param name="HttpStatus">Most recent HTTP status (null when not applicable).</param>
/// <param name="CreatedAtUtc">When the underlying S&amp;F message was first enqueued.</param>
/// <param name="OccurredAtUtc">When this attempt completed.</param>
/// <param name="DurationMs">Duration of the attempt in milliseconds (null when not measured).</param>
/// <param name="SourceInstanceId">Originating instance, when known.</param>
public sealed record CachedCallAttemptContext(
TrackedOperationId TrackedOperationId,
string Channel,
string Target,
string SourceSite,
CachedCallAttemptOutcome Outcome,
int RetryCount,
string? LastError,
int? HttpStatus,
DateTime CreatedAtUtc,
DateTime OccurredAtUtc,
int? DurationMs,
string? SourceInstanceId);
/// <summary>
/// Coarse outcome of one cached-call delivery attempt, observed from inside
/// the store-and-forward retry loop. The audit bridge maps this to the
/// <c>ApiCallCached</c>/<c>DbWriteCached</c> Attempted row and, when terminal,
/// the corresponding <c>CachedResolve</c> row.
/// </summary>
public enum CachedCallAttemptOutcome
{
/// <summary>Attempt delivered successfully — terminal Delivered state.</summary>
Delivered,
/// <summary>Attempt failed transiently; another retry will follow.</summary>
TransientFailure,
/// <summary>Attempt returned permanent failure — terminal Parked state (S&amp;F semantics).</summary>
PermanentFailure,
/// <summary>Retry budget exhausted — terminal Parked state.</summary>
ParkedMaxRetries,
}

View File

@@ -0,0 +1,34 @@
using ScadaLink.Commons.Messages.Integration;
namespace ScadaLink.Commons.Interfaces.Services;
/// <summary>
/// Site-side fan-out abstraction for cached-call lifecycle telemetry
/// (Audit Log #23 / M3). One <see cref="CachedCallTelemetry"/> packet carries
/// both an audit row and an operational <c>SiteCalls</c> upsert; the
/// implementation routes the audit half through <see cref="IAuditWriter"/>
/// and the operational half through the site-local tracking SQLite store.
/// </summary>
/// <remarks>
/// <para>
/// Defined in Commons so the script runtime (and the StoreAndForward retry
/// loop, Bundle E4) can take a dependency on the abstraction rather than on
/// the concrete forwarder living inside <c>ScadaLink.AuditLog</c> — the
/// existing dependency arrow runs from <c>SiteRuntime</c> to Commons, not to
/// AuditLog.
/// </para>
/// <para>
/// <b>Best-effort contract (alog.md §7):</b> implementations MUST swallow
/// internal failures rather than propagating to the calling script.
/// </para>
/// </remarks>
public interface ICachedCallTelemetryForwarder
{
/// <summary>
/// Fan one combined-telemetry packet out to the audit writer and the
/// tracking store. Best-effort — failures on either half are logged and
/// swallowed; the returned Task completes when both halves have been
/// attempted.
/// </summary>
Task ForwardAsync(CachedCallTelemetry telemetry, CancellationToken ct = default);
}

View File

@@ -1,4 +1,5 @@
using System.Data.Common;
using ScadaLink.Commons.Types;
namespace ScadaLink.Commons.Interfaces.Services;
@@ -20,10 +21,19 @@ public interface IDatabaseGateway
/// <summary>
/// Submits a SQL write to the store-and-forward engine for reliable delivery.
/// </summary>
/// <param name="trackedOperationId">
/// Audit Log #23 (M3): caller-supplied tracking id used as the
/// store-and-forward message id so the S&amp;F retry loop can read it
/// back via <c>StoreAndForwardMessage.Id</c> and emit per-attempt /
/// terminal cached-write telemetry under the same id. Defaults to
/// <c>null</c> — when omitted the S&amp;F engine mints a fresh GUID and no
/// M3 telemetry is correlated (pre-M3 caller behaviour).
/// </param>
Task CachedWriteAsync(
string connectionName,
string sql,
IReadOnlyDictionary<string, object?>? parameters = null,
string? originInstanceName = null,
CancellationToken cancellationToken = default);
CancellationToken cancellationToken = default,
TrackedOperationId? trackedOperationId = null);
}

View File

@@ -21,12 +21,22 @@ public interface IExternalSystemClient
/// Attempt immediate delivery; on transient failure, hand to S&amp;F engine.
/// Permanent failures returned to caller.
/// </summary>
/// <param name="trackedOperationId">
/// Audit Log #23 (M3): caller-supplied tracking id used as the
/// store-and-forward message id so the S&amp;F retry loop can read it
/// back via <c>StoreAndForwardMessage.Id</c> and emit per-attempt /
/// terminal cached-call telemetry under the same id. Defaults to
/// <c>null</c> — when omitted the S&amp;F engine mints a fresh GUID and no
/// M3 telemetry is correlated (the legacy behaviour pre-M3 callers rely
/// on).
/// </param>
Task<ExternalCallResult> CachedCallAsync(
string systemName,
string methodName,
IReadOnlyDictionary<string, object?>? parameters = null,
string? originInstanceName = null,
CancellationToken cancellationToken = default);
CancellationToken cancellationToken = default,
TrackedOperationId? trackedOperationId = null);
}
/// <summary>

View File

@@ -0,0 +1,30 @@
using ScadaLink.Commons.Entities.Audit;
namespace ScadaLink.Commons.Messages.Audit;
/// <summary>
/// Akka message sent to the central <c>AuditLogIngestActor</c> (Audit Log #23 M3
/// Bundle D dual-write transaction) carrying a batch of combined audit +
/// site-call telemetry packets decoded by the <c>SiteStreamGrpcServer</c> from a
/// site's <c>IngestCachedTelemetry</c> gRPC RPC. For each entry the actor writes
/// the <see cref="AuditEvent"/> row AND the <see cref="SiteCall"/> upsert inside
/// a single MS SQL transaction — both succeed or both roll back, so the audit
/// and operational mirrors never drift mid-row.
/// </summary>
/// <remarks>
/// Lives in <c>ScadaLink.Commons</c> for the same reason as
/// <c>IngestAuditEventsCommand</c>: the gRPC server in
/// <c>ScadaLink.Communication</c> constructs it and <c>ScadaLink.AuditLog</c>
/// already references Communication. Putting the message in Commons avoids a
/// project-reference cycle.
/// </remarks>
public sealed record IngestCachedTelemetryCommand(IReadOnlyList<CachedTelemetryEntry> Entries);
/// <summary>
/// One lifecycle event of a cached call: the <see cref="AuditEvent"/> to insert
/// (idempotent on <see cref="AuditEvent.EventId"/>) plus the
/// <see cref="SiteCall"/> to upsert (monotonic on
/// <see cref="SiteCall.TrackedOperationId"/>). The two rows are paired so the
/// central dual-write transaction can commit them atomically.
/// </summary>
public sealed record CachedTelemetryEntry(AuditEvent Audit, SiteCall SiteCall);

View File

@@ -0,0 +1,10 @@
namespace ScadaLink.Commons.Messages.Audit;
/// <summary>
/// Reply from the central <c>AuditLogIngestActor</c> for an
/// <see cref="IngestCachedTelemetryCommand"/>. <see cref="AcceptedEventIds"/>
/// lists every entry whose dual-write transaction (AuditLog INSERT + SiteCalls
/// UPSERT) committed; entries whose transaction rolled back are absent so the
/// site can leave the row Pending and retry on the next drain.
/// </summary>
public sealed record IngestCachedTelemetryReply(IReadOnlyList<Guid> AcceptedEventIds);

View File

@@ -0,0 +1,19 @@
using ScadaLink.Commons.Entities.Audit;
namespace ScadaLink.Commons.Messages.Audit;
/// <summary>
/// Akka message sent to the central <c>SiteCallAuditActor</c> (Site Call Audit
/// #22, Audit Log #23 M3 Bundle C) carrying one <see cref="SiteCall"/> row to
/// be persisted via <c>ISiteCallAuditRepository.UpsertAsync</c>. The repository
/// performs an insert-if-not-exists then monotonic update — duplicate gRPC
/// packets and reconciliation pulls can both feed the actor without rolling
/// state back.
/// </summary>
/// <remarks>
/// Lives in <c>ScadaLink.Commons</c> rather than <c>ScadaLink.SiteCallAudit</c>
/// so the gRPC server in <c>ScadaLink.Communication</c> can construct it
/// without taking a project reference on the actor's host project (Bundle D
/// adds the IngestCachedTelemetry RPC that will Tell this command).
/// </remarks>
public sealed record UpsertSiteCallCommand(SiteCall SiteCall);

View File

@@ -0,0 +1,14 @@
using ScadaLink.Commons.Types;
namespace ScadaLink.Commons.Messages.Audit;
/// <summary>
/// Reply from the central <c>SiteCallAuditActor</c> for an
/// <see cref="UpsertSiteCallCommand"/>. <see cref="Accepted"/> is <c>true</c>
/// when the upsert reached the repository without throwing (including the
/// monotonic-no-op case where the stored status' rank wins) and <c>false</c>
/// when persistence raised an exception. The actor itself stays alive in
/// either case — audit-write failures must NEVER abort the user-facing action
/// (Audit Log #23 §13).
/// </summary>
public sealed record UpsertSiteCallReply(TrackedOperationId TrackedOperationId, bool Accepted);

View File

@@ -0,0 +1,34 @@
using ScadaLink.Commons.Entities.Audit;
using ScadaLink.Commons.Types;
namespace ScadaLink.Commons.Messages.Integration;
/// <summary>
/// Combined audit + operational telemetry packet for cached outbound calls
/// (Audit Log #23 / M3). The site emits one packet per lifecycle event
/// — <c>Submit</c> (Audit kind <c>CachedSubmit</c>), per-attempt
/// <c>ApiCallCached</c>/<c>DbWriteCached</c>, terminal <c>CachedResolve</c> —
/// and central writes the <see cref="AuditEvent"/> row plus the
/// <see cref="SiteCallOperational"/> upsert in one MS SQL transaction. Two
/// payload shapes ride on a single wire message so the same on-the-wire batch
/// can carry mixed lifecycle events without the central dual-write needing a
/// second RPC for the operational state.
/// </summary>
/// <remarks>
/// <para>
/// Both inner records carry the same <c>TrackedOperationId</c> — the
/// idempotency key end-to-end. The <see cref="AuditEvent.CorrelationId"/>
/// pattern (used by Audit Log #23 to thread cached-call rows together) is
/// honoured by the site emitter; the packet itself is shape-only and makes no
/// independent correlation guarantees.
/// </para>
/// <para>
/// Additive-only per Commons REQ-COM-5a (M2 reviewer note) — this is a new
/// message, not a rename of any existing M2 envelope.
/// </para>
/// </remarks>
/// <param name="Audit">The Audit Log #23 row to insert at central.</param>
/// <param name="Operational">The <c>SiteCalls</c> upsert mirroring this lifecycle event.</param>
public sealed record CachedCallTelemetry(
AuditEvent Audit,
SiteCallOperational Operational);

View File

@@ -0,0 +1,15 @@
namespace ScadaLink.Commons.Types.Audit;
/// <summary>
/// Keyset paging cursor for
/// <see cref="ScadaLink.Commons.Interfaces.Repositories.ISiteCallAuditRepository.QueryAsync"/>.
/// The repository orders by <c>(CreatedAtUtc DESC, TrackedOperationId DESC)</c> — newest
/// calls first, with the strong-typed id breaking ties when two calls share an exact
/// <c>CreatedAtUtc</c>. Callers pass the last row of the previous page back as
/// <see cref="AfterCreatedAtUtc"/> + <see cref="AfterId"/> to fetch the next page.
/// Both must be non-null together, or both null (first page).
/// </summary>
public sealed record SiteCallPaging(
int PageSize,
DateTime? AfterCreatedAtUtc = null,
TrackedOperationId? AfterId = null);

View File

@@ -0,0 +1,21 @@
namespace ScadaLink.Commons.Types.Audit;
/// <summary>
/// Filter predicate for <see cref="ScadaLink.Commons.Interfaces.Repositories.ISiteCallAuditRepository.QueryAsync"/>.
/// Any field left <c>null</c> means "do not constrain on that column". Time bounds
/// are half-open in the spec sense — <see cref="FromUtc"/> is inclusive and
/// <see cref="ToUtc"/> is inclusive of the upper bound; the repository SQL uses
/// <c>&gt;=</c> / <c>&lt;=</c> respectively. All filter fields are AND-combined.
/// </summary>
/// <remarks>
/// Channel / Status / SourceSite / Target are matched as exact strings — the
/// underlying columns are bounded ASCII (varchar) and the Central UI Site Calls
/// page exposes them as drop-down filters, not free-text search.
/// </remarks>
public sealed record SiteCallQueryFilter(
string? Channel = null,
string? SourceSite = null,
string? Status = null,
string? Target = null,
DateTime? FromUtc = null,
DateTime? ToUtc = null);

View File

@@ -0,0 +1,46 @@
namespace ScadaLink.Commons.Types;
/// <summary>
/// Operational state of one cached call as seen by the site, carried on the
/// combined <c>CachedCallTelemetry</c> packet (Audit Log #23 / M3) and persisted
/// at central as the <c>SiteCalls</c> row mirroring the call's status.
/// </summary>
/// <remarks>
/// <para>
/// One row per <see cref="TrackedOperationId"/> at central; ingest is
/// insert-if-not-exists then upsert-on-newer-status (monotonic — never rolls
/// back). The site remains the source of truth — this record is the
/// "eventually-consistent mirror" the central UI's Site Calls page reads.
/// </para>
/// </remarks>
/// <param name="TrackedOperationId">Idempotency key shared with the audit row.</param>
/// <param name="Channel">
/// Trust-boundary channel — <c>"ApiOutbound"</c> or <c>"DbOutbound"</c>. String form
/// (not the <see cref="ScadaLink.Commons.Types.Enums.AuditChannel"/> enum) so the
/// record serialises identically across SQL / gRPC / JSON boundaries.
/// </param>
/// <param name="Target">Human-readable target (e.g. <c>"ERP.GetOrder"</c>).</param>
/// <param name="SourceSite">Site id that submitted the cached call.</param>
/// <param name="Status">
/// Lifecycle status — string form of <see cref="ScadaLink.Commons.Types.Enums.AuditStatus"/>:
/// <c>Submitted</c>, <c>Retrying</c>, <c>Attempted</c>, <c>Delivered</c>,
/// <c>Failed</c>, <c>Parked</c>, <c>Discarded</c>.
/// </param>
/// <param name="RetryCount">Number of dispatch attempts so far; 0 prior to first attempt.</param>
/// <param name="LastError">Most recent error message; null when no failures have occurred.</param>
/// <param name="HttpStatus">Most recent HTTP status code (API calls only); null otherwise.</param>
/// <param name="CreatedAtUtc">UTC timestamp the cached call was first submitted.</param>
/// <param name="UpdatedAtUtc">UTC timestamp of the latest status mutation.</param>
/// <param name="TerminalAtUtc">UTC timestamp the row reached a terminal status; null while still active.</param>
public sealed record SiteCallOperational(
TrackedOperationId TrackedOperationId,
string Channel,
string Target,
string SourceSite,
string Status,
int RetryCount,
string? LastError,
int? HttpStatus,
DateTime CreatedAtUtc,
DateTime UpdatedAtUtc,
DateTime? TerminalAtUtc);

View File

@@ -0,0 +1,53 @@
namespace ScadaLink.Commons.Types;
/// <summary>
/// Strongly-typed identifier for a cached outbound operation
/// (<c>ExternalSystem.CachedCall</c> / <c>Database.CachedWrite</c>) — the unified
/// tracking handle introduced by Audit Log #23 (M3). The same id is the
/// idempotency key end-to-end: it is stamped on every <c>AuditLog</c> row
/// produced for the operation's lifecycle (CachedSubmit → ApiCallCached /
/// DbWriteCached × N attempts → CachedResolve) and is the PK on the central
/// <c>SiteCalls</c> row that mirrors the operation's operational state.
/// </summary>
/// <remarks>
/// <para>
/// The struct wraps a <see cref="Guid"/> so it serialises identically to a
/// 36-character "D"-format string anywhere the existing GUID conventions are
/// used (gRPC strings, JSON, SQL TEXT columns). <see cref="ToString"/> returns
/// the lower-case 8-4-4-4-12 form unconditionally; never the brace- / parens-
/// wrapped variants — central ingest parses with <see cref="Guid.Parse"/>, which
/// is format-tolerant but the wire shape is fixed for log readability.
/// </para>
/// </remarks>
public readonly record struct TrackedOperationId(Guid Value)
{
/// <summary>Mint a fresh id at the call site (script-thread safe).</summary>
public static TrackedOperationId New() => new(Guid.NewGuid());
/// <summary>
/// Parse a serialised id back into the strong type. Throws when the input
/// is not a valid GUID — callers crossing untrusted boundaries should use
/// <see cref="TryParse"/> instead.
/// </summary>
public static TrackedOperationId Parse(string s) => new(Guid.Parse(s));
/// <summary>
/// Attempt to parse a serialised id. Returns <c>false</c> for null, empty
/// or non-GUID input; <paramref name="result"/> is <c>default</c> on
/// failure.
/// </summary>
public static bool TryParse(string? s, out TrackedOperationId result)
{
if (Guid.TryParse(s, out var g))
{
result = new TrackedOperationId(g);
return true;
}
result = default;
return false;
}
/// <inheritdoc/>
public override string ToString() => Value.ToString("D");
}

View File

@@ -0,0 +1,40 @@
namespace ScadaLink.Commons.Types;
/// <summary>
/// Site-local snapshot of a cached operation's tracking state, returned by the
/// <c>Tracking.Status(TrackedOperationId)</c> script API (Audit Log #23 / M3).
/// </summary>
/// <param name="Id">Tracking handle returned by <c>CachedCall</c>/<c>CachedWrite</c>.</param>
/// <param name="Kind">
/// Operation category — <c>"ApiCallCached"</c> or <c>"DbWriteCached"</c> — mirroring
/// the <see cref="ScadaLink.Commons.Types.Enums.AuditKind"/> per-attempt vocabulary.
/// </param>
/// <param name="TargetSummary">
/// Human-readable target (e.g. <c>"ERP.GetOrder"</c> or <c>"WarehouseDb"</c>); may be
/// null for early-lifecycle rows recorded before the target was resolved.
/// </param>
/// <param name="Status">
/// Lifecycle status — one of <c>Submitted</c>, <c>Forwarded</c>, <c>Retrying</c>,
/// <c>Attempted</c>, <c>Delivered</c>, <c>Failed</c>, <c>Parked</c>, <c>Discarded</c>.
/// </param>
/// <param name="RetryCount">Number of attempts made; 0 prior to first dispatch.</param>
/// <param name="LastError">Most recent error message; null while non-terminal-and-no-failures.</param>
/// <param name="HttpStatus">Most recent HTTP status code where applicable; null otherwise.</param>
/// <param name="CreatedAtUtc">UTC timestamp the tracking row was first recorded.</param>
/// <param name="UpdatedAtUtc">UTC timestamp of the latest status mutation.</param>
/// <param name="TerminalAtUtc">UTC timestamp the row reached a terminal status; null while still active.</param>
/// <param name="SourceInstanceId">Instance id that issued the cached call, when known.</param>
/// <param name="SourceScript">Script that issued the cached call, when known.</param>
public sealed record TrackingStatusSnapshot(
TrackedOperationId Id,
string Kind,
string? TargetSummary,
string Status,
int RetryCount,
string? LastError,
int? HttpStatus,
DateTime CreatedAtUtc,
DateTime UpdatedAtUtc,
DateTime? TerminalAtUtc,
string? SourceInstanceId,
string? SourceScript);

View File

@@ -6,6 +6,7 @@ using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using ScadaLink.Commons.Entities.Audit;
using ScadaLink.Commons.Messages.Audit;
using ScadaLink.Commons.Types;
using ScadaLink.Commons.Types.Enums;
using GrpcStatus = Grpc.Core.Status;
@@ -298,9 +299,132 @@ public class SiteStreamGrpcServer : SiteStreamService.SiteStreamServiceBase
return ack;
}
/// <summary>
/// Audit Log (#23) M3 site→central combined-telemetry push RPC. Decodes a
/// batch of <see cref="CachedTelemetryPacket"/> entries into matched
/// (AuditEvent, SiteCall) pairs, Asks the central <c>AuditLogIngestActor</c>
/// proxy to persist them in dual-write transactions, and echoes the
/// AuditEvent EventIds that committed back so the site can flip its local
/// rows to <c>Forwarded</c>.
/// </summary>
/// <remarks>
/// Same wiring-incomplete fallback as <see cref="IngestAuditEvents"/>: when
/// the actor proxy has not been set the RPC replies with an empty ack so
/// sites treat the outcome as transient and retry, never a hard fault.
/// </remarks>
public override async Task<IngestAck> IngestCachedTelemetry(
CachedTelemetryBatch request,
ServerCallContext context)
{
if (request.Packets.Count == 0)
{
return new IngestAck();
}
var actor = _auditIngestActor;
if (actor is null)
{
_logger.LogWarning(
"IngestCachedTelemetry received {Count} packets before SetAuditIngestActor was called; returning empty ack.",
request.Packets.Count);
return new IngestAck();
}
var entries = new List<CachedTelemetryEntry>(request.Packets.Count);
foreach (var packet in request.Packets)
{
var auditEvent = MapAuditEventFromDto(packet.AuditEvent);
var siteCall = MapSiteCallFromDto(packet.Operational);
entries.Add(new CachedTelemetryEntry(auditEvent, siteCall));
}
var cmd = new IngestCachedTelemetryCommand(entries);
IngestCachedTelemetryReply reply;
try
{
reply = await actor.Ask<IngestCachedTelemetryReply>(
cmd, AuditIngestAskTimeout, context.CancellationToken);
}
catch (Exception ex)
{
_logger.LogError(ex,
"AuditLogIngestActor Ask failed for combined telemetry batch of {Count} packets; returning empty ack.",
request.Packets.Count);
return new IngestAck();
}
var ack = new IngestAck();
foreach (var id in reply.AcceptedEventIds)
{
ack.AcceptedEventIds.Add(id.ToString());
}
return ack;
}
private static string? NullIfEmpty(string? value) =>
string.IsNullOrEmpty(value) ? null : value;
/// <summary>
/// Inlined audit-event DTO→entity translation, kept in sync with the
/// <see cref="IngestAuditEvents"/> handler above. Extracted to a private
/// helper so the M3 dual-write RPC can reuse it without duplicating yet
/// another copy. The shape still mirrors
/// <c>AuditEventMapper.FromDto</c> in <c>ScadaLink.AuditLog.Telemetry</c>;
/// the two must evolve together (the project-reference cycle that
/// prevents calling the AuditLog mapper directly is documented on
/// <see cref="IngestAuditEvents"/>).
/// </summary>
private static AuditEvent MapAuditEventFromDto(AuditEventDto dto) =>
new()
{
EventId = Guid.Parse(dto.EventId),
OccurredAtUtc = DateTime.SpecifyKind(dto.OccurredAtUtc.ToDateTime(), DateTimeKind.Utc),
IngestedAtUtc = null,
Channel = Enum.Parse<AuditChannel>(dto.Channel),
Kind = Enum.Parse<AuditKind>(dto.Kind),
CorrelationId = NullIfEmpty(dto.CorrelationId) is { } cid ? Guid.Parse(cid) : null,
SourceSiteId = NullIfEmpty(dto.SourceSiteId),
SourceInstanceId = NullIfEmpty(dto.SourceInstanceId),
SourceScript = NullIfEmpty(dto.SourceScript),
Actor = NullIfEmpty(dto.Actor),
Target = NullIfEmpty(dto.Target),
Status = Enum.Parse<AuditStatus>(dto.Status),
HttpStatus = dto.HttpStatus,
DurationMs = dto.DurationMs,
ErrorMessage = NullIfEmpty(dto.ErrorMessage),
ErrorDetail = NullIfEmpty(dto.ErrorDetail),
RequestSummary = NullIfEmpty(dto.RequestSummary),
ResponseSummary = NullIfEmpty(dto.ResponseSummary),
PayloadTruncated = dto.PayloadTruncated,
Extra = NullIfEmpty(dto.Extra),
ForwardState = null,
};
/// <summary>
/// Translates a <see cref="SiteCallOperationalDto"/> into the persistence
/// entity. <see cref="SiteCall.IngestedAtUtc"/> is stamped here as a
/// placeholder; the central ingest actor overwrites it inside the
/// dual-write transaction so the AuditLog and SiteCalls rows share one
/// instant.
/// </summary>
private static SiteCall MapSiteCallFromDto(SiteCallOperationalDto dto) => new()
{
TrackedOperationId = TrackedOperationId.Parse(dto.TrackedOperationId),
Channel = dto.Channel,
Target = dto.Target,
SourceSite = dto.SourceSite,
Status = dto.Status,
RetryCount = dto.RetryCount,
LastError = string.IsNullOrEmpty(dto.LastError) ? null : dto.LastError,
HttpStatus = dto.HttpStatus,
CreatedAtUtc = DateTime.SpecifyKind(dto.CreatedAtUtc.ToDateTime(), DateTimeKind.Utc),
UpdatedAtUtc = DateTime.SpecifyKind(dto.UpdatedAtUtc.ToDateTime(), DateTimeKind.Utc),
TerminalAtUtc = dto.TerminalAtUtc is null
? null
: DateTime.SpecifyKind(dto.TerminalAtUtc.ToDateTime(), DateTimeKind.Utc),
IngestedAtUtc = DateTime.UtcNow, // overwritten by AuditLogIngestActor
};
/// <summary>
/// Tracks a single active stream so cleanup only removes its own entry.
/// </summary>

View File

@@ -8,6 +8,7 @@ import "google/protobuf/wrappers.proto"; // Int32Value
service SiteStreamService {
rpc SubscribeInstance(InstanceStreamRequest) returns (stream SiteStreamEvent);
rpc IngestAuditEvents(AuditEventBatch) returns (IngestAck);
rpc IngestCachedTelemetry(CachedTelemetryBatch) returns (IngestAck);
}
message InstanceStreamRequest {
@@ -93,3 +94,28 @@ message AuditEventDto {
message AuditEventBatch { repeated AuditEventDto events = 1; }
message IngestAck { repeated string accepted_event_ids = 1; }
// Audit Log (#23) M3 cached-call combined telemetry: a single packet carries
// both the AuditEvent row to insert and the SiteCalls operational-state upsert
// for one lifecycle event of a cached outbound call. Central writes both rows
// in one MS SQL transaction so the audit and operational mirrors never drift.
message SiteCallOperationalDto {
string tracked_operation_id = 1; // GUID string ("D" format)
string channel = 2; // "ApiOutbound" | "DbOutbound"
string target = 3;
string source_site = 4;
string status = 5; // AuditStatus name
int32 retry_count = 6;
string last_error = 7; // empty when null
google.protobuf.Int32Value http_status = 8;
google.protobuf.Timestamp created_at_utc = 9;
google.protobuf.Timestamp updated_at_utc = 10;
google.protobuf.Timestamp terminal_at_utc = 11; // absent when not terminal
}
message CachedTelemetryPacket {
AuditEventDto audit_event = 1;
SiteCallOperationalDto operational = 2;
}
message CachedTelemetryBatch { repeated CachedTelemetryPacket packets = 1; }

File diff suppressed because it is too large Load Diff

View File

@@ -53,6 +53,8 @@ namespace ScadaLink.Communication.Grpc {
static readonly grpc::Marshaller<global::ScadaLink.Communication.Grpc.AuditEventBatch> __Marshaller_sitestream_AuditEventBatch = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::ScadaLink.Communication.Grpc.AuditEventBatch.Parser));
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
static readonly grpc::Marshaller<global::ScadaLink.Communication.Grpc.IngestAck> __Marshaller_sitestream_IngestAck = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::ScadaLink.Communication.Grpc.IngestAck.Parser));
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
static readonly grpc::Marshaller<global::ScadaLink.Communication.Grpc.CachedTelemetryBatch> __Marshaller_sitestream_CachedTelemetryBatch = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::ScadaLink.Communication.Grpc.CachedTelemetryBatch.Parser));
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
static readonly grpc::Method<global::ScadaLink.Communication.Grpc.InstanceStreamRequest, global::ScadaLink.Communication.Grpc.SiteStreamEvent> __Method_SubscribeInstance = new grpc::Method<global::ScadaLink.Communication.Grpc.InstanceStreamRequest, global::ScadaLink.Communication.Grpc.SiteStreamEvent>(
@@ -70,6 +72,14 @@ namespace ScadaLink.Communication.Grpc {
__Marshaller_sitestream_AuditEventBatch,
__Marshaller_sitestream_IngestAck);
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
static readonly grpc::Method<global::ScadaLink.Communication.Grpc.CachedTelemetryBatch, global::ScadaLink.Communication.Grpc.IngestAck> __Method_IngestCachedTelemetry = new grpc::Method<global::ScadaLink.Communication.Grpc.CachedTelemetryBatch, global::ScadaLink.Communication.Grpc.IngestAck>(
grpc::MethodType.Unary,
__ServiceName,
"IngestCachedTelemetry",
__Marshaller_sitestream_CachedTelemetryBatch,
__Marshaller_sitestream_IngestAck);
/// <summary>Service descriptor</summary>
public static global::Google.Protobuf.Reflection.ServiceDescriptor Descriptor
{
@@ -92,6 +102,12 @@ namespace ScadaLink.Communication.Grpc {
throw new grpc::RpcException(new grpc::Status(grpc::StatusCode.Unimplemented, ""));
}
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
public virtual global::System.Threading.Tasks.Task<global::ScadaLink.Communication.Grpc.IngestAck> IngestCachedTelemetry(global::ScadaLink.Communication.Grpc.CachedTelemetryBatch request, grpc::ServerCallContext context)
{
throw new grpc::RpcException(new grpc::Status(grpc::StatusCode.Unimplemented, ""));
}
}
/// <summary>Client for SiteStreamService</summary>
@@ -151,6 +167,26 @@ namespace ScadaLink.Communication.Grpc {
{
return CallInvoker.AsyncUnaryCall(__Method_IngestAuditEvents, null, options, request);
}
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
public virtual global::ScadaLink.Communication.Grpc.IngestAck IngestCachedTelemetry(global::ScadaLink.Communication.Grpc.CachedTelemetryBatch request, grpc::Metadata headers = null, global::System.DateTime? deadline = null, global::System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken))
{
return IngestCachedTelemetry(request, new grpc::CallOptions(headers, deadline, cancellationToken));
}
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
public virtual global::ScadaLink.Communication.Grpc.IngestAck IngestCachedTelemetry(global::ScadaLink.Communication.Grpc.CachedTelemetryBatch request, grpc::CallOptions options)
{
return CallInvoker.BlockingUnaryCall(__Method_IngestCachedTelemetry, null, options, request);
}
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
public virtual grpc::AsyncUnaryCall<global::ScadaLink.Communication.Grpc.IngestAck> IngestCachedTelemetryAsync(global::ScadaLink.Communication.Grpc.CachedTelemetryBatch request, grpc::Metadata headers = null, global::System.DateTime? deadline = null, global::System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken))
{
return IngestCachedTelemetryAsync(request, new grpc::CallOptions(headers, deadline, cancellationToken));
}
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
public virtual grpc::AsyncUnaryCall<global::ScadaLink.Communication.Grpc.IngestAck> IngestCachedTelemetryAsync(global::ScadaLink.Communication.Grpc.CachedTelemetryBatch request, grpc::CallOptions options)
{
return CallInvoker.AsyncUnaryCall(__Method_IngestCachedTelemetry, null, options, request);
}
/// <summary>Creates a new instance of client from given <c>ClientBaseConfiguration</c>.</summary>
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
protected override SiteStreamServiceClient NewInstance(ClientBaseConfiguration configuration)
@@ -166,7 +202,8 @@ namespace ScadaLink.Communication.Grpc {
{
return grpc::ServerServiceDefinition.CreateBuilder()
.AddMethod(__Method_SubscribeInstance, serviceImpl.SubscribeInstance)
.AddMethod(__Method_IngestAuditEvents, serviceImpl.IngestAuditEvents).Build();
.AddMethod(__Method_IngestAuditEvents, serviceImpl.IngestAuditEvents)
.AddMethod(__Method_IngestCachedTelemetry, serviceImpl.IngestCachedTelemetry).Build();
}
/// <summary>Register service method with a service binder with or without implementation. Useful when customizing the service binding logic.
@@ -178,6 +215,7 @@ namespace ScadaLink.Communication.Grpc {
{
serviceBinder.AddMethod(__Method_SubscribeInstance, serviceImpl == null ? null : new grpc::ServerStreamingServerMethod<global::ScadaLink.Communication.Grpc.InstanceStreamRequest, global::ScadaLink.Communication.Grpc.SiteStreamEvent>(serviceImpl.SubscribeInstance));
serviceBinder.AddMethod(__Method_IngestAuditEvents, serviceImpl == null ? null : new grpc::UnaryServerMethod<global::ScadaLink.Communication.Grpc.AuditEventBatch, global::ScadaLink.Communication.Grpc.IngestAck>(serviceImpl.IngestAuditEvents));
serviceBinder.AddMethod(__Method_IngestCachedTelemetry, serviceImpl == null ? null : new grpc::UnaryServerMethod<global::ScadaLink.Communication.Grpc.CachedTelemetryBatch, global::ScadaLink.Communication.Grpc.IngestAck>(serviceImpl.IngestCachedTelemetry));
}
}

View File

@@ -0,0 +1,75 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using ScadaLink.Commons.Entities.Audit;
using ScadaLink.Commons.Types;
namespace ScadaLink.ConfigurationDatabase.Configurations;
/// <summary>
/// Maps the <see cref="SiteCall"/> record to the central <c>SiteCalls</c> table
/// (Site Call Audit #22, Audit Log #23 M3 Bundle B). Operational state — NOT audit —
/// so the table is non-partitioned, standard <c>[PRIMARY]</c> filegroup, no DB-role
/// restriction. Two named indexes back the Central UI's "from this site" and
/// "in this status" queries.
/// </summary>
public class SiteCallEntityTypeConfiguration : IEntityTypeConfiguration<SiteCall>
{
public void Configure(EntityTypeBuilder<SiteCall> builder)
{
builder.ToTable("SiteCalls");
// PK is the strong-typed TrackedOperationId. Stored as varchar(36) by converting
// through the canonical "D"-format GUID string. Going through the string surface
// (rather than uniqueidentifier) keeps the column shape identical to how the id
// is serialised on the wire (gRPC strings, SQLite TEXT on the site) — one
// consistent format everywhere makes operational debugging far easier than
// mixing a uniqueidentifier central column with TEXT site columns.
builder.HasKey(s => s.TrackedOperationId);
builder.Property(s => s.TrackedOperationId)
.HasConversion(
id => id.Value.ToString("D"),
s => new TrackedOperationId(Guid.Parse(s)))
.HasMaxLength(36)
.IsUnicode(false)
.IsRequired();
// Enum-as-string columns: bounded varchar, ASCII.
builder.Property(s => s.Channel)
.HasMaxLength(32)
.IsUnicode(false)
.IsRequired();
builder.Property(s => s.Status)
.HasMaxLength(32)
.IsUnicode(false)
.IsRequired();
builder.Property(s => s.SourceSite)
.HasMaxLength(64)
.IsUnicode(false)
.IsRequired();
builder.Property(s => s.Target)
.HasMaxLength(256)
.IsUnicode(false)
.IsRequired();
// Bounded unicode message column.
builder.Property(s => s.LastError)
.HasMaxLength(1024);
// Indexes — names locked for reconciliation/migration discoverability.
// Source_Created backs "calls from this site" (Central UI Site Calls page,
// filter by SourceSite, newest first).
builder.HasIndex(s => new { s.SourceSite, s.CreatedAtUtc })
.IsDescending(false, true)
.HasDatabaseName("IX_SiteCalls_Source_Created");
// Status_Updated backs "calls in this status" (e.g. parked rows awaiting
// operator action, newest UpdatedAtUtc first).
builder.HasIndex(s => new { s.Status, s.UpdatedAtUtc })
.IsDescending(false, true)
.HasDatabaseName("IX_SiteCalls_Status_Updated");
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,56 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ScadaLink.ConfigurationDatabase.Migrations
{
/// <inheritdoc />
public partial class AddSiteCallsTable : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "SiteCalls",
columns: table => new
{
TrackedOperationId = table.Column<string>(type: "varchar(36)", unicode: false, maxLength: 36, nullable: false),
Channel = table.Column<string>(type: "varchar(32)", unicode: false, maxLength: 32, nullable: false),
Target = table.Column<string>(type: "varchar(256)", unicode: false, maxLength: 256, nullable: false),
SourceSite = table.Column<string>(type: "varchar(64)", unicode: false, maxLength: 64, nullable: false),
Status = table.Column<string>(type: "varchar(32)", unicode: false, maxLength: 32, nullable: false),
RetryCount = table.Column<int>(type: "int", nullable: false),
LastError = table.Column<string>(type: "nvarchar(1024)", maxLength: 1024, nullable: true),
HttpStatus = table.Column<int>(type: "int", nullable: true),
CreatedAtUtc = table.Column<DateTime>(type: "datetime2", nullable: false),
UpdatedAtUtc = table.Column<DateTime>(type: "datetime2", nullable: false),
TerminalAtUtc = table.Column<DateTime>(type: "datetime2", nullable: true),
IngestedAtUtc = table.Column<DateTime>(type: "datetime2", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_SiteCalls", x => x.TrackedOperationId);
});
migrationBuilder.CreateIndex(
name: "IX_SiteCalls_Source_Created",
table: "SiteCalls",
columns: new[] { "SourceSite", "CreatedAtUtc" },
descending: new[] { false, true });
migrationBuilder.CreateIndex(
name: "IX_SiteCalls_Status_Updated",
table: "SiteCalls",
columns: new[] { "Status", "UpdatedAtUtc" },
descending: new[] { false, true });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "SiteCalls");
}
}
}

View File

@@ -212,6 +212,72 @@ namespace ScadaLink.ConfigurationDatabase.Migrations
b.ToTable("AuditLogEntries");
});
modelBuilder.Entity("ScadaLink.Commons.Entities.Audit.SiteCall", b =>
{
b.Property<string>("TrackedOperationId")
.HasMaxLength(36)
.IsUnicode(false)
.HasColumnType("varchar(36)");
b.Property<string>("Channel")
.IsRequired()
.HasMaxLength(32)
.IsUnicode(false)
.HasColumnType("varchar(32)");
b.Property<DateTime>("CreatedAtUtc")
.HasColumnType("datetime2");
b.Property<int?>("HttpStatus")
.HasColumnType("int");
b.Property<DateTime>("IngestedAtUtc")
.HasColumnType("datetime2");
b.Property<string>("LastError")
.HasMaxLength(1024)
.HasColumnType("nvarchar(1024)");
b.Property<int>("RetryCount")
.HasColumnType("int");
b.Property<string>("SourceSite")
.IsRequired()
.HasMaxLength(64)
.IsUnicode(false)
.HasColumnType("varchar(64)");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
.IsUnicode(false)
.HasColumnType("varchar(32)");
b.Property<string>("Target")
.IsRequired()
.HasMaxLength(256)
.IsUnicode(false)
.HasColumnType("varchar(256)");
b.Property<DateTime?>("TerminalAtUtc")
.HasColumnType("datetime2");
b.Property<DateTime>("UpdatedAtUtc")
.HasColumnType("datetime2");
b.HasKey("TrackedOperationId");
b.HasIndex("SourceSite", "CreatedAtUtc")
.IsDescending(false, true)
.HasDatabaseName("IX_SiteCalls_Source_Created");
b.HasIndex("Status", "UpdatedAtUtc")
.IsDescending(false, true)
.HasDatabaseName("IX_SiteCalls_Status_Updated");
b.ToTable("SiteCalls", (string)null);
});
modelBuilder.Entity("ScadaLink.Commons.Entities.Deployment.DeployedConfigSnapshot", b =>
{
b.Property<int>("Id")

View File

@@ -0,0 +1,214 @@
using Microsoft.Data.SqlClient;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using ScadaLink.Commons.Entities.Audit;
using ScadaLink.Commons.Interfaces.Repositories;
using ScadaLink.Commons.Types;
using ScadaLink.Commons.Types.Audit;
namespace ScadaLink.ConfigurationDatabase.Repositories;
/// <summary>
/// EF Core implementation of <see cref="ISiteCallAuditRepository"/>. See the
/// interface for the monotonic-upsert contract; this class adds notes on the
/// data-access strategy used by each method.
/// </summary>
public class SiteCallAuditRepository : ISiteCallAuditRepository
{
// SQL Server duplicate-key error numbers, identical to the AuditLogRepository
// race-fix: 2601 = unique-index violation, 2627 = PK/unique-constraint
// violation. The IF NOT EXISTS … INSERT pattern has a check-then-act window
// and the loser surfaces as one of these; monotonic-upsert semantics demand
// we swallow them.
private const int SqlErrorUniqueIndexViolation = 2601;
private const int SqlErrorPrimaryKeyViolation = 2627;
// Monotonic status ordering. Lower rank wins on tie (same-rank upserts are
// no-ops, including terminal-over-terminal). Spec from Bundle B3 plan:
// Submitted < Forwarded < Attempted == Skipped < Delivered == Failed == Parked == Discarded.
private static readonly Dictionary<string, int> StatusRank = new(StringComparer.Ordinal)
{
["Submitted"] = 0,
["Forwarded"] = 1,
["Attempted"] = 2,
["Skipped"] = 2,
["Delivered"] = 3,
["Failed"] = 3,
["Parked"] = 3,
["Discarded"] = 3,
};
private readonly ScadaLinkDbContext _context;
private readonly ILogger<SiteCallAuditRepository> _logger;
public SiteCallAuditRepository(ScadaLinkDbContext context, ILogger<SiteCallAuditRepository>? logger = null)
{
_context = context ?? throw new ArgumentNullException(nameof(context));
_logger = logger ?? NullLogger<SiteCallAuditRepository>.Instance;
}
/// <summary>
/// Two-step: <c>IF NOT EXISTS INSERT</c> then conditional <c>UPDATE</c> with
/// an inline <c>CASE</c> rank comparison. Both go through
/// <see cref="Microsoft.EntityFrameworkCore.RelationalDatabaseFacadeExtensions.ExecuteSqlInterpolatedAsync"/>
/// so the change tracker is bypassed and the value-converted PK column is
/// written as the canonical "D"-format GUID string. Duplicate-key violations
/// from the insert race are swallowed.
/// </summary>
public async Task UpsertAsync(SiteCall siteCall, CancellationToken ct = default)
{
if (siteCall is null)
{
throw new ArgumentNullException(nameof(siteCall));
}
var idText = siteCall.TrackedOperationId.Value.ToString("D");
var incomingRank = GetRankOrThrow(siteCall.Status);
// 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.
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,
LastError, HttpStatus, CreatedAtUtc, UpdatedAtUtc, TerminalAtUtc, IngestedAtUtc)
VALUES
({idText}, {siteCall.Channel}, {siteCall.Target}, {siteCall.SourceSite}, {siteCall.Status}, {siteCall.RetryCount},
{siteCall.LastError}, {siteCall.HttpStatus}, {siteCall.CreatedAtUtc}, {siteCall.UpdatedAtUtc}, {siteCall.TerminalAtUtc}, {siteCall.IngestedAtUtc});",
ct);
}
catch (SqlException ex) when (
ex.Number == SqlErrorUniqueIndexViolation
|| ex.Number == SqlErrorPrimaryKeyViolation)
{
_logger.LogDebug(
ex,
"SiteCallAuditRepository.UpsertAsync swallowed duplicate-key violation (error {SqlErrorNumber}) for TrackedOperationId {TrackedOperationId}; falling through to monotonic update.",
ex.Number,
idText);
}
// Step 2: monotonic update. The CASE expression maps the stored Status
// 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.
await _context.Database.ExecuteSqlInterpolatedAsync(
$@"UPDATE dbo.SiteCalls
SET Status = {siteCall.Status},
RetryCount = {siteCall.RetryCount},
LastError = {siteCall.LastError},
HttpStatus = {siteCall.HttpStatus},
UpdatedAtUtc = {siteCall.UpdatedAtUtc},
TerminalAtUtc = {siteCall.TerminalAtUtc},
IngestedAtUtc = {siteCall.IngestedAtUtc}
WHERE TrackedOperationId = {idText}
AND {incomingRank} > (CASE Status
WHEN 'Submitted' THEN 0
WHEN 'Forwarded' THEN 1
WHEN 'Attempted' THEN 2
WHEN 'Skipped' THEN 2
WHEN 'Delivered' THEN 3
WHEN 'Failed' THEN 3
WHEN 'Parked' THEN 3
WHEN 'Discarded' THEN 3
ELSE -1
END);",
ct);
}
/// <summary>
/// Single <c>FindAsync</c> against the PK. Returns <c>null</c> for unknown ids.
/// </summary>
public async Task<SiteCall?> GetAsync(TrackedOperationId id, CancellationToken ct = default)
{
return await _context.Set<SiteCall>().FindAsync(new object?[] { id }, ct);
}
/// <summary>
/// Builds a parameterised SQL query against <c>dbo.SiteCalls</c> ordered by
/// <c>(CreatedAtUtc DESC, TrackedOperationId DESC)</c>, with keyset paging.
/// Raw SQL is used here (rather than LINQ) because EF Core 10 cannot
/// translate the lexicographic string comparison against the value-converted
/// <see cref="TrackedOperationId"/> column inside an expression tree — the
/// converter is applied to equality but not to inequality comparisons
/// against the underlying Guid. The keyset tiebreaker is varchar lex order,
/// which is deterministic and gives "no overlap, every row exactly once"
/// paging without depending on Guid byte ordering.
/// </summary>
public async Task<IReadOnlyList<SiteCall>> QueryAsync(
SiteCallQueryFilter filter, SiteCallPaging paging, CancellationToken ct = default)
{
if (filter is null)
{
throw new ArgumentNullException(nameof(filter));
}
if (paging is null)
{
throw new ArgumentNullException(nameof(paging));
}
// FormattableString interpolation parameterises every value (no concatenation)
// so this is injection-safe. EF Core resolves the parameter values, the
// composed sql is shaped to SQL Server's grammar and projected into the
// SiteCall entity via FromSqlInterpolated. The CASE expressions wrap each
// optional predicate so a null filter field degrades to a no-op (matches
// every row) instead of branching at C# level into N variants.
var afterCreated = paging.AfterCreatedAtUtc;
var afterIdString = paging.AfterId?.Value.ToString("D");
var hasCursor = afterCreated is not null && afterIdString is not null;
var fromUtc = filter.FromUtc;
var toUtc = filter.ToUtc;
FormattableString sql = $@"
SELECT TOP ({paging.PageSize})
TrackedOperationId, Channel, Target, SourceSite, Status, RetryCount,
LastError, HttpStatus, CreatedAtUtc, UpdatedAtUtc, TerminalAtUtc, IngestedAtUtc
FROM dbo.SiteCalls
WHERE ({filter.Channel} IS NULL OR Channel = {filter.Channel})
AND ({filter.SourceSite} IS NULL OR SourceSite = {filter.SourceSite})
AND ({filter.Status} IS NULL OR Status = {filter.Status})
AND ({filter.Target} IS NULL OR Target = {filter.Target})
AND ({fromUtc} IS NULL OR CreatedAtUtc >= {fromUtc})
AND ({toUtc} IS NULL OR CreatedAtUtc <= {toUtc})
AND ({(hasCursor ? 1 : 0)} = 0
OR CreatedAtUtc < {afterCreated}
OR (CreatedAtUtc = {afterCreated} AND TrackedOperationId < {afterIdString}))
ORDER BY CreatedAtUtc DESC, TrackedOperationId DESC;";
var rows = await _context.Set<SiteCall>()
.FromSqlInterpolated(sql)
.AsNoTracking()
.ToListAsync(ct);
return rows;
}
/// <summary>
/// Deletes rows whose <see cref="SiteCall.TerminalAtUtc"/> is non-null AND
/// strictly less than <paramref name="olderThanUtc"/>. Non-terminal rows are
/// never touched. Returns the number of rows removed.
/// </summary>
public async Task<int> PurgeTerminalAsync(DateTime olderThanUtc, CancellationToken ct = default)
{
return await _context.Database.ExecuteSqlInterpolatedAsync(
$"DELETE FROM dbo.SiteCalls WHERE TerminalAtUtc IS NOT NULL AND TerminalAtUtc < {olderThanUtc};",
ct);
}
private static int GetRankOrThrow(string status)
{
if (!StatusRank.TryGetValue(status, out var rank))
{
throw new ArgumentException(
$"Unknown SiteCall status '{status}'. Expected one of: {string.Join(", ", StatusRank.Keys)}.",
nameof(status));
}
return rank;
}
}

View File

@@ -85,6 +85,7 @@ public class ScadaLinkDbContext : DbContext, IDataProtectionKeyContext
// Audit
public DbSet<AuditLogEntry> AuditLogEntries => Set<AuditLogEntry>();
public DbSet<AuditEvent> AuditLogs => Set<AuditEvent>();
public DbSet<SiteCall> SiteCalls => Set<SiteCall>();
// Data Protection Keys (for shared ASP.NET Data Protection across nodes)
public DbSet<DataProtectionKey> DataProtectionKeys => Set<DataProtectionKey>();

View File

@@ -47,6 +47,7 @@ public static class ServiceCollectionExtensions
services.AddScoped<INotificationRepository, NotificationRepository>();
services.AddScoped<INotificationOutboxRepository, NotificationOutboxRepository>();
services.AddScoped<IAuditLogRepository, AuditLogRepository>();
services.AddScoped<ISiteCallAuditRepository, SiteCallAuditRepository>();
services.AddScoped<IInboundApiRepository, InboundApiRepository>();
services.AddScoped<IAuditService, AuditService>();
services.AddScoped<IInstanceLocator, InstanceLocator>();

View File

@@ -5,6 +5,7 @@ using Microsoft.Extensions.Logging;
using ScadaLink.Commons.Entities.ExternalSystems;
using ScadaLink.Commons.Interfaces.Repositories;
using ScadaLink.Commons.Interfaces.Services;
using ScadaLink.Commons.Types;
using ScadaLink.Commons.Types.Enums;
using ScadaLink.StoreAndForward;
@@ -71,12 +72,19 @@ public class DatabaseGateway : IDatabaseGateway
/// <summary>
/// Submits a SQL write to the store-and-forward engine for reliable delivery.
/// </summary>
/// <param name="trackedOperationId">
/// Audit Log #23 (M3): used as the S&amp;F message id so the retry loop can
/// recover it via <c>StoreAndForwardMessage.Id</c> and emit per-attempt /
/// terminal cached-write telemetry (Tasks E4/E5). Null preserves the
/// pre-M3 behaviour (S&amp;F mints a random GUID).
/// </param>
public async Task CachedWriteAsync(
string connectionName,
string sql,
IReadOnlyDictionary<string, object?>? parameters = null,
string? originInstanceName = null,
CancellationToken cancellationToken = default)
CancellationToken cancellationToken = default,
TrackedOperationId? trackedOperationId = null)
{
var definition = await ResolveConnectionAsync(connectionName, cancellationToken);
if (definition == null)
@@ -110,7 +118,13 @@ public class DatabaseGateway : IDatabaseGateway
payload,
originInstanceName,
definition.MaxRetries > 0 ? definition.MaxRetries : null,
definition.RetryDelay > TimeSpan.Zero ? definition.RetryDelay : null);
definition.RetryDelay > TimeSpan.Zero ? definition.RetryDelay : null,
// Audit Log #23 (M3): pin the S&F message id to the
// TrackedOperationId so the retry loop (Bundle E Tasks E4/E5) can
// read it back via StoreAndForwardMessage.Id and emit per-attempt +
// terminal cached-write telemetry. Null -> S&F mints its own GUID
// (legacy pre-M3 behaviour).
messageId: trackedOperationId?.ToString());
}
/// <summary>

View File

@@ -7,6 +7,7 @@ using Microsoft.Extensions.Options;
using ScadaLink.Commons.Entities.ExternalSystems;
using ScadaLink.Commons.Interfaces.Repositories;
using ScadaLink.Commons.Interfaces.Services;
using ScadaLink.Commons.Types;
using ScadaLink.Commons.Types.Enums;
using ScadaLink.StoreAndForward;
@@ -72,12 +73,20 @@ public class ExternalSystemClient : IExternalSystemClient
/// <summary>
/// WP-7: CachedCall — attempt immediate, transient failure goes to S&amp;F, permanent returned to script.
/// </summary>
/// <param name="trackedOperationId">
/// Audit Log #23 (M3): used as the S&amp;F message id so the retry loop can
/// recover it from <c>StoreAndForwardMessage.Id</c> and emit per-attempt /
/// terminal cached-call telemetry (Tasks E4/E5). When null the S&amp;F engine
/// mints its own GUID — preserving the pre-M3 behaviour for callers that
/// don't participate in the M3 audit pipeline.
/// </param>
public async Task<ExternalCallResult> CachedCallAsync(
string systemName,
string methodName,
IReadOnlyDictionary<string, object?>? parameters = null,
string? originInstanceName = null,
CancellationToken cancellationToken = default)
CancellationToken cancellationToken = default,
TrackedOperationId? trackedOperationId = null)
{
var (system, method) = await ResolveSystemAndMethodAsync(systemName, methodName, cancellationToken);
if (system == null || method == null)
@@ -129,7 +138,13 @@ public class ExternalSystemClient : IExternalSystemClient
originInstanceName,
system.MaxRetries > 0 ? system.MaxRetries : null,
system.RetryDelay > TimeSpan.Zero ? system.RetryDelay : null,
attemptImmediateDelivery: false);
attemptImmediateDelivery: false,
// Audit Log #23 (M3): pin the S&F message id to the
// TrackedOperationId so the retry loop can read it back via
// StoreAndForwardMessage.Id and emit per-attempt + terminal
// cached-call telemetry (Bundle E Tasks E4/E5). Null -> S&F
// mints its own GUID (legacy pre-M3 behaviour).
messageId: trackedOperationId?.ToString());
return new ExternalCallResult(true, null, null, WasBuffered: true);
}

View File

@@ -342,6 +342,35 @@ akka {{
"AuditLogIngestActor singleton created (gRPC server bound: {GrpcBound})",
grpcServer is not null);
// Site Call Audit (#22) — central singleton mirrors the AuditLogIngest
// and NotificationOutbox patterns. M3's dual-write transaction routes
// SiteCalls upserts through AuditLogIngestActor's own scope-per-message
// ISiteCallAuditRepository resolution, so this singleton is not on the
// M3 happy-path hot path; it exists so future direct-write callers
// (reconciliation puller, central→site Retry/Discard relay, KPI
// projector) Ask through a stable cluster proxy without further wiring.
// Like AuditLogIngestActor, the actor takes the root IServiceProvider
// and creates a fresh scope per message because ISiteCallAuditRepository
// is a scoped EF Core service.
var siteCallAuditLogger = _serviceProvider.GetRequiredService<ILoggerFactory>()
.CreateLogger<ScadaLink.SiteCallAudit.SiteCallAuditActor>();
var siteCallAuditSingletonProps = ClusterSingletonManager.Props(
singletonProps: Props.Create(() => new ScadaLink.SiteCallAudit.SiteCallAuditActor(
_serviceProvider,
siteCallAuditLogger)),
terminationMessage: PoisonPill.Instance,
settings: ClusterSingletonManagerSettings.Create(_actorSystem!)
.WithSingletonName("site-call-audit"));
_actorSystem!.ActorOf(siteCallAuditSingletonProps, "site-call-audit-singleton");
var siteCallAuditProxyProps = ClusterSingletonProxy.Props(
singletonManagerPath: "/user/site-call-audit-singleton",
settings: ClusterSingletonProxySettings.Create(_actorSystem)
.WithSingletonName("site-call-audit"));
_actorSystem.ActorOf(siteCallAuditProxyProps, "site-call-audit-proxy");
_logger.LogInformation("SiteCallAuditActor singleton created");
_logger.LogInformation("Central actors registered. CentralCommunicationActor created.");
}

View File

@@ -16,6 +16,7 @@ using ScadaLink.ManagementService;
using ScadaLink.NotificationOutbox;
using ScadaLink.NotificationService;
using ScadaLink.Security;
using ScadaLink.SiteCallAudit;
using ScadaLink.TemplateEngine;
using Serilog;
@@ -82,6 +83,12 @@ try
// IAuditLogRepository. The site writer chain is still registered (lazy
// singletons) but is never resolved on a central node.
builder.Services.AddAuditLog(builder.Configuration);
// Site Call Audit (#22) — central node owns the SiteCallAuditActor
// singleton (M3 Bundle F). The extension itself currently registers
// nothing — actor Props are constructed inline in AkkaHostedService —
// but the call is here for symmetry with the other audit composition
// roots so future per-actor DI lands without touching Program.cs.
builder.Services.AddSiteCallAudit();
builder.Services.AddTemplateEngine();
builder.Services.AddDeploymentManager();
builder.Services.AddSecurity();

View File

@@ -39,6 +39,7 @@
<ProjectReference Include="../ScadaLink.NotificationService/ScadaLink.NotificationService.csproj" />
<ProjectReference Include="../ScadaLink.NotificationOutbox/ScadaLink.NotificationOutbox.csproj" />
<ProjectReference Include="../ScadaLink.AuditLog/ScadaLink.AuditLog.csproj" />
<ProjectReference Include="../ScadaLink.SiteCallAudit/ScadaLink.SiteCallAudit.csproj" />
<ProjectReference Include="../ScadaLink.CentralUI/ScadaLink.CentralUI.csproj" />
<ProjectReference Include="../ScadaLink.Security/ScadaLink.Security.csproj" />
<ProjectReference Include="../ScadaLink.HealthMonitoring/ScadaLink.HealthMonitoring.csproj" />

View File

@@ -42,6 +42,14 @@ public static class SiteServiceRegistration
var siteDbPath = config["ScadaLink:Database:SiteDbPath"] ?? "site.db";
services.AddSiteRuntime($"Data Source={siteDbPath}");
services.AddDataConnectionLayer();
// Audit Log #23 (M3 Bundle F): adapter that surfaces the site id to
// StoreAndForwardService through DI WITHOUT introducing a
// StoreAndForward → HealthMonitoring project-reference cycle. Must be
// registered BEFORE AddStoreAndForward so the S&F factory resolves a
// non-empty SiteId at construction time (otherwise the S&F service is
// a singleton and the empty-string value would be cached for the
// lifetime of the process).
services.AddSingleton<ScadaLink.StoreAndForward.IStoreAndForwardSiteContext, StoreAndForwardSiteContext>();
services.AddStoreAndForward();
services.AddSiteEventLogging();

View File

@@ -0,0 +1,32 @@
using Microsoft.Extensions.Options;
using ScadaLink.StoreAndForward;
namespace ScadaLink.Host;
/// <summary>
/// Audit Log #23 (M3 Bundle F): Host-side adapter implementing the
/// optional <see cref="IStoreAndForwardSiteContext"/> the Store-and-Forward
/// service consults to stamp cached-call audit telemetry with the site id.
/// </summary>
/// <remarks>
/// Forwards <see cref="NodeOptions.SiteId"/> verbatim — the same value
/// <see cref="SiteIdentityProvider"/> exposes to HealthMonitoring. Defined as
/// a separate adapter (rather than reusing <see cref="SiteIdentityProvider"/>)
/// to avoid pulling HealthMonitoring into the StoreAndForward project's
/// dependency graph, which would create a project-reference cycle.
/// </remarks>
public class StoreAndForwardSiteContext : IStoreAndForwardSiteContext
{
public string SiteId { get; }
public StoreAndForwardSiteContext(IOptions<NodeOptions> nodeOptions)
{
// NodeOptions.SiteId is nullable; SiteServiceRegistration ONLY adds
// this binding on the site role, so a non-null site id is expected
// here. Mirror SiteIdentityProvider's hard fail so a missing site id
// surfaces at composition time rather than at the first cached call.
SiteId = nodeOptions.Value.SiteId
?? throw new InvalidOperationException(
"ScadaLink:Node:SiteId is required for the site role's StoreAndForward wiring.");
}
}

View File

@@ -0,0 +1,31 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<!-- SiteCallAuditActor is an Akka actor (central singleton in Bundle F); Akka is an explicit dependency. -->
<PackageReference Include="Akka" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../ScadaLink.Commons/ScadaLink.Commons.csproj" />
<!-- Site Call Audit (#22) sits alongside Notification Outbox (#21) and Audit Log (#23).
ISiteCallAuditRepository is registered by ScadaLink.ConfigurationDatabase; the
project reference is documented here so the actor's scope-per-message
GetRequiredService<ISiteCallAuditRepository>() compiles. -->
<ProjectReference Include="../ScadaLink.ConfigurationDatabase/ScadaLink.ConfigurationDatabase.csproj" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="ScadaLink.SiteCallAudit.Tests" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,39 @@
using Microsoft.Extensions.DependencyInjection;
namespace ScadaLink.SiteCallAudit;
/// <summary>
/// Composition root for the Site Call Audit (#22) component.
/// </summary>
/// <remarks>
/// <para>
/// M3 Bundle C ships the ingest-only minimum surface (the actor itself); the
/// full DI surface — reconciliation puller, KPI projector, central→site
/// Retry/Discard relay, options + validators — is deferred to a follow-up.
/// </para>
/// <para>
/// The repository (<c>ISiteCallAuditRepository</c>) is registered by
/// <c>ScadaLink.ConfigurationDatabase.ServiceCollectionExtensions.AddConfigurationDatabase</c>,
/// so callers (the Host on the central node) must also call that. The actor's
/// <c>Props</c> are wired up in Host registration (Bundle F); this extension
/// is currently a no-op placeholder kept for symmetry with the AuditLog and
/// NotificationOutbox composition roots — adding it now means consumers can
/// reference the method without re-touching the Host project later.
/// </para>
/// </remarks>
public static class ServiceCollectionExtensions
{
/// <summary>
/// Registers Site Call Audit (#22) services. Currently a no-op
/// placeholder — Bundle F will populate this with the actor's Props
/// factory + options bindings. The method is exposed now so the Host
/// wiring call already exists at the API boundary.
/// </summary>
public static IServiceCollection AddSiteCallAudit(this IServiceCollection services)
{
ArgumentNullException.ThrowIfNull(services);
// Actor props are constructed in Host wiring (Bundle F). This
// extension is a placeholder for future config + DI.
return services;
}
}

View File

@@ -0,0 +1,140 @@
using Akka.Actor;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using ScadaLink.Commons.Interfaces.Repositories;
using ScadaLink.Commons.Messages.Audit;
namespace ScadaLink.SiteCallAudit;
/// <summary>
/// Central singleton for Site Call Audit (#22). Receives
/// <see cref="UpsertSiteCallCommand"/> messages and persists each
/// <see cref="ScadaLink.Commons.Entities.Audit.SiteCall"/> row via
/// <see cref="ISiteCallAuditRepository.UpsertAsync"/> — idempotent monotonic
/// upsert. Out-of-order or duplicate updates are silent no-ops at the
/// repository layer; the actor always replies <see cref="UpsertSiteCallReply"/>
/// with <c>Accepted=true</c> in that case because storage state is consistent
/// and the site is free to consider its packet acked.
/// </summary>
/// <remarks>
/// <para>
/// M3 ships the minimum surface: ingest only. Reconciliation, KPIs, and
/// central→site Retry/Discard relay are deferred (per CLAUDE.md scope
/// discipline — Site Call Audit's KPIs and the Retry/Discard relay land in a
/// follow-up).
/// </para>
/// <para>
/// Per CLAUDE.md "audit-write failure NEVER aborts the user-facing action" —
/// the actor catches every exception from the repository call and replies
/// <c>Accepted=false</c> without rethrowing, so the central singleton stays
/// alive. The <see cref="SupervisorStrategy"/> uses <c>Resume</c> so an
/// unexpected throw before the catch (defence in depth) does not restart the
/// actor and reset in-flight state.
/// </para>
/// <para>
/// Two constructors exist for the same reason as
/// <c>AuditLogIngestActor</c>: production wiring (Bundle F) resolves the
/// scoped EF repository from a fresh DI scope per message because the actor
/// is a long-lived cluster singleton, while tests inject a concrete
/// <see cref="ISiteCallAuditRepository"/> against a per-test MSSQL fixture
/// so the actor exercises the real monotonic upsert SQL end to end.
/// </para>
/// </remarks>
public class SiteCallAuditActor : ReceiveActor
{
private readonly IServiceProvider? _serviceProvider;
private readonly ISiteCallAuditRepository? _injectedRepository;
private readonly ILogger<SiteCallAuditActor> _logger;
/// <summary>
/// Test-mode constructor — injects a concrete repository instance whose
/// lifetime exceeds the test, so the actor reuses the same instance
/// across every message. Used by Bundle C's MSSQL-backed TestKit fixture.
/// </summary>
public SiteCallAuditActor(
ISiteCallAuditRepository repository,
ILogger<SiteCallAuditActor> logger)
{
ArgumentNullException.ThrowIfNull(repository);
ArgumentNullException.ThrowIfNull(logger);
_injectedRepository = repository;
_logger = logger;
ReceiveAsync<UpsertSiteCallCommand>(OnUpsertAsync);
}
/// <summary>
/// Production constructor — resolves <see cref="ISiteCallAuditRepository"/>
/// from a fresh DI scope per message because the repository is a scoped EF
/// Core service registered by <c>AddConfigurationDatabase</c>. The actor
/// itself is a long-lived cluster singleton, so it cannot hold a scope
/// across messages.
/// </summary>
public SiteCallAuditActor(
IServiceProvider serviceProvider,
ILogger<SiteCallAuditActor> logger)
{
ArgumentNullException.ThrowIfNull(serviceProvider);
ArgumentNullException.ThrowIfNull(logger);
_serviceProvider = serviceProvider;
_logger = logger;
ReceiveAsync<UpsertSiteCallCommand>(OnUpsertAsync);
}
/// <summary>
/// Audit-write failures are best-effort by design (CLAUDE.md §Audit): a
/// thrown exception in the upsert pipeline must not crash the actor.
/// Resume keeps the actor's state intact so the next packet is processed
/// against the same repository instance.
/// </summary>
protected override SupervisorStrategy SupervisorStrategy()
{
return new OneForOneStrategy(maxNrOfRetries: 0, withinTimeRange: TimeSpan.Zero, decider:
Akka.Actor.SupervisorStrategy.DefaultDecider);
}
private async Task OnUpsertAsync(UpsertSiteCallCommand cmd)
{
// Sender is captured before the first await — Akka resets Sender
// between message dispatches, so a post-await Tell would go to
// DeadLetters.
var replyTo = Sender;
var id = cmd.SiteCall.TrackedOperationId;
// Scope-per-message mirrors AuditLogIngestActor — production EF
// repository is scoped; the injected-repository mode (tests) skips
// the scope entirely.
IServiceScope? scope = null;
ISiteCallAuditRepository repository;
if (_injectedRepository is not null)
{
repository = _injectedRepository;
}
else
{
scope = _serviceProvider!.CreateScope();
repository = scope.ServiceProvider.GetRequiredService<ISiteCallAuditRepository>();
}
try
{
await repository.UpsertAsync(cmd.SiteCall).ConfigureAwait(false);
replyTo.Tell(new UpsertSiteCallReply(id, Accepted: true));
}
catch (Exception ex)
{
// Per CLAUDE.md: audit-write failure NEVER aborts the user-facing
// action — log and reply Accepted=false; do NOT rethrow (the
// central singleton MUST stay alive).
_logger.LogError(ex, "SiteCallAudit upsert failed for {TrackedOperationId}", id);
replyTo.Tell(new UpsertSiteCallReply(id, Accepted: false));
}
finally
{
scope?.Dispose();
}
}
}

View File

@@ -2,6 +2,7 @@ using Akka.Actor;
using Microsoft.CodeAnalysis.Scripting;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using ScadaLink.Commons.Interfaces;
using ScadaLink.Commons.Interfaces.Services;
using ScadaLink.Commons.Messages.ScriptExecution;
using ScadaLink.Commons.Types;
@@ -105,6 +106,18 @@ public class ScriptExecutionActor : ReceiveActor
// composes the SQLite hot-path + drop-oldest ring); null in tests / hosts
// that haven't called AddAuditLog, which the helper handles as a no-op.
IAuditWriter? auditWriter = null;
// Audit Log #23 (M3 Bundle A — Task A3): site-local tracking store
// backing Tracking.Status(id). Singleton; null in tests / hosts
// that haven't wired the store, which the helper handles by
// throwing on access.
IOperationTrackingStore? operationTrackingStore = null;
// Audit Log #23 (M3 Bundle F — Task F1): site-side cached-call
// telemetry forwarder. Singleton bound to the AuditLog
// composition root; null in tests / hosts that haven't called
// AddAuditLog, in which case the cached-call helpers degrade
// to the no-emission path (the underlying S&F handoff still
// happens and a TrackedOperationId is still returned).
ICachedCallTelemetryForwarder? cachedForwarder = null;
if (serviceProvider != null)
{
@@ -115,6 +128,8 @@ public class ScriptExecutionActor : ReceiveActor
siteId = serviceScope.ServiceProvider.GetService<ISiteIdentityProvider>()?.SiteId
?? string.Empty;
auditWriter = serviceScope.ServiceProvider.GetService<IAuditWriter>();
operationTrackingStore = serviceScope.ServiceProvider.GetService<IOperationTrackingStore>();
cachedForwarder = serviceScope.ServiceProvider.GetService<ICachedCallTelemetryForwarder>();
}
var context = new ScriptRuntimeContext(
@@ -138,7 +153,18 @@ public class ScriptExecutionActor : ReceiveActor
// ExternalSystem.Call. Writer is best-effort; failures are logged
// and swallowed inside the helper so the script's call path is
// never aborted by an audit failure.
auditWriter: auditWriter);
auditWriter: auditWriter,
// Audit Log #23 (M3 Bundle A — Task A3): site-local tracking store
// backing Tracking.Status(id). Authoritative source of truth for
// cached-call status — read directly by the script API.
operationTrackingStore: operationTrackingStore,
// Audit Log #23 (M3 Bundle F — Task F1): cached-call telemetry
// forwarder for ExternalSystem.CachedCall / Database.CachedWrite
// CachedSubmit emission + the immediate-success terminal-row
// emission. Best-effort: null degrades the helpers to a
// no-emission path; the S&F handoff and TrackedOperationId
// return are unaffected.
cachedForwarder: cachedForwarder);
var globals = new ScriptGlobals
{

View File

@@ -4,8 +4,10 @@ using System.Text.RegularExpressions;
using Akka.Actor;
using Microsoft.Extensions.Logging;
using ScadaLink.Commons.Entities.Audit;
using ScadaLink.Commons.Interfaces;
using ScadaLink.Commons.Interfaces.Services;
using ScadaLink.Commons.Messages.Instance;
using ScadaLink.Commons.Messages.Integration;
using ScadaLink.Commons.Messages.Notification;
using ScadaLink.Commons.Messages.ScriptExecution;
using ScadaLink.Commons.Types;
@@ -85,6 +87,24 @@ public class ScriptRuntimeContext
/// </summary>
private readonly IAuditWriter? _auditWriter;
/// <summary>
/// Audit Log #23 (M3): site-local tracking store consulted by
/// <c>Tracking.Status(TrackedOperationId)</c>. Optional — when null the
/// helper throws on access, mirroring the existing
/// "service-not-wired" behaviour of the other integration helpers.
/// </summary>
private readonly IOperationTrackingStore? _operationTrackingStore;
/// <summary>
/// Audit Log #23 (M3 Bundle E — Task E3): site-side dual emitter for
/// cached-call lifecycle telemetry. Optional — when null
/// <c>ExternalSystem.CachedCall</c> / <c>Database.CachedWrite</c> still
/// return a <see cref="TrackedOperationId"/> and invoke the underlying
/// store-and-forward path, but no audit / SiteCalls telemetry is emitted
/// (tests / minimal hosts that don't wire the audit pipeline).
/// </summary>
private readonly ICachedCallTelemetryForwarder? _cachedForwarder;
public ScriptRuntimeContext(
IActorRef instanceActor,
IActorRef self,
@@ -100,7 +120,9 @@ public class ScriptRuntimeContext
ICanTell? siteCommunicationActor = null,
string siteId = "",
string? sourceScript = null,
IAuditWriter? auditWriter = null)
IAuditWriter? auditWriter = null,
IOperationTrackingStore? operationTrackingStore = null,
ICachedCallTelemetryForwarder? cachedForwarder = null)
{
_instanceActor = instanceActor;
_self = self;
@@ -117,6 +139,8 @@ public class ScriptRuntimeContext
_siteId = siteId;
_sourceScript = sourceScript;
_auditWriter = auditWriter;
_operationTrackingStore = operationTrackingStore;
_cachedForwarder = cachedForwarder;
}
/// <summary>
@@ -217,14 +241,21 @@ public class ScriptRuntimeContext
/// ExternalSystem.CachedCall("systemName", "methodName", params)
/// </summary>
public ExternalSystemHelper ExternalSystem => new(
_externalSystemClient, _instanceName, _logger, _auditWriter, _siteId, _sourceScript);
_externalSystemClient, _instanceName, _logger, _auditWriter, _siteId, _sourceScript,
// Audit Log #23 (M3 Bundle E — Task E3): emit CachedSubmit telemetry
// on every ExternalSystem.CachedCall enqueue.
_cachedForwarder);
/// <summary>
/// WP-13: Provides access to database operations.
/// Database.Connection("name")
/// Database.CachedWrite("name", "sql", params)
/// </summary>
public DatabaseHelper Database => new(_databaseGateway, _instanceName, _logger);
public DatabaseHelper Database => new(
_databaseGateway, _instanceName, _logger, _siteId, _sourceScript,
// Audit Log #23 (M3 Bundle E — Task E6): emit CachedSubmit telemetry on
// every Database.CachedWrite enqueue.
_cachedForwarder);
/// <summary>
/// Provides access to the Notification Outbox API.
@@ -235,6 +266,15 @@ public class ScriptRuntimeContext
public NotifyHelper Notify => new(
_storeAndForward, _siteCommunicationActor, _siteId, _instanceName, _sourceScript, _askTimeout, _logger);
/// <summary>
/// Audit Log #23 (M3): site-local tracking-status API for cached operations.
/// <c>Tracking.Status(trackedOperationId)</c> reads the site SQLite tracking row
/// directly (authoritative source of truth — no central round-trip) and
/// returns a <see cref="TrackingStatusSnapshot"/>, or <c>null</c> when the
/// id is unknown / has already been purged.
/// </summary>
public TrackingHelper Tracking => new(_operationTrackingStore, _logger);
/// <summary>
/// Helper class for Scripts.CallShared() syntax.
/// </summary>
@@ -308,14 +348,19 @@ public class ScriptRuntimeContext
private readonly IAuditWriter? _auditWriter;
private readonly string _siteId;
private readonly string? _sourceScript;
private readonly ICachedCallTelemetryForwarder? _cachedForwarder;
// Internal constructor for tests living in ScadaLink.SiteRuntime.Tests
// (via InternalsVisibleTo). Production sites resolve the helper through
// ScriptRuntimeContext.ExternalSystem.
internal ExternalSystemHelper(
IExternalSystemClient? client,
string instanceName,
ILogger logger,
IAuditWriter? auditWriter = null,
string siteId = "",
string? sourceScript = null)
string? sourceScript = null,
ICachedCallTelemetryForwarder? cachedForwarder = null)
{
_client = client;
_instanceName = instanceName;
@@ -323,6 +368,7 @@ public class ScriptRuntimeContext
_auditWriter = auditWriter;
_siteId = siteId;
_sourceScript = sourceScript;
_cachedForwarder = cachedForwarder;
}
public async Task<ExternalCallResult> Call(
@@ -361,7 +407,22 @@ public class ScriptRuntimeContext
}
}
public async Task<ExternalCallResult> CachedCall(
/// <summary>
/// Submit a cached outbound API call (Audit Log #23 / M3). Mints a
/// fresh <see cref="TrackedOperationId"/>, emits the lifecycle's first
/// <c>CachedSubmit</c> telemetry packet, hands the call to the
/// store-and-forward retry loop (which emits per-attempt and terminal
/// telemetry under the same id — Bundle E Tasks E4/E5), and returns
/// the id immediately so the script can later query
/// <c>Tracking.Status(id)</c>.
/// </summary>
/// <remarks>
/// <b>Best-effort emission (alog.md §7):</b> if the forwarder throws,
/// the failure is logged and swallowed; the underlying cached-call
/// path still runs and the id is still returned. The script must never
/// be aborted by an audit-pipeline failure.
/// </remarks>
public async Task<TrackedOperationId> CachedCall(
string systemName,
string methodName,
IReadOnlyDictionary<string, object?>? parameters = null,
@@ -370,7 +431,307 @@ public class ScriptRuntimeContext
if (_client == null)
throw new InvalidOperationException("External system client not available");
return await _client.CachedCallAsync(systemName, methodName, parameters, _instanceName, cancellationToken);
var trackedId = TrackedOperationId.New();
var occurredAtUtc = DateTime.UtcNow;
var target = $"{systemName}.{methodName}";
// Emit CachedSubmit telemetry BEFORE handing off to the S&F
// engine — that way the SiteCalls row is materialised before the
// first delivery attempt and Tracking.Status(id) can observe a
// Submitted row even if the immediate-delivery attempt happens to
// resolve before this method returns.
await EmitCachedSubmitTelemetryAsync(
systemName, methodName, target, trackedId, occurredAtUtc, cancellationToken)
.ConfigureAwait(false);
// Hand off to the existing cached-call path. The TrackedOperationId
// becomes the S&F message id so the retry loop (Bundle E Tasks
// E4/E5) can read it back via StoreAndForwardMessage.Id.
//
// M3 Bundle F (F2): the result is now retained because the
// immediate-success path (WasBuffered=false) bypasses S&F entirely
// — no retry loop, no ICachedCallLifecycleObserver fire. The
// helper must emit the Attempted + CachedResolve terminal rows
// itself, otherwise Tracking.Status(id) would stay in Submitted
// forever and the audit log would be missing the M3 lifecycle.
// The WasBuffered=true path is unaffected — the S&F retry loop
// owns the Attempted + Resolve emissions in that case.
ExternalCallResult? result;
try
{
result = await _client.CachedCallAsync(
systemName,
methodName,
parameters,
_instanceName,
cancellationToken,
trackedId).ConfigureAwait(false);
}
catch (Exception ex)
{
// The cached-call surface returns ExternalCallResult on permanent
// failure rather than throwing; a throw here is exceptional
// (e.g. cancellation, resolver outage). Log it and rethrow — the
// script does need to learn about catastrophic failures. The
// tracked id was still returned via the telemetry submit above.
_logger.LogWarning(ex,
"ExternalSystem.CachedCall threw for {System}.{Method} (TrackedOperationId {Id})",
systemName, methodName, trackedId);
throw;
}
// M3 Bundle F (F2): immediate-completion lifecycle — emit the
// missing Attempted + CachedResolve rows when the underlying call
// resolved without engaging the store-and-forward retry loop.
if (result is { WasBuffered: false })
{
await EmitImmediateTerminalTelemetryAsync(
systemName, methodName, target, trackedId, result, cancellationToken)
.ConfigureAwait(false);
}
return trackedId;
}
/// <summary>
/// Best-effort emission of the CachedSubmit lifecycle event. Any
/// exception thrown by the forwarder is logged and swallowed so the
/// calling script's enqueue is not disturbed.
/// </summary>
private async Task EmitCachedSubmitTelemetryAsync(
string systemName,
string methodName,
string target,
TrackedOperationId trackedId,
DateTime occurredAtUtc,
CancellationToken cancellationToken)
{
if (_cachedForwarder == null)
{
return;
}
CachedCallTelemetry telemetry;
try
{
telemetry = new CachedCallTelemetry(
Audit: new AuditEvent
{
EventId = Guid.NewGuid(),
OccurredAtUtc = DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc),
Channel = AuditChannel.ApiOutbound,
Kind = AuditKind.CachedSubmit,
CorrelationId = trackedId.Value,
SourceSiteId = string.IsNullOrEmpty(_siteId) ? null : _siteId,
SourceInstanceId = _instanceName,
SourceScript = _sourceScript,
Target = target,
Status = AuditStatus.Submitted,
ForwardState = AuditForwardState.Pending,
},
Operational: new SiteCallOperational(
TrackedOperationId: trackedId,
Channel: "ApiOutbound",
Target: target,
SourceSite: _siteId,
Status: "Submitted",
RetryCount: 0,
LastError: null,
HttpStatus: null,
CreatedAtUtc: occurredAtUtc,
UpdatedAtUtc: occurredAtUtc,
TerminalAtUtc: null));
}
catch (Exception buildEx)
{
_logger.LogWarning(buildEx,
"Failed to build CachedSubmit telemetry for {System}.{Method} (TrackedOperationId {Id}) — skipping emission",
systemName, methodName, trackedId);
return;
}
try
{
await _cachedForwarder.ForwardAsync(telemetry, cancellationToken)
.ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogWarning(ex,
"CachedSubmit telemetry forward failed for {System}.{Method} (TrackedOperationId {Id})",
systemName, methodName, trackedId);
}
}
/// <summary>
/// M3 Bundle F (F2): emit the Attempted + CachedResolve lifecycle
/// rows for an immediate-completion <c>CachedCall</c> (WasBuffered=false).
/// The S&amp;F retry loop never engaged, so the
/// <c>ICachedCallLifecycleObserver</c> never fires — the helper must
/// produce both rows itself to keep the M3 audit contract whole
/// (Submit → Attempted → Resolve under one TrackedOperationId).
/// </summary>
/// <remarks>
/// Best-effort emission: a throwing forwarder is logged and swallowed
/// per alog.md §7. The two rows are emitted INDEPENDENTLY so a single
/// forwarder fault doesn't drop both halves of the terminal pair.
/// </remarks>
private async Task EmitImmediateTerminalTelemetryAsync(
string systemName,
string methodName,
string target,
TrackedOperationId trackedId,
ExternalCallResult result,
CancellationToken cancellationToken)
{
if (_cachedForwarder == null)
{
return;
}
var occurredAtUtc = DateTime.UtcNow;
// Extract an HTTP status from the error message when present
// (mirrors EmitCallAudit's existing HttpStatusRegex behaviour so
// the immediate-failure row carries the same HttpStatus value the
// synchronous Call() audit row would have stamped).
int? httpStatus = null;
if (!result.Success && !string.IsNullOrEmpty(result.ErrorMessage))
{
var match = HttpStatusRegex.Match(result.ErrorMessage);
if (match.Success && int.TryParse(match.Groups["code"].Value, out var code))
{
httpStatus = code;
}
}
// Status mapping for immediate completion:
// Success=true -> Delivered (audit) / "Delivered" (operational)
// Success=false -> Failed (audit) / "Failed" (operational)
// Permanent vs transient is not relevant here: a permanent failure
// returns Success=false WasBuffered=false (parked-equivalent); a
// transient failure with NO S&F engine wired likewise lands here
// with Success=false. Either way the terminal state is "the
// immediate attempt failed and the operation is done".
var auditTerminalStatus = result.Success
? AuditStatus.Delivered
: AuditStatus.Failed;
var operationalTerminalStatus = result.Success ? "Delivered" : "Failed";
// --- Attempted row -------------------------------------------------
CachedCallTelemetry attempted;
try
{
attempted = new CachedCallTelemetry(
Audit: new AuditEvent
{
EventId = Guid.NewGuid(),
OccurredAtUtc = DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc),
Channel = AuditChannel.ApiOutbound,
Kind = AuditKind.ApiCallCached,
CorrelationId = trackedId.Value,
SourceSiteId = string.IsNullOrEmpty(_siteId) ? null : _siteId,
SourceInstanceId = _instanceName,
SourceScript = _sourceScript,
Target = target,
Status = AuditStatus.Attempted,
HttpStatus = httpStatus,
ErrorMessage = result.Success ? null : result.ErrorMessage,
ForwardState = AuditForwardState.Pending,
},
Operational: new SiteCallOperational(
TrackedOperationId: trackedId,
Channel: "ApiOutbound",
Target: target,
SourceSite: _siteId,
Status: "Attempted",
// RetryCount stays 0 — the operation never reached the
// S&F retry sweep, so no retries were performed.
RetryCount: 0,
LastError: result.Success ? null : result.ErrorMessage,
HttpStatus: httpStatus,
CreatedAtUtc: occurredAtUtc,
UpdatedAtUtc: occurredAtUtc,
TerminalAtUtc: null));
}
catch (Exception buildEx)
{
_logger.LogWarning(buildEx,
"Failed to build immediate-Attempted telemetry for {System}.{Method} (TrackedOperationId {Id}) — skipping emission",
systemName, methodName, trackedId);
attempted = null!;
}
if (attempted is not null)
{
try
{
await _cachedForwarder.ForwardAsync(attempted, cancellationToken)
.ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogWarning(ex,
"Immediate-Attempted telemetry forward failed for {System}.{Method} (TrackedOperationId {Id})",
systemName, methodName, trackedId);
}
}
// --- CachedResolve row --------------------------------------------
CachedCallTelemetry resolve;
try
{
resolve = new CachedCallTelemetry(
Audit: new AuditEvent
{
EventId = Guid.NewGuid(),
OccurredAtUtc = DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc),
Channel = AuditChannel.ApiOutbound,
Kind = AuditKind.CachedResolve,
CorrelationId = trackedId.Value,
SourceSiteId = string.IsNullOrEmpty(_siteId) ? null : _siteId,
SourceInstanceId = _instanceName,
SourceScript = _sourceScript,
Target = target,
Status = auditTerminalStatus,
HttpStatus = httpStatus,
ErrorMessage = result.Success ? null : result.ErrorMessage,
ForwardState = AuditForwardState.Pending,
},
Operational: new SiteCallOperational(
TrackedOperationId: trackedId,
Channel: "ApiOutbound",
Target: target,
SourceSite: _siteId,
Status: operationalTerminalStatus,
RetryCount: 0,
LastError: result.Success ? null : result.ErrorMessage,
HttpStatus: httpStatus,
CreatedAtUtc: occurredAtUtc,
UpdatedAtUtc: occurredAtUtc,
// Immediate-completion terminal — mark TerminalAtUtc so
// SiteCallAudit can move the row to its purge eligible
// set.
TerminalAtUtc: occurredAtUtc));
}
catch (Exception buildEx)
{
_logger.LogWarning(buildEx,
"Failed to build immediate-CachedResolve telemetry for {System}.{Method} (TrackedOperationId {Id}) — skipping emission",
systemName, methodName, trackedId);
return;
}
try
{
await _cachedForwarder.ForwardAsync(resolve, cancellationToken)
.ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogWarning(ex,
"Immediate-CachedResolve telemetry forward failed for {System}.{Method} (TrackedOperationId {Id})",
systemName, methodName, trackedId);
}
}
/// <summary>
@@ -516,17 +877,37 @@ public class ScriptRuntimeContext
/// <summary>
/// WP-13: Helper for Database.Connection/CachedWrite syntax.
/// </summary>
/// <remarks>
/// Audit Log #23 (M3 Bundle E — Task E6): <see cref="CachedWrite"/> mirrors
/// <see cref="ExternalSystemHelper.CachedCall"/> — mints a
/// <see cref="TrackedOperationId"/>, emits the lifecycle's first
/// CachedSubmit packet (Channel <c>DbOutbound</c>), hands off to the S&amp;F
/// retry loop, and returns the id. Per-attempt + terminal telemetry is
/// emitted by the retry loop (Tasks E4/E5).
/// </remarks>
public class DatabaseHelper
{
private readonly IDatabaseGateway? _gateway;
private readonly string _instanceName;
private readonly ILogger _logger;
private readonly string _siteId;
private readonly string? _sourceScript;
private readonly ICachedCallTelemetryForwarder? _cachedForwarder;
internal DatabaseHelper(IDatabaseGateway? gateway, string instanceName, ILogger logger)
internal DatabaseHelper(
IDatabaseGateway? gateway,
string instanceName,
ILogger logger,
string siteId = "",
string? sourceScript = null,
ICachedCallTelemetryForwarder? cachedForwarder = null)
{
_gateway = gateway;
_instanceName = instanceName;
_logger = logger;
_siteId = siteId;
_sourceScript = sourceScript;
_cachedForwarder = cachedForwarder;
}
public async Task<System.Data.Common.DbConnection> Connection(
@@ -539,7 +920,13 @@ public class ScriptRuntimeContext
return await _gateway.GetConnectionAsync(name, cancellationToken);
}
public async Task CachedWrite(
/// <summary>
/// Submit a cached outbound database write. Mints a fresh
/// <see cref="TrackedOperationId"/>, emits CachedSubmit telemetry on
/// <c>DbOutbound</c>, hands off to the cached-write S&amp;F path, and
/// returns the id. Best-effort emission per alog.md §7.
/// </summary>
public async Task<TrackedOperationId> CachedWrite(
string name,
string sql,
IReadOnlyDictionary<string, object?>? parameters = null,
@@ -548,7 +935,95 @@ public class ScriptRuntimeContext
if (_gateway == null)
throw new InvalidOperationException("Database gateway not available");
await _gateway.CachedWriteAsync(name, sql, parameters, _instanceName, cancellationToken);
var trackedId = TrackedOperationId.New();
var occurredAtUtc = DateTime.UtcNow;
// The DB cached-write target uses the connection name (the only
// human-readable handle the gateway carries on the buffered row).
var target = name;
await EmitCachedDbSubmitTelemetryAsync(
name, trackedId, target, occurredAtUtc, cancellationToken)
.ConfigureAwait(false);
try
{
await _gateway.CachedWriteAsync(
name, sql, parameters, _instanceName, cancellationToken, trackedId)
.ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogWarning(ex,
"Database.CachedWrite threw for {Connection} (TrackedOperationId {Id})",
name, trackedId);
throw;
}
return trackedId;
}
private async Task EmitCachedDbSubmitTelemetryAsync(
string connectionName,
TrackedOperationId trackedId,
string target,
DateTime occurredAtUtc,
CancellationToken cancellationToken)
{
if (_cachedForwarder == null)
{
return;
}
CachedCallTelemetry telemetry;
try
{
telemetry = new CachedCallTelemetry(
Audit: new AuditEvent
{
EventId = Guid.NewGuid(),
OccurredAtUtc = DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc),
Channel = AuditChannel.DbOutbound,
Kind = AuditKind.CachedSubmit,
CorrelationId = trackedId.Value,
SourceSiteId = string.IsNullOrEmpty(_siteId) ? null : _siteId,
SourceInstanceId = _instanceName,
SourceScript = _sourceScript,
Target = target,
Status = AuditStatus.Submitted,
ForwardState = AuditForwardState.Pending,
},
Operational: new SiteCallOperational(
TrackedOperationId: trackedId,
Channel: "DbOutbound",
Target: target,
SourceSite: _siteId,
Status: "Submitted",
RetryCount: 0,
LastError: null,
HttpStatus: null,
CreatedAtUtc: occurredAtUtc,
UpdatedAtUtc: occurredAtUtc,
TerminalAtUtc: null));
}
catch (Exception buildEx)
{
_logger.LogWarning(buildEx,
"Failed to build CachedSubmit telemetry for Database.CachedWrite {Connection} (TrackedOperationId {Id}) — skipping emission",
connectionName, trackedId);
return;
}
try
{
await _cachedForwarder.ForwardAsync(telemetry, cancellationToken)
.ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogWarning(ex,
"CachedSubmit telemetry forward failed for Database.CachedWrite {Connection} (TrackedOperationId {Id})",
connectionName, trackedId);
}
}
}
@@ -746,4 +1221,46 @@ public class ScriptRuntimeContext
return notificationId;
}
}
/// <summary>
/// Audit Log #23 (M3): script-side accessor for cached-operation tracking.
/// <c>Tracking.Status(trackedOperationId)</c> reads the site-local SQLite
/// row directly via <see cref="IOperationTrackingStore.GetStatusAsync"/> —
/// the site is the single source of truth for cached-call status, so no
/// central round-trip is needed and the call is answered authoritatively.
/// </summary>
public class TrackingHelper
{
private readonly IOperationTrackingStore? _store;
private readonly ILogger _logger;
internal TrackingHelper(IOperationTrackingStore? store, ILogger logger)
{
_store = store;
_logger = logger;
}
/// <summary>
/// Returns the latest tracking snapshot for the supplied id, or
/// <c>null</c> when the id is unknown (never recorded, or purged after
/// the retention window).
/// </summary>
/// <exception cref="InvalidOperationException">
/// Thrown when the script runtime was constructed without an
/// <see cref="IOperationTrackingStore"/> — mirrors the
/// "service-not-wired" failure mode of the other integration helpers.
/// </exception>
public Task<TrackingStatusSnapshot?> Status(
TrackedOperationId trackedOperationId,
CancellationToken cancellationToken = default)
{
if (_store == null)
{
throw new InvalidOperationException(
"Operation tracking store not available");
}
return _store.GetStatusAsync(trackedOperationId, cancellationToken);
}
}
}

View File

@@ -0,0 +1,21 @@
namespace ScadaLink.SiteRuntime.Tracking;
/// <summary>
/// Options for <see cref="OperationTrackingStore"/> — site-local cached-call
/// tracking SQLite store (Audit Log #23 / M3).
/// </summary>
public class OperationTrackingOptions
{
/// <summary>
/// Full ADO.NET connection string for the SQLite database (e.g.
/// <c>"Data Source=site-tracking.db"</c>). Tests use the
/// <c>Mode=Memory;Cache=Shared</c> form to keep the database in-memory.
/// </summary>
public string ConnectionString { get; set; } = "Data Source=site-tracking.db";
/// <summary>
/// Retention window for terminal tracking rows. The default purge cadence
/// (driven by the host) deletes terminal rows older than this many days.
/// </summary>
public int RetentionDays { get; set; } = 7;
}

View File

@@ -0,0 +1,333 @@
using System.Globalization;
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using ScadaLink.Commons.Interfaces;
using ScadaLink.Commons.Types;
namespace ScadaLink.SiteRuntime.Tracking;
/// <summary>
/// Site-local SQLite source-of-truth for cached-operation tracking — the row
/// that <c>Tracking.Status(TrackedOperationId)</c> reads (Audit Log #23 / M3).
/// </summary>
/// <remarks>
/// <para>
/// One row per <see cref="TrackedOperationId"/>; lifecycle is
/// <c>Submitted → Retrying → Delivered / Parked / Failed / Discarded</c>; terminal
/// rows are purged after the configured retention window
/// (<see cref="OperationTrackingOptions.RetentionDays"/>). Volume is bounded —
/// only cached calls produce rows, and only a handful of lifecycle events per
/// call — so we keep the implementation deliberately simple: a single owned
/// <see cref="SqliteConnection"/> serialised behind a <see cref="SemaphoreSlim"/>
/// (one async writer at a time). This is the pattern the M3 brief calls out as
/// "cleaner than the M2 Channel&lt;T&gt; pipeline given the volume"; the M2
/// audit-writer's batched-channel design is reserved for the high-volume audit
/// hot-path.
/// </para>
/// <para>
/// All mutations are idempotent / monotonic: <see cref="RecordEnqueueAsync"/> is
/// <c>INSERT OR IGNORE</c>, <see cref="RecordAttemptAsync"/> filters out terminal
/// rows in the <c>WHERE</c> clause, and <see cref="RecordTerminalAsync"/> only
/// fires on rows that haven't terminated yet (first-write-wins). This makes the
/// store safe under the at-least-once semantics of the site→central telemetry
/// path.
/// </para>
/// </remarks>
public class OperationTrackingStore : IOperationTrackingStore, IAsyncDisposable, IDisposable
{
private readonly SqliteConnection _connection;
private readonly SemaphoreSlim _gate = new(1, 1);
private readonly ILogger<OperationTrackingStore> _logger;
private bool _disposed;
public OperationTrackingStore(
IOptions<OperationTrackingOptions> options,
ILogger<OperationTrackingStore> logger)
{
ArgumentNullException.ThrowIfNull(options);
ArgumentNullException.ThrowIfNull(logger);
_logger = logger;
_connection = new SqliteConnection(options.Value.ConnectionString);
_connection.Open();
InitializeSchema();
}
private void InitializeSchema()
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = """
CREATE TABLE IF NOT EXISTS OperationTracking (
TrackedOperationId TEXT NOT NULL PRIMARY KEY,
Kind TEXT NOT NULL,
TargetSummary TEXT NULL,
Status TEXT NOT NULL,
RetryCount INTEGER NOT NULL DEFAULT 0,
LastError TEXT NULL,
HttpStatus INTEGER NULL,
CreatedAtUtc TEXT NOT NULL,
UpdatedAtUtc TEXT NOT NULL,
TerminalAtUtc TEXT NULL,
SourceInstanceId TEXT NULL,
SourceScript TEXT NULL
);
CREATE INDEX IF NOT EXISTS IX_OperationTracking_Status_Updated
ON OperationTracking (Status, UpdatedAtUtc);
""";
cmd.ExecuteNonQuery();
}
/// <inheritdoc/>
public async Task RecordEnqueueAsync(
TrackedOperationId id,
string kind,
string? targetSummary,
string? sourceInstanceId,
string? sourceScript,
CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(kind);
await _gate.WaitAsync(ct).ConfigureAwait(false);
try
{
ObjectDisposedException.ThrowIf(_disposed, this);
var now = DateTime.UtcNow.ToString("o", CultureInfo.InvariantCulture);
using var cmd = _connection.CreateCommand();
// INSERT OR IGNORE: duplicate ids are no-ops (first-write-wins) —
// matches the at-least-once semantics the site emits under.
cmd.CommandText = """
INSERT OR IGNORE INTO OperationTracking (
TrackedOperationId, Kind, TargetSummary, Status,
RetryCount, LastError, HttpStatus,
CreatedAtUtc, UpdatedAtUtc, TerminalAtUtc,
SourceInstanceId, SourceScript
) VALUES (
$id, $kind, $targetSummary, $status,
0, NULL, NULL,
$now, $now, NULL,
$sourceInstanceId, $sourceScript
);
""";
cmd.Parameters.AddWithValue("$id", id.ToString());
cmd.Parameters.AddWithValue("$kind", kind);
cmd.Parameters.AddWithValue("$targetSummary", (object?)targetSummary ?? DBNull.Value);
cmd.Parameters.AddWithValue("$status", "Submitted");
cmd.Parameters.AddWithValue("$now", now);
cmd.Parameters.AddWithValue("$sourceInstanceId", (object?)sourceInstanceId ?? DBNull.Value);
cmd.Parameters.AddWithValue("$sourceScript", (object?)sourceScript ?? DBNull.Value);
cmd.ExecuteNonQuery();
}
finally
{
_gate.Release();
}
}
/// <inheritdoc/>
public async Task RecordAttemptAsync(
TrackedOperationId id,
string status,
int retryCount,
string? lastError,
int? httpStatus,
CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(status);
await _gate.WaitAsync(ct).ConfigureAwait(false);
try
{
ObjectDisposedException.ThrowIf(_disposed, this);
var now = DateTime.UtcNow.ToString("o", CultureInfo.InvariantCulture);
using var cmd = _connection.CreateCommand();
// Terminal rows are immutable — the WHERE clause filters them out so
// late-arriving attempt telemetry never overwrites a resolved row.
cmd.CommandText = """
UPDATE OperationTracking
SET Status = $status,
RetryCount = $retryCount,
LastError = $lastError,
HttpStatus = $httpStatus,
UpdatedAtUtc = $now
WHERE TrackedOperationId = $id
AND TerminalAtUtc IS NULL;
""";
cmd.Parameters.AddWithValue("$id", id.ToString());
cmd.Parameters.AddWithValue("$status", status);
cmd.Parameters.AddWithValue("$retryCount", retryCount);
cmd.Parameters.AddWithValue("$lastError", (object?)lastError ?? DBNull.Value);
cmd.Parameters.AddWithValue("$httpStatus", (object?)httpStatus ?? DBNull.Value);
cmd.Parameters.AddWithValue("$now", now);
cmd.ExecuteNonQuery();
}
finally
{
_gate.Release();
}
}
/// <inheritdoc/>
public async Task RecordTerminalAsync(
TrackedOperationId id,
string status,
string? lastError,
int? httpStatus,
CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(status);
await _gate.WaitAsync(ct).ConfigureAwait(false);
try
{
ObjectDisposedException.ThrowIf(_disposed, this);
var now = DateTime.UtcNow.ToString("o", CultureInfo.InvariantCulture);
using var cmd = _connection.CreateCommand();
// First-write-wins on the terminal flip: only update rows that
// haven't already terminated.
cmd.CommandText = """
UPDATE OperationTracking
SET Status = $status,
LastError = $lastError,
HttpStatus = $httpStatus,
UpdatedAtUtc = $now,
TerminalAtUtc = $now
WHERE TrackedOperationId = $id
AND TerminalAtUtc IS NULL;
""";
cmd.Parameters.AddWithValue("$id", id.ToString());
cmd.Parameters.AddWithValue("$status", status);
cmd.Parameters.AddWithValue("$lastError", (object?)lastError ?? DBNull.Value);
cmd.Parameters.AddWithValue("$httpStatus", (object?)httpStatus ?? DBNull.Value);
cmd.Parameters.AddWithValue("$now", now);
cmd.ExecuteNonQuery();
}
finally
{
_gate.Release();
}
}
/// <inheritdoc/>
public async Task<TrackingStatusSnapshot?> GetStatusAsync(
TrackedOperationId id,
CancellationToken ct = default)
{
await _gate.WaitAsync(ct).ConfigureAwait(false);
try
{
ObjectDisposedException.ThrowIf(_disposed, this);
using var cmd = _connection.CreateCommand();
cmd.CommandText = """
SELECT TrackedOperationId, Kind, TargetSummary, Status,
RetryCount, LastError, HttpStatus,
CreatedAtUtc, UpdatedAtUtc, TerminalAtUtc,
SourceInstanceId, SourceScript
FROM OperationTracking
WHERE TrackedOperationId = $id;
""";
cmd.Parameters.AddWithValue("$id", id.ToString());
using var reader = cmd.ExecuteReader();
if (!reader.Read())
{
return null;
}
return new TrackingStatusSnapshot(
Id: TrackedOperationId.Parse(reader.GetString(0)),
Kind: reader.GetString(1),
TargetSummary: reader.IsDBNull(2) ? null : reader.GetString(2),
Status: reader.GetString(3),
RetryCount: reader.GetInt32(4),
LastError: reader.IsDBNull(5) ? null : reader.GetString(5),
HttpStatus: reader.IsDBNull(6) ? null : reader.GetInt32(6),
CreatedAtUtc: ParseUtc(reader.GetString(7)),
UpdatedAtUtc: ParseUtc(reader.GetString(8)),
TerminalAtUtc: reader.IsDBNull(9) ? null : ParseUtc(reader.GetString(9)),
SourceInstanceId: reader.IsDBNull(10) ? null : reader.GetString(10),
SourceScript: reader.IsDBNull(11) ? null : reader.GetString(11));
}
finally
{
_gate.Release();
}
}
/// <inheritdoc/>
public async Task PurgeTerminalAsync(
DateTime olderThanUtc,
CancellationToken ct = default)
{
await _gate.WaitAsync(ct).ConfigureAwait(false);
try
{
ObjectDisposedException.ThrowIf(_disposed, this);
using var cmd = _connection.CreateCommand();
// Non-terminal rows (TerminalAtUtc IS NULL) are kept regardless of
// age — the operation is still in flight.
cmd.CommandText = """
DELETE FROM OperationTracking
WHERE TerminalAtUtc IS NOT NULL
AND TerminalAtUtc < $threshold;
""";
cmd.Parameters.AddWithValue(
"$threshold",
olderThanUtc.ToString("o", CultureInfo.InvariantCulture));
cmd.ExecuteNonQuery();
}
finally
{
_gate.Release();
}
}
private static DateTime ParseUtc(string raw)
{
return DateTime.Parse(
raw,
CultureInfo.InvariantCulture,
DateTimeStyles.RoundtripKind);
}
public void Dispose()
{
DisposeAsyncCore().AsTask().GetAwaiter().GetResult();
GC.SuppressFinalize(this);
}
public async ValueTask DisposeAsync()
{
await DisposeAsyncCore().ConfigureAwait(false);
GC.SuppressFinalize(this);
}
private async ValueTask DisposeAsyncCore()
{
await _gate.WaitAsync().ConfigureAwait(false);
try
{
if (_disposed) return;
_disposed = true;
_connection.Dispose();
}
finally
{
_gate.Release();
_gate.Dispose();
}
}
}

View File

@@ -0,0 +1,27 @@
namespace ScadaLink.StoreAndForward;
/// <summary>
/// Optional ambient site context the Store-and-Forward service consults at
/// construction time. Carries the site identifier the S&amp;F retry loop
/// stamps onto cached-call audit telemetry (Audit Log #23 / M3 Bundle F).
/// </summary>
/// <remarks>
/// <para>
/// Defined here (not in <c>HealthMonitoring</c> alongside the existing
/// <c>ISiteIdentityProvider</c>) so the dependency arrow does not flip:
/// <c>HealthMonitoring</c> already references <c>StoreAndForward</c>, and
/// having S&amp;F take a dependency on <c>HealthMonitoring</c> would create a
/// project-reference cycle.
/// </para>
/// <para>
/// The Host registers a trivial adapter that forwards to the same
/// <c>NodeOptions.SiteId</c> the existing <c>ISiteIdentityProvider</c> reads.
/// Resolution is optional: when no binding is registered the S&amp;F service
/// stamps an empty site id, preserving the legacy pre-M3 behaviour exactly.
/// </para>
/// </remarks>
public interface IStoreAndForwardSiteContext
{
/// <summary>The site id stamped onto cached-call audit telemetry.</summary>
string SiteId { get; }
}

View File

@@ -1,6 +1,7 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using ScadaLink.Commons.Interfaces.Services;
namespace ScadaLink.StoreAndForward;
@@ -23,7 +24,28 @@ public static class ServiceCollectionExtensions
var options = sp.GetRequiredService<IOptions<StoreAndForwardOptions>>().Value;
var logger = sp.GetRequiredService<ILogger<StoreAndForwardService>>();
var replication = sp.GetRequiredService<ReplicationService>();
return new StoreAndForwardService(storage, options, logger, replication);
// Audit Log #23 (M3 Bundle F): Wire the cached-call lifecycle
// observer + site identity through DI so the S&F retry loop emits
// per-attempt + terminal telemetry under the same TrackedOperationId
// the script-thread CachedSubmit row used. Both bindings are
// optional — when null the legacy pre-M3 retry behaviour is
// preserved exactly (tests, central nodes without sites, hosts
// that haven't called AddAuditLog).
//
// Site identity is resolved through the optional
// IStoreAndForwardSiteContext binding (registered by the Host) to
// avoid a project-reference cycle with HealthMonitoring's
// ISiteIdentityProvider — HealthMonitoring already references S&F.
var cachedCallObserver = sp.GetService<ICachedCallLifecycleObserver>();
var siteContext = sp.GetService<IStoreAndForwardSiteContext>();
var siteId = siteContext?.SiteId ?? string.Empty;
return new StoreAndForwardService(
storage,
options,
logger,
replication,
cachedCallObserver,
siteId);
});
services.AddSingleton<ReplicationService>(sp =>

View File

@@ -1,4 +1,6 @@
using Microsoft.Extensions.Logging;
using ScadaLink.Commons.Interfaces.Services;
using ScadaLink.Commons.Types;
using ScadaLink.Commons.Types.Enums;
namespace ScadaLink.StoreAndForward;
@@ -33,6 +35,19 @@ public class StoreAndForwardService
private readonly StoreAndForwardOptions _options;
private readonly ReplicationService? _replication;
private readonly ILogger<StoreAndForwardService> _logger;
/// <summary>
/// Audit Log #23 (M3 Bundle E — Task E4): site-side observer notified
/// after every cached-call delivery attempt. Optional — when null no
/// telemetry is emitted; the legacy pre-M3 retry loop behaviour is
/// preserved exactly.
/// </summary>
private readonly ICachedCallLifecycleObserver? _cachedCallObserver;
/// <summary>
/// Audit Log #23 (M3 Bundle E — Task E4): site id stamped onto the
/// cached-call attempt context so the audit bridge can build the
/// <see cref="SiteCallOperational"/> half of the telemetry packet.
/// </summary>
private readonly string _siteId;
private Timer? _retryTimer;
private int _retryInProgress;
@@ -63,12 +78,16 @@ public class StoreAndForwardService
StoreAndForwardStorage storage,
StoreAndForwardOptions options,
ILogger<StoreAndForwardService> logger,
ReplicationService? replication = null)
ReplicationService? replication = null,
ICachedCallLifecycleObserver? cachedCallObserver = null,
string siteId = "")
{
_storage = storage;
_options = options;
_logger = logger;
_replication = replication;
_cachedCallObserver = cachedCallObserver;
_siteId = siteId;
}
/// <summary>
@@ -280,15 +299,33 @@ public class StoreAndForwardService
return;
}
// Audit Log #23 (M3 Bundle E — Tasks E4/E5): measure per-attempt
// duration so the audit row carries a meaningful DurationMs. Captured
// around the handler invocation only — storage / replication overhead
// is excluded.
var attemptStartUtc = DateTime.UtcNow;
var attemptStopwatch = System.Diagnostics.Stopwatch.StartNew();
try
{
var success = await handler(message);
attemptStopwatch.Stop();
if (success)
{
await _storage.RemoveMessageAsync(message.Id);
_replication?.ReplicateRemove(message.Id);
RaiseActivity("Delivered", message.Category,
$"Delivered to {message.Target} after {message.RetryCount} retries");
// M3: terminal Delivered observer notification — the audit
// bridge maps this to Attempted + CachedResolve(Delivered).
await NotifyCachedCallObserverAsync(
message,
CachedCallAttemptOutcome.Delivered,
lastError: null,
httpStatus: null,
occurredAtUtc: attemptStartUtc,
durationMs: (int)attemptStopwatch.ElapsedMilliseconds);
return;
}
@@ -311,9 +348,20 @@ public class StoreAndForwardService
_replication?.ReplicatePark(message);
RaiseActivity("Parked", message.Category,
$"Permanent failure for {message.Target}: handler returned false");
// M3: terminal PermanentFailure observer notification — the
// audit bridge maps this to Attempted(Failed) + CachedResolve(Parked).
await NotifyCachedCallObserverAsync(
message,
CachedCallAttemptOutcome.PermanentFailure,
lastError: message.LastError,
httpStatus: null,
occurredAtUtc: attemptStartUtc,
durationMs: (int)attemptStopwatch.ElapsedMilliseconds);
}
catch (Exception ex)
{
attemptStopwatch.Stop();
// Transient failure — increment retry, check max
message.RetryCount++;
message.LastAttemptAt = DateTimeOffset.UtcNow;
@@ -339,6 +387,16 @@ public class StoreAndForwardService
_logger.LogWarning(
"Message {MessageId} parked after {MaxRetries} retries to {Target}",
message.Id, message.MaxRetries, message.Target);
// M3: terminal ParkedMaxRetries observer notification — the
// audit bridge maps this to Attempted(Failed) + CachedResolve(Parked).
await NotifyCachedCallObserverAsync(
message,
CachedCallAttemptOutcome.ParkedMaxRetries,
lastError: ex.Message,
httpStatus: null,
occurredAtUtc: attemptStartUtc,
durationMs: (int)attemptStopwatch.ElapsedMilliseconds);
}
else
{
@@ -355,10 +413,113 @@ public class StoreAndForwardService
}
RaiseActivity("Retried", message.Category,
$"Retry {message.RetryCount}/{message.MaxRetries} for {message.Target}: {ex.Message}");
// M3: per-attempt TransientFailure observer notification —
// the audit bridge maps this to Attempted(Failed).
await NotifyCachedCallObserverAsync(
message,
CachedCallAttemptOutcome.TransientFailure,
lastError: ex.Message,
httpStatus: null,
occurredAtUtc: attemptStartUtc,
durationMs: (int)attemptStopwatch.ElapsedMilliseconds);
}
}
}
/// <summary>
/// Audit Log #23 (M3 Bundle E — Tasks E4/E5): notify the registered
/// <see cref="ICachedCallLifecycleObserver"/> of the just-completed
/// attempt. Only fires for cached-call categories
/// (<see cref="StoreAndForwardCategory.ExternalSystem"/> and
/// <see cref="StoreAndForwardCategory.CachedDbWrite"/>); the
/// <see cref="StoreAndForwardCategory.Notification"/> category has its
/// own central-side audit pipeline (Notification Outbox / #21) and must
/// not surface on this hook.
/// </summary>
/// <remarks>
/// Best-effort: an observer that throws is logged and swallowed so a
/// failing audit pipeline cannot corrupt S&amp;F retry bookkeeping
/// (alog.md §7 contract). Messages whose ids are not valid GUIDs (pre-M3
/// callers that didn't thread a TrackedOperationId in) are silently
/// skipped — the observer requires a parseable id by contract.
/// </remarks>
private async Task NotifyCachedCallObserverAsync(
StoreAndForwardMessage message,
CachedCallAttemptOutcome outcome,
string? lastError,
int? httpStatus,
DateTime occurredAtUtc,
int? durationMs)
{
if (_cachedCallObserver == null)
{
return;
}
// Only cached-call categories generate audit telemetry on this hook —
// notifications have their own outbox-side audit pipeline.
var channel = message.Category switch
{
StoreAndForwardCategory.ExternalSystem => "ApiOutbound",
StoreAndForwardCategory.CachedDbWrite => "DbOutbound",
_ => null,
};
if (channel is null)
{
return;
}
if (!TrackedOperationId.TryParse(message.Id, out var trackedId))
{
// Pre-M3 message (random GUID-N id from S&F itself, no
// TrackedOperationId threaded in). Skip — no audit row to bind to.
return;
}
CachedCallAttemptContext context;
try
{
context = new CachedCallAttemptContext(
TrackedOperationId: trackedId,
Channel: channel,
Target: message.Target,
SourceSite: _siteId,
Outcome: outcome,
RetryCount: message.RetryCount,
LastError: lastError,
HttpStatus: httpStatus,
CreatedAtUtc: message.CreatedAt.UtcDateTime,
OccurredAtUtc: DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc),
DurationMs: durationMs,
SourceInstanceId: message.OriginInstanceName);
}
catch (Exception buildEx)
{
// Defensive — record construction shouldn't throw, but the alog.md
// §7 contract requires this path be exception-safe regardless.
_logger.LogWarning(buildEx,
"Failed to build cached-call attempt context for {MessageId}; observer skipped",
message.Id);
return;
}
try
{
await _cachedCallObserver.OnAttemptCompletedAsync(context, CancellationToken.None)
.ConfigureAwait(false);
}
catch (Exception ex)
{
// alog.md §7 best-effort: an audit observer outage must NEVER be
// misclassified as a transient delivery failure or corrupt the
// S&F retry bookkeeping.
_logger.LogWarning(ex,
"ICachedCallLifecycleObserver threw for {MessageId} (Outcome {Outcome}); ignored",
message.Id, outcome);
}
}
/// <summary>
/// WP-12: Gets parked messages for central query (Pattern 8).
/// </summary>

View File

@@ -0,0 +1,391 @@
using Akka.Actor;
using Akka.TestKit.Xunit2;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using ScadaLink.AuditLog.Central;
using ScadaLink.Commons.Entities.Audit;
using ScadaLink.Commons.Interfaces.Repositories;
using ScadaLink.Commons.Messages.Audit;
using ScadaLink.Commons.Types;
using ScadaLink.Commons.Types.Audit;
using ScadaLink.Commons.Types.Enums;
using ScadaLink.ConfigurationDatabase;
using ScadaLink.ConfigurationDatabase.Repositories;
using ScadaLink.ConfigurationDatabase.Tests.Migrations;
namespace ScadaLink.AuditLog.Tests.Central;
/// <summary>
/// Bundle D D2 tests for <see cref="AuditLogIngestActor"/>'s M3 combined-
/// telemetry dual-write transaction. Uses the same <see cref="MsSqlMigrationFixture"/>
/// as the M1 + M2 repository tests so the actor exercises real
/// <see cref="AuditLogRepository.InsertIfNotExistsAsync"/> +
/// <see cref="SiteCallAuditRepository.UpsertAsync"/> against a per-test MSSQL
/// database. The transaction commits or rolls back inside one
/// <see cref="DbContext.Database"/>.
/// </summary>
public class AuditLogIngestActorCombinedTelemetryTests : TestKit, IClassFixture<MsSqlMigrationFixture>
{
private readonly MsSqlMigrationFixture _fixture;
public AuditLogIngestActorCombinedTelemetryTests(MsSqlMigrationFixture fixture)
{
_fixture = fixture;
}
private ScadaLinkDbContext CreateReadContext()
{
var options = new DbContextOptionsBuilder<ScadaLinkDbContext>()
.UseSqlServer(_fixture.ConnectionString)
.Options;
return new ScadaLinkDbContext(options);
}
private static string NewSiteId() =>
"test-bundle-d2-cached-" + Guid.NewGuid().ToString("N").Substring(0, 8);
private static (AuditEvent audit, SiteCall siteCall) NewEntry(
string siteId,
TrackedOperationId? trackedOperationId = null,
Guid? eventId = null,
string status = "Submitted",
AuditStatus auditStatus = AuditStatus.Submitted)
{
var trackedId = trackedOperationId ?? TrackedOperationId.New();
var now = new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc);
var audit = new AuditEvent
{
EventId = eventId ?? Guid.NewGuid(),
OccurredAtUtc = now,
Channel = AuditChannel.ApiOutbound,
Kind = AuditKind.CachedSubmit,
Status = auditStatus,
SourceSiteId = siteId,
CorrelationId = trackedId.Value,
};
var siteCall = new SiteCall
{
TrackedOperationId = trackedId,
Channel = "ApiOutbound",
Target = "ERP.GetOrder",
SourceSite = siteId,
Status = status,
RetryCount = 0,
CreatedAtUtc = now,
UpdatedAtUtc = now,
IngestedAtUtc = now, // overwritten by the actor
};
return (audit, siteCall);
}
/// <summary>
/// Builds a minimal DI container around the per-test MSSQL fixture's
/// connection string — DbContext + the two repositories the dual-write
/// handler resolves. Mirrors AddConfigurationDatabase without the
/// DataProtection wiring (we never write secret columns in these tests).
/// </summary>
private IServiceProvider BuildServiceProvider(
Func<ScadaLinkDbContext, ISiteCallAuditRepository>? siteCallRepoFactory = null)
{
var services = new ServiceCollection();
services.AddDbContext<ScadaLinkDbContext>(opts =>
opts.UseSqlServer(_fixture.ConnectionString)
.ConfigureWarnings(w => w.Ignore(
Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.PendingModelChangesWarning)));
services.AddScoped<IAuditLogRepository>(sp =>
new AuditLogRepository(sp.GetRequiredService<ScadaLinkDbContext>()));
if (siteCallRepoFactory is null)
{
services.AddScoped<ISiteCallAuditRepository>(sp =>
new SiteCallAuditRepository(sp.GetRequiredService<ScadaLinkDbContext>()));
}
else
{
services.AddScoped(sp =>
siteCallRepoFactory(sp.GetRequiredService<ScadaLinkDbContext>()));
}
return services.BuildServiceProvider();
}
private IActorRef CreateActor(IServiceProvider serviceProvider) =>
Sys.ActorOf(Props.Create(() => new AuditLogIngestActor(
serviceProvider,
NullLogger<AuditLogIngestActor>.Instance)));
[SkippableFact]
public async Task Receive_OneCachedPacket_WritesAuditRow_AND_SiteCallRow_AcksId()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var siteId = NewSiteId();
var (audit, siteCall) = NewEntry(siteId);
var sp = BuildServiceProvider();
var actor = CreateActor(sp);
actor.Tell(
new IngestCachedTelemetryCommand(new[] { new CachedTelemetryEntry(audit, siteCall) }),
TestActor);
var reply = ExpectMsg<IngestCachedTelemetryReply>(TimeSpan.FromSeconds(15));
Assert.Single(reply.AcceptedEventIds);
Assert.Equal(audit.EventId, reply.AcceptedEventIds[0]);
// Verify rows landed in both tables.
await using var read = CreateReadContext();
var auditRow = await read.Set<AuditEvent>().SingleOrDefaultAsync(e => e.EventId == audit.EventId);
Assert.NotNull(auditRow);
Assert.NotNull(auditRow!.IngestedAtUtc);
var siteCallRow = await read.Set<SiteCall>()
.SingleOrDefaultAsync(s => s.TrackedOperationId == siteCall.TrackedOperationId);
Assert.NotNull(siteCallRow);
Assert.Equal(siteCall.Status, siteCallRow!.Status);
}
[SkippableFact]
public async Task Receive_DuplicateEventId_SameStatus_NoOp_RowCountUnchanged_AcksId()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var siteId = NewSiteId();
var trackedId = TrackedOperationId.New();
var eventId = Guid.NewGuid();
var (audit, siteCall) = NewEntry(siteId, trackedId, eventId);
var sp = BuildServiceProvider();
var actor = CreateActor(sp);
// First write
actor.Tell(
new IngestCachedTelemetryCommand(new[] { new CachedTelemetryEntry(audit, siteCall) }),
TestActor);
ExpectMsg<IngestCachedTelemetryReply>(TimeSpan.FromSeconds(15));
// Second write — same EventId and TrackedOperationId, same status. Both
// the audit insert (idempotent) and the SiteCalls upsert (monotonic
// same-rank → no-op) should silently do nothing while still acking.
actor.Tell(
new IngestCachedTelemetryCommand(new[] { new CachedTelemetryEntry(audit, siteCall) }),
TestActor);
var reply = ExpectMsg<IngestCachedTelemetryReply>(TimeSpan.FromSeconds(15));
Assert.Single(reply.AcceptedEventIds);
Assert.Equal(eventId, reply.AcceptedEventIds[0]);
await using var read = CreateReadContext();
var auditCount = await read.Set<AuditEvent>().CountAsync(e => e.EventId == eventId);
Assert.Equal(1, auditCount);
var siteCallCount = await read.Set<SiteCall>()
.CountAsync(s => s.TrackedOperationId == trackedId);
Assert.Equal(1, siteCallCount);
}
[SkippableFact]
public async Task Receive_DuplicateEventId_AdvancedSiteCallStatus_UpdatesSiteCall()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var siteId = NewSiteId();
var trackedId = TrackedOperationId.New();
var sp = BuildServiceProvider();
var actor = CreateActor(sp);
// 1st packet — Submitted (audit EventId #1, SiteCalls Status=Submitted).
var (auditSubmit, siteCallSubmit) = NewEntry(
siteId, trackedId, status: "Submitted", auditStatus: AuditStatus.Submitted);
actor.Tell(
new IngestCachedTelemetryCommand(new[] { new CachedTelemetryEntry(auditSubmit, siteCallSubmit) }),
TestActor);
ExpectMsg<IngestCachedTelemetryReply>(TimeSpan.FromSeconds(15));
// 2nd packet — Attempted with retry count 1 (audit EventId #2,
// SiteCalls Status=Attempted — monotonic upsert wins). Same
// TrackedOperationId throughout.
var (auditAttempt, siteCallAttempt) = NewEntry(
siteId, trackedId, status: "Attempted", auditStatus: AuditStatus.Attempted);
var advanced = siteCallAttempt with { RetryCount = 1, UpdatedAtUtc = siteCallAttempt.UpdatedAtUtc.AddMinutes(1) };
actor.Tell(
new IngestCachedTelemetryCommand(new[] { new CachedTelemetryEntry(auditAttempt, advanced) }),
TestActor);
var reply = ExpectMsg<IngestCachedTelemetryReply>(TimeSpan.FromSeconds(15));
Assert.Single(reply.AcceptedEventIds);
Assert.Equal(auditAttempt.EventId, reply.AcceptedEventIds[0]);
// Both audit rows exist.
await using var read = CreateReadContext();
var auditRows = await read.Set<AuditEvent>()
.Where(e => e.SourceSiteId == siteId)
.ToListAsync();
Assert.Equal(2, auditRows.Count);
// SiteCalls row advanced to Attempted with retry count 1.
var siteCallRow = await read.Set<SiteCall>()
.SingleAsync(s => s.TrackedOperationId == trackedId);
Assert.Equal("Attempted", siteCallRow.Status);
Assert.Equal(1, siteCallRow.RetryCount);
}
[SkippableFact]
public async Task Receive_AuditInsertSucceeds_SiteCallThrows_BothRolledBack_NoOrphanRow()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var siteId = NewSiteId();
var (audit, siteCall) = NewEntry(siteId);
// Wrap the SiteCalls repo so UpsertAsync always throws — the dual-write
// transaction must roll back the AuditLog INSERT done in the same
// transaction, leaving no orphan row.
var sp = BuildServiceProvider(
ctx => new ThrowingSiteCallRepo(new SiteCallAuditRepository(ctx)));
var actor = CreateActor(sp);
actor.Tell(
new IngestCachedTelemetryCommand(new[] { new CachedTelemetryEntry(audit, siteCall) }),
TestActor);
var reply = ExpectMsg<IngestCachedTelemetryReply>(TimeSpan.FromSeconds(15));
Assert.Empty(reply.AcceptedEventIds);
await using var read = CreateReadContext();
var auditRow = await read.Set<AuditEvent>().SingleOrDefaultAsync(e => e.EventId == audit.EventId);
Assert.Null(auditRow);
var siteCallRow = await read.Set<SiteCall>()
.SingleOrDefaultAsync(s => s.TrackedOperationId == siteCall.TrackedOperationId);
Assert.Null(siteCallRow);
}
[SkippableFact]
public async Task Receive_FiveCachedPackets_AllPersistedSeparately_AllAcked()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var siteId = NewSiteId();
var entries = Enumerable.Range(0, 5).Select(_ =>
{
var (audit, siteCall) = NewEntry(siteId);
return new CachedTelemetryEntry(audit, siteCall);
}).ToList();
var sp = BuildServiceProvider();
var actor = CreateActor(sp);
actor.Tell(new IngestCachedTelemetryCommand(entries), TestActor);
var reply = ExpectMsg<IngestCachedTelemetryReply>(TimeSpan.FromSeconds(15));
Assert.Equal(5, reply.AcceptedEventIds.Count);
Assert.True(entries.Select(e => e.Audit.EventId).ToHashSet()
.SetEquals(reply.AcceptedEventIds.ToHashSet()));
await using var read = CreateReadContext();
var auditCount = await read.Set<AuditEvent>().CountAsync(e => e.SourceSiteId == siteId);
Assert.Equal(5, auditCount);
var siteCallCount = await read.Set<SiteCall>().CountAsync(s => s.SourceSite == siteId);
Assert.Equal(5, siteCallCount);
}
[SkippableFact]
public async Task Receive_OnePacketSucceeds_NextPacketThrows_FirstStillCommitted_BatchContinues()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var siteId = NewSiteId();
var (audit1, siteCall1) = NewEntry(siteId);
var (audit2, siteCall2) = NewEntry(siteId);
var (audit3, siteCall3) = NewEntry(siteId);
var poisonTrackedId = siteCall2.TrackedOperationId;
// Throw only for the middle entry's TrackedOperationId — entries on
// either side must commit their own transactions independently.
var sp = BuildServiceProvider(
ctx => new PoisonOnIdSiteCallRepo(new SiteCallAuditRepository(ctx), poisonTrackedId));
var actor = CreateActor(sp);
actor.Tell(
new IngestCachedTelemetryCommand(new[]
{
new CachedTelemetryEntry(audit1, siteCall1),
new CachedTelemetryEntry(audit2, siteCall2),
new CachedTelemetryEntry(audit3, siteCall3),
}),
TestActor);
var reply = ExpectMsg<IngestCachedTelemetryReply>(TimeSpan.FromSeconds(15));
// Two entries committed; poison entry rolled back.
Assert.Equal(2, reply.AcceptedEventIds.Count);
Assert.Contains(audit1.EventId, reply.AcceptedEventIds);
Assert.Contains(audit3.EventId, reply.AcceptedEventIds);
Assert.DoesNotContain(audit2.EventId, reply.AcceptedEventIds);
await using var read = CreateReadContext();
var auditRows = await read.Set<AuditEvent>().Where(e => e.SourceSiteId == siteId).ToListAsync();
Assert.Equal(2, auditRows.Count);
Assert.DoesNotContain(auditRows, r => r.EventId == audit2.EventId);
var siteCallRows = await read.Set<SiteCall>().Where(s => s.SourceSite == siteId).ToListAsync();
Assert.Equal(2, siteCallRows.Count);
Assert.DoesNotContain(siteCallRows, r => r.TrackedOperationId == poisonTrackedId);
}
/// <summary>
/// Test double — throws unconditionally from <see cref="UpsertAsync"/> so
/// the dual-write transaction is forced to roll back. Lets the AuditLog
/// row insert succeed in-transaction; the rollback must remove it.
/// </summary>
private sealed class ThrowingSiteCallRepo : ISiteCallAuditRepository
{
private readonly ISiteCallAuditRepository _inner;
public ThrowingSiteCallRepo(ISiteCallAuditRepository inner) { _inner = inner; }
public Task UpsertAsync(SiteCall siteCall, CancellationToken ct = default) =>
throw new InvalidOperationException("simulated SiteCalls upsert failure");
public Task<SiteCall?> GetAsync(TrackedOperationId id, CancellationToken ct = default) =>
_inner.GetAsync(id, ct);
public Task<IReadOnlyList<SiteCall>> QueryAsync(
SiteCallQueryFilter filter, SiteCallPaging paging, CancellationToken ct = default) =>
_inner.QueryAsync(filter, paging, ct);
public Task<int> PurgeTerminalAsync(DateTime olderThanUtc, CancellationToken ct = default) =>
_inner.PurgeTerminalAsync(olderThanUtc, ct);
}
/// <summary>
/// Test double — throws only when the supplied poison TrackedOperationId
/// is the one being upserted. Demonstrates per-entry transaction isolation:
/// one entry's failed transaction must not abort the batch's other entries.
/// </summary>
private sealed class PoisonOnIdSiteCallRepo : ISiteCallAuditRepository
{
private readonly ISiteCallAuditRepository _inner;
private readonly TrackedOperationId _poisonId;
public PoisonOnIdSiteCallRepo(ISiteCallAuditRepository inner, TrackedOperationId poisonId)
{
_inner = inner;
_poisonId = poisonId;
}
public Task UpsertAsync(SiteCall siteCall, CancellationToken ct = default)
{
if (siteCall.TrackedOperationId == _poisonId)
{
throw new InvalidOperationException("simulated SiteCalls upsert failure for poison id");
}
return _inner.UpsertAsync(siteCall, ct);
}
public Task<SiteCall?> GetAsync(TrackedOperationId id, CancellationToken ct = default) =>
_inner.GetAsync(id, ct);
public Task<IReadOnlyList<SiteCall>> QueryAsync(
SiteCallQueryFilter filter, SiteCallPaging paging, CancellationToken ct = default) =>
_inner.QueryAsync(filter, paging, ct);
public Task<int> PurgeTerminalAsync(DateTime olderThanUtc, CancellationToken ct = default) =>
_inner.PurgeTerminalAsync(olderThanUtc, ct);
}
}

View File

@@ -0,0 +1,270 @@
using Akka.TestKit.Xunit2;
using Microsoft.EntityFrameworkCore;
using ScadaLink.AuditLog.Tests.Integration.Infrastructure;
using ScadaLink.Commons.Entities.Audit;
using ScadaLink.Commons.Interfaces.Services;
using ScadaLink.Commons.Messages.Integration;
using ScadaLink.Commons.Types;
using ScadaLink.Commons.Types.Enums;
using ScadaLink.ConfigurationDatabase.Tests.Migrations;
namespace ScadaLink.AuditLog.Tests.Integration;
/// <summary>
/// Bundle G G2 end-to-end suite for cached <c>ExternalSystem.CachedCall</c>
/// lifecycle telemetry (Audit Log #23 / M3). Wires the full M3 pipeline:
/// site-local SQLite audit writer + operation tracking store + the production
/// <see cref="CachedCallTelemetryForwarder"/> + the test-side
/// <see cref="CombinedTelemetryDispatcher"/> that ALSO pushes each combined
/// packet through the stub gRPC client into the central
/// <c>AuditLogIngestActor</c>'s dual-write transaction against a per-test
/// MSSQL database. Asserts the audit rows + the SiteCalls row + the
/// site-local tracking row converge to the expected shape for each lifecycle.
/// </summary>
/// <remarks>
/// <para>
/// The bridge is driven directly via <see cref="CombinedTelemetryHarness.EmitAttemptAsync"/>
/// — these tests do NOT spin up the actual S&amp;F retry loop; that would
/// require a full SiteRuntime host and is out of scope for M3 (the S&amp;F
/// observer hooks are exercised in <c>ScadaLink.StoreAndForward.Tests</c> at
/// unit level). The submit row is emitted via
/// <see cref="CombinedTelemetryHarness.EmitSubmitAsync"/> because the
/// production submit emission happens at the script-call site, not inside the
/// S&amp;F loop.
/// </para>
/// <para>
/// Each test uses a unique <c>SourceSite</c> id (Guid suffix) so concurrent
/// tests sharing the per-fixture MSSQL database don't interfere with each
/// other.
/// </para>
/// </remarks>
public class CachedCallCombinedTelemetryTests : TestKit, IClassFixture<MsSqlMigrationFixture>
{
private readonly MsSqlMigrationFixture _fixture;
public CachedCallCombinedTelemetryTests(MsSqlMigrationFixture fixture)
{
_fixture = fixture;
}
private static string NewSiteId() =>
"test-g2-cached-" + Guid.NewGuid().ToString("N").Substring(0, 8);
private static CachedCallTelemetry SubmitPacket(
TrackedOperationId id, string siteId, DateTime nowUtc, string target = "ERP.GetOrder") =>
new(
Audit: new AuditEvent
{
EventId = Guid.NewGuid(),
OccurredAtUtc = nowUtc,
Channel = AuditChannel.ApiOutbound,
Kind = AuditKind.CachedSubmit,
CorrelationId = id.Value,
SourceSiteId = siteId,
SourceInstanceId = "Plant.Pump42",
SourceScript = "ScriptActor:doStuff",
Target = target,
Status = AuditStatus.Submitted,
ForwardState = AuditForwardState.Pending,
},
Operational: new SiteCallOperational(
TrackedOperationId: id,
Channel: "ApiOutbound",
Target: target,
SourceSite: siteId,
Status: "Submitted",
RetryCount: 0,
LastError: null,
HttpStatus: null,
CreatedAtUtc: nowUtc,
UpdatedAtUtc: nowUtc,
TerminalAtUtc: null));
private static CachedCallAttemptContext AttemptContext(
TrackedOperationId id,
string siteId,
CachedCallAttemptOutcome outcome,
int retryCount,
string? lastError,
int? httpStatus,
DateTime createdUtc,
DateTime occurredUtc,
string target = "ERP.GetOrder",
string channel = "ApiOutbound") =>
new(
TrackedOperationId: id,
Channel: channel,
Target: target,
SourceSite: siteId,
Outcome: outcome,
RetryCount: retryCount,
LastError: lastError,
HttpStatus: httpStatus,
CreatedAtUtc: createdUtc,
OccurredAtUtc: occurredUtc,
DurationMs: 42,
SourceInstanceId: "Plant.Pump42");
[SkippableFact]
public async Task CachedCall_FailFailSuccess_Emits_5_AuditRows_AND_1_SiteCall_Delivered()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var siteId = NewSiteId();
var trackedId = TrackedOperationId.New();
var t0 = new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc);
await using var harness = new CombinedTelemetryHarness(_fixture, this);
// Submit
await harness.EmitSubmitAsync(SubmitPacket(trackedId, siteId, t0));
// Attempt 1: transient HTTP 500
await harness.EmitAttemptAsync(AttemptContext(
trackedId, siteId,
CachedCallAttemptOutcome.TransientFailure,
retryCount: 1, lastError: "HTTP 500", httpStatus: 500,
createdUtc: t0, occurredUtc: t0.AddSeconds(5)));
// Attempt 2: transient HTTP 500
await harness.EmitAttemptAsync(AttemptContext(
trackedId, siteId,
CachedCallAttemptOutcome.TransientFailure,
retryCount: 2, lastError: "HTTP 500", httpStatus: 500,
createdUtc: t0, occurredUtc: t0.AddSeconds(15)));
// Attempt 3: delivered (terminal — emits Attempted + CachedResolve)
await harness.EmitAttemptAsync(AttemptContext(
trackedId, siteId,
CachedCallAttemptOutcome.Delivered,
retryCount: 3, lastError: null, httpStatus: 200,
createdUtc: t0, occurredUtc: t0.AddSeconds(25)));
// Central side: each forward through the dispatcher round-trips
// through the stub client + ingest actor, so by the time the awaits
// complete the rows are visible in MSSQL.
await using var read = harness.CreateReadContext();
// 1 Submit + 2 transient Attempted + 1 terminal Attempted + 1
// CachedResolve = 5 audit rows. The plan allows 4-5; this is the
// happy path emitting exactly 5.
var auditRows = await read.Set<AuditEvent>()
.Where(e => e.SourceSiteId == siteId)
.ToListAsync();
Assert.InRange(auditRows.Count, 4, 5);
// All audit rows must share the same CorrelationId (= TrackedOperationId).
Assert.All(auditRows, r => Assert.Equal(trackedId.Value, r.CorrelationId));
// Exactly one CachedSubmit row.
Assert.Single(auditRows, r => r.Kind == AuditKind.CachedSubmit);
// Exactly one terminal CachedResolve row, status Delivered.
var resolve = Assert.Single(auditRows, r => r.Kind == AuditKind.CachedResolve);
Assert.Equal(AuditStatus.Delivered, resolve.Status);
// SiteCalls row: Delivered, RetryCount=3, TerminalAtUtc set.
var siteCall = await read.Set<SiteCall>()
.SingleAsync(s => s.TrackedOperationId == trackedId);
Assert.Equal("Delivered", siteCall.Status);
Assert.Equal(3, siteCall.RetryCount);
Assert.NotNull(siteCall.TerminalAtUtc);
// Site-local Tracking.Status mirrors the same outcome.
var snapshot = await harness.TrackingStore.GetStatusAsync(trackedId);
Assert.NotNull(snapshot);
Assert.Equal("Delivered", snapshot!.Status);
Assert.NotNull(snapshot.TerminalAtUtc);
}
[SkippableFact]
public async Task CachedCall_AllAttemptsFailedAndParked_Emits_Terminal_Parked()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var siteId = NewSiteId();
var trackedId = TrackedOperationId.New();
var t0 = new DateTime(2026, 5, 20, 11, 0, 0, DateTimeKind.Utc);
await using var harness = new CombinedTelemetryHarness(_fixture, this);
await harness.EmitSubmitAsync(SubmitPacket(trackedId, siteId, t0));
// Three transient failures...
for (int i = 1; i <= 3; i++)
{
await harness.EmitAttemptAsync(AttemptContext(
trackedId, siteId,
CachedCallAttemptOutcome.TransientFailure,
retryCount: i, lastError: "HTTP 500", httpStatus: 500,
createdUtc: t0, occurredUtc: t0.AddSeconds(i * 5)));
}
// ...then S&F gives up — ParkedMaxRetries.
await harness.EmitAttemptAsync(AttemptContext(
trackedId, siteId,
CachedCallAttemptOutcome.ParkedMaxRetries,
retryCount: 4, lastError: "HTTP 500", httpStatus: 500,
createdUtc: t0, occurredUtc: t0.AddSeconds(30)));
await using var read = harness.CreateReadContext();
var siteCall = await read.Set<SiteCall>()
.SingleAsync(s => s.TrackedOperationId == trackedId);
Assert.Equal("Parked", siteCall.Status);
Assert.NotNull(siteCall.TerminalAtUtc);
// Terminal audit row should also be Parked.
var resolve = await read.Set<AuditEvent>()
.Where(e => e.SourceSiteId == siteId && e.Kind == AuditKind.CachedResolve)
.SingleAsync();
Assert.Equal(AuditStatus.Parked, resolve.Status);
// Site-local tracking matches.
var snapshot = await harness.TrackingStore.GetStatusAsync(trackedId);
Assert.NotNull(snapshot);
Assert.Equal("Parked", snapshot!.Status);
Assert.NotNull(snapshot.TerminalAtUtc);
}
[SkippableFact]
public async Task CachedCall_ImmediateSuccess_NoSF_Emits_Attempted_And_Resolve_Delivered()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var siteId = NewSiteId();
var trackedId = TrackedOperationId.New();
var t0 = new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc);
await using var harness = new CombinedTelemetryHarness(_fixture, this);
// Submit + immediate delivered attempt (RetryCount = 0).
await harness.EmitSubmitAsync(SubmitPacket(trackedId, siteId, t0));
await harness.EmitAttemptAsync(AttemptContext(
trackedId, siteId,
CachedCallAttemptOutcome.Delivered,
retryCount: 0, lastError: null, httpStatus: 200,
createdUtc: t0, occurredUtc: t0.AddMilliseconds(50)));
await using var read = harness.CreateReadContext();
var siteCall = await read.Set<SiteCall>()
.SingleAsync(s => s.TrackedOperationId == trackedId);
Assert.Equal("Delivered", siteCall.Status);
Assert.Equal(0, siteCall.RetryCount);
Assert.NotNull(siteCall.TerminalAtUtc);
// 1 Submit + 1 Attempted + 1 CachedResolve = 3 audit rows.
var auditRows = await read.Set<AuditEvent>()
.Where(e => e.SourceSiteId == siteId)
.ToListAsync();
Assert.Equal(3, auditRows.Count);
Assert.Single(auditRows, r => r.Kind == AuditKind.CachedSubmit);
Assert.Single(auditRows, r => r.Kind == AuditKind.ApiCallCached);
var resolve = Assert.Single(auditRows, r => r.Kind == AuditKind.CachedResolve);
Assert.Equal(AuditStatus.Delivered, resolve.Status);
var snapshot = await harness.TrackingStore.GetStatusAsync(trackedId);
Assert.NotNull(snapshot);
Assert.Equal("Delivered", snapshot!.Status);
}
}

View File

@@ -0,0 +1,196 @@
using Akka.TestKit.Xunit2;
using Microsoft.EntityFrameworkCore;
using ScadaLink.AuditLog.Tests.Integration.Infrastructure;
using ScadaLink.Commons.Entities.Audit;
using ScadaLink.Commons.Interfaces.Services;
using ScadaLink.Commons.Messages.Integration;
using ScadaLink.Commons.Types;
using ScadaLink.Commons.Types.Enums;
using ScadaLink.ConfigurationDatabase.Tests.Migrations;
namespace ScadaLink.AuditLog.Tests.Integration;
/// <summary>
/// Bundle G G3 mirror of <see cref="CachedCallCombinedTelemetryTests"/> for
/// <c>Database.CachedWrite</c>. Same pipeline composition, same dual-write
/// transaction, but the lifecycle bridge maps channel <c>"DbOutbound"</c> to
/// <see cref="AuditKind.DbWriteCached"/> on the per-attempt audit row (vs.
/// <see cref="AuditKind.ApiCallCached"/> for API calls). The
/// <see cref="AuditChannel"/> on the audit row, the <c>SiteCalls.Channel</c>
/// column, and the per-attempt <see cref="AuditKind"/> all need to come
/// through as the DB variants for this path to be considered exercised.
/// </summary>
/// <remarks>
/// As with G2, the bridge is driven directly via the harness — we do not
/// stand up a real <c>Database.CachedWrite</c> caller. The site-side
/// unit-level emission for the DB path is exercised in
/// <c>ScadaLink.SiteRuntime.Tests</c>; this suite verifies the end-to-end
/// combined-telemetry path produces the right central rows.
/// </remarks>
public class CachedWriteCombinedTelemetryTests : TestKit, IClassFixture<MsSqlMigrationFixture>
{
private readonly MsSqlMigrationFixture _fixture;
public CachedWriteCombinedTelemetryTests(MsSqlMigrationFixture fixture)
{
_fixture = fixture;
}
private static string NewSiteId() =>
"test-g3-cachedwrite-" + Guid.NewGuid().ToString("N").Substring(0, 8);
private static CachedCallTelemetry DbSubmitPacket(
TrackedOperationId id, string siteId, DateTime nowUtc, string target = "OperationsDb.UpdateOrder") =>
new(
Audit: new AuditEvent
{
EventId = Guid.NewGuid(),
OccurredAtUtc = nowUtc,
Channel = AuditChannel.DbOutbound,
Kind = AuditKind.CachedSubmit,
CorrelationId = id.Value,
SourceSiteId = siteId,
SourceInstanceId = "Plant.Pump42",
SourceScript = "ScriptActor:doStuff",
Target = target,
Status = AuditStatus.Submitted,
ForwardState = AuditForwardState.Pending,
},
Operational: new SiteCallOperational(
TrackedOperationId: id,
Channel: "DbOutbound",
Target: target,
SourceSite: siteId,
Status: "Submitted",
RetryCount: 0,
LastError: null,
HttpStatus: null,
CreatedAtUtc: nowUtc,
UpdatedAtUtc: nowUtc,
TerminalAtUtc: null));
private static CachedCallAttemptContext DbAttemptContext(
TrackedOperationId id,
string siteId,
CachedCallAttemptOutcome outcome,
int retryCount,
string? lastError,
DateTime createdUtc,
DateTime occurredUtc,
string target = "OperationsDb.UpdateOrder") =>
new(
TrackedOperationId: id,
Channel: "DbOutbound",
Target: target,
SourceSite: siteId,
Outcome: outcome,
RetryCount: retryCount,
LastError: lastError,
HttpStatus: null,
CreatedAtUtc: createdUtc,
OccurredAtUtc: occurredUtc,
DurationMs: 12,
SourceInstanceId: "Plant.Pump42");
[SkippableFact]
public async Task CachedWrite_Success_Emits_Delivered_Lifecycle()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var siteId = NewSiteId();
var trackedId = TrackedOperationId.New();
var t0 = new DateTime(2026, 5, 20, 13, 0, 0, DateTimeKind.Utc);
await using var harness = new CombinedTelemetryHarness(_fixture, this);
// Submit + immediate delivered attempt.
await harness.EmitSubmitAsync(DbSubmitPacket(trackedId, siteId, t0));
await harness.EmitAttemptAsync(DbAttemptContext(
trackedId, siteId,
CachedCallAttemptOutcome.Delivered,
retryCount: 0, lastError: null,
createdUtc: t0, occurredUtc: t0.AddMilliseconds(50)));
await using var read = harness.CreateReadContext();
// Central SiteCalls row — DbOutbound channel, Delivered.
var siteCall = await read.Set<SiteCall>()
.SingleAsync(s => s.TrackedOperationId == trackedId);
Assert.Equal("DbOutbound", siteCall.Channel);
Assert.Equal("Delivered", siteCall.Status);
Assert.Equal(0, siteCall.RetryCount);
Assert.NotNull(siteCall.TerminalAtUtc);
var auditRows = await read.Set<AuditEvent>()
.Where(e => e.SourceSiteId == siteId)
.ToListAsync();
Assert.Equal(3, auditRows.Count);
// Submit row: CachedSubmit + DbOutbound channel.
var submit = Assert.Single(auditRows, r => r.Kind == AuditKind.CachedSubmit);
Assert.Equal(AuditChannel.DbOutbound, submit.Channel);
// Per-attempt row: DbWriteCached (NOT ApiCallCached).
var attempt = Assert.Single(auditRows, r => r.Kind == AuditKind.DbWriteCached);
Assert.Equal(AuditStatus.Attempted, attempt.Status);
Assert.Equal(AuditChannel.DbOutbound, attempt.Channel);
// Terminal: CachedResolve Delivered.
var resolve = Assert.Single(auditRows, r => r.Kind == AuditKind.CachedResolve);
Assert.Equal(AuditStatus.Delivered, resolve.Status);
Assert.Equal(AuditChannel.DbOutbound, resolve.Channel);
// Site-local tracking row mirrors the same outcome.
var snapshot = await harness.TrackingStore.GetStatusAsync(trackedId);
Assert.NotNull(snapshot);
Assert.Equal("Delivered", snapshot!.Status);
}
[SkippableFact]
public async Task CachedWrite_Parked_Emits_Terminal_Parked()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var siteId = NewSiteId();
var trackedId = TrackedOperationId.New();
var t0 = new DateTime(2026, 5, 20, 14, 0, 0, DateTimeKind.Utc);
await using var harness = new CombinedTelemetryHarness(_fixture, this);
await harness.EmitSubmitAsync(DbSubmitPacket(trackedId, siteId, t0));
// Two transient SQL-error attempts...
for (int i = 1; i <= 2; i++)
{
await harness.EmitAttemptAsync(DbAttemptContext(
trackedId, siteId,
CachedCallAttemptOutcome.TransientFailure,
retryCount: i, lastError: "Deadlock victim",
createdUtc: t0, occurredUtc: t0.AddSeconds(i * 5)));
}
// ...then permanent failure → Parked terminal.
await harness.EmitAttemptAsync(DbAttemptContext(
trackedId, siteId,
CachedCallAttemptOutcome.PermanentFailure,
retryCount: 3, lastError: "ConstraintViolation: FK_Orders_Customer",
createdUtc: t0, occurredUtc: t0.AddSeconds(20)));
await using var read = harness.CreateReadContext();
var siteCall = await read.Set<SiteCall>()
.SingleAsync(s => s.TrackedOperationId == trackedId);
Assert.Equal("DbOutbound", siteCall.Channel);
Assert.Equal("Parked", siteCall.Status);
Assert.NotNull(siteCall.TerminalAtUtc);
var resolve = await read.Set<AuditEvent>()
.Where(e => e.SourceSiteId == siteId && e.Kind == AuditKind.CachedResolve)
.SingleAsync();
Assert.Equal(AuditStatus.Parked, resolve.Status);
Assert.Equal(AuditChannel.DbOutbound, resolve.Channel);
// Tracking store mirrors Parked.
var snapshot = await harness.TrackingStore.GetStatusAsync(trackedId);
Assert.NotNull(snapshot);
Assert.Equal("Parked", snapshot!.Status);
Assert.NotNull(snapshot.TerminalAtUtc);
}
}

View File

@@ -0,0 +1,202 @@
using Akka.TestKit.Xunit2;
using Microsoft.EntityFrameworkCore;
using ScadaLink.AuditLog.Tests.Integration.Infrastructure;
using ScadaLink.AuditLog.Telemetry;
using ScadaLink.Commons.Entities.Audit;
using ScadaLink.Commons.Messages.Integration;
using ScadaLink.Commons.Types;
using ScadaLink.Commons.Types.Enums;
using ScadaLink.ConfigurationDatabase.Tests.Migrations;
using ScadaLink.Communication.Grpc;
using Timestamp = Google.Protobuf.WellKnownTypes.Timestamp;
namespace ScadaLink.AuditLog.Tests.Integration;
/// <summary>
/// Bundle G G4 idempotency suite. Telemetry packets MUST round-trip safely
/// under retried delivery (at-least-once site→central) AND under out-of-order
/// arrival (a stale Submit packet arriving after the central row has already
/// advanced to Attempted must not regress the SiteCalls status, but must
/// still insert its own audit row because audit rows are append-only and the
/// lifecycle history is the source of truth for forensics).
/// </summary>
/// <remarks>
/// Pushes <see cref="CachedTelemetryBatch"/> packets directly through the
/// stub client (bypassing the local SQLite writer + tracking store) — the
/// scenario being modeled is a wire-level retry, not a fresh site call, so
/// the local stores' insert/no-op behaviour is already covered by the G2/G3
/// happy-path tests. This suite focuses on the central ingest actor's
/// dual-write transaction's idempotency contract.
/// </remarks>
public class CombinedTelemetryIdempotencyTests : TestKit, IClassFixture<MsSqlMigrationFixture>
{
private readonly MsSqlMigrationFixture _fixture;
public CombinedTelemetryIdempotencyTests(MsSqlMigrationFixture fixture)
{
_fixture = fixture;
}
private static string NewSiteId() =>
"test-g4-idem-" + Guid.NewGuid().ToString("N").Substring(0, 8);
private static CachedTelemetryPacket BuildPacket(
Guid eventId,
TrackedOperationId trackedId,
string siteId,
AuditKind kind,
AuditStatus auditStatus,
string operationalStatus,
int retryCount,
DateTime nowUtc,
DateTime? terminalUtc = null,
string? lastError = null,
int? httpStatus = null)
{
var dto = new CachedTelemetryPacket
{
AuditEvent = AuditEventMapper.ToDto(new AuditEvent
{
EventId = eventId,
OccurredAtUtc = nowUtc,
Channel = AuditChannel.ApiOutbound,
Kind = kind,
CorrelationId = trackedId.Value,
SourceSiteId = siteId,
Target = "ERP.GetOrder",
Status = auditStatus,
HttpStatus = httpStatus,
ErrorMessage = lastError,
ForwardState = AuditForwardState.Pending,
}),
Operational = new SiteCallOperationalDto
{
TrackedOperationId = trackedId.Value.ToString("D"),
Channel = "ApiOutbound",
Target = "ERP.GetOrder",
SourceSite = siteId,
Status = operationalStatus,
RetryCount = retryCount,
LastError = lastError ?? string.Empty,
CreatedAtUtc = Timestamp.FromDateTime(DateTime.SpecifyKind(nowUtc, DateTimeKind.Utc)),
UpdatedAtUtc = Timestamp.FromDateTime(DateTime.SpecifyKind(nowUtc, DateTimeKind.Utc)),
},
};
if (httpStatus.HasValue)
{
dto.Operational.HttpStatus = httpStatus.Value;
}
if (terminalUtc.HasValue)
{
dto.Operational.TerminalAtUtc =
Timestamp.FromDateTime(DateTime.SpecifyKind(terminalUtc.Value, DateTimeKind.Utc));
}
return dto;
}
private static CachedTelemetryBatch BatchOf(params CachedTelemetryPacket[] packets)
{
var batch = new CachedTelemetryBatch();
batch.Packets.AddRange(packets);
return batch;
}
[SkippableFact]
public async Task DuplicatePacket_AuditLogStaysAtOneRow_SiteCallsUpserted_Monotonically()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var siteId = NewSiteId();
var trackedId = TrackedOperationId.New();
var eventId = Guid.NewGuid();
var t0 = new DateTime(2026, 5, 20, 15, 0, 0, DateTimeKind.Utc);
await using var harness = new CombinedTelemetryHarness(_fixture, this);
var packet = BuildPacket(
eventId, trackedId, siteId,
AuditKind.CachedSubmit, AuditStatus.Submitted, "Submitted",
retryCount: 0, nowUtc: t0);
// First delivery
var ack1 = await harness.StubClient.IngestCachedTelemetryAsync(BatchOf(packet), CancellationToken.None);
Assert.Single(ack1.AcceptedEventIds);
// Second delivery — the exact same packet (simulates a retried gRPC).
var ack2 = await harness.StubClient.IngestCachedTelemetryAsync(BatchOf(packet), CancellationToken.None);
// Central acks both deliveries because storage state is consistent —
// the site is free to treat its local row as Forwarded either way.
Assert.Single(ack2.AcceptedEventIds);
Assert.Equal(eventId.ToString(), ack2.AcceptedEventIds[0]);
await using var read = harness.CreateReadContext();
// AuditLog: exactly ONE row for the EventId (insert-if-not-exists).
var auditCount = await read.Set<AuditEvent>()
.CountAsync(e => e.EventId == eventId);
Assert.Equal(1, auditCount);
// SiteCalls: exactly ONE row for the TrackedOperationId.
var siteCalls = await read.Set<SiteCall>()
.Where(s => s.TrackedOperationId == trackedId)
.ToListAsync();
Assert.Single(siteCalls);
Assert.Equal("Submitted", siteCalls[0].Status);
Assert.Equal(0, siteCalls[0].RetryCount);
}
[SkippableFact]
public async Task OutOfOrderPackets_OlderStatus_ArrivesAfterNewer_IsNoOp()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var siteId = NewSiteId();
var trackedId = TrackedOperationId.New();
var t0 = new DateTime(2026, 5, 20, 16, 0, 0, DateTimeKind.Utc);
await using var harness = new CombinedTelemetryHarness(_fixture, this);
// First: the Attempted (RetryCount=2) row arrives at central — perhaps
// the Submit packet got delayed in flight. SiteCalls advances straight
// to Attempted with retry count 2.
var attemptedEventId = Guid.NewGuid();
var attemptedPacket = BuildPacket(
attemptedEventId, trackedId, siteId,
AuditKind.ApiCallCached, AuditStatus.Attempted, "Attempted",
retryCount: 2, nowUtc: t0.AddSeconds(10),
lastError: "HTTP 500", httpStatus: 500);
var ack1 = await harness.StubClient.IngestCachedTelemetryAsync(BatchOf(attemptedPacket), CancellationToken.None);
Assert.Single(ack1.AcceptedEventIds);
// Now the stale Submit packet shows up. The audit row should still be
// inserted (audit is append-only — preserve the lifecycle history),
// but SiteCalls must NOT regress to Submitted/RetryCount=0.
var submitEventId = Guid.NewGuid();
var submitPacket = BuildPacket(
submitEventId, trackedId, siteId,
AuditKind.CachedSubmit, AuditStatus.Submitted, "Submitted",
retryCount: 0, nowUtc: t0);
var ack2 = await harness.StubClient.IngestCachedTelemetryAsync(BatchOf(submitPacket), CancellationToken.None);
Assert.Single(ack2.AcceptedEventIds);
await using var read = harness.CreateReadContext();
// AuditLog: TWO rows now exist for this lifecycle — the Submit and
// the Attempted. Their order is by OccurredAtUtc; the test doesn't
// assert ordering, only count + correlation.
var auditRows = await read.Set<AuditEvent>()
.Where(e => e.SourceSiteId == siteId)
.ToListAsync();
Assert.Equal(2, auditRows.Count);
Assert.All(auditRows, r => Assert.Equal(trackedId.Value, r.CorrelationId));
// SiteCalls: stuck at Attempted (monotonic — Submitted is rank 0,
// Attempted is rank 2, the upsert for the older row is a no-op).
var siteCall = await read.Set<SiteCall>()
.SingleAsync(s => s.TrackedOperationId == trackedId);
Assert.Equal("Attempted", siteCall.Status);
Assert.Equal(2, siteCall.RetryCount);
Assert.Equal("HTTP 500", siteCall.LastError);
Assert.Equal(500, siteCall.HttpStatus);
}
}

View File

@@ -0,0 +1,125 @@
using ScadaLink.AuditLog.Site.Telemetry;
using ScadaLink.AuditLog.Telemetry;
using ScadaLink.Commons.Interfaces.Services;
using ScadaLink.Commons.Messages.Integration;
using ScadaLink.Commons.Types;
using ScadaLink.Communication.Grpc;
using Google.Protobuf.WellKnownTypes;
using Timestamp = Google.Protobuf.WellKnownTypes.Timestamp;
namespace ScadaLink.AuditLog.Tests.Integration.Infrastructure;
/// <summary>
/// Test-side combined-telemetry dispatcher: wraps a production
/// <see cref="ICachedCallTelemetryForwarder"/> so the local audit + tracking
/// stores still get written, then projects the same packet onto the wire as a
/// <see cref="CachedTelemetryBatch"/> and pushes it through the supplied
/// <see cref="ISiteStreamAuditClient"/>. The bridge can be composed into the
/// existing <see cref="CachedCallLifecycleBridge"/> chain as the
/// <see cref="ICachedCallTelemetryForwarder"/> implementation so a single
/// observer callback fans out to both halves.
/// </summary>
/// <remarks>
/// <para>
/// Production wiring keeps the wire push deferred to M6 — the site SQLite hot
/// path is the source of truth and a future M6 drain will push the rows
/// through the gRPC client. For end-to-end testing today we need a way to
/// exercise the central dual-write transaction immediately, so this
/// dispatcher synthesises the wire packet inline and round-trips it through
/// the stub client. The shape mirrors what the M6 drain will eventually emit.
/// </para>
/// <para>
/// <b>Best-effort:</b> both the inner forwarder call and the wire push are
/// wrapped in independent try/catch blocks. A thrown wire client doesn't
/// abort the local writes (the audit row is already in SQLite); a thrown
/// local forwarder doesn't abort the wire push (central still gets the
/// dual-write attempt).
/// </para>
/// </remarks>
public sealed class CombinedTelemetryDispatcher : ICachedCallTelemetryForwarder
{
private readonly ICachedCallTelemetryForwarder _inner;
private readonly ISiteStreamAuditClient _wireClient;
public CombinedTelemetryDispatcher(
ICachedCallTelemetryForwarder inner,
ISiteStreamAuditClient wireClient)
{
_inner = inner ?? throw new ArgumentNullException(nameof(inner));
_wireClient = wireClient ?? throw new ArgumentNullException(nameof(wireClient));
}
/// <inheritdoc/>
public async Task ForwardAsync(CachedCallTelemetry telemetry, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(telemetry);
// Inner forwarder writes the audit row to SQLite + updates the
// tracking store. Best-effort — exceptions are already swallowed
// inside the production forwarder, but wrap defensively here too in
// case a test substitutes a stricter inner.
try
{
await _inner.ForwardAsync(telemetry, ct).ConfigureAwait(false);
}
catch
{
// Swallow — alog.md §7 best-effort contract.
}
// Project the same packet onto the wire and push it through the stub
// client. This is the bit a future M6 drain will subsume — until
// then the test wraps the two halves into one observer-driven step.
try
{
var batch = new CachedTelemetryBatch();
batch.Packets.Add(BuildPacket(telemetry));
await _wireClient.IngestCachedTelemetryAsync(batch, ct).ConfigureAwait(false);
}
catch
{
// Swallow — the audit row is still in SQLite for a future drain;
// the central row will materialise the next time the wire path
// is exercised (or via the M6 reconciliation pull).
}
}
private static CachedTelemetryPacket BuildPacket(CachedCallTelemetry telemetry)
{
return new CachedTelemetryPacket
{
AuditEvent = AuditEventMapper.ToDto(telemetry.Audit),
Operational = ToOperationalDto(telemetry.Operational),
};
}
private static SiteCallOperationalDto ToOperationalDto(SiteCallOperational op)
{
var dto = new SiteCallOperationalDto
{
TrackedOperationId = op.TrackedOperationId.Value.ToString("D"),
Channel = op.Channel,
Target = op.Target,
SourceSite = op.SourceSite,
Status = op.Status,
RetryCount = op.RetryCount,
LastError = op.LastError ?? string.Empty,
CreatedAtUtc = Timestamp.FromDateTime(EnsureUtc(op.CreatedAtUtc)),
UpdatedAtUtc = Timestamp.FromDateTime(EnsureUtc(op.UpdatedAtUtc)),
};
if (op.HttpStatus.HasValue)
{
dto.HttpStatus = op.HttpStatus.Value;
}
if (op.TerminalAtUtc.HasValue)
{
dto.TerminalAtUtc = Timestamp.FromDateTime(EnsureUtc(op.TerminalAtUtc.Value));
}
return dto;
}
private static DateTime EnsureUtc(DateTime value) =>
value.Kind == DateTimeKind.Utc
? value
: DateTime.SpecifyKind(value.ToUniversalTime(), DateTimeKind.Utc);
}

View File

@@ -0,0 +1,175 @@
using Akka.Actor;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using ScadaLink.AuditLog.Central;
using ScadaLink.AuditLog.Site;
using ScadaLink.AuditLog.Site.Telemetry;
using ScadaLink.Commons.Interfaces;
using ScadaLink.Commons.Interfaces.Repositories;
using ScadaLink.Commons.Interfaces.Services;
using ScadaLink.Commons.Messages.Integration;
using ScadaLink.ConfigurationDatabase;
using ScadaLink.ConfigurationDatabase.Repositories;
using ScadaLink.ConfigurationDatabase.Tests.Migrations;
using ScadaLink.SiteRuntime.Tracking;
namespace ScadaLink.AuditLog.Tests.Integration.Infrastructure;
/// <summary>
/// Shared end-to-end harness for the M3 cached-call combined telemetry tests
/// (G2/G3/G4). Composes the full pipeline:
/// <list type="bullet">
/// <item><description>Site-local SQLite <see cref="SqliteAuditWriter"/> (in-memory) +
/// <see cref="RingBufferFallback"/> + <see cref="FallbackAuditWriter"/>.</description></item>
/// <item><description>Site-local SQLite <see cref="OperationTrackingStore"/> (in-memory).</description></item>
/// <item><description>Production <see cref="CachedCallTelemetryForwarder"/> wrapped by a
/// test-side <see cref="CombinedTelemetryDispatcher"/> that also pushes each
/// packet through the stub gRPC client.</description></item>
/// <item><description><see cref="CachedCallLifecycleBridge"/> wired to the
/// dispatcher so a single observer call fans out audit + tracking + wire.</description></item>
/// <item><description><see cref="DirectActorSiteStreamAuditClient"/> connected
/// to an <see cref="AuditLogIngestActor"/> backed by the real
/// <see cref="AuditLogRepository"/> + <see cref="SiteCallAuditRepository"/>
/// against the per-test <see cref="MsSqlMigrationFixture"/> database.</description></item>
/// </list>
/// </summary>
/// <remarks>
/// <para>
/// Disposal cleans up the in-memory SQLite stores. The Akka actor system is
/// owned by the calling <see cref="Akka.TestKit.Xunit2.TestKit"/>; the harness
/// only owns the ingest actor IActorRef and the underlying repositories'
/// DbContext lifecycle.
/// </para>
/// </remarks>
public sealed class CombinedTelemetryHarness : IAsyncDisposable
{
public SqliteAuditWriter SqliteWriter { get; }
public RingBufferFallback Ring { get; }
public FallbackAuditWriter FallbackWriter { get; }
public OperationTrackingStore TrackingStore { get; }
public CachedCallTelemetryForwarder InnerForwarder { get; }
public CombinedTelemetryDispatcher Dispatcher { get; }
public CachedCallLifecycleBridge Bridge { get; }
public DirectActorSiteStreamAuditClient StubClient { get; }
public IActorRef IngestActor { get; }
public IServiceProvider ServiceProvider { get; }
private readonly MsSqlMigrationFixture _fixture;
private bool _disposed;
public CombinedTelemetryHarness(
MsSqlMigrationFixture fixture,
Akka.TestKit.Xunit2.TestKit testKit,
Func<ScadaLinkDbContext, ISiteCallAuditRepository>? siteCallRepoOverride = null)
{
_fixture = fixture ?? throw new ArgumentNullException(nameof(fixture));
ArgumentNullException.ThrowIfNull(testKit);
// Site SQLite — unique in-memory database per harness so tests don't share
// an audit queue. Mode=Memory + Cache=Shared keeps the file alive for the
// lifetime of the writer connection.
SqliteWriter = new SqliteAuditWriter(
Options.Create(new SqliteAuditWriterOptions
{
DatabasePath = "ignored",
BatchSize = 64,
ChannelCapacity = 1024,
}),
NullLogger<SqliteAuditWriter>.Instance,
connectionStringOverride:
$"Data Source=file:cachedcall-g-{Guid.NewGuid():N}?mode=memory&cache=shared");
Ring = new RingBufferFallback();
FallbackWriter = new FallbackAuditWriter(
SqliteWriter, Ring, new NoOpAuditWriteFailureCounter(),
NullLogger<FallbackAuditWriter>.Instance);
TrackingStore = new OperationTrackingStore(
Options.Create(new OperationTrackingOptions
{
// Same shared-in-memory pattern as the audit writer.
ConnectionString =
$"Data Source=file:tracking-g-{Guid.NewGuid():N}?mode=memory&cache=shared",
}),
NullLogger<OperationTrackingStore>.Instance);
// Central wiring: real repositories backed by the MSSQL fixture's DB.
ServiceProvider = BuildCentralServiceProvider(siteCallRepoOverride);
IngestActor = testKit.Sys.ActorOf(Props.Create(() => new AuditLogIngestActor(
ServiceProvider,
NullLogger<AuditLogIngestActor>.Instance)));
StubClient = new DirectActorSiteStreamAuditClient(IngestActor);
// Production forwarder writes the local stores; the dispatcher wraps
// it to ALSO push the same packet to central via the stub client.
InnerForwarder = new CachedCallTelemetryForwarder(
FallbackWriter, TrackingStore, NullLogger<CachedCallTelemetryForwarder>.Instance);
Dispatcher = new CombinedTelemetryDispatcher(InnerForwarder, StubClient);
Bridge = new CachedCallLifecycleBridge(Dispatcher, NullLogger<CachedCallLifecycleBridge>.Instance);
}
/// <summary>
/// Convenience: emit the initial submit packet directly through the
/// dispatcher (the bridge's hooks fire only for S&amp;F retry-loop
/// attempts; submit-row emission happens at the script call site).
/// </summary>
public Task EmitSubmitAsync(CachedCallTelemetry submit, CancellationToken ct = default) =>
Dispatcher.ForwardAsync(submit, ct);
/// <summary>
/// Convenience: route a per-attempt or terminal outcome through the bridge.
/// </summary>
public Task EmitAttemptAsync(CachedCallAttemptContext context, CancellationToken ct = default) =>
Bridge.OnAttemptCompletedAsync(context, ct);
public ScadaLinkDbContext CreateReadContext()
{
var options = new DbContextOptionsBuilder<ScadaLinkDbContext>()
.UseSqlServer(_fixture.ConnectionString)
.Options;
return new ScadaLinkDbContext(options);
}
private IServiceProvider BuildCentralServiceProvider(
Func<ScadaLinkDbContext, ISiteCallAuditRepository>? siteCallRepoOverride)
{
var services = new ServiceCollection();
services.AddDbContext<ScadaLinkDbContext>(opts =>
opts.UseSqlServer(_fixture.ConnectionString)
.ConfigureWarnings(w => w.Ignore(
Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.PendingModelChangesWarning)));
services.AddScoped<IAuditLogRepository>(sp =>
new AuditLogRepository(sp.GetRequiredService<ScadaLinkDbContext>()));
if (siteCallRepoOverride is null)
{
services.AddScoped<ISiteCallAuditRepository>(sp =>
new SiteCallAuditRepository(sp.GetRequiredService<ScadaLinkDbContext>()));
}
else
{
services.AddScoped(sp =>
siteCallRepoOverride(sp.GetRequiredService<ScadaLinkDbContext>()));
}
return services.BuildServiceProvider();
}
public async ValueTask DisposeAsync()
{
if (_disposed) return;
_disposed = true;
await SqliteWriter.DisposeAsync().ConfigureAwait(false);
await TrackingStore.DisposeAsync().ConfigureAwait(false);
if (ServiceProvider is IAsyncDisposable asyncSp)
{
await asyncSp.DisposeAsync().ConfigureAwait(false);
}
else if (ServiceProvider is IDisposable sp)
{
sp.Dispose();
}
}
}

View File

@@ -0,0 +1,177 @@
using Akka.Actor;
using ScadaLink.AuditLog.Site.Telemetry;
using ScadaLink.AuditLog.Telemetry;
using ScadaLink.Commons.Entities.Audit;
using ScadaLink.Commons.Messages.Audit;
using ScadaLink.Commons.Types;
using ScadaLink.Communication.Grpc;
namespace ScadaLink.AuditLog.Tests.Integration.Infrastructure;
/// <summary>
/// Shared component-level <see cref="ISiteStreamAuditClient"/> test double that
/// short-circuits the gRPC wire and forwards each batch directly to a central
/// <see cref="AuditLog.Central.AuditLogIngestActor"/> via Akka <see cref="Futures.Ask"/>.
/// Lives under <c>Integration/Infrastructure/</c> so both the M2 sync-call and
/// M3 cached-call end-to-end suites can reuse it.
/// </summary>
/// <remarks>
/// <para>
/// The class deliberately mirrors the production <c>SiteStreamGrpcServer</c>
/// flow: decode each DTO into the in-process entity, Ask the central ingest
/// actor with the matching Akka command, and convert the Akka reply's accepted
/// id list into the proto <see cref="IngestAck"/> the telemetry actor / forwarder
/// expects. The actor wiring (single-repository vs. <see cref="IServiceProvider"/>
/// ctor) lives in the central ingest actor itself — this stub just routes the
/// command.
/// </para>
/// <para>
/// <see cref="FailNextCallCount"/> arms a deterministic number of failures
/// before the stub recovers; it applies to BOTH RPCs because the M2 sync-call
/// retry behaviour and the M3 cached-telemetry retry behaviour share a single
/// SiteAuditTelemetryActor drain. Tests that need to differentiate per-RPC
/// failures should reach for a per-test wrapper rather than extending this
/// shared infrastructure.
/// </para>
/// </remarks>
public sealed class DirectActorSiteStreamAuditClient : ISiteStreamAuditClient
{
private readonly IActorRef _ingestActor;
private int _failsRemaining;
private int _callCount;
private int _cachedTelemetryCallCount;
public DirectActorSiteStreamAuditClient(IActorRef ingestActor)
{
_ingestActor = ingestActor ?? throw new ArgumentNullException(nameof(ingestActor));
}
/// <summary>
/// When &gt; 0, the next <c>FailNextCallCount</c> invocations of either
/// RPC throw to simulate a gRPC error; after that count is exhausted, calls
/// succeed normally.
/// </summary>
public int FailNextCallCount
{
get => _failsRemaining;
set => _failsRemaining = value;
}
/// <summary>
/// Total successful + failed invocations of <see cref="IngestAuditEventsAsync"/>.
/// </summary>
public int CallCount => Volatile.Read(ref _callCount);
/// <summary>
/// Total successful + failed invocations of <see cref="IngestCachedTelemetryAsync"/>.
/// Separate counter so cached-call tests can assert dispatch independently of
/// any sync-call traffic going through the same stub.
/// </summary>
public int CachedTelemetryCallCount => Volatile.Read(ref _cachedTelemetryCallCount);
public async Task<IngestAck> IngestAuditEventsAsync(AuditEventBatch batch, CancellationToken ct)
{
Interlocked.Increment(ref _callCount);
// Atomically consume one of the queued failures, if any. This lets the
// test arm a deterministic number of failures before the stub recovers.
if (Interlocked.Decrement(ref _failsRemaining) >= 0)
{
throw new InvalidOperationException("simulated gRPC failure for test");
}
// Clamp at -1 to keep the field bounded under many calls.
Interlocked.Exchange(ref _failsRemaining, -1);
// Decode the proto batch back into AuditEvent records — mirrors what
// SiteStreamGrpcServer does before dispatching to the ingest actor.
var events = new List<AuditEvent>(batch.Events.Count);
foreach (var dto in batch.Events)
{
events.Add(AuditEventMapper.FromDto(dto));
}
// Ask the central actor; the reply carries the accepted EventIds.
var reply = await _ingestActor
.Ask<IngestAuditEventsReply>(
new IngestAuditEventsCommand(events),
TimeSpan.FromSeconds(10))
.ConfigureAwait(false);
var ack = new IngestAck();
foreach (var id in reply.AcceptedEventIds)
{
ack.AcceptedEventIds.Add(id.ToString());
}
return ack;
}
/// <summary>
/// M3 dual-write path: decode each <see cref="CachedTelemetryPacket"/> into
/// the paired (<see cref="AuditEvent"/>, <see cref="SiteCall"/>) entry and
/// Ask the central ingest actor with an <see cref="IngestCachedTelemetryCommand"/>.
/// The accepted EventIds returned by the actor's dual-write transaction map
/// back into the proto ack.
/// </summary>
/// <remarks>
/// Uses the shared <see cref="AuditEventMapper.FromDto"/> for the audit half;
/// the SiteCall DTO is decoded inline because the AuditLog mapper does not
/// (and should not) know about <see cref="SiteCallOperationalDto"/> — the
/// production gRPC server (Bundle D) uses the same inline shape.
/// </remarks>
public async Task<IngestAck> IngestCachedTelemetryAsync(CachedTelemetryBatch batch, CancellationToken ct)
{
Interlocked.Increment(ref _cachedTelemetryCallCount);
if (Interlocked.Decrement(ref _failsRemaining) >= 0)
{
throw new InvalidOperationException("simulated gRPC failure for test");
}
Interlocked.Exchange(ref _failsRemaining, -1);
var entries = new List<CachedTelemetryEntry>(batch.Packets.Count);
foreach (var packet in batch.Packets)
{
var audit = AuditEventMapper.FromDto(packet.AuditEvent);
var siteCall = MapSiteCallFromDto(packet.Operational);
entries.Add(new CachedTelemetryEntry(audit, siteCall));
}
var reply = await _ingestActor
.Ask<IngestCachedTelemetryReply>(
new IngestCachedTelemetryCommand(entries),
TimeSpan.FromSeconds(10))
.ConfigureAwait(false);
var ack = new IngestAck();
foreach (var id in reply.AcceptedEventIds)
{
ack.AcceptedEventIds.Add(id.ToString());
}
return ack;
}
/// <summary>
/// Mirrors <c>SiteStreamGrpcServer.MapSiteCallFromDto</c> — keep the two in
/// sync. The placeholder <see cref="SiteCall.IngestedAtUtc"/> stamped here
/// is overwritten by the central ingest actor inside the dual-write
/// transaction, so the value sent on the wire is informational only.
/// </summary>
private static SiteCall MapSiteCallFromDto(SiteCallOperationalDto dto) => new()
{
TrackedOperationId = TrackedOperationId.Parse(dto.TrackedOperationId),
Channel = dto.Channel,
Target = dto.Target,
SourceSite = dto.SourceSite,
Status = dto.Status,
RetryCount = dto.RetryCount,
LastError = string.IsNullOrEmpty(dto.LastError) ? null : dto.LastError,
HttpStatus = dto.HttpStatus,
CreatedAtUtc = DateTime.SpecifyKind(dto.CreatedAtUtc.ToDateTime(), DateTimeKind.Utc),
UpdatedAtUtc = DateTime.SpecifyKind(dto.UpdatedAtUtc.ToDateTime(), DateTimeKind.Utc),
TerminalAtUtc = dto.TerminalAtUtc is null
? null
: DateTime.SpecifyKind(dto.TerminalAtUtc.ToDateTime(), DateTimeKind.Utc),
IngestedAtUtc = DateTime.UtcNow,
};
}

View File

@@ -6,15 +6,14 @@ using Microsoft.Extensions.Options;
using ScadaLink.AuditLog.Central;
using ScadaLink.AuditLog.Site;
using ScadaLink.AuditLog.Site.Telemetry;
using ScadaLink.AuditLog.Tests.Integration.Infrastructure;
using ScadaLink.Commons.Entities.Audit;
using ScadaLink.Commons.Interfaces.Repositories;
using ScadaLink.Commons.Messages.Audit;
using ScadaLink.Commons.Types.Audit;
using ScadaLink.Commons.Types.Enums;
using ScadaLink.ConfigurationDatabase;
using ScadaLink.ConfigurationDatabase.Repositories;
using ScadaLink.ConfigurationDatabase.Tests.Migrations;
using ScadaLink.Communication.Grpc;
namespace ScadaLink.AuditLog.Tests.Integration;
@@ -267,75 +266,4 @@ public class SyncCallEmissionEndToEndTests : TestKit, IClassFixture<MsSqlMigrati
}, TimeSpan.FromSeconds(15));
}
/// <summary>
/// Test double for <see cref="ISiteStreamAuditClient"/> that short-circuits
/// the gRPC wire and forwards the batch directly to a central
/// <see cref="AuditLogIngestActor"/> via Akka <see cref="Futures.Ask"/>. The
/// Akka <see cref="IngestAuditEventsReply"/> is converted to the proto
/// <see cref="IngestAck"/> that the telemetry actor expects.
/// </summary>
private sealed class DirectActorSiteStreamAuditClient : ISiteStreamAuditClient
{
private readonly IActorRef _ingestActor;
private int _failsRemaining;
private int _callCount;
public DirectActorSiteStreamAuditClient(IActorRef ingestActor)
{
_ingestActor = ingestActor ?? throw new ArgumentNullException(nameof(ingestActor));
}
/// <summary>
/// When &gt; 0, the next <c>FailNextCallCount</c> invocations of
/// <see cref="IngestAuditEventsAsync"/> throw to simulate a gRPC error;
/// after that count is exhausted, calls succeed normally.
/// </summary>
public int FailNextCallCount
{
get => _failsRemaining;
set => _failsRemaining = value;
}
public int CallCount => Volatile.Read(ref _callCount);
public async Task<IngestAck> IngestAuditEventsAsync(AuditEventBatch batch, CancellationToken ct)
{
Interlocked.Increment(ref _callCount);
// Atomically consume one of the queued failures, if any. This
// lets the test arm a deterministic number of failures before the
// stub recovers.
if (Interlocked.Decrement(ref _failsRemaining) >= 0)
{
throw new InvalidOperationException("simulated gRPC failure for test");
}
// Decrement under-ran into negative territory; clamp at -1 to keep
// the field bounded even under many calls.
Interlocked.Exchange(ref _failsRemaining, -1);
// Decode the proto batch back into AuditEvent records — this
// mirrors what the production SiteStreamGrpcServer does before
// dispatching to the ingest actor (see Bundle D's gRPC handler).
var events = new List<AuditEvent>(batch.Events.Count);
foreach (var dto in batch.Events)
{
events.Add(ScadaLink.AuditLog.Telemetry.AuditEventMapper.FromDto(dto));
}
// Ask the central actor; the reply carries the accepted EventIds.
var reply = await _ingestActor
.Ask<IngestAuditEventsReply>(
new IngestAuditEventsCommand(events),
TimeSpan.FromSeconds(10))
.ConfigureAwait(false);
var ack = new IngestAck();
foreach (var id in reply.AcceptedEventIds)
{
ack.AcceptedEventIds.Add(id.ToString());
}
return ack;
}
}
}

View File

@@ -48,6 +48,13 @@
the fixture + EF migrations come along without duplicating them.
-->
<ProjectReference Include="../ScadaLink.ConfigurationDatabase.Tests/ScadaLink.ConfigurationDatabase.Tests.csproj" />
<!--
G2/G3/G4: the cached-call combined telemetry integration tests compose the
production OperationTrackingStore (site SQLite source of truth for
Tracking.Status) alongside the M2 audit writer chain, so the harness
needs a project reference to SiteRuntime where the store lives.
-->
<ProjectReference Include="../../src/ScadaLink.SiteRuntime/ScadaLink.SiteRuntime.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,187 @@
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using ScadaLink.AuditLog.Site.Telemetry;
using ScadaLink.Commons.Interfaces.Services;
using ScadaLink.Commons.Messages.Integration;
using ScadaLink.Commons.Types;
using ScadaLink.Commons.Types.Enums;
namespace ScadaLink.AuditLog.Tests.Site.Telemetry;
/// <summary>
/// Bundle E Tasks E4/E5 bridge tests. The bridge ingests
/// <see cref="CachedCallAttemptContext"/> notifications from the S&amp;F
/// retry loop and routes them through <see cref="ICachedCallTelemetryForwarder"/>
/// as one or two <see cref="CachedCallTelemetry"/> packets:
/// <list type="bullet">
/// <item><description>Per-attempt: one <c>ApiCallCached</c>/<c>DbWriteCached</c> Attempted row.</description></item>
/// <item><description>Terminal (Delivered/PermanentFailure/ParkedMaxRetries): adds a CachedResolve row carrying the terminal Status.</description></item>
/// </list>
/// </summary>
public class CachedCallLifecycleBridgeTests
{
private readonly ICachedCallTelemetryForwarder _forwarder = Substitute.For<ICachedCallTelemetryForwarder>();
private readonly TrackedOperationId _id = TrackedOperationId.New();
private CachedCallLifecycleBridge CreateSut() => new(
_forwarder, NullLogger<CachedCallLifecycleBridge>.Instance);
private CachedCallAttemptContext Ctx(
CachedCallAttemptOutcome outcome,
string channel = "ApiOutbound",
int retryCount = 1,
string? lastError = null,
int? httpStatus = null) =>
new(
TrackedOperationId: _id,
Channel: channel,
Target: "ERP.GetOrder",
SourceSite: "site-77",
Outcome: outcome,
RetryCount: retryCount,
LastError: lastError,
HttpStatus: httpStatus,
CreatedAtUtc: new DateTime(2026, 5, 20, 9, 0, 0, DateTimeKind.Utc),
OccurredAtUtc: new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc),
DurationMs: 42,
SourceInstanceId: "Plant.Pump42");
[Fact]
public async Task TransientFailure_EmitsOneAttemptedRow_NoResolve()
{
var captured = new List<CachedCallTelemetry>();
_forwarder.ForwardAsync(Arg.Do<CachedCallTelemetry>(t => captured.Add(t)), Arg.Any<CancellationToken>())
.Returns(Task.CompletedTask);
var sut = CreateSut();
await sut.OnAttemptCompletedAsync(Ctx(
CachedCallAttemptOutcome.TransientFailure,
retryCount: 2,
lastError: "HTTP 503",
httpStatus: 503));
var packet = Assert.Single(captured);
Assert.Equal(AuditKind.ApiCallCached, packet.Audit.Kind);
Assert.Equal(AuditStatus.Attempted, packet.Audit.Status);
Assert.Equal(503, packet.Audit.HttpStatus);
Assert.Equal("HTTP 503", packet.Audit.ErrorMessage);
Assert.Equal(_id.Value, packet.Audit.CorrelationId);
Assert.Equal("Attempted", packet.Operational.Status);
Assert.Equal(2, packet.Operational.RetryCount);
Assert.Null(packet.Operational.TerminalAtUtc);
}
[Fact]
public async Task Delivered_EmitsAttemptedRow_AndCachedResolveDelivered()
{
var captured = new List<CachedCallTelemetry>();
_forwarder.ForwardAsync(Arg.Do<CachedCallTelemetry>(t => captured.Add(t)), Arg.Any<CancellationToken>())
.Returns(Task.CompletedTask);
var sut = CreateSut();
await sut.OnAttemptCompletedAsync(Ctx(CachedCallAttemptOutcome.Delivered));
Assert.Equal(2, captured.Count);
var attempted = captured[0];
Assert.Equal(AuditKind.ApiCallCached, attempted.Audit.Kind);
Assert.Equal(AuditStatus.Attempted, attempted.Audit.Status);
Assert.Equal("Attempted", attempted.Operational.Status);
Assert.Null(attempted.Operational.TerminalAtUtc);
var resolve = captured[1];
Assert.Equal(AuditKind.CachedResolve, resolve.Audit.Kind);
Assert.Equal(AuditStatus.Delivered, resolve.Audit.Status);
Assert.Equal("Delivered", resolve.Operational.Status);
Assert.NotNull(resolve.Operational.TerminalAtUtc);
Assert.Equal(_id.Value, resolve.Audit.CorrelationId);
}
[Fact]
public async Task PermanentFailure_EmitsAttempted_AndCachedResolveParked()
{
var captured = new List<CachedCallTelemetry>();
_forwarder.ForwardAsync(Arg.Do<CachedCallTelemetry>(t => captured.Add(t)), Arg.Any<CancellationToken>())
.Returns(Task.CompletedTask);
var sut = CreateSut();
await sut.OnAttemptCompletedAsync(Ctx(
CachedCallAttemptOutcome.PermanentFailure,
lastError: "Permanent failure (handler returned false)"));
Assert.Equal(2, captured.Count);
Assert.Equal(AuditKind.ApiCallCached, captured[0].Audit.Kind);
Assert.Equal(AuditKind.CachedResolve, captured[1].Audit.Kind);
Assert.Equal(AuditStatus.Parked, captured[1].Audit.Status);
Assert.Equal("Parked", captured[1].Operational.Status);
}
[Fact]
public async Task ParkedMaxRetries_EmitsAttempted_AndCachedResolveParked()
{
var captured = new List<CachedCallTelemetry>();
_forwarder.ForwardAsync(Arg.Do<CachedCallTelemetry>(t => captured.Add(t)), Arg.Any<CancellationToken>())
.Returns(Task.CompletedTask);
var sut = CreateSut();
await sut.OnAttemptCompletedAsync(Ctx(CachedCallAttemptOutcome.ParkedMaxRetries));
Assert.Equal(2, captured.Count);
Assert.Equal(AuditKind.CachedResolve, captured[1].Audit.Kind);
Assert.Equal(AuditStatus.Parked, captured[1].Audit.Status);
}
[Fact]
public async Task DbChannel_MapsToDbWriteCachedKind_AndDbOutboundChannel()
{
var captured = new List<CachedCallTelemetry>();
_forwarder.ForwardAsync(Arg.Do<CachedCallTelemetry>(t => captured.Add(t)), Arg.Any<CancellationToken>())
.Returns(Task.CompletedTask);
var sut = CreateSut();
await sut.OnAttemptCompletedAsync(Ctx(
CachedCallAttemptOutcome.Delivered, channel: "DbOutbound"));
Assert.Equal(2, captured.Count);
Assert.Equal(AuditKind.DbWriteCached, captured[0].Audit.Kind);
Assert.Equal(AuditChannel.DbOutbound, captured[0].Audit.Channel);
Assert.Equal("DbOutbound", captured[0].Operational.Channel);
Assert.Equal(AuditKind.CachedResolve, captured[1].Audit.Kind);
Assert.Equal(AuditChannel.DbOutbound, captured[1].Audit.Channel);
}
[Fact]
public async Task BridgeDoesNotThrow_WhenForwarderThrows()
{
_forwarder
.ForwardAsync(Arg.Any<CachedCallTelemetry>(), Arg.Any<CancellationToken>())
.Returns(Task.FromException(new InvalidOperationException("forwarder down")));
var sut = CreateSut();
// Must not throw — best-effort emission.
await sut.OnAttemptCompletedAsync(Ctx(CachedCallAttemptOutcome.Delivered));
}
[Fact]
public async Task BridgePopulatesProvenance_FromAttemptContext()
{
CachedCallTelemetry? captured = null;
_forwarder.ForwardAsync(Arg.Do<CachedCallTelemetry>(t => captured = t), Arg.Any<CancellationToken>())
.Returns(Task.CompletedTask);
var sut = CreateSut();
await sut.OnAttemptCompletedAsync(Ctx(
CachedCallAttemptOutcome.TransientFailure,
retryCount: 3,
lastError: "transient",
httpStatus: 500));
Assert.NotNull(captured);
Assert.Equal("site-77", captured!.Audit.SourceSiteId);
Assert.Equal("Plant.Pump42", captured.Audit.SourceInstanceId);
Assert.Equal("ERP.GetOrder", captured.Audit.Target);
Assert.Equal(42, captured.Audit.DurationMs);
Assert.Equal(_id.Value, captured.Audit.CorrelationId);
}
}

View File

@@ -0,0 +1,245 @@
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using NSubstitute.ExceptionExtensions;
using ScadaLink.AuditLog.Site.Telemetry;
using ScadaLink.Commons.Entities.Audit;
using ScadaLink.Commons.Interfaces;
using ScadaLink.Commons.Interfaces.Services;
using ScadaLink.Commons.Messages.Integration;
using ScadaLink.Commons.Types;
using ScadaLink.Commons.Types.Enums;
namespace ScadaLink.AuditLog.Tests.Site.Telemetry;
/// <summary>
/// Bundle E E2 tests for <see cref="CachedCallTelemetryForwarder"/>. The
/// forwarder is the site-side dual emitter: every cached-call lifecycle event
/// writes one <see cref="AuditEvent"/> to <see cref="IAuditWriter"/> and one
/// operational tracking-row mutation to <see cref="IOperationTrackingStore"/>.
/// Audit-emission contract: best-effort — a thrown writer or tracking store
/// must be logged and swallowed; the forwarder must never propagate the
/// exception to the calling script.
/// </summary>
public class CachedCallTelemetryForwarderTests
{
private readonly IAuditWriter _writer = Substitute.For<IAuditWriter>();
private readonly IOperationTrackingStore _tracking = Substitute.For<IOperationTrackingStore>();
private readonly TrackedOperationId _id = TrackedOperationId.New();
private readonly DateTime _now = new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc);
private CachedCallTelemetryForwarder CreateSut() => new(
_writer, _tracking, NullLogger<CachedCallTelemetryForwarder>.Instance);
private CachedCallTelemetry SubmitPacket() =>
new(
Audit: new AuditEvent
{
EventId = Guid.NewGuid(),
OccurredAtUtc = _now,
Channel = AuditChannel.ApiOutbound,
Kind = AuditKind.CachedSubmit,
CorrelationId = _id.Value,
SourceSiteId = "site-1",
SourceInstanceId = "inst-1",
SourceScript = "ScriptActor:doStuff",
Target = "ERP.GetOrder",
Status = AuditStatus.Submitted,
ForwardState = AuditForwardState.Pending,
},
Operational: new SiteCallOperational(
TrackedOperationId: _id,
Channel: "ApiOutbound",
Target: "ERP.GetOrder",
SourceSite: "site-1",
Status: "Submitted",
RetryCount: 0,
LastError: null,
HttpStatus: null,
CreatedAtUtc: _now,
UpdatedAtUtc: _now,
TerminalAtUtc: null));
private CachedCallTelemetry AttemptedPacket(int retryCount = 1, string? lastError = "HTTP 500", int? httpStatus = 500) =>
new(
Audit: new AuditEvent
{
EventId = Guid.NewGuid(),
OccurredAtUtc = _now,
Channel = AuditChannel.ApiOutbound,
Kind = AuditKind.ApiCallCached,
CorrelationId = _id.Value,
SourceSiteId = "site-1",
Target = "ERP.GetOrder",
Status = AuditStatus.Attempted,
HttpStatus = httpStatus,
ErrorMessage = lastError,
ForwardState = AuditForwardState.Pending,
},
Operational: new SiteCallOperational(
TrackedOperationId: _id,
Channel: "ApiOutbound",
Target: "ERP.GetOrder",
SourceSite: "site-1",
Status: "Attempted",
RetryCount: retryCount,
LastError: lastError,
HttpStatus: httpStatus,
CreatedAtUtc: _now,
UpdatedAtUtc: _now,
TerminalAtUtc: null));
private CachedCallTelemetry ResolvePacket(string status = "Delivered") =>
new(
Audit: new AuditEvent
{
EventId = Guid.NewGuid(),
OccurredAtUtc = _now,
Channel = AuditChannel.ApiOutbound,
Kind = AuditKind.CachedResolve,
CorrelationId = _id.Value,
SourceSiteId = "site-1",
Target = "ERP.GetOrder",
Status = Enum.Parse<AuditStatus>(status),
ForwardState = AuditForwardState.Pending,
},
Operational: new SiteCallOperational(
TrackedOperationId: _id,
Channel: "ApiOutbound",
Target: "ERP.GetOrder",
SourceSite: "site-1",
Status: status,
RetryCount: 2,
LastError: null,
HttpStatus: null,
CreatedAtUtc: _now,
UpdatedAtUtc: _now,
TerminalAtUtc: _now));
[Fact]
public async Task ForwardAsync_Submit_WritesAuditEvent_AndRecordsEnqueue()
{
var sut = CreateSut();
var packet = SubmitPacket();
await sut.ForwardAsync(packet, CancellationToken.None);
// Audit row: one WriteAsync of the submit event.
await _writer.Received(1).WriteAsync(
Arg.Is<AuditEvent>(e =>
e.EventId == packet.Audit.EventId
&& e.Kind == AuditKind.CachedSubmit
&& e.Status == AuditStatus.Submitted),
Arg.Any<CancellationToken>());
// Tracking row: insert-if-not-exists with kind discriminator.
await _tracking.Received(1).RecordEnqueueAsync(
_id,
"ApiOutbound",
"ERP.GetOrder",
"inst-1",
"ScriptActor:doStuff",
Arg.Any<CancellationToken>());
await _tracking.DidNotReceiveWithAnyArgs().RecordAttemptAsync(
default, default!, default, default, default, default);
await _tracking.DidNotReceiveWithAnyArgs().RecordTerminalAsync(
default, default!, default, default, default);
}
[Fact]
public async Task ForwardAsync_Attempted_WritesAuditEvent_AndRecordsAttempt()
{
var sut = CreateSut();
var packet = AttemptedPacket(retryCount: 2, lastError: "HTTP 503", httpStatus: 503);
await sut.ForwardAsync(packet, CancellationToken.None);
await _writer.Received(1).WriteAsync(
Arg.Is<AuditEvent>(e =>
e.EventId == packet.Audit.EventId
&& e.Kind == AuditKind.ApiCallCached
&& e.Status == AuditStatus.Attempted),
Arg.Any<CancellationToken>());
await _tracking.Received(1).RecordAttemptAsync(
_id, "Attempted", 2, "HTTP 503", 503, Arg.Any<CancellationToken>());
await _tracking.DidNotReceiveWithAnyArgs().RecordEnqueueAsync(
default, default!, default, default, default, default);
await _tracking.DidNotReceiveWithAnyArgs().RecordTerminalAsync(
default, default!, default, default, default);
}
[Fact]
public async Task ForwardAsync_Resolve_WritesAuditEvent_AndRecordsTerminal()
{
var sut = CreateSut();
var packet = ResolvePacket("Delivered");
await sut.ForwardAsync(packet, CancellationToken.None);
await _writer.Received(1).WriteAsync(
Arg.Is<AuditEvent>(e =>
e.EventId == packet.Audit.EventId
&& e.Kind == AuditKind.CachedResolve
&& e.Status == AuditStatus.Delivered),
Arg.Any<CancellationToken>());
await _tracking.Received(1).RecordTerminalAsync(
_id, "Delivered", null, null, Arg.Any<CancellationToken>());
await _tracking.DidNotReceiveWithAnyArgs().RecordEnqueueAsync(
default, default!, default, default, default, default);
await _tracking.DidNotReceiveWithAnyArgs().RecordAttemptAsync(
default, default!, default, default, default, default);
}
[Fact]
public async Task ForwardAsync_WriterThrows_Logs_DoesNotPropagate()
{
_writer.WriteAsync(Arg.Any<AuditEvent>(), Arg.Any<CancellationToken>())
.Throws(new InvalidOperationException("primary down"));
var sut = CreateSut();
// Must not throw.
await sut.ForwardAsync(SubmitPacket(), CancellationToken.None);
// Tracking still attempted — emission of the two halves is independent
// so a writer outage cannot starve the operational row (and vice-versa).
await _tracking.Received(1).RecordEnqueueAsync(
Arg.Any<TrackedOperationId>(),
Arg.Any<string>(),
Arg.Any<string?>(),
Arg.Any<string?>(),
Arg.Any<string?>(),
Arg.Any<CancellationToken>());
}
[Fact]
public async Task ForwardAsync_TrackingStoreThrows_Logs_DoesNotPropagate()
{
_tracking.RecordEnqueueAsync(
Arg.Any<TrackedOperationId>(),
Arg.Any<string>(),
Arg.Any<string?>(),
Arg.Any<string?>(),
Arg.Any<string?>(),
Arg.Any<CancellationToken>())
.Throws(new InvalidOperationException("sqlite locked"));
var sut = CreateSut();
await sut.ForwardAsync(SubmitPacket(), CancellationToken.None);
// Writer still attempted — emission halves are independent.
await _writer.Received(1).WriteAsync(
Arg.Any<AuditEvent>(), Arg.Any<CancellationToken>());
}
[Fact]
public async Task ForwardAsync_NullPacket_Throws()
{
var sut = CreateSut();
await Assert.ThrowsAsync<ArgumentNullException>(
() => sut.ForwardAsync(null!, CancellationToken.None));
}
}

View File

@@ -0,0 +1,58 @@
using ScadaLink.AuditLog.Site.Telemetry;
using ScadaLink.Communication.Grpc;
namespace ScadaLink.AuditLog.Tests.Site.Telemetry;
/// <summary>
/// Bundle E E1 tests for <see cref="NoOpSiteStreamAuditClient"/>. The NoOp
/// client is the default <see cref="ISiteStreamAuditClient"/> binding until M6
/// delivers the gRPC-backed implementation; both <c>IngestAuditEventsAsync</c>
/// (M2) and <c>IngestCachedTelemetryAsync</c> (M3) must return an empty ack
/// (no rows flipped to Forwarded) without throwing or partially handling the
/// batch.
/// </summary>
public class NoOpSiteStreamAuditClientTests
{
[Fact]
public async Task IngestCachedTelemetryAsync_EmptyBatch_ReturnsEmptyAck()
{
var sut = new NoOpSiteStreamAuditClient();
var batch = new CachedTelemetryBatch();
var ack = await sut.IngestCachedTelemetryAsync(batch, CancellationToken.None);
Assert.NotNull(ack);
Assert.Empty(ack.AcceptedEventIds);
}
[Fact]
public async Task IngestCachedTelemetryAsync_PopulatedBatch_ReturnsEmptyAck()
{
var sut = new NoOpSiteStreamAuditClient();
var batch = new CachedTelemetryBatch();
batch.Packets.Add(new CachedTelemetryPacket
{
AuditEvent = new AuditEventDto
{
EventId = Guid.NewGuid().ToString(),
Channel = "ApiOutbound",
Kind = "CachedSubmit",
Status = "Submitted",
},
});
var ack = await sut.IngestCachedTelemetryAsync(batch, CancellationToken.None);
// No EventIds flipped — NoOp does not forward to anyone.
Assert.Empty(ack.AcceptedEventIds);
}
[Fact]
public async Task IngestCachedTelemetryAsync_NullBatch_Throws()
{
var sut = new NoOpSiteStreamAuditClient();
await Assert.ThrowsAsync<ArgumentNullException>(
() => sut.IngestCachedTelemetryAsync(null!, CancellationToken.None));
}
}

View File

@@ -0,0 +1,203 @@
using ScadaLink.Commons.Entities.Audit;
using ScadaLink.Commons.Messages.Integration;
using ScadaLink.Commons.Types;
using ScadaLink.Commons.Types.Enums;
namespace ScadaLink.Commons.Tests.Messages.Integration;
/// <summary>
/// Audit Log #23 (M3 Bundle A — Task A4) — tests for the combined
/// audit + operational telemetry packet emitted per cached-call lifecycle event
/// (<c>Submit</c> → per-attempt <c>ApiCallCached</c> / <c>DbWriteCached</c> →
/// terminal <c>Resolve</c>). The site emits one packet per event; central writes
/// <c>AuditLog</c> + <c>SiteCalls</c> in one MS SQL transaction.
/// </summary>
public class CachedCallTelemetryTests
{
private static readonly DateTime FixedNowUtc = new(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc);
private const string SiteId = "site-77";
private const string InstanceName = "Plant.Pump42";
private const string SourceScript = "ScriptActor:OnTick";
private static AuditEvent BuildAuditEvent(
TrackedOperationId trackedId,
AuditKind kind,
AuditStatus status,
Guid? correlationId = null,
string? errorMessage = null,
int? httpStatus = null)
{
return new AuditEvent
{
EventId = Guid.NewGuid(),
OccurredAtUtc = FixedNowUtc,
Channel = AuditChannel.ApiOutbound,
Kind = kind,
CorrelationId = correlationId ?? trackedId.Value,
SourceSiteId = SiteId,
SourceInstanceId = InstanceName,
SourceScript = SourceScript,
Target = "ERP.GetOrder",
Status = status,
HttpStatus = httpStatus,
ErrorMessage = errorMessage,
PayloadTruncated = false,
ForwardState = AuditForwardState.Pending,
};
}
private static SiteCallOperational BuildOperational(
TrackedOperationId trackedId,
AuditStatus status,
int retryCount,
string? lastError = null,
int? httpStatus = null,
DateTime? terminalAtUtc = null)
{
return new SiteCallOperational(
TrackedOperationId: trackedId,
Channel: nameof(AuditChannel.ApiOutbound),
Target: "ERP.GetOrder",
SourceSite: SiteId,
Status: status.ToString(),
RetryCount: retryCount,
LastError: lastError,
HttpStatus: httpStatus,
CreatedAtUtc: FixedNowUtc,
UpdatedAtUtc: terminalAtUtc ?? FixedNowUtc,
TerminalAtUtc: terminalAtUtc);
}
[Fact]
public void SubmitPacket_AuditCarriesCachedSubmit_AndOperationalRetryCountZero()
{
var trackedId = TrackedOperationId.New();
var audit = BuildAuditEvent(trackedId, AuditKind.CachedSubmit, AuditStatus.Submitted);
var operational = BuildOperational(trackedId, AuditStatus.Submitted, retryCount: 0);
var packet = new CachedCallTelemetry(audit, operational);
Assert.Equal(AuditKind.CachedSubmit, packet.Audit.Kind);
Assert.Equal(AuditStatus.Submitted, packet.Audit.Status);
Assert.Equal(nameof(AuditStatus.Submitted), packet.Operational.Status);
Assert.Equal(0, packet.Operational.RetryCount);
Assert.Null(packet.Operational.TerminalAtUtc);
Assert.Equal(trackedId, packet.Operational.TrackedOperationId);
}
[Fact]
public void AttemptedPacket_AuditCarriesApiCallCached_RetryCountAlignsBetweenAuditAndOperational()
{
var trackedId = TrackedOperationId.New();
var audit = BuildAuditEvent(
trackedId,
AuditKind.ApiCallCached,
AuditStatus.Attempted,
errorMessage: "HTTP 503 from ERP",
httpStatus: 503);
var operational = BuildOperational(
trackedId,
AuditStatus.Attempted,
retryCount: 2,
lastError: "HTTP 503 from ERP",
httpStatus: 503);
var packet = new CachedCallTelemetry(audit, operational);
Assert.Equal(AuditKind.ApiCallCached, packet.Audit.Kind);
Assert.Equal(AuditStatus.Attempted, packet.Audit.Status);
Assert.Equal(nameof(AuditStatus.Attempted), packet.Operational.Status);
// Retry-count alignment: the operational row carries the canonical N;
// the audit row's error/http surface the same attempt's outcome.
Assert.Equal(packet.Audit.ErrorMessage, packet.Operational.LastError);
Assert.Equal(packet.Audit.HttpStatus, packet.Operational.HttpStatus);
Assert.Equal(2, packet.Operational.RetryCount);
Assert.Null(packet.Operational.TerminalAtUtc);
}
[Fact]
public void AttemptedPacket_DbWriteCached_CarriesDbWriteCachedKind()
{
var trackedId = TrackedOperationId.New();
var audit = BuildAuditEvent(
trackedId,
AuditKind.DbWriteCached,
AuditStatus.Attempted,
errorMessage: "Timeout",
httpStatus: null);
var operational = BuildOperational(
trackedId,
AuditStatus.Attempted,
retryCount: 1,
lastError: "Timeout");
var packet = new CachedCallTelemetry(audit, operational);
Assert.Equal(AuditKind.DbWriteCached, packet.Audit.Kind);
Assert.Equal(AuditStatus.Attempted, packet.Audit.Status);
Assert.Equal(1, packet.Operational.RetryCount);
}
[Theory]
[InlineData(AuditStatus.Delivered)]
[InlineData(AuditStatus.Failed)]
[InlineData(AuditStatus.Parked)]
[InlineData(AuditStatus.Discarded)]
public void ResolvePacket_AuditCarriesCachedResolve_OperationalTerminalAtUtcSet(AuditStatus terminalStatus)
{
var trackedId = TrackedOperationId.New();
var terminalAt = FixedNowUtc.AddMinutes(5);
var audit = BuildAuditEvent(trackedId, AuditKind.CachedResolve, terminalStatus);
var operational = BuildOperational(
trackedId,
terminalStatus,
retryCount: 3,
terminalAtUtc: terminalAt);
var packet = new CachedCallTelemetry(audit, operational);
Assert.Equal(AuditKind.CachedResolve, packet.Audit.Kind);
Assert.Equal(terminalStatus, packet.Audit.Status);
Assert.Equal(terminalStatus.ToString(), packet.Operational.Status);
Assert.NotNull(packet.Operational.TerminalAtUtc);
Assert.Equal(terminalAt, packet.Operational.TerminalAtUtc);
}
[Fact]
public void CachedCallTelemetry_RoundTripEquality()
{
var trackedId = TrackedOperationId.New();
var audit = BuildAuditEvent(trackedId, AuditKind.CachedSubmit, AuditStatus.Submitted);
var operational = BuildOperational(trackedId, AuditStatus.Submitted, retryCount: 0);
var a = new CachedCallTelemetry(audit, operational);
var b = new CachedCallTelemetry(audit, operational);
Assert.Equal(a, b);
Assert.Equal(a.GetHashCode(), b.GetHashCode());
var differentOperational = operational with { RetryCount = 1 };
var c = a with { Operational = differentOperational };
Assert.NotEqual(a, c);
Assert.Equal(0, a.Operational.RetryCount);
Assert.Equal(1, c.Operational.RetryCount);
}
[Fact]
public void SiteCallOperational_RoundTripEquality_AndWithExpression()
{
var trackedId = TrackedOperationId.New();
var a = BuildOperational(trackedId, AuditStatus.Submitted, retryCount: 0);
var b = BuildOperational(trackedId, AuditStatus.Submitted, retryCount: 0);
Assert.Equal(a, b);
Assert.Equal(a.GetHashCode(), b.GetHashCode());
var withDifferentRetry = a with { RetryCount = 5 };
Assert.NotEqual(a, withDifferentRetry);
Assert.Equal(0, a.RetryCount);
Assert.Equal(5, withDifferentRetry.RetryCount);
}
}

View File

@@ -0,0 +1,80 @@
using ScadaLink.Commons.Types;
namespace ScadaLink.Commons.Tests.Types;
/// <summary>
/// Audit Log #23 (M3): tests for the strongly-typed cached-operation identifier
/// produced by <c>ExternalSystem.CachedCall</c> / <c>Database.CachedWrite</c> and
/// surfaced to scripts via <c>Tracking.Status(id)</c>.
/// </summary>
public class TrackedOperationIdTests
{
[Fact]
public void New_ProducesUniqueIds()
{
var a = TrackedOperationId.New();
var b = TrackedOperationId.New();
Assert.NotEqual(a, b);
Assert.NotEqual(Guid.Empty, a.Value);
Assert.NotEqual(Guid.Empty, b.Value);
}
[Fact]
public void Parse_RoundTrip_PreservesValue()
{
var original = TrackedOperationId.New();
var serialized = original.ToString();
var parsed = TrackedOperationId.Parse(serialized);
Assert.Equal(original, parsed);
Assert.Equal(original.Value, parsed.Value);
}
[Fact]
public void TryParse_InvalidInput_ReturnsFalse()
{
Assert.False(TrackedOperationId.TryParse("not-a-guid", out var result));
Assert.Equal(default, result);
Assert.False(TrackedOperationId.TryParse(null, out var nullResult));
Assert.Equal(default, nullResult);
Assert.False(TrackedOperationId.TryParse(string.Empty, out var emptyResult));
Assert.Equal(default, emptyResult);
}
[Fact]
public void TryParse_ValidInput_ReturnsTrueAndId()
{
var original = TrackedOperationId.New();
var serialized = original.ToString();
Assert.True(TrackedOperationId.TryParse(serialized, out var parsed));
Assert.Equal(original, parsed);
}
[Fact]
public void Equality_BasedOnValue()
{
var guid = Guid.NewGuid();
var a = new TrackedOperationId(guid);
var b = new TrackedOperationId(guid);
Assert.Equal(a, b);
Assert.True(a == b);
Assert.False(a != b);
Assert.Equal(a.GetHashCode(), b.GetHashCode());
}
[Fact]
public void ToString_StandardGuidFormat()
{
var guid = Guid.Parse("12345678-1234-1234-1234-1234567890ab");
var id = new TrackedOperationId(guid);
// "D" format: 32 hex digits separated by hyphens (8-4-4-4-12).
Assert.Equal("12345678-1234-1234-1234-1234567890ab", id.ToString());
}
}

View File

@@ -0,0 +1,173 @@
using Google.Protobuf;
using Google.Protobuf.WellKnownTypes;
using ScadaLink.Communication.Grpc;
namespace ScadaLink.Communication.Tests.Protos;
/// <summary>
/// Wire-format round-trip tests for the Audit Log (#23) M3 cached-telemetry
/// proto messages (<see cref="SiteCallOperationalDto"/>,
/// <see cref="CachedTelemetryPacket"/>, <see cref="CachedTelemetryBatch"/>).
/// Locks the additive contract the central dual-write transaction depends on.
/// </summary>
public class CachedTelemetryProtoTests
{
private static AuditEventDto NewAuditDto(Guid? id = null) => new()
{
EventId = (id ?? Guid.NewGuid()).ToString(),
OccurredAtUtc = Timestamp.FromDateTimeOffset(
new DateTimeOffset(2026, 5, 20, 10, 15, 30, 123, TimeSpan.Zero)),
Channel = "ApiOutbound",
Kind = "CachedSubmit",
Status = "Submitted",
SourceSiteId = "site-1",
};
[Fact]
public void SiteCallOperationalDto_RoundTrip_PreservesAllFields()
{
var createdAt = Timestamp.FromDateTimeOffset(
new DateTimeOffset(2026, 5, 20, 10, 0, 0, TimeSpan.Zero));
var updatedAt = Timestamp.FromDateTimeOffset(
new DateTimeOffset(2026, 5, 20, 10, 5, 0, TimeSpan.Zero));
var terminalAt = Timestamp.FromDateTimeOffset(
new DateTimeOffset(2026, 5, 20, 10, 10, 0, TimeSpan.Zero));
var original = new SiteCallOperationalDto
{
TrackedOperationId = Guid.NewGuid().ToString(),
Channel = "ApiOutbound",
Target = "ERP.GetOrder",
SourceSite = "site-melbourne",
Status = "Delivered",
RetryCount = 3,
LastError = "transient 503",
HttpStatus = 200,
CreatedAtUtc = createdAt,
UpdatedAtUtc = updatedAt,
TerminalAtUtc = terminalAt,
};
var bytes = original.ToByteArray();
var deserialized = SiteCallOperationalDto.Parser.ParseFrom(bytes);
Assert.Equal(original.TrackedOperationId, deserialized.TrackedOperationId);
Assert.Equal(original.Channel, deserialized.Channel);
Assert.Equal(original.Target, deserialized.Target);
Assert.Equal(original.SourceSite, deserialized.SourceSite);
Assert.Equal(original.Status, deserialized.Status);
Assert.Equal(original.RetryCount, deserialized.RetryCount);
Assert.Equal(original.LastError, deserialized.LastError);
Assert.Equal(original.HttpStatus, deserialized.HttpStatus);
Assert.Equal(original.CreatedAtUtc, deserialized.CreatedAtUtc);
Assert.Equal(original.UpdatedAtUtc, deserialized.UpdatedAtUtc);
Assert.Equal(original.TerminalAtUtc, deserialized.TerminalAtUtc);
}
[Fact]
public void SiteCallOperationalDto_TerminalAt_AbsentWhenNotTerminal()
{
// Lifecycle events prior to the terminal step leave TerminalAtUtc unset;
// the well-known Timestamp wrapper is absent on the wire (null in C#).
var dto = new SiteCallOperationalDto
{
TrackedOperationId = Guid.NewGuid().ToString(),
Channel = "DbOutbound",
Target = "warehouse.dbo.WriteOrder",
SourceSite = "site-brisbane",
Status = "Attempted",
RetryCount = 1,
CreatedAtUtc = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow),
UpdatedAtUtc = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow),
};
Assert.Null(dto.TerminalAtUtc);
var bytes = dto.ToByteArray();
var deserialized = SiteCallOperationalDto.Parser.ParseFrom(bytes);
Assert.Null(deserialized.TerminalAtUtc);
}
[Fact]
public void SiteCallOperationalDto_NullableHttpStatus_AbsentByDefault()
{
// Int32Value wrapper-typed http_status — unset round-trips as null,
// matching DB nullable column semantics for non-API cached writes.
var dto = new SiteCallOperationalDto
{
TrackedOperationId = Guid.NewGuid().ToString(),
Channel = "DbOutbound",
Target = "warehouse.dbo.WriteOrder",
SourceSite = "site-brisbane",
Status = "Submitted",
RetryCount = 0,
CreatedAtUtc = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow),
UpdatedAtUtc = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow),
};
Assert.Null(dto.HttpStatus);
var bytes = dto.ToByteArray();
var deserialized = SiteCallOperationalDto.Parser.ParseFrom(bytes);
Assert.Null(deserialized.HttpStatus);
}
[Fact]
public void CachedTelemetryPacket_RoundTrip_PreservesNestedEntities()
{
var trackedOpId = Guid.NewGuid().ToString();
var auditDto = NewAuditDto();
auditDto.Target = "ERP.GetOrder";
auditDto.Status = "Attempted";
var operationalDto = new SiteCallOperationalDto
{
TrackedOperationId = trackedOpId,
Channel = "ApiOutbound",
Target = "ERP.GetOrder",
SourceSite = "site-1",
Status = "Attempted",
RetryCount = 2,
HttpStatus = 503,
LastError = "Service unavailable",
CreatedAtUtc = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow),
UpdatedAtUtc = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow),
};
var original = new CachedTelemetryPacket
{
AuditEvent = auditDto,
Operational = operationalDto,
};
var bytes = original.ToByteArray();
var deserialized = CachedTelemetryPacket.Parser.ParseFrom(bytes);
Assert.NotNull(deserialized.AuditEvent);
Assert.Equal(auditDto.EventId, deserialized.AuditEvent.EventId);
Assert.Equal(auditDto.Target, deserialized.AuditEvent.Target);
Assert.Equal(auditDto.Status, deserialized.AuditEvent.Status);
Assert.NotNull(deserialized.Operational);
Assert.Equal(trackedOpId, deserialized.Operational.TrackedOperationId);
Assert.Equal(operationalDto.Channel, deserialized.Operational.Channel);
Assert.Equal(operationalDto.Status, deserialized.Operational.Status);
Assert.Equal(operationalDto.RetryCount, deserialized.Operational.RetryCount);
Assert.Equal(operationalDto.HttpStatus, deserialized.Operational.HttpStatus);
Assert.Equal(operationalDto.LastError, deserialized.Operational.LastError);
}
[Fact]
public void CachedTelemetryBatch_Empty_RoundTrip_Yields_EmptyPackets()
{
var original = new CachedTelemetryBatch();
Assert.Empty(original.Packets);
var bytes = original.ToByteArray();
var deserialized = CachedTelemetryBatch.Parser.ParseFrom(bytes);
Assert.Empty(deserialized.Packets);
}
}

View File

@@ -0,0 +1,121 @@
using Akka.Actor;
using Akka.TestKit.Xunit2;
using Google.Protobuf.WellKnownTypes;
using Grpc.Core;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using ScadaLink.Commons.Messages.Audit;
using ScadaLink.Communication.Grpc;
namespace ScadaLink.Communication.Tests;
/// <summary>
/// Bundle D D2 tests for <see cref="SiteStreamGrpcServer.IngestCachedTelemetry"/>.
/// Verifies the DTO→entity→actor→ack round-trip through the gRPC handler. A
/// tiny <c>EchoCachedIngestActor</c> stands in for the central
/// <c>AuditLogIngestActor</c>, replying with the EventIds it received so the
/// test asserts the wiring without depending on MSSQL.
/// </summary>
public class SiteStreamIngestCachedTelemetryTests : TestKit
{
private readonly ISiteStreamSubscriber _subscriber = Substitute.For<ISiteStreamSubscriber>();
private SiteStreamGrpcServer CreateServer() =>
new(_subscriber, NullLogger<SiteStreamGrpcServer>.Instance);
private static ServerCallContext NewContext(CancellationToken ct = default)
{
var context = Substitute.For<ServerCallContext>();
context.CancellationToken.Returns(ct);
return context;
}
private static CachedTelemetryPacket NewPacket(Guid? eventId = null, Guid? trackedId = null)
{
var now = Timestamp.FromDateTime(
DateTime.SpecifyKind(new DateTime(2026, 5, 20, 10, 0, 0), DateTimeKind.Utc));
return new CachedTelemetryPacket
{
AuditEvent = new AuditEventDto
{
EventId = (eventId ?? Guid.NewGuid()).ToString(),
OccurredAtUtc = now,
Channel = "ApiOutbound",
Kind = "CachedSubmit",
Status = "Submitted",
SourceSiteId = "site-1",
CorrelationId = (trackedId ?? Guid.NewGuid()).ToString(),
},
Operational = new SiteCallOperationalDto
{
TrackedOperationId = (trackedId ?? Guid.NewGuid()).ToString(),
Channel = "ApiOutbound",
Target = "ERP.GetOrder",
SourceSite = "site-1",
Status = "Submitted",
RetryCount = 0,
CreatedAtUtc = now,
UpdatedAtUtc = now,
},
};
}
[Fact]
public async Task IngestCachedTelemetry_RoutesToActor_ReturnsReply()
{
// Arrange — stub actor that echoes every received EventId back.
var stubActor = Sys.ActorOf(Props.Create(() => new EchoCachedIngestActor()));
var server = CreateServer();
server.SetAuditIngestActor(stubActor);
var packets = Enumerable.Range(0, 3)
.Select(_ => NewPacket())
.ToList();
var batch = new CachedTelemetryBatch();
batch.Packets.AddRange(packets);
// Act
var ack = await server.IngestCachedTelemetry(batch, NewContext());
// Assert — every packet's EventId appears in the ack, demonstrating
// end-to-end routing through the actor.
Assert.Equal(3, ack.AcceptedEventIds.Count);
var expectedIds = packets.Select(p => p.AuditEvent.EventId).ToHashSet();
Assert.True(expectedIds.SetEquals(ack.AcceptedEventIds.ToHashSet()));
}
[Fact]
public async Task IngestCachedTelemetry_NoActorWired_ReturnsEmptyAck()
{
var server = CreateServer();
// Intentionally do NOT call SetAuditIngestActor — simulates host
// startup race window.
var batch = new CachedTelemetryBatch();
batch.Packets.Add(NewPacket());
var ack = await server.IngestCachedTelemetry(batch, NewContext());
Assert.Empty(ack.AcceptedEventIds);
}
/// <summary>
/// Tiny ReceiveActor that echoes every EventId in an incoming
/// <see cref="IngestCachedTelemetryCommand"/> back as an
/// <see cref="IngestCachedTelemetryReply"/>. Stands in for the central
/// AuditLogIngestActor so this test never touches MSSQL.
/// </summary>
private sealed class EchoCachedIngestActor : ReceiveActor
{
public EchoCachedIngestActor()
{
Receive<IngestCachedTelemetryCommand>(cmd =>
{
var ids = cmd.Entries.Select(e => e.Audit.EventId).ToList();
Sender.Tell(new IngestCachedTelemetryReply(ids));
});
}
}
}

View File

@@ -0,0 +1,109 @@
using Microsoft.EntityFrameworkCore;
using ScadaLink.Commons.Entities.Audit;
using ScadaLink.ConfigurationDatabase;
using ScadaLink.ConfigurationDatabase.Configurations;
namespace ScadaLink.ConfigurationDatabase.Tests.Configurations;
/// <summary>
/// Schema-level tests for <see cref="SiteCallEntityTypeConfiguration"/> (#22 / #23 M3 Bundle B).
/// Verifies the <see cref="SiteCall"/> record maps to the <c>SiteCalls</c> table with the
/// expected primary key, value conversion on <c>TrackedOperationId</c>, and the two named
/// indexes that back the "calls from this site" and "calls in this status" Central UI queries.
/// Mirrors the AuditLog Bundle B test pattern — inspects EF model metadata via the existing
/// in-memory SQLite test context, no database round-trips required.
/// </summary>
public class SiteCallEntityTypeConfigurationTests : IDisposable
{
private readonly ScadaLinkDbContext _context;
public SiteCallEntityTypeConfigurationTests()
{
_context = SqliteTestHelper.CreateInMemoryContext();
}
public void Dispose()
{
_context.Database.CloseConnection();
_context.Dispose();
}
[Fact]
public void Configure_MapsToSiteCallsTable()
{
var entity = _context.Model.FindEntityType(typeof(SiteCall));
Assert.NotNull(entity);
Assert.Equal("SiteCalls", entity!.GetTableName());
}
[Fact]
public void Configure_PrimaryKey_TrackedOperationId()
{
var entity = _context.Model.FindEntityType(typeof(SiteCall));
Assert.NotNull(entity);
var pk = entity!.FindPrimaryKey();
Assert.NotNull(pk);
var pkPropertyNames = pk!.Properties.Select(p => p.Name).ToArray();
Assert.Equal(new[] { nameof(SiteCall.TrackedOperationId) }, pkPropertyNames);
}
[Fact]
public void Configure_HasIndexes_NamedAndOrdered()
{
var entity = _context.Model.FindEntityType(typeof(SiteCall));
Assert.NotNull(entity);
var indexes = entity!.GetIndexes().ToList();
// IX_SiteCalls_Source_Created: (SourceSite ASC, CreatedAtUtc DESC).
var sourceCreated = indexes.SingleOrDefault(i => i.GetDatabaseName() == "IX_SiteCalls_Source_Created");
Assert.NotNull(sourceCreated);
var sourceCreatedProps = sourceCreated!.Properties.Select(p => p.Name).ToArray();
Assert.Equal(new[] { nameof(SiteCall.SourceSite), nameof(SiteCall.CreatedAtUtc) }, sourceCreatedProps);
// IX_SiteCalls_Status_Updated: (Status ASC, UpdatedAtUtc DESC).
var statusUpdated = indexes.SingleOrDefault(i => i.GetDatabaseName() == "IX_SiteCalls_Status_Updated");
Assert.NotNull(statusUpdated);
var statusUpdatedProps = statusUpdated!.Properties.Select(p => p.Name).ToArray();
Assert.Equal(new[] { nameof(SiteCall.Status), nameof(SiteCall.UpdatedAtUtc) }, statusUpdatedProps);
}
[Fact]
public void Configure_TrackedOperationId_ConvertedToString_Length36()
{
var entity = _context.Model.FindEntityType(typeof(SiteCall));
Assert.NotNull(entity);
var property = entity!.FindProperty(nameof(SiteCall.TrackedOperationId));
Assert.NotNull(property);
// Stored as varchar(36) (TrackedOperationId.ToString("D") is always 36 chars).
// The value-conversion target type is exposed via GetProviderClrType when set, or
// discovered indirectly through the configured converter; either way the on-wire
// CLR type is string.
var providerClrType = property!.GetProviderClrType() ?? property.GetValueConverter()?.ProviderClrType;
Assert.Equal(typeof(string), providerClrType);
Assert.Equal(36, property.GetMaxLength());
Assert.False(property.IsUnicode() ?? true);
}
[Theory]
[InlineData(nameof(SiteCall.Channel), 32)]
[InlineData(nameof(SiteCall.SourceSite), 64)]
[InlineData(nameof(SiteCall.Status), 32)]
[InlineData(nameof(SiteCall.Target), 256)]
public void Configure_AsciiBoundedColumns(string propertyName, int expectedMaxLength)
{
var entity = _context.Model.FindEntityType(typeof(SiteCall));
Assert.NotNull(entity);
var property = entity!.FindProperty(propertyName);
Assert.NotNull(property);
Assert.Equal(expectedMaxLength, property!.GetMaxLength());
Assert.False(property.IsUnicode() ?? true);
}
}

View File

@@ -0,0 +1,125 @@
using Microsoft.Data.SqlClient;
using Xunit;
namespace ScadaLink.ConfigurationDatabase.Tests.Migrations;
/// <summary>
/// Bundle B2 (#22, #23 M3) integration tests for the <c>AddSiteCallsTable</c>
/// migration: applies the EF migrations to a freshly-created MSSQL test database
/// on the running infra/mssql container and asserts that the resulting
/// <c>SiteCalls</c> table carries the expected columns, primary key, and the
/// two named operational indexes.
/// </summary>
/// <remarks>
/// Unlike <c>AddAuditLogTable</c>, the SiteCalls table is operational (mutable)
/// state — no partition function, no partition scheme, no DB-role restriction.
/// Standard <c>[PRIMARY]</c> filegroup. Tests pair <see cref="SkippableFactAttribute"/>
/// with <c>Skip.IfNot(...)</c> so the runner reports them as Skipped (not Passed)
/// when MSSQL is unreachable. The fixture applies the migration once at
/// construction time.
/// </remarks>
public class AddSiteCallsTableMigrationTests : IClassFixture<MsSqlMigrationFixture>
{
private readonly MsSqlMigrationFixture _fixture;
public AddSiteCallsTableMigrationTests(MsSqlMigrationFixture fixture)
{
_fixture = fixture;
}
[SkippableFact]
public async Task AppliesMigration_CreatesSiteCallsTable_WithExpectedColumns()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var exists = await ScalarAsync<int>(
"SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES " +
"WHERE TABLE_NAME = 'SiteCalls' AND TABLE_SCHEMA = 'dbo';");
Assert.Equal(1, exists);
// Every required column from SiteCall + IngestedAtUtc. We don't pin types
// here because EF's CreateTable layer already encodes them; the
// entity-config tests cover length / unicode / nullability for the
// value-converted PK column. Just confirm the schema has all twelve.
var expectedColumns = new[]
{
"TrackedOperationId",
"Channel",
"Target",
"SourceSite",
"Status",
"RetryCount",
"LastError",
"HttpStatus",
"CreatedAtUtc",
"UpdatedAtUtc",
"TerminalAtUtc",
"IngestedAtUtc",
};
foreach (var column in expectedColumns)
{
var present = await ScalarAsync<int>(
"SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS " +
$"WHERE TABLE_NAME = 'SiteCalls' AND COLUMN_NAME = '{column}';");
Assert.True(present == 1, $"Expected SiteCalls.{column} to exist; found {present}.");
}
}
[SkippableFact]
public async Task AppliesMigration_CreatesPK_OnTrackedOperationId()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
// Walk sys.indexes for the table's clustered PK index and confirm its
// single key column is TrackedOperationId. SiteCalls is non-partitioned
// so the PK is a simple single-column clustered index.
var pkColumn = await ScalarAsync<string?>(
"SELECT c.name FROM sys.indexes i " +
"INNER JOIN sys.objects o ON i.object_id = o.object_id " +
"INNER JOIN sys.index_columns ic ON i.object_id = ic.object_id AND i.index_id = ic.index_id " +
"INNER JOIN sys.columns c ON ic.object_id = c.object_id AND ic.column_id = c.column_id " +
"WHERE o.name = 'SiteCalls' AND i.is_primary_key = 1;");
Assert.Equal("TrackedOperationId", pkColumn);
}
[SkippableFact]
public async Task AppliesMigration_CreatesIndex_Source_Created()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var count = await ScalarAsync<int>(
"SELECT COUNT(*) FROM sys.indexes i " +
"INNER JOIN sys.objects o ON i.object_id = o.object_id " +
"WHERE o.name = 'SiteCalls' AND i.name = 'IX_SiteCalls_Source_Created';");
Assert.Equal(1, count);
}
[SkippableFact]
public async Task AppliesMigration_CreatesIndex_Status_Updated()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var count = await ScalarAsync<int>(
"SELECT COUNT(*) FROM sys.indexes i " +
"INNER JOIN sys.objects o ON i.object_id = o.object_id " +
"WHERE o.name = 'SiteCalls' AND i.name = 'IX_SiteCalls_Status_Updated';");
Assert.Equal(1, count);
}
// --- helpers ------------------------------------------------------------
private async Task<T> ScalarAsync<T>(string sql)
{
await using var conn = _fixture.OpenConnection();
await using var cmd = conn.CreateCommand();
cmd.CommandText = sql;
var result = await cmd.ExecuteScalarAsync();
if (result is null || result is DBNull)
{
return default!;
}
return (T)Convert.ChangeType(result, typeof(T) == typeof(string) ? typeof(string) : Nullable.GetUnderlyingType(typeof(T)) ?? typeof(T))!;
}
}

View File

@@ -0,0 +1,388 @@
using Microsoft.EntityFrameworkCore;
using ScadaLink.Commons.Entities.Audit;
using ScadaLink.Commons.Types;
using ScadaLink.Commons.Types.Audit;
using ScadaLink.ConfigurationDatabase;
using ScadaLink.ConfigurationDatabase.Repositories;
using ScadaLink.ConfigurationDatabase.Tests.Migrations;
using Xunit;
namespace ScadaLink.ConfigurationDatabase.Tests.Repositories;
/// <summary>
/// Bundle B3 (#22, #23 M3) integration tests for <see cref="SiteCallAuditRepository"/>.
/// Uses the same <see cref="MsSqlMigrationFixture"/> as the Bundle B2 migration tests so
/// the monotonic-upsert SQL executes against the real <c>SiteCalls</c> schema. Each test
/// scopes its data by minting a fresh <see cref="TrackedOperationId"/> (or a per-test
/// <c>SourceSite</c> suffix) so tests neither collide nor require teardown.
/// </summary>
public class SiteCallAuditRepositoryTests : IClassFixture<MsSqlMigrationFixture>
{
private readonly MsSqlMigrationFixture _fixture;
public SiteCallAuditRepositoryTests(MsSqlMigrationFixture fixture)
{
_fixture = fixture;
}
[SkippableFact]
public async Task UpsertAsync_FreshId_InsertsOneRow()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var id = TrackedOperationId.New();
await using var context = CreateContext();
var repo = new SiteCallAuditRepository(context);
var row = NewRow(id, status: "Submitted", retryCount: 0);
await repo.UpsertAsync(row);
await using var readContext = CreateContext();
var loaded = await readContext.Set<SiteCall>()
.Where(s => s.TrackedOperationId == id)
.ToListAsync();
Assert.Single(loaded);
Assert.Equal("Submitted", loaded[0].Status);
Assert.Equal(0, loaded[0].RetryCount);
}
[SkippableFact]
public async Task UpsertAsync_AdvancedStatus_UpdatesRow()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var id = TrackedOperationId.New();
await using var context = CreateContext();
var repo = new SiteCallAuditRepository(context);
// Submitted (rank 0) → Forwarded (rank 1) → Attempted (rank 2) — every
// step strictly advances the rank, so each upsert must mutate the row.
await repo.UpsertAsync(NewRow(id, status: "Submitted", retryCount: 0));
await repo.UpsertAsync(NewRow(id, status: "Forwarded", retryCount: 0));
await repo.UpsertAsync(NewRow(id, status: "Attempted", retryCount: 1, lastError: "transient 503"));
var loaded = await repo.GetAsync(id);
Assert.NotNull(loaded);
Assert.Equal("Attempted", loaded!.Status);
Assert.Equal(1, loaded.RetryCount);
Assert.Equal("transient 503", loaded.LastError);
}
[SkippableFact]
public async Task UpsertAsync_OlderStatus_IsNoOp_RowUnchanged()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var id = TrackedOperationId.New();
await using var context = CreateContext();
var repo = new SiteCallAuditRepository(context);
// First land Attempted (rank 2). A late-arriving Submitted (rank 0) must
// NOT roll the row back — silent no-op.
await repo.UpsertAsync(NewRow(id, status: "Attempted", retryCount: 5, lastError: "transient"));
var attemptedSnapshot = await repo.GetAsync(id);
await repo.UpsertAsync(NewRow(id, status: "Submitted", retryCount: 0, lastError: null));
var afterStale = await repo.GetAsync(id);
Assert.NotNull(afterStale);
Assert.Equal("Attempted", afterStale!.Status);
Assert.Equal(5, afterStale.RetryCount);
Assert.Equal("transient", afterStale.LastError);
// UpdatedAtUtc should not have moved when the stale write was rejected.
Assert.Equal(attemptedSnapshot!.UpdatedAtUtc, afterStale.UpdatedAtUtc);
}
[SkippableFact]
public async Task UpsertAsync_SameStatus_IsNoOp()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var id = TrackedOperationId.New();
await using var context = CreateContext();
var repo = new SiteCallAuditRepository(context);
await repo.UpsertAsync(NewRow(id, status: "Attempted", retryCount: 1, lastError: "first"));
var snapshot = await repo.GetAsync(id);
// Same rank (2) — repository must treat this as a no-op (no fields move).
await repo.UpsertAsync(NewRow(id, status: "Attempted", retryCount: 2, lastError: "second"));
var afterDuplicate = await repo.GetAsync(id);
Assert.NotNull(afterDuplicate);
Assert.Equal("Attempted", afterDuplicate!.Status);
Assert.Equal(1, afterDuplicate.RetryCount);
Assert.Equal("first", afterDuplicate.LastError);
Assert.Equal(snapshot!.UpdatedAtUtc, afterDuplicate.UpdatedAtUtc);
}
[SkippableFact]
public async Task UpsertAsync_TerminalOverTerminal_IsNoOp()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
// Bundle B3 plan: terminal statuses share rank 3 and are mutually
// exclusive — Delivered cannot overwrite Parked.
var id = TrackedOperationId.New();
await using var context = CreateContext();
var repo = new SiteCallAuditRepository(context);
await repo.UpsertAsync(NewRow(id, status: "Parked", retryCount: 3, lastError: "parked-reason", terminal: true));
var afterPark = await repo.GetAsync(id);
await repo.UpsertAsync(NewRow(id, status: "Delivered", retryCount: 4, lastError: null, terminal: true));
var afterDeliveredAttempt = await repo.GetAsync(id);
Assert.NotNull(afterDeliveredAttempt);
Assert.Equal("Parked", afterDeliveredAttempt!.Status);
Assert.Equal("parked-reason", afterDeliveredAttempt.LastError);
Assert.Equal(afterPark!.UpdatedAtUtc, afterDeliveredAttempt.UpdatedAtUtc);
}
[SkippableFact]
public async Task UpsertAsync_ConcurrentInserts_SameId_OnlyOneRow()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
// 50 parallel inserters with the same id. The IF NOT EXISTS … INSERT
// pattern has a check-then-act race; concurrent losers must surface as
// silent duplicate-key swallows, not thrown exceptions. Final row
// count must be exactly 1.
var id = TrackedOperationId.New();
var row = NewRow(id, status: "Submitted", retryCount: 0);
await Parallel.ForEachAsync(
Enumerable.Range(0, 50),
new ParallelOptions { MaxDegreeOfParallelism = 50 },
async (_, ct) =>
{
await using var context = CreateContext();
var repo = new SiteCallAuditRepository(context);
await repo.UpsertAsync(row, ct);
});
await using var readContext = CreateContext();
var count = await readContext.Set<SiteCall>()
.Where(s => s.TrackedOperationId == id)
.CountAsync();
Assert.Equal(1, count);
}
[SkippableFact]
public async Task GetAsync_KnownId_ReturnsRow()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var id = TrackedOperationId.New();
await using var context = CreateContext();
var repo = new SiteCallAuditRepository(context);
await repo.UpsertAsync(NewRow(id, status: "Submitted", retryCount: 0));
var loaded = await repo.GetAsync(id);
Assert.NotNull(loaded);
Assert.Equal(id, loaded!.TrackedOperationId);
}
[SkippableFact]
public async Task GetAsync_UnknownId_ReturnsNull()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
await using var context = CreateContext();
var repo = new SiteCallAuditRepository(context);
var loaded = await repo.GetAsync(TrackedOperationId.New());
Assert.Null(loaded);
}
[SkippableFact]
public async Task QueryAsync_FilterBySourceSite_ReturnsMatchingRows()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var siteA = NewSiteId();
var siteB = NewSiteId();
await using var context = CreateContext();
var repo = new SiteCallAuditRepository(context);
var t0 = new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc);
await repo.UpsertAsync(NewRow(TrackedOperationId.New(), sourceSite: siteA, createdAtUtc: t0));
await repo.UpsertAsync(NewRow(TrackedOperationId.New(), sourceSite: siteA, createdAtUtc: t0.AddMinutes(1)));
await repo.UpsertAsync(NewRow(TrackedOperationId.New(), sourceSite: siteB, createdAtUtc: t0.AddMinutes(2)));
var rows = await repo.QueryAsync(
new SiteCallQueryFilter(SourceSite: siteA),
new SiteCallPaging(PageSize: 10));
Assert.Equal(2, rows.Count);
Assert.All(rows, r => Assert.Equal(siteA, r.SourceSite));
}
[SkippableFact]
public async Task QueryAsync_KeysetPaging_NoOverlap()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var site = NewSiteId();
await using var context = CreateContext();
var repo = new SiteCallAuditRepository(context);
// Five rows with distinct CreatedAtUtc. Page-size 2 → page 1 returns
// minutes 4,3; cursor (minutes 3) → page 2 returns minutes 2,1; cursor
// (minutes 1) → page 3 returns minute 0.
var t0 = new DateTime(2026, 5, 20, 9, 0, 0, DateTimeKind.Utc);
for (var i = 0; i < 5; i++)
{
await repo.UpsertAsync(NewRow(TrackedOperationId.New(), sourceSite: site, createdAtUtc: t0.AddMinutes(i)));
}
var page1 = await repo.QueryAsync(
new SiteCallQueryFilter(SourceSite: site),
new SiteCallPaging(PageSize: 2));
Assert.Equal(2, page1.Count);
Assert.Equal(t0.AddMinutes(4), page1[0].CreatedAtUtc);
Assert.Equal(t0.AddMinutes(3), page1[1].CreatedAtUtc);
var cursor1 = page1[^1];
var page2 = await repo.QueryAsync(
new SiteCallQueryFilter(SourceSite: site),
new SiteCallPaging(
PageSize: 2,
AfterCreatedAtUtc: cursor1.CreatedAtUtc,
AfterId: cursor1.TrackedOperationId));
Assert.Equal(2, page2.Count);
Assert.Equal(t0.AddMinutes(2), page2[0].CreatedAtUtc);
Assert.Equal(t0.AddMinutes(1), page2[1].CreatedAtUtc);
var cursor2 = page2[^1];
var page3 = await repo.QueryAsync(
new SiteCallQueryFilter(SourceSite: site),
new SiteCallPaging(
PageSize: 2,
AfterCreatedAtUtc: cursor2.CreatedAtUtc,
AfterId: cursor2.TrackedOperationId));
Assert.Single(page3);
Assert.Equal(t0.AddMinutes(0), page3[0].CreatedAtUtc);
// No overlap across pages.
var allIds = page1.Concat(page2).Concat(page3).Select(r => r.TrackedOperationId).ToHashSet();
Assert.Equal(5, allIds.Count);
}
[SkippableFact]
public async Task PurgeTerminalAsync_RemovesTerminalAndOld()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var site = NewSiteId();
await using var context = CreateContext();
var repo = new SiteCallAuditRepository(context);
// One row that's been Delivered for a long time (5 days ago) — should be purged.
var oldId = TrackedOperationId.New();
var fiveDaysAgo = DateTime.UtcNow.AddDays(-5);
await repo.UpsertAsync(NewRow(
oldId,
sourceSite: site,
status: "Delivered",
retryCount: 1,
createdAtUtc: fiveDaysAgo.AddMinutes(-1),
updatedAtUtc: fiveDaysAgo,
terminal: true,
terminalAtUtc: fiveDaysAgo));
var purged = await repo.PurgeTerminalAsync(DateTime.UtcNow.AddDays(-1));
Assert.True(purged >= 1, $"Expected at least one purged row; got {purged}.");
Assert.Null(await repo.GetAsync(oldId));
}
[SkippableFact]
public async Task PurgeTerminalAsync_KeepsNonTerminalAndRecent()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var site = NewSiteId();
await using var context = CreateContext();
var repo = new SiteCallAuditRepository(context);
// Non-terminal row: never eligible.
var activeId = TrackedOperationId.New();
await repo.UpsertAsync(NewRow(
activeId,
sourceSite: site,
status: "Attempted",
retryCount: 1,
createdAtUtc: DateTime.UtcNow.AddDays(-10),
updatedAtUtc: DateTime.UtcNow.AddDays(-10),
terminal: false));
// Recent terminal row: TerminalAtUtc within the keep window.
var recentTerminalId = TrackedOperationId.New();
await repo.UpsertAsync(NewRow(
recentTerminalId,
sourceSite: site,
status: "Delivered",
retryCount: 0,
createdAtUtc: DateTime.UtcNow.AddHours(-2),
updatedAtUtc: DateTime.UtcNow.AddHours(-1),
terminal: true,
terminalAtUtc: DateTime.UtcNow.AddHours(-1)));
// Purge older than 1 day — both rows must survive.
await repo.PurgeTerminalAsync(DateTime.UtcNow.AddDays(-1));
Assert.NotNull(await repo.GetAsync(activeId));
Assert.NotNull(await repo.GetAsync(recentTerminalId));
}
// --- helpers ------------------------------------------------------------
private ScadaLinkDbContext CreateContext()
{
var options = new DbContextOptionsBuilder<ScadaLinkDbContext>()
.UseSqlServer(_fixture.ConnectionString)
.Options;
return new ScadaLinkDbContext(options);
}
private static string NewSiteId() =>
"site-b3-" + Guid.NewGuid().ToString("N").Substring(0, 8);
private static SiteCall NewRow(
TrackedOperationId id,
string? sourceSite = null,
string status = "Submitted",
int retryCount = 0,
string? lastError = null,
int? httpStatus = null,
DateTime? createdAtUtc = null,
DateTime? updatedAtUtc = null,
bool terminal = false,
DateTime? terminalAtUtc = null)
{
var created = createdAtUtc ?? DateTime.UtcNow;
var updated = updatedAtUtc ?? created;
DateTime? terminalAt = terminal
? (terminalAtUtc ?? updated)
: null;
return new SiteCall
{
TrackedOperationId = id,
Channel = "ApiOutbound",
Target = "ERP.GetOrder",
SourceSite = sourceSite ?? NewSiteId(),
Status = status,
RetryCount = retryCount,
LastError = lastError,
HttpStatus = httpStatus,
CreatedAtUtc = created,
UpdatedAtUtc = updated,
TerminalAtUtc = terminalAt,
IngestedAtUtc = DateTime.UtcNow,
};
}
}

View File

@@ -14,6 +14,7 @@ using ScadaLink.Commons.Interfaces.Services;
using ScadaLink.ConfigurationDatabase;
using ScadaLink.Host;
using ScadaLink.Host.Actors;
using ScadaLink.StoreAndForward;
namespace ScadaLink.Host.Tests;
@@ -189,6 +190,43 @@ public class CentralAuditWiringTests : IDisposable
Assert.NotNull(client);
Assert.IsType<NoOpSiteStreamAuditClient>(client);
}
/// <summary>
/// M3 Bundle F (T15): the Central composition root calls
/// <c>AddSiteCallAudit()</c>. Today that extension is a no-op placeholder,
/// but invoking it must not throw and the central host's service collection
/// must build successfully — the actor's Props are constructed inline in
/// <c>AkkaHostedService</c> (via the root <see cref="IServiceProvider"/>),
/// not from a DI factory. Asserting the host built confirms the wiring
/// call is in place; this test guards against accidentally removing it
/// from <c>Program.cs</c>.
/// </summary>
[Fact]
public void Central_HostBuilds_With_AddSiteCallAudit_Wired()
{
// Reaching _factory.Services means WebApplicationFactory built the host
// (DI validation completed). The fact this test is in the
// CentralAuditWiringTests fixture means it ran against the Central
// composition root path through Program.cs.
Assert.NotNull(_factory.Services);
}
/// <summary>
/// M3 Bundle F: the Central composition root registers
/// <c>ICachedCallTelemetryForwarder</c> as a lazy singleton (the
/// forwarder degrades to audit-only emission when the site-only
/// <c>IOperationTrackingStore</c> is absent, matching the M2 lazy chain
/// pattern). The binding is exercised here so a future regression that
/// removes the registration or makes IOperationTrackingStore mandatory
/// fails on the Central node, not just at first script execution.
/// </summary>
[Fact]
public void Central_Resolves_ICachedCallTelemetryForwarder_LazySingleton()
{
var forwarder = _factory.Services.GetService<ICachedCallTelemetryForwarder>();
Assert.NotNull(forwarder);
Assert.IsType<CachedCallTelemetryForwarder>(forwarder);
}
}
/// <summary>
@@ -303,4 +341,66 @@ public class SiteAuditWiringTests : IDisposable
Assert.Equal(5, opts.Value.BusyIntervalSeconds);
Assert.Equal(30, opts.Value.IdleIntervalSeconds);
}
/// <summary>
/// M3 Bundle F (T15): the site composition root resolves the cached-call
/// telemetry forwarder. ScriptExecutionActor consumes this through
/// <c>GetService&lt;ICachedCallTelemetryForwarder&gt;()</c> on every script
/// execution; a missing registration would silently degrade
/// <c>ExternalSystem.CachedCall</c> / <c>Database.CachedWrite</c> to the
/// "no-emission" path and break the M3 audit pipeline.
/// </summary>
[Fact]
public void Site_Resolves_ICachedCallTelemetryForwarder()
{
var forwarder = _host.Services.GetService<ICachedCallTelemetryForwarder>();
Assert.NotNull(forwarder);
Assert.IsType<CachedCallTelemetryForwarder>(forwarder);
}
/// <summary>
/// M3 Bundle F (T15): the site composition root resolves the lifecycle
/// bridge that translates S&amp;F retry-loop attempt notifications into
/// cached-call telemetry packets.
/// </summary>
[Fact]
public void Site_Resolves_CachedCallLifecycleBridge_AsSingleton()
{
var a = _host.Services.GetService<CachedCallLifecycleBridge>();
var b = _host.Services.GetService<CachedCallLifecycleBridge>();
Assert.NotNull(a);
Assert.NotNull(b);
Assert.Same(a, b);
}
/// <summary>
/// M3 Bundle F (T15): the lifecycle bridge is bound to the
/// <see cref="ICachedCallLifecycleObserver"/> contract that
/// StoreAndForwardService consults at construction time. Without this
/// binding the S&amp;F service is built with a null observer and the
/// retry-loop telemetry never reaches the audit pipeline.
/// </summary>
[Fact]
public void Site_ICachedCallLifecycleObserver_IsTheLifecycleBridge()
{
var observer = _host.Services.GetService<ICachedCallLifecycleObserver>();
var bridge = _host.Services.GetService<CachedCallLifecycleBridge>();
Assert.NotNull(observer);
Assert.NotNull(bridge);
Assert.Same(bridge, observer);
}
/// <summary>
/// M3 Bundle F (T15): the Host registers an
/// <see cref="IStoreAndForwardSiteContext"/> adapter so the S&amp;F service
/// can resolve the site id at composition time WITHOUT introducing a
/// StoreAndForward → HealthMonitoring project-reference cycle.
/// </summary>
[Fact]
public void Site_Resolves_IStoreAndForwardSiteContext_FromHost()
{
var ctx = _host.Services.GetService<IStoreAndForwardSiteContext>();
Assert.NotNull(ctx);
Assert.Equal("TestSite", ctx!.SiteId);
}
}

View File

@@ -0,0 +1,51 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Akka.TestKit.Xunit2" />
<PackageReference Include="coverlet.collector" />
<!--
MSSQL-backed SiteCallAuditActor tests use the MsSqlMigrationFixture
(reused from ConfigurationDatabase.Tests). Pinning 6.1.1 here mirrors
AuditLog.Tests: EF SqlServer 10.0.7 needs >= 6.1.1 but the central pin
is 6.0.2 (production ExternalSystemGateway). Override is test-only.
-->
<PackageReference Include="Microsoft.Data.SqlClient" VersionOverride="6.1.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio" />
<!--
SkippableFact pattern (xunit 2.9.x has no native Assert.Skip) — used by
the MSSQL-backed SiteCallAuditActor tests to report Skipped when the dev
MSSQL container is not reachable.
-->
<PackageReference Include="Xunit.SkippableFact" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../src/ScadaLink.SiteCallAudit/ScadaLink.SiteCallAudit.csproj" />
<!--
The actor tests use the real SiteCallAuditRepository against a per-test
MSSQL database via MsSqlMigrationFixture. The fixture lives in
ScadaLink.ConfigurationDatabase.Tests; we reference that test project so
the fixture + EF migrations come along without duplicating them
(same pattern as ScadaLink.AuditLog.Tests' Bundle D2).
-->
<ProjectReference Include="../ScadaLink.ConfigurationDatabase.Tests/ScadaLink.ConfigurationDatabase.Tests.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,221 @@
using Akka.Actor;
using Akka.TestKit.Xunit2;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using ScadaLink.Commons.Entities.Audit;
using ScadaLink.Commons.Interfaces.Repositories;
using ScadaLink.Commons.Messages.Audit;
using ScadaLink.Commons.Types;
using ScadaLink.Commons.Types.Audit;
using ScadaLink.ConfigurationDatabase;
using ScadaLink.ConfigurationDatabase.Repositories;
using ScadaLink.ConfigurationDatabase.Tests.Migrations;
namespace ScadaLink.SiteCallAudit.Tests;
/// <summary>
/// Bundle C1 (#22, #23 M3) tests for <see cref="SiteCallAuditActor"/>. Uses the
/// same <see cref="MsSqlMigrationFixture"/> as the Bundle B3 repository tests
/// so the actor exercises the real monotonic-upsert SQL end to end against the
/// <c>SiteCalls</c> schema. Each test scopes its data by minting a fresh
/// <see cref="TrackedOperationId"/> (and a per-test <c>SourceSite</c> suffix)
/// so tests neither collide nor require teardown.
/// </summary>
public class SiteCallAuditActorTests : TestKit, IClassFixture<MsSqlMigrationFixture>
{
private readonly MsSqlMigrationFixture _fixture;
public SiteCallAuditActorTests(MsSqlMigrationFixture fixture)
{
_fixture = fixture;
}
private ScadaLinkDbContext CreateContext()
{
var options = new DbContextOptionsBuilder<ScadaLinkDbContext>()
.UseSqlServer(_fixture.ConnectionString)
.Options;
return new ScadaLinkDbContext(options);
}
private static string NewSiteId() =>
"test-bundle-c1-" + Guid.NewGuid().ToString("N").Substring(0, 8);
private static SiteCall NewRow(
TrackedOperationId id,
string sourceSite,
string status = "Submitted",
int retryCount = 0,
string? lastError = null,
DateTime? createdAtUtc = null,
DateTime? updatedAtUtc = null,
bool terminal = false)
{
var created = createdAtUtc ?? DateTime.UtcNow;
var updated = updatedAtUtc ?? created;
return new SiteCall
{
TrackedOperationId = id,
Channel = "ApiOutbound",
Target = "ERP.GetOrder",
SourceSite = sourceSite,
Status = status,
RetryCount = retryCount,
LastError = lastError,
HttpStatus = null,
CreatedAtUtc = created,
UpdatedAtUtc = updated,
TerminalAtUtc = terminal ? updated : null,
IngestedAtUtc = DateTime.UtcNow,
};
}
private IActorRef CreateActor(ISiteCallAuditRepository repository) =>
Sys.ActorOf(Props.Create(() => new SiteCallAuditActor(
repository,
NullLogger<SiteCallAuditActor>.Instance)));
[SkippableFact]
public async Task Receive_UpsertSiteCallCommand_Persists_Replies_Accepted()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var siteId = NewSiteId();
var id = TrackedOperationId.New();
var row = NewRow(id, siteId, status: "Submitted", retryCount: 0);
await using var context = CreateContext();
var repo = new SiteCallAuditRepository(context);
var actor = CreateActor(repo);
actor.Tell(new UpsertSiteCallCommand(row), TestActor);
var reply = ExpectMsg<UpsertSiteCallReply>(TimeSpan.FromSeconds(10));
Assert.True(reply.Accepted, "Actor should reply Accepted=true on a successful upsert.");
Assert.Equal(id, reply.TrackedOperationId);
// Verify the row landed in MSSQL via a fresh context (separate from the
// actor's repository context).
await using var readContext = CreateContext();
var rows = await readContext.Set<SiteCall>()
.Where(s => s.SourceSite == siteId)
.ToListAsync();
Assert.Single(rows);
Assert.Equal("Submitted", rows[0].Status);
}
[SkippableFact]
public async Task Receive_DuplicateUpsert_OlderStatus_NoOp_StillRepliesAccepted()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
// Idempotency contract: a stale/duplicate packet (lower rank than the
// stored status) is a silent no-op at the repository — the actor must
// still reply Accepted=true so the site is free to consider its
// packet acked. Storage state is consistent either way.
var siteId = NewSiteId();
var id = TrackedOperationId.New();
await using var context = CreateContext();
var repo = new SiteCallAuditRepository(context);
var actor = CreateActor(repo);
// Land Attempted (rank 2) first.
actor.Tell(new UpsertSiteCallCommand(NewRow(id, siteId, status: "Attempted", retryCount: 1, lastError: "first")), TestActor);
var firstReply = ExpectMsg<UpsertSiteCallReply>(TimeSpan.FromSeconds(10));
Assert.True(firstReply.Accepted);
// Late-arriving Submitted (rank 0) — must be no-op in storage and
// still acked by the actor.
actor.Tell(new UpsertSiteCallCommand(NewRow(id, siteId, status: "Submitted", retryCount: 0)), TestActor);
var secondReply = ExpectMsg<UpsertSiteCallReply>(TimeSpan.FromSeconds(10));
Assert.True(secondReply.Accepted, "Stale upsert must still be acked (idempotent contract).");
// Storage must still show the rank-2 row, not rolled back.
await using var readContext = CreateContext();
var stored = await readContext.Set<SiteCall>()
.Where(s => s.TrackedOperationId == id)
.ToListAsync();
Assert.Single(stored);
Assert.Equal("Attempted", stored[0].Status);
Assert.Equal(1, stored[0].RetryCount);
Assert.Equal("first", stored[0].LastError);
}
[SkippableFact]
public async Task Receive_RepoThrowsTransient_RepliesAccepted_False_ActorStaysAlive()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
// Per CLAUDE.md: audit-write failure NEVER aborts the user-facing
// action. The actor must catch the throw, reply Accepted=false, and
// stay alive — a follow-up message on the same actor must still be
// processed (the singleton cannot die on a transient repo error).
var siteId = NewSiteId();
var poisonId = TrackedOperationId.New();
var healthyId = TrackedOperationId.New();
await using var context = CreateContext();
var realRepo = new SiteCallAuditRepository(context);
var wrappedRepo = new ThrowingRepository(realRepo, poisonId);
var actor = CreateActor(wrappedRepo);
// Poison row — the wrapper throws when this id arrives.
actor.Tell(new UpsertSiteCallCommand(NewRow(poisonId, siteId, status: "Submitted")), TestActor);
var poisonReply = ExpectMsg<UpsertSiteCallReply>(TimeSpan.FromSeconds(10));
Assert.False(poisonReply.Accepted, "Actor should reply Accepted=false when the repo throws.");
Assert.Equal(poisonId, poisonReply.TrackedOperationId);
// Healthy follow-up on the SAME actor — must still be processed
// (singleton staying alive proves the actor did not crash).
actor.Tell(new UpsertSiteCallCommand(NewRow(healthyId, siteId, status: "Submitted")), TestActor);
var healthyReply = ExpectMsg<UpsertSiteCallReply>(TimeSpan.FromSeconds(10));
Assert.True(healthyReply.Accepted, "Actor must stay alive after a transient repo failure.");
Assert.Equal(healthyId, healthyReply.TrackedOperationId);
// Verify storage: healthy row landed, poison row did not.
await using var readContext = CreateContext();
var rows = await readContext.Set<SiteCall>()
.Where(s => s.SourceSite == siteId)
.ToListAsync();
Assert.Single(rows);
Assert.Equal(healthyId, rows[0].TrackedOperationId);
}
/// <summary>
/// Tiny test double that delegates to a real repository but throws on a
/// specified <see cref="TrackedOperationId"/>. Used to verify the actor's
/// fault-isolation behaviour: a transient repository failure must produce
/// <c>Accepted=false</c> without crashing the singleton.
/// </summary>
private sealed class ThrowingRepository : ISiteCallAuditRepository
{
private readonly ISiteCallAuditRepository _inner;
private readonly TrackedOperationId _poisonId;
public ThrowingRepository(ISiteCallAuditRepository inner, TrackedOperationId poisonId)
{
_inner = inner;
_poisonId = poisonId;
}
public Task UpsertAsync(SiteCall siteCall, CancellationToken ct = default)
{
if (siteCall.TrackedOperationId == _poisonId)
{
throw new InvalidOperationException("simulated transient repo failure for poison row");
}
return _inner.UpsertAsync(siteCall, ct);
}
public Task<SiteCall?> GetAsync(TrackedOperationId id, CancellationToken ct = default) =>
_inner.GetAsync(id, ct);
public Task<IReadOnlyList<SiteCall>> QueryAsync(
SiteCallQueryFilter filter, SiteCallPaging paging, CancellationToken ct = default) =>
_inner.QueryAsync(filter, paging, ct);
public Task<int> PurgeTerminalAsync(DateTime olderThanUtc, CancellationToken ct = default) =>
_inner.PurgeTerminalAsync(olderThanUtc, ct);
}
}

View File

@@ -0,0 +1,170 @@
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using ScadaLink.Commons.Entities.Audit;
using ScadaLink.Commons.Interfaces.Services;
using ScadaLink.Commons.Messages.Integration;
using ScadaLink.Commons.Types;
using ScadaLink.Commons.Types.Enums;
using ScadaLink.SiteRuntime.Scripts;
namespace ScadaLink.SiteRuntime.Tests.Scripts;
/// <summary>
/// Audit Log #23 — M3 Bundle E (Task E6): every script-initiated
/// <c>Database.CachedWrite</c> emits exactly one <c>CachedSubmit</c>
/// combined-telemetry packet at enqueue time on the <c>DbOutbound</c>
/// channel, returns a fresh <see cref="TrackedOperationId"/>, and threads
/// the id into the database gateway so the store-and-forward retry loop can
/// emit per-attempt + terminal telemetry under the same id.
/// </summary>
public class DatabaseCachedWriteEmissionTests
{
private sealed class CapturingForwarder : ICachedCallTelemetryForwarder
{
public List<CachedCallTelemetry> Telemetry { get; } = new();
public Exception? ThrowOnForward { get; set; }
public Task ForwardAsync(CachedCallTelemetry telemetry, CancellationToken ct = default)
{
if (ThrowOnForward != null)
{
return Task.FromException(ThrowOnForward);
}
Telemetry.Add(telemetry);
return Task.CompletedTask;
}
}
private const string SiteId = "site-77";
private const string InstanceName = "Plant.Pump42";
private const string SourceScript = "ScriptActor:WriteAudit";
private static ScriptRuntimeContext.DatabaseHelper CreateHelper(
IDatabaseGateway gateway,
ICachedCallTelemetryForwarder? forwarder)
{
return new ScriptRuntimeContext.DatabaseHelper(
gateway,
InstanceName,
NullLogger.Instance,
siteId: SiteId,
sourceScript: SourceScript,
cachedForwarder: forwarder);
}
[Fact]
public async Task CachedWrite_EmitsSubmitTelemetry_OnEnqueue_KindCachedSubmit_ChannelDbOutbound()
{
var gateway = new Mock<IDatabaseGateway>();
gateway
.Setup(g => g.CachedWriteAsync(
"myDb", "INSERT INTO t VALUES (1)",
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
InstanceName,
It.IsAny<CancellationToken>(),
It.IsAny<TrackedOperationId?>()))
.Returns(Task.CompletedTask);
var forwarder = new CapturingForwarder();
var helper = CreateHelper(gateway.Object, forwarder);
var trackedId = await helper.CachedWrite("myDb", "INSERT INTO t VALUES (1)");
Assert.NotEqual(default, trackedId);
var packet = Assert.Single(forwarder.Telemetry);
Assert.Equal(AuditChannel.DbOutbound, packet.Audit.Channel);
Assert.Equal(AuditKind.CachedSubmit, packet.Audit.Kind);
Assert.Equal(AuditStatus.Submitted, packet.Audit.Status);
Assert.Equal("myDb", packet.Audit.Target);
Assert.Equal(trackedId.Value, packet.Audit.CorrelationId);
Assert.Equal(trackedId, packet.Operational.TrackedOperationId);
Assert.Equal("DbOutbound", packet.Operational.Channel);
Assert.Equal("myDb", packet.Operational.Target);
Assert.Equal(SiteId, packet.Operational.SourceSite);
Assert.Equal("Submitted", packet.Operational.Status);
Assert.Equal(0, packet.Operational.RetryCount);
Assert.Null(packet.Operational.TerminalAtUtc);
}
[Fact]
public async Task CachedWrite_ProvenancePopulated()
{
var gateway = new Mock<IDatabaseGateway>();
gateway
.Setup(g => g.CachedWriteAsync(
It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
It.IsAny<string?>(),
It.IsAny<CancellationToken>(),
It.IsAny<TrackedOperationId?>()))
.Returns(Task.CompletedTask);
var forwarder = new CapturingForwarder();
var helper = CreateHelper(gateway.Object, forwarder);
await helper.CachedWrite("myDb", "INSERT INTO t VALUES (1)");
var packet = Assert.Single(forwarder.Telemetry);
Assert.Equal(SiteId, packet.Audit.SourceSiteId);
Assert.Equal(InstanceName, packet.Audit.SourceInstanceId);
Assert.Equal(SourceScript, packet.Audit.SourceScript);
Assert.Equal(SiteId, packet.Operational.SourceSite);
}
[Fact]
public async Task CachedWrite_ReturnsTrackedOperationId_ThreadsIdToGateway()
{
var gateway = new Mock<IDatabaseGateway>();
gateway
.Setup(g => g.CachedWriteAsync(
It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
It.IsAny<string?>(),
It.IsAny<CancellationToken>(),
It.IsAny<TrackedOperationId?>()))
.Returns(Task.CompletedTask);
var forwarder = new CapturingForwarder();
var helper = CreateHelper(gateway.Object, forwarder);
var trackedId = await helper.CachedWrite("myDb", "INSERT INTO t VALUES (1)");
Assert.NotEqual(default, trackedId);
gateway.Verify(g => g.CachedWriteAsync(
"myDb", "INSERT INTO t VALUES (1)",
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
InstanceName,
It.IsAny<CancellationToken>(),
trackedId),
Times.Once);
}
[Fact]
public async Task CachedWrite_ForwarderThrows_StillReturnsTrackedOperationId()
{
var gateway = new Mock<IDatabaseGateway>();
gateway
.Setup(g => g.CachedWriteAsync(
It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
It.IsAny<string?>(),
It.IsAny<CancellationToken>(),
It.IsAny<TrackedOperationId?>()))
.Returns(Task.CompletedTask);
var forwarder = new CapturingForwarder
{
ThrowOnForward = new InvalidOperationException("simulated forwarder outage"),
};
var helper = CreateHelper(gateway.Object, forwarder);
var trackedId = await helper.CachedWrite("myDb", "INSERT INTO t VALUES (1)");
Assert.NotEqual(default, trackedId);
gateway.Verify(g => g.CachedWriteAsync(
"myDb", "INSERT INTO t VALUES (1)",
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
InstanceName,
It.IsAny<CancellationToken>(),
trackedId),
Times.Once);
}
}

View File

@@ -0,0 +1,358 @@
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using ScadaLink.Commons.Entities.Audit;
using ScadaLink.Commons.Interfaces.Services;
using ScadaLink.Commons.Messages.Integration;
using ScadaLink.Commons.Types;
using ScadaLink.Commons.Types.Enums;
using ScadaLink.SiteRuntime.Scripts;
namespace ScadaLink.SiteRuntime.Tests.Scripts;
/// <summary>
/// Audit Log #23 — M3 Bundle E (Task E3): every script-initiated
/// <c>ExternalSystem.CachedCall</c> emits exactly one <c>CachedSubmit</c>
/// combined-telemetry packet at enqueue time, returns a fresh
/// <see cref="TrackedOperationId"/>, and threads that id down to the
/// store-and-forward layer so the retry-loop emissions (Tasks E4/E5) can join
/// them by id. The audit emission is best-effort: a thrown forwarder must
/// never abort the script's call, and the original
/// <see cref="ExternalCallResult"/> must surface to the caller unchanged.
/// </summary>
public class ExternalSystemCachedCallEmissionTests
{
private sealed class CapturingForwarder : ICachedCallTelemetryForwarder
{
public List<CachedCallTelemetry> Telemetry { get; } = new();
public Exception? ThrowOnForward { get; set; }
public Task ForwardAsync(CachedCallTelemetry telemetry, CancellationToken ct = default)
{
if (ThrowOnForward != null)
{
return Task.FromException(ThrowOnForward);
}
Telemetry.Add(telemetry);
return Task.CompletedTask;
}
}
private const string SiteId = "site-77";
private const string InstanceName = "Plant.Pump42";
private const string SourceScript = "ScriptActor:CheckPressure";
private static ScriptRuntimeContext.ExternalSystemHelper CreateHelper(
IExternalSystemClient client,
ICachedCallTelemetryForwarder? forwarder)
{
return new ScriptRuntimeContext.ExternalSystemHelper(
client,
InstanceName,
NullLogger.Instance,
auditWriter: null,
siteId: SiteId,
sourceScript: SourceScript,
cachedForwarder: forwarder);
}
[Fact]
public async Task CachedCall_EmitsSubmitTelemetry_OnEnqueue()
{
var client = new Mock<IExternalSystemClient>();
client
.Setup(c => c.CachedCallAsync(
"ERP", "GetOrder",
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
InstanceName,
It.IsAny<CancellationToken>(),
It.IsAny<TrackedOperationId?>()))
.ReturnsAsync(new ExternalCallResult(true, null, null, WasBuffered: true));
var forwarder = new CapturingForwarder();
var helper = CreateHelper(client.Object, forwarder);
var trackedId = await helper.CachedCall("ERP", "GetOrder");
Assert.NotEqual(default, trackedId);
Assert.Single(forwarder.Telemetry);
var packet = forwarder.Telemetry[0];
Assert.Equal(AuditChannel.ApiOutbound, packet.Audit.Channel);
Assert.Equal(AuditKind.CachedSubmit, packet.Audit.Kind);
Assert.Equal(AuditStatus.Submitted, packet.Audit.Status);
Assert.Equal("ERP.GetOrder", packet.Audit.Target);
Assert.Equal(trackedId.Value, packet.Audit.CorrelationId);
Assert.Equal(AuditForwardState.Pending, packet.Audit.ForwardState);
// Operational mirror — same id, Submitted, RetryCount 0, not terminal.
Assert.Equal(trackedId, packet.Operational.TrackedOperationId);
Assert.Equal("ApiOutbound", packet.Operational.Channel);
Assert.Equal("ERP.GetOrder", packet.Operational.Target);
Assert.Equal(SiteId, packet.Operational.SourceSite);
Assert.Equal("Submitted", packet.Operational.Status);
Assert.Equal(0, packet.Operational.RetryCount);
Assert.Null(packet.Operational.LastError);
Assert.Null(packet.Operational.TerminalAtUtc);
}
[Fact]
public async Task CachedCall_ReturnsTrackedOperationId()
{
var client = new Mock<IExternalSystemClient>();
client
.Setup(c => c.CachedCallAsync(
It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
It.IsAny<string?>(),
It.IsAny<CancellationToken>(),
It.IsAny<TrackedOperationId?>()))
.ReturnsAsync(new ExternalCallResult(true, null, null, WasBuffered: true));
var forwarder = new CapturingForwarder();
var helper = CreateHelper(client.Object, forwarder);
var id1 = await helper.CachedCall("ERP", "GetOrder");
var id2 = await helper.CachedCall("ERP", "GetOrder");
Assert.NotEqual(default, id1);
Assert.NotEqual(default, id2);
Assert.NotEqual(id1, id2);
// Both ids were threaded into the client invocations.
client.Verify(c => c.CachedCallAsync(
"ERP", "GetOrder",
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
InstanceName,
It.IsAny<CancellationToken>(),
id1),
Times.Once);
client.Verify(c => c.CachedCallAsync(
"ERP", "GetOrder",
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
InstanceName,
It.IsAny<CancellationToken>(),
id2),
Times.Once);
}
[Fact]
public async Task CachedCall_ForwarderThrows_StillReturnsTrackedOperationId_OriginalCallProceeds()
{
var client = new Mock<IExternalSystemClient>();
client
.Setup(c => c.CachedCallAsync(
It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
It.IsAny<string?>(),
It.IsAny<CancellationToken>(),
It.IsAny<TrackedOperationId?>()))
.ReturnsAsync(new ExternalCallResult(true, null, null, WasBuffered: true));
var forwarder = new CapturingForwarder
{
ThrowOnForward = new InvalidOperationException("simulated forwarder outage"),
};
var helper = CreateHelper(client.Object, forwarder);
// Must not throw — best-effort emission contract.
var trackedId = await helper.CachedCall("ERP", "GetOrder");
Assert.NotEqual(default, trackedId);
// The underlying call still ran exactly once.
client.Verify(c => c.CachedCallAsync(
"ERP", "GetOrder",
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
InstanceName,
It.IsAny<CancellationToken>(),
trackedId),
Times.Once);
}
[Fact]
public async Task CachedCall_Provenance_Populated_FromContext()
{
var client = new Mock<IExternalSystemClient>();
client
.Setup(c => c.CachedCallAsync(
It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
It.IsAny<string?>(),
It.IsAny<CancellationToken>(),
It.IsAny<TrackedOperationId?>()))
.ReturnsAsync(new ExternalCallResult(true, null, null, WasBuffered: true));
var forwarder = new CapturingForwarder();
var helper = CreateHelper(client.Object, forwarder);
await helper.CachedCall("ERP", "GetOrder");
var packet = Assert.Single(forwarder.Telemetry);
Assert.Equal(SiteId, packet.Audit.SourceSiteId);
Assert.Equal(InstanceName, packet.Audit.SourceInstanceId);
Assert.Equal(SourceScript, packet.Audit.SourceScript);
Assert.Equal(SiteId, packet.Operational.SourceSite);
}
[Fact]
public async Task CachedCall_NoForwarder_StillReturnsTrackedOperationId()
{
// Forwarder not wired (tests / minimal hosts) — must still return a
// fresh id and invoke the underlying call.
var client = new Mock<IExternalSystemClient>();
client
.Setup(c => c.CachedCallAsync(
It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
It.IsAny<string?>(),
It.IsAny<CancellationToken>(),
It.IsAny<TrackedOperationId?>()))
.ReturnsAsync(new ExternalCallResult(true, null, null, WasBuffered: true));
var helper = CreateHelper(client.Object, forwarder: null);
var trackedId = await helper.CachedCall("ERP", "GetOrder");
Assert.NotEqual(default, trackedId);
client.Verify(c => c.CachedCallAsync(
"ERP", "GetOrder",
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
InstanceName,
It.IsAny<CancellationToken>(),
trackedId),
Times.Once);
}
/// <summary>
/// Audit Log #23 — M3 Bundle F (F2): when the underlying client call
/// completes immediately (no S&amp;F buffering, <c>WasBuffered=false</c>),
/// the S&amp;F retry loop never engages and the
/// <c>ICachedCallLifecycleObserver</c> hook never fires. The cached-call
/// helper itself must therefore emit the terminal lifecycle rows —
/// otherwise <c>Tracking.Status(id)</c> would return <c>Submitted</c>
/// forever and the audit log would be missing the <c>Attempted</c> /
/// <c>CachedResolve</c> pair the M3 contract requires.
///
/// Expected emissions on immediate success:
/// 1. CachedSubmit / Submitted (already covered)
/// 2. ApiCallCached / Attempted
/// 3. CachedResolve / Delivered (TerminalAtUtc set)
/// </summary>
[Fact]
public async Task CachedCall_ImmediateSuccess_EmitsAttemptedAndCachedResolve()
{
var client = new Mock<IExternalSystemClient>();
client
.Setup(c => c.CachedCallAsync(
"ERP", "GetOrder",
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
InstanceName,
It.IsAny<CancellationToken>(),
It.IsAny<TrackedOperationId?>()))
// WasBuffered=false — the immediate HTTP attempt succeeded; S&F
// is bypassed entirely.
.ReturnsAsync(new ExternalCallResult(true, "{\"ok\":true}", null, WasBuffered: false));
var forwarder = new CapturingForwarder();
var helper = CreateHelper(client.Object, forwarder);
var trackedId = await helper.CachedCall("ERP", "GetOrder");
// Three telemetry packets emitted: Submit, Attempted, Resolve.
Assert.Equal(3, forwarder.Telemetry.Count);
var submit = forwarder.Telemetry[0];
Assert.Equal(AuditKind.CachedSubmit, submit.Audit.Kind);
Assert.Equal(AuditStatus.Submitted, submit.Audit.Status);
Assert.Equal(trackedId, submit.Operational.TrackedOperationId);
Assert.Null(submit.Operational.TerminalAtUtc);
var attempted = forwarder.Telemetry[1];
Assert.Equal(AuditChannel.ApiOutbound, attempted.Audit.Channel);
Assert.Equal(AuditKind.ApiCallCached, attempted.Audit.Kind);
Assert.Equal(AuditStatus.Attempted, attempted.Audit.Status);
Assert.Equal(trackedId.Value, attempted.Audit.CorrelationId);
Assert.Equal("ERP.GetOrder", attempted.Audit.Target);
Assert.Equal(trackedId, attempted.Operational.TrackedOperationId);
Assert.Equal("Attempted", attempted.Operational.Status);
Assert.Null(attempted.Operational.TerminalAtUtc);
var resolve = forwarder.Telemetry[2];
Assert.Equal(AuditChannel.ApiOutbound, resolve.Audit.Channel);
Assert.Equal(AuditKind.CachedResolve, resolve.Audit.Kind);
Assert.Equal(AuditStatus.Delivered, resolve.Audit.Status);
Assert.Equal(trackedId.Value, resolve.Audit.CorrelationId);
Assert.Equal(trackedId, resolve.Operational.TrackedOperationId);
Assert.Equal("Delivered", resolve.Operational.Status);
// Terminal row carries TerminalAtUtc.
Assert.NotNull(resolve.Operational.TerminalAtUtc);
}
/// <summary>
/// Audit Log #23 — M3 Bundle F (F2): the immediate-failure terminal
/// path. When the client returns <c>Success=false</c> with
/// <c>WasBuffered=false</c> (a permanent failure or a transient failure
/// without an S&amp;F engine to buffer it), the cached-call helper must
/// still emit Attempted + CachedResolve with the failed status.
/// </summary>
[Fact]
public async Task CachedCall_ImmediateFailure_EmitsAttemptedAndCachedResolveFailed()
{
var client = new Mock<IExternalSystemClient>();
client
.Setup(c => c.CachedCallAsync(
"ERP", "GetOrder",
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
InstanceName,
It.IsAny<CancellationToken>(),
It.IsAny<TrackedOperationId?>()))
.ReturnsAsync(new ExternalCallResult(
false, null, "Permanent error: HTTP 422 bad payload", WasBuffered: false));
var forwarder = new CapturingForwarder();
var helper = CreateHelper(client.Object, forwarder);
var trackedId = await helper.CachedCall("ERP", "GetOrder");
Assert.Equal(3, forwarder.Telemetry.Count);
var attempted = forwarder.Telemetry[1];
Assert.Equal(AuditKind.ApiCallCached, attempted.Audit.Kind);
Assert.Equal(AuditStatus.Attempted, attempted.Audit.Status);
// The per-attempt row carries the error message.
Assert.NotNull(attempted.Audit.ErrorMessage);
var resolve = forwarder.Telemetry[2];
Assert.Equal(AuditKind.CachedResolve, resolve.Audit.Kind);
// Immediate permanent failure -> Failed audit status / operational Failed.
Assert.Equal(AuditStatus.Failed, resolve.Audit.Status);
Assert.Equal("Failed", resolve.Operational.Status);
Assert.NotNull(resolve.Operational.TerminalAtUtc);
Assert.NotNull(resolve.Operational.LastError);
}
/// <summary>
/// Audit Log #23 — M3 Bundle F (F2): when the client reports
/// <c>WasBuffered=true</c>, the helper hands the operation to S&amp;F and
/// the retry-loop observer owns the Attempted + Resolve emissions. The
/// helper must NOT emit those rows itself (otherwise we'd get duplicate
/// Attempted + Resolve audit rows under the same tracking id).
/// </summary>
[Fact]
public async Task CachedCall_BufferedPath_DoesNotEmitTerminalTelemetryFromHelper()
{
var client = new Mock<IExternalSystemClient>();
client
.Setup(c => c.CachedCallAsync(
"ERP", "GetOrder",
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
InstanceName,
It.IsAny<CancellationToken>(),
It.IsAny<TrackedOperationId?>()))
// S&F took ownership — Attempted + Resolve come from the
// CachedCallLifecycleBridge driven by the retry loop, not the helper.
.ReturnsAsync(new ExternalCallResult(true, null, null, WasBuffered: true));
var forwarder = new CapturingForwarder();
var helper = CreateHelper(client.Object, forwarder);
await helper.CachedCall("ERP", "GetOrder");
// Only the CachedSubmit row — no Attempted / Resolve from the helper.
var only = Assert.Single(forwarder.Telemetry);
Assert.Equal(AuditKind.CachedSubmit, only.Audit.Kind);
}
}

View File

@@ -0,0 +1,76 @@
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using ScadaLink.Commons.Interfaces;
using ScadaLink.Commons.Types;
using ScadaLink.SiteRuntime.Scripts;
namespace ScadaLink.SiteRuntime.Tests.Scripts;
/// <summary>
/// Audit Log #23 (M3 Bundle A — Task A3) — script-side API tests for
/// <c>Tracking.Status(TrackedOperationId)</c>. The helper reads the site-local
/// <see cref="IOperationTrackingStore"/> directly (no central round-trip) and
/// returns the latest <see cref="TrackingStatusSnapshot"/>, or <c>null</c> when
/// the id is unknown.
/// </summary>
public class TrackingApiTests
{
private static ScriptRuntimeContext.TrackingHelper CreateHelper(
IOperationTrackingStore? store)
{
return new ScriptRuntimeContext.TrackingHelper(store, NullLogger.Instance);
}
[Fact]
public async Task Status_UnknownId_ReturnsNull()
{
var store = new Mock<IOperationTrackingStore>();
store
.Setup(s => s.GetStatusAsync(It.IsAny<TrackedOperationId>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((TrackingStatusSnapshot?)null);
var helper = CreateHelper(store.Object);
var result = await helper.Status(TrackedOperationId.New());
Assert.Null(result);
}
[Fact]
public async Task Status_KnownId_ReturnsLatestSnapshot()
{
var id = TrackedOperationId.New();
var expected = new TrackingStatusSnapshot(
Id: id,
Kind: "ApiCallCached",
TargetSummary: "ERP.GetOrder",
Status: "Delivered",
RetryCount: 2,
LastError: null,
HttpStatus: 200,
CreatedAtUtc: new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc),
UpdatedAtUtc: new DateTime(2026, 5, 20, 10, 2, 30, DateTimeKind.Utc),
TerminalAtUtc: new DateTime(2026, 5, 20, 10, 2, 30, DateTimeKind.Utc),
SourceInstanceId: "Plant.Pump42",
SourceScript: "ScriptActor:OnTick");
var store = new Mock<IOperationTrackingStore>();
store
.Setup(s => s.GetStatusAsync(id, It.IsAny<CancellationToken>()))
.ReturnsAsync(expected);
var helper = CreateHelper(store.Object);
var result = await helper.Status(id);
Assert.NotNull(result);
Assert.Equal(expected, result);
}
[Fact]
public async Task Status_NoStoreWired_Throws()
{
var helper = CreateHelper(store: null);
await Assert.ThrowsAsync<InvalidOperationException>(
() => helper.Status(TrackedOperationId.New()));
}
}

View File

@@ -0,0 +1,286 @@
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using ScadaLink.Commons.Types;
using ScadaLink.Commons.Types.Enums;
using ScadaLink.SiteRuntime.Tracking;
namespace ScadaLink.SiteRuntime.Tests.Tracking;
/// <summary>
/// Audit Log #23 (M3 Bundle A — Task A2) — schema + behaviour tests for the
/// site-local <see cref="OperationTrackingStore"/>. Each test uses a unique
/// shared-cache in-memory SQLite database so the store and the verifier share
/// the same store without touching disk.
/// </summary>
public class OperationTrackingStoreTests
{
private static (OperationTrackingStore store, string dataSource) CreateStore(
string testName)
{
var dataSource = $"file:{testName}-{Guid.NewGuid():N}?mode=memory&cache=shared";
var connectionString = $"Data Source={dataSource};Cache=Shared";
var options = new OperationTrackingOptions
{
ConnectionString = connectionString,
};
var store = new OperationTrackingStore(
Options.Create(options),
NullLogger<OperationTrackingStore>.Instance);
return (store, dataSource);
}
private static SqliteConnection OpenVerifierConnection(string dataSource)
{
var connection = new SqliteConnection($"Data Source={dataSource};Cache=Shared");
connection.Open();
return connection;
}
[Fact]
public void Constructor_CreatesOperationTracking_SchemaOnFirstUse()
{
var (store, dataSource) = CreateStore(nameof(Constructor_CreatesOperationTracking_SchemaOnFirstUse));
using (store)
{
using var connection = OpenVerifierConnection(dataSource);
using var cmd = connection.CreateCommand();
cmd.CommandText = "PRAGMA table_info(OperationTracking);";
using var reader = cmd.ExecuteReader();
var columns = new List<(string Name, int Pk, int NotNull)>();
while (reader.Read())
{
columns.Add((reader.GetString(1), reader.GetInt32(5), reader.GetInt32(3)));
}
var expected = new[]
{
"TrackedOperationId", "Kind", "TargetSummary", "Status",
"RetryCount", "LastError", "HttpStatus", "CreatedAtUtc",
"UpdatedAtUtc", "TerminalAtUtc", "SourceInstanceId", "SourceScript",
};
Assert.Equal(
expected.OrderBy(n => n),
columns.Select(c => c.Name).OrderBy(n => n));
var pkColumns = columns.Where(c => c.Pk > 0).Select(c => c.Name).ToList();
Assert.Single(pkColumns);
Assert.Equal("TrackedOperationId", pkColumns[0]);
}
}
[Fact]
public async Task RecordEnqueueAsync_InsertsSubmittedRow_WithRetryCountZero()
{
var (store, dataSource) = CreateStore(nameof(RecordEnqueueAsync_InsertsSubmittedRow_WithRetryCountZero));
await using var _ = store;
var id = TrackedOperationId.New();
await store.RecordEnqueueAsync(
id,
kind: nameof(AuditKind.ApiCallCached),
targetSummary: "ERP.GetOrder",
sourceInstanceId: "Plant.Pump42",
sourceScript: "ScriptActor:OnTick");
var snapshot = await store.GetStatusAsync(id);
Assert.NotNull(snapshot);
Assert.Equal(id, snapshot!.Id);
Assert.Equal(nameof(AuditKind.ApiCallCached), snapshot.Kind);
Assert.Equal("ERP.GetOrder", snapshot.TargetSummary);
Assert.Equal(nameof(AuditStatus.Submitted), snapshot.Status);
Assert.Equal(0, snapshot.RetryCount);
Assert.Null(snapshot.LastError);
Assert.Null(snapshot.HttpStatus);
Assert.Null(snapshot.TerminalAtUtc);
Assert.Equal("Plant.Pump42", snapshot.SourceInstanceId);
Assert.Equal("ScriptActor:OnTick", snapshot.SourceScript);
Assert.Equal(DateTimeKind.Utc, snapshot.CreatedAtUtc.Kind);
Assert.Equal(DateTimeKind.Utc, snapshot.UpdatedAtUtc.Kind);
}
[Fact]
public async Task RecordEnqueueAsync_Duplicate_IsNoOp_FirstWriteWins()
{
var (store, _) = CreateStore(nameof(RecordEnqueueAsync_Duplicate_IsNoOp_FirstWriteWins));
await using var _store = store;
var id = TrackedOperationId.New();
await store.RecordEnqueueAsync(id, "ApiCallCached", "ERP.GetOrder", "Plant.Pump42", "ScriptActor:OnTick");
await store.RecordEnqueueAsync(id, "ApiCallCached", "OtherTarget", "Other.Instance", "ScriptActor:Other");
var snapshot = await store.GetStatusAsync(id);
Assert.NotNull(snapshot);
// First-write-wins: the second enqueue is ignored — Target/Source stay first.
Assert.Equal("ERP.GetOrder", snapshot!.TargetSummary);
Assert.Equal("Plant.Pump42", snapshot.SourceInstanceId);
Assert.Equal("ScriptActor:OnTick", snapshot.SourceScript);
Assert.Equal(nameof(AuditStatus.Submitted), snapshot.Status);
Assert.Equal(0, snapshot.RetryCount);
}
[Fact]
public async Task RecordAttemptAsync_AdvancesStatusAndRetryCount_OnNonTerminalRow()
{
var (store, _) = CreateStore(nameof(RecordAttemptAsync_AdvancesStatusAndRetryCount_OnNonTerminalRow));
await using var _store = store;
var id = TrackedOperationId.New();
await store.RecordEnqueueAsync(id, "ApiCallCached", "ERP.GetOrder", null, null);
await store.RecordAttemptAsync(
id,
status: nameof(AuditStatus.Attempted),
retryCount: 1,
lastError: "HTTP 503 from ERP",
httpStatus: 503);
var snapshot = await store.GetStatusAsync(id);
Assert.NotNull(snapshot);
Assert.Equal(nameof(AuditStatus.Attempted), snapshot!.Status);
Assert.Equal(1, snapshot.RetryCount);
Assert.Equal("HTTP 503 from ERP", snapshot.LastError);
Assert.Equal(503, snapshot.HttpStatus);
Assert.Null(snapshot.TerminalAtUtc);
// UpdatedAtUtc advances past CreatedAtUtc.
Assert.True(snapshot.UpdatedAtUtc >= snapshot.CreatedAtUtc);
}
[Fact]
public async Task RecordAttemptAsync_OnTerminalRow_IsNoOp()
{
var (store, _) = CreateStore(nameof(RecordAttemptAsync_OnTerminalRow_IsNoOp));
await using var _store = store;
var id = TrackedOperationId.New();
await store.RecordEnqueueAsync(id, "ApiCallCached", "ERP.GetOrder", null, null);
await store.RecordTerminalAsync(
id,
status: nameof(AuditStatus.Delivered),
lastError: null,
httpStatus: 200);
var terminalSnapshot = await store.GetStatusAsync(id);
Assert.NotNull(terminalSnapshot);
Assert.NotNull(terminalSnapshot!.TerminalAtUtc);
// Late attempt telemetry must NOT overwrite the terminal row.
await store.RecordAttemptAsync(
id,
status: nameof(AuditStatus.Attempted),
retryCount: 5,
lastError: "late attempt",
httpStatus: 500);
var afterLate = await store.GetStatusAsync(id);
Assert.NotNull(afterLate);
Assert.Equal(nameof(AuditStatus.Delivered), afterLate!.Status);
Assert.Equal(0, afterLate.RetryCount);
Assert.Null(afterLate.LastError);
Assert.Equal(200, afterLate.HttpStatus);
Assert.NotNull(afterLate.TerminalAtUtc);
}
[Fact]
public async Task RecordTerminalAsync_FlipsToTerminal_WithTerminalAtUtcSet()
{
var (store, _) = CreateStore(nameof(RecordTerminalAsync_FlipsToTerminal_WithTerminalAtUtcSet));
await using var _store = store;
var id = TrackedOperationId.New();
await store.RecordEnqueueAsync(id, "ApiCallCached", "ERP.GetOrder", null, null);
var beforeTerminal = DateTime.UtcNow;
await store.RecordTerminalAsync(
id,
status: nameof(AuditStatus.Parked),
lastError: "HTTP 503 (max retries)",
httpStatus: 503);
var snapshot = await store.GetStatusAsync(id);
Assert.NotNull(snapshot);
Assert.Equal(nameof(AuditStatus.Parked), snapshot!.Status);
Assert.NotNull(snapshot.TerminalAtUtc);
Assert.Equal(DateTimeKind.Utc, snapshot.TerminalAtUtc!.Value.Kind);
Assert.True(snapshot.TerminalAtUtc >= beforeTerminal.AddSeconds(-1));
Assert.Equal("HTTP 503 (max retries)", snapshot.LastError);
Assert.Equal(503, snapshot.HttpStatus);
}
[Fact]
public async Task GetStatusAsync_Unknown_ReturnsNull()
{
var (store, _) = CreateStore(nameof(GetStatusAsync_Unknown_ReturnsNull));
await using var _store = store;
var unknown = TrackedOperationId.New();
var snapshot = await store.GetStatusAsync(unknown);
Assert.Null(snapshot);
}
[Fact]
public async Task GetStatusAsync_ReturnsLatestSnapshot_AfterMultipleAttempts()
{
var (store, _) = CreateStore(nameof(GetStatusAsync_ReturnsLatestSnapshot_AfterMultipleAttempts));
await using var _store = store;
var id = TrackedOperationId.New();
await store.RecordEnqueueAsync(id, "ApiCallCached", "ERP.GetOrder", null, null);
await store.RecordAttemptAsync(id, nameof(AuditStatus.Attempted), 1, "first failure", 503);
await store.RecordAttemptAsync(id, nameof(AuditStatus.Attempted), 2, "second failure", 503);
await store.RecordAttemptAsync(id, nameof(AuditStatus.Attempted), 3, "third failure", 504);
var snapshot = await store.GetStatusAsync(id);
Assert.NotNull(snapshot);
Assert.Equal(3, snapshot!.RetryCount);
Assert.Equal("third failure", snapshot.LastError);
Assert.Equal(504, snapshot.HttpStatus);
}
[Fact]
public async Task PurgeTerminalAsync_RemovesOldTerminalRows_KeepsRecent_KeepsNonTerminal()
{
var (store, dataSource) = CreateStore(nameof(PurgeTerminalAsync_RemovesOldTerminalRows_KeepsRecent_KeepsNonTerminal));
await using var _store = store;
// Three rows:
// (a) terminal, old → should be purged
// (b) terminal, fresh → should be kept
// (c) non-terminal, ancient CreatedAt → should be kept (no TerminalAtUtc)
var aId = TrackedOperationId.New();
var bId = TrackedOperationId.New();
var cId = TrackedOperationId.New();
await store.RecordEnqueueAsync(aId, "ApiCallCached", "A", null, null);
await store.RecordEnqueueAsync(bId, "ApiCallCached", "B", null, null);
await store.RecordEnqueueAsync(cId, "ApiCallCached", "C", null, null);
await store.RecordTerminalAsync(aId, nameof(AuditStatus.Delivered), null, 200);
await store.RecordTerminalAsync(bId, nameof(AuditStatus.Delivered), null, 200);
// Backdate the (a) row's TerminalAtUtc to 30 days ago via a direct UPDATE
// — RecordTerminalAsync stamps DateTime.UtcNow which we cannot inject.
// The verifier connection shares the same in-memory store thanks to
// mode=memory&cache=shared.
using (var connection = OpenVerifierConnection(dataSource))
using (var cmd = connection.CreateCommand())
{
cmd.CommandText =
"UPDATE OperationTracking SET TerminalAtUtc = $old WHERE TrackedOperationId = $id;";
cmd.Parameters.AddWithValue("$old", DateTime.UtcNow.AddDays(-30).ToString("o"));
cmd.Parameters.AddWithValue("$id", aId.ToString());
cmd.ExecuteNonQuery();
}
// Purge anything terminal older than 7 days.
var threshold = DateTime.UtcNow.AddDays(-7);
await store.PurgeTerminalAsync(threshold);
Assert.Null(await store.GetStatusAsync(aId)); // purged
Assert.NotNull(await store.GetStatusAsync(bId)); // kept (recent terminal)
Assert.NotNull(await store.GetStatusAsync(cId)); // kept (non-terminal)
}
}

View File

@@ -0,0 +1,298 @@
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Logging.Abstractions;
using ScadaLink.Commons.Interfaces.Services;
using ScadaLink.Commons.Types;
using ScadaLink.Commons.Types.Enums;
namespace ScadaLink.StoreAndForward.Tests;
/// <summary>
/// Audit Log #23 — M3 Bundle E Tasks E4 + E5: the store-and-forward retry
/// loop invokes <see cref="ICachedCallLifecycleObserver"/> after every
/// cached-call attempt. The observer is given a
/// <see cref="CachedCallAttemptContext"/> derived from the underlying
/// <see cref="StoreAndForwardMessage"/>; the audit bridge then materialises
/// the right <c>CachedCallTelemetry</c> packet (Attempted on every retry,
/// CachedResolve on terminal transitions). Tests run with
/// <c>DefaultRetryInterval=Zero</c> so the timer-driven retry sweep is
/// short-circuited by directly invoking
/// <see cref="StoreAndForwardService.RetryPendingMessagesAsync"/>.
/// </summary>
public class CachedCallAttemptEmissionTests : IAsyncLifetime, IDisposable
{
private readonly SqliteConnection _keepAlive;
private readonly StoreAndForwardStorage _storage;
private readonly StoreAndForwardService _service;
private readonly StoreAndForwardOptions _options;
private readonly CapturingObserver _observer;
public CachedCallAttemptEmissionTests()
{
var dbName = $"E4Tests_{Guid.NewGuid():N}";
var connStr = $"Data Source={dbName};Mode=Memory;Cache=Shared";
_keepAlive = new SqliteConnection(connStr);
_keepAlive.Open();
_storage = new StoreAndForwardStorage(connStr, NullLogger<StoreAndForwardStorage>.Instance);
_options = new StoreAndForwardOptions
{
DefaultRetryInterval = TimeSpan.Zero,
DefaultMaxRetries = 3,
RetryTimerInterval = TimeSpan.FromMinutes(10),
};
_observer = new CapturingObserver();
_service = new StoreAndForwardService(
_storage,
_options,
NullLogger<StoreAndForwardService>.Instance,
replication: null,
cachedCallObserver: _observer,
siteId: "site-77");
}
public async Task InitializeAsync() => await _storage.InitializeAsync();
public Task DisposeAsync() => Task.CompletedTask;
public void Dispose() => _keepAlive.Dispose();
/// <summary>
/// Captures every observer notification so tests can assert on the
/// emitted lifecycle sequence.
/// </summary>
private sealed class CapturingObserver : ICachedCallLifecycleObserver
{
public List<CachedCallAttemptContext> Notifications { get; } = new();
public Exception? ThrowOnNotify { get; set; }
public Task OnAttemptCompletedAsync(CachedCallAttemptContext context, CancellationToken ct = default)
{
if (ThrowOnNotify != null)
{
return Task.FromException(ThrowOnNotify);
}
lock (Notifications)
{
Notifications.Add(context);
}
return Task.CompletedTask;
}
}
private async Task<TrackedOperationId> EnqueueBufferedAsync(
StoreAndForwardCategory category, string target, int maxRetries = 3)
{
// The TrackedOperationId is the S&F message id (Bundle E3 contract).
var trackedId = TrackedOperationId.New();
await _service.EnqueueAsync(
category,
target,
"""{"payload":"x"}""",
originInstanceName: "Plant.Pump42",
maxRetries: maxRetries,
retryInterval: TimeSpan.Zero,
attemptImmediateDelivery: false,
messageId: trackedId.ToString());
return trackedId;
}
// ── Task E4: per-attempt observer notifications ──
[Fact]
public async Task Attempt_FailWithHttp500_EmitsAttemptedTelemetry()
{
// ExternalSystem cached call buffered, retry sweep encounters a
// transient failure on the first attempt.
_service.RegisterDeliveryHandler(StoreAndForwardCategory.ExternalSystem,
_ => throw new HttpRequestException("HTTP 500 from ERP"));
var trackedId = await EnqueueBufferedAsync(
StoreAndForwardCategory.ExternalSystem, "ERP", maxRetries: 5);
await _service.RetryPendingMessagesAsync();
var notification = Assert.Single(_observer.Notifications);
Assert.Equal(trackedId, notification.TrackedOperationId);
Assert.Equal("ApiOutbound", notification.Channel);
Assert.Equal("ERP", notification.Target);
Assert.Equal("site-77", notification.SourceSite);
Assert.Equal(CachedCallAttemptOutcome.TransientFailure, notification.Outcome);
Assert.Equal(1, notification.RetryCount);
Assert.Contains("HTTP 500", notification.LastError);
Assert.Equal("Plant.Pump42", notification.SourceInstanceId);
}
[Fact]
public async Task Attempt_Success_EmitsDeliveredOutcome()
{
// ExternalSystem cached call buffered, retry sweep delivers the
// message successfully on its first attempt.
_service.RegisterDeliveryHandler(StoreAndForwardCategory.ExternalSystem,
_ => Task.FromResult(true));
var trackedId = await EnqueueBufferedAsync(
StoreAndForwardCategory.ExternalSystem, "ERP");
await _service.RetryPendingMessagesAsync();
var notification = Assert.Single(_observer.Notifications);
Assert.Equal(trackedId, notification.TrackedOperationId);
Assert.Equal(CachedCallAttemptOutcome.Delivered, notification.Outcome);
Assert.Null(notification.LastError);
}
[Fact]
public async Task Attempt_PermanentFailure_EmitsPermanentFailureOutcome()
{
_service.RegisterDeliveryHandler(StoreAndForwardCategory.ExternalSystem,
_ => Task.FromResult(false));
var trackedId = await EnqueueBufferedAsync(
StoreAndForwardCategory.ExternalSystem, "ERP");
await _service.RetryPendingMessagesAsync();
var notification = Assert.Single(_observer.Notifications);
Assert.Equal(trackedId, notification.TrackedOperationId);
Assert.Equal(CachedCallAttemptOutcome.PermanentFailure, notification.Outcome);
}
[Fact]
public async Task Attempt_CachedDbWrite_EmitsDbOutboundChannel()
{
_service.RegisterDeliveryHandler(StoreAndForwardCategory.CachedDbWrite,
_ => Task.FromResult(true));
var trackedId = await EnqueueBufferedAsync(
StoreAndForwardCategory.CachedDbWrite, "myDb");
await _service.RetryPendingMessagesAsync();
var notification = Assert.Single(_observer.Notifications);
Assert.Equal(trackedId, notification.TrackedOperationId);
Assert.Equal("DbOutbound", notification.Channel);
Assert.Equal("myDb", notification.Target);
}
[Fact]
public async Task Attempt_NotificationCategory_NoObserverNotification()
{
// Notifications are NOT cached calls — they're forwarded to central via
// a separate forwarder. The observer must not fire for Notification
// category messages.
_service.RegisterDeliveryHandler(StoreAndForwardCategory.Notification,
_ => Task.FromResult(true));
await _service.EnqueueAsync(
StoreAndForwardCategory.Notification,
"alerts",
"""{"subject":"x"}""",
originInstanceName: "Plant.Pump42",
attemptImmediateDelivery: false);
await _service.RetryPendingMessagesAsync();
Assert.Empty(_observer.Notifications);
}
[Fact]
public async Task Attempt_MessageIdNotAGuid_NoObserverNotification()
{
// Pre-M3 cached calls (no TrackedOperationId threaded in) use a random
// GUID-N message id from S&F itself. We should still emit (M3 expects
// post-rollout these are tracked) — BUT pre-rollout messages can have
// a non-parseable id, in which case the observer is silently skipped
// to keep S&F bookkeeping intact.
_service.RegisterDeliveryHandler(StoreAndForwardCategory.ExternalSystem,
_ => Task.FromResult(true));
await _service.EnqueueAsync(
StoreAndForwardCategory.ExternalSystem,
"ERP",
"""{}""",
originInstanceName: "Plant.Pump42",
attemptImmediateDelivery: false,
messageId: "not-a-valid-guid-id");
await _service.RetryPendingMessagesAsync();
Assert.Empty(_observer.Notifications);
}
// ── Task E5: terminal-state observer notifications ──
[Fact]
public async Task Terminal_Delivered_EmitsResolveWithDeliveredStatus()
{
// A successful retry produces a single Delivered observer notification
// — the audit bridge maps this to both an Attempted-Delivered audit row
// and the terminal CachedResolve(Delivered) row. The S&F layer fires
// ONE notification per attempt and lets the bridge fan out as needed.
_service.RegisterDeliveryHandler(StoreAndForwardCategory.ExternalSystem,
_ => Task.FromResult(true));
var trackedId = await EnqueueBufferedAsync(
StoreAndForwardCategory.ExternalSystem, "ERP");
await _service.RetryPendingMessagesAsync();
var notification = Assert.Single(_observer.Notifications);
Assert.Equal(trackedId, notification.TrackedOperationId);
Assert.Equal(CachedCallAttemptOutcome.Delivered, notification.Outcome);
}
[Fact]
public async Task Terminal_Parked_OnMaxRetries_EmitsParkedMaxRetries()
{
// Configure handler to throw transient every time.
_service.RegisterDeliveryHandler(StoreAndForwardCategory.ExternalSystem,
_ => throw new HttpRequestException("Connection refused"));
var trackedId = await EnqueueBufferedAsync(
StoreAndForwardCategory.ExternalSystem, "ERP", maxRetries: 2);
// Two sweeps -> RetryCount climbs to 2 -> parked on the second sweep.
await _service.RetryPendingMessagesAsync();
await _service.RetryPendingMessagesAsync();
Assert.Equal(2, _observer.Notifications.Count);
Assert.Equal(CachedCallAttemptOutcome.TransientFailure, _observer.Notifications[0].Outcome);
Assert.Equal(CachedCallAttemptOutcome.ParkedMaxRetries, _observer.Notifications[1].Outcome);
Assert.Equal(trackedId, _observer.Notifications[1].TrackedOperationId);
}
[Fact]
public async Task Lifecycle_RetryFail_RetrySucceed_EmitsExpectedSequence()
{
var calls = 0;
_service.RegisterDeliveryHandler(StoreAndForwardCategory.ExternalSystem, _ =>
{
calls++;
if (calls == 1) throw new HttpRequestException("transient");
return Task.FromResult(true);
});
var trackedId = await EnqueueBufferedAsync(
StoreAndForwardCategory.ExternalSystem, "ERP", maxRetries: 5);
await _service.RetryPendingMessagesAsync();
await _service.RetryPendingMessagesAsync();
Assert.Equal(2, _observer.Notifications.Count);
Assert.Equal(CachedCallAttemptOutcome.TransientFailure, _observer.Notifications[0].Outcome);
Assert.Equal(1, _observer.Notifications[0].RetryCount);
Assert.Equal(CachedCallAttemptOutcome.Delivered, _observer.Notifications[1].Outcome);
Assert.Equal(trackedId, _observer.Notifications[1].TrackedOperationId);
}
// ── Best-effort contract: observer throws must NOT corrupt retry bookkeeping ──
[Fact]
public async Task Observer_Throws_DoesNotCorruptRetryCount()
{
_observer.ThrowOnNotify = new InvalidOperationException("simulated audit failure");
_service.RegisterDeliveryHandler(StoreAndForwardCategory.ExternalSystem,
_ => Task.FromResult(true));
var trackedId = await EnqueueBufferedAsync(
StoreAndForwardCategory.ExternalSystem, "ERP");
// Must not throw — observer is best-effort.
await _service.RetryPendingMessagesAsync();
// The message was delivered (handler returned true) so it should be gone.
var msg = await _storage.GetMessageByIdAsync(trackedId.ToString());
Assert.Null(msg);
}
}